[Scummvm-git-logs] scummvm master -> 0b24b9b5b0caa395a68bde42e7508a3544d34533

alexbevi noreply at scummvm.org
Tue Oct 13 21:40:30 UTC 2026


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

Summary:
2c50e80242 HARVESTER: refactor BM/ABM resource handling
4f581772ae HARVESTER: Make command execution inputs and outputs explicit
3be8724e8b HARVESTER: extract RoomSession for managing room state and player interactions
febc916db5 HARVESTER: refactor to manage room state and synchronize runtime entities
8ee387d338 HARVESTER: refactor RoomSession to manage scene entity lifecycle and interactions
2be5442bcc HARVESTER: move inventory management and runtime mutations to RoomSession
a4044055a3 HARVESTER: refactor RoomSession to include Flow for enhanced scene management
59e4140ebc HARVESTER: RoomCombatEffects for managing hit effects and damage popups
f59e75bbdb HARVESTER: Add RoomSceneRuntime class to manage scene entities and interactions
36b5ca56de HARVESTER: Refactor RoomSession and RoomSceneRuntime for improved timer synchronization and state management
ef370dd63a HARVESTER: Refactor combat state management in RoomCombat and RoomSession
88b55c3962 HARVESTER: Refactor entity state comparison functions and consolidate some scene entity logic
91f5b74f02 HARVESTER: Implement player rebind after scene refresh and restore monster animations
4a19173857 HARVESTER: Refactor combat target selection and interaction methods
60d34b8f08 HARVESTER: Refactor combat interaction handling and NPC death transitions
79ef709056 HARVESTER: Refactor NPC combat state handling
1f2fc6454e HARVESTER: more RoomCombat refactoring
dd34002fcc HARVESTER: Refactor room item interactions some inventory handling
47ccd38593 HARVESTER: Refactor region interaction handling in RoomInteractionProcessor
a38bca9f3d HARVESTER: Refactor to separate presentation and modal operations
1d3c209f66 HARVESTER: Extract input handling
3238191c58 HARVESTER: Refactor session and input classes for improved interaction handling
ccfabd608b HARVESTER: begin splitting room/scene handling
d8ab1acf31 HARVESTER: refactor/rename Entity, Interaction, Combat and Input
939d4f10ea HARVESTER: Update walk direction/depth after animation
11452159f5 HARVESTER: fix player bounds checking with room exits
0cca8a4c2c HARVESTER: play walking blocked sounds
0b24b9b5b0 HARVESTER: cleanup and log verbosity adjustments


Commit: 2c50e80242f8b055b451c7d5c9de6688c270df84
    https://github.com/scummvm/scummvm/commit/2c50e80242f8b055b451c7d5c9de6688c270df84
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: refactor BM/ABM resource handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/bitmap.cpp
  A engines/harvester/bitmap.h
    engines/harvester/art.cpp
    engines/harvester/art.h
    engines/harvester/dialogue.cpp
    engines/harvester/flow.cpp
    engines/harvester/inventory.cpp
    engines/harvester/menu.cpp
    engines/harvester/module.mk
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h


diff --git a/engines/harvester/art.cpp b/engines/harvester/art.cpp
index 776264f9fc6..1ed1a09eeef 100644
--- a/engines/harvester/art.cpp
+++ b/engines/harvester/art.cpp
@@ -22,7 +22,6 @@
 #include "harvester/art.h"
 
 #include "common/debug.h"
-#include "common/endian.h"
 #include "graphics/blit.h"
 #include "graphics/screen.h"
 #include "harvester/detection.h"
@@ -174,16 +173,10 @@ bool Art::loadBitmap(ResourceManager &resources, const Common::String &path, Ind
 		return false;
 	}
 
-	bitmap.width = READ_LE_UINT32(data.data());
-	bitmap.height = READ_LE_UINT32(data.data() + 4);
-	const uint32 pixelCount = bitmap.width * bitmap.height;
-	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount) {
+	if (!decodeBitmap(data, bitmap)) {
 		warning("Harvester: invalid bitmap '%s'", path.c_str());
 		return false;
 	}
-
-	bitmap.pixels.resize(pixelCount);
-	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
 	return true;
 }
 
@@ -194,74 +187,15 @@ bool Art::loadAnimation(ResourceManager &resources, const Common::String &path,
 		return false;
 	}
 
-	const uint32 frameCount = READ_LE_UINT32(data.data());
-	uint32 offset = 8;
-	frames.resize(frameCount);
-
-	for (uint32 i = 0; i < frameCount; ++i) {
-		if (data.size() < offset + 25) {
-			warning("Harvester: short ABM header in '%s'", path.c_str());
-			return false;
-		}
-
-		AbmFrame &frame = frames[i];
-		frame.xOffset = (int32)READ_LE_UINT32(data.data() + offset);
-		frame.yOffset = (int32)READ_LE_UINT32(data.data() + offset + 4);
-		frame.width = READ_LE_UINT32(data.data() + offset + 8);
-		frame.height = READ_LE_UINT32(data.data() + offset + 12);
-		const bool compressed = data[offset + 16] != 0;
-		const uint32 sourceSize = READ_LE_UINT32(data.data() + offset + 17);
-		const uint32 pixelCount = frame.width * frame.height;
-		const uint32 payloadOffset = offset + 25;
-
-		if (frame.width == 0 || frame.height == 0 || data.size() < payloadOffset + sourceSize) {
-			warning("Harvester: invalid ABM frame %u in '%s'", i, path.c_str());
-			return false;
-		}
-
-		frame.pixels.resize(pixelCount);
-		if (!decodeAnimationFrame(data.data() + payloadOffset, sourceSize, compressed, frame.pixels))
-			return false;
-
-		offset = payloadOffset + sourceSize;
+	if (!decodeAbm(data, frames)) {
+		warning("Harvester: unable to decode animation '%s'", path.c_str());
+		return false;
 	}
 
 	debug(1, "Harvester: loaded %u frames from '%s'", (uint)frames.size(), path.c_str());
 	return true;
 }
 
-bool Art::decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) const {
-	if (!compressed) {
-		if (sourceSize < dest.size())
-			return false;
-
-		memcpy(dest.data(), source, dest.size());
-		return true;
-	}
-
-	uint32 srcOffset = 0;
-	uint32 dstOffset = 0;
-
-	while (srcOffset < sourceSize && dstOffset < dest.size()) {
-		const byte control = source[srcOffset++];
-		if ((control & 0x80) == 0) {
-			const uint32 literalCount = MIN<uint32>(control, MIN<uint32>(sourceSize - srcOffset, dest.size() - dstOffset));
-			memcpy(dest.data() + dstOffset, source + srcOffset, literalCount);
-			srcOffset += literalCount;
-			dstOffset += literalCount;
-		} else {
-			if (srcOffset >= sourceSize)
-				return false;
-
-			const uint32 repeatCount = MIN<uint32>(control & 0x7f, dest.size() - dstOffset);
-			memset(dest.data() + dstOffset, source[srcOffset++], repeatCount);
-			dstOffset += repeatCount;
-		}
-	}
-
-	return dstOffset == dest.size();
-}
-
 void Art::blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) const {
 	if (!bitmap.isValid())
 		return;
diff --git a/engines/harvester/art.h b/engines/harvester/art.h
index d3460b09447..3877523750e 100644
--- a/engines/harvester/art.h
+++ b/engines/harvester/art.h
@@ -22,7 +22,7 @@
 #ifndef HARVESTER_ART_H
 #define HARVESTER_ART_H
 
-#include "common/array.h"
+#include "harvester/bitmap.h"
 
 namespace Graphics {
 class Screen;
@@ -32,21 +32,6 @@ namespace Harvester {
 
 class ResourceManager;
 
-struct IndexedBitmap {
-	uint32 width = 0;
-	uint32 height = 0;
-	Common::Array<byte> pixels;
-
-	bool isValid() const {
-		return width != 0 && height != 0 && pixels.size() >= width * height;
-	}
-};
-
-struct AbmFrame : IndexedBitmap {
-	int32 xOffset = 0;
-	int32 yOffset = 0;
-};
-
 class Art {
 public:
 	bool load(ResourceManager &resources);
@@ -68,7 +53,6 @@ private:
 	bool loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const;
 	bool loadBitmap(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) const;
 	bool loadAnimation(ResourceManager &resources, const Common::String &path, Common::Array<AbmFrame> &frames) const;
-	bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) const;
 	void blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) const;
 	void blitTransparentAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames,
 		uint frameIndex, int x, int y) const;
diff --git a/engines/harvester/bitmap.cpp b/engines/harvester/bitmap.cpp
new file mode 100644
index 00000000000..1d1d8df4143
--- /dev/null
+++ b/engines/harvester/bitmap.cpp
@@ -0,0 +1,137 @@
+/* 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 "harvester/bitmap.h"
+
+#include "common/debug.h"
+#include "common/endian.h"
+#include "harvester/detection.h"
+
+namespace Harvester {
+
+namespace {
+
+static bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) {
+	if (!compressed) {
+		if (sourceSize < dest.size())
+			return false;
+
+		memcpy(dest.data(), source, dest.size());
+		return true;
+	}
+
+	uint32 srcOffset = 0;
+	uint32 dstOffset = 0;
+	while (srcOffset < sourceSize && dstOffset < dest.size()) {
+		const byte control = source[srcOffset++];
+		if ((control & 0x80) == 0) {
+			const uint32 literalCount = MIN<uint32>(control,
+				MIN<uint32>(sourceSize - srcOffset, dest.size() - dstOffset));
+			memcpy(dest.data() + dstOffset, source + srcOffset, literalCount);
+			srcOffset += literalCount;
+			dstOffset += literalCount;
+		} else {
+			if (srcOffset >= sourceSize)
+				return false;
+
+			const uint32 repeatCount = MIN<uint32>(control & 0x7f, dest.size() - dstOffset);
+			memset(dest.data() + dstOffset, source[srcOffset++], repeatCount);
+			dstOffset += repeatCount;
+		}
+	}
+
+	return dstOffset == dest.size();
+}
+
+} // End of anonymous namespace
+
+bool decodeBitmap(const Common::Array<byte> &data, IndexedBitmap &bitmap) {
+	if (data.size() < 12) {
+		debugC(1, kDebugResources, "Harvester: short BM header bytes=%u", data.size());
+		return false;
+	}
+
+	bitmap.width = READ_LE_UINT32(data.data());
+	bitmap.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = bitmap.width * bitmap.height;
+	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount) {
+		debugC(1, kDebugResources, "Harvester: invalid BM size=%ux%u bytes=%u",
+			bitmap.width, bitmap.height, data.size());
+		return false;
+	}
+
+	bitmap.pixels.resize(pixelCount);
+	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
+	debugC(3, kDebugResources, "Harvester: decoded BM size=%ux%u pixels=%u",
+		bitmap.width, bitmap.height, pixelCount);
+	return true;
+}
+
+bool decodeAbm(const Common::Array<byte> &data, Common::Array<AbmFrame> &frames) {
+	if (data.size() < 8) {
+		debugC(1, kDebugResources, "Harvester: short ABM header bytes=%u", data.size());
+		return false;
+	}
+
+	const uint32 frameCount = READ_LE_UINT32(data.data());
+	uint32 offset = 8;
+	frames.resize(frameCount);
+
+	for (uint32 i = 0; i < frameCount; ++i) {
+		if (data.size() < offset + 25) {
+			debugC(1, kDebugResources, "Harvester: short ABM frame header frame=%u offset=%u bytes=%u",
+				i, offset, data.size());
+			return false;
+		}
+
+		AbmFrame &frame = frames[i];
+		frame.xOffset = (int32)READ_LE_UINT32(data.data() + offset);
+		frame.yOffset = (int32)READ_LE_UINT32(data.data() + offset + 4);
+		frame.width = READ_LE_UINT32(data.data() + offset + 8);
+		frame.height = READ_LE_UINT32(data.data() + offset + 12);
+		const bool compressed = data[offset + 16] != 0;
+		const uint32 sourceSize = READ_LE_UINT32(data.data() + offset + 17);
+		const uint32 pixelCount = frame.width * frame.height;
+		const uint32 payloadOffset = offset + 25;
+
+		if (frame.width == 0 || frame.height == 0 || data.size() < payloadOffset + sourceSize) {
+			debugC(1, kDebugResources, "Harvester: invalid ABM frame=%u size=%ux%u offset=%u source_bytes=%u bytes=%u",
+				i, frame.width, frame.height, payloadOffset, sourceSize, data.size());
+			return false;
+		}
+
+		frame.pixels.resize(pixelCount);
+		if (!decodeAnimationFrame(data.data() + payloadOffset, sourceSize, compressed, frame.pixels)) {
+			debugC(1, kDebugResources, "Harvester: incomplete ABM pixels frame=%u source_bytes=%u pixels=%u",
+				i, sourceSize, pixelCount);
+			return false;
+		}
+
+		debugC(3, kDebugResources, "Harvester: decoded ABM frame=%u size=%ux%u position=(%d,%d) offset=%u source_bytes=%u compressed=%d",
+			i, frame.width, frame.height, frame.xOffset, frame.yOffset, payloadOffset, sourceSize, compressed);
+		offset = payloadOffset + sourceSize;
+	}
+
+	debugC(2, kDebugResources, "Harvester: decoded ABM frames=%u bytes=%u", frameCount, data.size());
+	return true;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/bitmap.h b/engines/harvester/bitmap.h
new file mode 100644
index 00000000000..9a87c2cbcaa
--- /dev/null
+++ b/engines/harvester/bitmap.h
@@ -0,0 +1,50 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_BITMAP_H
+#define HARVESTER_BITMAP_H
+
+#include "common/array.h"
+
+namespace Harvester {
+
+struct IndexedBitmap {
+	uint32 width = 0;
+	uint32 height = 0;
+	Common::Array<byte> pixels;
+
+	bool isValid() const {
+		return width != 0 && height != 0 && pixels.size() >= width * height;
+	}
+};
+
+struct AbmFrame : IndexedBitmap {
+	int32 xOffset = 0;
+	int32 yOffset = 0;
+};
+
+// Callers choose when to clear output. Failed decoding can leave partial header or frame data.
+bool decodeBitmap(const Common::Array<byte> &data, IndexedBitmap &bitmap);
+bool decodeAbm(const Common::Array<byte> &data, Common::Array<AbmFrame> &frames);
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_BITMAP_H
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 5267bbea0f5..830861b16c4 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -23,7 +23,6 @@
 
 #include "common/algorithm.h"
 #include "common/debug.h"
-#include "common/endian.h"
 #include "common/events.h"
 #include "common/ptr.h"
 #include "common/system.h"
@@ -284,15 +283,7 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 		return false;
 
 	bitmap = IndexedBitmap();
-	bitmap.width = READ_LE_UINT32(data.data());
-	bitmap.height = READ_LE_UINT32(data.data() + 4);
-	const uint32 pixelCount = bitmap.width * bitmap.height;
-	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
-		return false;
-
-	bitmap.pixels.resize(pixelCount);
-	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
-	return true;
+	return decodeBitmap(data, bitmap);
 }
 
 static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 955890d4e88..29138acfb87 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -26,7 +26,6 @@
 #include "common/algorithm.h"
 #include "common/config-manager.h"
 #include "common/debug.h"
-#include "common/endian.h"
 #include "common/events.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
@@ -726,15 +725,7 @@ bool loadBitmapResource(ResourceManager &resources, const Common::String &path,
 		return false;
 
 	bitmap = IndexedBitmap();
-	bitmap.width = READ_LE_UINT32(data.data());
-	bitmap.height = READ_LE_UINT32(data.data() + 4);
-	const uint32 pixelCount = bitmap.width * bitmap.height;
-	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
-		return false;
-
-	bitmap.pixels.resize(pixelCount);
-	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
-	return true;
+	return decodeBitmap(data, bitmap);
 }
 
 static void logSceneObjectSelection(const char *decision, const char *source, const ObjectRecord &object,
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 4c60875122e..81a646b8640 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -23,7 +23,6 @@
 
 #include "common/algorithm.h"
 #include "common/debug.h"
-#include "common/endian.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
 #include "graphics/screen.h"
@@ -173,15 +172,7 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 		return false;
 
 	bitmap = IndexedBitmap();
-	bitmap.width = READ_LE_UINT32(data.data());
-	bitmap.height = READ_LE_UINT32(data.data() + 4);
-	const uint32 pixelCount = bitmap.width * bitmap.height;
-	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
-		return false;
-
-	bitmap.pixels.resize(pixelCount);
-	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
-	return true;
+	return decodeBitmap(data, bitmap);
 }
 
 static bool resolveInventoryCombatLoadoutId(const Common::String &objectName, int &loadoutId) {
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index a788bad1412..c9485fb58f8 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -23,7 +23,6 @@
 
 #include "common/algorithm.h"
 #include "common/config-manager.h"
-#include "common/endian.h"
 #include "common/events.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
@@ -421,15 +420,7 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	if (!resources.loadFile(path, data) || data.size() < 12)
 		return false;
 
-	bitmap.width = READ_LE_UINT32(data.data());
-	bitmap.height = READ_LE_UINT32(data.data() + 4);
-	const uint32 pixelCount = bitmap.width * bitmap.height;
-	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
-		return false;
-
-	bitmap.pixels.resize(pixelCount);
-	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
-	return true;
+	return decodeBitmap(data, bitmap);
 }
 
 static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette) {
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 7cbfefba482..456143ea073 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -2,6 +2,7 @@ MODULE := engines/harvester
 
 MODULE_OBJS = \
 	art.o \
+	bitmap.o \
 	cft_font.o \
 	cheats.o \
 	console.o \
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index c82a14372aa..ba4273598e8 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -22,7 +22,6 @@
 #include "harvester/room.h"
 
 #include "common/algorithm.h"
-#include "common/endian.h"
 #include "common/events.h"
 #include "common/ptr.h"
 #include "common/system.h"
@@ -171,15 +170,7 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 		return false;
 
 	bitmap = IndexedBitmap();
-	bitmap.width = READ_LE_UINT32(data.data());
-	bitmap.height = READ_LE_UINT32(data.data() + 4);
-	const uint32 pixelCount = bitmap.width * bitmap.height;
-	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
-		return false;
-
-	bitmap.pixels.resize(pixelCount);
-	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
-	return true;
+	return decodeBitmap(data, bitmap);
 }
 
 static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette) {
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 7f5086a0754..509c8031693 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -25,7 +25,6 @@
 
 #include "common/algorithm.h"
 #include "common/debug.h"
-#include "common/endian.h"
 #include "common/system.h"
 #include "graphics/blit.h"
 #include "graphics/screen.h"
@@ -106,38 +105,6 @@ static void blitAnimationFrame(Graphics::Screen &screen, const Common::Array<Abm
 		screen.format.bytesPerPixel, kTransparentPaletteIndex);
 }
 
-static bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) {
-	if (!compressed) {
-		if (sourceSize < dest.size())
-			return false;
-
-		memcpy(dest.data(), source, dest.size());
-		return true;
-	}
-
-	uint32 srcOffset = 0;
-	uint32 dstOffset = 0;
-	while (srcOffset < sourceSize && dstOffset < dest.size()) {
-		const byte control = source[srcOffset++];
-		if ((control & 0x80) == 0) {
-			const uint32 literalCount = MIN<uint32>(control,
-				MIN<uint32>(sourceSize - srcOffset, dest.size() - dstOffset));
-			memcpy(dest.data() + dstOffset, source + srcOffset, literalCount);
-			srcOffset += literalCount;
-			dstOffset += literalCount;
-		} else {
-			if (srcOffset >= sourceSize)
-				return false;
-
-			const uint32 repeatCount = MIN<uint32>(control & 0x7f, dest.size() - dstOffset);
-			memset(dest.data() + dstOffset, source[srcOffset++], repeatCount);
-			dstOffset += repeatCount;
-		}
-	}
-
-	return dstOffset == dest.size();
-}
-
 } // End of anonymous namespace
 
 uint32 getRuntimeClockTicks() {
@@ -166,17 +133,11 @@ bool Entity::loadBitmapResource(ResourceManager &resources, const Common::String
 	}
 
 	AbmFrame frame;
-	frame.width = READ_LE_UINT32(data.data());
-	frame.height = READ_LE_UINT32(data.data() + 4);
-	const uint32 pixelCount = frame.width * frame.height;
-	if (frame.width == 0 || frame.height == 0 || data.size() < 12 + pixelCount) {
+	if (!decodeBitmap(data, frame)) {
 		warning("Harvester: invalid runtime bitmap '%s'", path.c_str());
 		return false;
 	}
 
-	frame.pixels.resize(pixelCount);
-	memcpy(frame.pixels.data(), data.data() + 12, pixelCount);
-
 	_frames.clear();
 	_frames.push_back(Common::move(frame));
 	_baseFrames = _frames;
@@ -201,36 +162,9 @@ bool Entity::loadAbmResource(ResourceManager &resources, const Common::String &p
 		return false;
 	}
 
-	const uint32 frameCount = READ_LE_UINT32(data.data());
-	uint32 offset = 8;
-	_frames.resize(frameCount);
-
-	for (uint32 i = 0; i < frameCount; ++i) {
-		if (data.size() < offset + 25) {
-			warning("Harvester: short runtime ABM header in '%s'", path.c_str());
-			return false;
-		}
-
-		AbmFrame &frame = _frames[i];
-		frame.xOffset = (int32)READ_LE_UINT32(data.data() + offset);
-		frame.yOffset = (int32)READ_LE_UINT32(data.data() + offset + 4);
-		frame.width = READ_LE_UINT32(data.data() + offset + 8);
-		frame.height = READ_LE_UINT32(data.data() + offset + 12);
-		const bool compressed = data[offset + 16] != 0;
-		const uint32 sourceSize = READ_LE_UINT32(data.data() + offset + 17);
-		const uint32 pixelCount = frame.width * frame.height;
-		const uint32 payloadOffset = offset + 25;
-
-		if (frame.width == 0 || frame.height == 0 || data.size() < payloadOffset + sourceSize) {
-			warning("Harvester: invalid runtime ABM frame %u in '%s'", i, path.c_str());
-			return false;
-		}
-
-		frame.pixels.resize(pixelCount);
-		if (!decodeAnimationFrame(data.data() + payloadOffset, sourceSize, compressed, frame.pixels))
-			return false;
-
-		offset = payloadOffset + sourceSize;
+	if (!decodeAbm(data, _frames)) {
+		warning("Harvester: unable to decode runtime entity animation '%s'", path.c_str());
+		return false;
 	}
 
 	_resourcePath = path;
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 61fdd0f0b39..5a7f0c2186c 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -25,7 +25,7 @@
 #include "common/array.h"
 #include "common/rect.h"
 #include "common/str.h"
-#include "harvester/art.h"
+#include "harvester/bitmap.h"
 
 namespace Graphics {
 class Screen;


Commit: 4f581772ae1683a5eed4650a2fe4d0d212d1ad63
    https://github.com/scummvm/scummvm/commit/4f581772ae1683a5eed4650a2fe4d0d212d1ad63
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: Make command execution inputs and outputs explicit

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index ea889b4ce03..6cf913b48c4 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1712,18 +1712,10 @@ bool Script::executeRoomEnterCommands(const Common::String &roomName,
 	if (!room)
 		return false;
 
-	executeCommandChain(room->onEnterCommand, "room entry command", room->roomName,
-		room->roomName, true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
-		&result.requestDemoEnding, &result.cdChangeDisc,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
-		&result.modalText, &result.lightingCommand, &result.moveEntityToPlayerZName,
-		&result.requestPlayerGotoXZ, &result.requestPlayerGotoZ,
-		&result.playerGotoX, &result.playerGotoZ,
-		&result.requestPlayerDeath, &result.playerDeathDamageType,
-		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.previousTimerRecords, &result.requestCloseupExit);
+	const CommandExecutionContext context = {
+		"room entry command", room->roomName, room->roomName, true
+	};
+	executeCommandChain(room->onEnterCommand, context, kCommandOutputsInteraction, result);
 	return true;
 }
 
@@ -1735,18 +1727,10 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 	if (!room)
 		return false;
 
-	executeCommandChain(room->onExitCommand, "room exit command", room->roomName,
-		room->roomName, false,
-		&result.musicPath, &result.audioCommands, nullptr, nullptr, &result.cutscenePath,
-		nullptr, nullptr, &result.requestDemoEnding, &result.cdChangeDisc,
-		&result.dialogueNpcName, &result.dialogueContinuationTag,
-		&result.continuationTag, &result.modalText, &result.lightingCommand,
-		&result.moveEntityToPlayerZName,
-		&result.requestPlayerGotoXZ, &result.requestPlayerGotoZ,
-		&result.playerGotoX, &result.playerGotoZ,
-		&result.requestPlayerDeath, &result.playerDeathDamageType,
-		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.previousTimerRecords, &result.requestCloseupExit);
+	const CommandExecutionContext context = {
+		"room exit command", room->roomName, room->roomName, false
+	};
+	executeCommandChain(room->onExitCommand, context, kCommandOutputsRoomExit, result);
 	return true;
 }
 
@@ -1775,18 +1759,10 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 	if (object.actionTag.empty())
 		return result.mutatedRuntimeState;
 
-	executeCommandChain(object.actionTag, "interaction command", object.objectName,
-		commandRoomName, true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
-		&result.requestDemoEnding, &result.cdChangeDisc,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
-		&result.modalText, &result.lightingCommand, &result.moveEntityToPlayerZName,
-		&result.requestPlayerGotoXZ, &result.requestPlayerGotoZ,
-		&result.playerGotoX, &result.playerGotoZ,
-		&result.requestPlayerDeath, &result.playerDeathDamageType,
-		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.previousTimerRecords, &result.requestCloseupExit);
+	const CommandExecutionContext context = {
+		"interaction command", object.objectName, commandRoomName, true
+	};
+	executeCommandChain(object.actionTag, context, kCommandOutputsInteraction, result);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu || result.requestDemoEnding ||
@@ -1807,18 +1783,10 @@ bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionRes
 		return false;
 	const Common::String &commandRoomName = roomName.empty() ? region.roomName : roomName;
 
-	executeCommandChain(region.actionTag, "region command", region.regionName,
-		commandRoomName, true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
-		&result.requestDemoEnding, &result.cdChangeDisc,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
-		&result.modalText, &result.lightingCommand, &result.moveEntityToPlayerZName,
-		&result.requestPlayerGotoXZ, &result.requestPlayerGotoZ,
-		&result.playerGotoX, &result.playerGotoZ,
-		&result.requestPlayerDeath, &result.playerDeathDamageType,
-		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.previousTimerRecords, &result.requestCloseupExit);
+	const CommandExecutionContext context = {
+		"region command", region.regionName, commandRoomName, true
+	};
+	executeCommandChain(region.actionTag, context, kCommandOutputsInteraction, result);
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu || result.requestDemoEnding ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
@@ -1845,19 +1813,12 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Obj
 		commandRoomName = target.currentOwnerOrRoom;
 	}
 
-	executeCommandChain(useItem->actionTag, "useitem command",
-		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()),
-		commandRoomName, true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
-		&result.requestDemoEnding, &result.cdChangeDisc,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
-		&result.modalText, &result.lightingCommand, &result.moveEntityToPlayerZName,
-		&result.requestPlayerGotoXZ, &result.requestPlayerGotoZ,
-		&result.playerGotoX, &result.playerGotoZ,
-		&result.requestPlayerDeath, &result.playerDeathDamageType,
-		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.previousTimerRecords, &result.requestCloseupExit);
+	const Common::String contextName = Common::String::format("%s -> %s",
+		itemName.c_str(), target.objectName.c_str());
+	const CommandExecutionContext context = {
+		"useitem command", contextName, commandRoomName, true
+	};
+	executeCommandChain(useItem->actionTag, context, kCommandOutputsInteraction, result);
 	return true;
 }
 
@@ -1890,17 +1851,10 @@ bool Script::executeActionTag(const Common::String &tag, InteractionResult &resu
 	if (tag.empty())
 		return false;
 
-	executeCommandChain(tag, "action tag", tag, roomName, allowTransitions,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
-		&result.requestDemoEnding, &result.cdChangeDisc,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
-		&result.modalText, &result.lightingCommand, &result.moveEntityToPlayerZName,
-		&result.requestPlayerGotoXZ, &result.requestPlayerGotoZ,
-		&result.playerGotoX, &result.playerGotoZ,
-		&result.requestPlayerDeath, &result.playerDeathDamageType,
-		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.previousTimerRecords, &result.requestCloseupExit);
+	const CommandExecutionContext context = {
+		"action tag", tag, roomName, allowTransitions
+	};
+	executeCommandChain(tag, context, kCommandOutputsInteraction, result);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu || result.requestDemoEnding ||
@@ -1931,18 +1885,10 @@ bool Script::executeTimerAction(const Common::String &timerName, InteractionResu
 	}
 	const Common::String &commandRoomName = roomName.empty() ? timer->arg1 : roomName;
 
-	executeCommandChain(timer->arg2, "timer command", timer->timerName, commandRoomName,
-		allowTransitions,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
-		&result.requestDemoEnding, &result.cdChangeDisc,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
-		&result.modalText, &result.lightingCommand, &result.moveEntityToPlayerZName,
-		&result.requestPlayerGotoXZ, &result.requestPlayerGotoZ,
-		&result.playerGotoX, &result.playerGotoZ,
-		&result.requestPlayerDeath, &result.playerDeathDamageType,
-		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.previousTimerRecords, &result.requestCloseupExit);
+	const CommandExecutionContext context = {
+		"timer command", timer->timerName, commandRoomName, allowTransitions
+	};
+	executeCommandChain(timer->arg2, context, kCommandOutputsInteraction, result);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu || result.requestDemoEnding ||
@@ -2787,62 +2733,45 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 	return true;
 }
 
-void Script::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
-		const Common::String &contextName, const Common::String &contextRoomName,
-		bool allowTransitions, Common::String *musicPath,
-		Common::Array<AudioCommand> *audioCommands, Common::String *nextRoomName,
-		StartupRoomTransitionKind *roomTransition,
-		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
-		bool *requestDemoEnding,
-		int *cdChangeDisc,
-		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
-		Common::String *continuationTag, ResolvedText *modalText,
-		StartupLightingCommand *lightingCommand, Common::String *moveEntityToPlayerZName,
-		bool *requestPlayerGotoXZ, bool *requestPlayerGotoZ,
-		int *playerGotoX, int *playerGotoZ,
-		bool *requestPlayerDeath, int *playerDeathDamageType,
-		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
-		Common::Array<TimerRecord> *previousTimerRecords, bool *requestCloseupExit) {
+void Script::executeCommandChain(const Common::String &initialTag, const CommandExecutionContext &context,
+		CommandOutputPolicy outputPolicy, InteractionResult &result) {
+	debugC(3, kDebugScene, "Harvester: command chain tag='%s' context='%s' source='%s' room='%s' transitions=%d outputs='%s'",
+		initialTag.c_str(), context.label, context.name.c_str(), context.roomName.c_str(), context.allowTransitions,
+		outputPolicy == kCommandOutputsRoomExit ? "room exit" : "interaction");
+
 	auto isTruthy = [](const Common::String &value) {
 		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
 	};
 	auto noteMutation = [&](bool changed) {
-		if (changed && mutatedRuntimeState)
-			*mutatedRuntimeState = true;
+		if (changed)
+			result.mutatedRuntimeState = true;
 	};
 	auto noteCurrentRoomVisualMutation = [&](bool changed, bool affectsCurrentRoom) {
 		noteMutation(changed);
-		if (changed && affectsCurrentRoom && visualRuntimeStateChanged)
-			*visualRuntimeStateChanged = true;
+		if (changed && affectsCurrentRoom)
+			result.visualRuntimeStateChanged = true;
 	};
 	auto noteTimerMutation = [&](const TimerRecord *previousTimer, bool changed) {
-		if (!changed || !previousTimerRecords || !previousTimer ||
-				previousTimer->timerName.empty()) {
+		if (!changed || !previousTimer || previousTimer->timerName.empty())
 			return;
-		}
 
-		for (const TimerRecord &record : *previousTimerRecords) {
+		for (const TimerRecord &record : result.previousTimerRecords) {
 			if (record.timerName.equalsIgnoreCase(previousTimer->timerName))
 				return;
 		}
 
-		previousTimerRecords->push_back(*previousTimer);
+		result.previousTimerRecords.push_back(*previousTimer);
 	};
 	auto belongsToContextRoom = [&](const Common::String &roomName) {
-		return !contextRoomName.empty() && roomName.equalsIgnoreCase(contextRoomName);
+		return !context.roomName.empty() && roomName.equalsIgnoreCase(context.roomName);
 	};
 	auto hasDeferredInteractionOutputs = [&]() {
-		return (nextRoomName && !nextRoomName->empty()) ||
-			(cutscenePath && !cutscenePath->empty()) ||
-			(deathFlicPath && !deathFlicPath->empty()) ||
-			(requestMainMenu && *requestMainMenu) ||
-			(requestDemoEnding && *requestDemoEnding) ||
-			(dialogueNpcName && !dialogueNpcName->empty()) ||
-			(continuationTag && !continuationTag->empty()) ||
-			(modalText && !modalText->value.empty()) ||
-			(lightingCommand && *lightingCommand != kStartupLightingCommandNone) ||
-			(moveEntityToPlayerZName && !moveEntityToPlayerZName->empty()) ||
-			(requestCloseupExit && *requestCloseupExit);
+		return (outputPolicy == kCommandOutputsInteraction &&
+				(!result.nextRoomName.empty() || !result.deathFlicPath.empty() || result.requestMainMenu)) ||
+			!result.cutscenePath.empty() || result.requestDemoEnding ||
+			!result.dialogueNpcName.empty() || !result.continuationTag.empty() ||
+			!result.modalText.value.empty() || result.lightingCommand != kStartupLightingCommandNone ||
+			!result.moveEntityToPlayerZName.empty() || result.requestCloseupExit;
 	};
 
 	Common::String currentTag = initialTag;
@@ -2850,20 +2779,20 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		const CommandRecord *command = findCommandRecord(currentTag);
 		if (!command) {
 			debug(1, "Harvester: unresolved %s tag '%s' for '%s'",
-				contextLabel, currentTag.c_str(), contextName.c_str());
+				context.label, currentTag.c_str(), context.name.c_str());
 			break;
 		}
 
 		debugC(1, kDebugScene,
 			"Harvester: %s '%s' step=%u tag='%s' opcode='%s' args=['%s','%s','%s','%s']",
-			contextLabel, contextName.c_str(), step, currentTag.c_str(), command->opcodeName.c_str(),
+			context.label, context.name.c_str(), step, currentTag.c_str(), command->opcodeName.c_str(),
 			command->arg1.c_str(), command->arg2.c_str(), command->arg3.c_str(), command->arg4.c_str());
 
 		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
 			const FlagRecord *flag = findRuntimeFlag(command->arg1);
 			const bool flagValue = flag && flag->value;
 			debugC(1, kDebugScene, "Harvester: %s '%s' flag '%s' -> %d",
-				contextLabel, contextName.c_str(), command->arg1.c_str(), flagValue);
+				context.label, context.name.c_str(), command->arg1.c_str(), flagValue);
 			currentTag = flagValue ? command->arg2 : command->arg3;
 			continue;
 		}
@@ -2894,7 +2823,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 			debugC(1, kDebugGeneral,
 				"Harvester: %s '%s' SET_FLAG '%s' %d -> %d existed=%d changed=%d",
-				contextLabel, contextName.c_str(), command->arg1.c_str(), oldValue, flagValue,
+				context.label, context.name.c_str(), command->arg1.c_str(), oldValue, flagValue,
 				existed, changed);
 			noteMutation(changed);
 			currentTag = command->arg4;
@@ -2902,23 +2831,14 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
-			if (musicPath)
-				*musicPath = command->arg1;
+			result.musicPath = command->arg1;
 			currentTag = command->arg4;
 			continue;
 		}
 
-		if (audioCommands) {
-			if (appendStartupAudioCommand(*command, *audioCommands)) {
-				currentTag = command->arg4;
-				continue;
-			}
-		} else {
-			Common::Array<AudioCommand> ignoredAudioCommands;
-			if (appendStartupAudioCommand(*command, ignoredAudioCommands)) {
-				currentTag = command->arg4;
-				continue;
-			}
+		if (appendStartupAudioCommand(*command, result.audioCommands)) {
+			currentTag = command->arg4;
+			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("ADD") ||
@@ -2926,7 +2846,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			ObjectRecord *currentObject = findRuntimeObject(command->arg1, command->arg2);
 			if (!currentObject) {
 				debug(1, "Harvester: unresolved object for %s '%s' owner='%s' object='%s'",
-					contextLabel, contextName.c_str(), command->arg1.c_str(), command->arg2.c_str());
+					context.label, context.name.c_str(), command->arg1.c_str(), command->arg2.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
@@ -2958,7 +2878,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			AnimRecord *currentAnimation = findRuntimeAnim(command->arg1);
 			if (!currentAnimation) {
 				debug(1, "Harvester: unresolved anim for %s '%s' anim='%s'",
-					contextLabel, contextName.c_str(), command->arg1.c_str());
+					context.label, context.name.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
@@ -2991,7 +2911,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			RegionRecord *currentRegion = findRuntimeRegion(command->arg1);
 			if (!currentRegion) {
 				debug(1, "Harvester: unresolved region for %s '%s' region='%s'",
-					contextLabel, contextName.c_str(), command->arg1.c_str());
+					context.label, context.name.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
@@ -3009,7 +2929,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			NpcRecord *currentNpc = findRuntimeNpc(command->arg1);
 			if (!currentNpc) {
 				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
-					contextLabel, contextName.c_str(), command->arg1.c_str());
+					context.label, context.name.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
@@ -3027,12 +2947,12 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 		if (command->opcodeName.equalsIgnoreCase("SET_MONSTER")) {
 			MonsterRecord monsterLookup;
-			monsterLookup.roomName = contextRoomName;
+			monsterLookup.roomName = context.roomName;
 			monsterLookup.monsterName = command->arg1;
 			MonsterRecord *currentMonster = findRuntimeMonster(monsterLookup);
 			if (!currentMonster) {
 				debug(1, "Harvester: unresolved monster for %s '%s' monster='%s'",
-					contextLabel, contextName.c_str(), command->arg1.c_str());
+					context.label, context.name.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
@@ -3089,22 +3009,16 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			const ExecListRecord *execList = findExecListRecord(command->arg1);
 			if (!execList) {
 				debug(1, "Harvester: unresolved exec list for %s '%s' list='%s'",
-					contextLabel, contextName.c_str(), command->arg1.c_str());
+					context.label, context.name.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
 
 			for (const Common::String &entry : execList->entries) {
-				executeCommandChain(entry, "exec list entry", execList->listName,
-					contextRoomName, allowTransitions,
-					musicPath, audioCommands, nextRoomName, roomTransition, cutscenePath,
-					deathFlicPath, requestMainMenu, requestDemoEnding, cdChangeDisc,
-					dialogueNpcName, dialogueContinuationTag,
-					continuationTag, modalText, lightingCommand, moveEntityToPlayerZName,
-					requestPlayerGotoXZ, requestPlayerGotoZ,
-					playerGotoX, playerGotoZ, requestPlayerDeath, playerDeathDamageType,
-					mutatedRuntimeState, visualRuntimeStateChanged,
-					previousTimerRecords, requestCloseupExit);
+				const CommandExecutionContext entryContext = {
+					"exec list entry", execList->listName, context.roomName, context.allowTransitions
+				};
+				executeCommandChain(entry, entryContext, outputPolicy, result);
 				if (hasDeferredInteractionOutputs())
 					return;
 			}
@@ -3113,55 +3027,41 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("START_DIALOG")) {
-			if (!dialogueNpcName || !dialogueContinuationTag) {
-				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without dialogue context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
-				return;
-			}
 			if (command->arg1.empty()) {
 				currentTag = command->arg4;
 				continue;
 			}
 
-			*dialogueNpcName = command->arg1;
-			*dialogueContinuationTag = command->arg4;
+			result.dialogueNpcName = command->arg1;
+			result.dialogueContinuationTag = command->arg4;
 			return;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("GOFLIC")) {
-			if (!cutscenePath) {
-				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no cutscene context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			*cutscenePath = command->arg1;
-			if (continuationTag)
-				*continuationTag = command->arg4;
+			result.cutscenePath = command->arg1;
+			result.continuationTag = command->arg4;
 			return;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("GODEATHFLIC")) {
-			if (!deathFlicPath || !requestMainMenu) {
+			if (outputPolicy == kCommandOutputsRoomExit) {
 				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without menu-exit context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+					command->opcodeName.c_str(), context.label, context.name.c_str());
 				return;
 			}
 
-			if (allowTransitions) {
-				*deathFlicPath = command->arg1;
-				*requestMainMenu = true;
+			if (context.allowTransitions) {
+				result.deathFlicPath = command->arg1;
+				result.requestMainMenu = true;
 			} else {
 				debugC(1, kDebugScene, "Harvester: skipped transition opcode '%s' while processing %s '%s'",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+					command->opcodeName.c_str(), context.label, context.name.c_str());
 			}
 			return;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("END_DEMO")) {
-			if (requestDemoEnding)
-				*requestDemoEnding = true;
+			result.requestDemoEnding = true;
 			return;
 		}
 
@@ -3170,7 +3070,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			NpcRecord *currentNpc = findRuntimeNpc(command->arg1);
 			if (!currentNpc) {
 				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
-					contextLabel, contextName.c_str(), command->arg1.c_str());
+					context.label, context.name.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
@@ -3178,8 +3078,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			const int deathDamageType = parseDeathDamageType(command->arg2);
 			const bool deferLiveNpcDeathTransition =
 				_deferredLiveNpcDeathTransitionDepth > 0 &&
-				!contextRoomName.empty() &&
-				currentNpc->roomName.equalsIgnoreCase(contextRoomName) &&
+				!context.roomName.empty() &&
+				currentNpc->roomName.equalsIgnoreCase(context.roomName) &&
 				currentNpc->active &&
 				currentNpc->visible &&
 				!currentNpc->deathOrMonsterfyFlag &&
@@ -3193,21 +3093,13 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SHOW_TEXT")) {
-			if (!modalText) {
-				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no modal-text context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			if (!resolveTextRecord(command->arg1, *modalText)) {
+			if (!resolveTextRecord(command->arg1, result.modalText)) {
 				debug(1, "Harvester: unresolved SHOW_TEXT record for %s '%s' key='%s'",
-					contextLabel, contextName.c_str(), command->arg1.c_str());
+					context.label, context.name.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
-			if (continuationTag)
-				*continuationTag = command->arg4;
+			result.continuationTag = command->arg4;
 			return;
 		}
 
@@ -3237,17 +3129,14 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("MOVE_BM2PCZ")) {
-			if (moveEntityToPlayerZName)
-				*moveEntityToPlayerZName = command->arg1;
+			result.moveEntityToPlayerZName = command->arg1;
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("KILL_PC")) {
-			if (requestPlayerDeath)
-				*requestPlayerDeath = true;
-			if (playerDeathDamageType)
-				*playerDeathDamageType = parseDeathDamageType(command->arg1);
+			result.requestPlayerDeath = true;
+			result.playerDeathDamageType = parseDeathDamageType(command->arg1);
 			noteMutation(setPlayerCurrentHitPoints(0));
 			currentTag = command->arg4;
 			continue;
@@ -3266,106 +3155,70 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ")) {
-			if (!requestPlayerGotoXZ || !playerGotoX || !playerGotoZ) {
-				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no player-move context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			*requestPlayerGotoXZ = true;
-			*playerGotoX = parseAsciiIntOrZero(command->arg1);
-			*playerGotoZ = parseAsciiIntOrZero(command->arg2);
+			result.requestPlayerGotoXZ = true;
+			result.playerGotoX = parseAsciiIntOrZero(command->arg1);
+			result.playerGotoZ = parseAsciiIntOrZero(command->arg2);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("PC_GOTO_Z")) {
-			if (!requestPlayerGotoZ || !playerGotoZ) {
-				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no player-move context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			*requestPlayerGotoZ = true;
-			*playerGotoZ = parseAsciiIntOrZero(command->arg1);
+			result.requestPlayerGotoZ = true;
+			result.playerGotoZ = parseAsciiIntOrZero(command->arg1);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING")) {
-			if (!lightingCommand) {
-				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no lighting context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
 			if (command->arg1.equalsIgnoreCase("DIM"))
-				*lightingCommand = kStartupLightingCommandDim;
+				result.lightingCommand = kStartupLightingCommandDim;
 			else if (command->arg1.equalsIgnoreCase("NORMAL"))
-				*lightingCommand = kStartupLightingCommandNormal;
+				result.lightingCommand = kStartupLightingCommandNormal;
 			else if (command->arg1.equalsIgnoreCase("NONE"))
-				*lightingCommand = kStartupLightingCommandBlack;
+				result.lightingCommand = kStartupLightingCommandBlack;
 			else if (command->arg1.equalsIgnoreCase("FADE_IN"))
-				*lightingCommand = kStartupLightingCommandFadeIn;
+				result.lightingCommand = kStartupLightingCommandFadeIn;
 			else
 				debug(1, "Harvester: unsupported CHANGE_LIGHTING mode '%s' for %s '%s', preserving control flow",
-					command->arg1.c_str(), contextLabel, contextName.c_str());
-			if (continuationTag)
-				*continuationTag = command->arg4;
+					command->arg1.c_str(), context.label, context.name.c_str());
+			result.continuationTag = command->arg4;
 			return;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("CHANGE_CD")) {
-			if (!cdChangeDisc) {
-				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no CD-change context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
 			const int requestedDisc = parseAsciiIntOrZero(command->arg1);
 			if (requestedDisc > 0)
-				*cdChangeDisc = requestedDisc;
+				result.cdChangeDisc = requestedDisc;
 			else
 				debug(1, "Harvester: invalid CHANGE_CD target '%s' for %s '%s'",
-					command->arg1.c_str(), contextLabel, contextName.c_str());
+					command->arg1.c_str(), context.label, context.name.c_str());
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
-			if (allowTransitions && nextRoomName) {
-				*nextRoomName = command->arg1;
-				if (roomTransition) {
-					*roomTransition = command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")
-						? kStartupRoomTransitionChangeRoom
-						: kStartupRoomTransitionCloseup;
-				}
+			if (context.allowTransitions && outputPolicy == kCommandOutputsInteraction) {
+				result.nextRoomName = command->arg1;
+				result.roomTransition = command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")
+					? kStartupRoomTransitionChangeRoom
+					: kStartupRoomTransitionCloseup;
 			} else {
 				debugC(1, kDebugScene, "Harvester: skipped transition opcode '%s' while processing %s '%s'",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+					command->opcodeName.c_str(), context.label, context.name.c_str());
 			}
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP")) {
-			if (requestCloseupExit) {
-				*requestCloseupExit = true;
-			} else {
-				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without closeup-exit context",
-					command->opcodeName.c_str(), contextLabel, contextName.c_str());
-			}
+			result.requestCloseupExit = true;
 			currentTag = command->arg4;
 			continue;
 		}
 
 		debug(1, "Harvester: unsupported startup command '%s' for %s '%s', continuing",
-			command->opcodeName.c_str(), contextLabel, contextName.c_str());
+			command->opcodeName.c_str(), context.label, context.name.c_str());
 		currentTag = command->arg4;
 	}
 }
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 1d721ec50dd..c3872803fae 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -434,6 +434,19 @@ public:
 	bool setRuntimeTimerEnabled(const Common::String &timerName, bool enabled);
 
 private:
+	struct CommandExecutionContext {
+		const char *label;
+		const Common::String &name;
+		const Common::String &roomName;
+		bool allowTransitions;
+	};
+
+	enum CommandOutputPolicy {
+		kCommandOutputsInteraction,
+		// Suppress destination rooms, death movies and main-menu requests.
+		kCommandOutputsRoomExit
+	};
+
 	bool loadConfig(ResourceManager &resources);
 	void decode();
 	void parseTownRecords(ResourceManager &resources);
@@ -459,22 +472,8 @@ private:
 	const TimerRecord *findRuntimeTimer(const Common::String &timerName) const;
 	bool buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,
 		ResourceManager &resources, RoomSetupState &state) const;
-	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
-		const Common::String &contextName, const Common::String &contextRoomName,
-		bool allowTransitions, Common::String *musicPath,
-		Common::Array<AudioCommand> *audioCommands, Common::String *nextRoomName,
-		StartupRoomTransitionKind *roomTransition,
-		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
-		bool *requestDemoEnding,
-		int *cdChangeDisc,
-		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
-		Common::String *continuationTag, ResolvedText *modalText,
-		StartupLightingCommand *lightingCommand, Common::String *moveEntityToPlayerZName,
-		bool *requestPlayerGotoXZ, bool *requestPlayerGotoZ,
-		int *playerGotoX, int *playerGotoZ,
-		bool *requestPlayerDeath, int *playerDeathDamageType,
-		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
-		Common::Array<TimerRecord> *previousTimerRecords, bool *requestCloseupExit);
+	void executeCommandChain(const Common::String &initialTag, const CommandExecutionContext &context,
+		CommandOutputPolicy outputPolicy, InteractionResult &result);
 	bool probePickupBlockingCommandChain(const Common::String &initialTag,
 		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;


Commit: 3be8724e8bcdba6ca2d9e25b837551727c307466
    https://github.com/scummvm/scummvm/commit/3be8724e8bcdba6ca2d9e25b837551727c307466
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: extract RoomSession for managing room state and player interactions

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/room_session.cpp
  A engines/harvester/room_session.h
    engines/harvester/module.mk
    engines/harvester/room.cpp


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 456143ea073..af951392791 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -19,6 +19,7 @@ MODULE_OBJS = \
 	resources.o \
 	room_combat.o \
 	room_interaction.o \
+	room_session.o \
 	room.o \
 	runtime_entity.o \
 	saveload.o \
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ba4273598e8..29f261aca4b 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -44,6 +44,7 @@
 #include "harvester/player.h"
 #include "harvester/room_combat.h"
 #include "harvester/room_interaction.h"
+#include "harvester/room_session.h"
 #include "harvester/room_support.h"
 
 namespace Harvester {
@@ -450,7 +451,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		flow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
 			(void)_engine.playMusic(scene.state.musicPath);
-		RoomPlayerState playerState;
+		RoomSession session;
+		debugC(1, kDebugRoom, "Harvester: created room session room='%s'", scene.state.roomName.c_str());
+		RoomPlayerState &playerState = session.getPlayerState();
 		playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
 		playerState.centerX = scene.state.playerSpawnX;
 		playerState.bottomY = scene.state.playerSpawnY;
@@ -483,12 +486,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		ResolvedText combatLoadoutStatusText;
 		uint32 combatLoadoutStatusHideTick = 0;
 		bool closeInventoryAfterCombatLoadoutStatus = false;
-		bool moveLeft = false;
-		bool moveRight = false;
-		bool moveUp = false;
-		bool moveDown = false;
+		bool &moveLeft = session.getMovementInput().moveLeft;
+		bool &moveRight = session.getMovementInput().moveRight;
+		bool &moveUp = session.getMovementInput().moveUp;
+		bool &moveDown = session.getMovementInput().moveDown;
 		bool attackModifierHeld = false;
-		Common::String pendingRegionName;
+		Common::String &pendingRegionName = session.getPendingRegionName();
 		Common::String pendingRoomChange;
 		bool pendingRoomChangeIsRoomName = false;
 		bool pendingRoomChangeUsesSavedRoomState = false;
@@ -496,7 +499,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		const bool canExitCloseupToParent = flow._roomLoopDepth > 1;
 		uint32 lastLeftButtonReleaseTick = 0;
 		uint32 nextKeyboardAttackAllowedTick = 0;
-		RoomIdleAnimationState idleState;
+		RoomIdleAnimationState &idleState = session.getIdleState();
 		bool needsRedraw = true;
 		Common::String carriedRoomItemName;
 		Common::String carriedRoomItemLabel;
@@ -542,40 +545,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			Entity *entity = entityManager->detachSceneEntityByName(entityName);
 			delete entity;
 		};
-		auto resetIdleState = [&]() {
-			idleState = RoomIdleAnimationState();
-			idleState.activityTick = Player::getRuntimeClockTicks();
-			idleState.resetTick = idleState.activityTick;
-			Player::updateIdleTrigger(idleState);
-		};
-		auto notePlayerActivity = [&]() {
-			idleState.activityTick = Player::getRuntimeClockTicks();
-			Player::updateIdleTrigger(idleState);
-		};
-		auto isPlayerCombatLocked = [&]() {
-			return playerState.attackActive || playerState.hitActive || playerState.deathActive;
-		};
-		auto cancelPlayerAttackAnimation = [&]() {
-			if (!playerState.attackActive)
-				return false;
 
-			playerState.attackActive = false;
-			playerState.attackFirstFrame = -1;
-			playerState.attackLastFrame = -1;
-			playerState.attackContactFrame = -1;
-			if (!playerState.entity)
-				return false;
-			playerState.attackSoundPlayed = false;
-			playerState.attackSoundPlaybackFrame = -1;
-			playerState.attackContactResolved = false;
-			playerState.attackTargetName.clear();
-			playerState.attackTargetClassId = -1;
-			const int resumeFacing = playerState.attackResumeFacing >= 0
-				? playerState.attackResumeFacing
-				: (playerState.facing >= 0 ? playerState.facing : 0);
-			playerState.attackResumeFacing = -1;
-			return Player::setIdleAnimation(playerState, resumeFacing);
-		};
 		auto syncCurrentRoomRuntimeState = [&]() {
 			Script *script = _engine.getScript();
 			if (!script)
@@ -1361,21 +1331,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			captureCurrentSaveState();
 			return _inventory.refresh();
 		};
-		resetIdleState();
-		auto stopPlayerRegionInteraction = [&]() {
-			moveLeft = false;
-			moveRight = false;
-			moveUp = false;
-			moveDown = false;
-			pendingRegionName.clear();
-			(void)cancelPlayerAttackAnimation();
-			playerState.hasMoveTarget = false;
-			playerState.turnActive = false;
-			playerState.turnTargetFacing = -1;
-			if (playerState.entity && playerState.facing >= 0)
-				(void)Player::setIdleAnimation(playerState, playerState.facing);
-			Player::syncAnimationPlacement(playerState);
-		};
+		session.resetIdleState();
+
 		auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
 			Common::Array<AudioCommand> entryAudioCommands = scene.state.audioCommands;
 			RoomSetupState refreshedState;
@@ -1438,7 +1395,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			monsterCombatStates.clear();
 			monsterCombatStates.resize(scene.state.roomMonsters.size());
 			pendingRegionName.clear();
-			resetIdleState();
+			session.resetIdleState();
 			flow.resetCursorAnimationSequence();
 			captureCurrentSaveState();
 			return _inventory.refresh();
@@ -1501,7 +1458,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			moveUp = false;
 			moveDown = false;
 			pendingRegionName.clear();
-			(void)cancelPlayerAttackAnimation();
+			(void)session.cancelPlayerAttackAnimation();
 			playerState.hasMoveTarget = false;
 			playerState.turnActive = false;
 			playerState.turnTargetFacing = -1;
@@ -1732,7 +1689,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				hasScreenRect ? screenRect.left : 0, hasScreenRect ? screenRect.top : 0,
 				hasScreenRect ? screenRect.right : 0, hasScreenRect ? screenRect.bottom : 0,
 				(double)depthScale);
-			resetIdleState();
+			session.resetIdleState();
 			captureCurrentSaveState();
 		};
 		auto runModalShowText = [&](const ResolvedText &modalText) -> Common::Error {
@@ -1856,12 +1813,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		typedef decltype(applyLightingCommand) ApplyLightingCommandFn;
 		typedef decltype(applyPlayerGotoXZ) ApplyPlayerGotoXZFn;
 		typedef decltype(runModalShowText) RunModalShowTextFn;
-		typedef decltype(resetIdleState) ResetIdleStateFn;
-		typedef decltype(stopPlayerRegionInteraction) StopPlayerRegionInteractionFn;
 		typedef decltype(startPlayerDefeatSequence) StartPlayerDefeatSequenceFn;
 
 		struct RoomInteractionCallbacksImpl : RoomInteractionCallbacks {
 			RoomInteractionCallbacksImpl(
+					RoomSession &session_,
 					RefreshCurrentSceneFn &refreshCurrentSceneFn_,
 					ApplyCurrentRoomRuntimeMutationsInPlaceFn &applyCurrentRoomRuntimeMutationsInPlaceFn_,
 					SyncGlobalTimerEntitiesFn &syncGlobalTimerEntitiesFn_,
@@ -1871,10 +1827,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					ApplyLightingCommandFn &applyLightingCommandFn_,
 					ApplyPlayerGotoXZFn &applyPlayerGotoXZFn_,
 					RunModalShowTextFn &runModalShowTextFn_,
-					ResetIdleStateFn &resetIdleStateFn_,
-					StopPlayerRegionInteractionFn &stopPlayerRegionInteractionFn_,
 					StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn_)
-				: refreshCurrentSceneFn(refreshCurrentSceneFn_),
+				: session(session_), refreshCurrentSceneFn(refreshCurrentSceneFn_),
 				  applyCurrentRoomRuntimeMutationsInPlaceFn(applyCurrentRoomRuntimeMutationsInPlaceFn_),
 				  syncGlobalTimerEntitiesFn(syncGlobalTimerEntitiesFn_),
 				  captureDialogueBackdropFn(captureDialogueBackdropFn_),
@@ -1883,8 +1837,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				  applyLightingCommandFn(applyLightingCommandFn_),
 				  applyPlayerGotoXZFn(applyPlayerGotoXZFn_),
 				  runModalShowTextFn(runModalShowTextFn_),
-				  resetIdleStateFn(resetIdleStateFn_),
-				  stopPlayerRegionInteractionFn(stopPlayerRegionInteractionFn_),
 				  startPlayerDefeatSequenceFn(startPlayerDefeatSequenceFn_) {
 			}
 
@@ -1925,11 +1877,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			void resetIdleState() override {
-				resetIdleStateFn();
+				session.resetIdleState();
 			}
 
 			void stopPlayerRegionInteraction() override {
-				stopPlayerRegionInteractionFn();
+				session.stopPlayerRegionInteraction();
 			}
 
 			void startPlayerDefeatSequence(const char *reason, const Common::String &sourceName,
@@ -1937,6 +1889,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				startPlayerDefeatSequenceFn(reason, sourceName, damageType);
 			}
 
+			RoomSession &session;
 			RefreshCurrentSceneFn &refreshCurrentSceneFn;
 			ApplyCurrentRoomRuntimeMutationsInPlaceFn &applyCurrentRoomRuntimeMutationsInPlaceFn;
 			SyncGlobalTimerEntitiesFn &syncGlobalTimerEntitiesFn;
@@ -1946,15 +1899,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			ApplyLightingCommandFn &applyLightingCommandFn;
 			ApplyPlayerGotoXZFn &applyPlayerGotoXZFn;
 			RunModalShowTextFn &runModalShowTextFn;
-			ResetIdleStateFn &resetIdleStateFn;
-			StopPlayerRegionInteractionFn &stopPlayerRegionInteractionFn;
 			StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn;
 		};
 		RoomInteractionCallbacksImpl interactionCallbacks(
+			session,
 			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace,
 			syncGlobalTimerEntities, captureDialogueBackdrop, showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
-			resetIdleState, stopPlayerRegionInteraction, startPlayerDefeatSequence);
+			startPlayerDefeatSequence);
 		RoomInteractionProcessor interactionProcessor(
 			_engine, flow, scene, playerState, pendingRegionName, pendingRoomChange,
 			pendingRoomChangeIsRoomName, pendingRoomChangeUsesSavedRoomState,
@@ -2008,7 +1960,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			flow.resetCursorAnimationSequence();
-			resetIdleState();
+			session.resetIdleState();
 		}
 		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
 			_engine.getScript());
@@ -2272,7 +2224,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (!flow.hasPendingMainMenuReturn() &&
 				_engine.getScript() &&
 				_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
-			stopPlayerRegionInteraction();
+			session.stopPlayerRegionInteraction();
 			startPlayerDefeatSequence("combat_interaction", Common::String(), 1);
 		}
 
@@ -3059,12 +3011,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 							playerState.entity->setVisible(true);
 							if (playerHitPointsAfter > 0 &&
 									Player::startHitAnimation(_engine, playerState, combatState.attackFirstFrame)) {
-								notePlayerActivity();
+								session.notePlayerActivity();
 							}
 						}
 						if (changed && playerHitPointsAfter <= 0) {
 							playerAlive = false;
-							stopPlayerRegionInteraction();
+							session.stopPlayerRegionInteraction();
 							startPlayerDefeatSequence(
 								"combat_monster_hit", monster.monsterName, monster.damageType);
 							return Common::kNoError;
@@ -3468,7 +3420,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return Common::kNoError;
 		}
 
-		stopPlayerRegionInteraction();
+		session.stopPlayerRegionInteraction();
 		return runRegionInteraction(*region);
 	};
 	auto tryActivatePassiveRegion = [&]() -> Common::Error {
@@ -3483,7 +3435,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (!doesPlayerFacingMatchRegion(playerState.facing, region))
 				continue;
 
-			stopPlayerRegionInteraction();
+			session.stopPlayerRegionInteraction();
 			return runRegionInteraction(region);
 		}
 
@@ -3578,7 +3530,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			const bool activeCarry = inventorySelectionActive || roomCarryActive;
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
 			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
-				isPlayerCombatLocked() ||
+				session.isPlayerCombatLocked() ||
 				(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
 			RoomHoverState hoverState = suppressHover
 				? RoomHoverState()
@@ -3638,7 +3590,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (Entity *cursor = entityManager ? entityManager->getCursorEntity() : nullptr) {
 				cursor->setAnimationSequence(
 					(showingInspectText || idleState.active || idleState.exiting ||
-						isPlayerCombatLocked() ||
+						session.isPlayerCombatLocked() ||
 						_inventory.isOpen() || activeCarry)
 						? 7
 						: hoverState.cursorSequence);
@@ -3704,13 +3656,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				needsRedraw = true;
 				break;
 			case Common::EVENT_RBUTTONDOWN: {
-				notePlayerActivity();
+				session.notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
 					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
 					break;
 				}
-				if (isPlayerCombatLocked())
+				if (session.isPlayerCombatLocked())
 					break;
 				pendingRegionName.clear();
 				if (_inventory.isOpen()) {
@@ -3824,7 +3776,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					break;
 				}
 				Script *script = _engine.getScript();
-				if (!showingInspectText && !isPlayerCombatLocked() && script && playerState.entity &&
+				if (!showingInspectText && !session.isPlayerCombatLocked() && script && playerState.entity &&
 						script->getPlayerCurrentHitPoints() > 0 && !script->isPlayerControlPaused()) {
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
@@ -3863,7 +3815,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				const bool isFastExitClick =
 					lastLeftButtonReleaseTick != 0 &&
 					now - lastLeftButtonReleaseTick < kRoomExitFastClickWindowTicks;
-				notePlayerActivity();
+				session.notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
 					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
@@ -3879,7 +3831,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					}
 					break;
 				}
-				if (isPlayerCombatLocked())
+				if (session.isPlayerCombatLocked())
 					break;
 
 				const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
@@ -4030,7 +3982,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					if (isFastExitClick &&
 						hoverState.region->startEnabled &&
 						roomAllowsImmediateExitClick(scene.state.roomName)) {
-						stopPlayerRegionInteraction();
+						session.stopPlayerRegionInteraction();
 						Common::Error interactionError = runRegionInteraction(*hoverState.region);
 						if (interactionError.getCode() != Common::kNoError)
 							return interactionError;
@@ -4164,7 +4116,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					break;
 				}
 
-				notePlayerActivity();
+				session.notePlayerActivity();
 				if (event.kbd.hasFlags(Common::KBD_CTRL))
 					attackModifierHeld = true;
 				if (idleState.active || idleState.exiting) {
@@ -4233,7 +4185,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						break;
 					}
 				}
-				if (isPlayerCombatLocked())
+				if (session.isPlayerCombatLocked())
 					break;
 
 				if (event.kbd.keycode == Common::KEYCODE_LCTRL ||
@@ -4258,7 +4210,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					needsRedraw = true;
 					break;
 				} else if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					if (cancelPlayerAttackAnimation())
+					if (session.cancelPlayerAttackAnimation())
 						needsRedraw = true;
 					if (!stowCarriedRoomItemToInventory())
 						return Common::kReadingFailed;
@@ -4284,7 +4236,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					if (menuError.getCode() != Common::kNoError)
 						return menuError;
 					flow.resetCursorAnimationSequence();
-					resetIdleState();
+					session.resetIdleState();
 					needsRedraw = true;
 					break;
 				}
@@ -4330,7 +4282,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				playerState.hasMoveTarget || playerState.turnActive ||
 				playerState.attackActive || playerState.hitActive)) {
 			attackModifierHeld = false;
-			stopPlayerRegionInteraction();
+			session.stopPlayerRegionInteraction();
 		}
 
 		// Native run_harvester_main_loop checks player/region overlap before
@@ -4338,7 +4290,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		// reaches a narrow exit: the actor update may otherwise satisfy the X
 		// target and replace that frame with a narrower idle frame first.
 		Common::Error pendingRegionError =
-			isPlayerCombatLocked()
+			session.isPlayerCombatLocked()
 				? Common::kNoError
 				: tryActivatePendingRegion();
 		if (pendingRegionError.getCode() != Common::kNoError)
@@ -4350,7 +4302,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				return Common::kReadingFailed;
 			break;
 		}
-		if (!isPlayerCombatLocked()) {
+		if (!session.isPlayerCombatLocked()) {
 			pendingRegionError = tryActivatePassiveRegion();
 			if (pendingRegionError.getCode() != Common::kNoError)
 				return pendingRegionError;
@@ -4399,7 +4351,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (Player::startKeyboardAttackAnimation(
 						scene.state, playerState, moveLeft, moveRight, moveUp, moveDown)) {
 					nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
-					notePlayerActivity();
+					session.notePlayerActivity();
 					debugC(1, kDebugCombat,
 						"Harvester: combat player keyboard attack start loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s' input=(L=%d R=%d U=%d D=%d) facing=%d",
 						playerState.combatLoadout,
@@ -4429,12 +4381,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				? pendingRegion->desiredFacing : -1;
 			if (Player::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
-				notePlayerActivity();
+				session.notePlayerActivity();
 				needsRedraw = true;
 			} else if (Player::stepMoveTarget(
 					_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, regionFacing)) {
-				notePlayerActivity();
+				session.notePlayerActivity();
 				needsRedraw = true;
 			}
 
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
new file mode 100644
index 00000000000..8cfd5d24297
--- /dev/null
+++ b/engines/harvester/room_session.cpp
@@ -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/>.
+ *
+ */
+
+#include "harvester/room_session.h"
+
+#include "harvester/player.h"
+
+namespace Harvester {
+
+void RoomSession::resetIdleState() {
+	_idleState = RoomIdleAnimationState();
+	_idleState.activityTick = Player::getRuntimeClockTicks();
+	_idleState.resetTick = _idleState.activityTick;
+	Player::updateIdleTrigger(_idleState);
+}
+
+void RoomSession::notePlayerActivity() {
+	_idleState.activityTick = Player::getRuntimeClockTicks();
+	Player::updateIdleTrigger(_idleState);
+}
+
+bool RoomSession::isPlayerCombatLocked() const {
+	return _playerState.attackActive || _playerState.hitActive || _playerState.deathActive;
+}
+
+bool RoomSession::cancelPlayerAttackAnimation() {
+	if (!_playerState.attackActive)
+		return false;
+
+	_playerState.attackActive = false;
+	_playerState.attackFirstFrame = -1;
+	_playerState.attackLastFrame = -1;
+	_playerState.attackContactFrame = -1;
+	if (!_playerState.entity)
+		return false;
+	_playerState.attackSoundPlayed = false;
+	_playerState.attackSoundPlaybackFrame = -1;
+	_playerState.attackContactResolved = false;
+	_playerState.attackTargetName.clear();
+	_playerState.attackTargetClassId = -1;
+	const int resumeFacing = _playerState.attackResumeFacing >= 0
+		? _playerState.attackResumeFacing
+		: (_playerState.facing >= 0 ? _playerState.facing : 0);
+	_playerState.attackResumeFacing = -1;
+	return Player::setIdleAnimation(_playerState, resumeFacing);
+}
+
+void RoomSession::stopPlayerRegionInteraction() {
+	_movementInput.moveLeft = false;
+	_movementInput.moveRight = false;
+	_movementInput.moveUp = false;
+	_movementInput.moveDown = false;
+	_pendingRegionName.clear();
+	(void)cancelPlayerAttackAnimation();
+	_playerState.hasMoveTarget = false;
+	_playerState.turnActive = false;
+	_playerState.turnTargetFacing = -1;
+	if (_playerState.entity && _playerState.facing >= 0)
+		(void)Player::setIdleAnimation(_playerState, _playerState.facing);
+	Player::syncAnimationPlacement(_playerState);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
new file mode 100644
index 00000000000..223bc6bc4ea
--- /dev/null
+++ b/engines/harvester/room_session.h
@@ -0,0 +1,60 @@
+/* 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 HARVESTER_ROOM_SESSION_H
+#define HARVESTER_ROOM_SESSION_H
+
+#include "harvester/room_support.h"
+
+namespace Harvester {
+
+struct RoomMovementInputState {
+	bool moveLeft = false;
+	bool moveRight = false;
+	bool moveUp = false;
+	bool moveDown = false;
+};
+
+// Scoped to one runRoomLoop iteration, including time spent in nested closeups.
+// Entity pointers remain owned by EntityManager.
+class RoomSession {
+public:
+	RoomPlayerState &getPlayerState() { return _playerState; }
+	RoomIdleAnimationState &getIdleState() { return _idleState; }
+	RoomMovementInputState &getMovementInput() { return _movementInput; }
+	Common::String &getPendingRegionName() { return _pendingRegionName; }
+
+	void resetIdleState();
+	void notePlayerActivity();
+	bool isPlayerCombatLocked() const;
+	bool cancelPlayerAttackAnimation();
+	void stopPlayerRegionInteraction();
+
+private:
+	RoomPlayerState _playerState;
+	RoomIdleAnimationState _idleState;
+	RoomMovementInputState _movementInput;
+	Common::String _pendingRegionName;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_ROOM_SESSION_H


Commit: febc916db51dbda79024fc46a4600cf036a8f6ba
    https://github.com/scummvm/scummvm/commit/febc916db51dbda79024fc46a4600cf036a8f6ba
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: refactor to manage room state and synchronize runtime entities

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 29f261aca4b..fd98818a96d 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -451,7 +451,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		flow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
 			(void)_engine.playMusic(scene.state.musicPath);
-		RoomSession session;
+		RoomSession session(_engine, scene, entityManager);
 		debugC(1, kDebugRoom, "Harvester: created room session room='%s'", scene.state.roomName.c_str());
 		RoomPlayerState &playerState = session.getPlayerState();
 		playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
@@ -546,58 +546,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			delete entity;
 		};
 
-		auto syncCurrentRoomRuntimeState = [&]() {
-			Script *script = _engine.getScript();
-			if (!script)
-				return;
-
-			// Region enable state is a persistent script mutation; keep the runtime record in lockstep
-			// with the currently materialized scene so one-shot regions do not resurrect on revisit.
-			for (const RegionRecord &baseRegion : script->getRegions()) {
-				if (!baseRegion.roomName.equalsIgnoreCase(scene.state.roomName))
-					continue;
-
-				const bool enabled =
-					findSceneRegionByName(scene.sceneRegions, baseRegion.regionName) != nullptr;
-				(void)script->setRuntimeRegionEnabled(baseRegion.regionName, enabled);
-			}
-
-			if (entityManager) {
-				for (AnimRecord &anim : scene.state.roomAnimations) {
-					Entity *entity = entityManager->findSceneEntityByName(anim.animName);
-					if (!entity)
-						continue;
-					(void)script->syncRuntimeAnimState(anim.animName,
-						entity->isAnimationEnabled(), anim.visible, entity->getCurrentFrame());
-				}
-			}
-			for (const MonsterRecord &monster : scene.state.roomMonsters)
-				(void)script->syncRuntimeMonsterRecord(monster);
-			if (!entityManager)
-				return;
-
-			for (const TimerRecord &baseTimer : script->getTimers()) {
-				Entity *entity = entityManager->findSceneEntityByName(baseTimer.timerName);
-				if (!entity || entity->getClassId() != kRuntimeEntityClassTimer)
-					continue;
-
-				const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
-				TimerRecord timer = runtimeTimer ? *runtimeTimer : baseTimer;
-				timer.currentValue = entity->getTimerCurrentValue();
-				timer.enabled = entity->isTimerEnabled();
-				timer.looping = entity->isTimerLooping();
-				timer.global = entity->isTimerGlobal();
-				(void)script->syncRuntimeTimerRecord(timer);
-			}
-		};
-		auto captureCurrentSaveState = [&]() {
-			Player::syncAnimationPlacement(playerState);
-			syncCurrentRoomRuntimeState();
-			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
-			_engine.captureCurrentSaveRoomState(scene.state.entranceName, scene.state.roomName,
-				playerState.centerX, playerState.bottomY, (int)playerState.z, facing,
-				scene.state.discNumber, _engine.getMusicPath());
-		};
 		auto getSceneObjectBounds = [&](const ObjectRecord &object) {
 			if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
 				return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
@@ -1022,61 +970,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			return removedAny;
 		};
-		auto isTimerMaterializedInCurrentRoom = [&](const Common::String &timerName) {
-			for (const TimerRecord &timer : scene.state.roomTimers) {
-				if (timer.timerName.equalsIgnoreCase(timerName))
-					return true;
-			}
-
-			return false;
-		};
-		auto syncGlobalTimerEntities =
-				[&](const Common::Array<TimerRecord> &previousTimerRecords,
-						bool includeCurrentRoomTimers = false) {
-			Script *script = _engine.getScript();
-			if (!script || !entityManager || previousTimerRecords.empty())
-				return;
-
-			for (const TimerRecord &previousTimer : previousTimerRecords) {
-				const TimerRecord *runtimeTimer =
-					script->findRuntimeTimerRecord(previousTimer.timerName);
-				if (!runtimeTimer || !runtimeTimer->global ||
-						(!includeCurrentRoomTimers &&
-							isTimerMaterializedInCurrentRoom(runtimeTimer->timerName))) {
-					continue;
-				}
-
-				Entity *entity = entityManager->findSceneEntityByName(runtimeTimer->timerName);
-				if (entity && entity->getClassId() != kRuntimeEntityClassTimer)
-					continue;
 
-				if (!entity) {
-					(void)script->syncRuntimeTimerRecord(previousTimer);
-					debugC(1, kDebugRoom,
-						"Harvester: global timer mutation ignored current_room='%s' timer='%s' owner_room='%s' reason='missing live timer entity'",
-						scene.state.roomName.c_str(), previousTimer.timerName.c_str(),
-						previousTimer.arg1.c_str());
-					continue;
-				}
-
-				if (entity->getTimerInitialValue() == runtimeTimer->initialValue &&
-						entity->isTimerEnabled() == runtimeTimer->enabled &&
-						entity->isTimerLooping() == runtimeTimer->looping &&
-						entity->isTimerGlobal() == runtimeTimer->global) {
-					continue;
-				}
-
-				entityManager->configureSceneTimerEntity(*entity,
-					runtimeTimer->initialValue, runtimeTimer->currentValue,
-					runtimeTimer->enabled, runtimeTimer->looping, runtimeTimer->global);
-				debugC(1, kDebugRoom,
-					"Harvester: global timer synced current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
-					scene.state.roomName.c_str(), runtimeTimer->timerName.c_str(),
-					runtimeTimer->arg1.c_str(), runtimeTimer->currentValue,
-					runtimeTimer->initialValue, runtimeTimer->enabled,
-					runtimeTimer->looping);
-			}
-		};
 		auto applyCurrentRoomRuntimeMutationsInPlace = [&](bool preservePaletteState) {
 			Script *script = _engine.getScript();
 			ResourceManager *resources = _engine.getResources();
@@ -1328,7 +1222,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				memcpy(scene.palette, updatedScene.palette, sizeof(scene.palette));
 				scene.targetPaletteBrightness = updatedScene.targetPaletteBrightness;
 			}
-			captureCurrentSaveState();
+			session.captureCurrentSaveState();
 			return _inventory.refresh();
 		};
 		session.resetIdleState();
@@ -1397,7 +1291,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			pendingRegionName.clear();
 			session.resetIdleState();
 			flow.resetCursorAnimationSequence();
-			captureCurrentSaveState();
+			session.captureCurrentSaveState();
 			return _inventory.refresh();
 		};
 		auto syncAnimatedRoomActorPlacement = [&]() {
@@ -1582,7 +1476,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					(void)_engine.playMusic(exitInteraction.musicPath);
 				flow.executeStartupAudioCommands(exitInteraction.audioCommands);
 				if (exitInteraction.mutatedRuntimeState)
-					syncGlobalTimerEntities(exitInteraction.previousTimerRecords, true);
+					session.syncGlobalTimerEntities(exitInteraction.previousTimerRecords, true);
 
 				if (!exitInteraction.cutscenePath.empty()) {
 					FstPlayer fstPlayer(_engine);
@@ -1690,7 +1584,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				hasScreenRect ? screenRect.right : 0, hasScreenRect ? screenRect.bottom : 0,
 				(double)depthScale);
 			session.resetIdleState();
-			captureCurrentSaveState();
+			session.captureCurrentSaveState();
 		};
 		auto runModalShowText = [&](const ResolvedText &modalText) -> Common::Error {
 			Graphics::Screen *activeScreen = getActiveScreen();
@@ -1806,7 +1700,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		};
 		typedef decltype(refreshCurrentScene) RefreshCurrentSceneFn;
 		typedef decltype(applyCurrentRoomRuntimeMutationsInPlace) ApplyCurrentRoomRuntimeMutationsInPlaceFn;
-		typedef decltype(syncGlobalTimerEntities) SyncGlobalTimerEntitiesFn;
 		typedef decltype(captureDialogueBackdrop) CaptureDialogueBackdropFn;
 		typedef decltype(showCdChangePrompt) ShowCdChangePromptFn;
 		typedef decltype(runRoomExitCommands) RunRoomExitCommandsFn;
@@ -1820,7 +1713,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					RoomSession &session_,
 					RefreshCurrentSceneFn &refreshCurrentSceneFn_,
 					ApplyCurrentRoomRuntimeMutationsInPlaceFn &applyCurrentRoomRuntimeMutationsInPlaceFn_,
-					SyncGlobalTimerEntitiesFn &syncGlobalTimerEntitiesFn_,
 					CaptureDialogueBackdropFn &captureDialogueBackdropFn_,
 					ShowCdChangePromptFn &showCdChangePromptFn_,
 					RunRoomExitCommandsFn &runRoomExitCommandsFn_,
@@ -1830,7 +1722,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn_)
 				: session(session_), refreshCurrentSceneFn(refreshCurrentSceneFn_),
 				  applyCurrentRoomRuntimeMutationsInPlaceFn(applyCurrentRoomRuntimeMutationsInPlaceFn_),
-				  syncGlobalTimerEntitiesFn(syncGlobalTimerEntitiesFn_),
 				  captureDialogueBackdropFn(captureDialogueBackdropFn_),
 				  showCdChangePromptFn(showCdChangePromptFn_),
 				  runRoomExitCommandsFn(runRoomExitCommandsFn_),
@@ -1849,7 +1740,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) override {
-				syncGlobalTimerEntitiesFn(previousTimerRecords);
+				session.syncGlobalTimerEntities(previousTimerRecords);
 			}
 
 			bool captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop) override {
@@ -1892,7 +1783,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			RoomSession &session;
 			RefreshCurrentSceneFn &refreshCurrentSceneFn;
 			ApplyCurrentRoomRuntimeMutationsInPlaceFn &applyCurrentRoomRuntimeMutationsInPlaceFn;
-			SyncGlobalTimerEntitiesFn &syncGlobalTimerEntitiesFn;
 			CaptureDialogueBackdropFn &captureDialogueBackdropFn;
 			ShowCdChangePromptFn &showCdChangePromptFn;
 			RunRoomExitCommandsFn &runRoomExitCommandsFn;
@@ -1904,7 +1794,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		RoomInteractionCallbacksImpl interactionCallbacks(
 			session,
 			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace,
-			syncGlobalTimerEntities, captureDialogueBackdrop, showCdChangePrompt,
+			captureDialogueBackdrop, showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			startPlayerDefeatSequence);
 		RoomInteractionProcessor interactionProcessor(
@@ -1921,7 +1811,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			// Native room_setup applies entry-time state changes before the first visible room frame.
 			if (roomEntryInteraction.mutatedRuntimeState) {
-				syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
+				session.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
 				if (!applyCurrentRoomRuntimeMutationsInPlace(false) &&
 						!refreshCurrentScene(true)) {
 					return Common::kReadingFailed;
@@ -3444,7 +3334,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 	if (!_inventory.refresh())
 		return Common::kReadingFailed;
 	Graphics::FrameLimiter limiter(g_system, 60);
-	captureCurrentSaveState();
+	session.captureCurrentSaveState();
 
 	if (shouldRunStartupRoomProbe())
 		logStartupRoomProbe(_engine, scene, currentRoomTarget, _mousePos, flow._menuTextConfig);
@@ -3503,7 +3393,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 			needsRedraw = true;
 		}
-		captureCurrentSaveState();
+		session.captureCurrentSaveState();
 		if (_inventory.isOpen() && _inventory.refreshIfRuntimeStateChanged())
 			needsRedraw = true;
 		if (!combatLoadoutStatusText.value.empty() &&
@@ -3697,7 +3587,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 									queueCombatLoadoutStatusMessage(statusMessage);
 									closeInventoryAfterCombatLoadoutStatus = true;
 								}
-								captureCurrentSaveState();
+								session.captureCurrentSaveState();
 								if (playerState.entity) {
 									(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
 										_engine.getScript()->getPlayerCombatLoadout());
@@ -4222,7 +4112,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
 					playerState.turnTargetFacing = -1;
-					captureCurrentSaveState();
+					session.captureCurrentSaveState();
 					Graphics::Screen *activeScreen = getActiveScreen();
 					if (!activeScreen)
 						return Common::kNoError;
@@ -4425,7 +4315,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (entityManager) {
 			Common::Array<Common::String> expiredTimerNames;
 			if (entityManager->takeExpiredTimerNames(expiredTimerNames)) {
-				syncCurrentRoomRuntimeState();
+				session.syncCurrentRoomRuntimeState();
 				for (const Common::String &timerName : expiredTimerNames) {
 					InteractionResult timerInteraction;
 					if (!_engine.getScript()->executeTimerAction(
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index 8cfd5d24297..61ffb17367e 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -21,10 +21,17 @@
 
 #include "harvester/room_session.h"
 
+#include "harvester/detection.h"
+#include "harvester/harvester.h"
 #include "harvester/player.h"
+#include "harvester/runtime_entity.h"
 
 namespace Harvester {
 
+RoomSession::RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager)
+	: _engine(engine), _scene(scene), _entityManager(entityManager) {
+}
+
 void RoomSession::resetIdleState() {
 	_idleState = RoomIdleAnimationState();
 	_idleState.activityTick = Player::getRuntimeClockTicks();
@@ -78,4 +85,115 @@ void RoomSession::stopPlayerRegionInteraction() {
 	Player::syncAnimationPlacement(_playerState);
 }
 
+void RoomSession::syncCurrentRoomRuntimeState() {
+	Script *script = _engine.getScript();
+	if (!script)
+		return;
+
+	debugC(3, kDebugRoom, "Harvester: syncing room runtime state room='%s' entities=%d",
+		_scene.state.roomName.c_str(), _entityManager != nullptr);
+
+	for (const RegionRecord &baseRegion : script->getRegions()) {
+		if (!baseRegion.roomName.equalsIgnoreCase(_scene.state.roomName))
+			continue;
+
+		const bool enabled =
+			findSceneRegionByName(_scene.sceneRegions, baseRegion.regionName) != nullptr;
+		(void)script->setRuntimeRegionEnabled(baseRegion.regionName, enabled);
+	}
+
+	if (_entityManager) {
+		for (AnimRecord &anim : _scene.state.roomAnimations) {
+			Entity *entity = _entityManager->findSceneEntityByName(anim.animName);
+			if (!entity)
+				continue;
+			(void)script->syncRuntimeAnimState(anim.animName,
+				entity->isAnimationEnabled(), anim.visible, entity->getCurrentFrame());
+		}
+	}
+	for (const MonsterRecord &monster : _scene.state.roomMonsters)
+		(void)script->syncRuntimeMonsterRecord(monster);
+	if (!_entityManager)
+		return;
+
+	for (const TimerRecord &baseTimer : script->getTimers()) {
+		Entity *entity = _entityManager->findSceneEntityByName(baseTimer.timerName);
+		if (!entity || entity->getClassId() != kRuntimeEntityClassTimer)
+			continue;
+
+		const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
+		TimerRecord timer = runtimeTimer ? *runtimeTimer : baseTimer;
+		timer.currentValue = entity->getTimerCurrentValue();
+		timer.enabled = entity->isTimerEnabled();
+		timer.looping = entity->isTimerLooping();
+		timer.global = entity->isTimerGlobal();
+		(void)script->syncRuntimeTimerRecord(timer);
+	}
+}
+
+void RoomSession::captureCurrentSaveState() {
+	Player::syncAnimationPlacement(_playerState);
+	syncCurrentRoomRuntimeState();
+	const int facing = _playerState.facing >= 0 ? _playerState.facing : _scene.state.playerFacing;
+	_engine.captureCurrentSaveRoomState(_scene.state.entranceName, _scene.state.roomName,
+		_playerState.centerX, _playerState.bottomY, (int)_playerState.z, facing,
+		_scene.state.discNumber, _engine.getMusicPath());
+}
+
+bool RoomSession::isTimerMaterializedInCurrentRoom(const Common::String &timerName) const {
+	for (const TimerRecord &timer : _scene.state.roomTimers) {
+		if (timer.timerName.equalsIgnoreCase(timerName))
+			return true;
+	}
+
+	return false;
+}
+
+void RoomSession::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
+		bool includeCurrentRoomTimers) {
+	Script *script = _engine.getScript();
+	if (!script || !_entityManager || previousTimerRecords.empty())
+		return;
+
+	for (const TimerRecord &previousTimer : previousTimerRecords) {
+		const TimerRecord *runtimeTimer =
+			script->findRuntimeTimerRecord(previousTimer.timerName);
+		if (!runtimeTimer || !runtimeTimer->global ||
+				(!includeCurrentRoomTimers &&
+					isTimerMaterializedInCurrentRoom(runtimeTimer->timerName))) {
+			continue;
+		}
+
+		Entity *entity = _entityManager->findSceneEntityByName(runtimeTimer->timerName);
+		if (entity && entity->getClassId() != kRuntimeEntityClassTimer)
+			continue;
+
+		if (!entity) {
+			(void)script->syncRuntimeTimerRecord(previousTimer);
+			debugC(1, kDebugRoom,
+				"Harvester: global timer mutation ignored current_room='%s' timer='%s' owner_room='%s' reason='missing live timer entity'",
+				_scene.state.roomName.c_str(), previousTimer.timerName.c_str(),
+				previousTimer.arg1.c_str());
+			continue;
+		}
+
+		if (entity->getTimerInitialValue() == runtimeTimer->initialValue &&
+				entity->isTimerEnabled() == runtimeTimer->enabled &&
+				entity->isTimerLooping() == runtimeTimer->looping &&
+				entity->isTimerGlobal() == runtimeTimer->global) {
+			continue;
+		}
+
+		_entityManager->configureSceneTimerEntity(*entity,
+			runtimeTimer->initialValue, runtimeTimer->currentValue,
+			runtimeTimer->enabled, runtimeTimer->looping, runtimeTimer->global);
+		debugC(1, kDebugRoom,
+			"Harvester: global timer synced current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
+			_scene.state.roomName.c_str(), runtimeTimer->timerName.c_str(),
+			runtimeTimer->arg1.c_str(), runtimeTimer->currentValue,
+			runtimeTimer->initialValue, runtimeTimer->enabled,
+			runtimeTimer->looping);
+	}
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 223bc6bc4ea..5dc334ae051 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -26,6 +26,8 @@
 
 namespace Harvester {
 
+class EntityManager;
+
 struct RoomMovementInputState {
 	bool moveLeft = false;
 	bool moveRight = false;
@@ -37,6 +39,8 @@ struct RoomMovementInputState {
 // Entity pointers remain owned by EntityManager.
 class RoomSession {
 public:
+	RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager);
+
 	RoomPlayerState &getPlayerState() { return _playerState; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
 	RoomMovementInputState &getMovementInput() { return _movementInput; }
@@ -47,8 +51,17 @@ public:
 	bool isPlayerCombatLocked() const;
 	bool cancelPlayerAttackAnimation();
 	void stopPlayerRegionInteraction();
+	void syncCurrentRoomRuntimeState();
+	void captureCurrentSaveState();
+	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
+		bool includeCurrentRoomTimers = false);
 
 private:
+	bool isTimerMaterializedInCurrentRoom(const Common::String &timerName) const;
+
+	HarvesterEngine &_engine;
+	RoomSceneResources &_scene;
+	EntityManager *_entityManager;
 	RoomPlayerState _playerState;
 	RoomIdleAnimationState _idleState;
 	RoomMovementInputState _movementInput;


Commit: 8ee387d338442748f365d8f66b4721f7f4a0d4d6
    https://github.com/scummvm/scummvm/commit/8ee387d338442748f365d8f66b4721f7f4a0d4d6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: refactor RoomSession to manage scene entity lifecycle and interactions

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index fd98818a96d..76e9bcb9a2d 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -538,111 +538,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (ownerOrRoom)
 				sceneObject->currentOwnerOrRoom = *ownerOrRoom;
 		};
-		auto removeSceneEntityByName = [&](const Common::String &entityName) {
-			if (!entityManager)
-				return;
-
-			Entity *entity = entityManager->detachSceneEntityByName(entityName);
-			delete entity;
-		};
 
-		auto getSceneObjectBounds = [&](const ObjectRecord &object) {
-			if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
-				return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
-			return Common::Rect();
-		};
-		auto resolveSceneObjectSpritePathLocal = [&](const ObjectRecord &object) {
-			const bool atInitialPlacement = object.currentX == object.initialX &&
-				object.currentY == object.initialY &&
-				object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
-			if (!object.altSpritePath.empty() &&
-					(!atInitialPlacement || object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)))
-				return object.altSpritePath;
-			return object.spritePath;
-		};
-		auto isInteractiveSceneHotspot = [&](const ObjectRecord &object) {
-			if (object.operatable || !object.actionTag.empty())
-				return true;
-
-			Script *script = _engine.getScript();
-			if (!script)
-				return false;
-			if (script->isPickupObject(object))
-				return true;
-
-			ResolvedText inspectText;
-			return script->resolveObjectInspectText(object, inspectText);
-		};
-		auto resolveSceneObjectClassLocal = [&](const ObjectRecord &object,
-				const Entity *entity) {
-			if (entity) {
-				return ((!scene.state.backgroundObjectName.empty() &&
-						object.objectName.equalsIgnoreCase(scene.state.backgroundObjectName)) ||
-					(object.initialX == 0 && object.initialY == 0 &&
-						entity->getBoundsWidth() == 640 && entity->getBoundsHeight() == 480))
-					? kRuntimeEntityClassBackground
-					: kRuntimeEntityClassObject;
-			}
-
-			return isInteractiveSceneHotspot(object)
-				? kRuntimeEntityClassRectHotspot
-				: kRuntimeEntityClassDisabledHotspot;
-		};
 		auto sameRect = [](const Common::Rect &lhs, const Common::Rect &rhs) {
 			return lhs.left == rhs.left && lhs.top == rhs.top &&
 				lhs.right == rhs.right && lhs.bottom == rhs.bottom;
 		};
-		auto spawnSceneObjectEntityFromRecord = [&](const ObjectRecord &object) {
-			if (!entityManager)
-				return;
 
-			const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
-			const Common::Rect hotspotBounds = getSceneObjectBounds(object);
-			Entity *entity = nullptr;
-			if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
-				entity = entityManager->spawnSceneBitmapEntity(object.objectName, spritePath,
-					Common::Point(object.currentX, object.currentY), (float)object.currentZ);
-			} else if (!hotspotBounds.isEmpty()) {
-				entity = entityManager->spawnSceneHotspotEntity(object.objectName, hotspotBounds,
-					(float)object.currentZ);
-			}
-			if (!entity)
-				return;
-
-			entity->setClassId(resolveSceneObjectClassLocal(
-				object, entity->hasFrames() ? entity : nullptr));
-			entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
-			entity->setZExtent((float)object.zExtent);
-		};
-		auto getSceneRegionBounds = [](const RegionRecord &region) {
-			if (region.right > region.left && region.bottom > region.top)
-				return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
-			return Common::Rect();
-		};
-		auto spawnSceneRegionEntityFromRecord = [&](const RegionRecord &region) {
-			if (!entityManager)
-				return;
-
-			const Common::Rect bounds = getSceneRegionBounds(region);
-			if (bounds.isEmpty())
-				return;
-
-			Entity *entity = entityManager->spawnSceneHotspotEntity(
-				region.regionName, bounds, (float)region.minZ);
-			if (!entity)
-				return;
-
-			entity->setClassId(kRuntimeEntityClassRectHotspot19);
-			entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
-			entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
-			if (!region.cursorEnabled)
-				entity->setHitTestMode(kRuntimeEntityHitTestNone);
-			debugC(1, kDebugRoom,
-				"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
-				region.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
-				bounds.left, bounds.top, bounds.right, bounds.bottom,
-				region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
-		};
 		auto findRoomAnimByNameConst = [](const Common::Array<AnimRecord> &anims,
 				const Common::String &animName) -> const AnimRecord * {
 			for (const AnimRecord &anim : anims) {
@@ -667,12 +568,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 			return nullptr;
 		};
-		auto shouldSpawnRoomNpcEntity = [](const NpcRecord &npc) {
-			return npc.visible && !npc.deathOrMonsterfyFlag;
-		};
-		auto shouldSpawnRoomMonsterEntity = [](const MonsterRecord &monster) {
-			return monster.visible;
-		};
+
 		auto sameNpcEntityState = [&](const NpcRecord &lhs, const NpcRecord &rhs) {
 			return lhs.posX == rhs.posX &&
 				lhs.posY == rhs.posY &&
@@ -728,8 +624,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				lhs.identTextKey.equalsIgnoreCase(rhs.identTextKey) &&
 				lhs.interactionLabel.equalsIgnoreCase(rhs.interactionLabel) &&
 				lhs.currentOwnerOrRoom.equalsIgnoreCase(rhs.currentOwnerOrRoom) &&
-				resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(resolveSceneObjectSpritePathLocal(rhs)) &&
-				sameRect(getSceneObjectBounds(lhs), getSceneObjectBounds(rhs));
+				session.resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(session.resolveSceneObjectSpritePathLocal(rhs)) &&
+				sameRect(session.getSceneObjectBounds(lhs), session.getSceneObjectBounds(rhs));
 		};
 		auto sameSceneRegionState = [&](const RegionRecord &lhs, const RegionRecord &rhs) {
 			return lhs.left == rhs.left &&
@@ -743,79 +639,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				lhs.desiredFacing == rhs.desiredFacing &&
 				lhs.actionTag.equalsIgnoreCase(rhs.actionTag);
 		};
-		auto applyRoomNpcPlacement = [&](Entity &entity, const NpcRecord &npc) {
-			int width = 0;
-			int height = 0;
-			int xOffset = 0;
-			int yOffset = 0;
-			if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
-				return false;
-
-			const float renderZ =
-				(float)npc.posZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
-			entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
-			entity.setPosition(npc.posX - xOffset - width / 2, npc.posY - height - yOffset, renderZ);
-			return true;
-		};
-		auto spawnSceneNpcEntityFromRecord = [&](const NpcRecord &npc) -> Entity * {
-			const bool shouldSpawnNpc = shouldSpawnRoomNpcEntity(npc);
-			const bool preservedCorpse = !npc.active &&
-				npc.runtimeSpawned &&
-				npc.deathDamageType != 0 &&
-				npc.runtimeState >= 0;
-			if (!entityManager || !shouldSpawnNpc)
-				return nullptr;
-
-			Entity *entity = entityManager->spawnSceneActorEntity(
-				npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
-			if (!entity)
-				return nullptr;
 
-			entity->setClassId(kRuntimeEntityClassNpc);
-			entity->setZExtent(kNativeNpcMonsterZExtent);
-			entity->setHitTestMode(preservedCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
-			entity->setVisible(true);
-			if (!applyRoomNpcPlacement(*entity, npc)) {
-				removeSceneEntityByName(npc.npcName);
-				return nullptr;
-			}
-			if (preservedCorpse) {
-				const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
-				entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
-				entity->setAnimationRate(0);
-				entity->setCurrentFrame(corpseFrame);
-				entity->setAnimationEnabled(false);
-			} else {
-				entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
-				entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
-			}
-			entityManager->reinsertSceneEntity(entity);
-			return entity;
-		};
-		auto spawnSceneMonsterEntityFromRecord = [&](const RoomSetupState &roomState,
-				const MonsterRecord &monster) -> Entity * {
-			if (!entityManager || !shouldSpawnRoomMonsterEntity(monster))
-				return nullptr;
-
-			Entity *entity = entityManager->spawnSceneActorEntity(
-				monster.monsterName, monster.modelPath,
-				Common::Point(monster.posX, monster.posY), (float)monster.posZ,
-				Monster::resolveFacingFrame(monster.facing));
-			if (!entity)
-				return nullptr;
-
-			entity->setClassId(kRuntimeEntityClassMonster);
-			entity->setZExtent(kNativeNpcMonsterZExtent);
-			entity->setHitTestMode(kRuntimeEntityHitTestNone);
-			entity->setVisible(monster.visible);
-			Monster::applyAnimation(*entity, monster);
-			if (!applyRoomActorPlacement(roomState, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
-				removeSceneEntityByName(monster.monsterName);
-				return nullptr;
-			}
-			entityManager->reinsertSceneEntity(entity);
-			return entity;
-		};
 		auto resolveHitEffectAnchor = [&](const Entity &targetEntity,
 				Common::Point &anchorPoint, float &renderZ) {
 			const Common::Rect targetRect = targetEntity.getScreenRect();
@@ -943,7 +767,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					!effectEntity->isAnimationEnabled() &&
 					effectEntity->getCurrentFrame() == effectEntity->getLastFrame();
 				if (finished && hitEffectStates[i].finished) {
-					removeSceneEntityByName(effectName);
+					session.removeSceneEntityByName(effectName);
 					hitEffectStates.remove_at(i);
 					removedAny = true;
 					continue;
@@ -1008,7 +832,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			for (const ObjectRecord &object : scene.sceneObjects) {
 				if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
-					removeSceneEntityByName(object.objectName);
+					session.removeSceneEntityByName(object.objectName);
 			}
 			for (const ObjectRecord &object : updatedScene.sceneObjects) {
 				const ObjectRecord *previous = findSceneObjectByName(scene.sceneObjects, object.objectName);
@@ -1016,13 +840,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!changed)
 					continue;
 
-				removeSceneEntityByName(object.objectName);
-				spawnSceneObjectEntityFromRecord(object);
+				session.removeSceneEntityByName(object.objectName);
+				session.spawnSceneObjectEntityFromRecord(object);
 			}
 
 			for (const RegionRecord &region : scene.sceneRegions) {
 				if (!findSceneRegionByName(updatedScene.sceneRegions, region.regionName))
-					removeSceneEntityByName(region.regionName);
+					session.removeSceneEntityByName(region.regionName);
 			}
 			for (const RegionRecord &region : updatedScene.sceneRegions) {
 				const RegionRecord *previous =
@@ -1031,8 +855,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!changed)
 					continue;
 
-				removeSceneEntityByName(region.regionName);
-				spawnSceneRegionEntityFromRecord(region);
+				session.removeSceneEntityByName(region.regionName);
+				session.spawnSceneRegionEntityFromRecord(region);
 			}
 
 			for (const TimerRecord &timer : scene.state.roomTimers) {
@@ -1044,7 +868,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					}
 				}
 				if (!found)
-					removeSceneEntityByName(timer.timerName);
+					session.removeSceneEntityByName(timer.timerName);
 			}
 			for (const TimerRecord &timer : updatedState.roomTimers) {
 				const TimerRecord *previous = nullptr;
@@ -1063,7 +887,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!changed)
 					continue;
 
-				removeSceneEntityByName(timer.timerName);
+				session.removeSceneEntityByName(timer.timerName);
 				if (entityManager) {
 					(void)entityManager->spawnSceneTimerEntity(
 						timer.timerName, timer.initialValue, timer.currentValue,
@@ -1084,7 +908,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					? entityManager->findSceneEntityByName(anim.animName)
 					: nullptr;
 				if (!isDisplayed) {
-					removeSceneEntityByName(anim.animName);
+					session.removeSceneEntityByName(anim.animName);
 					continue;
 				}
 
@@ -1098,7 +922,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					previous->pingPong != anim.pingPong ||
 					previous->backward != anim.backward;
 				if (needsRespawn) {
-					removeSceneEntityByName(anim.animName);
+					session.removeSceneEntityByName(anim.animName);
 					if (entityManager) {
 						entity = entityManager->spawnSceneAnimationEntity(
 							anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
@@ -1125,7 +949,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			for (const AnimRecord &anim : scene.state.roomAnimations) {
 				if (!findRoomAnimByNameConst(updatedState.roomAnimations, anim.animName))
-					removeSceneEntityByName(anim.animName);
+					session.removeSceneEntityByName(anim.animName);
 			}
 
 			for (const NpcRecord &npc : scene.state.roomNpcs) {
@@ -1138,7 +962,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					entityManager->findSceneEntityByName(npc.npcName);
 				const bool keepEntity =
 					updatedNpc &&
-					shouldSpawnRoomNpcEntity(*updatedNpc) &&
+					session.shouldSpawnRoomNpcEntity(*updatedNpc) &&
 					(sameNpcEntityState(npc, *updatedNpc) ||
 						isQueuedLiveNpcDeathMutation(npc, *updatedNpc));
 				if (keepCurrentRoomDeathEntity) {
@@ -1148,10 +972,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					continue;
 				}
 				if (!keepEntity)
-					removeSceneEntityByName(npc.npcName);
+					session.removeSceneEntityByName(npc.npcName);
 			}
 			for (const NpcRecord &npc : updatedState.roomNpcs) {
-				if (!shouldSpawnRoomNpcEntity(npc))
+				if (!session.shouldSpawnRoomNpcEntity(npc))
 					continue;
 
 				Entity *entity = entityManager
@@ -1170,8 +994,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!needsRespawn)
 					continue;
 
-				removeSceneEntityByName(npc.npcName);
-				if (!spawnSceneNpcEntityFromRecord(npc))
+				session.removeSceneEntityByName(npc.npcName);
+				if (!session.spawnSceneNpcEntityFromRecord(npc))
 					return false;
 			}
 
@@ -1180,13 +1004,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					findRoomMonsterByNameConst(updatedState.roomMonsters, monster.monsterName);
 				const bool keepEntity =
 					updatedMonster &&
-					shouldSpawnRoomMonsterEntity(*updatedMonster) &&
+					session.shouldSpawnRoomMonsterEntity(*updatedMonster) &&
 					sameMonsterEntityState(monster, *updatedMonster);
 				if (!keepEntity)
-					removeSceneEntityByName(monster.monsterName);
+					session.removeSceneEntityByName(monster.monsterName);
 			}
 			for (const MonsterRecord &monster : updatedState.roomMonsters) {
-				if (!shouldSpawnRoomMonsterEntity(monster))
+				if (!session.shouldSpawnRoomMonsterEntity(monster))
 					continue;
 
 				Entity *entity = entityManager
@@ -1199,8 +1023,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!needsRespawn)
 					continue;
 
-				removeSceneEntityByName(monster.monsterName);
-				if (!spawnSceneMonsterEntityFromRecord(updatedState, monster))
+				session.removeSceneEntityByName(monster.monsterName);
+				if (!session.spawnSceneMonsterEntityFromRecord(updatedState, monster))
 					return false;
 			}
 
@@ -2167,7 +1991,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			npcEntity->setHitTestMode(kRuntimeEntityHitTestNone);
 			npcEntity->setVisible(true);
 		} else {
-			removeSceneEntityByName(npc.npcName);
+			session.removeSceneEntityByName(npc.npcName);
 		}
 		npc.active = false;
 		npc.visible = false;
@@ -3068,7 +2892,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		script->addRuntimeObjectToInventory(object.objectName);
 		const Common::String inventoryOwner(kInventoryOwnerName);
 		hideSceneObject(object.objectName, &inventoryOwner);
-		removeSceneEntityByName(object.objectName);
+		session.removeSceneEntityByName(object.objectName);
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
@@ -3114,7 +2938,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		script->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
 		hideSceneObject(object.objectName, nullptr);
-		removeSceneEntityByName(object.objectName);
+		session.removeSceneEntityByName(object.objectName);
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnCarryStart(object)) {
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index 61ffb17367e..68726a168cb 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -23,11 +23,15 @@
 
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
+#include "harvester/monster.h"
 #include "harvester/player.h"
+#include "harvester/room_combat.h"
 #include "harvester/runtime_entity.h"
 
 namespace Harvester {
 
+static const char *const kInventoryOwnerName = "INVENTORY";
+
 RoomSession::RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager)
 	: _engine(engine), _scene(scene), _entityManager(entityManager) {
 }
@@ -196,4 +200,196 @@ void RoomSession::syncGlobalTimerEntities(const Common::Array<TimerRecord> &prev
 	}
 }
 
+void RoomSession::removeSceneEntityByName(const Common::String &entityName) {
+	if (!_entityManager)
+		return;
+
+	Entity *entity = _entityManager->detachSceneEntityByName(entityName);
+	delete entity;
+}
+
+Common::Rect RoomSession::getSceneObjectBounds(const ObjectRecord &object) {
+	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
+		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
+	return Common::Rect();
+}
+
+Common::String RoomSession::resolveSceneObjectSpritePathLocal(const ObjectRecord &object) {
+	const bool atInitialPlacement = object.currentX == object.initialX &&
+		object.currentY == object.initialY &&
+		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
+	if (!object.altSpritePath.empty() &&
+			(!atInitialPlacement || object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)))
+		return object.altSpritePath;
+	return object.spritePath;
+}
+
+bool RoomSession::isInteractiveSceneHotspot(const ObjectRecord &object) const {
+	if (object.operatable || !object.actionTag.empty())
+		return true;
+
+	Script *script = _engine.getScript();
+	if (!script)
+		return false;
+	if (script->isPickupObject(object))
+		return true;
+
+	ResolvedText inspectText;
+	return script->resolveObjectInspectText(object, inspectText);
+}
+
+RuntimeEntityClass RoomSession::resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const {
+	if (entity) {
+		return ((!_scene.state.backgroundObjectName.empty() &&
+				object.objectName.equalsIgnoreCase(_scene.state.backgroundObjectName)) ||
+			(object.initialX == 0 && object.initialY == 0 &&
+				entity->getBoundsWidth() == 640 && entity->getBoundsHeight() == 480))
+			? kRuntimeEntityClassBackground
+			: kRuntimeEntityClassObject;
+	}
+
+	return isInteractiveSceneHotspot(object)
+		? kRuntimeEntityClassRectHotspot
+		: kRuntimeEntityClassDisabledHotspot;
+}
+
+void RoomSession::spawnSceneObjectEntityFromRecord(const ObjectRecord &object) {
+	if (!_entityManager)
+		return;
+
+	const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
+	const Common::Rect hotspotBounds = getSceneObjectBounds(object);
+	Entity *entity = nullptr;
+	if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
+		entity = _entityManager->spawnSceneBitmapEntity(object.objectName, spritePath,
+			Common::Point(object.currentX, object.currentY), (float)object.currentZ);
+	} else if (!hotspotBounds.isEmpty()) {
+		entity = _entityManager->spawnSceneHotspotEntity(object.objectName, hotspotBounds,
+			(float)object.currentZ);
+	}
+	if (!entity)
+		return;
+
+	entity->setClassId(resolveSceneObjectClassLocal(
+		object, entity->hasFrames() ? entity : nullptr));
+	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity->setZExtent((float)object.zExtent);
+	debugC(3, kDebugRoom, "Harvester: scene object spawned object='%s' sprite='%s' class=0x%x",
+		object.objectName.c_str(), spritePath.c_str(), entity->getClassId());
+}
+
+Common::Rect RoomSession::getSceneRegionBounds(const RegionRecord &region) {
+	if (region.right > region.left && region.bottom > region.top)
+		return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
+	return Common::Rect();
+}
+
+void RoomSession::spawnSceneRegionEntityFromRecord(const RegionRecord &region) {
+	if (!_entityManager)
+		return;
+
+	const Common::Rect bounds = getSceneRegionBounds(region);
+	if (bounds.isEmpty())
+		return;
+
+	Entity *entity = _entityManager->spawnSceneHotspotEntity(
+		region.regionName, bounds, (float)region.minZ);
+	if (!entity)
+		return;
+
+	entity->setClassId(kRuntimeEntityClassRectHotspot19);
+	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
+	if (!region.cursorEnabled)
+		entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	debugC(1, kDebugRoom,
+		"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
+		region.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
+		bounds.left, bounds.top, bounds.right, bounds.bottom,
+		region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
+}
+
+bool RoomSession::shouldSpawnRoomNpcEntity(const NpcRecord &npc) {
+	return npc.visible && !npc.deathOrMonsterfyFlag;
+}
+
+bool RoomSession::shouldSpawnRoomMonsterEntity(const MonsterRecord &monster) {
+	return monster.visible;
+}
+
+bool RoomSession::applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc) {
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
+		return false;
+
+	const float renderZ =
+		(float)npc.posZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
+	entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity.setPosition(npc.posX - xOffset - width / 2, npc.posY - height - yOffset, renderZ);
+	return true;
+}
+
+Entity *RoomSession::spawnSceneNpcEntityFromRecord(const NpcRecord &npc) {
+	const bool shouldSpawnNpc = shouldSpawnRoomNpcEntity(npc);
+	const bool preservedCorpse = !npc.active &&
+		npc.runtimeSpawned &&
+		npc.deathDamageType != 0 &&
+		npc.runtimeState >= 0;
+	if (!_entityManager || !shouldSpawnNpc)
+		return nullptr;
+
+	Entity *entity = _entityManager->spawnSceneActorEntity(
+		npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
+	if (!entity)
+		return nullptr;
+
+	entity->setClassId(kRuntimeEntityClassNpc);
+	entity->setZExtent(kNativeNpcMonsterZExtent);
+	entity->setHitTestMode(preservedCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
+	entity->setVisible(true);
+	if (!applyRoomNpcPlacement(*entity, npc)) {
+		removeSceneEntityByName(npc.npcName);
+		return nullptr;
+	}
+	if (preservedCorpse) {
+		const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
+		entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
+		entity->setAnimationRate(0);
+		entity->setCurrentFrame(corpseFrame);
+		entity->setAnimationEnabled(false);
+	} else {
+		entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
+		entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+	}
+	_entityManager->reinsertSceneEntity(entity);
+	return entity;
+}
+
+Entity *RoomSession::spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster) {
+	if (!_entityManager || !shouldSpawnRoomMonsterEntity(monster))
+		return nullptr;
+
+	Entity *entity = _entityManager->spawnSceneActorEntity(
+		monster.monsterName, monster.modelPath,
+		Common::Point(monster.posX, monster.posY), (float)monster.posZ,
+		Monster::resolveFacingFrame(monster.facing));
+	if (!entity)
+		return nullptr;
+
+	entity->setClassId(kRuntimeEntityClassMonster);
+	entity->setZExtent(kNativeNpcMonsterZExtent);
+	entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	entity->setVisible(monster.visible);
+	Monster::applyAnimation(*entity, monster);
+	if (!applyRoomActorPlacement(roomState, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
+		removeSceneEntityByName(monster.monsterName);
+		return nullptr;
+	}
+	_entityManager->reinsertSceneEntity(entity);
+	return entity;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 5dc334ae051..93598e92643 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -23,6 +23,7 @@
 #define HARVESTER_ROOM_SESSION_H
 
 #include "harvester/room_support.h"
+#include "harvester/runtime_entity.h"
 
 namespace Harvester {
 
@@ -56,7 +57,22 @@ public:
 	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
 		bool includeCurrentRoomTimers = false);
 
+	void removeSceneEntityByName(const Common::String &entityName);
+	static Common::Rect getSceneObjectBounds(const ObjectRecord &object);
+	static Common::String resolveSceneObjectSpritePathLocal(const ObjectRecord &object);
+	void spawnSceneObjectEntityFromRecord(const ObjectRecord &object);
+	void spawnSceneRegionEntityFromRecord(const RegionRecord &region);
+	static bool shouldSpawnRoomNpcEntity(const NpcRecord &npc);
+	static bool shouldSpawnRoomMonsterEntity(const MonsterRecord &monster);
+	Entity *spawnSceneNpcEntityFromRecord(const NpcRecord &npc);
+	Entity *spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster);
+
 private:
+	bool isInteractiveSceneHotspot(const ObjectRecord &object) const;
+	RuntimeEntityClass resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const;
+	static Common::Rect getSceneRegionBounds(const RegionRecord &region);
+	static bool applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc);
+
 	bool isTimerMaterializedInCurrentRoom(const Common::String &timerName) const;
 
 	HarvesterEngine &_engine;


Commit: 2be5442bcc2ee00a6be295c385ca964d828d9384
    https://github.com/scummvm/scummvm/commit/2be5442bcc2ee00a6be295c385ca964d828d9384
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: move inventory management and runtime mutations to RoomSession

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 76e9bcb9a2d..3566a43038d 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -451,7 +451,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		flow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
 			(void)_engine.playMusic(scene.state.musicPath);
-		RoomSession session(_engine, scene, entityManager);
+		RoomSession session(_engine, scene, entityManager, _inventory);
 		debugC(1, kDebugRoom, "Harvester: created room session room='%s'", scene.state.roomName.c_str());
 		RoomPlayerState &playerState = session.getPlayerState();
 		playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
@@ -463,7 +463,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
-		Common::Array<RoomMonsterCombatState> monsterCombatStates;
+		Common::Array<RoomMonsterCombatState> &monsterCombatStates = session.getMonsterCombatStates();
 		monsterCombatStates.resize(scene.state.roomMonsters.size());
 		Common::Array<RoomNpcCombatState> npcCombatStates;
 		npcCombatStates.resize(scene.state.roomNpcs.size());
@@ -539,107 +539,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				sceneObject->currentOwnerOrRoom = *ownerOrRoom;
 		};
 
-		auto sameRect = [](const Common::Rect &lhs, const Common::Rect &rhs) {
-			return lhs.left == rhs.left && lhs.top == rhs.top &&
-				lhs.right == rhs.right && lhs.bottom == rhs.bottom;
-		};
-
-		auto findRoomAnimByNameConst = [](const Common::Array<AnimRecord> &anims,
-				const Common::String &animName) -> const AnimRecord * {
-			for (const AnimRecord &anim : anims) {
-				if (anim.animName.equalsIgnoreCase(animName))
-					return &anim;
-			}
-			return nullptr;
-		};
-		auto findRoomNpcByNameConst = [](const Common::Array<NpcRecord> &npcs,
-				const Common::String &npcName) -> const NpcRecord * {
-			for (const NpcRecord &npc : npcs) {
-				if (npc.npcName.equalsIgnoreCase(npcName))
-					return &npc;
-			}
-			return nullptr;
-		};
-		auto findRoomMonsterByNameConst = [](const Common::Array<MonsterRecord> &monsters,
-				const Common::String &monsterName) -> const MonsterRecord * {
-			for (const MonsterRecord &monster : monsters) {
-				if (monster.monsterName.equalsIgnoreCase(monsterName))
-					return &monster;
-			}
-			return nullptr;
-		};
-
-		auto sameNpcEntityState = [&](const NpcRecord &lhs, const NpcRecord &rhs) {
-			return lhs.posX == rhs.posX &&
-				lhs.posY == rhs.posY &&
-				lhs.posZ == rhs.posZ &&
-				lhs.frameDelay == rhs.frameDelay &&
-				lhs.active == rhs.active &&
-				lhs.visible == rhs.visible &&
-				lhs.runtimeSpawned == rhs.runtimeSpawned &&
-				lhs.runtimeState == rhs.runtimeState &&
-				lhs.deathDamageType == rhs.deathDamageType &&
-				lhs.deathOrMonsterfyFlag == rhs.deathOrMonsterfyFlag &&
-				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
-		};
-		auto isQueuedLiveNpcDeathMutation = [&](const NpcRecord &lhs, const NpcRecord &rhs) {
-			return lhs.posX == rhs.posX &&
-				lhs.posY == rhs.posY &&
-				lhs.posZ == rhs.posZ &&
-				lhs.frameDelay == rhs.frameDelay &&
-				lhs.active &&
-				rhs.active &&
-				lhs.visible &&
-				rhs.visible &&
-				!lhs.runtimeSpawned &&
-				!rhs.runtimeSpawned &&
-				lhs.runtimeState < 0 &&
-				rhs.runtimeState < 0 &&
-				!lhs.deathOrMonsterfyFlag &&
-				!rhs.deathOrMonsterfyFlag &&
-				lhs.deathDamageType == 0 &&
-				rhs.deathDamageType != 0 &&
-				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
-		};
-		auto sameMonsterEntityState = [&](const MonsterRecord &lhs, const MonsterRecord &rhs) {
-			return lhs.posX == rhs.posX &&
-				lhs.posY == rhs.posY &&
-				lhs.posZ == rhs.posZ &&
-				lhs.facing == rhs.facing &&
-				lhs.active == rhs.active &&
-				lhs.visible == rhs.visible &&
-				lhs.runtimeSpawned == rhs.runtimeSpawned &&
-				lhs.runtimeState == rhs.runtimeState &&
-				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
-		};
-		auto sameSceneObjectState = [&](const ObjectRecord &lhs, const ObjectRecord &rhs) {
-			return lhs.currentX == rhs.currentX &&
-				lhs.currentY == rhs.currentY &&
-				lhs.currentZ == rhs.currentZ &&
-				lhs.zExtent == rhs.zExtent &&
-				lhs.operatable == rhs.operatable &&
-				lhs.visible == rhs.visible &&
-				lhs.runtimeVisible == rhs.runtimeVisible &&
-				lhs.actionTag.equalsIgnoreCase(rhs.actionTag) &&
-				lhs.identTextKey.equalsIgnoreCase(rhs.identTextKey) &&
-				lhs.interactionLabel.equalsIgnoreCase(rhs.interactionLabel) &&
-				lhs.currentOwnerOrRoom.equalsIgnoreCase(rhs.currentOwnerOrRoom) &&
-				session.resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(session.resolveSceneObjectSpritePathLocal(rhs)) &&
-				sameRect(session.getSceneObjectBounds(lhs), session.getSceneObjectBounds(rhs));
-		};
-		auto sameSceneRegionState = [&](const RegionRecord &lhs, const RegionRecord &rhs) {
-			return lhs.left == rhs.left &&
-				lhs.top == rhs.top &&
-				lhs.right == rhs.right &&
-				lhs.bottom == rhs.bottom &&
-				lhs.minZ == rhs.minZ &&
-				lhs.maxZ == rhs.maxZ &&
-				lhs.startEnabled == rhs.startEnabled &&
-				lhs.cursorEnabled == rhs.cursorEnabled &&
-				lhs.desiredFacing == rhs.desiredFacing &&
-				lhs.actionTag.equalsIgnoreCase(rhs.actionTag);
-		};
-
 		auto resolveHitEffectAnchor = [&](const Entity &targetEntity,
 				Common::Point &anchorPoint, float &renderZ) {
 			const Common::Rect targetRect = targetEntity.getScreenRect();
@@ -795,260 +694,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return removedAny;
 		};
 
-		auto applyCurrentRoomRuntimeMutationsInPlace = [&](bool preservePaletteState) {
-			Script *script = _engine.getScript();
-			ResourceManager *resources = _engine.getResources();
-			if (!script || !resources)
-				return false;
-
-			byte previousPalette[256 * 3];
-			memcpy(previousPalette, scene.palette, sizeof(previousPalette));
-			const float previousPaletteBrightness = scene.targetPaletteBrightness;
-			Common::Array<AudioCommand> entryAudioCommands = scene.state.audioCommands;
-			RoomSetupState updatedState;
-			if (!script->materializeRoomState(
-					scene.state.entranceName, scene.state.roomName, updatedState, *resources)) {
-				return false;
-			}
-			updatedState.audioCommands = Common::move(entryAudioCommands);
-
-			RoomSceneResources updatedScene;
-			if (!loadRoomSceneResources(updatedState, *resources, updatedScene))
-				return false;
-			Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates;
-			updatedMonsterCombatStates.resize(updatedState.roomMonsters.size());
-			for (uint i = 0; i < updatedState.roomMonsters.size(); ++i) {
-				const MonsterRecord &updatedMonster = updatedState.roomMonsters[i];
-				for (uint j = 0; j < scene.state.roomMonsters.size() &&
-						j < monsterCombatStates.size(); ++j) {
-					const MonsterRecord &previousMonster = scene.state.roomMonsters[j];
-					if (!previousMonster.monsterName.equalsIgnoreCase(updatedMonster.monsterName))
-						continue;
-					if (sameMonsterEntityState(previousMonster, updatedMonster))
-						updatedMonsterCombatStates[i] = monsterCombatStates[j];
-					break;
-				}
-			}
-
-			for (const ObjectRecord &object : scene.sceneObjects) {
-				if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
-					session.removeSceneEntityByName(object.objectName);
-			}
-			for (const ObjectRecord &object : updatedScene.sceneObjects) {
-				const ObjectRecord *previous = findSceneObjectByName(scene.sceneObjects, object.objectName);
-				const bool changed = !previous || !sameSceneObjectState(*previous, object);
-				if (!changed)
-					continue;
-
-				session.removeSceneEntityByName(object.objectName);
-				session.spawnSceneObjectEntityFromRecord(object);
-			}
-
-			for (const RegionRecord &region : scene.sceneRegions) {
-				if (!findSceneRegionByName(updatedScene.sceneRegions, region.regionName))
-					session.removeSceneEntityByName(region.regionName);
-			}
-			for (const RegionRecord &region : updatedScene.sceneRegions) {
-				const RegionRecord *previous =
-					findSceneRegionByName(scene.sceneRegions, region.regionName);
-				const bool changed = !previous || !sameSceneRegionState(*previous, region);
-				if (!changed)
-					continue;
-
-				session.removeSceneEntityByName(region.regionName);
-				session.spawnSceneRegionEntityFromRecord(region);
-			}
-
-			for (const TimerRecord &timer : scene.state.roomTimers) {
-				bool found = false;
-				for (const TimerRecord &updatedTimer : updatedState.roomTimers) {
-					if (updatedTimer.timerName.equalsIgnoreCase(timer.timerName)) {
-						found = true;
-						break;
-					}
-				}
-				if (!found)
-					session.removeSceneEntityByName(timer.timerName);
-			}
-			for (const TimerRecord &timer : updatedState.roomTimers) {
-				const TimerRecord *previous = nullptr;
-				for (const TimerRecord &candidate : scene.state.roomTimers) {
-					if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
-						previous = &candidate;
-						break;
-					}
-				}
-				const bool changed = !previous ||
-					previous->initialValue != timer.initialValue ||
-					previous->currentValue != timer.currentValue ||
-					previous->enabled != timer.enabled ||
-					previous->looping != timer.looping ||
-					previous->global != timer.global;
-				if (!changed)
-					continue;
-
-				session.removeSceneEntityByName(timer.timerName);
-				if (entityManager) {
-					(void)entityManager->spawnSceneTimerEntity(
-						timer.timerName, timer.initialValue, timer.currentValue,
-						timer.enabled, timer.looping, timer.global);
-				}
-			}
-
-			for (AnimRecord &anim : updatedState.roomAnimations) {
-				const AnimRecord *previous =
-					findRoomAnimByNameConst(scene.state.roomAnimations, anim.animName);
-				const bool wasDisplayed = previous && shouldDisplaySceneAnimation(*previous);
-				const bool isDisplayed = shouldDisplaySceneAnimation(anim);
-				const bool becameVisible = !wasDisplayed && isDisplayed;
-				if (becameVisible)
-					anim.runtimeState = 0;
-
-				Entity *entity = entityManager
-					? entityManager->findSceneEntityByName(anim.animName)
-					: nullptr;
-				if (!isDisplayed) {
-					session.removeSceneEntityByName(anim.animName);
-					continue;
-				}
-
-				const bool needsRespawn = !entity || !previous ||
-					!previous->resourcePath.equalsIgnoreCase(anim.resourcePath) ||
-					previous->x != anim.x ||
-					previous->y != anim.y ||
-					previous->z != anim.z ||
-					previous->frameDelay != anim.frameDelay ||
-					previous->looping != anim.looping ||
-					previous->pingPong != anim.pingPong ||
-					previous->backward != anim.backward;
-				if (needsRespawn) {
-					session.removeSceneEntityByName(anim.animName);
-					if (entityManager) {
-						entity = entityManager->spawnSceneAnimationEntity(
-							anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
-							(float)anim.z, anim.frameDelay, anim.active, isDisplayed, anim.looping,
-							anim.backward, anim.pingPong, anim.runtimeState);
-					}
-				} else if (entity) {
-					const float renderZ =
-						(float)anim.z - floorf(MAX<float>(entity->getZExtent(), 0.0f) * 0.5f);
-					const bool zChanged = entity->getZ() != renderZ;
-					entity->setPosition(anim.x, anim.y, renderZ);
-					entity->setLooping(anim.looping);
-					entity->setPingPong(anim.pingPong);
-					entity->setPlayBackwards(anim.backward);
-					entity->setAnimationRate(anim.frameDelay);
-					entity->setVisible(isDisplayed);
-					if (anim.active && anim.runtimeState >= 0 && !entity->isAnimationEnabled())
-						entity->setCurrentFrame(anim.runtimeState);
-					entity->setAnimationEnabled(anim.active);
-					if (zChanged)
-						entityManager->reinsertSceneEntity(entity);
-				}
-			}
-
-			for (const AnimRecord &anim : scene.state.roomAnimations) {
-				if (!findRoomAnimByNameConst(updatedState.roomAnimations, anim.animName))
-					session.removeSceneEntityByName(anim.animName);
-			}
-
-			for (const NpcRecord &npc : scene.state.roomNpcs) {
-				const NpcRecord *updatedNpc =
-					findRoomNpcByNameConst(updatedState.roomNpcs, npc.npcName);
-				const bool keepCurrentRoomDeathEntity =
-					!updatedNpc &&
-					isRetainedCurrentRoomNpcDeathRecord(npc) &&
-					entityManager &&
-					entityManager->findSceneEntityByName(npc.npcName);
-				const bool keepEntity =
-					updatedNpc &&
-					session.shouldSpawnRoomNpcEntity(*updatedNpc) &&
-					(sameNpcEntityState(npc, *updatedNpc) ||
-						isQueuedLiveNpcDeathMutation(npc, *updatedNpc));
-				if (keepCurrentRoomDeathEntity) {
-					debugC(1, kDebugCombat,
-						"Harvester: retained current-room npc death entity npc='%s' damage_type=%d frame=%d",
-						npc.npcName.c_str(), npc.deathDamageType, npc.runtimeState);
-					continue;
-				}
-				if (!keepEntity)
-					session.removeSceneEntityByName(npc.npcName);
-			}
-			for (const NpcRecord &npc : updatedState.roomNpcs) {
-				if (!session.shouldSpawnRoomNpcEntity(npc))
-					continue;
-
-				Entity *entity = entityManager
-					? entityManager->findSceneEntityByName(npc.npcName)
-					: nullptr;
-				const NpcRecord *previousNpc =
-					findRoomNpcByNameConst(scene.state.roomNpcs, npc.npcName);
-				const bool keepQueuedDeathEntity =
-					entity &&
-					previousNpc &&
-					isQueuedLiveNpcDeathMutation(*previousNpc, npc);
-				if (keepQueuedDeathEntity)
-					continue;
-				const bool needsRespawn =
-					!entity || !previousNpc || !sameNpcEntityState(*previousNpc, npc);
-				if (!needsRespawn)
-					continue;
-
-				session.removeSceneEntityByName(npc.npcName);
-				if (!session.spawnSceneNpcEntityFromRecord(npc))
-					return false;
-			}
-
-			for (const MonsterRecord &monster : scene.state.roomMonsters) {
-				const MonsterRecord *updatedMonster =
-					findRoomMonsterByNameConst(updatedState.roomMonsters, monster.monsterName);
-				const bool keepEntity =
-					updatedMonster &&
-					session.shouldSpawnRoomMonsterEntity(*updatedMonster) &&
-					sameMonsterEntityState(monster, *updatedMonster);
-				if (!keepEntity)
-					session.removeSceneEntityByName(monster.monsterName);
-			}
-			for (const MonsterRecord &monster : updatedState.roomMonsters) {
-				if (!session.shouldSpawnRoomMonsterEntity(monster))
-					continue;
-
-				Entity *entity = entityManager
-					? entityManager->findSceneEntityByName(monster.monsterName)
-					: nullptr;
-				const MonsterRecord *previousMonster =
-					findRoomMonsterByNameConst(scene.state.roomMonsters, monster.monsterName);
-				const bool needsRespawn =
-					!entity || !previousMonster || !sameMonsterEntityState(*previousMonster, monster);
-				if (!needsRespawn)
-					continue;
-
-				session.removeSceneEntityByName(monster.monsterName);
-				if (!session.spawnSceneMonsterEntityFromRecord(updatedState, monster))
-					return false;
-			}
-
-			scene.state = updatedState;
-			monsterCombatStates = Common::move(updatedMonsterCombatStates);
-			scene.sceneObjects = updatedScene.sceneObjects;
-			scene.sceneAnimations = updatedScene.sceneAnimations;
-			scene.sceneRegions = updatedScene.sceneRegions;
-			const int playerCombatLoadout = script->getPlayerCombatLoadout();
-			if (playerState.entity && playerState.combatLoadout != playerCombatLoadout &&
-					!Player::syncCombatLoadoutVisual(
-						_engine, updatedState, playerState, playerCombatLoadout)) {
-				return false;
-			}
-			if (preservePaletteState) {
-				memcpy(scene.palette, previousPalette, sizeof(scene.palette));
-				scene.targetPaletteBrightness = previousPaletteBrightness;
-			} else {
-				memcpy(scene.palette, updatedScene.palette, sizeof(scene.palette));
-				scene.targetPaletteBrightness = updatedScene.targetPaletteBrightness;
-			}
-			session.captureCurrentSaveState();
-			return _inventory.refresh();
-		};
 		session.resetIdleState();
 
 		auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
@@ -1163,7 +808,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (clearCarryState)
 				clearCarriedRoomItem();
 
-			if (!applyCurrentRoomRuntimeMutationsInPlace(true) && !refreshCurrentScene(true))
+			if (!session.applyCurrentRoomRuntimeMutationsInPlace(true) && !refreshCurrentScene(true))
 				return Common::kReadingFailed;
 			if (!_inventory.refresh())
 				return Common::kReadingFailed;
@@ -1523,7 +1168,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					: 0);
 		};
 		typedef decltype(refreshCurrentScene) RefreshCurrentSceneFn;
-		typedef decltype(applyCurrentRoomRuntimeMutationsInPlace) ApplyCurrentRoomRuntimeMutationsInPlaceFn;
 		typedef decltype(captureDialogueBackdrop) CaptureDialogueBackdropFn;
 		typedef decltype(showCdChangePrompt) ShowCdChangePromptFn;
 		typedef decltype(runRoomExitCommands) RunRoomExitCommandsFn;
@@ -1536,7 +1180,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			RoomInteractionCallbacksImpl(
 					RoomSession &session_,
 					RefreshCurrentSceneFn &refreshCurrentSceneFn_,
-					ApplyCurrentRoomRuntimeMutationsInPlaceFn &applyCurrentRoomRuntimeMutationsInPlaceFn_,
 					CaptureDialogueBackdropFn &captureDialogueBackdropFn_,
 					ShowCdChangePromptFn &showCdChangePromptFn_,
 					RunRoomExitCommandsFn &runRoomExitCommandsFn_,
@@ -1545,7 +1188,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					RunModalShowTextFn &runModalShowTextFn_,
 					StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn_)
 				: session(session_), refreshCurrentSceneFn(refreshCurrentSceneFn_),
-				  applyCurrentRoomRuntimeMutationsInPlaceFn(applyCurrentRoomRuntimeMutationsInPlaceFn_),
 				  captureDialogueBackdropFn(captureDialogueBackdropFn_),
 				  showCdChangePromptFn(showCdChangePromptFn_),
 				  runRoomExitCommandsFn(runRoomExitCommandsFn_),
@@ -1560,7 +1202,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) override {
-				return applyCurrentRoomRuntimeMutationsInPlaceFn(preservePaletteState);
+				return session.applyCurrentRoomRuntimeMutationsInPlace(preservePaletteState);
 			}
 
 			void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) override {
@@ -1606,7 +1248,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			RoomSession &session;
 			RefreshCurrentSceneFn &refreshCurrentSceneFn;
-			ApplyCurrentRoomRuntimeMutationsInPlaceFn &applyCurrentRoomRuntimeMutationsInPlaceFn;
 			CaptureDialogueBackdropFn &captureDialogueBackdropFn;
 			ShowCdChangePromptFn &showCdChangePromptFn;
 			RunRoomExitCommandsFn &runRoomExitCommandsFn;
@@ -1617,7 +1258,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		};
 		RoomInteractionCallbacksImpl interactionCallbacks(
 			session,
-			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace,
+			refreshCurrentScene,
 			captureDialogueBackdrop, showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			startPlayerDefeatSequence);
@@ -1636,7 +1277,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			// Native room_setup applies entry-time state changes before the first visible room frame.
 			if (roomEntryInteraction.mutatedRuntimeState) {
 				session.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
-				if (!applyCurrentRoomRuntimeMutationsInPlace(false) &&
+				if (!session.applyCurrentRoomRuntimeMutationsInPlace(false) &&
 						!refreshCurrentScene(true)) {
 					return Common::kReadingFailed;
 				}
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index 68726a168cb..6e999a7a150 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -23,6 +23,7 @@
 
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
+#include "harvester/inventory.h"
 #include "harvester/monster.h"
 #include "harvester/player.h"
 #include "harvester/room_combat.h"
@@ -32,8 +33,113 @@ namespace Harvester {
 
 static const char *const kInventoryOwnerName = "INVENTORY";
 
-RoomSession::RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager)
-	: _engine(engine), _scene(scene), _entityManager(entityManager) {
+static bool sameRect(const Common::Rect &lhs, const Common::Rect &rhs) {
+	return lhs.left == rhs.left && lhs.top == rhs.top &&
+		lhs.right == rhs.right && lhs.bottom == rhs.bottom;
+}
+
+static const AnimRecord *findRoomAnimByNameConst(const Common::Array<AnimRecord> &anims, const Common::String &animName) {
+	for (const AnimRecord &anim : anims) {
+		if (anim.animName.equalsIgnoreCase(animName))
+			return &anim;
+	}
+	return nullptr;
+}
+
+static const NpcRecord *findRoomNpcByNameConst(const Common::Array<NpcRecord> &npcs, const Common::String &npcName) {
+	for (const NpcRecord &npc : npcs) {
+		if (npc.npcName.equalsIgnoreCase(npcName))
+			return &npc;
+	}
+	return nullptr;
+}
+
+static const MonsterRecord *findRoomMonsterByNameConst(const Common::Array<MonsterRecord> &monsters, const Common::String &monsterName) {
+	for (const MonsterRecord &monster : monsters) {
+		if (monster.monsterName.equalsIgnoreCase(monsterName))
+			return &monster;
+	}
+	return nullptr;
+}
+
+static bool sameNpcEntityState(const NpcRecord &lhs, const NpcRecord &rhs) {
+	return lhs.posX == rhs.posX &&
+		lhs.posY == rhs.posY &&
+		lhs.posZ == rhs.posZ &&
+		lhs.frameDelay == rhs.frameDelay &&
+		lhs.active == rhs.active &&
+		lhs.visible == rhs.visible &&
+		lhs.runtimeSpawned == rhs.runtimeSpawned &&
+		lhs.runtimeState == rhs.runtimeState &&
+		lhs.deathDamageType == rhs.deathDamageType &&
+		lhs.deathOrMonsterfyFlag == rhs.deathOrMonsterfyFlag &&
+		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+}
+
+static bool isQueuedLiveNpcDeathMutation(const NpcRecord &lhs, const NpcRecord &rhs) {
+	return lhs.posX == rhs.posX &&
+		lhs.posY == rhs.posY &&
+		lhs.posZ == rhs.posZ &&
+		lhs.frameDelay == rhs.frameDelay &&
+		lhs.active &&
+		rhs.active &&
+		lhs.visible &&
+		rhs.visible &&
+		!lhs.runtimeSpawned &&
+		!rhs.runtimeSpawned &&
+		lhs.runtimeState < 0 &&
+		rhs.runtimeState < 0 &&
+		!lhs.deathOrMonsterfyFlag &&
+		!rhs.deathOrMonsterfyFlag &&
+		lhs.deathDamageType == 0 &&
+		rhs.deathDamageType != 0 &&
+		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+}
+
+static bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs) {
+	return lhs.posX == rhs.posX &&
+		lhs.posY == rhs.posY &&
+		lhs.posZ == rhs.posZ &&
+		lhs.facing == rhs.facing &&
+		lhs.active == rhs.active &&
+		lhs.visible == rhs.visible &&
+		lhs.runtimeSpawned == rhs.runtimeSpawned &&
+		lhs.runtimeState == rhs.runtimeState &&
+		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+}
+
+static bool sameSceneObjectState(const ObjectRecord &lhs, const ObjectRecord &rhs) {
+	return lhs.currentX == rhs.currentX &&
+		lhs.currentY == rhs.currentY &&
+		lhs.currentZ == rhs.currentZ &&
+		lhs.zExtent == rhs.zExtent &&
+		lhs.operatable == rhs.operatable &&
+		lhs.visible == rhs.visible &&
+		lhs.runtimeVisible == rhs.runtimeVisible &&
+		lhs.actionTag.equalsIgnoreCase(rhs.actionTag) &&
+		lhs.identTextKey.equalsIgnoreCase(rhs.identTextKey) &&
+		lhs.interactionLabel.equalsIgnoreCase(rhs.interactionLabel) &&
+		lhs.currentOwnerOrRoom.equalsIgnoreCase(rhs.currentOwnerOrRoom) &&
+		RoomSession::resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(RoomSession::resolveSceneObjectSpritePathLocal(rhs)) &&
+		sameRect(RoomSession::getSceneObjectBounds(lhs), RoomSession::getSceneObjectBounds(rhs));
+}
+
+static bool sameSceneRegionState(const RegionRecord &lhs, const RegionRecord &rhs) {
+	return lhs.left == rhs.left &&
+		lhs.top == rhs.top &&
+		lhs.right == rhs.right &&
+		lhs.bottom == rhs.bottom &&
+		lhs.minZ == rhs.minZ &&
+		lhs.maxZ == rhs.maxZ &&
+		lhs.startEnabled == rhs.startEnabled &&
+		lhs.cursorEnabled == rhs.cursorEnabled &&
+		lhs.desiredFacing == rhs.desiredFacing &&
+		lhs.actionTag.equalsIgnoreCase(rhs.actionTag);
+}
+
+RoomSession::RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager,
+		InventorySystem &inventory)
+	: _engine(engine), _scene(scene), _entityManager(entityManager), _inventory(inventory) {
 }
 
 void RoomSession::resetIdleState() {
@@ -392,4 +498,261 @@ Entity *RoomSession::spawnSceneMonsterEntityFromRecord(const RoomSetupState &roo
 	return entity;
 }
 
+bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) {
+	Script *script = _engine.getScript();
+	ResourceManager *resources = _engine.getResources();
+	if (!script || !resources)
+		return false;
+
+	byte previousPalette[256 * 3];
+	memcpy(previousPalette, _scene.palette, sizeof(previousPalette));
+	const float previousPaletteBrightness = _scene.targetPaletteBrightness;
+	Common::Array<AudioCommand> entryAudioCommands = _scene.state.audioCommands;
+	debugC(2, kDebugRoom, "Harvester: reconciling room runtime mutations room='%s' preserve_palette=%d",
+		_scene.state.roomName.c_str(), preservePaletteState);
+	RoomSetupState updatedState;
+	if (!script->materializeRoomState(
+			_scene.state.entranceName, _scene.state.roomName, updatedState, *resources)) {
+		return false;
+	}
+	updatedState.audioCommands = Common::move(entryAudioCommands);
+
+	RoomSceneResources updatedScene;
+	if (!loadRoomSceneResources(updatedState, *resources, updatedScene))
+		return false;
+	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates;
+	updatedMonsterCombatStates.resize(updatedState.roomMonsters.size());
+	for (uint i = 0; i < updatedState.roomMonsters.size(); ++i) {
+		const MonsterRecord &updatedMonster = updatedState.roomMonsters[i];
+		for (uint j = 0; j < _scene.state.roomMonsters.size() &&
+				j < _monsterCombatStates.size(); ++j) {
+			const MonsterRecord &previousMonster = _scene.state.roomMonsters[j];
+			if (!previousMonster.monsterName.equalsIgnoreCase(updatedMonster.monsterName))
+				continue;
+			if (sameMonsterEntityState(previousMonster, updatedMonster))
+				updatedMonsterCombatStates[i] = _monsterCombatStates[j];
+			break;
+		}
+	}
+
+	for (const ObjectRecord &object : _scene.sceneObjects) {
+		if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
+			removeSceneEntityByName(object.objectName);
+	}
+	for (const ObjectRecord &object : updatedScene.sceneObjects) {
+		const ObjectRecord *previous = findSceneObjectByName(_scene.sceneObjects, object.objectName);
+		const bool changed = !previous || !sameSceneObjectState(*previous, object);
+		if (!changed)
+			continue;
+
+		removeSceneEntityByName(object.objectName);
+		spawnSceneObjectEntityFromRecord(object);
+	}
+
+	for (const RegionRecord &region : _scene.sceneRegions) {
+		if (!findSceneRegionByName(updatedScene.sceneRegions, region.regionName))
+			removeSceneEntityByName(region.regionName);
+	}
+	for (const RegionRecord &region : updatedScene.sceneRegions) {
+		const RegionRecord *previous =
+			findSceneRegionByName(_scene.sceneRegions, region.regionName);
+		const bool changed = !previous || !sameSceneRegionState(*previous, region);
+		if (!changed)
+			continue;
+
+		removeSceneEntityByName(region.regionName);
+		spawnSceneRegionEntityFromRecord(region);
+	}
+
+	for (const TimerRecord &timer : _scene.state.roomTimers) {
+		bool found = false;
+		for (const TimerRecord &updatedTimer : updatedState.roomTimers) {
+			if (updatedTimer.timerName.equalsIgnoreCase(timer.timerName)) {
+				found = true;
+				break;
+			}
+		}
+		if (!found)
+			removeSceneEntityByName(timer.timerName);
+	}
+	for (const TimerRecord &timer : updatedState.roomTimers) {
+		const TimerRecord *previous = nullptr;
+		for (const TimerRecord &candidate : _scene.state.roomTimers) {
+			if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
+				previous = &candidate;
+				break;
+			}
+		}
+		const bool changed = !previous ||
+			previous->initialValue != timer.initialValue ||
+			previous->currentValue != timer.currentValue ||
+			previous->enabled != timer.enabled ||
+			previous->looping != timer.looping ||
+			previous->global != timer.global;
+		if (!changed)
+			continue;
+
+		removeSceneEntityByName(timer.timerName);
+		if (_entityManager) {
+			(void)_entityManager->spawnSceneTimerEntity(
+				timer.timerName, timer.initialValue, timer.currentValue,
+				timer.enabled, timer.looping, timer.global);
+		}
+	}
+
+	for (AnimRecord &anim : updatedState.roomAnimations) {
+		const AnimRecord *previous =
+			findRoomAnimByNameConst(_scene.state.roomAnimations, anim.animName);
+		const bool wasDisplayed = previous && shouldDisplaySceneAnimation(*previous);
+		const bool isDisplayed = shouldDisplaySceneAnimation(anim);
+		const bool becameVisible = !wasDisplayed && isDisplayed;
+		if (becameVisible)
+			anim.runtimeState = 0;
+
+		Entity *entity = _entityManager
+			? _entityManager->findSceneEntityByName(anim.animName)
+			: nullptr;
+		if (!isDisplayed) {
+			removeSceneEntityByName(anim.animName);
+			continue;
+		}
+
+		const bool needsRespawn = !entity || !previous ||
+			!previous->resourcePath.equalsIgnoreCase(anim.resourcePath) ||
+			previous->x != anim.x ||
+			previous->y != anim.y ||
+			previous->z != anim.z ||
+			previous->frameDelay != anim.frameDelay ||
+			previous->looping != anim.looping ||
+			previous->pingPong != anim.pingPong ||
+			previous->backward != anim.backward;
+		if (needsRespawn) {
+			removeSceneEntityByName(anim.animName);
+			if (_entityManager) {
+				entity = _entityManager->spawnSceneAnimationEntity(
+					anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
+					(float)anim.z, anim.frameDelay, anim.active, isDisplayed, anim.looping,
+					anim.backward, anim.pingPong, anim.runtimeState);
+			}
+		} else if (entity) {
+			const float renderZ =
+				(float)anim.z - floorf(MAX<float>(entity->getZExtent(), 0.0f) * 0.5f);
+			const bool zChanged = entity->getZ() != renderZ;
+			entity->setPosition(anim.x, anim.y, renderZ);
+			entity->setLooping(anim.looping);
+			entity->setPingPong(anim.pingPong);
+			entity->setPlayBackwards(anim.backward);
+			entity->setAnimationRate(anim.frameDelay);
+			entity->setVisible(isDisplayed);
+			if (anim.active && anim.runtimeState >= 0 && !entity->isAnimationEnabled())
+				entity->setCurrentFrame(anim.runtimeState);
+			entity->setAnimationEnabled(anim.active);
+			if (zChanged)
+				_entityManager->reinsertSceneEntity(entity);
+		}
+	}
+
+	for (const AnimRecord &anim : _scene.state.roomAnimations) {
+		if (!findRoomAnimByNameConst(updatedState.roomAnimations, anim.animName))
+			removeSceneEntityByName(anim.animName);
+	}
+
+	for (const NpcRecord &npc : _scene.state.roomNpcs) {
+		const NpcRecord *updatedNpc =
+			findRoomNpcByNameConst(updatedState.roomNpcs, npc.npcName);
+		const bool keepCurrentRoomDeathEntity =
+			!updatedNpc &&
+			isRetainedCurrentRoomNpcDeathRecord(npc) &&
+			_entityManager &&
+			_entityManager->findSceneEntityByName(npc.npcName);
+		const bool keepEntity =
+			updatedNpc &&
+			shouldSpawnRoomNpcEntity(*updatedNpc) &&
+			(sameNpcEntityState(npc, *updatedNpc) ||
+				isQueuedLiveNpcDeathMutation(npc, *updatedNpc));
+		if (keepCurrentRoomDeathEntity) {
+			debugC(1, kDebugCombat,
+				"Harvester: retained current-room npc death entity npc='%s' damage_type=%d frame=%d",
+				npc.npcName.c_str(), npc.deathDamageType, npc.runtimeState);
+			continue;
+		}
+		if (!keepEntity)
+			removeSceneEntityByName(npc.npcName);
+	}
+	for (const NpcRecord &npc : updatedState.roomNpcs) {
+		if (!shouldSpawnRoomNpcEntity(npc))
+			continue;
+
+		Entity *entity = _entityManager
+			? _entityManager->findSceneEntityByName(npc.npcName)
+			: nullptr;
+		const NpcRecord *previousNpc =
+			findRoomNpcByNameConst(_scene.state.roomNpcs, npc.npcName);
+		const bool keepQueuedDeathEntity =
+			entity &&
+			previousNpc &&
+			isQueuedLiveNpcDeathMutation(*previousNpc, npc);
+		if (keepQueuedDeathEntity)
+			continue;
+		const bool needsRespawn =
+			!entity || !previousNpc || !sameNpcEntityState(*previousNpc, npc);
+		if (!needsRespawn)
+			continue;
+
+		removeSceneEntityByName(npc.npcName);
+		if (!spawnSceneNpcEntityFromRecord(npc))
+			return false;
+	}
+
+	for (const MonsterRecord &monster : _scene.state.roomMonsters) {
+		const MonsterRecord *updatedMonster =
+			findRoomMonsterByNameConst(updatedState.roomMonsters, monster.monsterName);
+		const bool keepEntity =
+			updatedMonster &&
+			shouldSpawnRoomMonsterEntity(*updatedMonster) &&
+			sameMonsterEntityState(monster, *updatedMonster);
+		if (!keepEntity)
+			removeSceneEntityByName(monster.monsterName);
+	}
+	for (const MonsterRecord &monster : updatedState.roomMonsters) {
+		if (!shouldSpawnRoomMonsterEntity(monster))
+			continue;
+
+		Entity *entity = _entityManager
+			? _entityManager->findSceneEntityByName(monster.monsterName)
+			: nullptr;
+		const MonsterRecord *previousMonster =
+			findRoomMonsterByNameConst(_scene.state.roomMonsters, monster.monsterName);
+		const bool needsRespawn =
+			!entity || !previousMonster || !sameMonsterEntityState(*previousMonster, monster);
+		if (!needsRespawn)
+			continue;
+
+		removeSceneEntityByName(monster.monsterName);
+		if (!spawnSceneMonsterEntityFromRecord(updatedState, monster))
+			return false;
+	}
+
+	_scene.state = updatedState;
+	_monsterCombatStates = Common::move(updatedMonsterCombatStates);
+	_scene.sceneObjects = updatedScene.sceneObjects;
+	_scene.sceneAnimations = updatedScene.sceneAnimations;
+	_scene.sceneRegions = updatedScene.sceneRegions;
+	const int playerCombatLoadout = script->getPlayerCombatLoadout();
+	if (_playerState.entity && _playerState.combatLoadout != playerCombatLoadout &&
+			!Player::syncCombatLoadoutVisual(
+				_engine, updatedState, _playerState, playerCombatLoadout)) {
+		return false;
+	}
+	if (preservePaletteState) {
+		memcpy(_scene.palette, previousPalette, sizeof(_scene.palette));
+		_scene.targetPaletteBrightness = previousPaletteBrightness;
+	} else {
+		memcpy(_scene.palette, updatedScene.palette, sizeof(_scene.palette));
+		_scene.targetPaletteBrightness = updatedScene.targetPaletteBrightness;
+	}
+	captureCurrentSaveState();
+	return _inventory.refresh();
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 93598e92643..2fecbcf9da2 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -22,12 +22,14 @@
 #ifndef HARVESTER_ROOM_SESSION_H
 #define HARVESTER_ROOM_SESSION_H
 
+#include "harvester/room_combat.h"
 #include "harvester/room_support.h"
 #include "harvester/runtime_entity.h"
 
 namespace Harvester {
 
 class EntityManager;
+class InventorySystem;
 
 struct RoomMovementInputState {
 	bool moveLeft = false;
@@ -40,9 +42,11 @@ struct RoomMovementInputState {
 // Entity pointers remain owned by EntityManager.
 class RoomSession {
 public:
-	RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager);
+	RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager,
+		InventorySystem &inventory);
 
 	RoomPlayerState &getPlayerState() { return _playerState; }
+	Common::Array<RoomMonsterCombatState> &getMonsterCombatStates() { return _monsterCombatStates; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
 	RoomMovementInputState &getMovementInput() { return _movementInput; }
 	Common::String &getPendingRegionName() { return _pendingRegionName; }
@@ -54,6 +58,7 @@ public:
 	void stopPlayerRegionInteraction();
 	void syncCurrentRoomRuntimeState();
 	void captureCurrentSaveState();
+	bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState);
 	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
 		bool includeCurrentRoomTimers = false);
 
@@ -78,7 +83,9 @@ private:
 	HarvesterEngine &_engine;
 	RoomSceneResources &_scene;
 	EntityManager *_entityManager;
+	InventorySystem &_inventory;
 	RoomPlayerState _playerState;
+	Common::Array<RoomMonsterCombatState> _monsterCombatStates;
 	RoomIdleAnimationState _idleState;
 	RoomMovementInputState _movementInput;
 	Common::String _pendingRegionName;


Commit: a4044055a35c79dec24548b6a2fdb1debf023f40
    https://github.com/scummvm/scummvm/commit/a4044055a35c79dec24548b6a2fdb1debf023f40
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: refactor RoomSession to include Flow for enhanced scene management

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/flow.h
    engines/harvester/room.cpp
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 8c42144c327..92d9e15dc5d 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -61,6 +61,7 @@ private:
 	friend class MenuSystem;
 	friend class RoomInteractionProcessor;
 	friend class RoomSystem;
+	friend class RoomSession;
 
 	bool loadQuickTips();
 	bool loadMenuItems();
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 3566a43038d..31c14b2a8a6 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -451,7 +451,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		flow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
 			(void)_engine.playMusic(scene.state.musicPath);
-		RoomSession session(_engine, scene, entityManager, _inventory);
+		RoomSession session(_engine, flow, scene, entityManager, _inventory);
 		debugC(1, kDebugRoom, "Harvester: created room session room='%s'", scene.state.roomName.c_str());
 		RoomPlayerState &playerState = session.getPlayerState();
 		playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
@@ -467,9 +467,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		monsterCombatStates.resize(scene.state.roomMonsters.size());
 		Common::Array<RoomNpcCombatState> npcCombatStates;
 		npcCombatStates.resize(scene.state.roomNpcs.size());
-		Common::Array<RoomHitEffectState> hitEffectStates;
+		Common::Array<RoomHitEffectState> &hitEffectStates = session.getHitEffectStates();
 		Common::Array<RoomCombatDamagePopupState> damagePopupStates;
-		uint nextCombatEffectId = 0;
+		uint &nextCombatEffectId = session.getNextCombatEffectId();
 		ResolvedText inspectText;
 		Common::String currentInteractionPromptText;
 		bool showingInspectText = false;
@@ -696,73 +696,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		session.resetIdleState();
 
-		auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
-			Common::Array<AudioCommand> entryAudioCommands = scene.state.audioCommands;
-			RoomSetupState refreshedState;
-			if (!_engine.getScript()->materializeRoomState(
-					scene.state.entranceName, scene.state.roomName, refreshedState, *_engine.getResources())) {
-				return false;
-			}
-
-			refreshedState.audioCommands = Common::move(entryAudioCommands);
-			if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), scene))
-				return false;
-			hitEffectStates.clear();
-			nextCombatEffectId = 0;
-			if (!flow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
-				return false;
-			if (entityManager) {
-				for (const MonsterRecord &monster : scene.state.roomMonsters) {
-					Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
-					if (entity)
-						Monster::applyAnimation(*entity, monster);
-				}
-			}
-
-			playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
-			if (playerState.entity) {
-				if (!preservePlayerPlacement || playerState.facing < 0) {
-					playerState.centerX = scene.state.playerSpawnX;
-					playerState.bottomY = scene.state.playerSpawnY;
-					playerState.z = (float)scene.state.playerSpawnZ;
-					playerState.facing = scene.state.playerFacing;
-				}
-
-				const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
-				(void)Player::setIdleAnimation(playerState, facing);
-				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
-					playerState.centerX, playerState.bottomY, playerState.z);
-			}
-
-			playerState.hasMoveTarget = false;
-			playerState.turnActive = false;
-			playerState.turnTargetFacing = -1;
-			playerState.attackActive = false;
-			playerState.attackFirstFrame = -1;
-			playerState.attackLastFrame = -1;
-			playerState.attackContactFrame = -1;
-			playerState.attackResumeFacing = -1;
-			playerState.attackSoundPlayed = false;
-			playerState.attackSoundPlaybackFrame = -1;
-			playerState.attackContactResolved = false;
-			playerState.attackTargetName.clear();
-			playerState.attackTargetClassId = -1;
-			playerState.hitActive = false;
-			playerState.hitFirstFrame = -1;
-			playerState.hitLastFrame = -1;
-			playerState.hitResumeFacing = -1;
-			playerState.hitKnockbackRemainingX = 0;
-			playerState.hitKnockbackDecayStep = 0;
-			playerState.combatLoadout =
-				_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
-			monsterCombatStates.clear();
-			monsterCombatStates.resize(scene.state.roomMonsters.size());
-			pendingRegionName.clear();
-			session.resetIdleState();
-			flow.resetCursorAnimationSequence();
-			session.captureCurrentSaveState();
-			return _inventory.refresh();
-		};
 		auto syncAnimatedRoomActorPlacement = [&]() {
 			Player::syncAnimationPlacement(playerState);
 			if (!entityManager)
@@ -808,7 +741,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (clearCarryState)
 				clearCarriedRoomItem();
 
-			if (!session.applyCurrentRoomRuntimeMutationsInPlace(true) && !refreshCurrentScene(true))
+			if (!session.applyCurrentRoomRuntimeMutationsInPlace(true) && !session.refreshCurrentScene(true))
 				return Common::kReadingFailed;
 			if (!_inventory.refresh())
 				return Common::kReadingFailed;
@@ -1167,7 +1100,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					? _engine.getScript()->getPlayerCurrentHitPoints()
 					: 0);
 		};
-		typedef decltype(refreshCurrentScene) RefreshCurrentSceneFn;
 		typedef decltype(captureDialogueBackdrop) CaptureDialogueBackdropFn;
 		typedef decltype(showCdChangePrompt) ShowCdChangePromptFn;
 		typedef decltype(runRoomExitCommands) RunRoomExitCommandsFn;
@@ -1179,7 +1111,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		struct RoomInteractionCallbacksImpl : RoomInteractionCallbacks {
 			RoomInteractionCallbacksImpl(
 					RoomSession &session_,
-					RefreshCurrentSceneFn &refreshCurrentSceneFn_,
 					CaptureDialogueBackdropFn &captureDialogueBackdropFn_,
 					ShowCdChangePromptFn &showCdChangePromptFn_,
 					RunRoomExitCommandsFn &runRoomExitCommandsFn_,
@@ -1187,7 +1118,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					ApplyPlayerGotoXZFn &applyPlayerGotoXZFn_,
 					RunModalShowTextFn &runModalShowTextFn_,
 					StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn_)
-				: session(session_), refreshCurrentSceneFn(refreshCurrentSceneFn_),
+				: session(session_),
 				  captureDialogueBackdropFn(captureDialogueBackdropFn_),
 				  showCdChangePromptFn(showCdChangePromptFn_),
 				  runRoomExitCommandsFn(runRoomExitCommandsFn_),
@@ -1198,7 +1129,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			bool refreshCurrentScene(bool preservePlayerPlacement) override {
-				return refreshCurrentSceneFn(preservePlayerPlacement);
+				return session.refreshCurrentScene(preservePlayerPlacement);
 			}
 
 			bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) override {
@@ -1247,7 +1178,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			RoomSession &session;
-			RefreshCurrentSceneFn &refreshCurrentSceneFn;
 			CaptureDialogueBackdropFn &captureDialogueBackdropFn;
 			ShowCdChangePromptFn &showCdChangePromptFn;
 			RunRoomExitCommandsFn &runRoomExitCommandsFn;
@@ -1258,7 +1188,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		};
 		RoomInteractionCallbacksImpl interactionCallbacks(
 			session,
-			refreshCurrentScene,
 			captureDialogueBackdrop, showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			startPlayerDefeatSequence);
@@ -1278,7 +1207,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (roomEntryInteraction.mutatedRuntimeState) {
 				session.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
 				if (!session.applyCurrentRoomRuntimeMutationsInPlace(false) &&
-						!refreshCurrentScene(true)) {
+						!session.refreshCurrentScene(true)) {
 					return Common::kReadingFailed;
 				}
 				roomEntryInteraction.mutatedRuntimeState = false;
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index 6e999a7a150..d432c77209f 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -22,6 +22,7 @@
 #include "harvester/room_session.h"
 
 #include "harvester/detection.h"
+#include "harvester/flow.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
 #include "harvester/monster.h"
@@ -137,9 +138,9 @@ static bool sameSceneRegionState(const RegionRecord &lhs, const RegionRecord &rh
 		lhs.actionTag.equalsIgnoreCase(rhs.actionTag);
 }
 
-RoomSession::RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager,
+RoomSession::RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
 		InventorySystem &inventory)
-	: _engine(engine), _scene(scene), _entityManager(entityManager), _inventory(inventory) {
+	: _engine(engine), _flow(flow), _scene(scene), _entityManager(entityManager), _inventory(inventory) {
 }
 
 void RoomSession::resetIdleState() {
@@ -755,4 +756,74 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 	return _inventory.refresh();
 }
 
+bool RoomSession::refreshCurrentScene(bool preservePlayerPlacement) {
+	debugC(2, kDebugRoom, "Harvester: refreshing room scene room='%s' preserve_player_placement=%d",
+		_scene.state.roomName.c_str(), preservePlayerPlacement);
+	Common::Array<AudioCommand> entryAudioCommands = _scene.state.audioCommands;
+	RoomSetupState refreshedState;
+	if (!_engine.getScript()->materializeRoomState(
+			_scene.state.entranceName, _scene.state.roomName, refreshedState, *_engine.getResources())) {
+		return false;
+	}
+
+	refreshedState.audioCommands = Common::move(entryAudioCommands);
+	if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), _scene))
+		return false;
+	_hitEffectStates.clear();
+	_nextCombatEffectId = 0;
+	if (!_flow.populateRoomSceneEntities(_scene.state, _scene.sceneObjects, _scene.sceneAnimations))
+		return false;
+	if (_entityManager) {
+		for (const MonsterRecord &monster : _scene.state.roomMonsters) {
+			Entity *entity = _entityManager->findSceneEntityByName(monster.monsterName);
+			if (entity)
+				Monster::applyAnimation(*entity, monster);
+		}
+	}
+
+	_playerState.entity = _entityManager ? _entityManager->findSceneEntityByName("PLAYER") : nullptr;
+	if (_playerState.entity) {
+		if (!preservePlayerPlacement || _playerState.facing < 0) {
+			_playerState.centerX = _scene.state.playerSpawnX;
+			_playerState.bottomY = _scene.state.playerSpawnY;
+			_playerState.z = (float)_scene.state.playerSpawnZ;
+			_playerState.facing = _scene.state.playerFacing;
+		}
+
+		const int facing = _playerState.facing >= 0 ? _playerState.facing : _scene.state.playerFacing;
+		(void)Player::setIdleAnimation(_playerState, facing);
+		(void)applyRoomActorPlacement(_scene.state, *_playerState.entity,
+			_playerState.centerX, _playerState.bottomY, _playerState.z);
+	}
+
+	_playerState.hasMoveTarget = false;
+	_playerState.turnActive = false;
+	_playerState.turnTargetFacing = -1;
+	_playerState.attackActive = false;
+	_playerState.attackFirstFrame = -1;
+	_playerState.attackLastFrame = -1;
+	_playerState.attackContactFrame = -1;
+	_playerState.attackResumeFacing = -1;
+	_playerState.attackSoundPlayed = false;
+	_playerState.attackSoundPlaybackFrame = -1;
+	_playerState.attackContactResolved = false;
+	_playerState.attackTargetName.clear();
+	_playerState.attackTargetClassId = -1;
+	_playerState.hitActive = false;
+	_playerState.hitFirstFrame = -1;
+	_playerState.hitLastFrame = -1;
+	_playerState.hitResumeFacing = -1;
+	_playerState.hitKnockbackRemainingX = 0;
+	_playerState.hitKnockbackDecayStep = 0;
+	_playerState.combatLoadout =
+		_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
+	_monsterCombatStates.clear();
+	_monsterCombatStates.resize(_scene.state.roomMonsters.size());
+	_pendingRegionName.clear();
+	resetIdleState();
+	_flow.resetCursorAnimationSequence();
+	captureCurrentSaveState();
+	return _inventory.refresh();
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 2fecbcf9da2..5a8b496d49e 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -29,6 +29,7 @@
 namespace Harvester {
 
 class EntityManager;
+class Flow;
 class InventorySystem;
 
 struct RoomMovementInputState {
@@ -42,11 +43,13 @@ struct RoomMovementInputState {
 // Entity pointers remain owned by EntityManager.
 class RoomSession {
 public:
-	RoomSession(HarvesterEngine &engine, RoomSceneResources &scene, EntityManager *entityManager,
+	RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
 		InventorySystem &inventory);
 
 	RoomPlayerState &getPlayerState() { return _playerState; }
 	Common::Array<RoomMonsterCombatState> &getMonsterCombatStates() { return _monsterCombatStates; }
+	Common::Array<RoomHitEffectState> &getHitEffectStates() { return _hitEffectStates; }
+	uint &getNextCombatEffectId() { return _nextCombatEffectId; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
 	RoomMovementInputState &getMovementInput() { return _movementInput; }
 	Common::String &getPendingRegionName() { return _pendingRegionName; }
@@ -59,6 +62,7 @@ public:
 	void syncCurrentRoomRuntimeState();
 	void captureCurrentSaveState();
 	bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState);
+	bool refreshCurrentScene(bool preservePlayerPlacement);
 	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
 		bool includeCurrentRoomTimers = false);
 
@@ -81,11 +85,14 @@ private:
 	bool isTimerMaterializedInCurrentRoom(const Common::String &timerName) const;
 
 	HarvesterEngine &_engine;
+	Flow &_flow;
 	RoomSceneResources &_scene;
 	EntityManager *_entityManager;
 	InventorySystem &_inventory;
 	RoomPlayerState _playerState;
 	Common::Array<RoomMonsterCombatState> _monsterCombatStates;
+	Common::Array<RoomHitEffectState> _hitEffectStates;
+	uint _nextCombatEffectId = 0;
 	RoomIdleAnimationState _idleState;
 	RoomMovementInputState _movementInput;
 	Common::String _pendingRegionName;


Commit: 59e4140ebc6cccbcf3b8a71f7144da0ad05764f6
    https://github.com/scummvm/scummvm/commit/59e4140ebc6cccbcf3b8a71f7144da0ad05764f6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: RoomCombatEffects for managing hit effects and damage popups

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_combat.cpp
    engines/harvester/room_combat.h
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 31c14b2a8a6..82eac8f328d 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -276,37 +276,6 @@ static void drawCombatLoadoutResourceIcons(Graphics::Screen &screen, const Art &
 			kNativeCombatResourceIconsY);
 }
 
-static byte findNearestPaletteColor(const byte *palette, byte red, byte green, byte blue) {
-	if (!palette)
-		return 0;
-
-	byte bestIndex = 0;
-	uint32 bestDistance = 0xffffffffu;
-	for (int i = 0; i < 256; ++i) {
-		const int paletteOffset = i * 3;
-		const int dr = (int)palette[paletteOffset] - red;
-		const int dg = (int)palette[paletteOffset + 1] - green;
-		const int db = (int)palette[paletteOffset + 2] - blue;
-		const uint32 distance = (uint32)(dr * dr + dg * dg + db * db);
-		if (distance < bestDistance) {
-			bestDistance = distance;
-			bestIndex = (byte)i;
-		}
-	}
-
-	return bestIndex;
-}
-
-static void drawShadowedRoomText(Graphics::Screen &screen, const Graphics::Font &font,
-		const Common::String &text, int x, int y, byte textColor, byte shadowColor) {
-	if (text.empty())
-		return;
-
-	const int width = font.getStringWidth(text);
-	font.drawString(&screen, text, x + 1, y + 1, width, shadowColor);
-	font.drawString(&screen, text, x, y, width, textColor);
-}
-
 static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
 	const float gammaBrightness = g_engine ? g_engine->getGammaBrightnessScale() : 1.0f;
@@ -467,9 +436,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		monsterCombatStates.resize(scene.state.roomMonsters.size());
 		Common::Array<RoomNpcCombatState> npcCombatStates;
 		npcCombatStates.resize(scene.state.roomNpcs.size());
-		Common::Array<RoomHitEffectState> &hitEffectStates = session.getHitEffectStates();
-		Common::Array<RoomCombatDamagePopupState> damagePopupStates;
-		uint &nextCombatEffectId = session.getNextCombatEffectId();
+		RoomCombatEffects &combatEffects = session.getCombatEffects();
 		ResolvedText inspectText;
 		Common::String currentInteractionPromptText;
 		bool showingInspectText = false;
@@ -539,161 +506,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				sceneObject->currentOwnerOrRoom = *ownerOrRoom;
 		};
 
-		auto resolveHitEffectAnchor = [&](const Entity &targetEntity,
-				Common::Point &anchorPoint, float &renderZ) {
-			const Common::Rect targetRect = targetEntity.getScreenRect();
-			anchorPoint.x = targetRect.left + targetRect.width() / 2;
-			anchorPoint.y = targetRect.top + targetRect.height() / 3;
-			renderZ = targetEntity.getZ() - kNativeHitEffectRenderZBias;
-		};
-		auto resolveCombatDamagePopupAnchor = [&](const Entity &targetEntity,
-				Common::Point &anchorPoint) {
-			const Common::Rect targetRect = targetEntity.getScreenRect();
-			anchorPoint.x = targetRect.left + targetRect.width() / 2;
-			anchorPoint.y = targetRect.top - kCombatDebugDamagePopupVerticalGap;
-		};
-		auto spawnCombatHitEffect = [&](Entity &sourceEntity,
-				const Common::String &followTargetName) {
-			if (!entityManager)
-				return false;
-
-			RoomHitEffectState effectState;
-			effectState.entityName = Common::String::format("__combat_blood_%u", nextCombatEffectId++);
-			effectState.followTargetName = followTargetName;
-			resolveHitEffectAnchor(sourceEntity, effectState.anchorPoint, effectState.renderZ);
-
-			Entity *effectEntity = entityManager->spawnSceneAnimationEntity(
-				effectState.entityName, kNativeHitEffectResourcePath, effectState.anchorPoint,
-				effectState.renderZ, kNativeHitEffectAnimationRate, true, true, false,
-				false, false, 0);
-			if (!effectEntity)
-				return false;
-
-			hitEffectStates.push_back(Common::move(effectState));
-			return true;
-		};
-		auto spawnCombatDamagePopup = [&](Entity &targetEntity,
-				const Common::String &followTargetName, int damageAmount) {
-			if (damageAmount <= 0)
-				return;
-
-			RoomCombatDamagePopupState popupState;
-			popupState.followTargetName = followTargetName;
-			popupState.startTick = Player::getRuntimeClockTicks();
-			popupState.damageAmount = damageAmount;
-			resolveCombatDamagePopupAnchor(targetEntity, popupState.anchorPoint);
-			damagePopupStates.push_back(Common::move(popupState));
-		};
-		auto syncCombatHitEffects = [&]() {
-			if (!entityManager)
-				return false;
-
-			bool changed = false;
-			for (RoomHitEffectState &effectState : hitEffectStates) {
-				Entity *effectEntity = entityManager->findSceneEntityByName(effectState.entityName);
-				if (!effectEntity)
-					continue;
-
-				if (!effectState.followTargetName.empty()) {
-					Entity *targetEntity =
-						entityManager->findSceneEntityByName(effectState.followTargetName);
-					if (targetEntity && targetEntity->isVisible()) {
-						resolveHitEffectAnchor(*targetEntity, effectState.anchorPoint, effectState.renderZ);
-					}
-				}
-
-				if (effectEntity->getX() != effectState.anchorPoint.x ||
-						effectEntity->getY() != effectState.anchorPoint.y ||
-						fabsf(effectEntity->getZ() - effectState.renderZ) > 0.0001f) {
-					effectEntity->setPosition(
-						effectState.anchorPoint.x, effectState.anchorPoint.y, effectState.renderZ);
-					entityManager->reinsertSceneEntity(effectEntity);
-					changed = true;
-				}
-			}
-
-			return changed;
-		};
-		auto drawCombatDamagePopups = [&](Graphics::Screen &screen) {
-			if (!_engine.isCombatDebugEnabled() || damagePopupStates.empty())
-				return;
-
-			const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-			if (!font)
-				return;
-
-			byte displayPalette[256 * 3];
-			screen.getPalette(displayPalette);
-			const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
-			const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
-			const uint32 now = Player::getRuntimeClockTicks();
-			for (RoomCombatDamagePopupState &popupState : damagePopupStates) {
-				if (entityManager && !popupState.followTargetName.empty()) {
-					Entity *targetEntity =
-						entityManager->findSceneEntityByName(popupState.followTargetName);
-					if (targetEntity && targetEntity->isVisible())
-						resolveCombatDamagePopupAnchor(*targetEntity, popupState.anchorPoint);
-				}
-
-				const uint32 elapsed = now - popupState.startTick;
-				const uint32 clampedElapsed = MIN<uint32>(elapsed, kCombatDebugDamagePopupDurationMs);
-				const int rise = (int)(
-					clampedElapsed * (uint32)kCombatDebugDamagePopupRisePixels /
-					kCombatDebugDamagePopupDurationMs);
-				const Common::String popupText = Common::String::format("+%d", popupState.damageAmount);
-				const int textWidth = font->getStringWidth(popupText);
-				const int drawX = CLIP<int>(popupState.anchorPoint.x - textWidth / 2,
-					0, MAX(0, screen.w - textWidth));
-				const int drawY = CLIP<int>(
-					popupState.anchorPoint.y - font->getFontHeight() - rise,
-					0, MAX(0, screen.h - font->getFontHeight()));
-				drawShadowedRoomText(screen, *font, popupText, drawX, drawY, white, black);
-			}
-		};
-		auto pruneCombatHitEffects = [&]() {
-			bool removedAny = false;
-			for (uint i = 0; i < hitEffectStates.size();) {
-				const Common::String effectName = hitEffectStates[i].entityName;
-				Entity *effectEntity = entityManager
-					? entityManager->findSceneEntityByName(effectName)
-					: nullptr;
-				if (!effectEntity) {
-					hitEffectStates.remove_at(i);
-					continue;
-				}
-
-				const bool finished =
-					!effectEntity->isAnimationEnabled() &&
-					effectEntity->getCurrentFrame() == effectEntity->getLastFrame();
-				if (finished && hitEffectStates[i].finished) {
-					session.removeSceneEntityByName(effectName);
-					hitEffectStates.remove_at(i);
-					removedAny = true;
-					continue;
-				}
-
-				hitEffectStates[i].finished = finished;
-				++i;
-			}
-
-			return removedAny;
-		};
-		auto pruneCombatDamagePopups = [&]() {
-			const uint32 now = Player::getRuntimeClockTicks();
-			bool removedAny = false;
-			for (uint i = 0; i < damagePopupStates.size();) {
-				if (now - damagePopupStates[i].startTick < kCombatDebugDamagePopupDurationMs) {
-					++i;
-					continue;
-				}
-
-				damagePopupStates.remove_at(i);
-				removedAny = true;
-			}
-
-			return removedAny;
-		};
-
 		session.resetIdleState();
 
 		auto syncAnimatedRoomActorPlacement = [&]() {
@@ -1680,7 +1492,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		const int damageLanded = monster->currentHitPoints;
 		monster->currentHitPoints = 0;
 		if (damageLanded > 0)
-			spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
+			combatEffects.spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
 
 		if (playerState.attackTargetClassId == kRuntimeEntityClassMonster &&
 				playerState.attackTargetName.equalsIgnoreCase(monster->monsterName)) {
@@ -1875,7 +1687,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		};
 		auto startMonsterHitReaction = [&](MonsterRecord &monster, Entity &monsterEntity,
 				RoomMonsterCombatState *combatState, int attackerAttackFirstFrame) {
-			if (spawnCombatHitEffect(monsterEntity, monster.monsterName))
+			if (combatEffects.spawnCombatHitEffect(monsterEntity, monster.monsterName))
 				needsRedraw = true;
 
 			if (!combatState) {
@@ -1982,7 +1794,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				return Common::kNoError;
 			if (runtimeChanged && npcEntity) {
 				const Common::String followTargetName = npc->monsterfyTargetName;
-				if (spawnCombatHitEffect(*npcEntity, followTargetName))
+				if (combatEffects.spawnCombatHitEffect(*npcEntity, followTargetName))
 					needsRedraw = true;
 			}
 			if (runtimeChanged && combatState && npcEntity &&
@@ -2040,7 +1852,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
 				Player::describeCombatDamageType(damageType), hitPointsBefore, monster->currentHitPoints);
 			if (damageLanded > 0) {
-				spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
+				combatEffects.spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
 				startMonsterHitReaction(*monster, *monsterEntity, combatState, playerState.attackFirstFrame);
 			}
 			if (monster->currentHitPoints > 0) {
@@ -2283,7 +2095,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 							playerHitPointsBefore, playerHitPointsAfter, changed,
 							damageDisabled ? 1 : 0);
 						if (damageLanded > 0 && playerState.entity) {
-							spawnCombatDamagePopup(*playerState.entity, playerState.entity->getName(), damageLanded);
+							combatEffects.spawnCombatDamagePopup(*playerState.entity, playerState.entity->getName(), damageLanded);
 							if (idleState.entity) {
 								idleState.entity->setVisible(false);
 								idleState.entity->setAnimationRate(0);
@@ -2881,7 +2693,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
-			drawCombatDamagePopups(*activeScreen);
+			combatEffects.drawCombatDamagePopups(*activeScreen, _engine.isCombatDebugEnabled());
 			if (_engine.getScript() && playerState.entity) {
 				drawCombatLoadoutResourceIcons(*activeScreen, *art, *_engine.getScript(),
 					_engine.getScript()->getPlayerCombatLoadout());
@@ -3687,7 +3499,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		combatError = updateRoomMonsterCombat();
 		if (combatError.getCode() != Common::kNoError)
 			return combatError;
-		if (syncCombatHitEffects())
+		if (combatEffects.syncCombatHitEffects())
 			needsRedraw = true;
 		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
@@ -3702,9 +3514,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		}
 		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
-		if (pruneCombatHitEffects())
+		if (combatEffects.pruneCombatHitEffects())
 			needsRedraw = true;
-		if (pruneCombatDamagePopups())
+		if (combatEffects.pruneCombatDamagePopups())
 			needsRedraw = true;
 		if (entityManager) {
 			Common::Array<Common::String> expiredTimerNames;
@@ -3732,7 +3544,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		}
 		if (Player::updateIdleAnimation(scene.state, playerState, idleState))
 			needsRedraw = true;
-		if (_engine.isCombatDebugEnabled() && !damagePopupStates.empty())
+		if (_engine.isCombatDebugEnabled() && combatEffects.hasDamagePopups())
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
index f336c2ac3cb..bea363a7d98 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/room_combat.cpp
@@ -22,6 +22,10 @@
 #include "harvester/room_combat.h"
 
 #include "common/util.h"
+#include "graphics/font.h"
+#include "graphics/fontman.h"
+#include "graphics/screen.h"
+#include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/player.h"
 #include "harvester/runtime_entity.h"
@@ -58,6 +62,38 @@ const int kNativeDefaultNpcDeathDamageType = 1;
 
 namespace {
 
+static byte findNearestPaletteColor(const byte *palette, byte red, byte green, byte blue) {
+	if (!palette)
+		return 0;
+
+	byte bestIndex = 0;
+	uint32 bestDistance = 0xffffffffu;
+	for (int i = 0; i < 256; ++i) {
+		const int paletteOffset = i * 3;
+		const int dr = (int)palette[paletteOffset] - red;
+		const int dg = (int)palette[paletteOffset + 1] - green;
+		const int db = (int)palette[paletteOffset + 2] - blue;
+		const uint32 distance = (uint32)(dr * dr + dg * dg + db * db);
+		if (distance < bestDistance) {
+			bestDistance = distance;
+			bestIndex = (byte)i;
+		}
+	}
+
+	return bestIndex;
+}
+
+static void drawShadowedRoomText(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &text, int x, int y, byte textColor, byte shadowColor) {
+	if (text.empty())
+		return;
+
+	const int width = font.getStringWidth(text);
+	font.drawString(&screen, text, x + 1, y + 1, width, shadowColor);
+	font.drawString(&screen, text, x, y, width, textColor);
+}
+
+
 struct RoomMonsterFacingAnimationRange {
 	RoomMonsterFacingAnimationRange() {}
 	RoomMonsterFacingAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
@@ -481,4 +517,177 @@ void clearRoomMonsterHitState(RoomMonsterCombatState &combatState) {
 	combatState.hitSoundPlayed = false;
 }
 
+RoomCombatEffects::RoomCombatEffects(EntityManager *entityManager) : _entityManager(entityManager) {
+	debugC(1, kDebugCombat, "Harvester: created room combat effects entities=%d", _entityManager != nullptr);
+}
+
+void RoomCombatEffects::clearHitEffects() {
+	debugC(2, kDebugCombat, "Harvester: resetting room blood effects records=%u next_id=%u",
+		(uint)_hitEffectStates.size(), _nextCombatEffectId);
+	_hitEffectStates.clear();
+	_nextCombatEffectId = 0;
+}
+
+void RoomCombatEffects::resolveHitEffectAnchor(const Entity &targetEntity, Common::Point &anchorPoint, float &renderZ) {
+	const Common::Rect targetRect = targetEntity.getScreenRect();
+	anchorPoint.x = targetRect.left + targetRect.width() / 2;
+	anchorPoint.y = targetRect.top + targetRect.height() / 3;
+	renderZ = targetEntity.getZ() - kNativeHitEffectRenderZBias;
+}
+
+void RoomCombatEffects::resolveCombatDamagePopupAnchor(const Entity &targetEntity, Common::Point &anchorPoint) {
+	const Common::Rect targetRect = targetEntity.getScreenRect();
+	anchorPoint.x = targetRect.left + targetRect.width() / 2;
+	anchorPoint.y = targetRect.top - kCombatDebugDamagePopupVerticalGap;
+}
+
+bool RoomCombatEffects::spawnCombatHitEffect(Entity &sourceEntity, const Common::String &followTargetName) {
+	if (!_entityManager)
+		return false;
+
+	RoomHitEffectState effectState;
+	effectState.entityName = Common::String::format("__combat_blood_%u", _nextCombatEffectId++);
+	effectState.followTargetName = followTargetName;
+	resolveHitEffectAnchor(sourceEntity, effectState.anchorPoint, effectState.renderZ);
+
+	Entity *effectEntity = _entityManager->spawnSceneAnimationEntity(
+		effectState.entityName, kNativeHitEffectResourcePath, effectState.anchorPoint,
+		effectState.renderZ, kNativeHitEffectAnimationRate, true, true, false,
+		false, false, 0);
+	if (!effectEntity) {
+		debugC(1, kDebugCombat, "Harvester: unable to spawn blood effect entity='%s' target='%s'",
+			effectState.entityName.c_str(), followTargetName.c_str());
+		return false;
+	}
+
+	_hitEffectStates.push_back(Common::move(effectState));
+	return true;
+}
+
+void RoomCombatEffects::spawnCombatDamagePopup(Entity &targetEntity, const Common::String &followTargetName, int damageAmount) {
+	if (damageAmount <= 0)
+		return;
+
+	RoomCombatDamagePopupState popupState;
+	popupState.followTargetName = followTargetName;
+	popupState.startTick = Player::getRuntimeClockTicks();
+	popupState.damageAmount = damageAmount;
+	resolveCombatDamagePopupAnchor(targetEntity, popupState.anchorPoint);
+	_damagePopupStates.push_back(Common::move(popupState));
+}
+
+bool RoomCombatEffects::syncCombatHitEffects() {
+	if (!_entityManager)
+		return false;
+
+	bool changed = false;
+	for (RoomHitEffectState &effectState : _hitEffectStates) {
+		Entity *effectEntity = _entityManager->findSceneEntityByName(effectState.entityName);
+		if (!effectEntity)
+			continue;
+
+		if (!effectState.followTargetName.empty()) {
+			Entity *targetEntity =
+				_entityManager->findSceneEntityByName(effectState.followTargetName);
+			if (targetEntity && targetEntity->isVisible()) {
+				resolveHitEffectAnchor(*targetEntity, effectState.anchorPoint, effectState.renderZ);
+			}
+		}
+
+		if (effectEntity->getX() != effectState.anchorPoint.x ||
+				effectEntity->getY() != effectState.anchorPoint.y ||
+				fabsf(effectEntity->getZ() - effectState.renderZ) > 0.0001f) {
+			effectEntity->setPosition(
+				effectState.anchorPoint.x, effectState.anchorPoint.y, effectState.renderZ);
+			_entityManager->reinsertSceneEntity(effectEntity);
+			changed = true;
+		}
+	}
+
+	return changed;
+}
+
+void RoomCombatEffects::drawCombatDamagePopups(Graphics::Screen &screen, bool combatDebugEnabled) {
+	if (!combatDebugEnabled || _damagePopupStates.empty())
+		return;
+
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!font)
+		return;
+
+	byte displayPalette[256 * 3];
+	screen.getPalette(displayPalette);
+	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
+	const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
+	const uint32 now = Player::getRuntimeClockTicks();
+	for (RoomCombatDamagePopupState &popupState : _damagePopupStates) {
+		if (_entityManager && !popupState.followTargetName.empty()) {
+			Entity *targetEntity =
+				_entityManager->findSceneEntityByName(popupState.followTargetName);
+			if (targetEntity && targetEntity->isVisible())
+				resolveCombatDamagePopupAnchor(*targetEntity, popupState.anchorPoint);
+		}
+
+		const uint32 elapsed = now - popupState.startTick;
+		const uint32 clampedElapsed = MIN<uint32>(elapsed, kCombatDebugDamagePopupDurationMs);
+		const int rise = (int)(
+			clampedElapsed * (uint32)kCombatDebugDamagePopupRisePixels /
+			kCombatDebugDamagePopupDurationMs);
+		const Common::String popupText = Common::String::format("+%d", popupState.damageAmount);
+		const int textWidth = font->getStringWidth(popupText);
+		const int drawX = CLIP<int>(popupState.anchorPoint.x - textWidth / 2,
+			0, MAX(0, screen.w - textWidth));
+		const int drawY = CLIP<int>(
+			popupState.anchorPoint.y - font->getFontHeight() - rise,
+			0, MAX(0, screen.h - font->getFontHeight()));
+		drawShadowedRoomText(screen, *font, popupText, drawX, drawY, white, black);
+	}
+}
+
+bool RoomCombatEffects::pruneCombatHitEffects() {
+	bool removedAny = false;
+	for (uint i = 0; i < _hitEffectStates.size();) {
+		const Common::String effectName = _hitEffectStates[i].entityName;
+		Entity *effectEntity = _entityManager
+			? _entityManager->findSceneEntityByName(effectName)
+			: nullptr;
+		if (!effectEntity) {
+			_hitEffectStates.remove_at(i);
+			continue;
+		}
+
+		const bool finished =
+			!effectEntity->isAnimationEnabled() &&
+			effectEntity->getCurrentFrame() == effectEntity->getLastFrame();
+		if (finished && _hitEffectStates[i].finished) {
+			Entity *removedEntity = _entityManager->detachSceneEntityByName(effectName);
+			delete removedEntity;
+			_hitEffectStates.remove_at(i);
+			removedAny = true;
+			continue;
+		}
+
+		_hitEffectStates[i].finished = finished;
+		++i;
+	}
+
+	return removedAny;
+}
+
+bool RoomCombatEffects::pruneCombatDamagePopups() {
+	const uint32 now = Player::getRuntimeClockTicks();
+	bool removedAny = false;
+	for (uint i = 0; i < _damagePopupStates.size();) {
+		if (now - _damagePopupStates[i].startTick < kCombatDebugDamagePopupDurationMs) {
+			++i;
+			continue;
+		}
+
+		_damagePopupStates.remove_at(i);
+		removedAny = true;
+	}
+
+	return removedAny;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
index 833b47e58a3..45932372945 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/room_combat.h
@@ -27,6 +27,7 @@
 namespace Harvester {
 
 class Entity;
+class EntityManager;
 class HarvesterEngine;
 
 extern const int kRoomMonsterAnimationRate;
@@ -130,6 +131,29 @@ struct RoomCombatDamagePopupState {
 	int damageAmount = 0;
 };
 
+class RoomCombatEffects {
+public:
+	explicit RoomCombatEffects(EntityManager *entityManager);
+
+	void clearHitEffects();
+	bool hasDamagePopups() const { return !_damagePopupStates.empty(); }
+	bool spawnCombatHitEffect(Entity &sourceEntity, const Common::String &followTargetName);
+	void spawnCombatDamagePopup(Entity &targetEntity, const Common::String &followTargetName, int damageAmount);
+	bool syncCombatHitEffects();
+	void drawCombatDamagePopups(Graphics::Screen &screen, bool combatDebugEnabled);
+	bool pruneCombatHitEffects();
+	bool pruneCombatDamagePopups();
+
+private:
+	static void resolveHitEffectAnchor(const Entity &targetEntity, Common::Point &anchorPoint, float &renderZ);
+	static void resolveCombatDamagePopupAnchor(const Entity &targetEntity, Common::Point &anchorPoint);
+
+	EntityManager *_entityManager;
+	Common::Array<RoomHitEffectState> _hitEffectStates;
+	Common::Array<RoomCombatDamagePopupState> _damagePopupStates;
+	uint _nextCombatEffectId = 0;
+};
+
 struct CombatLoadoutHudInfo {
 	uint iconIndex = 0;
 	int maxVisibleCount = 0;
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index d432c77209f..48af1b68d5e 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -140,7 +140,8 @@ static bool sameSceneRegionState(const RegionRecord &lhs, const RegionRecord &rh
 
 RoomSession::RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
 		InventorySystem &inventory)
-	: _engine(engine), _flow(flow), _scene(scene), _entityManager(entityManager), _inventory(inventory) {
+	: _engine(engine), _flow(flow), _scene(scene), _entityManager(entityManager), _inventory(inventory),
+	  _combatEffects(entityManager) {
 }
 
 void RoomSession::resetIdleState() {
@@ -769,8 +770,7 @@ bool RoomSession::refreshCurrentScene(bool preservePlayerPlacement) {
 	refreshedState.audioCommands = Common::move(entryAudioCommands);
 	if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), _scene))
 		return false;
-	_hitEffectStates.clear();
-	_nextCombatEffectId = 0;
+	_combatEffects.clearHitEffects();
 	if (!_flow.populateRoomSceneEntities(_scene.state, _scene.sceneObjects, _scene.sceneAnimations))
 		return false;
 	if (_entityManager) {
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 5a8b496d49e..0da23e58e67 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -48,8 +48,7 @@ public:
 
 	RoomPlayerState &getPlayerState() { return _playerState; }
 	Common::Array<RoomMonsterCombatState> &getMonsterCombatStates() { return _monsterCombatStates; }
-	Common::Array<RoomHitEffectState> &getHitEffectStates() { return _hitEffectStates; }
-	uint &getNextCombatEffectId() { return _nextCombatEffectId; }
+	RoomCombatEffects &getCombatEffects() { return _combatEffects; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
 	RoomMovementInputState &getMovementInput() { return _movementInput; }
 	Common::String &getPendingRegionName() { return _pendingRegionName; }
@@ -91,8 +90,7 @@ private:
 	InventorySystem &_inventory;
 	RoomPlayerState _playerState;
 	Common::Array<RoomMonsterCombatState> _monsterCombatStates;
-	Common::Array<RoomHitEffectState> _hitEffectStates;
-	uint _nextCombatEffectId = 0;
+	RoomCombatEffects _combatEffects;
 	RoomIdleAnimationState _idleState;
 	RoomMovementInputState _movementInput;
 	Common::String _pendingRegionName;


Commit: f59e75bbdb89a49a18cf8efcaef2b99da67b4198
    https://github.com/scummvm/scummvm/commit/f59e75bbdb89a49a18cf8efcaef2b99da67b4198
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: Add RoomSceneRuntime class to manage scene entities and interactions

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/room_scene_runtime.cpp
  A engines/harvester/room_scene_runtime.h
    engines/harvester/flow.cpp
    engines/harvester/module.mk
    engines/harvester/room.cpp
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 29138acfb87..6c3fc4fe55b 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -43,6 +43,7 @@
 #include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
+#include "harvester/room_scene_runtime.h"
 #include "harvester/art.h"
 #include "harvester/monster.h"
 #include "harvester/player.h"
@@ -98,8 +99,6 @@ static const char *const kDemoIntroductionText =
 static const float kTownMapNightPaletteBrightness = 0.6f;
 // Native keeps WAIT_BM visible while room construction runs; fast local loads need a short floor.
 static const uint32 kTransitionWaitFrameMinMs = 120;
-static const int kRoomNpcAmbientLastFrame = 0x3b;
-static const float kNativeNpcMonsterZExtent = 5.0f;
 static const int kTownMapEdgeThreshold = 9;
 static const int kTownMapCursorHitExtent = 5;
 
@@ -742,41 +741,6 @@ static void logSceneObjectSelection(const char *decision, const char *source, co
 		object.actionTag.c_str(), detail.c_str());
 }
 
-static bool isBackgroundSceneObject(const RoomSetupState &state,
-		const ObjectRecord &object, const Entity &entity) {
-	if (!state.backgroundObjectName.empty() &&
-			object.objectName.equalsIgnoreCase(state.backgroundObjectName)) {
-		return true;
-	}
-
-	return object.initialX == 0 && object.initialY == 0 &&
-		entity.getBoundsWidth() == 640 && entity.getBoundsHeight() == 480;
-}
-
-static bool isInteractiveSceneHotspot(const ObjectRecord &object, Script *script) {
-	if (object.operatable || !object.actionTag.empty())
-		return true;
-	if (!script)
-		return false;
-	if (script->isPickupObject(object))
-		return true;
-
-	ResolvedText inspectText;
-	return script->resolveObjectInspectText(object, inspectText);
-}
-
-static int resolveSceneObjectClass(const RoomSetupState &state,
-		const ObjectRecord &object, const Entity *entity, Script *script) {
-	if (entity)
-		return isBackgroundSceneObject(state, object, *entity)
-			? kRuntimeEntityClassBackground
-			: kRuntimeEntityClassObject;
-
-	return isInteractiveSceneHotspot(object, script)
-		? kRuntimeEntityClassRectHotspot
-		: kRuntimeEntityClassDisabledHotspot;
-}
-
 static bool shouldQueueSceneObject(const ObjectRecord &object) {
 	return object.visible;
 }
@@ -2144,35 +2108,10 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 	if (!entityManager)
 		return false;
 
+	RoomSceneRuntime sceneRuntime(state, _engine.getScript(), _engine.getResources(), entityManager);
 	Entity *preservedPlayer = entityManager->detachSceneEntityByName(kPlayerActorEntityName);
 	entityManager->clearSceneEntities(true);
-	for (const RegionRecord &region : state.roomRegions) {
-		const Common::Rect regionBounds = getRegionBounds(region);
-		if (regionBounds.isEmpty())
-			continue;
-
-		Entity *entity = entityManager->spawnSceneHotspotEntity(
-			region.regionName, regionBounds, (float)region.minZ);
-		if (!entity) {
-			debugC(1, kDebugRoom,
-				"Harvester: scene region skipped room='%s' region='%s' bounds=(%d,%d)-(%d,%d) z=[%d,%d]",
-				state.roomName.c_str(), region.regionName.c_str(),
-				regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
-				region.minZ, region.maxZ);
-			continue;
-		}
-
-		entity->setClassId(kRuntimeEntityClassRectHotspot19);
-		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
-		entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
-		if (!region.cursorEnabled)
-			entity->setHitTestMode(kRuntimeEntityHitTestNone);
-		debugC(1, kDebugRoom,
-			"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
-			state.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
-			regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
-			region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
-	}
+	sceneRuntime.populateRoomRegions();
 	for (const TimerRecord &timer : state.roomTimers) {
 		Entity *existingTimer = entityManager->findSceneEntityByName(timer.timerName);
 		if (existingTimer && existingTimer->getClassId() == kRuntimeEntityClassTimer) {
@@ -2196,44 +2135,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 			state.roomName.c_str(), timer.timerName.c_str(), timer.currentValue, timer.initialValue,
 			timer.enabled, timer.looping, timer.global);
 	}
-	for (const ObjectRecord &object : drawableObjects) {
-		Entity *entity = nullptr;
-		const Common::String spritePath = resolveSceneObjectSpritePath(object);
-		const Common::Rect hotspotBounds = getRoomObjectHotspotBounds(object);
-		if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
-			entity = entityManager->spawnSceneBitmapEntity(object.objectName, spritePath,
-				Common::Point(object.currentX, object.currentY), (float)object.currentZ);
-		} else {
-			if (!hotspotBounds.isEmpty())
-				entity = entityManager->spawnSceneHotspotEntity(object.objectName, hotspotBounds, (float)object.currentZ);
-		}
-
-		if (!entity) {
-			debugC(1, kDebugRoom,
-				"Harvester: scene entity skipped room='%s' object='%s' resolved='%s' bounds=(%d,%d)-(%d,%d) reason='%s'",
-				state.roomName.c_str(), object.objectName.c_str(), spritePath.c_str(),
-				hotspotBounds.left, hotspotBounds.top, hotspotBounds.right, hotspotBounds.bottom,
-				spritePath.empty() || !spritePath.hasSuffixIgnoreCase(".BM")
-					? "no_bitmap_and_no_hotspot_bounds"
-					: "spawn_failed");
-			debug(1, "Harvester: unable to spawn room object entity '%s' from '%s'",
-				object.objectName.c_str(), spritePath.c_str());
-			continue;
-		}
-
-		entity->setClassId(resolveSceneObjectClass(
-			state, object, entity->hasFrames() ? entity : nullptr, _engine.getScript()));
-		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
-		entity->setZExtent((float)object.zExtent);
-		const Common::Rect entityRect = entity->getScreenRect();
-		debugC(1, kDebugRoom,
-			"Harvester: scene entity spawned room='%s' object='%s' type='%s' class=0x%x pos=(%d,%d,z=%.2f) rect=(%d,%d)-(%d,%d) sprite='%s' action='%s' operatable=%d ident='%s'",
-			state.roomName.c_str(), object.objectName.c_str(),
-			entity->hasFrames() ? "bitmap" : "hotspot", entity->getClassId(),
-			entity->getX(), entity->getY(), (double)entity->getZ(),
-			entityRect.left, entityRect.top, entityRect.right, entityRect.bottom,
-			spritePath.c_str(), object.actionTag.c_str(), object.operatable, object.identTextKey.c_str());
-	}
+	sceneRuntime.populateRoomObjects(drawableObjects);
 	for (const AnimRecord &anim : drawableAnimations) {
 		const bool displayVisible = shouldDisplaySceneAnimation(anim);
 		if (!entityManager->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
@@ -2243,76 +2145,8 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 				anim.animName.c_str(), anim.resourcePath.c_str());
 		}
 	}
-	for (const NpcRecord &npc : state.roomNpcs) {
-		Entity *entity = entityManager->spawnSceneActorEntity(npc.npcName,
-			npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
-		if (!entity) {
-			debug(1, "Harvester: unable to spawn room npc entity '%s' from '%s'",
-				npc.npcName.c_str(), npc.modelPath.c_str());
-			continue;
-		}
-
-		entity->setClassId(kRuntimeEntityClassNpc);
-		entity->setZExtent(kNativeNpcMonsterZExtent);
-		const bool isCorpse = !npc.active &&
-			npc.runtimeSpawned &&
-			npc.deathDamageType != 0 &&
-			npc.runtimeState >= 0;
-		entity->setHitTestMode(isCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
-		// Native spawn positions frame 0 before selecting ambient or corpse frame banks.
-		if (!applyRoomActorPlacementInternal(state, *entity,
-				npc.posX, npc.posY, (float)npc.posZ, nullptr, false)) {
-			debug(1, "Harvester: unable to apply room npc placement for '%s'",
-				npc.npcName.c_str());
-		}
-		if (isCorpse) {
-			const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
-			entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
-			entity->setAnimationRate(0);
-			entity->setCurrentFrame(corpseFrame);
-			entity->setAnimationEnabled(false);
-		} else {
-			// Native spawn_npc_entity_from_record seeds the passive room-NPC loop as frames 0..0x3b.
-			entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
-			if (npc.frameDelay > 0)
-				entity->setAnimationRate(npc.frameDelay);
-		}
-		entityManager->reinsertSceneEntity(entity);
-		debugC(1, kDebugRoom,
-			"Harvester: scene npc spawned room='%s' npc='%s' class=0x%x pos=(%d,%d,z=%.2f) frame_delay=%d model='%s' active=%d visible=%d",
-			state.roomName.c_str(), npc.npcName.c_str(), entity->getClassId(),
-			entity->getX(), entity->getY(), (double)entity->getZ(),
-			npc.frameDelay, npc.modelPath.c_str(), npc.active, npc.visible);
-	}
-	for (const MonsterRecord &monster : state.roomMonsters) {
-		Entity *entity = entityManager->spawnSceneActorEntity(monster.monsterName,
-			monster.modelPath, Common::Point(monster.posX, monster.posY), (float)monster.posZ,
-			Monster::resolveFacingFrame(monster.facing));
-		if (!entity) {
-			debug(1, "Harvester: unable to spawn room monster entity '%s' from '%s'",
-				monster.monsterName.c_str(), monster.modelPath.c_str());
-			continue;
-		}
-
-		entity->setClassId(kRuntimeEntityClassMonster);
-		entity->setZExtent(kNativeNpcMonsterZExtent);
-		entity->setHitTestMode(kRuntimeEntityHitTestNone);
-		entity->setVisible(monster.visible);
-		// Native restores a saved monster corpse frame before set_entity_screen_position().
-		Monster::applyAnimation(*entity, monster);
-		if (!applyRoomActorPlacement(state, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
-			debug(1, "Harvester: unable to apply room monster placement for '%s'",
-				monster.monsterName.c_str());
-		}
-		entityManager->reinsertSceneEntity(entity);
-		debugC(1, kDebugRoom,
-			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' active=%d visible=%d",
-			state.roomName.c_str(), monster.monsterName.c_str(), entity->getClassId(),
-			entity->getX(), entity->getY(), (double)entity->getZ(), monster.facing,
-			monster.currentHitPoints, monster.initialHitPoints,
-			monster.damageAmount, monster.engageDistance, Player::describeCombatDamageType(monster.damageType),
-			monster.modelPath.c_str(), monster.active, monster.visible);
-	}
+	sceneRuntime.populateRoomNpcs();
+	sceneRuntime.populateRoomMonsters();
 	if (state.hasEntrance) {
 		const int playerFrame = Player::resolveFacingFrame(state.playerFacing);
 		Script *script = _engine.getScript();
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index af951392791..c3cc27e9b8c 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -19,6 +19,7 @@ MODULE_OBJS = \
 	resources.o \
 	room_combat.o \
 	room_interaction.o \
+	room_scene_runtime.o \
 	room_session.o \
 	room.o \
 	runtime_entity.o \
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 82eac8f328d..3f0d3ecc157 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -436,6 +436,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		monsterCombatStates.resize(scene.state.roomMonsters.size());
 		Common::Array<RoomNpcCombatState> npcCombatStates;
 		npcCombatStates.resize(scene.state.roomNpcs.size());
+		RoomSceneRuntime &sceneRuntime = session.getSceneRuntime();
 		RoomCombatEffects &combatEffects = session.getCombatEffects();
 		ResolvedText inspectText;
 		Common::String currentInteractionPromptText;
@@ -1373,7 +1374,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			npcEntity->setHitTestMode(kRuntimeEntityHitTestNone);
 			npcEntity->setVisible(true);
 		} else {
-			session.removeSceneEntityByName(npc.npcName);
+			sceneRuntime.removeSceneEntityByName(npc.npcName);
 		}
 		npc.active = false;
 		npc.visible = false;
@@ -2274,7 +2275,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		script->addRuntimeObjectToInventory(object.objectName);
 		const Common::String inventoryOwner(kInventoryOwnerName);
 		hideSceneObject(object.objectName, &inventoryOwner);
-		session.removeSceneEntityByName(object.objectName);
+		sceneRuntime.removeSceneEntityByName(object.objectName);
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
@@ -2320,7 +2321,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		script->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
 		hideSceneObject(object.objectName, nullptr);
-		session.removeSceneEntityByName(object.objectName);
+		sceneRuntime.removeSceneEntityByName(object.objectName);
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnCarryStart(object)) {
diff --git a/engines/harvester/room_scene_runtime.cpp b/engines/harvester/room_scene_runtime.cpp
new file mode 100644
index 00000000000..cbfdd79ba87
--- /dev/null
+++ b/engines/harvester/room_scene_runtime.cpp
@@ -0,0 +1,428 @@
+/* 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 "harvester/room_scene_runtime.h"
+
+#include "harvester/detection.h"
+#include "harvester/monster.h"
+#include "harvester/player.h"
+#include "harvester/room_combat.h"
+
+namespace Harvester {
+
+static const char *const kInventoryOwnerName = "INVENTORY";
+
+static bool isBackgroundSceneObject(const RoomSetupState &state,
+		const ObjectRecord &object, const Entity &entity) {
+	if (!state.backgroundObjectName.empty() &&
+			object.objectName.equalsIgnoreCase(state.backgroundObjectName)) {
+		return true;
+	}
+
+	return object.initialX == 0 && object.initialY == 0 &&
+		entity.getBoundsWidth() == 640 && entity.getBoundsHeight() == 480;
+}
+
+static bool isInteractiveSceneHotspot(const ObjectRecord &object, Script *script) {
+	if (object.operatable || !object.actionTag.empty())
+		return true;
+	if (!script)
+		return false;
+	if (script->isPickupObject(object))
+		return true;
+
+	ResolvedText inspectText;
+	return script->resolveObjectInspectText(object, inspectText);
+}
+
+static int resolveSceneObjectClass(const RoomSetupState &state,
+		const ObjectRecord &object, const Entity *entity, Script *script) {
+	if (entity)
+		return isBackgroundSceneObject(state, object, *entity)
+			? kRuntimeEntityClassBackground
+			: kRuntimeEntityClassObject;
+
+	return isInteractiveSceneHotspot(object, script)
+		? kRuntimeEntityClassRectHotspot
+		: kRuntimeEntityClassDisabledHotspot;
+}
+
+RoomSceneRuntime::RoomSceneRuntime(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager)
+	: _state(state), _script(script), _resources(resources), _entityManager(entityManager) {
+	debugC(1, kDebugRoom, "Harvester: created room scene runtime room='%s' entities=%d",
+		_state.roomName.c_str(), _entityManager != nullptr);
+}
+
+bool RoomSceneRuntime::loadSceneResources(const RoomSetupState &state, RoomSceneResources &scene) const {
+	return _resources && loadRoomSceneResources(state, *_resources, scene);
+}
+
+void RoomSceneRuntime::removeSceneEntityByName(const Common::String &entityName) {
+	if (!_entityManager)
+		return;
+
+	Entity *entity = _entityManager->detachSceneEntityByName(entityName);
+	delete entity;
+}
+
+Common::Rect RoomSceneRuntime::getSceneObjectBounds(const ObjectRecord &object) {
+	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
+		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
+	return Common::Rect();
+}
+
+Common::String RoomSceneRuntime::resolveSceneObjectSpritePathLocal(const ObjectRecord &object) {
+	const bool atInitialPlacement = object.currentX == object.initialX &&
+		object.currentY == object.initialY &&
+		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
+	if (!object.altSpritePath.empty() &&
+			(!atInitialPlacement || object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)))
+		return object.altSpritePath;
+	return object.spritePath;
+}
+
+bool RoomSceneRuntime::isInteractiveSceneHotspot(const ObjectRecord &object) const {
+	if (object.operatable || !object.actionTag.empty())
+		return true;
+
+	Script *script = _script;
+	if (!script)
+		return false;
+	if (script->isPickupObject(object))
+		return true;
+
+	ResolvedText inspectText;
+	return script->resolveObjectInspectText(object, inspectText);
+}
+
+RuntimeEntityClass RoomSceneRuntime::resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const {
+	if (entity) {
+		return ((!_state.backgroundObjectName.empty() &&
+				object.objectName.equalsIgnoreCase(_state.backgroundObjectName)) ||
+			(object.initialX == 0 && object.initialY == 0 &&
+				entity->getBoundsWidth() == 640 && entity->getBoundsHeight() == 480))
+			? kRuntimeEntityClassBackground
+			: kRuntimeEntityClassObject;
+	}
+
+	return isInteractiveSceneHotspot(object)
+		? kRuntimeEntityClassRectHotspot
+		: kRuntimeEntityClassDisabledHotspot;
+}
+
+void RoomSceneRuntime::spawnSceneObjectEntityFromRecord(const ObjectRecord &object) {
+	if (!_entityManager)
+		return;
+
+	const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
+	const Common::Rect hotspotBounds = getSceneObjectBounds(object);
+	Entity *entity = nullptr;
+	if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
+		entity = _entityManager->spawnSceneBitmapEntity(object.objectName, spritePath,
+			Common::Point(object.currentX, object.currentY), (float)object.currentZ);
+	} else if (!hotspotBounds.isEmpty()) {
+		entity = _entityManager->spawnSceneHotspotEntity(object.objectName, hotspotBounds,
+			(float)object.currentZ);
+	}
+	if (!entity)
+		return;
+
+	entity->setClassId(resolveSceneObjectClassLocal(
+		object, entity->hasFrames() ? entity : nullptr));
+	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity->setZExtent((float)object.zExtent);
+	debugC(3, kDebugRoom, "Harvester: scene object spawned object='%s' sprite='%s' class=0x%x",
+		object.objectName.c_str(), spritePath.c_str(), entity->getClassId());
+}
+
+Common::Rect RoomSceneRuntime::getSceneRegionBounds(const RegionRecord &region) {
+	if (region.right > region.left && region.bottom > region.top)
+		return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
+	return Common::Rect();
+}
+
+void RoomSceneRuntime::spawnSceneRegionEntityFromRecord(const RegionRecord &region) {
+	if (!_entityManager)
+		return;
+
+	const Common::Rect bounds = getSceneRegionBounds(region);
+	if (bounds.isEmpty())
+		return;
+
+	Entity *entity = _entityManager->spawnSceneHotspotEntity(
+		region.regionName, bounds, (float)region.minZ);
+	if (!entity)
+		return;
+
+	entity->setClassId(kRuntimeEntityClassRectHotspot19);
+	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
+	if (!region.cursorEnabled)
+		entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	debugC(1, kDebugRoom,
+		"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
+		region.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
+		bounds.left, bounds.top, bounds.right, bounds.bottom,
+		region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
+}
+
+bool RoomSceneRuntime::shouldSpawnRoomNpcEntity(const NpcRecord &npc) {
+	return npc.visible && !npc.deathOrMonsterfyFlag;
+}
+
+bool RoomSceneRuntime::shouldSpawnRoomMonsterEntity(const MonsterRecord &monster) {
+	return monster.visible;
+}
+
+bool RoomSceneRuntime::applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc) {
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
+		return false;
+
+	const float renderZ =
+		(float)npc.posZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
+	entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity.setPosition(npc.posX - xOffset - width / 2, npc.posY - height - yOffset, renderZ);
+	return true;
+}
+
+Entity *RoomSceneRuntime::spawnSceneNpcEntityFromRecord(const NpcRecord &npc) {
+	const bool shouldSpawnNpc = shouldSpawnRoomNpcEntity(npc);
+	const bool preservedCorpse = !npc.active &&
+		npc.runtimeSpawned &&
+		npc.deathDamageType != 0 &&
+		npc.runtimeState >= 0;
+	if (!_entityManager || !shouldSpawnNpc)
+		return nullptr;
+
+	Entity *entity = _entityManager->spawnSceneActorEntity(
+		npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
+	if (!entity)
+		return nullptr;
+
+	entity->setClassId(kRuntimeEntityClassNpc);
+	entity->setZExtent(kNativeNpcMonsterZExtent);
+	entity->setHitTestMode(preservedCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
+	entity->setVisible(true);
+	if (!applyRoomNpcPlacement(*entity, npc)) {
+		removeSceneEntityByName(npc.npcName);
+		return nullptr;
+	}
+	if (preservedCorpse) {
+		const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
+		entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
+		entity->setAnimationRate(0);
+		entity->setCurrentFrame(corpseFrame);
+		entity->setAnimationEnabled(false);
+	} else {
+		entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
+		entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+	}
+	_entityManager->reinsertSceneEntity(entity);
+	return entity;
+}
+
+Entity *RoomSceneRuntime::spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster) {
+	if (!_entityManager || !shouldSpawnRoomMonsterEntity(monster))
+		return nullptr;
+
+	Entity *entity = _entityManager->spawnSceneActorEntity(
+		monster.monsterName, monster.modelPath,
+		Common::Point(monster.posX, monster.posY), (float)monster.posZ,
+		Monster::resolveFacingFrame(monster.facing));
+	if (!entity)
+		return nullptr;
+
+	entity->setClassId(kRuntimeEntityClassMonster);
+	entity->setZExtent(kNativeNpcMonsterZExtent);
+	entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	entity->setVisible(monster.visible);
+	Monster::applyAnimation(*entity, monster);
+	if (!applyRoomActorPlacement(roomState, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
+		removeSceneEntityByName(monster.monsterName);
+		return nullptr;
+	}
+	_entityManager->reinsertSceneEntity(entity);
+	return entity;
+}
+
+void RoomSceneRuntime::populateRoomRegions() {
+	if (!_entityManager)
+		return;
+
+	for (const RegionRecord &region : _state.roomRegions) {
+		const Common::Rect regionBounds = getSceneRegionBounds(region);
+		if (regionBounds.isEmpty())
+			continue;
+
+		Entity *entity = _entityManager->spawnSceneHotspotEntity(
+			region.regionName, regionBounds, (float)region.minZ);
+		if (!entity) {
+			debugC(1, kDebugRoom,
+				"Harvester: scene region skipped room='%s' region='%s' bounds=(%d,%d)-(%d,%d) z=[%d,%d]",
+				_state.roomName.c_str(), region.regionName.c_str(),
+				regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
+				region.minZ, region.maxZ);
+			continue;
+		}
+
+		entity->setClassId(kRuntimeEntityClassRectHotspot19);
+		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+		entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
+		if (!region.cursorEnabled)
+			entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		debugC(1, kDebugRoom,
+			"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
+			_state.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
+			regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
+			region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
+	}
+}
+
+void RoomSceneRuntime::populateRoomObjects(const Common::Array<ObjectRecord> &drawableObjects) {
+	if (!_entityManager)
+		return;
+
+	for (const ObjectRecord &object : drawableObjects) {
+		Entity *entity = nullptr;
+		const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
+		const Common::Rect hotspotBounds = getRoomObjectHotspotBounds(object);
+		if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
+			entity = _entityManager->spawnSceneBitmapEntity(object.objectName, spritePath,
+				Common::Point(object.currentX, object.currentY), (float)object.currentZ);
+		} else {
+			if (!hotspotBounds.isEmpty())
+				entity = _entityManager->spawnSceneHotspotEntity(object.objectName, hotspotBounds, (float)object.currentZ);
+		}
+
+		if (!entity) {
+			debugC(1, kDebugRoom,
+				"Harvester: scene entity skipped room='%s' object='%s' resolved='%s' bounds=(%d,%d)-(%d,%d) reason='%s'",
+				_state.roomName.c_str(), object.objectName.c_str(), spritePath.c_str(),
+				hotspotBounds.left, hotspotBounds.top, hotspotBounds.right, hotspotBounds.bottom,
+				spritePath.empty() || !spritePath.hasSuffixIgnoreCase(".BM")
+					? "no_bitmap_and_no_hotspot_bounds"
+					: "spawn_failed");
+			debug(1, "Harvester: unable to spawn room object entity '%s' from '%s'",
+				object.objectName.c_str(), spritePath.c_str());
+			continue;
+		}
+
+		entity->setClassId(resolveSceneObjectClass(
+			_state, object, entity->hasFrames() ? entity : nullptr, _script));
+		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+		entity->setZExtent((float)object.zExtent);
+		const Common::Rect entityRect = entity->getScreenRect();
+		debugC(1, kDebugRoom,
+			"Harvester: scene entity spawned room='%s' object='%s' type='%s' class=0x%x pos=(%d,%d,z=%.2f) rect=(%d,%d)-(%d,%d) sprite='%s' action='%s' operatable=%d ident='%s'",
+			_state.roomName.c_str(), object.objectName.c_str(),
+			entity->hasFrames() ? "bitmap" : "hotspot", entity->getClassId(),
+			entity->getX(), entity->getY(), (double)entity->getZ(),
+			entityRect.left, entityRect.top, entityRect.right, entityRect.bottom,
+			spritePath.c_str(), object.actionTag.c_str(), object.operatable, object.identTextKey.c_str());
+	}
+}
+
+void RoomSceneRuntime::populateRoomNpcs() {
+	if (!_entityManager)
+		return;
+
+	for (const NpcRecord &npc : _state.roomNpcs) {
+		Entity *entity = _entityManager->spawnSceneActorEntity(npc.npcName,
+			npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
+		if (!entity) {
+			debug(1, "Harvester: unable to spawn room npc entity '%s' from '%s'",
+				npc.npcName.c_str(), npc.modelPath.c_str());
+			continue;
+		}
+
+		entity->setClassId(kRuntimeEntityClassNpc);
+		entity->setZExtent(kNativeNpcMonsterZExtent);
+		const bool isCorpse = !npc.active &&
+			npc.runtimeSpawned &&
+			npc.deathDamageType != 0 &&
+			npc.runtimeState >= 0;
+		entity->setHitTestMode(isCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
+		// Native spawn positions frame 0 before selecting ambient or corpse frame banks.
+		if (!applyRoomNpcPlacement(*entity, npc)) {
+			debug(1, "Harvester: unable to apply room npc placement for '%s'",
+				npc.npcName.c_str());
+		}
+		if (isCorpse) {
+			const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
+			entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
+			entity->setAnimationRate(0);
+			entity->setCurrentFrame(corpseFrame);
+			entity->setAnimationEnabled(false);
+		} else {
+			// Native spawn_npc_entity_from_record seeds the passive room-NPC loop as frames 0..0x3b.
+			entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
+			if (npc.frameDelay > 0)
+				entity->setAnimationRate(npc.frameDelay);
+		}
+		_entityManager->reinsertSceneEntity(entity);
+		debugC(1, kDebugRoom,
+			"Harvester: scene npc spawned room='%s' npc='%s' class=0x%x pos=(%d,%d,z=%.2f) frame_delay=%d model='%s' active=%d visible=%d",
+			_state.roomName.c_str(), npc.npcName.c_str(), entity->getClassId(),
+			entity->getX(), entity->getY(), (double)entity->getZ(),
+			npc.frameDelay, npc.modelPath.c_str(), npc.active, npc.visible);
+	}
+}
+
+void RoomSceneRuntime::populateRoomMonsters() {
+	if (!_entityManager)
+		return;
+
+	for (const MonsterRecord &monster : _state.roomMonsters) {
+		Entity *entity = _entityManager->spawnSceneActorEntity(monster.monsterName,
+			monster.modelPath, Common::Point(monster.posX, monster.posY), (float)monster.posZ,
+			Monster::resolveFacingFrame(monster.facing));
+		if (!entity) {
+			debug(1, "Harvester: unable to spawn room monster entity '%s' from '%s'",
+				monster.monsterName.c_str(), monster.modelPath.c_str());
+			continue;
+		}
+
+		entity->setClassId(kRuntimeEntityClassMonster);
+		entity->setZExtent(kNativeNpcMonsterZExtent);
+		entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		entity->setVisible(monster.visible);
+		// Native restores a saved monster corpse frame before set_entity_screen_position().
+		Monster::applyAnimation(*entity, monster);
+		if (!applyRoomActorPlacement(_state, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
+			debug(1, "Harvester: unable to apply room monster placement for '%s'",
+				monster.monsterName.c_str());
+		}
+		_entityManager->reinsertSceneEntity(entity);
+		debugC(1, kDebugRoom,
+			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' active=%d visible=%d",
+			_state.roomName.c_str(), monster.monsterName.c_str(), entity->getClassId(),
+			entity->getX(), entity->getY(), (double)entity->getZ(), monster.facing,
+			monster.currentHitPoints, monster.initialHitPoints,
+			monster.damageAmount, monster.engageDistance, Player::describeCombatDamageType(monster.damageType),
+			monster.modelPath.c_str(), monster.active, monster.visible);
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/room_scene_runtime.h b/engines/harvester/room_scene_runtime.h
new file mode 100644
index 00000000000..ad997302134
--- /dev/null
+++ b/engines/harvester/room_scene_runtime.h
@@ -0,0 +1,66 @@
+/* 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 HARVESTER_ROOM_SCENE_RUNTIME_H
+#define HARVESTER_ROOM_SCENE_RUNTIME_H
+
+#include "harvester/room_support.h"
+#include "harvester/runtime_entity.h"
+
+namespace Harvester {
+
+// Borrows the materialized room state; live entities remain owned by EntityManager.
+class RoomSceneRuntime {
+public:
+	RoomSceneRuntime(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager);
+
+	bool loadSceneResources(const RoomSetupState &state, RoomSceneResources &scene) const;
+	// Initial population skips failed spawns; in-place callers handle null actor results.
+	void populateRoomRegions();
+	void populateRoomObjects(const Common::Array<ObjectRecord> &drawableObjects);
+	void populateRoomNpcs();
+	void populateRoomMonsters();
+
+	void removeSceneEntityByName(const Common::String &entityName);
+	static Common::Rect getSceneObjectBounds(const ObjectRecord &object);
+	static Common::String resolveSceneObjectSpritePathLocal(const ObjectRecord &object);
+	void spawnSceneObjectEntityFromRecord(const ObjectRecord &object);
+	void spawnSceneRegionEntityFromRecord(const RegionRecord &region);
+	static bool shouldSpawnRoomNpcEntity(const NpcRecord &npc);
+	static bool shouldSpawnRoomMonsterEntity(const MonsterRecord &monster);
+	Entity *spawnSceneNpcEntityFromRecord(const NpcRecord &npc);
+	Entity *spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster);
+
+private:
+	bool isInteractiveSceneHotspot(const ObjectRecord &object) const;
+	RuntimeEntityClass resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const;
+	static Common::Rect getSceneRegionBounds(const RegionRecord &region);
+	static bool applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc);
+
+	RoomSetupState &_state;
+	Script *_script;
+	ResourceManager *_resources;
+	EntityManager *_entityManager;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_ROOM_SCENE_RUNTIME_H
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index 48af1b68d5e..e47bec38136 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -32,8 +32,6 @@
 
 namespace Harvester {
 
-static const char *const kInventoryOwnerName = "INVENTORY";
-
 static bool sameRect(const Common::Rect &lhs, const Common::Rect &rhs) {
 	return lhs.left == rhs.left && lhs.top == rhs.top &&
 		lhs.right == rhs.right && lhs.bottom == rhs.bottom;
@@ -121,8 +119,8 @@ static bool sameSceneObjectState(const ObjectRecord &lhs, const ObjectRecord &rh
 		lhs.identTextKey.equalsIgnoreCase(rhs.identTextKey) &&
 		lhs.interactionLabel.equalsIgnoreCase(rhs.interactionLabel) &&
 		lhs.currentOwnerOrRoom.equalsIgnoreCase(rhs.currentOwnerOrRoom) &&
-		RoomSession::resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(RoomSession::resolveSceneObjectSpritePathLocal(rhs)) &&
-		sameRect(RoomSession::getSceneObjectBounds(lhs), RoomSession::getSceneObjectBounds(rhs));
+		RoomSceneRuntime::resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(RoomSceneRuntime::resolveSceneObjectSpritePathLocal(rhs)) &&
+		sameRect(RoomSceneRuntime::getSceneObjectBounds(lhs), RoomSceneRuntime::getSceneObjectBounds(rhs));
 }
 
 static bool sameSceneRegionState(const RegionRecord &lhs, const RegionRecord &rhs) {
@@ -141,7 +139,7 @@ static bool sameSceneRegionState(const RegionRecord &lhs, const RegionRecord &rh
 RoomSession::RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
 		InventorySystem &inventory)
 	: _engine(engine), _flow(flow), _scene(scene), _entityManager(entityManager), _inventory(inventory),
-	  _combatEffects(entityManager) {
+	  _sceneRuntime(scene.state, engine.getScript(), engine.getResources(), entityManager), _combatEffects(entityManager) {
 }
 
 void RoomSession::resetIdleState() {
@@ -308,198 +306,6 @@ void RoomSession::syncGlobalTimerEntities(const Common::Array<TimerRecord> &prev
 	}
 }
 
-void RoomSession::removeSceneEntityByName(const Common::String &entityName) {
-	if (!_entityManager)
-		return;
-
-	Entity *entity = _entityManager->detachSceneEntityByName(entityName);
-	delete entity;
-}
-
-Common::Rect RoomSession::getSceneObjectBounds(const ObjectRecord &object) {
-	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
-		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
-	return Common::Rect();
-}
-
-Common::String RoomSession::resolveSceneObjectSpritePathLocal(const ObjectRecord &object) {
-	const bool atInitialPlacement = object.currentX == object.initialX &&
-		object.currentY == object.initialY &&
-		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
-	if (!object.altSpritePath.empty() &&
-			(!atInitialPlacement || object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)))
-		return object.altSpritePath;
-	return object.spritePath;
-}
-
-bool RoomSession::isInteractiveSceneHotspot(const ObjectRecord &object) const {
-	if (object.operatable || !object.actionTag.empty())
-		return true;
-
-	Script *script = _engine.getScript();
-	if (!script)
-		return false;
-	if (script->isPickupObject(object))
-		return true;
-
-	ResolvedText inspectText;
-	return script->resolveObjectInspectText(object, inspectText);
-}
-
-RuntimeEntityClass RoomSession::resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const {
-	if (entity) {
-		return ((!_scene.state.backgroundObjectName.empty() &&
-				object.objectName.equalsIgnoreCase(_scene.state.backgroundObjectName)) ||
-			(object.initialX == 0 && object.initialY == 0 &&
-				entity->getBoundsWidth() == 640 && entity->getBoundsHeight() == 480))
-			? kRuntimeEntityClassBackground
-			: kRuntimeEntityClassObject;
-	}
-
-	return isInteractiveSceneHotspot(object)
-		? kRuntimeEntityClassRectHotspot
-		: kRuntimeEntityClassDisabledHotspot;
-}
-
-void RoomSession::spawnSceneObjectEntityFromRecord(const ObjectRecord &object) {
-	if (!_entityManager)
-		return;
-
-	const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
-	const Common::Rect hotspotBounds = getSceneObjectBounds(object);
-	Entity *entity = nullptr;
-	if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
-		entity = _entityManager->spawnSceneBitmapEntity(object.objectName, spritePath,
-			Common::Point(object.currentX, object.currentY), (float)object.currentZ);
-	} else if (!hotspotBounds.isEmpty()) {
-		entity = _entityManager->spawnSceneHotspotEntity(object.objectName, hotspotBounds,
-			(float)object.currentZ);
-	}
-	if (!entity)
-		return;
-
-	entity->setClassId(resolveSceneObjectClassLocal(
-		object, entity->hasFrames() ? entity : nullptr));
-	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
-	entity->setZExtent((float)object.zExtent);
-	debugC(3, kDebugRoom, "Harvester: scene object spawned object='%s' sprite='%s' class=0x%x",
-		object.objectName.c_str(), spritePath.c_str(), entity->getClassId());
-}
-
-Common::Rect RoomSession::getSceneRegionBounds(const RegionRecord &region) {
-	if (region.right > region.left && region.bottom > region.top)
-		return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
-	return Common::Rect();
-}
-
-void RoomSession::spawnSceneRegionEntityFromRecord(const RegionRecord &region) {
-	if (!_entityManager)
-		return;
-
-	const Common::Rect bounds = getSceneRegionBounds(region);
-	if (bounds.isEmpty())
-		return;
-
-	Entity *entity = _entityManager->spawnSceneHotspotEntity(
-		region.regionName, bounds, (float)region.minZ);
-	if (!entity)
-		return;
-
-	entity->setClassId(kRuntimeEntityClassRectHotspot19);
-	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
-	entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
-	if (!region.cursorEnabled)
-		entity->setHitTestMode(kRuntimeEntityHitTestNone);
-	debugC(1, kDebugRoom,
-		"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
-		region.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
-		bounds.left, bounds.top, bounds.right, bounds.bottom,
-		region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
-}
-
-bool RoomSession::shouldSpawnRoomNpcEntity(const NpcRecord &npc) {
-	return npc.visible && !npc.deathOrMonsterfyFlag;
-}
-
-bool RoomSession::shouldSpawnRoomMonsterEntity(const MonsterRecord &monster) {
-	return monster.visible;
-}
-
-bool RoomSession::applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc) {
-	int width = 0;
-	int height = 0;
-	int xOffset = 0;
-	int yOffset = 0;
-	if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
-		return false;
-
-	const float renderZ =
-		(float)npc.posZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
-	entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
-	entity.setPosition(npc.posX - xOffset - width / 2, npc.posY - height - yOffset, renderZ);
-	return true;
-}
-
-Entity *RoomSession::spawnSceneNpcEntityFromRecord(const NpcRecord &npc) {
-	const bool shouldSpawnNpc = shouldSpawnRoomNpcEntity(npc);
-	const bool preservedCorpse = !npc.active &&
-		npc.runtimeSpawned &&
-		npc.deathDamageType != 0 &&
-		npc.runtimeState >= 0;
-	if (!_entityManager || !shouldSpawnNpc)
-		return nullptr;
-
-	Entity *entity = _entityManager->spawnSceneActorEntity(
-		npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
-	if (!entity)
-		return nullptr;
-
-	entity->setClassId(kRuntimeEntityClassNpc);
-	entity->setZExtent(kNativeNpcMonsterZExtent);
-	entity->setHitTestMode(preservedCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
-	entity->setVisible(true);
-	if (!applyRoomNpcPlacement(*entity, npc)) {
-		removeSceneEntityByName(npc.npcName);
-		return nullptr;
-	}
-	if (preservedCorpse) {
-		const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
-		entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
-		entity->setAnimationRate(0);
-		entity->setCurrentFrame(corpseFrame);
-		entity->setAnimationEnabled(false);
-	} else {
-		entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
-		entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
-	}
-	_entityManager->reinsertSceneEntity(entity);
-	return entity;
-}
-
-Entity *RoomSession::spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster) {
-	if (!_entityManager || !shouldSpawnRoomMonsterEntity(monster))
-		return nullptr;
-
-	Entity *entity = _entityManager->spawnSceneActorEntity(
-		monster.monsterName, monster.modelPath,
-		Common::Point(monster.posX, monster.posY), (float)monster.posZ,
-		Monster::resolveFacingFrame(monster.facing));
-	if (!entity)
-		return nullptr;
-
-	entity->setClassId(kRuntimeEntityClassMonster);
-	entity->setZExtent(kNativeNpcMonsterZExtent);
-	entity->setHitTestMode(kRuntimeEntityHitTestNone);
-	entity->setVisible(monster.visible);
-	Monster::applyAnimation(*entity, monster);
-	if (!applyRoomActorPlacement(roomState, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
-		removeSceneEntityByName(monster.monsterName);
-		return nullptr;
-	}
-	_entityManager->reinsertSceneEntity(entity);
-	return entity;
-}
-
 bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) {
 	Script *script = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
@@ -520,7 +326,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 	updatedState.audioCommands = Common::move(entryAudioCommands);
 
 	RoomSceneResources updatedScene;
-	if (!loadRoomSceneResources(updatedState, *resources, updatedScene))
+	if (!_sceneRuntime.loadSceneResources(updatedState, updatedScene))
 		return false;
 	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates;
 	updatedMonsterCombatStates.resize(updatedState.roomMonsters.size());
@@ -539,7 +345,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 
 	for (const ObjectRecord &object : _scene.sceneObjects) {
 		if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
-			removeSceneEntityByName(object.objectName);
+			_sceneRuntime.removeSceneEntityByName(object.objectName);
 	}
 	for (const ObjectRecord &object : updatedScene.sceneObjects) {
 		const ObjectRecord *previous = findSceneObjectByName(_scene.sceneObjects, object.objectName);
@@ -547,13 +353,13 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 		if (!changed)
 			continue;
 
-		removeSceneEntityByName(object.objectName);
-		spawnSceneObjectEntityFromRecord(object);
+		_sceneRuntime.removeSceneEntityByName(object.objectName);
+		_sceneRuntime.spawnSceneObjectEntityFromRecord(object);
 	}
 
 	for (const RegionRecord &region : _scene.sceneRegions) {
 		if (!findSceneRegionByName(updatedScene.sceneRegions, region.regionName))
-			removeSceneEntityByName(region.regionName);
+			_sceneRuntime.removeSceneEntityByName(region.regionName);
 	}
 	for (const RegionRecord &region : updatedScene.sceneRegions) {
 		const RegionRecord *previous =
@@ -562,8 +368,8 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 		if (!changed)
 			continue;
 
-		removeSceneEntityByName(region.regionName);
-		spawnSceneRegionEntityFromRecord(region);
+		_sceneRuntime.removeSceneEntityByName(region.regionName);
+		_sceneRuntime.spawnSceneRegionEntityFromRecord(region);
 	}
 
 	for (const TimerRecord &timer : _scene.state.roomTimers) {
@@ -575,7 +381,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 			}
 		}
 		if (!found)
-			removeSceneEntityByName(timer.timerName);
+			_sceneRuntime.removeSceneEntityByName(timer.timerName);
 	}
 	for (const TimerRecord &timer : updatedState.roomTimers) {
 		const TimerRecord *previous = nullptr;
@@ -594,7 +400,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 		if (!changed)
 			continue;
 
-		removeSceneEntityByName(timer.timerName);
+		_sceneRuntime.removeSceneEntityByName(timer.timerName);
 		if (_entityManager) {
 			(void)_entityManager->spawnSceneTimerEntity(
 				timer.timerName, timer.initialValue, timer.currentValue,
@@ -615,7 +421,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 			? _entityManager->findSceneEntityByName(anim.animName)
 			: nullptr;
 		if (!isDisplayed) {
-			removeSceneEntityByName(anim.animName);
+			_sceneRuntime.removeSceneEntityByName(anim.animName);
 			continue;
 		}
 
@@ -629,7 +435,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 			previous->pingPong != anim.pingPong ||
 			previous->backward != anim.backward;
 		if (needsRespawn) {
-			removeSceneEntityByName(anim.animName);
+			_sceneRuntime.removeSceneEntityByName(anim.animName);
 			if (_entityManager) {
 				entity = _entityManager->spawnSceneAnimationEntity(
 					anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
@@ -656,7 +462,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 
 	for (const AnimRecord &anim : _scene.state.roomAnimations) {
 		if (!findRoomAnimByNameConst(updatedState.roomAnimations, anim.animName))
-			removeSceneEntityByName(anim.animName);
+			_sceneRuntime.removeSceneEntityByName(anim.animName);
 	}
 
 	for (const NpcRecord &npc : _scene.state.roomNpcs) {
@@ -669,7 +475,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 			_entityManager->findSceneEntityByName(npc.npcName);
 		const bool keepEntity =
 			updatedNpc &&
-			shouldSpawnRoomNpcEntity(*updatedNpc) &&
+			_sceneRuntime.shouldSpawnRoomNpcEntity(*updatedNpc) &&
 			(sameNpcEntityState(npc, *updatedNpc) ||
 				isQueuedLiveNpcDeathMutation(npc, *updatedNpc));
 		if (keepCurrentRoomDeathEntity) {
@@ -679,10 +485,10 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 			continue;
 		}
 		if (!keepEntity)
-			removeSceneEntityByName(npc.npcName);
+			_sceneRuntime.removeSceneEntityByName(npc.npcName);
 	}
 	for (const NpcRecord &npc : updatedState.roomNpcs) {
-		if (!shouldSpawnRoomNpcEntity(npc))
+		if (!_sceneRuntime.shouldSpawnRoomNpcEntity(npc))
 			continue;
 
 		Entity *entity = _entityManager
@@ -701,8 +507,8 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 		if (!needsRespawn)
 			continue;
 
-		removeSceneEntityByName(npc.npcName);
-		if (!spawnSceneNpcEntityFromRecord(npc))
+		_sceneRuntime.removeSceneEntityByName(npc.npcName);
+		if (!_sceneRuntime.spawnSceneNpcEntityFromRecord(npc))
 			return false;
 	}
 
@@ -711,13 +517,13 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 			findRoomMonsterByNameConst(updatedState.roomMonsters, monster.monsterName);
 		const bool keepEntity =
 			updatedMonster &&
-			shouldSpawnRoomMonsterEntity(*updatedMonster) &&
+			_sceneRuntime.shouldSpawnRoomMonsterEntity(*updatedMonster) &&
 			sameMonsterEntityState(monster, *updatedMonster);
 		if (!keepEntity)
-			removeSceneEntityByName(monster.monsterName);
+			_sceneRuntime.removeSceneEntityByName(monster.monsterName);
 	}
 	for (const MonsterRecord &monster : updatedState.roomMonsters) {
-		if (!shouldSpawnRoomMonsterEntity(monster))
+		if (!_sceneRuntime.shouldSpawnRoomMonsterEntity(monster))
 			continue;
 
 		Entity *entity = _entityManager
@@ -730,8 +536,8 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 		if (!needsRespawn)
 			continue;
 
-		removeSceneEntityByName(monster.monsterName);
-		if (!spawnSceneMonsterEntityFromRecord(updatedState, monster))
+		_sceneRuntime.removeSceneEntityByName(monster.monsterName);
+		if (!_sceneRuntime.spawnSceneMonsterEntityFromRecord(updatedState, monster))
 			return false;
 	}
 
@@ -768,7 +574,7 @@ bool RoomSession::refreshCurrentScene(bool preservePlayerPlacement) {
 	}
 
 	refreshedState.audioCommands = Common::move(entryAudioCommands);
-	if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), _scene))
+	if (!_sceneRuntime.loadSceneResources(refreshedState, _scene))
 		return false;
 	_combatEffects.clearHitEffects();
 	if (!_flow.populateRoomSceneEntities(_scene.state, _scene.sceneObjects, _scene.sceneAnimations))
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 0da23e58e67..72a7d17c6df 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -23,6 +23,7 @@
 #define HARVESTER_ROOM_SESSION_H
 
 #include "harvester/room_combat.h"
+#include "harvester/room_scene_runtime.h"
 #include "harvester/room_support.h"
 #include "harvester/runtime_entity.h"
 
@@ -48,6 +49,7 @@ public:
 
 	RoomPlayerState &getPlayerState() { return _playerState; }
 	Common::Array<RoomMonsterCombatState> &getMonsterCombatStates() { return _monsterCombatStates; }
+	RoomSceneRuntime &getSceneRuntime() { return _sceneRuntime; }
 	RoomCombatEffects &getCombatEffects() { return _combatEffects; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
 	RoomMovementInputState &getMovementInput() { return _movementInput; }
@@ -65,22 +67,7 @@ public:
 	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
 		bool includeCurrentRoomTimers = false);
 
-	void removeSceneEntityByName(const Common::String &entityName);
-	static Common::Rect getSceneObjectBounds(const ObjectRecord &object);
-	static Common::String resolveSceneObjectSpritePathLocal(const ObjectRecord &object);
-	void spawnSceneObjectEntityFromRecord(const ObjectRecord &object);
-	void spawnSceneRegionEntityFromRecord(const RegionRecord &region);
-	static bool shouldSpawnRoomNpcEntity(const NpcRecord &npc);
-	static bool shouldSpawnRoomMonsterEntity(const MonsterRecord &monster);
-	Entity *spawnSceneNpcEntityFromRecord(const NpcRecord &npc);
-	Entity *spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster);
-
 private:
-	bool isInteractiveSceneHotspot(const ObjectRecord &object) const;
-	RuntimeEntityClass resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const;
-	static Common::Rect getSceneRegionBounds(const RegionRecord &region);
-	static bool applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc);
-
 	bool isTimerMaterializedInCurrentRoom(const Common::String &timerName) const;
 
 	HarvesterEngine &_engine;
@@ -88,6 +75,7 @@ private:
 	RoomSceneResources &_scene;
 	EntityManager *_entityManager;
 	InventorySystem &_inventory;
+	RoomSceneRuntime _sceneRuntime;
 	RoomPlayerState _playerState;
 	Common::Array<RoomMonsterCombatState> _monsterCombatStates;
 	RoomCombatEffects _combatEffects;


Commit: 36b5ca56de2ac6a6771cbe7070e83b52e4989ae1
    https://github.com/scummvm/scummvm/commit/36b5ca56de2ac6a6771cbe7070e83b52e4989ae1
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: Refactor RoomSession and RoomSceneRuntime for improved timer synchronization and state management

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_scene_runtime.cpp
    engines/harvester/room_scene_runtime.h
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 3f0d3ecc157..6b7700eab46 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -691,7 +691,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					(void)_engine.playMusic(exitInteraction.musicPath);
 				flow.executeStartupAudioCommands(exitInteraction.audioCommands);
 				if (exitInteraction.mutatedRuntimeState)
-					session.syncGlobalTimerEntities(exitInteraction.previousTimerRecords, true);
+					sceneRuntime.syncGlobalTimerEntities(exitInteraction.previousTimerRecords, true);
 
 				if (!exitInteraction.cutscenePath.empty()) {
 					FstPlayer fstPlayer(_engine);
@@ -950,7 +950,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) override {
-				session.syncGlobalTimerEntities(previousTimerRecords);
+				session.getSceneRuntime().syncGlobalTimerEntities(previousTimerRecords);
 			}
 
 			bool captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop) override {
@@ -1018,7 +1018,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			// Native room_setup applies entry-time state changes before the first visible room frame.
 			if (roomEntryInteraction.mutatedRuntimeState) {
-				session.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
+				sceneRuntime.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
 				if (!session.applyCurrentRoomRuntimeMutationsInPlace(false) &&
 						!session.refreshCurrentScene(true)) {
 					return Common::kReadingFailed;
@@ -3522,7 +3522,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (entityManager) {
 			Common::Array<Common::String> expiredTimerNames;
 			if (entityManager->takeExpiredTimerNames(expiredTimerNames)) {
-				session.syncCurrentRoomRuntimeState();
+				sceneRuntime.syncCurrentRoomRuntimeState(scene.sceneRegions);
 				for (const Common::String &timerName : expiredTimerNames) {
 					InteractionResult timerInteraction;
 					if (!_engine.getScript()->executeTimerAction(
diff --git a/engines/harvester/room_scene_runtime.cpp b/engines/harvester/room_scene_runtime.cpp
index cbfdd79ba87..8d136813b2d 100644
--- a/engines/harvester/room_scene_runtime.cpp
+++ b/engines/harvester/room_scene_runtime.cpp
@@ -75,6 +75,108 @@ bool RoomSceneRuntime::loadSceneResources(const RoomSetupState &state, RoomScene
 	return _resources && loadRoomSceneResources(state, *_resources, scene);
 }
 
+void RoomSceneRuntime::syncCurrentRoomRuntimeState(const Common::Array<RegionRecord> &sceneRegions) {
+	Script *script = _script;
+	if (!script)
+		return;
+
+	debugC(3, kDebugRoom, "Harvester: syncing room runtime state room='%s' entities=%d",
+		_state.roomName.c_str(), _entityManager != nullptr);
+
+	for (const RegionRecord &baseRegion : script->getRegions()) {
+		if (!baseRegion.roomName.equalsIgnoreCase(_state.roomName))
+			continue;
+
+		const bool enabled =
+			findSceneRegionByName(sceneRegions, baseRegion.regionName) != nullptr;
+		(void)script->setRuntimeRegionEnabled(baseRegion.regionName, enabled);
+	}
+
+	if (_entityManager) {
+		for (AnimRecord &anim : _state.roomAnimations) {
+			Entity *entity = _entityManager->findSceneEntityByName(anim.animName);
+			if (!entity)
+				continue;
+			(void)script->syncRuntimeAnimState(anim.animName,
+				entity->isAnimationEnabled(), anim.visible, entity->getCurrentFrame());
+		}
+	}
+	for (const MonsterRecord &monster : _state.roomMonsters)
+		(void)script->syncRuntimeMonsterRecord(monster);
+	if (!_entityManager)
+		return;
+
+	for (const TimerRecord &baseTimer : script->getTimers()) {
+		Entity *entity = _entityManager->findSceneEntityByName(baseTimer.timerName);
+		if (!entity || entity->getClassId() != kRuntimeEntityClassTimer)
+			continue;
+
+		const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
+		TimerRecord timer = runtimeTimer ? *runtimeTimer : baseTimer;
+		timer.currentValue = entity->getTimerCurrentValue();
+		timer.enabled = entity->isTimerEnabled();
+		timer.looping = entity->isTimerLooping();
+		timer.global = entity->isTimerGlobal();
+		(void)script->syncRuntimeTimerRecord(timer);
+	}
+}
+
+bool RoomSceneRuntime::isTimerMaterializedInCurrentRoom(const Common::String &timerName) const {
+	for (const TimerRecord &timer : _state.roomTimers) {
+		if (timer.timerName.equalsIgnoreCase(timerName))
+			return true;
+	}
+
+	return false;
+}
+
+void RoomSceneRuntime::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
+		bool includeCurrentRoomTimers) {
+	Script *script = _script;
+	if (!script || !_entityManager || previousTimerRecords.empty())
+		return;
+
+	for (const TimerRecord &previousTimer : previousTimerRecords) {
+		const TimerRecord *runtimeTimer =
+			script->findRuntimeTimerRecord(previousTimer.timerName);
+		if (!runtimeTimer || !runtimeTimer->global ||
+				(!includeCurrentRoomTimers &&
+					isTimerMaterializedInCurrentRoom(runtimeTimer->timerName))) {
+			continue;
+		}
+
+		Entity *entity = _entityManager->findSceneEntityByName(runtimeTimer->timerName);
+		if (entity && entity->getClassId() != kRuntimeEntityClassTimer)
+			continue;
+
+		if (!entity) {
+			(void)script->syncRuntimeTimerRecord(previousTimer);
+			debugC(1, kDebugRoom,
+				"Harvester: global timer mutation ignored current_room='%s' timer='%s' owner_room='%s' reason='missing live timer entity'",
+				_state.roomName.c_str(), previousTimer.timerName.c_str(),
+				previousTimer.arg1.c_str());
+			continue;
+		}
+
+		if (entity->getTimerInitialValue() == runtimeTimer->initialValue &&
+				entity->isTimerEnabled() == runtimeTimer->enabled &&
+				entity->isTimerLooping() == runtimeTimer->looping &&
+				entity->isTimerGlobal() == runtimeTimer->global) {
+			continue;
+		}
+
+		_entityManager->configureSceneTimerEntity(*entity,
+			runtimeTimer->initialValue, runtimeTimer->currentValue,
+			runtimeTimer->enabled, runtimeTimer->looping, runtimeTimer->global);
+		debugC(1, kDebugRoom,
+			"Harvester: global timer synced current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
+			_state.roomName.c_str(), runtimeTimer->timerName.c_str(),
+			runtimeTimer->arg1.c_str(), runtimeTimer->currentValue,
+			runtimeTimer->initialValue, runtimeTimer->enabled,
+			runtimeTimer->looping);
+	}
+}
+
 void RoomSceneRuntime::removeSceneEntityByName(const Common::String &entityName) {
 	if (!_entityManager)
 		return;
diff --git a/engines/harvester/room_scene_runtime.h b/engines/harvester/room_scene_runtime.h
index ad997302134..54c9f3e646a 100644
--- a/engines/harvester/room_scene_runtime.h
+++ b/engines/harvester/room_scene_runtime.h
@@ -33,6 +33,9 @@ public:
 	RoomSceneRuntime(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager);
 
 	bool loadSceneResources(const RoomSetupState &state, RoomSceneResources &scene) const;
+	void syncCurrentRoomRuntimeState(const Common::Array<RegionRecord> &sceneRegions);
+	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
+		bool includeCurrentRoomTimers = false);
 	// Initial population skips failed spawns; in-place callers handle null actor results.
 	void populateRoomRegions();
 	void populateRoomObjects(const Common::Array<ObjectRecord> &drawableObjects);
@@ -50,6 +53,7 @@ public:
 	Entity *spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster);
 
 private:
+	bool isTimerMaterializedInCurrentRoom(const Common::String &timerName) const;
 	bool isInteractiveSceneHotspot(const ObjectRecord &object) const;
 	RuntimeEntityClass resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const;
 	static Common::Rect getSceneRegionBounds(const RegionRecord &region);
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index e47bec38136..dea23a49793 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -192,120 +192,22 @@ void RoomSession::stopPlayerRegionInteraction() {
 	_playerState.turnTargetFacing = -1;
 	if (_playerState.entity && _playerState.facing >= 0)
 		(void)Player::setIdleAnimation(_playerState, _playerState.facing);
-	Player::syncAnimationPlacement(_playerState);
+	syncPlayerPlacement();
 }
 
-void RoomSession::syncCurrentRoomRuntimeState() {
-	Script *script = _engine.getScript();
-	if (!script)
-		return;
-
-	debugC(3, kDebugRoom, "Harvester: syncing room runtime state room='%s' entities=%d",
-		_scene.state.roomName.c_str(), _entityManager != nullptr);
-
-	for (const RegionRecord &baseRegion : script->getRegions()) {
-		if (!baseRegion.roomName.equalsIgnoreCase(_scene.state.roomName))
-			continue;
-
-		const bool enabled =
-			findSceneRegionByName(_scene.sceneRegions, baseRegion.regionName) != nullptr;
-		(void)script->setRuntimeRegionEnabled(baseRegion.regionName, enabled);
-	}
-
-	if (_entityManager) {
-		for (AnimRecord &anim : _scene.state.roomAnimations) {
-			Entity *entity = _entityManager->findSceneEntityByName(anim.animName);
-			if (!entity)
-				continue;
-			(void)script->syncRuntimeAnimState(anim.animName,
-				entity->isAnimationEnabled(), anim.visible, entity->getCurrentFrame());
-		}
-	}
-	for (const MonsterRecord &monster : _scene.state.roomMonsters)
-		(void)script->syncRuntimeMonsterRecord(monster);
-	if (!_entityManager)
-		return;
-
-	for (const TimerRecord &baseTimer : script->getTimers()) {
-		Entity *entity = _entityManager->findSceneEntityByName(baseTimer.timerName);
-		if (!entity || entity->getClassId() != kRuntimeEntityClassTimer)
-			continue;
-
-		const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
-		TimerRecord timer = runtimeTimer ? *runtimeTimer : baseTimer;
-		timer.currentValue = entity->getTimerCurrentValue();
-		timer.enabled = entity->isTimerEnabled();
-		timer.looping = entity->isTimerLooping();
-		timer.global = entity->isTimerGlobal();
-		(void)script->syncRuntimeTimerRecord(timer);
-	}
+void RoomSession::syncPlayerPlacement() {
+	Player::syncAnimationPlacement(_playerState);
 }
 
 void RoomSession::captureCurrentSaveState() {
-	Player::syncAnimationPlacement(_playerState);
-	syncCurrentRoomRuntimeState();
+	syncPlayerPlacement();
+	_sceneRuntime.syncCurrentRoomRuntimeState(_scene.sceneRegions);
 	const int facing = _playerState.facing >= 0 ? _playerState.facing : _scene.state.playerFacing;
 	_engine.captureCurrentSaveRoomState(_scene.state.entranceName, _scene.state.roomName,
 		_playerState.centerX, _playerState.bottomY, (int)_playerState.z, facing,
 		_scene.state.discNumber, _engine.getMusicPath());
 }
 
-bool RoomSession::isTimerMaterializedInCurrentRoom(const Common::String &timerName) const {
-	for (const TimerRecord &timer : _scene.state.roomTimers) {
-		if (timer.timerName.equalsIgnoreCase(timerName))
-			return true;
-	}
-
-	return false;
-}
-
-void RoomSession::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
-		bool includeCurrentRoomTimers) {
-	Script *script = _engine.getScript();
-	if (!script || !_entityManager || previousTimerRecords.empty())
-		return;
-
-	for (const TimerRecord &previousTimer : previousTimerRecords) {
-		const TimerRecord *runtimeTimer =
-			script->findRuntimeTimerRecord(previousTimer.timerName);
-		if (!runtimeTimer || !runtimeTimer->global ||
-				(!includeCurrentRoomTimers &&
-					isTimerMaterializedInCurrentRoom(runtimeTimer->timerName))) {
-			continue;
-		}
-
-		Entity *entity = _entityManager->findSceneEntityByName(runtimeTimer->timerName);
-		if (entity && entity->getClassId() != kRuntimeEntityClassTimer)
-			continue;
-
-		if (!entity) {
-			(void)script->syncRuntimeTimerRecord(previousTimer);
-			debugC(1, kDebugRoom,
-				"Harvester: global timer mutation ignored current_room='%s' timer='%s' owner_room='%s' reason='missing live timer entity'",
-				_scene.state.roomName.c_str(), previousTimer.timerName.c_str(),
-				previousTimer.arg1.c_str());
-			continue;
-		}
-
-		if (entity->getTimerInitialValue() == runtimeTimer->initialValue &&
-				entity->isTimerEnabled() == runtimeTimer->enabled &&
-				entity->isTimerLooping() == runtimeTimer->looping &&
-				entity->isTimerGlobal() == runtimeTimer->global) {
-			continue;
-		}
-
-		_entityManager->configureSceneTimerEntity(*entity,
-			runtimeTimer->initialValue, runtimeTimer->currentValue,
-			runtimeTimer->enabled, runtimeTimer->looping, runtimeTimer->global);
-		debugC(1, kDebugRoom,
-			"Harvester: global timer synced current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
-			_scene.state.roomName.c_str(), runtimeTimer->timerName.c_str(),
-			runtimeTimer->arg1.c_str(), runtimeTimer->currentValue,
-			runtimeTimer->initialValue, runtimeTimer->enabled,
-			runtimeTimer->looping);
-	}
-}
-
 bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) {
 	Script *script = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 72a7d17c6df..314f2bd5571 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -60,16 +60,12 @@ public:
 	bool isPlayerCombatLocked() const;
 	bool cancelPlayerAttackAnimation();
 	void stopPlayerRegionInteraction();
-	void syncCurrentRoomRuntimeState();
+	void syncPlayerPlacement();
 	void captureCurrentSaveState();
 	bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState);
 	bool refreshCurrentScene(bool preservePlayerPlacement);
-	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
-		bool includeCurrentRoomTimers = false);
 
 private:
-	bool isTimerMaterializedInCurrentRoom(const Common::String &timerName) const;
-
 	HarvesterEngine &_engine;
 	Flow &_flow;
 	RoomSceneResources &_scene;


Commit: ef370dd63a30ba93dacb19467617e235866d44cb
    https://github.com/scummvm/scummvm/commit/ef370dd63a30ba93dacb19467617e235866d44cb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: Refactor combat state management in RoomCombat and RoomSession

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_combat.cpp
    engines/harvester/room_combat.h
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 6b7700eab46..77dccb64a82 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -432,10 +432,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
-		Common::Array<RoomMonsterCombatState> &monsterCombatStates = session.getMonsterCombatStates();
-		monsterCombatStates.resize(scene.state.roomMonsters.size());
-		Common::Array<RoomNpcCombatState> npcCombatStates;
-		npcCombatStates.resize(scene.state.roomNpcs.size());
+		RoomCombat &combat = session.getCombat();
+		combat.initializeCombatStates(scene.state);
+		Common::Array<RoomMonsterCombatState> &monsterCombatStates = combat.getMonsterCombatStates();
+		Common::Array<RoomNpcCombatState> &npcCombatStates = combat.getNpcCombatStates();
 		RoomSceneRuntime &sceneRuntime = session.getSceneRuntime();
 		RoomCombatEffects &combatEffects = session.getCombatEffects();
 		ResolvedText inspectText;
@@ -1925,10 +1925,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			scene.state.roomZVelocityStep > 0.0f ? scene.state.roomZVelocityStep : 1.0f));
 		bool playerAlive = script.getPlayerCurrentHitPoints() > 0;
 
-		if (npcCombatStates.size() != scene.state.roomNpcs.size())
-			npcCombatStates.resize(scene.state.roomNpcs.size());
-		if (monsterCombatStates.size() != scene.state.roomMonsters.size())
-			monsterCombatStates.resize(scene.state.roomMonsters.size());
+		combat.resizeCombatStates(scene.state);
 
 		for (uint i = 0; i < scene.state.roomNpcs.size(); ++i) {
 			NpcRecord &npc = scene.state.roomNpcs[i];
diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
index bea363a7d98..255b9abf3a5 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/room_combat.cpp
@@ -488,6 +488,65 @@ int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
 		: kDefaultMonsterAttackContactFrameOffset;
 }
 
+bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs) {
+	return lhs.posX == rhs.posX &&
+		lhs.posY == rhs.posY &&
+		lhs.posZ == rhs.posZ &&
+		lhs.facing == rhs.facing &&
+		lhs.active == rhs.active &&
+		lhs.visible == rhs.visible &&
+		lhs.runtimeSpawned == rhs.runtimeSpawned &&
+		lhs.runtimeState == rhs.runtimeState &&
+		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+}
+
+void RoomCombat::initializeCombatStates(const RoomSetupState &state) {
+	_monsterCombatStates.resize(state.roomMonsters.size());
+	_npcCombatStates.resize(state.roomNpcs.size());
+	debugC(1, kDebugCombat, "Harvester: initialized room combat states monsters=%u npcs=%u",
+		_monsterCombatStates.size(), _npcCombatStates.size());
+}
+
+void RoomCombat::resizeCombatStates(const RoomSetupState &state) {
+	if (_npcCombatStates.size() != state.roomNpcs.size())
+		_npcCombatStates.resize(state.roomNpcs.size());
+	if (_monsterCombatStates.size() != state.roomMonsters.size())
+		_monsterCombatStates.resize(state.roomMonsters.size());
+}
+
+Common::Array<RoomMonsterCombatState> RoomCombat::prepareMonsterCombatStatesForSceneChange(
+		const Common::Array<MonsterRecord> &previousMonsters, const Common::Array<MonsterRecord> &updatedMonsters) const {
+	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates;
+	updatedMonsterCombatStates.resize(updatedMonsters.size());
+	for (uint i = 0; i < updatedMonsters.size(); ++i) {
+		const MonsterRecord &updatedMonster = updatedMonsters[i];
+		for (uint j = 0; j < previousMonsters.size() &&
+				j < _monsterCombatStates.size(); ++j) {
+			const MonsterRecord &previousMonster = previousMonsters[j];
+			if (!previousMonster.monsterName.equalsIgnoreCase(updatedMonster.monsterName))
+				continue;
+			if (sameMonsterEntityState(previousMonster, updatedMonster))
+				updatedMonsterCombatStates[i] = _monsterCombatStates[j];
+			break;
+		}
+	}
+
+	return updatedMonsterCombatStates;
+}
+
+void RoomCombat::commitMonsterCombatStatesForSceneChange(Common::Array<RoomMonsterCombatState> &&states) {
+	_monsterCombatStates = Common::move(states);
+	debugC(2, kDebugCombat, "Harvester: committed room monster combat states count=%u", _monsterCombatStates.size());
+}
+
+void RoomCombat::resetForSceneRefresh(const RoomSetupState &state) {
+	// NPC death state survives refresh and is resized by index at the next combat update.
+	_monsterCombatStates.clear();
+	_monsterCombatStates.resize(state.roomMonsters.size());
+	debugC(2, kDebugCombat, "Harvester: reset room monster combat states count=%u retained_npcs=%u",
+		_monsterCombatStates.size(), _npcCombatStates.size());
+}
+
 void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState) {
 	combatState = RoomMonsterCombatState();
 }
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
index 45932372945..8637d5aaea2 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/room_combat.h
@@ -116,6 +116,24 @@ struct RoomNpcCombatState {
 	int deathDamageType = 0;
 };
 
+class RoomCombat {
+public:
+	Common::Array<RoomMonsterCombatState> &getMonsterCombatStates() { return _monsterCombatStates; }
+	Common::Array<RoomNpcCombatState> &getNpcCombatStates() { return _npcCombatStates; }
+
+	void initializeCombatStates(const RoomSetupState &state);
+	void resizeCombatStates(const RoomSetupState &state);
+	// Keep prepared state separate until scene-entity mutations succeed.
+	Common::Array<RoomMonsterCombatState> prepareMonsterCombatStatesForSceneChange(
+		const Common::Array<MonsterRecord> &previousMonsters, const Common::Array<MonsterRecord> &updatedMonsters) const;
+	void commitMonsterCombatStatesForSceneChange(Common::Array<RoomMonsterCombatState> &&states);
+	void resetForSceneRefresh(const RoomSetupState &state);
+
+private:
+	Common::Array<RoomMonsterCombatState> _monsterCombatStates;
+	Common::Array<RoomNpcCombatState> _npcCombatStates;
+};
+
 struct RoomHitEffectState {
 	Common::String entityName;
 	Common::String followTargetName;
@@ -174,6 +192,7 @@ struct ScopedDeferredLiveNpcDeathTransitions {
 	Script *_script;
 };
 
+bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs);
 bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc);
 bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc);
 bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info);
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index dea23a49793..ab2320f41b1 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -95,18 +95,6 @@ static bool isQueuedLiveNpcDeathMutation(const NpcRecord &lhs, const NpcRecord &
 		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
 }
 
-static bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs) {
-	return lhs.posX == rhs.posX &&
-		lhs.posY == rhs.posY &&
-		lhs.posZ == rhs.posZ &&
-		lhs.facing == rhs.facing &&
-		lhs.active == rhs.active &&
-		lhs.visible == rhs.visible &&
-		lhs.runtimeSpawned == rhs.runtimeSpawned &&
-		lhs.runtimeState == rhs.runtimeState &&
-		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
-}
-
 static bool sameSceneObjectState(const ObjectRecord &lhs, const ObjectRecord &rhs) {
 	return lhs.currentX == rhs.currentX &&
 		lhs.currentY == rhs.currentY &&
@@ -230,20 +218,8 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 	RoomSceneResources updatedScene;
 	if (!_sceneRuntime.loadSceneResources(updatedState, updatedScene))
 		return false;
-	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates;
-	updatedMonsterCombatStates.resize(updatedState.roomMonsters.size());
-	for (uint i = 0; i < updatedState.roomMonsters.size(); ++i) {
-		const MonsterRecord &updatedMonster = updatedState.roomMonsters[i];
-		for (uint j = 0; j < _scene.state.roomMonsters.size() &&
-				j < _monsterCombatStates.size(); ++j) {
-			const MonsterRecord &previousMonster = _scene.state.roomMonsters[j];
-			if (!previousMonster.monsterName.equalsIgnoreCase(updatedMonster.monsterName))
-				continue;
-			if (sameMonsterEntityState(previousMonster, updatedMonster))
-				updatedMonsterCombatStates[i] = _monsterCombatStates[j];
-			break;
-		}
-	}
+	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates =
+		_combat.prepareMonsterCombatStatesForSceneChange(_scene.state.roomMonsters, updatedState.roomMonsters);
 
 	for (const ObjectRecord &object : _scene.sceneObjects) {
 		if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
@@ -444,7 +420,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 	}
 
 	_scene.state = updatedState;
-	_monsterCombatStates = Common::move(updatedMonsterCombatStates);
+	_combat.commitMonsterCombatStatesForSceneChange(Common::move(updatedMonsterCombatStates));
 	_scene.sceneObjects = updatedScene.sceneObjects;
 	_scene.sceneAnimations = updatedScene.sceneAnimations;
 	_scene.sceneRegions = updatedScene.sceneRegions;
@@ -525,8 +501,7 @@ bool RoomSession::refreshCurrentScene(bool preservePlayerPlacement) {
 	_playerState.hitKnockbackDecayStep = 0;
 	_playerState.combatLoadout =
 		_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
-	_monsterCombatStates.clear();
-	_monsterCombatStates.resize(_scene.state.roomMonsters.size());
+	_combat.resetForSceneRefresh(_scene.state);
 	_pendingRegionName.clear();
 	resetIdleState();
 	_flow.resetCursorAnimationSequence();
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 314f2bd5571..1a132068bbb 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -48,7 +48,7 @@ public:
 		InventorySystem &inventory);
 
 	RoomPlayerState &getPlayerState() { return _playerState; }
-	Common::Array<RoomMonsterCombatState> &getMonsterCombatStates() { return _monsterCombatStates; }
+	RoomCombat &getCombat() { return _combat; }
 	RoomSceneRuntime &getSceneRuntime() { return _sceneRuntime; }
 	RoomCombatEffects &getCombatEffects() { return _combatEffects; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
@@ -73,7 +73,7 @@ private:
 	InventorySystem &_inventory;
 	RoomSceneRuntime _sceneRuntime;
 	RoomPlayerState _playerState;
-	Common::Array<RoomMonsterCombatState> _monsterCombatStates;
+	RoomCombat _combat;
 	RoomCombatEffects _combatEffects;
 	RoomIdleAnimationState _idleState;
 	RoomMovementInputState _movementInput;


Commit: 88b55c39621ccde7fe71e231a188f3ab712e6d84
    https://github.com/scummvm/scummvm/commit/88b55c39621ccde7fe71e231a188f3ab712e6d84
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: Refactor entity state comparison functions and consolidate some scene entity logic

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room_combat.cpp
    engines/harvester/room_combat.h
    engines/harvester/room_scene_runtime.cpp
    engines/harvester/room_scene_runtime.h
    engines/harvester/room_session.cpp
    engines/harvester/room_support.h


diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
index 255b9abf3a5..1b05c868c2a 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/room_combat.cpp
@@ -488,18 +488,6 @@ int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
 		: kDefaultMonsterAttackContactFrameOffset;
 }
 
-bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs) {
-	return lhs.posX == rhs.posX &&
-		lhs.posY == rhs.posY &&
-		lhs.posZ == rhs.posZ &&
-		lhs.facing == rhs.facing &&
-		lhs.active == rhs.active &&
-		lhs.visible == rhs.visible &&
-		lhs.runtimeSpawned == rhs.runtimeSpawned &&
-		lhs.runtimeState == rhs.runtimeState &&
-		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
-}
-
 void RoomCombat::initializeCombatStates(const RoomSetupState &state) {
 	_monsterCombatStates.resize(state.roomMonsters.size());
 	_npcCombatStates.resize(state.roomNpcs.size());
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
index 8637d5aaea2..8a262f7f0db 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/room_combat.h
@@ -192,7 +192,6 @@ struct ScopedDeferredLiveNpcDeathTransitions {
 	Script *_script;
 };
 
-bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs);
 bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc);
 bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc);
 bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info);
diff --git a/engines/harvester/room_scene_runtime.cpp b/engines/harvester/room_scene_runtime.cpp
index 8d136813b2d..5a476392e51 100644
--- a/engines/harvester/room_scene_runtime.cpp
+++ b/engines/harvester/room_scene_runtime.cpp
@@ -65,6 +65,110 @@ static int resolveSceneObjectClass(const RoomSetupState &state,
 		: kRuntimeEntityClassDisabledHotspot;
 }
 
+static bool sameRect(const Common::Rect &lhs, const Common::Rect &rhs) {
+	return lhs.left == rhs.left && lhs.top == rhs.top &&
+		lhs.right == rhs.right && lhs.bottom == rhs.bottom;
+}
+
+static const AnimRecord *findRoomAnimByNameConst(const Common::Array<AnimRecord> &anims, const Common::String &animName) {
+	for (const AnimRecord &anim : anims) {
+		if (anim.animName.equalsIgnoreCase(animName))
+			return &anim;
+	}
+	return nullptr;
+}
+
+static const NpcRecord *findRoomNpcByNameConst(const Common::Array<NpcRecord> &npcs, const Common::String &npcName) {
+	for (const NpcRecord &npc : npcs) {
+		if (npc.npcName.equalsIgnoreCase(npcName))
+			return &npc;
+	}
+	return nullptr;
+}
+
+static const MonsterRecord *findRoomMonsterByNameConst(const Common::Array<MonsterRecord> &monsters, const Common::String &monsterName) {
+	for (const MonsterRecord &monster : monsters) {
+		if (monster.monsterName.equalsIgnoreCase(monsterName))
+			return &monster;
+	}
+	return nullptr;
+}
+
+static bool sameNpcEntityState(const NpcRecord &lhs, const NpcRecord &rhs) {
+	return lhs.posX == rhs.posX &&
+		lhs.posY == rhs.posY &&
+		lhs.posZ == rhs.posZ &&
+		lhs.frameDelay == rhs.frameDelay &&
+		lhs.active == rhs.active &&
+		lhs.visible == rhs.visible &&
+		lhs.runtimeSpawned == rhs.runtimeSpawned &&
+		lhs.runtimeState == rhs.runtimeState &&
+		lhs.deathDamageType == rhs.deathDamageType &&
+		lhs.deathOrMonsterfyFlag == rhs.deathOrMonsterfyFlag &&
+		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+}
+
+static bool isQueuedLiveNpcDeathMutation(const NpcRecord &lhs, const NpcRecord &rhs) {
+	return lhs.posX == rhs.posX &&
+		lhs.posY == rhs.posY &&
+		lhs.posZ == rhs.posZ &&
+		lhs.frameDelay == rhs.frameDelay &&
+		lhs.active &&
+		rhs.active &&
+		lhs.visible &&
+		rhs.visible &&
+		!lhs.runtimeSpawned &&
+		!rhs.runtimeSpawned &&
+		lhs.runtimeState < 0 &&
+		rhs.runtimeState < 0 &&
+		!lhs.deathOrMonsterfyFlag &&
+		!rhs.deathOrMonsterfyFlag &&
+		lhs.deathDamageType == 0 &&
+		rhs.deathDamageType != 0 &&
+		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+}
+
+static bool sameSceneObjectState(const ObjectRecord &lhs, const ObjectRecord &rhs) {
+	return lhs.currentX == rhs.currentX &&
+		lhs.currentY == rhs.currentY &&
+		lhs.currentZ == rhs.currentZ &&
+		lhs.zExtent == rhs.zExtent &&
+		lhs.operatable == rhs.operatable &&
+		lhs.visible == rhs.visible &&
+		lhs.runtimeVisible == rhs.runtimeVisible &&
+		lhs.actionTag.equalsIgnoreCase(rhs.actionTag) &&
+		lhs.identTextKey.equalsIgnoreCase(rhs.identTextKey) &&
+		lhs.interactionLabel.equalsIgnoreCase(rhs.interactionLabel) &&
+		lhs.currentOwnerOrRoom.equalsIgnoreCase(rhs.currentOwnerOrRoom) &&
+		RoomSceneRuntime::resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(RoomSceneRuntime::resolveSceneObjectSpritePathLocal(rhs)) &&
+		sameRect(RoomSceneRuntime::getSceneObjectBounds(lhs), RoomSceneRuntime::getSceneObjectBounds(rhs));
+}
+
+static bool sameSceneRegionState(const RegionRecord &lhs, const RegionRecord &rhs) {
+	return lhs.left == rhs.left &&
+		lhs.top == rhs.top &&
+		lhs.right == rhs.right &&
+		lhs.bottom == rhs.bottom &&
+		lhs.minZ == rhs.minZ &&
+		lhs.maxZ == rhs.maxZ &&
+		lhs.startEnabled == rhs.startEnabled &&
+		lhs.cursorEnabled == rhs.cursorEnabled &&
+		lhs.desiredFacing == rhs.desiredFacing &&
+		lhs.actionTag.equalsIgnoreCase(rhs.actionTag);
+}
+
+bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs) {
+	return lhs.posX == rhs.posX &&
+		lhs.posY == rhs.posY &&
+		lhs.posZ == rhs.posZ &&
+		lhs.facing == rhs.facing &&
+		lhs.active == rhs.active &&
+		lhs.visible == rhs.visible &&
+		lhs.runtimeSpawned == rhs.runtimeSpawned &&
+		lhs.runtimeState == rhs.runtimeState &&
+		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+}
+
 RoomSceneRuntime::RoomSceneRuntime(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager)
 	: _state(state), _script(script), _resources(resources), _entityManager(entityManager) {
 	debugC(1, kDebugRoom, "Harvester: created room scene runtime room='%s' entities=%d",
@@ -75,6 +179,215 @@ bool RoomSceneRuntime::loadSceneResources(const RoomSetupState &state, RoomScene
 	return _resources && loadRoomSceneResources(state, *_resources, scene);
 }
 
+bool RoomSceneRuntime::reconcileSceneEntitiesInPlace(const RoomSceneResources &previousScene,
+		RoomSetupState &updatedState, const RoomSceneResources &updatedScene) {
+	for (const ObjectRecord &object : previousScene.sceneObjects) {
+		if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
+			removeSceneEntityByName(object.objectName);
+	}
+	for (const ObjectRecord &object : updatedScene.sceneObjects) {
+		const ObjectRecord *previous = findSceneObjectByName(previousScene.sceneObjects, object.objectName);
+		const bool changed = !previous || !sameSceneObjectState(*previous, object);
+		if (!changed)
+			continue;
+
+		removeSceneEntityByName(object.objectName);
+		spawnSceneObjectEntityFromRecord(object);
+	}
+
+	for (const RegionRecord &region : previousScene.sceneRegions) {
+		if (!findSceneRegionByName(updatedScene.sceneRegions, region.regionName))
+			removeSceneEntityByName(region.regionName);
+	}
+	for (const RegionRecord &region : updatedScene.sceneRegions) {
+		const RegionRecord *previous =
+			findSceneRegionByName(previousScene.sceneRegions, region.regionName);
+		const bool changed = !previous || !sameSceneRegionState(*previous, region);
+		if (!changed)
+			continue;
+
+		removeSceneEntityByName(region.regionName);
+		spawnSceneRegionEntityFromRecord(region);
+	}
+
+	for (const TimerRecord &timer : _state.roomTimers) {
+		bool found = false;
+		for (const TimerRecord &updatedTimer : updatedState.roomTimers) {
+			if (updatedTimer.timerName.equalsIgnoreCase(timer.timerName)) {
+				found = true;
+				break;
+			}
+		}
+		if (!found)
+			removeSceneEntityByName(timer.timerName);
+	}
+	for (const TimerRecord &timer : updatedState.roomTimers) {
+		const TimerRecord *previous = nullptr;
+		for (const TimerRecord &candidate : _state.roomTimers) {
+			if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
+				previous = &candidate;
+				break;
+			}
+		}
+		const bool changed = !previous ||
+			previous->initialValue != timer.initialValue ||
+			previous->currentValue != timer.currentValue ||
+			previous->enabled != timer.enabled ||
+			previous->looping != timer.looping ||
+			previous->global != timer.global;
+		if (!changed)
+			continue;
+
+		removeSceneEntityByName(timer.timerName);
+		if (_entityManager) {
+			(void)_entityManager->spawnSceneTimerEntity(
+				timer.timerName, timer.initialValue, timer.currentValue,
+				timer.enabled, timer.looping, timer.global);
+		}
+	}
+
+	for (AnimRecord &anim : updatedState.roomAnimations) {
+		const AnimRecord *previous =
+			findRoomAnimByNameConst(_state.roomAnimations, anim.animName);
+		const bool wasDisplayed = previous && shouldDisplaySceneAnimation(*previous);
+		const bool isDisplayed = shouldDisplaySceneAnimation(anim);
+		const bool becameVisible = !wasDisplayed && isDisplayed;
+		if (becameVisible)
+			anim.runtimeState = 0;
+
+		Entity *entity = _entityManager
+			? _entityManager->findSceneEntityByName(anim.animName)
+			: nullptr;
+		if (!isDisplayed) {
+			removeSceneEntityByName(anim.animName);
+			continue;
+		}
+
+		const bool needsRespawn = !entity || !previous ||
+			!previous->resourcePath.equalsIgnoreCase(anim.resourcePath) ||
+			previous->x != anim.x ||
+			previous->y != anim.y ||
+			previous->z != anim.z ||
+			previous->frameDelay != anim.frameDelay ||
+			previous->looping != anim.looping ||
+			previous->pingPong != anim.pingPong ||
+			previous->backward != anim.backward;
+		if (needsRespawn) {
+			removeSceneEntityByName(anim.animName);
+			if (_entityManager) {
+				entity = _entityManager->spawnSceneAnimationEntity(
+					anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
+					(float)anim.z, anim.frameDelay, anim.active, isDisplayed, anim.looping,
+					anim.backward, anim.pingPong, anim.runtimeState);
+			}
+		} else if (entity) {
+			const float renderZ =
+				(float)anim.z - floorf(MAX<float>(entity->getZExtent(), 0.0f) * 0.5f);
+			const bool zChanged = entity->getZ() != renderZ;
+			entity->setPosition(anim.x, anim.y, renderZ);
+			entity->setLooping(anim.looping);
+			entity->setPingPong(anim.pingPong);
+			entity->setPlayBackwards(anim.backward);
+			entity->setAnimationRate(anim.frameDelay);
+			entity->setVisible(isDisplayed);
+			if (anim.active && anim.runtimeState >= 0 && !entity->isAnimationEnabled())
+				entity->setCurrentFrame(anim.runtimeState);
+			entity->setAnimationEnabled(anim.active);
+			if (zChanged)
+				_entityManager->reinsertSceneEntity(entity);
+		}
+	}
+
+	for (const AnimRecord &anim : _state.roomAnimations) {
+		if (!findRoomAnimByNameConst(updatedState.roomAnimations, anim.animName))
+			removeSceneEntityByName(anim.animName);
+	}
+
+	for (const NpcRecord &npc : _state.roomNpcs) {
+		const NpcRecord *updatedNpc =
+			findRoomNpcByNameConst(updatedState.roomNpcs, npc.npcName);
+		const bool keepCurrentRoomDeathEntity =
+			!updatedNpc &&
+			isRetainedCurrentRoomNpcDeathRecord(npc) &&
+			_entityManager &&
+			_entityManager->findSceneEntityByName(npc.npcName);
+		const bool keepEntity =
+			updatedNpc &&
+			shouldSpawnRoomNpcEntity(*updatedNpc) &&
+			(sameNpcEntityState(npc, *updatedNpc) ||
+				isQueuedLiveNpcDeathMutation(npc, *updatedNpc));
+		if (keepCurrentRoomDeathEntity) {
+			debugC(1, kDebugCombat,
+				"Harvester: retained current-room npc death entity npc='%s' damage_type=%d frame=%d",
+				npc.npcName.c_str(), npc.deathDamageType, npc.runtimeState);
+			continue;
+		}
+		if (!keepEntity)
+			removeSceneEntityByName(npc.npcName);
+	}
+	for (const NpcRecord &npc : updatedState.roomNpcs) {
+		if (!shouldSpawnRoomNpcEntity(npc))
+			continue;
+
+		Entity *entity = _entityManager
+			? _entityManager->findSceneEntityByName(npc.npcName)
+			: nullptr;
+		const NpcRecord *previousNpc =
+			findRoomNpcByNameConst(_state.roomNpcs, npc.npcName);
+		const bool keepQueuedDeathEntity =
+			entity &&
+			previousNpc &&
+			isQueuedLiveNpcDeathMutation(*previousNpc, npc);
+		if (keepQueuedDeathEntity)
+			continue;
+		const bool needsRespawn =
+			!entity || !previousNpc || !sameNpcEntityState(*previousNpc, npc);
+		if (!needsRespawn)
+			continue;
+
+		removeSceneEntityByName(npc.npcName);
+		if (!spawnSceneNpcEntityFromRecord(npc)) {
+			debugC(1, kDebugRoom, "Harvester: scene reconciliation stopped room='%s' npc='%s'",
+				_state.roomName.c_str(), npc.npcName.c_str());
+			return false;
+		}
+	}
+
+	for (const MonsterRecord &monster : _state.roomMonsters) {
+		const MonsterRecord *updatedMonster =
+			findRoomMonsterByNameConst(updatedState.roomMonsters, monster.monsterName);
+		const bool keepEntity =
+			updatedMonster &&
+			shouldSpawnRoomMonsterEntity(*updatedMonster) &&
+			sameMonsterEntityState(monster, *updatedMonster);
+		if (!keepEntity)
+			removeSceneEntityByName(monster.monsterName);
+	}
+	for (const MonsterRecord &monster : updatedState.roomMonsters) {
+		if (!shouldSpawnRoomMonsterEntity(monster))
+			continue;
+
+		Entity *entity = _entityManager
+			? _entityManager->findSceneEntityByName(monster.monsterName)
+			: nullptr;
+		const MonsterRecord *previousMonster =
+			findRoomMonsterByNameConst(_state.roomMonsters, monster.monsterName);
+		const bool needsRespawn =
+			!entity || !previousMonster || !sameMonsterEntityState(*previousMonster, monster);
+		if (!needsRespawn)
+			continue;
+
+		removeSceneEntityByName(monster.monsterName);
+		if (!spawnSceneMonsterEntityFromRecord(updatedState, monster)) {
+			debugC(1, kDebugRoom, "Harvester: scene reconciliation stopped room='%s' monster='%s'",
+				_state.roomName.c_str(), monster.monsterName.c_str());
+			return false;
+		}
+	}
+
+	return true;
+}
+
 void RoomSceneRuntime::syncCurrentRoomRuntimeState(const Common::Array<RegionRecord> &sceneRegions) {
 	Script *script = _script;
 	if (!script)
diff --git a/engines/harvester/room_scene_runtime.h b/engines/harvester/room_scene_runtime.h
index 54c9f3e646a..8df8f83a3de 100644
--- a/engines/harvester/room_scene_runtime.h
+++ b/engines/harvester/room_scene_runtime.h
@@ -33,10 +33,11 @@ public:
 	RoomSceneRuntime(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager);
 
 	bool loadSceneResources(const RoomSetupState &state, RoomSceneResources &scene) const;
+	bool reconcileSceneEntitiesInPlace(const RoomSceneResources &previousScene,
+		RoomSetupState &updatedState, const RoomSceneResources &updatedScene);
 	void syncCurrentRoomRuntimeState(const Common::Array<RegionRecord> &sceneRegions);
 	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
 		bool includeCurrentRoomTimers = false);
-	// Initial population skips failed spawns; in-place callers handle null actor results.
 	void populateRoomRegions();
 	void populateRoomObjects(const Common::Array<ObjectRecord> &drawableObjects);
 	void populateRoomNpcs();
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index ab2320f41b1..557da4e273f 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -32,98 +32,6 @@
 
 namespace Harvester {
 
-static bool sameRect(const Common::Rect &lhs, const Common::Rect &rhs) {
-	return lhs.left == rhs.left && lhs.top == rhs.top &&
-		lhs.right == rhs.right && lhs.bottom == rhs.bottom;
-}
-
-static const AnimRecord *findRoomAnimByNameConst(const Common::Array<AnimRecord> &anims, const Common::String &animName) {
-	for (const AnimRecord &anim : anims) {
-		if (anim.animName.equalsIgnoreCase(animName))
-			return &anim;
-	}
-	return nullptr;
-}
-
-static const NpcRecord *findRoomNpcByNameConst(const Common::Array<NpcRecord> &npcs, const Common::String &npcName) {
-	for (const NpcRecord &npc : npcs) {
-		if (npc.npcName.equalsIgnoreCase(npcName))
-			return &npc;
-	}
-	return nullptr;
-}
-
-static const MonsterRecord *findRoomMonsterByNameConst(const Common::Array<MonsterRecord> &monsters, const Common::String &monsterName) {
-	for (const MonsterRecord &monster : monsters) {
-		if (monster.monsterName.equalsIgnoreCase(monsterName))
-			return &monster;
-	}
-	return nullptr;
-}
-
-static bool sameNpcEntityState(const NpcRecord &lhs, const NpcRecord &rhs) {
-	return lhs.posX == rhs.posX &&
-		lhs.posY == rhs.posY &&
-		lhs.posZ == rhs.posZ &&
-		lhs.frameDelay == rhs.frameDelay &&
-		lhs.active == rhs.active &&
-		lhs.visible == rhs.visible &&
-		lhs.runtimeSpawned == rhs.runtimeSpawned &&
-		lhs.runtimeState == rhs.runtimeState &&
-		lhs.deathDamageType == rhs.deathDamageType &&
-		lhs.deathOrMonsterfyFlag == rhs.deathOrMonsterfyFlag &&
-		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
-}
-
-static bool isQueuedLiveNpcDeathMutation(const NpcRecord &lhs, const NpcRecord &rhs) {
-	return lhs.posX == rhs.posX &&
-		lhs.posY == rhs.posY &&
-		lhs.posZ == rhs.posZ &&
-		lhs.frameDelay == rhs.frameDelay &&
-		lhs.active &&
-		rhs.active &&
-		lhs.visible &&
-		rhs.visible &&
-		!lhs.runtimeSpawned &&
-		!rhs.runtimeSpawned &&
-		lhs.runtimeState < 0 &&
-		rhs.runtimeState < 0 &&
-		!lhs.deathOrMonsterfyFlag &&
-		!rhs.deathOrMonsterfyFlag &&
-		lhs.deathDamageType == 0 &&
-		rhs.deathDamageType != 0 &&
-		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
-}
-
-static bool sameSceneObjectState(const ObjectRecord &lhs, const ObjectRecord &rhs) {
-	return lhs.currentX == rhs.currentX &&
-		lhs.currentY == rhs.currentY &&
-		lhs.currentZ == rhs.currentZ &&
-		lhs.zExtent == rhs.zExtent &&
-		lhs.operatable == rhs.operatable &&
-		lhs.visible == rhs.visible &&
-		lhs.runtimeVisible == rhs.runtimeVisible &&
-		lhs.actionTag.equalsIgnoreCase(rhs.actionTag) &&
-		lhs.identTextKey.equalsIgnoreCase(rhs.identTextKey) &&
-		lhs.interactionLabel.equalsIgnoreCase(rhs.interactionLabel) &&
-		lhs.currentOwnerOrRoom.equalsIgnoreCase(rhs.currentOwnerOrRoom) &&
-		RoomSceneRuntime::resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(RoomSceneRuntime::resolveSceneObjectSpritePathLocal(rhs)) &&
-		sameRect(RoomSceneRuntime::getSceneObjectBounds(lhs), RoomSceneRuntime::getSceneObjectBounds(rhs));
-}
-
-static bool sameSceneRegionState(const RegionRecord &lhs, const RegionRecord &rhs) {
-	return lhs.left == rhs.left &&
-		lhs.top == rhs.top &&
-		lhs.right == rhs.right &&
-		lhs.bottom == rhs.bottom &&
-		lhs.minZ == rhs.minZ &&
-		lhs.maxZ == rhs.maxZ &&
-		lhs.startEnabled == rhs.startEnabled &&
-		lhs.cursorEnabled == rhs.cursorEnabled &&
-		lhs.desiredFacing == rhs.desiredFacing &&
-		lhs.actionTag.equalsIgnoreCase(rhs.actionTag);
-}
-
 RoomSession::RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
 		InventorySystem &inventory)
 	: _engine(engine), _flow(flow), _scene(scene), _entityManager(entityManager), _inventory(inventory),
@@ -221,203 +129,8 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates =
 		_combat.prepareMonsterCombatStatesForSceneChange(_scene.state.roomMonsters, updatedState.roomMonsters);
 
-	for (const ObjectRecord &object : _scene.sceneObjects) {
-		if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
-			_sceneRuntime.removeSceneEntityByName(object.objectName);
-	}
-	for (const ObjectRecord &object : updatedScene.sceneObjects) {
-		const ObjectRecord *previous = findSceneObjectByName(_scene.sceneObjects, object.objectName);
-		const bool changed = !previous || !sameSceneObjectState(*previous, object);
-		if (!changed)
-			continue;
-
-		_sceneRuntime.removeSceneEntityByName(object.objectName);
-		_sceneRuntime.spawnSceneObjectEntityFromRecord(object);
-	}
-
-	for (const RegionRecord &region : _scene.sceneRegions) {
-		if (!findSceneRegionByName(updatedScene.sceneRegions, region.regionName))
-			_sceneRuntime.removeSceneEntityByName(region.regionName);
-	}
-	for (const RegionRecord &region : updatedScene.sceneRegions) {
-		const RegionRecord *previous =
-			findSceneRegionByName(_scene.sceneRegions, region.regionName);
-		const bool changed = !previous || !sameSceneRegionState(*previous, region);
-		if (!changed)
-			continue;
-
-		_sceneRuntime.removeSceneEntityByName(region.regionName);
-		_sceneRuntime.spawnSceneRegionEntityFromRecord(region);
-	}
-
-	for (const TimerRecord &timer : _scene.state.roomTimers) {
-		bool found = false;
-		for (const TimerRecord &updatedTimer : updatedState.roomTimers) {
-			if (updatedTimer.timerName.equalsIgnoreCase(timer.timerName)) {
-				found = true;
-				break;
-			}
-		}
-		if (!found)
-			_sceneRuntime.removeSceneEntityByName(timer.timerName);
-	}
-	for (const TimerRecord &timer : updatedState.roomTimers) {
-		const TimerRecord *previous = nullptr;
-		for (const TimerRecord &candidate : _scene.state.roomTimers) {
-			if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
-				previous = &candidate;
-				break;
-			}
-		}
-		const bool changed = !previous ||
-			previous->initialValue != timer.initialValue ||
-			previous->currentValue != timer.currentValue ||
-			previous->enabled != timer.enabled ||
-			previous->looping != timer.looping ||
-			previous->global != timer.global;
-		if (!changed)
-			continue;
-
-		_sceneRuntime.removeSceneEntityByName(timer.timerName);
-		if (_entityManager) {
-			(void)_entityManager->spawnSceneTimerEntity(
-				timer.timerName, timer.initialValue, timer.currentValue,
-				timer.enabled, timer.looping, timer.global);
-		}
-	}
-
-	for (AnimRecord &anim : updatedState.roomAnimations) {
-		const AnimRecord *previous =
-			findRoomAnimByNameConst(_scene.state.roomAnimations, anim.animName);
-		const bool wasDisplayed = previous && shouldDisplaySceneAnimation(*previous);
-		const bool isDisplayed = shouldDisplaySceneAnimation(anim);
-		const bool becameVisible = !wasDisplayed && isDisplayed;
-		if (becameVisible)
-			anim.runtimeState = 0;
-
-		Entity *entity = _entityManager
-			? _entityManager->findSceneEntityByName(anim.animName)
-			: nullptr;
-		if (!isDisplayed) {
-			_sceneRuntime.removeSceneEntityByName(anim.animName);
-			continue;
-		}
-
-		const bool needsRespawn = !entity || !previous ||
-			!previous->resourcePath.equalsIgnoreCase(anim.resourcePath) ||
-			previous->x != anim.x ||
-			previous->y != anim.y ||
-			previous->z != anim.z ||
-			previous->frameDelay != anim.frameDelay ||
-			previous->looping != anim.looping ||
-			previous->pingPong != anim.pingPong ||
-			previous->backward != anim.backward;
-		if (needsRespawn) {
-			_sceneRuntime.removeSceneEntityByName(anim.animName);
-			if (_entityManager) {
-				entity = _entityManager->spawnSceneAnimationEntity(
-					anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
-					(float)anim.z, anim.frameDelay, anim.active, isDisplayed, anim.looping,
-					anim.backward, anim.pingPong, anim.runtimeState);
-			}
-		} else if (entity) {
-			const float renderZ =
-				(float)anim.z - floorf(MAX<float>(entity->getZExtent(), 0.0f) * 0.5f);
-			const bool zChanged = entity->getZ() != renderZ;
-			entity->setPosition(anim.x, anim.y, renderZ);
-			entity->setLooping(anim.looping);
-			entity->setPingPong(anim.pingPong);
-			entity->setPlayBackwards(anim.backward);
-			entity->setAnimationRate(anim.frameDelay);
-			entity->setVisible(isDisplayed);
-			if (anim.active && anim.runtimeState >= 0 && !entity->isAnimationEnabled())
-				entity->setCurrentFrame(anim.runtimeState);
-			entity->setAnimationEnabled(anim.active);
-			if (zChanged)
-				_entityManager->reinsertSceneEntity(entity);
-		}
-	}
-
-	for (const AnimRecord &anim : _scene.state.roomAnimations) {
-		if (!findRoomAnimByNameConst(updatedState.roomAnimations, anim.animName))
-			_sceneRuntime.removeSceneEntityByName(anim.animName);
-	}
-
-	for (const NpcRecord &npc : _scene.state.roomNpcs) {
-		const NpcRecord *updatedNpc =
-			findRoomNpcByNameConst(updatedState.roomNpcs, npc.npcName);
-		const bool keepCurrentRoomDeathEntity =
-			!updatedNpc &&
-			isRetainedCurrentRoomNpcDeathRecord(npc) &&
-			_entityManager &&
-			_entityManager->findSceneEntityByName(npc.npcName);
-		const bool keepEntity =
-			updatedNpc &&
-			_sceneRuntime.shouldSpawnRoomNpcEntity(*updatedNpc) &&
-			(sameNpcEntityState(npc, *updatedNpc) ||
-				isQueuedLiveNpcDeathMutation(npc, *updatedNpc));
-		if (keepCurrentRoomDeathEntity) {
-			debugC(1, kDebugCombat,
-				"Harvester: retained current-room npc death entity npc='%s' damage_type=%d frame=%d",
-				npc.npcName.c_str(), npc.deathDamageType, npc.runtimeState);
-			continue;
-		}
-		if (!keepEntity)
-			_sceneRuntime.removeSceneEntityByName(npc.npcName);
-	}
-	for (const NpcRecord &npc : updatedState.roomNpcs) {
-		if (!_sceneRuntime.shouldSpawnRoomNpcEntity(npc))
-			continue;
-
-		Entity *entity = _entityManager
-			? _entityManager->findSceneEntityByName(npc.npcName)
-			: nullptr;
-		const NpcRecord *previousNpc =
-			findRoomNpcByNameConst(_scene.state.roomNpcs, npc.npcName);
-		const bool keepQueuedDeathEntity =
-			entity &&
-			previousNpc &&
-			isQueuedLiveNpcDeathMutation(*previousNpc, npc);
-		if (keepQueuedDeathEntity)
-			continue;
-		const bool needsRespawn =
-			!entity || !previousNpc || !sameNpcEntityState(*previousNpc, npc);
-		if (!needsRespawn)
-			continue;
-
-		_sceneRuntime.removeSceneEntityByName(npc.npcName);
-		if (!_sceneRuntime.spawnSceneNpcEntityFromRecord(npc))
-			return false;
-	}
-
-	for (const MonsterRecord &monster : _scene.state.roomMonsters) {
-		const MonsterRecord *updatedMonster =
-			findRoomMonsterByNameConst(updatedState.roomMonsters, monster.monsterName);
-		const bool keepEntity =
-			updatedMonster &&
-			_sceneRuntime.shouldSpawnRoomMonsterEntity(*updatedMonster) &&
-			sameMonsterEntityState(monster, *updatedMonster);
-		if (!keepEntity)
-			_sceneRuntime.removeSceneEntityByName(monster.monsterName);
-	}
-	for (const MonsterRecord &monster : updatedState.roomMonsters) {
-		if (!_sceneRuntime.shouldSpawnRoomMonsterEntity(monster))
-			continue;
-
-		Entity *entity = _entityManager
-			? _entityManager->findSceneEntityByName(monster.monsterName)
-			: nullptr;
-		const MonsterRecord *previousMonster =
-			findRoomMonsterByNameConst(_scene.state.roomMonsters, monster.monsterName);
-		const bool needsRespawn =
-			!entity || !previousMonster || !sameMonsterEntityState(*previousMonster, monster);
-		if (!needsRespawn)
-			continue;
-
-		_sceneRuntime.removeSceneEntityByName(monster.monsterName);
-		if (!_sceneRuntime.spawnSceneMonsterEntityFromRecord(updatedState, monster))
-			return false;
-	}
+	if (!_sceneRuntime.reconcileSceneEntitiesInPlace(_scene, updatedState, updatedScene))
+		return false;
 
 	_scene.state = updatedState;
 	_combat.commitMonsterCombatStatesForSceneChange(Common::move(updatedMonsterCombatStates));
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 2c546f64153..463efbda54e 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -127,6 +127,7 @@ struct RoomIdleAnimationState {
 	bool exiting = false;
 };
 
+bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs);
 bool applyRoomActorPlacement(const RoomSetupState &state, Entity &entity,
 	int centerX, int bottomY, float z, const Common::String *entranceName = nullptr);
 void logScenePaletteSummary(const char *label, const RoomSceneResources &scene, float brightness);


Commit: 91f5b74f0258c00c29c5dccc175947dc745a8d1d
    https://github.com/scummvm/scummvm/commit/91f5b74f0258c00c29c5dccc175947dc745a8d1d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:13-04:00

Commit Message:
HARVESTER: Implement player rebind after scene refresh and restore monster animations

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room_scene_runtime.cpp
    engines/harvester/room_scene_runtime.h
    engines/harvester/room_session.cpp


diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 53b7a8bbbdc..a57b986cba5 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -1114,6 +1114,47 @@ static bool finishPlayerIdleAnimation(const RoomSetupState &state, RoomPlayerSta
 
 } // End of anonymous namespace
 
+void Player::rebindAfterSceneRefresh(const RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, bool preservePlayerPlacement) {
+	playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
+	if (playerState.entity) {
+		if (!preservePlayerPlacement || playerState.facing < 0) {
+			playerState.centerX = state.playerSpawnX;
+			playerState.bottomY = state.playerSpawnY;
+			playerState.z = (float)state.playerSpawnZ;
+			playerState.facing = state.playerFacing;
+		}
+
+		const int facing = playerState.facing >= 0 ? playerState.facing : state.playerFacing;
+		(void)setIdleAnimation(playerState, facing);
+		(void)applyRoomActorPlacement(state, *playerState.entity,
+			playerState.centerX, playerState.bottomY, playerState.z);
+	}
+
+	// Death state survives a scene refresh.
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.attackActive = false;
+	playerState.attackFirstFrame = -1;
+	playerState.attackLastFrame = -1;
+	playerState.attackContactFrame = -1;
+	playerState.attackResumeFacing = -1;
+	playerState.attackSoundPlayed = false;
+	playerState.attackSoundPlaybackFrame = -1;
+	playerState.attackContactResolved = false;
+	playerState.attackTargetName.clear();
+	playerState.attackTargetClassId = -1;
+	playerState.hitActive = false;
+	playerState.hitFirstFrame = -1;
+	playerState.hitLastFrame = -1;
+	playerState.hitResumeFacing = -1;
+	playerState.hitKnockbackRemainingX = 0;
+	playerState.hitKnockbackDecayStep = 0;
+	debugC(2, kDebugPlayer, "Harvester: rebound player after scene refresh room='%s' entity=%d preserve_placement=%d",
+		state.roomName.c_str(), playerState.entity != nullptr, preservePlayerPlacement);
+}
+
 void Player::syncAnimationPlacement(RoomPlayerState &playerState) {
 	if (!playerState.entity)
 		return;
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index bdf7d89649a..414ac7337fd 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -26,8 +26,12 @@
 
 namespace Harvester {
 
+class EntityManager;
+
 class Player {
 public:
+	static void rebindAfterSceneRefresh(const RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, bool preservePlayerPlacement);
 	static void syncAnimationPlacement(RoomPlayerState &playerState);
 	static float computeDepthScale(const RoomSetupState &state, float z);
 	static int resolveFacingFrame(int facing);
diff --git a/engines/harvester/room_scene_runtime.cpp b/engines/harvester/room_scene_runtime.cpp
index 5a476392e51..c4ab1749e8e 100644
--- a/engines/harvester/room_scene_runtime.cpp
+++ b/engines/harvester/room_scene_runtime.cpp
@@ -682,6 +682,16 @@ Entity *RoomSceneRuntime::spawnSceneMonsterEntityFromRecord(const RoomSetupState
 	return entity;
 }
 
+void RoomSceneRuntime::restoreRoomMonsterAnimations() {
+	if (_entityManager) {
+		for (const MonsterRecord &monster : _state.roomMonsters) {
+			Entity *entity = _entityManager->findSceneEntityByName(monster.monsterName);
+			if (entity)
+				Monster::applyAnimation(*entity, monster);
+		}
+	}
+}
+
 void RoomSceneRuntime::populateRoomRegions() {
 	if (!_entityManager)
 		return;
diff --git a/engines/harvester/room_scene_runtime.h b/engines/harvester/room_scene_runtime.h
index 8df8f83a3de..15febc6fbae 100644
--- a/engines/harvester/room_scene_runtime.h
+++ b/engines/harvester/room_scene_runtime.h
@@ -42,6 +42,7 @@ public:
 	void populateRoomObjects(const Common::Array<ObjectRecord> &drawableObjects);
 	void populateRoomNpcs();
 	void populateRoomMonsters();
+	void restoreRoomMonsterAnimations();
 
 	void removeSceneEntityByName(const Common::String &entityName);
 	static Common::Rect getSceneObjectBounds(const ObjectRecord &object);
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index 557da4e273f..fa83ff4c9c0 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -25,7 +25,6 @@
 #include "harvester/flow.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
-#include "harvester/monster.h"
 #include "harvester/player.h"
 #include "harvester/room_combat.h"
 #include "harvester/runtime_entity.h"
@@ -170,48 +169,8 @@ bool RoomSession::refreshCurrentScene(bool preservePlayerPlacement) {
 	_combatEffects.clearHitEffects();
 	if (!_flow.populateRoomSceneEntities(_scene.state, _scene.sceneObjects, _scene.sceneAnimations))
 		return false;
-	if (_entityManager) {
-		for (const MonsterRecord &monster : _scene.state.roomMonsters) {
-			Entity *entity = _entityManager->findSceneEntityByName(monster.monsterName);
-			if (entity)
-				Monster::applyAnimation(*entity, monster);
-		}
-	}
-
-	_playerState.entity = _entityManager ? _entityManager->findSceneEntityByName("PLAYER") : nullptr;
-	if (_playerState.entity) {
-		if (!preservePlayerPlacement || _playerState.facing < 0) {
-			_playerState.centerX = _scene.state.playerSpawnX;
-			_playerState.bottomY = _scene.state.playerSpawnY;
-			_playerState.z = (float)_scene.state.playerSpawnZ;
-			_playerState.facing = _scene.state.playerFacing;
-		}
-
-		const int facing = _playerState.facing >= 0 ? _playerState.facing : _scene.state.playerFacing;
-		(void)Player::setIdleAnimation(_playerState, facing);
-		(void)applyRoomActorPlacement(_scene.state, *_playerState.entity,
-			_playerState.centerX, _playerState.bottomY, _playerState.z);
-	}
-
-	_playerState.hasMoveTarget = false;
-	_playerState.turnActive = false;
-	_playerState.turnTargetFacing = -1;
-	_playerState.attackActive = false;
-	_playerState.attackFirstFrame = -1;
-	_playerState.attackLastFrame = -1;
-	_playerState.attackContactFrame = -1;
-	_playerState.attackResumeFacing = -1;
-	_playerState.attackSoundPlayed = false;
-	_playerState.attackSoundPlaybackFrame = -1;
-	_playerState.attackContactResolved = false;
-	_playerState.attackTargetName.clear();
-	_playerState.attackTargetClassId = -1;
-	_playerState.hitActive = false;
-	_playerState.hitFirstFrame = -1;
-	_playerState.hitLastFrame = -1;
-	_playerState.hitResumeFacing = -1;
-	_playerState.hitKnockbackRemainingX = 0;
-	_playerState.hitKnockbackDecayStep = 0;
+	_sceneRuntime.restoreRoomMonsterAnimations();
+	Player::rebindAfterSceneRefresh(_scene.state, _playerState, _entityManager, preservePlayerPlacement);
 	_playerState.combatLoadout =
 		_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
 	_combat.resetForSceneRefresh(_scene.state);


Commit: 4a1917385768bf0252d5fa73067af10a534cd059
    https://github.com/scummvm/scummvm/commit/4a1917385768bf0252d5fa73067af10a534cd059
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Refactor combat target selection and interaction methods

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_combat.cpp
    engines/harvester/room_combat.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 77dccb64a82..4aff9a22223 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1077,14 +1077,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			return -1;
 		};
-		auto findRoomMonsterRecordByName = [&](const Common::String &monsterName) -> MonsterRecord * {
-			for (MonsterRecord &monster : scene.state.roomMonsters) {
-				if (monster.monsterName.equalsIgnoreCase(monsterName))
-					return &monster;
-			}
-
-			return nullptr;
-		};
 		auto findRoomMonsterIndexByName = [&](const Common::String &monsterName) -> int {
 			for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
 				if (scene.state.roomMonsters[i].monsterName.equalsIgnoreCase(monsterName))
@@ -1124,194 +1116,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				monster.monsterName.c_str());
 			return true;
 		};
-		auto findMonsterTargetAtPoint = [&](const Common::Point &point) -> MonsterRecord * {
-			MonsterRecord *bestMonster = nullptr;
-			int bestZ = -0x7fffffff;
-			int bestTop = -0x7fffffff;
-			for (MonsterRecord &monster : scene.state.roomMonsters) {
-				if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
-					continue;
-
-				Entity *entity = findSceneRuntimeEntity(monster.monsterName);
-				if (!entity || !entity->isVisible())
-					continue;
-
-				const Common::Rect entityRect = entity->getScreenRect();
-				if (!entityRect.contains(point))
-					continue;
-
-				if (!bestMonster || monster.posZ > bestZ ||
-						(monster.posZ == bestZ && entityRect.top > bestTop)) {
-					bestMonster = &monster;
-					bestZ = monster.posZ;
-					bestTop = entityRect.top;
-				}
-			}
-
-			return bestMonster;
-		};
-		auto findOverlappingMonsterTarget = [&]() -> MonsterRecord * {
-			if (!playerState.entity)
-				return nullptr;
-
-			for (MonsterRecord &monster : scene.state.roomMonsters) {
-				if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
-					continue;
-
-				Entity *entity = findSceneRuntimeEntity(monster.monsterName);
-				if (entity && areCombatantsWithinRoomCombatReach(scene.state,
-						*playerState.entity, playerState.z, *entity, (float)monster.posZ, monster.engageDistance))
-					return &monster;
-			}
-
-			return nullptr;
-		};
-		auto findOverlappingNpcTarget = [&]() -> NpcRecord * {
-			if (!playerState.entity)
-				return nullptr;
-
-			for (NpcRecord &npc : scene.state.roomNpcs) {
-				if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
-					continue;
-
-				Entity *entity = findSceneRuntimeEntity(npc.npcName);
-				if (entity && areCombatantsWithinRoomCombatReach(scene.state,
-						*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
-					return &npc;
-			}
-
-			return nullptr;
-		};
-		auto playerAttackCanReachTarget = [&](Entity *targetEntity, int engageDistance = 0) {
-			if (!playerState.entity || !targetEntity)
-				return false;
-			if (Player::isProjectileCombatLoadout(playerState.combatLoadout))
-				return true;
-
-			return areCombatantsWithinRoomCombatReach(scene.state,
-				*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
-		};
-		enum NativeAttackSide {
-			kNativeAttackSideNone,
-			kNativeAttackSideLeft,
-			kNativeAttackSideRight
-		};
-		enum NativeAttackBand {
-			kNativeAttackBandMid,
-			kNativeAttackBandUpper,
-			kNativeAttackBandLower
-		};
-		auto resolveNativePlayerAttackGeometry = [](int attackFirstFrame,
-				NativeAttackSide &attackSide, NativeAttackBand &attackBand) {
-			attackSide = kNativeAttackSideNone;
-			attackBand = kNativeAttackBandMid;
-
-			switch (attackFirstFrame) {
-			case 0x50:
-				attackSide = kNativeAttackSideLeft;
-				attackBand = kNativeAttackBandUpper;
-				return true;
-			case 0x5a:
-				attackSide = kNativeAttackSideLeft;
-				attackBand = kNativeAttackBandMid;
-				return true;
-			case 0x64:
-				attackSide = kNativeAttackSideLeft;
-				attackBand = kNativeAttackBandLower;
-				return true;
-			case 0x6e:
-				attackSide = kNativeAttackSideRight;
-				attackBand = kNativeAttackBandUpper;
-				return true;
-			case 0x78:
-				attackSide = kNativeAttackSideRight;
-				attackBand = kNativeAttackBandMid;
-				return true;
-			case 0x82:
-				attackSide = kNativeAttackSideRight;
-				attackBand = kNativeAttackBandLower;
-				return true;
-			default:
-				return false;
-			}
-		};
-		auto findProjectileContactMonsterTarget = [&]() -> MonsterRecord * {
-			if (!playerState.entity)
-				return nullptr;
-
-			NativeAttackSide attackSide = kNativeAttackSideNone;
-			NativeAttackBand attackBand = kNativeAttackBandMid;
-			if (!resolveNativePlayerAttackGeometry(playerState.attackFirstFrame, attackSide, attackBand))
-				return nullptr;
-
-			const Common::Rect playerRect = playerState.entity->getScreenRect();
-			const int playerLeft = playerRect.left;
-			const int playerRight = playerRect.right;
-			const int playerCenterX = playerLeft + playerRect.width() / 2;
-			MonsterRecord *bestMonster = nullptr;
-			Entity *bestEntity = nullptr;
-			int bestLeadingEdge = attackSide == kNativeAttackSideLeft ? -0x7fffffff : 0x7fffffff;
-
-			for (MonsterRecord &monster : scene.state.roomMonsters) {
-				if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
-					continue;
-
-				Entity *entity = findSceneRuntimeEntity(monster.monsterName);
-				if (!entity || !entity->isVisible() ||
-						!doRuntimeEntityDepthExtentsOverlap(*playerState.entity, *entity)) {
-					continue;
-				}
 
-				const Common::Rect targetRect = entity->getScreenRect();
-				const int targetLeft = targetRect.left;
-				const int targetCenterX = targetLeft + targetRect.width() / 2;
-				bool matchesDirection = false;
-				if (attackSide == kNativeAttackSideLeft) {
-					if (attackBand == kNativeAttackBandMid)
-						matchesDirection = targetLeft < playerCenterX;
-					else
-						matchesDirection =
-							targetLeft < playerCenterX && targetCenterX > playerLeft - kNativeAttackSideWindow;
-					if (!matchesDirection || targetLeft <= bestLeadingEdge)
-						continue;
-				} else {
-					if (attackBand == kNativeAttackBandMid)
-						matchesDirection = playerCenterX < targetLeft;
-					else
-						matchesDirection =
-							playerCenterX < targetLeft && targetCenterX < playerRight + kNativeAttackSideWindow;
-					if (!matchesDirection || targetLeft >= bestLeadingEdge)
-						continue;
-				}
-
-				bestMonster = &monster;
-				bestEntity = entity;
-				bestLeadingEdge = targetLeft;
-			}
-
-			if (bestMonster && bestEntity) {
-				debugC(1, kDebugCombat,
-					"Harvester: combat player projectile contact fallback target='%s' attack_frame=%d player_rect=(%d,%d)-(%d,%d) target_rect=(%d,%d)-(%d,%d)",
-					bestMonster->monsterName.c_str(), playerState.attackFirstFrame,
-					playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
-					bestEntity->getScreenRect().left, bestEntity->getScreenRect().top,
-					bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
-			}
-
-			return bestMonster;
-		};
-		auto describeCombatTargetClass = [](int classId) -> const char * {
-		switch (classId) {
-		case kRuntimeEntityClassNpc:
-			return "npc";
-		case kRuntimeEntityClassMonster:
-			return "monster";
-		case kRuntimeEntityClassPlayer:
-			return "player";
-		default:
-			return "none";
-		}
-	};
 	auto handleCombatInteraction = [&](const InteractionResult &interaction) -> Common::Error {
 		bool didTransition = false;
 		Common::Error interactionError =
@@ -1411,70 +1216,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 	auto killDebugActiveMonster = [&]() -> Common::Error {
 		if (!_engine.isCombatDebugEnabled())
 			return Common::kNoError;
-
-		auto isKillableMonster = [&](const MonsterRecord *monster) {
-			return monster && monster->active && monster->visible && monster->currentHitPoints > 0;
-		};
-		auto selectMonsterEntity = [&](const MonsterRecord &monster) -> Entity * {
-			Entity *entity = findSceneRuntimeEntity(monster.monsterName);
-			return (entity && entity->isVisible()) ? entity : nullptr;
-		};
-		auto resolveKillTarget = [&]() -> MonsterRecord * {
-			if (playerState.attackTargetClassId == kRuntimeEntityClassMonster) {
-				MonsterRecord *monster = findRoomMonsterRecordByName(playerState.attackTargetName);
-				if (isKillableMonster(monster) && selectMonsterEntity(*monster))
-					return monster;
-			}
-
-			if (MonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
-				if (isKillableMonster(monster) && selectMonsterEntity(*monster))
-					return monster;
-			}
-
-			if (playerState.entity) {
-				for (uint i = 0; i < scene.state.roomMonsters.size() && i < monsterCombatStates.size(); ++i) {
-					MonsterRecord &monster = scene.state.roomMonsters[i];
-					const RoomMonsterCombatState &combatState = monsterCombatStates[i];
-					if (!combatState.attackActive || !isKillableMonster(&monster) || !selectMonsterEntity(monster))
-						continue;
-					if (!combatState.attackTargetName.empty() &&
-							playerState.entity->getName().equalsIgnoreCase(combatState.attackTargetName)) {
-						return &monster;
-					}
-				}
-			}
-
-			if (!playerState.entity)
-				return nullptr;
-
-			const Common::Rect playerRect = playerState.entity->getScreenRect();
-			const int playerCenterX = playerRect.left + playerRect.width() / 2;
-			MonsterRecord *bestMonster = nullptr;
-			int bestScore = 0x7fffffff;
-			for (MonsterRecord &monster : scene.state.roomMonsters) {
-				if (!isKillableMonster(&monster))
-					continue;
-
-				Entity *entity = selectMonsterEntity(monster);
-				if (!entity)
-					continue;
-
-				const Common::Rect monsterRect = entity->getScreenRect();
-				const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
-				const int score = ABS(monsterCenterX - playerCenterX) +
-					ABS(monster.posZ - roundRoomCombatFloat(playerState.z)) * 8;
-				if (!bestMonster || score < bestScore) {
-					bestMonster = &monster;
-					bestScore = score;
-				}
-			}
-
-			return bestMonster;
-		};
-
-		MonsterRecord *monster = resolveKillTarget();
-		Entity *monsterEntity = monster ? selectMonsterEntity(*monster) : nullptr;
-		if (!monster || !monsterEntity) {
+		MonsterRecord *monster = combat.resolveKillTarget(scene.state, playerState, entityManager, _mousePos);
+		Entity *monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
+		if (!monster || !monsterEntity || !monsterEntity->isVisible()) {
 			debugC(1, kDebugCombat,
 				"Harvester: debug combat kill skipped reason='no active monster' cursor=(%d,%d)",
 				_mousePos.x, _mousePos.y);
@@ -1550,142 +1294,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		return handleCombatInteraction(interaction);
 	};
 	auto capturePlayerAttackTarget = [&]() {
-			playerState.attackTargetName.clear();
-			playerState.attackTargetClassId = -1;
-
-			const RoomHoverState hoverState = resolveRoomHoverState(
-				_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
-				_mousePos, flow._menuTextConfig, &flow._dialogue);
-			if (hoverState.npc) {
-				playerState.attackTargetName = hoverState.npc->npcName;
-				playerState.attackTargetClassId = kRuntimeEntityClassNpc;
-				debugC(1, kDebugCombat,
-					"Harvester: combat player target capture class='npc' name='%s' cursor=(%d,%d)",
-					playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
-				return;
-			}
-
-		if (MonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
-			playerState.attackTargetName = monster->monsterName;
-			playerState.attackTargetClassId = kRuntimeEntityClassMonster;
-			debugC(1, kDebugCombat,
-				"Harvester: combat player target capture class='monster' name='%s' cursor=(%d,%d)",
-				playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
-			return;
-		}
-
-		debugC(1, kDebugCombat,
-			"Harvester: combat player target capture class='none' cursor=(%d,%d)",
-			_mousePos.x, _mousePos.y);
-	};
-	auto capturePlayerAttackTargetForKeyboard = [&](bool attackLeft, bool attackRight, bool attackUp, bool attackDown) {
 		playerState.attackTargetName.clear();
 		playerState.attackTargetClassId = -1;
-		if (!playerState.entity)
-			return;
-
-		NativeAttackSide attackSide = kNativeAttackSideNone;
-		NativeAttackBand attackBand = kNativeAttackBandMid;
-		if (attackRight) {
-			attackSide = kNativeAttackSideRight;
-			attackBand = kNativeAttackBandMid;
-		} else if (attackLeft) {
-			attackSide = kNativeAttackSideLeft;
-			attackBand = kNativeAttackBandMid;
-		} else if (attackUp) {
-			if (playerState.facing == 2) {
-				attackSide = kNativeAttackSideRight;
-				attackBand = kNativeAttackBandUpper;
-			} else if (playerState.facing == 1) {
-				attackSide = kNativeAttackSideLeft;
-				attackBand = kNativeAttackBandUpper;
-			}
-		} else if (attackDown) {
-			if (playerState.facing == 2) {
-				attackSide = kNativeAttackSideRight;
-				attackBand = kNativeAttackBandLower;
-			} else if (playerState.facing == 1) {
-				attackSide = kNativeAttackSideLeft;
-				attackBand = kNativeAttackBandLower;
-			}
-		}
-		if (attackSide == kNativeAttackSideNone) {
-			debugC(1, kDebugCombat,
-				"Harvester: combat player keyboard target capture class='none' input=(L=%d R=%d U=%d D=%d) facing=%d reason='no native attack family'",
-				attackLeft, attackRight, attackUp, attackDown, playerState.facing);
-			return;
-		}
-
-		const Common::Rect playerRect = playerState.entity->getScreenRect();
-		const int playerLeft = playerRect.left;
-		const int playerRight = playerRect.right;
-		const int playerCenterX = playerState.centerX;
-		Entity *bestEntity = nullptr;
-		Common::String bestName;
-		int bestClassId = -1;
-		int bestLeadingEdge = attackSide == kNativeAttackSideLeft ? -0x7fffffff : 0x7fffffff;
-
-		auto considerTarget = [&](Entity *targetEntity, const Common::String &targetName, int classId) {
-			if (!targetEntity || !targetEntity->isVisible() ||
-					!doRuntimeEntityDepthExtentsOverlap(*playerState.entity, *targetEntity)) {
-				return;
-			}
 
-			const Common::Rect targetRect = targetEntity->getScreenRect();
-			const int targetLeft = targetRect.left;
-			const int targetCenterX = targetLeft + (targetRect.width() / 2);
-			bool matchesDirection = false;
-			if (attackSide == kNativeAttackSideLeft) {
-				if (attackBand == kNativeAttackBandMid)
-					matchesDirection = targetLeft < playerCenterX;
-				else
-					matchesDirection =
-						targetLeft < playerCenterX && targetCenterX > playerLeft - kNativeAttackSideWindow;
-				if (!matchesDirection || targetLeft <= bestLeadingEdge)
-					return;
-			} else {
-				if (attackBand == kNativeAttackBandMid)
-					matchesDirection = playerCenterX < targetLeft;
-				else
-					matchesDirection =
-						playerCenterX < targetLeft && targetCenterX < playerRight + kNativeAttackSideWindow;
-				if (!matchesDirection || targetLeft >= bestLeadingEdge)
-					return;
-			}
-
-			bestEntity = targetEntity;
-			bestName = targetName;
-			bestClassId = classId;
-			bestLeadingEdge = targetLeft;
-		};
-
-		for (NpcRecord &npc : scene.state.roomNpcs) {
-			if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
-				continue;
-			considerTarget(findSceneRuntimeEntity(npc.npcName), npc.npcName, kRuntimeEntityClassNpc);
-		}
-		for (MonsterRecord &monster : scene.state.roomMonsters) {
-			if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
-				continue;
-			considerTarget(findSceneRuntimeEntity(monster.monsterName), monster.monsterName, kRuntimeEntityClassMonster);
-		}
-
-		if (!bestEntity) {
-			debugC(1, kDebugCombat,
-				"Harvester: combat player keyboard target capture class='none' input=(L=%d R=%d U=%d D=%d) facing=%d reason='no directional target'",
-				attackLeft, attackRight, attackUp, attackDown, playerState.facing);
-			return;
-		}
+		const RoomHoverState hoverState = resolveRoomHoverState(
+			_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
+			_mousePos, flow._menuTextConfig, &flow._dialogue);
+		combat.capturePlayerAttackTarget(scene.state, playerState, entityManager, _mousePos, hoverState.npc);
+	};
 
-		playerState.attackTargetName = bestName;
-		playerState.attackTargetClassId = bestClassId;
-		debugC(1, kDebugCombat,
-			"Harvester: combat player keyboard target capture class='%s' name='%s' input=(L=%d R=%d U=%d D=%d) facing=%d target_rect=(%d,%d)-(%d,%d)",
-			describeCombatTargetClass(bestClassId), bestName.c_str(),
-			attackLeft, attackRight, attackUp, attackDown, playerState.facing,
-			bestEntity->getScreenRect().left, bestEntity->getScreenRect().top,
-			bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
-		};
 		auto startMonsterHitReaction = [&](MonsterRecord &monster, Entity &monsterEntity,
 				RoomMonsterCombatState *combatState, int attackerAttackFirstFrame) {
 			if (combatEffects.spawnCombatHitEffect(monsterEntity, monster.monsterName))
@@ -1754,18 +1371,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (!script)
 			return Common::kNoError;
 
-		if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
-			if (NpcRecord *fallbackNpc = findOverlappingNpcTarget()) {
-				playerState.attackTargetName = fallbackNpc->npcName;
-				playerState.attackTargetClassId = kRuntimeEntityClassNpc;
-			}
-		}
+		combat.captureOverlappingNpcAttackTarget(scene.state, playerState, entityManager);
 		if (playerState.attackTargetClassId == kRuntimeEntityClassNpc) {
-			NpcRecord *npc = findRoomNpcRecordByName(playerState.attackTargetName);
-			Entity *npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
-			if (!npc || !npcEntity || !playerAttackCanReachTarget(npcEntity))
-				npc = Player::isProjectileCombatLoadout(playerState.combatLoadout) ? nullptr : findOverlappingNpcTarget();
-			npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
+			Entity *npcEntity = nullptr;
+			NpcRecord *npc = combat.selectNpcAttackContactTarget(scene.state, playerState, entityManager, npcEntity);
 			if (!npc) {
 				debugC(1, kDebugCombat,
 					"Harvester: combat player attack missed class='npc' target='%s' reason='no reachable npc'",
@@ -1813,24 +1422,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return Common::kNoError;
 		}
 
-		MonsterRecord *monster = nullptr;
-		if (playerState.attackTargetClassId == kRuntimeEntityClassMonster)
-			monster = findRoomMonsterRecordByName(playerState.attackTargetName);
-		Entity *monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
-		if (!monster || !monsterEntity || !playerAttackCanReachTarget(monsterEntity, monster->engageDistance)) {
-			if (!Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
-				monster = findOverlappingMonsterTarget();
-			} else if (playerState.attackTargetClassId < 0) {
-				monster = findProjectileContactMonsterTarget();
-				if (monster) {
-					playerState.attackTargetName = monster->monsterName;
-					playerState.attackTargetClassId = kRuntimeEntityClassMonster;
-				}
-			} else {
-				monster = nullptr;
-			}
-			monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
-		}
+		Entity *monsterEntity = nullptr;
+		MonsterRecord *monster = combat.selectMonsterAttackContactTarget(scene.state, playerState, entityManager, monsterEntity);
 		if (!monster || !monsterEntity) {
 			debugC(1, kDebugCombat,
 				"Harvester: combat player attack missed class='monster' target='%s' reason='no reachable monster'",
@@ -3441,7 +3034,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				!idleState.active && !idleState.exiting) {
 			const uint32 now = Player::getRuntimeClockTicks();
 			if (nextKeyboardAttackAllowedTick == 0 || (int32)(now - nextKeyboardAttackAllowedTick) >= 0) {
-				capturePlayerAttackTargetForKeyboard(moveLeft, moveRight, moveUp, moveDown);
+				combat.capturePlayerAttackTargetForKeyboard(scene.state, playerState, entityManager, moveLeft, moveRight, moveUp, moveDown);
 				if (Player::startKeyboardAttackAnimation(
 						scene.state, playerState, moveLeft, moveRight, moveUp, moveDown)) {
 					nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
index 1b05c868c2a..4210bd4ab7e 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/room_combat.cpp
@@ -62,6 +62,81 @@ const int kNativeDefaultNpcDeathDamageType = 1;
 
 namespace {
 
+static Entity *findSceneRuntimeEntity(EntityManager *entityManager, const Common::String &name) {
+	return entityManager ? entityManager->findSceneEntityByName(name) : nullptr;
+}
+
+static MonsterRecord *findRoomMonsterRecordByName(RoomSetupState &state, const Common::String &name) {
+	for (MonsterRecord &monster : state.roomMonsters) {
+		if (monster.monsterName.equalsIgnoreCase(name))
+			return &monster;
+	}
+	return nullptr;
+}
+
+static NpcRecord *findRoomNpcRecordByName(RoomSetupState &state, const Common::String &name) {
+	for (NpcRecord &npc : state.roomNpcs) {
+		if (npc.npcName.equalsIgnoreCase(name))
+			return &npc;
+	}
+	return nullptr;
+}
+
+static bool isKillableMonster(const MonsterRecord *monster) {
+	return monster && monster->active && monster->visible && monster->currentHitPoints > 0;
+}
+
+static Entity *selectMonsterEntity(EntityManager *entityManager, const MonsterRecord &monster) {
+	Entity *entity = findSceneRuntimeEntity(entityManager, monster.monsterName);
+	return (entity && entity->isVisible()) ? entity : nullptr;
+}
+
+enum NativeAttackSide {
+	kNativeAttackSideNone,
+	kNativeAttackSideLeft,
+	kNativeAttackSideRight
+};
+
+enum NativeAttackBand {
+	kNativeAttackBandMid,
+	kNativeAttackBandUpper,
+	kNativeAttackBandLower
+};
+
+static bool resolveNativePlayerAttackGeometry(int attackFirstFrame, NativeAttackSide &attackSide, NativeAttackBand &attackBand) {
+	attackSide = kNativeAttackSideNone;
+	attackBand = kNativeAttackBandMid;
+
+	switch (attackFirstFrame) {
+	case 0x50:
+		attackSide = kNativeAttackSideLeft;
+		attackBand = kNativeAttackBandUpper;
+		return true;
+	case 0x5a:
+		attackSide = kNativeAttackSideLeft;
+		attackBand = kNativeAttackBandMid;
+		return true;
+	case 0x64:
+		attackSide = kNativeAttackSideLeft;
+		attackBand = kNativeAttackBandLower;
+		return true;
+	case 0x6e:
+		attackSide = kNativeAttackSideRight;
+		attackBand = kNativeAttackBandUpper;
+		return true;
+	case 0x78:
+		attackSide = kNativeAttackSideRight;
+		attackBand = kNativeAttackBandMid;
+		return true;
+	case 0x82:
+		attackSide = kNativeAttackSideRight;
+		attackBand = kNativeAttackBandLower;
+		return true;
+	default:
+		return false;
+	}
+}
+
 static byte findNearestPaletteColor(const byte *palette, byte red, byte green, byte blue) {
 	if (!palette)
 		return 0;
@@ -488,6 +563,391 @@ int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
 		: kDefaultMonsterAttackContactFrameOffset;
 }
 
+const char *describeCombatTargetClass(int classId) {
+	switch (classId) {
+	case kRuntimeEntityClassNpc:
+		return "npc";
+	case kRuntimeEntityClassMonster:
+		return "monster";
+	case kRuntimeEntityClassPlayer:
+		return "player";
+	default:
+		return "none";
+	}
+}
+
+MonsterRecord *RoomCombat::findMonsterTargetAtPoint(RoomSetupState &state, EntityManager *entityManager, const Common::Point &point) const {
+	MonsterRecord *bestMonster = nullptr;
+	int bestZ = -0x7fffffff;
+	int bestTop = -0x7fffffff;
+	for (MonsterRecord &monster : state.roomMonsters) {
+		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+			continue;
+
+		Entity *entity = findSceneRuntimeEntity(entityManager, monster.monsterName);
+		if (!entity || !entity->isVisible())
+			continue;
+
+		const Common::Rect entityRect = entity->getScreenRect();
+		if (!entityRect.contains(point))
+			continue;
+
+		if (!bestMonster || monster.posZ > bestZ ||
+				(monster.posZ == bestZ && entityRect.top > bestTop)) {
+			bestMonster = &monster;
+			bestZ = monster.posZ;
+			bestTop = entityRect.top;
+		}
+	}
+
+	return bestMonster;
+}
+
+MonsterRecord *RoomCombat::findOverlappingMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+		EntityManager *entityManager) const {
+	if (!playerState.entity)
+		return nullptr;
+
+	for (MonsterRecord &monster : state.roomMonsters) {
+		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+			continue;
+
+		Entity *entity = findSceneRuntimeEntity(entityManager, monster.monsterName);
+		if (entity && areCombatantsWithinRoomCombatReach(state,
+				*playerState.entity, playerState.z, *entity, (float)monster.posZ, monster.engageDistance))
+			return &monster;
+	}
+
+	return nullptr;
+}
+
+NpcRecord *RoomCombat::findOverlappingNpcTarget(RoomSetupState &state, const RoomPlayerState &playerState, EntityManager *entityManager) const {
+	if (!playerState.entity)
+		return nullptr;
+
+	for (NpcRecord &npc : state.roomNpcs) {
+		if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
+			continue;
+
+		Entity *entity = findSceneRuntimeEntity(entityManager, npc.npcName);
+		if (entity && areCombatantsWithinRoomCombatReach(state,
+				*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
+			return &npc;
+	}
+
+	return nullptr;
+}
+
+bool RoomCombat::playerAttackCanReachTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+		Entity *targetEntity, int engageDistance) const {
+	if (!playerState.entity || !targetEntity)
+		return false;
+	if (Player::isProjectileCombatLoadout(playerState.combatLoadout))
+		return true;
+
+	return areCombatantsWithinRoomCombatReach(state,
+		*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
+}
+
+MonsterRecord *RoomCombat::findProjectileContactMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+		EntityManager *entityManager) const {
+	if (!playerState.entity)
+		return nullptr;
+
+	NativeAttackSide attackSide = kNativeAttackSideNone;
+	NativeAttackBand attackBand = kNativeAttackBandMid;
+	if (!resolveNativePlayerAttackGeometry(playerState.attackFirstFrame, attackSide, attackBand))
+		return nullptr;
+
+	const Common::Rect playerRect = playerState.entity->getScreenRect();
+	const int playerLeft = playerRect.left;
+	const int playerRight = playerRect.right;
+	const int playerCenterX = playerLeft + playerRect.width() / 2;
+	MonsterRecord *bestMonster = nullptr;
+	Entity *bestEntity = nullptr;
+	int bestLeadingEdge = attackSide == kNativeAttackSideLeft ? -0x7fffffff : 0x7fffffff;
+
+	for (MonsterRecord &monster : state.roomMonsters) {
+		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+			continue;
+
+		Entity *entity = findSceneRuntimeEntity(entityManager, monster.monsterName);
+		if (!entity || !entity->isVisible() ||
+				!doRuntimeEntityDepthExtentsOverlap(*playerState.entity, *entity)) {
+			continue;
+		}
+
+		const Common::Rect targetRect = entity->getScreenRect();
+		const int targetLeft = targetRect.left;
+		const int targetCenterX = targetLeft + targetRect.width() / 2;
+		bool matchesDirection = false;
+		if (attackSide == kNativeAttackSideLeft) {
+			if (attackBand == kNativeAttackBandMid)
+				matchesDirection = targetLeft < playerCenterX;
+			else
+				matchesDirection =
+					targetLeft < playerCenterX && targetCenterX > playerLeft - kNativeAttackSideWindow;
+			if (!matchesDirection || targetLeft <= bestLeadingEdge)
+				continue;
+		} else {
+			if (attackBand == kNativeAttackBandMid)
+				matchesDirection = playerCenterX < targetLeft;
+			else
+				matchesDirection =
+					playerCenterX < targetLeft && targetCenterX < playerRight + kNativeAttackSideWindow;
+			if (!matchesDirection || targetLeft >= bestLeadingEdge)
+				continue;
+		}
+
+		bestMonster = &monster;
+		bestEntity = entity;
+		bestLeadingEdge = targetLeft;
+	}
+
+	if (bestMonster && bestEntity) {
+		debugC(1, kDebugCombat,
+			"Harvester: combat player projectile contact fallback target='%s' attack_frame=%d player_rect=(%d,%d)-(%d,%d) target_rect=(%d,%d)-(%d,%d)",
+			bestMonster->monsterName.c_str(), playerState.attackFirstFrame,
+			playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
+			bestEntity->getScreenRect().left, bestEntity->getScreenRect().top,
+			bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
+	}
+
+	return bestMonster;
+}
+
+void RoomCombat::capturePlayerAttackTargetForKeyboard(RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, bool attackLeft, bool attackRight, bool attackUp, bool attackDown) const {
+	playerState.attackTargetName.clear();
+	playerState.attackTargetClassId = -1;
+	if (!playerState.entity)
+		return;
+
+	NativeAttackSide attackSide = kNativeAttackSideNone;
+	NativeAttackBand attackBand = kNativeAttackBandMid;
+	if (attackRight) {
+		attackSide = kNativeAttackSideRight;
+		attackBand = kNativeAttackBandMid;
+	} else if (attackLeft) {
+		attackSide = kNativeAttackSideLeft;
+		attackBand = kNativeAttackBandMid;
+	} else if (attackUp) {
+		if (playerState.facing == 2) {
+			attackSide = kNativeAttackSideRight;
+			attackBand = kNativeAttackBandUpper;
+		} else if (playerState.facing == 1) {
+			attackSide = kNativeAttackSideLeft;
+			attackBand = kNativeAttackBandUpper;
+		}
+	} else if (attackDown) {
+		if (playerState.facing == 2) {
+			attackSide = kNativeAttackSideRight;
+			attackBand = kNativeAttackBandLower;
+		} else if (playerState.facing == 1) {
+			attackSide = kNativeAttackSideLeft;
+			attackBand = kNativeAttackBandLower;
+		}
+	}
+	if (attackSide == kNativeAttackSideNone) {
+		debugC(1, kDebugCombat,
+			"Harvester: combat player keyboard target capture class='none' input=(L=%d R=%d U=%d D=%d) facing=%d reason='no native attack family'",
+			attackLeft, attackRight, attackUp, attackDown, playerState.facing);
+		return;
+	}
+
+	const Common::Rect playerRect = playerState.entity->getScreenRect();
+	const int playerLeft = playerRect.left;
+	const int playerRight = playerRect.right;
+	// Keyboard selection uses the cached center; projectile fallback uses the frame rectangle.
+	const int playerCenterX = playerState.centerX;
+	Entity *bestEntity = nullptr;
+	Common::String bestName;
+	int bestClassId = -1;
+	int bestLeadingEdge = attackSide == kNativeAttackSideLeft ? -0x7fffffff : 0x7fffffff;
+
+	auto considerTarget = [&](Entity *targetEntity, const Common::String &targetName, int classId) {
+		if (!targetEntity || !targetEntity->isVisible() ||
+				!doRuntimeEntityDepthExtentsOverlap(*playerState.entity, *targetEntity)) {
+			return;
+		}
+
+		const Common::Rect targetRect = targetEntity->getScreenRect();
+		const int targetLeft = targetRect.left;
+		const int targetCenterX = targetLeft + (targetRect.width() / 2);
+		bool matchesDirection = false;
+		if (attackSide == kNativeAttackSideLeft) {
+			if (attackBand == kNativeAttackBandMid)
+				matchesDirection = targetLeft < playerCenterX;
+			else
+				matchesDirection =
+					targetLeft < playerCenterX && targetCenterX > playerLeft - kNativeAttackSideWindow;
+			if (!matchesDirection || targetLeft <= bestLeadingEdge)
+				return;
+		} else {
+			if (attackBand == kNativeAttackBandMid)
+				matchesDirection = playerCenterX < targetLeft;
+			else
+				matchesDirection =
+					playerCenterX < targetLeft && targetCenterX < playerRight + kNativeAttackSideWindow;
+			if (!matchesDirection || targetLeft >= bestLeadingEdge)
+				return;
+		}
+
+		bestEntity = targetEntity;
+		bestName = targetName;
+		bestClassId = classId;
+		bestLeadingEdge = targetLeft;
+	};
+
+	for (NpcRecord &npc : state.roomNpcs) {
+		if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
+			continue;
+		considerTarget(findSceneRuntimeEntity(entityManager, npc.npcName), npc.npcName, kRuntimeEntityClassNpc);
+	}
+	for (MonsterRecord &monster : state.roomMonsters) {
+		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+			continue;
+		considerTarget(findSceneRuntimeEntity(entityManager, monster.monsterName), monster.monsterName, kRuntimeEntityClassMonster);
+	}
+
+	if (!bestEntity) {
+		debugC(1, kDebugCombat,
+			"Harvester: combat player keyboard target capture class='none' input=(L=%d R=%d U=%d D=%d) facing=%d reason='no directional target'",
+			attackLeft, attackRight, attackUp, attackDown, playerState.facing);
+		return;
+	}
+
+	playerState.attackTargetName = bestName;
+	playerState.attackTargetClassId = bestClassId;
+	debugC(1, kDebugCombat,
+		"Harvester: combat player keyboard target capture class='%s' name='%s' input=(L=%d R=%d U=%d D=%d) facing=%d target_rect=(%d,%d)-(%d,%d)",
+		describeCombatTargetClass(bestClassId), bestName.c_str(),
+		attackLeft, attackRight, attackUp, attackDown, playerState.facing,
+		bestEntity->getScreenRect().left, bestEntity->getScreenRect().top,
+		bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
+}
+
+MonsterRecord *RoomCombat::resolveKillTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+		EntityManager *entityManager, const Common::Point &mousePos) const {
+	if (playerState.attackTargetClassId == kRuntimeEntityClassMonster) {
+		MonsterRecord *monster = findRoomMonsterRecordByName(state, playerState.attackTargetName);
+		if (isKillableMonster(monster) && selectMonsterEntity(entityManager, *monster))
+			return monster;
+	}
+
+	if (MonsterRecord *monster = findMonsterTargetAtPoint(state, entityManager, mousePos)) {
+		if (isKillableMonster(monster) && selectMonsterEntity(entityManager, *monster))
+			return monster;
+	}
+
+	if (playerState.entity) {
+		for (uint i = 0; i < state.roomMonsters.size() && i < _monsterCombatStates.size(); ++i) {
+			MonsterRecord &monster = state.roomMonsters[i];
+			const RoomMonsterCombatState &combatState = _monsterCombatStates[i];
+			if (!combatState.attackActive || !isKillableMonster(&monster) || !selectMonsterEntity(entityManager, monster))
+				continue;
+			if (!combatState.attackTargetName.empty() &&
+					playerState.entity->getName().equalsIgnoreCase(combatState.attackTargetName)) {
+				return &monster;
+			}
+		}
+	}
+
+	if (!playerState.entity)
+		return nullptr;
+
+	const Common::Rect playerRect = playerState.entity->getScreenRect();
+	const int playerCenterX = playerRect.left + playerRect.width() / 2;
+	MonsterRecord *bestMonster = nullptr;
+	int bestScore = 0x7fffffff;
+	for (MonsterRecord &monster : state.roomMonsters) {
+		if (!isKillableMonster(&monster))
+			continue;
+
+		Entity *entity = selectMonsterEntity(entityManager, monster);
+		if (!entity)
+			continue;
+
+		const Common::Rect monsterRect = entity->getScreenRect();
+		const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
+		const int score = ABS(monsterCenterX - playerCenterX) +
+			ABS(monster.posZ - roundRoomCombatFloat(playerState.z)) * 8;
+		if (!bestMonster || score < bestScore) {
+			bestMonster = &monster;
+			bestScore = score;
+		}
+	}
+
+	return bestMonster;
+}
+
+void RoomCombat::capturePlayerAttackTarget(RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, const Common::Point &mousePos, const NpcRecord *hoverNpc) const {
+	if (hoverNpc) {
+		playerState.attackTargetName = hoverNpc->npcName;
+		playerState.attackTargetClassId = kRuntimeEntityClassNpc;
+		debugC(1, kDebugCombat,
+			"Harvester: combat player target capture class='npc' name='%s' cursor=(%d,%d)",
+			playerState.attackTargetName.c_str(), mousePos.x, mousePos.y);
+		return;
+	}
+
+	if (MonsterRecord *monster = findMonsterTargetAtPoint(state, entityManager, mousePos)) {
+		playerState.attackTargetName = monster->monsterName;
+		playerState.attackTargetClassId = kRuntimeEntityClassMonster;
+		debugC(1, kDebugCombat,
+			"Harvester: combat player target capture class='monster' name='%s' cursor=(%d,%d)",
+			playerState.attackTargetName.c_str(), mousePos.x, mousePos.y);
+		return;
+	}
+
+	debugC(1, kDebugCombat,
+		"Harvester: combat player target capture class='none' cursor=(%d,%d)",
+		mousePos.x, mousePos.y);
+}
+
+void RoomCombat::captureOverlappingNpcAttackTarget(RoomSetupState &state, RoomPlayerState &playerState, EntityManager *entityManager) const {
+	if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
+		if (NpcRecord *fallbackNpc = findOverlappingNpcTarget(state, playerState, entityManager)) {
+			playerState.attackTargetName = fallbackNpc->npcName;
+			playerState.attackTargetClassId = kRuntimeEntityClassNpc;
+		}
+	}
+}
+
+NpcRecord *RoomCombat::selectNpcAttackContactTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+		EntityManager *entityManager, Entity *&npcEntity) const {
+	NpcRecord *npc = findRoomNpcRecordByName(state, playerState.attackTargetName);
+	npcEntity = npc ? findSceneRuntimeEntity(entityManager, npc->npcName) : nullptr;
+	if (!npc || !npcEntity || !playerAttackCanReachTarget(state, playerState, npcEntity))
+		npc = Player::isProjectileCombatLoadout(playerState.combatLoadout) ? nullptr : findOverlappingNpcTarget(state, playerState, entityManager);
+	npcEntity = npc ? findSceneRuntimeEntity(entityManager, npc->npcName) : nullptr;
+	return npc;
+}
+
+MonsterRecord *RoomCombat::selectMonsterAttackContactTarget(RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, Entity *&monsterEntity) const {
+	MonsterRecord *monster = nullptr;
+	if (playerState.attackTargetClassId == kRuntimeEntityClassMonster)
+		monster = findRoomMonsterRecordByName(state, playerState.attackTargetName);
+	monsterEntity = monster ? findSceneRuntimeEntity(entityManager, monster->monsterName) : nullptr;
+	if (!monster || !monsterEntity || !playerAttackCanReachTarget(state, playerState, monsterEntity, monster->engageDistance)) {
+		if (!Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
+			monster = findOverlappingMonsterTarget(state, playerState, entityManager);
+		} else if (playerState.attackTargetClassId < 0) {
+			monster = findProjectileContactMonsterTarget(state, playerState, entityManager);
+			if (monster) {
+				playerState.attackTargetName = monster->monsterName;
+				playerState.attackTargetClassId = kRuntimeEntityClassMonster;
+			}
+		} else {
+			monster = nullptr;
+		}
+		monsterEntity = monster ? findSceneRuntimeEntity(entityManager, monster->monsterName) : nullptr;
+	}
+	return monster;
+}
+
 void RoomCombat::initializeCombatStates(const RoomSetupState &state) {
 	_monsterCombatStates.resize(state.roomMonsters.size());
 	_npcCombatStates.resize(state.roomNpcs.size());
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
index 8a262f7f0db..9e73c6cf315 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/room_combat.h
@@ -129,7 +129,27 @@ public:
 	void commitMonsterCombatStatesForSceneChange(Common::Array<RoomMonsterCombatState> &&states);
 	void resetForSceneRefresh(const RoomSetupState &state);
 
+	void capturePlayerAttackTargetForKeyboard(RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, bool attackLeft, bool attackRight, bool attackUp, bool attackDown) const;
+	MonsterRecord *resolveKillTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+		EntityManager *entityManager, const Common::Point &mousePos) const;
+	// The caller clears the captured target before resolving the room hover state.
+	void capturePlayerAttackTarget(RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, const Common::Point &mousePos, const NpcRecord *hoverNpc) const;
+	void captureOverlappingNpcAttackTarget(RoomSetupState &state, RoomPlayerState &playerState, EntityManager *entityManager) const;
+	NpcRecord *selectNpcAttackContactTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+		EntityManager *entityManager, Entity *&npcEntity) const;
+	MonsterRecord *selectMonsterAttackContactTarget(RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, Entity *&monsterEntity) const;
+
 private:
+	MonsterRecord *findMonsterTargetAtPoint(RoomSetupState &state, EntityManager *entityManager, const Common::Point &point) const;
+	MonsterRecord *findOverlappingMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState, EntityManager *entityManager) const;
+	NpcRecord *findOverlappingNpcTarget(RoomSetupState &state, const RoomPlayerState &playerState, EntityManager *entityManager) const;
+	bool playerAttackCanReachTarget(RoomSetupState &state, const RoomPlayerState &playerState, Entity *targetEntity, int engageDistance = 0) const;
+	MonsterRecord *findProjectileContactMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+		EntityManager *entityManager) const;
+
 	Common::Array<RoomMonsterCombatState> _monsterCombatStates;
 	Common::Array<RoomNpcCombatState> _npcCombatStates;
 };
@@ -192,6 +212,7 @@ struct ScopedDeferredLiveNpcDeathTransitions {
 	Script *_script;
 };
 
+const char *describeCombatTargetClass(int classId);
 bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc);
 bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc);
 bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info);


Commit: 60d34b8f08ef51bc3d689e73bd2bc06f0b6229e8
    https://github.com/scummvm/scummvm/commit/60d34b8f08ef51bc3d689e73bd2bc06f0b6229e8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Refactor combat interaction handling and NPC death transitions

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_combat.cpp
    engines/harvester/room_combat.h
    engines/harvester/room_interaction.cpp
    engines/harvester/room_interaction.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 4aff9a22223..f3ebdbbd8ed 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1061,22 +1061,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		}
 		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
 			_engine.getScript());
-		auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> NpcRecord * {
-			for (NpcRecord &npc : scene.state.roomNpcs) {
-				if (npc.npcName.equalsIgnoreCase(npcName))
-					return &npc;
-			}
 
-			return nullptr;
-		};
-		auto findRoomNpcIndexByName = [&](const Common::String &npcName) -> int {
-			for (uint i = 0; i < scene.state.roomNpcs.size(); ++i) {
-				if (scene.state.roomNpcs[i].npcName.equalsIgnoreCase(npcName))
-					return (int)i;
-			}
-
-			return -1;
-		};
 		auto findRoomMonsterIndexByName = [&](const Common::String &monsterName) -> int {
 			for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
 				if (scene.state.roomMonsters[i].monsterName.equalsIgnoreCase(monsterName))
@@ -1088,131 +1073,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> Entity * {
 			return entityManager ? entityManager->findSceneEntityByName(entityName) : nullptr;
 		};
-		auto queueInnerSanctumHerrillDeathAfterMuck = [&](const MonsterRecord &monster) {
-			if (!monster.roomName.equalsIgnoreCase(kInnerSanctumRoomName) ||
-					!monster.monsterName.equalsIgnoreCase(kMuckeyMonsterName) ||
-					!monster.onDeathActionTag.equalsIgnoreCase(kActivateInnerSanctumDoorActionTag)) {
-				return false;
-			}
-
-			Script *script = _engine.getScript();
-			NpcRecord *herrill = findRoomNpcRecordByName(kHerrillLogNpcName);
-			if (!script || !herrill || !herrill->visible ||
-					herrill->deathOrMonsterfyFlag || herrill->deathDamageType != 0) {
-				return false;
-			}
-
-			// Native update_actor_runtime_state has a hard-coded HERRILL_LOG removal path
-			// while the Inner Sanctum Muckey combat state is active; queue the ordinary
-			// live NPC death so the room's timer delay can play Herrill's death bank.
-			if (!script->queueRuntimeNpcDeathOrMonsterfy(
-					kHerrillLogNpcName, kNativeDefaultNpcDeathDamageType)) {
-				return false;
-			}
-
-			herrill->deathDamageType = kNativeDefaultNpcDeathDamageType;
-			debugC(1, kDebugCombat,
-				"Harvester: queued Inner Sanctum Herrill death after monster='%s'",
-				monster.monsterName.c_str());
-			return true;
-		};
-
-	auto handleCombatInteraction = [&](const InteractionResult &interaction) -> Common::Error {
-		bool didTransition = false;
-		Common::Error interactionError =
-			interactionProcessor.handleInteractionResult(interaction, didTransition, Common::String());
-		if (interactionError.getCode() != Common::kNoError)
-			return interactionError;
-		if (!flow.hasPendingMainMenuReturn() &&
-				_engine.getScript() &&
-				_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
-			session.stopPlayerRegionInteraction();
-			startPlayerDefeatSequence("combat_interaction", Common::String(), 1);
-		}
 
-		needsRedraw = true;
-		return Common::kNoError;
-	};
-	auto beginNpcDeathTransition = [&](NpcRecord &npc, Entity &npcEntity,
-			RoomNpcCombatState &combatState, int deathDamageType) {
-		RoomDeathAnimationRange deathRange;
-		if (!resolveNpcDeathAnimationRange(npcEntity, !npc.monsterfyTargetName.empty(),
-				deathDamageType, _engine.isGoreEnabled(), deathRange))
-			return false;
-
-		combatState.deathActive = true;
-		combatState.deathFirstFrame = deathRange.firstFrame;
-		combatState.deathLastFrame = deathRange.lastFrame;
-		combatState.deathDamageType = deathDamageType;
-		npc.deathDamageType = deathDamageType;
-		npcEntity.setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
-		npcEntity.setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
-		npcEntity.setAnimationEnabled(true);
-		npcEntity.setCurrentFrame(deathRange.firstFrame);
-		if (!npc.audioPath.empty())
-			(void)_engine.playSound(npc.audioPath);
-		debugC(1, kDebugCombat,
-			"Harvester: combat npc death start target='%s' damage_type=%d frames=%d..%d monsterfy='%s' on_death='%s'",
-			npc.npcName.c_str(), deathDamageType, deathRange.firstFrame, deathRange.lastFrame,
-			npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
-		needsRedraw = true;
-		return true;
-	};
-	auto finalizeNpcDeathTransition = [&](NpcRecord &npc,
-			RoomNpcCombatState &combatState) -> Common::Error {
-		Script *script = _engine.getScript();
-		Entity *npcEntity = findSceneRuntimeEntity(npc.npcName);
-		// Native state 0x35 leaves non-monsterfied NPCs as a dead state-0x38 actor
-		// in the current room, while the NpcRecord death flag suppresses later setup.
-		const bool retainDeathEntity =
-			shouldRetainNpcDeathEntityInCurrentRoom(npc) &&
-			combatState.deathLastFrame >= 0 &&
-			npcEntity;
-		const int monsterfyPosZ = (!npc.monsterfyTargetName.empty() && npcEntity)
-			? roundRoomCombatFloat(npcEntity->getZ())
-			: Script::kNoMonsterfyPosZOverride;
-		if (retainDeathEntity) {
-			npcEntity->setAnimationRate(0);
-			npcEntity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
-			npcEntity->setCurrentFrame(combatState.deathLastFrame);
-			npcEntity->setAnimationEnabled(false);
-			npcEntity->setHitTestMode(kRuntimeEntityHitTestNone);
-			npcEntity->setVisible(true);
-		} else {
-			sceneRuntime.removeSceneEntityByName(npc.npcName);
-		}
-		npc.active = false;
-		npc.visible = false;
-		npc.savedVisible = false;
-		npc.runtimeSpawned = retainDeathEntity;
-		npc.runtimeState = retainDeathEntity ? combatState.deathLastFrame : -1;
-		npc.deathOrMonsterfyFlag = true;
-		if (combatState.deathDamageType != 0)
-			npc.deathDamageType = combatState.deathDamageType;
-		const bool runtimeChanged = script
-			? script->finalizeRuntimeNpcDeathOrMonsterfy(
-				npc.npcName, npc.deathDamageType, false, -1, monsterfyPosZ)
-			: false;
-		debugC(1, kDebugCombat,
-			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d preserve_runtime_actor=%d monsterfy='%s' on_death='%s'",
-			npc.npcName.c_str(), combatState.deathDamageType, combatState.deathLastFrame,
-			retainDeathEntity, npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
-		clearRoomNpcCombatState(combatState);
-
-		InteractionResult interaction;
-		interaction.mutatedRuntimeState = runtimeChanged;
-		interaction.visualRuntimeStateChanged = runtimeChanged;
-		if (script && !npc.onDeathActionTag.empty()) {
-			InteractionResult deathInteraction;
-			if (script->executeActionTag(npc.onDeathActionTag, deathInteraction, true, npc.roomName)) {
-				interaction = Common::move(deathInteraction);
-				interaction.mutatedRuntimeState = true;
-				interaction.visualRuntimeStateChanged = true;
-			}
-		}
-
-		return handleCombatInteraction(interaction);
-	};
 	auto killDebugActiveMonster = [&]() -> Common::Error {
 		if (!_engine.isCombatDebugEnabled())
 			return Common::kNoError;
@@ -1291,7 +1152,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 		}
 
-		return handleCombatInteraction(interaction);
+		return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
 	};
 	auto capturePlayerAttackTarget = [&]() {
 		playerState.attackTargetName.clear();
@@ -1303,209 +1164,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		combat.capturePlayerAttackTarget(scene.state, playerState, entityManager, _mousePos, hoverState.npc);
 	};
 
-		auto startMonsterHitReaction = [&](MonsterRecord &monster, Entity &monsterEntity,
-				RoomMonsterCombatState *combatState, int attackerAttackFirstFrame) {
-			if (combatEffects.spawnCombatHitEffect(monsterEntity, monster.monsterName))
-				needsRedraw = true;
-
-			if (!combatState) {
-				(void)playRandomRoomAttackSound(_engine,
-					monster.hitSound1, monster.hitSound2, monster.hitSound3);
-				return;
-			}
-
-			RoomHitAnimationRange range;
-			if (!resolveRoomMonsterHitAnimationRange(monsterEntity, attackerAttackFirstFrame, range)) {
-				(void)playRandomRoomAttackSound(_engine,
-					monster.hitSound1, monster.hitSound2, monster.hitSound3);
-				debugC(1, kDebugCombat,
-					"Harvester: combat monster hit reaction skipped target='%s' attack_frame=%d reason='no native hit bank'",
-					monster.monsterName.c_str(), attackerAttackFirstFrame);
-				return;
-			}
-
-			clearRoomMonsterAttackState(*combatState);
-			clearRoomMonsterHitState(*combatState);
-			combatState->hitActive = true;
-			combatState->hitFirstFrame = range.firstFrame;
-			combatState->hitLastFrame = range.lastFrame;
-			combatState->hitResumeFacing = range.resumeFacing;
-			combatState->hitKnockbackRemainingX = range.knockbackX;
-			combatState->hitKnockbackDecayStep = kNativeCombatHitKnockbackDecayStep;
-			combatState->hitSoundPlayed =
-				monster.hitSound1.empty() && monster.hitSound2.empty() && monster.hitSound3.empty();
-			combatState->nextMovementTick = 0;
-			monster.facing = range.resumeFacing;
-			monsterEntity.setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-			monsterEntity.setAnimationRate(kNativeMonsterHitAnimationRate);
-			monsterEntity.setAnimationEnabled(true);
-			monsterEntity.setCurrentFrame(range.firstFrame);
-			if (!combatState->hitSoundPlayed && monster.hitSoundTriggerFrame <= 0) {
-				(void)playRandomRoomAttackSound(_engine,
-					monster.hitSound1, monster.hitSound2, monster.hitSound3);
-				combatState->hitSoundPlayed = true;
-			}
-			debugC(1, kDebugCombat,
-				"Harvester: combat monster hit reaction target='%s' attack_frame=%d frames=%d..%d resume_facing=%d knockback=%d rate=%d",
-				monster.monsterName.c_str(), attackerAttackFirstFrame, range.firstFrame, range.lastFrame,
-				range.resumeFacing, range.knockbackX, kNativeMonsterHitAnimationRate);
-			needsRedraw = true;
-		};
-		auto resolvePlayerAttackContact = [&]() -> Common::Error {
-		if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
-				playerState.attackContactFrame < 0 ||
-				playerState.entity->getCurrentFrame() < playerState.attackContactFrame) {
-			return Common::kNoError;
-		}
-
-		playerState.attackContactResolved = true;
-		const int damageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
-		const int damageAmount = Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout);
-		debugC(1, kDebugCombat,
-			"Harvester: combat player attack contact frame=%d loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s'",
-			playerState.entity->getCurrentFrame(), playerState.combatLoadout,
-			Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
-			Player::describeCombatDamageType(damageType),
-			describeCombatTargetClass(playerState.attackTargetClassId), playerState.attackTargetName.c_str());
-		Script *script = _engine.getScript();
-		if (!script)
-			return Common::kNoError;
-
-		combat.captureOverlappingNpcAttackTarget(scene.state, playerState, entityManager);
-		if (playerState.attackTargetClassId == kRuntimeEntityClassNpc) {
-			Entity *npcEntity = nullptr;
-			NpcRecord *npc = combat.selectNpcAttackContactTarget(scene.state, playerState, entityManager, npcEntity);
-			if (!npc) {
-				debugC(1, kDebugCombat,
-					"Harvester: combat player attack missed class='npc' target='%s' reason='no reachable npc'",
-					playerState.attackTargetName.c_str());
-				return Common::kNoError;
-			}
-
-			const bool runtimeChanged =
-				script->queueRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
-			const bool jimmyFlagChanged = npc->npcName.equalsIgnoreCase("JIMMY")
-				? script->setRuntimeFlagValue("JIMMY_ATTACKED", true)
-				: false;
-			const int npcIndex = findRoomNpcIndexByName(npc->npcName);
-			RoomNpcCombatState *combatState =
-				npcIndex >= 0 && npcIndex < (int)npcCombatStates.size()
-					? &npcCombatStates[(uint)npcIndex]
-					: nullptr;
-			if (runtimeChanged) {
-				npc->deathDamageType = damageType;
-				if (combatState)
-					clearRoomNpcCombatState(*combatState);
-			}
-			debugC(1, kDebugCombat,
-				"Harvester: combat player npc hit target='%s' damage_type=%d runtime_changed=%d jimmy_flag=%d",
-				npc->npcName.c_str(), damageType, runtimeChanged, jimmyFlagChanged);
-			if (!runtimeChanged && !jimmyFlagChanged)
-				return Common::kNoError;
-			if (runtimeChanged && npcEntity) {
-				const Common::String followTargetName = npc->monsterfyTargetName;
-				if (combatEffects.spawnCombatHitEffect(*npcEntity, followTargetName))
-					needsRedraw = true;
-			}
-			if (runtimeChanged && combatState && npcEntity &&
-					beginNpcDeathTransition(*npc, *npcEntity, *combatState, damageType))
-				return Common::kNoError;
-			if (runtimeChanged) {
-				debugC(1, kDebugCombat,
-					"Harvester: combat npc defeated target='%s' damage_type=%d on_death='%s' fallback='no death bank'",
-					npc->npcName.c_str(), damageType, npc->onDeathActionTag.c_str());
-				RoomNpcCombatState fallbackState;
-				fallbackState.deathDamageType = damageType;
-				return finalizeNpcDeathTransition(*npc, combatState ? *combatState : fallbackState);
-			}
-
-			return Common::kNoError;
-		}
-
-		Entity *monsterEntity = nullptr;
-		MonsterRecord *monster = combat.selectMonsterAttackContactTarget(scene.state, playerState, entityManager, monsterEntity);
-		if (!monster || !monsterEntity) {
-			debugC(1, kDebugCombat,
-				"Harvester: combat player attack missed class='monster' target='%s' reason='no reachable monster'",
-				playerState.attackTargetName.c_str());
-			return Common::kNoError;
-		}
-
-			const int hitPointsBefore = monster->currentHitPoints;
-			monster->currentHitPoints = MAX(0,
-				monster->currentHitPoints - damageAmount);
-			const int damageLanded = hitPointsBefore - monster->currentHitPoints;
-			const int monsterIndex = findRoomMonsterIndexByName(monster->monsterName);
-			RoomMonsterCombatState *combatState =
-				monsterIndex >= 0 && monsterIndex < (int)monsterCombatStates.size()
-					? &monsterCombatStates[(uint)monsterIndex]
-					: nullptr;
-			debugC(1, kDebugCombat,
-				"Harvester: combat player monster hit target='%s' loadout=%d weapon='%s' damage=%d damage_type='%s' hp=%d->%d reachable=1",
-				monster->monsterName.c_str(), playerState.combatLoadout,
-				Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
-				Player::describeCombatDamageType(damageType), hitPointsBefore, monster->currentHitPoints);
-			if (damageLanded > 0) {
-				combatEffects.spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
-				startMonsterHitReaction(*monster, *monsterEntity, combatState, playerState.attackFirstFrame);
-			}
-			if (monster->currentHitPoints > 0) {
-				(void)script->syncRuntimeMonsterRecord(*monster);
-				needsRedraw = true;
-				return Common::kNoError;
-			}
-			if (combatState)
-				clearRoomMonsterCombatState(*combatState);
-		(void)script->syncRuntimeMonsterRecord(*monster);
-
-		RoomDeathAnimationRange deathRange;
-		if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
-				damageType, _engine.isGoreEnabled(), deathRange)) {
-			combatState->deathActive = true;
-			combatState->deathFirstFrame = deathRange.firstFrame;
-			combatState->deathLastFrame = deathRange.lastFrame;
-			combatState->deathDamageType = damageType;
-			combatState->nextMovementTick = 0;
-			monsterEntity->setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
-			monsterEntity->setAnimationRate(kRoomMonsterAnimationRate);
-			monsterEntity->setAnimationEnabled(true);
-			monsterEntity->setCurrentFrame(deathRange.firstFrame);
-			if (!monster->deathSound.empty())
-				(void)_engine.playSound(monster->deathSound);
-			debugC(1, kDebugCombat,
-				"Harvester: combat monster death start target='%s' damage_type=%d frames=%d..%d gore=%d on_death='%s'",
-				monster->monsterName.c_str(), damageType,
-				deathRange.firstFrame, deathRange.lastFrame,
-				_engine.isGoreEnabled(), monster->onDeathActionTag.c_str());
-			needsRedraw = true;
-			return Common::kNoError;
-		}
-
-		monster->active = false;
-		monster->visible = false;
-		const bool herrillDeathQueued = queueInnerSanctumHerrillDeathAfterMuck(*monster);
-		debugC(1, kDebugCombat,
-			"Harvester: combat monster defeated target='%s' on_death='%s' fallback='no death bank'",
-			monster->monsterName.c_str(), monster->onDeathActionTag.c_str());
-		InteractionResult interaction;
-		interaction.mutatedRuntimeState = true;
-		interaction.visualRuntimeStateChanged = true;
-		if (!monster->onDeathActionTag.empty()) {
-			InteractionResult deathInteraction;
-			if (script->executeActionTag(
-					monster->onDeathActionTag, deathInteraction, true, monster->roomName)) {
-				interaction = Common::move(deathInteraction);
-				interaction.mutatedRuntimeState = true;
-				interaction.visualRuntimeStateChanged = true;
-			}
-		}
-		if (herrillDeathQueued) {
-			interaction.mutatedRuntimeState = true;
-			interaction.visualRuntimeStateChanged = true;
-		}
-
-		return handleCombatInteraction(interaction);
-	};
 	auto updateRoomMonsterCombat = [&]() -> Common::Error {
 		if (!entityManager)
 			return Common::kNoError;
@@ -1529,7 +1187,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				continue;
 			}
 			if (!combatState.deathActive && npc.active && npc.deathDamageType != 0) {
-				if (beginNpcDeathTransition(npc, *entity, combatState, npc.deathDamageType))
+				if (combat.beginNpcDeathTransition(_engine, npc, *entity, combatState, npc.deathDamageType, needsRedraw))
 					continue;
 
 				debugC(1, kDebugCombat,
@@ -1537,7 +1195,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					npc.npcName.c_str(), npc.deathDamageType, npc.onDeathActionTag.c_str());
 				combatState.deathDamageType = npc.deathDamageType;
 				combatState.deathLastFrame = entity->getCurrentFrame();
-				return finalizeNpcDeathTransition(npc, combatState);
+				return combat.finalizeNpcDeathTransition(_engine, entityManager, sceneRuntime, npc, combatState, interactionProcessor, needsRedraw);
 			}
 			if (!combatState.deathActive)
 				continue;
@@ -1548,7 +1206,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			entity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
 			entity->setCurrentFrame(combatState.deathLastFrame);
 			entity->setAnimationEnabled(false);
-			return finalizeNpcDeathTransition(npc, combatState);
+			return combat.finalizeNpcDeathTransition(_engine, entityManager, sceneRuntime, npc, combatState, interactionProcessor, needsRedraw);
 		}
 
 		for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
@@ -1577,7 +1235,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						monster.runtimeState,
 						monster.onDeathActionTag.c_str());
 					clearRoomMonsterCombatState(combatState);
-					const bool herrillDeathQueued = queueInnerSanctumHerrillDeathAfterMuck(monster);
+					const bool herrillDeathQueued = combat.queueInnerSanctumHerrillDeathAfterMuck(_engine, scene.state, monster);
 
 					InteractionResult interaction;
 					interaction.mutatedRuntimeState = true;
@@ -1596,7 +1254,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						interaction.visualRuntimeStateChanged = true;
 					}
 
-					return handleCombatInteraction(interaction);
+					return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
 				}
 				continue;
 			}
@@ -3010,7 +2668,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		Common::Error combatError = Common::kNoError;
 		if (!playerControlPaused) {
-			combatError = resolvePlayerAttackContact();
+			combatError = combat.resolvePlayerAttackContact(_engine, scene.state, playerState, entityManager,
+					sceneRuntime, combatEffects, interactionProcessor, needsRedraw);
 			if (combatError.getCode() != Common::kNoError)
 				return combatError;
 			if (flow.hasPendingMainMenuReturn())
diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
index 4210bd4ab7e..b2c5785ec21 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/room_combat.cpp
@@ -28,6 +28,8 @@
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/player.h"
+#include "harvester/room_interaction.h"
+#include "harvester/room_scene_runtime.h"
 #include "harvester/runtime_entity.h"
 
 namespace Harvester {
@@ -82,6 +84,24 @@ static NpcRecord *findRoomNpcRecordByName(RoomSetupState &state, const Common::S
 	return nullptr;
 }
 
+static int findRoomNpcIndexByName(const RoomSetupState &state, const Common::String &name) {
+	for (uint i = 0; i < state.roomNpcs.size(); ++i) {
+		if (state.roomNpcs[i].npcName.equalsIgnoreCase(name))
+			return (int)i;
+	}
+
+	return -1;
+}
+
+static int findRoomMonsterIndexByName(const RoomSetupState &state, const Common::String &name) {
+	for (uint i = 0; i < state.roomMonsters.size(); ++i) {
+		if (state.roomMonsters[i].monsterName.equalsIgnoreCase(name))
+			return (int)i;
+	}
+
+	return -1;
+}
+
 static bool isKillableMonster(const MonsterRecord *monster) {
 	return monster && monster->active && monster->visible && monster->currentHitPoints > 0;
 }
@@ -948,6 +968,328 @@ MonsterRecord *RoomCombat::selectMonsterAttackContactTarget(RoomSetupState &stat
 	return monster;
 }
 
+bool RoomCombat::queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const {
+	if (!monster.roomName.equalsIgnoreCase(kInnerSanctumRoomName) ||
+			!monster.monsterName.equalsIgnoreCase(kMuckeyMonsterName) ||
+			!monster.onDeathActionTag.equalsIgnoreCase(kActivateInnerSanctumDoorActionTag)) {
+		return false;
+	}
+
+	Script *script = engine.getScript();
+	NpcRecord *herrill = findRoomNpcRecordByName(state, kHerrillLogNpcName);
+	if (!script || !herrill || !herrill->visible ||
+			herrill->deathOrMonsterfyFlag || herrill->deathDamageType != 0) {
+		return false;
+	}
+
+	// Native update_actor_runtime_state has a hard-coded HERRILL_LOG removal path
+	// while the Inner Sanctum Muckey combat state is active; queue the ordinary
+	// live NPC death so the room's timer delay can play Herrill's death bank.
+	if (!script->queueRuntimeNpcDeathOrMonsterfy(
+			kHerrillLogNpcName, kNativeDefaultNpcDeathDamageType)) {
+		return false;
+	}
+
+	herrill->deathDamageType = kNativeDefaultNpcDeathDamageType;
+	debugC(1, kDebugCombat,
+		"Harvester: queued Inner Sanctum Herrill death after monster='%s'",
+		monster.monsterName.c_str());
+	return true;
+}
+
+bool RoomCombat::beginNpcDeathTransition(HarvesterEngine &engine,
+		NpcRecord &npc, Entity &npcEntity, RoomNpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const {
+	RoomDeathAnimationRange deathRange;
+	if (!resolveNpcDeathAnimationRange(npcEntity, !npc.monsterfyTargetName.empty(),
+			deathDamageType, engine.isGoreEnabled(), deathRange))
+		return false;
+
+	combatState.deathActive = true;
+	combatState.deathFirstFrame = deathRange.firstFrame;
+	combatState.deathLastFrame = deathRange.lastFrame;
+	combatState.deathDamageType = deathDamageType;
+	npc.deathDamageType = deathDamageType;
+	npcEntity.setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
+	npcEntity.setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+	npcEntity.setAnimationEnabled(true);
+	npcEntity.setCurrentFrame(deathRange.firstFrame);
+	if (!npc.audioPath.empty())
+		(void)engine.playSound(npc.audioPath);
+	debugC(1, kDebugCombat,
+		"Harvester: combat npc death start target='%s' damage_type=%d frames=%d..%d monsterfy='%s' on_death='%s'",
+		npc.npcName.c_str(), deathDamageType, deathRange.firstFrame, deathRange.lastFrame,
+		npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
+	needsRedraw = true;
+	return true;
+}
+
+Common::Error RoomCombat::finalizeNpcDeathTransition(HarvesterEngine &engine,
+		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) const {
+	Script *script = engine.getScript();
+	Entity *npcEntity = findSceneRuntimeEntity(entityManager, npc.npcName);
+	// Native state 0x35 leaves non-monsterfied NPCs as a dead state-0x38 actor
+	// in the current room, while the NpcRecord death flag suppresses later setup.
+	const bool retainDeathEntity =
+		shouldRetainNpcDeathEntityInCurrentRoom(npc) &&
+		combatState.deathLastFrame >= 0 &&
+		npcEntity;
+	const int monsterfyPosZ = (!npc.monsterfyTargetName.empty() && npcEntity)
+		? roundRoomCombatFloat(npcEntity->getZ())
+		: Script::kNoMonsterfyPosZOverride;
+	if (retainDeathEntity) {
+		npcEntity->setAnimationRate(0);
+		npcEntity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
+		npcEntity->setCurrentFrame(combatState.deathLastFrame);
+		npcEntity->setAnimationEnabled(false);
+		npcEntity->setHitTestMode(kRuntimeEntityHitTestNone);
+		npcEntity->setVisible(true);
+	} else {
+		sceneRuntime.removeSceneEntityByName(npc.npcName);
+	}
+	npc.active = false;
+	npc.visible = false;
+	npc.savedVisible = false;
+	npc.runtimeSpawned = retainDeathEntity;
+	npc.runtimeState = retainDeathEntity ? combatState.deathLastFrame : -1;
+	npc.deathOrMonsterfyFlag = true;
+	if (combatState.deathDamageType != 0)
+		npc.deathDamageType = combatState.deathDamageType;
+	const bool runtimeChanged = script
+		? script->finalizeRuntimeNpcDeathOrMonsterfy(
+			npc.npcName, npc.deathDamageType, false, -1, monsterfyPosZ)
+		: false;
+	debugC(1, kDebugCombat,
+		"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d preserve_runtime_actor=%d monsterfy='%s' on_death='%s'",
+		npc.npcName.c_str(), combatState.deathDamageType, combatState.deathLastFrame,
+		retainDeathEntity, npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
+	clearRoomNpcCombatState(combatState);
+
+	InteractionResult interaction;
+	interaction.mutatedRuntimeState = runtimeChanged;
+	interaction.visualRuntimeStateChanged = runtimeChanged;
+	if (script && !npc.onDeathActionTag.empty()) {
+		InteractionResult deathInteraction;
+		if (script->executeActionTag(npc.onDeathActionTag, deathInteraction, true, npc.roomName)) {
+			interaction = Common::move(deathInteraction);
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
+	}
+
+	return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
+}
+
+void RoomCombat::startMonsterHitReaction(HarvesterEngine &engine,
+		RoomCombatEffects &combatEffects, MonsterRecord &monster, Entity &monsterEntity, RoomMonsterCombatState *combatState,
+		int attackerAttackFirstFrame, bool &needsRedraw) const {
+	if (combatEffects.spawnCombatHitEffect(monsterEntity, monster.monsterName))
+		needsRedraw = true;
+
+	if (!combatState) {
+		(void)playRandomRoomAttackSound(engine,
+			monster.hitSound1, monster.hitSound2, monster.hitSound3);
+		return;
+	}
+
+	RoomHitAnimationRange range;
+	if (!resolveRoomMonsterHitAnimationRange(monsterEntity, attackerAttackFirstFrame, range)) {
+		(void)playRandomRoomAttackSound(engine,
+			monster.hitSound1, monster.hitSound2, monster.hitSound3);
+		debugC(1, kDebugCombat,
+			"Harvester: combat monster hit reaction skipped target='%s' attack_frame=%d reason='no native hit bank'",
+			monster.monsterName.c_str(), attackerAttackFirstFrame);
+		return;
+	}
+
+	clearRoomMonsterAttackState(*combatState);
+	clearRoomMonsterHitState(*combatState);
+	combatState->hitActive = true;
+	combatState->hitFirstFrame = range.firstFrame;
+	combatState->hitLastFrame = range.lastFrame;
+	combatState->hitResumeFacing = range.resumeFacing;
+	combatState->hitKnockbackRemainingX = range.knockbackX;
+	combatState->hitKnockbackDecayStep = kNativeCombatHitKnockbackDecayStep;
+	combatState->hitSoundPlayed =
+		monster.hitSound1.empty() && monster.hitSound2.empty() && monster.hitSound3.empty();
+	combatState->nextMovementTick = 0;
+	monster.facing = range.resumeFacing;
+	monsterEntity.setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	monsterEntity.setAnimationRate(kNativeMonsterHitAnimationRate);
+	monsterEntity.setAnimationEnabled(true);
+	monsterEntity.setCurrentFrame(range.firstFrame);
+	if (!combatState->hitSoundPlayed && monster.hitSoundTriggerFrame <= 0) {
+		(void)playRandomRoomAttackSound(engine,
+			monster.hitSound1, monster.hitSound2, monster.hitSound3);
+		combatState->hitSoundPlayed = true;
+	}
+	debugC(1, kDebugCombat,
+		"Harvester: combat monster hit reaction target='%s' attack_frame=%d frames=%d..%d resume_facing=%d knockback=%d rate=%d",
+		monster.monsterName.c_str(), attackerAttackFirstFrame, range.firstFrame, range.lastFrame,
+		range.resumeFacing, range.knockbackX, kNativeMonsterHitAnimationRate);
+	needsRedraw = true;
+}
+
+Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
+		RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		RoomCombatEffects &combatEffects,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+	if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
+			playerState.attackContactFrame < 0 ||
+			playerState.entity->getCurrentFrame() < playerState.attackContactFrame) {
+		return Common::kNoError;
+	}
+
+	playerState.attackContactResolved = true;
+	const int damageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
+	const int damageAmount = Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout);
+	debugC(1, kDebugCombat,
+		"Harvester: combat player attack contact frame=%d loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s'",
+		playerState.entity->getCurrentFrame(), playerState.combatLoadout,
+		Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
+		Player::describeCombatDamageType(damageType),
+		describeCombatTargetClass(playerState.attackTargetClassId), playerState.attackTargetName.c_str());
+	Script *script = engine.getScript();
+	if (!script)
+		return Common::kNoError;
+
+	captureOverlappingNpcAttackTarget(state, playerState, entityManager);
+	if (playerState.attackTargetClassId == kRuntimeEntityClassNpc) {
+		Entity *npcEntity = nullptr;
+		NpcRecord *npc = selectNpcAttackContactTarget(state, playerState, entityManager, npcEntity);
+		if (!npc) {
+			debugC(1, kDebugCombat,
+				"Harvester: combat player attack missed class='npc' target='%s' reason='no reachable npc'",
+				playerState.attackTargetName.c_str());
+			return Common::kNoError;
+		}
+
+		const bool runtimeChanged =
+			script->queueRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
+		const bool jimmyFlagChanged = npc->npcName.equalsIgnoreCase("JIMMY")
+			? script->setRuntimeFlagValue("JIMMY_ATTACKED", true)
+			: false;
+		const int npcIndex = findRoomNpcIndexByName(state, npc->npcName);
+		RoomNpcCombatState *combatState =
+			npcIndex >= 0 && npcIndex < (int)_npcCombatStates.size()
+				? &_npcCombatStates[(uint)npcIndex]
+				: nullptr;
+		if (runtimeChanged) {
+			npc->deathDamageType = damageType;
+			if (combatState)
+				clearRoomNpcCombatState(*combatState);
+		}
+		debugC(1, kDebugCombat,
+			"Harvester: combat player npc hit target='%s' damage_type=%d runtime_changed=%d jimmy_flag=%d",
+			npc->npcName.c_str(), damageType, runtimeChanged, jimmyFlagChanged);
+		if (!runtimeChanged && !jimmyFlagChanged)
+			return Common::kNoError;
+		if (runtimeChanged && npcEntity) {
+			const Common::String followTargetName = npc->monsterfyTargetName;
+			if (combatEffects.spawnCombatHitEffect(*npcEntity, followTargetName))
+				needsRedraw = true;
+		}
+		if (runtimeChanged && combatState && npcEntity &&
+				beginNpcDeathTransition(engine, *npc, *npcEntity, *combatState, damageType, needsRedraw))
+			return Common::kNoError;
+		if (runtimeChanged) {
+			debugC(1, kDebugCombat,
+				"Harvester: combat npc defeated target='%s' damage_type=%d on_death='%s' fallback='no death bank'",
+				npc->npcName.c_str(), damageType, npc->onDeathActionTag.c_str());
+			RoomNpcCombatState fallbackState;
+			fallbackState.deathDamageType = damageType;
+			return finalizeNpcDeathTransition(engine, entityManager, sceneRuntime, *npc, combatState ? *combatState : fallbackState, interactionProcessor, needsRedraw);
+		}
+
+		return Common::kNoError;
+	}
+
+	Entity *monsterEntity = nullptr;
+	MonsterRecord *monster = selectMonsterAttackContactTarget(state, playerState, entityManager, monsterEntity);
+	if (!monster || !monsterEntity) {
+		debugC(1, kDebugCombat,
+			"Harvester: combat player attack missed class='monster' target='%s' reason='no reachable monster'",
+			playerState.attackTargetName.c_str());
+		return Common::kNoError;
+	}
+
+	const int hitPointsBefore = monster->currentHitPoints;
+	monster->currentHitPoints = MAX(0,
+		monster->currentHitPoints - damageAmount);
+	const int damageLanded = hitPointsBefore - monster->currentHitPoints;
+	const int monsterIndex = findRoomMonsterIndexByName(state, monster->monsterName);
+	RoomMonsterCombatState *combatState =
+		monsterIndex >= 0 && monsterIndex < (int)_monsterCombatStates.size()
+			? &_monsterCombatStates[(uint)monsterIndex]
+			: nullptr;
+	debugC(1, kDebugCombat,
+		"Harvester: combat player monster hit target='%s' loadout=%d weapon='%s' damage=%d damage_type='%s' hp=%d->%d reachable=1",
+		monster->monsterName.c_str(), playerState.combatLoadout,
+		Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
+		Player::describeCombatDamageType(damageType), hitPointsBefore, monster->currentHitPoints);
+	if (damageLanded > 0) {
+		combatEffects.spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
+		startMonsterHitReaction(engine, combatEffects, *monster, *monsterEntity, combatState, playerState.attackFirstFrame, needsRedraw);
+	}
+	if (monster->currentHitPoints > 0) {
+		(void)script->syncRuntimeMonsterRecord(*monster);
+		needsRedraw = true;
+		return Common::kNoError;
+	}
+	if (combatState)
+		clearRoomMonsterCombatState(*combatState);
+	(void)script->syncRuntimeMonsterRecord(*monster);
+
+	RoomDeathAnimationRange deathRange;
+	if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
+			damageType, engine.isGoreEnabled(), deathRange)) {
+		combatState->deathActive = true;
+		combatState->deathFirstFrame = deathRange.firstFrame;
+		combatState->deathLastFrame = deathRange.lastFrame;
+		combatState->deathDamageType = damageType;
+		combatState->nextMovementTick = 0;
+		monsterEntity->setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
+		monsterEntity->setAnimationRate(kRoomMonsterAnimationRate);
+		monsterEntity->setAnimationEnabled(true);
+		monsterEntity->setCurrentFrame(deathRange.firstFrame);
+		if (!monster->deathSound.empty())
+			(void)engine.playSound(monster->deathSound);
+		debugC(1, kDebugCombat,
+			"Harvester: combat monster death start target='%s' damage_type=%d frames=%d..%d gore=%d on_death='%s'",
+			monster->monsterName.c_str(), damageType,
+			deathRange.firstFrame, deathRange.lastFrame,
+			engine.isGoreEnabled(), monster->onDeathActionTag.c_str());
+		needsRedraw = true;
+		return Common::kNoError;
+	}
+
+	monster->active = false;
+	monster->visible = false;
+	const bool herrillDeathQueued = queueInnerSanctumHerrillDeathAfterMuck(engine, state, *monster);
+	debugC(1, kDebugCombat,
+		"Harvester: combat monster defeated target='%s' on_death='%s' fallback='no death bank'",
+		monster->monsterName.c_str(), monster->onDeathActionTag.c_str());
+	InteractionResult interaction;
+	interaction.mutatedRuntimeState = true;
+	interaction.visualRuntimeStateChanged = true;
+	if (!monster->onDeathActionTag.empty()) {
+		InteractionResult deathInteraction;
+		if (script->executeActionTag(
+				monster->onDeathActionTag, deathInteraction, true, monster->roomName)) {
+			interaction = Common::move(deathInteraction);
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
+	}
+	if (herrillDeathQueued) {
+		interaction.mutatedRuntimeState = true;
+		interaction.visualRuntimeStateChanged = true;
+	}
+
+	return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
+}
+
 void RoomCombat::initializeCombatStates(const RoomSetupState &state) {
 	_monsterCombatStates.resize(state.roomMonsters.size());
 	_npcCombatStates.resize(state.roomNpcs.size());
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
index 9e73c6cf315..6847f891a28 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/room_combat.h
@@ -22,6 +22,7 @@
 #ifndef HARVESTER_ROOM_COMBAT_H
 #define HARVESTER_ROOM_COMBAT_H
 
+#include "common/error.h"
 #include "harvester/room_support.h"
 
 namespace Harvester {
@@ -29,6 +30,9 @@ namespace Harvester {
 class Entity;
 class EntityManager;
 class HarvesterEngine;
+class RoomCombatEffects;
+class RoomInteractionProcessor;
+class RoomSceneRuntime;
 
 extern const int kRoomMonsterAnimationRate;
 extern const int kNativeMonsterAttackAnimationRate;
@@ -142,7 +146,23 @@ public:
 	MonsterRecord *selectMonsterAttackContactTarget(RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, Entity *&monsterEntity) const;
 
+	bool queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const;
+	bool beginNpcDeathTransition(HarvesterEngine &engine,
+		NpcRecord &npc, Entity &npcEntity, RoomNpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const;
+	Common::Error finalizeNpcDeathTransition(HarvesterEngine &engine,
+		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) const;
+	Common::Error resolvePlayerAttackContact(HarvesterEngine &engine,
+		RoomSetupState &state, RoomPlayerState &playerState,
+		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		RoomCombatEffects &combatEffects,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+
 private:
+	void startMonsterHitReaction(HarvesterEngine &engine,
+		RoomCombatEffects &combatEffects, MonsterRecord &monster, Entity &monsterEntity, RoomMonsterCombatState *combatState,
+		int attackerAttackFirstFrame, bool &needsRedraw) const;
+
 	MonsterRecord *findMonsterTargetAtPoint(RoomSetupState &state, EntityManager *entityManager, const Common::Point &point) const;
 	MonsterRecord *findOverlappingMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState, EntityManager *entityManager) const;
 	NpcRecord *findOverlappingNpcTarget(RoomSetupState &state, const RoomPlayerState &playerState, EntityManager *entityManager) const;
diff --git a/engines/harvester/room_interaction.cpp b/engines/harvester/room_interaction.cpp
index 464759091bf..b0991ace310 100644
--- a/engines/harvester/room_interaction.cpp
+++ b/engines/harvester/room_interaction.cpp
@@ -50,6 +50,23 @@ RoomInteractionProcessor::RoomInteractionProcessor(HarvesterEngine &engine, Flow
 	  _canExitCloseupToParent(canExitCloseupToParent), _callbacks(callbacks) {
 }
 
+Common::Error RoomInteractionProcessor::handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw) {
+	bool didTransition = false;
+	Common::Error interactionError =
+		handleInteractionResult(interaction, didTransition, Common::String());
+	if (interactionError.getCode() != Common::kNoError)
+		return interactionError;
+	if (!_flow.hasPendingMainMenuReturn() &&
+			_engine.getScript() &&
+			_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
+		_callbacks.stopPlayerRegionInteraction();
+		_callbacks.startPlayerDefeatSequence("combat_interaction", Common::String(), 1);
+	}
+
+	needsRedraw = true;
+	return Common::kNoError;
+}
+
 Common::Error RoomInteractionProcessor::handleInteractionResult(const InteractionResult &interaction,
 		bool &didTransition, const Common::String &usedItemName, bool interruptPlayerMovement) {
 	didTransition = false;
diff --git a/engines/harvester/room_interaction.h b/engines/harvester/room_interaction.h
index 0fcdb578441..28142232c52 100644
--- a/engines/harvester/room_interaction.h
+++ b/engines/harvester/room_interaction.h
@@ -61,6 +61,7 @@ public:
 		bool &pendingRoomChangeUsesSavedRoomState, SaveRoomState &pendingRoomChangeSavedRoomState,
 		bool canExitCloseupToParent, RoomInteractionCallbacks &callbacks);
 
+	Common::Error handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw);
 	Common::Error handleInteractionResult(const InteractionResult &interaction,
 		bool &didTransition, const Common::String &usedItemName, bool interruptPlayerMovement = true);
 	Common::Error runScriptedDialogue(const Common::String &npcName, const Common::String &usedItemName,


Commit: 79ef7090566f6e5f49525e99ecdadba50f4fa39e
    https://github.com/scummvm/scummvm/commit/79ef7090566f6e5f49525e99ecdadba50f4fa39e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Refactor NPC combat state handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_combat.cpp
    engines/harvester/room_combat.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index f3ebdbbd8ed..eb51c57efc1 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -435,7 +435,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		RoomCombat &combat = session.getCombat();
 		combat.initializeCombatStates(scene.state);
 		Common::Array<RoomMonsterCombatState> &monsterCombatStates = combat.getMonsterCombatStates();
-		Common::Array<RoomNpcCombatState> &npcCombatStates = combat.getNpcCombatStates();
 		RoomSceneRuntime &sceneRuntime = session.getSceneRuntime();
 		RoomCombatEffects &combatEffects = session.getCombatEffects();
 		ResolvedText inspectText;
@@ -1178,36 +1177,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		combat.resizeCombatStates(scene.state);
 
-		for (uint i = 0; i < scene.state.roomNpcs.size(); ++i) {
-			NpcRecord &npc = scene.state.roomNpcs[i];
-			RoomNpcCombatState &combatState = npcCombatStates[i];
-			Entity *entity = findSceneRuntimeEntity(npc.npcName);
-			if (!entity || !npc.visible || npc.deathOrMonsterfyFlag) {
-				clearRoomNpcCombatState(combatState);
-				continue;
-			}
-			if (!combatState.deathActive && npc.active && npc.deathDamageType != 0) {
-				if (combat.beginNpcDeathTransition(_engine, npc, *entity, combatState, npc.deathDamageType, needsRedraw))
-					continue;
-
-				debugC(1, kDebugCombat,
-					"Harvester: room npc death fallback target='%s' damage_type=%d on_death='%s' reason='no death bank'",
-					npc.npcName.c_str(), npc.deathDamageType, npc.onDeathActionTag.c_str());
-				combatState.deathDamageType = npc.deathDamageType;
-				combatState.deathLastFrame = entity->getCurrentFrame();
-				return combat.finalizeNpcDeathTransition(_engine, entityManager, sceneRuntime, npc, combatState, interactionProcessor, needsRedraw);
-			}
-			if (!combatState.deathActive)
-				continue;
-			if (entity->getCurrentFrame() != combatState.deathLastFrame)
-				continue;
-
-			entity->setAnimationRate(0);
-			entity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
-			entity->setCurrentFrame(combatState.deathLastFrame);
-			entity->setAnimationEnabled(false);
-			return combat.finalizeNpcDeathTransition(_engine, entityManager, sceneRuntime, npc, combatState, interactionProcessor, needsRedraw);
-		}
+		const RoomNpcCombatUpdateResult npcResult = combat.updateRoomNpcCombat(_engine, scene.state,
+			entityManager, sceneRuntime, interactionProcessor, needsRedraw);
+		if (npcResult.finalizedDeath)
+			return npcResult.error;
 
 		for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
 			MonsterRecord &monster = scene.state.roomMonsters[i];
diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
index b2c5785ec21..ecfb46ca11d 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/room_combat.cpp
@@ -968,6 +968,47 @@ MonsterRecord *RoomCombat::selectMonsterAttackContactTarget(RoomSetupState &stat
 	return monster;
 }
 
+RoomNpcCombatUpdateResult RoomCombat::updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
+		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+	RoomNpcCombatUpdateResult result;
+	for (uint i = 0; i < state.roomNpcs.size(); ++i) {
+		NpcRecord &npc = state.roomNpcs[i];
+		RoomNpcCombatState &combatState = _npcCombatStates[i];
+		Entity *entity = findSceneRuntimeEntity(entityManager, npc.npcName);
+		if (!entity || !npc.visible || npc.deathOrMonsterfyFlag) {
+			clearRoomNpcCombatState(combatState);
+			continue;
+		}
+		if (!combatState.deathActive && npc.active && npc.deathDamageType != 0) {
+			if (beginNpcDeathTransition(engine, npc, *entity, combatState, npc.deathDamageType, needsRedraw))
+				continue;
+
+			debugC(1, kDebugCombat,
+				"Harvester: room npc death fallback target='%s' damage_type=%d on_death='%s' reason='no death bank'",
+				npc.npcName.c_str(), npc.deathDamageType, npc.onDeathActionTag.c_str());
+			combatState.deathDamageType = npc.deathDamageType;
+			combatState.deathLastFrame = entity->getCurrentFrame();
+			result.finalizedDeath = true;
+			result.error = finalizeNpcDeathTransition(engine, entityManager, sceneRuntime, npc, combatState, interactionProcessor, needsRedraw);
+			return result;
+		}
+		if (!combatState.deathActive)
+			continue;
+		if (entity->getCurrentFrame() != combatState.deathLastFrame)
+			continue;
+
+		entity->setAnimationRate(0);
+		entity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
+		entity->setCurrentFrame(combatState.deathLastFrame);
+		entity->setAnimationEnabled(false);
+		result.finalizedDeath = true;
+		result.error = finalizeNpcDeathTransition(engine, entityManager, sceneRuntime, npc, combatState, interactionProcessor, needsRedraw);
+		return result;
+	}
+	return result;
+}
+
 bool RoomCombat::queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const {
 	if (!monster.roomName.equalsIgnoreCase(kInnerSanctumRoomName) ||
 			!monster.monsterName.equalsIgnoreCase(kMuckeyMonsterName) ||
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
index 6847f891a28..ebc7286f763 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/room_combat.h
@@ -120,10 +120,14 @@ struct RoomNpcCombatState {
 	int deathDamageType = 0;
 };
 
+struct RoomNpcCombatUpdateResult {
+	Common::Error error = Common::kNoError;
+	bool finalizedDeath = false;
+};
+
 class RoomCombat {
 public:
 	Common::Array<RoomMonsterCombatState> &getMonsterCombatStates() { return _monsterCombatStates; }
-	Common::Array<RoomNpcCombatState> &getNpcCombatStates() { return _npcCombatStates; }
 
 	void initializeCombatStates(const RoomSetupState &state);
 	void resizeCombatStates(const RoomSetupState &state);
@@ -147,11 +151,10 @@ public:
 		EntityManager *entityManager, Entity *&monsterEntity) const;
 
 	bool queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const;
-	bool beginNpcDeathTransition(HarvesterEngine &engine,
-		NpcRecord &npc, Entity &npcEntity, RoomNpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const;
-	Common::Error finalizeNpcDeathTransition(HarvesterEngine &engine,
-		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) const;
+	// Called after resizeCombatStates; NPC finalization ends the entire combat update.
+	RoomNpcCombatUpdateResult updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
+		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
 	Common::Error resolvePlayerAttackContact(HarvesterEngine &engine,
 		RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
@@ -159,6 +162,12 @@ public:
 		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
 
 private:
+	bool beginNpcDeathTransition(HarvesterEngine &engine,
+		NpcRecord &npc, Entity &npcEntity, RoomNpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const;
+	Common::Error finalizeNpcDeathTransition(HarvesterEngine &engine,
+		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) const;
+
 	void startMonsterHitReaction(HarvesterEngine &engine,
 		RoomCombatEffects &combatEffects, MonsterRecord &monster, Entity &monsterEntity, RoomMonsterCombatState *combatState,
 		int attackerAttackFirstFrame, bool &needsRedraw) const;


Commit: 1f2fc6454ede8b87d4dd6c23af67662b479e6f11
    https://github.com/scummvm/scummvm/commit/1f2fc6454ede8b87d4dd6c23af67662b479e6f11
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: more RoomCombat refactoring

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_combat.cpp
    engines/harvester/room_combat.h
    engines/harvester/room_interaction.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index eb51c57efc1..6feeeb98ddc 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -434,7 +434,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		playerState.turnTargetFacing = -1;
 		RoomCombat &combat = session.getCombat();
 		combat.initializeCombatStates(scene.state);
-		Common::Array<RoomMonsterCombatState> &monsterCombatStates = combat.getMonsterCombatStates();
 		RoomSceneRuntime &sceneRuntime = session.getSceneRuntime();
 		RoomCombatEffects &combatEffects = session.getCombatEffects();
 		ResolvedText inspectText;
@@ -980,6 +979,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				session.resetIdleState();
 			}
 
+			void notePlayerActivity() override {
+				session.notePlayerActivity();
+			}
+
 			void stopPlayerRegionInteraction() override {
 				session.stopPlayerRegionInteraction();
 			}
@@ -1061,98 +1064,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
 			_engine.getScript());
 
-		auto findRoomMonsterIndexByName = [&](const Common::String &monsterName) -> int {
-			for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
-				if (scene.state.roomMonsters[i].monsterName.equalsIgnoreCase(monsterName))
-					return (int)i;
-			}
-
-			return -1;
-		};
-		auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> Entity * {
-			return entityManager ? entityManager->findSceneEntityByName(entityName) : nullptr;
-		};
-
-	auto killDebugActiveMonster = [&]() -> Common::Error {
-		if (!_engine.isCombatDebugEnabled())
-			return Common::kNoError;
-		MonsterRecord *monster = combat.resolveKillTarget(scene.state, playerState, entityManager, _mousePos);
-		Entity *monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
-		if (!monster || !monsterEntity || !monsterEntity->isVisible()) {
-			debugC(1, kDebugCombat,
-				"Harvester: debug combat kill skipped reason='no active monster' cursor=(%d,%d)",
-				_mousePos.x, _mousePos.y);
-			return Common::kNoError;
-		}
-
-		Script *script = _engine.getScript();
-		if (!script) {
-			debugC(1, kDebugCombat,
-				"Harvester: debug combat kill skipped target='%s' reason='no startup script'",
-				monster->monsterName.c_str());
-			return Common::kNoError;
-		}
-
-		const int debugDamageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
-		const int damageLanded = monster->currentHitPoints;
-		monster->currentHitPoints = 0;
-		if (damageLanded > 0)
-			combatEffects.spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
-
-		if (playerState.attackTargetClassId == kRuntimeEntityClassMonster &&
-				playerState.attackTargetName.equalsIgnoreCase(monster->monsterName)) {
-			playerState.attackTargetName.clear();
-			playerState.attackTargetClassId = -1;
-		}
-
-		debugC(1, kDebugCombat,
-			"Harvester: debug combat kill target='%s' damage=%d damage_type=%d cursor=(%d,%d)",
-			monster->monsterName.c_str(), damageLanded, debugDamageType, _mousePos.x, _mousePos.y);
-
-		const int monsterIndex = findRoomMonsterIndexByName(monster->monsterName);
-		RoomMonsterCombatState *combatState =
-			monsterIndex >= 0 && monsterIndex < (int)monsterCombatStates.size()
-				? &monsterCombatStates[(uint)monsterIndex]
-				: nullptr;
-		if (combatState)
-			clearRoomMonsterCombatState(*combatState);
-		(void)script->syncRuntimeMonsterRecord(*monster);
-
-		RoomDeathAnimationRange deathRange;
-		if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
-				debugDamageType, _engine.isGoreEnabled(), deathRange)) {
-			combatState->deathActive = true;
-			combatState->deathFirstFrame = deathRange.firstFrame;
-			combatState->deathLastFrame = deathRange.lastFrame;
-			combatState->deathDamageType = debugDamageType;
-			combatState->nextMovementTick = 0;
-			monsterEntity->setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
-			monsterEntity->setAnimationRate(kRoomMonsterAnimationRate);
-			monsterEntity->setAnimationEnabled(true);
-			monsterEntity->setCurrentFrame(deathRange.firstFrame);
-			if (!monster->deathSound.empty())
-				(void)_engine.playSound(monster->deathSound);
-			needsRedraw = true;
-			return Common::kNoError;
-		}
-
-		monster->active = false;
-		monster->visible = false;
-		InteractionResult interaction;
-		interaction.mutatedRuntimeState = true;
-		interaction.visualRuntimeStateChanged = true;
-		if (!monster->onDeathActionTag.empty()) {
-			InteractionResult deathInteraction;
-			if (script->executeActionTag(
-					monster->onDeathActionTag, deathInteraction, true, monster->roomName)) {
-				interaction = Common::move(deathInteraction);
-				interaction.mutatedRuntimeState = true;
-				interaction.visualRuntimeStateChanged = true;
-			}
-		}
-
-		return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
-	};
 	auto capturePlayerAttackTarget = [&]() {
 		playerState.attackTargetName.clear();
 		playerState.attackTargetClassId = -1;
@@ -1163,329 +1074,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		combat.capturePlayerAttackTarget(scene.state, playerState, entityManager, _mousePos, hoverState.npc);
 	};
 
-	auto updateRoomMonsterCombat = [&]() -> Common::Error {
-		if (!entityManager)
-			return Common::kNoError;
-
-		Script &script = *_engine.getScript();
-		const uint32 now = Player::getRuntimeClockTicks();
-		const uint32 moveInterval = MAX<uint32>(1, 100U / (uint32)kRoomMonsterAnimationRate);
-		const int horizontalStepBase = 8;
-		const int depthStep = MAX<int>(1, roundRoomCombatFloat(
-			scene.state.roomZVelocityStep > 0.0f ? scene.state.roomZVelocityStep : 1.0f));
-		bool playerAlive = script.getPlayerCurrentHitPoints() > 0;
-
-		combat.resizeCombatStates(scene.state);
-
-		const RoomNpcCombatUpdateResult npcResult = combat.updateRoomNpcCombat(_engine, scene.state,
-			entityManager, sceneRuntime, interactionProcessor, needsRedraw);
-		if (npcResult.finalizedDeath)
-			return npcResult.error;
-
-		for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
-			MonsterRecord &monster = scene.state.roomMonsters[i];
-			RoomMonsterCombatState &combatState = monsterCombatStates[i];
-			Entity *entity = findSceneRuntimeEntity(monster.monsterName);
-			if (!entity || !monster.visible) {
-				clearRoomMonsterCombatState(combatState);
-				continue;
-			}
-			if (combatState.deathActive) {
-				if (entity->getCurrentFrame() == combatState.deathLastFrame) {
-					combatState.deathActive = false;
-					monster.active = false;
-					monster.visible = true;
-					monster.runtimeSpawned = true;
-					monster.runtimeState = entity->getCurrentFrame();
-					entity->setAnimationRate(0);
-					entity->setAnimationFrameRange(monster.runtimeState, monster.runtimeState, false);
-					entity->setCurrentFrame(monster.runtimeState);
-					entity->setAnimationEnabled(false);
-					(void)script.syncRuntimeMonsterRecord(monster);
-					debugC(1, kDebugCombat,
-						"Harvester: combat monster death complete target='%s' damage_type=%d corpse_frame=%d on_death='%s'",
-						monster.monsterName.c_str(), combatState.deathDamageType,
-						monster.runtimeState,
-						monster.onDeathActionTag.c_str());
-					clearRoomMonsterCombatState(combatState);
-					const bool herrillDeathQueued = combat.queueInnerSanctumHerrillDeathAfterMuck(_engine, scene.state, monster);
-
-					InteractionResult interaction;
-					interaction.mutatedRuntimeState = true;
-					interaction.visualRuntimeStateChanged = true;
-					if (!monster.onDeathActionTag.empty()) {
-						InteractionResult deathInteraction;
-						if (script.executeActionTag(
-								monster.onDeathActionTag, deathInteraction, true, monster.roomName)) {
-							interaction = Common::move(deathInteraction);
-							interaction.mutatedRuntimeState = true;
-							interaction.visualRuntimeStateChanged = true;
-						}
-					}
-					if (herrillDeathQueued) {
-						interaction.mutatedRuntimeState = true;
-						interaction.visualRuntimeStateChanged = true;
-					}
-
-					return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
-				}
-				continue;
-			}
-			if (monster.currentHitPoints <= 0) {
-				clearRoomMonsterCombatState(combatState);
-				continue;
-			}
-
-			if (!monster.active || !playerState.entity || !playerAlive) {
-				clearRoomMonsterCombatState(combatState);
-				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
-				continue;
-			}
-
-			if (combatState.hitActive) {
-				const int currentFrame = entity->getCurrentFrame();
-				if (!combatState.hitSoundPlayed &&
-						monster.hitSoundTriggerFrame >= 0 &&
-						currentFrame >= combatState.hitFirstFrame + monster.hitSoundTriggerFrame) {
-					(void)playRandomRoomAttackSound(_engine,
-						monster.hitSound1, monster.hitSound2, monster.hitSound3);
-					combatState.hitSoundPlayed = true;
-				}
-				if (combatState.hitKnockbackRemainingX != 0) {
-					const int step = MAX(1, ABS(combatState.hitKnockbackDecayStep));
-					const int knockbackStep = combatState.hitKnockbackRemainingX > 0
-						? MIN(combatState.hitKnockbackRemainingX, step)
-						: MAX(combatState.hitKnockbackRemainingX, -step);
-					const int previousX = monster.posX;
-					monster.posX = CLIP<int>(
-						monster.posX + knockbackStep, monster.screenMinXBound, monster.screenMaxXBound);
-					if (monster.posX != previousX) {
-						(void)applyRoomActorPlacement(
-							scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
-						(void)script.syncRuntimeMonsterRecord(monster);
-						debugC(1, kDebugCombat,
-							"Harvester: combat monster hit knockback target='%s' from_x=%d to_x=%d remaining=%d",
-							monster.monsterName.c_str(), previousX, monster.posX,
-							combatState.hitKnockbackRemainingX - (monster.posX - previousX));
-						needsRedraw = true;
-						combatState.hitKnockbackRemainingX -= monster.posX - previousX;
-					} else {
-						combatState.hitKnockbackRemainingX = 0;
-					}
-				}
-				if (currentFrame == combatState.hitLastFrame) {
-					clearRoomMonsterHitState(combatState);
-					needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
-				}
-				continue;
-			}
-
-			if (combatState.attackActive) {
-				const int currentFrame = entity->getCurrentFrame();
-				if (!combatState.attackSoundPlayed &&
-						monster.attackSoundTriggerFrame >= 0 &&
-						currentFrame >= combatState.attackFirstFrame + monster.attackSoundTriggerFrame) {
-					(void)playRandomRoomAttackSound(_engine,
-						monster.attackSound1, monster.attackSound2, monster.attackSound3);
-					combatState.attackSoundPlayed = true;
-				}
-				if (!combatState.attackContactResolved &&
-						combatState.attackContactFrame >= 0 &&
-						currentFrame >= combatState.attackContactFrame) {
-					combatState.attackContactResolved = true;
-					combatState.attackContactFrame = -1;
-					const bool hasTrackedPlayerTarget =
-						playerState.entity &&
-						!combatState.attackTargetName.empty() &&
-						playerState.entity->getName().equalsIgnoreCase(combatState.attackTargetName);
-					if (!playerAlive || !hasTrackedPlayerTarget) {
-						debugC(1, kDebugCombat,
-							"Harvester: combat monster attack miss monster='%s' frame=%d player_hp=%d reason='no live target'",
-							monster.monsterName.c_str(), currentFrame, script.getPlayerCurrentHitPoints());
-					} else {
-						const int playerHitPointsBefore = script.getPlayerCurrentHitPoints();
-						const bool damageDisabled = _cheats.isPlayerDamageDisabled();
-						const bool changed = damageDisabled
-							? false
-							: script.adjustPlayerCurrentHitPoints(-monster.damageAmount);
-						const int playerHitPointsAfter = script.getPlayerCurrentHitPoints();
-						const int damageLanded = playerHitPointsBefore - playerHitPointsAfter;
-						debugC(1, kDebugCombat,
-							"Harvester: combat monster attack hit monster='%s' frame=%d damage=%d damage_type='%s' player_hp=%d->%d changed=%d damage_disabled=%d",
-							monster.monsterName.c_str(), currentFrame, monster.damageAmount,
-							Player::describeCombatDamageType(monster.damageType),
-							playerHitPointsBefore, playerHitPointsAfter, changed,
-							damageDisabled ? 1 : 0);
-						if (damageLanded > 0 && playerState.entity) {
-							combatEffects.spawnCombatDamagePopup(*playerState.entity, playerState.entity->getName(), damageLanded);
-							if (idleState.entity) {
-								idleState.entity->setVisible(false);
-								idleState.entity->setAnimationRate(0);
-							}
-							idleState.active = false;
-							idleState.loopStarted = false;
-							idleState.exiting = false;
-							idleState.restoreFacing = -1;
-							playerState.entity->setVisible(true);
-							if (playerHitPointsAfter > 0 &&
-									Player::startHitAnimation(_engine, playerState, combatState.attackFirstFrame)) {
-								session.notePlayerActivity();
-							}
-						}
-						if (changed && playerHitPointsAfter <= 0) {
-							playerAlive = false;
-							session.stopPlayerRegionInteraction();
-							startPlayerDefeatSequence(
-								"combat_monster_hit", monster.monsterName, monster.damageType);
-							return Common::kNoError;
-						}
-					}
-					needsRedraw = true;
-				}
-				if (currentFrame == combatState.attackLastFrame) {
-					clearRoomMonsterAttackState(combatState);
-					needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
-				}
-				continue;
-			}
-
-			const bool animationDrivesMovement =
-				entity->isAnimationEnabled() && entity->getAnimationRate() != 0;
-			if (animationDrivesMovement) {
-				combatState.nextMovementTick = 0;
-				// Native room-entry monster spawns seed a left/right pursue state immediately
-				// from the player's relative X position. Our room-combat shim does not model
-				// those actor-state bytes directly, so allow one immediate pursue evaluation
-				// before the first animation-driven movement tick lands.
-				if (!combatState.allowInitialPursuitStep && !entity->didAnimationAdvanceLastTick())
-					continue;
-			} else {
-				if (combatState.nextMovementTick != 0 && (int32)(now - combatState.nextMovementTick) < 0)
-					continue;
-				combatState.nextMovementTick = now + moveInterval;
-			}
-			combatState.allowInitialPursuitStep = false;
-
-			const int previousX = monster.posX;
-			const int previousY = monster.posY;
-			const int previousZ = monster.posZ;
-			const Common::Rect playerRect = playerState.entity->getScreenRect();
-			const Common::Rect monsterRect = entity->getScreenRect();
-			const int playerCenterX = playerRect.left + playerRect.width() / 2;
-			const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
-			const int liveCenterDx = playerCenterX - monsterCenterX;
-			const int absLiveCenterDx = ABS(liveCenterDx);
-			const int playerTargetZ = roundRoomCombatFloat(playerState.z);
-			const float zDelta = playerState.z - (float)monster.posZ;
-			const float absZDelta = zDelta >= 0.0f ? zDelta : -zDelta;
-			const int engageDistance = MAX(0, monster.engageDistance);
-			const int liveWaypointTolerance = roundRoomCombatFloat(
-				Player::computeDepthScale(scene.state,
-					MIN<float>(playerState.z, (float)monster.posZ)) * kNativeMonsterHorizontalWaypointTolerance);
-			bool attemptedMoveX = false;
-			bool attemptedMoveZ = false;
-			if (absZDelta > kNativeMonsterPursuitZTolerance) {
-				attemptedMoveZ = true;
-				monster.posZ = stepTowardsRoomCombatInt(monster.posZ, playerTargetZ, depthStep);
-				monster.posZ = (int)clampRoomDepthForEvent(scene.state, (float)monster.posZ);
-				monster.posY = mapRoomDepthToScreenYForCombat(scene.state, (float)monster.posZ, monster.posY);
-			} else if (absLiveCenterDx > engageDistance) {
-				// Native class-6 pursuit stores a target frame-left X, not a center X.
-				const int desiredFrameLeftX = monsterCenterX < playerCenterX
-					? playerRect.right + engageDistance - monsterRect.width() / 2
-					: playerRect.left - engageDistance + monsterRect.width() / 2;
-				if (ABS(monsterRect.left - desiredFrameLeftX) > liveWaypointTolerance) {
-					attemptedMoveX = true;
-					const int step = MAX<int>(1, roundRoomCombatFloat(
-						Player::computeDepthScale(scene.state, (float)monster.posZ) * (float)horizontalStepBase));
-					const int monsterFrameLeftOffset = monsterRect.left - monster.posX;
-					const int desiredLogicalCenterX = desiredFrameLeftX - monsterFrameLeftOffset;
-					monster.posX = stepTowardsRoomCombatInt(monster.posX, desiredLogicalCenterX, step);
-					monster.posX = CLIP<int>(monster.posX, monster.screenMinXBound, monster.screenMaxXBound);
-				}
-			}
-
-			const bool moved = monster.posX != previousX || monster.posY != previousY || monster.posZ != previousZ;
-			if (moved) {
-				const int movedX = monster.posX - previousX;
-				const int movedY = monster.posY - previousY;
-				if (ABS(movedX) > ABS(movedY))
-					monster.facing = movedX < 0 ? 1 : 2;
-				else if (movedY != 0)
-					monster.facing = movedY < 0 ? 3 : 0;
-				(void)applyRoomActorPlacement(scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
-				if (monster.posZ != previousZ)
-					entityManager->reinsertSceneEntity(entity);
-				(void)script.syncRuntimeMonsterRecord(monster);
-				debugC(1, kDebugCombat,
-					"Harvester: combat monster chase move monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d from=(%d,%d,z=%d) to=(%d,%d,z=%d) facing=%d",
-					monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
-					previousX, previousY, previousZ, monster.posX, monster.posY, monster.posZ, monster.facing);
-				needsRedraw = true;
-				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, true) || needsRedraw;
-				continue;
-			}
-
-			if (attemptedMoveX || attemptedMoveZ) {
-				debugC(1, kDebugCombat,
-					"Harvester: combat monster chase stalled monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d pos=(%d,%d,z=%d) attempted_x=%d attempted_z=%d",
-					monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
-					monster.posX, monster.posY, monster.posZ, attemptedMoveX, attemptedMoveZ);
-			}
-
-			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
-			const bool closeEnoughForAttack = isWithinNativeMonsterAttackEntryRange(
-				*entity, (float)monster.posZ, *playerState.entity, playerState.z, engageDistance);
-			// Native class-6 monsters store `now + random(99)` when they arm an attack, then
-			// apply the fixed 0x32-tick gate when deciding whether a later strike may start.
-			// A fresh zeroed spawn therefore attacks immediately once it reaches engage range.
-			const uint32 attackCooldownDeadline =
-				combatState.attackCooldownSeedTick + kNativeMonsterAttackCooldownBaseTicks;
-			const bool attackCooldownExpired = combatState.attackCooldownSeedTick == 0 ||
-				(int32)(now - attackCooldownDeadline) > 0;
-			const int closeRangeBypassDistance = MAX(1, engageDistance / 3);
-			if (!closeEnoughForAttack)
-				continue;
-			if (!attackCooldownExpired && absLiveCenterDx > closeRangeBypassDistance)
-				continue;
-
-			RoomAttackAnimationRange range;
-			if (!resolveMonsterAttackAnimationRange(_engine, *entity, monsterCenterX, playerCenterX, range)) {
-				debugC(1, kDebugCombat,
-					"Harvester: combat monster attack skipped monster='%s' reason='no native attack bank' target_side=%s",
-					monster.monsterName.c_str(), playerCenterX < monsterCenterX ? "left" : "right");
-				continue;
-			}
-			combatState.attackActive = true;
-			combatState.attackFirstFrame = range.firstFrame;
-			combatState.attackLastFrame = range.lastFrame;
-			combatState.attackContactFrame =
-				range.firstFrame + resolveMonsterAttackContactFrameOffset(monster);
-			combatState.attackResumeFacing = range.resumeFacing;
-			combatState.attackSoundPlayed =
-				monster.attackSound1.empty() && monster.attackSound2.empty() && monster.attackSound3.empty();
-			combatState.attackContactResolved = false;
-			combatState.attackTargetName = playerState.entity->getName();
-			combatState.attackCooldownSeedTick = now + _engine.getRandomNumber(99);
-			monster.facing = range.resumeFacing;
-			entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-			entity->setAnimationRate(kNativeMonsterAttackAnimationRate);
-			entity->setAnimationEnabled(true);
-			entity->setCurrentFrame(range.firstFrame);
-			debugC(2, kDebugCombat,
-				"Harvester: combat monster attack start monster='%s' target='%s'",
-				monster.monsterName.c_str(), combatState.attackTargetName.c_str());
-			debugC(3, kDebugCombat,
-				"Harvester: combat monster attack timing monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d rate=%d attack_seed_tick=%u close_bypass=%d",
-				monster.monsterName.c_str(), combatState.attackTargetName.c_str(), range.firstFrame, range.lastFrame,
-				combatState.attackContactFrame, range.resumeFacing, liveCenterDx, (double)zDelta,
-				engageDistance, kNativeMonsterAttackAnimationRate, combatState.attackCooldownSeedTick,
-				closeRangeBypassDistance);
-			needsRedraw = true;
-		}
-
-		return Common::kNoError;
-	};
 	auto moveRoomItemDirectlyToInventory = [&](const ObjectRecord &object) -> Common::Error {
 		Script *script = _engine.getScript();
 		if (!script)
@@ -2510,7 +2098,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						event.kbd.keycode == Common::KEYCODE_RCTRL) {
 					attackModifierHeld = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_k && _engine.isCombatDebugEnabled()) {
-					Common::Error debugKillError = killDebugActiveMonster();
+					Common::Error debugKillError = combat.killDebugActiveMonster(_engine, scene.state, playerState, entityManager,
+						_mousePos, combatEffects, interactionProcessor, needsRedraw);
 					if (debugKillError.getCode() != Common::kNoError)
 						return debugKillError;
 					break;
@@ -2719,7 +2308,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				needsRedraw = true;
 			}
 		}
-		combatError = updateRoomMonsterCombat();
+		combatError = combat.updateRoomMonsterCombat(_engine, scene.state, playerState, idleState,
+			entityManager, sceneRuntime, combatEffects, _cheats, interactionCallbacks, interactionProcessor, needsRedraw);
 		if (combatError.getCode() != Common::kNoError)
 			return combatError;
 		if (combatEffects.syncCombatHitEffects())
diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
index ecfb46ca11d..dfd27b7a156 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/room_combat.cpp
@@ -25,6 +25,7 @@
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/screen.h"
+#include "harvester/cheats.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/player.h"
@@ -1171,6 +1172,412 @@ void RoomCombat::startMonsterHitReaction(HarvesterEngine &engine,
 	needsRedraw = true;
 }
 
+Common::Error RoomCombat::killDebugActiveMonster(HarvesterEngine &engine, RoomSetupState &state,
+		RoomPlayerState &playerState, EntityManager *entityManager, const Common::Point &mousePos,
+		RoomCombatEffects &combatEffects, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+	if (!engine.isCombatDebugEnabled())
+		return Common::kNoError;
+	MonsterRecord *monster = resolveKillTarget(state, playerState, entityManager, mousePos);
+	Entity *monsterEntity = monster ? findSceneRuntimeEntity(entityManager, monster->monsterName) : nullptr;
+	if (!monster || !monsterEntity || !monsterEntity->isVisible()) {
+		debugC(1, kDebugCombat,
+			"Harvester: debug combat kill skipped reason='no active monster' cursor=(%d,%d)",
+			mousePos.x, mousePos.y);
+		return Common::kNoError;
+	}
+
+	Script *script = engine.getScript();
+	if (!script) {
+		debugC(1, kDebugCombat,
+			"Harvester: debug combat kill skipped target='%s' reason='no startup script'",
+			monster->monsterName.c_str());
+		return Common::kNoError;
+	}
+
+	const int debugDamageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
+	const int damageLanded = monster->currentHitPoints;
+	monster->currentHitPoints = 0;
+	if (damageLanded > 0)
+		combatEffects.spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
+
+	if (playerState.attackTargetClassId == kRuntimeEntityClassMonster &&
+			playerState.attackTargetName.equalsIgnoreCase(monster->monsterName)) {
+		playerState.attackTargetName.clear();
+		playerState.attackTargetClassId = -1;
+	}
+
+	debugC(1, kDebugCombat,
+		"Harvester: debug combat kill target='%s' damage=%d damage_type=%d cursor=(%d,%d)",
+		monster->monsterName.c_str(), damageLanded, debugDamageType, mousePos.x, mousePos.y);
+
+	const int monsterIndex = findRoomMonsterIndexByName(state, monster->monsterName);
+	RoomMonsterCombatState *combatState =
+		monsterIndex >= 0 && monsterIndex < (int)_monsterCombatStates.size()
+			? &_monsterCombatStates[(uint)monsterIndex]
+			: nullptr;
+	if (combatState)
+		clearRoomMonsterCombatState(*combatState);
+	(void)script->syncRuntimeMonsterRecord(*monster);
+
+	RoomDeathAnimationRange deathRange;
+	if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
+			debugDamageType, engine.isGoreEnabled(), deathRange)) {
+		combatState->deathActive = true;
+		combatState->deathFirstFrame = deathRange.firstFrame;
+		combatState->deathLastFrame = deathRange.lastFrame;
+		combatState->deathDamageType = debugDamageType;
+		combatState->nextMovementTick = 0;
+		monsterEntity->setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
+		monsterEntity->setAnimationRate(kRoomMonsterAnimationRate);
+		monsterEntity->setAnimationEnabled(true);
+		monsterEntity->setCurrentFrame(deathRange.firstFrame);
+		if (!monster->deathSound.empty())
+			(void)engine.playSound(monster->deathSound);
+		needsRedraw = true;
+		return Common::kNoError;
+	}
+
+	monster->active = false;
+	monster->visible = false;
+	InteractionResult interaction;
+	interaction.mutatedRuntimeState = true;
+	interaction.visualRuntimeStateChanged = true;
+	if (!monster->onDeathActionTag.empty()) {
+		InteractionResult deathInteraction;
+		if (script->executeActionTag(
+				monster->onDeathActionTag, deathInteraction, true, monster->roomName)) {
+			interaction = Common::move(deathInteraction);
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
+	}
+
+	return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
+}
+
+Common::Error RoomCombat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetupState &state,
+		RoomPlayerState &playerState, RoomIdleAnimationState &idleState, EntityManager *entityManager,
+		RoomSceneRuntime &sceneRuntime, RoomCombatEffects &combatEffects, const CheatSystem &cheats,
+		RoomInteractionCallbacks &callbacks, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+	if (!entityManager)
+		return Common::kNoError;
+
+	Script &script = *engine.getScript();
+	const uint32 now = Player::getRuntimeClockTicks();
+	const uint32 moveInterval = MAX<uint32>(1, 100U / (uint32)kRoomMonsterAnimationRate);
+	const int horizontalStepBase = 8;
+	const int depthStep = MAX<int>(1, roundRoomCombatFloat(
+		state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f));
+	bool playerAlive = script.getPlayerCurrentHitPoints() > 0;
+
+	resizeCombatStates(state);
+
+	const RoomNpcCombatUpdateResult npcResult = updateRoomNpcCombat(engine, state,
+		entityManager, sceneRuntime, interactionProcessor, needsRedraw);
+	if (npcResult.finalizedDeath)
+		return npcResult.error;
+
+	for (uint i = 0; i < state.roomMonsters.size(); ++i) {
+		MonsterRecord &monster = state.roomMonsters[i];
+		RoomMonsterCombatState &combatState = _monsterCombatStates[i];
+		Entity *entity = findSceneRuntimeEntity(entityManager, monster.monsterName);
+		if (!entity || !monster.visible) {
+			clearRoomMonsterCombatState(combatState);
+			continue;
+		}
+		if (combatState.deathActive) {
+			if (entity->getCurrentFrame() == combatState.deathLastFrame) {
+				combatState.deathActive = false;
+				monster.active = false;
+				monster.visible = true;
+				monster.runtimeSpawned = true;
+				monster.runtimeState = entity->getCurrentFrame();
+				entity->setAnimationRate(0);
+				entity->setAnimationFrameRange(monster.runtimeState, monster.runtimeState, false);
+				entity->setCurrentFrame(monster.runtimeState);
+				entity->setAnimationEnabled(false);
+				(void)script.syncRuntimeMonsterRecord(monster);
+				debugC(1, kDebugCombat,
+					"Harvester: combat monster death complete target='%s' damage_type=%d corpse_frame=%d on_death='%s'",
+					monster.monsterName.c_str(), combatState.deathDamageType,
+					monster.runtimeState,
+					monster.onDeathActionTag.c_str());
+				clearRoomMonsterCombatState(combatState);
+				const bool herrillDeathQueued = queueInnerSanctumHerrillDeathAfterMuck(engine, state, monster);
+
+				InteractionResult interaction;
+				interaction.mutatedRuntimeState = true;
+				interaction.visualRuntimeStateChanged = true;
+				if (!monster.onDeathActionTag.empty()) {
+					InteractionResult deathInteraction;
+					if (script.executeActionTag(
+							monster.onDeathActionTag, deathInteraction, true, monster.roomName)) {
+						interaction = Common::move(deathInteraction);
+						interaction.mutatedRuntimeState = true;
+						interaction.visualRuntimeStateChanged = true;
+					}
+				}
+				if (herrillDeathQueued) {
+					interaction.mutatedRuntimeState = true;
+					interaction.visualRuntimeStateChanged = true;
+				}
+
+				return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
+			}
+			continue;
+		}
+		if (monster.currentHitPoints <= 0) {
+			clearRoomMonsterCombatState(combatState);
+			continue;
+		}
+
+		if (!monster.active || !playerState.entity || !playerAlive) {
+			clearRoomMonsterCombatState(combatState);
+			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+			continue;
+		}
+
+		if (combatState.hitActive) {
+			const int currentFrame = entity->getCurrentFrame();
+			if (!combatState.hitSoundPlayed &&
+					monster.hitSoundTriggerFrame >= 0 &&
+					currentFrame >= combatState.hitFirstFrame + monster.hitSoundTriggerFrame) {
+				(void)playRandomRoomAttackSound(engine,
+					monster.hitSound1, monster.hitSound2, monster.hitSound3);
+				combatState.hitSoundPlayed = true;
+			}
+			if (combatState.hitKnockbackRemainingX != 0) {
+				const int step = MAX(1, ABS(combatState.hitKnockbackDecayStep));
+				const int knockbackStep = combatState.hitKnockbackRemainingX > 0
+					? MIN(combatState.hitKnockbackRemainingX, step)
+					: MAX(combatState.hitKnockbackRemainingX, -step);
+				const int previousX = monster.posX;
+				monster.posX = CLIP<int>(
+					monster.posX + knockbackStep, monster.screenMinXBound, monster.screenMaxXBound);
+				if (monster.posX != previousX) {
+					(void)applyRoomActorPlacement(
+						state, *entity, monster.posX, monster.posY, (float)monster.posZ);
+					(void)script.syncRuntimeMonsterRecord(monster);
+					debugC(1, kDebugCombat,
+						"Harvester: combat monster hit knockback target='%s' from_x=%d to_x=%d remaining=%d",
+						monster.monsterName.c_str(), previousX, monster.posX,
+						combatState.hitKnockbackRemainingX - (monster.posX - previousX));
+					needsRedraw = true;
+					combatState.hitKnockbackRemainingX -= monster.posX - previousX;
+				} else {
+					combatState.hitKnockbackRemainingX = 0;
+				}
+			}
+			if (currentFrame == combatState.hitLastFrame) {
+				clearRoomMonsterHitState(combatState);
+				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+			}
+			continue;
+		}
+
+		if (combatState.attackActive) {
+			const int currentFrame = entity->getCurrentFrame();
+			if (!combatState.attackSoundPlayed &&
+					monster.attackSoundTriggerFrame >= 0 &&
+					currentFrame >= combatState.attackFirstFrame + monster.attackSoundTriggerFrame) {
+				(void)playRandomRoomAttackSound(engine,
+					monster.attackSound1, monster.attackSound2, monster.attackSound3);
+				combatState.attackSoundPlayed = true;
+			}
+			if (!combatState.attackContactResolved &&
+					combatState.attackContactFrame >= 0 &&
+					currentFrame >= combatState.attackContactFrame) {
+				combatState.attackContactResolved = true;
+				combatState.attackContactFrame = -1;
+				const bool hasTrackedPlayerTarget =
+					playerState.entity &&
+					!combatState.attackTargetName.empty() &&
+					playerState.entity->getName().equalsIgnoreCase(combatState.attackTargetName);
+				if (!playerAlive || !hasTrackedPlayerTarget) {
+					debugC(1, kDebugCombat,
+						"Harvester: combat monster attack miss monster='%s' frame=%d player_hp=%d reason='no live target'",
+						monster.monsterName.c_str(), currentFrame, script.getPlayerCurrentHitPoints());
+				} else {
+					const int playerHitPointsBefore = script.getPlayerCurrentHitPoints();
+					const bool damageDisabled = cheats.isPlayerDamageDisabled();
+					const bool changed = damageDisabled
+						? false
+						: script.adjustPlayerCurrentHitPoints(-monster.damageAmount);
+					const int playerHitPointsAfter = script.getPlayerCurrentHitPoints();
+					const int damageLanded = playerHitPointsBefore - playerHitPointsAfter;
+					debugC(1, kDebugCombat,
+						"Harvester: combat monster attack hit monster='%s' frame=%d damage=%d damage_type='%s' player_hp=%d->%d changed=%d damage_disabled=%d",
+						monster.monsterName.c_str(), currentFrame, monster.damageAmount,
+						Player::describeCombatDamageType(monster.damageType),
+						playerHitPointsBefore, playerHitPointsAfter, changed,
+						damageDisabled ? 1 : 0);
+					if (damageLanded > 0 && playerState.entity) {
+						combatEffects.spawnCombatDamagePopup(*playerState.entity, playerState.entity->getName(), damageLanded);
+						if (idleState.entity) {
+							idleState.entity->setVisible(false);
+							idleState.entity->setAnimationRate(0);
+						}
+						idleState.active = false;
+						idleState.loopStarted = false;
+						idleState.exiting = false;
+						idleState.restoreFacing = -1;
+						playerState.entity->setVisible(true);
+						if (playerHitPointsAfter > 0 &&
+								Player::startHitAnimation(engine, playerState, combatState.attackFirstFrame)) {
+							callbacks.notePlayerActivity();
+						}
+					}
+					if (changed && playerHitPointsAfter <= 0) {
+						playerAlive = false;
+						callbacks.stopPlayerRegionInteraction();
+						callbacks.startPlayerDefeatSequence(
+							"combat_monster_hit", monster.monsterName, monster.damageType);
+						return Common::kNoError;
+					}
+				}
+				needsRedraw = true;
+			}
+			if (currentFrame == combatState.attackLastFrame) {
+				clearRoomMonsterAttackState(combatState);
+				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+			}
+			continue;
+		}
+
+		const bool animationDrivesMovement =
+			entity->isAnimationEnabled() && entity->getAnimationRate() != 0;
+		if (animationDrivesMovement) {
+			combatState.nextMovementTick = 0;
+			if (!combatState.allowInitialPursuitStep && !entity->didAnimationAdvanceLastTick())
+				continue;
+		} else {
+			if (combatState.nextMovementTick != 0 && (int32)(now - combatState.nextMovementTick) < 0)
+				continue;
+			combatState.nextMovementTick = now + moveInterval;
+		}
+		combatState.allowInitialPursuitStep = false;
+
+		const int previousX = monster.posX;
+		const int previousY = monster.posY;
+		const int previousZ = monster.posZ;
+		const Common::Rect playerRect = playerState.entity->getScreenRect();
+		const Common::Rect monsterRect = entity->getScreenRect();
+		const int playerCenterX = playerRect.left + playerRect.width() / 2;
+		const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
+		const int liveCenterDx = playerCenterX - monsterCenterX;
+		const int absLiveCenterDx = ABS(liveCenterDx);
+		const int playerTargetZ = roundRoomCombatFloat(playerState.z);
+		const float zDelta = playerState.z - (float)monster.posZ;
+		const float absZDelta = zDelta >= 0.0f ? zDelta : -zDelta;
+		const int engageDistance = MAX(0, monster.engageDistance);
+		const int liveWaypointTolerance = roundRoomCombatFloat(
+			Player::computeDepthScale(state,
+				MIN<float>(playerState.z, (float)monster.posZ)) * kNativeMonsterHorizontalWaypointTolerance);
+		bool attemptedMoveX = false;
+		bool attemptedMoveZ = false;
+		if (absZDelta > kNativeMonsterPursuitZTolerance) {
+			attemptedMoveZ = true;
+			monster.posZ = stepTowardsRoomCombatInt(monster.posZ, playerTargetZ, depthStep);
+			monster.posZ = (int)clampRoomDepthForEvent(state, (float)monster.posZ);
+			monster.posY = mapRoomDepthToScreenYForCombat(state, (float)monster.posZ, monster.posY);
+		} else if (absLiveCenterDx > engageDistance) {
+			// Native class-6 pursuit stores a target frame-left X, not a center X.
+			const int desiredFrameLeftX = monsterCenterX < playerCenterX
+				? playerRect.right + engageDistance - monsterRect.width() / 2
+				: playerRect.left - engageDistance + monsterRect.width() / 2;
+			if (ABS(monsterRect.left - desiredFrameLeftX) > liveWaypointTolerance) {
+				attemptedMoveX = true;
+				const int step = MAX<int>(1, roundRoomCombatFloat(
+					Player::computeDepthScale(state, (float)monster.posZ) * (float)horizontalStepBase));
+				const int monsterFrameLeftOffset = monsterRect.left - monster.posX;
+				const int desiredLogicalCenterX = desiredFrameLeftX - monsterFrameLeftOffset;
+				monster.posX = stepTowardsRoomCombatInt(monster.posX, desiredLogicalCenterX, step);
+				monster.posX = CLIP<int>(monster.posX, monster.screenMinXBound, monster.screenMaxXBound);
+			}
+		}
+
+		const bool moved = monster.posX != previousX || monster.posY != previousY || monster.posZ != previousZ;
+		if (moved) {
+			const int movedX = monster.posX - previousX;
+			const int movedY = monster.posY - previousY;
+			if (ABS(movedX) > ABS(movedY))
+				monster.facing = movedX < 0 ? 1 : 2;
+			else if (movedY != 0)
+				monster.facing = movedY < 0 ? 3 : 0;
+			(void)applyRoomActorPlacement(state, *entity, monster.posX, monster.posY, (float)monster.posZ);
+			if (monster.posZ != previousZ)
+				entityManager->reinsertSceneEntity(entity);
+			(void)script.syncRuntimeMonsterRecord(monster);
+			debugC(1, kDebugCombat,
+				"Harvester: combat monster chase move monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d from=(%d,%d,z=%d) to=(%d,%d,z=%d) facing=%d",
+				monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
+				previousX, previousY, previousZ, monster.posX, monster.posY, monster.posZ, monster.facing);
+			needsRedraw = true;
+			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, true) || needsRedraw;
+			continue;
+		}
+
+		if (attemptedMoveX || attemptedMoveZ) {
+			debugC(1, kDebugCombat,
+				"Harvester: combat monster chase stalled monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d pos=(%d,%d,z=%d) attempted_x=%d attempted_z=%d",
+				monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
+				monster.posX, monster.posY, monster.posZ, attemptedMoveX, attemptedMoveZ);
+		}
+
+		needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+		const bool closeEnoughForAttack = isWithinNativeMonsterAttackEntryRange(
+			*entity, (float)monster.posZ, *playerState.entity, playerState.z, engageDistance);
+		// Class-6 monsters store `now + random(99)` when they arm an attack, then
+		// apply a 0x32-tick gate when deciding whether a later strike may start.
+		// A fresh zeroed spawn therefore attacks immediately once it reaches engage range.
+		const uint32 attackCooldownDeadline =
+			combatState.attackCooldownSeedTick + kNativeMonsterAttackCooldownBaseTicks;
+		const bool attackCooldownExpired = combatState.attackCooldownSeedTick == 0 ||
+			(int32)(now - attackCooldownDeadline) > 0;
+		const int closeRangeBypassDistance = MAX(1, engageDistance / 3);
+		if (!closeEnoughForAttack)
+			continue;
+		if (!attackCooldownExpired && absLiveCenterDx > closeRangeBypassDistance)
+			continue;
+
+		RoomAttackAnimationRange range;
+		if (!resolveMonsterAttackAnimationRange(engine, *entity, monsterCenterX, playerCenterX, range)) {
+			debugC(1, kDebugCombat,
+				"Harvester: combat monster attack skipped monster='%s' reason='no native attack bank' target_side=%s",
+				monster.monsterName.c_str(), playerCenterX < monsterCenterX ? "left" : "right");
+			continue;
+		}
+		combatState.attackActive = true;
+		combatState.attackFirstFrame = range.firstFrame;
+		combatState.attackLastFrame = range.lastFrame;
+		combatState.attackContactFrame =
+			range.firstFrame + resolveMonsterAttackContactFrameOffset(monster);
+		combatState.attackResumeFacing = range.resumeFacing;
+		combatState.attackSoundPlayed =
+			monster.attackSound1.empty() && monster.attackSound2.empty() && monster.attackSound3.empty();
+		combatState.attackContactResolved = false;
+		combatState.attackTargetName = playerState.entity->getName();
+		combatState.attackCooldownSeedTick = now + engine.getRandomNumber(99);
+		monster.facing = range.resumeFacing;
+		entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+		entity->setAnimationRate(kNativeMonsterAttackAnimationRate);
+		entity->setAnimationEnabled(true);
+		entity->setCurrentFrame(range.firstFrame);
+		debugC(2, kDebugCombat,
+			"Harvester: combat monster attack start monster='%s' target='%s'",
+			monster.monsterName.c_str(), combatState.attackTargetName.c_str());
+		debugC(3, kDebugCombat,
+			"Harvester: combat monster attack timing monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d rate=%d attack_seed_tick=%u close_bypass=%d",
+			monster.monsterName.c_str(), combatState.attackTargetName.c_str(), range.firstFrame, range.lastFrame,
+			combatState.attackContactFrame, range.resumeFacing, liveCenterDx, (double)zDelta,
+			engageDistance, kNativeMonsterAttackAnimationRate, combatState.attackCooldownSeedTick,
+			closeRangeBypassDistance);
+		needsRedraw = true;
+	}
+
+	return Common::kNoError;
+}
+
 Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
 		RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
index ebc7286f763..95a3fac1b3a 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/room_combat.h
@@ -27,10 +27,12 @@
 
 namespace Harvester {
 
+class CheatSystem;
 class Entity;
 class EntityManager;
 class HarvesterEngine;
 class RoomCombatEffects;
+class RoomInteractionCallbacks;
 class RoomInteractionProcessor;
 class RoomSceneRuntime;
 
@@ -127,10 +129,7 @@ struct RoomNpcCombatUpdateResult {
 
 class RoomCombat {
 public:
-	Common::Array<RoomMonsterCombatState> &getMonsterCombatStates() { return _monsterCombatStates; }
-
 	void initializeCombatStates(const RoomSetupState &state);
-	void resizeCombatStates(const RoomSetupState &state);
 	// Keep prepared state separate until scene-entity mutations succeed.
 	Common::Array<RoomMonsterCombatState> prepareMonsterCombatStatesForSceneChange(
 		const Common::Array<MonsterRecord> &previousMonsters, const Common::Array<MonsterRecord> &updatedMonsters) const;
@@ -150,11 +149,14 @@ public:
 	MonsterRecord *selectMonsterAttackContactTarget(RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, Entity *&monsterEntity) const;
 
-	bool queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const;
-	// Called after resizeCombatStates; NPC finalization ends the entire combat update.
-	RoomNpcCombatUpdateResult updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
-		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+	Common::Error killDebugActiveMonster(HarvesterEngine &engine, RoomSetupState &state,
+		RoomPlayerState &playerState, EntityManager *entityManager, const Common::Point &mousePos,
+		RoomCombatEffects &combatEffects, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+
+	Common::Error updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetupState &state,
+		RoomPlayerState &playerState, RoomIdleAnimationState &idleState, EntityManager *entityManager,
+		RoomSceneRuntime &sceneRuntime, RoomCombatEffects &combatEffects, const CheatSystem &cheats,
+		RoomInteractionCallbacks &callbacks, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
 	Common::Error resolvePlayerAttackContact(HarvesterEngine &engine,
 		RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
@@ -162,6 +164,13 @@ public:
 		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
 
 private:
+	void resizeCombatStates(const RoomSetupState &state);
+	bool queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const;
+	// Called after resizeCombatStates; NPC finalization ends the entire combat update.
+	RoomNpcCombatUpdateResult updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
+		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+
 	bool beginNpcDeathTransition(HarvesterEngine &engine,
 		NpcRecord &npc, Entity &npcEntity, RoomNpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const;
 	Common::Error finalizeNpcDeathTransition(HarvesterEngine &engine,
diff --git a/engines/harvester/room_interaction.h b/engines/harvester/room_interaction.h
index 28142232c52..92e5d9217ba 100644
--- a/engines/harvester/room_interaction.h
+++ b/engines/harvester/room_interaction.h
@@ -48,6 +48,7 @@ public:
 	virtual void applyPlayerGotoXZ(int x, int z) = 0;
 	virtual Common::Error runModalShowText(const ResolvedText &modalText) = 0;
 	virtual void resetIdleState() = 0;
+	virtual void notePlayerActivity() = 0;
 	virtual void stopPlayerRegionInteraction() = 0;
 	virtual void startPlayerDefeatSequence(const char *reason, const Common::String &sourceName,
 		int damageType) = 0;


Commit: dd34002fcccda52d836fe9c5ac0d638a765631f3
    https://github.com/scummvm/scummvm/commit/dd34002fcccda52d836fe9c5ac0d638a765631f3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Refactor room item interactions some inventory handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_interaction.cpp
    engines/harvester/room_interaction.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 6feeeb98ddc..3d6f35971b3 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -51,7 +51,6 @@ namespace Harvester {
 
 static const uint32 kRoomExitFastClickWindowTicks = 20;
 static const char *const kPlayerInventoryLabel = "your inventory";
-static const char *const kInventoryOwnerName = "INVENTORY";
 static const byte kTransparentPaletteIndex = 0;
 static const float kNormalPaletteBrightness = 1.0f;
 static const float kDimPaletteBrightness = 0.6f;
@@ -159,12 +158,6 @@ static Common::String resolveStartupNpcLabel(const NpcRecord &npc) {
 	return label;
 }
 
-static Common::String resolveCarriedObjectSpritePath(const ObjectRecord &object) {
-	if (!object.altSpritePath.empty())
-		return object.altSpritePath;
-	return object.spritePath;
-}
-
 static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 12)
@@ -206,24 +199,6 @@ static void renderCdChangePromptScreen(HarvesterEngine &engine, const IndexedBit
 	screen->update();
 }
 
-static bool shouldDispatchPickupActionOnCarryStart(const ObjectRecord &object) {
-	if (object.actionTag.empty())
-		return false;
-
-	return !object.objectName.equalsIgnoreCase("SANDWICH") &&
-		!object.objectName.equalsIgnoreCase("SANDWICH2") &&
-		!object.objectName.equalsIgnoreCase("SYRINGE");
-}
-
-static bool shouldDispatchPickupActionOnDirectInventoryTransfer(const ObjectRecord &object) {
-	if (object.actionTag.empty())
-		return false;
-
-	return !object.objectName.equalsIgnoreCase("ST_ASPRIN") &&
-		!object.objectName.equalsIgnoreCase("ST_COUGHM") &&
-		!object.objectName.equalsIgnoreCase("ST_VITAMN");
-}
-
 static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
 		const Common::String &promptText, bool useNativeFont) {
 	if (promptText.empty())
@@ -467,17 +442,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		uint32 nextKeyboardAttackAllowedTick = 0;
 		RoomIdleAnimationState &idleState = session.getIdleState();
 		bool needsRedraw = true;
-		Common::String carriedRoomItemName;
-		Common::String carriedRoomItemLabel;
-		IndexedBitmap carriedRoomItemBitmap;
-		auto hasCarriedRoomItem = [&]() {
-			return !carriedRoomItemName.empty();
-		};
-		auto clearCarriedRoomItem = [&]() {
-			carriedRoomItemName.clear();
-			carriedRoomItemLabel.clear();
-			carriedRoomItemBitmap = IndexedBitmap();
-		};
+		RoomItemInteractions roomItems(_engine, scene, sceneRuntime, _inventory, pendingRoomChange);
+
 		auto queueCombatLoadoutStatusMessage = [&](const Common::String &message) {
 			if (message.empty()) {
 				const bool hadMessage = !combatLoadoutStatusText.value.empty();
@@ -494,16 +460,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				Player::getRuntimeClockTicks() + kCombatLoadoutStatusDisplayTicks;
 			needsRedraw = true;
 		};
-		auto hideSceneObject = [&](const Common::String &objectName, const Common::String *ownerOrRoom) {
-			ObjectRecord *sceneObject = findSceneObjectByName(scene.sceneObjects, objectName);
-			if (!sceneObject)
-				return;
-
-			sceneObject->visible = false;
-			sceneObject->runtimeVisible = false;
-			if (ownerOrRoom)
-				sceneObject->currentOwnerOrRoom = *ownerOrRoom;
-		};
 
 		session.resetIdleState();
 
@@ -521,44 +477,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					monster.posX, monster.posY, (float)monster.posZ);
 			}
 		};
-		auto stowCarriedRoomItemToInventory = [&]() {
-			if (!hasCarriedRoomItem())
-				return true;
-
-			_engine.getScript()->addRuntimeObjectToInventory(carriedRoomItemName);
-			clearCarriedRoomItem();
-			return _inventory.refresh();
-		};
-		auto shouldCancelDeniedPickup = [&](const InteractionResult &interaction,
-				bool didTransition) {
-			return !didTransition &&
-				!interaction.mutatedRuntimeState &&
-				!interaction.requestPlayerGotoXZ &&
-				!interaction.requestPlayerGotoZ &&
-				interaction.lightingCommand == kStartupLightingCommandNone &&
-				!interaction.requestMainMenu &&
-				!interaction.requestDemoEnding &&
-				interaction.dialogueNpcName.empty() &&
-				interaction.dialogueContinuationTag.empty() &&
-				interaction.continuationTag.empty() &&
-				interaction.nextRoomName.empty() &&
-				interaction.cutscenePath.empty() &&
-				!interaction.modalText.value.empty();
-		};
-		auto restoreDeniedPickup = [&](Script &script,
-				const ObjectRecord &savedObject, bool clearCarryState) -> Common::Error {
-			if (!script.syncRuntimeObjectRecord(savedObject))
-				return Common::kReadingFailed;
-			if (clearCarryState)
-				clearCarriedRoomItem();
 
-			if (!session.applyCurrentRoomRuntimeMutationsInPlace(true) && !session.refreshCurrentScene(true))
-				return Common::kReadingFailed;
-			if (!_inventory.refresh())
-				return Common::kReadingFailed;
-			needsRedraw = true;
-			return Common::kNoError;
-		};
 		auto openInventoryOverlay = [&]() {
 			moveLeft = false;
 			moveRight = false;
@@ -569,7 +488,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			playerState.hasMoveTarget = false;
 			playerState.turnActive = false;
 			playerState.turnTargetFacing = -1;
-			if (!stowCarriedRoomItemToInventory())
+			if (!roomItems.stowCarriedRoomItemToInventory())
 				return false;
 			return _inventory.open();
 		};
@@ -1074,184 +993,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		combat.capturePlayerAttackTarget(scene.state, playerState, entityManager, _mousePos, hoverState.npc);
 	};
 
-	auto moveRoomItemDirectlyToInventory = [&](const ObjectRecord &object) -> Common::Error {
-		Script *script = _engine.getScript();
-		if (!script)
-			return Common::kReadingFailed;
-
-		const ObjectRecord savedObject = object;
-
-		script->addRuntimeObjectToInventory(object.objectName);
-		const Common::String inventoryOwner(kInventoryOwnerName);
-		hideSceneObject(object.objectName, &inventoryOwner);
-		sceneRuntime.removeSceneEntityByName(object.objectName);
-
-		bool didTransition = false;
-		if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
-			InteractionResult pickupInteraction;
-			if (script->executeActionTag(
-					object.actionTag, pickupInteraction, true, scene.state.roomName)) {
-				Common::Error interactionError =
-					interactionProcessor.handleInteractionResult(
-						pickupInteraction, didTransition, Common::String());
-				if (interactionError.getCode() != Common::kNoError)
-					return interactionError;
-				if (flow.hasPendingMainMenuReturn())
-					return Common::kNoError;
-				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
-					debugC(1, kDebugRoom,
-						"Harvester: cancelling denied direct pickup object='%s' action='%s' text='%s'",
-						savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
-						pickupInteraction.modalText.value.c_str());
-					return restoreDeniedPickup(*script, savedObject, false);
-				}
-			}
-		}
-
-		if (!_inventory.refresh())
-			return Common::kReadingFailed;
-		needsRedraw = true;
-		return Common::kNoError;
-	};
-	auto beginRoomItemCarry = [&](const ObjectRecord &object) -> Common::Error {
-		Script *script = _engine.getScript();
-		ResourceManager *resources = _engine.getResources();
-		if (!script || !resources)
-			return Common::kReadingFailed;
-
-		const ObjectRecord savedObject = object;
-
-		clearCarriedRoomItem();
-		carriedRoomItemName = object.objectName;
-		carriedRoomItemLabel = script->resolveObjectLabel(object);
-		const Common::String spritePath = resolveCarriedObjectSpritePath(object);
-		if (!spritePath.empty())
-			loadBitmapResource(*resources, spritePath, carriedRoomItemBitmap);
-
-		script->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
-		hideSceneObject(object.objectName, nullptr);
-		sceneRuntime.removeSceneEntityByName(object.objectName);
-
-		bool didTransition = false;
-		if (shouldDispatchPickupActionOnCarryStart(object)) {
-			InteractionResult pickupInteraction;
-			if (script->executeActionTag(
-					object.actionTag, pickupInteraction, true, scene.state.roomName)) {
-				Common::Error interactionError =
-					interactionProcessor.handleInteractionResult(
-						pickupInteraction, didTransition, Common::String());
-				if (interactionError.getCode() != Common::kNoError)
-					return interactionError;
-				if (flow.hasPendingMainMenuReturn())
-					return Common::kNoError;
-				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
-					debugC(1, kDebugRoom,
-						"Harvester: cancelling denied carried pickup object='%s' action='%s' text='%s'",
-						savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
-						pickupInteraction.modalText.value.c_str());
-					return restoreDeniedPickup(*script, savedObject, true);
-				}
-				if (!didTransition &&
-						!pickupInteraction.dialogueNpcName.empty() &&
-						!script->isObjectInInventory(savedObject.objectName)) {
-					debugC(1, kDebugRoom,
-						"Harvester: restoring carried pickup after dialogue object='%s' action='%s' npc='%s'",
-						savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
-						pickupInteraction.dialogueNpcName.c_str());
-					return restoreDeniedPickup(*script, savedObject, true);
-				}
-			}
-		}
-
-		if (!pendingRoomChange.empty()) {
-			if (!stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			needsRedraw = true;
-			return Common::kNoError;
-		}
-
-		needsRedraw = true;
-		return Common::kNoError;
-	};
-	auto handleInventoryTargetInteraction = [&](const ObjectRecord &target) -> Common::Error {
-		if (!_inventory.hasSelection())
-			return Common::kNoError;
-
-		const Common::String selectedItemName = _inventory.getSelectedItemName();
-		InteractionResult interaction;
-		const bool handled = _engine.getScript()->resolveUseItemInteraction(
-			selectedItemName, target, interaction, scene.state.roomName);
-		if (!handled) {
-			debugC(1, kDebugInventory,
-				"Harvester: inventory target miss selected='%s' target='%s' owner='%s' action='%s'",
-				selectedItemName.c_str(), target.objectName.c_str(),
-				target.currentOwnerOrRoom.c_str(), target.actionTag.c_str());
-			return Common::kNoError;
-		}
-
-		StartupRoomTransitionKind roomTransition = interaction.roomTransition;
-		if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
-			roomTransition = kStartupRoomTransitionCloseup;
-
-		// Flow shares one inventory system across nested room loops. Clear the held item
-		// before entering a closeup so it is not still carried while the child room loop
-		// is active.
-		if (!interaction.nextRoomName.empty() &&
-				roomTransition != kStartupRoomTransitionChangeRoom)
-			_inventory.clearSelection();
-
-		bool didTransition = false;
-		Common::Error interactionError =
-			interactionProcessor.handleInteractionResult(interaction, didTransition, selectedItemName);
-		if (interactionError.getCode() != Common::kNoError)
-			return interactionError;
-		_inventory.clearSelection();
-		if (flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-		if (!_inventory.refresh())
-			return Common::kReadingFailed;
-
-		needsRedraw = true;
-		return Common::kNoError;
-	};
-	auto findSelectedInventoryRoomTarget = [&](const Common::Point &point) -> ObjectRecord * {
-		if (!_inventory.hasSelection())
-			return nullptr;
-
-		EntityManager *entityManager = _engine.getRuntimeEntities();
-		Script *script = _engine.getScript();
-		if (!entityManager || !script)
-			return nullptr;
-
-		const Common::String selectedItemName = _inventory.getSelectedItemName();
-		ObjectRecord *bestTarget = nullptr;
-		int bestDrawIndex = -1;
-		for (ObjectRecord &candidate : scene.sceneObjects) {
-			if (candidate.objectName.empty() ||
-					!script->hasUseItemInteraction(selectedItemName, candidate)) {
-				continue;
-			}
-
-			const Entity *entity = entityManager->findSceneEntityByName(candidate.objectName);
-			if (!entity || !entity->hitTest(point))
-				continue;
-			if (entity->getClassId() == kRuntimeEntityClassBackground ||
-					entity->getClassId() == kRuntimeEntityClassPlayer ||
-					entity->getClassId() == kRuntimeEntityClassRectHotspot19) {
-				continue;
-			}
-
-			const int drawIndex = entityManager->findSceneEntityDrawIndexByName(entity->getName());
-			if (drawIndex < 0)
-				continue;
-			if (!bestTarget || drawIndex > bestDrawIndex) {
-				bestTarget = &candidate;
-				bestDrawIndex = drawIndex;
-			}
-		}
-
-		return bestTarget;
-	};
 	auto queueRegionInteraction = [&](const RegionRecord &region) {
 		pendingRegionName = region.regionName;
 		if (!playerState.entity)
@@ -1364,7 +1105,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			pendingRoomChangeIsRoomName = false;
 			pendingRoomChangeUsesSavedRoomState = false;
 			pendingRoomChangeSavedRoomState.clear();
-			if (!stowCarriedRoomItemToInventory())
+			if (!roomItems.stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
 		}
@@ -1377,17 +1118,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				pendingRoomChange = _engine.getPendingLoadedSaveRoomState().roomName;
 				pendingRoomChangeIsRoomName = true;
 			}
-			if (!stowCarriedRoomItemToInventory())
+			if (!roomItems.stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
 		}
 		if (flow.hasPendingDebugRoomChange()) {
-			if (!stowCarriedRoomItemToInventory())
+			if (!roomItems.stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			return Common::kNoError;
 		}
 		if (!pendingRoomChange.empty()) {
-			if (!stowCarriedRoomItemToInventory())
+			if (!roomItems.stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
 		}
@@ -1403,7 +1144,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (flow.hasPendingMainMenuReturn())
 				return Common::kNoError;
 			if (!pendingRoomChange.empty()) {
-				if (!stowCarriedRoomItemToInventory())
+				if (!roomItems.stowCarriedRoomItemToInventory())
 					return Common::kReadingFailed;
 				break;
 			}
@@ -1432,7 +1173,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
 			const bool inventorySelectionActive = _inventory.hasSelection();
-			const bool roomCarryActive = hasCarriedRoomItem();
+			const bool roomCarryActive = roomItems.hasCarriedRoomItem();
 			const bool activeCarry = inventorySelectionActive || roomCarryActive;
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
 			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
@@ -1443,7 +1184,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 					scene.sceneRegions, _mousePos, flow._menuTextConfig, &flow._dialogue);
 			if (!suppressHover && inventorySelectionActive && !hoverState.npc) {
-				if (ObjectRecord *selectedTarget = findSelectedInventoryRoomTarget(_mousePos))
+				if (ObjectRecord *selectedTarget = roomItems.findSelectedInventoryRoomTarget(_mousePos))
 					hoverState.object = selectedTarget;
 			}
 			Common::String promptText;
@@ -1476,7 +1217,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					_inventory.setPromptText(promptText);
 				} else {
 					promptText = buildUseItemPrompt(
-						flow._menuTextConfig, carriedRoomItemLabel, targetLabel);
+						flow._menuTextConfig, roomItems.getCarriedRoomItemLabel(), targetLabel);
 				}
 				hoverState.cursorSequence = 7;
 			} else if (_inventory.isOpen()) {
@@ -1515,10 +1256,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 			if (inventorySelectionActive) {
 				_inventory.drawSelectedDragItem(*activeScreen, _mousePos);
-			} else if (roomCarryActive && carriedRoomItemBitmap.isValid()) {
-				const int drawX = _mousePos.x - (int)carriedRoomItemBitmap.width / 2;
-				const int drawY = _mousePos.y - (int)carriedRoomItemBitmap.height / 2;
-				blitBitmap(*activeScreen, carriedRoomItemBitmap, drawX, drawY);
+			} else if (roomCarryActive && roomItems.getCarriedRoomItemBitmap().isValid()) {
+				const int drawX = _mousePos.x - (int)roomItems.getCarriedRoomItemBitmap().width / 2;
+				const int drawY = _mousePos.y - (int)roomItems.getCarriedRoomItemBitmap().height / 2;
+				blitBitmap(*activeScreen, roomItems.getCarriedRoomItemBitmap(), drawX, drawY);
 			}
 
 			if (showingInspectText) {
@@ -1675,8 +1416,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						needsRedraw = true;
 					break;
 				}
-				if (hasCarriedRoomItem()) {
-					if (!stowCarriedRoomItemToInventory())
+				if (roomItems.hasCarriedRoomItem()) {
+					if (!roomItems.stowCarriedRoomItemToInventory())
 						return Common::kReadingFailed;
 					needsRedraw = true;
 					break;
@@ -1773,7 +1514,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 								_inventory.getSelectedItemName().c_str(),
 								inventoryHover->object.objectName.c_str());
 							Common::Error interactionError =
-								handleInventoryTargetInteraction(inventoryHover->object);
+								roomItems.handleInventoryTargetInteraction(inventoryHover->object, interactionProcessor, needsRedraw);
 							if (interactionError.getCode() != Common::kNoError)
 								return interactionError;
 						}
@@ -1796,7 +1537,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					_mousePos, flow._menuTextConfig, &flow._dialogue);
 				ObjectRecord *selectedRoomTarget = nullptr;
 				if (_inventory.hasSelection() && !hoverState.npc)
-					selectedRoomTarget = findSelectedInventoryRoomTarget(_mousePos);
+					selectedRoomTarget = roomItems.findSelectedInventoryRoomTarget(_mousePos);
 				if (selectedRoomTarget &&
 						(!hoverState.object ||
 						 !selectedRoomTarget->objectName.equalsIgnoreCase(hoverState.object->objectName))) {
@@ -1816,10 +1557,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					clickHoverState.npc ? clickHoverState.npc->npcName.c_str() : "",
 					clickHoverState.region ? clickHoverState.region->regionName.c_str() : "",
 					clickHoverState.cursorSequence, clickHoverState.promptText.c_str());
-					if (hasCarriedRoomItem()) {
+					if (roomItems.hasCarriedRoomItem()) {
 					if (clickHoverState.playerEntity && playerState.entity &&
 							clickHoverState.playerEntity == playerState.entity) {
-						if (!stowCarriedRoomItemToInventory())
+						if (!roomItems.stowCarriedRoomItemToInventory())
 							return Common::kReadingFailed;
 						needsRedraw = true;
 					}
@@ -1845,7 +1586,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						: nullptr;
 					if (roomTarget) {
 						Common::Error interactionError =
-							handleInventoryTargetInteraction(*roomTarget);
+							roomItems.handleInventoryTargetInteraction(*roomTarget, interactionProcessor, needsRedraw);
 						if (interactionError.getCode() != Common::kNoError)
 							return interactionError;
 						needsRedraw = true;
@@ -1966,22 +1707,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					break;
 				}
 				if (_engine.getScript()->isPickupObject(*clickedObject)) {
-					InteractionResult blockedPickupInteraction;
-					if (_engine.getScript()->isPickupBlockedByAction(
-							*clickedObject, &blockedPickupInteraction)) {
-						bool didTransition = false;
-						Common::Error interactionError =
-							interactionProcessor.handleInteractionResult(
-								blockedPickupInteraction, didTransition, Common::String());
-						if (interactionError.getCode() != Common::kNoError)
-							return interactionError;
-						needsRedraw = true;
-						break;
-					}
-
-					Common::Error pickupError = playerState.entity
-						? beginRoomItemCarry(*clickedObject)
-						: moveRoomItemDirectlyToInventory(*clickedObject);
+					Common::Error pickupError = roomItems.handleRoomItemPickup(*clickedObject, playerState.entity != nullptr,
+						interactionProcessor, interactionCallbacks, needsRedraw);
 					if (pickupError.getCode() != Common::kNoError)
 						return pickupError;
 					break;
@@ -2119,7 +1846,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				} else if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					if (session.cancelPlayerAttackAnimation())
 						needsRedraw = true;
-					if (!stowCarriedRoomItemToInventory())
+					if (!roomItems.stowCarriedRoomItemToInventory())
 						return Common::kReadingFailed;
 					moveLeft = false;
 					moveRight = false;
@@ -2150,7 +1877,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					if (!stowCarriedRoomItemToInventory())
+					if (!roomItems.stowCarriedRoomItemToInventory())
 						return Common::kReadingFailed;
 					Common::Error exitError = runRoomExitCommands();
 					if (exitError.getCode() != Common::kNoError)
@@ -2176,7 +1903,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 		}
 		if (!pendingRoomChange.empty()) {
-			if (!stowCarriedRoomItemToInventory())
+			if (!roomItems.stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
 		}
@@ -2205,7 +1932,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!pendingRoomChange.empty()) {
-			if (!stowCarriedRoomItemToInventory())
+			if (!roomItems.stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
 		}
@@ -2216,7 +1943,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (flow.hasPendingMainMenuReturn())
 				return Common::kNoError;
 			if (!pendingRoomChange.empty()) {
-				if (!stowCarriedRoomItemToInventory())
+				if (!roomItems.stowCarriedRoomItemToInventory())
 					return Common::kReadingFailed;
 				break;
 			}
@@ -2237,7 +1964,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (flow.hasPendingMainMenuReturn())
 				return Common::kNoError;
 			if (!pendingRoomChange.empty()) {
-				if (!stowCarriedRoomItemToInventory())
+				if (!roomItems.stowCarriedRoomItemToInventory())
 					return Common::kReadingFailed;
 				break;
 			}
@@ -2317,7 +2044,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!pendingRoomChange.empty()) {
-			if (!stowCarriedRoomItemToInventory())
+			if (!roomItems.stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
 		}
diff --git a/engines/harvester/room_interaction.cpp b/engines/harvester/room_interaction.cpp
index b0991ace310..5ca7ff6613f 100644
--- a/engines/harvester/room_interaction.cpp
+++ b/engines/harvester/room_interaction.cpp
@@ -29,6 +29,8 @@
 #include "harvester/flow.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
+#include "harvester/inventory.h"
+#include "harvester/room_scene_runtime.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 
@@ -36,6 +38,311 @@ namespace Harvester {
 
 const char *const kExitCloseupPendingRoomChange = "__EXIT_CLOSEUP__";
 
+static const char *const kInventoryOwnerName = "INVENTORY";
+
+static Common::String resolveCarriedObjectSpritePath(const ObjectRecord &object) {
+	if (!object.altSpritePath.empty())
+		return object.altSpritePath;
+	return object.spritePath;
+}
+
+static bool shouldDispatchPickupActionOnCarryStart(const ObjectRecord &object) {
+	if (object.actionTag.empty())
+		return false;
+
+	return !object.objectName.equalsIgnoreCase("SANDWICH") &&
+		!object.objectName.equalsIgnoreCase("SANDWICH2") &&
+		!object.objectName.equalsIgnoreCase("SYRINGE");
+}
+
+static bool shouldDispatchPickupActionOnDirectInventoryTransfer(const ObjectRecord &object) {
+	if (object.actionTag.empty())
+		return false;
+
+	return !object.objectName.equalsIgnoreCase("ST_ASPRIN") &&
+		!object.objectName.equalsIgnoreCase("ST_COUGHM") &&
+		!object.objectName.equalsIgnoreCase("ST_VITAMN");
+}
+
+static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12)
+		return false;
+
+	bitmap = IndexedBitmap();
+	return decodeBitmap(data, bitmap);
+}
+
+RoomItemInteractions::RoomItemInteractions(HarvesterEngine &engine, RoomSceneResources &scene,
+		RoomSceneRuntime &sceneRuntime, InventorySystem &inventory, const Common::String &pendingRoomChange)
+	: _engine(engine), _scene(scene), _sceneRuntime(sceneRuntime), _inventory(inventory),
+	  _pendingRoomChange(pendingRoomChange) {
+}
+
+bool RoomItemInteractions::hasCarriedRoomItem() const {
+	return !_carriedRoomItemName.empty();
+}
+
+void RoomItemInteractions::clearCarriedRoomItem() {
+	_carriedRoomItemName.clear();
+	_carriedRoomItemLabel.clear();
+	_carriedRoomItemBitmap = IndexedBitmap();
+}
+
+void RoomItemInteractions::hideSceneObject(const Common::String &objectName, const Common::String *ownerOrRoom) {
+	ObjectRecord *sceneObject = findSceneObjectByName(_scene.sceneObjects, objectName);
+	if (!sceneObject)
+		return;
+
+	sceneObject->visible = false;
+	sceneObject->runtimeVisible = false;
+	if (ownerOrRoom)
+		sceneObject->currentOwnerOrRoom = *ownerOrRoom;
+}
+
+bool RoomItemInteractions::stowCarriedRoomItemToInventory() {
+	if (!hasCarriedRoomItem())
+		return true;
+
+	_engine.getScript()->addRuntimeObjectToInventory(_carriedRoomItemName);
+	clearCarriedRoomItem();
+	return _inventory.refresh();
+}
+
+bool RoomItemInteractions::shouldCancelDeniedPickup(const InteractionResult &interaction, bool didTransition) const {
+	return !didTransition &&
+		!interaction.mutatedRuntimeState &&
+		!interaction.requestPlayerGotoXZ &&
+		!interaction.requestPlayerGotoZ &&
+		interaction.lightingCommand == kStartupLightingCommandNone &&
+		!interaction.requestMainMenu &&
+		!interaction.requestDemoEnding &&
+		interaction.dialogueNpcName.empty() &&
+		interaction.dialogueContinuationTag.empty() &&
+		interaction.continuationTag.empty() &&
+		interaction.nextRoomName.empty() &&
+		interaction.cutscenePath.empty() &&
+		!interaction.modalText.value.empty();
+}
+
+Common::Error RoomItemInteractions::restoreDeniedPickup(Script &script, const ObjectRecord &savedObject, bool clearCarryState,
+		RoomInteractionCallbacks &callbacks, bool &needsRedraw) {
+	if (!script.syncRuntimeObjectRecord(savedObject))
+		return Common::kReadingFailed;
+	if (clearCarryState)
+		clearCarriedRoomItem();
+
+	if (!callbacks.applyCurrentRoomRuntimeMutationsInPlace(true) && !callbacks.refreshCurrentScene(true))
+		return Common::kReadingFailed;
+	if (!_inventory.refresh())
+		return Common::kReadingFailed;
+	needsRedraw = true;
+	return Common::kNoError;
+}
+
+Common::Error RoomItemInteractions::moveRoomItemDirectlyToInventory(const ObjectRecord &object,
+		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw) {
+	Script *script = _engine.getScript();
+	if (!script)
+		return Common::kReadingFailed;
+
+	const ObjectRecord savedObject = object;
+
+	script->addRuntimeObjectToInventory(object.objectName);
+	const Common::String inventoryOwner(kInventoryOwnerName);
+	hideSceneObject(object.objectName, &inventoryOwner);
+	_sceneRuntime.removeSceneEntityByName(object.objectName);
+
+	bool didTransition = false;
+	if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
+		InteractionResult pickupInteraction;
+		if (script->executeActionTag(
+				object.actionTag, pickupInteraction, true, _scene.state.roomName)) {
+			Common::Error interactionError =
+				interactionProcessor.handleInteractionResult(
+					pickupInteraction, didTransition, Common::String());
+			if (interactionError.getCode() != Common::kNoError)
+				return interactionError;
+			if (interactionProcessor.hasPendingMainMenuReturn())
+				return Common::kNoError;
+			if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
+				debugC(1, kDebugRoom,
+					"Harvester: cancelling denied direct pickup object='%s' action='%s' text='%s'",
+					savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
+					pickupInteraction.modalText.value.c_str());
+				return restoreDeniedPickup(*script, savedObject, false, callbacks, needsRedraw);
+			}
+		}
+	}
+
+	if (!_inventory.refresh())
+		return Common::kReadingFailed;
+	needsRedraw = true;
+	return Common::kNoError;
+}
+
+Common::Error RoomItemInteractions::beginRoomItemCarry(const ObjectRecord &object,
+		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw) {
+	Script *script = _engine.getScript();
+	ResourceManager *resources = _engine.getResources();
+	if (!script || !resources)
+		return Common::kReadingFailed;
+
+	const ObjectRecord savedObject = object;
+
+	clearCarriedRoomItem();
+	_carriedRoomItemName = object.objectName;
+	_carriedRoomItemLabel = script->resolveObjectLabel(object);
+	const Common::String spritePath = resolveCarriedObjectSpritePath(object);
+	if (!spritePath.empty())
+		loadBitmapResource(*resources, spritePath, _carriedRoomItemBitmap);
+
+	script->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
+	hideSceneObject(object.objectName, nullptr);
+	_sceneRuntime.removeSceneEntityByName(object.objectName);
+
+	bool didTransition = false;
+	if (shouldDispatchPickupActionOnCarryStart(object)) {
+		InteractionResult pickupInteraction;
+		if (script->executeActionTag(
+				object.actionTag, pickupInteraction, true, _scene.state.roomName)) {
+			Common::Error interactionError =
+				interactionProcessor.handleInteractionResult(
+					pickupInteraction, didTransition, Common::String());
+			if (interactionError.getCode() != Common::kNoError)
+				return interactionError;
+			if (interactionProcessor.hasPendingMainMenuReturn())
+				return Common::kNoError;
+			if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
+				debugC(1, kDebugRoom,
+					"Harvester: cancelling denied carried pickup object='%s' action='%s' text='%s'",
+					savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
+					pickupInteraction.modalText.value.c_str());
+				return restoreDeniedPickup(*script, savedObject, true, callbacks, needsRedraw);
+			}
+			if (!didTransition &&
+					!pickupInteraction.dialogueNpcName.empty() &&
+					!script->isObjectInInventory(savedObject.objectName)) {
+				debugC(1, kDebugRoom,
+					"Harvester: restoring carried pickup after dialogue object='%s' action='%s' npc='%s'",
+					savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
+					pickupInteraction.dialogueNpcName.c_str());
+				return restoreDeniedPickup(*script, savedObject, true, callbacks, needsRedraw);
+			}
+		}
+	}
+
+	if (!_pendingRoomChange.empty()) {
+		if (!stowCarriedRoomItemToInventory())
+			return Common::kReadingFailed;
+		needsRedraw = true;
+		return Common::kNoError;
+	}
+
+	needsRedraw = true;
+	return Common::kNoError;
+}
+
+Common::Error RoomItemInteractions::handleInventoryTargetInteraction(const ObjectRecord &target,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+	if (!_inventory.hasSelection())
+		return Common::kNoError;
+
+	const Common::String selectedItemName = _inventory.getSelectedItemName();
+	InteractionResult interaction;
+	const bool handled = _engine.getScript()->resolveUseItemInteraction(
+		selectedItemName, target, interaction, _scene.state.roomName);
+	if (!handled) {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory target miss selected='%s' target='%s' owner='%s' action='%s'",
+			selectedItemName.c_str(), target.objectName.c_str(),
+			target.currentOwnerOrRoom.c_str(), target.actionTag.c_str());
+		return Common::kNoError;
+	}
+
+	StartupRoomTransitionKind roomTransition = interaction.roomTransition;
+	if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
+		roomTransition = kStartupRoomTransitionCloseup;
+
+	// Flow shares one inventory system across nested room loops. Clear the held item
+	// before entering a closeup so it is not still carried while the child room loop
+	// is active.
+	if (!interaction.nextRoomName.empty() &&
+			roomTransition != kStartupRoomTransitionChangeRoom)
+		_inventory.clearSelection();
+
+	bool didTransition = false;
+	Common::Error interactionError =
+		interactionProcessor.handleInteractionResult(interaction, didTransition, selectedItemName);
+	if (interactionError.getCode() != Common::kNoError)
+		return interactionError;
+	_inventory.clearSelection();
+	if (interactionProcessor.hasPendingMainMenuReturn())
+		return Common::kNoError;
+	if (!_inventory.refresh())
+		return Common::kReadingFailed;
+
+	needsRedraw = true;
+	return Common::kNoError;
+}
+
+ObjectRecord *RoomItemInteractions::findSelectedInventoryRoomTarget(const Common::Point &point) {
+	if (!_inventory.hasSelection())
+		return nullptr;
+
+	EntityManager *entityManager = _engine.getRuntimeEntities();
+	Script *script = _engine.getScript();
+	if (!entityManager || !script)
+		return nullptr;
+
+	const Common::String selectedItemName = _inventory.getSelectedItemName();
+	ObjectRecord *bestTarget = nullptr;
+	int bestDrawIndex = -1;
+	for (ObjectRecord &candidate : _scene.sceneObjects) {
+		if (candidate.objectName.empty() ||
+				!script->hasUseItemInteraction(selectedItemName, candidate)) {
+			continue;
+		}
+
+		const Entity *entity = entityManager->findSceneEntityByName(candidate.objectName);
+		if (!entity || !entity->hitTest(point))
+			continue;
+		if (entity->getClassId() == kRuntimeEntityClassBackground ||
+				entity->getClassId() == kRuntimeEntityClassPlayer ||
+				entity->getClassId() == kRuntimeEntityClassRectHotspot19) {
+			continue;
+		}
+
+		const int drawIndex = entityManager->findSceneEntityDrawIndexByName(entity->getName());
+		if (drawIndex < 0)
+			continue;
+		if (!bestTarget || drawIndex > bestDrawIndex) {
+			bestTarget = &candidate;
+			bestDrawIndex = drawIndex;
+		}
+	}
+
+	return bestTarget;
+}
+
+Common::Error RoomItemInteractions::handleRoomItemPickup(const ObjectRecord &object, bool playerPresent,
+		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw) {
+	InteractionResult blockedPickupInteraction;
+	if (_engine.getScript()->isPickupBlockedByAction(object, &blockedPickupInteraction)) {
+		bool didTransition = false;
+		Common::Error interactionError = interactionProcessor.handleInteractionResult(
+			blockedPickupInteraction, didTransition, Common::String());
+		if (interactionError.getCode() != Common::kNoError)
+			return interactionError;
+		needsRedraw = true;
+		return Common::kNoError;
+	}
+
+	return playerPresent
+		? beginRoomItemCarry(object, interactionProcessor, callbacks, needsRedraw)
+		: moveRoomItemDirectlyToInventory(object, interactionProcessor, callbacks, needsRedraw);
+}
+
 RoomInteractionProcessor::RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow,
 		RoomSceneResources &scene, RoomPlayerState &playerState,
 		Common::String &pendingRegionName, Common::String &pendingRoomChange,
@@ -50,6 +357,10 @@ RoomInteractionProcessor::RoomInteractionProcessor(HarvesterEngine &engine, Flow
 	  _canExitCloseupToParent(canExitCloseupToParent), _callbacks(callbacks) {
 }
 
+bool RoomInteractionProcessor::hasPendingMainMenuReturn() const {
+	return _flow.hasPendingMainMenuReturn();
+}
+
 Common::Error RoomInteractionProcessor::handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw) {
 	bool didTransition = false;
 	Common::Error interactionError =
diff --git a/engines/harvester/room_interaction.h b/engines/harvester/room_interaction.h
index 92e5d9217ba..d6e0f50cd21 100644
--- a/engines/harvester/room_interaction.h
+++ b/engines/harvester/room_interaction.h
@@ -23,6 +23,7 @@
 #define HARVESTER_ROOM_INTERACTION_H
 
 #include "common/error.h"
+#include "harvester/bitmap.h"
 #include "harvester/room_support.h"
 #include "harvester/saveload.h"
 
@@ -30,6 +31,9 @@ namespace Harvester {
 
 class Flow;
 class HarvesterEngine;
+class InventorySystem;
+class RoomInteractionProcessor;
+class RoomSceneRuntime;
 struct IndexedBitmap;
 
 extern const char *const kExitCloseupPendingRoomChange;
@@ -54,6 +58,42 @@ public:
 		int damageType) = 0;
 };
 
+class RoomItemInteractions {
+public:
+	RoomItemInteractions(HarvesterEngine &engine, RoomSceneResources &scene,
+		RoomSceneRuntime &sceneRuntime, InventorySystem &inventory, const Common::String &pendingRoomChange);
+
+	const Common::String &getCarriedRoomItemLabel() const { return _carriedRoomItemLabel; }
+	const IndexedBitmap &getCarriedRoomItemBitmap() const { return _carriedRoomItemBitmap; }
+	bool hasCarriedRoomItem() const;
+	bool stowCarriedRoomItemToInventory();
+	Common::Error handleInventoryTargetInteraction(const ObjectRecord &target,
+		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+	ObjectRecord *findSelectedInventoryRoomTarget(const Common::Point &point);
+	Common::Error handleRoomItemPickup(const ObjectRecord &object, bool playerPresent,
+		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw);
+
+private:
+	void clearCarriedRoomItem();
+	void hideSceneObject(const Common::String &objectName, const Common::String *ownerOrRoom);
+	bool shouldCancelDeniedPickup(const InteractionResult &interaction, bool didTransition) const;
+	Common::Error restoreDeniedPickup(Script &script, const ObjectRecord &savedObject, bool clearCarryState,
+		RoomInteractionCallbacks &callbacks, bool &needsRedraw);
+	Common::Error moveRoomItemDirectlyToInventory(const ObjectRecord &object,
+		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw);
+	Common::Error beginRoomItemCarry(const ObjectRecord &object,
+		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw);
+
+	HarvesterEngine &_engine;
+	RoomSceneResources &_scene;
+	RoomSceneRuntime &_sceneRuntime;
+	InventorySystem &_inventory;
+	const Common::String &_pendingRoomChange;
+	Common::String _carriedRoomItemName;
+	Common::String _carriedRoomItemLabel;
+	IndexedBitmap _carriedRoomItemBitmap;
+};
+
 class RoomInteractionProcessor {
 public:
 	RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene,
@@ -62,6 +102,7 @@ public:
 		bool &pendingRoomChangeUsesSavedRoomState, SaveRoomState &pendingRoomChangeSavedRoomState,
 		bool canExitCloseupToParent, RoomInteractionCallbacks &callbacks);
 
+	bool hasPendingMainMenuReturn() const;
 	Common::Error handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw);
 	Common::Error handleInteractionResult(const InteractionResult &interaction,
 		bool &didTransition, const Common::String &usedItemName, bool interruptPlayerMovement = true);


Commit: 47ccd38593428f6cc27ef55cd43b07bb0022e3e4
    https://github.com/scummvm/scummvm/commit/47ccd38593428f6cc27ef55cd43b07bb0022e3e4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Refactor region interaction handling in RoomInteractionProcessor

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/room_interaction.cpp
    engines/harvester/room_interaction.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 3d6f35971b3..98916bb1446 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -993,101 +993,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		combat.capturePlayerAttackTarget(scene.state, playerState, entityManager, _mousePos, hoverState.npc);
 	};
 
-	auto queueRegionInteraction = [&](const RegionRecord &region) {
-		pendingRegionName = region.regionName;
-		if (!playerState.entity)
-			return;
-
-		const bool playerOverlapsRegion = doesPlayerOverlapRegion(*playerState.entity, region);
-		if (_engine.isPathfindingDebugEnabled()) {
-			const Common::Rect playerRect = playerState.entity->getScreenRect();
-			const Common::Rect regionBounds(region.left, region.top, region.right + 1, region.bottom + 1);
-			debugC(1, kDebugPathfinding,
-				"Harvester: pathfinding region click room='%s' mouse=(%d,%d) region='%s' bounds=(%d,%d)-(%d,%d) z=[%d,%d] start=%d cursor=%d desired_facing=%d player=(%d,%d,z=%.2f) player_rect=(%d,%d)-(%d,%d) overlaps=%d action='%s'",
-				scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
-				region.regionName.c_str(),
-				regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
-				region.minZ, region.maxZ, region.startEnabled, region.cursorEnabled,
-				region.desiredFacing,
-				playerState.centerX, playerState.bottomY, (double)playerState.z,
-				playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
-				playerOverlapsRegion, region.actionTag.c_str());
-		}
-		if (playerOverlapsRegion && doesPlayerFacingMatchRegion(playerState.facing, region))
-			return;
-
-		Player::setRegionMoveTarget(scene.state, playerState,
-			Player::resolveRegionTargetX(scene.state, region, playerState),
-			Player::resolveRegionTargetZ(region));
-		if (_engine.isPathfindingDebugEnabled()) {
-			debugC(1, kDebugPathfinding,
-				"Harvester: pathfinding region target room='%s' region='%s' target=(%d,%d,z=%.2f)",
-				scene.state.roomName.c_str(), region.regionName.c_str(),
-				playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
-		}
-	};
-	auto runRegionInteraction = [&](const RegionRecord &region) -> Common::Error {
-		InteractionResult interaction;
-		if (!_engine.getScript()->resolveRegionInteraction(region, interaction, scene.state.roomName))
-			return Common::kNoError;
-		debugC(2, kDebugRoom,
-			"Harvester: region interaction room='%s' region='%s' action='%s' player=(%d,%d,z=%.2f) next_room='%s'",
-			scene.state.roomName.c_str(), region.regionName.c_str(), region.actionTag.c_str(),
-			playerState.centerX, playerState.bottomY, (double)playerState.z,
-			interaction.nextRoomName.c_str());
-
-		bool didTransition = false;
-		Common::Error interactionError =
-			interactionProcessor.handleInteractionResult(
-				interaction, didTransition, Common::String());
-		if (interactionError.getCode() != Common::kNoError)
-			return interactionError;
-		if (flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-
-		if (!_inventory.refresh())
-			return Common::kReadingFailed;
-		needsRedraw = true;
-		return Common::kNoError;
-	};
-	auto tryActivatePendingRegion = [&]() -> Common::Error {
-		if (pendingRegionName.empty() || !playerState.entity)
-			return Common::kNoError;
-
-		const RegionRecord *region = findSceneRegionByName(scene.sceneRegions, pendingRegionName);
-		if (!region || !region->startEnabled) {
-			pendingRegionName.clear();
-			return Common::kNoError;
-		}
-		if (!doesPlayerOverlapRegion(*playerState.entity, *region))
-			return Common::kNoError;
-		if (!doesPlayerFacingMatchRegion(playerState.facing, *region)) {
-			if (!playerState.hasMoveTarget && !playerState.turnActive && region->desiredFacing >= 0)
-				(void)Player::startTurnAnimation(playerState, region->desiredFacing);
-			return Common::kNoError;
-		}
-
-		session.stopPlayerRegionInteraction();
-		return runRegionInteraction(*region);
-	};
-	auto tryActivatePassiveRegion = [&]() -> Common::Error {
-		if (!playerState.entity)
-			return Common::kNoError;
-
-		for (const RegionRecord &region : scene.sceneRegions) {
-			if (!region.startEnabled)
-				continue;
-			if (!doesPlayerOverlapRegion(*playerState.entity, region))
-				continue;
-			if (!doesPlayerFacingMatchRegion(playerState.facing, region))
-				continue;
-
-			session.stopPlayerRegionInteraction();
-			return runRegionInteraction(region);
-		}
-
-		return Common::kNoError;
-	};
 	if (!_inventory.refresh())
 		return Common::kReadingFailed;
 	Graphics::FrameLimiter limiter(g_system, 60);
@@ -1630,7 +1535,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						hoverState.region->startEnabled &&
 						roomAllowsImmediateExitClick(scene.state.roomName)) {
 						session.stopPlayerRegionInteraction();
-						Common::Error interactionError = runRegionInteraction(*hoverState.region);
+						Common::Error interactionError =
+							interactionProcessor.runRegionInteraction(*hoverState.region, _inventory, needsRedraw);
 						if (interactionError.getCode() != Common::kNoError)
 							return interactionError;
 						if (flow.hasPendingMainMenuReturn())
@@ -1638,8 +1544,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						needsRedraw = true;
 						break;
 					}
-					queueRegionInteraction(*hoverState.region);
-					Common::Error interactionError = tryActivatePendingRegion();
+					interactionProcessor.queueRegionInteraction(*hoverState.region, _mousePos);
+					Common::Error interactionError =
+						interactionProcessor.tryActivatePendingRegion(_inventory, needsRedraw);
 					if (interactionError.getCode() != Common::kNoError)
 						return interactionError;
 					needsRedraw = true;
@@ -1926,7 +1833,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		Common::Error pendingRegionError =
 			session.isPlayerCombatLocked()
 				? Common::kNoError
-				: tryActivatePendingRegion();
+				: interactionProcessor.tryActivatePendingRegion(_inventory, needsRedraw);
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
 		if (flow.hasPendingMainMenuReturn())
@@ -1937,7 +1844,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			break;
 		}
 		if (!session.isPlayerCombatLocked()) {
-			pendingRegionError = tryActivatePassiveRegion();
+			pendingRegionError = interactionProcessor.tryActivatePassiveRegion(_inventory, needsRedraw);
 			if (pendingRegionError.getCode() != Common::kNoError)
 				return pendingRegionError;
 			if (flow.hasPendingMainMenuReturn())
@@ -2011,9 +1918,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (playerCanAct &&
 				!playerState.attackActive && !playerState.hitActive &&
 				!keyboardAttackRequested && !idleState.active && !idleState.exiting) {
-			const RegionRecord *pendingRegion = findSceneRegionByName(scene.sceneRegions, pendingRegionName);
-			const int regionFacing = pendingRegion && pendingRegion->startEnabled
-				? pendingRegion->desiredFacing : -1;
+			const int regionFacing = interactionProcessor.getPendingRegionFacing();
 			if (Player::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
 				session.notePlayerActivity();
diff --git a/engines/harvester/room_interaction.cpp b/engines/harvester/room_interaction.cpp
index 5ca7ff6613f..4110a8ae5aa 100644
--- a/engines/harvester/room_interaction.cpp
+++ b/engines/harvester/room_interaction.cpp
@@ -30,6 +30,7 @@
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
+#include "harvester/player.h"
 #include "harvester/room_scene_runtime.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
@@ -357,6 +358,110 @@ RoomInteractionProcessor::RoomInteractionProcessor(HarvesterEngine &engine, Flow
 	  _canExitCloseupToParent(canExitCloseupToParent), _callbacks(callbacks) {
 }
 
+void RoomInteractionProcessor::queueRegionInteraction(const RegionRecord &region, const Common::Point &mousePos) {
+	_pendingRegionName = region.regionName;
+	if (!_playerState.entity)
+		return;
+
+	const bool playerOverlapsRegion = doesPlayerOverlapRegion(*_playerState.entity, region);
+	if (_engine.isPathfindingDebugEnabled()) {
+		const Common::Rect playerRect = _playerState.entity->getScreenRect();
+		const Common::Rect regionBounds(region.left, region.top, region.right + 1, region.bottom + 1);
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding region click room='%s' mouse=(%d,%d) region='%s' bounds=(%d,%d)-(%d,%d) z=[%d,%d] start=%d cursor=%d desired_facing=%d player=(%d,%d,z=%.2f) player_rect=(%d,%d)-(%d,%d) overlaps=%d action='%s'",
+			_scene.state.roomName.c_str(), mousePos.x, mousePos.y,
+			region.regionName.c_str(),
+			regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
+			region.minZ, region.maxZ, region.startEnabled, region.cursorEnabled,
+			region.desiredFacing,
+			_playerState.centerX, _playerState.bottomY, (double)_playerState.z,
+			playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
+			playerOverlapsRegion, region.actionTag.c_str());
+	}
+	if (playerOverlapsRegion && doesPlayerFacingMatchRegion(_playerState.facing, region))
+		return;
+
+	Player::setRegionMoveTarget(_scene.state, _playerState,
+		Player::resolveRegionTargetX(_scene.state, region, _playerState),
+		Player::resolveRegionTargetZ(region));
+	if (_engine.isPathfindingDebugEnabled()) {
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding region target room='%s' region='%s' target=(%d,%d,z=%.2f)",
+			_scene.state.roomName.c_str(), region.regionName.c_str(),
+			_playerState.targetX, _playerState.targetBottomY, (double)_playerState.targetZ);
+	}
+}
+
+Common::Error RoomInteractionProcessor::runRegionInteraction(const RegionRecord &region,
+		InventorySystem &inventory, bool &needsRedraw) {
+	InteractionResult interaction;
+	if (!_engine.getScript()->resolveRegionInteraction(region, interaction, _scene.state.roomName))
+		return Common::kNoError;
+	debugC(2, kDebugRoom,
+		"Harvester: region interaction room='%s' region='%s' action='%s' player=(%d,%d,z=%.2f) next_room='%s'",
+		_scene.state.roomName.c_str(), region.regionName.c_str(), region.actionTag.c_str(),
+		_playerState.centerX, _playerState.bottomY, (double)_playerState.z,
+		interaction.nextRoomName.c_str());
+
+	bool didTransition = false;
+	Common::Error interactionError =
+		handleInteractionResult(interaction, didTransition, Common::String());
+	if (interactionError.getCode() != Common::kNoError)
+		return interactionError;
+	if (_flow.hasPendingMainMenuReturn())
+		return Common::kNoError;
+
+	if (!inventory.refresh())
+		return Common::kReadingFailed;
+	needsRedraw = true;
+	return Common::kNoError;
+}
+
+Common::Error RoomInteractionProcessor::tryActivatePendingRegion(InventorySystem &inventory, bool &needsRedraw) {
+	if (_pendingRegionName.empty() || !_playerState.entity)
+		return Common::kNoError;
+
+	const RegionRecord *region = findSceneRegionByName(_scene.sceneRegions, _pendingRegionName);
+	if (!region || !region->startEnabled) {
+		_pendingRegionName.clear();
+		return Common::kNoError;
+	}
+	if (!doesPlayerOverlapRegion(*_playerState.entity, *region))
+		return Common::kNoError;
+	if (!doesPlayerFacingMatchRegion(_playerState.facing, *region)) {
+		if (!_playerState.hasMoveTarget && !_playerState.turnActive && region->desiredFacing >= 0)
+			(void)Player::startTurnAnimation(_playerState, region->desiredFacing);
+		return Common::kNoError;
+	}
+
+	_callbacks.stopPlayerRegionInteraction();
+	return runRegionInteraction(*region, inventory, needsRedraw);
+}
+
+Common::Error RoomInteractionProcessor::tryActivatePassiveRegion(InventorySystem &inventory, bool &needsRedraw) {
+	if (!_playerState.entity)
+		return Common::kNoError;
+
+	for (const RegionRecord &region : _scene.sceneRegions) {
+		if (!region.startEnabled)
+			continue;
+		if (!doesPlayerOverlapRegion(*_playerState.entity, region))
+			continue;
+		if (!doesPlayerFacingMatchRegion(_playerState.facing, region))
+			continue;
+
+		_callbacks.stopPlayerRegionInteraction();
+		return runRegionInteraction(region, inventory, needsRedraw);
+	}
+
+	return Common::kNoError;
+}
+
+int RoomInteractionProcessor::getPendingRegionFacing() const {
+	const RegionRecord *pendingRegion = findSceneRegionByName(_scene.sceneRegions, _pendingRegionName);
+	return pendingRegion && pendingRegion->startEnabled ? pendingRegion->desiredFacing : -1;
+}
+
 bool RoomInteractionProcessor::hasPendingMainMenuReturn() const {
 	return _flow.hasPendingMainMenuReturn();
 }
diff --git a/engines/harvester/room_interaction.h b/engines/harvester/room_interaction.h
index d6e0f50cd21..7ae7db80601 100644
--- a/engines/harvester/room_interaction.h
+++ b/engines/harvester/room_interaction.h
@@ -102,6 +102,13 @@ public:
 		bool &pendingRoomChangeUsesSavedRoomState, SaveRoomState &pendingRoomChangeSavedRoomState,
 		bool canExitCloseupToParent, RoomInteractionCallbacks &callbacks);
 
+	void queueRegionInteraction(const RegionRecord &region, const Common::Point &mousePos);
+	Common::Error runRegionInteraction(const RegionRecord &region,
+		InventorySystem &inventory, bool &needsRedraw);
+	Common::Error tryActivatePendingRegion(InventorySystem &inventory, bool &needsRedraw);
+	Common::Error tryActivatePassiveRegion(InventorySystem &inventory, bool &needsRedraw);
+	int getPendingRegionFacing() const;
+
 	bool hasPendingMainMenuReturn() const;
 	Common::Error handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw);
 	Common::Error handleInteractionResult(const InteractionResult &interaction,


Commit: a38bca9f3d7a3170f671a745dd8daf8da8adcabf
    https://github.com/scummvm/scummvm/commit/a38bca9f3d7a3170f671a745dd8daf8da8adcabf
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Refactor to separate presentation and modal operations

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/room_presentation.cpp
  A engines/harvester/room_presentation.h
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/module.mk
    engines/harvester/room.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 6c3fc4fe55b..ae98fb4a091 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1747,6 +1747,40 @@ Common::Error Flow::runMainMenuStub() {
 	return _menu.runMainMenuStub(*this);
 }
 
+Common::Error Flow::showCdChangePrompt(int discNumber) {
+	if (discNumber <= 0)
+		return Common::kNoError;
+
+	ResourceManager *resources = _engine.getResources();
+	if (!resources)
+		return Common::kReadingFailed;
+	const int previousDisc = resources->getCurrentDisc();
+
+	if (_engine.shouldShowCdChangePrompts()) {
+		bool promptCompleted = false;
+		Common::Error promptError = _menu.showCdChangePrompt(discNumber, *resources, *this, promptCompleted);
+		// System-event exits can return kNoError without completing the prompt.
+		if (promptError.getCode() != Common::kNoError || !promptCompleted)
+			return promptError;
+	}
+
+	if (!_engine.activateDisc(discNumber)) {
+		warning("Harvester: unable to activate disc %d resources", discNumber);
+		return Common::kReadingFailed;
+	}
+	if ((discNumber == 1 || discNumber == 3) &&
+			previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
+		Script *script = _engine.getScript();
+		if (!script || !script->reloadTownWorld(*resources)) {
+			warning("Harvester: unable to reload town script after disc prompt %d -> %d",
+				previousDisc, discNumber);
+			return Common::kReadingFailed;
+		}
+	}
+
+	return Common::kNoError;
+}
+
 Common::Error Flow::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, bool canSaveGame) {
 	return _menu.runRoomMenuStub(backdrop, palette, paletteBrightness, *this, canSaveGame);
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 92d9e15dc5d..776e9f650e8 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -52,6 +52,8 @@ public:
 	bool buildDialogueSaveStateBlob(Common::Array<byte> &blob, uint32 saveVersion);
 	bool loadDialogueSaveStateBlob(const Common::Array<byte> &blob, uint32 saveVersion);
 	Common::Error run();
+	Common::Error showCdChangePrompt(int discNumber);
+	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 
 private:
 	friend class HarvesterEngine;
@@ -115,7 +117,6 @@ private:
 	void resetRoomNpcDialogueState();
 	void resetCursorAnimationSequence();
 	bool tickRuntimeEntities();
-	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 
 	HarvesterEngine &_engine;
 	Common::Array<Common::String> _quickTips;
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index c9485fb58f8..ecf0537af55 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -146,6 +146,7 @@ static const int kPasswordMaxCharacters = 8;
 static const uint32 kPasswordCursorBlinkMs = 480;
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
+static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 static const float kPaletteBrightnessBlack = 0.0f;
 
 class ScopedSceneTimerPause {
@@ -442,6 +443,22 @@ static void applyMenuPalette(Graphics::Screen &screen, const HarvesterEngine &en
 	screen.setPalette(displayPalette);
 }
 
+static void renderCdChangePromptScreen(HarvesterEngine &engine, const IndexedBitmap &bitmap,
+		const byte *palette) {
+	Graphics::Screen *screen = engine.getScreen();
+	if (!screen)
+		return;
+
+	applyMenuPalette(*screen, engine, palette, 1.0f);
+	screen->fillRect(screen->getBounds(), 0);
+	if (bitmap.isValid())
+		screen->copyRectToSurface(bitmap.pixels.data(), bitmap.width, 0, 0, bitmap.width, bitmap.height);
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
 static void renderHelpScreen(HarvesterEngine &engine, const IndexedBitmap &bitmap, const byte *palette) {
 	Graphics::Screen *screen = engine.getScreen();
 	if (!screen)
@@ -845,6 +862,78 @@ MenuSystem::MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 	: _engine(engine), _mousePos(mousePos), _menuItems(menuItems) {
 }
 
+Common::Error MenuSystem::showCdChangePrompt(int discNumber, ResourceManager &resources,
+		Flow &flow, bool &promptCompleted) {
+	promptCompleted = false;
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen)
+		return Common::kReadingFailed;
+
+	const Common::String bitmapPath = Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
+	IndexedBitmap promptBitmap;
+	byte promptPalette[256 * 3];
+	if (!loadBitmapResource(resources, bitmapPath, promptBitmap) ||
+			!loadPaletteResource(resources, kCdChangePromptPalettePath, promptPalette)) {
+		warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
+		return Common::kReadingFailed;
+	}
+
+	debugC(1, kDebugScene, "Harvester: showing CD change prompt for disc %d", discNumber);
+	_engine.stopMusic();
+
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		Common::Error result = Common::kNoError;
+		if (flow.handleSystemEvent(event, result))
+			return result;
+		if (event.type == Common::EVENT_MOUSEMOVE)
+			_mousePos = event.mouse;
+	}
+
+	bool waitingForRelease = false;
+	bool needsRedraw = true;
+	bool activateRequestedDisc = false;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit() && !activateRequestedDisc) {
+		if (needsRedraw) {
+			renderCdChangePromptScreen(_engine, promptBitmap, promptPalette);
+			needsRedraw = false;
+		}
+
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (flow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				_mousePos = event.mouse;
+				needsRedraw = true;
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+				waitingForRelease = true;
+				break;
+			case Common::EVENT_LBUTTONUP:
+				if (waitingForRelease)
+					activateRequestedDisc = true;
+				break;
+			default:
+				break;
+			}
+		}
+
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+		if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+	promptCompleted = true;
+	return Common::kNoError;
+}
+
 Common::Error MenuSystem::runMainMenuStub(Flow &flow) {
 	const Art *art = _engine.getArt();
 	if (!art)
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index e077f4af1c7..be59851e1ac 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -36,6 +36,7 @@ namespace Harvester {
 
 class HarvesterEngine;
 class Flow;
+class ResourceManager;
 
 struct MenuTextConfig {
 	Common::Array<Common::String> optionItems;
@@ -91,6 +92,8 @@ public:
 	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &flow, bool canSaveGame);
 	Common::Error validateParentalPassword(Flow &flow);
+	Common::Error showCdChangePrompt(int discNumber, ResourceManager &resources,
+		Flow &flow, bool &promptCompleted);
 
 private:
 	Common::Error runLoadGameMenu(const byte *palette, float paletteBrightness,
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index c3cc27e9b8c..de8ce1ecbb2 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -19,6 +19,7 @@ MODULE_OBJS = \
 	resources.o \
 	room_combat.o \
 	room_interaction.o \
+	room_presentation.o \
 	room_scene_runtime.o \
 	room_session.o \
 	room.o \
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 98916bb1446..984f1bd4a55 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -34,7 +34,6 @@
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
-#include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/art.h"
@@ -44,6 +43,7 @@
 #include "harvester/player.h"
 #include "harvester/room_combat.h"
 #include "harvester/room_interaction.h"
+#include "harvester/room_presentation.h"
 #include "harvester/room_session.h"
 #include "harvester/room_support.h"
 
@@ -51,9 +51,7 @@ namespace Harvester {
 
 static const uint32 kRoomExitFastClickWindowTicks = 20;
 static const char *const kPlayerInventoryLabel = "your inventory";
-static const byte kTransparentPaletteIndex = 0;
-static const float kNormalPaletteBrightness = 1.0f;
-static const float kDimPaletteBrightness = 0.6f;
+
 static const int kNativeInventoryDragCloseLeft = 0x45;
 static const int kNativeInventoryDragCloseTop = 0x4b;
 static const int kNativeInventoryDragCloseRight = 0x239;
@@ -67,7 +65,6 @@ static const uint32 kCombatLoadoutStatusDisplayTicks = 75;
 static const int kNativeCombatResourceIconsX = 10;
 static const int kNativeCombatResourceIconsY = 30;
 static const int kNativeCombatResourceIconSpacing = 15;
-static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
@@ -100,41 +97,6 @@ static void debugLogInventoryClick(const char *buttonLabel, const Common::Point
 		InventorySystem::isStatusObject(inventoryHover->object));
 }
 
-static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
-	if (!bitmap.isValid())
-		return;
-
-	int destX = x;
-	int destY = y;
-	int srcX = 0;
-	int srcY = 0;
-	int width = (int)bitmap.width;
-	int height = (int)bitmap.height;
-
-	if (destX < 0) {
-		srcX = -destX;
-		width += destX;
-		destX = 0;
-	}
-	if (destY < 0) {
-		srcY = -destY;
-		height += destY;
-		destY = 0;
-	}
-	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
-		return;
-
-	width = MIN<int>(width, screen.w - destX);
-	height = MIN<int>(height, screen.h - destY);
-	if (width <= 0 || height <= 0)
-		return;
-
-	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
-	byte *dst = (byte *)screen.getBasePtr(destX, destY);
-	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
-		screen.format.bytesPerPixel, kTransparentPaletteIndex);
-}
-
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const Text *text = engine.getText();
 	if (!text || !fontName)
@@ -158,47 +120,6 @@ static Common::String resolveStartupNpcLabel(const NpcRecord &npc) {
 	return label;
 }
 
-static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
-	Common::Array<byte> data;
-	if (!resources.loadFile(path, data) || data.size() < 12)
-		return false;
-
-	bitmap = IndexedBitmap();
-	return decodeBitmap(data, bitmap);
-}
-
-static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette) {
-	Common::Array<byte> data;
-	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
-		return false;
-
-	memcpy(palette, data.data(), 256 * 3);
-	return true;
-}
-
-static void applyRoomPalette(Graphics::Screen &screen, const HarvesterEngine &engine,
-		const byte *palette, float brightness) {
-	byte scaledPalette[256 * 3];
-	buildHarvesterDisplayPalette(palette, brightness * engine.getGammaBrightnessScale(), scaledPalette);
-	screen.setPalette(scaledPalette);
-}
-
-static void renderCdChangePromptScreen(HarvesterEngine &engine, const IndexedBitmap &bitmap,
-		const byte *palette) {
-	Graphics::Screen *screen = engine.getScreen();
-	if (!screen)
-		return;
-
-	applyRoomPalette(*screen, engine, palette, 1.0f);
-	screen->fillRect(screen->getBounds(), 0);
-	if (bitmap.isValid())
-		screen->copyRectToSurface(bitmap.pixels.data(), bitmap.width, 0, 0, bitmap.width, bitmap.height);
-	if (engine.getRuntimeEntities())
-		engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
 static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
 		const Common::String &promptText, bool useNativeFont) {
 	if (promptText.empty())
@@ -247,17 +168,10 @@ static void drawCombatLoadoutResourceIcons(Graphics::Screen &screen, const Art &
 
 	const int iconCount = CLIP<int>(script.getPlayerCombatResourceCount(loadout), 0, info.maxVisibleCount);
 	for (int i = 0; i < iconCount; ++i)
-		blitBitmap(screen, icon, kNativeCombatResourceIconsX + i * kNativeCombatResourceIconSpacing,
+		RoomPresentation::blitBitmap(screen, icon, kNativeCombatResourceIconsX + i * kNativeCombatResourceIconSpacing,
 			kNativeCombatResourceIconsY);
 }
 
-static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
-	byte scaledPalette[256 * 3];
-	const float gammaBrightness = g_engine ? g_engine->getGammaBrightnessScale() : 1.0f;
-	buildHarvesterDisplayPalette(palette, brightness * gammaBrightness, scaledPalette);
-	screen.setPalette(scaledPalette);
-}
-
 RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		InventorySystem &inventory, CheatSystem &cheats)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory), _cheats(cheats) {
@@ -492,107 +406,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				return false;
 			return _inventory.open();
 		};
-		auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
-			Graphics::Screen *activeScreen = getActiveScreen();
-			if (!activeScreen)
-				return false;
-
-			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
-			return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
-		};
-		auto showCdChangePrompt = [&](int discNumber) -> Common::Error {
-			if (discNumber <= 0)
-				return Common::kNoError;
-
-			ResourceManager *resources = _engine.getResources();
-			if (!resources)
-				return Common::kReadingFailed;
-			const int previousDisc = resources->getCurrentDisc();
-
-			if (_engine.shouldShowCdChangePrompts()) {
-				Graphics::Screen *screen = _engine.getScreen();
-				if (!screen)
-					return Common::kReadingFailed;
-
-				const Common::String bitmapPath = Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
-				IndexedBitmap promptBitmap;
-				byte promptPalette[256 * 3];
-				if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
-						!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
-					warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
-					return Common::kReadingFailed;
-				}
-
-				debugC(1, kDebugScene, "Harvester: showing CD change prompt for disc %d", discNumber);
-				_engine.stopMusic();
-
-				Common::Event event;
-				while (g_system->getEventManager()->pollEvent(event)) {
-					Common::Error result = Common::kNoError;
-					if (flow.handleSystemEvent(event, result))
-						return result;
-					if (event.type == Common::EVENT_MOUSEMOVE)
-						_mousePos = event.mouse;
-				}
-
-				bool waitingForRelease = false;
-				bool needsRedraw = true;
-				bool activateRequestedDisc = false;
-				Graphics::FrameLimiter limiter(g_system, 60);
-
-				while (!_engine.shouldQuit() && !activateRequestedDisc) {
-					if (needsRedraw) {
-						renderCdChangePromptScreen(_engine, promptBitmap, promptPalette);
-						needsRedraw = false;
-					}
-
-					while (g_system->getEventManager()->pollEvent(event)) {
-						Common::Error result = Common::kNoError;
-						if (flow.handleSystemEvent(event, result))
-							return result;
-
-						switch (event.type) {
-						case Common::EVENT_MOUSEMOVE:
-							_mousePos = event.mouse;
-							needsRedraw = true;
-							break;
-						case Common::EVENT_LBUTTONDOWN:
-							waitingForRelease = true;
-							break;
-						case Common::EVENT_LBUTTONUP:
-							if (waitingForRelease)
-								activateRequestedDisc = true;
-							break;
-						default:
-							break;
-						}
-					}
 
-					EntityManager *entityManager = _engine.getRuntimeEntities();
-					if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
-						needsRedraw = true;
-
-					limiter.delayBeforeSwap();
-					limiter.startFrame();
-				}
-			}
-
-			if (!_engine.activateDisc(discNumber)) {
-				warning("Harvester: unable to activate disc %d resources", discNumber);
-				return Common::kReadingFailed;
-			}
-			if ((discNumber == 1 || discNumber == 3) &&
-					previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
-				Script *script = _engine.getScript();
-				if (!script || !script->reloadTownWorld(*resources)) {
-					warning("Harvester: unable to reload town script after disc prompt %d -> %d",
-						previousDisc, discNumber);
-					return Common::kReadingFailed;
-				}
-			}
+		RoomPresentation presentation(_engine, scene, art, inspectFont, useNativeInspectFont);
 
-			return Common::kNoError;
-		};
 		auto runRoomExitCommands = [&]() -> Common::Error {
 			InteractionResult exitInteraction;
 			if (!_engine.getScript()->executeRoomExitCommands(scene.state.roomName, exitInteraction))
@@ -600,7 +416,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			for (uint exitStep = 0; exitStep < 128; ++exitStep) {
 				if (exitInteraction.cdChangeDisc > 0) {
-					Common::Error cdPromptError = showCdChangePrompt(exitInteraction.cdChangeDisc);
+					Common::Error cdPromptError = flow.showCdChangePrompt(exitInteraction.cdChangeDisc);
 					if (cdPromptError.getCode() != Common::kNoError)
 						return cdPromptError;
 				}
@@ -644,51 +460,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				scene.state.roomName.c_str());
 			return Common::kNoError;
 		};
-		auto applyLightingCommand = [&](StartupLightingCommand lightingCommand) -> Common::Error {
-			Graphics::Screen *activeScreen = getActiveScreen();
-			if (!activeScreen)
-				return Common::kNoError;
-
-			switch (lightingCommand) {
-			case kStartupLightingCommandNone:
-				return Common::kNoError;
-			case kStartupLightingCommandDim:
-				scene.targetPaletteBrightness = kDimPaletteBrightness;
-				drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
-				setScaledRoomPalette(*activeScreen, scene.palette, scene.targetPaletteBrightness);
-				activeScreen->makeAllDirty();
-				activeScreen->update();
-				return Common::kNoError;
-			case kStartupLightingCommandNormal:
-				if (ResourceManager *resources = _engine.getResources()) {
-					if (!scene.state.palettePath.empty() &&
-							!loadPaletteResource(*resources, scene.state.palettePath, scene.palette)) {
-						warning("Harvester: unable to reload room palette '%s' for CHANGE_LIGHTING NORMAL",
-							scene.state.palettePath.c_str());
-					}
-				}
-				scene.targetPaletteBrightness = kNormalPaletteBrightness;
-				drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
-				setScaledRoomPalette(*activeScreen, scene.palette, scene.targetPaletteBrightness);
-				activeScreen->makeAllDirty();
-				activeScreen->update();
-				return Common::kNoError;
-			case kStartupLightingCommandBlack: {
-				byte blackPalette[256 * 3] = { 0 };
-				activeScreen->fillRect(activeScreen->getBounds(), 0);
-				activeScreen->setPalette(blackPalette);
-				activeScreen->makeAllDirty();
-				activeScreen->update();
-				return Common::kNoError;
-			}
-			case kStartupLightingCommandFadeIn:
-				debugC(1, kDebugRoom,
-					"Harvester: CHANGE_LIGHTING FADE_IN has no direct room-side equivalent yet; preserving control flow");
-				return Common::kNoError;
-			}
 
-			return Common::kNoError;
-		};
 		auto applyPlayerGotoXZ = [&](int x, int z) {
 			playerState.hasMoveTarget = false;
 			playerState.turnActive = false;
@@ -718,72 +490,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			session.resetIdleState();
 			session.captureCurrentSaveState();
 		};
-		auto runModalShowText = [&](const ResolvedText &modalText) -> Common::Error {
-			Graphics::Screen *activeScreen = getActiveScreen();
-			if (!activeScreen || !art || !inspectFont)
-				return Common::kNoError;
-			if (!resolveInspectTextboxBitmap(*art, modalText)) {
-				debug(1, "Harvester: unsupported SHOW_TEXT textbox '%s' text='%s'",
-					modalText.boxName.c_str(), modalText.value.c_str());
-				return Common::kNoError;
-			}
-
-			IndexedBitmap backdrop;
-			if (!captureDialogueBackdrop(backdrop))
-				return Common::kReadingFailed;
 
-			Graphics::FrameLimiter limiter(g_system, 60);
-			bool initialInputReleased = false;
-			bool dismissPressed = false;
-			uint frameCount = 0;
-
-			while (!_engine.shouldQuit()) {
-				Common::Event event;
-				Common::Error result = Common::kNoError;
-				while (g_system->getEventManager()->pollEvent(event)) {
-					if (flow.handleSystemEvent(event, result))
-						return result;
-
-					if (!initialInputReleased) {
-						if (event.type == Common::EVENT_LBUTTONUP ||
-								event.type == Common::EVENT_RBUTTONUP ||
-								event.type == Common::EVENT_KEYUP) {
-							initialInputReleased = true;
-						}
-						continue;
-					}
-
-					if (!dismissPressed) {
-						if (event.type == Common::EVENT_LBUTTONDOWN ||
-								event.type == Common::EVENT_RBUTTONDOWN) {
-							dismissPressed = true;
-						} else if (event.type == Common::EVENT_KEYDOWN) {
-							return Common::kNoError;
-						}
-						continue;
-					}
-
-					if (event.type == Common::EVENT_LBUTTONUP ||
-							event.type == Common::EVENT_RBUTTONUP) {
-						return Common::kNoError;
-					}
-				}
-
-				if (!initialInputReleased && frameCount++ > 0)
-					initialInputReleased = true;
-
-				blitBitmap(*activeScreen, backdrop, 0, 0);
-				setScaledRoomPalette(*activeScreen, scene.palette, scene.targetPaletteBrightness);
-				drawRoomInspectText(*activeScreen, *art, *inspectFont, modalText, useNativeInspectFont);
-				activeScreen->makeAllDirty();
-				activeScreen->update();
-
-				limiter.delayBeforeSwap();
-				limiter.startFrame();
-			}
-
-			return Common::kNoError;
-		};
 		auto requestPlayerGameOver = [&](const char *reason, const Common::String &sourceName) {
 			if (flow.hasPendingMainMenuReturn())
 				return;
@@ -830,31 +537,19 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					? _engine.getScript()->getPlayerCurrentHitPoints()
 					: 0);
 		};
-		typedef decltype(captureDialogueBackdrop) CaptureDialogueBackdropFn;
-		typedef decltype(showCdChangePrompt) ShowCdChangePromptFn;
 		typedef decltype(runRoomExitCommands) RunRoomExitCommandsFn;
-		typedef decltype(applyLightingCommand) ApplyLightingCommandFn;
 		typedef decltype(applyPlayerGotoXZ) ApplyPlayerGotoXZFn;
-		typedef decltype(runModalShowText) RunModalShowTextFn;
 		typedef decltype(startPlayerDefeatSequence) StartPlayerDefeatSequenceFn;
 
 		struct RoomInteractionCallbacksImpl : RoomInteractionCallbacks {
 			RoomInteractionCallbacksImpl(
-					RoomSession &session_,
-					CaptureDialogueBackdropFn &captureDialogueBackdropFn_,
-					ShowCdChangePromptFn &showCdChangePromptFn_,
+					RoomSession &session_, RoomPresentation &presentation_, Flow &flow_,
 					RunRoomExitCommandsFn &runRoomExitCommandsFn_,
-					ApplyLightingCommandFn &applyLightingCommandFn_,
 					ApplyPlayerGotoXZFn &applyPlayerGotoXZFn_,
-					RunModalShowTextFn &runModalShowTextFn_,
 					StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn_)
-				: session(session_),
-				  captureDialogueBackdropFn(captureDialogueBackdropFn_),
-				  showCdChangePromptFn(showCdChangePromptFn_),
+				: session(session_), presentation(presentation_), flow(flow_),
 				  runRoomExitCommandsFn(runRoomExitCommandsFn_),
-				  applyLightingCommandFn(applyLightingCommandFn_),
 				  applyPlayerGotoXZFn(applyPlayerGotoXZFn_),
-				  runModalShowTextFn(runModalShowTextFn_),
 				  startPlayerDefeatSequenceFn(startPlayerDefeatSequenceFn_) {
 			}
 
@@ -871,11 +566,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			bool captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop) override {
-				return captureDialogueBackdropFn(dialogueBackdrop);
+				return presentation.captureDialogueBackdrop(dialogueBackdrop);
 			}
 
 			Common::Error showCdChangePrompt(int discNumber) override {
-				return showCdChangePromptFn(discNumber);
+				return flow.showCdChangePrompt(discNumber);
 			}
 
 			Common::Error runRoomExitCommands() override {
@@ -883,7 +578,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			Common::Error applyLightingCommand(StartupLightingCommand lightingCommand) override {
-				return applyLightingCommandFn(lightingCommand);
+				return presentation.applyLightingCommand(lightingCommand);
 			}
 
 			void applyPlayerGotoXZ(int x, int z) override {
@@ -891,7 +586,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			Common::Error runModalShowText(const ResolvedText &modalText) override {
-				return runModalShowTextFn(modalText);
+				return presentation.runModalShowText(modalText, flow);
 			}
 
 			void resetIdleState() override {
@@ -912,18 +607,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 
 			RoomSession &session;
-			CaptureDialogueBackdropFn &captureDialogueBackdropFn;
-			ShowCdChangePromptFn &showCdChangePromptFn;
+			RoomPresentation &presentation;
+			Flow &flow;
 			RunRoomExitCommandsFn &runRoomExitCommandsFn;
-			ApplyLightingCommandFn &applyLightingCommandFn;
 			ApplyPlayerGotoXZFn &applyPlayerGotoXZFn;
-			RunModalShowTextFn &runModalShowTextFn;
 			StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn;
 		};
 		RoomInteractionCallbacksImpl interactionCallbacks(
-			session,
-			captureDialogueBackdrop, showCdChangePrompt,
-			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
+			session, presentation, flow, runRoomExitCommands, applyPlayerGotoXZ,
 			startPlayerDefeatSequence);
 		RoomInteractionProcessor interactionProcessor(
 			_engine, flow, scene, playerState, pendingRegionName, pendingRoomChange,
@@ -1164,7 +855,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			} else if (roomCarryActive && roomItems.getCarriedRoomItemBitmap().isValid()) {
 				const int drawX = _mousePos.x - (int)roomItems.getCarriedRoomItemBitmap().width / 2;
 				const int drawY = _mousePos.y - (int)roomItems.getCarriedRoomItemBitmap().height / 2;
-				blitBitmap(*activeScreen, roomItems.getCarriedRoomItemBitmap(), drawX, drawY);
+				RoomPresentation::blitBitmap(*activeScreen, roomItems.getCarriedRoomItemBitmap(), drawX, drawY);
 			}
 
 			if (showingInspectText) {
diff --git a/engines/harvester/room_presentation.cpp b/engines/harvester/room_presentation.cpp
new file mode 100644
index 00000000000..ea63ac33e0d
--- /dev/null
+++ b/engines/harvester/room_presentation.cpp
@@ -0,0 +1,224 @@
+/* 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 "harvester/room_presentation.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "common/textconsole.h"
+#include "graphics/blit.h"
+#include "graphics/framelimiter.h"
+#include "harvester/art.h"
+#include "harvester/detection.h"
+#include "harvester/flow.h"
+#include "harvester/harvester.h"
+#include "harvester/palette_utils.h"
+#include "harvester/resources.h"
+#include "harvester/room_support.h"
+
+namespace Harvester {
+
+static const byte kTransparentPaletteIndex = 0;
+static const float kNormalPaletteBrightness = 1.0f;
+static const float kDimPaletteBrightness = 0.6f;
+
+static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
+		return false;
+
+	memcpy(palette, data.data(), 256 * 3);
+	return true;
+}
+
+static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
+	byte scaledPalette[256 * 3];
+	const float gammaBrightness = g_engine ? g_engine->getGammaBrightnessScale() : 1.0f;
+	buildHarvesterDisplayPalette(palette, brightness * gammaBrightness, scaledPalette);
+	screen.setPalette(scaledPalette);
+}
+
+void RoomPresentation::blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	int destX = x;
+	int destY = y;
+	int srcX = 0;
+	int srcY = 0;
+	int width = (int)bitmap.width;
+	int height = (int)bitmap.height;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
+}
+
+RoomPresentation::RoomPresentation(HarvesterEngine &engine, RoomSceneResources &scene,
+		const Art *art, const Graphics::Font *inspectFont, bool useNativeInspectFont)
+	: _engine(engine), _scene(scene), _art(art), _inspectFont(inspectFont),
+	  _useNativeInspectFont(useNativeInspectFont) {
+}
+
+bool RoomPresentation::captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop) {
+	Graphics::Screen *activeScreen = _engine.getScreen();
+	if (!activeScreen)
+		return false;
+
+	drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
+	return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
+}
+
+Common::Error RoomPresentation::applyLightingCommand(StartupLightingCommand lightingCommand) {
+	Graphics::Screen *activeScreen = _engine.getScreen();
+	if (!activeScreen)
+		return Common::kNoError;
+
+	switch (lightingCommand) {
+	case kStartupLightingCommandNone:
+		return Common::kNoError;
+	case kStartupLightingCommandDim:
+		_scene.targetPaletteBrightness = kDimPaletteBrightness;
+		drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
+		setScaledRoomPalette(*activeScreen, _scene.palette, _scene.targetPaletteBrightness);
+		activeScreen->makeAllDirty();
+		activeScreen->update();
+		return Common::kNoError;
+	case kStartupLightingCommandNormal:
+		if (ResourceManager *resources = _engine.getResources()) {
+			if (!_scene.state.palettePath.empty() &&
+					!loadPaletteResource(*resources, _scene.state.palettePath, _scene.palette)) {
+				warning("Harvester: unable to reload room palette '%s' for CHANGE_LIGHTING NORMAL",
+					_scene.state.palettePath.c_str());
+			}
+		}
+		_scene.targetPaletteBrightness = kNormalPaletteBrightness;
+		drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
+		setScaledRoomPalette(*activeScreen, _scene.palette, _scene.targetPaletteBrightness);
+		activeScreen->makeAllDirty();
+		activeScreen->update();
+		return Common::kNoError;
+	case kStartupLightingCommandBlack: {
+		byte blackPalette[256 * 3] = { 0 };
+		activeScreen->fillRect(activeScreen->getBounds(), 0);
+		activeScreen->setPalette(blackPalette);
+		activeScreen->makeAllDirty();
+		activeScreen->update();
+		return Common::kNoError;
+	}
+	case kStartupLightingCommandFadeIn:
+		debugC(1, kDebugRoom,
+			"Harvester: CHANGE_LIGHTING FADE_IN has no direct room-side equivalent yet; preserving control flow");
+		return Common::kNoError;
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error RoomPresentation::runModalShowText(const ResolvedText &modalText, Flow &flow) {
+	Graphics::Screen *activeScreen = _engine.getScreen();
+	if (!activeScreen || !_art || !_inspectFont)
+		return Common::kNoError;
+	if (!resolveInspectTextboxBitmap(*_art, modalText)) {
+		debug(1, "Harvester: unsupported SHOW_TEXT textbox '%s' text='%s'",
+			modalText.boxName.c_str(), modalText.value.c_str());
+		return Common::kNoError;
+	}
+
+	IndexedBitmap backdrop;
+	if (!captureDialogueBackdrop(backdrop))
+		return Common::kReadingFailed;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	bool initialInputReleased = false;
+	bool dismissPressed = false;
+	uint frameCount = 0;
+
+	while (!_engine.shouldQuit()) {
+		Common::Event event;
+		Common::Error result = Common::kNoError;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (flow.handleSystemEvent(event, result))
+				return result;
+
+			if (!initialInputReleased) {
+				if (event.type == Common::EVENT_LBUTTONUP ||
+						event.type == Common::EVENT_RBUTTONUP ||
+						event.type == Common::EVENT_KEYUP) {
+					initialInputReleased = true;
+				}
+				continue;
+			}
+
+			if (!dismissPressed) {
+				if (event.type == Common::EVENT_LBUTTONDOWN ||
+						event.type == Common::EVENT_RBUTTONDOWN) {
+					dismissPressed = true;
+				} else if (event.type == Common::EVENT_KEYDOWN) {
+					return Common::kNoError;
+				}
+				continue;
+			}
+
+			if (event.type == Common::EVENT_LBUTTONUP ||
+					event.type == Common::EVENT_RBUTTONUP) {
+				return Common::kNoError;
+			}
+		}
+
+		if (!initialInputReleased && frameCount++ > 0)
+			initialInputReleased = true;
+
+		blitBitmap(*activeScreen, backdrop, 0, 0);
+		setScaledRoomPalette(*activeScreen, _scene.palette, _scene.targetPaletteBrightness);
+		drawRoomInspectText(*activeScreen, *_art, *_inspectFont, modalText, _useNativeInspectFont);
+		activeScreen->makeAllDirty();
+		activeScreen->update();
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/room_presentation.h b/engines/harvester/room_presentation.h
new file mode 100644
index 00000000000..1c97dbeabf2
--- /dev/null
+++ b/engines/harvester/room_presentation.h
@@ -0,0 +1,62 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_ROOM_PRESENTATION_H
+#define HARVESTER_ROOM_PRESENTATION_H
+
+#include "common/error.h"
+#include "harvester/script.h"
+
+namespace Graphics {
+class Font;
+class Screen;
+}
+
+namespace Harvester {
+
+class Art;
+class Flow;
+class HarvesterEngine;
+struct IndexedBitmap;
+struct ResolvedText;
+struct RoomSceneResources;
+
+class RoomPresentation {
+public:
+	RoomPresentation(HarvesterEngine &engine, RoomSceneResources &scene,
+		const Art *art, const Graphics::Font *inspectFont, bool useNativeInspectFont);
+
+	bool captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop);
+	Common::Error applyLightingCommand(StartupLightingCommand lightingCommand);
+	Common::Error runModalShowText(const ResolvedText &modalText, Flow &flow);
+	static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y);
+
+private:
+	HarvesterEngine &_engine;
+	RoomSceneResources &_scene;
+	const Art *_art;
+	const Graphics::Font *_inspectFont;
+	bool _useNativeInspectFont;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_ROOM_PRESENTATION_H


Commit: 1d3c209f66d4596e9a77c024bb13d627f20ae8e6
    https://github.com/scummvm/scummvm/commit/1d3c209f66d4596e9a77c024bb13d627f20ae8e6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Extract input handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/room_input.cpp
  A engines/harvester/room_input.h
    engines/harvester/flow.h
    engines/harvester/module.mk
    engines/harvester/room.cpp
    engines/harvester/room_presentation.h
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 776e9f650e8..c7328121d5e 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -63,6 +63,7 @@ private:
 	friend class MenuSystem;
 	friend class RoomInteractionProcessor;
 	friend class RoomSystem;
+	friend class RoomInput;
 	friend class RoomSession;
 
 	bool loadQuickTips();
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index de8ce1ecbb2..3487d10e819 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -19,6 +19,7 @@ MODULE_OBJS = \
 	resources.o \
 	room_combat.o \
 	room_interaction.o \
+	room_input.o \
 	room_presentation.o \
 	room_scene_runtime.o \
 	room_session.o \
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 984f1bd4a55..f8dd76ee4c6 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -43,60 +43,25 @@
 #include "harvester/player.h"
 #include "harvester/room_combat.h"
 #include "harvester/room_interaction.h"
+#include "harvester/room_input.h"
 #include "harvester/room_presentation.h"
 #include "harvester/room_session.h"
 #include "harvester/room_support.h"
 
 namespace Harvester {
 
-static const uint32 kRoomExitFastClickWindowTicks = 20;
 static const char *const kPlayerInventoryLabel = "your inventory";
 
-static const int kNativeInventoryDragCloseLeft = 0x45;
-static const int kNativeInventoryDragCloseTop = 0x4b;
-static const int kNativeInventoryDragCloseRight = 0x239;
-static const int kNativeInventoryDragCloseBottom = 0x1b4;
 static const int kNativeInventoryTooltipX = 0xbc;
 static const int kNativeInventoryTooltipY = 0x19e;
 static const int kNativeInventoryWeekdayX = 0x1e0;
 static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
-static const uint32 kCombatLoadoutStatusDisplayTicks = 75;
+
 static const int kNativeCombatResourceIconsX = 10;
 static const int kNativeCombatResourceIconsY = 30;
 static const int kNativeCombatResourceIconSpacing = 15;
 
-static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
-	return !roomName.equalsIgnoreCase("LAVAPIT") &&
-		!roomName.equalsIgnoreCase("BOWLSNTRY1");
-}
-
-static bool isOutsideNativeInventoryDragCloseBounds(const Common::Point &point) {
-	return point.x < kNativeInventoryDragCloseLeft ||
-		point.x >= kNativeInventoryDragCloseRight ||
-		point.y < kNativeInventoryDragCloseTop ||
-		point.y >= kNativeInventoryDragCloseBottom;
-}
-
-static void debugLogInventoryClick(const char *buttonLabel, const Common::Point &point,
-		const InventoryVisual *inventoryHover) {
-	if (!inventoryHover) {
-		debugC(1, kDebugInventory,
-			"Harvester: inventory %s click at (%d,%d) hit no item", buttonLabel, point.x, point.y);
-		return;
-	}
-
-	debugC(1, kDebugInventory,
-		"Harvester: inventory %s click at (%d,%d) object='%s' sprite='%s' alt='%s' action='%s' owner='%s' text='%s' bounds=(%d,%d)-(%d,%d) exit=%d status=%d",
-		buttonLabel, point.x, point.y, inventoryHover->object.objectName.c_str(),
-		inventoryHover->object.spritePath.c_str(), inventoryHover->object.altSpritePath.c_str(),
-		inventoryHover->object.actionTag.c_str(), inventoryHover->object.currentOwnerOrRoom.c_str(),
-		inventoryHover->object.inventoryTextKey.c_str(), inventoryHover->bounds.left,
-		inventoryHover->bounds.top, inventoryHover->bounds.right, inventoryHover->bounds.bottom,
-		InventorySystem::isExitObject(inventoryHover->object),
-		InventorySystem::isStatusObject(inventoryHover->object));
-}
-
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const Text *text = engine.getText();
 	if (!text || !fontName)
@@ -325,56 +290,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		combat.initializeCombatStates(scene.state);
 		RoomSceneRuntime &sceneRuntime = session.getSceneRuntime();
 		RoomCombatEffects &combatEffects = session.getCombatEffects();
-		ResolvedText inspectText;
-		Common::String currentInteractionPromptText;
-		bool showingInspectText = false;
-		bool inspectCanDismiss = false;
-		auto beginInspectText = [&](const ObjectRecord &object, const ResolvedText &text) {
-			inspectText = text;
-			showingInspectText = true;
-			inspectCanDismiss = false;
-			debugC(2, kDebugRoom,
-				"Harvester: showing IDENT object='%s' prompt='%s' box='%s' text='%s'",
-				object.objectName.c_str(), currentInteractionPromptText.c_str(),
-				inspectText.boxName.c_str(), inspectText.value.c_str());
-		};
-		ResolvedText combatLoadoutStatusText;
-		uint32 combatLoadoutStatusHideTick = 0;
-		bool closeInventoryAfterCombatLoadoutStatus = false;
-		bool &moveLeft = session.getMovementInput().moveLeft;
-		bool &moveRight = session.getMovementInput().moveRight;
-		bool &moveUp = session.getMovementInput().moveUp;
-		bool &moveDown = session.getMovementInput().moveDown;
-		bool attackModifierHeld = false;
+		RoomPresentationState presentationState;
+
+		RoomInputState &inputState = session.getInputState();
+
 		Common::String &pendingRegionName = session.getPendingRegionName();
 		Common::String pendingRoomChange;
 		bool pendingRoomChangeIsRoomName = false;
 		bool pendingRoomChangeUsesSavedRoomState = false;
 		SaveRoomState pendingRoomChangeSavedRoomState;
 		const bool canExitCloseupToParent = flow._roomLoopDepth > 1;
-		uint32 lastLeftButtonReleaseTick = 0;
-		uint32 nextKeyboardAttackAllowedTick = 0;
+
 		RoomIdleAnimationState &idleState = session.getIdleState();
 		bool needsRedraw = true;
 		RoomItemInteractions roomItems(_engine, scene, sceneRuntime, _inventory, pendingRoomChange);
 
-		auto queueCombatLoadoutStatusMessage = [&](const Common::String &message) {
-			if (message.empty()) {
-				const bool hadMessage = !combatLoadoutStatusText.value.empty();
-				combatLoadoutStatusText = ResolvedText();
-				combatLoadoutStatusHideTick = 0;
-				if (hadMessage)
-					needsRedraw = true;
-				return;
-			}
-
-			combatLoadoutStatusText.boxName = "BOX1";
-			combatLoadoutStatusText.value = message;
-			combatLoadoutStatusHideTick =
-				Player::getRuntimeClockTicks() + kCombatLoadoutStatusDisplayTicks;
-			needsRedraw = true;
-		};
-
 		session.resetIdleState();
 
 		auto syncAnimatedRoomActorPlacement = [&]() {
@@ -392,21 +322,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 		};
 
-		auto openInventoryOverlay = [&]() {
-			moveLeft = false;
-			moveRight = false;
-			moveUp = false;
-			moveDown = false;
-			pendingRegionName.clear();
-			(void)session.cancelPlayerAttackAnimation();
-			playerState.hasMoveTarget = false;
-			playerState.turnActive = false;
-			playerState.turnTargetFacing = -1;
-			if (!roomItems.stowCarriedRoomItemToInventory())
-				return false;
-			return _inventory.open();
-		};
-
 		RoomPresentation presentation(_engine, scene, art, inspectFont, useNativeInspectFont);
 
 		auto runRoomExitCommands = [&]() -> Common::Error {
@@ -674,15 +589,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
 			_engine.getScript());
 
-	auto capturePlayerAttackTarget = [&]() {
-		playerState.attackTargetName.clear();
-		playerState.attackTargetClassId = -1;
-
-		const RoomHoverState hoverState = resolveRoomHoverState(
-			_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
-			_mousePos, flow._menuTextConfig, &flow._dialogue);
-		combat.capturePlayerAttackTarget(scene.state, playerState, entityManager, _mousePos, hoverState.npc);
-	};
+	RoomInput input(_engine, flow, session, scene, entityManager, _inventory, _cheats,
+		roomItems, interactionProcessor, interactionCallbacks, presentationState, *art, canExitCloseupToParent);
 
 	if (!_inventory.refresh())
 		return Common::kReadingFailed;
@@ -749,13 +657,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		session.captureCurrentSaveState();
 		if (_inventory.isOpen() && _inventory.refreshIfRuntimeStateChanged())
 			needsRedraw = true;
-		if (!combatLoadoutStatusText.value.empty() &&
-				combatLoadoutStatusHideTick != 0 &&
-				(int32)(Player::getRuntimeClockTicks() - combatLoadoutStatusHideTick) >= 0) {
-			combatLoadoutStatusText = ResolvedText();
-			combatLoadoutStatusHideTick = 0;
-			if (closeInventoryAfterCombatLoadoutStatus) {
-				closeInventoryAfterCombatLoadoutStatus = false;
+		if (!presentationState.combatLoadoutStatusText.value.empty() &&
+				presentationState.combatLoadoutStatusHideTick != 0 &&
+				(int32)(Player::getRuntimeClockTicks() - presentationState.combatLoadoutStatusHideTick) >= 0) {
+			presentationState.combatLoadoutStatusText = ResolvedText();
+			presentationState.combatLoadoutStatusHideTick = 0;
+			if (presentationState.closeInventoryAfterCombatLoadoutStatus) {
+				presentationState.closeInventoryAfterCombatLoadoutStatus = false;
 				if (_inventory.close())
 					needsRedraw = true;
 			}
@@ -772,7 +680,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			const bool roomCarryActive = roomItems.hasCarriedRoomItem();
 			const bool activeCarry = inventorySelectionActive || roomCarryActive;
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
-			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
+			const bool suppressHover = presentationState.showingInspectText || idleState.active || idleState.exiting ||
 				session.isPlayerCombatLocked() ||
 				(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
 			RoomHoverState hoverState = suppressHover
@@ -828,11 +736,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			} else {
 				promptText = hoverState.promptText;
 			}
-			if (!showingInspectText)
-				currentInteractionPromptText = promptText;
+			if (!presentationState.showingInspectText)
+				presentationState.currentInteractionPromptText = promptText;
 			if (Entity *cursor = entityManager ? entityManager->getCursorEntity() : nullptr) {
 				cursor->setAnimationSequence(
-					(showingInspectText || idleState.active || idleState.exiting ||
+					(presentationState.showingInspectText || idleState.active || idleState.exiting ||
 						session.isPlayerCombatLocked() ||
 						_inventory.isOpen() || activeCarry)
 						? 7
@@ -858,13 +766,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				RoomPresentation::blitBitmap(*activeScreen, roomItems.getCarriedRoomItemBitmap(), drawX, drawY);
 			}
 
-			if (showingInspectText) {
-				drawRoomInspectText(*activeScreen, *art, *inspectFont, inspectText, useNativeInspectFont);
-				if (!currentInteractionPromptText.empty())
+			if (presentationState.showingInspectText) {
+				drawRoomInspectText(*activeScreen, *art, *inspectFont, presentationState.inspectText, useNativeInspectFont);
+				if (!presentationState.currentInteractionPromptText.empty())
 					drawRoomPrompt(*activeScreen, *promptFont,
-						currentInteractionPromptText, useNativePromptFont);
-			} else if (!combatLoadoutStatusText.value.empty()) {
-				drawRoomInspectText(*activeScreen, *art, *inspectFont, combatLoadoutStatusText,
+						presentationState.currentInteractionPromptText, useNativePromptFont);
+			} else if (!presentationState.combatLoadoutStatusText.value.empty()) {
+				drawRoomInspectText(*activeScreen, *art, *inspectFont, presentationState.combatLoadoutStatusText,
 					useNativeInspectFont);
 			} else if (!inventoryTooltipText.empty()) {
 				drawInventoryTooltip(*activeScreen, *inventoryTooltipFont, inventoryTooltipText);
@@ -881,624 +789,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (flow.handleSystemEvent(event, result))
-				return result;
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE:
-				if (_inventory.isOpen() && _inventory.hasSelection()) {
-					const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
-					// Native inventory handoff is driven by the latched selected item, not by
-					// a second click after the selection has already been made.
-					if (!inventoryHover && isOutsideNativeInventoryDragCloseBounds(_mousePos)) {
-						if (_inventory.close())
-							needsRedraw = true;
-					}
-				}
-				needsRedraw = true;
-				break;
-			case Common::EVENT_RBUTTONDOWN: {
-				session.notePlayerActivity();
-				if (idleState.active || idleState.exiting) {
-					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
-						needsRedraw = true;
-					break;
-				}
-				if (session.isPlayerCombatLocked())
-					break;
-				pendingRegionName.clear();
-				if (_inventory.isOpen()) {
-					const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
-					debugLogInventoryClick("right", _mousePos, inventoryHover);
-					if (_inventory.hasSelection()) {
-						debugC(1, kDebugInventory,
-							"Harvester: inventory right click cleared active selection='%s'",
-							_inventory.getSelectedItemName().c_str());
-						if (_inventory.clearSelection())
-							needsRedraw = true;
-						break;
-					}
-					if (inventoryHover &&
-							!InventorySystem::isExitObject(inventoryHover->object) &&
-							!InventorySystem::isStatusObject(inventoryHover->object)) {
-						Script *script = _engine.getScript();
-						const int previousLoadout = playerState.combatLoadout >= 0
-							? playerState.combatLoadout
-							: (script ? script->getPlayerCombatLoadout() : 0);
-						bool loadoutChanged = false;
-						if (_inventory.toggleCombatLoadout(inventoryHover->object,
-								playerState.combatLoadout, loadoutChanged)) {
-							debugC(1, kDebugInventory,
-								"Harvester: inventory right click handled as combat toggle object='%s' changed=%d",
-								inventoryHover->object.objectName.c_str(), loadoutChanged);
-							if (loadoutChanged) {
-								if (script) {
-									const Common::String statusMessage = buildCombatLoadoutStatusMessage(
-										*script, inventoryHover->object, previousLoadout,
-										script->getPlayerCombatLoadout());
-									queueCombatLoadoutStatusMessage(statusMessage);
-									closeInventoryAfterCombatLoadoutStatus = true;
-								}
-								session.captureCurrentSaveState();
-								if (playerState.entity) {
-									(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
-										_engine.getScript()->getPlayerCombatLoadout());
-								} else if (_engine.getScript()) {
-									playerState.combatLoadout =
-										_engine.getScript()->getPlayerCombatLoadout();
-								}
-								if (!_inventory.refresh())
-									return Common::kReadingFailed;
-							}
-							needsRedraw = true;
-							break;
-						}
-
-						InventorySecondaryAction secondaryAction;
-						if (_inventory.resolveSecondaryAction(inventoryHover->object, secondaryAction)) {
-							debugC(1, kDebugInventory,
-								"Harvester: inventory right click executing secondary action object='%s' action='%s' closeInventory=%d",
-								inventoryHover->object.objectName.c_str(),
-								secondaryAction.actionTag.c_str(), secondaryAction.closeInventory);
-							if (secondaryAction.closeInventory)
-								(void)_inventory.close();
-
-							InteractionResult interaction;
-							bool didTransition = false;
-							const bool executedActionTag = _engine.getScript()->executeActionTag(
-									secondaryAction.actionTag, interaction, true, scene.state.roomName);
-							debugC(1, kDebugInventory,
-								"Harvester: inventory right click executeActionTag('%s') -> %d music='%s' cutscene='%s' nextRoom='%s'",
-								secondaryAction.actionTag.c_str(), executedActionTag,
-								interaction.musicPath.c_str(), interaction.cutscenePath.c_str(),
-								interaction.nextRoomName.c_str());
-							if (executedActionTag) {
-								Common::Error interactionError =
-									interactionProcessor.handleInteractionResult(
-										interaction, didTransition, Common::String());
-								debugC(1, kDebugInventory,
-									"Harvester: inventory right click action='%s' interaction result error=%d transitioned=%d pendingMainMenu=%d",
-									secondaryAction.actionTag.c_str(), interactionError.getCode(),
-									didTransition, flow.hasPendingMainMenuReturn());
-								if (interactionError.getCode() != Common::kNoError)
-									return interactionError;
-								if (flow.hasPendingMainMenuReturn())
-									return Common::kNoError;
-							}
-
-							if (_inventory.isOpen()) {
-								if (!_inventory.refresh())
-									return Common::kReadingFailed;
-							} else {
-								debugC(1, kDebugInventory,
-									"Harvester: inventory right click skipped overlay refresh after action='%s' transitioned=%d closeInventory=%d",
-									secondaryAction.actionTag.c_str(), didTransition, secondaryAction.closeInventory);
-							}
-							needsRedraw = true;
-							break;
-						}
-						debugC(1, kDebugInventory,
-							"Harvester: inventory right click object='%s' produced no combat toggle or secondary action",
-							inventoryHover->object.objectName.c_str());
-					}
-					debugC(1, kDebugInventory, "Harvester: inventory right click closing overlay");
-					if (_inventory.close())
-						needsRedraw = true;
-					break;
-				}
-				if (_inventory.hasSelection()) {
-					if (_inventory.clearSelection())
-						needsRedraw = true;
-					break;
-				}
-				if (roomItems.hasCarriedRoomItem()) {
-					if (!roomItems.stowCarriedRoomItemToInventory())
-						return Common::kReadingFailed;
-					needsRedraw = true;
-					break;
-				}
-				Script *script = _engine.getScript();
-				if (!showingInspectText && !session.isPlayerCombatLocked() && script && playerState.entity &&
-						script->getPlayerCurrentHitPoints() > 0 && !script->isPlayerControlPaused()) {
-					playerState.hasMoveTarget = false;
-					playerState.turnActive = false;
-					playerState.turnTargetFacing = -1;
-					playerState.turnFirstFrame = -1;
-					playerState.turnLastFrame = -1;
-					playerState.turnEndFrame = -1;
-					playerState.turnPlayBackwards = false;
-					(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
-						script->getPlayerCombatLoadout());
-					capturePlayerAttackTarget();
-					if (Player::startAttackAnimation(scene.state, playerState, _mousePos)) {
-						debugC(1, kDebugCombat,
-							"Harvester: combat player attack start loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s' cursor=(%d,%d)",
-							playerState.combatLoadout,
-							Player::describeCombatLoadout(playerState.combatLoadout),
-							Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout),
-							Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(playerState.combatLoadout)),
-							describeCombatTargetClass(playerState.attackTargetClassId),
-							playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
-						needsRedraw = true;
-					} else {
-						playerState.attackTargetName.clear();
-						playerState.attackTargetClassId = -1;
-					}
-				}
-				break;
-			}
-			case Common::EVENT_LBUTTONUP:
-				lastLeftButtonReleaseTick = Player::getRuntimeClockTicks();
-				if (showingInspectText)
-					inspectCanDismiss = true;
-				break;
-			case Common::EVENT_LBUTTONDOWN: {
-				const uint32 now = Player::getRuntimeClockTicks();
-				const bool isFastExitClick =
-					lastLeftButtonReleaseTick != 0 &&
-					now - lastLeftButtonReleaseTick < kRoomExitFastClickWindowTicks;
-				session.notePlayerActivity();
-				if (idleState.active || idleState.exiting) {
-					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
-						needsRedraw = true;
-					break;
-				}
-
-				if (showingInspectText) {
-					if (inspectCanDismiss) {
-						showingInspectText = false;
-						inspectCanDismiss = false;
-						inspectText = ResolvedText();
-						needsRedraw = true;
-					}
-					break;
-				}
-				if (session.isPlayerCombatLocked())
-					break;
-
-				const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
-				if (_inventory.isOpen()) {
-					const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
-					debugLogInventoryClick("left", _mousePos, inventoryHover);
-					if (inventoryHover) {
-						if (InventorySystem::isExitObject(inventoryHover->object)) {
-							debugC(1, kDebugInventory, "Harvester: inventory left click exit button");
-							const bool clearedSelection = _inventory.clearSelection();
-							const bool closedInventory = _inventory.close();
-							if (clearedSelection || closedInventory)
-								needsRedraw = true;
-							break;
-						}
-						if (InventorySystem::isStatusObject(inventoryHover->object))
-							break;
-
-						if (!_inventory.hasSelection()) {
-							debugC(1, kDebugInventory,
-								"Harvester: inventory left click selecting object='%s'",
-								inventoryHover->object.objectName.c_str());
-							_inventory.selectItem(inventoryHover->object.objectName);
-							_inventory.setPromptText(_inventory.buildSelectedPrompt(
-								Common::String(), flow._menuTextConfig));
-							needsRedraw = true;
-							break;
-						}
-
-						if (!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
-							debugC(1, kDebugInventory,
-								"Harvester: inventory left click using selected='%s' on target='%s'",
-								_inventory.getSelectedItemName().c_str(),
-								inventoryHover->object.objectName.c_str());
-							Common::Error interactionError =
-								roomItems.handleInventoryTargetInteraction(inventoryHover->object, interactionProcessor, needsRedraw);
-							if (interactionError.getCode() != Common::kNoError)
-								return interactionError;
-						}
-						needsRedraw = true;
-						break;
-					}
-
-					if (_inventory.hasSelection()) {
-						if (!inventoryPanelBounds.contains(_mousePos) && _inventory.close())
-							needsRedraw = true;
-						break;
-					}
-					if (!inventoryPanelBounds.contains(_mousePos) && _inventory.close())
-						needsRedraw = true;
-					break;
-				}
-
-				const RoomHoverState hoverState = resolveRoomHoverState(
-					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
-					_mousePos, flow._menuTextConfig, &flow._dialogue);
-				ObjectRecord *selectedRoomTarget = nullptr;
-				if (_inventory.hasSelection() && !hoverState.npc)
-					selectedRoomTarget = roomItems.findSelectedInventoryRoomTarget(_mousePos);
-				if (selectedRoomTarget &&
-						(!hoverState.object ||
-						 !selectedRoomTarget->objectName.equalsIgnoreCase(hoverState.object->objectName))) {
-					debugC(1, kDebugInventory,
-						"Harvester: inventory target remap selected='%s' hovered='%s' remapped='%s'",
-						_inventory.getSelectedItemName().c_str(),
-						hoverState.object ? hoverState.object->objectName.c_str() : "",
-						selectedRoomTarget->objectName.c_str());
-				}
-				RoomHoverState clickHoverState = hoverState;
-				if (selectedRoomTarget)
-					clickHoverState.object = selectedRoomTarget;
-				debugC(1, kDebugRoom,
-					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' npc='%s' region='%s' cursor_sequence=%d prompt='%s'",
-					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
-					clickHoverState.object ? clickHoverState.object->objectName.c_str() : "",
-					clickHoverState.npc ? clickHoverState.npc->npcName.c_str() : "",
-					clickHoverState.region ? clickHoverState.region->regionName.c_str() : "",
-					clickHoverState.cursorSequence, clickHoverState.promptText.c_str());
-					if (roomItems.hasCarriedRoomItem()) {
-					if (clickHoverState.playerEntity && playerState.entity &&
-							clickHoverState.playerEntity == playerState.entity) {
-						if (!roomItems.stowCarriedRoomItemToInventory())
-							return Common::kReadingFailed;
-						needsRedraw = true;
-					}
-					break;
-				}
-				if (_inventory.hasSelection()) {
-					const Common::String selectedItemName = _inventory.getSelectedItemName();
-					if (clickHoverState.npc) {
-						bool didTransition = false;
-						Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
-							clickHoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
-						if (dialogueError.getCode() != Common::kNoError)
-							return dialogueError;
-						if (flow.hasPendingMainMenuReturn())
-							return Common::kNoError;
-						if (_inventory.clearSelection())
-							needsRedraw = true;
-						break;
-					}
-
-					ObjectRecord *roomTarget = clickHoverState.object
-						? findSceneObjectByName(scene.sceneObjects, clickHoverState.object->objectName)
-						: nullptr;
-					if (roomTarget) {
-						Common::Error interactionError =
-							roomItems.handleInventoryTargetInteraction(*roomTarget, interactionProcessor, needsRedraw);
-						if (interactionError.getCode() != Common::kNoError)
-							return interactionError;
-						needsRedraw = true;
-					}
-					break;
-				}
-				if (hoverState.playerEntity) {
-					if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
-						idleState.active || idleState.exiting ||
-						playerState.hasMoveTarget || playerState.turnActive ||
-						playerState.entity->getAnimationRate() != 0) {
-						break;
-					}
-					if (!openInventoryOverlay())
-						return Common::kReadingFailed;
-					needsRedraw = true;
-					break;
-				}
-				if (hoverState.npc) {
-					pendingRegionName.clear();
-					moveLeft = false;
-					moveRight = false;
-					moveUp = false;
-					moveDown = false;
-					playerState.hasMoveTarget = false;
-					playerState.turnActive = false;
-					playerState.turnTargetFacing = -1;
-					bool didTransition = false;
-					Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
-						hoverState.npc->npcName, Common::String(), Common::String(), didTransition);
-					if (dialogueError.getCode() != Common::kNoError)
-						return dialogueError;
-					if (flow.hasPendingMainMenuReturn())
-						return Common::kNoError;
-					needsRedraw = true;
-					break;
-				}
-				// select one target in reverse render order; a selected object prevents exit-region selection.
-				if (hoverState.region && !hoverState.object && playerState.entity) {
-					if (isFastExitClick &&
-						hoverState.region->startEnabled &&
-						roomAllowsImmediateExitClick(scene.state.roomName)) {
-						session.stopPlayerRegionInteraction();
-						Common::Error interactionError =
-							interactionProcessor.runRegionInteraction(*hoverState.region, _inventory, needsRedraw);
-						if (interactionError.getCode() != Common::kNoError)
-							return interactionError;
-						if (flow.hasPendingMainMenuReturn())
-							return Common::kNoError;
-						needsRedraw = true;
-						break;
-					}
-					interactionProcessor.queueRegionInteraction(*hoverState.region, _mousePos);
-					Common::Error interactionError =
-						interactionProcessor.tryActivatePendingRegion(_inventory, needsRedraw);
-					if (interactionError.getCode() != Common::kNoError)
-						return interactionError;
-					needsRedraw = true;
-					break;
-				}
-				ObjectRecord *clickedObject = hoverState.object
-					? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
-					: nullptr;
-				if (!clickedObject) {
-					pendingRegionName.clear();
-					if (hoverState.cursorSequence == 0 && playerState.entity) {
-						if (_engine.isPathfindingDebugEnabled()) {
-							const Common::Rect playerRect = playerState.entity->getScreenRect();
-							debugC(1, kDebugPathfinding,
-								"Harvester: pathfinding click accepted room='%s' mouse=(%d,%d) movement_band=(y=%d..%d,z=%d..%d) player=(%d,%d,z=%.2f) player_rect=(%d,%d)-(%d,%d)",
-								scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
-								scene.state.roomMaxZScreenY, scene.state.roomMinZScreenY,
-								scene.state.roomMinZ, scene.state.roomMaxZ,
-								playerState.centerX, playerState.bottomY, (double)playerState.z,
-								playerRect.left, playerRect.top, playerRect.right, playerRect.bottom);
-						}
-						Player::setMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
-						if (_engine.isPathfindingDebugEnabled()) {
-							debugC(1, kDebugPathfinding,
-								"Harvester: pathfinding click target room='%s' mouse=(%d,%d) target=(%d,%d,z=%.2f)",
-								scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
-								playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
-						}
-						needsRedraw = true;
-					}
-					break;
-				}
-				pendingRegionName.clear();
-				if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
-					clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
-					Common::Error exitError = runRoomExitCommands();
-					if (exitError.getCode() != Common::kNoError)
-						return exitError;
-					// Native closeup exits return through room_setup so the parent on-enter command runs.
-					if (canExitCloseupToParent)
-						flow.requestCloseupParentRestart();
-					return Common::kNoError;
-				}
-
-				ResolvedText resolvedInspectText;
-				const bool hasInspectText =
-					_engine.getScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
-				const bool unlocksAfterInitialExamine =
-					unlocksRoomObjectInteractionAfterInitialExamine(*clickedObject, *_engine.getScript());
-				const bool canShowInspectText =
-					hasInspectText && resolveInspectTextboxBitmap(*art, resolvedInspectText);
-				if (!clickedObject->identShown && unlocksAfterInitialExamine) {
-					clickedObject->identShown = true;
-					_engine.getScript()->markObjectIdentShown(*clickedObject);
-					if (canShowInspectText) {
-						beginInspectText(*clickedObject, resolvedInspectText);
-					} else if (hasInspectText) {
-						debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
-							resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
-					}
-					playerState.hasMoveTarget = false;
-					playerState.turnActive = false;
-					playerState.turnTargetFacing = -1;
-					needsRedraw = true;
-					break;
-				}
-				if (_engine.getScript()->isPickupObject(*clickedObject)) {
-					Common::Error pickupError = roomItems.handleRoomItemPickup(*clickedObject, playerState.entity != nullptr,
-						interactionProcessor, interactionCallbacks, needsRedraw);
-					if (pickupError.getCode() != Common::kNoError)
-						return pickupError;
-					break;
-				}
-
-				InteractionResult interaction;
-				if (!_engine.getScript()->resolveObjectInteraction(
-						*clickedObject, interaction, scene.state.roomName)) {
-					if (canShowInspectText) {
-						beginInspectText(*clickedObject, resolvedInspectText);
-					} else if (hasInspectText) {
-						debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
-							resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
-					}
-					needsRedraw = true;
-					break;
-				}
-
-				bool didTransition = false;
-				Common::Error interactionError =
-					interactionProcessor.handleInteractionResult(
-						interaction, didTransition, Common::String());
-				if (interactionError.getCode() != Common::kNoError)
-					return interactionError;
-				if (flow.hasPendingMainMenuReturn())
-					return Common::kNoError;
-				needsRedraw = true;
-				break;
-			}
-			case Common::EVENT_KEYDOWN:
-				if (showingInspectText) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						showingInspectText = false;
-						inspectCanDismiss = false;
-						inspectText = ResolvedText();
-						needsRedraw = true;
-					}
-					break;
-				}
-
-				session.notePlayerActivity();
-				if (event.kbd.hasFlags(Common::KBD_CTRL))
-					attackModifierHeld = true;
-				if (idleState.active || idleState.exiting) {
-					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
-						needsRedraw = true;
-					break;
-				}
-
-				if (Script *script = _engine.getScript()) {
-					InteractionResult cheatInteraction;
-					CheatActivation cheatActivation;
-					const CheatInputResult cheatInputResult = _cheats.processKey(
-						event.kbd, *script, scene.state.roomName,
-						cheatInteraction, cheatActivation);
-					if (cheatInputResult != kCheatInputIgnored) {
-						if (cheatInputResult == kCheatInputComplete && cheatActivation.activated) {
-							if (cheatActivation.clearInteractionState && _inventory.clearSelection())
-								needsRedraw = true;
-							if (cheatActivation.inventoryChanged && _inventory.isOpen() &&
-									!_inventory.refresh()) {
-								return Common::kReadingFailed;
-							}
-							if (cheatActivation.hasInteraction) {
-								if (_inventory.isOpen())
-									(void)_inventory.close();
-								bool didTransition = false;
-								Common::Error cheatError = interactionProcessor.handleInteractionResult(
-									cheatInteraction, didTransition, Common::String());
-								if (cheatError.getCode() != Common::kNoError)
-									return cheatError;
-								if (flow.hasPendingMainMenuReturn())
-									return Common::kNoError;
-							}
-							needsRedraw = true;
-						}
-						break;
-					}
-				}
-
-				if (_inventory.isOpen()) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						const bool clearedSelection = _inventory.clearSelection();
-						const bool closedInventory = _inventory.close();
-						if (clearedSelection || closedInventory)
-							needsRedraw = true;
-					} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-							event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
-							event.kbd.keycode == Common::KEYCODE_i) {
-						if (_inventory.close())
-							needsRedraw = true;
-					}
-					break;
-				}
-				if (_inventory.hasSelection()) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						if (_inventory.clearSelection())
-							needsRedraw = true;
-						break;
-					}
-					if (event.kbd.keycode == Common::KEYCODE_i) {
-						if (_inventory.clearSelection())
-							needsRedraw = true;
-						if (!openInventoryOverlay())
-							return Common::kReadingFailed;
-						needsRedraw = true;
-						break;
-					}
-				}
-				if (session.isPlayerCombatLocked())
-					break;
-
-				if (event.kbd.keycode == Common::KEYCODE_LCTRL ||
-						event.kbd.keycode == Common::KEYCODE_RCTRL) {
-					attackModifierHeld = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_k && _engine.isCombatDebugEnabled()) {
-					Common::Error debugKillError = combat.killDebugActiveMonster(_engine, scene.state, playerState, entityManager,
-						_mousePos, combatEffects, interactionProcessor, needsRedraw);
-					if (debugKillError.getCode() != Common::kNoError)
-						return debugKillError;
-					break;
-				} else if (event.kbd.keycode == Common::KEYCODE_LEFT)
-					moveLeft = true;
-				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
-					moveRight = true;
-				else if (event.kbd.keycode == Common::KEYCODE_UP)
-					moveUp = true;
-				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
-					moveDown = true;
-				else if (event.kbd.keycode == Common::KEYCODE_i) {
-					if (!openInventoryOverlay())
-						return Common::kReadingFailed;
-					needsRedraw = true;
-					break;
-				} else if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					if (session.cancelPlayerAttackAnimation())
-						needsRedraw = true;
-					if (!roomItems.stowCarriedRoomItemToInventory())
-						return Common::kReadingFailed;
-					moveLeft = false;
-					moveRight = false;
-					moveUp = false;
-					moveDown = false;
-					pendingRegionName.clear();
-					playerState.hasMoveTarget = false;
-					playerState.turnActive = false;
-					playerState.turnTargetFacing = -1;
-					session.captureCurrentSaveState();
-					Graphics::Screen *activeScreen = getActiveScreen();
-					if (!activeScreen)
-						return Common::kNoError;
-					IndexedBitmap roomMenuBackdrop;
-					drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
-					if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
-						return Common::kReadingFailed;
-					Common::Error menuError = flow.runRoomMenuStub(
-						roomMenuBackdrop, scene.palette, scene.targetPaletteBrightness,
-						scene.state.hasEntrance);
-					if (menuError.getCode() != Common::kNoError)
-						return menuError;
-					flow.resetCursorAnimationSequence();
-					session.resetIdleState();
-					needsRedraw = true;
-					break;
-				}
-
-				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-					event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					if (!roomItems.stowCarriedRoomItemToInventory())
-						return Common::kReadingFailed;
-					Common::Error exitError = runRoomExitCommands();
-					if (exitError.getCode() != Common::kNoError)
-						return exitError;
-					return Common::kNoError;
-				}
-				break;
-			case Common::EVENT_KEYUP:
-				if (event.kbd.keycode == Common::KEYCODE_LCTRL ||
-						event.kbd.keycode == Common::KEYCODE_RCTRL)
-					attackModifierHeld = false;
-				else if (event.kbd.keycode == Common::KEYCODE_LEFT)
-					moveLeft = false;
-				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
-					moveRight = false;
-				else if (event.kbd.keycode == Common::KEYCODE_UP)
-					moveUp = false;
-				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
-					moveDown = false;
-				break;
-			default:
-				break;
-			}
+			const RoomInputResult inputResult = input.handleEvent(event, needsRedraw);
+			if (inputResult.returnFromRoom)
+				return inputResult.error;
 		}
 		if (!pendingRoomChange.empty()) {
 			if (!roomItems.stowCarriedRoomItemToInventory())
@@ -1510,10 +803,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		const bool playerAlive = script && script->getPlayerCurrentHitPoints() > 0;
 		const bool playerControlPaused = script && script->isPlayerControlPaused();
 		const bool playerCanAct = playerAlive && !playerControlPaused;
-		if (!playerAlive && (moveLeft || moveRight || moveUp || moveDown ||
+		if (!playerAlive && (inputState.moveLeft || inputState.moveRight || inputState.moveUp || inputState.moveDown ||
 				playerState.hasMoveTarget || playerState.turnActive ||
 				playerState.attackActive || playerState.hitActive)) {
-			attackModifierHeld = false;
+			inputState.attackModifierHeld = false;
 			session.stopPlayerRegionInteraction();
 		}
 
@@ -1574,16 +867,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 		}
 		const bool keyboardAttackRequested =
-			attackModifierHeld && (moveLeft || moveRight || moveUp || moveDown);
+			inputState.attackModifierHeld && (inputState.moveLeft || inputState.moveRight || inputState.moveUp || inputState.moveDown);
 		if (playerCanAct &&
 				!playerState.attackActive && !playerState.hitActive && keyboardAttackRequested &&
 				!idleState.active && !idleState.exiting) {
 			const uint32 now = Player::getRuntimeClockTicks();
-			if (nextKeyboardAttackAllowedTick == 0 || (int32)(now - nextKeyboardAttackAllowedTick) >= 0) {
-				combat.capturePlayerAttackTargetForKeyboard(scene.state, playerState, entityManager, moveLeft, moveRight, moveUp, moveDown);
+			if (inputState.nextKeyboardAttackAllowedTick == 0 || (int32)(now - inputState.nextKeyboardAttackAllowedTick) >= 0) {
+				combat.capturePlayerAttackTargetForKeyboard(scene.state, playerState, entityManager, inputState.moveLeft, inputState.moveRight, inputState.moveUp, inputState.moveDown);
 				if (Player::startKeyboardAttackAnimation(
-						scene.state, playerState, moveLeft, moveRight, moveUp, moveDown)) {
-					nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
+						scene.state, playerState, inputState.moveLeft, inputState.moveRight, inputState.moveUp, inputState.moveDown)) {
+					inputState.nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
 					session.notePlayerActivity();
 					debugC(1, kDebugCombat,
 						"Harvester: combat player keyboard attack start loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s' input=(L=%d R=%d U=%d D=%d) facing=%d",
@@ -1593,7 +886,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(playerState.combatLoadout)),
 						describeCombatTargetClass(playerState.attackTargetClassId),
 						playerState.attackTargetName.c_str(),
-						moveLeft, moveRight, moveUp, moveDown, playerState.facing);
+						inputState.moveLeft, inputState.moveRight, inputState.moveUp, inputState.moveDown, playerState.facing);
 					needsRedraw = true;
 				} else {
 					playerState.attackTargetName.clear();
@@ -1611,7 +904,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				!keyboardAttackRequested && !idleState.active && !idleState.exiting) {
 			const int regionFacing = interactionProcessor.getPendingRegionFacing();
 			if (Player::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
-					playerState, moveLeft, moveRight, moveUp, moveDown)) {
+					playerState, inputState.moveLeft, inputState.moveRight, inputState.moveUp, inputState.moveDown)) {
 				session.notePlayerActivity();
 				needsRedraw = true;
 			} else if (Player::stepMoveTarget(
@@ -1621,8 +914,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				needsRedraw = true;
 			}
 
-			if (!showingInspectText && !keyboardAttackRequested &&
-					!moveLeft && !moveRight && !moveUp && !moveDown &&
+			if (!presentationState.showingInspectText && !keyboardAttackRequested &&
+					!inputState.moveLeft && !inputState.moveRight && !inputState.moveUp && !inputState.moveDown &&
 					!playerState.hasMoveTarget && !playerState.turnActive && !playerState.hitActive &&
 					playerState.entity && playerState.facing >= 0 &&
 					!Player::isIdleAnimationExcludedRoom(scene.state.roomName) &&
diff --git a/engines/harvester/room_input.cpp b/engines/harvester/room_input.cpp
new file mode 100644
index 00000000000..7ca357be3ab
--- /dev/null
+++ b/engines/harvester/room_input.cpp
@@ -0,0 +1,809 @@
+/* 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 "harvester/room_input.h"
+
+#include "common/debug.h"
+#include "graphics/screen.h"
+#include "harvester/art.h"
+#include "harvester/cheats.h"
+#include "harvester/detection.h"
+#include "harvester/flow.h"
+#include "harvester/harvester.h"
+#include "harvester/inventory.h"
+#include "harvester/player.h"
+#include "harvester/room_interaction.h"
+#include "harvester/room_presentation.h"
+#include "harvester/room_session.h"
+#include "harvester/room_support.h"
+
+namespace Harvester {
+
+static const uint32 kRoomExitFastClickWindowTicks = 20;
+static const int kNativeInventoryDragCloseLeft = 0x45;
+static const int kNativeInventoryDragCloseTop = 0x4b;
+static const int kNativeInventoryDragCloseRight = 0x239;
+static const int kNativeInventoryDragCloseBottom = 0x1b4;
+static const uint32 kCombatLoadoutStatusDisplayTicks = 75;
+
+static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
+	return !roomName.equalsIgnoreCase("LAVAPIT") &&
+		!roomName.equalsIgnoreCase("BOWLSNTRY1");
+}
+
+static bool isOutsideNativeInventoryDragCloseBounds(const Common::Point &point) {
+	return point.x < kNativeInventoryDragCloseLeft ||
+		point.x >= kNativeInventoryDragCloseRight ||
+		point.y < kNativeInventoryDragCloseTop ||
+		point.y >= kNativeInventoryDragCloseBottom;
+}
+
+static void debugLogInventoryClick(const char *buttonLabel, const Common::Point &point,
+		const InventoryVisual *inventoryHover) {
+	if (!inventoryHover) {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory %s click at (%d,%d) hit no item", buttonLabel, point.x, point.y);
+		return;
+	}
+
+	debugC(1, kDebugInventory,
+		"Harvester: inventory %s click at (%d,%d) object='%s' sprite='%s' alt='%s' action='%s' owner='%s' text='%s' bounds=(%d,%d)-(%d,%d) exit=%d status=%d",
+		buttonLabel, point.x, point.y, inventoryHover->object.objectName.c_str(),
+		inventoryHover->object.spritePath.c_str(), inventoryHover->object.altSpritePath.c_str(),
+		inventoryHover->object.actionTag.c_str(), inventoryHover->object.currentOwnerOrRoom.c_str(),
+		inventoryHover->object.inventoryTextKey.c_str(), inventoryHover->bounds.left,
+		inventoryHover->bounds.top, inventoryHover->bounds.right, inventoryHover->bounds.bottom,
+		InventorySystem::isExitObject(inventoryHover->object),
+		InventorySystem::isStatusObject(inventoryHover->object));
+}
+
+RoomInput::RoomInput(HarvesterEngine &engine, Flow &flow, RoomSession &session, RoomSceneResources &scene,
+		EntityManager *entityManager, InventorySystem &inventory, CheatSystem &cheats,
+		RoomItemInteractions &roomItems, RoomInteractionProcessor &interactionProcessor,
+		RoomInteractionCallbacks &interactionCallbacks, RoomPresentationState &presentationState,
+		const Art &art, bool canExitCloseupToParent)
+	: _engine(engine), _flow(flow), _session(session), _scene(scene), _entityManager(entityManager),
+	  _inventory(inventory), _cheats(cheats), _roomItems(roomItems), _interactionProcessor(interactionProcessor),
+	  _interactionCallbacks(interactionCallbacks), _presentationState(presentationState), _art(art),
+	  _canExitCloseupToParent(canExitCloseupToParent) {
+}
+
+void RoomInput::beginInspectText(const ObjectRecord &object, const ResolvedText &text) {
+	_presentationState.inspectText = text;
+	_presentationState.showingInspectText = true;
+	_presentationState.inspectCanDismiss = false;
+	debugC(2, kDebugRoom,
+		"Harvester: showing IDENT object='%s' prompt='%s' box='%s' text='%s'",
+		object.objectName.c_str(), _presentationState.currentInteractionPromptText.c_str(),
+		_presentationState.inspectText.boxName.c_str(), _presentationState.inspectText.value.c_str());
+}
+
+void RoomInput::queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw) {
+	if (message.empty()) {
+		const bool hadMessage = !_presentationState.combatLoadoutStatusText.value.empty();
+		_presentationState.combatLoadoutStatusText = ResolvedText();
+		_presentationState.combatLoadoutStatusHideTick = 0;
+		if (hadMessage)
+			needsRedraw = true;
+		return;
+	}
+
+	_presentationState.combatLoadoutStatusText.boxName = "BOX1";
+	_presentationState.combatLoadoutStatusText.value = message;
+	_presentationState.combatLoadoutStatusHideTick =
+		Player::getRuntimeClockTicks() + kCombatLoadoutStatusDisplayTicks;
+	needsRedraw = true;
+}
+
+bool RoomInput::openInventoryOverlay() {
+	RoomPlayerState &playerState = _session.getPlayerState();
+	RoomInputState &inputState = _session.getInputState();
+	Common::String &pendingRegionName = _session.getPendingRegionName();
+
+	inputState.moveLeft = false;
+	inputState.moveRight = false;
+	inputState.moveUp = false;
+	inputState.moveDown = false;
+	pendingRegionName.clear();
+	(void)_session.cancelPlayerAttackAnimation();
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	if (!_roomItems.stowCarriedRoomItemToInventory())
+		return false;
+	return _inventory.open();
+}
+
+void RoomInput::capturePlayerAttackTarget() {
+	RoomPlayerState &playerState = _session.getPlayerState();
+
+	playerState.attackTargetName.clear();
+	playerState.attackTargetClassId = -1;
+
+	const RoomHoverState hoverState = resolveRoomHoverState(
+		_engine, _scene.state, _scene.sceneObjects, _scene.state.roomNpcs, _scene.sceneRegions,
+		_flow._mousePos, _flow._menuTextConfig, &_flow._dialogue);
+	_session.getCombat().capturePlayerAttackTarget(_scene.state, playerState, _entityManager, _flow._mousePos, hoverState.npc);
+}
+
+void RoomInput::handleMouseMove(bool &needsRedraw) {
+	if (_inventory.isOpen() && _inventory.hasSelection()) {
+		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow._mousePos);
+		// Native inventory handoff is driven by the latched selected item, not by
+		// a second click after the selection has already been made.
+		if (!inventoryHover && isOutsideNativeInventoryDragCloseBounds(_flow._mousePos)) {
+			if (_inventory.close())
+				needsRedraw = true;
+		}
+	}
+	needsRedraw = true;
+}
+
+RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
+	RoomPlayerState &playerState = _session.getPlayerState();
+	RoomIdleAnimationState &idleState = _session.getIdleState();
+	Common::String &pendingRegionName = _session.getPendingRegionName();
+
+	_session.notePlayerActivity();
+	if (idleState.active || idleState.exiting) {
+		if (Player::requestIdleAnimationExit(_scene.state, playerState, idleState))
+			needsRedraw = true;
+		return RoomInputResult();
+	}
+	if (_session.isPlayerCombatLocked())
+		return RoomInputResult();
+	pendingRegionName.clear();
+	if (_inventory.isOpen()) {
+		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow._mousePos);
+		debugLogInventoryClick("right", _flow._mousePos, inventoryHover);
+		if (_inventory.hasSelection()) {
+			debugC(1, kDebugInventory,
+				"Harvester: inventory right click cleared active selection='%s'",
+				_inventory.getSelectedItemName().c_str());
+			if (_inventory.clearSelection())
+				needsRedraw = true;
+			return RoomInputResult();
+		}
+		if (inventoryHover &&
+				!InventorySystem::isExitObject(inventoryHover->object) &&
+				!InventorySystem::isStatusObject(inventoryHover->object)) {
+			Script *script = _engine.getScript();
+			const int previousLoadout = playerState.combatLoadout >= 0
+				? playerState.combatLoadout
+				: (script ? script->getPlayerCombatLoadout() : 0);
+			bool loadoutChanged = false;
+			if (_inventory.toggleCombatLoadout(inventoryHover->object,
+					playerState.combatLoadout, loadoutChanged)) {
+				debugC(1, kDebugInventory,
+					"Harvester: inventory right click handled as combat toggle object='%s' changed=%d",
+					inventoryHover->object.objectName.c_str(), loadoutChanged);
+				if (loadoutChanged) {
+					if (script) {
+						const Common::String statusMessage = buildCombatLoadoutStatusMessage(
+							*script, inventoryHover->object, previousLoadout,
+							script->getPlayerCombatLoadout());
+						queueCombatLoadoutStatusMessage(statusMessage, needsRedraw);
+						_presentationState.closeInventoryAfterCombatLoadoutStatus = true;
+					}
+					_session.captureCurrentSaveState();
+					if (playerState.entity) {
+						(void)Player::syncCombatLoadoutVisual(_engine, _scene.state, playerState,
+							_engine.getScript()->getPlayerCombatLoadout());
+					} else if (_engine.getScript()) {
+						playerState.combatLoadout =
+							_engine.getScript()->getPlayerCombatLoadout();
+					}
+					if (!_inventory.refresh())
+						return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				}
+				needsRedraw = true;
+				return RoomInputResult();
+			}
+
+			InventorySecondaryAction secondaryAction;
+			if (_inventory.resolveSecondaryAction(inventoryHover->object, secondaryAction)) {
+				debugC(1, kDebugInventory,
+					"Harvester: inventory right click executing secondary action object='%s' action='%s' closeInventory=%d",
+					inventoryHover->object.objectName.c_str(),
+					secondaryAction.actionTag.c_str(), secondaryAction.closeInventory);
+				if (secondaryAction.closeInventory)
+					(void)_inventory.close();
+
+				InteractionResult interaction;
+				bool didTransition = false;
+				const bool executedActionTag = _engine.getScript()->executeActionTag(
+						secondaryAction.actionTag, interaction, true, _scene.state.roomName);
+				debugC(1, kDebugInventory,
+					"Harvester: inventory right click executeActionTag('%s') -> %d music='%s' cutscene='%s' nextRoom='%s'",
+					secondaryAction.actionTag.c_str(), executedActionTag,
+					interaction.musicPath.c_str(), interaction.cutscenePath.c_str(),
+					interaction.nextRoomName.c_str());
+				if (executedActionTag) {
+					Common::Error interactionError =
+						_interactionProcessor.handleInteractionResult(
+							interaction, didTransition, Common::String());
+					debugC(1, kDebugInventory,
+						"Harvester: inventory right click action='%s' interaction result error=%d transitioned=%d pendingMainMenu=%d",
+						secondaryAction.actionTag.c_str(), interactionError.getCode(),
+						didTransition, _flow.hasPendingMainMenuReturn());
+					if (interactionError.getCode() != Common::kNoError)
+						return RoomInputResult::leaveRoom(interactionError);
+					if (_flow.hasPendingMainMenuReturn())
+						return RoomInputResult::leaveRoom(Common::kNoError);
+				}
+
+				if (_inventory.isOpen()) {
+					if (!_inventory.refresh())
+						return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				} else {
+					debugC(1, kDebugInventory,
+						"Harvester: inventory right click skipped overlay refresh after action='%s' transitioned=%d closeInventory=%d",
+						secondaryAction.actionTag.c_str(), didTransition, secondaryAction.closeInventory);
+				}
+				needsRedraw = true;
+				return RoomInputResult();
+			}
+			debugC(1, kDebugInventory,
+				"Harvester: inventory right click object='%s' produced no combat toggle or secondary action",
+				inventoryHover->object.objectName.c_str());
+		}
+		debugC(1, kDebugInventory, "Harvester: inventory right click closing overlay");
+		if (_inventory.close())
+			needsRedraw = true;
+		return RoomInputResult();
+	}
+	if (_inventory.hasSelection()) {
+		if (_inventory.clearSelection())
+			needsRedraw = true;
+		return RoomInputResult();
+	}
+	if (_roomItems.hasCarriedRoomItem()) {
+		if (!_roomItems.stowCarriedRoomItemToInventory())
+			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+		needsRedraw = true;
+		return RoomInputResult();
+	}
+	Script *script = _engine.getScript();
+	if (!_presentationState.showingInspectText && !_session.isPlayerCombatLocked() && script && playerState.entity &&
+			script->getPlayerCurrentHitPoints() > 0 && !script->isPlayerControlPaused()) {
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		playerState.turnFirstFrame = -1;
+		playerState.turnLastFrame = -1;
+		playerState.turnEndFrame = -1;
+		playerState.turnPlayBackwards = false;
+		(void)Player::syncCombatLoadoutVisual(_engine, _scene.state, playerState,
+			script->getPlayerCombatLoadout());
+		capturePlayerAttackTarget();
+		if (Player::startAttackAnimation(_scene.state, playerState, _flow._mousePos)) {
+			debugC(1, kDebugCombat,
+				"Harvester: combat player attack start loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s' cursor=(%d,%d)",
+				playerState.combatLoadout,
+				Player::describeCombatLoadout(playerState.combatLoadout),
+				Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout),
+				Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(playerState.combatLoadout)),
+				describeCombatTargetClass(playerState.attackTargetClassId),
+				playerState.attackTargetName.c_str(), _flow._mousePos.x, _flow._mousePos.y);
+			needsRedraw = true;
+		} else {
+			playerState.attackTargetName.clear();
+			playerState.attackTargetClassId = -1;
+		}
+	}
+	return RoomInputResult();
+}
+
+void RoomInput::handleLeftButtonUp() {
+	RoomInputState &inputState = _session.getInputState();
+
+	inputState.lastLeftButtonReleaseTick = Player::getRuntimeClockTicks();
+	if (_presentationState.showingInspectText)
+		_presentationState.inspectCanDismiss = true;
+}
+
+RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
+	RoomPlayerState &playerState = _session.getPlayerState();
+	RoomIdleAnimationState &idleState = _session.getIdleState();
+	RoomInputState &inputState = _session.getInputState();
+	Common::String &pendingRegionName = _session.getPendingRegionName();
+
+	const uint32 now = Player::getRuntimeClockTicks();
+	const bool isFastExitClick =
+		inputState.lastLeftButtonReleaseTick != 0 &&
+		now - inputState.lastLeftButtonReleaseTick < kRoomExitFastClickWindowTicks;
+	_session.notePlayerActivity();
+	if (idleState.active || idleState.exiting) {
+		if (Player::requestIdleAnimationExit(_scene.state, playerState, idleState))
+			needsRedraw = true;
+		return RoomInputResult();
+	}
+
+	if (_presentationState.showingInspectText) {
+		if (_presentationState.inspectCanDismiss) {
+			_presentationState.showingInspectText = false;
+			_presentationState.inspectCanDismiss = false;
+			_presentationState.inspectText = ResolvedText();
+			needsRedraw = true;
+		}
+		return RoomInputResult();
+	}
+	if (_session.isPlayerCombatLocked())
+		return RoomInputResult();
+
+	const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
+	if (_inventory.isOpen()) {
+		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow._mousePos);
+		debugLogInventoryClick("left", _flow._mousePos, inventoryHover);
+		if (inventoryHover) {
+			if (InventorySystem::isExitObject(inventoryHover->object)) {
+				debugC(1, kDebugInventory, "Harvester: inventory left click exit button");
+				const bool clearedSelection = _inventory.clearSelection();
+				const bool closedInventory = _inventory.close();
+				if (clearedSelection || closedInventory)
+					needsRedraw = true;
+				return RoomInputResult();
+			}
+			if (InventorySystem::isStatusObject(inventoryHover->object))
+				return RoomInputResult();
+
+			if (!_inventory.hasSelection()) {
+				debugC(1, kDebugInventory,
+					"Harvester: inventory left click selecting object='%s'",
+					inventoryHover->object.objectName.c_str());
+				_inventory.selectItem(inventoryHover->object.objectName);
+				_inventory.setPromptText(_inventory.buildSelectedPrompt(
+					Common::String(), _flow._menuTextConfig));
+				needsRedraw = true;
+				return RoomInputResult();
+			}
+
+			if (!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
+				debugC(1, kDebugInventory,
+					"Harvester: inventory left click using selected='%s' on target='%s'",
+					_inventory.getSelectedItemName().c_str(),
+					inventoryHover->object.objectName.c_str());
+				Common::Error interactionError =
+					_roomItems.handleInventoryTargetInteraction(inventoryHover->object, _interactionProcessor, needsRedraw);
+				if (interactionError.getCode() != Common::kNoError)
+					return RoomInputResult::leaveRoom(interactionError);
+			}
+			needsRedraw = true;
+			return RoomInputResult();
+		}
+
+		if (_inventory.hasSelection()) {
+			if (!inventoryPanelBounds.contains(_flow._mousePos) && _inventory.close())
+				needsRedraw = true;
+			return RoomInputResult();
+		}
+		if (!inventoryPanelBounds.contains(_flow._mousePos) && _inventory.close())
+			needsRedraw = true;
+		return RoomInputResult();
+	}
+
+	const RoomHoverState hoverState = resolveRoomHoverState(
+		_engine, _scene.state, _scene.sceneObjects, _scene.state.roomNpcs, _scene.sceneRegions,
+		_flow._mousePos, _flow._menuTextConfig, &_flow._dialogue);
+	ObjectRecord *selectedRoomTarget = nullptr;
+	if (_inventory.hasSelection() && !hoverState.npc)
+		selectedRoomTarget = _roomItems.findSelectedInventoryRoomTarget(_flow._mousePos);
+	if (selectedRoomTarget &&
+			(!hoverState.object ||
+			 !selectedRoomTarget->objectName.equalsIgnoreCase(hoverState.object->objectName))) {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory target remap selected='%s' hovered='%s' remapped='%s'",
+			_inventory.getSelectedItemName().c_str(),
+			hoverState.object ? hoverState.object->objectName.c_str() : "",
+			selectedRoomTarget->objectName.c_str());
+	}
+	RoomHoverState clickHoverState = hoverState;
+	if (selectedRoomTarget)
+		clickHoverState.object = selectedRoomTarget;
+	debugC(1, kDebugRoom,
+		"Harvester: room click room='%s' mouse=(%d,%d) object='%s' npc='%s' region='%s' cursor_sequence=%d prompt='%s'",
+		_scene.state.roomName.c_str(), _flow._mousePos.x, _flow._mousePos.y,
+		clickHoverState.object ? clickHoverState.object->objectName.c_str() : "",
+		clickHoverState.npc ? clickHoverState.npc->npcName.c_str() : "",
+		clickHoverState.region ? clickHoverState.region->regionName.c_str() : "",
+		clickHoverState.cursorSequence, clickHoverState.promptText.c_str());
+	if (_roomItems.hasCarriedRoomItem()) {
+		if (clickHoverState.playerEntity && playerState.entity &&
+				clickHoverState.playerEntity == playerState.entity) {
+			if (!_roomItems.stowCarriedRoomItemToInventory())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			needsRedraw = true;
+		}
+		return RoomInputResult();
+	}
+	if (_inventory.hasSelection()) {
+		const Common::String selectedItemName = _inventory.getSelectedItemName();
+		if (clickHoverState.npc) {
+			bool didTransition = false;
+			Common::Error dialogueError = _interactionProcessor.runScriptedDialogue(
+				clickHoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
+			if (dialogueError.getCode() != Common::kNoError)
+				return RoomInputResult::leaveRoom(dialogueError);
+			if (_flow.hasPendingMainMenuReturn())
+				return RoomInputResult::leaveRoom(Common::kNoError);
+			if (_inventory.clearSelection())
+				needsRedraw = true;
+			return RoomInputResult();
+		}
+
+		ObjectRecord *roomTarget = clickHoverState.object
+			? findSceneObjectByName(_scene.sceneObjects, clickHoverState.object->objectName)
+			: nullptr;
+		if (roomTarget) {
+			Common::Error interactionError =
+				_roomItems.handleInventoryTargetInteraction(*roomTarget, _interactionProcessor, needsRedraw);
+			if (interactionError.getCode() != Common::kNoError)
+				return RoomInputResult::leaveRoom(interactionError);
+			needsRedraw = true;
+		}
+		return RoomInputResult();
+	}
+	if (hoverState.playerEntity) {
+		if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
+			idleState.active || idleState.exiting ||
+			playerState.hasMoveTarget || playerState.turnActive ||
+			playerState.entity->getAnimationRate() != 0) {
+			return RoomInputResult();
+		}
+		if (!openInventoryOverlay())
+			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+		needsRedraw = true;
+		return RoomInputResult();
+	}
+	if (hoverState.npc) {
+		pendingRegionName.clear();
+		inputState.moveLeft = false;
+		inputState.moveRight = false;
+		inputState.moveUp = false;
+		inputState.moveDown = false;
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		bool didTransition = false;
+		Common::Error dialogueError = _interactionProcessor.runScriptedDialogue(
+			hoverState.npc->npcName, Common::String(), Common::String(), didTransition);
+		if (dialogueError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(dialogueError);
+		if (_flow.hasPendingMainMenuReturn())
+			return RoomInputResult::leaveRoom(Common::kNoError);
+		needsRedraw = true;
+		return RoomInputResult();
+	}
+	// select one target in reverse render order; a selected object prevents exit-region selection.
+	if (hoverState.region && !hoverState.object && playerState.entity) {
+		if (isFastExitClick &&
+			hoverState.region->startEnabled &&
+			roomAllowsImmediateExitClick(_scene.state.roomName)) {
+			_session.stopPlayerRegionInteraction();
+			Common::Error interactionError =
+				_interactionProcessor.runRegionInteraction(*hoverState.region, _inventory, needsRedraw);
+			if (interactionError.getCode() != Common::kNoError)
+				return RoomInputResult::leaveRoom(interactionError);
+			if (_flow.hasPendingMainMenuReturn())
+				return RoomInputResult::leaveRoom(Common::kNoError);
+			needsRedraw = true;
+			return RoomInputResult();
+		}
+		_interactionProcessor.queueRegionInteraction(*hoverState.region, _flow._mousePos);
+		Common::Error interactionError =
+			_interactionProcessor.tryActivatePendingRegion(_inventory, needsRedraw);
+		if (interactionError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(interactionError);
+		needsRedraw = true;
+		return RoomInputResult();
+	}
+	ObjectRecord *clickedObject = hoverState.object
+		? findSceneObjectByName(_scene.sceneObjects, hoverState.object->objectName)
+		: nullptr;
+	if (!clickedObject) {
+		pendingRegionName.clear();
+		if (hoverState.cursorSequence == 0 && playerState.entity) {
+			if (_engine.isPathfindingDebugEnabled()) {
+				const Common::Rect playerRect = playerState.entity->getScreenRect();
+				debugC(1, kDebugPathfinding,
+					"Harvester: pathfinding click accepted room='%s' mouse=(%d,%d) movement_band=(y=%d..%d,z=%d..%d) player=(%d,%d,z=%.2f) player_rect=(%d,%d)-(%d,%d)",
+					_scene.state.roomName.c_str(), _flow._mousePos.x, _flow._mousePos.y,
+					_scene.state.roomMaxZScreenY, _scene.state.roomMinZScreenY,
+					_scene.state.roomMinZ, _scene.state.roomMaxZ,
+					playerState.centerX, playerState.bottomY, (double)playerState.z,
+					playerRect.left, playerRect.top, playerRect.right, playerRect.bottom);
+			}
+			Player::setMoveTargetFromScreenPoint(_scene.state, playerState, _flow._mousePos.x, _flow._mousePos.y);
+			if (_engine.isPathfindingDebugEnabled()) {
+				debugC(1, kDebugPathfinding,
+					"Harvester: pathfinding click target room='%s' mouse=(%d,%d) target=(%d,%d,z=%.2f)",
+					_scene.state.roomName.c_str(), _flow._mousePos.x, _flow._mousePos.y,
+					playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
+			}
+			needsRedraw = true;
+		}
+		return RoomInputResult();
+	}
+	pendingRegionName.clear();
+	if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
+		clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
+		Common::Error exitError = _interactionCallbacks.runRoomExitCommands();
+		if (exitError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(exitError);
+		// Native closeup exits return through room_setup so the parent on-enter command runs.
+		if (_canExitCloseupToParent)
+			_flow.requestCloseupParentRestart();
+		return RoomInputResult::leaveRoom(Common::kNoError);
+	}
+
+	ResolvedText resolvedInspectText;
+	const bool hasInspectText =
+		_engine.getScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
+	const bool unlocksAfterInitialExamine =
+		unlocksRoomObjectInteractionAfterInitialExamine(*clickedObject, *_engine.getScript());
+	const bool canShowInspectText =
+		hasInspectText && resolveInspectTextboxBitmap(_art, resolvedInspectText);
+	if (!clickedObject->identShown && unlocksAfterInitialExamine) {
+		clickedObject->identShown = true;
+		_engine.getScript()->markObjectIdentShown(*clickedObject);
+		if (canShowInspectText) {
+			beginInspectText(*clickedObject, resolvedInspectText);
+		} else if (hasInspectText) {
+			debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
+				resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
+		}
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		needsRedraw = true;
+		return RoomInputResult();
+	}
+	if (_engine.getScript()->isPickupObject(*clickedObject)) {
+		Common::Error pickupError = _roomItems.handleRoomItemPickup(*clickedObject, playerState.entity != nullptr,
+			_interactionProcessor, _interactionCallbacks, needsRedraw);
+		if (pickupError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(pickupError);
+		return RoomInputResult();
+	}
+
+	InteractionResult interaction;
+	if (!_engine.getScript()->resolveObjectInteraction(
+			*clickedObject, interaction, _scene.state.roomName)) {
+		if (canShowInspectText) {
+			beginInspectText(*clickedObject, resolvedInspectText);
+		} else if (hasInspectText) {
+			debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
+				resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
+		}
+		needsRedraw = true;
+		return RoomInputResult();
+	}
+
+	bool didTransition = false;
+	Common::Error interactionError =
+		_interactionProcessor.handleInteractionResult(
+			interaction, didTransition, Common::String());
+	if (interactionError.getCode() != Common::kNoError)
+		return RoomInputResult::leaveRoom(interactionError);
+	if (_flow.hasPendingMainMenuReturn())
+		return RoomInputResult::leaveRoom(Common::kNoError);
+	needsRedraw = true;
+	return RoomInputResult();
+}
+
+RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &needsRedraw) {
+	RoomPlayerState &playerState = _session.getPlayerState();
+	RoomIdleAnimationState &idleState = _session.getIdleState();
+	RoomInputState &inputState = _session.getInputState();
+	Common::String &pendingRegionName = _session.getPendingRegionName();
+
+	if (_presentationState.showingInspectText) {
+		if (key.keycode == Common::KEYCODE_ESCAPE) {
+			_presentationState.showingInspectText = false;
+			_presentationState.inspectCanDismiss = false;
+			_presentationState.inspectText = ResolvedText();
+			needsRedraw = true;
+		}
+		return RoomInputResult();
+	}
+
+	_session.notePlayerActivity();
+	if (key.hasFlags(Common::KBD_CTRL))
+		inputState.attackModifierHeld = true;
+	if (idleState.active || idleState.exiting) {
+		if (Player::requestIdleAnimationExit(_scene.state, playerState, idleState))
+			needsRedraw = true;
+		return RoomInputResult();
+	}
+
+	if (Script *script = _engine.getScript()) {
+		InteractionResult cheatInteraction;
+		CheatActivation cheatActivation;
+		const CheatInputResult cheatInputResult = _cheats.processKey(
+			key, *script, _scene.state.roomName,
+			cheatInteraction, cheatActivation);
+		if (cheatInputResult != kCheatInputIgnored) {
+			if (cheatInputResult == kCheatInputComplete && cheatActivation.activated) {
+				if (cheatActivation.clearInteractionState && _inventory.clearSelection())
+					needsRedraw = true;
+				if (cheatActivation.inventoryChanged && _inventory.isOpen() &&
+						!_inventory.refresh()) {
+					return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				}
+				if (cheatActivation.hasInteraction) {
+					if (_inventory.isOpen())
+						(void)_inventory.close();
+					bool didTransition = false;
+					Common::Error cheatError = _interactionProcessor.handleInteractionResult(
+						cheatInteraction, didTransition, Common::String());
+					if (cheatError.getCode() != Common::kNoError)
+						return RoomInputResult::leaveRoom(cheatError);
+					if (_flow.hasPendingMainMenuReturn())
+						return RoomInputResult::leaveRoom(Common::kNoError);
+				}
+				needsRedraw = true;
+			}
+			return RoomInputResult();
+		}
+	}
+
+	if (_inventory.isOpen()) {
+		if (key.keycode == Common::KEYCODE_ESCAPE) {
+			const bool clearedSelection = _inventory.clearSelection();
+			const bool closedInventory = _inventory.close();
+			if (clearedSelection || closedInventory)
+				needsRedraw = true;
+		} else if (key.keycode == Common::KEYCODE_RETURN ||
+				key.keycode == Common::KEYCODE_KP_ENTER ||
+				key.keycode == Common::KEYCODE_i) {
+			if (_inventory.close())
+				needsRedraw = true;
+		}
+		return RoomInputResult();
+	}
+	if (_inventory.hasSelection()) {
+		if (key.keycode == Common::KEYCODE_ESCAPE) {
+			if (_inventory.clearSelection())
+				needsRedraw = true;
+			return RoomInputResult();
+		}
+		if (key.keycode == Common::KEYCODE_i) {
+			if (_inventory.clearSelection())
+				needsRedraw = true;
+			if (!openInventoryOverlay())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			needsRedraw = true;
+			return RoomInputResult();
+		}
+	}
+	if (_session.isPlayerCombatLocked())
+		return RoomInputResult();
+
+	if (key.keycode == Common::KEYCODE_LCTRL ||
+			key.keycode == Common::KEYCODE_RCTRL) {
+		inputState.attackModifierHeld = true;
+	} else if (key.keycode == Common::KEYCODE_k && _engine.isCombatDebugEnabled()) {
+		Common::Error debugKillError = _session.getCombat().killDebugActiveMonster(_engine, _scene.state, playerState, _entityManager,
+			_flow._mousePos, _session.getCombatEffects(), _interactionProcessor, needsRedraw);
+		if (debugKillError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(debugKillError);
+		return RoomInputResult();
+	} else if (key.keycode == Common::KEYCODE_LEFT)
+		inputState.moveLeft = true;
+	else if (key.keycode == Common::KEYCODE_RIGHT)
+		inputState.moveRight = true;
+	else if (key.keycode == Common::KEYCODE_UP)
+		inputState.moveUp = true;
+	else if (key.keycode == Common::KEYCODE_DOWN)
+		inputState.moveDown = true;
+	else if (key.keycode == Common::KEYCODE_i) {
+		if (!openInventoryOverlay())
+			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+		needsRedraw = true;
+		return RoomInputResult();
+	} else if (key.keycode == Common::KEYCODE_ESCAPE) {
+		if (_session.cancelPlayerAttackAnimation())
+			needsRedraw = true;
+		if (!_roomItems.stowCarriedRoomItemToInventory())
+			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+		inputState.moveLeft = false;
+		inputState.moveRight = false;
+		inputState.moveUp = false;
+		inputState.moveDown = false;
+		pendingRegionName.clear();
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		_session.captureCurrentSaveState();
+		Graphics::Screen *activeScreen = _engine.getScreen();
+		if (!activeScreen)
+			return RoomInputResult::leaveRoom(Common::kNoError);
+		IndexedBitmap roomMenuBackdrop;
+		drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
+		if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
+			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+		Common::Error menuError = _flow.runRoomMenuStub(
+			roomMenuBackdrop, _scene.palette, _scene.targetPaletteBrightness,
+			_scene.state.hasEntrance);
+		if (menuError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(menuError);
+		_flow.resetCursorAnimationSequence();
+		_session.resetIdleState();
+		needsRedraw = true;
+		return RoomInputResult();
+	}
+
+	if (key.keycode == Common::KEYCODE_RETURN ||
+		key.keycode == Common::KEYCODE_KP_ENTER) {
+		if (!_roomItems.stowCarriedRoomItemToInventory())
+			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+		Common::Error exitError = _interactionCallbacks.runRoomExitCommands();
+		if (exitError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(exitError);
+		return RoomInputResult::leaveRoom(Common::kNoError);
+	}
+	return RoomInputResult();
+}
+
+void RoomInput::handleKeyUp(const Common::KeyState &key) {
+	RoomInputState &inputState = _session.getInputState();
+
+	if (key.keycode == Common::KEYCODE_LCTRL ||
+			key.keycode == Common::KEYCODE_RCTRL)
+		inputState.attackModifierHeld = false;
+	else if (key.keycode == Common::KEYCODE_LEFT)
+		inputState.moveLeft = false;
+	else if (key.keycode == Common::KEYCODE_RIGHT)
+		inputState.moveRight = false;
+	else if (key.keycode == Common::KEYCODE_UP)
+		inputState.moveUp = false;
+	else if (key.keycode == Common::KEYCODE_DOWN)
+		inputState.moveDown = false;
+}
+
+RoomInputResult RoomInput::handleEvent(const Common::Event &event, bool &needsRedraw) {
+	Common::Error result = Common::kNoError;
+	if (_flow.handleSystemEvent(event, result))
+		return RoomInputResult::leaveRoom(result);
+
+	switch (event.type) {
+	case Common::EVENT_MOUSEMOVE:
+		handleMouseMove(needsRedraw);
+		break;
+	case Common::EVENT_RBUTTONDOWN:
+		return handleRightButtonDown(needsRedraw);
+	case Common::EVENT_LBUTTONUP:
+		handleLeftButtonUp();
+		break;
+	case Common::EVENT_LBUTTONDOWN:
+		return handleLeftButtonDown(needsRedraw);
+	case Common::EVENT_KEYDOWN:
+		return handleKeyDown(event.kbd, needsRedraw);
+	case Common::EVENT_KEYUP:
+		handleKeyUp(event.kbd);
+		break;
+	default:
+		break;
+	}
+	return RoomInputResult();
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/room_input.h b/engines/harvester/room_input.h
new file mode 100644
index 00000000000..0cdbe9653a8
--- /dev/null
+++ b/engines/harvester/room_input.h
@@ -0,0 +1,107 @@
+/* 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 HARVESTER_ROOM_INPUT_H
+#define HARVESTER_ROOM_INPUT_H
+
+#include "common/error.h"
+#include "common/events.h"
+
+namespace Harvester {
+
+class Art;
+class CheatSystem;
+class EntityManager;
+class Flow;
+class HarvesterEngine;
+class InventorySystem;
+class RoomInteractionCallbacks;
+class RoomInteractionProcessor;
+class RoomItemInteractions;
+class RoomSession;
+struct ObjectRecord;
+struct ResolvedText;
+struct RoomPresentationState;
+struct RoomSceneResources;
+
+struct RoomInputState {
+	bool moveLeft = false;
+	bool moveRight = false;
+	bool moveUp = false;
+	bool moveDown = false;
+	bool attackModifierHeld = false;
+	uint32 lastLeftButtonReleaseTick = 0;
+	uint32 nextKeyboardAttackAllowedTick = 0;
+};
+
+// Enter and closeup exits return from runRoomLoop even when the error is kNoError.
+struct RoomInputResult {
+	Common::Error error;
+	bool returnFromRoom = false;
+
+	static RoomInputResult leaveRoom(const Common::Error &error) {
+		RoomInputResult result;
+		result.error = error;
+		result.returnFromRoom = true;
+		return result;
+	}
+};
+
+class RoomInput {
+public:
+	RoomInput(HarvesterEngine &engine, Flow &flow, RoomSession &session, RoomSceneResources &scene,
+		EntityManager *entityManager, InventorySystem &inventory, CheatSystem &cheats,
+		RoomItemInteractions &roomItems, RoomInteractionProcessor &interactionProcessor,
+		RoomInteractionCallbacks &interactionCallbacks, RoomPresentationState &presentationState,
+		const Art &art, bool canExitCloseupToParent);
+
+	RoomInputResult handleEvent(const Common::Event &event, bool &needsRedraw);
+
+private:
+	void handleMouseMove(bool &needsRedraw);
+	RoomInputResult handleRightButtonDown(bool &needsRedraw);
+	void handleLeftButtonUp();
+	RoomInputResult handleLeftButtonDown(bool &needsRedraw);
+	RoomInputResult handleKeyDown(const Common::KeyState &key, bool &needsRedraw);
+	void handleKeyUp(const Common::KeyState &key);
+	void beginInspectText(const ObjectRecord &object, const ResolvedText &text);
+	void queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw);
+	bool openInventoryOverlay();
+	void capturePlayerAttackTarget();
+
+	HarvesterEngine &_engine;
+	Flow &_flow;
+	RoomSession &_session;
+	RoomSceneResources &_scene;
+	EntityManager *_entityManager;
+	InventorySystem &_inventory;
+	CheatSystem &_cheats;
+	RoomItemInteractions &_roomItems;
+	RoomInteractionProcessor &_interactionProcessor;
+	RoomInteractionCallbacks &_interactionCallbacks;
+	RoomPresentationState &_presentationState;
+	const Art &_art;
+	bool _canExitCloseupToParent;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_ROOM_INPUT_H
diff --git a/engines/harvester/room_presentation.h b/engines/harvester/room_presentation.h
index 1c97dbeabf2..10aa4443041 100644
--- a/engines/harvester/room_presentation.h
+++ b/engines/harvester/room_presentation.h
@@ -39,6 +39,16 @@ struct IndexedBitmap;
 struct ResolvedText;
 struct RoomSceneResources;
 
+struct RoomPresentationState {
+	ResolvedText inspectText;
+	Common::String currentInteractionPromptText;
+	bool showingInspectText = false;
+	bool inspectCanDismiss = false;
+	ResolvedText combatLoadoutStatusText;
+	uint32 combatLoadoutStatusHideTick = 0;
+	bool closeInventoryAfterCombatLoadoutStatus = false;
+};
+
 class RoomPresentation {
 public:
 	RoomPresentation(HarvesterEngine &engine, RoomSceneResources &scene,
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index fa83ff4c9c0..dbbff282ca9 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -76,10 +76,10 @@ bool RoomSession::cancelPlayerAttackAnimation() {
 }
 
 void RoomSession::stopPlayerRegionInteraction() {
-	_movementInput.moveLeft = false;
-	_movementInput.moveRight = false;
-	_movementInput.moveUp = false;
-	_movementInput.moveDown = false;
+	_inputState.moveLeft = false;
+	_inputState.moveRight = false;
+	_inputState.moveUp = false;
+	_inputState.moveDown = false;
 	_pendingRegionName.clear();
 	(void)cancelPlayerAttackAnimation();
 	_playerState.hasMoveTarget = false;
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 1a132068bbb..3642623f263 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -23,6 +23,7 @@
 #define HARVESTER_ROOM_SESSION_H
 
 #include "harvester/room_combat.h"
+#include "harvester/room_input.h"
 #include "harvester/room_scene_runtime.h"
 #include "harvester/room_support.h"
 #include "harvester/runtime_entity.h"
@@ -33,13 +34,6 @@ class EntityManager;
 class Flow;
 class InventorySystem;
 
-struct RoomMovementInputState {
-	bool moveLeft = false;
-	bool moveRight = false;
-	bool moveUp = false;
-	bool moveDown = false;
-};
-
 // Scoped to one runRoomLoop iteration, including time spent in nested closeups.
 // Entity pointers remain owned by EntityManager.
 class RoomSession {
@@ -52,7 +46,7 @@ public:
 	RoomSceneRuntime &getSceneRuntime() { return _sceneRuntime; }
 	RoomCombatEffects &getCombatEffects() { return _combatEffects; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
-	RoomMovementInputState &getMovementInput() { return _movementInput; }
+	RoomInputState &getInputState() { return _inputState; }
 	Common::String &getPendingRegionName() { return _pendingRegionName; }
 
 	void resetIdleState();
@@ -76,7 +70,7 @@ private:
 	RoomCombat _combat;
 	RoomCombatEffects _combatEffects;
 	RoomIdleAnimationState _idleState;
-	RoomMovementInputState _movementInput;
+	RoomInputState _inputState;
 	Common::String _pendingRegionName;
 };
 


Commit: 3238191c58f0321de1a28572ef43e612e72dbf8b
    https://github.com/scummvm/scummvm/commit/3238191c58f0321de1a28572ef43e612e72dbf8b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Refactor session and input classes for improved interaction handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/room.h
    engines/harvester/room_input.cpp
    engines/harvester/room_input.h
    engines/harvester/room_presentation.h
    engines/harvester/room_session.cpp
    engines/harvester/room_session.h


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index ae98fb4a091..2c7ae9ef5b1 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1357,7 +1357,7 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const RoomSceneResour
 Flow::Flow(HarvesterEngine &engine)
 	: _engine(engine), _mousePos(320, 200), _dialogue(engine, _mousePos), _inventory(engine),
 	  _menu(engine, _mousePos, _menuItems), _cheats(engine),
-	  _room(engine, _mousePos, _inventory, _cheats) {
+	  _room(engine, _inventory, _cheats) {
 }
 
 bool Flow::load() {
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index f8dd76ee4c6..e67ef6539b2 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -50,18 +50,6 @@
 
 namespace Harvester {
 
-static const char *const kPlayerInventoryLabel = "your inventory";
-
-static const int kNativeInventoryTooltipX = 0xbc;
-static const int kNativeInventoryTooltipY = 0x19e;
-static const int kNativeInventoryWeekdayX = 0x1e0;
-static const int kNativeInventoryWeekdayY = 0x18c;
-static const byte kNativeInventoryTooltipColor = 0xf4;
-
-static const int kNativeCombatResourceIconsX = 10;
-static const int kNativeCombatResourceIconsY = 30;
-static const int kNativeCombatResourceIconSpacing = 15;
-
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const Text *text = engine.getText();
 	if (!text || !fontName)
@@ -75,71 +63,9 @@ static const CftFontResource *findStartupFontByName(const HarvesterEngine &engin
 	return nullptr;
 }
 
-static Common::String resolveStartupNpcLabel(const NpcRecord &npc) {
-	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
-	for (uint i = 0; i < label.size(); ++i) {
-		if (label[i] == '_')
-			label.setChar(' ', i);
-	}
-
-	return label;
-}
-
-static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
-		const Common::String &promptText, bool useNativeFont) {
-	if (promptText.empty())
-		return;
-
-	if (useNativeFont) {
-		font.drawString(&screen, promptText, 0, 462, 640, 0, Graphics::kTextAlignCenter);
-		return;
-	}
-
-	font.drawString(&screen, promptText, 1, 463, 640, 0, Graphics::kTextAlignCenter);
-	font.drawString(&screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
-}
-
-static void drawInventoryTooltip(Graphics::Screen &screen, const Graphics::Font &font,
-		const Common::String &tooltipText) {
-	if (tooltipText.empty())
-		return;
-
-	font.drawString(&screen, tooltipText, kNativeInventoryTooltipX, kNativeInventoryTooltipY,
-		font.getStringWidth(tooltipText), kNativeInventoryTooltipColor);
-}
-
-static void drawInventoryWeekday(Graphics::Screen &screen, const Graphics::Font &font,
-		const Common::String &weekdayText) {
-	if (weekdayText.empty())
-		return;
-
-	font.drawString(&screen, weekdayText, kNativeInventoryWeekdayX, kNativeInventoryWeekdayY,
-		font.getStringWidth(weekdayText), kNativeInventoryTooltipColor);
-}
-
-static void drawCombatLoadoutResourceIcons(Graphics::Screen &screen, const Art &art,
-		const Script &script, int loadout) {
-	CombatLoadoutHudInfo info;
-	if (!resolveCombatLoadoutHudInfo(loadout, info))
-		return;
-
-	const Common::Array<IndexedBitmap> &ammoIcons = art.getAmmoIcons();
-	if (info.iconIndex >= ammoIcons.size())
-		return;
-
-	const IndexedBitmap &icon = ammoIcons[info.iconIndex];
-	if (!icon.isValid())
-		return;
-
-	const int iconCount = CLIP<int>(script.getPlayerCombatResourceCount(loadout), 0, info.maxVisibleCount);
-	for (int i = 0; i < iconCount; ++i)
-		RoomPresentation::blitBitmap(screen, icon, kNativeCombatResourceIconsX + i * kNativeCombatResourceIconSpacing,
-			kNativeCombatResourceIconsY);
-}
-
-RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
+RoomSystem::RoomSystem(HarvesterEngine &engine,
 		InventorySystem &inventory, CheatSystem &cheats)
-	: _engine(engine), _mousePos(mousePos), _inventory(inventory), _cheats(cheats) {
+	: _engine(engine), _inventory(inventory), _cheats(cheats) {
 }
 
 Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetName,
@@ -225,10 +151,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
 			return Common::kReadingFailed;
 
-		auto getActiveScreen = [&]() -> Graphics::Screen * {
-			return _engine.getScreen();
-		};
-		Graphics::Screen *screen = getActiveScreen();
+		Graphics::Screen *screen = _engine.getScreen();
 		const Art *art = _engine.getArt();
 		const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 		const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
@@ -274,267 +197,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		flow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
 			(void)_engine.playMusic(scene.state.musicPath);
-		RoomSession session(_engine, flow, scene, entityManager, _inventory);
+		const RoomViewResources view = { art, promptFont, inspectFont, inventoryTooltipFont,
+			useNativePromptFont, useNativeInspectFont };
+		RoomSession session(_engine, flow, scene, entityManager, _inventory, _cheats, view,
+			flow._roomLoopDepth > 1);
 		debugC(1, kDebugRoom, "Harvester: created room session room='%s'", scene.state.roomName.c_str());
-		RoomPlayerState &playerState = session.getPlayerState();
-		playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
-		playerState.centerX = scene.state.playerSpawnX;
-		playerState.bottomY = scene.state.playerSpawnY;
-		playerState.z = (float)scene.state.playerSpawnZ;
-		playerState.facing = scene.state.playerFacing;
-		playerState.combatLoadout =
-			_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
-		playerState.turnActive = false;
-		playerState.turnTargetFacing = -1;
-		RoomCombat &combat = session.getCombat();
-		combat.initializeCombatStates(scene.state);
-		RoomSceneRuntime &sceneRuntime = session.getSceneRuntime();
-		RoomCombatEffects &combatEffects = session.getCombatEffects();
-		RoomPresentationState presentationState;
-
-		RoomInputState &inputState = session.getInputState();
-
-		Common::String &pendingRegionName = session.getPendingRegionName();
-		Common::String pendingRoomChange;
-		bool pendingRoomChangeIsRoomName = false;
-		bool pendingRoomChangeUsesSavedRoomState = false;
-		SaveRoomState pendingRoomChangeSavedRoomState;
-		const bool canExitCloseupToParent = flow._roomLoopDepth > 1;
-
-		RoomIdleAnimationState &idleState = session.getIdleState();
-		bool needsRedraw = true;
-		RoomItemInteractions roomItems(_engine, scene, sceneRuntime, _inventory, pendingRoomChange);
+		session.initializePlayerState();
 
 		session.resetIdleState();
 
-		auto syncAnimatedRoomActorPlacement = [&]() {
-			Player::syncAnimationPlacement(playerState);
-			if (!entityManager)
-				return;
-
-			// Class-4 NPCs retain their spawn base through ambient and death animation banks.
-			for (const MonsterRecord &monster : scene.state.roomMonsters) {
-				Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
-				if (!entity)
-					continue;
-				(void)applyRoomActorPlacement(scene.state, *entity,
-					monster.posX, monster.posY, (float)monster.posZ);
-			}
-		};
-
-		RoomPresentation presentation(_engine, scene, art, inspectFont, useNativeInspectFont);
-
-		auto runRoomExitCommands = [&]() -> Common::Error {
-			InteractionResult exitInteraction;
-			if (!_engine.getScript()->executeRoomExitCommands(scene.state.roomName, exitInteraction))
-				return Common::kReadingFailed;
-
-			for (uint exitStep = 0; exitStep < 128; ++exitStep) {
-				if (exitInteraction.cdChangeDisc > 0) {
-					Common::Error cdPromptError = flow.showCdChangePrompt(exitInteraction.cdChangeDisc);
-					if (cdPromptError.getCode() != Common::kNoError)
-						return cdPromptError;
-				}
-				if (!exitInteraction.musicPath.empty())
-					(void)_engine.playMusic(exitInteraction.musicPath);
-				flow.executeStartupAudioCommands(exitInteraction.audioCommands);
-				if (exitInteraction.mutatedRuntimeState)
-					sceneRuntime.syncGlobalTimerEntities(exitInteraction.previousTimerRecords, true);
-
-				if (!exitInteraction.cutscenePath.empty()) {
-					FstPlayer fstPlayer(_engine);
-					if (!fstPlayer.play(exitInteraction.cutscenePath))
-						return Common::kReadingFailed;
-				}
-
-				if (!exitInteraction.dialogueNpcName.empty() ||
-						!exitInteraction.dialogueContinuationTag.empty() ||
-						!exitInteraction.modalText.value.empty() ||
-						exitInteraction.lightingCommand != kStartupLightingCommandNone ||
-						exitInteraction.requestPlayerGotoXZ ||
-						exitInteraction.requestPlayerGotoZ) {
-					debugC(1, kDebugRoom,
-						"Harvester: room exit command for '%s' produced unsupported deferred output; preserving accumulated state",
-						scene.state.roomName.c_str());
-				}
-
-				if (exitInteraction.continuationTag.empty())
-					return Common::kNoError;
-
-				InteractionResult continuationInteraction;
-				if (!_engine.getScript()->executeActionTag(
-						exitInteraction.continuationTag, continuationInteraction, false,
-						scene.state.roomName)) {
-					return Common::kNoError;
-				}
-
-				exitInteraction = Common::move(continuationInteraction);
-			}
-
-			warning("Harvester: room exit command chain for '%s' exceeded continuation safety limit",
-				scene.state.roomName.c_str());
-			return Common::kNoError;
-		};
-
-		auto applyPlayerGotoXZ = [&](int x, int z) {
-			playerState.hasMoveTarget = false;
-			playerState.turnActive = false;
-			playerState.turnTargetFacing = -1;
-			playerState.centerX = x;
-			playerState.z = (float)z;
-			playerState.bottomY = mapRoomDepthToScreenYForCombat(
-				scene.state, playerState.z, playerState.bottomY);
-			Common::Rect screenRect;
-			bool hasScreenRect = false;
-			float depthScale = 0.0f;
-			if (playerState.entity) {
-				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
-					playerState.centerX, playerState.bottomY, playerState.z);
-				if (entityManager)
-					entityManager->reinsertSceneEntity(playerState.entity);
-				screenRect = playerState.entity->getScreenRect();
-				hasScreenRect = true;
-				depthScale = playerState.entity->getDepthScale();
-			}
-			debugC(1, kDebugPlayer,
-				"Harvester: PC_GOTO_XZ applied x=%d z=%d pos=(%d,%d,z=%.2f) screen_rect=(%d,%d)-(%d,%d) depth_scale=%.3f",
-				x, z, playerState.centerX, playerState.bottomY, (double)playerState.z,
-				hasScreenRect ? screenRect.left : 0, hasScreenRect ? screenRect.top : 0,
-				hasScreenRect ? screenRect.right : 0, hasScreenRect ? screenRect.bottom : 0,
-				(double)depthScale);
-			session.resetIdleState();
-			session.captureCurrentSaveState();
-		};
-
-		auto requestPlayerGameOver = [&](const char *reason, const Common::String &sourceName) {
-			if (flow.hasPendingMainMenuReturn())
-				return;
-
-			debugC(1, kDebugCombat,
-				"Harvester: player game over queued reason='%s' source='%s' hp=%d",
-				reason ? reason : "", sourceName.c_str(),
-				_engine.getScript()
-					? _engine.getScript()->getPlayerCurrentHitPoints()
-					: 0);
-			flow.requestGameOverReturn();
-		};
-		auto startPlayerDefeatSequence = [&](const char *reason, const Common::String &sourceName,
-				int damageType) {
-			if (flow.hasPendingMainMenuReturn() || playerState.deathActive)
-				return;
-
-			if (idleState.entity) {
-				idleState.entity->setVisible(false);
-				idleState.entity->setAnimationRate(0);
-			}
-			idleState.active = false;
-			idleState.loopStarted = false;
-			idleState.exiting = false;
-			idleState.restoreFacing = -1;
-			if (playerState.entity)
-				playerState.entity->setVisible(true);
-
-			if (!Player::startDeathAnimation(playerState, damageType, _engine.isGoreEnabled())) {
-				debugC(1, kDebugCombat,
-					"Harvester: player defeat fallback reason='%s' source='%s' damage_type=%d hp=%d",
-					reason ? reason : "", sourceName.c_str(), damageType,
-					_engine.getScript()
-						? _engine.getScript()->getPlayerCurrentHitPoints()
-						: 0);
-				requestPlayerGameOver(reason, sourceName);
-				return;
-			}
-
-			debugC(1, kDebugCombat,
-				"Harvester: player defeat started reason='%s' source='%s' damage_type=%d hp=%d",
-				reason ? reason : "", sourceName.c_str(), damageType,
-				_engine.getScript()
-					? _engine.getScript()->getPlayerCurrentHitPoints()
-					: 0);
-		};
-		typedef decltype(runRoomExitCommands) RunRoomExitCommandsFn;
-		typedef decltype(applyPlayerGotoXZ) ApplyPlayerGotoXZFn;
-		typedef decltype(startPlayerDefeatSequence) StartPlayerDefeatSequenceFn;
-
-		struct RoomInteractionCallbacksImpl : RoomInteractionCallbacks {
-			RoomInteractionCallbacksImpl(
-					RoomSession &session_, RoomPresentation &presentation_, Flow &flow_,
-					RunRoomExitCommandsFn &runRoomExitCommandsFn_,
-					ApplyPlayerGotoXZFn &applyPlayerGotoXZFn_,
-					StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn_)
-				: session(session_), presentation(presentation_), flow(flow_),
-				  runRoomExitCommandsFn(runRoomExitCommandsFn_),
-				  applyPlayerGotoXZFn(applyPlayerGotoXZFn_),
-				  startPlayerDefeatSequenceFn(startPlayerDefeatSequenceFn_) {
-			}
-
-			bool refreshCurrentScene(bool preservePlayerPlacement) override {
-				return session.refreshCurrentScene(preservePlayerPlacement);
-			}
-
-			bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) override {
-				return session.applyCurrentRoomRuntimeMutationsInPlace(preservePaletteState);
-			}
-
-			void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) override {
-				session.getSceneRuntime().syncGlobalTimerEntities(previousTimerRecords);
-			}
-
-			bool captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop) override {
-				return presentation.captureDialogueBackdrop(dialogueBackdrop);
-			}
-
-			Common::Error showCdChangePrompt(int discNumber) override {
-				return flow.showCdChangePrompt(discNumber);
-			}
-
-			Common::Error runRoomExitCommands() override {
-				return runRoomExitCommandsFn();
-			}
-
-			Common::Error applyLightingCommand(StartupLightingCommand lightingCommand) override {
-				return presentation.applyLightingCommand(lightingCommand);
-			}
-
-			void applyPlayerGotoXZ(int x, int z) override {
-				applyPlayerGotoXZFn(x, z);
-			}
-
-			Common::Error runModalShowText(const ResolvedText &modalText) override {
-				return presentation.runModalShowText(modalText, flow);
-			}
-
-			void resetIdleState() override {
-				session.resetIdleState();
-			}
-
-			void notePlayerActivity() override {
-				session.notePlayerActivity();
-			}
-
-			void stopPlayerRegionInteraction() override {
-				session.stopPlayerRegionInteraction();
-			}
-
-			void startPlayerDefeatSequence(const char *reason, const Common::String &sourceName,
-					int damageType) override {
-				startPlayerDefeatSequenceFn(reason, sourceName, damageType);
-			}
-
-			RoomSession &session;
-			RoomPresentation &presentation;
-			Flow &flow;
-			RunRoomExitCommandsFn &runRoomExitCommandsFn;
-			ApplyPlayerGotoXZFn &applyPlayerGotoXZFn;
-			StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn;
-		};
-		RoomInteractionCallbacksImpl interactionCallbacks(
-			session, presentation, flow, runRoomExitCommands, applyPlayerGotoXZ,
-			startPlayerDefeatSequence);
-		RoomInteractionProcessor interactionProcessor(
-			_engine, flow, scene, playerState, pendingRegionName, pendingRoomChange,
-			pendingRoomChangeIsRoomName, pendingRoomChangeUsesSavedRoomState,
-			pendingRoomChangeSavedRoomState, canExitCloseupToParent, interactionCallbacks);
 		InteractionResult roomEntryInteraction;
 		bool hasPendingRoomEntryInteraction = false;
 		if (shouldRunRoomEntryCommands) {
@@ -545,7 +216,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			// Native room_setup applies entry-time state changes before the first visible room frame.
 			if (roomEntryInteraction.mutatedRuntimeState) {
-				sceneRuntime.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
+				session.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
 				if (!session.applyCurrentRoomRuntimeMutationsInPlace(false) &&
 						!session.refreshCurrentScene(true)) {
 					return Common::kReadingFailed;
@@ -574,13 +245,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		if (shouldRunRoomEntryCommands) {
 			if (hasPendingRoomEntryInteraction) {
-				bool didEntryTransition = false;
-				Common::Error entryInteractionError = interactionProcessor.handleInteractionResult(
-					roomEntryInteraction, didEntryTransition, Common::String());
-				if (entryInteractionError.getCode() != Common::kNoError)
-					return entryInteractionError;
-				if (flow.hasPendingMainMenuReturn() || didEntryTransition)
-					return Common::kNoError;
+				const RoomInputResult entryResult = session.runRoomEntryInteraction(roomEntryInteraction);
+				if (entryResult.returnFromRoom)
+					return entryResult.error;
 			}
 
 			flow.resetCursorAnimationSequence();
@@ -589,411 +256,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
 			_engine.getScript());
 
-	RoomInput input(_engine, flow, session, scene, entityManager, _inventory, _cheats,
-		roomItems, interactionProcessor, interactionCallbacks, presentationState, *art, canExitCloseupToParent);
-
-	if (!_inventory.refresh())
-		return Common::kReadingFailed;
-	Graphics::FrameLimiter limiter(g_system, 60);
-	session.captureCurrentSaveState();
-
-	if (shouldRunStartupRoomProbe())
-		logStartupRoomProbe(_engine, scene, currentRoomTarget, _mousePos, flow._menuTextConfig);
-
-	while (!_engine.shouldQuit()) {
-		if (flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-		if (flow.takePendingNewGameRestart()) {
-			flow.prepareForNewGame();
-			pendingRoomChange = "START";
-			pendingRoomChangeIsRoomName = false;
-			pendingRoomChangeUsesSavedRoomState = false;
-			pendingRoomChangeSavedRoomState.clear();
-			if (!roomItems.stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			break;
-		}
-		if (_engine.hasPendingLoadedSaveRoomState()) {
-			pendingRoomChange = _engine.getPendingLoadedSaveRoomState().entranceName;
-			pendingRoomChangeIsRoomName = false;
-			pendingRoomChangeUsesSavedRoomState = false;
-			pendingRoomChangeSavedRoomState.clear();
-			if (pendingRoomChange.empty()) {
-				pendingRoomChange = _engine.getPendingLoadedSaveRoomState().roomName;
-				pendingRoomChangeIsRoomName = true;
-			}
-			if (!roomItems.stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			break;
-		}
-		if (flow.hasPendingDebugRoomChange()) {
-			if (!roomItems.stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			return Common::kNoError;
-		}
-		if (!pendingRoomChange.empty()) {
-			if (!roomItems.stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			break;
-		}
-		InteractionResult queuedDebugInteraction;
-		if (flow.takeQueuedDebugInteraction(queuedDebugInteraction)) {
-			// Run console-injected commands through the normal room interaction handler so
-			// CHANGE_ROOM, modal text, dialogue, and chained tags behave exactly like script-driven actions.
-			bool didDebugTransition = false;
-			Common::Error debugInteractionError = interactionProcessor.handleInteractionResult(
-				queuedDebugInteraction, didDebugTransition, Common::String());
-			if (debugInteractionError.getCode() != Common::kNoError)
-				return debugInteractionError;
-			if (flow.hasPendingMainMenuReturn())
-				return Common::kNoError;
-			if (!pendingRoomChange.empty()) {
-				if (!roomItems.stowCarriedRoomItemToInventory())
-					return Common::kReadingFailed;
-				break;
-			}
-			needsRedraw = true;
-		}
-		session.captureCurrentSaveState();
-		if (_inventory.isOpen() && _inventory.refreshIfRuntimeStateChanged())
-			needsRedraw = true;
-		if (!presentationState.combatLoadoutStatusText.value.empty() &&
-				presentationState.combatLoadoutStatusHideTick != 0 &&
-				(int32)(Player::getRuntimeClockTicks() - presentationState.combatLoadoutStatusHideTick) >= 0) {
-			presentationState.combatLoadoutStatusText = ResolvedText();
-			presentationState.combatLoadoutStatusHideTick = 0;
-			if (presentationState.closeInventoryAfterCombatLoadoutStatus) {
-				presentationState.closeInventoryAfterCombatLoadoutStatus = false;
-				if (_inventory.close())
-					needsRedraw = true;
-			}
-			needsRedraw = true;
-		}
-
-		if (needsRedraw) {
-			Graphics::Screen *activeScreen = getActiveScreen();
-			if (!activeScreen)
-				return Common::kNoError;
-
-			const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
-			const bool inventorySelectionActive = _inventory.hasSelection();
-			const bool roomCarryActive = roomItems.hasCarriedRoomItem();
-			const bool activeCarry = inventorySelectionActive || roomCarryActive;
-			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
-			const bool suppressHover = presentationState.showingInspectText || idleState.active || idleState.exiting ||
-				session.isPlayerCombatLocked() ||
-				(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
-			RoomHoverState hoverState = suppressHover
-				? RoomHoverState()
-				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
-					scene.sceneRegions, _mousePos, flow._menuTextConfig, &flow._dialogue);
-			if (!suppressHover && inventorySelectionActive && !hoverState.npc) {
-				if (ObjectRecord *selectedTarget = roomItems.findSelectedInventoryRoomTarget(_mousePos))
-					hoverState.object = selectedTarget;
-			}
-			Common::String promptText;
-			Common::String inventoryTooltipText;
-			auto resolveCarryTargetLabel = [&]() {
-				if (hoverState.playerEntity && playerState.entity && hoverState.playerEntity == playerState.entity)
-					return Common::String(kPlayerInventoryLabel);
-				if (hoverState.object)
-					return _engine.getScript()->resolveObjectLabel(*hoverState.object);
-				if (hoverState.npc)
-					return resolveStartupNpcLabel(*hoverState.npc);
-				return Common::String();
-			};
-			if (activeCarry) {
-				Common::String targetLabel;
-				const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
-				if (_inventory.isOpen()) {
-					if (inventoryHover && !InventorySystem::isExitObject(inventoryHover->object) &&
-							!InventorySystem::isStatusObject(inventoryHover->object) &&
-							!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
-						targetLabel = _engine.getScript()->resolveObjectLabel(inventoryHover->object);
-					} else if (!inventoryPanelContainsMouse) {
-						targetLabel = resolveCarryTargetLabel();
-					}
-				} else {
-					targetLabel = resolveCarryTargetLabel();
-				}
-				if (inventorySelectionActive) {
-					promptText = _inventory.buildSelectedPrompt(targetLabel, flow._menuTextConfig);
-					_inventory.setPromptText(promptText);
-				} else {
-					promptText = buildUseItemPrompt(
-						flow._menuTextConfig, roomItems.getCarriedRoomItemLabel(), targetLabel);
-				}
-				hoverState.cursorSequence = 7;
-			} else if (_inventory.isOpen()) {
-				const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
-				if (inventoryHover &&
-						!InventorySystem::isExitObject(inventoryHover->object)) {
-					inventoryTooltipText =
-						_engine.getScript()->resolveInventoryTooltipText(inventoryHover->object);
-				}
-				_inventory.setPromptText(Common::String());
-				hoverState.cursorSequence = 7;
-			} else {
-				promptText = hoverState.promptText;
-			}
-			if (!presentationState.showingInspectText)
-				presentationState.currentInteractionPromptText = promptText;
-			if (Entity *cursor = entityManager ? entityManager->getCursorEntity() : nullptr) {
-				cursor->setAnimationSequence(
-					(presentationState.showingInspectText || idleState.active || idleState.exiting ||
-						session.isPlayerCombatLocked() ||
-						_inventory.isOpen() || activeCarry)
-						? 7
-						: hoverState.cursorSequence);
-			}
-
-			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
-			combatEffects.drawCombatDamagePopups(*activeScreen, _engine.isCombatDebugEnabled());
-			if (_engine.getScript() && playerState.entity) {
-				drawCombatLoadoutResourceIcons(*activeScreen, *art, *_engine.getScript(),
-					_engine.getScript()->getPlayerCombatLoadout());
-			}
-			if (_inventory.isOpen()) {
-				_inventory.drawOverlay(*activeScreen);
-				drawInventoryWeekday(*activeScreen, *inventoryTooltipFont,
-					_inventory.resolveWeekdayLabel(flow._menuTextConfig));
-			}
-			if (inventorySelectionActive) {
-				_inventory.drawSelectedDragItem(*activeScreen, _mousePos);
-			} else if (roomCarryActive && roomItems.getCarriedRoomItemBitmap().isValid()) {
-				const int drawX = _mousePos.x - (int)roomItems.getCarriedRoomItemBitmap().width / 2;
-				const int drawY = _mousePos.y - (int)roomItems.getCarriedRoomItemBitmap().height / 2;
-				RoomPresentation::blitBitmap(*activeScreen, roomItems.getCarriedRoomItemBitmap(), drawX, drawY);
-			}
-
-			if (presentationState.showingInspectText) {
-				drawRoomInspectText(*activeScreen, *art, *inspectFont, presentationState.inspectText, useNativeInspectFont);
-				if (!presentationState.currentInteractionPromptText.empty())
-					drawRoomPrompt(*activeScreen, *promptFont,
-						presentationState.currentInteractionPromptText, useNativePromptFont);
-			} else if (!presentationState.combatLoadoutStatusText.value.empty()) {
-				drawRoomInspectText(*activeScreen, *art, *inspectFont, presentationState.combatLoadoutStatusText,
-					useNativeInspectFont);
-			} else if (!inventoryTooltipText.empty()) {
-				drawInventoryTooltip(*activeScreen, *inventoryTooltipFont, inventoryTooltipText);
-			} else if (!promptText.empty()) {
-				drawRoomPrompt(*activeScreen, *promptFont, promptText, useNativePromptFont);
-			}
-
-			if (entityManager)
-				entityManager->drawCursor(*activeScreen);
-			activeScreen->makeAllDirty();
-			activeScreen->update();
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			const RoomInputResult inputResult = input.handleEvent(event, needsRedraw);
-			if (inputResult.returnFromRoom)
-				return inputResult.error;
-		}
-		if (!pendingRoomChange.empty()) {
-			if (!roomItems.stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			break;
-		}
-
-		Script *script = _engine.getScript();
-		const bool playerAlive = script && script->getPlayerCurrentHitPoints() > 0;
-		const bool playerControlPaused = script && script->isPlayerControlPaused();
-		const bool playerCanAct = playerAlive && !playerControlPaused;
-		if (!playerAlive && (inputState.moveLeft || inputState.moveRight || inputState.moveUp || inputState.moveDown ||
-				playerState.hasMoveTarget || playerState.turnActive ||
-				playerState.attackActive || playerState.hitActive)) {
-			inputState.attackModifierHeld = false;
-			session.stopPlayerRegionInteraction();
-		}
-
-		// Native run_harvester_main_loop checks player/region overlap before
-		// update_actor_runtime_state. This is significant when a wide walk frame
-		// reaches a narrow exit: the actor update may otherwise satisfy the X
-		// target and replace that frame with a narrower idle frame first.
-		Common::Error pendingRegionError =
-			session.isPlayerCombatLocked()
-				? Common::kNoError
-				: interactionProcessor.tryActivatePendingRegion(_inventory, needsRedraw);
-		if (pendingRegionError.getCode() != Common::kNoError)
-			return pendingRegionError;
-		if (flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-		if (!pendingRoomChange.empty()) {
-			if (!roomItems.stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			break;
-		}
-		if (!session.isPlayerCombatLocked()) {
-			pendingRegionError = interactionProcessor.tryActivatePassiveRegion(_inventory, needsRedraw);
-			if (pendingRegionError.getCode() != Common::kNoError)
-				return pendingRegionError;
-			if (flow.hasPendingMainMenuReturn())
-				return Common::kNoError;
-			if (!pendingRoomChange.empty()) {
-				if (!roomItems.stowCarriedRoomItemToInventory())
-					return Common::kReadingFailed;
-				break;
-			}
-		}
-
-		// At 60 Hz, consume animation advances before the next screen update so
-		// a walk frame and its movement step are presented together.
-		if (flow.tickRuntimeEntities())
-			needsRedraw = true;
-		syncAnimatedRoomActorPlacement();
-
-		Common::Error combatError = Common::kNoError;
-		if (!playerControlPaused) {
-			combatError = combat.resolvePlayerAttackContact(_engine, scene.state, playerState, entityManager,
-					sceneRuntime, combatEffects, interactionProcessor, needsRedraw);
-			if (combatError.getCode() != Common::kNoError)
-				return combatError;
-			if (flow.hasPendingMainMenuReturn())
-				return Common::kNoError;
-			if (!pendingRoomChange.empty()) {
-				if (!roomItems.stowCarriedRoomItemToInventory())
-					return Common::kReadingFailed;
-				break;
-			}
-			if (Player::updateAttackAnimationState(_engine, playerState))
-				needsRedraw = true;
-			if (Player::updateHitAnimationState(
-					_engine, scene.state, scene.sceneObjects, scene.sceneAnimations, playerState)) {
-				needsRedraw = true;
-			}
-		}
-		const bool keyboardAttackRequested =
-			inputState.attackModifierHeld && (inputState.moveLeft || inputState.moveRight || inputState.moveUp || inputState.moveDown);
-		if (playerCanAct &&
-				!playerState.attackActive && !playerState.hitActive && keyboardAttackRequested &&
-				!idleState.active && !idleState.exiting) {
-			const uint32 now = Player::getRuntimeClockTicks();
-			if (inputState.nextKeyboardAttackAllowedTick == 0 || (int32)(now - inputState.nextKeyboardAttackAllowedTick) >= 0) {
-				combat.capturePlayerAttackTargetForKeyboard(scene.state, playerState, entityManager, inputState.moveLeft, inputState.moveRight, inputState.moveUp, inputState.moveDown);
-				if (Player::startKeyboardAttackAnimation(
-						scene.state, playerState, inputState.moveLeft, inputState.moveRight, inputState.moveUp, inputState.moveDown)) {
-					inputState.nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
-					session.notePlayerActivity();
-					debugC(1, kDebugCombat,
-						"Harvester: combat player keyboard attack start loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s' input=(L=%d R=%d U=%d D=%d) facing=%d",
-						playerState.combatLoadout,
-						Player::describeCombatLoadout(playerState.combatLoadout),
-						Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout),
-						Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(playerState.combatLoadout)),
-						describeCombatTargetClass(playerState.attackTargetClassId),
-						playerState.attackTargetName.c_str(),
-						inputState.moveLeft, inputState.moveRight, inputState.moveUp, inputState.moveDown, playerState.facing);
-					needsRedraw = true;
-				} else {
-					playerState.attackTargetName.clear();
-					playerState.attackTargetClassId = -1;
-				}
-			}
-		}
-		if (playerCanAct && !playerState.attackActive && !playerState.hitActive &&
-				Player::updateTurnAnimationState(playerState)) {
-			needsRedraw = true;
-		}
-
-		if (playerCanAct &&
-				!playerState.attackActive && !playerState.hitActive &&
-				!keyboardAttackRequested && !idleState.active && !idleState.exiting) {
-			const int regionFacing = interactionProcessor.getPendingRegionFacing();
-			if (Player::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
-					playerState, inputState.moveLeft, inputState.moveRight, inputState.moveUp, inputState.moveDown)) {
-				session.notePlayerActivity();
-				needsRedraw = true;
-			} else if (Player::stepMoveTarget(
-					_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
-					playerState, regionFacing)) {
-				session.notePlayerActivity();
-				needsRedraw = true;
-			}
-
-			if (!presentationState.showingInspectText && !keyboardAttackRequested &&
-					!inputState.moveLeft && !inputState.moveRight && !inputState.moveUp && !inputState.moveDown &&
-					!playerState.hasMoveTarget && !playerState.turnActive && !playerState.hitActive &&
-					playerState.entity && playerState.facing >= 0 &&
-					!Player::isIdleAnimationExcludedRoom(scene.state.roomName) &&
-					Player::getRuntimeClockTicks() > idleState.triggerTick &&
-					Player::startIdleAnimation(_engine, scene.state, playerState, idleState)) {
-				needsRedraw = true;
-			}
-		}
-		combatError = combat.updateRoomMonsterCombat(_engine, scene.state, playerState, idleState,
-			entityManager, sceneRuntime, combatEffects, _cheats, interactionCallbacks, interactionProcessor, needsRedraw);
-		if (combatError.getCode() != Common::kNoError)
-			return combatError;
-		if (combatEffects.syncCombatHitEffects())
-			needsRedraw = true;
-		if (flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-		if (!pendingRoomChange.empty()) {
-			if (!roomItems.stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			break;
-		}
-		if (Player::updateDeathAnimationState(playerState)) {
-			requestPlayerGameOver("combat_player_death_complete", Common::String());
-			needsRedraw = true;
-		}
-		if (flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-		if (combatEffects.pruneCombatHitEffects())
-			needsRedraw = true;
-		if (combatEffects.pruneCombatDamagePopups())
-			needsRedraw = true;
-		if (entityManager) {
-			Common::Array<Common::String> expiredTimerNames;
-			if (entityManager->takeExpiredTimerNames(expiredTimerNames)) {
-				sceneRuntime.syncCurrentRoomRuntimeState(scene.sceneRegions);
-				for (const Common::String &timerName : expiredTimerNames) {
-					InteractionResult timerInteraction;
-					if (!_engine.getScript()->executeTimerAction(
-							timerName, timerInteraction, true, scene.state.roomName))
-						continue;
-
-					bool didTransition = false;
-					Common::Error interactionError =
-						interactionProcessor.handleInteractionResult(
-							timerInteraction, didTransition, Common::String(), false);
-					if (interactionError.getCode() != Common::kNoError)
-						return interactionError;
-					if (flow.hasPendingMainMenuReturn())
-						return Common::kNoError;
-					needsRedraw = true;
-					if (!pendingRoomChange.empty())
-						break;
-				}
-			}
-		}
-		if (Player::updateIdleAnimation(scene.state, playerState, idleState))
-			needsRedraw = true;
-		if (_engine.isCombatDebugEnabled() && combatEffects.hasDamagePopups())
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-	if (_engine.shouldQuit())
-		return Common::kNoError;
-	if (flow.hasPendingMainMenuReturn())
-		return Common::kNoError;
-	if (pendingRoomChange.empty())
-		return Common::kNoError;
-	if (pendingRoomChange.equals(kExitCloseupPendingRoomChange)) {
-		flow.requestCloseupParentRestart();
-		return Common::kNoError;
-	}
+		const RoomInputResult frameResult = session.runRoomFrames(currentRoomTarget);
+		if (frameResult.returnFromRoom)
+			return frameResult.error;
+		const RoomPendingTransition &pendingTransition = session.getPendingTransition();
 
-	currentRoomTarget = pendingRoomChange;
-	currentTargetIsRoomName = pendingRoomChangeIsRoomName;
-	currentTargetUsesSavedRoomState = pendingRoomChangeUsesSavedRoomState;
-	currentTargetSavedRoomState = pendingRoomChangeSavedRoomState;
+		currentRoomTarget = pendingTransition.roomName;
+		currentTargetIsRoomName = pendingTransition.isRoomName;
+		currentTargetUsesSavedRoomState = pendingTransition.usesSavedRoomState;
+		currentTargetSavedRoomState = pendingTransition.savedRoomState;
 	}
 
 	return Common::kNoError;
diff --git a/engines/harvester/room.h b/engines/harvester/room.h
index 0f3cab9fc48..2c0f15e864d 100644
--- a/engines/harvester/room.h
+++ b/engines/harvester/room.h
@@ -23,7 +23,6 @@
 #define HARVESTER_ROOM_H
 
 #include "common/error.h"
-#include "common/rect.h"
 #include "common/str.h"
 
 namespace Harvester {
@@ -35,14 +34,13 @@ class InventorySystem;
 
 class RoomSystem {
 public:
-	RoomSystem(HarvesterEngine &engine, Common::Point &mousePos, InventorySystem &inventory,
+	RoomSystem(HarvesterEngine &engine, InventorySystem &inventory,
 		CheatSystem &cheats);
 
 	Common::Error runRoomLoop(Flow &flow, const Common::String &targetName, bool targetIsRoomName);
 
 private:
 	HarvesterEngine &_engine;
-	Common::Point &_mousePos;
 	InventorySystem &_inventory;
 	CheatSystem &_cheats;
 };
diff --git a/engines/harvester/room_input.cpp b/engines/harvester/room_input.cpp
index 7ca357be3ab..55346caf9d5 100644
--- a/engines/harvester/room_input.cpp
+++ b/engines/harvester/room_input.cpp
@@ -76,13 +76,13 @@ static void debugLogInventoryClick(const char *buttonLabel, const Common::Point
 }
 
 RoomInput::RoomInput(HarvesterEngine &engine, Flow &flow, RoomSession &session, RoomSceneResources &scene,
-		EntityManager *entityManager, InventorySystem &inventory, CheatSystem &cheats,
+		InventorySystem &inventory, CheatSystem &cheats,
 		RoomItemInteractions &roomItems, RoomInteractionProcessor &interactionProcessor,
-		RoomInteractionCallbacks &interactionCallbacks, RoomPresentationState &presentationState,
+		RoomPresentationState &presentationState,
 		const Art &art, bool canExitCloseupToParent)
-	: _engine(engine), _flow(flow), _session(session), _scene(scene), _entityManager(entityManager),
+	: _engine(engine), _flow(flow), _session(session), _scene(scene),
 	  _inventory(inventory), _cheats(cheats), _roomItems(roomItems), _interactionProcessor(interactionProcessor),
-	  _interactionCallbacks(interactionCallbacks), _presentationState(presentationState), _art(art),
+	  _presentationState(presentationState), _art(art),
 	  _canExitCloseupToParent(canExitCloseupToParent) {
 }
 
@@ -116,13 +116,12 @@ void RoomInput::queueCombatLoadoutStatusMessage(const Common::String &message, b
 bool RoomInput::openInventoryOverlay() {
 	RoomPlayerState &playerState = _session.getPlayerState();
 	RoomInputState &inputState = _session.getInputState();
-	Common::String &pendingRegionName = _session.getPendingRegionName();
 
 	inputState.moveLeft = false;
 	inputState.moveRight = false;
 	inputState.moveUp = false;
 	inputState.moveDown = false;
-	pendingRegionName.clear();
+	_session.clearPendingRegionInteraction();
 	(void)_session.cancelPlayerAttackAnimation();
 	playerState.hasMoveTarget = false;
 	playerState.turnActive = false;
@@ -141,7 +140,7 @@ void RoomInput::capturePlayerAttackTarget() {
 	const RoomHoverState hoverState = resolveRoomHoverState(
 		_engine, _scene.state, _scene.sceneObjects, _scene.state.roomNpcs, _scene.sceneRegions,
 		_flow._mousePos, _flow._menuTextConfig, &_flow._dialogue);
-	_session.getCombat().capturePlayerAttackTarget(_scene.state, playerState, _entityManager, _flow._mousePos, hoverState.npc);
+	_session.capturePlayerAttackTarget(_flow._mousePos, hoverState.npc);
 }
 
 void RoomInput::handleMouseMove(bool &needsRedraw) {
@@ -160,7 +159,6 @@ void RoomInput::handleMouseMove(bool &needsRedraw) {
 RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
 	RoomPlayerState &playerState = _session.getPlayerState();
 	RoomIdleAnimationState &idleState = _session.getIdleState();
-	Common::String &pendingRegionName = _session.getPendingRegionName();
 
 	_session.notePlayerActivity();
 	if (idleState.active || idleState.exiting) {
@@ -170,7 +168,7 @@ RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
 	}
 	if (_session.isPlayerCombatLocked())
 		return RoomInputResult();
-	pendingRegionName.clear();
+	_session.clearPendingRegionInteraction();
 	if (_inventory.isOpen()) {
 		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow._mousePos);
 		debugLogInventoryClick("right", _flow._mousePos, inventoryHover);
@@ -324,7 +322,6 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 	RoomPlayerState &playerState = _session.getPlayerState();
 	RoomIdleAnimationState &idleState = _session.getIdleState();
 	RoomInputState &inputState = _session.getInputState();
-	Common::String &pendingRegionName = _session.getPendingRegionName();
 
 	const uint32 now = Player::getRuntimeClockTicks();
 	const bool isFastExitClick =
@@ -474,7 +471,7 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 		return RoomInputResult();
 	}
 	if (hoverState.npc) {
-		pendingRegionName.clear();
+		_session.clearPendingRegionInteraction();
 		inputState.moveLeft = false;
 		inputState.moveRight = false;
 		inputState.moveUp = false;
@@ -519,7 +516,7 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 		? findSceneObjectByName(_scene.sceneObjects, hoverState.object->objectName)
 		: nullptr;
 	if (!clickedObject) {
-		pendingRegionName.clear();
+		_session.clearPendingRegionInteraction();
 		if (hoverState.cursorSequence == 0 && playerState.entity) {
 			if (_engine.isPathfindingDebugEnabled()) {
 				const Common::Rect playerRect = playerState.entity->getScreenRect();
@@ -542,10 +539,10 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 		}
 		return RoomInputResult();
 	}
-	pendingRegionName.clear();
+	_session.clearPendingRegionInteraction();
 	if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
 		clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
-		Common::Error exitError = _interactionCallbacks.runRoomExitCommands();
+		Common::Error exitError = _session.runRoomExitCommands();
 		if (exitError.getCode() != Common::kNoError)
 			return RoomInputResult::leaveRoom(exitError);
 		// Native closeup exits return through room_setup so the parent on-enter command runs.
@@ -578,7 +575,7 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 	}
 	if (_engine.getScript()->isPickupObject(*clickedObject)) {
 		Common::Error pickupError = _roomItems.handleRoomItemPickup(*clickedObject, playerState.entity != nullptr,
-			_interactionProcessor, _interactionCallbacks, needsRedraw);
+			_interactionProcessor, _session, needsRedraw);
 		if (pickupError.getCode() != Common::kNoError)
 			return RoomInputResult::leaveRoom(pickupError);
 		return RoomInputResult();
@@ -613,7 +610,6 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 	RoomPlayerState &playerState = _session.getPlayerState();
 	RoomIdleAnimationState &idleState = _session.getIdleState();
 	RoomInputState &inputState = _session.getInputState();
-	Common::String &pendingRegionName = _session.getPendingRegionName();
 
 	if (_presentationState.showingInspectText) {
 		if (key.keycode == Common::KEYCODE_ESCAPE) {
@@ -701,8 +697,7 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 			key.keycode == Common::KEYCODE_RCTRL) {
 		inputState.attackModifierHeld = true;
 	} else if (key.keycode == Common::KEYCODE_k && _engine.isCombatDebugEnabled()) {
-		Common::Error debugKillError = _session.getCombat().killDebugActiveMonster(_engine, _scene.state, playerState, _entityManager,
-			_flow._mousePos, _session.getCombatEffects(), _interactionProcessor, needsRedraw);
+		Common::Error debugKillError = _session.killDebugActiveMonster(_flow._mousePos, needsRedraw);
 		if (debugKillError.getCode() != Common::kNoError)
 			return RoomInputResult::leaveRoom(debugKillError);
 		return RoomInputResult();
@@ -728,7 +723,7 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 		inputState.moveRight = false;
 		inputState.moveUp = false;
 		inputState.moveDown = false;
-		pendingRegionName.clear();
+		_session.clearPendingRegionInteraction();
 		playerState.hasMoveTarget = false;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
@@ -755,7 +750,7 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 		key.keycode == Common::KEYCODE_KP_ENTER) {
 		if (!_roomItems.stowCarriedRoomItemToInventory())
 			return RoomInputResult::leaveRoom(Common::kReadingFailed);
-		Common::Error exitError = _interactionCallbacks.runRoomExitCommands();
+		Common::Error exitError = _session.runRoomExitCommands();
 		if (exitError.getCode() != Common::kNoError)
 			return RoomInputResult::leaveRoom(exitError);
 		return RoomInputResult::leaveRoom(Common::kNoError);
diff --git a/engines/harvester/room_input.h b/engines/harvester/room_input.h
index 0cdbe9653a8..5986475b58c 100644
--- a/engines/harvester/room_input.h
+++ b/engines/harvester/room_input.h
@@ -29,11 +29,9 @@ namespace Harvester {
 
 class Art;
 class CheatSystem;
-class EntityManager;
 class Flow;
 class HarvesterEngine;
 class InventorySystem;
-class RoomInteractionCallbacks;
 class RoomInteractionProcessor;
 class RoomItemInteractions;
 class RoomSession;
@@ -68,9 +66,9 @@ struct RoomInputResult {
 class RoomInput {
 public:
 	RoomInput(HarvesterEngine &engine, Flow &flow, RoomSession &session, RoomSceneResources &scene,
-		EntityManager *entityManager, InventorySystem &inventory, CheatSystem &cheats,
+		InventorySystem &inventory, CheatSystem &cheats,
 		RoomItemInteractions &roomItems, RoomInteractionProcessor &interactionProcessor,
-		RoomInteractionCallbacks &interactionCallbacks, RoomPresentationState &presentationState,
+		RoomPresentationState &presentationState,
 		const Art &art, bool canExitCloseupToParent);
 
 	RoomInputResult handleEvent(const Common::Event &event, bool &needsRedraw);
@@ -91,12 +89,10 @@ private:
 	Flow &_flow;
 	RoomSession &_session;
 	RoomSceneResources &_scene;
-	EntityManager *_entityManager;
 	InventorySystem &_inventory;
 	CheatSystem &_cheats;
 	RoomItemInteractions &_roomItems;
 	RoomInteractionProcessor &_interactionProcessor;
-	RoomInteractionCallbacks &_interactionCallbacks;
 	RoomPresentationState &_presentationState;
 	const Art &_art;
 	bool _canExitCloseupToParent;
diff --git a/engines/harvester/room_presentation.h b/engines/harvester/room_presentation.h
index 10aa4443041..2339d791b79 100644
--- a/engines/harvester/room_presentation.h
+++ b/engines/harvester/room_presentation.h
@@ -39,6 +39,15 @@ struct IndexedBitmap;
 struct ResolvedText;
 struct RoomSceneResources;
 
+struct RoomViewResources {
+	const Art *art;
+	const Graphics::Font *promptFont;
+	const Graphics::Font *inspectFont;
+	const Graphics::Font *inventoryTooltipFont;
+	bool useNativePromptFont;
+	bool useNativeInspectFont;
+};
+
 struct RoomPresentationState {
 	ResolvedText inspectText;
 	Common::String currentInteractionPromptText;
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/room_session.cpp
index dbbff282ca9..6406f03009d 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/room_session.cpp
@@ -21,7 +21,14 @@
 
 #include "harvester/room_session.h"
 
+#include "common/algorithm.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/font.h"
+#include "graphics/framelimiter.h"
+#include "harvester/art.h"
 #include "harvester/detection.h"
+#include "harvester/fst_player.h"
 #include "harvester/flow.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
@@ -31,10 +38,87 @@
 
 namespace Harvester {
 
+static const char *const kPlayerInventoryLabel = "your inventory";
+static const int kNativeInventoryTooltipX = 0xbc;
+static const int kNativeInventoryTooltipY = 0x19e;
+static const int kNativeInventoryWeekdayX = 0x1e0;
+static const int kNativeInventoryWeekdayY = 0x18c;
+static const byte kNativeInventoryTooltipColor = 0xf4;
+static const int kNativeCombatResourceIconsX = 10;
+static const int kNativeCombatResourceIconsY = 30;
+static const int kNativeCombatResourceIconSpacing = 15;
+static Common::String resolveStartupNpcLabel(const NpcRecord &npc) {
+	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return label;
+}
+
+static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &promptText, bool useNativeFont) {
+	if (promptText.empty())
+		return;
+
+	if (useNativeFont) {
+		font.drawString(&screen, promptText, 0, 462, 640, 0, Graphics::kTextAlignCenter);
+		return;
+	}
+
+	font.drawString(&screen, promptText, 1, 463, 640, 0, Graphics::kTextAlignCenter);
+	font.drawString(&screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
+}
+
+static void drawInventoryTooltip(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &tooltipText) {
+	if (tooltipText.empty())
+		return;
+
+	font.drawString(&screen, tooltipText, kNativeInventoryTooltipX, kNativeInventoryTooltipY,
+		font.getStringWidth(tooltipText), kNativeInventoryTooltipColor);
+}
+
+static void drawInventoryWeekday(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &weekdayText) {
+	if (weekdayText.empty())
+		return;
+
+	font.drawString(&screen, weekdayText, kNativeInventoryWeekdayX, kNativeInventoryWeekdayY,
+		font.getStringWidth(weekdayText), kNativeInventoryTooltipColor);
+}
+
+static void drawCombatLoadoutResourceIcons(Graphics::Screen &screen, const Art &art,
+		const Script &script, int loadout) {
+	CombatLoadoutHudInfo info;
+	if (!resolveCombatLoadoutHudInfo(loadout, info))
+		return;
+
+	const Common::Array<IndexedBitmap> &ammoIcons = art.getAmmoIcons();
+	if (info.iconIndex >= ammoIcons.size())
+		return;
+
+	const IndexedBitmap &icon = ammoIcons[info.iconIndex];
+	if (!icon.isValid())
+		return;
+
+	const int iconCount = CLIP<int>(script.getPlayerCombatResourceCount(loadout), 0, info.maxVisibleCount);
+	for (int i = 0; i < iconCount; ++i)
+		RoomPresentation::blitBitmap(screen, icon, kNativeCombatResourceIconsX + i * kNativeCombatResourceIconSpacing,
+			kNativeCombatResourceIconsY);
+}
+
 RoomSession::RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
-		InventorySystem &inventory)
+		InventorySystem &inventory, CheatSystem &cheats, const RoomViewResources &view, bool canExitCloseupToParent)
 	: _engine(engine), _flow(flow), _scene(scene), _entityManager(entityManager), _inventory(inventory),
-	  _sceneRuntime(scene.state, engine.getScript(), engine.getResources(), entityManager), _combatEffects(entityManager) {
+	  _sceneRuntime(scene.state, engine.getScript(), engine.getResources(), entityManager), _combatEffects(entityManager),
+	  _cheats(cheats), _view(view), _canExitCloseupToParent(canExitCloseupToParent),
+	  _roomItems(engine, scene, _sceneRuntime, inventory, _pendingTransition.roomName),
+	  _presentation(engine, scene, view.art, view.inspectFont, view.useNativeInspectFont),
+	  _interactionProcessor(engine, flow, scene, _playerState, _pendingRegionName, _pendingTransition.roomName,
+		_pendingTransition.isRoomName, _pendingTransition.usesSavedRoomState, _pendingTransition.savedRoomState,
+		canExitCloseupToParent, *this) {
 }
 
 void RoomSession::resetIdleState() {
@@ -181,4 +265,682 @@ bool RoomSession::refreshCurrentScene(bool preservePlayerPlacement) {
 	return _inventory.refresh();
 }
 
+void RoomSession::initializePlayerState() {
+	_playerState.entity = _entityManager ? _entityManager->findSceneEntityByName("PLAYER") : nullptr;
+	_playerState.centerX = _scene.state.playerSpawnX;
+	_playerState.bottomY = _scene.state.playerSpawnY;
+	_playerState.z = (float)_scene.state.playerSpawnZ;
+	_playerState.facing = _scene.state.playerFacing;
+	_playerState.combatLoadout =
+		_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
+	_playerState.turnActive = false;
+	_playerState.turnTargetFacing = -1;
+	_combat.initializeCombatStates(_scene.state);
+}
+
+void RoomSession::syncAnimatedRoomActorPlacement() {
+	Player::syncAnimationPlacement(_playerState);
+	if (!_entityManager)
+		return;
+
+	// Class-4 NPCs retain their spawn base through ambient and death animation banks.
+	for (const MonsterRecord &monster : _scene.state.roomMonsters) {
+		Entity *entity = _entityManager->findSceneEntityByName(monster.monsterName);
+		if (!entity)
+			continue;
+		(void)applyRoomActorPlacement(_scene.state, *entity,
+			monster.posX, monster.posY, (float)monster.posZ);
+	}
+}
+
+Common::Error RoomSession::runRoomExitCommands() {
+	InteractionResult exitInteraction;
+	if (!_engine.getScript()->executeRoomExitCommands(_scene.state.roomName, exitInteraction))
+		return Common::kReadingFailed;
+
+	for (uint exitStep = 0; exitStep < 128; ++exitStep) {
+		if (exitInteraction.cdChangeDisc > 0) {
+			Common::Error cdPromptError = _flow.showCdChangePrompt(exitInteraction.cdChangeDisc);
+			if (cdPromptError.getCode() != Common::kNoError)
+				return cdPromptError;
+		}
+		if (!exitInteraction.musicPath.empty())
+			(void)_engine.playMusic(exitInteraction.musicPath);
+		_flow.executeStartupAudioCommands(exitInteraction.audioCommands);
+		if (exitInteraction.mutatedRuntimeState)
+			_sceneRuntime.syncGlobalTimerEntities(exitInteraction.previousTimerRecords, true);
+
+		if (!exitInteraction.cutscenePath.empty()) {
+			FstPlayer fstPlayer(_engine);
+			if (!fstPlayer.play(exitInteraction.cutscenePath))
+				return Common::kReadingFailed;
+		}
+
+		if (!exitInteraction.dialogueNpcName.empty() ||
+				!exitInteraction.dialogueContinuationTag.empty() ||
+				!exitInteraction.modalText.value.empty() ||
+				exitInteraction.lightingCommand != kStartupLightingCommandNone ||
+				exitInteraction.requestPlayerGotoXZ ||
+				exitInteraction.requestPlayerGotoZ) {
+			debugC(1, kDebugRoom,
+				"Harvester: room exit command for '%s' produced unsupported deferred output; preserving accumulated state",
+				_scene.state.roomName.c_str());
+		}
+
+		if (exitInteraction.continuationTag.empty())
+			return Common::kNoError;
+
+		InteractionResult continuationInteraction;
+		if (!_engine.getScript()->executeActionTag(
+				exitInteraction.continuationTag, continuationInteraction, false,
+				_scene.state.roomName)) {
+			return Common::kNoError;
+		}
+
+		exitInteraction = Common::move(continuationInteraction);
+	}
+
+	warning("Harvester: room exit command chain for '%s' exceeded continuation safety limit",
+		_scene.state.roomName.c_str());
+	return Common::kNoError;
+}
+
+void RoomSession::applyPlayerGotoXZ(int x, int z) {
+	_playerState.hasMoveTarget = false;
+	_playerState.turnActive = false;
+	_playerState.turnTargetFacing = -1;
+	_playerState.centerX = x;
+	_playerState.z = (float)z;
+	_playerState.bottomY = mapRoomDepthToScreenYForCombat(
+		_scene.state, _playerState.z, _playerState.bottomY);
+	Common::Rect screenRect;
+	bool hasScreenRect = false;
+	float depthScale = 0.0f;
+	if (_playerState.entity) {
+		(void)applyRoomActorPlacement(_scene.state, *_playerState.entity,
+			_playerState.centerX, _playerState.bottomY, _playerState.z);
+		if (_entityManager)
+			_entityManager->reinsertSceneEntity(_playerState.entity);
+		screenRect = _playerState.entity->getScreenRect();
+		hasScreenRect = true;
+		depthScale = _playerState.entity->getDepthScale();
+	}
+	debugC(1, kDebugPlayer,
+		"Harvester: PC_GOTO_XZ applied x=%d z=%d pos=(%d,%d,z=%.2f) screen_rect=(%d,%d)-(%d,%d) depth_scale=%.3f",
+		x, z, _playerState.centerX, _playerState.bottomY, (double)_playerState.z,
+		hasScreenRect ? screenRect.left : 0, hasScreenRect ? screenRect.top : 0,
+		hasScreenRect ? screenRect.right : 0, hasScreenRect ? screenRect.bottom : 0,
+		(double)depthScale);
+	resetIdleState();
+	captureCurrentSaveState();
+}
+
+void RoomSession::requestPlayerGameOver(const char *reason, const Common::String &sourceName) {
+	if (_flow.hasPendingMainMenuReturn())
+		return;
+
+	debugC(1, kDebugCombat,
+		"Harvester: player game over queued reason='%s' source='%s' hp=%d",
+		reason ? reason : "", sourceName.c_str(),
+		_engine.getScript()
+			? _engine.getScript()->getPlayerCurrentHitPoints()
+			: 0);
+	_flow.requestGameOverReturn();
+}
+
+void RoomSession::startPlayerDefeatSequence(const char *reason, const Common::String &sourceName, int damageType) {
+	if (_flow.hasPendingMainMenuReturn() || _playerState.deathActive)
+		return;
+
+	if (_idleState.entity) {
+		_idleState.entity->setVisible(false);
+		_idleState.entity->setAnimationRate(0);
+	}
+	_idleState.active = false;
+	_idleState.loopStarted = false;
+	_idleState.exiting = false;
+	_idleState.restoreFacing = -1;
+	if (_playerState.entity)
+		_playerState.entity->setVisible(true);
+
+	if (!Player::startDeathAnimation(_playerState, damageType, _engine.isGoreEnabled())) {
+		debugC(1, kDebugCombat,
+			"Harvester: player defeat fallback reason='%s' source='%s' damage_type=%d hp=%d",
+			reason ? reason : "", sourceName.c_str(), damageType,
+			_engine.getScript()
+				? _engine.getScript()->getPlayerCurrentHitPoints()
+				: 0);
+		requestPlayerGameOver(reason, sourceName);
+		return;
+	}
+
+	debugC(1, kDebugCombat,
+		"Harvester: player defeat started reason='%s' source='%s' damage_type=%d hp=%d",
+		reason ? reason : "", sourceName.c_str(), damageType,
+		_engine.getScript()
+			? _engine.getScript()->getPlayerCurrentHitPoints()
+			: 0);
+}
+
+RoomInputResult RoomSession::runRoomEntryInteraction(const InteractionResult &roomEntryInteraction) {
+	bool didEntryTransition = false;
+	Common::Error entryInteractionError = _interactionProcessor.handleInteractionResult(
+		roomEntryInteraction, didEntryTransition, Common::String());
+	if (entryInteractionError.getCode() != Common::kNoError)
+		return RoomInputResult::leaveRoom(entryInteractionError);
+	if (_flow.hasPendingMainMenuReturn() || didEntryTransition)
+		return RoomInputResult::leaveRoom(Common::kNoError);
+	return RoomInputResult();
+}
+
+void RoomSession::updateRoomInterface(bool &needsRedraw) {
+	captureCurrentSaveState();
+	if (_inventory.isOpen() && _inventory.refreshIfRuntimeStateChanged())
+		needsRedraw = true;
+	if (!_presentationState.combatLoadoutStatusText.value.empty() &&
+			_presentationState.combatLoadoutStatusHideTick != 0 &&
+			(int32)(Player::getRuntimeClockTicks() - _presentationState.combatLoadoutStatusHideTick) >= 0) {
+		_presentationState.combatLoadoutStatusText = ResolvedText();
+		_presentationState.combatLoadoutStatusHideTick = 0;
+		if (_presentationState.closeInventoryAfterCombatLoadoutStatus) {
+			_presentationState.closeInventoryAfterCombatLoadoutStatus = false;
+			if (_inventory.close())
+				needsRedraw = true;
+		}
+		needsRedraw = true;
+	}
+}
+
+bool RoomSession::drawRoomFrame(bool &needsRedraw) {
+	if (!needsRedraw)
+		return true;
+
+	Graphics::Screen *activeScreen = _engine.getScreen();
+	if (!activeScreen)
+		return false;
+
+	const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
+	const bool inventorySelectionActive = _inventory.hasSelection();
+	const bool roomCarryActive = _roomItems.hasCarriedRoomItem();
+	const bool activeCarry = inventorySelectionActive || roomCarryActive;
+	const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_flow._mousePos);
+	const bool suppressHover = _presentationState.showingInspectText || _idleState.active || _idleState.exiting ||
+		isPlayerCombatLocked() ||
+		(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
+	RoomHoverState hoverState = suppressHover
+		? RoomHoverState()
+		: resolveRoomHoverState(_engine, _scene.state, _scene.sceneObjects, _scene.state.roomNpcs,
+			_scene.sceneRegions, _flow._mousePos, _flow._menuTextConfig, &_flow._dialogue);
+	if (!suppressHover && inventorySelectionActive && !hoverState.npc) {
+		if (ObjectRecord *selectedTarget = _roomItems.findSelectedInventoryRoomTarget(_flow._mousePos))
+			hoverState.object = selectedTarget;
+	}
+	Common::String promptText;
+	Common::String inventoryTooltipText;
+	auto resolveCarryTargetLabel = [&]() {
+		if (hoverState.playerEntity && _playerState.entity && hoverState.playerEntity == _playerState.entity)
+			return Common::String(kPlayerInventoryLabel);
+		if (hoverState.object)
+			return _engine.getScript()->resolveObjectLabel(*hoverState.object);
+		if (hoverState.npc)
+			return resolveStartupNpcLabel(*hoverState.npc);
+		return Common::String();
+	};
+	if (activeCarry) {
+		Common::String targetLabel;
+		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow._mousePos);
+		if (_inventory.isOpen()) {
+			if (inventoryHover && !InventorySystem::isExitObject(inventoryHover->object) &&
+					!InventorySystem::isStatusObject(inventoryHover->object) &&
+					!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
+				targetLabel = _engine.getScript()->resolveObjectLabel(inventoryHover->object);
+			} else if (!inventoryPanelContainsMouse) {
+				targetLabel = resolveCarryTargetLabel();
+			}
+		} else {
+			targetLabel = resolveCarryTargetLabel();
+		}
+		if (inventorySelectionActive) {
+			promptText = _inventory.buildSelectedPrompt(targetLabel, _flow._menuTextConfig);
+			_inventory.setPromptText(promptText);
+		} else {
+			promptText = buildUseItemPrompt(
+				_flow._menuTextConfig, _roomItems.getCarriedRoomItemLabel(), targetLabel);
+		}
+		hoverState.cursorSequence = 7;
+	} else if (_inventory.isOpen()) {
+		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow._mousePos);
+		if (inventoryHover &&
+				!InventorySystem::isExitObject(inventoryHover->object)) {
+			inventoryTooltipText =
+				_engine.getScript()->resolveInventoryTooltipText(inventoryHover->object);
+		}
+		_inventory.setPromptText(Common::String());
+		hoverState.cursorSequence = 7;
+	} else {
+		promptText = hoverState.promptText;
+	}
+	if (!_presentationState.showingInspectText)
+		_presentationState.currentInteractionPromptText = promptText;
+	if (Entity *cursor = _entityManager ? _entityManager->getCursorEntity() : nullptr) {
+		cursor->setAnimationSequence(
+			(_presentationState.showingInspectText || _idleState.active || _idleState.exiting ||
+				isPlayerCombatLocked() ||
+				_inventory.isOpen() || activeCarry)
+				? 7
+				: hoverState.cursorSequence);
+	}
+
+	drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
+	_combatEffects.drawCombatDamagePopups(*activeScreen, _engine.isCombatDebugEnabled());
+	if (_engine.getScript() && _playerState.entity) {
+		drawCombatLoadoutResourceIcons(*activeScreen, *_view.art, *_engine.getScript(),
+			_engine.getScript()->getPlayerCombatLoadout());
+	}
+	if (_inventory.isOpen()) {
+		_inventory.drawOverlay(*activeScreen);
+		drawInventoryWeekday(*activeScreen, *_view.inventoryTooltipFont,
+			_inventory.resolveWeekdayLabel(_flow._menuTextConfig));
+	}
+	if (inventorySelectionActive) {
+		_inventory.drawSelectedDragItem(*activeScreen, _flow._mousePos);
+	} else if (roomCarryActive && _roomItems.getCarriedRoomItemBitmap().isValid()) {
+		const int drawX = _flow._mousePos.x - (int)_roomItems.getCarriedRoomItemBitmap().width / 2;
+		const int drawY = _flow._mousePos.y - (int)_roomItems.getCarriedRoomItemBitmap().height / 2;
+		RoomPresentation::blitBitmap(*activeScreen, _roomItems.getCarriedRoomItemBitmap(), drawX, drawY);
+	}
+
+	if (_presentationState.showingInspectText) {
+		drawRoomInspectText(*activeScreen, *_view.art, *_view.inspectFont, _presentationState.inspectText, _view.useNativeInspectFont);
+		if (!_presentationState.currentInteractionPromptText.empty())
+			drawRoomPrompt(*activeScreen, *_view.promptFont,
+				_presentationState.currentInteractionPromptText, _view.useNativePromptFont);
+	} else if (!_presentationState.combatLoadoutStatusText.value.empty()) {
+		drawRoomInspectText(*activeScreen, *_view.art, *_view.inspectFont, _presentationState.combatLoadoutStatusText,
+			_view.useNativeInspectFont);
+	} else if (!inventoryTooltipText.empty()) {
+		drawInventoryTooltip(*activeScreen, *_view.inventoryTooltipFont, inventoryTooltipText);
+	} else if (!promptText.empty()) {
+		drawRoomPrompt(*activeScreen, *_view.promptFont, promptText, _view.useNativePromptFont);
+	}
+
+	if (_entityManager)
+		_entityManager->drawCursor(*activeScreen);
+	activeScreen->makeAllDirty();
+	activeScreen->update();
+	needsRedraw = false;
+
+	return true;
+}
+
+RoomInputResult RoomSession::pollRoomEvents(RoomInput &input, bool &needsRedraw) {
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		const RoomInputResult inputResult = input.handleEvent(event, needsRedraw);
+		if (inputResult.returnFromRoom)
+			return inputResult;
+	}
+	return RoomInputResult();
+}
+
+RoomSession::PlayerControlState RoomSession::preparePlayerControlState() {
+	Script *script = _engine.getScript();
+	const bool playerAlive = script && script->getPlayerCurrentHitPoints() > 0;
+	const bool playerControlPaused = script && script->isPlayerControlPaused();
+	const bool playerCanAct = playerAlive && !playerControlPaused;
+	if (!playerAlive && (_inputState.moveLeft || _inputState.moveRight || _inputState.moveUp || _inputState.moveDown ||
+			_playerState.hasMoveTarget || _playerState.turnActive ||
+			_playerState.attackActive || _playerState.hitActive)) {
+		_inputState.attackModifierHeld = false;
+		stopPlayerRegionInteraction();
+	}
+
+	PlayerControlState control;
+	control.alive = playerAlive;
+	control.paused = playerControlPaused;
+	control.canAct = playerCanAct;
+	return control;
+}
+
+void RoomSession::advanceRuntimeEntities(bool &needsRedraw) {
+	if (_flow.tickRuntimeEntities())
+		needsRedraw = true;
+	syncAnimatedRoomActorPlacement();
+}
+
+void RoomSession::updatePlayerCombatAnimations(bool &needsRedraw) {
+	if (Player::updateAttackAnimationState(_engine, _playerState))
+		needsRedraw = true;
+	if (Player::updateHitAnimationState(
+			_engine, _scene.state, _scene.sceneObjects, _scene.sceneAnimations, _playerState)) {
+		needsRedraw = true;
+	}
+}
+
+void RoomSession::updatePlayerControls(const PlayerControlState &control, bool &needsRedraw) {
+	const bool keyboardAttackRequested =
+		_inputState.attackModifierHeld && (_inputState.moveLeft || _inputState.moveRight || _inputState.moveUp || _inputState.moveDown);
+	if (control.canAct &&
+			!_playerState.attackActive && !_playerState.hitActive && keyboardAttackRequested &&
+			!_idleState.active && !_idleState.exiting) {
+		const uint32 now = Player::getRuntimeClockTicks();
+		if (_inputState.nextKeyboardAttackAllowedTick == 0 || (int32)(now - _inputState.nextKeyboardAttackAllowedTick) >= 0) {
+			_combat.capturePlayerAttackTargetForKeyboard(_scene.state, _playerState, _entityManager, _inputState.moveLeft, _inputState.moveRight, _inputState.moveUp, _inputState.moveDown);
+			if (Player::startKeyboardAttackAnimation(
+					_scene.state, _playerState, _inputState.moveLeft, _inputState.moveRight, _inputState.moveUp, _inputState.moveDown)) {
+				_inputState.nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
+				notePlayerActivity();
+				debugC(1, kDebugCombat,
+					"Harvester: _combat player keyboard attack start loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s' input=(L=%d R=%d U=%d D=%d) facing=%d",
+					_playerState.combatLoadout,
+					Player::describeCombatLoadout(_playerState.combatLoadout),
+					Player::resolveCombatLoadoutDamageAmount(_playerState.combatLoadout),
+					Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(_playerState.combatLoadout)),
+					describeCombatTargetClass(_playerState.attackTargetClassId),
+					_playerState.attackTargetName.c_str(),
+					_inputState.moveLeft, _inputState.moveRight, _inputState.moveUp, _inputState.moveDown, _playerState.facing);
+				needsRedraw = true;
+			} else {
+				_playerState.attackTargetName.clear();
+				_playerState.attackTargetClassId = -1;
+			}
+		}
+	}
+	if (control.canAct && !_playerState.attackActive && !_playerState.hitActive &&
+			Player::updateTurnAnimationState(_playerState)) {
+		needsRedraw = true;
+	}
+
+	if (control.canAct &&
+			!_playerState.attackActive && !_playerState.hitActive &&
+			!keyboardAttackRequested && !_idleState.active && !_idleState.exiting) {
+		const int regionFacing = _interactionProcessor.getPendingRegionFacing();
+		if (Player::stepKeyboardMovement(_engine, _scene.state, _scene.sceneObjects, _scene.sceneAnimations,
+				_playerState, _inputState.moveLeft, _inputState.moveRight, _inputState.moveUp, _inputState.moveDown)) {
+			notePlayerActivity();
+			needsRedraw = true;
+		} else if (Player::stepMoveTarget(
+				_engine, _scene.state, _scene.sceneObjects, _scene.sceneAnimations,
+				_playerState, regionFacing)) {
+			notePlayerActivity();
+			needsRedraw = true;
+		}
+
+		if (!_presentationState.showingInspectText && !keyboardAttackRequested &&
+				!_inputState.moveLeft && !_inputState.moveRight && !_inputState.moveUp && !_inputState.moveDown &&
+				!_playerState.hasMoveTarget && !_playerState.turnActive && !_playerState.hitActive &&
+				_playerState.entity && _playerState.facing >= 0 &&
+				!Player::isIdleAnimationExcludedRoom(_scene.state.roomName) &&
+				Player::getRuntimeClockTicks() > _idleState.triggerTick &&
+				Player::startIdleAnimation(_engine, _scene.state, _playerState, _idleState)) {
+			needsRedraw = true;
+		}
+	}
+}
+
+void RoomSession::updatePlayerDeath(bool &needsRedraw) {
+	if (Player::updateDeathAnimationState(_playerState)) {
+		requestPlayerGameOver("combat_player_death_complete", Common::String());
+		needsRedraw = true;
+	}
+}
+
+void RoomSession::pruneCombatEffects(bool &needsRedraw) {
+	if (_combatEffects.pruneCombatHitEffects())
+		needsRedraw = true;
+	if (_combatEffects.pruneCombatDamagePopups())
+		needsRedraw = true;
+}
+
+RoomInputResult RoomSession::dispatchExpiredTimers(bool &needsRedraw) {
+	if (_entityManager) {
+		Common::Array<Common::String> expiredTimerNames;
+		if (_entityManager->takeExpiredTimerNames(expiredTimerNames)) {
+			_sceneRuntime.syncCurrentRoomRuntimeState(_scene.sceneRegions);
+			for (const Common::String &timerName : expiredTimerNames) {
+				InteractionResult timerInteraction;
+				if (!_engine.getScript()->executeTimerAction(
+						timerName, timerInteraction, true, _scene.state.roomName))
+					continue;
+
+				bool didTransition = false;
+				Common::Error interactionError =
+					_interactionProcessor.handleInteractionResult(
+						timerInteraction, didTransition, Common::String(), false);
+				if (interactionError.getCode() != Common::kNoError)
+					return RoomInputResult::leaveRoom(interactionError);
+				if (_flow.hasPendingMainMenuReturn())
+					return RoomInputResult::leaveRoom(Common::kNoError);
+				needsRedraw = true;
+				if (!_pendingTransition.roomName.empty())
+					break;
+			}
+		}
+	}
+	return RoomInputResult();
+}
+
+RoomInputResult RoomSession::runRoomFrames(const Common::String &currentRoomTarget) {
+	RoomInput input(_engine, _flow, *this, _scene, _inventory, _cheats,
+		_roomItems, _interactionProcessor, _presentationState, *_view.art, _canExitCloseupToParent);
+	bool needsRedraw = true;
+
+	if (!_inventory.refresh())
+		return RoomInputResult::leaveRoom(Common::kReadingFailed);
+	Graphics::FrameLimiter limiter(g_system, 60);
+	captureCurrentSaveState();
+
+	if (shouldRunStartupRoomProbe())
+		logStartupRoomProbe(_engine, _scene, currentRoomTarget, _flow._mousePos, _flow._menuTextConfig);
+
+	while (!_engine.shouldQuit()) {
+		if (_flow.hasPendingMainMenuReturn())
+			return RoomInputResult::leaveRoom(Common::kNoError);
+		if (_flow.takePendingNewGameRestart()) {
+			_flow.prepareForNewGame();
+			_pendingTransition.roomName = "START";
+			_pendingTransition.isRoomName = false;
+			_pendingTransition.usesSavedRoomState = false;
+			_pendingTransition.savedRoomState.clear();
+			if (!_roomItems.stowCarriedRoomItemToInventory())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			break;
+		}
+		if (_engine.hasPendingLoadedSaveRoomState()) {
+			_pendingTransition.roomName = _engine.getPendingLoadedSaveRoomState().entranceName;
+			_pendingTransition.isRoomName = false;
+			_pendingTransition.usesSavedRoomState = false;
+			_pendingTransition.savedRoomState.clear();
+			if (_pendingTransition.roomName.empty()) {
+				_pendingTransition.roomName = _engine.getPendingLoadedSaveRoomState().roomName;
+				_pendingTransition.isRoomName = true;
+			}
+			if (!_roomItems.stowCarriedRoomItemToInventory())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			break;
+		}
+		if (_flow.hasPendingDebugRoomChange()) {
+			if (!_roomItems.stowCarriedRoomItemToInventory())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			return RoomInputResult::leaveRoom(Common::kNoError);
+		}
+		if (!_pendingTransition.roomName.empty()) {
+			if (!_roomItems.stowCarriedRoomItemToInventory())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			break;
+		}
+		InteractionResult queuedDebugInteraction;
+		if (_flow.takeQueuedDebugInteraction(queuedDebugInteraction)) {
+			// Run console-injected commands through the normal room interaction handler so
+			// CHANGE_ROOM, modal text, dialogue, and chained tags behave exactly like script-driven actions.
+			bool didDebugTransition = false;
+			Common::Error debugInteractionError = _interactionProcessor.handleInteractionResult(
+				queuedDebugInteraction, didDebugTransition, Common::String());
+			if (debugInteractionError.getCode() != Common::kNoError)
+				return RoomInputResult::leaveRoom(debugInteractionError);
+			if (_flow.hasPendingMainMenuReturn())
+				return RoomInputResult::leaveRoom(Common::kNoError);
+			if (!_pendingTransition.roomName.empty()) {
+				if (!_roomItems.stowCarriedRoomItemToInventory())
+					return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				break;
+			}
+			needsRedraw = true;
+		}
+		updateRoomInterface(needsRedraw);
+		if (!drawRoomFrame(needsRedraw))
+			return RoomInputResult::leaveRoom(Common::kNoError);
+
+		const RoomInputResult inputResult = pollRoomEvents(input, needsRedraw);
+		if (inputResult.returnFromRoom)
+			return inputResult;
+		if (!_pendingTransition.roomName.empty()) {
+			if (!_roomItems.stowCarriedRoomItemToInventory())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			break;
+		}
+
+		const PlayerControlState control = preparePlayerControlState();
+		// Native run_harvester_main_loop checks player/region overlap before
+		// update_actor_runtime_state. This is significant when a wide walk frame
+		// reaches a narrow exit: the actor update may otherwise satisfy the X
+		// target and replace that frame with a narrower idle frame first.
+		Common::Error pendingRegionError =
+			tryActivatePendingRegion(needsRedraw);
+		if (pendingRegionError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(pendingRegionError);
+		if (_flow.hasPendingMainMenuReturn())
+			return RoomInputResult::leaveRoom(Common::kNoError);
+		if (!_pendingTransition.roomName.empty()) {
+			if (!_roomItems.stowCarriedRoomItemToInventory())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			break;
+		}
+		if (!isPlayerCombatLocked()) {
+			pendingRegionError = tryActivatePassiveRegion(needsRedraw);
+			if (pendingRegionError.getCode() != Common::kNoError)
+				return RoomInputResult::leaveRoom(pendingRegionError);
+			if (_flow.hasPendingMainMenuReturn())
+				return RoomInputResult::leaveRoom(Common::kNoError);
+			if (!_pendingTransition.roomName.empty()) {
+				if (!_roomItems.stowCarriedRoomItemToInventory())
+					return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				break;
+			}
+		}
+
+		// At 60 Hz, consume animation advances before the next screen update so
+		// a walk frame and its movement step are presented together.
+		advanceRuntimeEntities(needsRedraw);
+		Common::Error combatError = Common::kNoError;
+		if (!control.paused) {
+			combatError = resolvePlayerAttackContact(needsRedraw);
+			if (combatError.getCode() != Common::kNoError)
+				return RoomInputResult::leaveRoom(combatError);
+			if (_flow.hasPendingMainMenuReturn())
+				return RoomInputResult::leaveRoom(Common::kNoError);
+			if (!_pendingTransition.roomName.empty()) {
+				if (!_roomItems.stowCarriedRoomItemToInventory())
+					return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				break;
+			}
+			updatePlayerCombatAnimations(needsRedraw);
+		}
+		updatePlayerControls(control, needsRedraw);
+		combatError = updateRoomMonsterCombat(needsRedraw);
+		if (combatError.getCode() != Common::kNoError)
+			return RoomInputResult::leaveRoom(combatError);
+		syncCombatHitEffects(needsRedraw);
+		if (_flow.hasPendingMainMenuReturn())
+			return RoomInputResult::leaveRoom(Common::kNoError);
+		if (!_pendingTransition.roomName.empty()) {
+			if (!_roomItems.stowCarriedRoomItemToInventory())
+				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			break;
+		}
+		updatePlayerDeath(needsRedraw);
+		if (_flow.hasPendingMainMenuReturn())
+			return RoomInputResult::leaveRoom(Common::kNoError);
+		pruneCombatEffects(needsRedraw);
+		const RoomInputResult timerResult = dispatchExpiredTimers(needsRedraw);
+		if (timerResult.returnFromRoom)
+			return timerResult;
+		updateIdleAnimation(needsRedraw);
+		if (_engine.isCombatDebugEnabled() && _combatEffects.hasDamagePopups())
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+	if (_engine.shouldQuit())
+		return RoomInputResult::leaveRoom(Common::kNoError);
+	if (_flow.hasPendingMainMenuReturn())
+		return RoomInputResult::leaveRoom(Common::kNoError);
+	if (_pendingTransition.roomName.empty())
+		return RoomInputResult::leaveRoom(Common::kNoError);
+	if (_pendingTransition.roomName.equals(kExitCloseupPendingRoomChange)) {
+		_flow.requestCloseupParentRestart();
+		return RoomInputResult::leaveRoom(Common::kNoError);
+	}
+
+	return RoomInputResult();
+}
+
+void RoomSession::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) {
+	_sceneRuntime.syncGlobalTimerEntities(previousTimerRecords);
+}
+
+bool RoomSession::captureDialogueBackdrop(IndexedBitmap &backdrop) {
+	return _presentation.captureDialogueBackdrop(backdrop);
+}
+
+Common::Error RoomSession::showCdChangePrompt(int discNumber) {
+	return _flow.showCdChangePrompt(discNumber);
+}
+
+Common::Error RoomSession::applyLightingCommand(StartupLightingCommand command) {
+	return _presentation.applyLightingCommand(command);
+}
+
+Common::Error RoomSession::runModalShowText(const ResolvedText &text) {
+	return _presentation.runModalShowText(text, _flow);
+}
+
+Common::Error RoomSession::tryActivatePendingRegion(bool &needsRedraw) {
+	return isPlayerCombatLocked() ? Common::kNoError : _interactionProcessor.tryActivatePendingRegion(_inventory, needsRedraw);
+}
+
+Common::Error RoomSession::tryActivatePassiveRegion(bool &needsRedraw) {
+	return _interactionProcessor.tryActivatePassiveRegion(_inventory, needsRedraw);
+}
+
+Common::Error RoomSession::resolvePlayerAttackContact(bool &needsRedraw) {
+	return _combat.resolvePlayerAttackContact(_engine, _scene.state, _playerState, _entityManager,
+		_sceneRuntime, _combatEffects, _interactionProcessor, needsRedraw);
+}
+
+Common::Error RoomSession::updateRoomMonsterCombat(bool &needsRedraw) {
+	return _combat.updateRoomMonsterCombat(_engine, _scene.state, _playerState, _idleState,
+		_entityManager, _sceneRuntime, _combatEffects, _cheats, *this, _interactionProcessor, needsRedraw);
+}
+
+void RoomSession::syncCombatHitEffects(bool &needsRedraw) {
+	if (_combatEffects.syncCombatHitEffects())
+		needsRedraw = true;
+}
+
+void RoomSession::updateIdleAnimation(bool &needsRedraw) {
+	if (Player::updateIdleAnimation(_scene.state, _playerState, _idleState))
+		needsRedraw = true;
+}
+
+void RoomSession::capturePlayerAttackTarget(const Common::Point &mousePos, const NpcRecord *npc) {
+	_combat.capturePlayerAttackTarget(_scene.state, _playerState, _entityManager, mousePos, npc);
+}
+
+Common::Error RoomSession::killDebugActiveMonster(const Common::Point &mousePos, bool &needsRedraw) {
+	return _combat.killDebugActiveMonster(_engine, _scene.state, _playerState, _entityManager,
+		mousePos, _combatEffects, _interactionProcessor, needsRedraw);
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/room_session.h b/engines/harvester/room_session.h
index 3642623f263..55f3e7e52d4 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/room_session.h
@@ -24,6 +24,8 @@
 
 #include "harvester/room_combat.h"
 #include "harvester/room_input.h"
+#include "harvester/room_interaction.h"
+#include "harvester/room_presentation.h"
 #include "harvester/room_scene_runtime.h"
 #include "harvester/room_support.h"
 #include "harvester/runtime_entity.h"
@@ -34,32 +36,76 @@ class EntityManager;
 class Flow;
 class InventorySystem;
 
+struct RoomPendingTransition {
+	Common::String roomName;
+	bool isRoomName = false;
+	bool usesSavedRoomState = false;
+	SaveRoomState savedRoomState;
+};
+
 // Scoped to one runRoomLoop iteration, including time spent in nested closeups.
 // Entity pointers remain owned by EntityManager.
-class RoomSession {
+class RoomSession : public RoomInteractionCallbacks {
 public:
 	RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
-		InventorySystem &inventory);
+		InventorySystem &inventory, CheatSystem &cheats, const RoomViewResources &view, bool canExitCloseupToParent);
 
 	RoomPlayerState &getPlayerState() { return _playerState; }
-	RoomCombat &getCombat() { return _combat; }
-	RoomSceneRuntime &getSceneRuntime() { return _sceneRuntime; }
-	RoomCombatEffects &getCombatEffects() { return _combatEffects; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
 	RoomInputState &getInputState() { return _inputState; }
-	Common::String &getPendingRegionName() { return _pendingRegionName; }
 
-	void resetIdleState();
-	void notePlayerActivity();
+	void initializePlayerState();
+	RoomInputResult runRoomEntryInteraction(const InteractionResult &interaction);
+	RoomInputResult runRoomFrames(const Common::String &currentRoomTarget);
+	const RoomPendingTransition &getPendingTransition() const { return _pendingTransition; }
+	void clearPendingRegionInteraction() { _pendingRegionName.clear(); }
+	void capturePlayerAttackTarget(const Common::Point &mousePos, const NpcRecord *npc);
+	Common::Error killDebugActiveMonster(const Common::Point &mousePos, bool &needsRedraw);
+
+	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) override;
+	bool captureDialogueBackdrop(IndexedBitmap &backdrop) override;
+	Common::Error showCdChangePrompt(int discNumber) override;
+	Common::Error runRoomExitCommands() override;
+	Common::Error applyLightingCommand(StartupLightingCommand command) override;
+	void applyPlayerGotoXZ(int x, int z) override;
+	Common::Error runModalShowText(const ResolvedText &text) override;
+	void startPlayerDefeatSequence(const char *reason, const Common::String &sourceName, int damageType) override;
+	void resetIdleState() override;
+	void notePlayerActivity() override;
 	bool isPlayerCombatLocked() const;
 	bool cancelPlayerAttackAnimation();
-	void stopPlayerRegionInteraction();
+	void stopPlayerRegionInteraction() override;
 	void syncPlayerPlacement();
 	void captureCurrentSaveState();
-	bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState);
-	bool refreshCurrentScene(bool preservePlayerPlacement);
+	bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) override;
+	bool refreshCurrentScene(bool preservePlayerPlacement) override;
 
 private:
+	struct PlayerControlState {
+		bool alive;
+		bool paused;
+		bool canAct;
+	};
+
+	void syncAnimatedRoomActorPlacement();
+	void requestPlayerGameOver(const char *reason, const Common::String &sourceName);
+	void updateRoomInterface(bool &needsRedraw);
+	bool drawRoomFrame(bool &needsRedraw);
+	RoomInputResult pollRoomEvents(RoomInput &input, bool &needsRedraw);
+	PlayerControlState preparePlayerControlState();
+	Common::Error tryActivatePendingRegion(bool &needsRedraw);
+	Common::Error tryActivatePassiveRegion(bool &needsRedraw);
+	void advanceRuntimeEntities(bool &needsRedraw);
+	Common::Error resolvePlayerAttackContact(bool &needsRedraw);
+	void updatePlayerCombatAnimations(bool &needsRedraw);
+	void updatePlayerControls(const PlayerControlState &control, bool &needsRedraw);
+	Common::Error updateRoomMonsterCombat(bool &needsRedraw);
+	void syncCombatHitEffects(bool &needsRedraw);
+	void updatePlayerDeath(bool &needsRedraw);
+	void pruneCombatEffects(bool &needsRedraw);
+	RoomInputResult dispatchExpiredTimers(bool &needsRedraw);
+	void updateIdleAnimation(bool &needsRedraw);
+
 	HarvesterEngine &_engine;
 	Flow &_flow;
 	RoomSceneResources &_scene;
@@ -72,6 +118,14 @@ private:
 	RoomIdleAnimationState _idleState;
 	RoomInputState _inputState;
 	Common::String _pendingRegionName;
+	CheatSystem &_cheats;
+	const RoomViewResources _view;
+	bool _canExitCloseupToParent;
+	RoomPresentationState _presentationState;
+	RoomPendingTransition _pendingTransition;
+	RoomItemInteractions _roomItems;
+	RoomPresentation _presentation;
+	RoomInteractionProcessor _interactionProcessor;
 };
 
 } // End of namespace Harvester


Commit: ccfabd608beec7dccf1bdb9ab1e94168c1b98fac
    https://github.com/scummvm/scummvm/commit/ccfabd608beec7dccf1bdb9ab1e94168c1b98fac
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: begin splitting room/scene handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/scene.cpp
  A engines/harvester/scene.h
  A engines/harvester/scene_entities.cpp
  A engines/harvester/scene_entities.h
  R engines/harvester/room.cpp
  R engines/harvester/room.h
  R engines/harvester/room_scene_runtime.cpp
  R engines/harvester/room_scene_runtime.h
  R engines/harvester/room_session.cpp
  R engines/harvester/room_session.h
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/module.mk
    engines/harvester/room_combat.cpp
    engines/harvester/room_combat.h
    engines/harvester/room_input.cpp
    engines/harvester/room_input.h
    engines/harvester/room_interaction.cpp
    engines/harvester/room_interaction.h
    engines/harvester/room_presentation.cpp
    engines/harvester/room_presentation.h
    engines/harvester/room_support.h


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 2c7ae9ef5b1..c3fbb62dbda 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -43,7 +43,7 @@
 #include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
-#include "harvester/room_scene_runtime.h"
+#include "harvester/scene_entities.h"
 #include "harvester/art.h"
 #include "harvester/monster.h"
 #include "harvester/player.h"
@@ -205,7 +205,7 @@ static void drawRoomDebugLabel(Graphics::Screen &screen, const Graphics::Font &f
 }
 
 static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
-		const RoomSceneResources &scene) {
+		const SceneState &scene) {
 	if (!engine.isRoomDebugEnabled())
 		return;
 
@@ -254,7 +254,7 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 }
 
 static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
-		const RoomSceneResources &scene) {
+		const SceneState &scene) {
 	if (!engine.isCombatDebugEnabled())
 		return;
 
@@ -348,7 +348,7 @@ static void drawPathfindingBlockerOutline(Graphics::Screen &screen, EntityManage
 }
 
 static void drawPathfindingDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
-		const RoomSceneResources &scene) {
+		const SceneState &scene) {
 	if (!engine.isPathfindingDebugEnabled())
 		return;
 
@@ -384,7 +384,7 @@ static Common::String resolveTimerDebugLabel(const TimerRecord &timer, const Ent
 }
 
 static void drawTimerDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
-		const RoomSceneResources &) {
+		const SceneState &) {
 	if (!engine.isTimerDebugEnabled())
 		return;
 
@@ -574,7 +574,7 @@ static uint32 hashPalette(const byte *palette) {
 	return hash;
 }
 
-void logScenePaletteSummary(const char *label, const RoomSceneResources &scene, float brightness) {
+void logScenePaletteSummary(const char *label, const SceneState &scene, float brightness) {
 	const byte *palette = scene.palette;
 	byte minValue = 0;
 	byte maxValue = 0;
@@ -768,8 +768,8 @@ static void queueSceneObject(const char *source, const ObjectRecord &object,
 	logSceneObjectSelection("queued", source, object);
 }
 
-bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resources, RoomSceneResources &scene) {
-	scene = RoomSceneResources();
+bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resources, SceneState &scene) {
+	scene = SceneState();
 	scene.state = state;
 	scene.targetPaletteBrightness = state.paletteBrightness;
 	if (!loadPaletteResource(resources, state.palettePath, scene.palette)) {
@@ -804,7 +804,7 @@ bool shouldRunStartupRoomProbe() {
 		ConfMan.getBool("harvester_debug_probe_startup_room");
 }
 
-void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const RoomSceneResources &scene,
+void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const SceneState &scene,
 		float brightness) {
 	setScaledPalette(screen, scene.palette, brightness);
 	screen.fillRect(screen.getBounds(), 0);
@@ -1201,7 +1201,7 @@ static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Arra
 	return false;
 }
 
-void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scene,
+void logStartupRoomProbe(HarvesterEngine &engine, const SceneState &scene,
 		const Common::String &entranceName, Common::Point &mousePos,
 		const MenuTextConfig &menuTextConfig) {
 	EntityManager *entityManager = engine.getRuntimeEntities();
@@ -1329,7 +1329,7 @@ static Common::String trimAsciiLine(const Common::String &value) {
 	return value.substr(start, end - start);
 }
 
-static bool loadQuickTipsScene(HarvesterEngine &engine, RoomSceneResources &scene) {
+static bool loadQuickTipsScene(HarvesterEngine &engine, SceneState &scene) {
 	RoomSetupState state;
 	// The original startup path enters the START room first and overlays quick tips on top of it.
 	if (!engine.getScript()->resolveRoomSetupState("START", state, *engine.getResources()))
@@ -1338,7 +1338,7 @@ static bool loadQuickTipsScene(HarvesterEngine &engine, RoomSceneResources &scen
 	return loadRoomSceneResources(state, *engine.getResources(), scene);
 }
 
-static void renderQuickTipsScreen(HarvesterEngine &engine, const RoomSceneResources &scene,
+static void renderQuickTipsScreen(HarvesterEngine &engine, const SceneState &scene,
 		const MenuTextConfig &config, const QuickTipsLayout &layout,
 		const Common::String &tipText) {
 	Graphics::Screen *screen = engine.getScreen();
@@ -1510,7 +1510,7 @@ Common::Error Flow::runQuickTips() {
 	if (transitionError.getCode() != Common::kNoError)
 		return transitionError;
 
-	RoomSceneResources scene;
+	SceneState scene;
 	if (!loadQuickTipsScene(_engine, scene))
 		return Common::kReadingFailed;
 
@@ -2142,7 +2142,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 	if (!entityManager)
 		return false;
 
-	RoomSceneRuntime sceneRuntime(state, _engine.getScript(), _engine.getResources(), entityManager);
+	SceneEntities sceneRuntime(state, _engine.getScript(), _engine.getResources(), entityManager);
 	Entity *preservedPlayer = entityManager->detachSceneEntityByName(kPlayerActorEntityName);
 	entityManager->clearSceneEntities(true);
 	sceneRuntime.populateRoomRegions();
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index c7328121d5e..78a204857a0 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -30,7 +30,7 @@
 #include "harvester/dialogue.h"
 #include "harvester/inventory.h"
 #include "harvester/menu.h"
-#include "harvester/room.h"
+#include "harvester/scene.h"
 #include "harvester/script.h"
 
 namespace Common {
@@ -62,9 +62,9 @@ private:
 	friend class InventorySystem;
 	friend class MenuSystem;
 	friend class RoomInteractionProcessor;
-	friend class RoomSystem;
+	friend class SceneRunner;
 	friend class RoomInput;
-	friend class RoomSession;
+	friend class Scene;
 
 	bool loadQuickTips();
 	bool loadMenuItems();
@@ -128,7 +128,7 @@ private:
 	InventorySystem _inventory;
 	MenuSystem _menu;
 	CheatSystem _cheats;
-	RoomSystem _room;
+	SceneRunner _room;
 	InteractionResult _queuedDialogueInteraction;
 	bool _hasQueuedDialogueInteraction = false;
 	InteractionResult _queuedDebugInteraction;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 3487d10e819..1955e9fe591 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -21,9 +21,8 @@ MODULE_OBJS = \
 	room_interaction.o \
 	room_input.o \
 	room_presentation.o \
-	room_scene_runtime.o \
-	room_session.o \
-	room.o \
+	scene_entities.o \
+	scene.o \
 	runtime_entity.o \
 	saveload.o \
 	script.o \
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
deleted file mode 100644
index e67ef6539b2..00000000000
--- a/engines/harvester/room.cpp
+++ /dev/null
@@ -1,273 +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 "harvester/room.h"
-
-#include "common/algorithm.h"
-#include "common/events.h"
-#include "common/ptr.h"
-#include "common/system.h"
-#include "graphics/blit.h"
-#include "graphics/font.h"
-#include "graphics/fontman.h"
-#include "graphics/framelimiter.h"
-#include "harvester/cft_font.h"
-#include "harvester/cheats.h"
-#include "harvester/detection.h"
-#include "harvester/fst_player.h"
-#include "harvester/harvester.h"
-#include "harvester/resources.h"
-#include "harvester/runtime_entity.h"
-#include "harvester/art.h"
-#include "harvester/flow.h"
-#include "harvester/inventory.h"
-#include "harvester/monster.h"
-#include "harvester/player.h"
-#include "harvester/room_combat.h"
-#include "harvester/room_interaction.h"
-#include "harvester/room_input.h"
-#include "harvester/room_presentation.h"
-#include "harvester/room_session.h"
-#include "harvester/room_support.h"
-
-namespace Harvester {
-
-static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *text = engine.getText();
-	if (!text || !fontName)
-		return nullptr;
-
-	for (const CftFontResource &font : text->getFonts()) {
-		if (font.name.equalsIgnoreCase(fontName))
-			return &font;
-	}
-
-	return nullptr;
-}
-
-RoomSystem::RoomSystem(HarvesterEngine &engine,
-		InventorySystem &inventory, CheatSystem &cheats)
-	: _engine(engine), _inventory(inventory), _cheats(cheats) {
-}
-
-Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetName,
-		bool targetIsRoomName) {
-	if (flow.hasPendingMainMenuReturn())
-		return Common::kNoError;
-
-	Common::String currentRoomTarget = targetName;
-	bool currentTargetIsRoomName = targetIsRoomName;
-	bool currentTargetUsesSavedRoomState = false;
-	SaveRoomState currentTargetSavedRoomState;
-	while (!currentRoomTarget.empty()) {
-		RoomSetupState state;
-		bool shouldRunRoomEntryCommands = false;
-		if (_engine.hasPendingLoadedSaveRoomState()) {
-			// Native load destroys live render entities and frees preserved global timers
-			// before room_setup rebuilds the loaded room from restored TIMER records.
-			if (EntityManager *entityManager = _engine.getRuntimeEntities())
-				entityManager->clearSceneEntities(false);
-			flow.resetRoomNpcDialogueState();
-			if (_engine.hasPendingLoadedDialogueStateBlob()) {
-				if (!flow.loadDialogueSaveStateBlob(
-						_engine.getPendingLoadedDialogueStateBlob(),
-						_engine.getPendingLoadedDialogueStateBlobVersion()))
-					return Common::kReadingFailed;
-				_engine.clearPendingLoadedDialogueStateBlob();
-			}
-			if (_engine.getPendingLoadedDisc() > 0 &&
-					!_engine.activateDisc(_engine.getPendingLoadedDisc())) {
-				return Common::kReadingFailed;
-			}
-			const SaveRoomState &loadedState = _engine.getPendingLoadedSaveRoomState();
-			debugC(1, kDebugRoom,
-				"Harvester: applying pending loaded room state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
-				loadedState.entranceName.c_str(), loadedState.roomName.c_str(),
-				loadedState.playerX, loadedState.playerY, loadedState.playerZ,
-				loadedState.playerFacing, loadedState.musicPath.c_str());
-			if (!_engine.getScript()->materializeRoomState(
-					loadedState.entranceName, loadedState.roomName, state, *_engine.getResources())) {
-				return Common::kReadingFailed;
-			}
-			state.entranceName = loadedState.entranceName;
-			state.playerSpawnX = loadedState.playerX;
-			state.playerSpawnY = loadedState.playerY;
-			state.playerSpawnZ = loadedState.playerZ;
-			state.playerFacing = loadedState.playerFacing;
-			state.musicPath = loadedState.musicPath;
-			currentRoomTarget = !loadedState.entranceName.empty() ? loadedState.entranceName : loadedState.roomName;
-			_engine.clearPendingLoadedSaveRoomState();
-			currentTargetIsRoomName = false;
-			shouldRunRoomEntryCommands = false;
-		} else if ((currentTargetIsRoomName
-				? !_engine.getScript()->resolveRoomSetupStateByRoomName(
-					currentRoomTarget, state, *_engine.getResources())
-				: !_engine.getScript()->resolveRoomSetupState(
-					currentRoomTarget, state, *_engine.getResources()))) {
-			return Common::kReadingFailed;
-		} else {
-			shouldRunRoomEntryCommands = true;
-		}
-		if (shouldRunRoomEntryCommands && currentTargetUsesSavedRoomState &&
-				currentTargetSavedRoomState.valid) {
-			const SaveRoomState &savedState = currentTargetSavedRoomState;
-			state.entranceName = savedState.entranceName;
-			state.playerSpawnX = savedState.playerX;
-			state.playerSpawnY = savedState.playerY;
-			state.playerSpawnZ = savedState.playerZ;
-			state.playerFacing = savedState.playerFacing;
-			if (!savedState.musicPath.empty())
-				state.musicPath = savedState.musicPath;
-			debugC(1, kDebugRoom,
-				"Harvester: applying saved room restart state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
-				savedState.entranceName.c_str(), savedState.roomName.c_str(),
-				savedState.playerX, savedState.playerY, savedState.playerZ,
-				savedState.playerFacing, savedState.musicPath.c_str());
-		}
-
-		Common::Error transitionError = flow.beginRoomSetupTransition();
-		if (transitionError.getCode() != Common::kNoError)
-			return transitionError;
-
-		RoomSceneResources scene;
-		if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
-			return Common::kReadingFailed;
-
-		Graphics::Screen *screen = _engine.getScreen();
-		const Art *art = _engine.getArt();
-		const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-		const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
-		const CftFontResource *inventoryTooltipFontResource = findStartupFontByName(_engine, "TEXTFONT");
-		Common::ScopedPtr<HarvesterCftFont> promptCftFont;
-		Common::ScopedPtr<HarvesterCftFont> inventoryTooltipCftFont;
-		const Graphics::Font *promptFont = bodyFont;
-		const Graphics::Font *inspectFont = bodyFont;
-		const Graphics::Font *inventoryTooltipFont = bodyFont;
-		bool useNativePromptFont = false;
-		bool useNativeInspectFont = false;
-		if (promptFontResource) {
-			promptCftFont.reset(new HarvesterCftFont(*promptFontResource));
-			if (promptCftFont->isValid()) {
-				promptFont = promptCftFont.get();
-				useNativePromptFont = true;
-			}
-		}
-		if (inventoryTooltipFontResource) {
-			inventoryTooltipCftFont.reset(new HarvesterCftFont(*inventoryTooltipFontResource));
-			if (inventoryTooltipCftFont->isValid()) {
-				inventoryTooltipFont = inventoryTooltipCftFont.get();
-				inspectFont = inventoryTooltipCftFont.get();
-				useNativeInspectFont = true;
-			}
-		}
-		if (!screen || !art || !bodyFont || !promptFont || !inventoryTooltipFont || !inspectFont)
-			return Common::kNoError;
-		EntityManager *entityManager = _engine.getRuntimeEntities();
-
-		if (!flow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
-			return Common::kReadingFailed;
-		if (entityManager) {
-			for (const MonsterRecord &monster : scene.state.roomMonsters) {
-				Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
-				if (entity)
-					Monster::applyAnimation(*entity, monster);
-			}
-			entityManager->pauseTimerCountdowns();
-		}
-
-		flow.resetCursorAnimationSequence();
-		flow.executeStartupAudioCommands(scene.state.audioCommands);
-		if (!scene.state.musicPath.empty())
-			(void)_engine.playMusic(scene.state.musicPath);
-		const RoomViewResources view = { art, promptFont, inspectFont, inventoryTooltipFont,
-			useNativePromptFont, useNativeInspectFont };
-		RoomSession session(_engine, flow, scene, entityManager, _inventory, _cheats, view,
-			flow._roomLoopDepth > 1);
-		debugC(1, kDebugRoom, "Harvester: created room session room='%s'", scene.state.roomName.c_str());
-		session.initializePlayerState();
-
-		session.resetIdleState();
-
-		InteractionResult roomEntryInteraction;
-		bool hasPendingRoomEntryInteraction = false;
-		if (shouldRunRoomEntryCommands) {
-			if (!_engine.getScript()->executeRoomEnterCommands(
-					scene.state.roomName, roomEntryInteraction)) {
-				return Common::kReadingFailed;
-			}
-
-			// Native room_setup applies entry-time state changes before the first visible room frame.
-			if (roomEntryInteraction.mutatedRuntimeState) {
-				session.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
-				if (!session.applyCurrentRoomRuntimeMutationsInPlace(false) &&
-						!session.refreshCurrentScene(true)) {
-					return Common::kReadingFailed;
-				}
-				roomEntryInteraction.mutatedRuntimeState = false;
-				roomEntryInteraction.visualRuntimeStateChanged = false;
-			}
-			hasPendingRoomEntryInteraction = hasRoomEntryInteraction(roomEntryInteraction);
-		}
-
-		transitionError = flow.waitForRoomSetupTransitionHold();
-		if (transitionError.getCode() != Common::kNoError)
-			return transitionError;
-
-		logScenePaletteSummary("room setup stub palette", scene, 0.0f);
-		drawRoomScene(_engine, *screen, scene, 0.0f);
-		screen->makeAllDirty();
-		screen->update();
-
-		logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
-		transitionError = flow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
-		if (transitionError.getCode() != Common::kNoError)
-			return transitionError;
-		if (entityManager)
-			entityManager->resumeTimerCountdowns();
-
-		if (shouldRunRoomEntryCommands) {
-			if (hasPendingRoomEntryInteraction) {
-				const RoomInputResult entryResult = session.runRoomEntryInteraction(roomEntryInteraction);
-				if (entryResult.returnFromRoom)
-					return entryResult.error;
-			}
-
-			flow.resetCursorAnimationSequence();
-			session.resetIdleState();
-		}
-		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
-			_engine.getScript());
-
-		const RoomInputResult frameResult = session.runRoomFrames(currentRoomTarget);
-		if (frameResult.returnFromRoom)
-			return frameResult.error;
-		const RoomPendingTransition &pendingTransition = session.getPendingTransition();
-
-		currentRoomTarget = pendingTransition.roomName;
-		currentTargetIsRoomName = pendingTransition.isRoomName;
-		currentTargetUsesSavedRoomState = pendingTransition.usesSavedRoomState;
-		currentTargetSavedRoomState = pendingTransition.savedRoomState;
-	}
-
-	return Common::kNoError;
-}
-
-} // End of namespace Harvester
diff --git a/engines/harvester/room.h b/engines/harvester/room.h
deleted file mode 100644
index 2c0f15e864d..00000000000
--- a/engines/harvester/room.h
+++ /dev/null
@@ -1,50 +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 HARVESTER_ROOM_H
-#define HARVESTER_ROOM_H
-
-#include "common/error.h"
-#include "common/str.h"
-
-namespace Harvester {
-
-class HarvesterEngine;
-class CheatSystem;
-class Flow;
-class InventorySystem;
-
-class RoomSystem {
-public:
-	RoomSystem(HarvesterEngine &engine, InventorySystem &inventory,
-		CheatSystem &cheats);
-
-	Common::Error runRoomLoop(Flow &flow, const Common::String &targetName, bool targetIsRoomName);
-
-private:
-	HarvesterEngine &_engine;
-	InventorySystem &_inventory;
-	CheatSystem &_cheats;
-};
-
-} // End of namespace Harvester
-
-#endif // HARVESTER_ROOM_H
diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
index dfd27b7a156..53eb6a5ffb1 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/room_combat.cpp
@@ -30,7 +30,7 @@
 #include "harvester/harvester.h"
 #include "harvester/player.h"
 #include "harvester/room_interaction.h"
-#include "harvester/room_scene_runtime.h"
+#include "harvester/scene_entities.h"
 #include "harvester/runtime_entity.h"
 
 namespace Harvester {
@@ -970,7 +970,7 @@ MonsterRecord *RoomCombat::selectMonsterAttackContactTarget(RoomSetupState &stat
 }
 
 RoomNpcCombatUpdateResult RoomCombat::updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
-		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		EntityManager *entityManager, SceneEntities &sceneRuntime,
 		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
 	RoomNpcCombatUpdateResult result;
 	for (uint i = 0; i < state.roomNpcs.size(); ++i) {
@@ -1066,7 +1066,7 @@ bool RoomCombat::beginNpcDeathTransition(HarvesterEngine &engine,
 }
 
 Common::Error RoomCombat::finalizeNpcDeathTransition(HarvesterEngine &engine,
-		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
+		EntityManager *entityManager, SceneEntities &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
 		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) const {
 	Script *script = engine.getScript();
 	Entity *npcEntity = findSceneRuntimeEntity(entityManager, npc.npcName);
@@ -1257,7 +1257,7 @@ Common::Error RoomCombat::killDebugActiveMonster(HarvesterEngine &engine, RoomSe
 
 Common::Error RoomCombat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetupState &state,
 		RoomPlayerState &playerState, RoomIdleAnimationState &idleState, EntityManager *entityManager,
-		RoomSceneRuntime &sceneRuntime, RoomCombatEffects &combatEffects, const CheatSystem &cheats,
+		SceneEntities &sceneRuntime, RoomCombatEffects &combatEffects, const CheatSystem &cheats,
 		RoomInteractionCallbacks &callbacks, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
 	if (!entityManager)
 		return Common::kNoError;
@@ -1580,7 +1580,7 @@ Common::Error RoomCombat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomS
 
 Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
 		RoomSetupState &state, RoomPlayerState &playerState,
-		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		EntityManager *entityManager, SceneEntities &sceneRuntime,
 		RoomCombatEffects &combatEffects,
 		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
 	if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
index 95a3fac1b3a..86bf6a61d29 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/room_combat.h
@@ -34,7 +34,7 @@ class HarvesterEngine;
 class RoomCombatEffects;
 class RoomInteractionCallbacks;
 class RoomInteractionProcessor;
-class RoomSceneRuntime;
+class SceneEntities;
 
 extern const int kRoomMonsterAnimationRate;
 extern const int kNativeMonsterAttackAnimationRate;
@@ -155,11 +155,11 @@ public:
 
 	Common::Error updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetupState &state,
 		RoomPlayerState &playerState, RoomIdleAnimationState &idleState, EntityManager *entityManager,
-		RoomSceneRuntime &sceneRuntime, RoomCombatEffects &combatEffects, const CheatSystem &cheats,
+		SceneEntities &sceneRuntime, RoomCombatEffects &combatEffects, const CheatSystem &cheats,
 		RoomInteractionCallbacks &callbacks, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
 	Common::Error resolvePlayerAttackContact(HarvesterEngine &engine,
 		RoomSetupState &state, RoomPlayerState &playerState,
-		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		EntityManager *entityManager, SceneEntities &sceneRuntime,
 		RoomCombatEffects &combatEffects,
 		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
 
@@ -168,13 +168,13 @@ private:
 	bool queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const;
 	// Called after resizeCombatStates; NPC finalization ends the entire combat update.
 	RoomNpcCombatUpdateResult updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
-		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime,
+		EntityManager *entityManager, SceneEntities &sceneRuntime,
 		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
 
 	bool beginNpcDeathTransition(HarvesterEngine &engine,
 		NpcRecord &npc, Entity &npcEntity, RoomNpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const;
 	Common::Error finalizeNpcDeathTransition(HarvesterEngine &engine,
-		EntityManager *entityManager, RoomSceneRuntime &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
+		EntityManager *entityManager, SceneEntities &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
 		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) const;
 
 	void startMonsterHitReaction(HarvesterEngine &engine,
diff --git a/engines/harvester/room_input.cpp b/engines/harvester/room_input.cpp
index 55346caf9d5..976719fb2b2 100644
--- a/engines/harvester/room_input.cpp
+++ b/engines/harvester/room_input.cpp
@@ -32,7 +32,7 @@
 #include "harvester/player.h"
 #include "harvester/room_interaction.h"
 #include "harvester/room_presentation.h"
-#include "harvester/room_session.h"
+#include "harvester/scene.h"
 #include "harvester/room_support.h"
 
 namespace Harvester {
@@ -75,7 +75,7 @@ static void debugLogInventoryClick(const char *buttonLabel, const Common::Point
 		InventorySystem::isStatusObject(inventoryHover->object));
 }
 
-RoomInput::RoomInput(HarvesterEngine &engine, Flow &flow, RoomSession &session, RoomSceneResources &scene,
+RoomInput::RoomInput(HarvesterEngine &engine, Flow &flow, Scene &session, SceneState &scene,
 		InventorySystem &inventory, CheatSystem &cheats,
 		RoomItemInteractions &roomItems, RoomInteractionProcessor &interactionProcessor,
 		RoomPresentationState &presentationState,
diff --git a/engines/harvester/room_input.h b/engines/harvester/room_input.h
index 5986475b58c..9da9b566ef8 100644
--- a/engines/harvester/room_input.h
+++ b/engines/harvester/room_input.h
@@ -34,11 +34,11 @@ class HarvesterEngine;
 class InventorySystem;
 class RoomInteractionProcessor;
 class RoomItemInteractions;
-class RoomSession;
+class Scene;
 struct ObjectRecord;
 struct ResolvedText;
 struct RoomPresentationState;
-struct RoomSceneResources;
+struct SceneState;
 
 struct RoomInputState {
 	bool moveLeft = false;
@@ -65,7 +65,7 @@ struct RoomInputResult {
 
 class RoomInput {
 public:
-	RoomInput(HarvesterEngine &engine, Flow &flow, RoomSession &session, RoomSceneResources &scene,
+	RoomInput(HarvesterEngine &engine, Flow &flow, Scene &session, SceneState &scene,
 		InventorySystem &inventory, CheatSystem &cheats,
 		RoomItemInteractions &roomItems, RoomInteractionProcessor &interactionProcessor,
 		RoomPresentationState &presentationState,
@@ -87,8 +87,8 @@ private:
 
 	HarvesterEngine &_engine;
 	Flow &_flow;
-	RoomSession &_session;
-	RoomSceneResources &_scene;
+	Scene &_session;
+	SceneState &_scene;
 	InventorySystem &_inventory;
 	CheatSystem &_cheats;
 	RoomItemInteractions &_roomItems;
diff --git a/engines/harvester/room_interaction.cpp b/engines/harvester/room_interaction.cpp
index 4110a8ae5aa..1ece1ed9e87 100644
--- a/engines/harvester/room_interaction.cpp
+++ b/engines/harvester/room_interaction.cpp
@@ -31,7 +31,7 @@
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
 #include "harvester/player.h"
-#include "harvester/room_scene_runtime.h"
+#include "harvester/scene_entities.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 
@@ -74,8 +74,8 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return decodeBitmap(data, bitmap);
 }
 
-RoomItemInteractions::RoomItemInteractions(HarvesterEngine &engine, RoomSceneResources &scene,
-		RoomSceneRuntime &sceneRuntime, InventorySystem &inventory, const Common::String &pendingRoomChange)
+RoomItemInteractions::RoomItemInteractions(HarvesterEngine &engine, SceneState &scene,
+		SceneEntities &sceneRuntime, InventorySystem &inventory, const Common::String &pendingRoomChange)
 	: _engine(engine), _scene(scene), _sceneRuntime(sceneRuntime), _inventory(inventory),
 	  _pendingRoomChange(pendingRoomChange) {
 }
@@ -345,7 +345,7 @@ Common::Error RoomItemInteractions::handleRoomItemPickup(const ObjectRecord &obj
 }
 
 RoomInteractionProcessor::RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow,
-		RoomSceneResources &scene, RoomPlayerState &playerState,
+		SceneState &scene, RoomPlayerState &playerState,
 		Common::String &pendingRegionName, Common::String &pendingRoomChange,
 		bool &pendingRoomChangeIsRoomName, bool &pendingRoomChangeUsesSavedRoomState,
 		SaveRoomState &pendingRoomChangeSavedRoomState, bool canExitCloseupToParent,
diff --git a/engines/harvester/room_interaction.h b/engines/harvester/room_interaction.h
index 7ae7db80601..052bd309dfe 100644
--- a/engines/harvester/room_interaction.h
+++ b/engines/harvester/room_interaction.h
@@ -33,7 +33,7 @@ class Flow;
 class HarvesterEngine;
 class InventorySystem;
 class RoomInteractionProcessor;
-class RoomSceneRuntime;
+class SceneEntities;
 struct IndexedBitmap;
 
 extern const char *const kExitCloseupPendingRoomChange;
@@ -60,8 +60,8 @@ public:
 
 class RoomItemInteractions {
 public:
-	RoomItemInteractions(HarvesterEngine &engine, RoomSceneResources &scene,
-		RoomSceneRuntime &sceneRuntime, InventorySystem &inventory, const Common::String &pendingRoomChange);
+	RoomItemInteractions(HarvesterEngine &engine, SceneState &scene,
+		SceneEntities &sceneRuntime, InventorySystem &inventory, const Common::String &pendingRoomChange);
 
 	const Common::String &getCarriedRoomItemLabel() const { return _carriedRoomItemLabel; }
 	const IndexedBitmap &getCarriedRoomItemBitmap() const { return _carriedRoomItemBitmap; }
@@ -85,8 +85,8 @@ private:
 		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw);
 
 	HarvesterEngine &_engine;
-	RoomSceneResources &_scene;
-	RoomSceneRuntime &_sceneRuntime;
+	SceneState &_scene;
+	SceneEntities &_sceneRuntime;
 	InventorySystem &_inventory;
 	const Common::String &_pendingRoomChange;
 	Common::String _carriedRoomItemName;
@@ -96,7 +96,7 @@ private:
 
 class RoomInteractionProcessor {
 public:
-	RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene,
+	RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow, SceneState &scene,
 		RoomPlayerState &playerState, Common::String &pendingRegionName,
 		Common::String &pendingRoomChange, bool &pendingRoomChangeIsRoomName,
 		bool &pendingRoomChangeUsesSavedRoomState, SaveRoomState &pendingRoomChangeSavedRoomState,
@@ -119,7 +119,7 @@ public:
 private:
 	HarvesterEngine &_engine;
 	Flow &_flow;
-	RoomSceneResources &_scene;
+	SceneState &_scene;
 	RoomPlayerState &_playerState;
 	Common::String &_pendingRegionName;
 	Common::String &_pendingRoomChange;
diff --git a/engines/harvester/room_presentation.cpp b/engines/harvester/room_presentation.cpp
index ea63ac33e0d..7cfc3505581 100644
--- a/engines/harvester/room_presentation.cpp
+++ b/engines/harvester/room_presentation.cpp
@@ -93,7 +93,7 @@ void RoomPresentation::blitBitmap(Graphics::Screen &screen, const IndexedBitmap
 		screen.format.bytesPerPixel, kTransparentPaletteIndex);
 }
 
-RoomPresentation::RoomPresentation(HarvesterEngine &engine, RoomSceneResources &scene,
+RoomPresentation::RoomPresentation(HarvesterEngine &engine, SceneState &scene,
 		const Art *art, const Graphics::Font *inspectFont, bool useNativeInspectFont)
 	: _engine(engine), _scene(scene), _art(art), _inspectFont(inspectFont),
 	  _useNativeInspectFont(useNativeInspectFont) {
diff --git a/engines/harvester/room_presentation.h b/engines/harvester/room_presentation.h
index 2339d791b79..93ecb8985f8 100644
--- a/engines/harvester/room_presentation.h
+++ b/engines/harvester/room_presentation.h
@@ -37,7 +37,7 @@ class Flow;
 class HarvesterEngine;
 struct IndexedBitmap;
 struct ResolvedText;
-struct RoomSceneResources;
+struct SceneState;
 
 struct RoomViewResources {
 	const Art *art;
@@ -60,7 +60,7 @@ struct RoomPresentationState {
 
 class RoomPresentation {
 public:
-	RoomPresentation(HarvesterEngine &engine, RoomSceneResources &scene,
+	RoomPresentation(HarvesterEngine &engine, SceneState &scene,
 		const Art *art, const Graphics::Font *inspectFont, bool useNativeInspectFont);
 
 	bool captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop);
@@ -70,7 +70,7 @@ public:
 
 private:
 	HarvesterEngine &_engine;
-	RoomSceneResources &_scene;
+	SceneState &_scene;
 	const Art *_art;
 	const Graphics::Font *_inspectFont;
 	bool _useNativeInspectFont;
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 463efbda54e..1908dc848e5 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -43,7 +43,7 @@ class DialogueSystem;
 struct IndexedBitmap;
 struct MenuTextConfig;
 
-struct RoomSceneResources {
+struct SceneState {
 	RoomSetupState state;
 	byte palette[256 * 3] = { 0 };
 	Common::Array<ObjectRecord> sceneObjects;
@@ -130,13 +130,13 @@ struct RoomIdleAnimationState {
 bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs);
 bool applyRoomActorPlacement(const RoomSetupState &state, Entity &entity,
 	int centerX, int bottomY, float z, const Common::String *entranceName = nullptr);
-void logScenePaletteSummary(const char *label, const RoomSceneResources &scene, float brightness);
+void logScenePaletteSummary(const char *label, const SceneState &scene, float brightness);
 bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap);
 Common::Rect getRoomObjectHotspotBounds(const ObjectRecord &object);
 bool shouldDisplaySceneAnimation(const AnimRecord &anim);
-bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resources, RoomSceneResources &scene);
+bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resources, SceneState &scene);
 bool shouldRunStartupRoomProbe();
-void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const RoomSceneResources &scene,
+void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const SceneState &scene,
 	float brightness);
 const ObjectRecord *findSceneObjectByName(const Common::Array<ObjectRecord> &objects,
 	const Common::String &objectName);
@@ -157,7 +157,7 @@ RoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const RoomSetupSta
 	const Common::Array<RegionRecord> &regions,
 	const Common::Point &mousePos, const MenuTextConfig &menuTextConfig,
 	const DialogueSystem *dialogue = nullptr);
-void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scene,
+void logStartupRoomProbe(HarvesterEngine &engine, const SceneState &scene,
 	const Common::String &entranceName, Common::Point &mousePos,
 	const MenuTextConfig &menuTextConfig);
 
diff --git a/engines/harvester/room_session.cpp b/engines/harvester/scene.cpp
similarity index 74%
rename from engines/harvester/room_session.cpp
rename to engines/harvester/scene.cpp
index 6406f03009d..d08d12df167 100644
--- a/engines/harvester/room_session.cpp
+++ b/engines/harvester/scene.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/room_session.h"
+#include "harvester/scene.h"
 
 #include "common/algorithm.h"
 #include "common/events.h"
@@ -36,6 +36,18 @@
 #include "harvester/room_combat.h"
 #include "harvester/runtime_entity.h"
 
+#include "common/ptr.h"
+#include "graphics/blit.h"
+#include "graphics/fontman.h"
+#include "harvester/cft_font.h"
+#include "harvester/cheats.h"
+#include "harvester/resources.h"
+#include "harvester/monster.h"
+#include "harvester/room_interaction.h"
+#include "harvester/room_input.h"
+#include "harvester/room_presentation.h"
+#include "harvester/room_support.h"
+
 namespace Harvester {
 
 static const char *const kPlayerInventoryLabel = "your inventory";
@@ -109,7 +121,7 @@ static void drawCombatLoadoutResourceIcons(Graphics::Screen &screen, const Art &
 			kNativeCombatResourceIconsY);
 }
 
-RoomSession::RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
+Scene::Scene(HarvesterEngine &engine, Flow &flow, SceneState &scene, EntityManager *entityManager,
 		InventorySystem &inventory, CheatSystem &cheats, const RoomViewResources &view, bool canExitCloseupToParent)
 	: _engine(engine), _flow(flow), _scene(scene), _entityManager(entityManager), _inventory(inventory),
 	  _sceneRuntime(scene.state, engine.getScript(), engine.getResources(), entityManager), _combatEffects(entityManager),
@@ -121,23 +133,23 @@ RoomSession::RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources
 		canExitCloseupToParent, *this) {
 }
 
-void RoomSession::resetIdleState() {
+void Scene::resetIdleState() {
 	_idleState = RoomIdleAnimationState();
 	_idleState.activityTick = Player::getRuntimeClockTicks();
 	_idleState.resetTick = _idleState.activityTick;
 	Player::updateIdleTrigger(_idleState);
 }
 
-void RoomSession::notePlayerActivity() {
+void Scene::notePlayerActivity() {
 	_idleState.activityTick = Player::getRuntimeClockTicks();
 	Player::updateIdleTrigger(_idleState);
 }
 
-bool RoomSession::isPlayerCombatLocked() const {
+bool Scene::isPlayerCombatLocked() const {
 	return _playerState.attackActive || _playerState.hitActive || _playerState.deathActive;
 }
 
-bool RoomSession::cancelPlayerAttackAnimation() {
+bool Scene::cancelPlayerAttackAnimation() {
 	if (!_playerState.attackActive)
 		return false;
 
@@ -159,7 +171,7 @@ bool RoomSession::cancelPlayerAttackAnimation() {
 	return Player::setIdleAnimation(_playerState, resumeFacing);
 }
 
-void RoomSession::stopPlayerRegionInteraction() {
+void Scene::stopPlayerRegionInteraction() {
 	_inputState.moveLeft = false;
 	_inputState.moveRight = false;
 	_inputState.moveUp = false;
@@ -174,11 +186,11 @@ void RoomSession::stopPlayerRegionInteraction() {
 	syncPlayerPlacement();
 }
 
-void RoomSession::syncPlayerPlacement() {
+void Scene::syncPlayerPlacement() {
 	Player::syncAnimationPlacement(_playerState);
 }
 
-void RoomSession::captureCurrentSaveState() {
+void Scene::captureCurrentSaveState() {
 	syncPlayerPlacement();
 	_sceneRuntime.syncCurrentRoomRuntimeState(_scene.sceneRegions);
 	const int facing = _playerState.facing >= 0 ? _playerState.facing : _scene.state.playerFacing;
@@ -187,7 +199,7 @@ void RoomSession::captureCurrentSaveState() {
 		_scene.state.discNumber, _engine.getMusicPath());
 }
 
-bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) {
+bool Scene::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) {
 	Script *script = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
 	if (!script || !resources)
@@ -206,7 +218,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 	}
 	updatedState.audioCommands = Common::move(entryAudioCommands);
 
-	RoomSceneResources updatedScene;
+	SceneState updatedScene;
 	if (!_sceneRuntime.loadSceneResources(updatedState, updatedScene))
 		return false;
 	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates =
@@ -237,7 +249,7 @@ bool RoomSession::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteSt
 	return _inventory.refresh();
 }
 
-bool RoomSession::refreshCurrentScene(bool preservePlayerPlacement) {
+bool Scene::refreshCurrentScene(bool preservePlayerPlacement) {
 	debugC(2, kDebugRoom, "Harvester: refreshing room scene room='%s' preserve_player_placement=%d",
 		_scene.state.roomName.c_str(), preservePlayerPlacement);
 	Common::Array<AudioCommand> entryAudioCommands = _scene.state.audioCommands;
@@ -265,7 +277,7 @@ bool RoomSession::refreshCurrentScene(bool preservePlayerPlacement) {
 	return _inventory.refresh();
 }
 
-void RoomSession::initializePlayerState() {
+void Scene::initializePlayerState() {
 	_playerState.entity = _entityManager ? _entityManager->findSceneEntityByName("PLAYER") : nullptr;
 	_playerState.centerX = _scene.state.playerSpawnX;
 	_playerState.bottomY = _scene.state.playerSpawnY;
@@ -278,7 +290,7 @@ void RoomSession::initializePlayerState() {
 	_combat.initializeCombatStates(_scene.state);
 }
 
-void RoomSession::syncAnimatedRoomActorPlacement() {
+void Scene::syncAnimatedRoomActorPlacement() {
 	Player::syncAnimationPlacement(_playerState);
 	if (!_entityManager)
 		return;
@@ -293,7 +305,7 @@ void RoomSession::syncAnimatedRoomActorPlacement() {
 	}
 }
 
-Common::Error RoomSession::runRoomExitCommands() {
+Common::Error Scene::runRoomExitCommands() {
 	InteractionResult exitInteraction;
 	if (!_engine.getScript()->executeRoomExitCommands(_scene.state.roomName, exitInteraction))
 		return Common::kReadingFailed;
@@ -345,7 +357,7 @@ Common::Error RoomSession::runRoomExitCommands() {
 	return Common::kNoError;
 }
 
-void RoomSession::applyPlayerGotoXZ(int x, int z) {
+void Scene::applyPlayerGotoXZ(int x, int z) {
 	_playerState.hasMoveTarget = false;
 	_playerState.turnActive = false;
 	_playerState.turnTargetFacing = -1;
@@ -375,7 +387,7 @@ void RoomSession::applyPlayerGotoXZ(int x, int z) {
 	captureCurrentSaveState();
 }
 
-void RoomSession::requestPlayerGameOver(const char *reason, const Common::String &sourceName) {
+void Scene::requestPlayerGameOver(const char *reason, const Common::String &sourceName) {
 	if (_flow.hasPendingMainMenuReturn())
 		return;
 
@@ -388,7 +400,7 @@ void RoomSession::requestPlayerGameOver(const char *reason, const Common::String
 	_flow.requestGameOverReturn();
 }
 
-void RoomSession::startPlayerDefeatSequence(const char *reason, const Common::String &sourceName, int damageType) {
+void Scene::startPlayerDefeatSequence(const char *reason, const Common::String &sourceName, int damageType) {
 	if (_flow.hasPendingMainMenuReturn() || _playerState.deathActive)
 		return;
 
@@ -422,7 +434,7 @@ void RoomSession::startPlayerDefeatSequence(const char *reason, const Common::St
 			: 0);
 }
 
-RoomInputResult RoomSession::runRoomEntryInteraction(const InteractionResult &roomEntryInteraction) {
+RoomInputResult Scene::runRoomEntryInteraction(const InteractionResult &roomEntryInteraction) {
 	bool didEntryTransition = false;
 	Common::Error entryInteractionError = _interactionProcessor.handleInteractionResult(
 		roomEntryInteraction, didEntryTransition, Common::String());
@@ -433,7 +445,7 @@ RoomInputResult RoomSession::runRoomEntryInteraction(const InteractionResult &ro
 	return RoomInputResult();
 }
 
-void RoomSession::updateRoomInterface(bool &needsRedraw) {
+void Scene::updateRoomInterface(bool &needsRedraw) {
 	captureCurrentSaveState();
 	if (_inventory.isOpen() && _inventory.refreshIfRuntimeStateChanged())
 		needsRedraw = true;
@@ -451,7 +463,7 @@ void RoomSession::updateRoomInterface(bool &needsRedraw) {
 	}
 }
 
-bool RoomSession::drawRoomFrame(bool &needsRedraw) {
+bool Scene::drawRoomFrame(bool &needsRedraw) {
 	if (!needsRedraw)
 		return true;
 
@@ -573,7 +585,7 @@ bool RoomSession::drawRoomFrame(bool &needsRedraw) {
 	return true;
 }
 
-RoomInputResult RoomSession::pollRoomEvents(RoomInput &input, bool &needsRedraw) {
+RoomInputResult Scene::pollRoomEvents(RoomInput &input, bool &needsRedraw) {
 	Common::Event event;
 	while (g_system->getEventManager()->pollEvent(event)) {
 		const RoomInputResult inputResult = input.handleEvent(event, needsRedraw);
@@ -583,7 +595,7 @@ RoomInputResult RoomSession::pollRoomEvents(RoomInput &input, bool &needsRedraw)
 	return RoomInputResult();
 }
 
-RoomSession::PlayerControlState RoomSession::preparePlayerControlState() {
+Scene::PlayerControlState Scene::preparePlayerControlState() {
 	Script *script = _engine.getScript();
 	const bool playerAlive = script && script->getPlayerCurrentHitPoints() > 0;
 	const bool playerControlPaused = script && script->isPlayerControlPaused();
@@ -602,13 +614,13 @@ RoomSession::PlayerControlState RoomSession::preparePlayerControlState() {
 	return control;
 }
 
-void RoomSession::advanceRuntimeEntities(bool &needsRedraw) {
+void Scene::advanceRuntimeEntities(bool &needsRedraw) {
 	if (_flow.tickRuntimeEntities())
 		needsRedraw = true;
 	syncAnimatedRoomActorPlacement();
 }
 
-void RoomSession::updatePlayerCombatAnimations(bool &needsRedraw) {
+void Scene::updatePlayerCombatAnimations(bool &needsRedraw) {
 	if (Player::updateAttackAnimationState(_engine, _playerState))
 		needsRedraw = true;
 	if (Player::updateHitAnimationState(
@@ -617,7 +629,7 @@ void RoomSession::updatePlayerCombatAnimations(bool &needsRedraw) {
 	}
 }
 
-void RoomSession::updatePlayerControls(const PlayerControlState &control, bool &needsRedraw) {
+void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsRedraw) {
 	const bool keyboardAttackRequested =
 		_inputState.attackModifierHeld && (_inputState.moveLeft || _inputState.moveRight || _inputState.moveUp || _inputState.moveDown);
 	if (control.canAct &&
@@ -678,21 +690,21 @@ void RoomSession::updatePlayerControls(const PlayerControlState &control, bool &
 	}
 }
 
-void RoomSession::updatePlayerDeath(bool &needsRedraw) {
+void Scene::updatePlayerDeath(bool &needsRedraw) {
 	if (Player::updateDeathAnimationState(_playerState)) {
 		requestPlayerGameOver("combat_player_death_complete", Common::String());
 		needsRedraw = true;
 	}
 }
 
-void RoomSession::pruneCombatEffects(bool &needsRedraw) {
+void Scene::pruneCombatEffects(bool &needsRedraw) {
 	if (_combatEffects.pruneCombatHitEffects())
 		needsRedraw = true;
 	if (_combatEffects.pruneCombatDamagePopups())
 		needsRedraw = true;
 }
 
-RoomInputResult RoomSession::dispatchExpiredTimers(bool &needsRedraw) {
+RoomInputResult Scene::dispatchExpiredTimers(bool &needsRedraw) {
 	if (_entityManager) {
 		Common::Array<Common::String> expiredTimerNames;
 		if (_entityManager->takeExpiredTimerNames(expiredTimerNames)) {
@@ -720,7 +732,7 @@ RoomInputResult RoomSession::dispatchExpiredTimers(bool &needsRedraw) {
 	return RoomInputResult();
 }
 
-RoomInputResult RoomSession::runRoomFrames(const Common::String &currentRoomTarget) {
+RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 	RoomInput input(_engine, _flow, *this, _scene, _inventory, _cheats,
 		_roomItems, _interactionProcessor, _presentationState, *_view.art, _canExitCloseupToParent);
 	bool needsRedraw = true;
@@ -886,61 +898,281 @@ RoomInputResult RoomSession::runRoomFrames(const Common::String &currentRoomTarg
 	return RoomInputResult();
 }
 
-void RoomSession::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) {
+void Scene::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) {
 	_sceneRuntime.syncGlobalTimerEntities(previousTimerRecords);
 }
 
-bool RoomSession::captureDialogueBackdrop(IndexedBitmap &backdrop) {
+bool Scene::captureDialogueBackdrop(IndexedBitmap &backdrop) {
 	return _presentation.captureDialogueBackdrop(backdrop);
 }
 
-Common::Error RoomSession::showCdChangePrompt(int discNumber) {
+Common::Error Scene::showCdChangePrompt(int discNumber) {
 	return _flow.showCdChangePrompt(discNumber);
 }
 
-Common::Error RoomSession::applyLightingCommand(StartupLightingCommand command) {
+Common::Error Scene::applyLightingCommand(StartupLightingCommand command) {
 	return _presentation.applyLightingCommand(command);
 }
 
-Common::Error RoomSession::runModalShowText(const ResolvedText &text) {
+Common::Error Scene::runModalShowText(const ResolvedText &text) {
 	return _presentation.runModalShowText(text, _flow);
 }
 
-Common::Error RoomSession::tryActivatePendingRegion(bool &needsRedraw) {
+Common::Error Scene::tryActivatePendingRegion(bool &needsRedraw) {
 	return isPlayerCombatLocked() ? Common::kNoError : _interactionProcessor.tryActivatePendingRegion(_inventory, needsRedraw);
 }
 
-Common::Error RoomSession::tryActivatePassiveRegion(bool &needsRedraw) {
+Common::Error Scene::tryActivatePassiveRegion(bool &needsRedraw) {
 	return _interactionProcessor.tryActivatePassiveRegion(_inventory, needsRedraw);
 }
 
-Common::Error RoomSession::resolvePlayerAttackContact(bool &needsRedraw) {
+Common::Error Scene::resolvePlayerAttackContact(bool &needsRedraw) {
 	return _combat.resolvePlayerAttackContact(_engine, _scene.state, _playerState, _entityManager,
 		_sceneRuntime, _combatEffects, _interactionProcessor, needsRedraw);
 }
 
-Common::Error RoomSession::updateRoomMonsterCombat(bool &needsRedraw) {
+Common::Error Scene::updateRoomMonsterCombat(bool &needsRedraw) {
 	return _combat.updateRoomMonsterCombat(_engine, _scene.state, _playerState, _idleState,
 		_entityManager, _sceneRuntime, _combatEffects, _cheats, *this, _interactionProcessor, needsRedraw);
 }
 
-void RoomSession::syncCombatHitEffects(bool &needsRedraw) {
+void Scene::syncCombatHitEffects(bool &needsRedraw) {
 	if (_combatEffects.syncCombatHitEffects())
 		needsRedraw = true;
 }
 
-void RoomSession::updateIdleAnimation(bool &needsRedraw) {
+void Scene::updateIdleAnimation(bool &needsRedraw) {
 	if (Player::updateIdleAnimation(_scene.state, _playerState, _idleState))
 		needsRedraw = true;
 }
 
-void RoomSession::capturePlayerAttackTarget(const Common::Point &mousePos, const NpcRecord *npc) {
+void Scene::capturePlayerAttackTarget(const Common::Point &mousePos, const NpcRecord *npc) {
 	_combat.capturePlayerAttackTarget(_scene.state, _playerState, _entityManager, mousePos, npc);
 }
 
-Common::Error RoomSession::killDebugActiveMonster(const Common::Point &mousePos, bool &needsRedraw) {
+Common::Error Scene::killDebugActiveMonster(const Common::Point &mousePos, bool &needsRedraw) {
 	return _combat.killDebugActiveMonster(_engine, _scene.state, _playerState, _entityManager,
 		mousePos, _combatEffects, _interactionProcessor, needsRedraw);
 }
 
+static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
+	const Text *text = engine.getText();
+	if (!text || !fontName)
+		return nullptr;
+
+	for (const CftFontResource &font : text->getFonts()) {
+		if (font.name.equalsIgnoreCase(fontName))
+			return &font;
+	}
+
+	return nullptr;
+}
+
+SceneRunner::SceneRunner(HarvesterEngine &engine,
+		InventorySystem &inventory, CheatSystem &cheats)
+	: _engine(engine), _inventory(inventory), _cheats(cheats) {
+}
+
+Common::Error SceneRunner::runRoomLoop(Flow &flow, const Common::String &targetName,
+		bool targetIsRoomName) {
+	if (flow.hasPendingMainMenuReturn())
+		return Common::kNoError;
+
+	Common::String currentRoomTarget = targetName;
+	bool currentTargetIsRoomName = targetIsRoomName;
+	bool currentTargetUsesSavedRoomState = false;
+	SaveRoomState currentTargetSavedRoomState;
+	while (!currentRoomTarget.empty()) {
+		RoomSetupState state;
+		bool shouldRunRoomEntryCommands = false;
+		if (_engine.hasPendingLoadedSaveRoomState()) {
+			// Native load destroys live render entities and frees preserved global timers
+			// before room_setup rebuilds the loaded room from restored TIMER records.
+			if (EntityManager *entityManager = _engine.getRuntimeEntities())
+				entityManager->clearSceneEntities(false);
+			flow.resetRoomNpcDialogueState();
+			if (_engine.hasPendingLoadedDialogueStateBlob()) {
+				if (!flow.loadDialogueSaveStateBlob(
+						_engine.getPendingLoadedDialogueStateBlob(),
+						_engine.getPendingLoadedDialogueStateBlobVersion()))
+					return Common::kReadingFailed;
+				_engine.clearPendingLoadedDialogueStateBlob();
+			}
+			if (_engine.getPendingLoadedDisc() > 0 &&
+					!_engine.activateDisc(_engine.getPendingLoadedDisc())) {
+				return Common::kReadingFailed;
+			}
+			const SaveRoomState &loadedState = _engine.getPendingLoadedSaveRoomState();
+			debugC(1, kDebugRoom,
+				"Harvester: applying pending loaded room state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+				loadedState.entranceName.c_str(), loadedState.roomName.c_str(),
+				loadedState.playerX, loadedState.playerY, loadedState.playerZ,
+				loadedState.playerFacing, loadedState.musicPath.c_str());
+			if (!_engine.getScript()->materializeRoomState(
+					loadedState.entranceName, loadedState.roomName, state, *_engine.getResources())) {
+				return Common::kReadingFailed;
+			}
+			state.entranceName = loadedState.entranceName;
+			state.playerSpawnX = loadedState.playerX;
+			state.playerSpawnY = loadedState.playerY;
+			state.playerSpawnZ = loadedState.playerZ;
+			state.playerFacing = loadedState.playerFacing;
+			state.musicPath = loadedState.musicPath;
+			currentRoomTarget = !loadedState.entranceName.empty() ? loadedState.entranceName : loadedState.roomName;
+			_engine.clearPendingLoadedSaveRoomState();
+			currentTargetIsRoomName = false;
+			shouldRunRoomEntryCommands = false;
+		} else if ((currentTargetIsRoomName
+				? !_engine.getScript()->resolveRoomSetupStateByRoomName(
+					currentRoomTarget, state, *_engine.getResources())
+				: !_engine.getScript()->resolveRoomSetupState(
+					currentRoomTarget, state, *_engine.getResources()))) {
+			return Common::kReadingFailed;
+		} else {
+			shouldRunRoomEntryCommands = true;
+		}
+		if (shouldRunRoomEntryCommands && currentTargetUsesSavedRoomState &&
+				currentTargetSavedRoomState.valid) {
+			const SaveRoomState &savedState = currentTargetSavedRoomState;
+			state.entranceName = savedState.entranceName;
+			state.playerSpawnX = savedState.playerX;
+			state.playerSpawnY = savedState.playerY;
+			state.playerSpawnZ = savedState.playerZ;
+			state.playerFacing = savedState.playerFacing;
+			if (!savedState.musicPath.empty())
+				state.musicPath = savedState.musicPath;
+			debugC(1, kDebugRoom,
+				"Harvester: applying saved room restart state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+				savedState.entranceName.c_str(), savedState.roomName.c_str(),
+				savedState.playerX, savedState.playerY, savedState.playerZ,
+				savedState.playerFacing, savedState.musicPath.c_str());
+		}
+
+		Common::Error transitionError = flow.beginRoomSetupTransition();
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+
+		SceneState scene;
+		if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
+			return Common::kReadingFailed;
+
+		Graphics::Screen *screen = _engine.getScreen();
+		const Art *art = _engine.getArt();
+		const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+		const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
+		const CftFontResource *inventoryTooltipFontResource = findStartupFontByName(_engine, "TEXTFONT");
+		Common::ScopedPtr<HarvesterCftFont> promptCftFont;
+		Common::ScopedPtr<HarvesterCftFont> inventoryTooltipCftFont;
+		const Graphics::Font *promptFont = bodyFont;
+		const Graphics::Font *inspectFont = bodyFont;
+		const Graphics::Font *inventoryTooltipFont = bodyFont;
+		bool useNativePromptFont = false;
+		bool useNativeInspectFont = false;
+		if (promptFontResource) {
+			promptCftFont.reset(new HarvesterCftFont(*promptFontResource));
+			if (promptCftFont->isValid()) {
+				promptFont = promptCftFont.get();
+				useNativePromptFont = true;
+			}
+		}
+		if (inventoryTooltipFontResource) {
+			inventoryTooltipCftFont.reset(new HarvesterCftFont(*inventoryTooltipFontResource));
+			if (inventoryTooltipCftFont->isValid()) {
+				inventoryTooltipFont = inventoryTooltipCftFont.get();
+				inspectFont = inventoryTooltipCftFont.get();
+				useNativeInspectFont = true;
+			}
+		}
+		if (!screen || !art || !bodyFont || !promptFont || !inventoryTooltipFont || !inspectFont)
+			return Common::kNoError;
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+
+		if (!flow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+			return Common::kReadingFailed;
+		if (entityManager) {
+			for (const MonsterRecord &monster : scene.state.roomMonsters) {
+				Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
+				if (entity)
+					Monster::applyAnimation(*entity, monster);
+			}
+			entityManager->pauseTimerCountdowns();
+		}
+
+		flow.resetCursorAnimationSequence();
+		flow.executeStartupAudioCommands(scene.state.audioCommands);
+		if (!scene.state.musicPath.empty())
+			(void)_engine.playMusic(scene.state.musicPath);
+		const RoomViewResources view = { art, promptFont, inspectFont, inventoryTooltipFont,
+			useNativePromptFont, useNativeInspectFont };
+		Scene session(_engine, flow, scene, entityManager, _inventory, _cheats, view,
+			flow._roomLoopDepth > 1);
+		debugC(1, kDebugRoom, "Harvester: created room session room='%s'", scene.state.roomName.c_str());
+		session.initializePlayerState();
+
+		session.resetIdleState();
+
+		InteractionResult roomEntryInteraction;
+		bool hasPendingRoomEntryInteraction = false;
+		if (shouldRunRoomEntryCommands) {
+			if (!_engine.getScript()->executeRoomEnterCommands(
+					scene.state.roomName, roomEntryInteraction)) {
+				return Common::kReadingFailed;
+			}
+
+			// Native room_setup applies entry-time state changes before the first visible room frame.
+			if (roomEntryInteraction.mutatedRuntimeState) {
+				session.syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
+				if (!session.applyCurrentRoomRuntimeMutationsInPlace(false) &&
+						!session.refreshCurrentScene(true)) {
+					return Common::kReadingFailed;
+				}
+				roomEntryInteraction.mutatedRuntimeState = false;
+				roomEntryInteraction.visualRuntimeStateChanged = false;
+			}
+			hasPendingRoomEntryInteraction = hasRoomEntryInteraction(roomEntryInteraction);
+		}
+
+		transitionError = flow.waitForRoomSetupTransitionHold();
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+
+		logScenePaletteSummary("room setup stub palette", scene, 0.0f);
+		drawRoomScene(_engine, *screen, scene, 0.0f);
+		screen->makeAllDirty();
+		screen->update();
+
+		logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
+		transitionError = flow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+		if (entityManager)
+			entityManager->resumeTimerCountdowns();
+
+		if (shouldRunRoomEntryCommands) {
+			if (hasPendingRoomEntryInteraction) {
+				const RoomInputResult entryResult = session.runRoomEntryInteraction(roomEntryInteraction);
+				if (entryResult.returnFromRoom)
+					return entryResult.error;
+			}
+
+			flow.resetCursorAnimationSequence();
+			session.resetIdleState();
+		}
+		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
+			_engine.getScript());
+
+		const RoomInputResult frameResult = session.runRoomFrames(currentRoomTarget);
+		if (frameResult.returnFromRoom)
+			return frameResult.error;
+		const RoomPendingTransition &pendingTransition = session.getPendingTransition();
+
+		currentRoomTarget = pendingTransition.roomName;
+		currentTargetIsRoomName = pendingTransition.isRoomName;
+		currentTargetUsesSavedRoomState = pendingTransition.usesSavedRoomState;
+		currentTargetSavedRoomState = pendingTransition.savedRoomState;
+	}
+
+	return Common::kNoError;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/room_session.h b/engines/harvester/scene.h
similarity index 87%
rename from engines/harvester/room_session.h
rename to engines/harvester/scene.h
index 55f3e7e52d4..da19ea16e3f 100644
--- a/engines/harvester/room_session.h
+++ b/engines/harvester/scene.h
@@ -19,14 +19,16 @@
  *
  */
 
-#ifndef HARVESTER_ROOM_SESSION_H
-#define HARVESTER_ROOM_SESSION_H
+#ifndef HARVESTER_SCENE_H
+#define HARVESTER_SCENE_H
 
+#include "common/error.h"
+#include "common/str.h"
 #include "harvester/room_combat.h"
 #include "harvester/room_input.h"
 #include "harvester/room_interaction.h"
 #include "harvester/room_presentation.h"
-#include "harvester/room_scene_runtime.h"
+#include "harvester/scene_entities.h"
 #include "harvester/room_support.h"
 #include "harvester/runtime_entity.h"
 
@@ -36,6 +38,19 @@ class EntityManager;
 class Flow;
 class InventorySystem;
 
+class SceneRunner {
+public:
+	SceneRunner(HarvesterEngine &engine, InventorySystem &inventory,
+		CheatSystem &cheats);
+
+	Common::Error runRoomLoop(Flow &flow, const Common::String &targetName, bool targetIsRoomName);
+
+private:
+	HarvesterEngine &_engine;
+	InventorySystem &_inventory;
+	CheatSystem &_cheats;
+};
+
 struct RoomPendingTransition {
 	Common::String roomName;
 	bool isRoomName = false;
@@ -43,11 +58,9 @@ struct RoomPendingTransition {
 	SaveRoomState savedRoomState;
 };
 
-// Scoped to one runRoomLoop iteration, including time spent in nested closeups.
-// Entity pointers remain owned by EntityManager.
-class RoomSession : public RoomInteractionCallbacks {
+class Scene : public RoomInteractionCallbacks {
 public:
-	RoomSession(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene, EntityManager *entityManager,
+	Scene(HarvesterEngine &engine, Flow &flow, SceneState &scene, EntityManager *entityManager,
 		InventorySystem &inventory, CheatSystem &cheats, const RoomViewResources &view, bool canExitCloseupToParent);
 
 	RoomPlayerState &getPlayerState() { return _playerState; }
@@ -108,10 +121,10 @@ private:
 
 	HarvesterEngine &_engine;
 	Flow &_flow;
-	RoomSceneResources &_scene;
+	SceneState &_scene;
 	EntityManager *_entityManager;
 	InventorySystem &_inventory;
-	RoomSceneRuntime _sceneRuntime;
+	SceneEntities _sceneRuntime;
 	RoomPlayerState _playerState;
 	RoomCombat _combat;
 	RoomCombatEffects _combatEffects;
@@ -130,4 +143,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_ROOM_SESSION_H
+#endif // HARVESTER_SCENE_H
diff --git a/engines/harvester/room_scene_runtime.cpp b/engines/harvester/scene_entities.cpp
similarity index 91%
rename from engines/harvester/room_scene_runtime.cpp
rename to engines/harvester/scene_entities.cpp
index c4ab1749e8e..1ba485826cc 100644
--- a/engines/harvester/room_scene_runtime.cpp
+++ b/engines/harvester/scene_entities.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/room_scene_runtime.h"
+#include "harvester/scene_entities.h"
 
 #include "harvester/detection.h"
 #include "harvester/monster.h"
@@ -140,8 +140,8 @@ static bool sameSceneObjectState(const ObjectRecord &lhs, const ObjectRecord &rh
 		lhs.identTextKey.equalsIgnoreCase(rhs.identTextKey) &&
 		lhs.interactionLabel.equalsIgnoreCase(rhs.interactionLabel) &&
 		lhs.currentOwnerOrRoom.equalsIgnoreCase(rhs.currentOwnerOrRoom) &&
-		RoomSceneRuntime::resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(RoomSceneRuntime::resolveSceneObjectSpritePathLocal(rhs)) &&
-		sameRect(RoomSceneRuntime::getSceneObjectBounds(lhs), RoomSceneRuntime::getSceneObjectBounds(rhs));
+		SceneEntities::resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(SceneEntities::resolveSceneObjectSpritePathLocal(rhs)) &&
+		sameRect(SceneEntities::getSceneObjectBounds(lhs), SceneEntities::getSceneObjectBounds(rhs));
 }
 
 static bool sameSceneRegionState(const RegionRecord &lhs, const RegionRecord &rhs) {
@@ -169,18 +169,18 @@ bool sameMonsterEntityState(const MonsterRecord &lhs, const MonsterRecord &rhs)
 		lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
 }
 
-RoomSceneRuntime::RoomSceneRuntime(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager)
+SceneEntities::SceneEntities(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager)
 	: _state(state), _script(script), _resources(resources), _entityManager(entityManager) {
 	debugC(1, kDebugRoom, "Harvester: created room scene runtime room='%s' entities=%d",
 		_state.roomName.c_str(), _entityManager != nullptr);
 }
 
-bool RoomSceneRuntime::loadSceneResources(const RoomSetupState &state, RoomSceneResources &scene) const {
+bool SceneEntities::loadSceneResources(const RoomSetupState &state, SceneState &scene) const {
 	return _resources && loadRoomSceneResources(state, *_resources, scene);
 }
 
-bool RoomSceneRuntime::reconcileSceneEntitiesInPlace(const RoomSceneResources &previousScene,
-		RoomSetupState &updatedState, const RoomSceneResources &updatedScene) {
+bool SceneEntities::reconcileSceneEntitiesInPlace(const SceneState &previousScene,
+		RoomSetupState &updatedState, const SceneState &updatedScene) {
 	for (const ObjectRecord &object : previousScene.sceneObjects) {
 		if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
 			removeSceneEntityByName(object.objectName);
@@ -388,14 +388,11 @@ bool RoomSceneRuntime::reconcileSceneEntitiesInPlace(const RoomSceneResources &p
 	return true;
 }
 
-void RoomSceneRuntime::syncCurrentRoomRuntimeState(const Common::Array<RegionRecord> &sceneRegions) {
+void SceneEntities::syncCurrentRoomRuntimeState(const Common::Array<RegionRecord> &sceneRegions) {
 	Script *script = _script;
 	if (!script)
 		return;
-
-	debugC(3, kDebugRoom, "Harvester: syncing room runtime state room='%s' entities=%d",
-		_state.roomName.c_str(), _entityManager != nullptr);
-
+	
 	for (const RegionRecord &baseRegion : script->getRegions()) {
 		if (!baseRegion.roomName.equalsIgnoreCase(_state.roomName))
 			continue;
@@ -434,7 +431,7 @@ void RoomSceneRuntime::syncCurrentRoomRuntimeState(const Common::Array<RegionRec
 	}
 }
 
-bool RoomSceneRuntime::isTimerMaterializedInCurrentRoom(const Common::String &timerName) const {
+bool SceneEntities::isTimerMaterializedInCurrentRoom(const Common::String &timerName) const {
 	for (const TimerRecord &timer : _state.roomTimers) {
 		if (timer.timerName.equalsIgnoreCase(timerName))
 			return true;
@@ -443,7 +440,7 @@ bool RoomSceneRuntime::isTimerMaterializedInCurrentRoom(const Common::String &ti
 	return false;
 }
 
-void RoomSceneRuntime::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
+void SceneEntities::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
 		bool includeCurrentRoomTimers) {
 	Script *script = _script;
 	if (!script || !_entityManager || previousTimerRecords.empty())
@@ -490,7 +487,7 @@ void RoomSceneRuntime::syncGlobalTimerEntities(const Common::Array<TimerRecord>
 	}
 }
 
-void RoomSceneRuntime::removeSceneEntityByName(const Common::String &entityName) {
+void SceneEntities::removeSceneEntityByName(const Common::String &entityName) {
 	if (!_entityManager)
 		return;
 
@@ -498,13 +495,13 @@ void RoomSceneRuntime::removeSceneEntityByName(const Common::String &entityName)
 	delete entity;
 }
 
-Common::Rect RoomSceneRuntime::getSceneObjectBounds(const ObjectRecord &object) {
+Common::Rect SceneEntities::getSceneObjectBounds(const ObjectRecord &object) {
 	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
 		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
 	return Common::Rect();
 }
 
-Common::String RoomSceneRuntime::resolveSceneObjectSpritePathLocal(const ObjectRecord &object) {
+Common::String SceneEntities::resolveSceneObjectSpritePathLocal(const ObjectRecord &object) {
 	const bool atInitialPlacement = object.currentX == object.initialX &&
 		object.currentY == object.initialY &&
 		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
@@ -514,7 +511,7 @@ Common::String RoomSceneRuntime::resolveSceneObjectSpritePathLocal(const ObjectR
 	return object.spritePath;
 }
 
-bool RoomSceneRuntime::isInteractiveSceneHotspot(const ObjectRecord &object) const {
+bool SceneEntities::isInteractiveSceneHotspot(const ObjectRecord &object) const {
 	if (object.operatable || !object.actionTag.empty())
 		return true;
 
@@ -528,7 +525,7 @@ bool RoomSceneRuntime::isInteractiveSceneHotspot(const ObjectRecord &object) con
 	return script->resolveObjectInspectText(object, inspectText);
 }
 
-RuntimeEntityClass RoomSceneRuntime::resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const {
+RuntimeEntityClass SceneEntities::resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const {
 	if (entity) {
 		return ((!_state.backgroundObjectName.empty() &&
 				object.objectName.equalsIgnoreCase(_state.backgroundObjectName)) ||
@@ -543,7 +540,7 @@ RuntimeEntityClass RoomSceneRuntime::resolveSceneObjectClassLocal(const ObjectRe
 		: kRuntimeEntityClassDisabledHotspot;
 }
 
-void RoomSceneRuntime::spawnSceneObjectEntityFromRecord(const ObjectRecord &object) {
+void SceneEntities::spawnSceneObjectEntityFromRecord(const ObjectRecord &object) {
 	if (!_entityManager)
 		return;
 
@@ -568,13 +565,13 @@ void RoomSceneRuntime::spawnSceneObjectEntityFromRecord(const ObjectRecord &obje
 		object.objectName.c_str(), spritePath.c_str(), entity->getClassId());
 }
 
-Common::Rect RoomSceneRuntime::getSceneRegionBounds(const RegionRecord &region) {
+Common::Rect SceneEntities::getSceneRegionBounds(const RegionRecord &region) {
 	if (region.right > region.left && region.bottom > region.top)
 		return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
 	return Common::Rect();
 }
 
-void RoomSceneRuntime::spawnSceneRegionEntityFromRecord(const RegionRecord &region) {
+void SceneEntities::spawnSceneRegionEntityFromRecord(const RegionRecord &region) {
 	if (!_entityManager)
 		return;
 
@@ -599,15 +596,15 @@ void RoomSceneRuntime::spawnSceneRegionEntityFromRecord(const RegionRecord &regi
 		region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
 }
 
-bool RoomSceneRuntime::shouldSpawnRoomNpcEntity(const NpcRecord &npc) {
+bool SceneEntities::shouldSpawnRoomNpcEntity(const NpcRecord &npc) {
 	return npc.visible && !npc.deathOrMonsterfyFlag;
 }
 
-bool RoomSceneRuntime::shouldSpawnRoomMonsterEntity(const MonsterRecord &monster) {
+bool SceneEntities::shouldSpawnRoomMonsterEntity(const MonsterRecord &monster) {
 	return monster.visible;
 }
 
-bool RoomSceneRuntime::applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc) {
+bool SceneEntities::applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc) {
 	int width = 0;
 	int height = 0;
 	int xOffset = 0;
@@ -622,7 +619,7 @@ bool RoomSceneRuntime::applyRoomNpcPlacement(Entity &entity, const NpcRecord &np
 	return true;
 }
 
-Entity *RoomSceneRuntime::spawnSceneNpcEntityFromRecord(const NpcRecord &npc) {
+Entity *SceneEntities::spawnSceneNpcEntityFromRecord(const NpcRecord &npc) {
 	const bool shouldSpawnNpc = shouldSpawnRoomNpcEntity(npc);
 	const bool preservedCorpse = !npc.active &&
 		npc.runtimeSpawned &&
@@ -658,7 +655,7 @@ Entity *RoomSceneRuntime::spawnSceneNpcEntityFromRecord(const NpcRecord &npc) {
 	return entity;
 }
 
-Entity *RoomSceneRuntime::spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster) {
+Entity *SceneEntities::spawnSceneMonsterEntityFromRecord(const RoomSetupState &roomState, const MonsterRecord &monster) {
 	if (!_entityManager || !shouldSpawnRoomMonsterEntity(monster))
 		return nullptr;
 
@@ -682,7 +679,7 @@ Entity *RoomSceneRuntime::spawnSceneMonsterEntityFromRecord(const RoomSetupState
 	return entity;
 }
 
-void RoomSceneRuntime::restoreRoomMonsterAnimations() {
+void SceneEntities::restoreRoomMonsterAnimations() {
 	if (_entityManager) {
 		for (const MonsterRecord &monster : _state.roomMonsters) {
 			Entity *entity = _entityManager->findSceneEntityByName(monster.monsterName);
@@ -692,7 +689,7 @@ void RoomSceneRuntime::restoreRoomMonsterAnimations() {
 	}
 }
 
-void RoomSceneRuntime::populateRoomRegions() {
+void SceneEntities::populateRoomRegions() {
 	if (!_entityManager)
 		return;
 
@@ -725,7 +722,7 @@ void RoomSceneRuntime::populateRoomRegions() {
 	}
 }
 
-void RoomSceneRuntime::populateRoomObjects(const Common::Array<ObjectRecord> &drawableObjects) {
+void SceneEntities::populateRoomObjects(const Common::Array<ObjectRecord> &drawableObjects) {
 	if (!_entityManager)
 		return;
 
@@ -769,7 +766,7 @@ void RoomSceneRuntime::populateRoomObjects(const Common::Array<ObjectRecord> &dr
 	}
 }
 
-void RoomSceneRuntime::populateRoomNpcs() {
+void SceneEntities::populateRoomNpcs() {
 	if (!_entityManager)
 		return;
 
@@ -815,7 +812,7 @@ void RoomSceneRuntime::populateRoomNpcs() {
 	}
 }
 
-void RoomSceneRuntime::populateRoomMonsters() {
+void SceneEntities::populateRoomMonsters() {
 	if (!_entityManager)
 		return;
 
diff --git a/engines/harvester/room_scene_runtime.h b/engines/harvester/scene_entities.h
similarity index 84%
rename from engines/harvester/room_scene_runtime.h
rename to engines/harvester/scene_entities.h
index 15febc6fbae..fb451a6c12a 100644
--- a/engines/harvester/room_scene_runtime.h
+++ b/engines/harvester/scene_entities.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_ROOM_SCENE_RUNTIME_H
-#define HARVESTER_ROOM_SCENE_RUNTIME_H
+#ifndef HARVESTER_SCENE_ENTITIES_H
+#define HARVESTER_SCENE_ENTITIES_H
 
 #include "harvester/room_support.h"
 #include "harvester/runtime_entity.h"
@@ -28,13 +28,13 @@
 namespace Harvester {
 
 // Borrows the materialized room state; live entities remain owned by EntityManager.
-class RoomSceneRuntime {
+class SceneEntities {
 public:
-	RoomSceneRuntime(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager);
+	SceneEntities(RoomSetupState &state, Script *script, ResourceManager *resources, EntityManager *entityManager);
 
-	bool loadSceneResources(const RoomSetupState &state, RoomSceneResources &scene) const;
-	bool reconcileSceneEntitiesInPlace(const RoomSceneResources &previousScene,
-		RoomSetupState &updatedState, const RoomSceneResources &updatedScene);
+	bool loadSceneResources(const RoomSetupState &state, SceneState &scene) const;
+	bool reconcileSceneEntitiesInPlace(const SceneState &previousScene,
+		RoomSetupState &updatedState, const SceneState &updatedScene);
 	void syncCurrentRoomRuntimeState(const Common::Array<RegionRecord> &sceneRegions);
 	void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords,
 		bool includeCurrentRoomTimers = false);
@@ -69,4 +69,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_ROOM_SCENE_RUNTIME_H
+#endif // HARVESTER_SCENE_ENTITIES_H


Commit: d8ab1acf31a44aa1ced4dd4890af257f25138ec2
    https://github.com/scummvm/scummvm/commit/d8ab1acf31a44aa1ced4dd4890af257f25138ec2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: refactor/rename Entity, Interaction, Combat and Input

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/combat.cpp
  A engines/harvester/combat.h
  A engines/harvester/entity.cpp
  A engines/harvester/entity.h
  A engines/harvester/input.cpp
  A engines/harvester/input.h
  A engines/harvester/interaction.cpp
  A engines/harvester/interaction.h
  R engines/harvester/room_combat.cpp
  R engines/harvester/room_combat.h
  R engines/harvester/room_input.cpp
  R engines/harvester/room_input.h
  R engines/harvester/room_interaction.cpp
  R engines/harvester/room_interaction.h
  R engines/harvester/runtime_entity.cpp
  R engines/harvester/runtime_entity.h
    engines/harvester/dialogue.cpp
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/media_manager.cpp
    engines/harvester/menu.cpp
    engines/harvester/module.mk
    engines/harvester/monster.cpp
    engines/harvester/player.cpp
    engines/harvester/scene.cpp
    engines/harvester/scene.h
    engines/harvester/scene_entities.cpp
    engines/harvester/scene_entities.h


diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/combat.cpp
similarity index 89%
rename from engines/harvester/room_combat.cpp
rename to engines/harvester/combat.cpp
index 53eb6a5ffb1..45cbbbf6bb2 100644
--- a/engines/harvester/room_combat.cpp
+++ b/engines/harvester/combat.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/room_combat.h"
+#include "harvester/combat.h"
 
 #include "common/util.h"
 #include "graphics/font.h"
@@ -29,9 +29,9 @@
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/player.h"
-#include "harvester/room_interaction.h"
+#include "harvester/interaction.h"
 #include "harvester/scene_entities.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 
 namespace Harvester {
 
@@ -364,21 +364,21 @@ bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, in
 
 bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 		const Entity &entity, int actorCenterX, int targetCenterX,
-		RoomAttackAnimationRange &range) {
+		AttackAnimationRange &range) {
 	// Native class-6 attackers pick one of the 0x16..0x1b attack states, which map to
 	// the 0x50..0x8b strike banks. The 0x8c..0x9d banks are hit reactions, not attacks.
-	static const RoomAttackAnimationRange kLeftAttackRanges[3] = {
-		RoomAttackAnimationRange(0x64, 0x6d, 1),
-		RoomAttackAnimationRange(0x5a, 0x63, 1),
-		RoomAttackAnimationRange(0x50, 0x59, 1)
+	static const AttackAnimationRange kLeftAttackRanges[3] = {
+		AttackAnimationRange(0x64, 0x6d, 1),
+		AttackAnimationRange(0x5a, 0x63, 1),
+		AttackAnimationRange(0x50, 0x59, 1)
 	};
-	static const RoomAttackAnimationRange kRightAttackRanges[3] = {
-		RoomAttackAnimationRange(0x82, 0x8b, 2),
-		RoomAttackAnimationRange(0x78, 0x81, 2),
-		RoomAttackAnimationRange(0x6e, 0x77, 2)
+	static const AttackAnimationRange kRightAttackRanges[3] = {
+		AttackAnimationRange(0x82, 0x8b, 2),
+		AttackAnimationRange(0x78, 0x81, 2),
+		AttackAnimationRange(0x6e, 0x77, 2)
 	};
 
-	const RoomAttackAnimationRange *candidates =
+	const AttackAnimationRange *candidates =
 		targetCenterX < actorCenterX ? kLeftAttackRanges : kRightAttackRanges;
 	int availableCandidateIndices[3];
 	int availableCandidateCount = 0;
@@ -398,23 +398,23 @@ bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 }
 
 bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
-		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
+		int deathDamageType, bool goreEnabled, DeathAnimationRange &range) {
 	const bool preferLeftBank = facing == 1;
-	const RoomDeathAnimationRange rightBludge(0xb0, 0xb9);
-	const RoomDeathAnimationRange leftBludge(0xba, 0xc3);
-	const RoomDeathAnimationRange rightSlash(0x100, 0x109);
-	const RoomDeathAnimationRange leftSlash(0x10a, 0x113);
-	const RoomDeathAnimationRange rightProjectile(0x114, 0x11d);
-	const RoomDeathAnimationRange leftProjectile(0x11e, 0x127);
-
-	const RoomDeathAnimationRange primaryBludge = preferLeftBank ? leftBludge : rightBludge;
-	const RoomDeathAnimationRange primarySlash = preferLeftBank ? leftSlash : rightSlash;
-	const RoomDeathAnimationRange primaryProjectile = preferLeftBank ? leftProjectile : rightProjectile;
-	const RoomDeathAnimationRange fallbackBludge = preferLeftBank ? rightBludge : leftBludge;
-	const RoomDeathAnimationRange fallbackSlash = preferLeftBank ? rightSlash : leftSlash;
-	const RoomDeathAnimationRange fallbackProjectile = preferLeftBank ? rightProjectile : leftProjectile;
-
-	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
+	const DeathAnimationRange rightBludge(0xb0, 0xb9);
+	const DeathAnimationRange leftBludge(0xba, 0xc3);
+	const DeathAnimationRange rightSlash(0x100, 0x109);
+	const DeathAnimationRange leftSlash(0x10a, 0x113);
+	const DeathAnimationRange rightProjectile(0x114, 0x11d);
+	const DeathAnimationRange leftProjectile(0x11e, 0x127);
+
+	const DeathAnimationRange primaryBludge = preferLeftBank ? leftBludge : rightBludge;
+	const DeathAnimationRange primarySlash = preferLeftBank ? leftSlash : rightSlash;
+	const DeathAnimationRange primaryProjectile = preferLeftBank ? leftProjectile : rightProjectile;
+	const DeathAnimationRange fallbackBludge = preferLeftBank ? rightBludge : leftBludge;
+	const DeathAnimationRange fallbackSlash = preferLeftBank ? rightSlash : leftSlash;
+	const DeathAnimationRange fallbackProjectile = preferLeftBank ? rightProjectile : leftProjectile;
+
+	auto chooseIfAvailable = [&](const DeathAnimationRange &candidate) {
 		if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
 			return false;
 		range = candidate;
@@ -437,12 +437,12 @@ bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
 }
 
 bool resolveNpcDeathAnimationRange(const Entity &entity, bool hasMonsterfyTarget,
-		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
-	const RoomDeathAnimationRange goreBank(0x3c, 0x45);
-	const RoomDeathAnimationRange bludgeBank(0x46, 0x4f);
+		int deathDamageType, bool goreEnabled, DeathAnimationRange &range) {
+	const DeathAnimationRange goreBank(0x3c, 0x45);
+	const DeathAnimationRange bludgeBank(0x46, 0x4f);
 	const bool preferGoreBank = hasMonsterfyTarget || (goreEnabled && deathDamageType != 1);
 
-	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
+	auto chooseIfAvailable = [&](const DeathAnimationRange &candidate) {
 		if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
 			return false;
 		range = candidate;
@@ -528,26 +528,26 @@ bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &so
 }
 
 bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
-		int attackerAttackFirstFrame, RoomHitAnimationRange &range) {
-	RoomHitAnimationRange candidate;
+		int attackerAttackFirstFrame, HitAnimationRange &range) {
+	HitAnimationRange candidate;
 	switch (attackerAttackFirstFrame) {
 	case 0x64:
-		candidate = RoomHitAnimationRange(0x92, 0x94, 2, -kNativeCombatHitKnockbackDistance);
+		candidate = HitAnimationRange(0x92, 0x94, 2, -kNativeCombatHitKnockbackDistance);
 		break;
 	case 0x5a:
-		candidate = RoomHitAnimationRange(0x8f, 0x91, 2, -kNativeCombatHitKnockbackDistance);
+		candidate = HitAnimationRange(0x8f, 0x91, 2, -kNativeCombatHitKnockbackDistance);
 		break;
 	case 0x50:
-		candidate = RoomHitAnimationRange(0x8c, 0x8e, 2, -kNativeCombatHitKnockbackDistance);
+		candidate = HitAnimationRange(0x8c, 0x8e, 2, -kNativeCombatHitKnockbackDistance);
 		break;
 	case 0x82:
-		candidate = RoomHitAnimationRange(0x9b, 0x9d, 1, kNativeCombatHitKnockbackDistance);
+		candidate = HitAnimationRange(0x9b, 0x9d, 1, kNativeCombatHitKnockbackDistance);
 		break;
 	case 0x78:
-		candidate = RoomHitAnimationRange(0x98, 0x9a, 1, kNativeCombatHitKnockbackDistance);
+		candidate = HitAnimationRange(0x98, 0x9a, 1, kNativeCombatHitKnockbackDistance);
 		break;
 	case 0x6e:
-		candidate = RoomHitAnimationRange(0x95, 0x97, 1, kNativeCombatHitKnockbackDistance);
+		candidate = HitAnimationRange(0x95, 0x97, 1, kNativeCombatHitKnockbackDistance);
 		break;
 	default:
 		return false;
@@ -586,18 +586,18 @@ int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
 
 const char *describeCombatTargetClass(int classId) {
 	switch (classId) {
-	case kRuntimeEntityClassNpc:
+	case kEntityClassNpc:
 		return "npc";
-	case kRuntimeEntityClassMonster:
+	case kEntityClassMonster:
 		return "monster";
-	case kRuntimeEntityClassPlayer:
+	case kEntityClassPlayer:
 		return "player";
 	default:
 		return "none";
 	}
 }
 
-MonsterRecord *RoomCombat::findMonsterTargetAtPoint(RoomSetupState &state, EntityManager *entityManager, const Common::Point &point) const {
+MonsterRecord *Combat::findMonsterTargetAtPoint(RoomSetupState &state, EntityManager *entityManager, const Common::Point &point) const {
 	MonsterRecord *bestMonster = nullptr;
 	int bestZ = -0x7fffffff;
 	int bestTop = -0x7fffffff;
@@ -624,7 +624,7 @@ MonsterRecord *RoomCombat::findMonsterTargetAtPoint(RoomSetupState &state, Entit
 	return bestMonster;
 }
 
-MonsterRecord *RoomCombat::findOverlappingMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+MonsterRecord *Combat::findOverlappingMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState,
 		EntityManager *entityManager) const {
 	if (!playerState.entity)
 		return nullptr;
@@ -642,7 +642,7 @@ MonsterRecord *RoomCombat::findOverlappingMonsterTarget(RoomSetupState &state, c
 	return nullptr;
 }
 
-NpcRecord *RoomCombat::findOverlappingNpcTarget(RoomSetupState &state, const RoomPlayerState &playerState, EntityManager *entityManager) const {
+NpcRecord *Combat::findOverlappingNpcTarget(RoomSetupState &state, const RoomPlayerState &playerState, EntityManager *entityManager) const {
 	if (!playerState.entity)
 		return nullptr;
 
@@ -659,7 +659,7 @@ NpcRecord *RoomCombat::findOverlappingNpcTarget(RoomSetupState &state, const Roo
 	return nullptr;
 }
 
-bool RoomCombat::playerAttackCanReachTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+bool Combat::playerAttackCanReachTarget(RoomSetupState &state, const RoomPlayerState &playerState,
 		Entity *targetEntity, int engageDistance) const {
 	if (!playerState.entity || !targetEntity)
 		return false;
@@ -670,7 +670,7 @@ bool RoomCombat::playerAttackCanReachTarget(RoomSetupState &state, const RoomPla
 		*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
 }
 
-MonsterRecord *RoomCombat::findProjectileContactMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+MonsterRecord *Combat::findProjectileContactMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState,
 		EntityManager *entityManager) const {
 	if (!playerState.entity)
 		return nullptr;
@@ -737,7 +737,7 @@ MonsterRecord *RoomCombat::findProjectileContactMonsterTarget(RoomSetupState &st
 	return bestMonster;
 }
 
-void RoomCombat::capturePlayerAttackTargetForKeyboard(RoomSetupState &state, RoomPlayerState &playerState,
+void Combat::capturePlayerAttackTargetForKeyboard(RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, bool attackLeft, bool attackRight, bool attackUp, bool attackDown) const {
 	playerState.attackTargetName.clear();
 	playerState.attackTargetClassId = -1;
@@ -823,12 +823,12 @@ void RoomCombat::capturePlayerAttackTargetForKeyboard(RoomSetupState &state, Roo
 	for (NpcRecord &npc : state.roomNpcs) {
 		if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
 			continue;
-		considerTarget(findSceneRuntimeEntity(entityManager, npc.npcName), npc.npcName, kRuntimeEntityClassNpc);
+		considerTarget(findSceneRuntimeEntity(entityManager, npc.npcName), npc.npcName, kEntityClassNpc);
 	}
 	for (MonsterRecord &monster : state.roomMonsters) {
 		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
 			continue;
-		considerTarget(findSceneRuntimeEntity(entityManager, monster.monsterName), monster.monsterName, kRuntimeEntityClassMonster);
+		considerTarget(findSceneRuntimeEntity(entityManager, monster.monsterName), monster.monsterName, kEntityClassMonster);
 	}
 
 	if (!bestEntity) {
@@ -848,9 +848,9 @@ void RoomCombat::capturePlayerAttackTargetForKeyboard(RoomSetupState &state, Roo
 		bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
 }
 
-MonsterRecord *RoomCombat::resolveKillTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+MonsterRecord *Combat::resolveKillTarget(RoomSetupState &state, const RoomPlayerState &playerState,
 		EntityManager *entityManager, const Common::Point &mousePos) const {
-	if (playerState.attackTargetClassId == kRuntimeEntityClassMonster) {
+	if (playerState.attackTargetClassId == kEntityClassMonster) {
 		MonsterRecord *monster = findRoomMonsterRecordByName(state, playerState.attackTargetName);
 		if (isKillableMonster(monster) && selectMonsterEntity(entityManager, *monster))
 			return monster;
@@ -864,7 +864,7 @@ MonsterRecord *RoomCombat::resolveKillTarget(RoomSetupState &state, const RoomPl
 	if (playerState.entity) {
 		for (uint i = 0; i < state.roomMonsters.size() && i < _monsterCombatStates.size(); ++i) {
 			MonsterRecord &monster = state.roomMonsters[i];
-			const RoomMonsterCombatState &combatState = _monsterCombatStates[i];
+			const MonsterCombatState &combatState = _monsterCombatStates[i];
 			if (!combatState.attackActive || !isKillableMonster(&monster) || !selectMonsterEntity(entityManager, monster))
 				continue;
 			if (!combatState.attackTargetName.empty() &&
@@ -902,11 +902,11 @@ MonsterRecord *RoomCombat::resolveKillTarget(RoomSetupState &state, const RoomPl
 	return bestMonster;
 }
 
-void RoomCombat::capturePlayerAttackTarget(RoomSetupState &state, RoomPlayerState &playerState,
+void Combat::capturePlayerAttackTarget(RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, const Common::Point &mousePos, const NpcRecord *hoverNpc) const {
 	if (hoverNpc) {
 		playerState.attackTargetName = hoverNpc->npcName;
-		playerState.attackTargetClassId = kRuntimeEntityClassNpc;
+		playerState.attackTargetClassId = kEntityClassNpc;
 		debugC(1, kDebugCombat,
 			"Harvester: combat player target capture class='npc' name='%s' cursor=(%d,%d)",
 			playerState.attackTargetName.c_str(), mousePos.x, mousePos.y);
@@ -915,7 +915,7 @@ void RoomCombat::capturePlayerAttackTarget(RoomSetupState &state, RoomPlayerStat
 
 	if (MonsterRecord *monster = findMonsterTargetAtPoint(state, entityManager, mousePos)) {
 		playerState.attackTargetName = monster->monsterName;
-		playerState.attackTargetClassId = kRuntimeEntityClassMonster;
+		playerState.attackTargetClassId = kEntityClassMonster;
 		debugC(1, kDebugCombat,
 			"Harvester: combat player target capture class='monster' name='%s' cursor=(%d,%d)",
 			playerState.attackTargetName.c_str(), mousePos.x, mousePos.y);
@@ -927,16 +927,16 @@ void RoomCombat::capturePlayerAttackTarget(RoomSetupState &state, RoomPlayerStat
 		mousePos.x, mousePos.y);
 }
 
-void RoomCombat::captureOverlappingNpcAttackTarget(RoomSetupState &state, RoomPlayerState &playerState, EntityManager *entityManager) const {
+void Combat::captureOverlappingNpcAttackTarget(RoomSetupState &state, RoomPlayerState &playerState, EntityManager *entityManager) const {
 	if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
 		if (NpcRecord *fallbackNpc = findOverlappingNpcTarget(state, playerState, entityManager)) {
 			playerState.attackTargetName = fallbackNpc->npcName;
-			playerState.attackTargetClassId = kRuntimeEntityClassNpc;
+			playerState.attackTargetClassId = kEntityClassNpc;
 		}
 	}
 }
 
-NpcRecord *RoomCombat::selectNpcAttackContactTarget(RoomSetupState &state, const RoomPlayerState &playerState,
+NpcRecord *Combat::selectNpcAttackContactTarget(RoomSetupState &state, const RoomPlayerState &playerState,
 		EntityManager *entityManager, Entity *&npcEntity) const {
 	NpcRecord *npc = findRoomNpcRecordByName(state, playerState.attackTargetName);
 	npcEntity = npc ? findSceneRuntimeEntity(entityManager, npc->npcName) : nullptr;
@@ -946,10 +946,10 @@ NpcRecord *RoomCombat::selectNpcAttackContactTarget(RoomSetupState &state, const
 	return npc;
 }
 
-MonsterRecord *RoomCombat::selectMonsterAttackContactTarget(RoomSetupState &state, RoomPlayerState &playerState,
+MonsterRecord *Combat::selectMonsterAttackContactTarget(RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, Entity *&monsterEntity) const {
 	MonsterRecord *monster = nullptr;
-	if (playerState.attackTargetClassId == kRuntimeEntityClassMonster)
+	if (playerState.attackTargetClassId == kEntityClassMonster)
 		monster = findRoomMonsterRecordByName(state, playerState.attackTargetName);
 	monsterEntity = monster ? findSceneRuntimeEntity(entityManager, monster->monsterName) : nullptr;
 	if (!monster || !monsterEntity || !playerAttackCanReachTarget(state, playerState, monsterEntity, monster->engageDistance)) {
@@ -959,7 +959,7 @@ MonsterRecord *RoomCombat::selectMonsterAttackContactTarget(RoomSetupState &stat
 			monster = findProjectileContactMonsterTarget(state, playerState, entityManager);
 			if (monster) {
 				playerState.attackTargetName = monster->monsterName;
-				playerState.attackTargetClassId = kRuntimeEntityClassMonster;
+				playerState.attackTargetClassId = kEntityClassMonster;
 			}
 		} else {
 			monster = nullptr;
@@ -969,13 +969,13 @@ MonsterRecord *RoomCombat::selectMonsterAttackContactTarget(RoomSetupState &stat
 	return monster;
 }
 
-RoomNpcCombatUpdateResult RoomCombat::updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
+NpcCombatUpdateResult Combat::updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
 		EntityManager *entityManager, SceneEntities &sceneRuntime,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
-	RoomNpcCombatUpdateResult result;
+		Interaction &interactionProcessor, bool &needsRedraw) {
+	NpcCombatUpdateResult result;
 	for (uint i = 0; i < state.roomNpcs.size(); ++i) {
 		NpcRecord &npc = state.roomNpcs[i];
-		RoomNpcCombatState &combatState = _npcCombatStates[i];
+		NpcCombatState &combatState = _npcCombatStates[i];
 		Entity *entity = findSceneRuntimeEntity(entityManager, npc.npcName);
 		if (!entity || !npc.visible || npc.deathOrMonsterfyFlag) {
 			clearRoomNpcCombatState(combatState);
@@ -1010,7 +1010,7 @@ RoomNpcCombatUpdateResult RoomCombat::updateRoomNpcCombat(HarvesterEngine &engin
 	return result;
 }
 
-bool RoomCombat::queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const {
+bool Combat::queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const {
 	if (!monster.roomName.equalsIgnoreCase(kInnerSanctumRoomName) ||
 			!monster.monsterName.equalsIgnoreCase(kMuckeyMonsterName) ||
 			!monster.onDeathActionTag.equalsIgnoreCase(kActivateInnerSanctumDoorActionTag)) {
@@ -1039,9 +1039,9 @@ bool RoomCombat::queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine,
 	return true;
 }
 
-bool RoomCombat::beginNpcDeathTransition(HarvesterEngine &engine,
-		NpcRecord &npc, Entity &npcEntity, RoomNpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const {
-	RoomDeathAnimationRange deathRange;
+bool Combat::beginNpcDeathTransition(HarvesterEngine &engine,
+		NpcRecord &npc, Entity &npcEntity, NpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const {
+	DeathAnimationRange deathRange;
 	if (!resolveNpcDeathAnimationRange(npcEntity, !npc.monsterfyTargetName.empty(),
 			deathDamageType, engine.isGoreEnabled(), deathRange))
 		return false;
@@ -1065,9 +1065,9 @@ bool RoomCombat::beginNpcDeathTransition(HarvesterEngine &engine,
 	return true;
 }
 
-Common::Error RoomCombat::finalizeNpcDeathTransition(HarvesterEngine &engine,
-		EntityManager *entityManager, SceneEntities &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) const {
+Common::Error Combat::finalizeNpcDeathTransition(HarvesterEngine &engine,
+		EntityManager *entityManager, SceneEntities &sceneRuntime, NpcRecord &npc, NpcCombatState &combatState,
+		Interaction &interactionProcessor, bool &needsRedraw) const {
 	Script *script = engine.getScript();
 	Entity *npcEntity = findSceneRuntimeEntity(entityManager, npc.npcName);
 	// Native state 0x35 leaves non-monsterfied NPCs as a dead state-0x38 actor
@@ -1084,7 +1084,7 @@ Common::Error RoomCombat::finalizeNpcDeathTransition(HarvesterEngine &engine,
 		npcEntity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
 		npcEntity->setCurrentFrame(combatState.deathLastFrame);
 		npcEntity->setAnimationEnabled(false);
-		npcEntity->setHitTestMode(kRuntimeEntityHitTestNone);
+		npcEntity->setHitTestMode(kEntityHitTestNone);
 		npcEntity->setVisible(true);
 	} else {
 		sceneRuntime.removeSceneEntityByName(npc.npcName);
@@ -1122,8 +1122,8 @@ Common::Error RoomCombat::finalizeNpcDeathTransition(HarvesterEngine &engine,
 	return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
 }
 
-void RoomCombat::startMonsterHitReaction(HarvesterEngine &engine,
-		RoomCombatEffects &combatEffects, MonsterRecord &monster, Entity &monsterEntity, RoomMonsterCombatState *combatState,
+void Combat::startMonsterHitReaction(HarvesterEngine &engine,
+		CombatEffects &combatEffects, MonsterRecord &monster, Entity &monsterEntity, MonsterCombatState *combatState,
 		int attackerAttackFirstFrame, bool &needsRedraw) const {
 	if (combatEffects.spawnCombatHitEffect(monsterEntity, monster.monsterName))
 		needsRedraw = true;
@@ -1134,7 +1134,7 @@ void RoomCombat::startMonsterHitReaction(HarvesterEngine &engine,
 		return;
 	}
 
-	RoomHitAnimationRange range;
+	HitAnimationRange range;
 	if (!resolveRoomMonsterHitAnimationRange(monsterEntity, attackerAttackFirstFrame, range)) {
 		(void)playRandomRoomAttackSound(engine,
 			monster.hitSound1, monster.hitSound2, monster.hitSound3);
@@ -1172,9 +1172,9 @@ void RoomCombat::startMonsterHitReaction(HarvesterEngine &engine,
 	needsRedraw = true;
 }
 
-Common::Error RoomCombat::killDebugActiveMonster(HarvesterEngine &engine, RoomSetupState &state,
+Common::Error Combat::killDebugActiveMonster(HarvesterEngine &engine, RoomSetupState &state,
 		RoomPlayerState &playerState, EntityManager *entityManager, const Common::Point &mousePos,
-		RoomCombatEffects &combatEffects, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+		CombatEffects &combatEffects, Interaction &interactionProcessor, bool &needsRedraw) {
 	if (!engine.isCombatDebugEnabled())
 		return Common::kNoError;
 	MonsterRecord *monster = resolveKillTarget(state, playerState, entityManager, mousePos);
@@ -1200,7 +1200,7 @@ Common::Error RoomCombat::killDebugActiveMonster(HarvesterEngine &engine, RoomSe
 	if (damageLanded > 0)
 		combatEffects.spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
 
-	if (playerState.attackTargetClassId == kRuntimeEntityClassMonster &&
+	if (playerState.attackTargetClassId == kEntityClassMonster &&
 			playerState.attackTargetName.equalsIgnoreCase(monster->monsterName)) {
 		playerState.attackTargetName.clear();
 		playerState.attackTargetClassId = -1;
@@ -1211,7 +1211,7 @@ Common::Error RoomCombat::killDebugActiveMonster(HarvesterEngine &engine, RoomSe
 		monster->monsterName.c_str(), damageLanded, debugDamageType, mousePos.x, mousePos.y);
 
 	const int monsterIndex = findRoomMonsterIndexByName(state, monster->monsterName);
-	RoomMonsterCombatState *combatState =
+	MonsterCombatState *combatState =
 		monsterIndex >= 0 && monsterIndex < (int)_monsterCombatStates.size()
 			? &_monsterCombatStates[(uint)monsterIndex]
 			: nullptr;
@@ -1219,7 +1219,7 @@ Common::Error RoomCombat::killDebugActiveMonster(HarvesterEngine &engine, RoomSe
 		clearRoomMonsterCombatState(*combatState);
 	(void)script->syncRuntimeMonsterRecord(*monster);
 
-	RoomDeathAnimationRange deathRange;
+	DeathAnimationRange deathRange;
 	if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
 			debugDamageType, engine.isGoreEnabled(), deathRange)) {
 		combatState->deathActive = true;
@@ -1255,10 +1255,10 @@ Common::Error RoomCombat::killDebugActiveMonster(HarvesterEngine &engine, RoomSe
 	return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
 }
 
-Common::Error RoomCombat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetupState &state,
+Common::Error Combat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetupState &state,
 		RoomPlayerState &playerState, RoomIdleAnimationState &idleState, EntityManager *entityManager,
-		SceneEntities &sceneRuntime, RoomCombatEffects &combatEffects, const CheatSystem &cheats,
-		RoomInteractionCallbacks &callbacks, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+		SceneEntities &sceneRuntime, CombatEffects &combatEffects, const CheatSystem &cheats,
+		InteractionCallbacks &callbacks, Interaction &interactionProcessor, bool &needsRedraw) {
 	if (!entityManager)
 		return Common::kNoError;
 
@@ -1272,14 +1272,14 @@ Common::Error RoomCombat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomS
 
 	resizeCombatStates(state);
 
-	const RoomNpcCombatUpdateResult npcResult = updateRoomNpcCombat(engine, state,
+	const NpcCombatUpdateResult npcResult = updateRoomNpcCombat(engine, state,
 		entityManager, sceneRuntime, interactionProcessor, needsRedraw);
 	if (npcResult.finalizedDeath)
 		return npcResult.error;
 
 	for (uint i = 0; i < state.roomMonsters.size(); ++i) {
 		MonsterRecord &monster = state.roomMonsters[i];
-		RoomMonsterCombatState &combatState = _monsterCombatStates[i];
+		MonsterCombatState &combatState = _monsterCombatStates[i];
 		Entity *entity = findSceneRuntimeEntity(entityManager, monster.monsterName);
 		if (!entity || !monster.visible) {
 			clearRoomMonsterCombatState(combatState);
@@ -1540,7 +1540,7 @@ Common::Error RoomCombat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomS
 		if (!attackCooldownExpired && absLiveCenterDx > closeRangeBypassDistance)
 			continue;
 
-		RoomAttackAnimationRange range;
+		AttackAnimationRange range;
 		if (!resolveMonsterAttackAnimationRange(engine, *entity, monsterCenterX, playerCenterX, range)) {
 			debugC(1, kDebugCombat,
 				"Harvester: combat monster attack skipped monster='%s' reason='no native attack bank' target_side=%s",
@@ -1578,11 +1578,11 @@ Common::Error RoomCombat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomS
 	return Common::kNoError;
 }
 
-Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
+Common::Error Combat::resolvePlayerAttackContact(HarvesterEngine &engine,
 		RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, SceneEntities &sceneRuntime,
-		RoomCombatEffects &combatEffects,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+		CombatEffects &combatEffects,
+		Interaction &interactionProcessor, bool &needsRedraw) {
 	if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
 			playerState.attackContactFrame < 0 ||
 			playerState.entity->getCurrentFrame() < playerState.attackContactFrame) {
@@ -1603,7 +1603,7 @@ Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
 		return Common::kNoError;
 
 	captureOverlappingNpcAttackTarget(state, playerState, entityManager);
-	if (playerState.attackTargetClassId == kRuntimeEntityClassNpc) {
+	if (playerState.attackTargetClassId == kEntityClassNpc) {
 		Entity *npcEntity = nullptr;
 		NpcRecord *npc = selectNpcAttackContactTarget(state, playerState, entityManager, npcEntity);
 		if (!npc) {
@@ -1619,7 +1619,7 @@ Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
 			? script->setRuntimeFlagValue("JIMMY_ATTACKED", true)
 			: false;
 		const int npcIndex = findRoomNpcIndexByName(state, npc->npcName);
-		RoomNpcCombatState *combatState =
+		NpcCombatState *combatState =
 			npcIndex >= 0 && npcIndex < (int)_npcCombatStates.size()
 				? &_npcCombatStates[(uint)npcIndex]
 				: nullptr;
@@ -1645,7 +1645,7 @@ Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
 			debugC(1, kDebugCombat,
 				"Harvester: combat npc defeated target='%s' damage_type=%d on_death='%s' fallback='no death bank'",
 				npc->npcName.c_str(), damageType, npc->onDeathActionTag.c_str());
-			RoomNpcCombatState fallbackState;
+			NpcCombatState fallbackState;
 			fallbackState.deathDamageType = damageType;
 			return finalizeNpcDeathTransition(engine, entityManager, sceneRuntime, *npc, combatState ? *combatState : fallbackState, interactionProcessor, needsRedraw);
 		}
@@ -1667,7 +1667,7 @@ Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
 		monster->currentHitPoints - damageAmount);
 	const int damageLanded = hitPointsBefore - monster->currentHitPoints;
 	const int monsterIndex = findRoomMonsterIndexByName(state, monster->monsterName);
-	RoomMonsterCombatState *combatState =
+	MonsterCombatState *combatState =
 		monsterIndex >= 0 && monsterIndex < (int)_monsterCombatStates.size()
 			? &_monsterCombatStates[(uint)monsterIndex]
 			: nullptr;
@@ -1689,7 +1689,7 @@ Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
 		clearRoomMonsterCombatState(*combatState);
 	(void)script->syncRuntimeMonsterRecord(*monster);
 
-	RoomDeathAnimationRange deathRange;
+	DeathAnimationRange deathRange;
 	if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
 			damageType, engine.isGoreEnabled(), deathRange)) {
 		combatState->deathActive = true;
@@ -1738,23 +1738,23 @@ Common::Error RoomCombat::resolvePlayerAttackContact(HarvesterEngine &engine,
 	return interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
 }
 
-void RoomCombat::initializeCombatStates(const RoomSetupState &state) {
+void Combat::initializeCombatStates(const RoomSetupState &state) {
 	_monsterCombatStates.resize(state.roomMonsters.size());
 	_npcCombatStates.resize(state.roomNpcs.size());
 	debugC(1, kDebugCombat, "Harvester: initialized room combat states monsters=%u npcs=%u",
 		_monsterCombatStates.size(), _npcCombatStates.size());
 }
 
-void RoomCombat::resizeCombatStates(const RoomSetupState &state) {
+void Combat::resizeCombatStates(const RoomSetupState &state) {
 	if (_npcCombatStates.size() != state.roomNpcs.size())
 		_npcCombatStates.resize(state.roomNpcs.size());
 	if (_monsterCombatStates.size() != state.roomMonsters.size())
 		_monsterCombatStates.resize(state.roomMonsters.size());
 }
 
-Common::Array<RoomMonsterCombatState> RoomCombat::prepareMonsterCombatStatesForSceneChange(
+Common::Array<MonsterCombatState> Combat::prepareMonsterCombatStatesForSceneChange(
 		const Common::Array<MonsterRecord> &previousMonsters, const Common::Array<MonsterRecord> &updatedMonsters) const {
-	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates;
+	Common::Array<MonsterCombatState> updatedMonsterCombatStates;
 	updatedMonsterCombatStates.resize(updatedMonsters.size());
 	for (uint i = 0; i < updatedMonsters.size(); ++i) {
 		const MonsterRecord &updatedMonster = updatedMonsters[i];
@@ -1772,12 +1772,12 @@ Common::Array<RoomMonsterCombatState> RoomCombat::prepareMonsterCombatStatesForS
 	return updatedMonsterCombatStates;
 }
 
-void RoomCombat::commitMonsterCombatStatesForSceneChange(Common::Array<RoomMonsterCombatState> &&states) {
+void Combat::commitMonsterCombatStatesForSceneChange(Common::Array<MonsterCombatState> &&states) {
 	_monsterCombatStates = Common::move(states);
 	debugC(2, kDebugCombat, "Harvester: committed room monster combat states count=%u", _monsterCombatStates.size());
 }
 
-void RoomCombat::resetForSceneRefresh(const RoomSetupState &state) {
+void Combat::resetForSceneRefresh(const RoomSetupState &state) {
 	// NPC death state survives refresh and is resized by index at the next combat update.
 	_monsterCombatStates.clear();
 	_monsterCombatStates.resize(state.roomMonsters.size());
@@ -1785,15 +1785,15 @@ void RoomCombat::resetForSceneRefresh(const RoomSetupState &state) {
 		_monsterCombatStates.size(), _npcCombatStates.size());
 }
 
-void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState) {
-	combatState = RoomMonsterCombatState();
+void clearRoomMonsterCombatState(MonsterCombatState &combatState) {
+	combatState = MonsterCombatState();
 }
 
-void clearRoomNpcCombatState(RoomNpcCombatState &combatState) {
-	combatState = RoomNpcCombatState();
+void clearRoomNpcCombatState(NpcCombatState &combatState) {
+	combatState = NpcCombatState();
 }
 
-void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState) {
+void clearRoomMonsterAttackState(MonsterCombatState &combatState) {
 	combatState.attackActive = false;
 	combatState.attackFirstFrame = -1;
 	combatState.attackLastFrame = -1;
@@ -1804,7 +1804,7 @@ void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState) {
 	combatState.attackTargetName.clear();
 }
 
-void clearRoomMonsterHitState(RoomMonsterCombatState &combatState) {
+void clearRoomMonsterHitState(MonsterCombatState &combatState) {
 	combatState.hitActive = false;
 	combatState.hitFirstFrame = -1;
 	combatState.hitLastFrame = -1;
@@ -1814,35 +1814,35 @@ void clearRoomMonsterHitState(RoomMonsterCombatState &combatState) {
 	combatState.hitSoundPlayed = false;
 }
 
-RoomCombatEffects::RoomCombatEffects(EntityManager *entityManager) : _entityManager(entityManager) {
+CombatEffects::CombatEffects(EntityManager *entityManager) : _entityManager(entityManager) {
 	debugC(1, kDebugCombat, "Harvester: created room combat effects entities=%d", _entityManager != nullptr);
 }
 
-void RoomCombatEffects::clearHitEffects() {
+void CombatEffects::clearHitEffects() {
 	debugC(2, kDebugCombat, "Harvester: resetting room blood effects records=%u next_id=%u",
 		(uint)_hitEffectStates.size(), _nextCombatEffectId);
 	_hitEffectStates.clear();
 	_nextCombatEffectId = 0;
 }
 
-void RoomCombatEffects::resolveHitEffectAnchor(const Entity &targetEntity, Common::Point &anchorPoint, float &renderZ) {
+void CombatEffects::resolveHitEffectAnchor(const Entity &targetEntity, Common::Point &anchorPoint, float &renderZ) {
 	const Common::Rect targetRect = targetEntity.getScreenRect();
 	anchorPoint.x = targetRect.left + targetRect.width() / 2;
 	anchorPoint.y = targetRect.top + targetRect.height() / 3;
 	renderZ = targetEntity.getZ() - kNativeHitEffectRenderZBias;
 }
 
-void RoomCombatEffects::resolveCombatDamagePopupAnchor(const Entity &targetEntity, Common::Point &anchorPoint) {
+void CombatEffects::resolveCombatDamagePopupAnchor(const Entity &targetEntity, Common::Point &anchorPoint) {
 	const Common::Rect targetRect = targetEntity.getScreenRect();
 	anchorPoint.x = targetRect.left + targetRect.width() / 2;
 	anchorPoint.y = targetRect.top - kCombatDebugDamagePopupVerticalGap;
 }
 
-bool RoomCombatEffects::spawnCombatHitEffect(Entity &sourceEntity, const Common::String &followTargetName) {
+bool CombatEffects::spawnCombatHitEffect(Entity &sourceEntity, const Common::String &followTargetName) {
 	if (!_entityManager)
 		return false;
 
-	RoomHitEffectState effectState;
+	HitEffectState effectState;
 	effectState.entityName = Common::String::format("__combat_blood_%u", _nextCombatEffectId++);
 	effectState.followTargetName = followTargetName;
 	resolveHitEffectAnchor(sourceEntity, effectState.anchorPoint, effectState.renderZ);
@@ -1861,11 +1861,11 @@ bool RoomCombatEffects::spawnCombatHitEffect(Entity &sourceEntity, const Common:
 	return true;
 }
 
-void RoomCombatEffects::spawnCombatDamagePopup(Entity &targetEntity, const Common::String &followTargetName, int damageAmount) {
+void CombatEffects::spawnCombatDamagePopup(Entity &targetEntity, const Common::String &followTargetName, int damageAmount) {
 	if (damageAmount <= 0)
 		return;
 
-	RoomCombatDamagePopupState popupState;
+	CombatDamagePopupState popupState;
 	popupState.followTargetName = followTargetName;
 	popupState.startTick = Player::getRuntimeClockTicks();
 	popupState.damageAmount = damageAmount;
@@ -1873,12 +1873,12 @@ void RoomCombatEffects::spawnCombatDamagePopup(Entity &targetEntity, const Commo
 	_damagePopupStates.push_back(Common::move(popupState));
 }
 
-bool RoomCombatEffects::syncCombatHitEffects() {
+bool CombatEffects::syncCombatHitEffects() {
 	if (!_entityManager)
 		return false;
 
 	bool changed = false;
-	for (RoomHitEffectState &effectState : _hitEffectStates) {
+	for (HitEffectState &effectState : _hitEffectStates) {
 		Entity *effectEntity = _entityManager->findSceneEntityByName(effectState.entityName);
 		if (!effectEntity)
 			continue;
@@ -1904,7 +1904,7 @@ bool RoomCombatEffects::syncCombatHitEffects() {
 	return changed;
 }
 
-void RoomCombatEffects::drawCombatDamagePopups(Graphics::Screen &screen, bool combatDebugEnabled) {
+void CombatEffects::drawCombatDamagePopups(Graphics::Screen &screen, bool combatDebugEnabled) {
 	if (!combatDebugEnabled || _damagePopupStates.empty())
 		return;
 
@@ -1917,7 +1917,7 @@ void RoomCombatEffects::drawCombatDamagePopups(Graphics::Screen &screen, bool co
 	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
 	const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
 	const uint32 now = Player::getRuntimeClockTicks();
-	for (RoomCombatDamagePopupState &popupState : _damagePopupStates) {
+	for (CombatDamagePopupState &popupState : _damagePopupStates) {
 		if (_entityManager && !popupState.followTargetName.empty()) {
 			Entity *targetEntity =
 				_entityManager->findSceneEntityByName(popupState.followTargetName);
@@ -1941,7 +1941,7 @@ void RoomCombatEffects::drawCombatDamagePopups(Graphics::Screen &screen, bool co
 	}
 }
 
-bool RoomCombatEffects::pruneCombatHitEffects() {
+bool CombatEffects::pruneCombatHitEffects() {
 	bool removedAny = false;
 	for (uint i = 0; i < _hitEffectStates.size();) {
 		const Common::String effectName = _hitEffectStates[i].entityName;
@@ -1971,7 +1971,7 @@ bool RoomCombatEffects::pruneCombatHitEffects() {
 	return removedAny;
 }
 
-bool RoomCombatEffects::pruneCombatDamagePopups() {
+bool CombatEffects::pruneCombatDamagePopups() {
 	const uint32 now = Player::getRuntimeClockTicks();
 	bool removedAny = false;
 	for (uint i = 0; i < _damagePopupStates.size();) {
diff --git a/engines/harvester/room_combat.h b/engines/harvester/combat.h
similarity index 79%
rename from engines/harvester/room_combat.h
rename to engines/harvester/combat.h
index 86bf6a61d29..84a2e9bf6f3 100644
--- a/engines/harvester/room_combat.h
+++ b/engines/harvester/combat.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_ROOM_COMBAT_H
-#define HARVESTER_ROOM_COMBAT_H
+#ifndef HARVESTER_COMBAT_H
+#define HARVESTER_COMBAT_H
 
 #include "common/error.h"
 #include "harvester/room_support.h"
@@ -31,9 +31,9 @@ class CheatSystem;
 class Entity;
 class EntityManager;
 class HarvesterEngine;
-class RoomCombatEffects;
-class RoomInteractionCallbacks;
-class RoomInteractionProcessor;
+class CombatEffects;
+class InteractionCallbacks;
+class Interaction;
 class SceneEntities;
 
 extern const int kRoomMonsterAnimationRate;
@@ -59,9 +59,9 @@ extern const char *const kMuckeyMonsterName;
 extern const char *const kActivateInnerSanctumDoorActionTag;
 extern const int kNativeDefaultNpcDeathDamageType;
 
-struct RoomAttackAnimationRange {
-	RoomAttackAnimationRange() {}
-	RoomAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
+struct AttackAnimationRange {
+	AttackAnimationRange() {}
+	AttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
 		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
 
 	int firstFrame = 0;
@@ -69,18 +69,18 @@ struct RoomAttackAnimationRange {
 	int resumeFacing = 0;
 };
 
-struct RoomDeathAnimationRange {
-	RoomDeathAnimationRange() {}
-	RoomDeathAnimationRange(int firstFrame, int lastFrame)
+struct DeathAnimationRange {
+	DeathAnimationRange() {}
+	DeathAnimationRange(int firstFrame, int lastFrame)
 		: firstFrame(firstFrame), lastFrame(lastFrame) {}
 
 	int firstFrame = -1;
 	int lastFrame = -1;
 };
 
-struct RoomHitAnimationRange {
-	RoomHitAnimationRange() {}
-	RoomHitAnimationRange(int firstFrame, int lastFrame, int resumeFacing, int knockbackX)
+struct HitAnimationRange {
+	HitAnimationRange() {}
+	HitAnimationRange(int firstFrame, int lastFrame, int resumeFacing, int knockbackX)
 		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing),
 		  knockbackX(knockbackX) {}
 
@@ -90,7 +90,7 @@ struct RoomHitAnimationRange {
 	int knockbackX = 0;
 };
 
-struct RoomMonsterCombatState {
+struct MonsterCombatState {
 	bool attackActive = false;
 	int attackFirstFrame = -1;
 	int attackLastFrame = -1;
@@ -115,25 +115,25 @@ struct RoomMonsterCombatState {
 	uint32 attackCooldownSeedTick = 0;
 };
 
-struct RoomNpcCombatState {
+struct NpcCombatState {
 	bool deathActive = false;
 	int deathFirstFrame = -1;
 	int deathLastFrame = -1;
 	int deathDamageType = 0;
 };
 
-struct RoomNpcCombatUpdateResult {
+struct NpcCombatUpdateResult {
 	Common::Error error = Common::kNoError;
 	bool finalizedDeath = false;
 };
 
-class RoomCombat {
+class Combat {
 public:
 	void initializeCombatStates(const RoomSetupState &state);
 	// Keep prepared state separate until scene-entity mutations succeed.
-	Common::Array<RoomMonsterCombatState> prepareMonsterCombatStatesForSceneChange(
+	Common::Array<MonsterCombatState> prepareMonsterCombatStatesForSceneChange(
 		const Common::Array<MonsterRecord> &previousMonsters, const Common::Array<MonsterRecord> &updatedMonsters) const;
-	void commitMonsterCombatStatesForSceneChange(Common::Array<RoomMonsterCombatState> &&states);
+	void commitMonsterCombatStatesForSceneChange(Common::Array<MonsterCombatState> &&states);
 	void resetForSceneRefresh(const RoomSetupState &state);
 
 	void capturePlayerAttackTargetForKeyboard(RoomSetupState &state, RoomPlayerState &playerState,
@@ -151,34 +151,34 @@ public:
 
 	Common::Error killDebugActiveMonster(HarvesterEngine &engine, RoomSetupState &state,
 		RoomPlayerState &playerState, EntityManager *entityManager, const Common::Point &mousePos,
-		RoomCombatEffects &combatEffects, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+		CombatEffects &combatEffects, Interaction &interactionProcessor, bool &needsRedraw);
 
 	Common::Error updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetupState &state,
 		RoomPlayerState &playerState, RoomIdleAnimationState &idleState, EntityManager *entityManager,
-		SceneEntities &sceneRuntime, RoomCombatEffects &combatEffects, const CheatSystem &cheats,
-		RoomInteractionCallbacks &callbacks, RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+		SceneEntities &sceneRuntime, CombatEffects &combatEffects, const CheatSystem &cheats,
+		InteractionCallbacks &callbacks, Interaction &interactionProcessor, bool &needsRedraw);
 	Common::Error resolvePlayerAttackContact(HarvesterEngine &engine,
 		RoomSetupState &state, RoomPlayerState &playerState,
 		EntityManager *entityManager, SceneEntities &sceneRuntime,
-		RoomCombatEffects &combatEffects,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+		CombatEffects &combatEffects,
+		Interaction &interactionProcessor, bool &needsRedraw);
 
 private:
 	void resizeCombatStates(const RoomSetupState &state);
 	bool queueInnerSanctumHerrillDeathAfterMuck(HarvesterEngine &engine, RoomSetupState &state, const MonsterRecord &monster) const;
 	// Called after resizeCombatStates; NPC finalization ends the entire combat update.
-	RoomNpcCombatUpdateResult updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
+	NpcCombatUpdateResult updateRoomNpcCombat(HarvesterEngine &engine, RoomSetupState &state,
 		EntityManager *entityManager, SceneEntities &sceneRuntime,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+		Interaction &interactionProcessor, bool &needsRedraw);
 
 	bool beginNpcDeathTransition(HarvesterEngine &engine,
-		NpcRecord &npc, Entity &npcEntity, RoomNpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const;
+		NpcRecord &npc, Entity &npcEntity, NpcCombatState &combatState, int deathDamageType, bool &needsRedraw) const;
 	Common::Error finalizeNpcDeathTransition(HarvesterEngine &engine,
-		EntityManager *entityManager, SceneEntities &sceneRuntime, NpcRecord &npc, RoomNpcCombatState &combatState,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) const;
+		EntityManager *entityManager, SceneEntities &sceneRuntime, NpcRecord &npc, NpcCombatState &combatState,
+		Interaction &interactionProcessor, bool &needsRedraw) const;
 
 	void startMonsterHitReaction(HarvesterEngine &engine,
-		RoomCombatEffects &combatEffects, MonsterRecord &monster, Entity &monsterEntity, RoomMonsterCombatState *combatState,
+		CombatEffects &combatEffects, MonsterRecord &monster, Entity &monsterEntity, MonsterCombatState *combatState,
 		int attackerAttackFirstFrame, bool &needsRedraw) const;
 
 	MonsterRecord *findMonsterTargetAtPoint(RoomSetupState &state, EntityManager *entityManager, const Common::Point &point) const;
@@ -188,11 +188,11 @@ private:
 	MonsterRecord *findProjectileContactMonsterTarget(RoomSetupState &state, const RoomPlayerState &playerState,
 		EntityManager *entityManager) const;
 
-	Common::Array<RoomMonsterCombatState> _monsterCombatStates;
-	Common::Array<RoomNpcCombatState> _npcCombatStates;
+	Common::Array<MonsterCombatState> _monsterCombatStates;
+	Common::Array<NpcCombatState> _npcCombatStates;
 };
 
-struct RoomHitEffectState {
+struct HitEffectState {
 	Common::String entityName;
 	Common::String followTargetName;
 	Common::Point anchorPoint;
@@ -200,16 +200,16 @@ struct RoomHitEffectState {
 	bool finished = false;
 };
 
-struct RoomCombatDamagePopupState {
+struct CombatDamagePopupState {
 	Common::String followTargetName;
 	Common::Point anchorPoint;
 	uint32 startTick = 0;
 	int damageAmount = 0;
 };
 
-class RoomCombatEffects {
+class CombatEffects {
 public:
-	explicit RoomCombatEffects(EntityManager *entityManager);
+	explicit CombatEffects(EntityManager *entityManager);
 
 	void clearHitEffects();
 	bool hasDamagePopups() const { return !_damagePopupStates.empty(); }
@@ -225,8 +225,8 @@ private:
 	static void resolveCombatDamagePopupAnchor(const Entity &targetEntity, Common::Point &anchorPoint);
 
 	EntityManager *_entityManager;
-	Common::Array<RoomHitEffectState> _hitEffectStates;
-	Common::Array<RoomCombatDamagePopupState> _damagePopupStates;
+	Common::Array<HitEffectState> _hitEffectStates;
+	Common::Array<CombatDamagePopupState> _damagePopupStates;
 	uint _nextCombatEffectId = 0;
 };
 
@@ -261,11 +261,11 @@ bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFr
 bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, int lastFrame);
 bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 	const Entity &entity, int actorCenterX, int targetCenterX,
-	RoomAttackAnimationRange &range);
+	AttackAnimationRange &range);
 bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
-	int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range);
+	int deathDamageType, bool goreEnabled, DeathAnimationRange &range);
 bool resolveNpcDeathAnimationRange(const Entity &entity, bool hasMonsterfyTarget,
-	int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range);
+	int deathDamageType, bool goreEnabled, DeathAnimationRange &range);
 int stepTowardsRoomCombatInt(int current, int target, int step);
 int computeRuntimeEntityHorizontalGap(const Entity &left, const Entity &right);
 bool doRuntimeEntityDepthExtentsOverlap(const Entity &first, const Entity &second);
@@ -277,16 +277,16 @@ bool isWithinNativeMonsterAttackEntryRange(const Entity &monster, float monsterZ
 bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
 	const Common::String &sound2, const Common::String &sound3);
 bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
-	int attackerAttackFirstFrame, RoomHitAnimationRange &range);
+	int attackerAttackFirstFrame, HitAnimationRange &range);
 float clampRoomDepthForEvent(const RoomSetupState &state, float z);
 int roundRoomCombatFloat(float value);
 int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY);
 int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster);
-void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState);
-void clearRoomNpcCombatState(RoomNpcCombatState &combatState);
-void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState);
-void clearRoomMonsterHitState(RoomMonsterCombatState &combatState);
+void clearRoomMonsterCombatState(MonsterCombatState &combatState);
+void clearRoomNpcCombatState(NpcCombatState &combatState);
+void clearRoomMonsterAttackState(MonsterCombatState &combatState);
+void clearRoomMonsterHitState(MonsterCombatState &combatState);
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_ROOM_COMBAT_H
+#endif // HARVESTER_COMBAT_H
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 830861b16c4..259240bc17f 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -86,7 +86,7 @@
 #include "harvester/npc/whaley_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
 #include "harvester/resources.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 #include "harvester/art.h"
 #include "harvester/flow.h"
 #include "harvester/text.h"
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/entity.cpp
similarity index 91%
rename from engines/harvester/runtime_entity.cpp
rename to engines/harvester/entity.cpp
index 509c8031693..52ef2385650 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/entity.cpp
@@ -21,7 +21,7 @@
 
 #include <math.h>
 
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
@@ -120,9 +120,9 @@ uint32 getRuntimeClockTicks() {
 }
 
 static bool isRectangleOnlyHitClass(int classId) {
-	return classId == kRuntimeEntityClassRectHotspot ||
-		classId == kRuntimeEntityClassRectHotspot18 ||
-		classId == kRuntimeEntityClassRectHotspot19;
+	return classId == kEntityClassRectHotspot ||
+		classId == kEntityClassRectHotspot18 ||
+		classId == kEntityClassRectHotspot19;
 }
 
 bool Entity::loadBitmapResource(ResourceManager &resources, const Common::String &path) {
@@ -151,7 +151,7 @@ bool Entity::loadBitmapResource(ResourceManager &resources, const Common::String
 	_drawEnabled = true;
 	_depthScale = 1.0f;
 	updateBoundsFromCurrentFrame();
-	_hitTestMode = kRuntimeEntityHitTestOpaquePixels;
+	_hitTestMode = kEntityHitTestOpaquePixels;
 	return true;
 }
 
@@ -178,7 +178,7 @@ bool Entity::loadAbmResource(ResourceManager &resources, const Common::String &p
 	_drawEnabled = true;
 	_depthScale = 1.0f;
 	updateBoundsFromCurrentFrame();
-	_hitTestMode = kRuntimeEntityHitTestOpaquePixels;
+	_hitTestMode = kEntityHitTestOpaquePixels;
 	return true;
 }
 
@@ -194,7 +194,7 @@ void Entity::setPosition(int x, int y, float z) {
 	if (anchorChanged)
 		updateScreenBaseFromCurrentFrame();
 
-	if ((anchorChanged || previousZ != z) && _classId == kRuntimeEntityClassNpc) {
+	if ((anchorChanged || previousZ != z) && _classId == kEntityClassNpc) {
 		int width = 0;
 		int height = 0;
 		int xOffset = 0;
@@ -210,7 +210,7 @@ void Entity::setPosition(int x, int y, float z) {
 	}
 }
 
-void Entity::setAnchorMode(RuntimeEntityAnchorMode anchorMode) {
+void Entity::setAnchorMode(EntityAnchorMode anchorMode) {
 	_anchorMode = anchorMode;
 	updateScreenBaseFromCurrentFrame();
 }
@@ -225,7 +225,7 @@ void Entity::setAnimationRate(int rate) {
 	if (rate != _animationRate) {
 		_nextAnimationTick = 0;
 		_animationRate = rate;
-		if (_classId == kRuntimeEntityClassNpc) {
+		if (_classId == kEntityClassNpc) {
 			debugC(2, kDebugPlayer,
 				"Harvester: npc animation rate npc='%s' rate=%d->%d interval_ticks=%u next_tick=%u",
 				_name.c_str(), previousRate, _animationRate,
@@ -237,7 +237,7 @@ void Entity::setAnimationRate(int rate) {
 void Entity::setAnimationEnabled(bool enabled) {
 	const bool wasEnabled = _animationEnabled;
 	_animationEnabled = enabled && !_frames.empty() && _currentFrame >= 0;
-	if (wasEnabled != _animationEnabled && _classId == kRuntimeEntityClassNpc) {
+	if (wasEnabled != _animationEnabled && _classId == kEntityClassNpc) {
 		debugC(2, kDebugPlayer,
 			"Harvester: npc animation enabled npc='%s' enabled=%d->%d frame=%d range=%d..%d",
 			_name.c_str(), wasEnabled, _animationEnabled,
@@ -276,7 +276,7 @@ void Entity::setAnimationFrameRange(int firstFrame, int lastFrame, bool looping)
 	else
 		updateBoundsFromCurrentFrame();
 
-	if (_classId == kRuntimeEntityClassNpc) {
+	if (_classId == kEntityClassNpc) {
 		debugC(2, kDebugPlayer,
 			"Harvester: npc animation range npc='%s' frames=%d..%d->%d..%d current=%d looping=%d->%d enabled=%d",
 			_name.c_str(), previousFirstFrame, previousLastFrame,
@@ -297,7 +297,7 @@ void Entity::setAnimationSequence(int sequence) {
 	_lastFrame = MIN<int>(_firstFrame + kFramesPerSequence - 1, (int)_frames.size() - 1);
 	advanceAnimationFrame(_firstFrame);
 
-	if (_classId == kRuntimeEntityClassCursor) {
+	if (_classId == kEntityClassCursor) {
 		debugC(1, kDebugCursor,
 			"Harvester: cursor animation sequence=%d frames=%d..%d current=%d",
 			_animationSequence, _firstFrame, _lastFrame, _currentFrame);
@@ -315,7 +315,7 @@ void Entity::configureHotspotBounds(int width, int height) {
 	_drawEnabled = false;
 	_boundsWidth = MAX(width, 1);
 	_boundsHeight = MAX(height, 1);
-	_hitTestMode = kRuntimeEntityHitTestBounds;
+	_hitTestMode = kEntityHitTestBounds;
 	_depthScale = 1.0f;
 	updateScreenBaseFromCurrentFrame();
 }
@@ -389,14 +389,14 @@ bool Entity::tickVisualState(uint32 now) {
 	_nextAnimationTick = now + _animationTickInterval;
 	_animationAdvancedLastTick = true;
 
-	if (_classId == kRuntimeEntityClassPlayer && previousFrameIndex != _currentFrame) {
+	if (_classId == kEntityClassPlayer && previousFrameIndex != _currentFrame) {
 		debugC(4, kDebugPlayer,
 			"Harvester: player animation advance frame=%d->%d range=%d..%d rate=%d tick=%u interval=%u next_tick=%u",
 			previousFrameIndex, _currentFrame, _firstFrame, _lastFrame,
 			_animationRate, now, _animationTickInterval, _nextAnimationTick);
 	}
 
-	if (_classId == kRuntimeEntityClassNpc) {
+	if (_classId == kEntityClassNpc) {
 		const AbmFrame &currentFrame = _frames[(uint)_currentFrame];
 		const Common::Point drawOrigin = getDrawOrigin();
 		const bool loopReset = _looping && !_pingPong &&
@@ -443,12 +443,12 @@ void Entity::configureTimerCountdown(int initialValue, int currentValue,
 	_visible = false;
 	_drawEnabled = false;
 	_animationEnabled = false;
-	_hitTestMode = kRuntimeEntityHitTestNone;
+	_hitTestMode = kEntityHitTestNone;
 	setTimerEnabled(enabled);
 }
 
 void Entity::setTimerEnabled(bool enabled) {
-	if (_classId != kRuntimeEntityClassTimer)
+	if (_classId != kEntityClassTimer)
 		return;
 
 	if (!enabled) {
@@ -471,7 +471,7 @@ void Entity::setTimerEnabled(bool enabled) {
 }
 
 bool Entity::tickTimerState(uint32 now, Common::Array<Common::String> &expiredTimerNames) {
-	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || _timerPaused)
+	if (_classId != kEntityClassTimer || !_timerEnabled || _timerPaused)
 		return false;
 
 	if (_timerNextFireTick != 0 && (int32)(now - _timerNextFireTick) < 0) {
@@ -496,7 +496,7 @@ bool Entity::tickTimerState(uint32 now, Common::Array<Common::String> &expiredTi
 }
 
 void Entity::pauseTimerCountdown(uint32 now) {
-	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || _timerPaused)
+	if (_classId != kEntityClassTimer || !_timerEnabled || _timerPaused)
 		return;
 
 	if (_timerNextFireTick != 0 && (int32)(now - _timerNextFireTick) < 0) {
@@ -511,7 +511,7 @@ void Entity::pauseTimerCountdown(uint32 now) {
 }
 
 void Entity::resumeTimerCountdown(uint32 now) {
-	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || !_timerPaused)
+	if (_classId != kEntityClassTimer || !_timerEnabled || !_timerPaused)
 		return;
 
 	if (_timerNextFireTick != 0)
@@ -525,7 +525,7 @@ void Entity::draw(Graphics::Screen &screen) const {
 		return;
 
 	const Common::Point drawOrigin = getDrawOrigin();
-	if (_classId == kRuntimeEntityClassPlayer) {
+	if (_classId == kEntityClassPlayer) {
 		debugC(4, kDebugPlayer,
 			"Harvester: player animation draw frame=%d pos=(%d,%d,z=%.2f) origin=(%d,%d) tick=%u",
 			_currentFrame, _x, _y, _z, drawOrigin.x, drawOrigin.y, getRuntimeClockTicks());
@@ -568,17 +568,17 @@ bool Entity::isOpaqueAt(const Common::Point &point) const {
 bool Entity::hitTest(const Common::Point &point) const {
 	if (!_visible)
 		return false;
-	if (_classId == kRuntimeEntityClassDisabledHotspot)
+	if (_classId == kEntityClassDisabledHotspot)
 		return false;
-	if (_classId == kRuntimeEntityClassBackground)
+	if (_classId == kEntityClassBackground)
 		return true;
-	if (_hitTestMode == kRuntimeEntityHitTestNone)
+	if (_hitTestMode == kEntityHitTestNone)
 		return false;
 
 	const Common::Rect bounds = getFrameRect();
 	if (!bounds.contains(point))
 		return false;
-	if (_hitTestMode != kRuntimeEntityHitTestOpaquePixels || isRectangleOnlyHitClass(_classId))
+	if (_hitTestMode != kEntityHitTestOpaquePixels || isRectangleOnlyHitClass(_classId))
 		return true;
 
 	return isOpaqueAt(point);
@@ -587,10 +587,10 @@ bool Entity::hitTest(const Common::Point &point) const {
 bool Entity::overlapsEntity(const Entity &other) const {
 	if (this == &other || !_visible || !other._visible)
 		return false;
-	if (_classId == kRuntimeEntityClassCursor || _classId == kRuntimeEntityClassBackground ||
-		_classId == kRuntimeEntityClassRectHotspot || _classId == kRuntimeEntityClassRectHotspot19 ||
-		other._classId == kRuntimeEntityClassCursor || other._classId == kRuntimeEntityClassBackground ||
-		other._classId == kRuntimeEntityClassRectHotspot || other._classId == kRuntimeEntityClassRectHotspot19) {
+	if (_classId == kEntityClassCursor || _classId == kEntityClassBackground ||
+		_classId == kEntityClassRectHotspot || _classId == kEntityClassRectHotspot19 ||
+		other._classId == kEntityClassCursor || other._classId == kEntityClassBackground ||
+		other._classId == kEntityClassRectHotspot || other._classId == kEntityClassRectHotspot19) {
 		return false;
 	}
 
@@ -607,8 +607,8 @@ bool Entity::overlapsEntity(const Entity &other) const {
 	const int bottom = MIN(thisRect.bottom, otherRect.bottom);
 	if (left >= right || top >= bottom)
 		return false;
-	if (_classId == kRuntimeEntityClassDisabledHotspot ||
-			other._classId == kRuntimeEntityClassDisabledHotspot) {
+	if (_classId == kEntityClassDisabledHotspot ||
+			other._classId == kEntityClassDisabledHotspot) {
 		return true;
 	}
 	if (!hasOpaqueFrame() || !other.hasOpaqueFrame())
@@ -668,7 +668,7 @@ void Entity::advanceAnimationFrame(int directive) {
 
 		if (!_looping) {
 			_currentFrame = _firstFrame;
-			if (_classId == kRuntimeEntityClassAnimation)
+			if (_classId == kEntityClassAnimation)
 				_animationEnabled = false;
 			goto done;
 		}
@@ -690,7 +690,7 @@ void Entity::advanceAnimationFrame(int directive) {
 
 		if (!_looping) {
 			_currentFrame = _lastFrame;
-			if (_classId == kRuntimeEntityClassAnimation)
+			if (_classId == kEntityClassAnimation)
 				_animationEnabled = false;
 			goto done;
 		}
@@ -728,7 +728,7 @@ void Entity::updateScreenBaseFromCurrentFrame() {
 	int baseX = _x;
 	int baseY = _y;
 
-	if (_anchorMode == kRuntimeEntityAnchorCentered) {
+	if (_anchorMode == kEntityAnchorCentered) {
 		baseX -= _boundsWidth / 2;
 		baseY -= _boundsHeight / 2;
 	}
@@ -781,7 +781,7 @@ void EntityManager::clearSceneEntities(bool preserveGlobalTimers) {
 	Common::Array<Entity *> preservedEntities;
 	for (Entity *entity : _sceneEntities) {
 		if (preserveGlobalTimers &&
-				entity->getClassId() == kRuntimeEntityClassTimer &&
+				entity->getClassId() == kEntityClassTimer &&
 				entity->isTimerGlobal()) {
 			preservedEntities.push_back(entity);
 			continue;
@@ -831,11 +831,11 @@ Entity *EntityManager::spawnCursorEntity(const Common::Point &position) {
 		return _cursorEntity;
 
 	_cursorEntity = spawnAbmEntityFromResource(kCursorEntityName, kCursorResourcePath,
-		kRuntimeEntityClassCursor, position, kCursorEntityZ, kCursorAnimationRate, true, false);
+		kEntityClassCursor, position, kCursorEntityZ, kCursorAnimationRate, true, false);
 	if (_cursorEntity)
 		_cursorEntity->setAnimationSequence(0);
 	if (_cursorEntity)
-		_cursorEntity->setHitTestMode(kRuntimeEntityHitTestNone);
+		_cursorEntity->setHitTestMode(kEntityHitTestNone);
 	if (_cursorEntity) {
 		const uint32 animationInterval = _cursorEntity->getAnimationRate() == 0 ? 0 :
 			(100U / (uint32)_cursorEntity->getAnimationRate());
@@ -851,7 +851,7 @@ Entity *EntityManager::spawnCursorEntity(const Common::Point &position) {
 
 Entity *EntityManager::spawnSceneBitmapEntity(const Common::String &name,
 		const Common::String &resourcePath, const Common::Point &position, float z) {
-	Entity *entity = spawnBitmapEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
+	Entity *entity = spawnBitmapEntityFromResource(name, resourcePath, kEntityClassObject,
 		position, z);
 	if (entity)
 		insertSceneEntity(entity);
@@ -862,7 +862,7 @@ Entity *EntityManager::spawnSceneHotspotEntity(const Common::String &name,
 		const Common::Rect &bounds, float z) {
 	Entity *entity = new Entity();
 	entity->setName(name);
-	entity->setClassId(kRuntimeEntityClassObject);
+	entity->setClassId(kEntityClassObject);
 	entity->setPosition(bounds.left, bounds.top, z);
 	entity->configureHotspotBounds(bounds.width(), bounds.height());
 	insertSceneEntity(entity);
@@ -872,7 +872,7 @@ Entity *EntityManager::spawnSceneHotspotEntity(const Common::String &name,
 Entity *EntityManager::spawnSceneAnimationEntity(const Common::String &name,
 		const Common::String &resourcePath, const Common::Point &position, float z, int animationRate,
 		bool active, bool visible, bool looping, bool playBackwards, bool pingPong, int initialFrame) {
-	Entity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassAnimation,
+	Entity *entity = spawnAbmEntityFromResource(name, resourcePath, kEntityClassAnimation,
 		position, z, animationRate, looping, pingPong);
 	if (!entity)
 		return nullptr;
@@ -881,12 +881,12 @@ Entity *EntityManager::spawnSceneAnimationEntity(const Common::String &name,
 	// x/y pivot, lower the render-list depth anchor by half z_extent, and then
 	// keep that cached screen base while later frame ABM x/y offsets shift the
 	// final draw rect.
-	entity->setAnchorMode(kRuntimeEntityAnchorCentered);
+	entity->setAnchorMode(kEntityAnchorCentered);
 	entity->setZExtent(3.0f);
 	entity->setPosition(position.x, position.y, z - floorf(MAX<float>(entity->getZExtent(), 0.0f) * 0.5f));
 	entity->setVisible(visible);
 	entity->setPlayBackwards(playBackwards);
-	entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	entity->setHitTestMode(kEntityHitTestNone);
 	int startFrame = playBackwards ? entity->getLastFrame() : 0;
 	if (!playBackwards && initialFrame >= 0)
 		startFrame = CLIP<int>(initialFrame, 0, entity->getLastFrame());
@@ -903,14 +903,14 @@ Entity *EntityManager::spawnSceneAnimationEntity(const Common::String &name,
 
 Entity *EntityManager::spawnSceneActorEntity(const Common::String &name,
 		const Common::String &resourcePath, const Common::Point &position, float z, int initialFrame) {
-	Entity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
+	Entity *entity = spawnAbmEntityFromResource(name, resourcePath, kEntityClassObject,
 		position, z, 0, false, false);
 	if (!entity)
 		return nullptr;
 
-	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity->setAnchorMode(kEntityAnchorTopLeft);
 	entity->setZExtent(3.0f);
-	entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
+	entity->setHitTestMode(kEntityHitTestOpaquePixels);
 	entity->setCurrentFrame(initialFrame);
 	debugC(1, kDebugScene,
 		"Harvester: spawned scene actor '%s' resource='%s' frames=0..%d initial_frame=%d animation_rate=%d sequence=%d pos=(%d,%d,z=%.2f)",
@@ -925,7 +925,7 @@ Entity *EntityManager::spawnSceneTimerEntity(const Common::String &name,
 		int initialValue, int currentValue, bool enabled, bool looping, bool global) {
 	Entity *entity = new Entity();
 	entity->setName(name);
-	entity->setClassId(kRuntimeEntityClassTimer);
+	entity->setClassId(kEntityClassTimer);
 	configureSceneTimerEntity(*entity, initialValue, currentValue, enabled, looping, global);
 	insertSceneEntity(entity);
 	return entity;
@@ -982,7 +982,7 @@ bool EntityManager::tickSceneEntities() {
 	bool changed = false;
 
 	for (Entity *entity : _sceneEntities) {
-		if (entity->getClassId() == kRuntimeEntityClassTimer) {
+		if (entity->getClassId() == kEntityClassTimer) {
 			if (_timerPauseDepth == 0)
 				(void)entity->tickTimerState(now, _expiredTimerNames);
 			continue;
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/entity.h
similarity index 88%
rename from engines/harvester/runtime_entity.h
rename to engines/harvester/entity.h
index 5a7f0c2186c..8fbff46d5de 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/entity.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_RUNTIME_ENTITY_H
-#define HARVESTER_RUNTIME_ENTITY_H
+#ifndef HARVESTER_ENTITY_H
+#define HARVESTER_ENTITY_H
 
 #include "common/array.h"
 #include "common/rect.h"
@@ -37,30 +37,30 @@ class ResourceManager;
 
 uint32 getRuntimeClockTicks();
 
-enum RuntimeEntityClass {
-	kRuntimeEntityClassObject = 0,
-	kRuntimeEntityClassAnimation = 1,
-	kRuntimeEntityClassCursor = 2,
-	kRuntimeEntityClassBackground = 3,
-	kRuntimeEntityClassNpc = 4,
-	kRuntimeEntityClassPlayer = 5,
-	kRuntimeEntityClassMonster = 6,
-	kRuntimeEntityClassRectHotspot = 0x15,
-	kRuntimeEntityClassDisabledHotspot = 0x16,
-	kRuntimeEntityClassTimer = 0x17,
-	kRuntimeEntityClassRectHotspot18 = 0x18,
-	kRuntimeEntityClassRectHotspot19 = 0x19
+enum EntityClass {
+	kEntityClassObject = 0,
+	kEntityClassAnimation = 1,
+	kEntityClassCursor = 2,
+	kEntityClassBackground = 3,
+	kEntityClassNpc = 4,
+	kEntityClassPlayer = 5,
+	kEntityClassMonster = 6,
+	kEntityClassRectHotspot = 0x15,
+	kEntityClassDisabledHotspot = 0x16,
+	kEntityClassTimer = 0x17,
+	kEntityClassRectHotspot18 = 0x18,
+	kEntityClassRectHotspot19 = 0x19
 };
 
-enum RuntimeEntityHitTestMode {
-	kRuntimeEntityHitTestNone = 0,
-	kRuntimeEntityHitTestBounds = 1,
-	kRuntimeEntityHitTestOpaquePixels = 2
+enum EntityHitTestMode {
+	kEntityHitTestNone = 0,
+	kEntityHitTestBounds = 1,
+	kEntityHitTestOpaquePixels = 2
 };
 
-enum RuntimeEntityAnchorMode {
-	kRuntimeEntityAnchorTopLeft = 0,
-	kRuntimeEntityAnchorCentered = 1
+enum EntityAnchorMode {
+	kEntityAnchorTopLeft = 0,
+	kEntityAnchorCentered = 1
 };
 
 class Entity {
@@ -73,7 +73,7 @@ public:
 
 	void setClassId(int classId) { _classId = classId; }
 	int getClassId() const { return _classId; }
-	void setAnchorMode(RuntimeEntityAnchorMode anchorMode);
+	void setAnchorMode(EntityAnchorMode anchorMode);
 	void setZExtent(float zExtent) { _zExtent = zExtent; }
 	float getZExtent() const { return _zExtent; }
 
@@ -101,7 +101,7 @@ public:
 	void setAnimationSequence(int sequence);
 	int getAnimationSequence() const { return _animationSequence; }
 	void configureHotspotBounds(int width, int height);
-	void setHitTestMode(RuntimeEntityHitTestMode mode) { _hitTestMode = mode; }
+	void setHitTestMode(EntityHitTestMode mode) { _hitTestMode = mode; }
 	int getBoundsWidth() const { return _boundsWidth; }
 	int getBoundsHeight() const { return _boundsHeight; }
 	bool getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const;
@@ -165,8 +165,8 @@ private:
 	bool _drawEnabled = true;
 	int _boundsWidth = 0;
 	int _boundsHeight = 0;
-	RuntimeEntityHitTestMode _hitTestMode = kRuntimeEntityHitTestNone;
-	RuntimeEntityAnchorMode _anchorMode = kRuntimeEntityAnchorTopLeft;
+	EntityHitTestMode _hitTestMode = kEntityHitTestNone;
+	EntityAnchorMode _anchorMode = kEntityAnchorTopLeft;
 	float _zExtent = 0.0f;
 	float _depthScale = 1.0f;
 	int _timerInitialValue = 0;
@@ -234,4 +234,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_RUNTIME_ENTITY_H
+#endif // HARVESTER_ENTITY_H
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index c3fbb62dbda..a90f0af6792 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -42,7 +42,7 @@
 #include "harvester/harvester.h"
 #include "harvester/palette_utils.h"
 #include "harvester/resources.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 #include "harvester/scene_entities.h"
 #include "harvester/art.h"
 #include "harvester/monster.h"
@@ -233,7 +233,7 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 		const Entity *entity = entityManager
 			? entityManager->findSceneEntityByName(object.objectName)
 			: nullptr;
-		if (entity && entity->getClassId() == kRuntimeEntityClassBackground)
+		if (entity && entity->getClassId() == kEntityClassBackground)
 			continue;
 
 		drawRoomDebugLabel(screen, *font, resolveRoomDebugObjectLabel(engine, object),
@@ -244,7 +244,7 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 		const Entity *entity = entityManager
 			? entityManager->findSceneEntityByName(npc.npcName)
 			: nullptr;
-		if (!entity || entity->getClassId() != kRuntimeEntityClassNpc || !entity->isVisible())
+		if (!entity || entity->getClassId() != kEntityClassNpc || !entity->isVisible())
 			continue;
 
 		const Common::Rect npcRect = entity->getScreenRect();
@@ -272,7 +272,7 @@ static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &sc
 
 	const Entity *playerEntity = entityManager->findSceneEntityByName(kPlayerActorEntityName);
 	if (script && playerEntity &&
-			playerEntity->getClassId() == kRuntimeEntityClassPlayer &&
+			playerEntity->getClassId() == kEntityClassPlayer &&
 			playerEntity->isVisible()) {
 		const Common::Rect playerRect = playerEntity->getScreenRect();
 		drawRoomDebugLabel(screen, *font,
@@ -283,7 +283,7 @@ static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &sc
 
 	for (const MonsterRecord &monster : scene.state.roomMonsters) {
 		const Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
-		if (!entity || entity->getClassId() != kRuntimeEntityClassMonster || !entity->isVisible())
+		if (!entity || entity->getClassId() != kEntityClassMonster || !entity->isVisible())
 			continue;
 
 		const Common::Rect monsterRect = entity->getScreenRect();
@@ -315,18 +315,18 @@ static bool canEntityAffectPlayerPathfinding(const Entity &entity) {
 		return false;
 
 	switch (entity.getClassId()) {
-	case kRuntimeEntityClassCursor:
-	case kRuntimeEntityClassBackground:
-	case kRuntimeEntityClassPlayer:
-	case kRuntimeEntityClassRectHotspot:
-	case kRuntimeEntityClassRectHotspot19:
-	case kRuntimeEntityClassTimer:
+	case kEntityClassCursor:
+	case kEntityClassBackground:
+	case kEntityClassPlayer:
+	case kEntityClassRectHotspot:
+	case kEntityClassRectHotspot19:
+	case kEntityClassTimer:
 		return false;
 	default:
 		break;
 	}
 
-	return entity.getClassId() == kRuntimeEntityClassDisabledHotspot || entity.hasFrames();
+	return entity.getClassId() == kEntityClassDisabledHotspot || entity.hasFrames();
 }
 
 static void drawPathfindingBlockerOutline(Graphics::Screen &screen, EntityManager &entityManager,
@@ -397,7 +397,7 @@ static void drawTimerDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scr
 	Common::Array<Common::String> labels;
 	for (const TimerRecord &timer : script->getTimers()) {
 		const Entity *entity = entityManager->findSceneEntityByName(timer.timerName);
-		if (!entity || entity->getClassId() != kRuntimeEntityClassTimer || !entity->isTimerEnabled())
+		if (!entity || entity->getClassId() != kEntityClassTimer || !entity->isTimerEnabled())
 			continue;
 
 		const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(timer.timerName);
@@ -529,7 +529,7 @@ static bool applyRoomActorPlacementInternal(const RoomSetupState &state, Entity
 		return false;
 
 	const float renderZ = computeRoomActorRenderZ(z, entity);
-	entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity.setAnchorMode(kEntityAnchorTopLeft);
 	setRoomActorScreenPosition(entity, centerX, bottomY, renderZ, width, height, xOffset, yOffset);
 
 	float depthScale = 1.0f;
@@ -881,9 +881,9 @@ static const ObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 		const Entity *entity = entityManager->findSceneEntityByName(object.objectName);
 		if (!entity || !entity->hitTest(point))
 			continue;
-		if (entity->getClassId() == kRuntimeEntityClassBackground ||
-			entity->getClassId() == kRuntimeEntityClassPlayer ||
-			entity->getClassId() == kRuntimeEntityClassRectHotspot19) {
+		if (entity->getClassId() == kEntityClassBackground ||
+			entity->getClassId() == kEntityClassPlayer ||
+			entity->getClassId() == kEntityClassRectHotspot19) {
 			continue;
 		}
 		const int drawIndex = entityManager->findSceneEntityDrawIndexByName(entity->getName());
@@ -917,7 +917,7 @@ static const RegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,
 		const Entity *entity = entityManager->findSceneEntityByName(region.regionName);
 		if (!entity || !entity->hitTest(point))
 			continue;
-		if (entity->getClassId() != kRuntimeEntityClassRectHotspot19)
+		if (entity->getClassId() != kEntityClassRectHotspot19)
 			continue;
 		const int drawIndex = entityManager->findSceneEntityDrawIndexByName(entity->getName());
 		if (drawIndex < 0)
@@ -939,7 +939,7 @@ static const Entity *findRoomPlayerAtPoint(HarvesterEngine &engine, const Common
 		return nullptr;
 
 	const Entity *entity = entityManager->findTopSceneEntityAt(point);
-	if (!entity || entity->getClassId() != kRuntimeEntityClassPlayer)
+	if (!entity || entity->getClassId() != kEntityClassPlayer)
 		return nullptr;
 
 	return entity;
@@ -953,7 +953,7 @@ static const NpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
 		return nullptr;
 
 	const Entity *entity = entityManager->findTopSceneEntityAt(point);
-	if (!entity || entity->getClassId() != kRuntimeEntityClassNpc)
+	if (!entity || entity->getClassId() != kEntityClassNpc)
 		return nullptr;
 
 	const NpcRecord *npc = findSceneNpcByName(sceneNpcs, entity->getName());
@@ -2148,7 +2148,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 	sceneRuntime.populateRoomRegions();
 	for (const TimerRecord &timer : state.roomTimers) {
 		Entity *existingTimer = entityManager->findSceneEntityByName(timer.timerName);
-		if (existingTimer && existingTimer->getClassId() == kRuntimeEntityClassTimer) {
+		if (existingTimer && existingTimer->getClassId() == kEntityClassTimer) {
 			debugC(1, kDebugRoom,
 				"Harvester: scene timer reused room='%s' timer='%s' current=%d initial=%d enabled=%d loop=%d global=%d",
 				state.roomName.c_str(), timer.timerName.c_str(), existingTimer->getTimerCurrentValue(),
@@ -2197,9 +2197,9 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		if (!player) {
 			debug(1, "Harvester: unable to spawn startup player actor from '%s'", playerResourcePath.c_str());
 		} else {
-			player->setClassId(kRuntimeEntityClassPlayer);
-			player->setAnchorMode(kRuntimeEntityAnchorTopLeft);
-			player->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
+			player->setClassId(kEntityClassPlayer);
+			player->setAnchorMode(kEntityAnchorTopLeft);
+			player->setHitTestMode(kEntityHitTestOpaquePixels);
 			player->setVisible(true);
 			player->setAnimationRate(0);
 			player->setAnimationFrameRange(playerFrame, playerFrame, false);
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 78a204857a0..f5d2157cfc8 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -41,7 +41,7 @@ namespace Harvester {
 
 class HarvesterEngine;
 class DialogueFlowAccess;
-class RoomInteractionProcessor;
+class Interaction;
 struct IndexedBitmap;
 
 class Flow {
@@ -61,9 +61,9 @@ private:
 	friend class DialogueSystem;
 	friend class InventorySystem;
 	friend class MenuSystem;
-	friend class RoomInteractionProcessor;
+	friend class Interaction;
 	friend class SceneRunner;
-	friend class RoomInput;
+	friend class Input;
 	friend class Scene;
 
 	bool loadQuickTips();
diff --git a/engines/harvester/room_input.cpp b/engines/harvester/input.cpp
similarity index 86%
rename from engines/harvester/room_input.cpp
rename to engines/harvester/input.cpp
index 976719fb2b2..f55c8c14b3b 100644
--- a/engines/harvester/room_input.cpp
+++ b/engines/harvester/input.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/room_input.h"
+#include "harvester/input.h"
 
 #include "common/debug.h"
 #include "graphics/screen.h"
@@ -30,7 +30,7 @@
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
 #include "harvester/player.h"
-#include "harvester/room_interaction.h"
+#include "harvester/interaction.h"
 #include "harvester/room_presentation.h"
 #include "harvester/scene.h"
 #include "harvester/room_support.h"
@@ -75,9 +75,9 @@ static void debugLogInventoryClick(const char *buttonLabel, const Common::Point
 		InventorySystem::isStatusObject(inventoryHover->object));
 }
 
-RoomInput::RoomInput(HarvesterEngine &engine, Flow &flow, Scene &session, SceneState &scene,
+Input::Input(HarvesterEngine &engine, Flow &flow, Scene &session, SceneState &scene,
 		InventorySystem &inventory, CheatSystem &cheats,
-		RoomItemInteractions &roomItems, RoomInteractionProcessor &interactionProcessor,
+		ItemInteractions &roomItems, Interaction &interactionProcessor,
 		RoomPresentationState &presentationState,
 		const Art &art, bool canExitCloseupToParent)
 	: _engine(engine), _flow(flow), _session(session), _scene(scene),
@@ -86,7 +86,7 @@ RoomInput::RoomInput(HarvesterEngine &engine, Flow &flow, Scene &session, SceneS
 	  _canExitCloseupToParent(canExitCloseupToParent) {
 }
 
-void RoomInput::beginInspectText(const ObjectRecord &object, const ResolvedText &text) {
+void Input::beginInspectText(const ObjectRecord &object, const ResolvedText &text) {
 	_presentationState.inspectText = text;
 	_presentationState.showingInspectText = true;
 	_presentationState.inspectCanDismiss = false;
@@ -96,7 +96,7 @@ void RoomInput::beginInspectText(const ObjectRecord &object, const ResolvedText
 		_presentationState.inspectText.boxName.c_str(), _presentationState.inspectText.value.c_str());
 }
 
-void RoomInput::queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw) {
+void Input::queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw) {
 	if (message.empty()) {
 		const bool hadMessage = !_presentationState.combatLoadoutStatusText.value.empty();
 		_presentationState.combatLoadoutStatusText = ResolvedText();
@@ -113,9 +113,9 @@ void RoomInput::queueCombatLoadoutStatusMessage(const Common::String &message, b
 	needsRedraw = true;
 }
 
-bool RoomInput::openInventoryOverlay() {
+bool Input::openInventoryOverlay() {
 	RoomPlayerState &playerState = _session.getPlayerState();
-	RoomInputState &inputState = _session.getInputState();
+	InputState &inputState = _session.getInputState();
 
 	inputState.moveLeft = false;
 	inputState.moveRight = false;
@@ -131,7 +131,7 @@ bool RoomInput::openInventoryOverlay() {
 	return _inventory.open();
 }
 
-void RoomInput::capturePlayerAttackTarget() {
+void Input::capturePlayerAttackTarget() {
 	RoomPlayerState &playerState = _session.getPlayerState();
 
 	playerState.attackTargetName.clear();
@@ -143,7 +143,7 @@ void RoomInput::capturePlayerAttackTarget() {
 	_session.capturePlayerAttackTarget(_flow._mousePos, hoverState.npc);
 }
 
-void RoomInput::handleMouseMove(bool &needsRedraw) {
+void Input::handleMouseMove(bool &needsRedraw) {
 	if (_inventory.isOpen() && _inventory.hasSelection()) {
 		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow._mousePos);
 		// Native inventory handoff is driven by the latched selected item, not by
@@ -156,7 +156,7 @@ void RoomInput::handleMouseMove(bool &needsRedraw) {
 	needsRedraw = true;
 }
 
-RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
+InputResult Input::handleRightButtonDown(bool &needsRedraw) {
 	RoomPlayerState &playerState = _session.getPlayerState();
 	RoomIdleAnimationState &idleState = _session.getIdleState();
 
@@ -164,10 +164,10 @@ RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
 	if (idleState.active || idleState.exiting) {
 		if (Player::requestIdleAnimationExit(_scene.state, playerState, idleState))
 			needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (_session.isPlayerCombatLocked())
-		return RoomInputResult();
+		return InputResult();
 	_session.clearPendingRegionInteraction();
 	if (_inventory.isOpen()) {
 		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow._mousePos);
@@ -178,7 +178,7 @@ RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
 				_inventory.getSelectedItemName().c_str());
 			if (_inventory.clearSelection())
 				needsRedraw = true;
-			return RoomInputResult();
+			return InputResult();
 		}
 		if (inventoryHover &&
 				!InventorySystem::isExitObject(inventoryHover->object) &&
@@ -210,10 +210,10 @@ RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
 							_engine.getScript()->getPlayerCombatLoadout();
 					}
 					if (!_inventory.refresh())
-						return RoomInputResult::leaveRoom(Common::kReadingFailed);
+						return InputResult::leaveRoom(Common::kReadingFailed);
 				}
 				needsRedraw = true;
-				return RoomInputResult();
+				return InputResult();
 			}
 
 			InventorySecondaryAction secondaryAction;
@@ -243,21 +243,21 @@ RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
 						secondaryAction.actionTag.c_str(), interactionError.getCode(),
 						didTransition, _flow.hasPendingMainMenuReturn());
 					if (interactionError.getCode() != Common::kNoError)
-						return RoomInputResult::leaveRoom(interactionError);
+						return InputResult::leaveRoom(interactionError);
 					if (_flow.hasPendingMainMenuReturn())
-						return RoomInputResult::leaveRoom(Common::kNoError);
+						return InputResult::leaveRoom(Common::kNoError);
 				}
 
 				if (_inventory.isOpen()) {
 					if (!_inventory.refresh())
-						return RoomInputResult::leaveRoom(Common::kReadingFailed);
+						return InputResult::leaveRoom(Common::kReadingFailed);
 				} else {
 					debugC(1, kDebugInventory,
 						"Harvester: inventory right click skipped overlay refresh after action='%s' transitioned=%d closeInventory=%d",
 						secondaryAction.actionTag.c_str(), didTransition, secondaryAction.closeInventory);
 				}
 				needsRedraw = true;
-				return RoomInputResult();
+				return InputResult();
 			}
 			debugC(1, kDebugInventory,
 				"Harvester: inventory right click object='%s' produced no combat toggle or secondary action",
@@ -266,18 +266,18 @@ RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
 		debugC(1, kDebugInventory, "Harvester: inventory right click closing overlay");
 		if (_inventory.close())
 			needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (_inventory.hasSelection()) {
 		if (_inventory.clearSelection())
 			needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (_roomItems.hasCarriedRoomItem()) {
 		if (!_roomItems.stowCarriedRoomItemToInventory())
-			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			return InputResult::leaveRoom(Common::kReadingFailed);
 		needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 	Script *script = _engine.getScript();
 	if (!_presentationState.showingInspectText && !_session.isPlayerCombatLocked() && script && playerState.entity &&
@@ -307,21 +307,21 @@ RoomInputResult RoomInput::handleRightButtonDown(bool &needsRedraw) {
 			playerState.attackTargetClassId = -1;
 		}
 	}
-	return RoomInputResult();
+	return InputResult();
 }
 
-void RoomInput::handleLeftButtonUp() {
-	RoomInputState &inputState = _session.getInputState();
+void Input::handleLeftButtonUp() {
+	InputState &inputState = _session.getInputState();
 
 	inputState.lastLeftButtonReleaseTick = Player::getRuntimeClockTicks();
 	if (_presentationState.showingInspectText)
 		_presentationState.inspectCanDismiss = true;
 }
 
-RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
+InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	RoomPlayerState &playerState = _session.getPlayerState();
 	RoomIdleAnimationState &idleState = _session.getIdleState();
-	RoomInputState &inputState = _session.getInputState();
+	InputState &inputState = _session.getInputState();
 
 	const uint32 now = Player::getRuntimeClockTicks();
 	const bool isFastExitClick =
@@ -331,7 +331,7 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 	if (idleState.active || idleState.exiting) {
 		if (Player::requestIdleAnimationExit(_scene.state, playerState, idleState))
 			needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 
 	if (_presentationState.showingInspectText) {
@@ -341,10 +341,10 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 			_presentationState.inspectText = ResolvedText();
 			needsRedraw = true;
 		}
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (_session.isPlayerCombatLocked())
-		return RoomInputResult();
+		return InputResult();
 
 	const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
 	if (_inventory.isOpen()) {
@@ -357,10 +357,10 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 				const bool closedInventory = _inventory.close();
 				if (clearedSelection || closedInventory)
 					needsRedraw = true;
-				return RoomInputResult();
+				return InputResult();
 			}
 			if (InventorySystem::isStatusObject(inventoryHover->object))
-				return RoomInputResult();
+				return InputResult();
 
 			if (!_inventory.hasSelection()) {
 				debugC(1, kDebugInventory,
@@ -370,7 +370,7 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 				_inventory.setPromptText(_inventory.buildSelectedPrompt(
 					Common::String(), _flow._menuTextConfig));
 				needsRedraw = true;
-				return RoomInputResult();
+				return InputResult();
 			}
 
 			if (!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
@@ -381,20 +381,20 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 				Common::Error interactionError =
 					_roomItems.handleInventoryTargetInteraction(inventoryHover->object, _interactionProcessor, needsRedraw);
 				if (interactionError.getCode() != Common::kNoError)
-					return RoomInputResult::leaveRoom(interactionError);
+					return InputResult::leaveRoom(interactionError);
 			}
 			needsRedraw = true;
-			return RoomInputResult();
+			return InputResult();
 		}
 
 		if (_inventory.hasSelection()) {
 			if (!inventoryPanelBounds.contains(_flow._mousePos) && _inventory.close())
 				needsRedraw = true;
-			return RoomInputResult();
+			return InputResult();
 		}
 		if (!inventoryPanelBounds.contains(_flow._mousePos) && _inventory.close())
 			needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 
 	const RoomHoverState hoverState = resolveRoomHoverState(
@@ -426,10 +426,10 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 		if (clickHoverState.playerEntity && playerState.entity &&
 				clickHoverState.playerEntity == playerState.entity) {
 			if (!_roomItems.stowCarriedRoomItemToInventory())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				return InputResult::leaveRoom(Common::kReadingFailed);
 			needsRedraw = true;
 		}
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (_inventory.hasSelection()) {
 		const Common::String selectedItemName = _inventory.getSelectedItemName();
@@ -438,12 +438,12 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 			Common::Error dialogueError = _interactionProcessor.runScriptedDialogue(
 				clickHoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
 			if (dialogueError.getCode() != Common::kNoError)
-				return RoomInputResult::leaveRoom(dialogueError);
+				return InputResult::leaveRoom(dialogueError);
 			if (_flow.hasPendingMainMenuReturn())
-				return RoomInputResult::leaveRoom(Common::kNoError);
+				return InputResult::leaveRoom(Common::kNoError);
 			if (_inventory.clearSelection())
 				needsRedraw = true;
-			return RoomInputResult();
+			return InputResult();
 		}
 
 		ObjectRecord *roomTarget = clickHoverState.object
@@ -453,22 +453,22 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 			Common::Error interactionError =
 				_roomItems.handleInventoryTargetInteraction(*roomTarget, _interactionProcessor, needsRedraw);
 			if (interactionError.getCode() != Common::kNoError)
-				return RoomInputResult::leaveRoom(interactionError);
+				return InputResult::leaveRoom(interactionError);
 			needsRedraw = true;
 		}
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (hoverState.playerEntity) {
 		if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
 			idleState.active || idleState.exiting ||
 			playerState.hasMoveTarget || playerState.turnActive ||
 			playerState.entity->getAnimationRate() != 0) {
-			return RoomInputResult();
+			return InputResult();
 		}
 		if (!openInventoryOverlay())
-			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			return InputResult::leaveRoom(Common::kReadingFailed);
 		needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (hoverState.npc) {
 		_session.clearPendingRegionInteraction();
@@ -483,11 +483,11 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 		Common::Error dialogueError = _interactionProcessor.runScriptedDialogue(
 			hoverState.npc->npcName, Common::String(), Common::String(), didTransition);
 		if (dialogueError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(dialogueError);
+			return InputResult::leaveRoom(dialogueError);
 		if (_flow.hasPendingMainMenuReturn())
-			return RoomInputResult::leaveRoom(Common::kNoError);
+			return InputResult::leaveRoom(Common::kNoError);
 		needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 	// select one target in reverse render order; a selected object prevents exit-region selection.
 	if (hoverState.region && !hoverState.object && playerState.entity) {
@@ -498,19 +498,19 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 			Common::Error interactionError =
 				_interactionProcessor.runRegionInteraction(*hoverState.region, _inventory, needsRedraw);
 			if (interactionError.getCode() != Common::kNoError)
-				return RoomInputResult::leaveRoom(interactionError);
+				return InputResult::leaveRoom(interactionError);
 			if (_flow.hasPendingMainMenuReturn())
-				return RoomInputResult::leaveRoom(Common::kNoError);
+				return InputResult::leaveRoom(Common::kNoError);
 			needsRedraw = true;
-			return RoomInputResult();
+			return InputResult();
 		}
 		_interactionProcessor.queueRegionInteraction(*hoverState.region, _flow._mousePos);
 		Common::Error interactionError =
 			_interactionProcessor.tryActivatePendingRegion(_inventory, needsRedraw);
 		if (interactionError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(interactionError);
+			return InputResult::leaveRoom(interactionError);
 		needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 	ObjectRecord *clickedObject = hoverState.object
 		? findSceneObjectByName(_scene.sceneObjects, hoverState.object->objectName)
@@ -537,18 +537,18 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 			}
 			needsRedraw = true;
 		}
-		return RoomInputResult();
+		return InputResult();
 	}
 	_session.clearPendingRegionInteraction();
 	if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
 		clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
 		Common::Error exitError = _session.runRoomExitCommands();
 		if (exitError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(exitError);
+			return InputResult::leaveRoom(exitError);
 		// Native closeup exits return through room_setup so the parent on-enter command runs.
 		if (_canExitCloseupToParent)
 			_flow.requestCloseupParentRestart();
-		return RoomInputResult::leaveRoom(Common::kNoError);
+		return InputResult::leaveRoom(Common::kNoError);
 	}
 
 	ResolvedText resolvedInspectText;
@@ -571,14 +571,14 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
 		needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (_engine.getScript()->isPickupObject(*clickedObject)) {
 		Common::Error pickupError = _roomItems.handleRoomItemPickup(*clickedObject, playerState.entity != nullptr,
 			_interactionProcessor, _session, needsRedraw);
 		if (pickupError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(pickupError);
-		return RoomInputResult();
+			return InputResult::leaveRoom(pickupError);
+		return InputResult();
 	}
 
 	InteractionResult interaction;
@@ -591,7 +591,7 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 				resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
 		}
 		needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 
 	bool didTransition = false;
@@ -599,17 +599,17 @@ RoomInputResult RoomInput::handleLeftButtonDown(bool &needsRedraw) {
 		_interactionProcessor.handleInteractionResult(
 			interaction, didTransition, Common::String());
 	if (interactionError.getCode() != Common::kNoError)
-		return RoomInputResult::leaveRoom(interactionError);
+		return InputResult::leaveRoom(interactionError);
 	if (_flow.hasPendingMainMenuReturn())
-		return RoomInputResult::leaveRoom(Common::kNoError);
+		return InputResult::leaveRoom(Common::kNoError);
 	needsRedraw = true;
-	return RoomInputResult();
+	return InputResult();
 }
 
-RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &needsRedraw) {
+InputResult Input::handleKeyDown(const Common::KeyState &key, bool &needsRedraw) {
 	RoomPlayerState &playerState = _session.getPlayerState();
 	RoomIdleAnimationState &idleState = _session.getIdleState();
-	RoomInputState &inputState = _session.getInputState();
+	InputState &inputState = _session.getInputState();
 
 	if (_presentationState.showingInspectText) {
 		if (key.keycode == Common::KEYCODE_ESCAPE) {
@@ -618,7 +618,7 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 			_presentationState.inspectText = ResolvedText();
 			needsRedraw = true;
 		}
-		return RoomInputResult();
+		return InputResult();
 	}
 
 	_session.notePlayerActivity();
@@ -627,7 +627,7 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 	if (idleState.active || idleState.exiting) {
 		if (Player::requestIdleAnimationExit(_scene.state, playerState, idleState))
 			needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 
 	if (Script *script = _engine.getScript()) {
@@ -642,7 +642,7 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 					needsRedraw = true;
 				if (cheatActivation.inventoryChanged && _inventory.isOpen() &&
 						!_inventory.refresh()) {
-					return RoomInputResult::leaveRoom(Common::kReadingFailed);
+					return InputResult::leaveRoom(Common::kReadingFailed);
 				}
 				if (cheatActivation.hasInteraction) {
 					if (_inventory.isOpen())
@@ -651,13 +651,13 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 					Common::Error cheatError = _interactionProcessor.handleInteractionResult(
 						cheatInteraction, didTransition, Common::String());
 					if (cheatError.getCode() != Common::kNoError)
-						return RoomInputResult::leaveRoom(cheatError);
+						return InputResult::leaveRoom(cheatError);
 					if (_flow.hasPendingMainMenuReturn())
-						return RoomInputResult::leaveRoom(Common::kNoError);
+						return InputResult::leaveRoom(Common::kNoError);
 				}
 				needsRedraw = true;
 			}
-			return RoomInputResult();
+			return InputResult();
 		}
 	}
 
@@ -673,25 +673,25 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 			if (_inventory.close())
 				needsRedraw = true;
 		}
-		return RoomInputResult();
+		return InputResult();
 	}
 	if (_inventory.hasSelection()) {
 		if (key.keycode == Common::KEYCODE_ESCAPE) {
 			if (_inventory.clearSelection())
 				needsRedraw = true;
-			return RoomInputResult();
+			return InputResult();
 		}
 		if (key.keycode == Common::KEYCODE_i) {
 			if (_inventory.clearSelection())
 				needsRedraw = true;
 			if (!openInventoryOverlay())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				return InputResult::leaveRoom(Common::kReadingFailed);
 			needsRedraw = true;
-			return RoomInputResult();
+			return InputResult();
 		}
 	}
 	if (_session.isPlayerCombatLocked())
-		return RoomInputResult();
+		return InputResult();
 
 	if (key.keycode == Common::KEYCODE_LCTRL ||
 			key.keycode == Common::KEYCODE_RCTRL) {
@@ -699,8 +699,8 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 	} else if (key.keycode == Common::KEYCODE_k && _engine.isCombatDebugEnabled()) {
 		Common::Error debugKillError = _session.killDebugActiveMonster(_flow._mousePos, needsRedraw);
 		if (debugKillError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(debugKillError);
-		return RoomInputResult();
+			return InputResult::leaveRoom(debugKillError);
+		return InputResult();
 	} else if (key.keycode == Common::KEYCODE_LEFT)
 		inputState.moveLeft = true;
 	else if (key.keycode == Common::KEYCODE_RIGHT)
@@ -711,14 +711,14 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 		inputState.moveDown = true;
 	else if (key.keycode == Common::KEYCODE_i) {
 		if (!openInventoryOverlay())
-			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			return InputResult::leaveRoom(Common::kReadingFailed);
 		needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	} else if (key.keycode == Common::KEYCODE_ESCAPE) {
 		if (_session.cancelPlayerAttackAnimation())
 			needsRedraw = true;
 		if (!_roomItems.stowCarriedRoomItemToInventory())
-			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			return InputResult::leaveRoom(Common::kReadingFailed);
 		inputState.moveLeft = false;
 		inputState.moveRight = false;
 		inputState.moveUp = false;
@@ -730,36 +730,36 @@ RoomInputResult RoomInput::handleKeyDown(const Common::KeyState &key, bool &need
 		_session.captureCurrentSaveState();
 		Graphics::Screen *activeScreen = _engine.getScreen();
 		if (!activeScreen)
-			return RoomInputResult::leaveRoom(Common::kNoError);
+			return InputResult::leaveRoom(Common::kNoError);
 		IndexedBitmap roomMenuBackdrop;
 		drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
 		if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
-			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			return InputResult::leaveRoom(Common::kReadingFailed);
 		Common::Error menuError = _flow.runRoomMenuStub(
 			roomMenuBackdrop, _scene.palette, _scene.targetPaletteBrightness,
 			_scene.state.hasEntrance);
 		if (menuError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(menuError);
+			return InputResult::leaveRoom(menuError);
 		_flow.resetCursorAnimationSequence();
 		_session.resetIdleState();
 		needsRedraw = true;
-		return RoomInputResult();
+		return InputResult();
 	}
 
 	if (key.keycode == Common::KEYCODE_RETURN ||
 		key.keycode == Common::KEYCODE_KP_ENTER) {
 		if (!_roomItems.stowCarriedRoomItemToInventory())
-			return RoomInputResult::leaveRoom(Common::kReadingFailed);
+			return InputResult::leaveRoom(Common::kReadingFailed);
 		Common::Error exitError = _session.runRoomExitCommands();
 		if (exitError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(exitError);
-		return RoomInputResult::leaveRoom(Common::kNoError);
+			return InputResult::leaveRoom(exitError);
+		return InputResult::leaveRoom(Common::kNoError);
 	}
-	return RoomInputResult();
+	return InputResult();
 }
 
-void RoomInput::handleKeyUp(const Common::KeyState &key) {
-	RoomInputState &inputState = _session.getInputState();
+void Input::handleKeyUp(const Common::KeyState &key) {
+	InputState &inputState = _session.getInputState();
 
 	if (key.keycode == Common::KEYCODE_LCTRL ||
 			key.keycode == Common::KEYCODE_RCTRL)
@@ -774,10 +774,10 @@ void RoomInput::handleKeyUp(const Common::KeyState &key) {
 		inputState.moveDown = false;
 }
 
-RoomInputResult RoomInput::handleEvent(const Common::Event &event, bool &needsRedraw) {
+InputResult Input::handleEvent(const Common::Event &event, bool &needsRedraw) {
 	Common::Error result = Common::kNoError;
 	if (_flow.handleSystemEvent(event, result))
-		return RoomInputResult::leaveRoom(result);
+		return InputResult::leaveRoom(result);
 
 	switch (event.type) {
 	case Common::EVENT_MOUSEMOVE:
@@ -798,7 +798,7 @@ RoomInputResult RoomInput::handleEvent(const Common::Event &event, bool &needsRe
 	default:
 		break;
 	}
-	return RoomInputResult();
+	return InputResult();
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/room_input.h b/engines/harvester/input.h
similarity index 73%
rename from engines/harvester/room_input.h
rename to engines/harvester/input.h
index 9da9b566ef8..f4eeddfcd52 100644
--- a/engines/harvester/room_input.h
+++ b/engines/harvester/input.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_ROOM_INPUT_H
-#define HARVESTER_ROOM_INPUT_H
+#ifndef HARVESTER_INPUT_H
+#define HARVESTER_INPUT_H
 
 #include "common/error.h"
 #include "common/events.h"
@@ -32,15 +32,15 @@ class CheatSystem;
 class Flow;
 class HarvesterEngine;
 class InventorySystem;
-class RoomInteractionProcessor;
-class RoomItemInteractions;
+class Interaction;
+class ItemInteractions;
 class Scene;
 struct ObjectRecord;
 struct ResolvedText;
 struct RoomPresentationState;
 struct SceneState;
 
-struct RoomInputState {
+struct InputState {
 	bool moveLeft = false;
 	bool moveRight = false;
 	bool moveUp = false;
@@ -51,34 +51,34 @@ struct RoomInputState {
 };
 
 // Enter and closeup exits return from runRoomLoop even when the error is kNoError.
-struct RoomInputResult {
+struct InputResult {
 	Common::Error error;
 	bool returnFromRoom = false;
 
-	static RoomInputResult leaveRoom(const Common::Error &error) {
-		RoomInputResult result;
+	static InputResult leaveRoom(const Common::Error &error) {
+		InputResult result;
 		result.error = error;
 		result.returnFromRoom = true;
 		return result;
 	}
 };
 
-class RoomInput {
+class Input {
 public:
-	RoomInput(HarvesterEngine &engine, Flow &flow, Scene &session, SceneState &scene,
+	Input(HarvesterEngine &engine, Flow &flow, Scene &session, SceneState &scene,
 		InventorySystem &inventory, CheatSystem &cheats,
-		RoomItemInteractions &roomItems, RoomInteractionProcessor &interactionProcessor,
+		ItemInteractions &roomItems, Interaction &interactionProcessor,
 		RoomPresentationState &presentationState,
 		const Art &art, bool canExitCloseupToParent);
 
-	RoomInputResult handleEvent(const Common::Event &event, bool &needsRedraw);
+	InputResult handleEvent(const Common::Event &event, bool &needsRedraw);
 
 private:
 	void handleMouseMove(bool &needsRedraw);
-	RoomInputResult handleRightButtonDown(bool &needsRedraw);
+	InputResult handleRightButtonDown(bool &needsRedraw);
 	void handleLeftButtonUp();
-	RoomInputResult handleLeftButtonDown(bool &needsRedraw);
-	RoomInputResult handleKeyDown(const Common::KeyState &key, bool &needsRedraw);
+	InputResult handleLeftButtonDown(bool &needsRedraw);
+	InputResult handleKeyDown(const Common::KeyState &key, bool &needsRedraw);
 	void handleKeyUp(const Common::KeyState &key);
 	void beginInspectText(const ObjectRecord &object, const ResolvedText &text);
 	void queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw);
@@ -91,8 +91,8 @@ private:
 	SceneState &_scene;
 	InventorySystem &_inventory;
 	CheatSystem &_cheats;
-	RoomItemInteractions &_roomItems;
-	RoomInteractionProcessor &_interactionProcessor;
+	ItemInteractions &_roomItems;
+	Interaction &_interactionProcessor;
 	RoomPresentationState &_presentationState;
 	const Art &_art;
 	bool _canExitCloseupToParent;
@@ -100,4 +100,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_ROOM_INPUT_H
+#endif // HARVESTER_INPUT_H
diff --git a/engines/harvester/room_interaction.cpp b/engines/harvester/interaction.cpp
similarity index 91%
rename from engines/harvester/room_interaction.cpp
rename to engines/harvester/interaction.cpp
index 1ece1ed9e87..8a6500efe65 100644
--- a/engines/harvester/room_interaction.cpp
+++ b/engines/harvester/interaction.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/room_interaction.h"
+#include "harvester/interaction.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
@@ -33,7 +33,7 @@
 #include "harvester/player.h"
 #include "harvester/scene_entities.h"
 #include "harvester/resources.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 
 namespace Harvester {
 
@@ -74,23 +74,23 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return decodeBitmap(data, bitmap);
 }
 
-RoomItemInteractions::RoomItemInteractions(HarvesterEngine &engine, SceneState &scene,
+ItemInteractions::ItemInteractions(HarvesterEngine &engine, SceneState &scene,
 		SceneEntities &sceneRuntime, InventorySystem &inventory, const Common::String &pendingRoomChange)
 	: _engine(engine), _scene(scene), _sceneRuntime(sceneRuntime), _inventory(inventory),
 	  _pendingRoomChange(pendingRoomChange) {
 }
 
-bool RoomItemInteractions::hasCarriedRoomItem() const {
+bool ItemInteractions::hasCarriedRoomItem() const {
 	return !_carriedRoomItemName.empty();
 }
 
-void RoomItemInteractions::clearCarriedRoomItem() {
+void ItemInteractions::clearCarriedRoomItem() {
 	_carriedRoomItemName.clear();
 	_carriedRoomItemLabel.clear();
 	_carriedRoomItemBitmap = IndexedBitmap();
 }
 
-void RoomItemInteractions::hideSceneObject(const Common::String &objectName, const Common::String *ownerOrRoom) {
+void ItemInteractions::hideSceneObject(const Common::String &objectName, const Common::String *ownerOrRoom) {
 	ObjectRecord *sceneObject = findSceneObjectByName(_scene.sceneObjects, objectName);
 	if (!sceneObject)
 		return;
@@ -101,7 +101,7 @@ void RoomItemInteractions::hideSceneObject(const Common::String &objectName, con
 		sceneObject->currentOwnerOrRoom = *ownerOrRoom;
 }
 
-bool RoomItemInteractions::stowCarriedRoomItemToInventory() {
+bool ItemInteractions::stowCarriedRoomItemToInventory() {
 	if (!hasCarriedRoomItem())
 		return true;
 
@@ -110,7 +110,7 @@ bool RoomItemInteractions::stowCarriedRoomItemToInventory() {
 	return _inventory.refresh();
 }
 
-bool RoomItemInteractions::shouldCancelDeniedPickup(const InteractionResult &interaction, bool didTransition) const {
+bool ItemInteractions::shouldCancelDeniedPickup(const InteractionResult &interaction, bool didTransition) const {
 	return !didTransition &&
 		!interaction.mutatedRuntimeState &&
 		!interaction.requestPlayerGotoXZ &&
@@ -126,8 +126,8 @@ bool RoomItemInteractions::shouldCancelDeniedPickup(const InteractionResult &int
 		!interaction.modalText.value.empty();
 }
 
-Common::Error RoomItemInteractions::restoreDeniedPickup(Script &script, const ObjectRecord &savedObject, bool clearCarryState,
-		RoomInteractionCallbacks &callbacks, bool &needsRedraw) {
+Common::Error ItemInteractions::restoreDeniedPickup(Script &script, const ObjectRecord &savedObject, bool clearCarryState,
+		InteractionCallbacks &callbacks, bool &needsRedraw) {
 	if (!script.syncRuntimeObjectRecord(savedObject))
 		return Common::kReadingFailed;
 	if (clearCarryState)
@@ -141,8 +141,8 @@ Common::Error RoomItemInteractions::restoreDeniedPickup(Script &script, const Ob
 	return Common::kNoError;
 }
 
-Common::Error RoomItemInteractions::moveRoomItemDirectlyToInventory(const ObjectRecord &object,
-		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw) {
+Common::Error ItemInteractions::moveRoomItemDirectlyToInventory(const ObjectRecord &object,
+		Interaction &interactionProcessor, InteractionCallbacks &callbacks, bool &needsRedraw) {
 	Script *script = _engine.getScript();
 	if (!script)
 		return Common::kReadingFailed;
@@ -182,8 +182,8 @@ Common::Error RoomItemInteractions::moveRoomItemDirectlyToInventory(const Object
 	return Common::kNoError;
 }
 
-Common::Error RoomItemInteractions::beginRoomItemCarry(const ObjectRecord &object,
-		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw) {
+Common::Error ItemInteractions::beginRoomItemCarry(const ObjectRecord &object,
+		Interaction &interactionProcessor, InteractionCallbacks &callbacks, bool &needsRedraw) {
 	Script *script = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
 	if (!script || !resources)
@@ -244,8 +244,8 @@ Common::Error RoomItemInteractions::beginRoomItemCarry(const ObjectRecord &objec
 	return Common::kNoError;
 }
 
-Common::Error RoomItemInteractions::handleInventoryTargetInteraction(const ObjectRecord &target,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw) {
+Common::Error ItemInteractions::handleInventoryTargetInteraction(const ObjectRecord &target,
+		Interaction &interactionProcessor, bool &needsRedraw) {
 	if (!_inventory.hasSelection())
 		return Common::kNoError;
 
@@ -287,7 +287,7 @@ Common::Error RoomItemInteractions::handleInventoryTargetInteraction(const Objec
 	return Common::kNoError;
 }
 
-ObjectRecord *RoomItemInteractions::findSelectedInventoryRoomTarget(const Common::Point &point) {
+ObjectRecord *ItemInteractions::findSelectedInventoryRoomTarget(const Common::Point &point) {
 	if (!_inventory.hasSelection())
 		return nullptr;
 
@@ -308,9 +308,9 @@ ObjectRecord *RoomItemInteractions::findSelectedInventoryRoomTarget(const Common
 		const Entity *entity = entityManager->findSceneEntityByName(candidate.objectName);
 		if (!entity || !entity->hitTest(point))
 			continue;
-		if (entity->getClassId() == kRuntimeEntityClassBackground ||
-				entity->getClassId() == kRuntimeEntityClassPlayer ||
-				entity->getClassId() == kRuntimeEntityClassRectHotspot19) {
+		if (entity->getClassId() == kEntityClassBackground ||
+				entity->getClassId() == kEntityClassPlayer ||
+				entity->getClassId() == kEntityClassRectHotspot19) {
 			continue;
 		}
 
@@ -326,8 +326,8 @@ ObjectRecord *RoomItemInteractions::findSelectedInventoryRoomTarget(const Common
 	return bestTarget;
 }
 
-Common::Error RoomItemInteractions::handleRoomItemPickup(const ObjectRecord &object, bool playerPresent,
-		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw) {
+Common::Error ItemInteractions::handleRoomItemPickup(const ObjectRecord &object, bool playerPresent,
+		Interaction &interactionProcessor, InteractionCallbacks &callbacks, bool &needsRedraw) {
 	InteractionResult blockedPickupInteraction;
 	if (_engine.getScript()->isPickupBlockedByAction(object, &blockedPickupInteraction)) {
 		bool didTransition = false;
@@ -344,12 +344,12 @@ Common::Error RoomItemInteractions::handleRoomItemPickup(const ObjectRecord &obj
 		: moveRoomItemDirectlyToInventory(object, interactionProcessor, callbacks, needsRedraw);
 }
 
-RoomInteractionProcessor::RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow,
+Interaction::Interaction(HarvesterEngine &engine, Flow &flow,
 		SceneState &scene, RoomPlayerState &playerState,
 		Common::String &pendingRegionName, Common::String &pendingRoomChange,
 		bool &pendingRoomChangeIsRoomName, bool &pendingRoomChangeUsesSavedRoomState,
 		SaveRoomState &pendingRoomChangeSavedRoomState, bool canExitCloseupToParent,
-		RoomInteractionCallbacks &callbacks)
+		InteractionCallbacks &callbacks)
 	: _engine(engine), _flow(flow), _scene(scene), _playerState(playerState),
 	  _pendingRegionName(pendingRegionName), _pendingRoomChange(pendingRoomChange),
 	  _pendingRoomChangeIsRoomName(pendingRoomChangeIsRoomName),
@@ -358,7 +358,7 @@ RoomInteractionProcessor::RoomInteractionProcessor(HarvesterEngine &engine, Flow
 	  _canExitCloseupToParent(canExitCloseupToParent), _callbacks(callbacks) {
 }
 
-void RoomInteractionProcessor::queueRegionInteraction(const RegionRecord &region, const Common::Point &mousePos) {
+void Interaction::queueRegionInteraction(const RegionRecord &region, const Common::Point &mousePos) {
 	_pendingRegionName = region.regionName;
 	if (!_playerState.entity)
 		return;
@@ -392,7 +392,7 @@ void RoomInteractionProcessor::queueRegionInteraction(const RegionRecord &region
 	}
 }
 
-Common::Error RoomInteractionProcessor::runRegionInteraction(const RegionRecord &region,
+Common::Error Interaction::runRegionInteraction(const RegionRecord &region,
 		InventorySystem &inventory, bool &needsRedraw) {
 	InteractionResult interaction;
 	if (!_engine.getScript()->resolveRegionInteraction(region, interaction, _scene.state.roomName))
@@ -417,7 +417,7 @@ Common::Error RoomInteractionProcessor::runRegionInteraction(const RegionRecord
 	return Common::kNoError;
 }
 
-Common::Error RoomInteractionProcessor::tryActivatePendingRegion(InventorySystem &inventory, bool &needsRedraw) {
+Common::Error Interaction::tryActivatePendingRegion(InventorySystem &inventory, bool &needsRedraw) {
 	if (_pendingRegionName.empty() || !_playerState.entity)
 		return Common::kNoError;
 
@@ -438,7 +438,7 @@ Common::Error RoomInteractionProcessor::tryActivatePendingRegion(InventorySystem
 	return runRegionInteraction(*region, inventory, needsRedraw);
 }
 
-Common::Error RoomInteractionProcessor::tryActivatePassiveRegion(InventorySystem &inventory, bool &needsRedraw) {
+Common::Error Interaction::tryActivatePassiveRegion(InventorySystem &inventory, bool &needsRedraw) {
 	if (!_playerState.entity)
 		return Common::kNoError;
 
@@ -457,16 +457,16 @@ Common::Error RoomInteractionProcessor::tryActivatePassiveRegion(InventorySystem
 	return Common::kNoError;
 }
 
-int RoomInteractionProcessor::getPendingRegionFacing() const {
+int Interaction::getPendingRegionFacing() const {
 	const RegionRecord *pendingRegion = findSceneRegionByName(_scene.sceneRegions, _pendingRegionName);
 	return pendingRegion && pendingRegion->startEnabled ? pendingRegion->desiredFacing : -1;
 }
 
-bool RoomInteractionProcessor::hasPendingMainMenuReturn() const {
+bool Interaction::hasPendingMainMenuReturn() const {
 	return _flow.hasPendingMainMenuReturn();
 }
 
-Common::Error RoomInteractionProcessor::handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw) {
+Common::Error Interaction::handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw) {
 	bool didTransition = false;
 	Common::Error interactionError =
 		handleInteractionResult(interaction, didTransition, Common::String());
@@ -483,7 +483,7 @@ Common::Error RoomInteractionProcessor::handleCombatInteraction(const Interactio
 	return Common::kNoError;
 }
 
-Common::Error RoomInteractionProcessor::handleInteractionResult(const InteractionResult &interaction,
+Common::Error Interaction::handleInteractionResult(const InteractionResult &interaction,
 		bool &didTransition, const Common::String &usedItemName, bool interruptPlayerMovement) {
 	didTransition = false;
 
@@ -745,7 +745,7 @@ Common::Error RoomInteractionProcessor::handleInteractionResult(const Interactio
 	return Common::kNoError;
 }
 
-Common::Error RoomInteractionProcessor::runScriptedDialogue(const Common::String &npcName,
+Common::Error Interaction::runScriptedDialogue(const Common::String &npcName,
 		const Common::String &usedItemName, const Common::String &continuationTag,
 		bool &didTransition) {
 	didTransition = false;
diff --git a/engines/harvester/room_interaction.h b/engines/harvester/interaction.h
similarity index 83%
rename from engines/harvester/room_interaction.h
rename to engines/harvester/interaction.h
index 052bd309dfe..1e80ef1cb38 100644
--- a/engines/harvester/room_interaction.h
+++ b/engines/harvester/interaction.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_ROOM_INTERACTION_H
-#define HARVESTER_ROOM_INTERACTION_H
+#ifndef HARVESTER_INTERACTION_H
+#define HARVESTER_INTERACTION_H
 
 #include "common/error.h"
 #include "harvester/bitmap.h"
@@ -32,15 +32,15 @@ namespace Harvester {
 class Flow;
 class HarvesterEngine;
 class InventorySystem;
-class RoomInteractionProcessor;
+class Interaction;
 class SceneEntities;
 struct IndexedBitmap;
 
 extern const char *const kExitCloseupPendingRoomChange;
 
-class RoomInteractionCallbacks {
+class InteractionCallbacks {
 public:
-	virtual ~RoomInteractionCallbacks() {}
+	virtual ~InteractionCallbacks() {}
 
 	virtual bool refreshCurrentScene(bool preservePlayerPlacement) = 0;
 	virtual bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) = 0;
@@ -58,9 +58,9 @@ public:
 		int damageType) = 0;
 };
 
-class RoomItemInteractions {
+class ItemInteractions {
 public:
-	RoomItemInteractions(HarvesterEngine &engine, SceneState &scene,
+	ItemInteractions(HarvesterEngine &engine, SceneState &scene,
 		SceneEntities &sceneRuntime, InventorySystem &inventory, const Common::String &pendingRoomChange);
 
 	const Common::String &getCarriedRoomItemLabel() const { return _carriedRoomItemLabel; }
@@ -68,21 +68,21 @@ public:
 	bool hasCarriedRoomItem() const;
 	bool stowCarriedRoomItemToInventory();
 	Common::Error handleInventoryTargetInteraction(const ObjectRecord &target,
-		RoomInteractionProcessor &interactionProcessor, bool &needsRedraw);
+		Interaction &interactionProcessor, bool &needsRedraw);
 	ObjectRecord *findSelectedInventoryRoomTarget(const Common::Point &point);
 	Common::Error handleRoomItemPickup(const ObjectRecord &object, bool playerPresent,
-		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw);
+		Interaction &interactionProcessor, InteractionCallbacks &callbacks, bool &needsRedraw);
 
 private:
 	void clearCarriedRoomItem();
 	void hideSceneObject(const Common::String &objectName, const Common::String *ownerOrRoom);
 	bool shouldCancelDeniedPickup(const InteractionResult &interaction, bool didTransition) const;
 	Common::Error restoreDeniedPickup(Script &script, const ObjectRecord &savedObject, bool clearCarryState,
-		RoomInteractionCallbacks &callbacks, bool &needsRedraw);
+		InteractionCallbacks &callbacks, bool &needsRedraw);
 	Common::Error moveRoomItemDirectlyToInventory(const ObjectRecord &object,
-		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw);
+		Interaction &interactionProcessor, InteractionCallbacks &callbacks, bool &needsRedraw);
 	Common::Error beginRoomItemCarry(const ObjectRecord &object,
-		RoomInteractionProcessor &interactionProcessor, RoomInteractionCallbacks &callbacks, bool &needsRedraw);
+		Interaction &interactionProcessor, InteractionCallbacks &callbacks, bool &needsRedraw);
 
 	HarvesterEngine &_engine;
 	SceneState &_scene;
@@ -94,13 +94,13 @@ private:
 	IndexedBitmap _carriedRoomItemBitmap;
 };
 
-class RoomInteractionProcessor {
+class Interaction {
 public:
-	RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow, SceneState &scene,
+	Interaction(HarvesterEngine &engine, Flow &flow, SceneState &scene,
 		RoomPlayerState &playerState, Common::String &pendingRegionName,
 		Common::String &pendingRoomChange, bool &pendingRoomChangeIsRoomName,
 		bool &pendingRoomChangeUsesSavedRoomState, SaveRoomState &pendingRoomChangeSavedRoomState,
-		bool canExitCloseupToParent, RoomInteractionCallbacks &callbacks);
+		bool canExitCloseupToParent, InteractionCallbacks &callbacks);
 
 	void queueRegionInteraction(const RegionRecord &region, const Common::Point &mousePos);
 	Common::Error runRegionInteraction(const RegionRecord &region,
@@ -127,11 +127,11 @@ private:
 	bool &_pendingRoomChangeUsesSavedRoomState;
 	SaveRoomState &_pendingRoomChangeSavedRoomState;
 	bool _canExitCloseupToParent;
-	RoomInteractionCallbacks &_callbacks;
+	InteractionCallbacks &_callbacks;
 };
 
 bool hasRoomEntryInteraction(const InteractionResult &interaction);
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_ROOM_INTERACTION_H
+#endif // HARVESTER_INTERACTION_H
diff --git a/engines/harvester/media_manager.cpp b/engines/harvester/media_manager.cpp
index 02e4a522859..da0b4335fec 100644
--- a/engines/harvester/media_manager.cpp
+++ b/engines/harvester/media_manager.cpp
@@ -34,7 +34,7 @@
 #include "graphics/screen.h"
 #include "harvester/art.h"
 #include "harvester/resources.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 #include "harvester/script.h"
 #include "harvester/text.h"
 
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index ecf0537af55..9cbcd4b90b9 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -38,7 +38,7 @@
 #include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/room_support.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 #include "harvester/art.h"
 #include "harvester/flow.h"
 #include "harvester/script.h"
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 1955e9fe591..ddcde655bc6 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -17,13 +17,13 @@ MODULE_OBJS = \
 	monster.o \
 	player.o \
 	resources.o \
-	room_combat.o \
-	room_interaction.o \
-	room_input.o \
+	combat.o \
+	interaction.o \
+	input.o \
 	room_presentation.o \
 	scene_entities.o \
 	scene.o \
-	runtime_entity.o \
+	entity.o \
 	saveload.o \
 	script.o \
 	text.o \
diff --git a/engines/harvester/monster.cpp b/engines/harvester/monster.cpp
index 50be328beaf..82198abd7be 100644
--- a/engines/harvester/monster.cpp
+++ b/engines/harvester/monster.cpp
@@ -21,7 +21,7 @@
 
 #include "harvester/monster.h"
 
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index a57b986cba5..229a080de6d 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -28,7 +28,7 @@
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 
 namespace Harvester {
 
@@ -1052,7 +1052,7 @@ static void positionPlayerIdleAnimationEntity(const RoomSetupState &state,
 	if (!playerState.entity)
 		return;
 
-	idleEntity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	idleEntity.setAnchorMode(kEntityAnchorTopLeft);
 	idleEntity.setDepthScale(Player::computeDepthScale(state, playerState.z));
 	idleEntity.setPosition(playerState.entity->getX(),
 		playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
diff --git a/engines/harvester/scene.cpp b/engines/harvester/scene.cpp
index d08d12df167..7829e3da046 100644
--- a/engines/harvester/scene.cpp
+++ b/engines/harvester/scene.cpp
@@ -33,8 +33,8 @@
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
 #include "harvester/player.h"
-#include "harvester/room_combat.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/combat.h"
+#include "harvester/entity.h"
 
 #include "common/ptr.h"
 #include "graphics/blit.h"
@@ -43,8 +43,8 @@
 #include "harvester/cheats.h"
 #include "harvester/resources.h"
 #include "harvester/monster.h"
-#include "harvester/room_interaction.h"
-#include "harvester/room_input.h"
+#include "harvester/interaction.h"
+#include "harvester/input.h"
 #include "harvester/room_presentation.h"
 #include "harvester/room_support.h"
 
@@ -221,7 +221,7 @@ bool Scene::applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) {
 	SceneState updatedScene;
 	if (!_sceneRuntime.loadSceneResources(updatedState, updatedScene))
 		return false;
-	Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates =
+	Common::Array<MonsterCombatState> updatedMonsterCombatStates =
 		_combat.prepareMonsterCombatStatesForSceneChange(_scene.state.roomMonsters, updatedState.roomMonsters);
 
 	if (!_sceneRuntime.reconcileSceneEntitiesInPlace(_scene, updatedState, updatedScene))
@@ -434,15 +434,15 @@ void Scene::startPlayerDefeatSequence(const char *reason, const Common::String &
 			: 0);
 }
 
-RoomInputResult Scene::runRoomEntryInteraction(const InteractionResult &roomEntryInteraction) {
+InputResult Scene::runRoomEntryInteraction(const InteractionResult &roomEntryInteraction) {
 	bool didEntryTransition = false;
 	Common::Error entryInteractionError = _interactionProcessor.handleInteractionResult(
 		roomEntryInteraction, didEntryTransition, Common::String());
 	if (entryInteractionError.getCode() != Common::kNoError)
-		return RoomInputResult::leaveRoom(entryInteractionError);
+		return InputResult::leaveRoom(entryInteractionError);
 	if (_flow.hasPendingMainMenuReturn() || didEntryTransition)
-		return RoomInputResult::leaveRoom(Common::kNoError);
-	return RoomInputResult();
+		return InputResult::leaveRoom(Common::kNoError);
+	return InputResult();
 }
 
 void Scene::updateRoomInterface(bool &needsRedraw) {
@@ -585,14 +585,14 @@ bool Scene::drawRoomFrame(bool &needsRedraw) {
 	return true;
 }
 
-RoomInputResult Scene::pollRoomEvents(RoomInput &input, bool &needsRedraw) {
+InputResult Scene::pollRoomEvents(Input &input, bool &needsRedraw) {
 	Common::Event event;
 	while (g_system->getEventManager()->pollEvent(event)) {
-		const RoomInputResult inputResult = input.handleEvent(event, needsRedraw);
+		const InputResult inputResult = input.handleEvent(event, needsRedraw);
 		if (inputResult.returnFromRoom)
 			return inputResult;
 	}
-	return RoomInputResult();
+	return InputResult();
 }
 
 Scene::PlayerControlState Scene::preparePlayerControlState() {
@@ -704,7 +704,7 @@ void Scene::pruneCombatEffects(bool &needsRedraw) {
 		needsRedraw = true;
 }
 
-RoomInputResult Scene::dispatchExpiredTimers(bool &needsRedraw) {
+InputResult Scene::dispatchExpiredTimers(bool &needsRedraw) {
 	if (_entityManager) {
 		Common::Array<Common::String> expiredTimerNames;
 		if (_entityManager->takeExpiredTimerNames(expiredTimerNames)) {
@@ -720,25 +720,25 @@ RoomInputResult Scene::dispatchExpiredTimers(bool &needsRedraw) {
 					_interactionProcessor.handleInteractionResult(
 						timerInteraction, didTransition, Common::String(), false);
 				if (interactionError.getCode() != Common::kNoError)
-					return RoomInputResult::leaveRoom(interactionError);
+					return InputResult::leaveRoom(interactionError);
 				if (_flow.hasPendingMainMenuReturn())
-					return RoomInputResult::leaveRoom(Common::kNoError);
+					return InputResult::leaveRoom(Common::kNoError);
 				needsRedraw = true;
 				if (!_pendingTransition.roomName.empty())
 					break;
 			}
 		}
 	}
-	return RoomInputResult();
+	return InputResult();
 }
 
-RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
-	RoomInput input(_engine, _flow, *this, _scene, _inventory, _cheats,
+InputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
+	Input input(_engine, _flow, *this, _scene, _inventory, _cheats,
 		_roomItems, _interactionProcessor, _presentationState, *_view.art, _canExitCloseupToParent);
 	bool needsRedraw = true;
 
 	if (!_inventory.refresh())
-		return RoomInputResult::leaveRoom(Common::kReadingFailed);
+		return InputResult::leaveRoom(Common::kReadingFailed);
 	Graphics::FrameLimiter limiter(g_system, 60);
 	captureCurrentSaveState();
 
@@ -747,7 +747,7 @@ RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 
 	while (!_engine.shouldQuit()) {
 		if (_flow.hasPendingMainMenuReturn())
-			return RoomInputResult::leaveRoom(Common::kNoError);
+			return InputResult::leaveRoom(Common::kNoError);
 		if (_flow.takePendingNewGameRestart()) {
 			_flow.prepareForNewGame();
 			_pendingTransition.roomName = "START";
@@ -755,7 +755,7 @@ RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 			_pendingTransition.usesSavedRoomState = false;
 			_pendingTransition.savedRoomState.clear();
 			if (!_roomItems.stowCarriedRoomItemToInventory())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				return InputResult::leaveRoom(Common::kReadingFailed);
 			break;
 		}
 		if (_engine.hasPendingLoadedSaveRoomState()) {
@@ -768,17 +768,17 @@ RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 				_pendingTransition.isRoomName = true;
 			}
 			if (!_roomItems.stowCarriedRoomItemToInventory())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				return InputResult::leaveRoom(Common::kReadingFailed);
 			break;
 		}
 		if (_flow.hasPendingDebugRoomChange()) {
 			if (!_roomItems.stowCarriedRoomItemToInventory())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
-			return RoomInputResult::leaveRoom(Common::kNoError);
+				return InputResult::leaveRoom(Common::kReadingFailed);
+			return InputResult::leaveRoom(Common::kNoError);
 		}
 		if (!_pendingTransition.roomName.empty()) {
 			if (!_roomItems.stowCarriedRoomItemToInventory())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				return InputResult::leaveRoom(Common::kReadingFailed);
 			break;
 		}
 		InteractionResult queuedDebugInteraction;
@@ -789,26 +789,26 @@ RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 			Common::Error debugInteractionError = _interactionProcessor.handleInteractionResult(
 				queuedDebugInteraction, didDebugTransition, Common::String());
 			if (debugInteractionError.getCode() != Common::kNoError)
-				return RoomInputResult::leaveRoom(debugInteractionError);
+				return InputResult::leaveRoom(debugInteractionError);
 			if (_flow.hasPendingMainMenuReturn())
-				return RoomInputResult::leaveRoom(Common::kNoError);
+				return InputResult::leaveRoom(Common::kNoError);
 			if (!_pendingTransition.roomName.empty()) {
 				if (!_roomItems.stowCarriedRoomItemToInventory())
-					return RoomInputResult::leaveRoom(Common::kReadingFailed);
+					return InputResult::leaveRoom(Common::kReadingFailed);
 				break;
 			}
 			needsRedraw = true;
 		}
 		updateRoomInterface(needsRedraw);
 		if (!drawRoomFrame(needsRedraw))
-			return RoomInputResult::leaveRoom(Common::kNoError);
+			return InputResult::leaveRoom(Common::kNoError);
 
-		const RoomInputResult inputResult = pollRoomEvents(input, needsRedraw);
+		const InputResult inputResult = pollRoomEvents(input, needsRedraw);
 		if (inputResult.returnFromRoom)
 			return inputResult;
 		if (!_pendingTransition.roomName.empty()) {
 			if (!_roomItems.stowCarriedRoomItemToInventory())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				return InputResult::leaveRoom(Common::kReadingFailed);
 			break;
 		}
 
@@ -820,23 +820,23 @@ RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 		Common::Error pendingRegionError =
 			tryActivatePendingRegion(needsRedraw);
 		if (pendingRegionError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(pendingRegionError);
+			return InputResult::leaveRoom(pendingRegionError);
 		if (_flow.hasPendingMainMenuReturn())
-			return RoomInputResult::leaveRoom(Common::kNoError);
+			return InputResult::leaveRoom(Common::kNoError);
 		if (!_pendingTransition.roomName.empty()) {
 			if (!_roomItems.stowCarriedRoomItemToInventory())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				return InputResult::leaveRoom(Common::kReadingFailed);
 			break;
 		}
 		if (!isPlayerCombatLocked()) {
 			pendingRegionError = tryActivatePassiveRegion(needsRedraw);
 			if (pendingRegionError.getCode() != Common::kNoError)
-				return RoomInputResult::leaveRoom(pendingRegionError);
+				return InputResult::leaveRoom(pendingRegionError);
 			if (_flow.hasPendingMainMenuReturn())
-				return RoomInputResult::leaveRoom(Common::kNoError);
+				return InputResult::leaveRoom(Common::kNoError);
 			if (!_pendingTransition.roomName.empty()) {
 				if (!_roomItems.stowCarriedRoomItemToInventory())
-					return RoomInputResult::leaveRoom(Common::kReadingFailed);
+					return InputResult::leaveRoom(Common::kReadingFailed);
 				break;
 			}
 		}
@@ -848,12 +848,12 @@ RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 		if (!control.paused) {
 			combatError = resolvePlayerAttackContact(needsRedraw);
 			if (combatError.getCode() != Common::kNoError)
-				return RoomInputResult::leaveRoom(combatError);
+				return InputResult::leaveRoom(combatError);
 			if (_flow.hasPendingMainMenuReturn())
-				return RoomInputResult::leaveRoom(Common::kNoError);
+				return InputResult::leaveRoom(Common::kNoError);
 			if (!_pendingTransition.roomName.empty()) {
 				if (!_roomItems.stowCarriedRoomItemToInventory())
-					return RoomInputResult::leaveRoom(Common::kReadingFailed);
+					return InputResult::leaveRoom(Common::kReadingFailed);
 				break;
 			}
 			updatePlayerCombatAnimations(needsRedraw);
@@ -861,20 +861,20 @@ RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 		updatePlayerControls(control, needsRedraw);
 		combatError = updateRoomMonsterCombat(needsRedraw);
 		if (combatError.getCode() != Common::kNoError)
-			return RoomInputResult::leaveRoom(combatError);
+			return InputResult::leaveRoom(combatError);
 		syncCombatHitEffects(needsRedraw);
 		if (_flow.hasPendingMainMenuReturn())
-			return RoomInputResult::leaveRoom(Common::kNoError);
+			return InputResult::leaveRoom(Common::kNoError);
 		if (!_pendingTransition.roomName.empty()) {
 			if (!_roomItems.stowCarriedRoomItemToInventory())
-				return RoomInputResult::leaveRoom(Common::kReadingFailed);
+				return InputResult::leaveRoom(Common::kReadingFailed);
 			break;
 		}
 		updatePlayerDeath(needsRedraw);
 		if (_flow.hasPendingMainMenuReturn())
-			return RoomInputResult::leaveRoom(Common::kNoError);
+			return InputResult::leaveRoom(Common::kNoError);
 		pruneCombatEffects(needsRedraw);
-		const RoomInputResult timerResult = dispatchExpiredTimers(needsRedraw);
+		const InputResult timerResult = dispatchExpiredTimers(needsRedraw);
 		if (timerResult.returnFromRoom)
 			return timerResult;
 		updateIdleAnimation(needsRedraw);
@@ -885,17 +885,17 @@ RoomInputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 		limiter.startFrame();
 	}
 	if (_engine.shouldQuit())
-		return RoomInputResult::leaveRoom(Common::kNoError);
+		return InputResult::leaveRoom(Common::kNoError);
 	if (_flow.hasPendingMainMenuReturn())
-		return RoomInputResult::leaveRoom(Common::kNoError);
+		return InputResult::leaveRoom(Common::kNoError);
 	if (_pendingTransition.roomName.empty())
-		return RoomInputResult::leaveRoom(Common::kNoError);
+		return InputResult::leaveRoom(Common::kNoError);
 	if (_pendingTransition.roomName.equals(kExitCloseupPendingRoomChange)) {
 		_flow.requestCloseupParentRestart();
-		return RoomInputResult::leaveRoom(Common::kNoError);
+		return InputResult::leaveRoom(Common::kNoError);
 	}
 
-	return RoomInputResult();
+	return InputResult();
 }
 
 void Scene::syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) {
@@ -1150,7 +1150,7 @@ Common::Error SceneRunner::runRoomLoop(Flow &flow, const Common::String &targetN
 
 		if (shouldRunRoomEntryCommands) {
 			if (hasPendingRoomEntryInteraction) {
-				const RoomInputResult entryResult = session.runRoomEntryInteraction(roomEntryInteraction);
+				const InputResult entryResult = session.runRoomEntryInteraction(roomEntryInteraction);
 				if (entryResult.returnFromRoom)
 					return entryResult.error;
 			}
@@ -1161,7 +1161,7 @@ Common::Error SceneRunner::runRoomLoop(Flow &flow, const Common::String &targetN
 		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
 			_engine.getScript());
 
-		const RoomInputResult frameResult = session.runRoomFrames(currentRoomTarget);
+		const InputResult frameResult = session.runRoomFrames(currentRoomTarget);
 		if (frameResult.returnFromRoom)
 			return frameResult.error;
 		const RoomPendingTransition &pendingTransition = session.getPendingTransition();
diff --git a/engines/harvester/scene.h b/engines/harvester/scene.h
index da19ea16e3f..8c896f14bf7 100644
--- a/engines/harvester/scene.h
+++ b/engines/harvester/scene.h
@@ -24,13 +24,13 @@
 
 #include "common/error.h"
 #include "common/str.h"
-#include "harvester/room_combat.h"
-#include "harvester/room_input.h"
-#include "harvester/room_interaction.h"
+#include "harvester/combat.h"
+#include "harvester/input.h"
+#include "harvester/interaction.h"
 #include "harvester/room_presentation.h"
 #include "harvester/scene_entities.h"
 #include "harvester/room_support.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 
 namespace Harvester {
 
@@ -58,18 +58,18 @@ struct RoomPendingTransition {
 	SaveRoomState savedRoomState;
 };
 
-class Scene : public RoomInteractionCallbacks {
+class Scene : public InteractionCallbacks {
 public:
 	Scene(HarvesterEngine &engine, Flow &flow, SceneState &scene, EntityManager *entityManager,
 		InventorySystem &inventory, CheatSystem &cheats, const RoomViewResources &view, bool canExitCloseupToParent);
 
 	RoomPlayerState &getPlayerState() { return _playerState; }
 	RoomIdleAnimationState &getIdleState() { return _idleState; }
-	RoomInputState &getInputState() { return _inputState; }
+	InputState &getInputState() { return _inputState; }
 
 	void initializePlayerState();
-	RoomInputResult runRoomEntryInteraction(const InteractionResult &interaction);
-	RoomInputResult runRoomFrames(const Common::String &currentRoomTarget);
+	InputResult runRoomEntryInteraction(const InteractionResult &interaction);
+	InputResult runRoomFrames(const Common::String &currentRoomTarget);
 	const RoomPendingTransition &getPendingTransition() const { return _pendingTransition; }
 	void clearPendingRegionInteraction() { _pendingRegionName.clear(); }
 	void capturePlayerAttackTarget(const Common::Point &mousePos, const NpcRecord *npc);
@@ -104,7 +104,7 @@ private:
 	void requestPlayerGameOver(const char *reason, const Common::String &sourceName);
 	void updateRoomInterface(bool &needsRedraw);
 	bool drawRoomFrame(bool &needsRedraw);
-	RoomInputResult pollRoomEvents(RoomInput &input, bool &needsRedraw);
+	InputResult pollRoomEvents(Input &input, bool &needsRedraw);
 	PlayerControlState preparePlayerControlState();
 	Common::Error tryActivatePendingRegion(bool &needsRedraw);
 	Common::Error tryActivatePassiveRegion(bool &needsRedraw);
@@ -116,7 +116,7 @@ private:
 	void syncCombatHitEffects(bool &needsRedraw);
 	void updatePlayerDeath(bool &needsRedraw);
 	void pruneCombatEffects(bool &needsRedraw);
-	RoomInputResult dispatchExpiredTimers(bool &needsRedraw);
+	InputResult dispatchExpiredTimers(bool &needsRedraw);
 	void updateIdleAnimation(bool &needsRedraw);
 
 	HarvesterEngine &_engine;
@@ -126,19 +126,19 @@ private:
 	InventorySystem &_inventory;
 	SceneEntities _sceneRuntime;
 	RoomPlayerState _playerState;
-	RoomCombat _combat;
-	RoomCombatEffects _combatEffects;
+	Combat _combat;
+	CombatEffects _combatEffects;
 	RoomIdleAnimationState _idleState;
-	RoomInputState _inputState;
+	InputState _inputState;
 	Common::String _pendingRegionName;
 	CheatSystem &_cheats;
 	const RoomViewResources _view;
 	bool _canExitCloseupToParent;
 	RoomPresentationState _presentationState;
 	RoomPendingTransition _pendingTransition;
-	RoomItemInteractions _roomItems;
+	ItemInteractions _roomItems;
 	RoomPresentation _presentation;
-	RoomInteractionProcessor _interactionProcessor;
+	Interaction _interactionProcessor;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/scene_entities.cpp b/engines/harvester/scene_entities.cpp
index 1ba485826cc..0c39ba1fdd8 100644
--- a/engines/harvester/scene_entities.cpp
+++ b/engines/harvester/scene_entities.cpp
@@ -24,7 +24,7 @@
 #include "harvester/detection.h"
 #include "harvester/monster.h"
 #include "harvester/player.h"
-#include "harvester/room_combat.h"
+#include "harvester/combat.h"
 
 namespace Harvester {
 
@@ -57,12 +57,12 @@ static int resolveSceneObjectClass(const RoomSetupState &state,
 		const ObjectRecord &object, const Entity *entity, Script *script) {
 	if (entity)
 		return isBackgroundSceneObject(state, object, *entity)
-			? kRuntimeEntityClassBackground
-			: kRuntimeEntityClassObject;
+			? kEntityClassBackground
+			: kEntityClassObject;
 
 	return isInteractiveSceneHotspot(object, script)
-		? kRuntimeEntityClassRectHotspot
-		: kRuntimeEntityClassDisabledHotspot;
+		? kEntityClassRectHotspot
+		: kEntityClassDisabledHotspot;
 }
 
 static bool sameRect(const Common::Rect &lhs, const Common::Rect &rhs) {
@@ -418,7 +418,7 @@ void SceneEntities::syncCurrentRoomRuntimeState(const Common::Array<RegionRecord
 
 	for (const TimerRecord &baseTimer : script->getTimers()) {
 		Entity *entity = _entityManager->findSceneEntityByName(baseTimer.timerName);
-		if (!entity || entity->getClassId() != kRuntimeEntityClassTimer)
+		if (!entity || entity->getClassId() != kEntityClassTimer)
 			continue;
 
 		const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
@@ -456,7 +456,7 @@ void SceneEntities::syncGlobalTimerEntities(const Common::Array<TimerRecord> &pr
 		}
 
 		Entity *entity = _entityManager->findSceneEntityByName(runtimeTimer->timerName);
-		if (entity && entity->getClassId() != kRuntimeEntityClassTimer)
+		if (entity && entity->getClassId() != kEntityClassTimer)
 			continue;
 
 		if (!entity) {
@@ -525,19 +525,19 @@ bool SceneEntities::isInteractiveSceneHotspot(const ObjectRecord &object) const
 	return script->resolveObjectInspectText(object, inspectText);
 }
 
-RuntimeEntityClass SceneEntities::resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const {
+EntityClass SceneEntities::resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const {
 	if (entity) {
 		return ((!_state.backgroundObjectName.empty() &&
 				object.objectName.equalsIgnoreCase(_state.backgroundObjectName)) ||
 			(object.initialX == 0 && object.initialY == 0 &&
 				entity->getBoundsWidth() == 640 && entity->getBoundsHeight() == 480))
-			? kRuntimeEntityClassBackground
-			: kRuntimeEntityClassObject;
+			? kEntityClassBackground
+			: kEntityClassObject;
 	}
 
 	return isInteractiveSceneHotspot(object)
-		? kRuntimeEntityClassRectHotspot
-		: kRuntimeEntityClassDisabledHotspot;
+		? kEntityClassRectHotspot
+		: kEntityClassDisabledHotspot;
 }
 
 void SceneEntities::spawnSceneObjectEntityFromRecord(const ObjectRecord &object) {
@@ -559,7 +559,7 @@ void SceneEntities::spawnSceneObjectEntityFromRecord(const ObjectRecord &object)
 
 	entity->setClassId(resolveSceneObjectClassLocal(
 		object, entity->hasFrames() ? entity : nullptr));
-	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity->setAnchorMode(kEntityAnchorTopLeft);
 	entity->setZExtent((float)object.zExtent);
 	debugC(3, kDebugRoom, "Harvester: scene object spawned object='%s' sprite='%s' class=0x%x",
 		object.objectName.c_str(), spritePath.c_str(), entity->getClassId());
@@ -584,11 +584,11 @@ void SceneEntities::spawnSceneRegionEntityFromRecord(const RegionRecord &region)
 	if (!entity)
 		return;
 
-	entity->setClassId(kRuntimeEntityClassRectHotspot19);
-	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity->setClassId(kEntityClassRectHotspot19);
+	entity->setAnchorMode(kEntityAnchorTopLeft);
 	entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
 	if (!region.cursorEnabled)
-		entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		entity->setHitTestMode(kEntityHitTestNone);
 	debugC(1, kDebugRoom,
 		"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
 		region.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
@@ -614,7 +614,7 @@ bool SceneEntities::applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc)
 
 	const float renderZ =
 		(float)npc.posZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
-	entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	entity.setAnchorMode(kEntityAnchorTopLeft);
 	entity.setPosition(npc.posX - xOffset - width / 2, npc.posY - height - yOffset, renderZ);
 	return true;
 }
@@ -633,9 +633,9 @@ Entity *SceneEntities::spawnSceneNpcEntityFromRecord(const NpcRecord &npc) {
 	if (!entity)
 		return nullptr;
 
-	entity->setClassId(kRuntimeEntityClassNpc);
+	entity->setClassId(kEntityClassNpc);
 	entity->setZExtent(kNativeNpcMonsterZExtent);
-	entity->setHitTestMode(preservedCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
+	entity->setHitTestMode(preservedCorpse ? kEntityHitTestNone : kEntityHitTestOpaquePixels);
 	entity->setVisible(true);
 	if (!applyRoomNpcPlacement(*entity, npc)) {
 		removeSceneEntityByName(npc.npcName);
@@ -666,9 +666,9 @@ Entity *SceneEntities::spawnSceneMonsterEntityFromRecord(const RoomSetupState &r
 	if (!entity)
 		return nullptr;
 
-	entity->setClassId(kRuntimeEntityClassMonster);
+	entity->setClassId(kEntityClassMonster);
 	entity->setZExtent(kNativeNpcMonsterZExtent);
-	entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	entity->setHitTestMode(kEntityHitTestNone);
 	entity->setVisible(monster.visible);
 	Monster::applyAnimation(*entity, monster);
 	if (!applyRoomActorPlacement(roomState, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
@@ -709,11 +709,11 @@ void SceneEntities::populateRoomRegions() {
 			continue;
 		}
 
-		entity->setClassId(kRuntimeEntityClassRectHotspot19);
-		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+		entity->setClassId(kEntityClassRectHotspot19);
+		entity->setAnchorMode(kEntityAnchorTopLeft);
 		entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
 		if (!region.cursorEnabled)
-			entity->setHitTestMode(kRuntimeEntityHitTestNone);
+			entity->setHitTestMode(kEntityHitTestNone);
 		debugC(1, kDebugRoom,
 			"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
 			_state.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
@@ -753,7 +753,7 @@ void SceneEntities::populateRoomObjects(const Common::Array<ObjectRecord> &drawa
 
 		entity->setClassId(resolveSceneObjectClass(
 			_state, object, entity->hasFrames() ? entity : nullptr, _script));
-		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+		entity->setAnchorMode(kEntityAnchorTopLeft);
 		entity->setZExtent((float)object.zExtent);
 		const Common::Rect entityRect = entity->getScreenRect();
 		debugC(1, kDebugRoom,
@@ -779,13 +779,13 @@ void SceneEntities::populateRoomNpcs() {
 			continue;
 		}
 
-		entity->setClassId(kRuntimeEntityClassNpc);
+		entity->setClassId(kEntityClassNpc);
 		entity->setZExtent(kNativeNpcMonsterZExtent);
 		const bool isCorpse = !npc.active &&
 			npc.runtimeSpawned &&
 			npc.deathDamageType != 0 &&
 			npc.runtimeState >= 0;
-		entity->setHitTestMode(isCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
+		entity->setHitTestMode(isCorpse ? kEntityHitTestNone : kEntityHitTestOpaquePixels);
 		// Native spawn positions frame 0 before selecting ambient or corpse frame banks.
 		if (!applyRoomNpcPlacement(*entity, npc)) {
 			debug(1, "Harvester: unable to apply room npc placement for '%s'",
@@ -826,9 +826,9 @@ void SceneEntities::populateRoomMonsters() {
 			continue;
 		}
 
-		entity->setClassId(kRuntimeEntityClassMonster);
+		entity->setClassId(kEntityClassMonster);
 		entity->setZExtent(kNativeNpcMonsterZExtent);
-		entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		entity->setHitTestMode(kEntityHitTestNone);
 		entity->setVisible(monster.visible);
 		// Native restores a saved monster corpse frame before set_entity_screen_position().
 		Monster::applyAnimation(*entity, monster);
diff --git a/engines/harvester/scene_entities.h b/engines/harvester/scene_entities.h
index fb451a6c12a..7265a47da3a 100644
--- a/engines/harvester/scene_entities.h
+++ b/engines/harvester/scene_entities.h
@@ -23,7 +23,7 @@
 #define HARVESTER_SCENE_ENTITIES_H
 
 #include "harvester/room_support.h"
-#include "harvester/runtime_entity.h"
+#include "harvester/entity.h"
 
 namespace Harvester {
 
@@ -57,7 +57,7 @@ public:
 private:
 	bool isTimerMaterializedInCurrentRoom(const Common::String &timerName) const;
 	bool isInteractiveSceneHotspot(const ObjectRecord &object) const;
-	RuntimeEntityClass resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const;
+	EntityClass resolveSceneObjectClassLocal(const ObjectRecord &object, const Entity *entity) const;
 	static Common::Rect getSceneRegionBounds(const RegionRecord &region);
 	static bool applyRoomNpcPlacement(Entity &entity, const NpcRecord &npc);
 


Commit: 939d4f10ea0751dba6f1992c4efdd0f8cf4719dd
    https://github.com/scummvm/scummvm/commit/939d4f10ea0751dba6f1992c4efdd0f8cf4719dd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: Update walk direction/depth after animation

Fixes #17328

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/player.cpp
    engines/harvester/room_support.h


diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 229a080de6d..66043207e8f 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -596,6 +596,10 @@ static int computePlayerFrameLeftX(const RoomPlayerState &playerState) {
 	return playerState.entity ? playerState.entity->getScreenRect().left : playerState.centerX;
 }
 
+static float computePlayerRenderZ(const RoomPlayerState &playerState) {
+	return playerState.entity ? playerState.entity->getZ() : playerState.z;
+}
+
 static void resetPlayerMoveTargetProgress(RoomPlayerState &playerState) {
 	playerState.moveTargetXReached = false;
 	playerState.moveTargetZReached = false;
@@ -603,7 +607,7 @@ static void resetPlayerMoveTargetProgress(RoomPlayerState &playerState) {
 
 static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
 		const RoomPlayerState &playerState, int regionFacing) {
-	const float slack = Player::computeDepthScale(state, playerState.z) *
+	const float slack = Player::computeDepthScale(state, computePlayerRenderZ(playerState)) *
 		kRoomPlayerHorizontalTargetSlackBase;
 	const float frameLeftX = (float)computePlayerFrameLeftX(playerState);
 	if (frameLeftX < (float)playerState.targetX - slack || regionFacing == 2)
@@ -615,9 +619,10 @@ static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
 }
 
 static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState, int regionFacing) {
-	if (playerState.targetZ < playerState.z - kRoomPlayerDepthTargetSlack || regionFacing == 0)
+	const float z = computePlayerRenderZ(playerState);
+	if (playerState.targetZ < z - kRoomPlayerDepthTargetSlack || regionFacing == 0)
 		return 1;
-	if (playerState.z + kRoomPlayerDepthTargetSlack < playerState.targetZ || regionFacing == 3)
+	if (z + kRoomPlayerDepthTargetSlack < playerState.targetZ || regionFacing == 3)
 		return -1;
 
 	return 0;
@@ -684,14 +689,6 @@ static bool consumePlayerMovementTick(RoomPlayerState &playerState) {
 	return true;
 }
 
-static float stepTowardsFloat(float current, float target, float step) {
-	if (step <= 0.0f || fabsf(target - current) <= kRoomDepthCompareEpsilon)
-		return target;
-	if (current < target)
-		return MIN(current + step, target);
-	return MAX(current - step, target);
-}
-
 static bool shouldIgnoreMovementBlocker(const Entity &entity, const Common::String *ignoredBlockerName) {
 	return ignoredBlockerName && !ignoredBlockerName->empty() &&
 		entity.getName().equalsIgnoreCase(*ignoredBlockerName);
@@ -1353,7 +1350,7 @@ int Player::resolveRegionTargetX(const RoomSetupState &state, const RegionRecord
 		targetX -= kRoomRegionTargetXBias;
 
 	const int targetSlack = MAX<int>(1,
-		roundToInt(Player::computeDepthScale(state, playerState.z) *
+		roundToInt(Player::computeDepthScale(state, computePlayerRenderZ(playerState)) *
 			kRoomPlayerHorizontalTargetSlackBase));
 	if (region.desiredFacing == 1)
 		targetX = MIN<int>(targetX, region.right - targetSlack);
@@ -1431,6 +1428,8 @@ bool Player::setIdleAnimation(RoomPlayerState &playerState, int facing) {
 	playerState.facing = facing;
 	playerState.nextMovementTick = 0;
 	playerState.keyboardWalkRequest = -1;
+	playerState.walkHorizontalDirection = 0;
+	playerState.walkDepthDirection = 0;
 	playerState.entity->setAnimationRate(0);
 	playerState.entity->setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
 	playerState.entity->setCurrentFrame(range.idleFrame);
@@ -1742,7 +1741,15 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	// run_harvester_main_loop (0x6dc70) latches the clicked region facing at
 	// 0xc3f0c. update_actor_runtime_state (0x4d750) uses it to keep the matching
 	// target component active inside the usual X/Z stopping tolerance.
-	updatePlayerMoveTargetProgress(state, playerState, regionFacing);
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
+	const int currentFrame = playerState.entity->getCurrentFrame();
+	const bool walking = playerState.entity->getAnimationRate() == kRoomPlayerWalkAnimationRate &&
+		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
+	// update_actor_runtime_state (0x4d750) evaluates waypoints only at a bank
+	// endpoint. Between endpoints the current walk state retains frame_z_step.
+	const bool updateTarget = !walking || currentFrame == range.walkLastFrame;
+	if (updateTarget)
+		updatePlayerMoveTargetProgress(state, playerState, regionFacing);
 	if (hasPlayerReachedMoveTarget(playerState)) {
 		playerState.hasMoveTarget = false;
 		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
@@ -1750,9 +1757,9 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 
 	const int previousCenterX = playerState.centerX;
 	const int previousBottomY = playerState.bottomY;
-	int horizontalDirection = playerState.moveTargetXReached
-		? 0
-		: resolveMoveTargetHorizontalDirection(state, playerState, regionFacing);
+	int horizontalDirection = updateTarget
+		? (playerState.moveTargetXReached ? 0 : resolveMoveTargetHorizontalDirection(state, playerState, regionFacing))
+		: playerState.walkHorizontalDirection;
 	if (horizontalDirection != 0 && isHorizontalDirectionBlockedByHistory(playerState, horizontalDirection)) {
 		if (engine.isPathfindingDebugEnabled()) {
 			debugC(1, kDebugPathfinding,
@@ -1765,9 +1772,9 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 		}
 		horizontalDirection = 0;
 	}
-	int depthDirection = !playerState.moveTargetZReached
-		? resolveMoveTargetDepthDirection(playerState, regionFacing)
-		: 0;
+	int depthDirection = updateTarget
+		? (playerState.moveTargetZReached ? 0 : resolveMoveTargetDepthDirection(playerState, regionFacing))
+		: playerState.walkDepthDirection;
 	if (depthDirection != 0 && isDepthDirectionBlockedByHistory(playerState, depthDirection)) {
 		if (engine.isPathfindingDebugEnabled()) {
 			debugC(1, kDebugPathfinding,
@@ -1801,7 +1808,7 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	int candidateBottomY = playerState.bottomY;
 	float candidateZ = playerState.z;
 	if (horizontalDirection != 0) {
-		const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+		const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
 		candidateCenterX = clampPlayerCenterXToNativeBounds(
 			playerState, playerState.centerX + horizontalDirection * horizontalStep);
 		candidateZ = clampRoomDepth(state, playerState.z - depthDirection);
@@ -1813,9 +1820,7 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 			: playerState.z <= (float)state.roomMinZ;
 		if (!depthBoundaryReached) {
 			candidateBottomY = playerState.bottomY + depthDirection * verticalStep;
-			candidateZ = regionFacing == 0 || regionFacing == 3
-				? clampRoomDepth(state, playerState.z - depthDirection * depthStep)
-				: stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
+			candidateZ = clampRoomDepth(state, playerState.z - depthDirection * depthStep);
 		}
 	}
 
@@ -1865,7 +1870,16 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	}
 
 	const int actualFacing = desiredFacing;
+	playerState.walkHorizontalDirection = horizontalDirection;
+	playerState.walkDepthDirection = depthDirection;
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
+	if (walking && currentFrame == range.walkLastFrame) {
+		playerState.entity->setCurrentFrame(resolvePlayerAnimationRange(actualFacing).walkFirstFrame);
+		debugC(2, kDebugPlayer,
+			"Harvester: player target walk bank restart facing=%d frame=%d render_z=%.2f direction=(%d,%d)",
+			actualFacing, playerState.entity->getCurrentFrame(), (double)computePlayerRenderZ(playerState),
+			horizontalDirection, depthDirection);
+	}
 	syncAnimationPlacement(playerState);
 	refreshPlayerCurrentBlocker(engine, sceneObjects, sceneAnimations, playerState);
 	if (engine.isPathfindingDebugEnabled()) {
@@ -1875,11 +1889,6 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 			playerState.centerX, playerState.bottomY, (double)playerState.z,
 			actualFacing);
 	}
-	updatePlayerMoveTargetProgress(state, playerState, regionFacing);
-	if (hasPlayerReachedMoveTarget(playerState)) {
-		playerState.hasMoveTarget = false;
-		(void)setIdleAnimation(playerState, actualFacing);
-	}
 	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugPlayer,
 		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d moved=%d",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
@@ -1929,7 +1938,7 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 	const int previousCenterX = playerState.centerX;
 	const int previousBottomY = playerState.bottomY;
 	const float previousZ = playerState.z;
-	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+	const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
 	const int verticalStep = computeRoomPlayerVerticalScreenStep();
 	const float depthStep = computeRoomPlayerDepthStep(state);
 	int candidateCenterX = clampPlayerCenterXToNativeBounds(
@@ -1985,6 +1994,8 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 
 	const int actualFacing = resolveFacingFromRoomMovement(
 		previousBaseX, previousBaseY, playerState.entity->getX(), playerState.entity->getY());
+	playerState.walkHorizontalDirection = actualFacing == 1 ? -1 : actualFacing == 2 ? 1 : 0;
+	playerState.walkDepthDirection = actualFacing == 3 ? -1 : actualFacing == 0 ? 1 : 0;
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
 	if (keyboardWalking && currentFrame == range.walkLastFrame) {
 		playerState.keyboardWalkRequest = -1;
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 1908dc848e5..8a3cb38e265 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -58,11 +58,11 @@ struct RoomPlayerState {
 	int bottomY = 0;
 	float z = 0.0f;
 	int facing = -1;
-	// Mirrors native RenderEntityRuntime.player_combat_loadout_id (+0x11bc).
 	int combatLoadout = 0;
 	uint32 nextMovementTick = 0;
-	// Held input queues the next walk bank; release preserves that request.
 	int keyboardWalkRequest = -1;
+	int walkHorizontalDirection = 0;
+	int walkDepthDirection = 0;
 	bool hasMoveTarget = false;
 	int targetX = 0;
 	int targetBottomY = 0;
@@ -70,9 +70,7 @@ struct RoomPlayerState {
 	bool moveTargetXReached = false;
 	bool moveTargetZReached = false;
 	Common::String currentBlockerName;
-	// Native RenderEntityRuntime keeps these directional blocker-history
-	// pointers at +0x108c..+0x1098 and clears them when no current
-	// opaque blocker overlaps the actor.
+
 	Common::String upwardBlockerHistory;
 	Common::String downwardBlockerHistory;
 	Common::String leftwardBlockerHistory;
@@ -91,8 +89,7 @@ struct RoomPlayerState {
 	bool attackSoundPlayed = false;
 	int attackSoundPlaybackFrame = -1;
 	bool attackContactResolved = false;
-	// Native room combat tracks the live chase/attack focus through
-	// RenderEntityRuntime.combat_target_entity (+0x11a4).
+
 	Common::String attackTargetName;
 	int attackTargetClassId = -1;
 	bool hitActive = false;


Commit: 11452159f5c120846b919a2da4109f2b7970c30f
    https://github.com/scummvm/scummvm/commit/11452159f5c120846b919a2da4109f2b7970c30f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:14-04:00

Commit Message:
HARVESTER: fix player bounds checking with room exits

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/entity.cpp
    engines/harvester/flow.cpp
    engines/harvester/player.cpp
    engines/harvester/scene.cpp
    engines/harvester/scene_entities.cpp


diff --git a/engines/harvester/entity.cpp b/engines/harvester/entity.cpp
index 52ef2385650..bd471b1d939 100644
--- a/engines/harvester/entity.cpp
+++ b/engines/harvester/entity.cpp
@@ -44,12 +44,8 @@ static const uint32 kDosPitInputFrequency = 1193180;
 static const uint32 kDosPitTimerDivisor = 65536;
 static const byte kTransparentPaletteIndex = 0;
 
-static int roundToInt(float value) {
-	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
-}
-
 static int scaleDimension(uint32 value, float scale) {
-	return MAX<int>(roundToInt((float)value * scale), 1);
+	return MAX<int>((int)((float)value * scale), 1);
 }
 
 static void scaleIndexedBitmapNearest(const IndexedBitmap &source, IndexedBitmap &dest,
@@ -359,7 +355,7 @@ void Entity::setDepthScale(float scale) {
 	if (_frames.empty())
 		return;
 
-	const float newScale = scale > 0.0f ? scale : 1.0f;
+	const float newScale = scale >= 0.0f ? scale : 1.0f;
 	if (fabsf(_depthScale - newScale) < 0.0001f)
 		return;
 
@@ -594,9 +590,9 @@ bool Entity::overlapsEntity(const Entity &other) const {
 		return false;
 	}
 
-	const float thisZMax = _z + _zExtent;
-	const float otherZMax = other._z + other._zExtent;
-	if (thisZMax < other._z || otherZMax < _z)
+	const int thisZMax = (int)(_z + _zExtent);
+	const int otherZMax = (int)(other._z + other._zExtent);
+	if (thisZMax < (int)other._z || otherZMax < (int)_z)
 		return false;
 
 	const Common::Rect thisRect = getFrameRect();
@@ -756,7 +752,7 @@ void Entity::scaleCurrentFrame() {
 			scaleDimension(source.height, _depthScale));
 		// Native depth scaling preserves the authored horizontal frame offset.
 		scaled.xOffset = source.xOffset;
-		scaled.yOffset = roundToInt((float)source.yOffset * _depthScale);
+		scaled.yOffset = (int)((float)source.yOffset * _depthScale);
 	}
 	_frameDepthScales[frameIndex] = _depthScale;
 	debugC(3, kDebugPlayer,
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index a90f0af6792..b43355f7873 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -702,7 +702,7 @@ Common::Rect getRoomObjectHotspotBounds(const ObjectRecord &object) {
 
 static Common::Rect getRegionBounds(const RegionRecord &region) {
 	if (region.right > region.left && region.bottom > region.top)
-		return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
+		return Common::Rect(region.left, region.top, region.right, region.bottom);
 
 	return Common::Rect();
 }
@@ -1103,8 +1103,8 @@ bool doesPlayerOverlapRegion(const Entity &playerEntity, const RegionRecord &reg
 		return false;
 	}
 
-	const float playerMaxZ = playerEntity.getZ() + playerEntity.getZExtent();
-	return playerMaxZ >= (float)region.minZ && (float)region.maxZ >= playerEntity.getZ();
+	const int playerMaxZ = (int)(playerEntity.getZ() + playerEntity.getZExtent());
+	return playerMaxZ >= region.minZ && region.maxZ >= (int)playerEntity.getZ();
 }
 
 RoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const RoomSetupState &state,
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 66043207e8f..8e83244f67f 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -578,7 +578,7 @@ static int resolveFacingFromRoomMovement(
 }
 
 static int computeRoomPlayerHorizontalStep(const RoomSetupState &state, float z) {
-	return MAX<int>(1, roundToInt(Player::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
+	return MAX<int>(1, (int)(Player::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
 }
 
 static float computeRoomPlayerDepthStep(const RoomSetupState &state) {
@@ -836,40 +836,6 @@ static bool tryApplyPlayerMovement(HarvesterEngine &engine, const RoomSetupState
 	return true;
 }
 
-static bool tryApplyPlayerMovementWithFallbacks(HarvesterEngine &engine, const RoomSetupState &state,
-		const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState,
-		int previousCenterX, int previousBottomY, float previousZ,
-		int candidateCenterX, int candidateBottomY, float candidateZ) {
-	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, candidateBottomY, candidateZ)) {
-		return true;
-	}
-	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, previousBottomY, candidateZ)) {
-		return true;
-	}
-	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, candidateBottomY, previousZ)) {
-		return true;
-	}
-	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, previousBottomY, previousZ)) {
-		return true;
-	}
-	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, previousCenterX, candidateBottomY, candidateZ)) {
-		return true;
-	}
-	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, previousCenterX, candidateBottomY, previousZ)) {
-		return true;
-	}
-	return tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-		playerState, previousCenterX, previousBottomY, candidateZ);
-}
-
 static bool isPathfindingDebugEnabled() {
 	return g_engine && g_engine->isPathfindingDebugEnabled();
 }
@@ -996,6 +962,37 @@ static bool recordNativePrimaryWalkBlockerHistory(const RoomSetupState &state,
 	return false;
 }
 
+static bool tryApplyPlayerWalkMovement(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations, RoomPlayerState &playerState,
+		int candidateCenterX, int candidateBottomY, float candidateZ,
+		int horizontalDirection, int depthDirection, const Entity **blockerOut = nullptr) {
+	const Entity *blocker = findPlayerRoomBlocker(engine, sceneObjects, sceneAnimations,
+		*playerState.entity, nullptr);
+	setPlayerCurrentBlocker(playerState, blocker);
+	if (blockerOut)
+		*blockerOut = nullptr;
+	if (blocker && recordNativePrimaryWalkBlockerHistory(state, playerState, *blocker,
+			horizontalDirection, depthDirection)) {
+		if (blockerOut)
+			*blockerOut = blocker;
+		return false;
+	}
+
+	if (candidateCenterX == playerState.centerX &&
+			candidateBottomY == playerState.bottomY &&
+			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon)
+		return false;
+
+	// tick_entity_visual_state (0x4cae0) records overlap after placement.
+	// Region activation gets the resulting frame before the next blocker response.
+	applyPlayerMovementPlacement(state, playerState, candidateCenterX, candidateBottomY, candidateZ);
+	playerState.z = candidateZ;
+	Player::syncAnimationPlacement(playerState);
+	reinsertPlayerSceneEntity(engine, playerState);
+	return true;
+}
+
 static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &playerState,
 		int targetX, float targetZ, int targetBottomY) {
 	playerState.hasMoveTarget = true;
@@ -1172,7 +1169,7 @@ float Player::computeDepthScale(const RoomSetupState &state, float z) {
 	float scale = 1.0f;
 	if (state.roomPerspectiveScale != 0.0f) {
 		scale -= (z - (float)state.roomFullScaleZ) * state.roomPerspectiveScale;
-		if (scale <= 0.0f)
+		if (scale < 0.0f)
 			scale = 1.0f;
 	}
 
@@ -1340,7 +1337,7 @@ bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetup
 
 int Player::resolveRegionTargetX(const RoomSetupState &state, const RegionRecord &region,
 		const RoomPlayerState &playerState) {
-	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
+	const Common::Rect bounds(region.left, region.top, region.right, region.bottom);
 	int targetX = bounds.left + bounds.width() / 2;
 	if (!playerState.entity)
 		return CLIP<int>(targetX, 0, 639);
@@ -1349,13 +1346,6 @@ int Player::resolveRegionTargetX(const RoomSetupState &state, const RegionRecord
 	if (targetX + kRoomRegionTargetXBias < frameLeftX)
 		targetX -= kRoomRegionTargetXBias;
 
-	const int targetSlack = MAX<int>(1,
-		roundToInt(Player::computeDepthScale(state, computePlayerRenderZ(playerState)) *
-			kRoomPlayerHorizontalTargetSlackBase));
-	if (region.desiredFacing == 1)
-		targetX = MIN<int>(targetX, region.right - targetSlack);
-	else if (region.desiredFacing == 2)
-		targetX = MAX<int>(targetX, region.left + targetSlack);
 	return CLIP<int>(targetX, 0, 639);
 }
 
@@ -1835,9 +1825,9 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	}
 
 	const Entity *blocker = nullptr;
-	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+	bool moved = tryApplyPlayerWalkMovement(engine, state, sceneObjects, sceneAnimations,
 		playerState, candidateCenterX, candidateBottomY, candidateZ,
-		nullptr, &blocker);
+		horizontalDirection, depthDirection, &blocker);
 	if (!moved) {
 		if (engine.isPathfindingDebugEnabled()) {
 			const Common::Rect blockerRect = blocker ? blocker->getScreenRect() : Common::Rect();
@@ -1851,8 +1841,7 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 				blocker ? (double)blocker->getZExtent() : 0.0);
 		}
 		setPlayerCurrentBlocker(playerState, blocker);
-		if (blocker && recordNativePrimaryWalkBlockerHistory(state, playerState, *blocker,
-				horizontalDirection, depthDirection)) {
+		if (blocker) {
 			return setIdleAnimation(playerState,
 				resolveFacingFromMoveDirection(horizontalDirection, depthDirection,
 					playerState.facing));
@@ -1935,9 +1924,6 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 
 	const int previousBaseX = playerState.entity->getX();
 	const int previousBaseY = playerState.entity->getY();
-	const int previousCenterX = playerState.centerX;
-	const int previousBottomY = playerState.bottomY;
-	const float previousZ = playerState.z;
 	const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
 	const int verticalStep = computeRoomPlayerVerticalScreenStep();
 	const float depthStep = computeRoomPlayerDepthStep(state);
@@ -1986,9 +1972,9 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 		return false;
 
 	playerState.hasMoveTarget = false;
-	bool moved = tryApplyPlayerMovementWithFallbacks(engine, state, sceneObjects, sceneAnimations,
-		playerState, previousCenterX, previousBottomY, previousZ,
-		candidateCenterX, candidateBottomY, candidateZ);
+	bool moved = tryApplyPlayerWalkMovement(engine, state, sceneObjects, sceneAnimations,
+		playerState, candidateCenterX, candidateBottomY, candidateZ,
+		horizontalInput, verticalInput);
 	if (!moved)
 		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
 
diff --git a/engines/harvester/scene.cpp b/engines/harvester/scene.cpp
index 7829e3da046..96e14515427 100644
--- a/engines/harvester/scene.cpp
+++ b/engines/harvester/scene.cpp
@@ -813,10 +813,7 @@ InputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 		}
 
 		const PlayerControlState control = preparePlayerControlState();
-		// Native run_harvester_main_loop checks player/region overlap before
-		// update_actor_runtime_state. This is significant when a wide walk frame
-		// reaches a narrow exit: the actor update may otherwise satisfy the X
-		// target and replace that frame with a narrower idle frame first.
+		advanceRuntimeEntities(needsRedraw);
 		Common::Error pendingRegionError =
 			tryActivatePendingRegion(needsRedraw);
 		if (pendingRegionError.getCode() != Common::kNoError)
@@ -841,9 +838,6 @@ InputResult Scene::runRoomFrames(const Common::String &currentRoomTarget) {
 			}
 		}
 
-		// At 60 Hz, consume animation advances before the next screen update so
-		// a walk frame and its movement step are presented together.
-		advanceRuntimeEntities(needsRedraw);
 		Common::Error combatError = Common::kNoError;
 		if (!control.paused) {
 			combatError = resolvePlayerAttackContact(needsRedraw);
diff --git a/engines/harvester/scene_entities.cpp b/engines/harvester/scene_entities.cpp
index 0c39ba1fdd8..fba500d6c03 100644
--- a/engines/harvester/scene_entities.cpp
+++ b/engines/harvester/scene_entities.cpp
@@ -567,7 +567,7 @@ void SceneEntities::spawnSceneObjectEntityFromRecord(const ObjectRecord &object)
 
 Common::Rect SceneEntities::getSceneRegionBounds(const RegionRecord &region) {
 	if (region.right > region.left && region.bottom > region.top)
-		return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
+		return Common::Rect(region.left, region.top, region.right, region.bottom);
 	return Common::Rect();
 }
 


Commit: 0cca8a4c2cb38b1b028d66560f6f70c0a1edf103
    https://github.com/scummvm/scummvm/commit/0cca8a4c2cb38b1b028d66560f6f70c0a1edf103
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:15-04:00

Commit Message:
HARVESTER: play walking blocked sounds

Fixes #17331

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/player.cpp


diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 8e83244f67f..8d838aa2f48 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -962,6 +962,28 @@ static bool recordNativePrimaryWalkBlockerHistory(const RoomSetupState &state,
 	return false;
 }
 
+static void playPlayerBlockedWalkSounds(HarvesterEngine &engine, const RoomSetupState &state,
+		const RoomPlayerState &playerState, const Entity &blocker) {
+	const Entity &entity = *playerState.entity;
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
+	if (entity.getAnimationRate() != kRoomPlayerWalkAnimationRate ||
+			entity.getCurrentFrame() < range.walkFirstFrame ||
+			entity.getCurrentFrame() > range.walkLastFrame)
+		return;
+
+	const int frameLeftX = computePlayerFrameLeftX(playerState);
+	if ((playerState.facing == 1 && frameLeftX < 30) ||
+			(playerState.facing == 2 && frameLeftX >= 611) ||
+			(playerState.facing == 0 && entity.getZ() <= state.roomMinZ) ||
+			(playerState.facing == 3 && entity.getZ() >= state.roomMaxZ))
+		return;
+
+	(void)engine.playSound("2:/SOUND/EFFECTS/FOOTLFT1.WAV");
+	(void)engine.playSound("2:/SOUND/EFFECTS/FOOTRHT1.WAV");
+	debugC(2, kDebugPlayer, "Harvester: player blocked walk sound blocker='%s' facing=%d",
+		blocker.getName().c_str(), playerState.facing);
+}
+
 static bool tryApplyPlayerWalkMovement(HarvesterEngine &engine, const RoomSetupState &state,
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations, RoomPlayerState &playerState,
@@ -974,6 +996,7 @@ static bool tryApplyPlayerWalkMovement(HarvesterEngine &engine, const RoomSetupS
 		*blockerOut = nullptr;
 	if (blocker && recordNativePrimaryWalkBlockerHistory(state, playerState, *blocker,
 			horizontalDirection, depthDirection)) {
+		playPlayerBlockedWalkSounds(engine, state, playerState, *blocker);
 		if (blockerOut)
 			*blockerOut = blocker;
 		return false;


Commit: 0b24b9b5b0caa395a68bde42e7508a3544d34533
    https://github.com/scummvm/scummvm/commit/0b24b9b5b0caa395a68bde42e7508a3544d34533
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-13T17:39:15-04:00

Commit Message:
HARVESTER: cleanup and log verbosity adjustments

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/art.cpp
    engines/harvester/combat.cpp
    engines/harvester/combat.h
    engines/harvester/dialogue.cpp
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/input.cpp
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/scene.cpp
    engines/harvester/script.cpp


diff --git a/engines/harvester/art.cpp b/engines/harvester/art.cpp
index 1ed1a09eeef..aab5aed1950 100644
--- a/engines/harvester/art.cpp
+++ b/engines/harvester/art.cpp
@@ -60,7 +60,7 @@ static void logPaletteSummary(const char *label, const Common::String &path, con
 	byte maxValue = 0;
 	getPaletteByteRange(palette, minValue, maxValue);
 
-	debugC(1, kDebugGeneral,
+	debugC(3, kDebugGeneral,
 		"Harvester: %s '%s' bytes=%u range=[%u,%u] hash=%08x idx0=(%u,%u,%u) idx1=(%u,%u,%u) idx127=(%u,%u,%u) idx255=(%u,%u,%u)",
 		label, path.c_str(), 256U * 3U, minValue, maxValue, hashPalette(palette),
 		palette[0], palette[1], palette[2],
diff --git a/engines/harvester/combat.cpp b/engines/harvester/combat.cpp
index 45cbbbf6bb2..c3b20c1ad67 100644
--- a/engines/harvester/combat.cpp
+++ b/engines/harvester/combat.cpp
@@ -1497,6 +1497,9 @@ Common::Error Combat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetup
 		}
 
 		const bool moved = monster.posX != previousX || monster.posY != previousY || monster.posZ != previousZ;
+		const int stalledDirections = moved ? 0 : ((attemptedMoveX ? 1 : 0) | (attemptedMoveZ ? 2 : 0));
+		const bool stallChanged = combatState.loggedPursuitStallDirections != stalledDirections;
+		combatState.loggedPursuitStallDirections = stalledDirections;
 		if (moved) {
 			const int movedX = monster.posX - previousX;
 			const int movedY = monster.posY - previousY;
@@ -1508,7 +1511,7 @@ Common::Error Combat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetup
 			if (monster.posZ != previousZ)
 				entityManager->reinsertSceneEntity(entity);
 			(void)script.syncRuntimeMonsterRecord(monster);
-			debugC(1, kDebugCombat,
+			debugC(3, kDebugCombat,
 				"Harvester: combat monster chase move monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d from=(%d,%d,z=%d) to=(%d,%d,z=%d) facing=%d",
 				monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
 				previousX, previousY, previousZ, monster.posX, monster.posY, monster.posZ, monster.facing);
@@ -1517,8 +1520,8 @@ Common::Error Combat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetup
 			continue;
 		}
 
-		if (attemptedMoveX || attemptedMoveZ) {
-			debugC(1, kDebugCombat,
+		if (stalledDirections != 0 && stallChanged) {
+			debugC(2, kDebugCombat,
 				"Harvester: combat monster chase stalled monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d pos=(%d,%d,z=%d) attempted_x=%d attempted_z=%d",
 				monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
 				monster.posX, monster.posY, monster.posZ, attemptedMoveX, attemptedMoveZ);
@@ -1558,6 +1561,7 @@ Common::Error Combat::updateRoomMonsterCombat(HarvesterEngine &engine, RoomSetup
 		combatState.attackContactResolved = false;
 		combatState.attackTargetName = playerState.entity->getName();
 		combatState.attackCooldownSeedTick = now + engine.getRandomNumber(99);
+		combatState.loggedPursuitStallDirections = 0;
 		monster.facing = range.resumeFacing;
 		entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
 		entity->setAnimationRate(kNativeMonsterAttackAnimationRate);
diff --git a/engines/harvester/combat.h b/engines/harvester/combat.h
index 84a2e9bf6f3..cb04d5f37ea 100644
--- a/engines/harvester/combat.h
+++ b/engines/harvester/combat.h
@@ -110,6 +110,7 @@ struct MonsterCombatState {
 	int deathFirstFrame = -1;
 	int deathLastFrame = -1;
 	int deathDamageType = 0;
+	int loggedPursuitStallDirections = 0;
 	bool allowInitialPursuitStep = true;
 	uint32 nextMovementTick = 0;
 	uint32 attackCooldownSeedTick = 0;
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 259240bc17f..92e7c579581 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -384,10 +384,10 @@ static void splitDialogueMenuLine(const Common::String &line, Common::Array<Comm
 static void logDialogueMenuItems(const char *label, int sourceLineIndex,
 		const Common::String &rawLine, const Common::Array<Common::String> &items) {
 	if (sourceLineIndex >= 0) {
-		debugC(1, kDebugDialogue, "Harvester: %s source line 0x%x (%d) raw='%s'",
+		debugC(3, kDebugDialogue, "Harvester: %s source line 0x%x (%d) raw='%s'",
 			label, sourceLineIndex, sourceLineIndex + 1, rawLine.c_str());
 	} else {
-		debugC(1, kDebugDialogue, "Harvester: %s source raw='%s'", label, rawLine.c_str());
+		debugC(3, kDebugDialogue, "Harvester: %s source raw='%s'", label, rawLine.c_str());
 	}
 
 	if (items.empty()) {
@@ -396,7 +396,7 @@ static void logDialogueMenuItems(const char *label, int sourceLineIndex,
 	}
 
 	for (uint i = 0; i < items.size(); ++i)
-		debugC(1, kDebugDialogue, "Harvester: %s item[%u]='%s'", label, i + 1, items[i].c_str());
+		debugC(3, kDebugDialogue, "Harvester: %s item[%u]='%s'", label, i + 1, items[i].c_str());
 }
 
 static Common::String buildDialogueHeadId(const Common::String &speakerId, int headVariant) {
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index b43355f7873..2b8b985c9b6 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -575,19 +575,8 @@ static uint32 hashPalette(const byte *palette) {
 }
 
 void logScenePaletteSummary(const char *label, const SceneState &scene, float brightness) {
-	const byte *palette = scene.palette;
-	byte minValue = 0;
-	byte maxValue = 0;
-	getPaletteByteRange(palette, minValue, maxValue);
-
-	debugC(1, kDebugRoom,
-		"Harvester: %s room='%s' palette='%s' bytes=%u range=[%u,%u] brightness=%.2f hash=%08x idx0=(%u,%u,%u) idx1=(%u,%u,%u) idx127=(%u,%u,%u) idx255=(%u,%u,%u)",
-		label, scene.state.roomName.c_str(), scene.state.palettePath.c_str(), 256U * 3U, minValue, maxValue,
-		(double)brightness, hashPalette(palette),
-		palette[0], palette[1], palette[2],
-		palette[3], palette[4], palette[5],
-		palette[127 * 3], palette[127 * 3 + 1], palette[127 * 3 + 2],
-		palette[255 * 3], palette[255 * 3 + 1], palette[255 * 3 + 2]);
+	debugC(2, kDebugRoom, "Harvester: %s room='%s' palette='%s' brightness=%.2f",
+		label, scene.state.roomName.c_str(), scene.state.palettePath.c_str(), (double)brightness);
 }
 
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
@@ -684,10 +673,12 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	byte minValue = 0;
 	byte maxValue = 0;
 	getPaletteByteRange(dest, minValue, maxValue);
-	debugC(1, kDebugRoom,
-		"Harvester: decoded room palette '%s' bytes=%u range=[%u,%u] idx0=(%u,%u,%u) idx255=(%u,%u,%u)",
-		path.c_str(), (uint)data.size(), minValue, maxValue,
+	debugC(3, kDebugRoom,
+		"Harvester: decoded room palette '%s' bytes=%u range=[%u,%u] hash=%08x idx0=(%u,%u,%u) idx1=(%u,%u,%u) idx127=(%u,%u,%u) idx255=(%u,%u,%u)",
+		path.c_str(), (uint)data.size(), minValue, maxValue, hashPalette(dest),
 		dest[0], dest[1], dest[2],
+		dest[3], dest[4], dest[5],
+		dest[127 * 3], dest[127 * 3 + 1], dest[127 * 3 + 2],
 		dest[255 * 3], dest[255 * 3 + 1], dest[255 * 3 + 2]);
 
 	return true;
@@ -731,7 +722,7 @@ static void logSceneObjectSelection(const char *decision, const char *source, co
 		const Common::String &detail = Common::String()) {
 	const Common::Rect hotspotBounds = getRoomObjectHotspotBounds(object);
 	const Common::String resolvedSpritePath = resolveSceneObjectSpritePath(object);
-	debugC(1, kDebugScene,
+	debugC(3, kDebugScene,
 		"Harvester: scene object %s source='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' resolved='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s' detail='%s'",
 		decision, source, object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
 		object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
@@ -789,12 +780,11 @@ bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resour
 	}
 	scene.sceneRegions = state.roomRegions;
 
-	debugC(1, kDebugRoom,
-		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u npcs=%u monsters=%u regions=%u",
-		state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str(), (double)state.paletteBrightness,
-		(uint)state.roomObjects.size(), (uint)state.activeObjects.size(), (uint)scene.sceneObjects.size(),
-		(uint)state.roomAnimations.size(), (uint)scene.sceneAnimations.size(),
-		(uint)state.roomNpcs.size(), (uint)state.roomMonsters.size(), (uint)scene.sceneRegions.size());
+	debugC(2, kDebugRoom,
+		"Harvester: room scene loaded room='%s' objects=%u anims=%u npcs=%u monsters=%u timers=%u regions=%u",
+		state.roomName.c_str(), (uint)scene.sceneObjects.size(), (uint)scene.sceneAnimations.size(),
+		(uint)state.roomNpcs.size(), (uint)state.roomMonsters.size(),
+		(uint)state.roomTimers.size(), (uint)scene.sceneRegions.size());
 
 	return true;
 }
@@ -1432,7 +1422,7 @@ Common::Error Flow::run() {
 
 	_engine.stopMusic();
 	_engine.stopSound();
-	return runMainMenuStub();
+	return runMainMenu();
 }
 
 bool Flow::loadQuickTips() {
@@ -1743,8 +1733,8 @@ Common::Error Flow::runDemoIntroduction() {
 	return Common::kNoError;
 }
 
-Common::Error Flow::runMainMenuStub() {
-	return _menu.runMainMenuStub(*this);
+Common::Error Flow::runMainMenu() {
+	return _menu.runMainMenu(*this);
 }
 
 Common::Error Flow::showCdChangePrompt(int discNumber) {
@@ -1781,9 +1771,9 @@ Common::Error Flow::showCdChangePrompt(int discNumber) {
 	return Common::kNoError;
 }
 
-Common::Error Flow::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+Common::Error Flow::runRoomMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, bool canSaveGame) {
-	return _menu.runRoomMenuStub(backdrop, palette, paletteBrightness, *this, canSaveGame);
+	return _menu.runRoomMenu(backdrop, palette, paletteBrightness, *this, canSaveGame);
 }
 
 Common::Error Flow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index f5d2157cfc8..445e629dd45 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -71,8 +71,8 @@ private:
 	Common::Error runQuickTips();
 	Common::Error runDemoIntroduction();
 	Common::Error runDemoEnding();
-	Common::Error runMainMenuStub();
-	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+	Common::Error runMainMenu();
+	Common::Error runRoomMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, bool canSaveGame);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName);
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index d8373fad21b..9896cfeecaa 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -328,7 +328,6 @@ Common::Error HarvesterEngine::run() {
 	// The intro FST files play on the narrower startup movie surface.
 	setDisplayMode(320, 200);
 
-	// Set the engine's debugger console
 	setDebugger(new Console());
 
 	if (shouldSkipIntroMoviesForDebug()) {
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 7ffd3941cdc..805a718994a 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -52,16 +52,10 @@ public:
 	Common::Error run() override;
 	bool hasFeature(EngineFeature f) const override;
 
-	/**
-	 * Returns the game Id
-	 */
 	Common::String getGameId() const;
 	Common::Language getLanguage() const;
 	bool isDemo() const;
 
-	/**
-	 * Gets a random number
-	 */
 	uint32 getRandomNumber(uint maxNum) {
 		return _randomSource.getRandomNumber(maxNum);
 	}
@@ -69,10 +63,6 @@ public:
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
 	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override;
 
-	/**
-	 * Uses a serializer to allow implementing savegame
-	 * loading and saving using a single method
-	 */
 	Common::Error syncGame(Common::Serializer &s);
 	Common::Error saveGameStream(Common::WriteStream *stream, bool isAutosave = false) override;
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
diff --git a/engines/harvester/input.cpp b/engines/harvester/input.cpp
index f55c8c14b3b..c4a93f1e195 100644
--- a/engines/harvester/input.cpp
+++ b/engines/harvester/input.cpp
@@ -735,7 +735,7 @@ InputResult Input::handleKeyDown(const Common::KeyState &key, bool &needsRedraw)
 		drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
 		if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
 			return InputResult::leaveRoom(Common::kReadingFailed);
-		Common::Error menuError = _flow.runRoomMenuStub(
+		Common::Error menuError = _flow.runRoomMenu(
 			roomMenuBackdrop, _scene.palette, _scene.targetPaletteBrightness,
 			_scene.state.hasEntrance);
 		if (menuError.getCode() != Common::kNoError)
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 9cbcd4b90b9..0a60adc48f4 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -368,11 +368,6 @@ bool loadMenuTextConfig(HarvesterEngine &engine, MenuTextConfig &config) {
 	loadMenuDisplayValue(menu, "keyword", config.dialogueKeywordLabel);
 	for (uint i = 0; i < config.weekdayLabels.size(); ++i)
 		loadMenuDisplayValue(menu, kMenuWeekdays[i].key, config.weekdayLabels[i]);
-	debugC(2, kDebugGeneral,
-		"Harvester: inventory weekdays monday='%s' tuesday='%s' wednesday='%s' thursday='%s' friday='%s' saturday='%s'",
-		config.weekdayLabels[0].c_str(), config.weekdayLabels[1].c_str(),
-		config.weekdayLabels[2].c_str(), config.weekdayLabels[3].c_str(),
-		config.weekdayLabels[4].c_str(), config.weekdayLabels[5].c_str());
 	loadMenuDisplayValue(menu, "Exit", config.quickTipsExitLabel);
 	loadMenuDisplayValue(menu, "next", config.quickTipsNextLabel);
 	loadMenuDisplayValue(menu, "show_tips_on", config.quickTipsOnLabel);
@@ -934,7 +929,7 @@ Common::Error MenuSystem::showCdChangePrompt(int discNumber, ResourceManager &re
 	return Common::kNoError;
 }
 
-Common::Error MenuSystem::runMainMenuStub(Flow &flow) {
+Common::Error MenuSystem::runMainMenu(Flow &flow) {
 	const Art *art = _engine.getArt();
 	if (!art)
 		return Common::kReadingFailed;
@@ -1090,7 +1085,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &flow) {
 			if (_hasMainMenuBackdrop)
 				renderBackdropMenuScreen(_mainMenuBackdrop, _mainMenuBackdropPalette, 1.0f, mainMenuItems, selectedItem);
 			else
-				renderMainMenuStub(mainMenuItems, selectedItem, statusMessage);
+				renderMainMenuScreen(selectedItem, statusMessage, true, mainMenuItems);
 			needsRedraw = false;
 		}
 
@@ -1231,7 +1226,7 @@ void MenuSystem::clearMainMenuBackdrop() {
 	_hasMainMenuBackdrop = false;
 }
 
-Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+Common::Error MenuSystem::runRoomMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &flow, bool canSaveGame) {
 	Graphics::FrameLimiter limiter(g_system, 60);
 	Common::Array<Common::String> roomMenuItems;
@@ -2481,11 +2476,6 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 	return Common::kNoError;
 }
 
-void MenuSystem::renderMainMenuStub(const Common::Array<Common::String> &menuItems, int selectedItem,
-		const Common::String &statusMessage) const {
-	renderMainMenuScreen(selectedItem, statusMessage, true, menuItems);
-}
-
 void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
 		bool drawCursor, const Common::Array<Common::String> &menuItems) const {
 	Graphics::Screen *screen = _engine.getScreen();
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index be59851e1ac..58dd3782ca9 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -88,8 +88,8 @@ public:
 	MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		const Common::Array<Common::String> &menuItems);
 
-	Common::Error runMainMenuStub(Flow &flow);
-	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+	Common::Error runMainMenu(Flow &flow);
+	Common::Error runRoomMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &flow, bool canSaveGame);
 	Common::Error validateParentalPassword(Flow &flow);
 	Common::Error showCdChangePrompt(int discNumber, ResourceManager &resources,
@@ -115,8 +115,6 @@ private:
 		bool &accepted) const;
 	Common::Error showGameOverBackdrop(Flow &flow);
 	void clearMainMenuBackdrop();
-	void renderMainMenuStub(const Common::Array<Common::String> &menuItems, int selectedItem,
-		const Common::String &statusMessage) const;
 	void renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
 		bool drawCursor, const Common::Array<Common::String> &menuItems) const;
 	void renderBackdropMenuScreen(const IndexedBitmap &backdrop, const byte *palette,
diff --git a/engines/harvester/scene.cpp b/engines/harvester/scene.cpp
index 96e14515427..7f92daf4227 100644
--- a/engines/harvester/scene.cpp
+++ b/engines/harvester/scene.cpp
@@ -1130,7 +1130,7 @@ Common::Error SceneRunner::runRoomLoop(Flow &flow, const Common::String &targetN
 		if (transitionError.getCode() != Common::kNoError)
 			return transitionError;
 
-		logScenePaletteSummary("room setup stub palette", scene, 0.0f);
+		logScenePaletteSummary("room setup initial palette", scene, 0.0f);
 		drawRoomScene(_engine, *screen, scene, 0.0f);
 		screen->makeAllDirty();
 		screen->update();
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 6cf913b48c4..de71ae5f6e5 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1384,7 +1384,7 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, RoomSetup
 	if (!buildRuntimeRoomState(*room, entrance, resources, state))
 		return false;
 
-	debugC(1, kDebugRoom,
+	debugC(3, kDebugRoom,
 		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u",
 		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
@@ -1419,7 +1419,7 @@ bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, Roo
 	if (!buildRuntimeRoomState(*room, entrance, resources, state))
 		return false;
 
-	debugC(1, kDebugRoom,
+	debugC(3, kDebugRoom,
 		"Harvester: resolveRoomSetupStateByRoomName('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u",
 		roomName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
@@ -1502,7 +1502,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 			}
 		}
 		if (!baseFlag || flag.value != baseFlag->value) {
-			debugC(1, kDebugGeneral,
+			debugC(3, kDebugGeneral,
 				"Harvester: %s runtime flag '%s' value=%d base=%d defined_in_script=%d",
 				operation, flag.name.c_str(), flag.value, baseFlag ? baseFlag->value : 0,
 				baseFlag != nullptr);
@@ -1533,7 +1533,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 				object.runtimeVisible != defaultRuntimeVisible ||
 				object.visible != defaultVisible ||
 				object.identShown != defaultIdentShown) {
-			debugC(1, kDebugGeneral,
+			debugC(3, kDebugGeneral,
 				"Harvester: %s runtime object '%s' owner='%s' pos=(%d,%d,%d) visible=%d runtimeVisible=%d identShown=%d base_owner='%s' base_pos=(%d,%d,%d) base_visible=%d base_runtimeVisible=%d base_identShown=%d",
 				operation, object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
 				object.currentX, object.currentY, object.currentZ,
@@ -1566,7 +1566,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 				npc.deathDamageType != defaultDeathDamageType ||
 				npc.runtimeSpawned ||
 				npc.runtimeState != defaultRuntimeState) {
-			debugC(1, kDebugGeneral,
+			debugC(3, kDebugGeneral,
 				"Harvester: %s runtime npc '%s' active=%d visible=%d savedVisible=%d spawned=%d runtimeState=%d deathOrMonsterfy=%d damageType=%d base_active=%d base_visible=%d base_savedVisible=%d base_spawned=%d base_runtimeState=%d base_deathOrMonsterfy=%d base_damageType=%d",
 				operation, npc.npcName.c_str(), npc.active, npc.visible, npc.savedVisible,
 				npc.runtimeSpawned, npc.runtimeState, npc.deathOrMonsterfyFlag, npc.deathDamageType,
@@ -1595,7 +1595,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 				monster.runtimeState != -1 ||
 				monster.screenMinXBound != defaultScreenMinXBound ||
 				monster.screenMaxXBound != defaultScreenMaxXBound) {
-			debugC(1, kDebugGeneral,
+			debugC(3, kDebugGeneral,
 				"Harvester: %s runtime monster '%s' active=%d visible=%d savedVisible=%d spawned=%d runtimeState=%d hp=%d/%d screen_bounds=[%d,%d] base_active=%d base_visible=%d base_savedVisible=%d base_runtimeState=%d base_hp=%d/%d base_screen_bounds=[%d,%d]",
 				operation, monster.monsterName.c_str(), monster.active, monster.visible,
 				monster.savedVisible, monster.runtimeSpawned, monster.runtimeState,
@@ -1627,7 +1627,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 				timer.enabled != defaultEnabled ||
 				timer.looping != defaultLooping ||
 				timer.global != defaultGlobal) {
-			debugC(1, kDebugGeneral,
+			debugC(3, kDebugGeneral,
 				"Harvester: %s runtime timer '%s' current=%d initial=%d enabled=%d loop=%d global=%d base_current=%d base_initial=%d base_enabled=%d base_loop=%d base_global=%d",
 				operation, timer.timerName.c_str(), timer.currentValue, timer.initialValue,
 				timer.enabled, timer.looping, timer.global,
@@ -2358,12 +2358,8 @@ bool Script::consumePlayerCombatResourceUnit(int loadout) {
 	if (maxCount <= 0)
 		return true;
 
-	if (getPlayerCombatResourceCount(loadout) < 1) {
-		debugC(1, kDebugGeneral,
-			"Harvester: PLAYER_ATTACK player combat resource loadout=%d('%s') delta=0 count=0->0 max=%d changed=0",
-			loadout, Player::describeCombatLoadout(loadout), maxCount);
+	if (getPlayerCombatResourceCount(loadout) < 1)
 		return false;
-	}
 
 	return adjustPlayerCombatResourceCount(loadout, -1, maxCount, "PLAYER_ATTACK");
 }
@@ -2689,7 +2685,7 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 		: kDefaultPaletteBrightness;
 
 	for (const ObjectRecord &object : state.roomObjects) {
-		debugC(1, kDebugRoom,
+		debugC(3, kDebugRoom,
 			"Harvester: materialized room object room='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s'",
 			state.roomName.c_str(), object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
 			object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
@@ -2697,7 +2693,7 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
 	}
 	for (const NpcRecord &npc : state.roomNpcs) {
-		debugC(1, kDebugRoom,
+		debugC(3, kDebugRoom,
 			"Harvester: materialized room npc room='%s' npc='%s' visible=%d active=%d spawned=%d runtimeState=%d damage_type=%d pos=(%d,%d,%d) frame_delay=%d model='%s' on_death='%s' audio='%s'",
 			state.roomName.c_str(), npc.npcName.c_str(), npc.visible, npc.active,
 			npc.runtimeSpawned, npc.runtimeState, npc.deathDamageType,
@@ -2705,7 +2701,7 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 			npc.modelPath.c_str(), npc.onDeathActionTag.c_str(), npc.audioPath.c_str());
 	}
 	for (const MonsterRecord &monster : state.roomMonsters) {
-		debugC(1, kDebugRoom,
+		debugC(3, kDebugRoom,
 			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d spawned=%d runtimeState=%d pos=(%d,%d,%d) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' on_death='%s'",
 			state.roomName.c_str(), monster.monsterName.c_str(), monster.visible, monster.active,
 			monster.runtimeSpawned, monster.runtimeState,
@@ -2715,13 +2711,13 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 			monster.modelPath.c_str(), monster.onDeathActionTag.c_str());
 	}
 	for (const TimerRecord &timer : state.roomTimers) {
-		debugC(1, kDebugRoom,
+		debugC(3, kDebugRoom,
 			"Harvester: materialized room timer room='%s' timer='%s' current=%d initial=%d enabled=%d loop=%d global=%d",
 			state.roomName.c_str(), timer.timerName.c_str(), timer.currentValue, timer.initialValue,
 			timer.enabled, timer.looping, timer.global);
 	}
 
-	debugC(1, kDebugRoom,
+	debugC(3, kDebugRoom,
 		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u",
 		state.roomName.c_str(), state.entranceName.c_str(), state.playerSpawnX, state.playerSpawnY,
 		state.playerSpawnZ, state.playerFacing, state.palettePath.c_str(), state.backgroundPath.c_str(),
@@ -2783,7 +2779,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const Command
 			break;
 		}
 
-		debugC(1, kDebugScene,
+		debugC(3, kDebugScene,
 			"Harvester: %s '%s' step=%u tag='%s' opcode='%s' args=['%s','%s','%s','%s']",
 			context.label, context.name.c_str(), step, currentTag.c_str(), command->opcodeName.c_str(),
 			command->arg1.c_str(), command->arg2.c_str(), command->arg3.c_str(), command->arg4.c_str());
@@ -2791,7 +2787,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const Command
 		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
 			const FlagRecord *flag = findRuntimeFlag(command->arg1);
 			const bool flagValue = flag && flag->value;
-			debugC(1, kDebugScene, "Harvester: %s '%s' flag '%s' -> %d",
+			debugC(3, kDebugScene, "Harvester: %s '%s' flag '%s' -> %d",
 				context.label, context.name.c_str(), command->arg1.c_str(), flagValue);
 			currentTag = flagValue ? command->arg2 : command->arg3;
 			continue;




More information about the Scummvm-git-logs mailing list