[Scummvm-git-logs] scummvm master -> c2412793813a3aacc1023ee63b25cdb44ba028cd

alexbevi noreply at scummvm.org
Mon Oct 19 23:03:33 UTC 2026


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

Summary:
33dbffedb3 HARVESTER: Add remappable movement and combat controls
a1d6761463 HARVESTER: Add remappable attack, duck and jump actions
255088e37d HARVESTER: Separate town script record parsing
1f1f13ca21 HARVESTER: Split command execution into opcode handlers
4dd258fce8 HARVESTER: Separate room state materialization
3b56649b1a HARVESTER: Separate script save and reload handling
29bc2c2283 HARVESTER: Separate script runtime and actor state handling
32b820a747 HARVESTER: Show IDENT text before non-operatable object actions
a8a8ddc7c2 HARVESTER: Separate menu presentation and text configuration
eb8f943def HARVESTER: Separate options menu actions and event handling
981f404499 HARVESTER: reset hidden-to-visible ANIMs
f73b790460 HARVESTER: Retry missed melee contact until the attack ends
e1d9798441 HARVESTER: Correct player depth walking and room limits
cfeac713a6 HARVESTER: Separate main and room menu navigation
78e23bd564 HARVESTER: Separate save menus and modal prompts
7dbf808e6e HARVESTER: Separate combat targeting and reach geometry
2110408162 HARVESTER: Separate combat hit and death transitions
ab8709bc89 HARVESTER: Separate monster pursuit and attack progression
edf006a381 HARVESTER: Separate player attack contact handling
00f6720ad4 HARVESTER: Separate combat effects and HUD text
a56f1982e4 HARVESTER: Separate player movement and collision helpers
6be6653618 HARVESTER: Separate player target walking
3d8586a32d HARVESTER: Separate player keyboard walking
19a46f3107 HARVESTER: Separate player combat animation
58a5bca82e HARVESTER: Separate player lifecycle and idle animation
b8478f4522 HARVESTER: Separate flow startup diagnostics
bef83f3ebf HARVESTER: Separate town map selection and event handling
998e763b93 HARVESTER: Separate startup overlays and transitions
1e5757bcf2 HARVESTER: Separate room population and actor placement
c241279381 HARVESTER: Organize subsystem sources into directories


Commit: 33dbffedb31d929a180ed6573bad73f430784162
    https://github.com/scummvm/scummvm/commit/33dbffedb31d929a180ed6573bad73f430784162
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Add remappable movement and combat controls

Fixes #17295

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/input.cpp
    engines/harvester/input.h
    engines/harvester/metaengine.cpp
    engines/harvester/metaengine.h
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/scene.cpp


diff --git a/engines/harvester/input.cpp b/engines/harvester/input.cpp
index 4a2122367f9..7d13ef95605 100644
--- a/engines/harvester/input.cpp
+++ b/engines/harvester/input.cpp
@@ -371,10 +371,7 @@ bool Input::openInventoryOverlay() {
 	Player &player = _session.getPlayer();
 	InputState &inputState = _session.getInputState();
 
-	inputState.moveLeft = false;
-	inputState.moveRight = false;
-	inputState.moveUp = false;
-	inputState.moveDown = false;
+	inputState.clearHeldActions();
 	_session.clearPendingRegionInteraction();
 	(void)_session.cancelPlayerAttackAnimation();
 	player.cancelMovement();
@@ -675,6 +672,7 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	if (_roomItems.hasSelectedItem()) {
 		const Common::String selectedItemName = _roomItems.getSelectedItemName();
 		if (clickHoverState.npc) {
+			inputState.clearHeldActions();
 			_roomItems.clearSelectedItem();
 			bool didTransition = false;
 			Common::Error dialogueError = _interactionProcessor.runScriptedDialogue(
@@ -717,10 +715,7 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	}
 	if (hoverState.npc) {
 		_session.clearPendingRegionInteraction();
-		inputState.moveLeft = false;
-		inputState.moveRight = false;
-		inputState.moveUp = false;
-		inputState.moveDown = false;
+		inputState.clearHeldActions();
 		player.cancelMovement();
 		bool didTransition = false;
 		Common::Error dialogueError = _interactionProcessor.runScriptedDialogue(
@@ -847,10 +842,28 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	return InputResult();
 }
 
-InputResult Input::handleKeyDown(const Common::KeyState &key, bool &needsRedraw) {
+InputResult Input::handleGameAction(GameAction action, bool pressed, bool &needsRedraw) {
+	static const Common::KeyCode keys[] = {
+		Common::KEYCODE_LEFT, Common::KEYCODE_RIGHT, Common::KEYCODE_UP, Common::KEYCODE_DOWN,
+		Common::KEYCODE_LCTRL, Common::KEYCODE_RSHIFT, Common::KEYCODE_LALT,
+		Common::KEYCODE_i, Common::KEYCODE_ESCAPE
+	};
+	if ((uint)action >= ARRAYSIZE(keys))
+		return InputResult();
+	const Common::KeyState key(keys[action]);
+	if (pressed)
+		return handleKeyDown(key, needsRedraw, true);
+	handleKeyUp(key, true);
+	return InputResult();
+}
+
+InputResult Input::handleKeyDown(const Common::KeyState &key, bool &needsRedraw, bool gameAction) {
 	Player &player = _session.getPlayer();
 	const RoomIdleAnimationState &idleState = player.getIdleState();
 	InputState &inputState = _session.getInputState();
+	// GOG update_actor_runtime_state (0x4d750) tests the last scan code,
+	// not the held Alt slot. Any other key press or release clears fast speed.
+	inputState.speedUpActive = gameAction && key.keycode == Common::KEYCODE_LALT;
 
 	if (_presentationState.showingInspectText) {
 		if (key.keycode == Common::KEYCODE_ESCAPE) {
@@ -863,8 +876,6 @@ InputResult Input::handleKeyDown(const Common::KeyState &key, bool &needsRedraw)
 	}
 
 	_session.notePlayerActivity();
-	if (key.hasFlags(Common::KBD_CTRL))
-		inputState.attackModifierHeld = true;
 	if (idleState.active || idleState.exiting) {
 		if (player.requestIdleAnimationExit(_scene.state))
 			needsRedraw = true;
@@ -931,39 +942,38 @@ InputResult Input::handleKeyDown(const Common::KeyState &key, bool &needsRedraw)
 			return InputResult();
 		}
 	}
+	if (gameAction && key.keycode == Common::KEYCODE_LCTRL) {
+		inputState.attackModifierHeld = true;
+	} else if (gameAction && key.keycode == Common::KEYCODE_RSHIFT) {
+		inputState.evasiveModifierHeld = true;
+	} else if (gameAction && key.keycode == Common::KEYCODE_LEFT)
+		inputState.moveLeft = true;
+	else if (gameAction && key.keycode == Common::KEYCODE_RIGHT)
+		inputState.moveRight = true;
+	else if (gameAction && key.keycode == Common::KEYCODE_UP)
+		inputState.moveUp = true;
+	else if (gameAction && key.keycode == Common::KEYCODE_DOWN)
+		inputState.moveDown = true;
+
 	if (_session.isPlayerCombatLocked())
 		return InputResult();
 
-	if (key.keycode == Common::KEYCODE_LCTRL ||
-			key.keycode == Common::KEYCODE_RCTRL) {
-		inputState.attackModifierHeld = true;
-	} else if (key.keycode == Common::KEYCODE_k && _engine.isCombatDebugEnabled()) {
+	if (key.keycode == Common::KEYCODE_k && _engine.isCombatDebugEnabled()) {
 		Common::Error debugKillError = _session.killDebugActiveMonster(_flow.getMousePos(), needsRedraw);
 		if (debugKillError.getCode() != Common::kNoError)
 			return InputResult::leaveRoom(debugKillError);
 		return InputResult();
-	} 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) {
+	} else if (gameAction && key.keycode == Common::KEYCODE_i) {
 		if (!openInventoryOverlay())
 			return InputResult::leaveRoom(Common::kReadingFailed);
 		needsRedraw = true;
 		return InputResult();
-	} else if (key.keycode == Common::KEYCODE_ESCAPE) {
+	} else if (gameAction && key.keycode == Common::KEYCODE_ESCAPE) {
 		if (_session.cancelPlayerAttackAnimation())
 			needsRedraw = true;
 		if (!_roomItems.stowCarriedRoomItemToInventory())
 			return InputResult::leaveRoom(Common::kReadingFailed);
-		inputState.moveLeft = false;
-		inputState.moveRight = false;
-		inputState.moveUp = false;
-		inputState.moveDown = false;
+		inputState.clearHeldActions();
 		_session.clearPendingRegionInteraction();
 		player.cancelMovement();
 		_session.captureCurrentSaveState();
@@ -997,12 +1007,17 @@ InputResult Input::handleKeyDown(const Common::KeyState &key, bool &needsRedraw)
 	return InputResult();
 }
 
-void Input::handleKeyUp(const Common::KeyState &key) {
+void Input::handleKeyUp(const Common::KeyState &key, bool gameAction) {
 	InputState &inputState = _session.getInputState();
+	inputState.speedUpActive = false;
+	if (!gameAction)
+		return;
 
 	if (key.keycode == Common::KEYCODE_LCTRL ||
 			key.keycode == Common::KEYCODE_RCTRL)
 		inputState.attackModifierHeld = false;
+	else if (key.keycode == Common::KEYCODE_RSHIFT)
+		inputState.evasiveModifierHeld = false;
 	else if (key.keycode == Common::KEYCODE_LEFT)
 		inputState.moveLeft = false;
 	else if (key.keycode == Common::KEYCODE_RIGHT)
@@ -1019,6 +1034,10 @@ InputResult Input::handleEvent(const Common::Event &event, bool &needsRedraw) {
 		return InputResult::leaveRoom(result);
 
 	switch (event.type) {
+	case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
+	case Common::EVENT_CUSTOM_ENGINE_ACTION_END:
+		return handleGameAction((GameAction)event.customType,
+			event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START, needsRedraw);
 	case Common::EVENT_MOUSEMOVE:
 		handleMouseMove(needsRedraw);
 		break;
diff --git a/engines/harvester/input.h b/engines/harvester/input.h
index 224722a90d6..ac340b018e2 100644
--- a/engines/harvester/input.h
+++ b/engines/harvester/input.h
@@ -48,14 +48,33 @@ struct RoomPresentationState;
 struct RoomSetupState;
 struct SceneState;
 
+enum GameAction {
+	kActionMoveLeft,
+	kActionMoveRight,
+	kActionMoveUp,
+	kActionMoveDown,
+	kActionAttackModifier,
+	kActionEvasiveModifier,
+	kActionSpeedUp,
+	kActionInventory,
+	kActionMenu
+};
+
 struct InputState {
 	bool moveLeft = false;
 	bool moveRight = false;
 	bool moveUp = false;
 	bool moveDown = false;
 	bool attackModifierHeld = false;
+	bool evasiveModifierHeld = false;
+	bool speedUpActive = false;
 	uint32 lastLeftButtonReleaseTick = 0;
 	uint32 nextKeyboardAttackAllowedTick = 0;
+
+	void clearHeldActions() {
+		moveLeft = moveRight = moveUp = moveDown = false;
+		attackModifierHeld = evasiveModifierHeld = speedUpActive = false;
+	}
 };
 
 // Enter and closeup exits return from runRoomLoop even when the error is kNoError.
@@ -95,8 +114,9 @@ private:
 	InputResult handleRightButtonDown(bool &needsRedraw);
 	void handleLeftButtonUp();
 	InputResult handleLeftButtonDown(bool &needsRedraw);
-	InputResult handleKeyDown(const Common::KeyState &key, bool &needsRedraw);
-	void handleKeyUp(const Common::KeyState &key);
+	InputResult handleKeyDown(const Common::KeyState &key, bool &needsRedraw, bool gameAction = false);
+	void handleKeyUp(const Common::KeyState &key, bool gameAction = false);
+	InputResult handleGameAction(GameAction action, bool pressed, bool &needsRedraw);
 	void beginInspectText(const ObjectRecord &object, const ResolvedText &text);
 	void queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw);
 	bool openInventoryOverlay();
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
index 58f4a4b2053..a91f4f8dbd7 100644
--- a/engines/harvester/metaengine.cpp
+++ b/engines/harvester/metaengine.cpp
@@ -20,11 +20,15 @@
  */
 
 #include "base/plugins.h"
+#include "backends/keymapper/action.h"
+#include "backends/keymapper/keymap.h"
+#include "backends/keymapper/standard-actions.h"
 
 #include "common/translation.h"
 #include "harvester/detection.h"
 #include "harvester/metaengine.h"
 #include "harvester/harvester.h"
+#include "harvester/input.h"
 
 static const ADExtraGuiOptionsMap gameGuiOptions[] = {
 	{
@@ -57,6 +61,54 @@ const char *HarvesterMetaEngine::getName() const {
 	return "harvester";
 }
 
+Common::KeymapArray HarvesterMetaEngine::initKeymaps(const char *target) const {
+	using namespace Common;
+	using namespace Harvester;
+
+	KeymapArray keymaps = MetaEngine::initKeymaps(target);
+	Keymap *gameplay = new Keymap(Keymap::kKeymapTypeGame, "harvester-gameplay", _("Gameplay"));
+	Action *click = new Action(kStandardActionLeftClick, _("Left click"));
+	click->setLeftClickEvent();
+	click->addDefaultInputMapping("MOUSE_LEFT");
+	click->addDefaultInputMapping("JOY_A");
+	gameplay->addAction(click);
+	click = new Action(kStandardActionRightClick, _("Right click"));
+	click->setRightClickEvent();
+	click->addDefaultInputMapping("MOUSE_RIGHT");
+	click->addDefaultInputMapping("JOY_B");
+	gameplay->addAction(click);
+	const struct {
+		const char *id;
+		const char *description;
+		GameAction action;
+		const char *mapping;
+		const char *alternateMapping;
+	} bindings[] = {
+		{ "LEFT", _s("Move left"), kActionMoveLeft, "LEFT", "JOY_LEFT" },
+		{ "RIGHT", _s("Move right"), kActionMoveRight, "RIGHT", "JOY_RIGHT" },
+		{ "UP", _s("Move up"), kActionMoveUp, "UP", "JOY_UP" },
+		{ "DOWN", _s("Move down"), kActionMoveDown, "DOWN", "JOY_DOWN" },
+		{ "ATTACK", _s("Attack modifier"), kActionAttackModifier, "LCTRL", "RCTRL" },
+		{ "EVADE", _s("Evasive movement modifier"), kActionEvasiveModifier, "RSHIFT", nullptr },
+		{ "SPEEDUP", _s("Speed up"), kActionSpeedUp, "LALT", "RALT" },
+		{ "INVENTORY", _s("Inventory"), kActionInventory, "i", nullptr },
+		{ "MENU", _s("Game menu"), kActionMenu, "ESCAPE", "JOY_Y" }
+	};
+	for (const auto &binding : bindings) {
+		Action *action = new Action(binding.id, _(binding.description));
+		action->setCustomEngineActionEvent(binding.action);
+		if (binding.action <= kActionSpeedUp)
+			action->allowKbdRepeats();
+		action->addDefaultInputMapping(binding.mapping);
+		if (binding.alternateMapping)
+			action->addDefaultInputMapping(binding.alternateMapping);
+		gameplay->addAction(action);
+	}
+	gameplay->setEnabled(false);
+	keymaps.push_back(gameplay);
+	return keymaps;
+}
+
 const ADExtraGuiOptionsMap *HarvesterMetaEngine::getAdvancedExtraGuiOptions() const {
 	return gameGuiOptions;
 }
diff --git a/engines/harvester/metaengine.h b/engines/harvester/metaengine.h
index 57644f2b606..9d9b95b9090 100644
--- a/engines/harvester/metaengine.h
+++ b/engines/harvester/metaengine.h
@@ -28,6 +28,7 @@ class HarvesterMetaEngine : public AdvancedMetaEngine<ADGameDescription> {
 public:
 	const char *getName() const override;
 	const ADExtraGuiOptionsMap *getAdvancedExtraGuiOptions() const override;
+	Common::Array<Common::Keymap *> initKeymaps(const char *target) const override;
 
 	Common::Error createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const override;
 	int getMaximumSaveSlot() const override;
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 5829792cb05..cbec6ab7dc2 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -45,13 +45,9 @@ static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
 static const int kRoomPlayerVerticalScreenStep = 1;
-static const int kRoomPlayerAttackAnimationRate = kRoomPlayerWalkAnimationRate;
+static const int kRoomPlayerFastAnimationRate = 30;
 static const int kRoomPlayerHitAnimationRate = 4;
-// update_actor_runtime_state (0x4d750) seeds a zero-HP transition at 0x4e576
-// with rate 14, then death states 0x28..0x33 consume the normal rate 17 seeded
-// at 0x4d889 after the first death frame advances.
 static const int kNativePlayerDeathEntryAnimationRate = 14;
-static const int kNativePlayerDeathAnimationRate = kRoomPlayerWalkAnimationRate;
 static const int kRoomPlayerMinOpaqueLeftX = 4;
 static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
 static const float kRoomPlayerAttackUpperYOffset = 144.44f;
@@ -61,7 +57,6 @@ static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM
 static const uint32 kRoomPlayerIdleDelayTicks = 3000;
 static const int kPlayerHitKnockbackDistance = 18;
 static const int kPlayerHitKnockbackDecayStep = 3;
-// Native run_harvester_main_loop sets EBX=0x0e before spawning PCLOUN02.ABM.
 static const int kRoomPlayerIdleAnimationRate = 14;
 static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
 static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
@@ -130,10 +125,6 @@ struct PlayerCombatLoadoutTuning {
 	int contactFrameOffset;
 };
 
-// The live player avatar seeds these from RenderEntityRuntime.player_combat_loadout_id (+0x11bc)
-// into attack_damage_amount (+0x1180) and attack_contact_frame_offset (+0x113c).
-// Recovered from spawn_player_combat_avatar and update_actor_runtime_state:
-// the native room-combat path commits player attack audio on first_frame + 1.
 static const int kPlayerAttackSoundTriggerFrameOffset = 1;
 static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
 	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
@@ -319,8 +310,6 @@ static bool resolvePlayerDeathAnimationRange(const RoomPlayerState &playerState,
 	if (!playerState.entity)
 		return false;
 
-	// Native update_actor_runtime_state selects the player death bank from the
-	// live combat state family before the terminal 0x38 game-over handoff.
 	enum PlayerDeathBankFamily {
 		kPlayerDeathBankHorizontalRight,
 		kPlayerDeathBankHorizontalLeft,
@@ -421,9 +410,6 @@ static bool resolvePlayerAttackAnimationRange(const RoomSetupState &state,
 static bool resolveKeyboardAttackAnimationRange(const RoomPlayerState &playerState,
 		bool attackLeft, bool attackRight, bool attackUp, bool attackDown,
 		PlayerAttackAnimationRange &range) {
-	// Native update_player_combat_avatar_state uses scan-code 0x1d as the attack modifier:
-	// Left/Right pick the side mid attacks directly, while Up/Down only choose upper/lower
-	// attacks when the current actor state is already in the matching horizontal family.
 	if (attackRight) {
 		range = PlayerAttackAnimationRange(0x78, 0x81, 2);
 		return true;
@@ -497,6 +483,8 @@ static bool startResolvedAttackAnimation(RoomPlayerState &playerState,
 		return false;
 
 	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
 	playerState.turnActive = false;
 	playerState.turnTargetFacing = -1;
 	playerState.turnFirstFrame = -1;
@@ -516,7 +504,7 @@ static bool startResolvedAttackAnimation(RoomPlayerState &playerState,
 	playerState.attackContactResolved = false;
 	playerState.nextMovementTick = 0;
 	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-	playerState.entity->setAnimationRate(kRoomPlayerAttackAnimationRate);
+	playerState.entity->setAnimationRate(playerState.animationRate);
 	playerState.entity->setCurrentFrame(range.firstFrame);
 	return true;
 }
@@ -589,9 +577,6 @@ static float computeRoomPlayerDepthStep(const RoomSetupState &state) {
 }
 
 static int computeRoomPlayerVerticalScreenStep() {
-	// Native spawn_player_combat_avatar seeds player runtime +0x1140 with 1,
-	// and update_actor_runtime_state copies abs(+0x1140) into frame_y_step for
-	// the vertical walk states instead of remapping screen Y from depth.
 	return kRoomPlayerVerticalScreenStep;
 }
 
@@ -676,7 +661,7 @@ static bool consumePlayerMovementTick(RoomPlayerState &playerState) {
 		return playerState.entity->didAnimationAdvanceLastTick();
 	}
 
-	const uint32 interval = computeAnimationTickInterval(kRoomPlayerWalkAnimationRate);
+	const uint32 interval = computeAnimationTickInterval(playerState.animationRate);
 	if (interval == 0) {
 		playerState.nextMovementTick = 0;
 		return true;
@@ -772,8 +757,6 @@ static void reinsertPlayerSceneEntity(HarvesterEngine &engine, const RoomPlayerS
 static void applyPlayerMovementPlacement(const RoomSetupState &state, RoomPlayerState &playerState,
 		int candidateCenterX, int candidateBottomY, float candidateZ) {
 	Entity &entity = *playerState.entity;
-	// advance_entity_animation_frame (0x4c8a0) moves the pre-offset screen base.
-	// rescale_entity_sprite_for_depth (0x4b440) changes bounds without recentering it.
 	const float renderZ = candidateZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
 	entity.setPosition(entity.getX() + candidateCenterX - playerState.centerX,
 		entity.getY() + candidateBottomY - playerState.bottomY, renderZ);
@@ -969,7 +952,7 @@ static void playPlayerBlockedWalkSounds(HarvesterEngine &engine, const RoomSetup
 		const RoomPlayerState &playerState, const Entity &blocker) {
 	const Entity &entity = *playerState.entity;
 	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
-	if (entity.getAnimationRate() != kRoomPlayerWalkAnimationRate ||
+	if (entity.getAnimationRate() != playerState.animationRate ||
 			entity.getCurrentFrame() < range.walkFirstFrame ||
 			entity.getCurrentFrame() > range.walkLastFrame)
 		return;
@@ -1010,8 +993,6 @@ static bool tryApplyPlayerWalkMovement(HarvesterEngine &engine, const RoomSetupS
 			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;
 	syncPlayerAnimationPlacement(playerState);
@@ -1053,17 +1034,17 @@ static bool setPlayerWalkAnimation(RoomPlayerState &playerState, int facing) {
 	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
 	const int currentFrame = playerState.entity->getCurrentFrame();
 	const bool alreadyWalking = playerState.facing == facing &&
-		playerState.entity->getAnimationRate() == kRoomPlayerWalkAnimationRate &&
+		playerState.entity->getAnimationRate() == playerState.animationRate &&
 		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
 	if (alreadyWalking)
 		return false;
 
 	playerState.facing = facing;
 	playerState.entity->setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
-	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
+	playerState.entity->setAnimationRate(playerState.animationRate);
 	debugC(1, kDebugPlayer,
 		"Harvester: player walk animation facing=%d frames=%d..%d rate=%d",
-		facing, range.walkFirstFrame, range.walkLastFrame, kRoomPlayerWalkAnimationRate);
+		facing, range.walkFirstFrame, range.walkLastFrame, playerState.animationRate);
 	return true;
 }
 
@@ -1161,6 +1142,8 @@ static bool setPlayerIdleAnimation(RoomPlayerState &playerState, int facing) {
 		playerState.entity->getCurrentFrame() != range.idleFrame ||
 		playerState.entity->getAnimationRate() != 0;
 	playerState.facing = facing;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
 	playerState.nextMovementTick = 0;
 	playerState.keyboardWalkRequest = -1;
 	playerState.walkHorizontalDirection = 0;
@@ -1186,16 +1169,20 @@ void Player::initialize(const RoomSetupState &state, Entity *entity, int combatL
 	playerState.z = (float)state.playerSpawnZ;
 	playerState.facing = state.playerFacing;
 	playerState.combatLoadout = combatLoadout;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
 	playerState.turnActive = false;
 	playerState.turnTargetFacing = -1;
 }
 
 bool Player::isCombatLocked() const {
-	return _state.attackActive || _state.hitActive || _state.deathActive;
+	return _state.attackActive || _state.hitActive || _state.deathActive || _state.evasiveState >= 0;
 }
 
 void Player::cancelMovement() {
 	_state.hasMoveTarget = false;
+	_state.evasiveState = -1;
+	_state.evasiveXStep = 0;
 	_state.turnActive = false;
 	_state.turnTargetFacing = -1;
 }
@@ -1277,6 +1264,8 @@ void Player::cancelIdleAnimation() {
 void Player::applyGotoXZ(const RoomSetupState &state, EntityManager *entityManager, int x, int z) {
 	RoomPlayerState &playerState = _state;
 	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
 	playerState.turnActive = false;
 	playerState.turnTargetFacing = -1;
 	playerState.centerX = x;
@@ -1323,6 +1312,8 @@ void Player::rebindAfterSceneRefresh(HarvesterEngine &engine, const RoomSetupSta
 
 	// Death state survives a scene refresh.
 	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
 	playerState.turnActive = false;
 	playerState.turnTargetFacing = -1;
 	playerState.attackActive = false;
@@ -1600,6 +1591,107 @@ bool Player::setIdleAnimation(int facing) {
 	return setPlayerIdleAnimation(_state, facing);
 }
 
+bool Player::updateAnimationRate(bool speedUp) {
+	RoomPlayerState &playerState = _state;
+	const int rate = speedUp ? kRoomPlayerFastAnimationRate : kRoomPlayerWalkAnimationRate;
+	if (playerState.animationRate == rate)
+		return false;
+	playerState.animationRate = rate;
+	if (playerState.entity && !playerState.hitActive && !playerState.deathActive &&
+			playerState.entity->getAnimationRate() != 0)
+		playerState.entity->setAnimationRate(rate);
+	debugC(2, kDebugPlayer, "Harvester: player actor rate=%d", rate);
+	return true;
+}
+
+bool Player::startEvasiveAnimation(bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || playerState.attackActive || playerState.hitActive || playerState.deathActive ||
+			playerState.evasiveState >= 0)
+		return false;
+	playerState.hasMoveTarget = false;
+	playerState.keyboardWalkRequest = -1;
+	if (playerState.turnActive)
+		return false;
+	const PlayerAnimationRange walk = resolvePlayerAnimationRange(playerState.facing);
+	const int frame = playerState.entity->getCurrentFrame();
+	if (playerState.entity->getAnimationRate() != 0 &&
+			frame >= walk.walkFirstFrame && frame < walk.walkLastFrame)
+		return false;
+
+	// Right Shift branch:
+	// vertical requests select duck/leap banks; horizontal requests can override them.
+	int actorState = -1;
+	if (playerState.facing == 1 || playerState.facing == 2) {
+		if (moveUp)
+			actorState = playerState.facing == 2 ? 0x12 : 0x13;
+		else if (moveDown)
+			actorState = playerState.facing == 2 ? 0x14 : 0x15;
+		if (moveLeft) {
+			if (playerState.facing == 2)
+				actorState = 0x08;
+		} else if (moveRight && playerState.facing == 1) {
+			actorState = 0x0f;
+		}
+	}
+	if (actorState < 0)
+		return false;
+
+	const bool backwards = actorState == 0x08 || actorState == 0x0f;
+	const int firstFrame = actorState == 0x08 ? 0x2d : actorState == 0x0f ? 0x0f :
+		0x3c + (actorState - 0x12) * 5;
+	const int lastFrame = firstFrame + (backwards ? 9 : 4);
+	if (!playerEntityHasFrameRange(*playerState.entity, firstFrame, lastFrame))
+		return false;
+	playerState.evasiveState = actorState;
+	playerState.evasiveFirstFrame = firstFrame;
+	playerState.evasiveLastFrame = lastFrame;
+	playerState.evasiveXStep = backwards
+		? (int)(playerState.walkHorizontalDirection * playerState.entity->getDepthScale() * 8.0f) : 0;
+	playerState.walkHorizontalDirection = 0;
+	playerState.walkDepthDirection = 0;
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationFrameRange(firstFrame, lastFrame, false);
+	playerState.entity->setPlayBackwards(backwards);
+	playerState.entity->setAnimationRate(playerState.animationRate);
+	playerState.entity->setCurrentFrame(backwards ? lastFrame : firstFrame);
+	debugC(2, kDebugPlayer, "Harvester: player evasive state=0x%x facing=%d frames=%d..%d backwards=%d",
+		actorState, playerState.facing, firstFrame, lastFrame, backwards);
+	return true;
+}
+
+bool Player::updateEvasiveAnimation(HarvesterEngine &engine) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || playerState.evasiveState < 0 ||
+			!playerState.entity->didAnimationAdvanceLastTick())
+		return false;
+
+	Entity &entity = *playerState.entity;
+	const int actorState = playerState.evasiveState;
+	if (playerState.evasiveXStep != 0) {
+		entity.setPosition(entity.getX() + playerState.evasiveXStep, entity.getY(), entity.getZ());
+		syncAnimationPlacement();
+		reinsertPlayerSceneEntity(engine, playerState);
+		debugC(3, kDebugPlayer, "Harvester: player evasive step state=0x%x frame=%d dx=%d",
+			actorState, entity.getCurrentFrame(), playerState.evasiveXStep);
+	}
+	const bool backwards = actorState == 0x08 || actorState == 0x0f;
+	if (entity.getCurrentFrame() == (backwards ? playerState.evasiveFirstFrame : playerState.evasiveLastFrame)) {
+		debugC(2, kDebugPlayer, "Harvester: player evasive complete state=0x%x facing=%d",
+			actorState, playerState.facing);
+		return setIdleAnimation(playerState.facing);
+	}
+
+	const int frameLeft = computePlayerFrameLeftX(playerState);
+	if ((actorState == 0x08 && frameLeft > 20) || (actorState == 0x0f && frameLeft < 620)) {
+		playerState.evasiveXStep = (int)(entity.getDepthScale() * (actorState == 0x08 ? -8.0f : 8.0f));
+	} else if (entity.getCurrentFrame() > playerState.evasiveFirstFrame + 2 &&
+			((actorState == 0x12 && frameLeft > 36) || (actorState == 0x13 && frameLeft < 616))) {
+		playerState.evasiveXStep = (int)(entity.getDepthScale() * (actorState == 0x12 ? -16.0f : 16.0f));
+	}
+	return true;
+}
+
 bool Player::startAttackAnimation(const RoomSetupState &state, const Common::Point &mousePos) {
 	RoomPlayerState &playerState = _state;
 	PlayerAttackAnimationRange range;
@@ -1687,6 +1779,8 @@ bool Player::startHitAnimation(HarvesterEngine &engine, int monsterAttackFirstFr
 		return false;
 
 	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
 	playerState.turnActive = false;
 	playerState.turnTargetFacing = -1;
 	playerState.turnFirstFrame = -1;
@@ -1771,6 +1865,8 @@ bool Player::startDeathAnimation(int damageType, bool goreEnabled) {
 		return false;
 
 	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
 	playerState.turnActive = false;
 	playerState.turnTargetFacing = -1;
 	playerState.turnFirstFrame = -1;
@@ -1809,7 +1905,7 @@ bool Player::startDeathAnimation(int damageType, bool goreEnabled) {
 		"Harvester: player death animation timing frames=%d..%d entry_rate=%d entry_interval_ticks=%d running_rate=%d running_interval_ticks=%d",
 		range.firstFrame, range.lastFrame,
 		kNativePlayerDeathEntryAnimationRate, 100 / kNativePlayerDeathEntryAnimationRate,
-		kNativePlayerDeathAnimationRate, 100 / kNativePlayerDeathAnimationRate);
+		playerState.animationRate, 100 / playerState.animationRate);
 	return true;
 }
 
@@ -1821,12 +1917,12 @@ bool Player::updateDeathAnimationState() {
 	const int currentFrame = playerState.entity->getCurrentFrame();
 	if (currentFrame > playerState.deathFirstFrame &&
 			currentFrame < playerState.deathLastFrame &&
-			playerState.entity->getAnimationRate() != kNativePlayerDeathAnimationRate) {
-		playerState.entity->setAnimationRate(kNativePlayerDeathAnimationRate);
+			playerState.entity->getAnimationRate() != playerState.animationRate) {
+		playerState.entity->setAnimationRate(playerState.animationRate);
 		debugC(3, kDebugCombat,
 			"Harvester: player death animation running frame=%d rate=%d interval_ticks=%d",
-			currentFrame, kNativePlayerDeathAnimationRate,
-			100 / kNativePlayerDeathAnimationRate);
+			currentFrame, playerState.animationRate,
+			100 / playerState.animationRate);
 	}
 
 	if (currentFrame < playerState.deathLastFrame)
@@ -1841,7 +1937,7 @@ bool Player::updateDeathAnimationState() {
 
 bool Player::startTurnAnimation(int targetFacing) {
 	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || playerState.hitActive ||
+	if (!playerState.entity || playerState.hitActive || playerState.evasiveState >= 0 ||
 			playerState.facing < 0 || playerState.facing == targetFacing)
 		return false;
 
@@ -1868,12 +1964,12 @@ bool Player::startTurnAnimation(int targetFacing) {
 	playerState.nextMovementTick = 0;
 	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
 	playerState.entity->setPlayBackwards(range.playBackwards);
-	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
+	playerState.entity->setAnimationRate(playerState.animationRate);
 	playerState.entity->setCurrentFrame(range.playBackwards ? range.lastFrame : range.firstFrame);
 	debugC(1, kDebugPlayer,
 		"Harvester: player turn animation from=%d to=%d frames=%d..%d backwards=%d rate=%d requested_facing=%d",
 		playerState.facing, nextFacing, range.firstFrame, range.lastFrame,
-		range.playBackwards, kRoomPlayerWalkAnimationRate, targetFacing);
+		range.playBackwards, playerState.animationRate, targetFacing);
 	return true;
 }
 
@@ -1902,20 +1998,16 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations,
 		int regionFacing) {
 	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive || playerState.hitActive)
+	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive ||
+			playerState.hitActive || playerState.evasiveState >= 0)
 		return false;
 
 	refreshPlayerCurrentBlocker(engine, sceneObjects, sceneAnimations, playerState);
 
-	// 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.
 	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
 	const int currentFrame = playerState.entity->getCurrentFrame();
-	const bool walking = playerState.entity->getAnimationRate() == kRoomPlayerWalkAnimationRate &&
+	const bool walking = playerState.entity->getAnimationRate() == playerState.animationRate &&
 		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);
@@ -2070,7 +2162,7 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 	RoomPlayerState &playerState = _state;
 	if (!playerState.entity || !supportsMovementBand(state))
 		return false;
-	if (playerState.turnActive || playerState.hitActive)
+	if (playerState.turnActive || playerState.hitActive || playerState.evasiveState >= 0)
 		return false;
 
 	int horizontalInput = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
@@ -2078,7 +2170,7 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
 	const int currentFrame = playerState.entity->getCurrentFrame();
 	const bool keyboardWalking = !playerState.hasMoveTarget &&
-		playerState.entity->getAnimationRate() == kRoomPlayerWalkAnimationRate &&
+		playerState.entity->getAnimationRate() == playerState.animationRate &&
 		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
 	if (keyboardWalking) {
 		if (horizontalInput != 0 || verticalInput != 0)
@@ -2221,6 +2313,8 @@ bool Player::startIdleAnimation(HarvesterEngine &engine, const RoomSetupState &s
 	idleEntity->setCurrentFrame(0);
 	idleEntity->setAnimationRate(kRoomPlayerIdleAnimationRate);
 	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
 	playerState.turnActive = false;
 	playerState.turnTargetFacing = -1;
 	playerState.entity->setVisible(false);
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index 25cacd7a423..d9d81b80abd 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -35,6 +35,11 @@ struct RoomPlayerState {
 	float z = 0.0f;
 	int facing = -1;
 	int combatLoadout = 0;
+	int animationRate = 17;
+	int evasiveState = -1;
+	int evasiveFirstFrame = -1;
+	int evasiveLastFrame = -1;
+	int evasiveXStep = 0;
 	uint32 nextMovementTick = 0;
 	int keyboardWalkRequest = -1;
 	int walkHorizontalDirection = 0;
@@ -138,6 +143,9 @@ public:
 	void syncCombatLoadout(HarvesterEngine &engine, const RoomSetupState &state);
 	bool syncCombatLoadoutVisual(HarvesterEngine &engine, const RoomSetupState &state, int loadout);
 	bool setIdleAnimation(int facing);
+	bool updateAnimationRate(bool speedUp);
+	bool startEvasiveAnimation(bool moveLeft, bool moveRight, bool moveUp, bool moveDown);
+	bool updateEvasiveAnimation(HarvesterEngine &engine);
 	bool startAttackAnimation(const RoomSetupState &state, const Common::Point &mousePos);
 	bool startKeyboardAttackAnimation(const RoomSetupState &state,
 		bool attackLeft, bool attackRight, bool attackUp, bool attackDown);
diff --git a/engines/harvester/scene.cpp b/engines/harvester/scene.cpp
index baaac7fd858..9b6a5f988df 100644
--- a/engines/harvester/scene.cpp
+++ b/engines/harvester/scene.cpp
@@ -21,6 +21,7 @@
 
 #include "harvester/scene.h"
 
+#include "backends/keymapper/keymapper.h"
 #include "common/algorithm.h"
 #include "common/events.h"
 #include "common/system.h"
@@ -531,8 +532,24 @@ bool Scene::drawRoomFrame(bool &needsRedraw) {
 }
 
 InputResult Scene::pollRoomEvents(Input &input, bool &needsRedraw) {
+	Common::Keymap *gameplay = g_system->getEventManager()->getKeymapper()->getKeymap("harvester-gameplay");
+	Common::Keymap *defaults = g_system->getEventManager()->getKeymapper()->getKeymap("engine-default");
+	const bool defaultsEnabled = defaults && defaults->isEnabled();
 	Common::Event event;
-	while (g_system->getEventManager()->pollEvent(event)) {
+	while (true) {
+		// Nested menus and dialogue retain their keyboard input while the room
+		// handler runs. Only the room event poll uses the gameplay actions.
+		if (gameplay)
+			gameplay->setEnabled(true);
+		if (gameplay && defaults)
+			defaults->setEnabled(false);
+		const bool hasEvent = g_system->getEventManager()->pollEvent(event);
+		if (gameplay)
+			gameplay->setEnabled(false);
+		if (gameplay && defaults)
+			defaults->setEnabled(defaultsEnabled);
+		if (!hasEvent)
+			break;
 		const InputResult inputResult = input.handleEvent(event, needsRedraw);
 		if (inputResult.returnFromRoom)
 			return inputResult;
@@ -546,6 +563,7 @@ Scene::PlayerControlState Scene::preparePlayerControlState() {
 	const bool playerAlive = script && script->getPlayerCurrentHitPoints() > 0;
 	const bool playerControlPaused = script && script->isPlayerControlPaused();
 	const bool playerCanAct = playerAlive && !playerControlPaused;
+	(void)_player.updateAnimationRate(!playerControlPaused && _inputState.speedUpActive);
 	if (!playerAlive && (_inputState.moveLeft || _inputState.moveRight || _inputState.moveUp || _inputState.moveDown ||
 			playerState.hasMoveTarget || playerState.turnActive ||
 			playerState.attackActive || playerState.hitActive)) {
@@ -572,6 +590,8 @@ void Scene::updatePlayerCombatAnimations(bool &needsRedraw) {
 	if (_player.updateHitAnimationState(_engine, _scene.state, _scene.sceneObjects, _scene.sceneAnimations)) {
 		needsRedraw = true;
 	}
+	if (_player.updateEvasiveAnimation(_engine))
+		needsRedraw = true;
 }
 
 void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsRedraw) {
@@ -580,7 +600,7 @@ void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsR
 	const bool keyboardAttackRequested =
 		_inputState.attackModifierHeld && (_inputState.moveLeft || _inputState.moveRight || _inputState.moveUp || _inputState.moveDown);
 	if (control.canAct &&
-			!playerState.attackActive && !playerState.hitActive && keyboardAttackRequested &&
+			!playerState.attackActive && !playerState.hitActive && playerState.evasiveState < 0 && keyboardAttackRequested &&
 			!idleState.active && !idleState.exiting) {
 		const uint32 now = Player::getRuntimeClockTicks();
 		if (_inputState.nextKeyboardAttackAllowedTick == 0 || (int32)(now - _inputState.nextKeyboardAttackAllowedTick) >= 0) {
@@ -611,10 +631,18 @@ void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsR
 	}
 
 	if (control.canAct &&
-			!playerState.attackActive && !playerState.hitActive &&
+			!playerState.attackActive && !playerState.hitActive && playerState.evasiveState < 0 &&
 			!keyboardAttackRequested && !idleState.active && !idleState.exiting) {
 		const int regionFacing = _interactionProcessor.getPendingRegionFacing();
-		if (_player.stepKeyboardMovement(_engine, _scene.state, _scene.sceneObjects, _scene.sceneAnimations,
+		if (_inputState.evasiveModifierHeld) {
+			if (_player.startEvasiveAnimation(_inputState.moveLeft, _inputState.moveRight,
+					_inputState.moveUp, _inputState.moveDown) ||
+					_player.stepKeyboardMovement(_engine, _scene.state, _scene.sceneObjects, _scene.sceneAnimations,
+						false, false, false, false)) {
+				notePlayerActivity();
+				needsRedraw = true;
+			}
+		} else if (_player.stepKeyboardMovement(_engine, _scene.state, _scene.sceneObjects, _scene.sceneAnimations,
 				_inputState.moveLeft, _inputState.moveRight, _inputState.moveUp, _inputState.moveDown)) {
 			notePlayerActivity();
 			needsRedraw = true;
@@ -625,7 +653,7 @@ void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsR
 
 		if (!_presentationState.showingInspectText && !keyboardAttackRequested &&
 				!_inputState.moveLeft && !_inputState.moveRight && !_inputState.moveUp && !_inputState.moveDown &&
-				!playerState.hasMoveTarget && !playerState.turnActive && !playerState.hitActive &&
+				!playerState.hasMoveTarget && !playerState.turnActive && !playerState.hitActive && playerState.evasiveState < 0 &&
 				playerState.entity && playerState.facing >= 0 &&
 				!Player::isIdleAnimationExcludedRoom(_scene.state.roomName) &&
 				Player::getRuntimeClockTicks() > idleState.triggerTick &&


Commit: a1d676146395f00978253e915e58a83eeb6cfc1d
    https://github.com/scummvm/scummvm/commit/a1d676146395f00978253e915e58a83eeb6cfc1d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Add remappable attack, duck and jump actions

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/input.cpp
    engines/harvester/input.h
    engines/harvester/metaengine.cpp
    engines/harvester/scene.cpp


diff --git a/engines/harvester/input.cpp b/engines/harvester/input.cpp
index 7d13ef95605..f296ff6175c 100644
--- a/engines/harvester/input.cpp
+++ b/engines/harvester/input.cpp
@@ -843,6 +843,46 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 }
 
 InputResult Input::handleGameAction(GameAction action, bool pressed, bool &needsRedraw) {
+	InputState &inputState = _session.getInputState();
+	bool *heldAction = nullptr;
+	switch (action) {
+	case kActionHighAttack:
+		heldAction = &inputState.highAttackHeld;
+		break;
+	case kActionMediumAttack:
+		heldAction = &inputState.mediumAttackHeld;
+		break;
+	case kActionLowAttack:
+		heldAction = &inputState.lowAttackHeld;
+		break;
+	case kActionDuck:
+		heldAction = &inputState.duckHeld;
+		break;
+	case kActionJump:
+		heldAction = &inputState.jumpHeld;
+		break;
+	default:
+		break;
+	}
+	if (heldAction) {
+		inputState.speedUpActive = false;
+		if (!pressed) {
+			*heldAction = false;
+			return InputResult();
+		}
+		_session.notePlayerActivity();
+		if (_presentationState.showingInspectText || _inventory.isOpen())
+			return InputResult();
+		Player &player = _session.getPlayer();
+		const RoomIdleAnimationState &idleState = player.getIdleState();
+		if (idleState.active || idleState.exiting) {
+			if (player.requestIdleAnimationExit(_scene.state))
+				needsRedraw = true;
+			return InputResult();
+		}
+		*heldAction = true;
+		return InputResult();
+	}
 	static const Common::KeyCode keys[] = {
 		Common::KEYCODE_LEFT, Common::KEYCODE_RIGHT, Common::KEYCODE_UP, Common::KEYCODE_DOWN,
 		Common::KEYCODE_LCTRL, Common::KEYCODE_RSHIFT, Common::KEYCODE_LALT,
diff --git a/engines/harvester/input.h b/engines/harvester/input.h
index ac340b018e2..9865e564217 100644
--- a/engines/harvester/input.h
+++ b/engines/harvester/input.h
@@ -57,7 +57,12 @@ enum GameAction {
 	kActionEvasiveModifier,
 	kActionSpeedUp,
 	kActionInventory,
-	kActionMenu
+	kActionMenu,
+	kActionHighAttack,
+	kActionMediumAttack,
+	kActionLowAttack,
+	kActionDuck,
+	kActionJump
 };
 
 struct InputState {
@@ -68,12 +73,18 @@ struct InputState {
 	bool attackModifierHeld = false;
 	bool evasiveModifierHeld = false;
 	bool speedUpActive = false;
+	bool highAttackHeld = false;
+	bool mediumAttackHeld = false;
+	bool lowAttackHeld = false;
+	bool duckHeld = false;
+	bool jumpHeld = false;
 	uint32 lastLeftButtonReleaseTick = 0;
 	uint32 nextKeyboardAttackAllowedTick = 0;
 
 	void clearHeldActions() {
 		moveLeft = moveRight = moveUp = moveDown = false;
 		attackModifierHeld = evasiveModifierHeld = speedUpActive = false;
+		highAttackHeld = mediumAttackHeld = lowAttackHeld = duckHeld = jumpHeld = false;
 	}
 };
 
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
index a91f4f8dbd7..a51f6ff6be3 100644
--- a/engines/harvester/metaengine.cpp
+++ b/engines/harvester/metaengine.cpp
@@ -92,14 +92,20 @@ Common::KeymapArray HarvesterMetaEngine::initKeymaps(const char *target) const {
 		{ "EVADE", _s("Evasive movement modifier"), kActionEvasiveModifier, "RSHIFT", nullptr },
 		{ "SPEEDUP", _s("Speed up"), kActionSpeedUp, "LALT", "RALT" },
 		{ "INVENTORY", _s("Inventory"), kActionInventory, "i", nullptr },
-		{ "MENU", _s("Game menu"), kActionMenu, "ESCAPE", "JOY_Y" }
+		{ "MENU", _s("Game menu"), kActionMenu, "ESCAPE", "JOY_Y" },
+		{ "HIGHATTACK", _s("High attack"), kActionHighAttack, nullptr, nullptr },
+		{ "MEDIUMATTACK", _s("Medium attack"), kActionMediumAttack, nullptr, nullptr },
+		{ "LOWATTACK", _s("Low attack"), kActionLowAttack, nullptr, nullptr },
+		{ "DUCK", _s("Duck"), kActionDuck, nullptr, nullptr },
+		{ "JUMP", _s("Jump"), kActionJump, nullptr, nullptr }
 	};
 	for (const auto &binding : bindings) {
 		Action *action = new Action(binding.id, _(binding.description));
 		action->setCustomEngineActionEvent(binding.action);
-		if (binding.action <= kActionSpeedUp)
+		if (binding.action <= kActionSpeedUp || binding.action >= kActionHighAttack)
 			action->allowKbdRepeats();
-		action->addDefaultInputMapping(binding.mapping);
+		if (binding.mapping)
+			action->addDefaultInputMapping(binding.mapping);
 		if (binding.alternateMapping)
 			action->addDefaultInputMapping(binding.alternateMapping);
 		gameplay->addAction(action);
diff --git a/engines/harvester/scene.cpp b/engines/harvester/scene.cpp
index 9b6a5f988df..3f61575dbae 100644
--- a/engines/harvester/scene.cpp
+++ b/engines/harvester/scene.cpp
@@ -597,17 +597,24 @@ void Scene::updatePlayerCombatAnimations(bool &needsRedraw) {
 void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsRedraw) {
 	const RoomPlayerState &playerState = _player.getState();
 	const RoomIdleAnimationState &idleState = _player.getIdleState();
+	const bool attackLeft = (_inputState.attackModifierHeld && _inputState.moveLeft) ||
+		(_inputState.mediumAttackHeld && playerState.facing == 1);
+	const bool attackRight = (_inputState.attackModifierHeld && _inputState.moveRight) ||
+		(_inputState.mediumAttackHeld && playerState.facing == 2);
+	const bool attackUp = _inputState.highAttackHeld || (_inputState.attackModifierHeld && _inputState.moveUp);
+	const bool attackDown = _inputState.lowAttackHeld || (_inputState.attackModifierHeld && _inputState.moveDown);
 	const bool keyboardAttackRequested =
-		_inputState.attackModifierHeld && (_inputState.moveLeft || _inputState.moveRight || _inputState.moveUp || _inputState.moveDown);
+		attackLeft || attackRight || attackUp || attackDown || _inputState.mediumAttackHeld;
+	const bool directEvasiveRequested = _inputState.duckHeld || _inputState.jumpHeld;
 	if (control.canAct &&
 			!playerState.attackActive && !playerState.hitActive && playerState.evasiveState < 0 && keyboardAttackRequested &&
 			!idleState.active && !idleState.exiting) {
 		const uint32 now = Player::getRuntimeClockTicks();
 		if (_inputState.nextKeyboardAttackAllowedTick == 0 || (int32)(now - _inputState.nextKeyboardAttackAllowedTick) >= 0) {
 			_combat.capturePlayerAttackTargetForKeyboard(
-				_inputState.moveLeft, _inputState.moveRight, _inputState.moveUp, _inputState.moveDown);
+				attackLeft, attackRight, attackUp, attackDown);
 			if (_player.startKeyboardAttackAnimation(_scene.state,
-					_inputState.moveLeft, _inputState.moveRight, _inputState.moveUp, _inputState.moveDown)) {
+					attackLeft, attackRight, attackUp, attackDown)) {
 				_inputState.nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
 				notePlayerActivity();
 				debugC(1, kDebugCombat,
@@ -618,7 +625,7 @@ void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsR
 					Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(playerState.combatLoadout)),
 					describeCombatTargetClass(playerState.attackTargetClassId),
 					playerState.attackTargetName.c_str(),
-					_inputState.moveLeft, _inputState.moveRight, _inputState.moveUp, _inputState.moveDown, playerState.facing);
+					attackLeft, attackRight, attackUp, attackDown, playerState.facing);
 				needsRedraw = true;
 			} else {
 				_player.clearAttackTarget();
@@ -634,9 +641,12 @@ void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsR
 			!playerState.attackActive && !playerState.hitActive && playerState.evasiveState < 0 &&
 			!keyboardAttackRequested && !idleState.active && !idleState.exiting) {
 		const int regionFacing = _interactionProcessor.getPendingRegionFacing();
-		if (_inputState.evasiveModifierHeld) {
-			if (_player.startEvasiveAnimation(_inputState.moveLeft, _inputState.moveRight,
-					_inputState.moveUp, _inputState.moveDown) ||
+		if (_inputState.evasiveModifierHeld || directEvasiveRequested) {
+			if (_player.startEvasiveAnimation(
+					!directEvasiveRequested && _inputState.moveLeft,
+					!directEvasiveRequested && _inputState.moveRight,
+					_inputState.jumpHeld || (!directEvasiveRequested && _inputState.moveUp),
+					_inputState.duckHeld || (!directEvasiveRequested && _inputState.moveDown)) ||
 					_player.stepKeyboardMovement(_engine, _scene.state, _scene.sceneObjects, _scene.sceneAnimations,
 						false, false, false, false)) {
 				notePlayerActivity();
@@ -651,7 +661,7 @@ void Scene::updatePlayerControls(const PlayerControlState &control, bool &needsR
 			needsRedraw = true;
 		}
 
-		if (!_presentationState.showingInspectText && !keyboardAttackRequested &&
+		if (!_presentationState.showingInspectText && !keyboardAttackRequested && !directEvasiveRequested &&
 				!_inputState.moveLeft && !_inputState.moveRight && !_inputState.moveUp && !_inputState.moveDown &&
 				!playerState.hasMoveTarget && !playerState.turnActive && !playerState.hitActive && playerState.evasiveState < 0 &&
 				playerState.entity && playerState.facing >= 0 &&


Commit: 255088e37d6530d859155b1964e3b81778fa62f2
    https://github.com/scummvm/scummvm/commit/255088e37d6530d859155b1964e3b81778fa62f2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Separate town script record parsing

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/script_records.cpp
    engines/harvester/module.mk
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index ddcde655bc6..4ccd5daa022 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -26,6 +26,7 @@ MODULE_OBJS = \
 	entity.o \
 	saveload.o \
 	script.o \
+	script_records.o \
 	text.o \
 	xfile_archive.o \
 	npc/authority_dialogue.o \
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index bd69c58ef5b..a865c23c32e 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -42,7 +42,6 @@ static const char *const kConfigSectionName = "harvester";
 static const char *const kDefaultTownScript = "HARVEST.SCR";
 static const char *const kInventoryOwnerName = "INVENTORY";
 static const char *const kDefaultVoicePath = "SOUND/VOICE/";
-static const byte kTownScriptXorKey = 0xaa;
 static const float kDefaultPaletteBrightness = 1.0f;
 static const float kDimmedPaletteBrightness = 0.6f;
 static const int kDefaultPlayerCombatLoadout = 0;
@@ -388,18 +387,6 @@ static const char *resolveInventoryStatusObjectName(int hitPoints) {
 	return "INV_STAT1";
 }
 
-static Common::String trimAsciiLine(const Common::String &value) {
-	uint start = 0;
-	uint end = value.size();
-
-	while (start < end && (value[start] == ' ' || value[start] == '\t'))
-		++start;
-	while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t' || value[end - 1] == '\r'))
-		--end;
-
-	return value.substr(start, end - start);
-}
-
 static Common::String normalizeInteractionLabel(const Common::String &value) {
 	Common::String label = value;
 	for (uint i = 0; i < label.size(); ++i) {
@@ -432,30 +419,6 @@ static Common::String dialogueTextModeToConfigValue(StartupDialogueTextMode mode
 	}
 }
 
-static int parseEntranceFacing(const Common::String &direction) {
-	if (direction.equalsIgnoreCase("FRONT"))
-		return 0;
-	if (direction.equalsIgnoreCase("LEFT"))
-		return 1;
-	if (direction.equalsIgnoreCase("RIGHT"))
-		return 2;
-	if (direction.equalsIgnoreCase("BACK"))
-		return 3;
-
-	return -1;
-}
-
-static int parseDeathDamageType(const Common::String &value) {
-	if (value.equalsIgnoreCase("BLUDGE"))
-		return 1;
-	if (value.equalsIgnoreCase("SLASH"))
-		return 2;
-	if (value.equalsIgnoreCase("PROJ"))
-		return 4;
-
-	return 0;
-}
-
 static bool appendStartupAudioCommand(const CommandRecord &command, Common::Array<AudioCommand> &commands) {
 	if (command.opcodeName.equalsIgnoreCase("START_WAV")) {
 		AudioCommand audioCommand;
@@ -536,47 +499,6 @@ static bool isFullscreenRoomBackgroundObject(const ObjectRecord &candidate,
 	return width == 640 && height == 480;
 }
 
-static void tokenizeTownScriptLine(const Common::String &line, Common::Array<Common::String> &tokens) {
-	tokens.clear();
-
-	for (uint i = 0; i < line.size();) {
-		while (i < line.size() && (line[i] == ' ' || line[i] == '\t'))
-			++i;
-		if (i >= line.size())
-			break;
-
-		if (line[i] == '"') {
-			++i;
-			Common::String token;
-			bool hasEmbeddedQuote = false;
-			while (i < line.size()) {
-				const bool isClosingQuote = line[i] == '"' &&
-					(i + 1 == line.size() || line[i + 1] == ' ' || line[i + 1] == '\t');
-				if (isClosingQuote)
-					break;
-
-				// French TEXT records contain quotes inside underscore-separated values.
-				// The native word reader keeps them and strips only the outer quote pair.
-				if (line[i] == '"')
-					hasEmbeddedQuote = true;
-				token += line[i++];
-			}
-			if (i < line.size() && line[i] == '"')
-				++i;
-			if (hasEmbeddedQuote)
-				debugC(3, kDebugGeneral, "Harvester: TownScript token contains embedded quote value='%s'",
-					token.c_str());
-			tokens.push_back(Common::move(token));
-			continue;
-		}
-
-		Common::String token;
-		while (i < line.size() && line[i] != ' ' && line[i] != '\t')
-			token += line[i++];
-		tokens.push_back(Common::move(token));
-	}
-}
-
 bool Script::load(ResourceManager &resources) {
 	_path = kDefaultTownScript;
 	_data.clear();
@@ -907,467 +829,6 @@ void Script::setGammaLevel(int level) {
 	_gammaLevel = clampStartupOptionLevel(level);
 }
 
-void Script::decode() {
-	for (byte &value : _data) {
-		if (value == '\r' || value == '\n')
-			continue;
-
-		value ^= kTownScriptXorKey;
-	}
-}
-
-void Script::parseTownRecords(ResourceManager &resources) {
-	_entrances.clear();
-	_mapEntrances.clear();
-	_mapLocations.clear();
-	_rooms.clear();
-	_objects.clear();
-	_animations.clear();
-	_npcs.clear();
-	_monsters.clear();
-	_timers.clear();
-	_regions.clear();
-	_flags.clear();
-	_commands.clear();
-	_execLists.clear();
-	_texts.clear();
-	_heads.clear();
-	_useItems.clear();
-
-	uint32 anonymousObjectId = 0;
-	auto parseLine = [&](const Common::String &rawLine) {
-		const Common::String line = trimAsciiLine(rawLine);
-		if (line.empty() || line[0] == '{')
-			return;
-
-		Common::Array<Common::String> tokens;
-		tokenizeTownScriptLine(line, tokens);
-		if (tokens.empty())
-			return;
-
-		uint tagIndex = tokens.size();
-		for (uint i = 0; i < tokens.size(); ++i) {
-			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "MAP_ENTRANCE" ||
-				tokens[i] == "MAP_LOCATION" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
-				tokens[i] == "NPC" || tokens[i] == "MONSTER" || tokens[i] == "REGION" ||
-				tokens[i] == "HEAD" || tokens[i] == "FLAG" || tokens[i] == "COMMAND" ||
-				tokens[i] == "EXEC_LIST" || tokens[i] == "TEXT" || tokens[i] == "TIMER" ||
-				tokens[i] == "USEITEM") {
-				tagIndex = i;
-				break;
-			}
-		}
-		if (tagIndex == tokens.size())
-			return;
-
-		const Common::String &tag = tokens[tagIndex];
-		if (tag == "FLAG") {
-			if (tokens.size() < tagIndex + 3)
-				return;
-
-			FlagRecord flag;
-			flag.name = tokens[tagIndex + 1];
-			flag.value = tokens[tagIndex + 2].equalsIgnoreCase("T");
-			if (!flag.name.empty())
-				_flags.push_back(Common::move(flag));
-			return;
-		}
-
-		if (tag == "COMMAND") {
-			if (tokens.size() < tagIndex + 6)
-				return;
-
-			CommandRecord command;
-			command.triggerTag = tokens[tagIndex + 1];
-			command.opcodeName = tokens[tagIndex + 2];
-			command.arg1 = resources.normalizeResourcePath(tokens[tagIndex + 3]);
-			command.arg2 = tokens[tagIndex + 4];
-			command.arg3 = tokens[tagIndex + 5];
-			command.arg4 = tokens.size() > tagIndex + 6 ? tokens[tagIndex + 6] : Common::String();
-
-			// Only path-like operands should be normalized.
-			if (!command.opcodeName.equalsIgnoreCase("SPOOL_MUSIC") &&
-				!command.opcodeName.equalsIgnoreCase("GOFLIC") &&
-				!command.opcodeName.equalsIgnoreCase("START_WAV") &&
-				!command.opcodeName.equalsIgnoreCase("LOAD_WAV") &&
-				!command.opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
-				command.arg1 = tokens[tagIndex + 3];
-			}
-
-			if (!command.triggerTag.empty() && !command.opcodeName.empty())
-				_commands.push_back(Common::move(command));
-			return;
-		}
-
-		if (tag == "EXEC_LIST") {
-			if (tokens.size() < tagIndex + 2)
-				return;
-
-			ExecListRecord execList;
-			execList.listName = tokens[tagIndex + 1];
-			for (uint i = tagIndex + 2; i < tokens.size(); ++i) {
-				if (tokens[i].empty())
-					break;
-				execList.entries.push_back(tokens[i]);
-			}
-			if (!execList.listName.empty())
-				_execLists.push_back(Common::move(execList));
-			return;
-		}
-
-		if (tag == "TEXT") {
-			if (tokens.size() < tagIndex + 4)
-				return;
-
-			TextRecord textRecord;
-			textRecord.key = tokens[tagIndex + 1];
-			textRecord.boxName = tokens[tagIndex + 2];
-			textRecord.value = tokens[tagIndex + 3];
-			for (uint i = 0; i < textRecord.value.size(); ++i) {
-				if (textRecord.value[i] == '_')
-					textRecord.value.setChar(' ', i);
-			}
-			if (!textRecord.key.empty())
-				_texts.push_back(Common::move(textRecord));
-			return;
-		}
-
-		if (tag == "HEAD") {
-			if (tokens.size() < tagIndex + 3)
-				return;
-
-			HeadRecord head;
-			head.headId = tokens[tagIndex + 1];
-			head.portraitPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
-			if (!head.headId.empty() && !head.portraitPath.empty())
-				_heads.push_back(Common::move(head));
-			return;
-		}
-
-		if (tag == "USEITEM") {
-			if (tokens.size() < tagIndex + 5)
-				return;
-
-			UseItemRecord useItem;
-			useItem.itemName = tokens[tagIndex + 1];
-			useItem.ownerOrRoom = tokens[tagIndex + 2];
-			useItem.targetName = tokens[tagIndex + 3];
-			useItem.actionTag = tokens[tagIndex + 4];
-			if (!useItem.itemName.empty() && !useItem.targetName.empty())
-				_useItems.push_back(Common::move(useItem));
-			return;
-		}
-
-		if (tag == "ENTRANCE") {
-			if (tokens.size() < tagIndex + 4)
-				return;
-
-			EntranceRecord entrance;
-			if (tagIndex >= 3) {
-				entrance.posX = parseAsciiIntOrZero(tokens[0]);
-				entrance.posY = parseAsciiIntOrZero(tokens[1]);
-				entrance.posZ = parseAsciiIntOrZero(tokens[2]);
-			}
-			entrance.direction = tokens[tagIndex + 1];
-			entrance.facing = parseEntranceFacing(entrance.direction);
-			entrance.roomName = tokens[tagIndex + 2];
-			entrance.entranceName = tokens[tagIndex + 3];
-			if (!entrance.roomName.empty() && !entrance.entranceName.empty())
-				_entrances.push_back(Common::move(entrance));
-			return;
-		}
-
-		if (tag == "MAP_ENTRANCE") {
-			if (tokens.size() < tagIndex + 2)
-				return;
-
-			MapEntranceRecord mapEntrance;
-			if (tagIndex >= 3) {
-				mapEntrance.mapX = parseAsciiIntOrZero(tokens[0]);
-				mapEntrance.mapY = parseAsciiIntOrZero(tokens[1]);
-				mapEntrance.initialPanelIndex = parseAsciiIntOrZero(tokens[2]);
-			}
-			mapEntrance.entryName = tokens[tagIndex + 1];
-			if (!mapEntrance.entryName.empty())
-				_mapEntrances.push_back(Common::move(mapEntrance));
-			return;
-		}
-
-		if (tag == "MAP_LOCATION") {
-			if (tokens.size() < tagIndex + 3)
-				return;
-
-			MapLocationRecord mapLocation;
-			if (tagIndex >= 7) {
-				mapLocation.minX = parseAsciiIntOrZero(tokens[0]);
-				mapLocation.minY = parseAsciiIntOrZero(tokens[1]);
-				mapLocation.maxX = parseAsciiIntOrZero(tokens[2]);
-				mapLocation.maxY = parseAsciiIntOrZero(tokens[3]);
-				mapLocation.panelIndex = parseAsciiIntOrZero(tokens[4]);
-				mapLocation.labelX = parseAsciiIntOrZero(tokens[5]);
-				mapLocation.labelY = parseAsciiIntOrZero(tokens[6]);
-			}
-			mapLocation.labelText = tokens[tagIndex + 1];
-			for (uint i = 0; i < mapLocation.labelText.size(); ++i) {
-				if (mapLocation.labelText[i] == '_')
-					mapLocation.labelText.setChar(' ', i);
-			}
-			mapLocation.destinationEntranceName = tokens[tagIndex + 2];
-			if (!mapLocation.destinationEntranceName.empty())
-				_mapLocations.push_back(Common::move(mapLocation));
-			return;
-		}
-
-		if (tag == "ROOM") {
-			if (tokens.size() < tagIndex + 10)
-				return;
-
-			RoomRecord room;
-			if (tagIndex >= 6) {
-				room.minZ = parseAsciiIntOrZero(tokens[0]);
-				room.maxZ = parseAsciiIntOrZero(tokens[1]);
-				room.maxZScreenY = parseAsciiIntOrZero(tokens[2]);
-				room.minZScreenY = parseAsciiIntOrZero(tokens[3]);
-				room.fullScaleZ = parseAsciiIntOrZero(tokens[4]);
-				room.maxZScalePercent = parseAsciiIntOrZero(tokens[5]);
-				if (room.maxZ != room.fullScaleZ) {
-					room.perspectiveScale = ((100.0f - (float)room.maxZScalePercent) /
-						(float)(room.maxZ - room.fullScaleZ)) * 0.01f;
-				}
-			}
-			room.roomName = tokens[tagIndex + 1];
-			room.musicPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
-			room.reservedString38 = tokens[tagIndex + 3];
-			room.reservedString3c = tokens[tagIndex + 4];
-			room.reservedString40 = tokens[tagIndex + 5];
-			room.palettePath = resources.normalizeResourcePath(tokens[tagIndex + 6]);
-			room.dimmable = tokens[tagIndex + 7].equalsIgnoreCase("T");
-			room.onEnterCommand = tokens[tagIndex + 8];
-			room.onExitCommand = tokens[tagIndex + 9];
-			if (!room.roomName.empty())
-				_rooms.push_back(Common::move(room));
-			return;
-		}
-
-		if (tag == "NPC") {
-			if (tokens.size() < tagIndex + 10)
-				return;
-
-			NpcRecord npc;
-			if (tagIndex >= 4) {
-				npc.posX = parseAsciiIntOrZero(tokens[0]);
-				npc.posY = parseAsciiIntOrZero(tokens[1]);
-				npc.posZ = parseAsciiIntOrZero(tokens[2]);
-				npc.frameDelay = parseAsciiIntOrZero(tokens[3]);
-			}
-			npc.roomName = tokens[tagIndex + 1];
-			npc.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
-			npc.npcName = tokens[tagIndex + 3];
-			npc.monsterfyTargetName = tokens[tagIndex + 4];
-			npc.active = tokens[tagIndex + 5].equalsIgnoreCase("T");
-			npc.visible = tokens[tagIndex + 6].equalsIgnoreCase("T");
-			if (npc.active)
-				npc.visible = true;
-			npc.savedVisible = npc.visible;
-			npc.onDeathActionTag = tokens[tagIndex + 7];
-			npc.audioPath = resources.normalizeResourcePath(tokens[tagIndex + 8]);
-			npc.entityInitArg = tokens[tagIndex + 9];
-			if (!npc.roomName.empty() && !npc.modelPath.empty() && !npc.npcName.empty())
-				_npcs.push_back(Common::move(npc));
-			return;
-		}
-
-		if (tag == "MONSTER") {
-			if (tokens.size() < tagIndex + 24)
-				return;
-
-			MonsterRecord monster;
-			if (tagIndex >= 6) {
-				monster.posX = parseAsciiIntOrZero(tokens[0]);
-				monster.posY = parseAsciiIntOrZero(tokens[1]);
-				monster.posZ = parseAsciiIntOrZero(tokens[2]);
-				// Native MonsterRecord numeric order is engage distance, initial HP, then damage.
-				monster.engageDistance = parseAsciiIntOrZero(tokens[3]);
-				monster.initialHitPoints = parseAsciiIntOrZero(tokens[4]);
-				monster.damageAmount = parseAsciiIntOrZero(tokens[5]);
-			}
-			if (tagIndex >= 10) {
-				monster.attackSoundTriggerFrame = parseAsciiIntOrZero(tokens[6]);
-				monster.hitSoundTriggerFrame = parseAsciiIntOrZero(tokens[7]);
-				monster.footstepSoundTriggerFrame = parseAsciiIntOrZero(tokens[8]);
-				monster.deathSoundTriggerFrame = parseAsciiIntOrZero(tokens[9]);
-			}
-			monster.roomName = tokens[tagIndex + 1];
-			monster.monsterName = tokens[tagIndex + 2];
-			monster.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
-			monster.reservedString44 = tokens[tagIndex + 4];
-			monster.reservedString48 = tokens[tagIndex + 5];
-			monster.reservedString38 = tokens[tagIndex + 6];
-			monster.reservedString3c = tokens[tagIndex + 7];
-			monster.facing = parseEntranceFacing(tokens[tagIndex + 8]);
-			monster.damageType = parseDeathDamageType(tokens[tagIndex + 9]);
-			monster.attackSound1 = resources.normalizeResourcePath(tokens[tagIndex + 10]);
-			monster.attackSound2 = resources.normalizeResourcePath(tokens[tagIndex + 11]);
-			monster.attackSound3 = resources.normalizeResourcePath(tokens[tagIndex + 12]);
-			monster.hitSound1 = resources.normalizeResourcePath(tokens[tagIndex + 13]);
-			monster.hitSound2 = resources.normalizeResourcePath(tokens[tagIndex + 14]);
-			monster.hitSound3 = resources.normalizeResourcePath(tokens[tagIndex + 15]);
-			monster.footstepSoundLeft = resources.normalizeResourcePath(tokens[tagIndex + 16]);
-			monster.footstepSoundRight = resources.normalizeResourcePath(tokens[tagIndex + 17]);
-			monster.deathSound = resources.normalizeResourcePath(tokens[tagIndex + 18]);
-			monster.active = tokens[tagIndex + 19].equalsIgnoreCase("T");
-			monster.visible = tokens[tagIndex + 20].equalsIgnoreCase("T");
-			monster.onDeathActionTag = tokens[tagIndex + 21];
-			if (tokens.size() > tagIndex + 22 && !tokens[tagIndex + 22].empty())
-				monster.screenMinXBound = parseAsciiIntOrZero(tokens[tagIndex + 22]);
-			if (tokens.size() > tagIndex + 23 && !tokens[tagIndex + 23].empty())
-				monster.screenMaxXBound = parseAsciiIntOrZero(tokens[tagIndex + 23]);
-			monster.currentHitPoints = monster.initialHitPoints;
-			monster.savedVisible = monster.visible;
-			if (monster.active)
-				monster.visible = true;
-			if (!monster.roomName.empty() && !monster.monsterName.empty() && !monster.modelPath.empty()) {
-				monster.recordIndex = (int)_monsters.size();
-				_monsters.push_back(Common::move(monster));
-			}
-			return;
-		}
-
-		if (tag == "TIMER") {
-			if (tokens.size() < tagIndex + 7)
-				return;
-
-			TimerRecord timer;
-			if (tagIndex >= 1) {
-				timer.initialValue = parseAsciiIntOrZero(tokens[0]);
-				timer.currentValue = timer.initialValue;
-			}
-			timer.timerName = tokens[tagIndex + 1];
-			timer.arg1 = tokens[tagIndex + 2];
-			timer.arg2 = tokens[tagIndex + 3];
-			timer.enabled = tokens[tagIndex + 4].equalsIgnoreCase("T");
-			timer.looping = tokens[tagIndex + 5].equalsIgnoreCase("T");
-			timer.global = tokens[tagIndex + 6].equalsIgnoreCase("T");
-			if (!timer.timerName.empty())
-				_timers.push_back(Common::move(timer));
-			return;
-		}
-
-		if (tag == "REGION") {
-			if (tokens.size() < tagIndex + 7)
-				return;
-
-			RegionRecord region;
-			if (tagIndex >= 6) {
-				region.left = parseAsciiIntOrZero(tokens[0]);
-				region.top = parseAsciiIntOrZero(tokens[1]);
-				region.right = parseAsciiIntOrZero(tokens[2]);
-				region.bottom = parseAsciiIntOrZero(tokens[3]);
-				region.minZ = parseAsciiIntOrZero(tokens[4]);
-				region.maxZ = parseAsciiIntOrZero(tokens[5]);
-			}
-			region.regionName = tokens[tagIndex + 1];
-			region.direction = tokens[tagIndex + 2];
-			region.desiredFacing = parseEntranceFacing(region.direction);
-			region.roomName = tokens[tagIndex + 3];
-			region.actionTag = tokens[tagIndex + 4];
-			region.startEnabled = tokens[tagIndex + 5].equalsIgnoreCase("T");
-			region.cursorEnabled = tokens[tagIndex + 6].equalsIgnoreCase("T");
-			if (!region.roomName.empty() && !region.regionName.empty())
-				_regions.push_back(Common::move(region));
-			return;
-		}
-
-		if (tag == "ANIM") {
-			if (tokens.size() < tagIndex + 10)
-				return;
-
-			AnimRecord anim;
-			if (tagIndex >= 4) {
-				anim.x = parseAsciiIntOrZero(tokens[0]);
-				anim.y = parseAsciiIntOrZero(tokens[1]);
-				anim.z = parseAsciiIntOrZero(tokens[2]);
-				anim.frameDelay = parseAsciiIntOrZero(tokens[3]);
-			}
-			anim.roomName = tokens[tagIndex + 1];
-			anim.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
-			anim.animName = tokens[tagIndex + 3];
-			anim.active = tokens[tagIndex + 4].equalsIgnoreCase("T");
-			anim.visible = tokens[tagIndex + 5].equalsIgnoreCase("T");
-			anim.looping = tokens[tagIndex + 6].equalsIgnoreCase("T");
-			anim.backward = tokens[tagIndex + 7].equalsIgnoreCase("T");
-			anim.pingPong = tokens[tagIndex + 8].equalsIgnoreCase("T");
-			anim.remove = tokens[tagIndex + 9].equalsIgnoreCase("T");
-			anim.runtimeActive = anim.active;
-			anim.runtimeVisible = anim.visible;
-			if (!anim.roomName.empty() && !anim.resourcePath.empty() && !anim.animName.empty())
-				_animations.push_back(Common::move(anim));
-			return;
-		}
-
-		if (tokens.size() < tagIndex + 13)
-			return;
-
-		ObjectRecord object;
-		if (tagIndex >= 6) {
-			object.initialX = parseAsciiIntOrZero(tokens[0]);
-			object.initialY = parseAsciiIntOrZero(tokens[1]);
-			object.boundsX2 = parseAsciiIntOrZero(tokens[2]);
-			object.boundsY2 = parseAsciiIntOrZero(tokens[3]);
-			object.initialZ = parseAsciiIntOrZero(tokens[4]);
-			object.zExtent = parseAsciiIntOrZero(tokens[5]);
-		}
-		object.currentX = object.initialX;
-		object.currentY = object.initialY;
-		object.currentZ = object.initialZ;
-		object.initialOwnerOrRoom = tokens[tagIndex + 1];
-		object.objectName = tokens[tagIndex + 2];
-		object.spritePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
-		object.altSpritePath = resources.normalizeResourcePath(tokens[tagIndex + 4]);
-		object.reservedString40 = tokens[tagIndex + 5];
-		object.inventoryTextKey = tokens[tagIndex + 6];
-		object.reservedXFlag = tokens[tagIndex + 7];
-		object.identTextKey = tokens[tagIndex + 8];
-		object.operatable = tokens[tagIndex + 9].equalsIgnoreCase("T");
-		object.visible = tokens[tagIndex + 10].equalsIgnoreCase("T");
-		object.actionTag = tokens[tagIndex + 11];
-		object.interactionLabel = tokens[tagIndex + 12];
-		object.currentOwnerOrRoom = object.initialOwnerOrRoom;
-		object.runtimeVisible = object.visible;
-		object.identShown = object.identTextKey.empty();
-		if (object.objectName.empty()) {
-			// Native room scripts use unnamed OBJECT entries as blocker rectangles.
-			object.objectName = Common::String::format("__ANON_OBJECT_%u", anonymousObjectId++);
-		}
-		if (!object.initialOwnerOrRoom.empty())
-			_objects.push_back(Common::move(object));
-		};
-
-	Common::String line;
-	for (uint i = 0; i < _data.size(); ++i) {
-		const char ch = (char)_data[i];
-		if (ch == '\r')
-			continue;
-		if (ch == '\n') {
-			parseLine(line);
-			line.clear();
-			continue;
-		}
-
-		line += ch;
-	}
-
-	parseLine(line);
-
-	debug(1, "Harvester: parsed %u entrances, %u map entrances, %u map locations, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u exec_lists, %u texts, %u heads, %u useitems from '%s'",
-		(uint)_entrances.size(), (uint)_mapEntrances.size(), (uint)_mapLocations.size(),
-		(uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
-		(uint)_npcs.size(), (uint)_regions.size(),
-		(uint)_flags.size(), (uint)_commands.size(), (uint)_execLists.size(),
-		(uint)_texts.size(), (uint)_heads.size(),
-		(uint)_useItems.size(), _path.c_str());
-}
-
 bool Script::resolveRoomSetupState(const Common::String &entranceName, RoomSetupState &state,
 		ResourceManager &resources) {
 	state = RoomSetupState();
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 70cc7ff6f93..f045cbfc545 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -473,6 +473,32 @@ private:
 	bool loadConfig(ResourceManager &resources);
 	void decode();
 	void parseTownRecords(ResourceManager &resources);
+	void parseTownRecordLine(ResourceManager &resources,
+		const Common::String &rawLine, uint32 &anonymousObjectId);
+	void parseFlagRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseCommandRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseExecListRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseTextRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseHeadRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseUseItemRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseEntranceRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseMapEntranceRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseMapLocationRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseRoomRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseNpcRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseMonsterRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseTimerRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseRegionRecord(const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseAnimRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex);
+	void parseObjectRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex, uint32 &anonymousObjectId);
+	static int parseDeathDamageType(const Common::String &value);
 	const RoomRecord *findRoomRecord(const Common::String &roomName) const;
 	const CommandRecord *findCommandRecord(const Common::String &tag) const;
 	const ExecListRecord *findExecListRecord(const Common::String &name) const;
diff --git a/engines/harvester/script_records.cpp b/engines/harvester/script_records.cpp
new file mode 100644
index 00000000000..28550c473d4
--- /dev/null
+++ b/engines/harvester/script_records.cpp
@@ -0,0 +1,601 @@
+/* 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/script.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "harvester/detection.h"
+#include "harvester/parse_utils.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+static const byte kTownScriptXorKey = 0xaa;
+
+static Common::String trimAsciiLine(const Common::String &value) {
+	uint start = 0;
+	uint end = value.size();
+
+	while (start < end && (value[start] == ' ' || value[start] == '\t'))
+		++start;
+	while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t' || value[end - 1] == '\r'))
+		--end;
+
+	return value.substr(start, end - start);
+}
+
+static int parseEntranceFacing(const Common::String &direction) {
+	if (direction.equalsIgnoreCase("FRONT"))
+		return 0;
+	if (direction.equalsIgnoreCase("LEFT"))
+		return 1;
+	if (direction.equalsIgnoreCase("RIGHT"))
+		return 2;
+	if (direction.equalsIgnoreCase("BACK"))
+		return 3;
+
+	return -1;
+}
+
+int Script::parseDeathDamageType(const Common::String &value) {
+	if (value.equalsIgnoreCase("BLUDGE"))
+		return 1;
+	if (value.equalsIgnoreCase("SLASH"))
+		return 2;
+	if (value.equalsIgnoreCase("PROJ"))
+		return 4;
+
+	return 0;
+}
+
+static void tokenizeTownScriptLine(const Common::String &line, Common::Array<Common::String> &tokens) {
+	tokens.clear();
+
+	for (uint i = 0; i < line.size();) {
+		while (i < line.size() && (line[i] == ' ' || line[i] == '\t'))
+			++i;
+		if (i >= line.size())
+			break;
+
+		if (line[i] == '"') {
+			++i;
+			Common::String token;
+			bool hasEmbeddedQuote = false;
+			while (i < line.size()) {
+				const bool isClosingQuote = line[i] == '"' &&
+					(i + 1 == line.size() || line[i + 1] == ' ' || line[i + 1] == '\t');
+				if (isClosingQuote)
+					break;
+
+				// French TEXT records contain quotes inside underscore-separated values.
+				// The native word reader keeps them and strips only the outer quote pair.
+				if (line[i] == '"')
+					hasEmbeddedQuote = true;
+				token += line[i++];
+			}
+			if (i < line.size() && line[i] == '"')
+				++i;
+			if (hasEmbeddedQuote)
+				debugC(3, kDebugGeneral, "Harvester: TownScript token contains embedded quote value='%s'",
+					token.c_str());
+			tokens.push_back(Common::move(token));
+			continue;
+		}
+
+		Common::String token;
+		while (i < line.size() && line[i] != ' ' && line[i] != '\t')
+			token += line[i++];
+		tokens.push_back(Common::move(token));
+	}
+}
+
+void Script::decode() {
+	for (byte &value : _data) {
+		if (value == '\r' || value == '\n')
+			continue;
+
+		value ^= kTownScriptXorKey;
+	}
+}
+
+void Script::parseFlagRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 3)
+		return;
+
+	FlagRecord flag;
+	flag.name = tokens[tagIndex + 1];
+	flag.value = tokens[tagIndex + 2].equalsIgnoreCase("T");
+	if (!flag.name.empty())
+		_flags.push_back(Common::move(flag));
+}
+
+void Script::parseCommandRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 6)
+		return;
+
+	CommandRecord command;
+	command.triggerTag = tokens[tagIndex + 1];
+	command.opcodeName = tokens[tagIndex + 2];
+	command.arg1 = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+	command.arg2 = tokens[tagIndex + 4];
+	command.arg3 = tokens[tagIndex + 5];
+	command.arg4 = tokens.size() > tagIndex + 6 ? tokens[tagIndex + 6] : Common::String();
+
+	// Only path-like operands should be normalized.
+	if (!command.opcodeName.equalsIgnoreCase("SPOOL_MUSIC") &&
+		!command.opcodeName.equalsIgnoreCase("GOFLIC") &&
+		!command.opcodeName.equalsIgnoreCase("START_WAV") &&
+		!command.opcodeName.equalsIgnoreCase("LOAD_WAV") &&
+		!command.opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
+		command.arg1 = tokens[tagIndex + 3];
+	}
+
+	if (!command.triggerTag.empty() && !command.opcodeName.empty())
+		_commands.push_back(Common::move(command));
+}
+
+void Script::parseExecListRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 2)
+		return;
+
+	ExecListRecord execList;
+	execList.listName = tokens[tagIndex + 1];
+	for (uint i = tagIndex + 2; i < tokens.size(); ++i) {
+		if (tokens[i].empty())
+			break;
+		execList.entries.push_back(tokens[i]);
+	}
+	if (!execList.listName.empty())
+		_execLists.push_back(Common::move(execList));
+}
+
+void Script::parseTextRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 4)
+		return;
+
+	TextRecord textRecord;
+	textRecord.key = tokens[tagIndex + 1];
+	textRecord.boxName = tokens[tagIndex + 2];
+	textRecord.value = tokens[tagIndex + 3];
+	for (uint i = 0; i < textRecord.value.size(); ++i) {
+		if (textRecord.value[i] == '_')
+			textRecord.value.setChar(' ', i);
+	}
+	if (!textRecord.key.empty())
+		_texts.push_back(Common::move(textRecord));
+}
+
+void Script::parseHeadRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 3)
+		return;
+
+	HeadRecord head;
+	head.headId = tokens[tagIndex + 1];
+	head.portraitPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+	if (!head.headId.empty() && !head.portraitPath.empty())
+		_heads.push_back(Common::move(head));
+}
+
+void Script::parseUseItemRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 5)
+		return;
+
+	UseItemRecord useItem;
+	useItem.itemName = tokens[tagIndex + 1];
+	useItem.ownerOrRoom = tokens[tagIndex + 2];
+	useItem.targetName = tokens[tagIndex + 3];
+	useItem.actionTag = tokens[tagIndex + 4];
+	if (!useItem.itemName.empty() && !useItem.targetName.empty())
+		_useItems.push_back(Common::move(useItem));
+}
+
+void Script::parseEntranceRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 4)
+		return;
+
+	EntranceRecord entrance;
+	if (tagIndex >= 3) {
+		entrance.posX = parseAsciiIntOrZero(tokens[0]);
+		entrance.posY = parseAsciiIntOrZero(tokens[1]);
+		entrance.posZ = parseAsciiIntOrZero(tokens[2]);
+	}
+	entrance.direction = tokens[tagIndex + 1];
+	entrance.facing = parseEntranceFacing(entrance.direction);
+	entrance.roomName = tokens[tagIndex + 2];
+	entrance.entranceName = tokens[tagIndex + 3];
+	if (!entrance.roomName.empty() && !entrance.entranceName.empty())
+		_entrances.push_back(Common::move(entrance));
+}
+
+void Script::parseMapEntranceRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 2)
+		return;
+
+	MapEntranceRecord mapEntrance;
+	if (tagIndex >= 3) {
+		mapEntrance.mapX = parseAsciiIntOrZero(tokens[0]);
+		mapEntrance.mapY = parseAsciiIntOrZero(tokens[1]);
+		mapEntrance.initialPanelIndex = parseAsciiIntOrZero(tokens[2]);
+	}
+	mapEntrance.entryName = tokens[tagIndex + 1];
+	if (!mapEntrance.entryName.empty())
+		_mapEntrances.push_back(Common::move(mapEntrance));
+}
+
+void Script::parseMapLocationRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 3)
+		return;
+
+	MapLocationRecord mapLocation;
+	if (tagIndex >= 7) {
+		mapLocation.minX = parseAsciiIntOrZero(tokens[0]);
+		mapLocation.minY = parseAsciiIntOrZero(tokens[1]);
+		mapLocation.maxX = parseAsciiIntOrZero(tokens[2]);
+		mapLocation.maxY = parseAsciiIntOrZero(tokens[3]);
+		mapLocation.panelIndex = parseAsciiIntOrZero(tokens[4]);
+		mapLocation.labelX = parseAsciiIntOrZero(tokens[5]);
+		mapLocation.labelY = parseAsciiIntOrZero(tokens[6]);
+	}
+	mapLocation.labelText = tokens[tagIndex + 1];
+	for (uint i = 0; i < mapLocation.labelText.size(); ++i) {
+		if (mapLocation.labelText[i] == '_')
+			mapLocation.labelText.setChar(' ', i);
+	}
+	mapLocation.destinationEntranceName = tokens[tagIndex + 2];
+	if (!mapLocation.destinationEntranceName.empty())
+		_mapLocations.push_back(Common::move(mapLocation));
+}
+
+void Script::parseRoomRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 10)
+		return;
+
+	RoomRecord room;
+	if (tagIndex >= 6) {
+		room.minZ = parseAsciiIntOrZero(tokens[0]);
+		room.maxZ = parseAsciiIntOrZero(tokens[1]);
+		room.maxZScreenY = parseAsciiIntOrZero(tokens[2]);
+		room.minZScreenY = parseAsciiIntOrZero(tokens[3]);
+		room.fullScaleZ = parseAsciiIntOrZero(tokens[4]);
+		room.maxZScalePercent = parseAsciiIntOrZero(tokens[5]);
+		if (room.maxZ != room.fullScaleZ) {
+			room.perspectiveScale = ((100.0f - static_cast<float>(room.maxZScalePercent)) /
+				static_cast<float>(room.maxZ - room.fullScaleZ)) * 0.01f;
+		}
+	}
+	room.roomName = tokens[tagIndex + 1];
+	room.musicPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+	room.reservedString38 = tokens[tagIndex + 3];
+	room.reservedString3c = tokens[tagIndex + 4];
+	room.reservedString40 = tokens[tagIndex + 5];
+	room.palettePath = resources.normalizeResourcePath(tokens[tagIndex + 6]);
+	room.dimmable = tokens[tagIndex + 7].equalsIgnoreCase("T");
+	room.onEnterCommand = tokens[tagIndex + 8];
+	room.onExitCommand = tokens[tagIndex + 9];
+	if (!room.roomName.empty())
+		_rooms.push_back(Common::move(room));
+}
+
+void Script::parseNpcRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 10)
+		return;
+
+	NpcRecord npc;
+	if (tagIndex >= 4) {
+		npc.posX = parseAsciiIntOrZero(tokens[0]);
+		npc.posY = parseAsciiIntOrZero(tokens[1]);
+		npc.posZ = parseAsciiIntOrZero(tokens[2]);
+		npc.frameDelay = parseAsciiIntOrZero(tokens[3]);
+	}
+	npc.roomName = tokens[tagIndex + 1];
+	npc.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+	npc.npcName = tokens[tagIndex + 3];
+	npc.monsterfyTargetName = tokens[tagIndex + 4];
+	npc.active = tokens[tagIndex + 5].equalsIgnoreCase("T");
+	npc.visible = tokens[tagIndex + 6].equalsIgnoreCase("T");
+	if (npc.active)
+		npc.visible = true;
+	npc.savedVisible = npc.visible;
+	npc.onDeathActionTag = tokens[tagIndex + 7];
+	npc.audioPath = resources.normalizeResourcePath(tokens[tagIndex + 8]);
+	npc.entityInitArg = tokens[tagIndex + 9];
+	if (!npc.roomName.empty() && !npc.modelPath.empty() && !npc.npcName.empty())
+		_npcs.push_back(Common::move(npc));
+}
+
+void Script::parseMonsterRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 24)
+		return;
+
+	MonsterRecord monster;
+	if (tagIndex >= 6) {
+		monster.posX = parseAsciiIntOrZero(tokens[0]);
+		monster.posY = parseAsciiIntOrZero(tokens[1]);
+		monster.posZ = parseAsciiIntOrZero(tokens[2]);
+		// Native MonsterRecord numeric order is engage distance, initial HP, then damage.
+		monster.engageDistance = parseAsciiIntOrZero(tokens[3]);
+		monster.initialHitPoints = parseAsciiIntOrZero(tokens[4]);
+		monster.damageAmount = parseAsciiIntOrZero(tokens[5]);
+	}
+	if (tagIndex >= 10) {
+		monster.attackSoundTriggerFrame = parseAsciiIntOrZero(tokens[6]);
+		monster.hitSoundTriggerFrame = parseAsciiIntOrZero(tokens[7]);
+		monster.footstepSoundTriggerFrame = parseAsciiIntOrZero(tokens[8]);
+		monster.deathSoundTriggerFrame = parseAsciiIntOrZero(tokens[9]);
+	}
+	monster.roomName = tokens[tagIndex + 1];
+	monster.monsterName = tokens[tagIndex + 2];
+	monster.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+	monster.reservedString44 = tokens[tagIndex + 4];
+	monster.reservedString48 = tokens[tagIndex + 5];
+	monster.reservedString38 = tokens[tagIndex + 6];
+	monster.reservedString3c = tokens[tagIndex + 7];
+	monster.facing = parseEntranceFacing(tokens[tagIndex + 8]);
+	monster.damageType = parseDeathDamageType(tokens[tagIndex + 9]);
+	monster.attackSound1 = resources.normalizeResourcePath(tokens[tagIndex + 10]);
+	monster.attackSound2 = resources.normalizeResourcePath(tokens[tagIndex + 11]);
+	monster.attackSound3 = resources.normalizeResourcePath(tokens[tagIndex + 12]);
+	monster.hitSound1 = resources.normalizeResourcePath(tokens[tagIndex + 13]);
+	monster.hitSound2 = resources.normalizeResourcePath(tokens[tagIndex + 14]);
+	monster.hitSound3 = resources.normalizeResourcePath(tokens[tagIndex + 15]);
+	monster.footstepSoundLeft = resources.normalizeResourcePath(tokens[tagIndex + 16]);
+	monster.footstepSoundRight = resources.normalizeResourcePath(tokens[tagIndex + 17]);
+	monster.deathSound = resources.normalizeResourcePath(tokens[tagIndex + 18]);
+	monster.active = tokens[tagIndex + 19].equalsIgnoreCase("T");
+	monster.visible = tokens[tagIndex + 20].equalsIgnoreCase("T");
+	monster.onDeathActionTag = tokens[tagIndex + 21];
+	if (tokens.size() > tagIndex + 22 && !tokens[tagIndex + 22].empty())
+		monster.screenMinXBound = parseAsciiIntOrZero(tokens[tagIndex + 22]);
+	if (tokens.size() > tagIndex + 23 && !tokens[tagIndex + 23].empty())
+		monster.screenMaxXBound = parseAsciiIntOrZero(tokens[tagIndex + 23]);
+	monster.currentHitPoints = monster.initialHitPoints;
+	monster.savedVisible = monster.visible;
+	if (monster.active)
+		monster.visible = true;
+	if (!monster.roomName.empty() && !monster.monsterName.empty() && !monster.modelPath.empty()) {
+		monster.recordIndex = static_cast<int>(_monsters.size());
+		_monsters.push_back(Common::move(monster));
+	}
+}
+
+void Script::parseTimerRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 7)
+		return;
+
+	TimerRecord timer;
+	if (tagIndex >= 1) {
+		timer.initialValue = parseAsciiIntOrZero(tokens[0]);
+		timer.currentValue = timer.initialValue;
+	}
+	timer.timerName = tokens[tagIndex + 1];
+	timer.arg1 = tokens[tagIndex + 2];
+	timer.arg2 = tokens[tagIndex + 3];
+	timer.enabled = tokens[tagIndex + 4].equalsIgnoreCase("T");
+	timer.looping = tokens[tagIndex + 5].equalsIgnoreCase("T");
+	timer.global = tokens[tagIndex + 6].equalsIgnoreCase("T");
+	if (!timer.timerName.empty())
+		_timers.push_back(Common::move(timer));
+}
+
+void Script::parseRegionRecord(const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 7)
+		return;
+
+	RegionRecord region;
+	if (tagIndex >= 6) {
+		region.left = parseAsciiIntOrZero(tokens[0]);
+		region.top = parseAsciiIntOrZero(tokens[1]);
+		region.right = parseAsciiIntOrZero(tokens[2]);
+		region.bottom = parseAsciiIntOrZero(tokens[3]);
+		region.minZ = parseAsciiIntOrZero(tokens[4]);
+		region.maxZ = parseAsciiIntOrZero(tokens[5]);
+	}
+	region.regionName = tokens[tagIndex + 1];
+	region.direction = tokens[tagIndex + 2];
+	region.desiredFacing = parseEntranceFacing(region.direction);
+	region.roomName = tokens[tagIndex + 3];
+	region.actionTag = tokens[tagIndex + 4];
+	region.startEnabled = tokens[tagIndex + 5].equalsIgnoreCase("T");
+	region.cursorEnabled = tokens[tagIndex + 6].equalsIgnoreCase("T");
+	if (!region.roomName.empty() && !region.regionName.empty())
+		_regions.push_back(Common::move(region));
+}
+
+void Script::parseAnimRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex) {
+	if (tokens.size() < tagIndex + 10)
+		return;
+
+	AnimRecord anim;
+	if (tagIndex >= 4) {
+		anim.x = parseAsciiIntOrZero(tokens[0]);
+		anim.y = parseAsciiIntOrZero(tokens[1]);
+		anim.z = parseAsciiIntOrZero(tokens[2]);
+		anim.frameDelay = parseAsciiIntOrZero(tokens[3]);
+	}
+	anim.roomName = tokens[tagIndex + 1];
+	anim.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+	anim.animName = tokens[tagIndex + 3];
+	anim.active = tokens[tagIndex + 4].equalsIgnoreCase("T");
+	anim.visible = tokens[tagIndex + 5].equalsIgnoreCase("T");
+	anim.looping = tokens[tagIndex + 6].equalsIgnoreCase("T");
+	anim.backward = tokens[tagIndex + 7].equalsIgnoreCase("T");
+	anim.pingPong = tokens[tagIndex + 8].equalsIgnoreCase("T");
+	anim.remove = tokens[tagIndex + 9].equalsIgnoreCase("T");
+	anim.runtimeActive = anim.active;
+	anim.runtimeVisible = anim.visible;
+	if (!anim.roomName.empty() && !anim.resourcePath.empty() && !anim.animName.empty())
+		_animations.push_back(Common::move(anim));
+}
+
+void Script::parseObjectRecord(ResourceManager &resources,
+		const Common::Array<Common::String> &tokens, uint tagIndex, uint32 &anonymousObjectId) {
+	if (tokens.size() < tagIndex + 13)
+		return;
+
+	ObjectRecord object;
+	if (tagIndex >= 6) {
+		object.initialX = parseAsciiIntOrZero(tokens[0]);
+		object.initialY = parseAsciiIntOrZero(tokens[1]);
+		object.boundsX2 = parseAsciiIntOrZero(tokens[2]);
+		object.boundsY2 = parseAsciiIntOrZero(tokens[3]);
+		object.initialZ = parseAsciiIntOrZero(tokens[4]);
+		object.zExtent = parseAsciiIntOrZero(tokens[5]);
+	}
+	object.currentX = object.initialX;
+	object.currentY = object.initialY;
+	object.currentZ = object.initialZ;
+	object.initialOwnerOrRoom = tokens[tagIndex + 1];
+	object.objectName = tokens[tagIndex + 2];
+	object.spritePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+	object.altSpritePath = resources.normalizeResourcePath(tokens[tagIndex + 4]);
+	object.reservedString40 = tokens[tagIndex + 5];
+	object.inventoryTextKey = tokens[tagIndex + 6];
+	object.reservedXFlag = tokens[tagIndex + 7];
+	object.identTextKey = tokens[tagIndex + 8];
+	object.operatable = tokens[tagIndex + 9].equalsIgnoreCase("T");
+	object.visible = tokens[tagIndex + 10].equalsIgnoreCase("T");
+	object.actionTag = tokens[tagIndex + 11];
+	object.interactionLabel = tokens[tagIndex + 12];
+	object.currentOwnerOrRoom = object.initialOwnerOrRoom;
+	object.runtimeVisible = object.visible;
+	object.identShown = object.identTextKey.empty();
+	if (object.objectName.empty()) {
+		// Native room scripts use unnamed OBJECT entries as blocker rectangles.
+		object.objectName = Common::String::format("__ANON_OBJECT_%u", anonymousObjectId++);
+	}
+	if (!object.initialOwnerOrRoom.empty())
+		_objects.push_back(Common::move(object));
+}
+
+void Script::parseTownRecordLine(ResourceManager &resources,
+		const Common::String &rawLine, uint32 &anonymousObjectId) {
+	const Common::String line = trimAsciiLine(rawLine);
+	if (line.empty() || line[0] == '{')
+		return;
+
+	Common::Array<Common::String> tokens;
+	tokenizeTownScriptLine(line, tokens);
+	if (tokens.empty())
+		return;
+
+	uint tagIndex = tokens.size();
+	for (uint i = 0; i < tokens.size(); ++i) {
+		if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "MAP_ENTRANCE" ||
+			tokens[i] == "MAP_LOCATION" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
+			tokens[i] == "NPC" || tokens[i] == "MONSTER" || tokens[i] == "REGION" ||
+			tokens[i] == "HEAD" || tokens[i] == "FLAG" || tokens[i] == "COMMAND" ||
+			tokens[i] == "EXEC_LIST" || tokens[i] == "TEXT" || tokens[i] == "TIMER" ||
+			tokens[i] == "USEITEM") {
+			tagIndex = i;
+			break;
+		}
+	}
+	if (tagIndex == tokens.size())
+		return;
+
+	const Common::String &tag = tokens[tagIndex];
+	if (tag == "FLAG")
+		parseFlagRecord(tokens, tagIndex);
+	else if (tag == "COMMAND")
+		parseCommandRecord(resources, tokens, tagIndex);
+	else if (tag == "EXEC_LIST")
+		parseExecListRecord(tokens, tagIndex);
+	else if (tag == "TEXT")
+		parseTextRecord(tokens, tagIndex);
+	else if (tag == "HEAD")
+		parseHeadRecord(resources, tokens, tagIndex);
+	else if (tag == "USEITEM")
+		parseUseItemRecord(tokens, tagIndex);
+	else if (tag == "ENTRANCE")
+		parseEntranceRecord(tokens, tagIndex);
+	else if (tag == "MAP_ENTRANCE")
+		parseMapEntranceRecord(tokens, tagIndex);
+	else if (tag == "MAP_LOCATION")
+		parseMapLocationRecord(tokens, tagIndex);
+	else if (tag == "ROOM")
+		parseRoomRecord(resources, tokens, tagIndex);
+	else if (tag == "NPC")
+		parseNpcRecord(resources, tokens, tagIndex);
+	else if (tag == "MONSTER")
+		parseMonsterRecord(resources, tokens, tagIndex);
+	else if (tag == "TIMER")
+		parseTimerRecord(tokens, tagIndex);
+	else if (tag == "REGION")
+		parseRegionRecord(tokens, tagIndex);
+	else if (tag == "ANIM")
+		parseAnimRecord(resources, tokens, tagIndex);
+	else
+		parseObjectRecord(resources, tokens, tagIndex, anonymousObjectId);
+}
+
+void Script::parseTownRecords(ResourceManager &resources) {
+	_entrances.clear();
+	_mapEntrances.clear();
+	_mapLocations.clear();
+	_rooms.clear();
+	_objects.clear();
+	_animations.clear();
+	_npcs.clear();
+	_monsters.clear();
+	_timers.clear();
+	_regions.clear();
+	_flags.clear();
+	_commands.clear();
+	_execLists.clear();
+	_texts.clear();
+	_heads.clear();
+	_useItems.clear();
+
+	uint32 anonymousObjectId = 0;
+
+	Common::String line;
+	for (uint i = 0; i < _data.size(); ++i) {
+		const char ch = static_cast<char>(_data[i]);
+		if (ch == '\r')
+			continue;
+		if (ch == '\n') {
+			parseTownRecordLine(resources, line, anonymousObjectId);
+			line.clear();
+			continue;
+		}
+
+		line += ch;
+	}
+
+	parseTownRecordLine(resources, line, anonymousObjectId);
+
+	debug(1, "Harvester: parsed %u entrances, %u map entrances, %u map locations, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u exec_lists, %u texts, %u heads, %u useitems from '%s'",
+		(uint)_entrances.size(), (uint)_mapEntrances.size(), (uint)_mapLocations.size(),
+		(uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
+		(uint)_npcs.size(), (uint)_regions.size(),
+		(uint)_flags.size(), (uint)_commands.size(), (uint)_execLists.size(),
+		(uint)_texts.size(), (uint)_heads.size(),
+		(uint)_useItems.size(), _path.c_str());
+}
+
+} // End of namespace Harvester


Commit: 1f1f13ca21fe72cb835cbfbb1452b2c65bdd6fe3
    https://github.com/scummvm/scummvm/commit/1f1f13ca21fe72cb835cbfbb1452b2c65bdd6fe3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Split command execution into opcode handlers

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/script_commands.cpp
    engines/harvester/module.mk
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 4ccd5daa022..a7f91293f6d 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -26,6 +26,7 @@ MODULE_OBJS = \
 	entity.o \
 	saveload.o \
 	script.o \
+	script_commands.o \
 	script_records.o \
 	text.o \
 	xfile_archive.o \
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index a865c23c32e..e70d2bc8d7c 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -30,7 +30,6 @@
 #include "common/memstream.h"
 #include "common/serializer.h"
 #include "harvester/detection.h"
-#include "harvester/harvester.h"
 #include "harvester/parse_utils.h"
 #include "harvester/player.h"
 #include "harvester/resources.h"
@@ -313,7 +312,7 @@ static int clampCombatResourceCount(int loadout, int count) {
 	return CLIP<int>(count, 0, maxCount);
 }
 
-static int resolveCombatResourcePickupMax(int loadout) {
+int Script::resolveCombatResourcePickupMax(int loadout) {
 	switch (loadout) {
 	case 2:
 	case 3:
@@ -328,22 +327,6 @@ static int resolveCombatResourcePickupMax(int loadout) {
 	}
 }
 
-static int resolveCombatResourceLoadoutForOpcode(const Common::String &opcodeName) {
-	// Confirmed from dispatch_room_event_actions and HARVEST.SCR.
-	if (opcodeName.equalsIgnoreCase("ADD_NAILS"))
-		return 2;
-	if (opcodeName.equalsIgnoreCase("ADD_SHOTGUN_SHELLS"))
-		return 3;
-	if (opcodeName.equalsIgnoreCase("ADD_GASCANS"))
-		return 14;
-
-	return 0;
-}
-
-static bool isCombatResourceOpcode(const Common::String &opcodeName) {
-	return resolveCombatResourceLoadoutForOpcode(opcodeName) != 0;
-}
-
 static const char *resolveCombatLoadoutInventoryObjectName(int loadout) {
 	for (const InventoryCombatLoadoutEntry &entry : kInventoryCombatLoadoutMap) {
 		if (entry.loadoutId == loadout)
@@ -419,51 +402,6 @@ static Common::String dialogueTextModeToConfigValue(StartupDialogueTextMode mode
 	}
 }
 
-static bool appendStartupAudioCommand(const CommandRecord &command, Common::Array<AudioCommand> &commands) {
-	if (command.opcodeName.equalsIgnoreCase("START_WAV")) {
-		AudioCommand audioCommand;
-		audioCommand.type = kStartupAudioCommandStartWav;
-		audioCommand.path = command.arg1;
-		commands.push_back(Common::move(audioCommand));
-		return true;
-	}
-
-	if (command.opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
-		AudioCommand audioCommand;
-		audioCommand.type = kStartupAudioCommandStartSingleWav;
-		audioCommand.path = command.arg1;
-		commands.push_back(Common::move(audioCommand));
-		return true;
-	}
-
-	if (command.opcodeName.equalsIgnoreCase("LOAD_WAV")) {
-		AudioCommand audioCommand;
-		audioCommand.type = kStartupAudioCommandLoadWav;
-		audioCommand.path = command.arg1;
-		audioCommand.slot = command.arg2.empty() ? -1 : parseAsciiIntOrZero(command.arg2);
-		commands.push_back(Common::move(audioCommand));
-		return true;
-	}
-
-	if (command.opcodeName.equalsIgnoreCase("PLAY_WAV")) {
-		AudioCommand audioCommand;
-		audioCommand.type = kStartupAudioCommandPlayWav;
-		audioCommand.slot = command.arg1.empty() ? -1 : parseAsciiIntOrZero(command.arg1);
-		commands.push_back(Common::move(audioCommand));
-		return true;
-	}
-
-	if (command.opcodeName.equalsIgnoreCase("DELETE_WAV")) {
-		AudioCommand audioCommand;
-		audioCommand.type = kStartupAudioCommandDeleteWav;
-		audioCommand.slot = command.arg1.empty() ? -1 : parseAsciiIntOrZero(command.arg1);
-		commands.push_back(Common::move(audioCommand));
-		return true;
-	}
-
-	return false;
-}
-
 static bool loadBitmapDimensions(ResourceManager &resources, const Common::String &path,
 		uint32 &width, uint32 &height) {
 	Common::Array<byte> data;
@@ -2195,650 +2133,6 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 	return true;
 }
 
-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)
-			result.mutatedRuntimeState = true;
-	};
-	auto noteCurrentRoomVisualMutation = [&](bool changed, bool affectsCurrentRoom) {
-		noteMutation(changed);
-		if (changed && affectsCurrentRoom)
-			result.visualRuntimeStateChanged = true;
-	};
-	auto noteTimerMutation = [&](const TimerRecord *previousTimer, bool changed) {
-		if (!changed || !previousTimer || previousTimer->timerName.empty())
-			return;
-
-		for (const TimerRecord &record : result.previousTimerRecords) {
-			if (record.timerName.equalsIgnoreCase(previousTimer->timerName))
-				return;
-		}
-
-		result.previousTimerRecords.push_back(*previousTimer);
-	};
-	auto belongsToContextRoom = [&](const Common::String &roomName) {
-		return !context.roomName.empty() && roomName.equalsIgnoreCase(context.roomName);
-	};
-	auto hasDeferredInteractionOutputs = [&]() {
-		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;
-	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const CommandRecord *command = findCommandRecord(currentTag);
-		if (!command) {
-			debug(1, "Harvester: unresolved %s tag '%s' for '%s'",
-				context.label, currentTag.c_str(), context.name.c_str());
-			break;
-		}
-
-		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());
-
-		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			const FlagRecord *flag = findRuntimeFlag(command->arg1);
-			const bool flagValue = flag && flag->value;
-			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;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC")) {
-			const int threshold = CLIP<int>(parseAsciiIntOrZero(command->arg1), 0, 100);
-			const int roll = g_engine ? (int)g_engine->getRandomNumber(99) : 0;
-			currentTag = roll < threshold ? command->arg2 : command->arg3;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
-			const bool flagValue = isTruthy(command->arg2);
-			FlagRecord *flag = findRuntimeFlag(command->arg1);
-			bool changed = false;
-			bool oldValue = false;
-			const bool existed = flag != nullptr;
-			if (flag) {
-				oldValue = flag->value;
-				changed = flag->value != flagValue;
-				flag->value = flagValue;
-			} else {
-				FlagRecord newFlag;
-				newFlag.name = command->arg1;
-				newFlag.value = flagValue;
-				_currentFlags.push_back(Common::move(newFlag));
-				changed = true;
-			}
-			debugC(1, kDebugGeneral,
-				"Harvester: %s '%s' SET_FLAG '%s' %d -> %d existed=%d changed=%d",
-				context.label, context.name.c_str(), command->arg1.c_str(), oldValue, flagValue,
-				existed, changed);
-			noteMutation(changed);
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
-			result.musicPath = command->arg1;
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (appendStartupAudioCommand(*command, result.audioCommands)) {
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("ADD") ||
-			command->opcodeName.equalsIgnoreCase("DELETE")) {
-			ObjectRecord *currentObject = findRuntimeObject(command->arg1, command->arg2);
-			if (!currentObject) {
-				debug(1, "Harvester: unresolved object for %s '%s' owner='%s' object='%s'",
-					context.label, context.name.c_str(), command->arg1.c_str(), command->arg2.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			const bool visible = command->opcodeName.equalsIgnoreCase("ADD");
-			const bool affectsCurrentRoom = belongsToContextRoom(currentObject->currentOwnerOrRoom);
-			const bool changed = currentObject->visible != visible ||
-				currentObject->runtimeVisible != visible;
-			currentObject->visible = visible;
-			currentObject->runtimeVisible = visible;
-			if (visible && currentObject->currentOwnerOrRoom.equalsIgnoreCase("INVENTORY"))
-				currentObject->identShown = true;
-			noteCurrentRoomVisualMutation(changed, affectsCurrentRoom);
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("ADD2INV")) {
-			const ObjectRecord *currentObject = findRuntimeObject(Common::String(), command->arg1);
-			const bool affectsCurrentRoom = currentObject &&
-				belongsToContextRoom(currentObject->currentOwnerOrRoom);
-			noteCurrentRoomVisualMutation(
-				addRuntimeObjectToInventory(command->arg1), affectsCurrentRoom);
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SET_ANIM")) {
-			AnimRecord *currentAnimation = findRuntimeAnim(command->arg1);
-			if (!currentAnimation) {
-				debug(1, "Harvester: unresolved anim for %s '%s' anim='%s'",
-					context.label, context.name.c_str(), command->arg1.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			const bool wasVisible = currentAnimation->visible;
-			const bool active = isTruthy(command->arg2);
-			const bool visible = isTruthy(command->arg3);
-			int runtimeState = currentAnimation->runtimeState;
-			if (active && visible &&
-					(!wasVisible || !currentAnimation->runtimeActive || runtimeState < 0)) {
-				runtimeState = 0;
-			}
-			const bool changed = currentAnimation->active != active ||
-				currentAnimation->visible != visible ||
-				currentAnimation->runtimeActive != active ||
-				currentAnimation->runtimeVisible != visible ||
-				currentAnimation->runtimeState != runtimeState;
-			currentAnimation->active = active;
-			currentAnimation->visible = visible;
-			currentAnimation->runtimeActive = active;
-			currentAnimation->runtimeVisible = visible;
-			currentAnimation->runtimeState = runtimeState;
-			noteCurrentRoomVisualMutation(changed,
-				belongsToContextRoom(currentAnimation->roomName));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SET_REGION")) {
-			RegionRecord *currentRegion = findRuntimeRegion(command->arg1);
-			if (!currentRegion) {
-				debug(1, "Harvester: unresolved region for %s '%s' region='%s'",
-					context.label, context.name.c_str(), command->arg1.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			const bool enabled = !command->arg2.equalsIgnoreCase("F");
-			const bool changed = currentRegion->startEnabled != enabled;
-			currentRegion->startEnabled = enabled;
-			noteCurrentRoomVisualMutation(changed,
-				belongsToContextRoom(currentRegion->roomName));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SET_NPC")) {
-			NpcRecord *currentNpc = findRuntimeNpc(command->arg1);
-			if (!currentNpc) {
-				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
-					context.label, context.name.c_str(), command->arg1.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			const bool active = isTruthy(command->arg2);
-			const bool visible = isTruthy(command->arg3);
-			const bool changed = currentNpc->active != active || currentNpc->visible != visible;
-			currentNpc->active = active;
-			currentNpc->visible = visible;
-			noteCurrentRoomVisualMutation(changed,
-				belongsToContextRoom(currentNpc->roomName));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SET_MONSTER")) {
-			MonsterRecord monsterLookup;
-			monsterLookup.roomName = context.roomName;
-			monsterLookup.monsterName = command->arg1;
-			MonsterRecord *currentMonster = findRuntimeMonster(monsterLookup);
-			if (!currentMonster) {
-				debug(1, "Harvester: unresolved monster for %s '%s' monster='%s'",
-					context.label, context.name.c_str(), command->arg1.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			const bool active = isTruthy(command->arg2);
-			const bool visible = isTruthy(command->arg3);
-			const bool changed = currentMonster->active != active || currentMonster->visible != visible;
-			currentMonster->active = active;
-			currentMonster->visible = active ? true : visible;
-			if (active) {
-				currentMonster->runtimeSpawned = false;
-				currentMonster->runtimeState = -1;
-			}
-			if (!currentMonster->visible) {
-				currentMonster->runtimeSpawned = false;
-				currentMonster->runtimeState = -1;
-			}
-			if (active && currentMonster->currentHitPoints <= 0)
-				currentMonster->currentHitPoints = currentMonster->initialHitPoints;
-			noteCurrentRoomVisualMutation(changed,
-				belongsToContextRoom(currentMonster->roomName));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SET_TIMER")) {
-			const TimerRecord *currentTimer = findRuntimeTimer(command->arg1);
-			TimerRecord previousTimer;
-			if (currentTimer)
-				previousTimer = *currentTimer;
-			const bool changed =
-				setRuntimeTimerEnabled(command->arg1, command->arg2.equalsIgnoreCase("ON"));
-			noteCurrentRoomVisualMutation(changed,
-				currentTimer && belongsToContextRoom(currentTimer->arg1));
-			noteTimerMutation(currentTimer ? &previousTimer : nullptr, changed);
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("KILL_TIMER")) {
-			const TimerRecord *currentTimer = findRuntimeTimer(command->arg1);
-			TimerRecord previousTimer;
-			if (currentTimer)
-				previousTimer = *currentTimer;
-			const bool changed = setRuntimeTimerEnabled(command->arg1, false);
-			noteCurrentRoomVisualMutation(changed,
-				currentTimer && belongsToContextRoom(currentTimer->arg1));
-			noteTimerMutation(currentTimer ? &previousTimer : nullptr, changed);
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
-			const ExecListRecord *execList = findExecListRecord(command->arg1);
-			if (!execList) {
-				debug(1, "Harvester: unresolved exec list for %s '%s' list='%s'",
-					context.label, context.name.c_str(), command->arg1.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			for (const Common::String &entry : execList->entries) {
-				const CommandExecutionContext entryContext = {
-					"exec list entry", execList->listName, context.roomName, context.allowTransitions
-				};
-				executeCommandChain(entry, entryContext, outputPolicy, result);
-				if (hasDeferredInteractionOutputs())
-					return;
-			}
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("START_DIALOG")) {
-			if (command->arg1.empty()) {
-				currentTag = command->arg4;
-				continue;
-			}
-
-			result.dialogueNpcName = command->arg1;
-			result.dialogueContinuationTag = command->arg4;
-			return;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("GOFLIC")) {
-			result.cutscenePath = command->arg1;
-			result.continuationTag = command->arg4;
-			return;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("GODEATHFLIC")) {
-			if (outputPolicy == kCommandOutputsRoomExit) {
-				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without menu-exit context",
-					command->opcodeName.c_str(), context.label, context.name.c_str());
-				return;
-			}
-
-			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(), context.label, context.name.c_str());
-			}
-			return;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("END_DEMO")) {
-			result.requestDemoEnding = true;
-			return;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
-				command->opcodeName.equalsIgnoreCase("MONSTERFY")) {
-			NpcRecord *currentNpc = findRuntimeNpc(command->arg1);
-			if (!currentNpc) {
-				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
-					context.label, context.name.c_str(), command->arg1.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-
-			const int deathDamageType = parseDeathDamageType(command->arg2);
-			const bool deferLiveNpcDeathTransition =
-				_deferredLiveNpcDeathTransitionDepth > 0 &&
-				!context.roomName.empty() &&
-				currentNpc->roomName.equalsIgnoreCase(context.roomName) &&
-				currentNpc->active &&
-				currentNpc->visible &&
-				!currentNpc->deathOrMonsterfyFlag &&
-				currentNpc->runtimeState < 0;
-			noteCurrentRoomVisualMutation(deferLiveNpcDeathTransition
-				? queueRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType)
-				: finalizeRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType),
-				belongsToContextRoom(currentNpc->roomName));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SHOW_TEXT")) {
-			if (!resolveTextRecord(command->arg1, result.modalText)) {
-				debug(1, "Harvester: unresolved SHOW_TEXT record for %s '%s' key='%s'",
-					context.label, context.name.c_str(), command->arg1.c_str());
-				currentTag = command->arg4;
-				continue;
-			}
-			result.continuationTag = command->arg4;
-			return;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
-			command->opcodeName.equalsIgnoreCase("ADJ_HP")) {
-			noteMutation(setPlayerCurrentHitPoints(
-				_playerCurrentHitPoints + parseAsciiIntOrZero(command->arg1)));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		const int combatResourceLoadout = resolveCombatResourceLoadoutForOpcode(command->opcodeName);
-		if (combatResourceLoadout != 0) {
-			noteMutation(adjustPlayerCombatResourceCount(combatResourceLoadout,
-				parseAsciiIntOrZero(command->arg1),
-				resolveCombatResourcePickupMax(combatResourceLoadout),
-				command->opcodeName));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("PC_CHANGE_WEAPON")) {
-			noteCurrentRoomVisualMutation(
-				setPlayerCombatLoadout(parseAsciiIntOrZero(command->arg1)), true);
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("MOVE_BM2PCZ")) {
-			result.moveEntityToPlayerZName = command->arg1;
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("KILL_PC")) {
-			result.requestPlayerDeath = true;
-			result.playerDeathDamageType = parseDeathDamageType(command->arg1);
-			noteMutation(setPlayerCurrentHitPoints(0));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("PAUSE_PC")) {
-			noteMutation(setPlayerControlPaused(true));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("RESUME_PC")) {
-			noteMutation(setPlayerControlPaused(false));
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ")) {
-			result.requestPlayerGotoXZ = true;
-			result.playerGotoX = parseAsciiIntOrZero(command->arg1);
-			result.playerGotoZ = parseAsciiIntOrZero(command->arg2);
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("PC_GOTO_Z")) {
-			result.requestPlayerGotoZ = true;
-			result.playerGotoZ = parseAsciiIntOrZero(command->arg1);
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING")) {
-			if (command->arg1.equalsIgnoreCase("DIM"))
-				result.lightingCommand = kStartupLightingCommandDim;
-			else if (command->arg1.equalsIgnoreCase("NORMAL"))
-				result.lightingCommand = kStartupLightingCommandNormal;
-			else if (command->arg1.equalsIgnoreCase("NONE"))
-				result.lightingCommand = kStartupLightingCommandBlack;
-			else if (command->arg1.equalsIgnoreCase("FADE_IN"))
-				result.lightingCommand = kStartupLightingCommandFadeIn;
-			else
-				debug(1, "Harvester: unsupported CHANGE_LIGHTING mode '%s' for %s '%s', preserving control flow",
-					command->arg1.c_str(), context.label, context.name.c_str());
-			result.continuationTag = command->arg4;
-			return;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("CHANGE_CD")) {
-			const int requestedDisc = parseAsciiIntOrZero(command->arg1);
-			if (requestedDisc > 0)
-				result.cdChangeDisc = requestedDisc;
-			else
-				debug(1, "Harvester: invalid CHANGE_CD target '%s' for %s '%s'",
-					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 (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(), context.label, context.name.c_str());
-			}
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP")) {
-			result.requestCloseupExit = true;
-			currentTag = command->arg4;
-			continue;
-		}
-
-		debug(1, "Harvester: unsupported startup command '%s' for %s '%s', continuing",
-			command->opcodeName.c_str(), context.label, context.name.c_str());
-		currentTag = command->arg4;
-	}
-}
-
-bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
-		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const {
-	if (initialTag.empty() || recursionDepth > 8)
-		return false;
-
-	Common::String currentTag = initialTag;
-	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const CommandRecord *command = findCommandRecord(currentTag);
-		if (!command) {
-			debug(1, "Harvester: unresolved pickup preflight tag '%s' for '%s'",
-				currentTag.c_str(), contextName.c_str());
-			return false;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			const FlagRecord *flag = findRuntimeFlag(command->arg1);
-			currentTag = (flag && flag->value) ? command->arg2 : command->arg3;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC"))
-			return false;
-
-		if (command->opcodeName.equalsIgnoreCase("SHOW_TEXT")) {
-			if (!resolveTextRecord(command->arg1, result.modalText))
-				return false;
-			result.continuationTag = command->arg4;
-			return !result.modalText.value.empty();
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
-			const ExecListRecord *execList = findExecListRecord(command->arg1);
-			if (!execList)
-				return false;
-
-			for (const Common::String &entry : execList->entries) {
-				if (probePickupBlockingCommandChain(entry, contextName, result, recursionDepth + 1))
-					return true;
-			}
-			return false;
-		}
-
-		Common::Array<AudioCommand> audioCommands;
-		if (appendStartupAudioCommand(*command, audioCommands))
-			return false;
-
-		if (command->opcodeName.equalsIgnoreCase("SET_FLAG") ||
-			command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC") ||
-			command->opcodeName.equalsIgnoreCase("ADD") ||
-			command->opcodeName.equalsIgnoreCase("DELETE") ||
-			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
-			isCombatResourceOpcode(command->opcodeName) ||
-			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
-			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
-			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
-			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
-			command->opcodeName.equalsIgnoreCase("SET_TIMER") ||
-			command->opcodeName.equalsIgnoreCase("KILL_TIMER") ||
-			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
-			command->opcodeName.equalsIgnoreCase("GOFLIC") ||
-			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
-			command->opcodeName.equalsIgnoreCase("END_DEMO") ||
-			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
-			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
-			command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
-			command->opcodeName.equalsIgnoreCase("ADJ_HP") ||
-			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
-			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
-			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
-			command->opcodeName.equalsIgnoreCase("CHANGE_CD") ||
-			command->opcodeName.equalsIgnoreCase("MOVE_BM2PCZ") ||
-			command->opcodeName.equalsIgnoreCase("PC_CHANGE_WEAPON") ||
-			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
-			command->opcodeName.equalsIgnoreCase("PC_GOTO_Z") ||
-			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
-			command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP") ||
-			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
-			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
-			return false;
-		}
-
-		currentTag = command->arg4;
-	}
-
-	return false;
-}
-
-bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
-	Common::String currentTag = initialTag;
-	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const CommandRecord *command = findCommandRecord(currentTag);
-		if (!command) {
-			debug(1, "Harvester: unresolved interaction probe tag '%s'", currentTag.c_str());
-			break;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			const FlagRecord *flag = findRuntimeFlag(command->arg1);
-			currentTag = (flag && flag->value) ? command->arg2 : command->arg3;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC")) {
-			currentTag = command->arg2.empty() ? command->arg3 : command->arg2;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SET_FLAG") ||
-			command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC") ||
-			command->opcodeName.equalsIgnoreCase("ADD") ||
-			command->opcodeName.equalsIgnoreCase("DELETE") ||
-			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
-			isCombatResourceOpcode(command->opcodeName) ||
-			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
-			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
-			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
-			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
-			command->opcodeName.equalsIgnoreCase("SET_TIMER") ||
-			command->opcodeName.equalsIgnoreCase("KILL_TIMER") ||
-			command->opcodeName.equalsIgnoreCase("EXEC_LIST") ||
-			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
-			command->opcodeName.equalsIgnoreCase("GOFLIC") ||
-			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
-			command->opcodeName.equalsIgnoreCase("END_DEMO") ||
-			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
-			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
-			command->opcodeName.equalsIgnoreCase("SHOW_TEXT") ||
-			command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
-			command->opcodeName.equalsIgnoreCase("ADJ_HP") ||
-			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
-			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
-			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
-			command->opcodeName.equalsIgnoreCase("CHANGE_CD") ||
-			command->opcodeName.equalsIgnoreCase("MOVE_BM2PCZ") ||
-			command->opcodeName.equalsIgnoreCase("PC_CHANGE_WEAPON") ||
-			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
-			command->opcodeName.equalsIgnoreCase("PC_GOTO_Z") ||
-			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
-			command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP") ||
-			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
-			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
-			return true;
-		}
-
-		Common::Array<AudioCommand> audioCommands;
-		if (appendStartupAudioCommand(*command, audioCommands))
-			return true;
-
-		currentTag = command->arg4;
-	}
-
-	return false;
-}
-
 const TextRecord *Script::findTextRecord(const Common::String &key) const {
 	if (key.empty())
 		return nullptr;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index f045cbfc545..4e654f2df1c 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -470,6 +470,12 @@ private:
 		kCommandOutputsRoomExit
 	};
 
+	enum CommandStepResult {
+		kCommandUnhandled,
+		kCommandContinue,
+		kCommandStop
+	};
+
 	bool loadConfig(ResourceManager &resources);
 	void decode();
 	void parseTownRecords(ResourceManager &resources);
@@ -523,6 +529,37 @@ private:
 		ResourceManager &resources, RoomSetupState &state) const;
 	void executeCommandChain(const Common::String &initialTag, const CommandExecutionContext &context,
 		CommandOutputPolicy outputPolicy, InteractionResult &result);
+	CommandStepResult executeCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, CommandOutputPolicy outputPolicy,
+		InteractionResult &result, Common::String &nextTag);
+	CommandStepResult executeBranchCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result, Common::String &nextTag);
+	CommandStepResult executeAudioCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result);
+	CommandStepResult executeObjectCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result);
+	CommandStepResult executeAnimationCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result);
+	CommandStepResult executeRegionCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result);
+	CommandStepResult executeNpcCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result);
+	CommandStepResult executeMonsterCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result);
+	CommandStepResult executeTimerCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result);
+	CommandStepResult executeExecListCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, CommandOutputPolicy outputPolicy, InteractionResult &result);
+	CommandStepResult executeModalCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result);
+	CommandStepResult executePlayerCommand(const CommandRecord &command,
+		InteractionResult &result);
+	CommandStepResult executeMovementCommand(const CommandRecord &command,
+		InteractionResult &result);
+	CommandStepResult executeTransitionCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, CommandOutputPolicy outputPolicy, InteractionResult &result);
+	static bool hasDeferredInteractionOutputs(const InteractionResult &result, CommandOutputPolicy outputPolicy);
+	static int resolveCombatResourcePickupMax(int loadout);
 	bool probePickupBlockingCommandChain(const Common::String &initialTag,
 		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
diff --git a/engines/harvester/script_commands.cpp b/engines/harvester/script_commands.cpp
new file mode 100644
index 00000000000..ded7902ee1c
--- /dev/null
+++ b/engines/harvester/script_commands.cpp
@@ -0,0 +1,822 @@
+/* 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/script.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "harvester/detection.h"
+#include "harvester/harvester.h"
+#include "harvester/parse_utils.h"
+
+namespace Harvester {
+
+static int resolveCombatResourceLoadoutForOpcode(const Common::String &opcodeName) {
+	// Confirmed from dispatch_room_event_actions and HARVEST.SCR.
+	if (opcodeName.equalsIgnoreCase("ADD_NAILS"))
+		return 2;
+	if (opcodeName.equalsIgnoreCase("ADD_SHOTGUN_SHELLS"))
+		return 3;
+	if (opcodeName.equalsIgnoreCase("ADD_GASCANS"))
+		return 14;
+
+	return 0;
+}
+
+static bool isCombatResourceOpcode(const Common::String &opcodeName) {
+	return resolveCombatResourceLoadoutForOpcode(opcodeName) != 0;
+}
+
+static bool appendStartupAudioCommand(const CommandRecord &command, Common::Array<AudioCommand> &commands) {
+	if (command.opcodeName.equalsIgnoreCase("START_WAV")) {
+		AudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandStartWav;
+		audioCommand.path = command.arg1;
+		commands.push_back(Common::move(audioCommand));
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
+		AudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandStartSingleWav;
+		audioCommand.path = command.arg1;
+		commands.push_back(Common::move(audioCommand));
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("LOAD_WAV")) {
+		AudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandLoadWav;
+		audioCommand.path = command.arg1;
+		audioCommand.slot = command.arg2.empty() ? -1 : parseAsciiIntOrZero(command.arg2);
+		commands.push_back(Common::move(audioCommand));
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("PLAY_WAV")) {
+		AudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandPlayWav;
+		audioCommand.slot = command.arg1.empty() ? -1 : parseAsciiIntOrZero(command.arg1);
+		commands.push_back(Common::move(audioCommand));
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("DELETE_WAV")) {
+		AudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandDeleteWav;
+		audioCommand.slot = command.arg1.empty() ? -1 : parseAsciiIntOrZero(command.arg1);
+		commands.push_back(Common::move(audioCommand));
+		return true;
+	}
+
+	return false;
+}
+
+static bool isTruthy(const Common::String &value) {
+	return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
+}
+
+static void noteMutation(InteractionResult &result, bool changed) {
+	if (changed)
+		result.mutatedRuntimeState = true;
+}
+
+static void noteCurrentRoomVisualMutation(InteractionResult &result, bool changed, bool affectsCurrentRoom) {
+	noteMutation(result, changed);
+	if (changed && affectsCurrentRoom)
+		result.visualRuntimeStateChanged = true;
+}
+
+static void noteTimerMutation(InteractionResult &result, const TimerRecord *previousTimer, bool changed) {
+	if (!changed || !previousTimer || previousTimer->timerName.empty())
+		return;
+
+	for (const TimerRecord &record : result.previousTimerRecords) {
+		if (record.timerName.equalsIgnoreCase(previousTimer->timerName))
+			return;
+	}
+
+	result.previousTimerRecords.push_back(*previousTimer);
+}
+
+static bool belongsToContextRoom(const Common::String &contextRoom, const Common::String &roomName) {
+	return !contextRoom.empty() && roomName.equalsIgnoreCase(contextRoom);
+}
+
+bool Script::hasDeferredInteractionOutputs(const InteractionResult &result, CommandOutputPolicy outputPolicy) {
+	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;
+}
+
+Script::CommandStepResult Script::executeBranchCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result, Common::String &nextTag) {
+	if (command.opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+		const FlagRecord *flag = findRuntimeFlag(command.arg1);
+		const bool flagValue = flag && flag->value;
+		debugC(3, kDebugScene, "Harvester: %s '%s' flag '%s' -> %d",
+			context.label, context.name.c_str(), command.arg1.c_str(), flagValue);
+		nextTag = flagValue ? command.arg2 : command.arg3;
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("CHECK_PERC")) {
+		const int threshold = CLIP<int>(parseAsciiIntOrZero(command.arg1), 0, 100);
+		const int roll = g_engine ? static_cast<int>(g_engine->getRandomNumber(99)) : 0;
+		nextTag = roll < threshold ? command.arg2 : command.arg3;
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("SET_FLAG")) {
+		const bool flagValue = isTruthy(command.arg2);
+		FlagRecord *flag = findRuntimeFlag(command.arg1);
+		bool changed = false;
+		bool oldValue = false;
+		const bool existed = flag != nullptr;
+		if (flag) {
+			oldValue = flag->value;
+			changed = flag->value != flagValue;
+			flag->value = flagValue;
+		} else {
+			FlagRecord newFlag;
+			newFlag.name = command.arg1;
+			newFlag.value = flagValue;
+			_currentFlags.push_back(Common::move(newFlag));
+			changed = true;
+		}
+		debugC(1, kDebugGeneral,
+			"Harvester: %s '%s' SET_FLAG '%s' %d -> %d existed=%d changed=%d",
+			context.label, context.name.c_str(), command.arg1.c_str(), oldValue, flagValue,
+			existed, changed);
+		noteMutation(result, changed);
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeAudioCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
+		result.musicPath = command.arg1;
+		return kCommandContinue;
+	}
+
+	if (appendStartupAudioCommand(command, result.audioCommands)) {
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("CHANGE_CD")) {
+		const int requestedDisc = parseAsciiIntOrZero(command.arg1);
+		if (requestedDisc > 0)
+			result.cdChangeDisc = requestedDisc;
+		else
+			debug(1, "Harvester: invalid CHANGE_CD target '%s' for %s '%s'",
+				command.arg1.c_str(), context.label, context.name.c_str());
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeObjectCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("ADD") ||
+		command.opcodeName.equalsIgnoreCase("DELETE")) {
+		ObjectRecord *currentObject = findRuntimeObject(command.arg1, command.arg2);
+		if (!currentObject) {
+			debug(1, "Harvester: unresolved object for %s '%s' owner='%s' object='%s'",
+				context.label, context.name.c_str(), command.arg1.c_str(), command.arg2.c_str());
+			return kCommandContinue;
+		}
+
+		const bool visible = command.opcodeName.equalsIgnoreCase("ADD");
+		const bool affectsCurrentRoom = belongsToContextRoom(context.roomName, currentObject->currentOwnerOrRoom);
+		const bool changed = currentObject->visible != visible ||
+			currentObject->runtimeVisible != visible;
+		currentObject->visible = visible;
+		currentObject->runtimeVisible = visible;
+		if (visible && currentObject->currentOwnerOrRoom.equalsIgnoreCase("INVENTORY"))
+			currentObject->identShown = true;
+		noteCurrentRoomVisualMutation(result, changed, affectsCurrentRoom);
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("ADD2INV")) {
+		const ObjectRecord *currentObject = findRuntimeObject(Common::String(), command.arg1);
+		const bool affectsCurrentRoom = currentObject &&
+			belongsToContextRoom(context.roomName, currentObject->currentOwnerOrRoom);
+		noteCurrentRoomVisualMutation(result,
+			addRuntimeObjectToInventory(command.arg1), affectsCurrentRoom);
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeAnimationCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("SET_ANIM")) {
+		AnimRecord *currentAnimation = findRuntimeAnim(command.arg1);
+		if (!currentAnimation) {
+			debug(1, "Harvester: unresolved anim for %s '%s' anim='%s'",
+				context.label, context.name.c_str(), command.arg1.c_str());
+			return kCommandContinue;
+		}
+
+		const bool wasVisible = currentAnimation->visible;
+		const bool active = isTruthy(command.arg2);
+		const bool visible = isTruthy(command.arg3);
+		int runtimeState = currentAnimation->runtimeState;
+		if (active && visible &&
+				(!wasVisible || !currentAnimation->runtimeActive || runtimeState < 0)) {
+			runtimeState = 0;
+		}
+		const bool changed = currentAnimation->active != active ||
+			currentAnimation->visible != visible ||
+			currentAnimation->runtimeActive != active ||
+			currentAnimation->runtimeVisible != visible ||
+			currentAnimation->runtimeState != runtimeState;
+		currentAnimation->active = active;
+		currentAnimation->visible = visible;
+		currentAnimation->runtimeActive = active;
+		currentAnimation->runtimeVisible = visible;
+		currentAnimation->runtimeState = runtimeState;
+		noteCurrentRoomVisualMutation(result, changed,
+			belongsToContextRoom(context.roomName, currentAnimation->roomName));
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeRegionCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("SET_REGION")) {
+		RegionRecord *currentRegion = findRuntimeRegion(command.arg1);
+		if (!currentRegion) {
+			debug(1, "Harvester: unresolved region for %s '%s' region='%s'",
+				context.label, context.name.c_str(), command.arg1.c_str());
+			return kCommandContinue;
+		}
+
+		const bool enabled = !command.arg2.equalsIgnoreCase("F");
+		const bool changed = currentRegion->startEnabled != enabled;
+		currentRegion->startEnabled = enabled;
+		noteCurrentRoomVisualMutation(result, changed,
+			belongsToContextRoom(context.roomName, currentRegion->roomName));
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeNpcCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("SET_NPC")) {
+		NpcRecord *currentNpc = findRuntimeNpc(command.arg1);
+		if (!currentNpc) {
+			debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
+				context.label, context.name.c_str(), command.arg1.c_str());
+			return kCommandContinue;
+		}
+
+		const bool active = isTruthy(command.arg2);
+		const bool visible = isTruthy(command.arg3);
+		const bool changed = currentNpc->active != active || currentNpc->visible != visible;
+		currentNpc->active = active;
+		currentNpc->visible = visible;
+		noteCurrentRoomVisualMutation(result, changed,
+			belongsToContextRoom(context.roomName, currentNpc->roomName));
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("KILL_NPC") ||
+			command.opcodeName.equalsIgnoreCase("MONSTERFY")) {
+		NpcRecord *currentNpc = findRuntimeNpc(command.arg1);
+		if (!currentNpc) {
+			debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
+				context.label, context.name.c_str(), command.arg1.c_str());
+			return kCommandContinue;
+		}
+
+		const int deathDamageType = parseDeathDamageType(command.arg2);
+		const bool deferLiveNpcDeathTransition =
+			_deferredLiveNpcDeathTransitionDepth > 0 &&
+			!context.roomName.empty() &&
+			currentNpc->roomName.equalsIgnoreCase(context.roomName) &&
+			currentNpc->active &&
+			currentNpc->visible &&
+			!currentNpc->deathOrMonsterfyFlag &&
+			currentNpc->runtimeState < 0;
+		noteCurrentRoomVisualMutation(result, deferLiveNpcDeathTransition
+			? queueRuntimeNpcDeathOrMonsterfy(command.arg1, deathDamageType)
+			: finalizeRuntimeNpcDeathOrMonsterfy(command.arg1, deathDamageType),
+			belongsToContextRoom(context.roomName, currentNpc->roomName));
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeMonsterCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("SET_MONSTER")) {
+		MonsterRecord monsterLookup;
+		monsterLookup.roomName = context.roomName;
+		monsterLookup.monsterName = command.arg1;
+		MonsterRecord *currentMonster = findRuntimeMonster(monsterLookup);
+		if (!currentMonster) {
+			debug(1, "Harvester: unresolved monster for %s '%s' monster='%s'",
+				context.label, context.name.c_str(), command.arg1.c_str());
+			return kCommandContinue;
+		}
+
+		const bool active = isTruthy(command.arg2);
+		const bool visible = isTruthy(command.arg3);
+		const bool changed = currentMonster->active != active || currentMonster->visible != visible;
+		currentMonster->active = active;
+		currentMonster->visible = active ? true : visible;
+		if (active) {
+			currentMonster->runtimeSpawned = false;
+			currentMonster->runtimeState = -1;
+		}
+		if (!currentMonster->visible) {
+			currentMonster->runtimeSpawned = false;
+			currentMonster->runtimeState = -1;
+		}
+		if (active && currentMonster->currentHitPoints <= 0)
+			currentMonster->currentHitPoints = currentMonster->initialHitPoints;
+		noteCurrentRoomVisualMutation(result, changed,
+			belongsToContextRoom(context.roomName, currentMonster->roomName));
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeTimerCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("SET_TIMER")) {
+		const TimerRecord *currentTimer = findRuntimeTimer(command.arg1);
+		TimerRecord previousTimer;
+		if (currentTimer)
+			previousTimer = *currentTimer;
+		const bool changed =
+			setRuntimeTimerEnabled(command.arg1, command.arg2.equalsIgnoreCase("ON"));
+		noteCurrentRoomVisualMutation(result, changed,
+			currentTimer && belongsToContextRoom(context.roomName, currentTimer->arg1));
+		noteTimerMutation(result, currentTimer ? &previousTimer : nullptr, changed);
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("KILL_TIMER")) {
+		const TimerRecord *currentTimer = findRuntimeTimer(command.arg1);
+		TimerRecord previousTimer;
+		if (currentTimer)
+			previousTimer = *currentTimer;
+		const bool changed = setRuntimeTimerEnabled(command.arg1, false);
+		noteCurrentRoomVisualMutation(result, changed,
+			currentTimer && belongsToContextRoom(context.roomName, currentTimer->arg1));
+		noteTimerMutation(result, currentTimer ? &previousTimer : nullptr, changed);
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeExecListCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, CommandOutputPolicy outputPolicy, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("EXEC_LIST")) {
+		const ExecListRecord *execList = findExecListRecord(command.arg1);
+		if (!execList) {
+			debug(1, "Harvester: unresolved exec list for %s '%s' list='%s'",
+				context.label, context.name.c_str(), command.arg1.c_str());
+			return kCommandContinue;
+		}
+
+		for (const Common::String &entry : execList->entries) {
+			const CommandExecutionContext entryContext = {
+				"exec list entry", execList->listName, context.roomName, context.allowTransitions
+			};
+			executeCommandChain(entry, entryContext, outputPolicy, result);
+			if (hasDeferredInteractionOutputs(result, outputPolicy))
+				return kCommandStop;
+		}
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeModalCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("START_DIALOG")) {
+		if (command.arg1.empty()) {
+			return kCommandContinue;
+		}
+
+		result.dialogueNpcName = command.arg1;
+		result.dialogueContinuationTag = command.arg4;
+		return kCommandStop;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("GOFLIC")) {
+		result.cutscenePath = command.arg1;
+		result.continuationTag = command.arg4;
+		return kCommandStop;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("SHOW_TEXT")) {
+		if (!resolveTextRecord(command.arg1, result.modalText)) {
+			debug(1, "Harvester: unresolved SHOW_TEXT record for %s '%s' key='%s'",
+				context.label, context.name.c_str(), command.arg1.c_str());
+			return kCommandContinue;
+		}
+		result.continuationTag = command.arg4;
+		return kCommandStop;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("CHANGE_LIGHTING")) {
+		if (command.arg1.equalsIgnoreCase("DIM"))
+			result.lightingCommand = kStartupLightingCommandDim;
+		else if (command.arg1.equalsIgnoreCase("NORMAL"))
+			result.lightingCommand = kStartupLightingCommandNormal;
+		else if (command.arg1.equalsIgnoreCase("NONE"))
+			result.lightingCommand = kStartupLightingCommandBlack;
+		else if (command.arg1.equalsIgnoreCase("FADE_IN"))
+			result.lightingCommand = kStartupLightingCommandFadeIn;
+		else
+			debug(1, "Harvester: unsupported CHANGE_LIGHTING mode '%s' for %s '%s', preserving control flow",
+				command.arg1.c_str(), context.label, context.name.c_str());
+		result.continuationTag = command.arg4;
+		return kCommandStop;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executePlayerCommand(const CommandRecord &command,
+		InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("HEAL_PC") ||
+		command.opcodeName.equalsIgnoreCase("ADJ_HP")) {
+		noteMutation(result, setPlayerCurrentHitPoints(
+			_playerCurrentHitPoints + parseAsciiIntOrZero(command.arg1)));
+		return kCommandContinue;
+	}
+
+	const int combatResourceLoadout = resolveCombatResourceLoadoutForOpcode(command.opcodeName);
+	if (combatResourceLoadout != 0) {
+		noteMutation(result, adjustPlayerCombatResourceCount(combatResourceLoadout,
+			parseAsciiIntOrZero(command.arg1),
+			resolveCombatResourcePickupMax(combatResourceLoadout),
+			command.opcodeName));
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("PC_CHANGE_WEAPON")) {
+		noteCurrentRoomVisualMutation(result,
+			setPlayerCombatLoadout(parseAsciiIntOrZero(command.arg1)), true);
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("KILL_PC")) {
+		result.requestPlayerDeath = true;
+		result.playerDeathDamageType = parseDeathDamageType(command.arg1);
+		noteMutation(result, setPlayerCurrentHitPoints(0));
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("PAUSE_PC")) {
+		noteMutation(result, setPlayerControlPaused(true));
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("RESUME_PC")) {
+		noteMutation(result, setPlayerControlPaused(false));
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeMovementCommand(const CommandRecord &command,
+		InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("MOVE_BM2PCZ")) {
+		result.moveEntityToPlayerZName = command.arg1;
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("PC_GOTO_XZ")) {
+		result.requestPlayerGotoXZ = true;
+		result.playerGotoX = parseAsciiIntOrZero(command.arg1);
+		result.playerGotoZ = parseAsciiIntOrZero(command.arg2);
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("PC_GOTO_Z")) {
+		result.requestPlayerGotoZ = true;
+		result.playerGotoZ = parseAsciiIntOrZero(command.arg1);
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeTransitionCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, CommandOutputPolicy outputPolicy, InteractionResult &result) {
+	if (command.opcodeName.equalsIgnoreCase("GODEATHFLIC")) {
+		if (outputPolicy == kCommandOutputsRoomExit) {
+			debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without menu-exit context",
+				command.opcodeName.c_str(), context.label, context.name.c_str());
+			return kCommandStop;
+		}
+
+		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(), context.label, context.name.c_str());
+		}
+		return kCommandStop;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("END_DEMO")) {
+		result.requestDemoEnding = true;
+		return kCommandStop;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("CLOSEUP") ||
+		command.opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
+		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(), context.label, context.name.c_str());
+		}
+		return kCommandContinue;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("EXIT_CLOSEUP")) {
+		result.requestCloseupExit = true;
+		return kCommandContinue;
+	}
+
+	return kCommandUnhandled;
+}
+
+Script::CommandStepResult Script::executeCommand(const CommandRecord &command,
+		const CommandExecutionContext &context, CommandOutputPolicy outputPolicy,
+		InteractionResult &result, Common::String &nextTag) {
+	CommandStepResult step = executeBranchCommand(command, context, result, nextTag);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeAudioCommand(command, context, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeObjectCommand(command, context, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeAnimationCommand(command, context, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeRegionCommand(command, context, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeNpcCommand(command, context, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeMonsterCommand(command, context, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeTimerCommand(command, context, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeExecListCommand(command, context, outputPolicy, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeModalCommand(command, context, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executePlayerCommand(command, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeMovementCommand(command, result);
+	if (step != kCommandUnhandled)
+		return step;
+	step = executeTransitionCommand(command, context, outputPolicy, result);
+	if (step != kCommandUnhandled)
+		return step;
+
+	debug(1, "Harvester: unsupported startup command '%s' for %s '%s', continuing",
+		command.opcodeName.c_str(), context.label, context.name.c_str());
+	return kCommandContinue;
+}
+
+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");
+
+	Common::String currentTag = initialTag;
+	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
+		const CommandRecord *command = findCommandRecord(currentTag);
+		if (!command) {
+			debug(1, "Harvester: unresolved %s tag '%s' for '%s'",
+				context.label, currentTag.c_str(), context.name.c_str());
+			break;
+		}
+
+		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());
+
+		Common::String nextTag = command->arg4;
+		if (executeCommand(*command, context, outputPolicy, result, nextTag) == kCommandStop)
+			return;
+		currentTag = nextTag;
+	}
+}
+
+bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
+		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const {
+	if (initialTag.empty() || recursionDepth > 8)
+		return false;
+
+	Common::String currentTag = initialTag;
+	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
+		const CommandRecord *command = findCommandRecord(currentTag);
+		if (!command) {
+			debug(1, "Harvester: unresolved pickup preflight tag '%s' for '%s'",
+				currentTag.c_str(), contextName.c_str());
+			return false;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+			const FlagRecord *flag = findRuntimeFlag(command->arg1);
+			currentTag = (flag && flag->value) ? command->arg2 : command->arg3;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC"))
+			return false;
+
+		if (command->opcodeName.equalsIgnoreCase("SHOW_TEXT")) {
+			if (!resolveTextRecord(command->arg1, result.modalText))
+				return false;
+			result.continuationTag = command->arg4;
+			return !result.modalText.value.empty();
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
+			const ExecListRecord *execList = findExecListRecord(command->arg1);
+			if (!execList)
+				return false;
+
+			for (const Common::String &entry : execList->entries) {
+				if (probePickupBlockingCommandChain(entry, contextName, result, recursionDepth + 1))
+					return true;
+			}
+			return false;
+		}
+
+		Common::Array<AudioCommand> audioCommands;
+		if (appendStartupAudioCommand(*command, audioCommands))
+			return false;
+
+		if (command->opcodeName.equalsIgnoreCase("SET_FLAG") ||
+			command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC") ||
+			command->opcodeName.equalsIgnoreCase("ADD") ||
+			command->opcodeName.equalsIgnoreCase("DELETE") ||
+			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
+			isCombatResourceOpcode(command->opcodeName) ||
+			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
+			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
+			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
+			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
+			command->opcodeName.equalsIgnoreCase("SET_TIMER") ||
+			command->opcodeName.equalsIgnoreCase("KILL_TIMER") ||
+			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
+			command->opcodeName.equalsIgnoreCase("GOFLIC") ||
+			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
+			command->opcodeName.equalsIgnoreCase("END_DEMO") ||
+			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
+			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
+			command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
+			command->opcodeName.equalsIgnoreCase("ADJ_HP") ||
+			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
+			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
+			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_CD") ||
+			command->opcodeName.equalsIgnoreCase("MOVE_BM2PCZ") ||
+			command->opcodeName.equalsIgnoreCase("PC_CHANGE_WEAPON") ||
+			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
+			command->opcodeName.equalsIgnoreCase("PC_GOTO_Z") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
+			command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP") ||
+			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
+			return false;
+		}
+
+		currentTag = command->arg4;
+	}
+
+	return false;
+}
+
+bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
+	Common::String currentTag = initialTag;
+	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
+		const CommandRecord *command = findCommandRecord(currentTag);
+		if (!command) {
+			debug(1, "Harvester: unresolved interaction probe tag '%s'", currentTag.c_str());
+			break;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+			const FlagRecord *flag = findRuntimeFlag(command->arg1);
+			currentTag = (flag && flag->value) ? command->arg2 : command->arg3;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC")) {
+			currentTag = command->arg2.empty() ? command->arg3 : command->arg2;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("SET_FLAG") ||
+			command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC") ||
+			command->opcodeName.equalsIgnoreCase("ADD") ||
+			command->opcodeName.equalsIgnoreCase("DELETE") ||
+			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
+			isCombatResourceOpcode(command->opcodeName) ||
+			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
+			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
+			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
+			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
+			command->opcodeName.equalsIgnoreCase("SET_TIMER") ||
+			command->opcodeName.equalsIgnoreCase("KILL_TIMER") ||
+			command->opcodeName.equalsIgnoreCase("EXEC_LIST") ||
+			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
+			command->opcodeName.equalsIgnoreCase("GOFLIC") ||
+			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
+			command->opcodeName.equalsIgnoreCase("END_DEMO") ||
+			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
+			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
+			command->opcodeName.equalsIgnoreCase("SHOW_TEXT") ||
+			command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
+			command->opcodeName.equalsIgnoreCase("ADJ_HP") ||
+			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
+			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
+			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_CD") ||
+			command->opcodeName.equalsIgnoreCase("MOVE_BM2PCZ") ||
+			command->opcodeName.equalsIgnoreCase("PC_CHANGE_WEAPON") ||
+			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
+			command->opcodeName.equalsIgnoreCase("PC_GOTO_Z") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
+			command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP") ||
+			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
+			return true;
+		}
+
+		Common::Array<AudioCommand> audioCommands;
+		if (appendStartupAudioCommand(*command, audioCommands))
+			return true;
+
+		currentTag = command->arg4;
+	}
+
+	return false;
+}
+
+} // End of namespace Harvester


Commit: 4dd258fce8bf82042a70c17da9e0e36ff0100a52
    https://github.com/scummvm/scummvm/commit/4dd258fce8bf82042a70c17da9e0e36ff0100a52
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Separate room state materialization

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/script_room.cpp
    engines/harvester/module.mk
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index a7f91293f6d..39b47e54559 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -28,6 +28,7 @@ MODULE_OBJS = \
 	script.o \
 	script_commands.o \
 	script_records.o \
+	script_room.o \
 	text.o \
 	xfile_archive.o \
 	npc/authority_dialogue.o \
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index e70d2bc8d7c..84d0be50f2f 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -24,7 +24,6 @@
 #include "common/algorithm.h"
 #include "common/config-manager.h"
 #include "common/debug.h"
-#include "common/endian.h"
 #include "common/file.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
@@ -41,8 +40,6 @@ static const char *const kConfigSectionName = "harvester";
 static const char *const kDefaultTownScript = "HARVEST.SCR";
 static const char *const kInventoryOwnerName = "INVENTORY";
 static const char *const kDefaultVoicePath = "SOUND/VOICE/";
-static const float kDefaultPaletteBrightness = 1.0f;
-static const float kDimmedPaletteBrightness = 0.6f;
 static const int kDefaultPlayerCombatLoadout = 0;
 static const int kMaxPlayerCombatLoadout = 0x14;
 static const int kMaxStartupOptionLevel = 9;
@@ -402,41 +399,6 @@ static Common::String dialogueTextModeToConfigValue(StartupDialogueTextMode mode
 	}
 }
 
-static bool loadBitmapDimensions(ResourceManager &resources, const Common::String &path,
-		uint32 &width, uint32 &height) {
-	Common::Array<byte> data;
-	if (!resources.loadFile(path, data) || data.size() < 8)
-		return false;
-
-	width = READ_LE_UINT32(data.data());
-	height = READ_LE_UINT32(data.data() + 4);
-	return true;
-}
-
-static bool isRoomBackgroundSpriteCandidate(const ObjectRecord &candidate,
-		const RoomRecord &room) {
-	if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room.roomName) ||
-			candidate.spritePath.empty() ||
-			candidate.initialX != 0 || candidate.initialY != 0) {
-		return false;
-	}
-
-	return true;
-}
-
-static bool isFullscreenRoomBackgroundObject(const ObjectRecord &candidate,
-		ResourceManager &resources) {
-	if (candidate.spritePath.empty())
-		return false;
-
-	uint32 width = 0;
-	uint32 height = 0;
-	if (!loadBitmapDimensions(resources, candidate.spritePath, width, height))
-		return false;
-
-	return width == 640 && height == 480;
-}
-
 bool Script::load(ResourceManager &resources) {
 	_path = kDefaultTownScript;
 	_data.clear();
@@ -767,70 +729,6 @@ void Script::setGammaLevel(int level) {
 	_gammaLevel = clampStartupOptionLevel(level);
 }
 
-bool Script::resolveRoomSetupState(const Common::String &entranceName, RoomSetupState &state,
-		ResourceManager &resources) {
-	state = RoomSetupState();
-
-	const EntranceRecord *entrance = findEntranceRecord(entranceName);
-	const Common::String resolvedRoomName = entrance ? entrance->roomName : entranceName;
-	const RoomRecord *room = findRoomRecord(resolvedRoomName);
-	if (!room) {
-		warning("Harvester: unresolved room setup target '%s' (resolved room '%s')",
-			entranceName.c_str(), resolvedRoomName.c_str());
-		return false;
-	}
-
-	if (!buildRuntimeRoomState(*room, entrance, resources, state))
-		return false;
-
-	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,
-		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
-		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
-		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
-		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
-		(uint)state.roomRegions.size());
-
-	return true;
-}
-
-bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, RoomSetupState &state,
-		ResourceManager &resources) {
-	state = RoomSetupState();
-
-	const RoomRecord *room = findRoomRecord(roomName);
-	if (!room) {
-		warning("Harvester: unresolved room setup room '%s'", roomName.c_str());
-		return false;
-	}
-
-	const EntranceRecord *entrance = nullptr;
-	for (const EntranceRecord &candidate : _entrances) {
-		if (!candidate.roomName.equalsIgnoreCase(room->roomName))
-			continue;
-
-		entrance = &candidate;
-		break;
-	}
-
-	if (!buildRuntimeRoomState(*room, entrance, resources, state))
-		return false;
-
-	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,
-		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
-		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
-		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
-		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
-		(uint)state.roomRegions.size());
-
-	return true;
-}
-
 void Script::resetRuntimeState() {
 	_currentFlags = _flags;
 	_currentObjects = _objects;
@@ -1070,39 +968,6 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	}
 }
 
-bool Script::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
-		RoomSetupState &state, ResourceManager &resources) const {
-	state = RoomSetupState();
-
-	const EntranceRecord *entrance = findEntranceRecord(entranceName);
-	const Common::String resolvedRoomName = !roomName.empty()
-		? roomName
-		: (entrance ? entrance->roomName : Common::String());
-	if (resolvedRoomName.empty())
-		return false;
-
-	const RoomRecord *room = findRoomRecord(resolvedRoomName);
-	if (!room) {
-		warning("Harvester: unresolved materialized room target entrance='%s' room='%s'",
-			entranceName.c_str(), resolvedRoomName.c_str());
-		return false;
-	}
-
-	return buildRuntimeRoomState(*room, entrance, resources, state);
-}
-
-bool Script::hasRoomSetupTarget(const Common::String &entranceName,
-		const Common::String &roomName) const {
-	const EntranceRecord *entrance = findEntranceRecord(entranceName);
-	const Common::String resolvedRoomName = !roomName.empty()
-		? roomName
-		: (entrance ? entrance->roomName : Common::String());
-	if (resolvedRoomName.empty())
-		return false;
-
-	return findRoomRecord(resolvedRoomName) != nullptr;
-}
-
 bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		InteractionResult &result) {
 	result = InteractionResult();
@@ -1980,159 +1845,6 @@ void Script::popDeferredLiveNpcDeathTransitions() {
 		--_deferredLiveNpcDeathTransitionDepth;
 }
 
-bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,
-		ResourceManager &resources, RoomSetupState &state) const {
-	state = RoomSetupState();
-
-	const ObjectRecord *background = nullptr;
-	const ObjectRecord *backgroundFallback = nullptr;
-	bool usedBackgroundFallback = false;
-	for (const ObjectRecord &candidate : _currentObjects) {
-		if (!isRoomBackgroundSpriteCandidate(candidate, room))
-			continue;
-
-		if (!backgroundFallback)
-			backgroundFallback = &candidate;
-		if (isFullscreenRoomBackgroundObject(candidate, resources)) {
-			background = &candidate;
-			break;
-		}
-	}
-	if (!background && backgroundFallback) {
-		background = backgroundFallback;
-		usedBackgroundFallback = true;
-	}
-	if (!background) {
-		warning("Harvester: no room background object found for room '%s'", room.roomName.c_str());
-		return false;
-	}
-	if (usedBackgroundFallback) {
-		debugC(1, kDebugRoom,
-			"Harvester: using origin room sprite as background room='%s' object='%s' sprite='%s'",
-			room.roomName.c_str(), background->objectName.c_str(), background->spritePath.c_str());
-	}
-
-	state.roomName = room.roomName;
-	state.backgroundObjectName = background->objectName;
-	state.palettePath = room.palettePath;
-	state.backgroundPath = background->spritePath;
-	state.discNumber = resources.getCurrentDisc();
-	state.roomMinZ = room.minZ;
-	state.roomMaxZ = room.maxZ;
-	state.roomMaxZScreenY = room.maxZScreenY;
-	state.roomMinZScreenY = room.minZScreenY;
-	state.roomFullScaleZ = room.fullScaleZ;
-	state.roomMaxZScalePercent = room.maxZScalePercent;
-	state.roomPerspectiveScale = room.perspectiveScale;
-	state.roomZVelocityStep = room.zVelocityStep;
-	state.musicPath = room.musicPath;
-	if (entrance && entrance->roomName.equalsIgnoreCase(room.roomName)) {
-		state.entranceName = entrance->entranceName;
-		state.hasEntrance = true;
-		state.playerSpawnX = entrance->posX;
-		state.playerSpawnY = entrance->posY;
-		state.playerSpawnZ = entrance->posZ;
-		state.playerFacing = entrance->facing;
-	}
-	for (const ObjectRecord &object : _currentObjects) {
-		if (object.visible && object.currentOwnerOrRoom.equalsIgnoreCase(room.roomName))
-			state.roomObjects.push_back(object);
-	}
-	if (!state.hasEntrance) {
-		static const char *const kCloseupExitObjects[] = { "EXIT_BM", "EXIT_HS" };
-		for (const char *name : kCloseupExitObjects) {
-			for (const ObjectRecord &object : _currentObjects) {
-				if (object.objectName.equalsIgnoreCase(name)) {
-					state.roomObjects.push_back(object);
-					break;
-				}
-			}
-		}
-	}
-	for (const AnimRecord &anim : _currentAnimations) {
-		if (anim.roomName.equalsIgnoreCase(room.roomName))
-			state.roomAnimations.push_back(anim);
-	}
-	for (const NpcRecord &npc : _currentNpcs) {
-		if (!npc.roomName.equalsIgnoreCase(room.roomName) || !npc.visible)
-			continue;
-		if (npc.deathOrMonsterfyFlag) {
-			debugC(1, kDebugRoom,
-				"Harvester: suppressed room npc room='%s' npc='%s' death_or_monsterfy=%d damage_type=%d model='%s' monsterfy_target='%s'",
-				room.roomName.c_str(), npc.npcName.c_str(), npc.deathOrMonsterfyFlag, npc.deathDamageType,
-				npc.modelPath.c_str(), npc.monsterfyTargetName.c_str());
-			continue;
-		}
-
-		state.roomNpcs.push_back(npc);
-	}
-	for (const MonsterRecord &monster : _currentMonsters) {
-		if (!monster.roomName.equalsIgnoreCase(room.roomName))
-			continue;
-		if (!monster.active && !monster.visible)
-			continue;
-
-		state.roomMonsters.push_back(monster);
-	}
-	for (const TimerRecord &timer : _currentTimers) {
-		if (timer.arg1.equalsIgnoreCase(room.roomName))
-			state.roomTimers.push_back(timer);
-	}
-	for (const RegionRecord &region : _currentRegions) {
-		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
-			state.roomRegions.push_back(region);
-	}
-
-	const FlagRecord *dayFlag = findRuntimeFlag("DAY_FLAG");
-	state.paletteBrightness = (room.dimmable && (!dayFlag || !dayFlag->value))
-		? kDimmedPaletteBrightness
-		: kDefaultPaletteBrightness;
-
-	for (const ObjectRecord &object : state.roomObjects) {
-		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(),
-			object.currentX, object.currentY, object.currentZ,
-			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
-	}
-	for (const NpcRecord &npc : state.roomNpcs) {
-		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,
-			npc.posX, npc.posY, npc.posZ, npc.frameDelay,
-			npc.modelPath.c_str(), npc.onDeathActionTag.c_str(), npc.audioPath.c_str());
-	}
-	for (const MonsterRecord &monster : state.roomMonsters) {
-		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,
-			monster.posX, monster.posY, monster.posZ, monster.facing,
-			monster.currentHitPoints, monster.initialHitPoints,
-			monster.damageAmount, monster.engageDistance, Player::describeCombatDamageType(monster.damageType),
-			monster.modelPath.c_str(), monster.onDeathActionTag.c_str());
-	}
-	for (const TimerRecord &timer : state.roomTimers) {
-		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(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(),
-		state.musicPath.c_str(), (double)state.paletteBrightness,
-		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size(),
-		(uint)state.roomNpcs.size(), (uint)state.roomMonsters.size(),
-		(uint)state.roomTimers.size(), (uint)state.roomRegions.size());
-
-	return true;
-}
-
 const TextRecord *Script::findTextRecord(const Common::String &key) const {
 	if (key.empty())
 		return nullptr;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 4e654f2df1c..565ca7b7870 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -525,6 +525,8 @@ private:
 	const MonsterRecord *findBaseMonster(const MonsterRecord &monster) const;
 	TimerRecord *findRuntimeTimer(const Common::String &timerName);
 	const TimerRecord *findRuntimeTimer(const Common::String &timerName) const;
+	const ObjectRecord *findRoomBackground(const RoomRecord &room, ResourceManager &resources) const;
+	void collectRuntimeRoomRecords(const RoomRecord &room, RoomSetupState &state) const;
 	bool buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,
 		ResourceManager &resources, RoomSetupState &state) const;
 	void executeCommandChain(const Common::String &initialTag, const CommandExecutionContext &context,
diff --git a/engines/harvester/script_room.cpp b/engines/harvester/script_room.cpp
new file mode 100644
index 00000000000..de311cdffa3
--- /dev/null
+++ b/engines/harvester/script_room.cpp
@@ -0,0 +1,341 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/script.h"
+
+#include "common/debug.h"
+#include "common/endian.h"
+#include "harvester/detection.h"
+#include "harvester/player.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+static const float kDefaultPaletteBrightness = 1.0f;
+static const float kDimmedPaletteBrightness = 0.6f;
+
+static bool loadBitmapDimensions(ResourceManager &resources, const Common::String &path,
+		uint32 &width, uint32 &height) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 8)
+		return false;
+
+	width = READ_LE_UINT32(data.data());
+	height = READ_LE_UINT32(data.data() + 4);
+	return true;
+}
+
+static bool isRoomBackgroundSpriteCandidate(const ObjectRecord &candidate,
+		const RoomRecord &room) {
+	if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room.roomName) ||
+			candidate.spritePath.empty() ||
+			candidate.initialX != 0 || candidate.initialY != 0) {
+		return false;
+	}
+
+	return true;
+}
+
+static bool isFullscreenRoomBackgroundObject(const ObjectRecord &candidate,
+		ResourceManager &resources) {
+	if (candidate.spritePath.empty())
+		return false;
+
+	uint32 width = 0;
+	uint32 height = 0;
+	if (!loadBitmapDimensions(resources, candidate.spritePath, width, height))
+		return false;
+
+	return width == 640 && height == 480;
+}
+
+static void initializeRoomSetupState(const RoomRecord &room, const EntranceRecord *entrance,
+		const ObjectRecord &background, ResourceManager &resources, RoomSetupState &state) {
+	state.roomName = room.roomName;
+	state.backgroundObjectName = background.objectName;
+	state.palettePath = room.palettePath;
+	state.backgroundPath = background.spritePath;
+	state.discNumber = resources.getCurrentDisc();
+	state.roomMinZ = room.minZ;
+	state.roomMaxZ = room.maxZ;
+	state.roomMaxZScreenY = room.maxZScreenY;
+	state.roomMinZScreenY = room.minZScreenY;
+	state.roomFullScaleZ = room.fullScaleZ;
+	state.roomMaxZScalePercent = room.maxZScalePercent;
+	state.roomPerspectiveScale = room.perspectiveScale;
+	state.roomZVelocityStep = room.zVelocityStep;
+	state.musicPath = room.musicPath;
+	if (entrance && entrance->roomName.equalsIgnoreCase(room.roomName)) {
+		state.entranceName = entrance->entranceName;
+		state.hasEntrance = true;
+		state.playerSpawnX = entrance->posX;
+		state.playerSpawnY = entrance->posY;
+		state.playerSpawnZ = entrance->posZ;
+		state.playerFacing = entrance->facing;
+	}
+}
+
+static void logRuntimeRoomState(const RoomSetupState &state) {
+	for (const ObjectRecord &object : state.roomObjects) {
+		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(),
+			object.currentX, object.currentY, object.currentZ,
+			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
+	}
+	for (const NpcRecord &npc : state.roomNpcs) {
+		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,
+			npc.posX, npc.posY, npc.posZ, npc.frameDelay,
+			npc.modelPath.c_str(), npc.onDeathActionTag.c_str(), npc.audioPath.c_str());
+	}
+	for (const MonsterRecord &monster : state.roomMonsters) {
+		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,
+			monster.posX, monster.posY, monster.posZ, monster.facing,
+			monster.currentHitPoints, monster.initialHitPoints,
+			monster.damageAmount, monster.engageDistance, Player::describeCombatDamageType(monster.damageType),
+			monster.modelPath.c_str(), monster.onDeathActionTag.c_str());
+	}
+	for (const TimerRecord &timer : state.roomTimers) {
+		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(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(),
+		state.musicPath.c_str(), static_cast<double>(state.paletteBrightness),
+		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size(),
+		(uint)state.roomNpcs.size(), (uint)state.roomMonsters.size(),
+		(uint)state.roomTimers.size(), (uint)state.roomRegions.size());
+}
+
+bool Script::resolveRoomSetupState(const Common::String &entranceName, RoomSetupState &state,
+		ResourceManager &resources) {
+	state = RoomSetupState();
+
+	const EntranceRecord *entrance = findEntranceRecord(entranceName);
+	const Common::String resolvedRoomName = entrance ? entrance->roomName : entranceName;
+	const RoomRecord *room = findRoomRecord(resolvedRoomName);
+	if (!room) {
+		warning("Harvester: unresolved room setup target '%s' (resolved room '%s')",
+			entranceName.c_str(), resolvedRoomName.c_str());
+		return false;
+	}
+
+	if (!buildRuntimeRoomState(*room, entrance, resources, state))
+		return false;
+
+	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,
+		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
+		static_cast<double>(state.paletteBrightness), (uint)state.roomObjects.size(),
+		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
+		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
+		(uint)state.roomRegions.size());
+
+	return true;
+}
+
+bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, RoomSetupState &state,
+		ResourceManager &resources) {
+	state = RoomSetupState();
+
+	const RoomRecord *room = findRoomRecord(roomName);
+	if (!room) {
+		warning("Harvester: unresolved room setup room '%s'", roomName.c_str());
+		return false;
+	}
+
+	const EntranceRecord *entrance = nullptr;
+	for (const EntranceRecord &candidate : _entrances) {
+		if (!candidate.roomName.equalsIgnoreCase(room->roomName))
+			continue;
+
+		entrance = &candidate;
+		break;
+	}
+
+	if (!buildRuntimeRoomState(*room, entrance, resources, state))
+		return false;
+
+	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,
+		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
+		static_cast<double>(state.paletteBrightness), (uint)state.roomObjects.size(),
+		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
+		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
+		(uint)state.roomRegions.size());
+
+	return true;
+}
+
+bool Script::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
+		RoomSetupState &state, ResourceManager &resources) const {
+	state = RoomSetupState();
+
+	const EntranceRecord *entrance = findEntranceRecord(entranceName);
+	const Common::String resolvedRoomName = !roomName.empty()
+		? roomName
+		: (entrance ? entrance->roomName : Common::String());
+	if (resolvedRoomName.empty())
+		return false;
+
+	const RoomRecord *room = findRoomRecord(resolvedRoomName);
+	if (!room) {
+		warning("Harvester: unresolved materialized room target entrance='%s' room='%s'",
+			entranceName.c_str(), resolvedRoomName.c_str());
+		return false;
+	}
+
+	return buildRuntimeRoomState(*room, entrance, resources, state);
+}
+
+bool Script::hasRoomSetupTarget(const Common::String &entranceName,
+		const Common::String &roomName) const {
+	const EntranceRecord *entrance = findEntranceRecord(entranceName);
+	const Common::String resolvedRoomName = !roomName.empty()
+		? roomName
+		: (entrance ? entrance->roomName : Common::String());
+	if (resolvedRoomName.empty())
+		return false;
+
+	return findRoomRecord(resolvedRoomName) != nullptr;
+}
+
+const ObjectRecord *Script::findRoomBackground(const RoomRecord &room,
+		ResourceManager &resources) const {
+	const ObjectRecord *background = nullptr;
+	const ObjectRecord *backgroundFallback = nullptr;
+	bool usedBackgroundFallback = false;
+	for (const ObjectRecord &candidate : _currentObjects) {
+		if (!isRoomBackgroundSpriteCandidate(candidate, room))
+			continue;
+
+		if (!backgroundFallback)
+			backgroundFallback = &candidate;
+		if (isFullscreenRoomBackgroundObject(candidate, resources)) {
+			background = &candidate;
+			break;
+		}
+	}
+	if (!background && backgroundFallback) {
+		background = backgroundFallback;
+		usedBackgroundFallback = true;
+	}
+	if (!background) {
+		warning("Harvester: no room background object found for room '%s'", room.roomName.c_str());
+		return nullptr;
+	}
+	if (usedBackgroundFallback) {
+		debugC(1, kDebugRoom,
+			"Harvester: using origin room sprite as background room='%s' object='%s' sprite='%s'",
+			room.roomName.c_str(), background->objectName.c_str(), background->spritePath.c_str());
+	}
+
+	return background;
+}
+
+void Script::collectRuntimeRoomRecords(const RoomRecord &room, RoomSetupState &state) const {
+	for (const ObjectRecord &object : _currentObjects) {
+		if (object.visible && object.currentOwnerOrRoom.equalsIgnoreCase(room.roomName))
+			state.roomObjects.push_back(object);
+	}
+	if (!state.hasEntrance) {
+		static const char *const kCloseupExitObjects[] = { "EXIT_BM", "EXIT_HS" };
+		for (const char *name : kCloseupExitObjects) {
+			for (const ObjectRecord &object : _currentObjects) {
+				if (object.objectName.equalsIgnoreCase(name)) {
+					state.roomObjects.push_back(object);
+					break;
+				}
+			}
+		}
+	}
+	for (const AnimRecord &anim : _currentAnimations) {
+		if (anim.roomName.equalsIgnoreCase(room.roomName))
+			state.roomAnimations.push_back(anim);
+	}
+	for (const NpcRecord &npc : _currentNpcs) {
+		if (!npc.roomName.equalsIgnoreCase(room.roomName) || !npc.visible)
+			continue;
+		if (npc.deathOrMonsterfyFlag) {
+			debugC(1, kDebugRoom,
+				"Harvester: suppressed room npc room='%s' npc='%s' death_or_monsterfy=%d damage_type=%d model='%s' monsterfy_target='%s'",
+				room.roomName.c_str(), npc.npcName.c_str(), npc.deathOrMonsterfyFlag, npc.deathDamageType,
+				npc.modelPath.c_str(), npc.monsterfyTargetName.c_str());
+			continue;
+		}
+
+		state.roomNpcs.push_back(npc);
+	}
+	for (const MonsterRecord &monster : _currentMonsters) {
+		if (!monster.roomName.equalsIgnoreCase(room.roomName))
+			continue;
+		if (!monster.active && !monster.visible)
+			continue;
+
+		state.roomMonsters.push_back(monster);
+	}
+	for (const TimerRecord &timer : _currentTimers) {
+		if (timer.arg1.equalsIgnoreCase(room.roomName))
+			state.roomTimers.push_back(timer);
+	}
+	for (const RegionRecord &region : _currentRegions) {
+		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
+			state.roomRegions.push_back(region);
+	}
+}
+
+bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,
+		ResourceManager &resources, RoomSetupState &state) const {
+	state = RoomSetupState();
+
+	const ObjectRecord *background = findRoomBackground(room, resources);
+	if (!background)
+		return false;
+
+	initializeRoomSetupState(room, entrance, *background, resources, state);
+	collectRuntimeRoomRecords(room, state);
+
+	const FlagRecord *dayFlag = findRuntimeFlag("DAY_FLAG");
+	state.paletteBrightness = (room.dimmable && (!dayFlag || !dayFlag->value))
+		? kDimmedPaletteBrightness
+		: kDefaultPaletteBrightness;
+
+	logRuntimeRoomState(state);
+	return true;
+}
+
+} // End of namespace Harvester


Commit: 3b56649b1a50d88c72c3f90e93c115f2a358edac
    https://github.com/scummvm/scummvm/commit/3b56649b1a50d88c72c3f90e93c115f2a358edac
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Separate script save and reload handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/script_save.cpp
    engines/harvester/module.mk
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 39b47e54559..b51b1dbb459 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -29,6 +29,7 @@ MODULE_OBJS = \
 	script_commands.o \
 	script_records.o \
 	script_room.o \
+	script_save.o \
 	text.o \
 	xfile_archive.o \
 	npc/authority_dialogue.o \
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 84d0be50f2f..073a4db2b30 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -27,7 +27,6 @@
 #include "common/file.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
-#include "common/serializer.h"
 #include "harvester/detection.h"
 #include "harvester/parse_utils.h"
 #include "harvester/player.h"
@@ -72,135 +71,6 @@ static const InventoryCombatLoadoutEntry kInventoryCombatLoadoutMap[] = {
 	{ 20, "POOLSTICK" }
 };
 
-static void syncBool(Common::Serializer &s, bool &value) {
-	byte serialized = value ? 1 : 0;
-	s.syncAsByte(serialized);
-	if (s.isLoading())
-		value = serialized != 0;
-}
-
-template <typename RecordType, typename SyncFn>
-static void syncRecordArray(Common::Serializer &s, Common::Array<RecordType> &records, SyncFn syncFn) {
-	uint32 count = (uint32)records.size();
-	s.syncAsUint32LE(count);
-	if (s.isLoading())
-		records.resize(count);
-	for (uint32 i = 0; i < count; ++i)
-		syncFn(s, records[i]);
-}
-
-static void syncStartupFlagRecord(Common::Serializer &s, FlagRecord &record) {
-	s.syncString(record.name);
-	syncBool(s, record.value);
-}
-
-static void syncStartupObjectRecord(Common::Serializer &s, ObjectRecord &record) {
-	s.syncAsSint32LE(record.initialX);
-	s.syncAsSint32LE(record.initialY);
-	s.syncAsSint32LE(record.initialZ);
-	s.syncAsSint32LE(record.currentX);
-	s.syncAsSint32LE(record.currentY);
-	s.syncAsSint32LE(record.currentZ);
-	s.syncAsSint32LE(record.boundsX2);
-	s.syncAsSint32LE(record.boundsY2);
-	s.syncAsSint32LE(record.zExtent);
-	s.syncString(record.actionTag);
-	s.syncString(record.spritePath);
-	s.syncString(record.altSpritePath);
-	s.syncString(record.objectName);
-	s.syncString(record.reservedXFlag);
-	s.syncString(record.identTextKey);
-	s.syncString(record.currentOwnerOrRoom);
-	s.syncString(record.reservedString40);
-	s.syncString(record.inventoryTextKey);
-	s.syncString(record.initialOwnerOrRoom);
-	s.syncString(record.interactionLabel);
-	syncBool(s, record.operatable);
-	syncBool(s, record.visible);
-	syncBool(s, record.runtimeVisible);
-	syncBool(s, record.identShown);
-}
-
-static void syncStartupAnimRecord(Common::Serializer &s, AnimRecord &record) {
-	s.syncAsSint32LE(record.x);
-	s.syncAsSint32LE(record.y);
-	s.syncAsSint32LE(record.z);
-	s.syncAsSint32LE(record.frameDelay);
-	s.syncString(record.roomName);
-	s.syncString(record.resourcePath);
-	s.syncString(record.animName);
-	syncBool(s, record.active);
-	syncBool(s, record.visible);
-	syncBool(s, record.looping);
-	syncBool(s, record.backward);
-	syncBool(s, record.pingPong);
-	syncBool(s, record.remove);
-	syncBool(s, record.runtimeActive);
-	syncBool(s, record.runtimeVisible);
-	s.syncAsSint32LE(record.runtimeState);
-}
-
-static void syncStartupNpcRecord(Common::Serializer &s, NpcRecord &record) {
-	s.syncAsSint32LE(record.posX);
-	s.syncAsSint32LE(record.posY);
-	s.syncAsSint32LE(record.posZ);
-	s.syncAsSint32LE(record.frameDelay);
-	s.syncString(record.onDeathActionTag);
-	s.syncString(record.modelPath);
-	s.syncString(record.npcName);
-	s.syncString(record.monsterfyTargetName);
-	s.syncString(record.roomName);
-	syncBool(s, record.deathOrMonsterfyFlag);
-	syncBool(s, record.runtimeSpawned);
-	s.syncAsSint32LE(record.runtimeState);
-	syncBool(s, record.active);
-	syncBool(s, record.visible);
-	syncBool(s, record.savedVisible);
-	s.syncAsSint32LE(record.deathDamageType);
-	s.syncString(record.audioPath);
-	s.syncString(record.entityInitArg);
-}
-
-static void syncStartupMonsterRecord(Common::Serializer &s, MonsterRecord &record) {
-	s.syncAsSint32LE(record.posX);
-	s.syncAsSint32LE(record.posY);
-	s.syncAsSint32LE(record.posZ);
-	s.syncAsSint32LE(record.facing);
-	s.syncString(record.roomName);
-	s.syncString(record.monsterName);
-	s.syncString(record.modelPath);
-	s.syncString(record.onDeathActionTag);
-	syncBool(s, record.active);
-	syncBool(s, record.visible);
-	s.syncAsSint32LE(record.initialHitPoints);
-	s.syncAsSint32LE(record.currentHitPoints);
-	s.syncAsSint32LE(record.damageAmount);
-	s.syncAsSint32LE(record.engageDistance);
-	s.syncAsSint32LE(record.damageType);
-	s.syncString(record.reservedString38);
-	s.syncString(record.reservedString3c);
-	s.syncString(record.reservedString44);
-	s.syncString(record.reservedString48);
-	s.syncString(record.attackSound1);
-	s.syncString(record.attackSound2);
-	s.syncString(record.attackSound3);
-	s.syncString(record.hitSound1);
-	s.syncString(record.hitSound2);
-	s.syncString(record.hitSound3);
-	s.syncString(record.footstepSoundLeft);
-	s.syncString(record.footstepSoundRight);
-	s.syncString(record.deathSound);
-	syncBool(s, record.savedVisible);
-	syncBool(s, record.runtimeSpawned);
-	s.syncAsSint32LE(record.screenMinXBound);
-	s.syncAsSint32LE(record.screenMaxXBound);
-	s.syncAsSint32LE(record.attackSoundTriggerFrame);
-	s.syncAsSint32LE(record.hitSoundTriggerFrame);
-	s.syncAsSint32LE(record.footstepSoundTriggerFrame);
-	s.syncAsSint32LE(record.deathSoundTriggerFrame);
-	s.syncAsSint32LE(record.runtimeState);
-}
-
 static bool matchesMonsterIdentity(const MonsterRecord &candidate, const MonsterRecord &monster) {
 	if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
 		return false;
@@ -214,56 +84,6 @@ static bool matchesMonsterIdentity(const MonsterRecord &candidate, const Monster
 	return true;
 }
 
-static void restoreMonsterRecordIndices(const Common::Array<MonsterRecord> &baseMonsters,
-		Common::Array<MonsterRecord> &runtimeMonsters) {
-	for (uint i = 0; i < runtimeMonsters.size(); ++i) {
-		if (i < baseMonsters.size() &&
-				baseMonsters[i].monsterName.equalsIgnoreCase(runtimeMonsters[i].monsterName) &&
-				baseMonsters[i].roomName.equalsIgnoreCase(runtimeMonsters[i].roomName)) {
-			runtimeMonsters[i].recordIndex = baseMonsters[i].recordIndex;
-			continue;
-		}
-
-		runtimeMonsters[i].recordIndex = -1;
-		for (const MonsterRecord &baseMonster : baseMonsters) {
-			if (!baseMonster.monsterName.equalsIgnoreCase(runtimeMonsters[i].monsterName) ||
-					!baseMonster.roomName.equalsIgnoreCase(runtimeMonsters[i].roomName)) {
-				continue;
-			}
-
-			runtimeMonsters[i].recordIndex = baseMonster.recordIndex;
-			break;
-		}
-	}
-}
-
-static void syncStartupTimerRecord(Common::Serializer &s, TimerRecord &record) {
-	s.syncAsSint32LE(record.initialValue);
-	s.syncAsSint32LE(record.currentValue);
-	s.syncString(record.timerName);
-	s.syncString(record.arg1);
-	s.syncString(record.arg2);
-	syncBool(s, record.enabled);
-	syncBool(s, record.looping);
-	syncBool(s, record.global);
-}
-
-static void syncStartupRegionRecord(Common::Serializer &s, RegionRecord &record) {
-	s.syncAsSint32LE(record.left);
-	s.syncAsSint32LE(record.top);
-	s.syncAsSint32LE(record.right);
-	s.syncAsSint32LE(record.bottom);
-	s.syncAsSint32LE(record.minZ);
-	s.syncAsSint32LE(record.maxZ);
-	s.syncAsSint32LE(record.desiredFacing);
-	s.syncString(record.regionName);
-	s.syncString(record.direction);
-	s.syncString(record.roomName);
-	s.syncString(record.actionTag);
-	syncBool(s, record.startEnabled);
-	syncBool(s, record.cursorEnabled);
-}
-
 static int clampStartupOptionLevel(int level) {
 	if (level < 0)
 		return 0;
@@ -273,7 +93,7 @@ static int clampStartupOptionLevel(int level) {
 	return level;
 }
 
-static int clampPlayerHitPoints(int hitPoints) {
+int Script::clampPlayerHitPoints(int hitPoints) {
 	if (hitPoints < 0)
 		return 0;
 	if (hitPoints > Script::kDefaultPlayerHitPoints)
@@ -282,7 +102,7 @@ static int clampPlayerHitPoints(int hitPoints) {
 	return hitPoints;
 }
 
-static int clampPlayerCombatLoadout(int loadout) {
+int Script::clampPlayerCombatLoadout(int loadout) {
 	return CLIP<int>(loadout, 0, kMaxPlayerCombatLoadout);
 }
 
@@ -301,7 +121,7 @@ static int resolveCombatResourceDisplayMax(int loadout) {
 	}
 }
 
-static int clampCombatResourceCount(int loadout, int count) {
+int Script::clampCombatResourceCount(int loadout, int count) {
 	const int maxCount = resolveCombatResourceDisplayMax(loadout);
 	if (maxCount <= 0)
 		return 0;
@@ -333,7 +153,7 @@ static const char *resolveCombatLoadoutInventoryObjectName(int loadout) {
 	return nullptr;
 }
 
-static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<ObjectRecord> &currentObjects,
+int Script::validatePlayerCombatLoadoutAgainstInventory(const Common::Array<ObjectRecord> &currentObjects,
 		int loadout, const char *contextLabel) {
 	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
 	if (clampedLoadout == kDefaultPlayerCombatLoadout)
@@ -457,153 +277,6 @@ bool Script::load(ResourceManager &resources) {
 	return true;
 }
 
-bool Script::reloadTownWorld(ResourceManager &resources) {
-	Common::Array<byte> reloadedData;
-	if (!resources.loadFile(_path, reloadedData)) {
-		warning("Harvester: unable to reload town script '%s'", _path.c_str());
-		return false;
-	}
-
-	const Common::Array<FlagRecord> currentFlags = _currentFlags;
-	const Common::Array<ObjectRecord> currentObjects = _currentObjects;
-	const Common::Array<AnimRecord> currentAnimations = _currentAnimations;
-	const Common::Array<RegionRecord> currentRegions = _currentRegions;
-	const Common::Array<NpcRecord> currentNpcs = _currentNpcs;
-	const Common::Array<MonsterRecord> currentMonsters = _currentMonsters;
-	const Common::Array<TimerRecord> currentTimers = _currentTimers;
-	const int nailgunAmmoCount = _nailgunAmmoCount;
-	const int shotgunShellCount = _shotgunShellCount;
-	const int nineGunBulletCount = _nineGunBulletCount;
-	const int thirtyEightGunBulletCount = _thirtyEightGunBulletCount;
-	const int chainsawFuelCount = _chainsawFuelCount;
-	const int playerCurrentHitPoints = _playerCurrentHitPoints;
-	const int playerCombatLoadout = _playerCombatLoadout;
-	const bool playerControlPaused = _playerControlPaused;
-
-	_data = Common::move(reloadedData);
-	decode();
-	parseTownRecords(resources);
-	resetRuntimeState();
-
-	for (const FlagRecord &flag : currentFlags) {
-		FlagRecord *currentFlag = findRuntimeFlag(flag.name);
-		if (currentFlag) {
-			currentFlag->value = flag.value;
-			continue;
-		}
-
-		_currentFlags.push_back(flag);
-	}
-
-	for (const ObjectRecord &object : currentObjects) {
-		ObjectRecord *currentObject = findRuntimeObject(object.initialOwnerOrRoom, object.objectName);
-		if (!currentObject)
-			currentObject = findRuntimeObject(Common::String(), object.objectName);
-		if (!currentObject) {
-			_currentObjects.push_back(object);
-			continue;
-		}
-
-		currentObject->currentX = object.currentX;
-		currentObject->currentY = object.currentY;
-		currentObject->currentZ = object.currentZ;
-		currentObject->currentOwnerOrRoom = object.currentOwnerOrRoom;
-		currentObject->visible = object.visible;
-		currentObject->runtimeVisible = object.runtimeVisible;
-		currentObject->identShown = object.identShown;
-	}
-
-	for (const AnimRecord &anim : currentAnimations) {
-		AnimRecord *currentAnimation = findRuntimeAnim(anim.animName);
-		if (!currentAnimation) {
-			_currentAnimations.push_back(anim);
-			continue;
-		}
-
-		currentAnimation->active = anim.active;
-		currentAnimation->visible = anim.visible;
-		currentAnimation->runtimeActive = anim.runtimeActive;
-		currentAnimation->runtimeVisible = anim.runtimeVisible;
-		currentAnimation->runtimeState = anim.runtimeState;
-	}
-
-	for (const RegionRecord &region : currentRegions) {
-		RegionRecord *currentRegion = findRuntimeRegion(region.regionName);
-		if (!currentRegion) {
-			_currentRegions.push_back(region);
-			continue;
-		}
-
-		currentRegion->startEnabled = region.startEnabled;
-	}
-
-	for (const NpcRecord &npc : currentNpcs) {
-		NpcRecord *currentNpc = findRuntimeNpc(npc.npcName);
-		if (!currentNpc) {
-			_currentNpcs.push_back(npc);
-			continue;
-		}
-
-		currentNpc->active = npc.active;
-		currentNpc->visible = npc.visible;
-		currentNpc->savedVisible = npc.savedVisible;
-		currentNpc->runtimeSpawned = npc.runtimeSpawned;
-		currentNpc->runtimeState = npc.runtimeState;
-		currentNpc->deathOrMonsterfyFlag = npc.deathOrMonsterfyFlag;
-		currentNpc->deathDamageType = npc.deathDamageType;
-	}
-
-	for (const MonsterRecord &monster : currentMonsters) {
-		MonsterRecord *currentMonster = findRuntimeMonster(monster);
-		if (!currentMonster) {
-			_currentMonsters.push_back(monster);
-			continue;
-		}
-
-		currentMonster->posX = monster.posX;
-		currentMonster->posY = monster.posY;
-		currentMonster->posZ = monster.posZ;
-		currentMonster->facing = monster.facing;
-		currentMonster->active = monster.active;
-		currentMonster->visible = monster.visible;
-		currentMonster->savedVisible = monster.savedVisible;
-		currentMonster->runtimeSpawned = monster.runtimeSpawned;
-		currentMonster->runtimeState = monster.runtimeState;
-		currentMonster->currentHitPoints = monster.currentHitPoints;
-		currentMonster->screenMinXBound = monster.screenMinXBound;
-		currentMonster->screenMaxXBound = monster.screenMaxXBound;
-	}
-
-	for (const TimerRecord &timer : currentTimers) {
-		TimerRecord *currentTimer = findRuntimeTimer(timer.timerName);
-		if (!currentTimer) {
-			_currentTimers.push_back(timer);
-			continue;
-		}
-
-		currentTimer->initialValue = timer.initialValue;
-		currentTimer->currentValue = timer.currentValue;
-		currentTimer->enabled = timer.enabled;
-		currentTimer->looping = timer.looping;
-		currentTimer->global = timer.global;
-	}
-
-	_playerCurrentHitPoints = clampPlayerHitPoints(playerCurrentHitPoints);
-	_nailgunAmmoCount = clampCombatResourceCount(2, nailgunAmmoCount);
-	_shotgunShellCount = clampCombatResourceCount(3, shotgunShellCount);
-	_nineGunBulletCount = clampCombatResourceCount(4, nineGunBulletCount);
-	_thirtyEightGunBulletCount = clampCombatResourceCount(5, thirtyEightGunBulletCount);
-	_chainsawFuelCount = clampCombatResourceCount(14, chainsawFuelCount);
-	_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
-		_currentObjects, playerCombatLoadout, "town reload");
-	_playerControlPaused = playerControlPaused;
-
-	debugC(1, kDebugGeneral,
-		"Harvester: reloaded town script '%s' for disc %d while preserving runtime state",
-		_path.c_str(), resources.getCurrentDisc());
-	return true;
-}
-
 bool Script::loadConfig(ResourceManager &resources) {
 	Common::Array<byte> configData;
 	if (!resources.loadFile(kStartupConfigPath, configData)) {
@@ -780,194 +453,6 @@ void Script::resetRuntimeState() {
 		timer.currentValue = timer.initialValue;
 }
 
-void Script::logRuntimeSaveState(const char *operation) const {
-	debugC(1, kDebugGeneral,
-		"Harvester: %s runtime save state flags=%u objects=%u anims=%u regions=%u npcs=%u monsters=%u timers=%u hp=%d loadout=%d paused=%d ammo=[nails:%d shells:%d bullets9:%d bullets38:%d gas:%d]",
-		operation, (uint)_currentFlags.size(), (uint)_currentObjects.size(),
-		(uint)_currentAnimations.size(), (uint)_currentRegions.size(),
-		(uint)_currentNpcs.size(), (uint)_currentMonsters.size(),
-		(uint)_currentTimers.size(), _playerCurrentHitPoints, _playerCombatLoadout,
-		_playerControlPaused, _nailgunAmmoCount, _shotgunShellCount,
-		_nineGunBulletCount, _thirtyEightGunBulletCount, _chainsawFuelCount);
-
-	for (const FlagRecord &flag : _currentFlags) {
-		const FlagRecord *baseFlag = nullptr;
-		for (const FlagRecord &candidate : _flags) {
-			if (candidate.name.equalsIgnoreCase(flag.name)) {
-				baseFlag = &candidate;
-				break;
-			}
-		}
-		if (!baseFlag || flag.value != baseFlag->value) {
-			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);
-		}
-	}
-
-	for (const ObjectRecord &object : _currentObjects) {
-		const ObjectRecord *baseObject = nullptr;
-		for (const ObjectRecord &candidate : _objects) {
-			if (candidate.objectName.equalsIgnoreCase(object.objectName)) {
-				baseObject = &candidate;
-				break;
-			}
-		}
-
-		const Common::String &defaultOwner = baseObject ? baseObject->initialOwnerOrRoom : object.initialOwnerOrRoom;
-		const int defaultX = baseObject ? baseObject->initialX : object.initialX;
-		const int defaultY = baseObject ? baseObject->initialY : object.initialY;
-		const int defaultZ = baseObject ? baseObject->initialZ : object.initialZ;
-		const bool defaultRuntimeVisible = baseObject ? baseObject->visible : object.visible;
-		const bool defaultVisible = baseObject ? baseObject->visible : object.visible;
-		const bool defaultIdentShown = baseObject ? baseObject->identTextKey.empty() : object.identTextKey.empty();
-		if (!baseObject ||
-				!object.currentOwnerOrRoom.equalsIgnoreCase(defaultOwner) ||
-				object.currentX != defaultX ||
-				object.currentY != defaultY ||
-				object.currentZ != defaultZ ||
-				object.runtimeVisible != defaultRuntimeVisible ||
-				object.visible != defaultVisible ||
-				object.identShown != defaultIdentShown) {
-			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,
-				object.visible, object.runtimeVisible, object.identShown,
-				defaultOwner.c_str(), defaultX, defaultY, defaultZ,
-				defaultVisible, defaultRuntimeVisible, defaultIdentShown);
-		}
-	}
-
-	for (const NpcRecord &npc : _currentNpcs) {
-		const NpcRecord *baseNpc = nullptr;
-		for (const NpcRecord &candidate : _npcs) {
-			if (candidate.npcName.equalsIgnoreCase(npc.npcName)) {
-				baseNpc = &candidate;
-				break;
-			}
-		}
-
-		const bool defaultActive = baseNpc ? baseNpc->active : npc.active;
-		const bool defaultVisible = baseNpc ? baseNpc->visible : npc.visible;
-		const bool defaultSavedVisible = baseNpc ? baseNpc->visible : npc.visible;
-		const bool defaultDeathOrMonsterfy = baseNpc ? baseNpc->deathOrMonsterfyFlag : npc.deathOrMonsterfyFlag;
-		const int defaultDeathDamageType = baseNpc ? baseNpc->deathDamageType : npc.deathDamageType;
-		const int defaultRuntimeState = -1;
-		if (!baseNpc ||
-				npc.active != defaultActive ||
-				npc.visible != defaultVisible ||
-				npc.savedVisible != defaultSavedVisible ||
-				npc.deathOrMonsterfyFlag != defaultDeathOrMonsterfy ||
-				npc.deathDamageType != defaultDeathDamageType ||
-				npc.runtimeSpawned ||
-				npc.runtimeState != defaultRuntimeState) {
-			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,
-				defaultActive, defaultVisible, defaultSavedVisible,
-				0, defaultRuntimeState, defaultDeathOrMonsterfy, defaultDeathDamageType);
-		}
-	}
-
-	for (const MonsterRecord &monster : _currentMonsters) {
-		const MonsterRecord *baseMonster = findBaseMonster(monster);
-
-		const bool defaultActive = baseMonster ? baseMonster->active : monster.active;
-		const bool defaultVisible = baseMonster
-			? (baseMonster->active ? true : baseMonster->visible)
-			: (monster.active ? true : monster.visible);
-		const bool defaultSavedVisible = defaultVisible;
-		const int defaultCurrentHitPoints = baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints;
-		const int defaultScreenMinXBound = baseMonster ? baseMonster->screenMinXBound : monster.screenMinXBound;
-		const int defaultScreenMaxXBound = baseMonster ? baseMonster->screenMaxXBound : monster.screenMaxXBound;
-		if (!baseMonster ||
-				monster.active != defaultActive ||
-				monster.visible != defaultVisible ||
-				monster.savedVisible != defaultSavedVisible ||
-				monster.currentHitPoints != defaultCurrentHitPoints ||
-				monster.runtimeSpawned ||
-				monster.runtimeState != -1 ||
-				monster.screenMinXBound != defaultScreenMinXBound ||
-				monster.screenMaxXBound != defaultScreenMaxXBound) {
-			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,
-				monster.currentHitPoints, monster.initialHitPoints,
-				monster.screenMinXBound, monster.screenMaxXBound,
-				defaultActive, defaultVisible, defaultSavedVisible, -1,
-				defaultCurrentHitPoints, baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints,
-				defaultScreenMinXBound, defaultScreenMaxXBound);
-		}
-	}
-
-	for (const TimerRecord &timer : _currentTimers) {
-		const TimerRecord *baseTimer = nullptr;
-		for (const TimerRecord &candidate : _timers) {
-			if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
-				baseTimer = &candidate;
-				break;
-			}
-		}
-
-		const int defaultInitialValue = baseTimer ? baseTimer->initialValue : timer.initialValue;
-		const int defaultCurrentValue = defaultInitialValue;
-		const bool defaultEnabled = baseTimer ? baseTimer->enabled : timer.enabled;
-		const bool defaultLooping = baseTimer ? baseTimer->looping : timer.looping;
-		const bool defaultGlobal = baseTimer ? baseTimer->global : timer.global;
-		if (!baseTimer ||
-				timer.initialValue != defaultInitialValue ||
-				timer.currentValue != defaultCurrentValue ||
-				timer.enabled != defaultEnabled ||
-				timer.looping != defaultLooping ||
-				timer.global != defaultGlobal) {
-			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,
-				defaultCurrentValue, defaultInitialValue, defaultEnabled, defaultLooping, defaultGlobal);
-		}
-	}
-}
-
-void Script::syncRuntimeSaveState(Common::Serializer &s) {
-	if (s.isSaving())
-		logRuntimeSaveState("saving");
-
-	syncRecordArray(s, _currentFlags, syncStartupFlagRecord);
-	syncRecordArray(s, _currentObjects, syncStartupObjectRecord);
-	syncRecordArray(s, _currentAnimations, syncStartupAnimRecord);
-	syncRecordArray(s, _currentRegions, syncStartupRegionRecord);
-	syncRecordArray(s, _currentNpcs, syncStartupNpcRecord);
-	syncRecordArray(s, _currentMonsters, syncStartupMonsterRecord);
-	if (s.isLoading())
-		restoreMonsterRecordIndices(_monsters, _currentMonsters);
-	s.syncAsSint32LE(_playerCurrentHitPoints);
-	syncRecordArray(s, _currentTimers, syncStartupTimerRecord);
-	s.syncAsSint32LE(_playerCombatLoadout);
-	syncBool(s, _playerControlPaused);
-	s.syncAsSint32LE(_nailgunAmmoCount);
-	s.syncAsSint32LE(_shotgunShellCount);
-	s.syncAsSint32LE(_nineGunBulletCount);
-	s.syncAsSint32LE(_thirtyEightGunBulletCount);
-	s.syncAsSint32LE(_chainsawFuelCount);
-	if (s.isLoading()) {
-		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
-		_playerCombatLoadout = clampPlayerCombatLoadout(_playerCombatLoadout);
-		_nailgunAmmoCount = clampCombatResourceCount(2, _nailgunAmmoCount);
-		_shotgunShellCount = clampCombatResourceCount(3, _shotgunShellCount);
-		_nineGunBulletCount = clampCombatResourceCount(4, _nineGunBulletCount);
-		_thirtyEightGunBulletCount = clampCombatResourceCount(5, _thirtyEightGunBulletCount);
-		_chainsawFuelCount = clampCombatResourceCount(14, _chainsawFuelCount);
-		_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
-			_currentObjects, _playerCombatLoadout, "save load");
-		logRuntimeSaveState("loaded");
-	}
-}
-
 bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		InteractionResult &result) {
 	result = InteractionResult();
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 565ca7b7870..93825f8ec66 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -569,6 +569,23 @@ private:
 	const int *getPlayerCombatResourceCountPtr(int loadout) const;
 	bool adjustPlayerCombatResourceCount(int loadout, int delta, int maxCount,
 		const Common::String &reason);
+	void restoreRuntimeFlags(const Common::Array<FlagRecord> &currentFlags);
+	void restoreRuntimeObjects(const Common::Array<ObjectRecord> &currentObjects);
+	void restoreRuntimeAnimations(const Common::Array<AnimRecord> &currentAnimations);
+	void restoreRuntimeRegions(const Common::Array<RegionRecord> &currentRegions);
+	void restoreRuntimeNpcs(const Common::Array<NpcRecord> &currentNpcs);
+	void restoreRuntimeMonsters(const Common::Array<MonsterRecord> &currentMonsters);
+	void restoreRuntimeTimers(const Common::Array<TimerRecord> &currentTimers);
+	void logRuntimeSaveFlags(const char *operation) const;
+	void logRuntimeSaveObjects(const char *operation) const;
+	void logRuntimeSaveNpcs(const char *operation) const;
+	void logRuntimeSaveMonsters(const char *operation) const;
+	void logRuntimeSaveTimers(const char *operation) const;
+	static int clampPlayerHitPoints(int hitPoints);
+	static int clampPlayerCombatLoadout(int loadout);
+	static int clampCombatResourceCount(int loadout, int count);
+	static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<ObjectRecord> &currentObjects,
+		int loadout, const char *contextLabel);
 	void logRuntimeSaveState(const char *operation) const;
 
 	Common::String _path;
diff --git a/engines/harvester/script_save.cpp b/engines/harvester/script_save.cpp
new file mode 100644
index 00000000000..0af41d760d5
--- /dev/null
+++ b/engines/harvester/script_save.cpp
@@ -0,0 +1,584 @@
+/* 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/script.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "common/serializer.h"
+#include "harvester/detection.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+static void syncBool(Common::Serializer &s, bool &value) {
+	byte serialized = value ? 1 : 0;
+	s.syncAsByte(serialized);
+	if (s.isLoading())
+		value = serialized != 0;
+}
+
+template <typename RecordType, typename SyncFn>
+static void syncRecordArray(Common::Serializer &s, Common::Array<RecordType> &records, SyncFn syncFn) {
+	uint32 count = (uint32)records.size();
+	s.syncAsUint32LE(count);
+	if (s.isLoading())
+		records.resize(count);
+	for (uint32 i = 0; i < count; ++i)
+		syncFn(s, records[i]);
+}
+
+static void syncStartupFlagRecord(Common::Serializer &s, FlagRecord &record) {
+	s.syncString(record.name);
+	syncBool(s, record.value);
+}
+
+static void syncStartupObjectRecord(Common::Serializer &s, ObjectRecord &record) {
+	s.syncAsSint32LE(record.initialX);
+	s.syncAsSint32LE(record.initialY);
+	s.syncAsSint32LE(record.initialZ);
+	s.syncAsSint32LE(record.currentX);
+	s.syncAsSint32LE(record.currentY);
+	s.syncAsSint32LE(record.currentZ);
+	s.syncAsSint32LE(record.boundsX2);
+	s.syncAsSint32LE(record.boundsY2);
+	s.syncAsSint32LE(record.zExtent);
+	s.syncString(record.actionTag);
+	s.syncString(record.spritePath);
+	s.syncString(record.altSpritePath);
+	s.syncString(record.objectName);
+	s.syncString(record.reservedXFlag);
+	s.syncString(record.identTextKey);
+	s.syncString(record.currentOwnerOrRoom);
+	s.syncString(record.reservedString40);
+	s.syncString(record.inventoryTextKey);
+	s.syncString(record.initialOwnerOrRoom);
+	s.syncString(record.interactionLabel);
+	syncBool(s, record.operatable);
+	syncBool(s, record.visible);
+	syncBool(s, record.runtimeVisible);
+	syncBool(s, record.identShown);
+}
+
+static void syncStartupAnimRecord(Common::Serializer &s, AnimRecord &record) {
+	s.syncAsSint32LE(record.x);
+	s.syncAsSint32LE(record.y);
+	s.syncAsSint32LE(record.z);
+	s.syncAsSint32LE(record.frameDelay);
+	s.syncString(record.roomName);
+	s.syncString(record.resourcePath);
+	s.syncString(record.animName);
+	syncBool(s, record.active);
+	syncBool(s, record.visible);
+	syncBool(s, record.looping);
+	syncBool(s, record.backward);
+	syncBool(s, record.pingPong);
+	syncBool(s, record.remove);
+	syncBool(s, record.runtimeActive);
+	syncBool(s, record.runtimeVisible);
+	s.syncAsSint32LE(record.runtimeState);
+}
+
+static void syncStartupNpcRecord(Common::Serializer &s, NpcRecord &record) {
+	s.syncAsSint32LE(record.posX);
+	s.syncAsSint32LE(record.posY);
+	s.syncAsSint32LE(record.posZ);
+	s.syncAsSint32LE(record.frameDelay);
+	s.syncString(record.onDeathActionTag);
+	s.syncString(record.modelPath);
+	s.syncString(record.npcName);
+	s.syncString(record.monsterfyTargetName);
+	s.syncString(record.roomName);
+	syncBool(s, record.deathOrMonsterfyFlag);
+	syncBool(s, record.runtimeSpawned);
+	s.syncAsSint32LE(record.runtimeState);
+	syncBool(s, record.active);
+	syncBool(s, record.visible);
+	syncBool(s, record.savedVisible);
+	s.syncAsSint32LE(record.deathDamageType);
+	s.syncString(record.audioPath);
+	s.syncString(record.entityInitArg);
+}
+
+static void syncStartupMonsterRecord(Common::Serializer &s, MonsterRecord &record) {
+	s.syncAsSint32LE(record.posX);
+	s.syncAsSint32LE(record.posY);
+	s.syncAsSint32LE(record.posZ);
+	s.syncAsSint32LE(record.facing);
+	s.syncString(record.roomName);
+	s.syncString(record.monsterName);
+	s.syncString(record.modelPath);
+	s.syncString(record.onDeathActionTag);
+	syncBool(s, record.active);
+	syncBool(s, record.visible);
+	s.syncAsSint32LE(record.initialHitPoints);
+	s.syncAsSint32LE(record.currentHitPoints);
+	s.syncAsSint32LE(record.damageAmount);
+	s.syncAsSint32LE(record.engageDistance);
+	s.syncAsSint32LE(record.damageType);
+	s.syncString(record.reservedString38);
+	s.syncString(record.reservedString3c);
+	s.syncString(record.reservedString44);
+	s.syncString(record.reservedString48);
+	s.syncString(record.attackSound1);
+	s.syncString(record.attackSound2);
+	s.syncString(record.attackSound3);
+	s.syncString(record.hitSound1);
+	s.syncString(record.hitSound2);
+	s.syncString(record.hitSound3);
+	s.syncString(record.footstepSoundLeft);
+	s.syncString(record.footstepSoundRight);
+	s.syncString(record.deathSound);
+	syncBool(s, record.savedVisible);
+	syncBool(s, record.runtimeSpawned);
+	s.syncAsSint32LE(record.screenMinXBound);
+	s.syncAsSint32LE(record.screenMaxXBound);
+	s.syncAsSint32LE(record.attackSoundTriggerFrame);
+	s.syncAsSint32LE(record.hitSoundTriggerFrame);
+	s.syncAsSint32LE(record.footstepSoundTriggerFrame);
+	s.syncAsSint32LE(record.deathSoundTriggerFrame);
+	s.syncAsSint32LE(record.runtimeState);
+}
+
+static void restoreMonsterRecordIndices(const Common::Array<MonsterRecord> &baseMonsters,
+		Common::Array<MonsterRecord> &runtimeMonsters) {
+	for (uint i = 0; i < runtimeMonsters.size(); ++i) {
+		if (i < baseMonsters.size() &&
+				baseMonsters[i].monsterName.equalsIgnoreCase(runtimeMonsters[i].monsterName) &&
+				baseMonsters[i].roomName.equalsIgnoreCase(runtimeMonsters[i].roomName)) {
+			runtimeMonsters[i].recordIndex = baseMonsters[i].recordIndex;
+			continue;
+		}
+
+		runtimeMonsters[i].recordIndex = -1;
+		for (const MonsterRecord &baseMonster : baseMonsters) {
+			if (!baseMonster.monsterName.equalsIgnoreCase(runtimeMonsters[i].monsterName) ||
+					!baseMonster.roomName.equalsIgnoreCase(runtimeMonsters[i].roomName)) {
+				continue;
+			}
+
+			runtimeMonsters[i].recordIndex = baseMonster.recordIndex;
+			break;
+		}
+	}
+}
+
+static void syncStartupTimerRecord(Common::Serializer &s, TimerRecord &record) {
+	s.syncAsSint32LE(record.initialValue);
+	s.syncAsSint32LE(record.currentValue);
+	s.syncString(record.timerName);
+	s.syncString(record.arg1);
+	s.syncString(record.arg2);
+	syncBool(s, record.enabled);
+	syncBool(s, record.looping);
+	syncBool(s, record.global);
+}
+
+static void syncStartupRegionRecord(Common::Serializer &s, RegionRecord &record) {
+	s.syncAsSint32LE(record.left);
+	s.syncAsSint32LE(record.top);
+	s.syncAsSint32LE(record.right);
+	s.syncAsSint32LE(record.bottom);
+	s.syncAsSint32LE(record.minZ);
+	s.syncAsSint32LE(record.maxZ);
+	s.syncAsSint32LE(record.desiredFacing);
+	s.syncString(record.regionName);
+	s.syncString(record.direction);
+	s.syncString(record.roomName);
+	s.syncString(record.actionTag);
+	syncBool(s, record.startEnabled);
+	syncBool(s, record.cursorEnabled);
+}
+
+void Script::restoreRuntimeFlags(const Common::Array<FlagRecord> &currentFlags) {
+	for (const FlagRecord &flag : currentFlags) {
+		FlagRecord *currentFlag = findRuntimeFlag(flag.name);
+		if (currentFlag) {
+			currentFlag->value = flag.value;
+			continue;
+		}
+
+		_currentFlags.push_back(flag);
+	}
+}
+
+void Script::restoreRuntimeObjects(const Common::Array<ObjectRecord> &currentObjects) {
+	for (const ObjectRecord &object : currentObjects) {
+		ObjectRecord *currentObject = findRuntimeObject(object.initialOwnerOrRoom, object.objectName);
+		if (!currentObject)
+			currentObject = findRuntimeObject(Common::String(), object.objectName);
+		if (!currentObject) {
+			_currentObjects.push_back(object);
+			continue;
+		}
+
+		currentObject->currentX = object.currentX;
+		currentObject->currentY = object.currentY;
+		currentObject->currentZ = object.currentZ;
+		currentObject->currentOwnerOrRoom = object.currentOwnerOrRoom;
+		currentObject->visible = object.visible;
+		currentObject->runtimeVisible = object.runtimeVisible;
+		currentObject->identShown = object.identShown;
+	}
+}
+
+void Script::restoreRuntimeAnimations(const Common::Array<AnimRecord> &currentAnimations) {
+	for (const AnimRecord &anim : currentAnimations) {
+		AnimRecord *currentAnimation = findRuntimeAnim(anim.animName);
+		if (!currentAnimation) {
+			_currentAnimations.push_back(anim);
+			continue;
+		}
+
+		currentAnimation->active = anim.active;
+		currentAnimation->visible = anim.visible;
+		currentAnimation->runtimeActive = anim.runtimeActive;
+		currentAnimation->runtimeVisible = anim.runtimeVisible;
+		currentAnimation->runtimeState = anim.runtimeState;
+	}
+}
+
+void Script::restoreRuntimeRegions(const Common::Array<RegionRecord> &currentRegions) {
+	for (const RegionRecord &region : currentRegions) {
+		RegionRecord *currentRegion = findRuntimeRegion(region.regionName);
+		if (!currentRegion) {
+			_currentRegions.push_back(region);
+			continue;
+		}
+
+		currentRegion->startEnabled = region.startEnabled;
+	}
+}
+
+void Script::restoreRuntimeNpcs(const Common::Array<NpcRecord> &currentNpcs) {
+	for (const NpcRecord &npc : currentNpcs) {
+		NpcRecord *currentNpc = findRuntimeNpc(npc.npcName);
+		if (!currentNpc) {
+			_currentNpcs.push_back(npc);
+			continue;
+		}
+
+		currentNpc->active = npc.active;
+		currentNpc->visible = npc.visible;
+		currentNpc->savedVisible = npc.savedVisible;
+		currentNpc->runtimeSpawned = npc.runtimeSpawned;
+		currentNpc->runtimeState = npc.runtimeState;
+		currentNpc->deathOrMonsterfyFlag = npc.deathOrMonsterfyFlag;
+		currentNpc->deathDamageType = npc.deathDamageType;
+	}
+}
+
+void Script::restoreRuntimeMonsters(const Common::Array<MonsterRecord> &currentMonsters) {
+	for (const MonsterRecord &monster : currentMonsters) {
+		MonsterRecord *currentMonster = findRuntimeMonster(monster);
+		if (!currentMonster) {
+			_currentMonsters.push_back(monster);
+			continue;
+		}
+
+		currentMonster->posX = monster.posX;
+		currentMonster->posY = monster.posY;
+		currentMonster->posZ = monster.posZ;
+		currentMonster->facing = monster.facing;
+		currentMonster->active = monster.active;
+		currentMonster->visible = monster.visible;
+		currentMonster->savedVisible = monster.savedVisible;
+		currentMonster->runtimeSpawned = monster.runtimeSpawned;
+		currentMonster->runtimeState = monster.runtimeState;
+		currentMonster->currentHitPoints = monster.currentHitPoints;
+		currentMonster->screenMinXBound = monster.screenMinXBound;
+		currentMonster->screenMaxXBound = monster.screenMaxXBound;
+	}
+}
+
+void Script::restoreRuntimeTimers(const Common::Array<TimerRecord> &currentTimers) {
+	for (const TimerRecord &timer : currentTimers) {
+		TimerRecord *currentTimer = findRuntimeTimer(timer.timerName);
+		if (!currentTimer) {
+			_currentTimers.push_back(timer);
+			continue;
+		}
+
+		currentTimer->initialValue = timer.initialValue;
+		currentTimer->currentValue = timer.currentValue;
+		currentTimer->enabled = timer.enabled;
+		currentTimer->looping = timer.looping;
+		currentTimer->global = timer.global;
+	}
+}
+
+bool Script::reloadTownWorld(ResourceManager &resources) {
+	Common::Array<byte> reloadedData;
+	if (!resources.loadFile(_path, reloadedData)) {
+		warning("Harvester: unable to reload town script '%s'", _path.c_str());
+		return false;
+	}
+
+	const Common::Array<FlagRecord> currentFlags = _currentFlags;
+	const Common::Array<ObjectRecord> currentObjects = _currentObjects;
+	const Common::Array<AnimRecord> currentAnimations = _currentAnimations;
+	const Common::Array<RegionRecord> currentRegions = _currentRegions;
+	const Common::Array<NpcRecord> currentNpcs = _currentNpcs;
+	const Common::Array<MonsterRecord> currentMonsters = _currentMonsters;
+	const Common::Array<TimerRecord> currentTimers = _currentTimers;
+	const int nailgunAmmoCount = _nailgunAmmoCount;
+	const int shotgunShellCount = _shotgunShellCount;
+	const int nineGunBulletCount = _nineGunBulletCount;
+	const int thirtyEightGunBulletCount = _thirtyEightGunBulletCount;
+	const int chainsawFuelCount = _chainsawFuelCount;
+	const int playerCurrentHitPoints = _playerCurrentHitPoints;
+	const int playerCombatLoadout = _playerCombatLoadout;
+	const bool playerControlPaused = _playerControlPaused;
+
+	_data = Common::move(reloadedData);
+	decode();
+	parseTownRecords(resources);
+	resetRuntimeState();
+
+	restoreRuntimeFlags(currentFlags);
+	restoreRuntimeObjects(currentObjects);
+	restoreRuntimeAnimations(currentAnimations);
+	restoreRuntimeRegions(currentRegions);
+	restoreRuntimeNpcs(currentNpcs);
+	restoreRuntimeMonsters(currentMonsters);
+	restoreRuntimeTimers(currentTimers);
+
+	_playerCurrentHitPoints = clampPlayerHitPoints(playerCurrentHitPoints);
+	_nailgunAmmoCount = clampCombatResourceCount(2, nailgunAmmoCount);
+	_shotgunShellCount = clampCombatResourceCount(3, shotgunShellCount);
+	_nineGunBulletCount = clampCombatResourceCount(4, nineGunBulletCount);
+	_thirtyEightGunBulletCount = clampCombatResourceCount(5, thirtyEightGunBulletCount);
+	_chainsawFuelCount = clampCombatResourceCount(14, chainsawFuelCount);
+	_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
+		_currentObjects, playerCombatLoadout, "town reload");
+	_playerControlPaused = playerControlPaused;
+
+	debugC(1, kDebugGeneral,
+		"Harvester: reloaded town script '%s' for disc %d while preserving runtime state",
+		_path.c_str(), resources.getCurrentDisc());
+	return true;
+}
+
+void Script::logRuntimeSaveFlags(const char *operation) const {
+	for (const FlagRecord &flag : _currentFlags) {
+		const FlagRecord *baseFlag = nullptr;
+		for (const FlagRecord &candidate : _flags) {
+			if (candidate.name.equalsIgnoreCase(flag.name)) {
+				baseFlag = &candidate;
+				break;
+			}
+		}
+		if (!baseFlag || flag.value != baseFlag->value) {
+			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);
+		}
+	}
+}
+
+void Script::logRuntimeSaveObjects(const char *operation) const {
+	for (const ObjectRecord &object : _currentObjects) {
+		const ObjectRecord *baseObject = nullptr;
+		for (const ObjectRecord &candidate : _objects) {
+			if (candidate.objectName.equalsIgnoreCase(object.objectName)) {
+				baseObject = &candidate;
+				break;
+			}
+		}
+
+		const Common::String &defaultOwner = baseObject ? baseObject->initialOwnerOrRoom : object.initialOwnerOrRoom;
+		const int defaultX = baseObject ? baseObject->initialX : object.initialX;
+		const int defaultY = baseObject ? baseObject->initialY : object.initialY;
+		const int defaultZ = baseObject ? baseObject->initialZ : object.initialZ;
+		const bool defaultRuntimeVisible = baseObject ? baseObject->visible : object.visible;
+		const bool defaultVisible = baseObject ? baseObject->visible : object.visible;
+		const bool defaultIdentShown = baseObject ? baseObject->identTextKey.empty() : object.identTextKey.empty();
+		if (!baseObject ||
+				!object.currentOwnerOrRoom.equalsIgnoreCase(defaultOwner) ||
+				object.currentX != defaultX ||
+				object.currentY != defaultY ||
+				object.currentZ != defaultZ ||
+				object.runtimeVisible != defaultRuntimeVisible ||
+				object.visible != defaultVisible ||
+				object.identShown != defaultIdentShown) {
+			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,
+				object.visible, object.runtimeVisible, object.identShown,
+				defaultOwner.c_str(), defaultX, defaultY, defaultZ,
+				defaultVisible, defaultRuntimeVisible, defaultIdentShown);
+		}
+	}
+}
+
+void Script::logRuntimeSaveNpcs(const char *operation) const {
+	for (const NpcRecord &npc : _currentNpcs) {
+		const NpcRecord *baseNpc = nullptr;
+		for (const NpcRecord &candidate : _npcs) {
+			if (candidate.npcName.equalsIgnoreCase(npc.npcName)) {
+				baseNpc = &candidate;
+				break;
+			}
+		}
+
+		const bool defaultActive = baseNpc ? baseNpc->active : npc.active;
+		const bool defaultVisible = baseNpc ? baseNpc->visible : npc.visible;
+		const bool defaultSavedVisible = baseNpc ? baseNpc->visible : npc.visible;
+		const bool defaultDeathOrMonsterfy = baseNpc ? baseNpc->deathOrMonsterfyFlag : npc.deathOrMonsterfyFlag;
+		const int defaultDeathDamageType = baseNpc ? baseNpc->deathDamageType : npc.deathDamageType;
+		const int defaultRuntimeState = -1;
+		if (!baseNpc ||
+				npc.active != defaultActive ||
+				npc.visible != defaultVisible ||
+				npc.savedVisible != defaultSavedVisible ||
+				npc.deathOrMonsterfyFlag != defaultDeathOrMonsterfy ||
+				npc.deathDamageType != defaultDeathDamageType ||
+				npc.runtimeSpawned ||
+				npc.runtimeState != defaultRuntimeState) {
+			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,
+				defaultActive, defaultVisible, defaultSavedVisible,
+				0, defaultRuntimeState, defaultDeathOrMonsterfy, defaultDeathDamageType);
+		}
+	}
+}
+
+void Script::logRuntimeSaveMonsters(const char *operation) const {
+	for (const MonsterRecord &monster : _currentMonsters) {
+		const MonsterRecord *baseMonster = findBaseMonster(monster);
+
+		const bool defaultActive = baseMonster ? baseMonster->active : monster.active;
+		const bool defaultVisible = baseMonster
+			? (baseMonster->active ? true : baseMonster->visible)
+			: (monster.active ? true : monster.visible);
+		const bool defaultSavedVisible = defaultVisible;
+		const int defaultCurrentHitPoints = baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints;
+		const int defaultScreenMinXBound = baseMonster ? baseMonster->screenMinXBound : monster.screenMinXBound;
+		const int defaultScreenMaxXBound = baseMonster ? baseMonster->screenMaxXBound : monster.screenMaxXBound;
+		if (!baseMonster ||
+				monster.active != defaultActive ||
+				monster.visible != defaultVisible ||
+				monster.savedVisible != defaultSavedVisible ||
+				monster.currentHitPoints != defaultCurrentHitPoints ||
+				monster.runtimeSpawned ||
+				monster.runtimeState != -1 ||
+				monster.screenMinXBound != defaultScreenMinXBound ||
+				monster.screenMaxXBound != defaultScreenMaxXBound) {
+			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,
+				monster.currentHitPoints, monster.initialHitPoints,
+				monster.screenMinXBound, monster.screenMaxXBound,
+				defaultActive, defaultVisible, defaultSavedVisible, -1,
+				defaultCurrentHitPoints, baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints,
+				defaultScreenMinXBound, defaultScreenMaxXBound);
+		}
+	}
+}
+
+void Script::logRuntimeSaveTimers(const char *operation) const {
+	for (const TimerRecord &timer : _currentTimers) {
+		const TimerRecord *baseTimer = nullptr;
+		for (const TimerRecord &candidate : _timers) {
+			if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
+				baseTimer = &candidate;
+				break;
+			}
+		}
+
+		const int defaultInitialValue = baseTimer ? baseTimer->initialValue : timer.initialValue;
+		const int defaultCurrentValue = defaultInitialValue;
+		const bool defaultEnabled = baseTimer ? baseTimer->enabled : timer.enabled;
+		const bool defaultLooping = baseTimer ? baseTimer->looping : timer.looping;
+		const bool defaultGlobal = baseTimer ? baseTimer->global : timer.global;
+		if (!baseTimer ||
+				timer.initialValue != defaultInitialValue ||
+				timer.currentValue != defaultCurrentValue ||
+				timer.enabled != defaultEnabled ||
+				timer.looping != defaultLooping ||
+				timer.global != defaultGlobal) {
+			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,
+				defaultCurrentValue, defaultInitialValue, defaultEnabled, defaultLooping, defaultGlobal);
+		}
+	}
+}
+
+void Script::logRuntimeSaveState(const char *operation) const {
+	debugC(1, kDebugGeneral,
+		"Harvester: %s runtime save state flags=%u objects=%u anims=%u regions=%u npcs=%u monsters=%u timers=%u hp=%d loadout=%d paused=%d ammo=[nails:%d shells:%d bullets9:%d bullets38:%d gas:%d]",
+		operation, (uint)_currentFlags.size(), (uint)_currentObjects.size(),
+		(uint)_currentAnimations.size(), (uint)_currentRegions.size(),
+		(uint)_currentNpcs.size(), (uint)_currentMonsters.size(),
+		(uint)_currentTimers.size(), _playerCurrentHitPoints, _playerCombatLoadout,
+		_playerControlPaused, _nailgunAmmoCount, _shotgunShellCount,
+		_nineGunBulletCount, _thirtyEightGunBulletCount, _chainsawFuelCount);
+
+	logRuntimeSaveFlags(operation);
+	logRuntimeSaveObjects(operation);
+	logRuntimeSaveNpcs(operation);
+	logRuntimeSaveMonsters(operation);
+	logRuntimeSaveTimers(operation);
+}
+
+void Script::syncRuntimeSaveState(Common::Serializer &s) {
+	if (s.isSaving())
+		logRuntimeSaveState("saving");
+
+	syncRecordArray(s, _currentFlags, syncStartupFlagRecord);
+	syncRecordArray(s, _currentObjects, syncStartupObjectRecord);
+	syncRecordArray(s, _currentAnimations, syncStartupAnimRecord);
+	syncRecordArray(s, _currentRegions, syncStartupRegionRecord);
+	syncRecordArray(s, _currentNpcs, syncStartupNpcRecord);
+	syncRecordArray(s, _currentMonsters, syncStartupMonsterRecord);
+	if (s.isLoading())
+		restoreMonsterRecordIndices(_monsters, _currentMonsters);
+	s.syncAsSint32LE(_playerCurrentHitPoints);
+	syncRecordArray(s, _currentTimers, syncStartupTimerRecord);
+	s.syncAsSint32LE(_playerCombatLoadout);
+	syncBool(s, _playerControlPaused);
+	s.syncAsSint32LE(_nailgunAmmoCount);
+	s.syncAsSint32LE(_shotgunShellCount);
+	s.syncAsSint32LE(_nineGunBulletCount);
+	s.syncAsSint32LE(_thirtyEightGunBulletCount);
+	s.syncAsSint32LE(_chainsawFuelCount);
+	if (s.isLoading()) {
+		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
+		_playerCombatLoadout = clampPlayerCombatLoadout(_playerCombatLoadout);
+		_nailgunAmmoCount = clampCombatResourceCount(2, _nailgunAmmoCount);
+		_shotgunShellCount = clampCombatResourceCount(3, _shotgunShellCount);
+		_nineGunBulletCount = clampCombatResourceCount(4, _nineGunBulletCount);
+		_thirtyEightGunBulletCount = clampCombatResourceCount(5, _thirtyEightGunBulletCount);
+		_chainsawFuelCount = clampCombatResourceCount(14, _chainsawFuelCount);
+		_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
+			_currentObjects, _playerCombatLoadout, "save load");
+		logRuntimeSaveState("loaded");
+	}
+}
+
+} // End of namespace Harvester


Commit: 29bc2c22834db25bd274bf6520102acd12d429e9
    https://github.com/scummvm/scummvm/commit/29bc2c22834db25bd274bf6520102acd12d429e9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Separate script runtime and actor state handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/script_actors.cpp
  A engines/harvester/script_runtime.cpp
    engines/harvester/module.mk
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index b51b1dbb459..a48c049f33f 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -26,9 +26,11 @@ MODULE_OBJS = \
 	entity.o \
 	saveload.o \
 	script.o \
+	script_actors.o \
 	script_commands.o \
 	script_records.o \
 	script_room.o \
+	script_runtime.o \
 	script_save.o \
 	text.o \
 	xfile_archive.o \
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 073a4db2b30..11523f22d04 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -21,7 +21,6 @@
 
 #include "harvester/script.h"
 
-#include "common/algorithm.h"
 #include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/file.h"
@@ -29,7 +28,6 @@
 #include "common/memstream.h"
 #include "harvester/detection.h"
 #include "harvester/parse_utils.h"
-#include "harvester/player.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -37,53 +35,9 @@ namespace Harvester {
 static const char *const kStartupConfigPath = "CONFIG.INI";
 static const char *const kConfigSectionName = "harvester";
 static const char *const kDefaultTownScript = "HARVEST.SCR";
-static const char *const kInventoryOwnerName = "INVENTORY";
 static const char *const kDefaultVoicePath = "SOUND/VOICE/";
-static const int kDefaultPlayerCombatLoadout = 0;
-static const int kMaxPlayerCombatLoadout = 0x14;
 static const int kMaxStartupOptionLevel = 9;
 
-struct InventoryCombatLoadoutEntry {
-	int loadoutId;
-	const char *objectName;
-};
-
-static const InventoryCombatLoadoutEntry kInventoryCombatLoadoutMap[] = {
-	{ 1, "CLEAVER" },
-	{ 2, "NAILGUN" },
-	{ 3, "SHOTGUN" },
-	{ 4, "9GUN" },
-	{ 5, "38GUN" },
-	{ 6, "TOMAHAWK" },
-	{ 7, "KNIFE" },
-	{ 8, "FLAIL" },
-	{ 9, "HANDAXE" },
-	{ 10, "WRENCH" },
-	{ 11, "PITCHFORK" },
-	{ 12, "SCYTHE" },
-	{ 13, "SWORD" },
-	{ 14, "CHAINSAW" },
-	{ 15, "HARVEST_BLADE" },
-	{ 16, "SHOVEL" },
-	{ 17, "FIREAXE" },
-	{ 18, "BAT" },
-	{ 19, "RAZOR" },
-	{ 20, "POOLSTICK" }
-};
-
-static bool matchesMonsterIdentity(const MonsterRecord &candidate, const MonsterRecord &monster) {
-	if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
-		return false;
-
-	if (candidate.recordIndex >= 0 && monster.recordIndex >= 0)
-		return candidate.recordIndex == monster.recordIndex;
-
-	if (!monster.roomName.empty())
-		return candidate.roomName.equalsIgnoreCase(monster.roomName);
-
-	return true;
-}
-
 static int clampStartupOptionLevel(int level) {
 	if (level < 0)
 		return 0;
@@ -93,110 +47,6 @@ static int clampStartupOptionLevel(int level) {
 	return level;
 }
 
-int Script::clampPlayerHitPoints(int hitPoints) {
-	if (hitPoints < 0)
-		return 0;
-	if (hitPoints > Script::kDefaultPlayerHitPoints)
-		return Script::kDefaultPlayerHitPoints;
-
-	return hitPoints;
-}
-
-int Script::clampPlayerCombatLoadout(int loadout) {
-	return CLIP<int>(loadout, 0, kMaxPlayerCombatLoadout);
-}
-
-static int resolveCombatResourceDisplayMax(int loadout) {
-	switch (loadout) {
-	case 2:
-	case 3:
-	case 14:
-		return 16;
-	case 4:
-		return 8;
-	case 5:
-		return 6;
-	default:
-		return 0;
-	}
-}
-
-int Script::clampCombatResourceCount(int loadout, int count) {
-	const int maxCount = resolveCombatResourceDisplayMax(loadout);
-	if (maxCount <= 0)
-		return 0;
-
-	return CLIP<int>(count, 0, maxCount);
-}
-
-int Script::resolveCombatResourcePickupMax(int loadout) {
-	switch (loadout) {
-	case 2:
-	case 3:
-	case 14:
-		return 15;
-	case 4:
-		return 7;
-	case 5:
-		return 5;
-	default:
-		return 0;
-	}
-}
-
-static const char *resolveCombatLoadoutInventoryObjectName(int loadout) {
-	for (const InventoryCombatLoadoutEntry &entry : kInventoryCombatLoadoutMap) {
-		if (entry.loadoutId == loadout)
-			return entry.objectName;
-	}
-
-	return nullptr;
-}
-
-int Script::validatePlayerCombatLoadoutAgainstInventory(const Common::Array<ObjectRecord> &currentObjects,
-		int loadout, const char *contextLabel) {
-	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
-	if (clampedLoadout == kDefaultPlayerCombatLoadout)
-		return clampedLoadout;
-
-	const char *requiredObjectName = resolveCombatLoadoutInventoryObjectName(clampedLoadout);
-	if (!requiredObjectName)
-		return clampedLoadout;
-
-	for (const ObjectRecord &object : currentObjects) {
-		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
-				object.objectName.equalsIgnoreCase(requiredObjectName))
-			return clampedLoadout;
-	}
-
-	debugC(1, kDebugGeneral,
-		"Harvester: cleared player combat loadout %d('%s') after %s because inventory object '%s' is absent",
-		clampedLoadout, Player::describeCombatLoadout(clampedLoadout),
-		contextLabel ? contextLabel : "runtime restore", requiredObjectName);
-	return kDefaultPlayerCombatLoadout;
-}
-
-static const char *resolveInventoryStatusObjectName(int hitPoints) {
-	if (hitPoints < 8)
-		return "INV_STAT4";
-	if (hitPoints < 15)
-		return "INV_STAT3";
-	if (hitPoints < 23)
-		return "INV_STAT2";
-
-	return "INV_STAT1";
-}
-
-static Common::String normalizeInteractionLabel(const Common::String &value) {
-	Common::String label = value;
-	for (uint i = 0; i < label.size(); ++i) {
-		if (label[i] == '_')
-			label.setChar(' ', i);
-	}
-
-	return label;
-}
-
 static Common::String toNativeConfigPath(const Common::String &value) {
 	Common::String result(value);
 	for (uint i = 0; i < result.size(); ++i) {
@@ -402,57 +252,6 @@ void Script::setGammaLevel(int level) {
 	_gammaLevel = clampStartupOptionLevel(level);
 }
 
-void Script::resetRuntimeState() {
-	_currentFlags = _flags;
-	_currentObjects = _objects;
-	_currentAnimations = _animations;
-	_currentRegions = _regions;
-	_currentNpcs = _npcs;
-	_currentMonsters = _monsters;
-	_currentTimers = _timers;
-	_nailgunAmmoCount = 0;
-	_shotgunShellCount = 0;
-	_nineGunBulletCount = 0;
-	_thirtyEightGunBulletCount = 0;
-	_chainsawFuelCount = 0;
-	_playerCurrentHitPoints = Script::kDefaultPlayerHitPoints;
-	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
-	_playerControlPaused = false;
-
-	for (ObjectRecord &object : _currentObjects) {
-		object.currentX = object.initialX;
-		object.currentY = object.initialY;
-		object.currentZ = object.initialZ;
-		object.currentOwnerOrRoom = object.initialOwnerOrRoom;
-		object.runtimeVisible = object.visible;
-		object.identShown = object.identTextKey.empty();
-	}
-
-	for (AnimRecord &anim : _currentAnimations) {
-		anim.runtimeActive = anim.active;
-		anim.runtimeVisible = anim.visible;
-		anim.runtimeState = -1;
-	}
-
-	for (NpcRecord &npc : _currentNpcs) {
-		npc.runtimeSpawned = false;
-		npc.runtimeState = -1;
-		npc.savedVisible = npc.visible;
-	}
-
-	for (MonsterRecord &monster : _currentMonsters) {
-		monster.currentHitPoints = monster.initialHitPoints;
-		monster.runtimeSpawned = false;
-		monster.runtimeState = -1;
-		monster.savedVisible = monster.visible;
-		if (monster.active)
-			monster.visible = true;
-	}
-
-	for (TimerRecord &timer : _currentTimers)
-		timer.currentValue = timer.initialValue;
-}
-
 bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		InteractionResult &result) {
 	result = InteractionResult();
@@ -697,812 +496,4 @@ bool Script::hasUseItemInteraction(const Common::String &itemName, const ObjectR
 	return findUseItemRecord(itemName, target) != nullptr;
 }
 
-void Script::getVisibleInventoryObjects(Common::Array<ObjectRecord> &objects) const {
-	objects.clear();
-	const char *const statusObjectName = resolveInventoryStatusObjectName(_playerCurrentHitPoints);
-	const ObjectRecord *statusObject = nullptr;
-
-	for (const ObjectRecord &object : _currentObjects) {
-		if (object.objectName.hasPrefixIgnoreCase("INV_STAT")) {
-			if (object.objectName.equalsIgnoreCase(statusObjectName))
-				statusObject = &object;
-			continue;
-		}
-
-		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) && object.visible)
-			objects.push_back(object);
-	}
-
-	if (!statusObject)
-		return;
-
-	ObjectRecord activeStatusObject = *statusObject;
-	activeStatusObject.currentOwnerOrRoom = kInventoryOwnerName;
-	activeStatusObject.visible = true;
-	activeStatusObject.runtimeVisible = true;
-	activeStatusObject.identShown = true;
-	objects.push_back(Common::move(activeStatusObject));
-}
-
-bool Script::isObjectInInventory(const Common::String &objectName) const {
-	if (objectName.empty())
-		return false;
-
-	for (const ObjectRecord &object : _currentObjects) {
-		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
-				object.objectName.equalsIgnoreCase(objectName))
-			return true;
-	}
-
-	return false;
-}
-
-void Script::markObjectIdentShown(const ObjectRecord &object) {
-	if (ObjectRecord *currentObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
-		currentObject->identShown = true;
-}
-
-const EntranceRecord *Script::findEntranceRecord(const Common::String &entranceName) const {
-	if (entranceName.empty())
-		return nullptr;
-
-	for (const EntranceRecord &entrance : _entrances) {
-		if (entrance.entranceName.equalsIgnoreCase(entranceName))
-			return &entrance;
-	}
-
-	return nullptr;
-}
-
-const MapEntranceRecord *Script::findMapEntranceRecord(const Common::String &entryName) const {
-	if (entryName.empty())
-		return nullptr;
-
-	for (const MapEntranceRecord &mapEntrance : _mapEntrances) {
-		if (mapEntrance.entryName.equalsIgnoreCase(entryName))
-			return &mapEntrance;
-	}
-
-	return nullptr;
-}
-
-const UseItemRecord *Script::findUseItemRecord(const Common::String &itemName,
-		const ObjectRecord &target) const {
-	if (itemName.empty() || target.objectName.empty())
-		return nullptr;
-
-	for (const UseItemRecord &useItem : _useItems) {
-		if (!useItem.itemName.equalsIgnoreCase(itemName) ||
-			!useItem.targetName.equalsIgnoreCase(target.objectName)) {
-			continue;
-		}
-
-		// Native dispatch ignores the parsed owner/group string and matches only
-		// the carried item name plus the overlapped target entity name.
-		return &useItem;
-	}
-
-	return nullptr;
-}
-
-const RoomRecord *Script::findRoomRecord(const Common::String &roomName) const {
-	if (roomName.empty())
-		return nullptr;
-
-	for (const RoomRecord &room : _rooms) {
-		if (room.roomName.equalsIgnoreCase(roomName))
-			return &room;
-	}
-
-	return nullptr;
-}
-
-const CommandRecord *Script::findCommandRecord(const Common::String &tag) const {
-	if (tag.empty())
-		return nullptr;
-
-	for (const CommandRecord &command : _commands) {
-		if (command.triggerTag.equalsIgnoreCase(tag))
-			return &command;
-	}
-
-	return nullptr;
-}
-
-const ExecListRecord *Script::findExecListRecord(const Common::String &name) const {
-	if (name.empty())
-		return nullptr;
-
-	for (const ExecListRecord &execList : _execLists) {
-		if (execList.listName.equalsIgnoreCase(name))
-			return &execList;
-	}
-
-	return nullptr;
-}
-
-const FlagRecord *Script::findRuntimeFlag(const Common::String &flagName) const {
-	if (flagName.empty())
-		return nullptr;
-
-	for (const FlagRecord &flag : _currentFlags) {
-		if (flag.name.equalsIgnoreCase(flagName))
-			return &flag;
-	}
-
-	return nullptr;
-}
-
-FlagRecord *Script::findRuntimeFlag(const Common::String &flagName) {
-	if (flagName.empty())
-		return nullptr;
-
-	for (FlagRecord &flag : _currentFlags) {
-		if (flag.name.equalsIgnoreCase(flagName))
-			return &flag;
-	}
-
-	return nullptr;
-}
-
-ObjectRecord *Script::findRuntimeObject(const Common::String &ownerOrRoom,
-		const Common::String &objectName) {
-	if (objectName.empty())
-		return nullptr;
-
-	ObjectRecord *fallback = nullptr;
-	for (ObjectRecord &object : _currentObjects) {
-		if (!object.objectName.equalsIgnoreCase(objectName))
-			continue;
-
-		if (ownerOrRoom.empty())
-			return &object;
-
-		if (object.currentOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) ||
-			object.initialOwnerOrRoom.equalsIgnoreCase(ownerOrRoom)) {
-			return &object;
-		}
-
-		if (!fallback)
-			fallback = &object;
-	}
-
-	return fallback;
-}
-
-AnimRecord *Script::findRuntimeAnim(const Common::String &animName) {
-	if (animName.empty())
-		return nullptr;
-
-	for (AnimRecord &anim : _currentAnimations) {
-		if (anim.animName.equalsIgnoreCase(animName))
-			return &anim;
-	}
-
-	return nullptr;
-}
-
-RegionRecord *Script::findRuntimeRegion(const Common::String &regionName) {
-	if (regionName.empty())
-		return nullptr;
-
-	for (RegionRecord &region : _currentRegions) {
-		if (region.regionName.equalsIgnoreCase(regionName))
-			return ®ion;
-	}
-
-	return nullptr;
-}
-
-NpcRecord *Script::findRuntimeNpc(const Common::String &npcName) {
-	if (npcName.empty())
-		return nullptr;
-
-	for (NpcRecord &npc : _currentNpcs) {
-		if (npc.npcName.equalsIgnoreCase(npcName))
-			return &npc;
-	}
-
-	return nullptr;
-}
-
-MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) {
-	if (monsterName.empty())
-		return nullptr;
-
-	for (MonsterRecord &monster : _currentMonsters) {
-		if (monster.monsterName.equalsIgnoreCase(monsterName))
-			return &monster;
-	}
-
-	return nullptr;
-}
-
-MonsterRecord *Script::findRuntimeMonster(const MonsterRecord &monster) {
-	if (monster.monsterName.empty())
-		return nullptr;
-
-	MonsterRecord *fallback = nullptr;
-	for (MonsterRecord &candidate : _currentMonsters) {
-		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
-			continue;
-		if (matchesMonsterIdentity(candidate, monster))
-			return &candidate;
-		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
-			fallback = &candidate;
-	}
-
-	return fallback;
-}
-
-TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) {
-	if (timerName.empty())
-		return nullptr;
-
-	for (TimerRecord &timer : _currentTimers) {
-		if (timer.timerName.equalsIgnoreCase(timerName))
-			return &timer;
-	}
-
-	return nullptr;
-}
-
-const NpcRecord *Script::findRuntimeNpc(const Common::String &npcName) const {
-	if (npcName.empty())
-		return nullptr;
-
-	for (const NpcRecord &npc : _currentNpcs) {
-		if (npc.npcName.equalsIgnoreCase(npcName))
-			return &npc;
-	}
-
-	return nullptr;
-}
-
-const MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) const {
-	if (monsterName.empty())
-		return nullptr;
-
-	for (const MonsterRecord &monster : _currentMonsters) {
-		if (monster.monsterName.equalsIgnoreCase(monsterName))
-			return &monster;
-	}
-
-	return nullptr;
-}
-
-const MonsterRecord *Script::findRuntimeMonster(const MonsterRecord &monster) const {
-	if (monster.monsterName.empty())
-		return nullptr;
-
-	const MonsterRecord *fallback = nullptr;
-	for (const MonsterRecord &candidate : _currentMonsters) {
-		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
-			continue;
-		if (matchesMonsterIdentity(candidate, monster))
-			return &candidate;
-		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
-			fallback = &candidate;
-	}
-
-	return fallback;
-}
-
-const MonsterRecord *Script::findBaseMonster(const MonsterRecord &monster) const {
-	if (monster.monsterName.empty())
-		return nullptr;
-
-	const MonsterRecord *fallback = nullptr;
-	for (const MonsterRecord &candidate : _monsters) {
-		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
-			continue;
-		if (matchesMonsterIdentity(candidate, monster))
-			return &candidate;
-		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
-			fallback = &candidate;
-	}
-
-	return fallback;
-}
-
-const TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) const {
-	if (timerName.empty())
-		return nullptr;
-
-	for (const TimerRecord &timer : _currentTimers) {
-		if (timer.timerName.equalsIgnoreCase(timerName))
-			return &timer;
-	}
-
-	return nullptr;
-}
-
-const TimerRecord *Script::findRuntimeTimerRecord(const Common::String &timerName) const {
-	return findRuntimeTimer(timerName);
-}
-
-const NpcRecord *Script::findRuntimeNpcRecord(const Common::String &npcName) const {
-	return findRuntimeNpc(npcName);
-}
-
-bool Script::addRuntimeObjectToInventory(const Common::String &objectName) {
-	ObjectRecord *currentObject = findRuntimeObject(Common::String(), objectName);
-	if (!currentObject)
-		return false;
-
-	const bool changed = !currentObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) ||
-		!currentObject->visible || !currentObject->runtimeVisible || !currentObject->identShown;
-	currentObject->currentOwnerOrRoom = kInventoryOwnerName;
-	currentObject->visible = true;
-	currentObject->runtimeVisible = true;
-	currentObject->identShown = true;
-	return changed;
-}
-
-bool Script::syncRuntimeObjectRecord(const ObjectRecord &object) {
-	if (object.objectName.empty())
-		return false;
-
-	ObjectRecord *currentObject = findRuntimeObject(Common::String(), object.objectName);
-	if (!currentObject)
-		return false;
-
-	const bool changed = currentObject->currentX != object.currentX ||
-		currentObject->currentY != object.currentY ||
-		currentObject->currentZ != object.currentZ ||
-		!currentObject->currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) ||
-		currentObject->visible != object.visible ||
-		currentObject->runtimeVisible != object.runtimeVisible ||
-		currentObject->identShown != object.identShown;
-	*currentObject = object;
-	return changed;
-}
-
-bool Script::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
-		const Common::String &objectName, bool visible) {
-	if (objectName.empty())
-		return false;
-
-	ObjectRecord *currentObject = findRuntimeObject(ownerOrRoom, objectName);
-	if (!currentObject)
-		return false;
-
-	const bool changed = currentObject->visible != visible ||
-		currentObject->runtimeVisible != visible;
-	currentObject->visible = visible;
-	currentObject->runtimeVisible = visible;
-	if (visible && currentObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName))
-		currentObject->identShown = true;
-
-	return changed;
-}
-
-bool Script::setRuntimeNpcState(const Common::String &npcName, bool active, bool visible) {
-	if (npcName.empty())
-		return false;
-
-	NpcRecord *currentNpc = findRuntimeNpc(npcName);
-	if (!currentNpc)
-		return false;
-
-	const bool changed = currentNpc->active != active || currentNpc->visible != visible;
-	currentNpc->active = active;
-	currentNpc->visible = visible;
-	return changed;
-}
-
-bool Script::setPlayerCurrentHitPoints(int hitPoints) {
-	const int clampedHitPoints = clampPlayerHitPoints(hitPoints);
-	const bool changed = _playerCurrentHitPoints != clampedHitPoints;
-	_playerCurrentHitPoints = clampedHitPoints;
-	return changed;
-}
-
-bool Script::adjustPlayerCurrentHitPoints(int delta) {
-	return setPlayerCurrentHitPoints(_playerCurrentHitPoints + delta);
-}
-
-int Script::getPlayerCombatResourceCount(int loadout) const {
-	const int *count = getPlayerCombatResourceCountPtr(loadout);
-	return count ? *count : 0;
-}
-
-bool Script::consumePlayerCombatResourceUnit(int loadout) {
-	const int maxCount = resolveCombatResourcePickupMax(loadout);
-	if (maxCount <= 0)
-		return true;
-
-	if (getPlayerCombatResourceCount(loadout) < 1)
-		return false;
-
-	return adjustPlayerCombatResourceCount(loadout, -1, maxCount, "PLAYER_ATTACK");
-}
-
-bool Script::setPlayerCombatResourceCount(int loadout, int count) {
-	int *currentCount = getPlayerCombatResourceCountPtr(loadout);
-	const int maxCount = resolveCombatResourceDisplayMax(loadout);
-	if (!currentCount || maxCount <= 0)
-		return false;
-
-	const int clampedCount = CLIP<int>(count, 0, maxCount);
-	const bool changed = *currentCount != clampedCount;
-	*currentCount = clampedCount;
-	return changed;
-}
-
-int *Script::getPlayerCombatResourceCountPtr(int loadout) {
-	switch (loadout) {
-	case 2:
-		return &_nailgunAmmoCount;
-	case 3:
-		return &_shotgunShellCount;
-	case 4:
-		return &_nineGunBulletCount;
-	case 5:
-		return &_thirtyEightGunBulletCount;
-	case 14:
-		return &_chainsawFuelCount;
-	default:
-		return nullptr;
-	}
-}
-
-const int *Script::getPlayerCombatResourceCountPtr(int loadout) const {
-	switch (loadout) {
-	case 2:
-		return &_nailgunAmmoCount;
-	case 3:
-		return &_shotgunShellCount;
-	case 4:
-		return &_nineGunBulletCount;
-	case 5:
-		return &_thirtyEightGunBulletCount;
-	case 14:
-		return &_chainsawFuelCount;
-	default:
-		return nullptr;
-	}
-}
-
-bool Script::adjustPlayerCombatResourceCount(int loadout, int delta, int maxCount,
-		const Common::String &reason) {
-	int *count = getPlayerCombatResourceCountPtr(loadout);
-	if (!count || maxCount <= 0)
-		return false;
-
-	const int oldCount = *count;
-	const int newCount = CLIP<int>(oldCount + delta, 0, maxCount);
-	*count = newCount;
-	debugC(1, kDebugGeneral,
-		"Harvester: %s player combat resource loadout=%d('%s') delta=%d count=%d->%d max=%d changed=%d",
-		reason.c_str(), loadout, Player::describeCombatLoadout(loadout),
-		delta, oldCount, newCount, maxCount, oldCount != newCount);
-	return oldCount != newCount;
-}
-
-bool Script::setPlayerCombatLoadout(int loadout) {
-	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
-	const bool changed = _playerCombatLoadout != clampedLoadout;
-	_playerCombatLoadout = clampedLoadout;
-	return changed;
-}
-
-bool Script::setPlayerControlPaused(bool paused) {
-	const bool changed = _playerControlPaused != paused;
-	_playerControlPaused = paused;
-	return changed;
-}
-
-bool Script::syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame) {
-	AnimRecord *currentAnimation = findRuntimeAnim(animName);
-	if (!currentAnimation)
-		return false;
-
-	const bool changed = currentAnimation->active != active ||
-		currentAnimation->visible != visible ||
-		currentAnimation->runtimeActive != active ||
-		currentAnimation->runtimeVisible != visible ||
-		currentAnimation->runtimeState != currentFrame;
-	currentAnimation->active = active;
-	currentAnimation->visible = visible;
-	currentAnimation->runtimeActive = active;
-	currentAnimation->runtimeVisible = visible;
-	currentAnimation->runtimeState = currentFrame;
-	return changed;
-}
-
-bool Script::syncRuntimeMonsterRecord(const MonsterRecord &monster) {
-	MonsterRecord *currentMonster = findRuntimeMonster(monster);
-	if (!currentMonster)
-		return false;
-
-	*currentMonster = monster;
-	return true;
-}
-
-bool Script::syncRuntimeTimerRecord(const TimerRecord &timer) {
-	TimerRecord *currentTimer = findRuntimeTimer(timer.timerName);
-	if (!currentTimer)
-		return false;
-
-	*currentTimer = timer;
-	return true;
-}
-
-bool Script::setRuntimeRegionEnabled(const Common::String &regionName, bool enabled) {
-	RegionRecord *currentRegion = findRuntimeRegion(regionName);
-	if (!currentRegion)
-		return false;
-
-	const bool changed = currentRegion->startEnabled != enabled;
-	currentRegion->startEnabled = enabled;
-	return changed;
-}
-
-bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enabled) {
-	TimerRecord *currentTimer = findRuntimeTimer(timerName);
-	if (!currentTimer)
-		return false;
-
-	const bool wasEnabled = currentTimer->enabled;
-	const bool changed = wasEnabled != enabled ||
-		(enabled && !wasEnabled && currentTimer->currentValue != currentTimer->initialValue);
-	currentTimer->enabled = enabled;
-	if (enabled && !wasEnabled)
-		currentTimer->currentValue = currentTimer->initialValue;
-	return changed;
-}
-
-bool Script::queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType) {
-	if (npcName.empty())
-		return false;
-
-	NpcRecord *currentNpc = findRuntimeNpc(npcName);
-	if (!currentNpc || currentNpc->deathOrMonsterfyFlag)
-		return false;
-
-	// Native live MONSTERFY queues actor state 0x35 without a damage argument.
-	// Use bludge as the queued animation selector when scripts leave it blank.
-	const int queuedDeathDamageType = deathDamageType != 0 ? deathDamageType : 1;
-	if (currentNpc->deathDamageType == queuedDeathDamageType)
-		return false;
-
-	currentNpc->deathDamageType = queuedDeathDamageType;
-	return true;
-}
-
-bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType,
-		bool preserveCorpse, int corpseFrame, int monsterfyPosZ) {
-	if (npcName.empty())
-		return false;
-
-	NpcRecord *currentNpc = findRuntimeNpc(npcName);
-	if (!currentNpc)
-		return false;
-
-	bool monsterChanged = false;
-	if (!currentNpc->monsterfyTargetName.empty()) {
-		MonsterRecord monsterLookup;
-		monsterLookup.roomName = currentNpc->roomName;
-		monsterLookup.monsterName = currentNpc->monsterfyTargetName;
-		MonsterRecord *currentMonster = findRuntimeMonster(monsterLookup);
-		if (currentMonster) {
-			const bool overrideMonsterPosZ = monsterfyPosZ != kNoMonsterfyPosZOverride;
-			monsterChanged = !currentMonster->active || !currentMonster->visible ||
-				(overrideMonsterPosZ && currentMonster->posZ != monsterfyPosZ);
-			if (overrideMonsterPosZ)
-				currentMonster->posZ = monsterfyPosZ;
-			currentMonster->active = true;
-			currentMonster->visible = true;
-			currentMonster->runtimeSpawned = false;
-			currentMonster->runtimeState = -1;
-			if (currentMonster->currentHitPoints <= 0)
-				currentMonster->currentHitPoints = currentMonster->initialHitPoints;
-		} else {
-			debug(1, "Harvester: unresolved monsterfy target for dialogue npc='%s' target='%s'",
-				currentNpc->npcName.c_str(), currentNpc->monsterfyTargetName.c_str());
-		}
-	}
-
-	const bool changed = !currentNpc->deathOrMonsterfyFlag ||
-		currentNpc->active ||
-		currentNpc->visible ||
-		currentNpc->runtimeSpawned ||
-		currentNpc->runtimeState != (preserveCorpse && corpseFrame >= 0 ? corpseFrame : -1) ||
-		(deathDamageType != 0 && currentNpc->deathDamageType != deathDamageType);
-	currentNpc->active = false;
-	currentNpc->visible = preserveCorpse && corpseFrame >= 0 && currentNpc->monsterfyTargetName.empty();
-	currentNpc->savedVisible = currentNpc->visible;
-	currentNpc->deathOrMonsterfyFlag = !currentNpc->visible;
-	currentNpc->runtimeSpawned = currentNpc->visible;
-	currentNpc->runtimeState = currentNpc->visible ? corpseFrame : -1;
-	if (deathDamageType != 0)
-		currentNpc->deathDamageType = deathDamageType;
-	return changed || monsterChanged;
-}
-
-void Script::pushDeferredLiveNpcDeathTransitions() {
-	++_deferredLiveNpcDeathTransitionDepth;
-}
-
-void Script::popDeferredLiveNpcDeathTransitions() {
-	if (_deferredLiveNpcDeathTransitionDepth > 0)
-		--_deferredLiveNpcDeathTransitionDepth;
-}
-
-const TextRecord *Script::findTextRecord(const Common::String &key) const {
-	if (key.empty())
-		return nullptr;
-
-	for (const TextRecord &textRecord : _texts) {
-		if (textRecord.key.equalsIgnoreCase(key))
-			return &textRecord;
-	}
-
-	return nullptr;
-}
-
-bool Script::resolveObjectInspectText(const ObjectRecord &object, ResolvedText &text) const {
-	text = ResolvedText();
-
-	const TextRecord *textRecord = findTextRecord(object.identTextKey);
-	if (!textRecord)
-		return false;
-
-	text.boxName = textRecord->boxName;
-	text.value = textRecord->value;
-	return !text.value.empty();
-}
-
-bool Script::resolveTextRecord(const Common::String &key, ResolvedText &text) const {
-	text = ResolvedText();
-
-	const TextRecord *textRecord = findTextRecord(key);
-	if (!textRecord)
-		return false;
-
-	text.boxName = textRecord->boxName;
-	text.value = textRecord->value;
-	return !text.value.empty();
-}
-
-Common::String Script::resolveInventoryTooltipText(const ObjectRecord &object) const {
-	const TextRecord *textRecord = findTextRecord(object.inventoryTextKey);
-	if (textRecord && !textRecord->value.empty())
-		return textRecord->value;
-
-	return Common::String();
-}
-
-Common::String Script::resolveObjectLabel(const ObjectRecord &object) const {
-	if (!object.interactionLabel.empty() && !object.interactionLabel.equalsIgnoreCase("NULL_ID"))
-		return normalizeInteractionLabel(object.interactionLabel);
-	if (object.interactionLabel.equalsIgnoreCase("NULL_ID"))
-		return Common::String();
-
-	if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
-		const Common::String inventoryLabel = resolveInventoryTooltipText(object);
-		if (!inventoryLabel.empty())
-			return inventoryLabel;
-	}
-
-	const TextRecord *textRecord = findTextRecord(object.identTextKey);
-	if (textRecord && !textRecord->value.empty())
-		return textRecord->value;
-
-	Common::String label = normalizeInteractionLabel(object.objectName);
-	if (!label.empty() && !label.equalsIgnoreCase("NULL ID"))
-		return label;
-
-	return Common::String();
-}
-
-Common::String Script::resolveTextValue(const Common::String &key) const {
-	const TextRecord *textRecord = findTextRecord(key);
-	if (textRecord && !textRecord->value.empty())
-		return textRecord->value;
-
-	return normalizeInteractionLabel(key);
-}
-
-const HeadRecord *Script::findHeadRecord(const Common::String &headId) const {
-	if (headId.empty())
-		return nullptr;
-
-	for (const HeadRecord &head : _heads) {
-		if (head.headId.equalsIgnoreCase(headId))
-			return &head;
-	}
-
-	return nullptr;
-}
-
-bool Script::getFlagValue(const Common::String &flagName) const {
-	const FlagRecord *flag = findRuntimeFlag(flagName);
-	return flag && flag->value;
-}
-
-bool Script::setRuntimeFlagValue(const Common::String &flagName, bool value) {
-	if (flagName.empty())
-		return false;
-
-	FlagRecord *flag = findRuntimeFlag(flagName);
-	if (flag) {
-		const bool changed = flag->value != value;
-		const bool oldValue = flag->value;
-		flag->value = value;
-		debugC(1, kDebugGeneral,
-			"Harvester: direct runtime flag '%s' %d -> %d existed=1 changed=%d",
-			flagName.c_str(), oldValue, value, changed);
-		return changed;
-	}
-
-	FlagRecord newFlag;
-	newFlag.name = flagName;
-	newFlag.value = value;
-	_currentFlags.push_back(Common::move(newFlag));
-	debugC(1, kDebugGeneral,
-		"Harvester: direct runtime flag '%s' %d -> %d existed=0 changed=1",
-		flagName.c_str(), 0, value);
-	return true;
-}
-
-bool Script::resetRuntimeObjectToInitialState(const Common::String &objectName) {
-	if (objectName.empty())
-		return false;
-
-	ObjectRecord *currentObject = findRuntimeObject(Common::String(), objectName);
-	if (!currentObject)
-		return false;
-
-	const ObjectRecord *baseObject = nullptr;
-	for (const ObjectRecord &object : _objects) {
-		if (object.objectName.equalsIgnoreCase(objectName)) {
-			baseObject = &object;
-			break;
-		}
-	}
-	if (!baseObject)
-		return false;
-
-	const bool changed = currentObject->currentX != currentObject->initialX ||
-		currentObject->currentY != currentObject->initialY ||
-		currentObject->currentZ != currentObject->initialZ ||
-		!currentObject->currentOwnerOrRoom.equalsIgnoreCase(currentObject->initialOwnerOrRoom) ||
-		currentObject->visible != baseObject->visible ||
-		currentObject->runtimeVisible != baseObject->visible ||
-		currentObject->identShown != baseObject->identShown;
-
-	currentObject->currentX = currentObject->initialX;
-	currentObject->currentY = currentObject->initialY;
-	currentObject->currentZ = currentObject->initialZ;
-	currentObject->currentOwnerOrRoom = currentObject->initialOwnerOrRoom;
-	currentObject->visible = baseObject->visible;
-	currentObject->runtimeVisible = baseObject->visible;
-	currentObject->identShown = baseObject->identShown;
-	return changed;
-}
-
-bool Script::isNamedNpcDeathTypeClear(const Common::String &npcName) const {
-	const NpcRecord *npc = findRuntimeNpc(npcName);
-	return npc && npc->deathDamageType == 0;
-}
-
-int Script::getCurrentStoryDayIndex() const {
-	if (getFlagValue("DAY_1"))
-		return 1;
-	if (getFlagValue("DAY_2") || getFlagValue("NIGHT_2"))
-		return 2;
-	if (getFlagValue("DAY_3") || getFlagValue("NIGHT_3"))
-		return 3;
-	if (getFlagValue("DAY_4") || getFlagValue("NIGHT_4"))
-		return 4;
-	if (getFlagValue("DAY_5") || getFlagValue("NIGHT_5"))
-		return 5;
-	if (getFlagValue("DAY_6"))
-		return 6;
-
-	return 0;
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 93825f8ec66..38b53cf8fe8 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -457,6 +457,9 @@ public:
 	bool setRuntimeTimerEnabled(const Common::String &timerName, bool enabled);
 
 private:
+	static const char *const kInventoryOwnerName;
+	static const int kDefaultPlayerCombatLoadout;
+
 	struct CommandExecutionContext {
 		const char *label;
 		const Common::String &name;
diff --git a/engines/harvester/script_actors.cpp b/engines/harvester/script_actors.cpp
new file mode 100644
index 00000000000..959de57a459
--- /dev/null
+++ b/engines/harvester/script_actors.cpp
@@ -0,0 +1,351 @@
+/* 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/script.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "harvester/detection.h"
+#include "harvester/player.h"
+
+namespace Harvester {
+
+static const int kMaxPlayerCombatLoadout = 0x14;
+
+struct InventoryCombatLoadoutEntry {
+	int loadoutId;
+	const char *objectName;
+};
+
+static const InventoryCombatLoadoutEntry kInventoryCombatLoadoutMap[] = {
+	{ 1, "CLEAVER" },
+	{ 2, "NAILGUN" },
+	{ 3, "SHOTGUN" },
+	{ 4, "9GUN" },
+	{ 5, "38GUN" },
+	{ 6, "TOMAHAWK" },
+	{ 7, "KNIFE" },
+	{ 8, "FLAIL" },
+	{ 9, "HANDAXE" },
+	{ 10, "WRENCH" },
+	{ 11, "PITCHFORK" },
+	{ 12, "SCYTHE" },
+	{ 13, "SWORD" },
+	{ 14, "CHAINSAW" },
+	{ 15, "HARVEST_BLADE" },
+	{ 16, "SHOVEL" },
+	{ 17, "FIREAXE" },
+	{ 18, "BAT" },
+	{ 19, "RAZOR" },
+	{ 20, "POOLSTICK" }
+};
+
+int Script::clampPlayerHitPoints(int hitPoints) {
+	if (hitPoints < 0)
+		return 0;
+	if (hitPoints > Script::kDefaultPlayerHitPoints)
+		return Script::kDefaultPlayerHitPoints;
+
+	return hitPoints;
+}
+
+int Script::clampPlayerCombatLoadout(int loadout) {
+	return CLIP<int>(loadout, 0, kMaxPlayerCombatLoadout);
+}
+
+static int resolveCombatResourceDisplayMax(int loadout) {
+	switch (loadout) {
+	case 2:
+	case 3:
+	case 14:
+		return 16;
+	case 4:
+		return 8;
+	case 5:
+		return 6;
+	default:
+		return 0;
+	}
+}
+
+int Script::clampCombatResourceCount(int loadout, int count) {
+	const int maxCount = resolveCombatResourceDisplayMax(loadout);
+	if (maxCount <= 0)
+		return 0;
+
+	return CLIP<int>(count, 0, maxCount);
+}
+
+int Script::resolveCombatResourcePickupMax(int loadout) {
+	switch (loadout) {
+	case 2:
+	case 3:
+	case 14:
+		return 15;
+	case 4:
+		return 7;
+	case 5:
+		return 5;
+	default:
+		return 0;
+	}
+}
+
+static const char *resolveCombatLoadoutInventoryObjectName(int loadout) {
+	for (const InventoryCombatLoadoutEntry &entry : kInventoryCombatLoadoutMap) {
+		if (entry.loadoutId == loadout)
+			return entry.objectName;
+	}
+
+	return nullptr;
+}
+
+int Script::validatePlayerCombatLoadoutAgainstInventory(const Common::Array<ObjectRecord> &currentObjects,
+		int loadout, const char *contextLabel) {
+	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
+	if (clampedLoadout == kDefaultPlayerCombatLoadout)
+		return clampedLoadout;
+
+	const char *requiredObjectName = resolveCombatLoadoutInventoryObjectName(clampedLoadout);
+	if (!requiredObjectName)
+		return clampedLoadout;
+
+	for (const ObjectRecord &object : currentObjects) {
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
+				object.objectName.equalsIgnoreCase(requiredObjectName))
+			return clampedLoadout;
+	}
+
+	debugC(1, kDebugGeneral,
+		"Harvester: cleared player combat loadout %d('%s') after %s because inventory object '%s' is absent",
+		clampedLoadout, Player::describeCombatLoadout(clampedLoadout),
+		contextLabel ? contextLabel : "runtime restore", requiredObjectName);
+	return kDefaultPlayerCombatLoadout;
+}
+
+bool Script::setRuntimeNpcState(const Common::String &npcName, bool active, bool visible) {
+	if (npcName.empty())
+		return false;
+
+	NpcRecord *currentNpc = findRuntimeNpc(npcName);
+	if (!currentNpc)
+		return false;
+
+	const bool changed = currentNpc->active != active || currentNpc->visible != visible;
+	currentNpc->active = active;
+	currentNpc->visible = visible;
+	return changed;
+}
+
+bool Script::setPlayerCurrentHitPoints(int hitPoints) {
+	const int clampedHitPoints = clampPlayerHitPoints(hitPoints);
+	const bool changed = _playerCurrentHitPoints != clampedHitPoints;
+	_playerCurrentHitPoints = clampedHitPoints;
+	return changed;
+}
+
+bool Script::adjustPlayerCurrentHitPoints(int delta) {
+	return setPlayerCurrentHitPoints(_playerCurrentHitPoints + delta);
+}
+
+int Script::getPlayerCombatResourceCount(int loadout) const {
+	const int *count = getPlayerCombatResourceCountPtr(loadout);
+	return count ? *count : 0;
+}
+
+bool Script::consumePlayerCombatResourceUnit(int loadout) {
+	const int maxCount = resolveCombatResourcePickupMax(loadout);
+	if (maxCount <= 0)
+		return true;
+
+	if (getPlayerCombatResourceCount(loadout) < 1)
+		return false;
+
+	return adjustPlayerCombatResourceCount(loadout, -1, maxCount, "PLAYER_ATTACK");
+}
+
+bool Script::setPlayerCombatResourceCount(int loadout, int count) {
+	int *currentCount = getPlayerCombatResourceCountPtr(loadout);
+	const int maxCount = resolveCombatResourceDisplayMax(loadout);
+	if (!currentCount || maxCount <= 0)
+		return false;
+
+	const int clampedCount = CLIP<int>(count, 0, maxCount);
+	const bool changed = *currentCount != clampedCount;
+	*currentCount = clampedCount;
+	return changed;
+}
+
+int *Script::getPlayerCombatResourceCountPtr(int loadout) {
+	switch (loadout) {
+	case 2:
+		return &_nailgunAmmoCount;
+	case 3:
+		return &_shotgunShellCount;
+	case 4:
+		return &_nineGunBulletCount;
+	case 5:
+		return &_thirtyEightGunBulletCount;
+	case 14:
+		return &_chainsawFuelCount;
+	default:
+		return nullptr;
+	}
+}
+
+const int *Script::getPlayerCombatResourceCountPtr(int loadout) const {
+	switch (loadout) {
+	case 2:
+		return &_nailgunAmmoCount;
+	case 3:
+		return &_shotgunShellCount;
+	case 4:
+		return &_nineGunBulletCount;
+	case 5:
+		return &_thirtyEightGunBulletCount;
+	case 14:
+		return &_chainsawFuelCount;
+	default:
+		return nullptr;
+	}
+}
+
+bool Script::adjustPlayerCombatResourceCount(int loadout, int delta, int maxCount,
+		const Common::String &reason) {
+	int *count = getPlayerCombatResourceCountPtr(loadout);
+	if (!count || maxCount <= 0)
+		return false;
+
+	const int oldCount = *count;
+	const int newCount = CLIP<int>(oldCount + delta, 0, maxCount);
+	*count = newCount;
+	debugC(1, kDebugGeneral,
+		"Harvester: %s player combat resource loadout=%d('%s') delta=%d count=%d->%d max=%d changed=%d",
+		reason.c_str(), loadout, Player::describeCombatLoadout(loadout),
+		delta, oldCount, newCount, maxCount, oldCount != newCount);
+	return oldCount != newCount;
+}
+
+bool Script::setPlayerCombatLoadout(int loadout) {
+	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
+	const bool changed = _playerCombatLoadout != clampedLoadout;
+	_playerCombatLoadout = clampedLoadout;
+	return changed;
+}
+
+bool Script::setPlayerControlPaused(bool paused) {
+	const bool changed = _playerControlPaused != paused;
+	_playerControlPaused = paused;
+	return changed;
+}
+
+bool Script::syncRuntimeMonsterRecord(const MonsterRecord &monster) {
+	MonsterRecord *currentMonster = findRuntimeMonster(monster);
+	if (!currentMonster)
+		return false;
+
+	*currentMonster = monster;
+	return true;
+}
+
+bool Script::queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType) {
+	if (npcName.empty())
+		return false;
+
+	NpcRecord *currentNpc = findRuntimeNpc(npcName);
+	if (!currentNpc || currentNpc->deathOrMonsterfyFlag)
+		return false;
+
+	// Native live MONSTERFY queues actor state 0x35 without a damage argument.
+	// Use bludge as the queued animation selector when scripts leave it blank.
+	const int queuedDeathDamageType = deathDamageType != 0 ? deathDamageType : 1;
+	if (currentNpc->deathDamageType == queuedDeathDamageType)
+		return false;
+
+	currentNpc->deathDamageType = queuedDeathDamageType;
+	return true;
+}
+
+bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType,
+		bool preserveCorpse, int corpseFrame, int monsterfyPosZ) {
+	if (npcName.empty())
+		return false;
+
+	NpcRecord *currentNpc = findRuntimeNpc(npcName);
+	if (!currentNpc)
+		return false;
+
+	bool monsterChanged = false;
+	if (!currentNpc->monsterfyTargetName.empty()) {
+		MonsterRecord monsterLookup;
+		monsterLookup.roomName = currentNpc->roomName;
+		monsterLookup.monsterName = currentNpc->monsterfyTargetName;
+		MonsterRecord *currentMonster = findRuntimeMonster(monsterLookup);
+		if (currentMonster) {
+			const bool overrideMonsterPosZ = monsterfyPosZ != kNoMonsterfyPosZOverride;
+			monsterChanged = !currentMonster->active || !currentMonster->visible ||
+				(overrideMonsterPosZ && currentMonster->posZ != monsterfyPosZ);
+			if (overrideMonsterPosZ)
+				currentMonster->posZ = monsterfyPosZ;
+			currentMonster->active = true;
+			currentMonster->visible = true;
+			currentMonster->runtimeSpawned = false;
+			currentMonster->runtimeState = -1;
+			if (currentMonster->currentHitPoints <= 0)
+				currentMonster->currentHitPoints = currentMonster->initialHitPoints;
+		} else {
+			debug(1, "Harvester: unresolved monsterfy target for dialogue npc='%s' target='%s'",
+				currentNpc->npcName.c_str(), currentNpc->monsterfyTargetName.c_str());
+		}
+	}
+
+	const bool changed = !currentNpc->deathOrMonsterfyFlag ||
+		currentNpc->active ||
+		currentNpc->visible ||
+		currentNpc->runtimeSpawned ||
+		currentNpc->runtimeState != (preserveCorpse && corpseFrame >= 0 ? corpseFrame : -1) ||
+		(deathDamageType != 0 && currentNpc->deathDamageType != deathDamageType);
+	currentNpc->active = false;
+	currentNpc->visible = preserveCorpse && corpseFrame >= 0 && currentNpc->monsterfyTargetName.empty();
+	currentNpc->savedVisible = currentNpc->visible;
+	currentNpc->deathOrMonsterfyFlag = !currentNpc->visible;
+	currentNpc->runtimeSpawned = currentNpc->visible;
+	currentNpc->runtimeState = currentNpc->visible ? corpseFrame : -1;
+	if (deathDamageType != 0)
+		currentNpc->deathDamageType = deathDamageType;
+	return changed || monsterChanged;
+}
+
+void Script::pushDeferredLiveNpcDeathTransitions() {
+	++_deferredLiveNpcDeathTransitionDepth;
+}
+
+void Script::popDeferredLiveNpcDeathTransitions() {
+	if (_deferredLiveNpcDeathTransitionDepth > 0)
+		--_deferredLiveNpcDeathTransitionDepth;
+}
+
+bool Script::isNamedNpcDeathTypeClear(const Common::String &npcName) const {
+	const NpcRecord *npc = findRuntimeNpc(npcName);
+	return npc && npc->deathDamageType == 0;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/script_runtime.cpp b/engines/harvester/script_runtime.cpp
new file mode 100644
index 00000000000..70d529703d9
--- /dev/null
+++ b/engines/harvester/script_runtime.cpp
@@ -0,0 +1,719 @@
+/* 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/script.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "harvester/detection.h"
+
+namespace Harvester {
+
+const char *const Script::kInventoryOwnerName = "INVENTORY";
+const int Script::kDefaultPlayerCombatLoadout = 0;
+
+static bool matchesMonsterIdentity(const MonsterRecord &candidate, const MonsterRecord &monster) {
+	if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
+		return false;
+
+	if (candidate.recordIndex >= 0 && monster.recordIndex >= 0)
+		return candidate.recordIndex == monster.recordIndex;
+
+	if (!monster.roomName.empty())
+		return candidate.roomName.equalsIgnoreCase(monster.roomName);
+
+	return true;
+}
+
+static const char *resolveInventoryStatusObjectName(int hitPoints) {
+	if (hitPoints < 8)
+		return "INV_STAT4";
+	if (hitPoints < 15)
+		return "INV_STAT3";
+	if (hitPoints < 23)
+		return "INV_STAT2";
+
+	return "INV_STAT1";
+}
+
+static Common::String normalizeInteractionLabel(const Common::String &value) {
+	Common::String label = value;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return label;
+}
+
+void Script::resetRuntimeState() {
+	_currentFlags = _flags;
+	_currentObjects = _objects;
+	_currentAnimations = _animations;
+	_currentRegions = _regions;
+	_currentNpcs = _npcs;
+	_currentMonsters = _monsters;
+	_currentTimers = _timers;
+	_nailgunAmmoCount = 0;
+	_shotgunShellCount = 0;
+	_nineGunBulletCount = 0;
+	_thirtyEightGunBulletCount = 0;
+	_chainsawFuelCount = 0;
+	_playerCurrentHitPoints = Script::kDefaultPlayerHitPoints;
+	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
+	_playerControlPaused = false;
+
+	for (ObjectRecord &object : _currentObjects) {
+		object.currentX = object.initialX;
+		object.currentY = object.initialY;
+		object.currentZ = object.initialZ;
+		object.currentOwnerOrRoom = object.initialOwnerOrRoom;
+		object.runtimeVisible = object.visible;
+		object.identShown = object.identTextKey.empty();
+	}
+
+	for (AnimRecord &anim : _currentAnimations) {
+		anim.runtimeActive = anim.active;
+		anim.runtimeVisible = anim.visible;
+		anim.runtimeState = -1;
+	}
+
+	for (NpcRecord &npc : _currentNpcs) {
+		npc.runtimeSpawned = false;
+		npc.runtimeState = -1;
+		npc.savedVisible = npc.visible;
+	}
+
+	for (MonsterRecord &monster : _currentMonsters) {
+		monster.currentHitPoints = monster.initialHitPoints;
+		monster.runtimeSpawned = false;
+		monster.runtimeState = -1;
+		monster.savedVisible = monster.visible;
+		if (monster.active)
+			monster.visible = true;
+	}
+
+	for (TimerRecord &timer : _currentTimers)
+		timer.currentValue = timer.initialValue;
+}
+
+void Script::getVisibleInventoryObjects(Common::Array<ObjectRecord> &objects) const {
+	objects.clear();
+	const char *const statusObjectName = resolveInventoryStatusObjectName(_playerCurrentHitPoints);
+	const ObjectRecord *statusObject = nullptr;
+
+	for (const ObjectRecord &object : _currentObjects) {
+		if (object.objectName.hasPrefixIgnoreCase("INV_STAT")) {
+			if (object.objectName.equalsIgnoreCase(statusObjectName))
+				statusObject = &object;
+			continue;
+		}
+
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) && object.visible)
+			objects.push_back(object);
+	}
+
+	if (!statusObject)
+		return;
+
+	ObjectRecord activeStatusObject = *statusObject;
+	activeStatusObject.currentOwnerOrRoom = kInventoryOwnerName;
+	activeStatusObject.visible = true;
+	activeStatusObject.runtimeVisible = true;
+	activeStatusObject.identShown = true;
+	objects.push_back(Common::move(activeStatusObject));
+}
+
+bool Script::isObjectInInventory(const Common::String &objectName) const {
+	if (objectName.empty())
+		return false;
+
+	for (const ObjectRecord &object : _currentObjects) {
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
+				object.objectName.equalsIgnoreCase(objectName))
+			return true;
+	}
+
+	return false;
+}
+
+void Script::markObjectIdentShown(const ObjectRecord &object) {
+	if (ObjectRecord *currentObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
+		currentObject->identShown = true;
+}
+
+const EntranceRecord *Script::findEntranceRecord(const Common::String &entranceName) const {
+	if (entranceName.empty())
+		return nullptr;
+
+	for (const EntranceRecord &entrance : _entrances) {
+		if (entrance.entranceName.equalsIgnoreCase(entranceName))
+			return &entrance;
+	}
+
+	return nullptr;
+}
+
+const MapEntranceRecord *Script::findMapEntranceRecord(const Common::String &entryName) const {
+	if (entryName.empty())
+		return nullptr;
+
+	for (const MapEntranceRecord &mapEntrance : _mapEntrances) {
+		if (mapEntrance.entryName.equalsIgnoreCase(entryName))
+			return &mapEntrance;
+	}
+
+	return nullptr;
+}
+
+const UseItemRecord *Script::findUseItemRecord(const Common::String &itemName,
+		const ObjectRecord &target) const {
+	if (itemName.empty() || target.objectName.empty())
+		return nullptr;
+
+	for (const UseItemRecord &useItem : _useItems) {
+		if (!useItem.itemName.equalsIgnoreCase(itemName) ||
+			!useItem.targetName.equalsIgnoreCase(target.objectName)) {
+			continue;
+		}
+
+		// Native dispatch ignores the parsed owner/group string and matches only
+		// the carried item name plus the overlapped target entity name.
+		return &useItem;
+	}
+
+	return nullptr;
+}
+
+const RoomRecord *Script::findRoomRecord(const Common::String &roomName) const {
+	if (roomName.empty())
+		return nullptr;
+
+	for (const RoomRecord &room : _rooms) {
+		if (room.roomName.equalsIgnoreCase(roomName))
+			return &room;
+	}
+
+	return nullptr;
+}
+
+const CommandRecord *Script::findCommandRecord(const Common::String &tag) const {
+	if (tag.empty())
+		return nullptr;
+
+	for (const CommandRecord &command : _commands) {
+		if (command.triggerTag.equalsIgnoreCase(tag))
+			return &command;
+	}
+
+	return nullptr;
+}
+
+const ExecListRecord *Script::findExecListRecord(const Common::String &name) const {
+	if (name.empty())
+		return nullptr;
+
+	for (const ExecListRecord &execList : _execLists) {
+		if (execList.listName.equalsIgnoreCase(name))
+			return &execList;
+	}
+
+	return nullptr;
+}
+
+const FlagRecord *Script::findRuntimeFlag(const Common::String &flagName) const {
+	if (flagName.empty())
+		return nullptr;
+
+	for (const FlagRecord &flag : _currentFlags) {
+		if (flag.name.equalsIgnoreCase(flagName))
+			return &flag;
+	}
+
+	return nullptr;
+}
+
+FlagRecord *Script::findRuntimeFlag(const Common::String &flagName) {
+	if (flagName.empty())
+		return nullptr;
+
+	for (FlagRecord &flag : _currentFlags) {
+		if (flag.name.equalsIgnoreCase(flagName))
+			return &flag;
+	}
+
+	return nullptr;
+}
+
+ObjectRecord *Script::findRuntimeObject(const Common::String &ownerOrRoom,
+		const Common::String &objectName) {
+	if (objectName.empty())
+		return nullptr;
+
+	ObjectRecord *fallback = nullptr;
+	for (ObjectRecord &object : _currentObjects) {
+		if (!object.objectName.equalsIgnoreCase(objectName))
+			continue;
+
+		if (ownerOrRoom.empty())
+			return &object;
+
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) ||
+			object.initialOwnerOrRoom.equalsIgnoreCase(ownerOrRoom)) {
+			return &object;
+		}
+
+		if (!fallback)
+			fallback = &object;
+	}
+
+	return fallback;
+}
+
+AnimRecord *Script::findRuntimeAnim(const Common::String &animName) {
+	if (animName.empty())
+		return nullptr;
+
+	for (AnimRecord &anim : _currentAnimations) {
+		if (anim.animName.equalsIgnoreCase(animName))
+			return &anim;
+	}
+
+	return nullptr;
+}
+
+RegionRecord *Script::findRuntimeRegion(const Common::String &regionName) {
+	if (regionName.empty())
+		return nullptr;
+
+	for (RegionRecord &region : _currentRegions) {
+		if (region.regionName.equalsIgnoreCase(regionName))
+			return ®ion;
+	}
+
+	return nullptr;
+}
+
+NpcRecord *Script::findRuntimeNpc(const Common::String &npcName) {
+	if (npcName.empty())
+		return nullptr;
+
+	for (NpcRecord &npc : _currentNpcs) {
+		if (npc.npcName.equalsIgnoreCase(npcName))
+			return &npc;
+	}
+
+	return nullptr;
+}
+
+MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) {
+	if (monsterName.empty())
+		return nullptr;
+
+	for (MonsterRecord &monster : _currentMonsters) {
+		if (monster.monsterName.equalsIgnoreCase(monsterName))
+			return &monster;
+	}
+
+	return nullptr;
+}
+
+MonsterRecord *Script::findRuntimeMonster(const MonsterRecord &monster) {
+	if (monster.monsterName.empty())
+		return nullptr;
+
+	MonsterRecord *fallback = nullptr;
+	for (MonsterRecord &candidate : _currentMonsters) {
+		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
+			continue;
+		if (matchesMonsterIdentity(candidate, monster))
+			return &candidate;
+		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
+			fallback = &candidate;
+	}
+
+	return fallback;
+}
+
+TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) {
+	if (timerName.empty())
+		return nullptr;
+
+	for (TimerRecord &timer : _currentTimers) {
+		if (timer.timerName.equalsIgnoreCase(timerName))
+			return &timer;
+	}
+
+	return nullptr;
+}
+
+const NpcRecord *Script::findRuntimeNpc(const Common::String &npcName) const {
+	if (npcName.empty())
+		return nullptr;
+
+	for (const NpcRecord &npc : _currentNpcs) {
+		if (npc.npcName.equalsIgnoreCase(npcName))
+			return &npc;
+	}
+
+	return nullptr;
+}
+
+const MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) const {
+	if (monsterName.empty())
+		return nullptr;
+
+	for (const MonsterRecord &monster : _currentMonsters) {
+		if (monster.monsterName.equalsIgnoreCase(monsterName))
+			return &monster;
+	}
+
+	return nullptr;
+}
+
+const MonsterRecord *Script::findRuntimeMonster(const MonsterRecord &monster) const {
+	if (monster.monsterName.empty())
+		return nullptr;
+
+	const MonsterRecord *fallback = nullptr;
+	for (const MonsterRecord &candidate : _currentMonsters) {
+		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
+			continue;
+		if (matchesMonsterIdentity(candidate, monster))
+			return &candidate;
+		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
+			fallback = &candidate;
+	}
+
+	return fallback;
+}
+
+const MonsterRecord *Script::findBaseMonster(const MonsterRecord &monster) const {
+	if (monster.monsterName.empty())
+		return nullptr;
+
+	const MonsterRecord *fallback = nullptr;
+	for (const MonsterRecord &candidate : _monsters) {
+		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
+			continue;
+		if (matchesMonsterIdentity(candidate, monster))
+			return &candidate;
+		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
+			fallback = &candidate;
+	}
+
+	return fallback;
+}
+
+const TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) const {
+	if (timerName.empty())
+		return nullptr;
+
+	for (const TimerRecord &timer : _currentTimers) {
+		if (timer.timerName.equalsIgnoreCase(timerName))
+			return &timer;
+	}
+
+	return nullptr;
+}
+
+const TimerRecord *Script::findRuntimeTimerRecord(const Common::String &timerName) const {
+	return findRuntimeTimer(timerName);
+}
+
+const NpcRecord *Script::findRuntimeNpcRecord(const Common::String &npcName) const {
+	return findRuntimeNpc(npcName);
+}
+
+bool Script::addRuntimeObjectToInventory(const Common::String &objectName) {
+	ObjectRecord *currentObject = findRuntimeObject(Common::String(), objectName);
+	if (!currentObject)
+		return false;
+
+	const bool changed = !currentObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) ||
+		!currentObject->visible || !currentObject->runtimeVisible || !currentObject->identShown;
+	currentObject->currentOwnerOrRoom = kInventoryOwnerName;
+	currentObject->visible = true;
+	currentObject->runtimeVisible = true;
+	currentObject->identShown = true;
+	return changed;
+}
+
+bool Script::syncRuntimeObjectRecord(const ObjectRecord &object) {
+	if (object.objectName.empty())
+		return false;
+
+	ObjectRecord *currentObject = findRuntimeObject(Common::String(), object.objectName);
+	if (!currentObject)
+		return false;
+
+	const bool changed = currentObject->currentX != object.currentX ||
+		currentObject->currentY != object.currentY ||
+		currentObject->currentZ != object.currentZ ||
+		!currentObject->currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) ||
+		currentObject->visible != object.visible ||
+		currentObject->runtimeVisible != object.runtimeVisible ||
+		currentObject->identShown != object.identShown;
+	*currentObject = object;
+	return changed;
+}
+
+bool Script::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
+		const Common::String &objectName, bool visible) {
+	if (objectName.empty())
+		return false;
+
+	ObjectRecord *currentObject = findRuntimeObject(ownerOrRoom, objectName);
+	if (!currentObject)
+		return false;
+
+	const bool changed = currentObject->visible != visible ||
+		currentObject->runtimeVisible != visible;
+	currentObject->visible = visible;
+	currentObject->runtimeVisible = visible;
+	if (visible && currentObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName))
+		currentObject->identShown = true;
+
+	return changed;
+}
+
+bool Script::syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame) {
+	AnimRecord *currentAnimation = findRuntimeAnim(animName);
+	if (!currentAnimation)
+		return false;
+
+	const bool changed = currentAnimation->active != active ||
+		currentAnimation->visible != visible ||
+		currentAnimation->runtimeActive != active ||
+		currentAnimation->runtimeVisible != visible ||
+		currentAnimation->runtimeState != currentFrame;
+	currentAnimation->active = active;
+	currentAnimation->visible = visible;
+	currentAnimation->runtimeActive = active;
+	currentAnimation->runtimeVisible = visible;
+	currentAnimation->runtimeState = currentFrame;
+	return changed;
+}
+
+bool Script::syncRuntimeTimerRecord(const TimerRecord &timer) {
+	TimerRecord *currentTimer = findRuntimeTimer(timer.timerName);
+	if (!currentTimer)
+		return false;
+
+	*currentTimer = timer;
+	return true;
+}
+
+bool Script::setRuntimeRegionEnabled(const Common::String &regionName, bool enabled) {
+	RegionRecord *currentRegion = findRuntimeRegion(regionName);
+	if (!currentRegion)
+		return false;
+
+	const bool changed = currentRegion->startEnabled != enabled;
+	currentRegion->startEnabled = enabled;
+	return changed;
+}
+
+bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enabled) {
+	TimerRecord *currentTimer = findRuntimeTimer(timerName);
+	if (!currentTimer)
+		return false;
+
+	const bool wasEnabled = currentTimer->enabled;
+	const bool changed = wasEnabled != enabled ||
+		(enabled && !wasEnabled && currentTimer->currentValue != currentTimer->initialValue);
+	currentTimer->enabled = enabled;
+	if (enabled && !wasEnabled)
+		currentTimer->currentValue = currentTimer->initialValue;
+	return changed;
+}
+
+const TextRecord *Script::findTextRecord(const Common::String &key) const {
+	if (key.empty())
+		return nullptr;
+
+	for (const TextRecord &textRecord : _texts) {
+		if (textRecord.key.equalsIgnoreCase(key))
+			return &textRecord;
+	}
+
+	return nullptr;
+}
+
+bool Script::resolveObjectInspectText(const ObjectRecord &object, ResolvedText &text) const {
+	text = ResolvedText();
+
+	const TextRecord *textRecord = findTextRecord(object.identTextKey);
+	if (!textRecord)
+		return false;
+
+	text.boxName = textRecord->boxName;
+	text.value = textRecord->value;
+	return !text.value.empty();
+}
+
+bool Script::resolveTextRecord(const Common::String &key, ResolvedText &text) const {
+	text = ResolvedText();
+
+	const TextRecord *textRecord = findTextRecord(key);
+	if (!textRecord)
+		return false;
+
+	text.boxName = textRecord->boxName;
+	text.value = textRecord->value;
+	return !text.value.empty();
+}
+
+Common::String Script::resolveInventoryTooltipText(const ObjectRecord &object) const {
+	const TextRecord *textRecord = findTextRecord(object.inventoryTextKey);
+	if (textRecord && !textRecord->value.empty())
+		return textRecord->value;
+
+	return Common::String();
+}
+
+Common::String Script::resolveObjectLabel(const ObjectRecord &object) const {
+	if (!object.interactionLabel.empty() && !object.interactionLabel.equalsIgnoreCase("NULL_ID"))
+		return normalizeInteractionLabel(object.interactionLabel);
+	if (object.interactionLabel.equalsIgnoreCase("NULL_ID"))
+		return Common::String();
+
+	if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
+		const Common::String inventoryLabel = resolveInventoryTooltipText(object);
+		if (!inventoryLabel.empty())
+			return inventoryLabel;
+	}
+
+	const TextRecord *textRecord = findTextRecord(object.identTextKey);
+	if (textRecord && !textRecord->value.empty())
+		return textRecord->value;
+
+	Common::String label = normalizeInteractionLabel(object.objectName);
+	if (!label.empty() && !label.equalsIgnoreCase("NULL ID"))
+		return label;
+
+	return Common::String();
+}
+
+Common::String Script::resolveTextValue(const Common::String &key) const {
+	const TextRecord *textRecord = findTextRecord(key);
+	if (textRecord && !textRecord->value.empty())
+		return textRecord->value;
+
+	return normalizeInteractionLabel(key);
+}
+
+const HeadRecord *Script::findHeadRecord(const Common::String &headId) const {
+	if (headId.empty())
+		return nullptr;
+
+	for (const HeadRecord &head : _heads) {
+		if (head.headId.equalsIgnoreCase(headId))
+			return &head;
+	}
+
+	return nullptr;
+}
+
+bool Script::getFlagValue(const Common::String &flagName) const {
+	const FlagRecord *flag = findRuntimeFlag(flagName);
+	return flag && flag->value;
+}
+
+bool Script::setRuntimeFlagValue(const Common::String &flagName, bool value) {
+	if (flagName.empty())
+		return false;
+
+	FlagRecord *flag = findRuntimeFlag(flagName);
+	if (flag) {
+		const bool changed = flag->value != value;
+		const bool oldValue = flag->value;
+		flag->value = value;
+		debugC(1, kDebugGeneral,
+			"Harvester: direct runtime flag '%s' %d -> %d existed=1 changed=%d",
+			flagName.c_str(), oldValue, value, changed);
+		return changed;
+	}
+
+	FlagRecord newFlag;
+	newFlag.name = flagName;
+	newFlag.value = value;
+	_currentFlags.push_back(Common::move(newFlag));
+	debugC(1, kDebugGeneral,
+		"Harvester: direct runtime flag '%s' %d -> %d existed=0 changed=1",
+		flagName.c_str(), 0, value);
+	return true;
+}
+
+bool Script::resetRuntimeObjectToInitialState(const Common::String &objectName) {
+	if (objectName.empty())
+		return false;
+
+	ObjectRecord *currentObject = findRuntimeObject(Common::String(), objectName);
+	if (!currentObject)
+		return false;
+
+	const ObjectRecord *baseObject = nullptr;
+	for (const ObjectRecord &object : _objects) {
+		if (object.objectName.equalsIgnoreCase(objectName)) {
+			baseObject = &object;
+			break;
+		}
+	}
+	if (!baseObject)
+		return false;
+
+	const bool changed = currentObject->currentX != currentObject->initialX ||
+		currentObject->currentY != currentObject->initialY ||
+		currentObject->currentZ != currentObject->initialZ ||
+		!currentObject->currentOwnerOrRoom.equalsIgnoreCase(currentObject->initialOwnerOrRoom) ||
+		currentObject->visible != baseObject->visible ||
+		currentObject->runtimeVisible != baseObject->visible ||
+		currentObject->identShown != baseObject->identShown;
+
+	currentObject->currentX = currentObject->initialX;
+	currentObject->currentY = currentObject->initialY;
+	currentObject->currentZ = currentObject->initialZ;
+	currentObject->currentOwnerOrRoom = currentObject->initialOwnerOrRoom;
+	currentObject->visible = baseObject->visible;
+	currentObject->runtimeVisible = baseObject->visible;
+	currentObject->identShown = baseObject->identShown;
+	return changed;
+}
+
+int Script::getCurrentStoryDayIndex() const {
+	if (getFlagValue("DAY_1"))
+		return 1;
+	if (getFlagValue("DAY_2") || getFlagValue("NIGHT_2"))
+		return 2;
+	if (getFlagValue("DAY_3") || getFlagValue("NIGHT_3"))
+		return 3;
+	if (getFlagValue("DAY_4") || getFlagValue("NIGHT_4"))
+		return 4;
+	if (getFlagValue("DAY_5") || getFlagValue("NIGHT_5"))
+		return 5;
+	if (getFlagValue("DAY_6"))
+		return 6;
+
+	return 0;
+}
+
+} // End of namespace Harvester


Commit: 32b820a74757b39ef9f04691757996f6cd0d00e5
    https://github.com/scummvm/scummvm/commit/32b820a74757b39ef9f04691757996f6cd0d00e5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Show IDENT text before non-operatable object actions

Assisted-by: Codex:gpt-6.1-sol

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


diff --git a/engines/harvester/input.cpp b/engines/harvester/input.cpp
index f296ff6175c..4aa4bca1928 100644
--- a/engines/harvester/input.cpp
+++ b/engines/harvester/input.cpp
@@ -341,6 +341,8 @@ Input::Input(HarvesterEngine &engine, Flow &flow, Scene &session, SceneState &sc
 }
 
 void Input::beginInspectText(const ObjectRecord &object, const ResolvedText &text) {
+	_pendingInspectObjectName.clear();
+	_inspectDismissPressed = false;
 	_presentationState.inspectText = text;
 	_presentationState.showingInspectText = true;
 	_presentationState.inspectCanDismiss = false;
@@ -350,6 +352,38 @@ void Input::beginInspectText(const ObjectRecord &object, const ResolvedText &tex
 		_presentationState.inspectText.boxName.c_str(), _presentationState.inspectText.value.c_str());
 }
 
+InputResult Input::finishInspectText(bool &needsRedraw) {
+	_inspectDismissPressed = false;
+	_presentationState.showingInspectText = false;
+	_presentationState.inspectCanDismiss = false;
+	_presentationState.inspectText = ResolvedText();
+	needsRedraw = true;
+	const Common::String objectName = _pendingInspectObjectName;
+	_pendingInspectObjectName.clear();
+	if (objectName.empty())
+		return InputResult();
+
+	const ObjectRecord *object = SceneEntities::findSceneObjectByName(_scene.sceneObjects, objectName);
+	InteractionResult interaction;
+	if (!object || !_engine.getScript()->resolveObjectInteraction(*object, interaction, _scene.executionContext()))
+		return InputResult();
+
+	return applyObjectInteraction(interaction, needsRedraw);
+}
+
+InputResult Input::applyObjectInteraction(InteractionResult &interaction, bool &needsRedraw) {
+	bool didTransition = false;
+	Common::Error interactionError =
+		_interactionProcessor.handleInteractionResult(
+			interaction, didTransition, Common::String());
+	if (interactionError.getCode() != Common::kNoError)
+		return InputResult::leaveRoom(interactionError);
+	if (_flow.hasPendingMainMenuReturn())
+		return InputResult::leaveRoom(Common::kNoError);
+	needsRedraw = true;
+	return InputResult();
+}
+
 void Input::queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw) {
 	if (message.empty()) {
 		const bool hadMessage = !_presentationState.combatLoadoutStatusText.value.empty();
@@ -569,12 +603,8 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	}
 
 	if (_presentationState.showingInspectText) {
-		if (_presentationState.inspectCanDismiss) {
-			_presentationState.showingInspectText = false;
-			_presentationState.inspectCanDismiss = false;
-			_presentationState.inspectText = ResolvedText();
-			needsRedraw = true;
-		}
+		if (_presentationState.inspectCanDismiss)
+			_inspectDismissPressed = true;
 		return InputResult();
 	}
 	if (_session.isPlayerCombatLocked())
@@ -763,7 +793,7 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 					_scene.state.roomName.c_str(), _flow.getMousePos().x, _flow.getMousePos().y,
 					_scene.state.roomMaxZScreenY, _scene.state.roomMinZScreenY,
 					_scene.state.roomMinZ, _scene.state.roomMaxZ,
-					playerState.centerX, playerState.bottomY, (double)playerState.z,
+					playerState.centerX, playerState.bottomY, static_cast<double>(playerState.z),
 					playerRect.left, playerRect.top, playerRect.right, playerRect.bottom);
 			}
 			player.setMoveTargetFromScreenPoint(_scene.state, _flow.getMousePos().x, _flow.getMousePos().y);
@@ -771,7 +801,7 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 				debugC(1, kDebugPathfinding,
 					"Harvester: pathfinding click target room='%s' mouse=(%d,%d) target=(%d,%d,z=%.2f)",
 					_scene.state.roomName.c_str(), _flow.getMousePos().x, _flow.getMousePos().y,
-					playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
+					playerState.targetX, playerState.targetBottomY, static_cast<double>(playerState.targetZ));
 			}
 			needsRedraw = true;
 		}
@@ -809,6 +839,16 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 		needsRedraw = true;
 		return InputResult();
 	}
+	// An IDENT can be shown before a non-operatable object's action, without setting identShown. Dispatch after dismissal.
+	if (!clickedObject->identShown && !suppressesInitialObjectInspectGate(*clickedObject) &&
+			!unlocksAfterInitialExamine && canShowInspectText) {
+		beginInspectText(*clickedObject, resolvedInspectText);
+		if (!clickedObject->actionTag.empty())
+			_pendingInspectObjectName = clickedObject->objectName;
+		player.cancelMovement();
+		needsRedraw = true;
+		return InputResult();
+	}
 	if (_engine.getScript()->isPickupObject(*clickedObject)) {
 		Common::Error pickupError = _roomItems.handleRoomItemPickup(*clickedObject, playerState.entity != nullptr,
 			_interactionProcessor, _session, needsRedraw);
@@ -830,16 +870,7 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 		return InputResult();
 	}
 
-	bool didTransition = false;
-	Common::Error interactionError =
-		_interactionProcessor.handleInteractionResult(
-			interaction, didTransition, Common::String());
-	if (interactionError.getCode() != Common::kNoError)
-		return InputResult::leaveRoom(interactionError);
-	if (_flow.hasPendingMainMenuReturn())
-		return InputResult::leaveRoom(Common::kNoError);
-	needsRedraw = true;
-	return InputResult();
+	return applyObjectInteraction(interaction, needsRedraw);
 }
 
 InputResult Input::handleGameAction(GameAction action, bool pressed, bool &needsRedraw) {
@@ -906,12 +937,8 @@ InputResult Input::handleKeyDown(const Common::KeyState &key, bool &needsRedraw,
 	inputState.speedUpActive = gameAction && key.keycode == Common::KEYCODE_LALT;
 
 	if (_presentationState.showingInspectText) {
-		if (key.keycode == Common::KEYCODE_ESCAPE) {
-			_presentationState.showingInspectText = false;
-			_presentationState.inspectCanDismiss = false;
-			_presentationState.inspectText = ResolvedText();
-			needsRedraw = true;
-		}
+		if (key.keycode == Common::KEYCODE_ESCAPE)
+			_inspectDismissPressed = true;
 		return InputResult();
 	}
 
@@ -1073,6 +1100,16 @@ InputResult Input::handleEvent(const Common::Event &event, bool &needsRedraw) {
 	if (_flow.handleSystemEvent(event, result))
 		return InputResult::leaveRoom(result);
 
+	if (_presentationState.showingInspectText && _inspectDismissPressed) {
+		if (event.type == Common::EVENT_LBUTTONUP ||
+				(event.type == Common::EVENT_KEYUP && event.kbd.keycode == Common::KEYCODE_ESCAPE) ||
+				(event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_END && event.customType == kActionMenu)) {
+			if (event.type == Common::EVENT_LBUTTONUP)
+				handleLeftButtonUp();
+			return finishInspectText(needsRedraw);
+		}
+	}
+
 	switch (event.type) {
 	case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
 	case Common::EVENT_CUSTOM_ENGINE_ACTION_END:
diff --git a/engines/harvester/input.h b/engines/harvester/input.h
index 9865e564217..136a47eae03 100644
--- a/engines/harvester/input.h
+++ b/engines/harvester/input.h
@@ -26,6 +26,7 @@
 #include "common/error.h"
 #include "common/events.h"
 #include "common/rect.h"
+#include "common/str.h"
 
 namespace Harvester {
 
@@ -38,6 +39,7 @@ class InventorySystem;
 class Interaction;
 class ItemInteractions;
 class Scene;
+struct InteractionResult;
 struct MenuTextConfig;
 struct NpcRecord;
 struct ObjectRecord;
@@ -129,6 +131,8 @@ private:
 	void handleKeyUp(const Common::KeyState &key, bool gameAction = false);
 	InputResult handleGameAction(GameAction action, bool pressed, bool &needsRedraw);
 	void beginInspectText(const ObjectRecord &object, const ResolvedText &text);
+	InputResult finishInspectText(bool &needsRedraw);
+	InputResult applyObjectInteraction(InteractionResult &interaction, bool &needsRedraw);
 	void queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw);
 	bool openInventoryOverlay();
 	void capturePlayerAttackTarget();
@@ -144,6 +148,8 @@ private:
 	RoomPresentationState &_presentationState;
 	const Art &_art;
 	bool _canExitCloseupToParent;
+	Common::String _pendingInspectObjectName;
+	bool _inspectDismissPressed = false;
 };
 
 } // End of namespace Harvester


Commit: a8a8ddc7c202f84f952ddfcb0a0c3aadf7181627
    https://github.com/scummvm/scummvm/commit/a8a8ddc7c202f84f952ddfcb0a0c3aadf7181627
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Separate menu presentation and text configuration

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/menu_config.cpp
  A engines/harvester/menu_render.cpp
  A engines/harvester/menu_render.h
    engines/harvester/menu.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index e2653e448c2..1b3328d345b 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -20,12 +20,11 @@
  */
 
 #include "harvester/menu.h"
+#include "harvester/menu_render.h"
 
 #include "common/algorithm.h"
 #include "common/config-manager.h"
 #include "common/events.h"
-#include "common/formats/ini-file.h"
-#include "common/memstream.h"
 #include "common/system.h"
 #include "engines/metaengine.h"
 #include "graphics/font.h"
@@ -47,37 +46,6 @@ namespace Harvester {
 
 namespace {
 
-static const int kInventoryX = 64;
-static const int kInventoryY = 48;
-static const int kLogoX = 160;
-static const int kLogoY = 0;
-static const int kMenuStartY = 100;
-static const int kMenuLineSpacing = 28;
-
-static const byte kTextColorNormal = 255;
-static const byte kTextColorHover = 251;
-static const byte kTextColorDim = 248;
-static const byte kShadowColor = 0;
-static const byte kPanelFillColor = 1;
-static const byte kQuickTipActionColor = 0xc3;
-
-static const char *const kMenuPath = "MENU.INI";
-static const char *const kMenuSectionName = "menu";
-
-struct MenuWeekdayEntry {
-	const char *key;
-	const char *englishFallback;
-};
-
-static const MenuWeekdayEntry kMenuWeekdays[] = {
-	{ "monday", "Monday" },
-	{ "tuesday", "Tuesday" },
-	{ "wednesday", "Wednesday" },
-	{ "thursday", "Thursday" },
-	{ "friday", "Friday" },
-	{ "saturday", "Saturday" }
-};
-
 static const char *const kOptionsVolumeBitmapPath = "1:/GRAPHIC/OTHER/VOLUME.BM";
 static const char *const kOptionsIndicatorBitmapPath = "1:/GRAPHIC/OTHER/INDICATR.BM";
 static const char *const kOptionsPreviewSoundPath = "1:/SOUND/EFFECTS/WHIP2.WAV";
@@ -88,47 +56,9 @@ static const char *const kSaveGamePalettePath = "1:/GRAPHIC/PAL/SAVEGAME.PAL";
 static const char *const kGameOverBitmapPath = "1:/GRAPHIC/OTHER/GAMEOVER.BM";
 static const char *const kGameOverPalettePath = "1:/GRAPHIC/PAL/GAMEOVER.PAL";
 static const char *const kGameOverMusicPath = "SOUND/MUSIC/ANXIETY.CMP";
-
-static const int kOptionsSliderMinX = 0x16f;
 static const int kOptionsSliderMaxX = 0x22b;
-static const int kOptionsSliderStep = 0x13;
-static const int kOptionsVolumeBitmapX = 0x168;
-static const int kOptionsVolumeBitmapY = 0x68;
-static const int kOptionsIndicatorY = 0x6c;
 static const int kOptionsSliderHeight = 0x1e;
-static const int kOptionsItemCount = 7;
 static const int kStartupOptionMaxLevel = 9;
-
-// The first three strings retain the padding from the European executable's
-// fixed-width table so their centered text sits underneath the volume bars.
-static const char *const kEuropeanBuiltInOptionItems[kOptionsItemCount] = {
-	" SOUND FX              ",
-	" MUSIC               ",
-	" GAMMA               ",
-	"TEXT",
-	"GORE",
-	"QUICK TIPS",
-	"PASSWORD"
-};
-
-static const int kQuickTipsOverlayX = 167;
-static const int kQuickTipsOverlayY = 200;
-static const int kQuickTipsHeaderY = 202;
-static const int kQuickTipTextX = 180;
-static const int kQuickTipTextY = 228;
-static const int kEnglishQuickTipTextWidth = 280;
-static const int kLocalizedQuickTipTextWidth = 300;
-static const int kLocalizedQuickTipActionY = 308;
-static const int kQuickTipExitX = 180;
-static const int kQuickTipToggleX = 258;
-static const int kQuickTipNextRightInset = 8;
-static const int kConfirmDialogX = 167;
-static const int kConfirmDialogY = 200;
-static const int kConfirmPromptTextX = 0xea;
-static const int kConfirmPromptTextY = 0xd6;
-static const int kConfirmChoiceTextY = 0xf8;
-static const int kConfirmYesTextX = 0xdc;
-static const int kConfirmNoTextX = 0x17c;
 static const int kSaveSlotCount = 25;
 static const int kSaveSlotLabelX = 8;
 static const int kSaveSlotNameX = 0x50;
@@ -137,15 +67,13 @@ static const int kSaveSlotStride = 0x11;
 static const int kSaveSlotTextWidth = 0x27b - kSaveSlotNameX;
 static const int kSaveStatusTextY = 0x1b8;
 static const int kSaveSlotMaxCharacters = 0xf6;
-static const int kPasswordEntryX = 0xdc;
-static const int kPasswordEntryY = 0xdc;
-static const int kPasswordEntryWidth = 0x226;
 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;
+static const char *const kBlankMenuSlot = " ";
 
 class ScopedSceneTimerPause {
 public:
@@ -179,9 +107,6 @@ private:
 	bool _paused = false;
 };
 
-static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y);
-static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont);
-
 static int clampStartupOptionLevel(int level) {
 	if (level < 0)
 		return 0;
@@ -213,167 +138,18 @@ static Common::Rect quitConfirmNoRect() {
 }
 
 static Common::Rect optionsSliderRect(int sliderIndex, int lineHeight) {
-	const int y = kOptionsIndicatorY + sliderIndex * lineHeight;
-	return Common::Rect(kOptionsSliderMinX, y, kOptionsSliderMaxX + 1, y + kOptionsSliderHeight + 1);
+	const int y = MenuRendering::kOptionsIndicatorY + sliderIndex * lineHeight;
+	return Common::Rect(MenuRendering::kOptionsSliderMinX, y, kOptionsSliderMaxX + 1, y + kOptionsSliderHeight + 1);
 }
 
 static int resolveOptionsSliderIndexFromMouseX(int mouseX) {
-	const int relativeX = mouseX - kOptionsSliderMinX;
-	return clampStartupOptionLevel((relativeX + (kOptionsSliderStep / 2)) / kOptionsSliderStep);
-}
-
-static bool loadRawMenuValue(const Common::Array<byte> &data, const char *key, Common::String &value) {
-	value.clear();
-	if (!key)
-		return false;
-
-	Common::String line;
-	for (uint i = 0; i <= data.size(); ++i) {
-		const bool atEnd = i == data.size();
-		const byte chr = atEnd ? '\n' : data[i];
-		if (chr == '\r')
-			continue;
-
-		if (chr != '\n') {
-			line += (char)chr;
-			continue;
-		}
-
-		const char *separator = strchr(line.c_str(), '=');
-		if (separator) {
-			const Common::String currentKey(line.c_str(), separator);
-			if (currentKey.equalsIgnoreCase(key)) {
-				value = Common::String(separator + 1);
-				return true;
-			}
-		}
-
-		line.clear();
-	}
-
-	return false;
-}
-
-static void loadMenuDisplayValue(Common::INIFile &menu, const char *key, Common::String &dest) {
-	Common::String value;
-	if (!menu.getKey(key, kMenuSectionName, value) || value.empty())
-		return;
-
-	for (uint i = 0; i < value.size(); ++i) {
-		if (value[i] == '_')
-			value.setChar(' ', i);
-	}
-	dest = Common::move(value);
-}
-
-static void applyEuropeanBuiltInMenuText(MenuTextConfig &config) {
-	config.yesLabel = "Yes";
-	config.noLabel = "No";
-	config.clickLabel = "Click";
-	config.newGamePrompt = "START A NEW GAME?";
-	config.quitGamePrompt = " QUIT HARVESTER?";
-	for (uint i = 0; i < config.optionItems.size(); ++i)
-		config.optionItems[i] = kEuropeanBuiltInOptionItems[i];
+	const int relativeX = mouseX - MenuRendering::kOptionsSliderMinX;
+	return clampStartupOptionLevel(
+		(relativeX + (MenuRendering::kOptionsSliderStep / 2)) / MenuRendering::kOptionsSliderStep);
 }
 
 } // End of anonymous namespace
 
-bool loadMenuTextConfig(HarvesterEngine &engine, MenuTextConfig &config) {
-	config = MenuTextConfig();
-	if (engine.isDemo()) {
-		config.clickLabel = "Click";
-		config.newGamePrompt = "START A NEW GAME?";
-		config.quitGamePrompt = " QUIT HARVESTER?";
-	}
-	config.optionItems.resize(kOptionsItemCount);
-	config.optionItems[0] = "SOUND FX";
-	config.optionItems[1] = "MUSIC";
-	config.optionItems[2] = "GAMMA";
-	config.optionItems[3] = "TEXT";
-	config.optionItems[4] = "GORE";
-	config.optionItems[5] = "QUICK TIPS";
-	config.optionItems[6] = "PASSWORD";
-	config.weekdayLabels.resize(ARRAYSIZE(kMenuWeekdays));
-	for (uint i = 0; i < config.weekdayLabels.size(); ++i)
-		config.weekdayLabels[i] = kMenuWeekdays[i].englishFallback;
-
-	ResourceManager *resources = engine.getResources();
-	if (!resources)
-		return false;
-
-	Common::Array<byte> data;
-	if (!resources->loadFile(kMenuPath, data)) {
-		if (!engine.isDemo())
-			applyEuropeanBuiltInMenuText(config);
-		debugC(2, kDebugGeneral,
-			"Harvester: using built-in menu text because '%s' is unavailable", kMenuPath);
-		return true;
-	}
-
-	Common::MemoryReadStream stream(data.data(), data.size());
-	Common::INIFile menu;
-	menu.setDefaultSectionName(kMenuSectionName);
-	menu.requireKeyValueDelimiter();
-	menu.suppressValuelessLineWarning();
-	if (!menu.loadFromStream(stream))
-		return false;
-
-	for (uint i = 0; i < config.optionItems.size(); ++i) {
-		const Common::String key = Common::String::format("options_menu_%u", i + 1);
-		Common::String value;
-		if (loadRawMenuValue(data, key.c_str(), value) && !value.empty())
-			config.optionItems[i] = Common::move(value);
-		else if (menu.getKey(key, kMenuSectionName, value) && !value.empty())
-			config.optionItems[i] = Common::move(value);
-	}
-
-	Common::String value;
-	if (menu.getKey("yes", kMenuSectionName, value) && !value.empty())
-		config.yesLabel = Common::move(value);
-	if (menu.getKey("no", kMenuSectionName, value) && !value.empty())
-		config.noLabel = Common::move(value);
-	if (menu.getKey("click", kMenuSectionName, value) && !value.empty())
-		config.clickLabel = Common::move(value);
-	loadMenuDisplayValue(menu, "on", config.onLabel);
-	loadMenuDisplayValue(menu, "off", config.offLabel);
-	loadMenuDisplayValue(menu, "enter_password", config.enterPasswordLabel);
-	if (loadRawMenuValue(data, "newgame", value) && !value.empty())
-		config.newGamePrompt = Common::move(value);
-	if (loadRawMenuValue(data, "quitgame", value) && !value.empty())
-		config.quitGamePrompt = Common::move(value);
-	loadMenuDisplayValue(menu, "talk_to", config.talkToVerb);
-	loadMenuDisplayValue(menu, "examine", config.examineVerb);
-	loadMenuDisplayValue(menu, "examine_the", config.examineTheVerb);
-	loadMenuDisplayValue(menu, "operate", config.operateVerb);
-	loadMenuDisplayValue(menu, "pick_up", config.pickUpVerb);
-	loadMenuDisplayValue(menu, "use", config.useVerb);
-	loadMenuDisplayValue(menu, "use_on", config.useOnPreposition);
-	loadMenuDisplayValue(menu, "other", config.dialogueOtherLabel);
-	loadMenuDisplayValue(menu, "responses", config.dialogueResponsesLabel);
-	loadMenuDisplayValue(menu, "keyword", config.dialogueKeywordLabel);
-	for (uint i = 0; i < config.weekdayLabels.size(); ++i)
-		loadMenuDisplayValue(menu, kMenuWeekdays[i].key, config.weekdayLabels[i]);
-	loadMenuDisplayValue(menu, "Exit", config.quickTipsExitLabel);
-	loadMenuDisplayValue(menu, "next", config.quickTipsNextLabel);
-	loadMenuDisplayValue(menu, "show_tips_on", config.quickTipsOnLabel);
-	loadMenuDisplayValue(menu, "show_tips_off", config.quickTipsOffLabel);
-	loadMenuDisplayValue(menu, "quick_tips_header", config.quickTipsHeader);
-
-	return true;
-}
-
-Common::String buildUseItemPrompt(const MenuTextConfig &config,
-		const Common::String &itemLabel, const Common::String &targetLabel) {
-	if (itemLabel.empty())
-		return Common::String();
-	if (targetLabel.empty())
-		return Common::String::format("%s %s %s",
-			config.useVerb.c_str(), itemLabel.c_str(), config.useOnPreposition.c_str());
-
-	return Common::String::format("%s %s %s %s", config.useVerb.c_str(), itemLabel.c_str(),
-		config.useOnPreposition.c_str(), targetLabel.c_str());
-}
-
 namespace {
 
 enum MainMenuItem {
@@ -385,8 +161,6 @@ enum MainMenuItem {
 	kMainMenuItemQuitGame
 };
 
-static const char *const kBlankMenuSlot = " ";
-
 static void buildDisplayMainMenuItems(const Common::Array<Common::String> &source,
 		bool canSaveGame, bool canLoadGame, Common::Array<Common::String> &dest) {
 	dest = source;
@@ -413,262 +187,20 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
-static void applyMenuPalette(Graphics::Screen &screen, const HarvesterEngine &engine,
-		const byte *palette, float brightness) {
-	if (!palette)
-		return;
-
-	byte displayPalette[256 * 3];
-	buildHarvesterDisplayPalette(palette, brightness * engine.getGammaBrightnessScale(), displayPalette);
-	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)
-		return;
-
-	applyMenuPalette(*screen, engine, palette, 1.0f);
-	screen->fillRect(screen->getBounds(), 0);
-	blitBitmap(*screen, bitmap, 0, 0);
-	if (engine.getRuntimeEntities())
-		engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
-static Common::String buildTextModeSuffix(const Script &script, const MenuTextConfig &config) {
-	switch (script.getDialogueTextMode()) {
-	case kStartupDialogueTextNone:
-		return Common::String::format(" - %s", config.noLabel.c_str());
-	case kStartupDialogueTextClick:
-		return Common::String::format(" - %s", config.clickLabel.c_str());
-	case kStartupDialogueTextYes:
-	default:
-		return Common::String::format(" - %s", config.yesLabel.c_str());
-	}
-}
-
-static Common::String buildOptionsMenuItemLabel(const Script &script,
-		const MenuTextConfig &config, int index) {
-	if (index < 0 || index >= (int)config.optionItems.size())
-		return Common::String();
-
-	switch (index) {
-	case 3:
-		return config.optionItems[index] + buildTextModeSuffix(script, config);
-	case 4:
-		return Common::String::format("%s - %s", config.optionItems[index].c_str(),
-			(script.isGoreEnabled() ? config.onLabel : config.offLabel).c_str());
-	case 6:
-		return Common::String::format("%s - %s", config.optionItems[index].c_str(),
-			(script.getParentalPassword().empty() ? config.offLabel : config.onLabel).c_str());
-	default:
-		return config.optionItems[index];
-	}
-}
-
 static Common::String buildDefaultSaveSlotLabel(int slotIndex) {
 	return Common::String::format("GAME %02d", slotIndex + 1);
 }
 
-static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
-	if (!bitmap.isValid())
-		return;
-
-	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
-}
-
-static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
-		int x, int y, int width, byte color, Graphics::TextAlign align = Graphics::kTextAlignLeft) {
-	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
-	font.drawString(&screen, text, x, y, width, color, align);
-}
-
-static void drawSinglePassString(Graphics::Screen &screen, const Graphics::Font &font,
-		const Common::String &text, int x, int y) {
-	if (text.empty())
-		return;
-
-	font.drawString(&screen, text, x, y, font.getStringWidth(text), 0);
-}
-
-static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Font &font,
-		const Common::String &text, int x, int y, int width, byte color) {
-	Common::Array<Common::String> lines;
-	font.wordWrapText(text, width, lines);
-	for (uint i = 0; i < lines.size(); ++i)
-		drawShadowedString(screen, font, lines[i], x, y + (int)i * (font.getFontHeight() + 2), width, color);
-}
-
-static void splitMenuConfigLines(const Common::String &text, Common::Array<Common::String> &lines) {
-	lines.clear();
-	Common::String currentLine;
-	for (uint i = 0; i < text.size(); ++i) {
-		if (text[i] == '/') {
-			lines.push_back(Common::move(currentLine));
-			currentLine.clear();
-			continue;
-		}
-		currentLine += text[i];
-	}
-	lines.push_back(Common::move(currentLine));
-}
-
-static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
-		const byte *palette, float paletteBrightness,
-		const Graphics::Font &selectedFont, const Graphics::Font &unselectedFont,
-		const Art &art, const MenuTextConfig &config,
-		const IndexedBitmap &volumeBar, const IndexedBitmap &indicator, int selectedItem,
-		bool drawCursor = true) {
-	Graphics::Screen *screen = engine.getScreen();
-	Script *script = engine.getScript();
-	if (!screen || !script)
-		return;
-
-	applyMenuPalette(*screen, engine, palette, paletteBrightness);
-	blitBitmap(*screen, backdrop, 0, 0);
-	blitTransparentBitmap(*screen, art.getLogoBitmap(), kLogoX, kLogoY);
-
-	const int lineHeight = getNativeRoomMenuLineHeight(selectedFont);
-	for (int i = 0; i < (int)config.optionItems.size(); ++i) {
-		const Common::String label = buildOptionsMenuItemLabel(*script, config, i);
-		const Graphics::Font &font = (i == selectedItem) ? selectedFont : unselectedFont;
-		const int width = font.getStringWidth(label);
-		const int x = (screen->w - width) / 2;
-		const int y = kMenuStartY + i * lineHeight;
-		font.drawString(screen, label, x, y, width, 0);
-	}
-
-	// Native run_main_menu spawns the option text entities before adding the
-	// VOLUME/INDICATR bitmap entities, so the bars clip the trailing overlap on
-	// the first three rows instead of the text painting over them.
-	for (int i = 0; i < 3; ++i)
-		blitTransparentBitmap(*screen, volumeBar, kOptionsVolumeBitmapX, kOptionsVolumeBitmapY + i * lineHeight);
-
-	const int levels[3] = {
-		script->getFxVolumeLevel(),
-		script->getMusicVolumeLevel(),
-		script->getGammaLevel()
-	};
-	for (int i = 0; i < 3; ++i) {
-		blitTransparentBitmap(*screen, indicator, kOptionsSliderMinX + levels[i] * kOptionsSliderStep,
-			kOptionsIndicatorY + i * lineHeight);
-	}
-
-	if (drawCursor && engine.getRuntimeEntities())
-		engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
-static void renderPasswordPromptScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
-		const byte *palette, float paletteBrightness, const Graphics::Font &titleFont,
-		const Graphics::Font &entryFont, const Art &art, const Common::String &title,
-		const Common::String &text, bool cursorVisible, bool drawLogo) {
-	Graphics::Screen *screen = engine.getScreen();
-	if (!screen)
-		return;
-
-	if (palette)
-		applyMenuPalette(*screen, engine, palette, paletteBrightness);
-	blitBitmap(*screen, backdrop, 0, 0);
-	if (drawLogo)
-		blitTransparentBitmap(*screen, art.getLogoBitmap(), kLogoX, kLogoY);
-
-	const int titleWidth = titleFont.getStringWidth(title);
-	const int titleX = (screen->w - titleWidth) / 2;
-	titleFont.drawString(screen, title, titleX, 0xa0, titleWidth, 0);
-
-	Common::String displayText = text;
-	if (cursorVisible)
-		displayText += '~';
-	entryFont.drawString(screen, displayText, kPasswordEntryX, kPasswordEntryY,
-		kPasswordEntryWidth, 0);
-
-	screen->makeAllDirty();
-	screen->update();
-}
-
-static void renderQuickTipsOverlay(HarvesterEngine &engine, const IndexedBitmap &backdrop,
-		const byte *palette, float paletteBrightness,
-		const MenuTextConfig &config, const QuickTipsLayout &layout,
-		const Common::String &tipText) {
-	Graphics::Screen *screen = engine.getScreen();
-	const Art *art = engine.getArt();
-	if (!screen || !art)
-		return;
-
-	applyMenuPalette(*screen, engine, palette, paletteBrightness);
-	blitBitmap(*screen, backdrop, 0, 0);
-	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
-	drawQuickTipsPanel(engine, config, layout, tipText);
-
-	if (engine.getRuntimeEntities())
-		engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
-static void renderConfirmPromptScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
-		const byte *palette, float paletteBrightness, const Graphics::Font &promptFont,
-		const Graphics::Font &yesFont, const Graphics::Font &noFont,
-		const IndexedBitmap &textbox, const Common::String &promptText,
-		const MenuTextConfig &config) {
-	Graphics::Screen *screen = engine.getScreen();
-	const Art *art = engine.getArt();
-	if (!screen || !art)
-		return;
-
-	applyMenuPalette(*screen, engine, palette, paletteBrightness);
-	blitBitmap(*screen, backdrop, 0, 0);
-	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
-	blitTransparentBitmap(*screen, textbox, kConfirmDialogX, kConfirmDialogY);
-	Common::Array<Common::String> promptLines;
-	splitMenuConfigLines(promptText, promptLines);
-	for (uint i = 0; i < promptLines.size(); ++i) {
-		drawSinglePassString(*screen, promptFont, promptLines[i], kConfirmPromptTextX,
-			kConfirmPromptTextY + (int)i * (promptFont.getFontHeight() + 2));
-	}
-	drawSinglePassString(*screen, yesFont, config.yesLabel, kConfirmYesTextX, kConfirmChoiceTextY);
-	drawSinglePassString(*screen, noFont, config.noLabel, kConfirmNoTextX, kConfirmChoiceTextY);
-
-	if (engine.getRuntimeEntities())
-		engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
-static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
-	return selectedFont.getFontHeight() + 2;
-}
-
 static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
 		const Common::Point &mousePos) {
 	if (itemCount == 0)
 		return -1;
 
-	int selection = mousePos.y - kMenuStartY;
+	int selection = mousePos.y - MenuRendering::kMenuStartY;
 	if (selection < 1)
 		selection = 1;
 
-	selection /= MAX<int>(1, getNativeRoomMenuLineHeight(selectedFont));
+	selection /= MAX<int>(1, MenuRendering::getNativeRoomMenuLineHeight(selectedFont));
 	if (selection >= (int)itemCount)
 		selection = (int)itemCount - 1;
 
@@ -677,77 +209,6 @@ static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFon
 
 } // End of anonymous namespace
 
-bool resolveQuickTipsLayout(HarvesterEngine &engine, const MenuTextConfig &config,
-		QuickTipsLayout &layout) {
-	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	const Art *art = engine.getArt();
-	const Script *script = engine.getScript();
-	if (!font || !art || !script)
-		return false;
-
-	if (!config.hasQuickTipsHeader()) {
-		layout.exitRect = Common::Rect(180, 280, 238, 291);
-		layout.nextRect = Common::Rect(420, 280, 492, 291);
-		layout.toggleRect = Common::Rect(258, 280, 366, 291);
-		return true;
-	}
-
-	const IndexedBitmap *panel = art->getQuickTipsTextboxBitmap();
-	if (!panel || !panel->isValid())
-		return false;
-
-	const Common::String &toggleLabel = script->isQuickTipsEnabled()
-		? config.quickTipsOnLabel : config.quickTipsOffLabel;
-	auto makeLabelRect = [font](const Common::String &label, int x) {
-		const int width = MAX<int>(1, font->getStringWidth(label));
-		const int height = MAX<int>(1, font->getFontHeight());
-		return Common::Rect(x, kLocalizedQuickTipActionY,
-			x + width, kLocalizedQuickTipActionY + height);
-	};
-
-	layout.exitRect = makeLabelRect(config.quickTipsExitLabel, kQuickTipExitX);
-	layout.toggleRect = makeLabelRect(toggleLabel, kQuickTipToggleX);
-	const int nextX = kQuickTipsOverlayX + (int)panel->width
-		- font->getStringWidth(config.quickTipsNextLabel) - kQuickTipNextRightInset;
-	layout.nextRect = makeLabelRect(config.quickTipsNextLabel, nextX);
-	return true;
-}
-
-void drawQuickTipsPanel(HarvesterEngine &engine, const MenuTextConfig &config,
-		const QuickTipsLayout &layout, const Common::String &tipText) {
-	Graphics::Screen *screen = engine.getScreen();
-	const Art *art = engine.getArt();
-	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	const Script *script = engine.getScript();
-	if (!screen || !art || !font || !script)
-		return;
-
-	const IndexedBitmap *panel = config.hasQuickTipsHeader()
-		? art->getQuickTipsTextboxBitmap() : &art->getTipsBitmap();
-	if (!panel || !panel->isValid())
-		return;
-
-	blitBitmap(*screen, *panel, kQuickTipsOverlayX, kQuickTipsOverlayY);
-	if (config.hasQuickTipsHeader()) {
-		drawShadowedString(*screen, *font, config.quickTipsHeader,
-			kQuickTipsOverlayX, kQuickTipsHeaderY, panel->width,
-			kQuickTipActionColor, Graphics::kTextAlignCenter);
-	}
-
-	const int tipTextWidth = config.hasQuickTipsHeader()
-		? kLocalizedQuickTipTextWidth : kEnglishQuickTipTextWidth;
-	drawWrappedShadowedText(*screen, *font, tipText,
-		kQuickTipTextX, kQuickTipTextY, tipTextWidth, kTextColorNormal);
-	const Common::String &toggleLabel = script->isQuickTipsEnabled()
-		? config.quickTipsOnLabel : config.quickTipsOffLabel;
-	drawShadowedString(*screen, *font, config.quickTipsExitLabel,
-		layout.exitRect.left, layout.exitRect.top, layout.exitRect.width(), kQuickTipActionColor);
-	drawShadowedString(*screen, *font, config.quickTipsNextLabel,
-		layout.nextRect.left, layout.nextRect.top, layout.nextRect.width(), kQuickTipActionColor);
-	drawShadowedString(*screen, *font, toggleLabel,
-		layout.toggleRect.left, layout.toggleRect.top, layout.toggleRect.width(), kQuickTipActionColor);
-}
-
 MenuSystem::MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		const Common::Array<Common::String> &menuItems)
 	: _engine(engine), _mousePos(mousePos), _menuItems(menuItems) {
@@ -788,7 +249,7 @@ Common::Error MenuSystem::showCdChangePrompt(int discNumber, ResourceManager &re
 
 	while (!_engine.shouldQuit() && !activateRequestedDisc) {
 		if (needsRedraw) {
-			renderCdChangePromptScreen(_engine, promptBitmap, promptPalette);
+			MenuRendering::renderCdChangePromptScreen(_engine, promptBitmap, promptPalette);
 			needsRedraw = false;
 		}
 
@@ -1075,9 +536,9 @@ Common::Error MenuSystem::showGameOverBackdrop(Flow &flow) {
 	Graphics::FrameLimiter limiter(g_system, 60);
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			applyMenuPalette(*screen, _engine, _mainMenuBackdropPalette, 1.0f);
+			MenuRendering::applyMenuPalette(*screen, _engine, _mainMenuBackdropPalette, 1.0f);
 			screen->fillRect(screen->getBounds(), 0);
-			blitBitmap(*screen, _mainMenuBackdrop, 0, 0);
+			MenuRendering::blitBitmap(*screen, _mainMenuBackdrop, 0, 0);
 			if (_engine.getRuntimeEntities())
 				_engine.getRuntimeEntities()->drawCursor(*screen);
 			screen->makeAllDirty();
@@ -1350,8 +811,8 @@ void MenuSystem::renderSaveSlotMenu(const SaveSlotMenu &menu, const Common::Stri
 	if (!screen)
 		return;
 
-	applyMenuPalette(*screen, _engine, menu.palette, 1.0f);
-	blitBitmap(*screen, menu.background, 0, 0);
+	MenuRendering::applyMenuPalette(*screen, _engine, menu.palette, 1.0f);
+	MenuRendering::blitBitmap(*screen, menu.background, 0, 0);
 
 	for (int i = 0; i < kSaveSlotCount; ++i) {
 		const Graphics::Font &labelFont = (i == menu.activeSlot) ? menu.slotNameFont : menu.slotLabelFont;
@@ -1367,8 +828,9 @@ void MenuSystem::renderSaveSlotMenu(const SaveSlotMenu &menu, const Common::Stri
 	}
 
 	if (!editingText && !menu.statusMessage.empty()) {
-		drawShadowedString(*screen, menu.slotNameFont, menu.statusMessage, 20, kSaveStatusTextY,
-			screen->w - 40, kTextColorNormal, Graphics::kTextAlignCenter);
+		MenuRendering::drawShadowedString(*screen, menu.slotNameFont, menu.statusMessage,
+			MenuRendering::TextLayout(20, kSaveStatusTextY, screen->w - 40),
+			MenuRendering::kTextColorNormal, Graphics::kTextAlignCenter);
 	}
 
 	if (_engine.getRuntimeEntities())
@@ -1638,8 +1100,8 @@ Common::Error MenuSystem::restoreRoomBackdropAfterSave(const IndexedBitmap &back
 	}
 
 	screen->fillRect(screen->getBounds(), 0);
-	blitBitmap(*screen, backdrop, 0, 0);
-	applyMenuPalette(*screen, _engine, palette, kPaletteBrightnessBlack);
+	MenuRendering::blitBitmap(*screen, backdrop, 0, 0);
+	MenuRendering::applyMenuPalette(*screen, _engine, palette, kPaletteBrightnessBlack);
 	screen->makeAllDirty();
 	screen->update();
 
@@ -1679,7 +1141,7 @@ Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const
 				: static_cast<const Graphics::Font &>(choiceFont);
 			const Graphics::Font &noFont = hoverNo ? static_cast<const Graphics::Font &>(promptFont)
 				: static_cast<const Graphics::Font &>(choiceFont);
-			renderConfirmPromptScreen(_engine, backdrop, palette, paletteBrightness,
+			MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderConfirmPrompt(
 				promptFont, yesFont, noFont, *textbox, promptText, config);
 			needsRedraw = false;
 		}
@@ -1771,9 +1233,9 @@ Common::Error MenuSystem::runPasswordPrompt(const IndexedBitmap &backdrop, const
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderPasswordPromptScreen(_engine, backdrop, palette, paletteBrightness,
-				titleFont, entryFont, *art, flow.getMenuTextConfig().enterPasswordLabel,
-				password, cursorVisible, drawLogo);
+			MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderPasswordPrompt(
+				MenuRendering::FontPair(titleFont, entryFont), *art,
+				flow.getMenuTextConfig().enterPasswordLabel, password, cursorVisible, drawLogo);
 			needsRedraw = false;
 		}
 
@@ -1808,7 +1270,7 @@ Common::Error MenuSystem::runPasswordPrompt(const IndexedBitmap &backdrop, const
 				}
 				if (event.kbd.ascii >= 32 && event.kbd.ascii <= 126 && event.kbd.ascii != '~') {
 					needsRedraw |= appendTextEntryCharacter(password, entryFont, event.kbd.ascii,
-						kPasswordMaxCharacters, kPasswordEntryWidth);
+						kPasswordMaxCharacters, MenuRendering::kPasswordEntryWidth);
 				}
 				break;
 			default:
@@ -1891,7 +1353,7 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 		return Common::kReadingFailed;
 	}
 
-	const int lineHeight = getNativeRoomMenuLineHeight(selectedFont);
+	const int lineHeight = MenuRendering::getNativeRoomMenuLineHeight(selectedFont);
 	int selectedItem = 0;
 	int capturedSlider = -1;
 	bool showingQuickTips = false;
@@ -2030,11 +1492,12 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
 			if (showingQuickTips) {
-				renderQuickTipsOverlay(_engine, backdrop, palette, paletteBrightness,
+				MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderQuickTipsOverlay(
 					config, quickTipsLayout, flow.getQuickTip(quickTipIndex));
 			} else {
-				renderOptionsMenuScreen(_engine, backdrop, palette, paletteBrightness,
-					selectedFont, unselectedFont, *art, config, volumeBar, indicator, selectedItem);
+				MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderOptionsMenu(
+					MenuRendering::FontPair(selectedFont, unselectedFont), *art, config,
+					MenuRendering::SliderBitmaps(volumeBar, indicator), selectedItem);
 			}
 			needsRedraw = false;
 		}
@@ -2145,14 +1608,14 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 					break;
 				case Common::KEYCODE_LEFT:
 					if (selectedItem < 3) {
-						_mousePos.x = MAX<int>(kOptionsSliderMinX, _mousePos.x - kOptionsSliderStep);
+						_mousePos.x = MAX<int>(MenuRendering::kOptionsSliderMinX, _mousePos.x - MenuRendering::kOptionsSliderStep);
 						updateSlider(selectedItem);
 						needsRedraw = true;
 					}
 					break;
 				case Common::KEYCODE_RIGHT:
 					if (selectedItem < 3) {
-						_mousePos.x = MIN<int>(kOptionsSliderMaxX, _mousePos.x + kOptionsSliderStep);
+						_mousePos.x = MIN<int>(kOptionsSliderMaxX, _mousePos.x + MenuRendering::kOptionsSliderStep);
 						updateSlider(selectedItem);
 						needsRedraw = true;
 					}
@@ -2225,7 +1688,7 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderHelpScreen(_engine, page == 0 ? mouseHelp : keysHelp,
+			MenuRendering::renderHelpScreen(_engine, page == 0 ? mouseHelp : keysHelp,
 				page == 0 ? mouseHelpPalette : keysHelpPalette);
 			needsRedraw = false;
 		}
@@ -2242,7 +1705,7 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 				break;
 			case Common::EVENT_RBUTTONDOWN:
 				if (Graphics::Screen *screen = _engine.getScreen()) {
-					applyMenuPalette(*screen, _engine, palette, paletteBrightness);
+					MenuRendering::applyMenuPalette(*screen, _engine, palette, paletteBrightness);
 					screen->fillRect(screen->getBounds(), 0);
 				}
 				return Common::kNoError;
@@ -2253,7 +1716,7 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 			case Common::EVENT_KEYDOWN:
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					if (Graphics::Screen *screen = _engine.getScreen()) {
-						applyMenuPalette(*screen, _engine, palette, paletteBrightness);
+						MenuRendering::applyMenuPalette(*screen, _engine, palette, paletteBrightness);
 						screen->fillRect(screen->getBounds(), 0);
 					}
 					return Common::kNoError;
@@ -2277,85 +1740,6 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 	return Common::kNoError;
 }
 
-void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
-		bool drawCursor, const Common::Array<Common::String> &menuItems) const {
-	Graphics::Screen *screen = _engine.getScreen();
-	const Art *art = _engine.getArt();
-	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
-	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !art || !titleFont || !bodyFont)
-		return;
-
-	byte displayPalette[256 * 3];
-	buildHarvesterDisplayPalette(art->getWaitPalette(), 1.0f, displayPalette);
-	screen->setPalette(displayPalette);
-	screen->fillRect(screen->getBounds(), 0);
-	blitBitmap(*screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
-	blitBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
-
-	const Common::Rect panel(96, 96, 544, 432);
-	screen->fillRect(panel, kPanelFillColor);
-
-	drawShadowedString(*screen, *titleFont, "Harvester", panel.left, 110, panel.width(),
-		kTextColorNormal, Graphics::kTextAlignCenter);
-
-	for (uint i = 0; i < menuItems.size(); ++i) {
-		const int itemY = kMenuStartY + (int)i * kMenuLineSpacing;
-		const byte color = ((int)i == selectedItem) ? kTextColorHover : kTextColorNormal;
-		drawShadowedString(*screen, *bodyFont, menuItems[i], panel.left, itemY, panel.width(), color,
-			Graphics::kTextAlignCenter);
-	}
-
-	if (!statusMessage.empty()) {
-		drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 352, panel.width() - 48,
-			kTextColorNormal);
-	}
-	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Enter activates. Esc returns to launcher.",
-		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
-
-	if (drawCursor && _engine.getRuntimeEntities())
-		_engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
-void MenuSystem::renderBackdropMenuScreen(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, const Common::Array<Common::String> &menuItems,
-		int selectedItem) const {
-	Graphics::Screen *screen = _engine.getScreen();
-	const Art *art = _engine.getArt();
-	const Text *text = _engine.getText();
-	const CftFontResource *selectedFontResource = text ? text->findFont("HARVFONT") : nullptr;
-	const CftFontResource *unselectedFontResource = text ? text->findFont("HARVFNT2") : nullptr;
-	if (!screen || !art || !selectedFontResource || !unselectedFontResource || !backdrop.isValid())
-		return;
-
-	HarvesterCftFont selectedFont(*selectedFontResource);
-	HarvesterCftFont unselectedFont(*unselectedFontResource);
-	if (!selectedFont.isValid() || !unselectedFont.isValid())
-		return;
-
-	applyMenuPalette(*screen, _engine, palette, paletteBrightness);
-	blitBitmap(*screen, backdrop, 0, 0);
-	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
-
-	const int lineSpacing = getNativeRoomMenuLineHeight(selectedFont);
-	for (uint i = 0; i < menuItems.size(); ++i) {
-		const Graphics::Font *font = ((int)i == selectedItem)
-			? static_cast<const Graphics::Font *>(&selectedFont)
-			: static_cast<const Graphics::Font *>(&unselectedFont);
-		const int width = font->getStringWidth(menuItems[i]);
-		const int x = (screen->w - width) / 2;
-		const int y = kMenuStartY + (int)i * lineSpacing;
-		font->drawString(screen, menuItems[i], x, y, width, 0);
-	}
-
-	if (_engine.getRuntimeEntities())
-		_engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
 int MenuSystem::getMenuItemAt(const Common::Point &mousePos,
 		const Common::Array<Common::String> &menuItems) const {
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
@@ -2365,7 +1749,7 @@ int MenuSystem::getMenuItemAt(const Common::Point &mousePos,
 	for (uint i = 0; i < menuItems.size(); ++i) {
 		const int width = font->getStringWidth(menuItems[i]);
 		const int x = (640 - width) / 2;
-		const int y = kMenuStartY + (int)i * kMenuLineSpacing;
+		const int y = MenuRendering::kMenuStartY + (int)i * MenuRendering::kMenuLineSpacing;
 		const Common::Rect bounds(x - 12, y - 2, x + width + 12, y + font->getFontHeight() + 2);
 		if (bounds.contains(mousePos))
 			return (int)i;
diff --git a/engines/harvester/menu_config.cpp b/engines/harvester/menu_config.cpp
new file mode 100644
index 00000000000..11c7f086e28
--- /dev/null
+++ b/engines/harvester/menu_config.cpp
@@ -0,0 +1,214 @@
+/* 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/menu.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "common/formats/ini-file.h"
+#include "common/memstream.h"
+#include "harvester/detection.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+static const char *const kMenuPath = "MENU.INI";
+static const char *const kMenuSectionName = "menu";
+static const int kOptionsItemCount = 7;
+
+struct MenuWeekdayEntry {
+	const char *key;
+	const char *englishFallback;
+};
+
+static const MenuWeekdayEntry kMenuWeekdays[] = {
+	{ "monday", "Monday" },
+	{ "tuesday", "Tuesday" },
+	{ "wednesday", "Wednesday" },
+	{ "thursday", "Thursday" },
+	{ "friday", "Friday" },
+	{ "saturday", "Saturday" }
+};
+
+// The first three strings retain the padding from the European executable's
+// fixed-width table so their centered text sits underneath the volume bars.
+static const char *const kEuropeanBuiltInOptionItems[kOptionsItemCount] = {
+	" SOUND FX              ",
+	" MUSIC               ",
+	" GAMMA               ",
+	"TEXT",
+	"GORE",
+	"QUICK TIPS",
+	"PASSWORD"
+};
+
+static bool loadRawMenuValue(const Common::Array<byte> &data, const char *key, Common::String &value) {
+	value.clear();
+	if (!key)
+		return false;
+
+	Common::String line;
+	for (uint i = 0; i <= data.size(); ++i) {
+		const bool atEnd = i == data.size();
+		const byte chr = atEnd ? '\n' : data[i];
+		if (chr == '\r')
+			continue;
+
+		if (chr != '\n') {
+			line += static_cast<char>(chr);
+			continue;
+		}
+
+		const char *separator = strchr(line.c_str(), '=');
+		if (separator) {
+			const Common::String currentKey(line.c_str(), separator);
+			if (currentKey.equalsIgnoreCase(key)) {
+				value = Common::String(separator + 1);
+				return true;
+			}
+		}
+
+		line.clear();
+	}
+
+	return false;
+}
+
+static void loadMenuDisplayValue(Common::INIFile &menu, const char *key, Common::String &dest) {
+	Common::String value;
+	if (!menu.getKey(key, kMenuSectionName, value) || value.empty())
+		return;
+
+	for (uint i = 0; i < value.size(); ++i) {
+		if (value[i] == '_')
+			value.setChar(' ', i);
+	}
+	dest = Common::move(value);
+}
+
+static void applyEuropeanBuiltInMenuText(MenuTextConfig &config) {
+	config.yesLabel = "Yes";
+	config.noLabel = "No";
+	config.clickLabel = "Click";
+	config.newGamePrompt = "START A NEW GAME?";
+	config.quitGamePrompt = " QUIT HARVESTER?";
+	for (uint i = 0; i < config.optionItems.size(); ++i)
+		config.optionItems[i] = kEuropeanBuiltInOptionItems[i];
+}
+
+bool loadMenuTextConfig(HarvesterEngine &engine, MenuTextConfig &config) {
+	config = MenuTextConfig();
+	if (engine.isDemo()) {
+		config.clickLabel = "Click";
+		config.newGamePrompt = "START A NEW GAME?";
+		config.quitGamePrompt = " QUIT HARVESTER?";
+	}
+	config.optionItems.resize(kOptionsItemCount);
+	config.optionItems[0] = "SOUND FX";
+	config.optionItems[1] = "MUSIC";
+	config.optionItems[2] = "GAMMA";
+	config.optionItems[3] = "TEXT";
+	config.optionItems[4] = "GORE";
+	config.optionItems[5] = "QUICK TIPS";
+	config.optionItems[6] = "PASSWORD";
+	config.weekdayLabels.resize(ARRAYSIZE(kMenuWeekdays));
+	for (uint i = 0; i < config.weekdayLabels.size(); ++i)
+		config.weekdayLabels[i] = kMenuWeekdays[i].englishFallback;
+
+	ResourceManager *resources = engine.getResources();
+	if (!resources)
+		return false;
+
+	Common::Array<byte> data;
+	if (!resources->loadFile(kMenuPath, data)) {
+		if (!engine.isDemo())
+			applyEuropeanBuiltInMenuText(config);
+		debugC(2, kDebugGeneral,
+			"Harvester: using built-in menu text because '%s' is unavailable", kMenuPath);
+		return true;
+	}
+
+	Common::MemoryReadStream stream(data.data(), data.size());
+	Common::INIFile menu;
+	menu.setDefaultSectionName(kMenuSectionName);
+	menu.requireKeyValueDelimiter();
+	menu.suppressValuelessLineWarning();
+	if (!menu.loadFromStream(stream))
+		return false;
+
+	for (uint i = 0; i < config.optionItems.size(); ++i) {
+		const Common::String key = Common::String::format("options_menu_%u", i + 1);
+		Common::String value;
+		if (loadRawMenuValue(data, key.c_str(), value) && !value.empty())
+			config.optionItems[i] = Common::move(value);
+		else if (menu.getKey(key, kMenuSectionName, value) && !value.empty())
+			config.optionItems[i] = Common::move(value);
+	}
+
+	Common::String value;
+	if (menu.getKey("yes", kMenuSectionName, value) && !value.empty())
+		config.yesLabel = Common::move(value);
+	if (menu.getKey("no", kMenuSectionName, value) && !value.empty())
+		config.noLabel = Common::move(value);
+	if (menu.getKey("click", kMenuSectionName, value) && !value.empty())
+		config.clickLabel = Common::move(value);
+	loadMenuDisplayValue(menu, "on", config.onLabel);
+	loadMenuDisplayValue(menu, "off", config.offLabel);
+	loadMenuDisplayValue(menu, "enter_password", config.enterPasswordLabel);
+	if (loadRawMenuValue(data, "newgame", value) && !value.empty())
+		config.newGamePrompt = Common::move(value);
+	if (loadRawMenuValue(data, "quitgame", value) && !value.empty())
+		config.quitGamePrompt = Common::move(value);
+	loadMenuDisplayValue(menu, "talk_to", config.talkToVerb);
+	loadMenuDisplayValue(menu, "examine", config.examineVerb);
+	loadMenuDisplayValue(menu, "examine_the", config.examineTheVerb);
+	loadMenuDisplayValue(menu, "operate", config.operateVerb);
+	loadMenuDisplayValue(menu, "pick_up", config.pickUpVerb);
+	loadMenuDisplayValue(menu, "use", config.useVerb);
+	loadMenuDisplayValue(menu, "use_on", config.useOnPreposition);
+	loadMenuDisplayValue(menu, "other", config.dialogueOtherLabel);
+	loadMenuDisplayValue(menu, "responses", config.dialogueResponsesLabel);
+	loadMenuDisplayValue(menu, "keyword", config.dialogueKeywordLabel);
+	for (uint i = 0; i < config.weekdayLabels.size(); ++i)
+		loadMenuDisplayValue(menu, kMenuWeekdays[i].key, config.weekdayLabels[i]);
+	loadMenuDisplayValue(menu, "Exit", config.quickTipsExitLabel);
+	loadMenuDisplayValue(menu, "next", config.quickTipsNextLabel);
+	loadMenuDisplayValue(menu, "show_tips_on", config.quickTipsOnLabel);
+	loadMenuDisplayValue(menu, "show_tips_off", config.quickTipsOffLabel);
+	loadMenuDisplayValue(menu, "quick_tips_header", config.quickTipsHeader);
+
+	return true;
+}
+
+Common::String buildUseItemPrompt(const MenuTextConfig &config,
+		const Common::String &itemLabel, const Common::String &targetLabel) {
+	if (itemLabel.empty())
+		return Common::String();
+	if (targetLabel.empty())
+		return Common::String::format("%s %s %s",
+			config.useVerb.c_str(), itemLabel.c_str(), config.useOnPreposition.c_str());
+
+	return Common::String::format("%s %s %s %s", config.useVerb.c_str(), itemLabel.c_str(),
+		config.useOnPreposition.c_str(), targetLabel.c_str());
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/menu_render.cpp b/engines/harvester/menu_render.cpp
new file mode 100644
index 00000000000..4de241de250
--- /dev/null
+++ b/engines/harvester/menu_render.cpp
@@ -0,0 +1,476 @@
+/* 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/menu_render.h"
+
+#include "common/algorithm.h"
+#include "graphics/fontman.h"
+#include "harvester/cft_font.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/palette_utils.h"
+#include "harvester/script.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+namespace MenuRendering {
+
+static const int kInventoryX = 64;
+static const int kInventoryY = 48;
+static const int kLogoX = 160;
+static const int kLogoY = 0;
+const int kMenuStartY = 100;
+const int kMenuLineSpacing = 28;
+const byte kTextColorNormal = 255;
+static const byte kTextColorHover = 251;
+static const byte kTextColorDim = 248;
+static const byte kShadowColor = 0;
+static const byte kPanelFillColor = 1;
+static const byte kQuickTipActionColor = 0xc3;
+const int kOptionsSliderMinX = 0x16f;
+const int kOptionsSliderStep = 0x13;
+static const int kOptionsVolumeBitmapX = 0x168;
+static const int kOptionsVolumeBitmapY = 0x68;
+const int kOptionsIndicatorY = 0x6c;
+static const int kQuickTipsOverlayX = 167;
+static const int kQuickTipsOverlayY = 200;
+static const int kQuickTipsHeaderY = 202;
+static const int kQuickTipTextX = 180;
+static const int kQuickTipTextY = 228;
+static const int kEnglishQuickTipTextWidth = 280;
+static const int kLocalizedQuickTipTextWidth = 300;
+static const int kLocalizedQuickTipActionY = 308;
+static const int kQuickTipExitX = 180;
+static const int kQuickTipToggleX = 258;
+static const int kQuickTipNextRightInset = 8;
+static const int kConfirmDialogX = 167;
+static const int kConfirmDialogY = 200;
+static const int kConfirmPromptTextX = 0xea;
+static const int kConfirmPromptTextY = 0xd6;
+static const int kConfirmChoiceTextY = 0xf8;
+static const int kConfirmYesTextX = 0xdc;
+static const int kConfirmNoTextX = 0x17c;
+static const int kPasswordEntryX = 0xdc;
+static const int kPasswordEntryY = 0xdc;
+const int kPasswordEntryWidth = 0x226;
+
+void applyMenuPalette(Graphics::Screen &screen, const HarvesterEngine &engine,
+		const byte *palette, float brightness) {
+	if (!palette)
+		return;
+
+	byte displayPalette[256 * 3];
+	buildHarvesterDisplayPalette(palette, brightness * engine.getGammaBrightnessScale(), displayPalette);
+	screen.setPalette(displayPalette);
+}
+
+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();
+}
+
+void renderHelpScreen(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);
+	blitBitmap(*screen, bitmap, 0, 0);
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+static Common::String buildTextModeSuffix(const Script &script, const MenuTextConfig &config) {
+	switch (script.getDialogueTextMode()) {
+	case kStartupDialogueTextNone:
+		return Common::String::format(" - %s", config.noLabel.c_str());
+	case kStartupDialogueTextClick:
+		return Common::String::format(" - %s", config.clickLabel.c_str());
+	case kStartupDialogueTextYes:
+	default:
+		return Common::String::format(" - %s", config.yesLabel.c_str());
+	}
+}
+
+static Common::String buildOptionsMenuItemLabel(const Script &script,
+		const MenuTextConfig &config, int index) {
+	if (index < 0 || index >= static_cast<int>(config.optionItems.size()))
+		return Common::String();
+
+	switch (index) {
+	case 3:
+		return config.optionItems[index] + buildTextModeSuffix(script, config);
+	case 4:
+		return Common::String::format("%s - %s", config.optionItems[index].c_str(),
+			(script.isGoreEnabled() ? config.onLabel : config.offLabel).c_str());
+	case 6:
+		return Common::String::format("%s - %s", config.optionItems[index].c_str(),
+			(script.getParentalPassword().empty() ? config.offLabel : config.onLabel).c_str());
+	default:
+		return config.optionItems[index];
+	}
+}
+
+void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
+}
+
+void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &text, const TextLayout &layout, byte color, Graphics::TextAlign align) {
+	const int x = layout.x;
+	const int y = layout.y;
+	const int width = layout.width;
+
+	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
+	font.drawString(&screen, text, x, y, width, color, align);
+}
+
+static void drawSinglePassString(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &text, int x, int y) {
+	if (text.empty())
+		return;
+
+	font.drawString(&screen, text, x, y, font.getStringWidth(text), 0);
+}
+
+static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &text, const TextLayout &layout, byte color) {
+	const int x = layout.x;
+	const int y = layout.y;
+	const int width = layout.width;
+
+	Common::Array<Common::String> lines;
+	font.wordWrapText(text, width, lines);
+	for (uint i = 0; i < lines.size(); ++i)
+		drawShadowedString(screen, font, lines[i],
+			TextLayout(x, y + static_cast<int>(i) * (font.getFontHeight() + 2), width), color);
+}
+
+static void splitMenuConfigLines(const Common::String &text, Common::Array<Common::String> &lines) {
+	lines.clear();
+	Common::String currentLine;
+	for (uint i = 0; i < text.size(); ++i) {
+		if (text[i] == '/') {
+			lines.push_back(Common::move(currentLine));
+			currentLine.clear();
+			continue;
+		}
+		currentLine += text[i];
+	}
+	lines.push_back(Common::move(currentLine));
+}
+
+void Renderer::renderOptionsMenu(const FontPair &fonts, const Art &art,
+		const MenuTextConfig &config, const SliderBitmaps &sliders, int selectedItem, bool drawCursor) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	Script *script = _engine.getScript();
+	if (!screen || !script)
+		return;
+
+	applyMenuPalette(*screen, _engine, _palette, _paletteBrightness);
+	blitBitmap(*screen, _backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art.getLogoBitmap(), kLogoX, kLogoY);
+
+	const int lineHeight = getNativeRoomMenuLineHeight(fonts.primary);
+	for (int i = 0; i < static_cast<int>(config.optionItems.size()); ++i) {
+		const Common::String label = buildOptionsMenuItemLabel(*script, config, i);
+		const Graphics::Font &font = (i == selectedItem) ? fonts.primary : fonts.secondary;
+		const int width = font.getStringWidth(label);
+		const int x = (screen->w - width) / 2;
+		const int y = kMenuStartY + i * lineHeight;
+		font.drawString(screen, label, x, y, width, 0);
+	}
+
+	// Native run_main_menu spawns the option text entities before adding the
+	// VOLUME/INDICATR bitmap entities, so the bars clip the trailing overlap on
+	// the first three rows instead of the text painting over them.
+	for (int i = 0; i < 3; ++i)
+		blitTransparentBitmap(*screen, sliders.bar, kOptionsVolumeBitmapX, kOptionsVolumeBitmapY + i * lineHeight);
+
+	const int levels[3] = {
+		script->getFxVolumeLevel(),
+		script->getMusicVolumeLevel(),
+		script->getGammaLevel()
+	};
+	for (int i = 0; i < 3; ++i) {
+		blitTransparentBitmap(*screen, sliders.indicator, kOptionsSliderMinX + levels[i] * kOptionsSliderStep,
+			kOptionsIndicatorY + i * lineHeight);
+	}
+
+	if (drawCursor && _engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+void Renderer::renderPasswordPrompt(const FontPair &fonts, const Art &art,
+		const Common::String &title, const Common::String &text, bool cursorVisible, bool drawLogo) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen)
+		return;
+
+	if (_palette)
+		applyMenuPalette(*screen, _engine, _palette, _paletteBrightness);
+	blitBitmap(*screen, _backdrop, 0, 0);
+	if (drawLogo)
+		blitTransparentBitmap(*screen, art.getLogoBitmap(), kLogoX, kLogoY);
+
+	const int titleWidth = fonts.primary.getStringWidth(title);
+	const int titleX = (screen->w - titleWidth) / 2;
+	fonts.primary.drawString(screen, title, titleX, 0xa0, titleWidth, 0);
+
+	Common::String displayText = text;
+	if (cursorVisible)
+		displayText += '~';
+	fonts.secondary.drawString(screen, displayText, kPasswordEntryX, kPasswordEntryY,
+		kPasswordEntryWidth, 0);
+
+	screen->makeAllDirty();
+	screen->update();
+}
+
+void Renderer::renderQuickTipsOverlay(const MenuTextConfig &config,
+		const QuickTipsLayout &layout, const Common::String &tipText) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	const Art *art = _engine.getArt();
+	if (!screen || !art)
+		return;
+
+	applyMenuPalette(*screen, _engine, _palette, _paletteBrightness);
+	blitBitmap(*screen, _backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+	drawQuickTipsPanel(_engine, config, layout, tipText);
+
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+void Renderer::renderConfirmPrompt(const Graphics::Font &promptFont,
+		const Graphics::Font &yesFont, const Graphics::Font &noFont,
+		const IndexedBitmap &textbox, const Common::String &promptText, const MenuTextConfig &config) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	const Art *art = _engine.getArt();
+	if (!screen || !art)
+		return;
+
+	applyMenuPalette(*screen, _engine, _palette, _paletteBrightness);
+	blitBitmap(*screen, _backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+	blitTransparentBitmap(*screen, textbox, kConfirmDialogX, kConfirmDialogY);
+	Common::Array<Common::String> promptLines;
+	splitMenuConfigLines(promptText, promptLines);
+	for (uint i = 0; i < promptLines.size(); ++i) {
+		drawSinglePassString(*screen, promptFont, promptLines[i], kConfirmPromptTextX,
+			kConfirmPromptTextY + static_cast<int>(i) * (promptFont.getFontHeight() + 2));
+	}
+	drawSinglePassString(*screen, yesFont, config.yesLabel, kConfirmYesTextX, kConfirmChoiceTextY);
+	drawSinglePassString(*screen, noFont, config.noLabel, kConfirmNoTextX, kConfirmChoiceTextY);
+
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
+	return selectedFont.getFontHeight() + 2;
+}
+
+} // End of namespace MenuRendering
+
+bool resolveQuickTipsLayout(HarvesterEngine &engine, const MenuTextConfig &config,
+		QuickTipsLayout &layout) {
+	using namespace MenuRendering;
+
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	const Art *art = engine.getArt();
+	const Script *script = engine.getScript();
+	if (!font || !art || !script)
+		return false;
+
+	if (!config.hasQuickTipsHeader()) {
+		layout.exitRect = Common::Rect(180, 280, 238, 291);
+		layout.nextRect = Common::Rect(420, 280, 492, 291);
+		layout.toggleRect = Common::Rect(258, 280, 366, 291);
+		return true;
+	}
+
+	const IndexedBitmap *panel = art->getQuickTipsTextboxBitmap();
+	if (!panel || !panel->isValid())
+		return false;
+
+	const Common::String &toggleLabel = script->isQuickTipsEnabled()
+		? config.quickTipsOnLabel : config.quickTipsOffLabel;
+	auto makeLabelRect = [font](const Common::String &label, int x) {
+		const int width = MAX<int>(1, font->getStringWidth(label));
+		const int height = MAX<int>(1, font->getFontHeight());
+		return Common::Rect(x, kLocalizedQuickTipActionY,
+			x + width, kLocalizedQuickTipActionY + height);
+	};
+
+	layout.exitRect = makeLabelRect(config.quickTipsExitLabel, kQuickTipExitX);
+	layout.toggleRect = makeLabelRect(toggleLabel, kQuickTipToggleX);
+	const int nextX = kQuickTipsOverlayX + static_cast<int>(panel->width)
+		- font->getStringWidth(config.quickTipsNextLabel) - kQuickTipNextRightInset;
+	layout.nextRect = makeLabelRect(config.quickTipsNextLabel, nextX);
+	return true;
+}
+
+void drawQuickTipsPanel(HarvesterEngine &engine, const MenuTextConfig &config,
+		const QuickTipsLayout &layout, const Common::String &tipText) {
+	using namespace MenuRendering;
+
+	Graphics::Screen *screen = engine.getScreen();
+	const Art *art = engine.getArt();
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	const Script *script = engine.getScript();
+	if (!screen || !art || !font || !script)
+		return;
+
+	const IndexedBitmap *panel = config.hasQuickTipsHeader()
+		? art->getQuickTipsTextboxBitmap() : &art->getTipsBitmap();
+	if (!panel || !panel->isValid())
+		return;
+
+	blitBitmap(*screen, *panel, kQuickTipsOverlayX, kQuickTipsOverlayY);
+	if (config.hasQuickTipsHeader()) {
+		drawShadowedString(*screen, *font, config.quickTipsHeader,
+			TextLayout(kQuickTipsOverlayX, kQuickTipsHeaderY, panel->width),
+			kQuickTipActionColor, Graphics::kTextAlignCenter);
+	}
+
+	const int tipTextWidth = config.hasQuickTipsHeader()
+		? kLocalizedQuickTipTextWidth : kEnglishQuickTipTextWidth;
+	drawWrappedShadowedText(*screen, *font, tipText,
+		TextLayout(kQuickTipTextX, kQuickTipTextY, tipTextWidth), kTextColorNormal);
+	const Common::String &toggleLabel = script->isQuickTipsEnabled()
+		? config.quickTipsOnLabel : config.quickTipsOffLabel;
+	drawShadowedString(*screen, *font, config.quickTipsExitLabel,
+		TextLayout(layout.exitRect.left, layout.exitRect.top, layout.exitRect.width()), kQuickTipActionColor);
+	drawShadowedString(*screen, *font, config.quickTipsNextLabel,
+		TextLayout(layout.nextRect.left, layout.nextRect.top, layout.nextRect.width()), kQuickTipActionColor);
+	drawShadowedString(*screen, *font, toggleLabel,
+		TextLayout(layout.toggleRect.left, layout.toggleRect.top, layout.toggleRect.width()), kQuickTipActionColor);
+}
+
+void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
+		bool drawCursor, const Common::Array<Common::String> &menuItems) const {
+	using namespace MenuRendering;
+
+	Graphics::Screen *screen = _engine.getScreen();
+	const Art *art = _engine.getArt();
+	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
+	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !art || !titleFont || !bodyFont)
+		return;
+
+	byte displayPalette[256 * 3];
+	buildHarvesterDisplayPalette(art->getWaitPalette(), 1.0f, displayPalette);
+	screen->setPalette(displayPalette);
+	screen->fillRect(screen->getBounds(), 0);
+	blitBitmap(*screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
+	blitBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+
+	const Common::Rect panel(96, 96, 544, 432);
+	screen->fillRect(panel, kPanelFillColor);
+
+	drawShadowedString(*screen, *titleFont, "Harvester", TextLayout(panel.left, 110, panel.width()),
+		kTextColorNormal, Graphics::kTextAlignCenter);
+
+	for (uint i = 0; i < menuItems.size(); ++i) {
+		const int itemY = kMenuStartY + static_cast<int>(i) * kMenuLineSpacing;
+		const byte color = (static_cast<int>(i) == selectedItem) ? kTextColorHover : kTextColorNormal;
+		drawShadowedString(*screen, *bodyFont, menuItems[i],
+			TextLayout(panel.left, itemY, panel.width()), color, Graphics::kTextAlignCenter);
+	}
+
+	if (!statusMessage.empty()) {
+		drawWrappedShadowedText(*screen, *bodyFont, statusMessage,
+			TextLayout(panel.left + 24, 352, panel.width() - 48), kTextColorNormal);
+	}
+	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Enter activates. Esc returns to launcher.",
+		TextLayout(panel.left, 404, panel.width()), kTextColorDim, Graphics::kTextAlignCenter);
+
+	if (drawCursor && _engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+void MenuSystem::renderBackdropMenuScreen(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const Common::Array<Common::String> &menuItems,
+		int selectedItem) const {
+	using namespace MenuRendering;
+
+	Graphics::Screen *screen = _engine.getScreen();
+	const Art *art = _engine.getArt();
+	const Text *text = _engine.getText();
+	const CftFontResource *selectedFontResource = text ? text->findFont("HARVFONT") : nullptr;
+	const CftFontResource *unselectedFontResource = text ? text->findFont("HARVFNT2") : nullptr;
+	if (!screen || !art || !selectedFontResource || !unselectedFontResource || !backdrop.isValid())
+		return;
+
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	HarvesterCftFont unselectedFont(*unselectedFontResource);
+	if (!selectedFont.isValid() || !unselectedFont.isValid())
+		return;
+
+	applyMenuPalette(*screen, _engine, palette, paletteBrightness);
+	blitBitmap(*screen, backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+
+	const int lineSpacing = getNativeRoomMenuLineHeight(selectedFont);
+	for (uint i = 0; i < menuItems.size(); ++i) {
+		const Graphics::Font *font = (static_cast<int>(i) == selectedItem)
+			? static_cast<const Graphics::Font *>(&selectedFont)
+			: static_cast<const Graphics::Font *>(&unselectedFont);
+		const int width = font->getStringWidth(menuItems[i]);
+		const int x = (screen->w - width) / 2;
+		const int y = kMenuStartY + static_cast<int>(i) * lineSpacing;
+		font->drawString(screen, menuItems[i], x, y, width, 0);
+	}
+
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/menu_render.h b/engines/harvester/menu_render.h
new file mode 100644
index 00000000000..b64ab5280af
--- /dev/null
+++ b/engines/harvester/menu_render.h
@@ -0,0 +1,104 @@
+/* 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_MENU_RENDER_H
+#define HARVESTER_MENU_RENDER_H
+
+#include "graphics/font.h"
+#include "harvester/menu.h"
+
+namespace Graphics {
+class Screen;
+}
+
+namespace Harvester {
+namespace MenuRendering {
+
+extern const int kMenuStartY;
+extern const int kMenuLineSpacing;
+extern const byte kTextColorNormal;
+extern const int kOptionsSliderMinX;
+extern const int kOptionsSliderStep;
+extern const int kOptionsIndicatorY;
+extern const int kPasswordEntryWidth;
+
+struct TextLayout {
+	TextLayout(int x, int y, int width) : x(x), y(y), width(width) {}
+
+	int x;
+	int y;
+	int width;
+};
+
+struct FontPair {
+	FontPair(const Graphics::Font &primary, const Graphics::Font &secondary)
+		: primary(primary), secondary(secondary) {}
+
+	const Graphics::Font &primary;
+	const Graphics::Font &secondary;
+};
+
+struct SliderBitmaps {
+	SliderBitmaps(const IndexedBitmap &bar, const IndexedBitmap &indicator)
+		: bar(bar), indicator(indicator) {}
+
+	const IndexedBitmap &bar;
+	const IndexedBitmap &indicator;
+};
+
+// Drawing calls borrow their backdrop, palette, fonts, and bitmaps synchronously.
+class Renderer {
+public:
+	Renderer(HarvesterEngine &engine, const IndexedBitmap &backdrop,
+		const byte *palette, float paletteBrightness)
+		: _engine(engine), _backdrop(backdrop), _palette(palette), _paletteBrightness(paletteBrightness) {}
+
+	void renderOptionsMenu(const FontPair &fonts, const Art &art, const MenuTextConfig &config,
+		const SliderBitmaps &sliders, int selectedItem, bool drawCursor = true) const;
+	void renderPasswordPrompt(const FontPair &fonts, const Art &art, const Common::String &title,
+		const Common::String &text, bool cursorVisible, bool drawLogo) const;
+	void renderQuickTipsOverlay(const MenuTextConfig &config, const QuickTipsLayout &layout,
+		const Common::String &tipText) const;
+	void renderConfirmPrompt(const Graphics::Font &promptFont,
+		const Graphics::Font &yesFont, const Graphics::Font &noFont,
+		const IndexedBitmap &textbox, const Common::String &promptText, const MenuTextConfig &config) const;
+
+private:
+	HarvesterEngine &_engine;
+	const IndexedBitmap &_backdrop;
+	const byte *_palette;
+	float _paletteBrightness;
+};
+
+void applyMenuPalette(Graphics::Screen &screen, const HarvesterEngine &engine,
+	const byte *palette, float brightness);
+void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y);
+void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font,
+	const Common::String &text, const TextLayout &layout, byte color,
+	Graphics::TextAlign align = Graphics::kTextAlignLeft);
+void renderCdChangePromptScreen(HarvesterEngine &engine, const IndexedBitmap &bitmap, const byte *palette);
+void renderHelpScreen(HarvesterEngine &engine, const IndexedBitmap &bitmap, const byte *palette);
+int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont);
+
+} // End of namespace MenuRendering
+} // End of namespace Harvester
+
+#endif // HARVESTER_MENU_RENDER_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index a48c049f33f..6c630d2bb7e 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -13,6 +13,8 @@ MODULE_OBJS = \
 	inventory.o \
 	media_manager.o \
 	menu.o \
+	menu_config.o \
+	menu_render.o \
 	metaengine.o \
 	monster.o \
 	player.o \


Commit: eb8f943defd471fb1732568ee424affbe62da5dd
    https://github.com/scummvm/scummvm/commit/eb8f943defd471fb1732568ee424affbe62da5dd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:01-04:00

Commit Message:
HARVESTER: Separate options menu actions and event handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/menu_options.cpp
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/module.mk


diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 1b3328d345b..15de0f22985 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -46,9 +46,6 @@ namespace Harvester {
 
 namespace {
 
-static const char *const kOptionsVolumeBitmapPath = "1:/GRAPHIC/OTHER/VOLUME.BM";
-static const char *const kOptionsIndicatorBitmapPath = "1:/GRAPHIC/OTHER/INDICATR.BM";
-static const char *const kOptionsPreviewSoundPath = "1:/SOUND/EFFECTS/WHIP2.WAV";
 static const char *const kLoadGameBitmapPath = "1:/GRAPHIC/OTHER/LOADGAME.BM";
 static const char *const kLoadGamePalettePath = "1:/GRAPHIC/PAL/LOADGAME.PAL";
 static const char *const kSaveGameBitmapPath = "1:/GRAPHIC/OTHER/SAVEGAME.BM";
@@ -56,9 +53,6 @@ static const char *const kSaveGamePalettePath = "1:/GRAPHIC/PAL/SAVEGAME.PAL";
 static const char *const kGameOverBitmapPath = "1:/GRAPHIC/OTHER/GAMEOVER.BM";
 static const char *const kGameOverPalettePath = "1:/GRAPHIC/PAL/GAMEOVER.PAL";
 static const char *const kGameOverMusicPath = "SOUND/MUSIC/ANXIETY.CMP";
-static const int kOptionsSliderMaxX = 0x22b;
-static const int kOptionsSliderHeight = 0x1e;
-static const int kStartupOptionMaxLevel = 9;
 static const int kSaveSlotCount = 25;
 static const int kSaveSlotLabelX = 8;
 static const int kSaveSlotNameX = 0x50;
@@ -107,15 +101,6 @@ private:
 	bool _paused = false;
 };
 
-static int clampStartupOptionLevel(int level) {
-	if (level < 0)
-		return 0;
-	if (level > kStartupOptionMaxLevel)
-		return kStartupOptionMaxLevel;
-
-	return level;
-}
-
 static Common::Rect saveSlotRect(int slotIndex) {
 	const int top = kSaveSlotStartY + slotIndex * kSaveSlotStride;
 	return Common::Rect(8, top, 0x27b + 1, top + kSaveSlotStride);
@@ -137,17 +122,6 @@ static Common::Rect quitConfirmNoRect() {
 	return Common::Rect(0x172, 0xee, 0x19a + 1, 0x108 + 1);
 }
 
-static Common::Rect optionsSliderRect(int sliderIndex, int lineHeight) {
-	const int y = MenuRendering::kOptionsIndicatorY + sliderIndex * lineHeight;
-	return Common::Rect(MenuRendering::kOptionsSliderMinX, y, kOptionsSliderMaxX + 1, y + kOptionsSliderHeight + 1);
-}
-
-static int resolveOptionsSliderIndexFromMouseX(int mouseX) {
-	const int relativeX = mouseX - MenuRendering::kOptionsSliderMinX;
-	return clampStartupOptionLevel(
-		(relativeX + (MenuRendering::kOptionsSliderStep / 2)) / MenuRendering::kOptionsSliderStep);
-}
-
 } // End of anonymous namespace
 
 namespace {
@@ -170,14 +144,6 @@ static void buildDisplayMainMenuItems(const Common::Array<Common::String> &sourc
 		dest[2] = kBlankMenuSlot;
 }
 
-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;
-
-	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)
@@ -191,7 +157,17 @@ static Common::String buildDefaultSaveSlotLabel(int slotIndex) {
 	return Common::String::format("GAME %02d", slotIndex + 1);
 }
 
-static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
+} // End of anonymous namespace
+
+bool MenuSystem::loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12)
+		return false;
+
+	return decodeBitmap(data, bitmap);
+}
+
+int MenuSystem::getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
 		const Common::Point &mousePos) {
 	if (itemCount == 0)
 		return -1;
@@ -201,14 +177,12 @@ static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFon
 		selection = 1;
 
 	selection /= MAX<int>(1, MenuRendering::getNativeRoomMenuLineHeight(selectedFont));
-	if (selection >= (int)itemCount)
-		selection = (int)itemCount - 1;
+	if (selection >= static_cast<int>(itemCount))
+		selection = static_cast<int>(itemCount) - 1;
 
 	return selection;
 }
 
-} // End of anonymous namespace
-
 MenuSystem::MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		const Common::Array<Common::String> &menuItems)
 	: _engine(engine), _mousePos(mousePos), _menuItems(menuItems) {
@@ -1328,344 +1302,6 @@ Common::Error MenuSystem::validateParentalPassword(Flow &flow) {
 	return Common::kNoError;
 }
 
-Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &flow) {
-	const Art *art = _engine.getArt();
-	Script *script = _engine.getScript();
-	ResourceManager *resources = _engine.getResources();
-	const Text *text = _engine.getText();
-	const CftFontResource *selectedFontResource = text ? text->findFont("HARVFONT") : nullptr;
-	const CftFontResource *unselectedFontResource = text ? text->findFont("HARVFNT2") : nullptr;
-	if (!art || !script || !resources || !selectedFontResource || !unselectedFontResource)
-		return Common::kReadingFailed;
-
-	HarvesterCftFont selectedFont(*selectedFontResource);
-	HarvesterCftFont unselectedFont(*unselectedFontResource);
-	if (!selectedFont.isValid() || !unselectedFont.isValid())
-		return Common::kReadingFailed;
-
-	const MenuTextConfig &config = flow.getMenuTextConfig();
-
-	IndexedBitmap volumeBar;
-	IndexedBitmap indicator;
-	if (!loadBitmapResource(*resources, kOptionsVolumeBitmapPath, volumeBar) ||
-			!loadBitmapResource(*resources, kOptionsIndicatorBitmapPath, indicator)) {
-		return Common::kReadingFailed;
-	}
-
-	const int lineHeight = MenuRendering::getNativeRoomMenuLineHeight(selectedFont);
-	int selectedItem = 0;
-	int capturedSlider = -1;
-	bool showingQuickTips = false;
-	bool needsRedraw = true;
-	uint quickTipIndex = flow.getQuickTipCount() == 0 ? 0 : _engine.getRandomNumber(flow.getQuickTipCount() - 1);
-	QuickTipsLayout quickTipsLayout;
-
-	auto persistConfig = [&]() -> bool {
-		return script->saveConfig();
-	};
-
-	auto updateSlider = [&](int sliderIndex) {
-		const int newLevel = resolveOptionsSliderIndexFromMouseX(_mousePos.x);
-		switch (sliderIndex) {
-		case 0:
-			if (newLevel != script->getFxVolumeLevel()) {
-				_engine.setFxVolumeLevel(newLevel);
-				_engine.playSingleSound(kOptionsPreviewSoundPath);
-				persistConfig();
-				needsRedraw = true;
-			}
-			break;
-		case 1:
-			if (newLevel != script->getMusicVolumeLevel()) {
-				_engine.setMusicVolumeLevel(newLevel);
-				persistConfig();
-				needsRedraw = true;
-			}
-			break;
-		case 2:
-			if (newLevel != script->getGammaLevel()) {
-				_engine.setGammaLevel(newLevel);
-				persistConfig();
-				needsRedraw = true;
-			}
-			break;
-		default:
-			break;
-		}
-	};
-
-	auto cycleTextMode = [&]() {
-		switch (script->getDialogueTextMode()) {
-		case kStartupDialogueTextNone:
-			script->setDialogueTextMode(kStartupDialogueTextYes);
-			break;
-		case kStartupDialogueTextYes:
-			script->setDialogueTextMode(kStartupDialogueTextClick);
-			break;
-		case kStartupDialogueTextClick:
-		default:
-			script->setDialogueTextMode(kStartupDialogueTextNone);
-			break;
-		}
-		persistConfig();
-		needsRedraw = true;
-	};
-
-	auto toggleGore = [&]() {
-		script->setGoreEnabled(!script->isGoreEnabled());
-		ConfMan.setBool("gore", script->isGoreEnabled());
-		persistConfig();
-		needsRedraw = true;
-	};
-
-	auto togglePassword = [&]() {
-		if (script->getParentalPassword().empty()) {
-			debugC(2, kDebugGeneral, "Harvester: parental password set prompt opened");
-			Common::String password;
-			bool accepted = false;
-			Common::Error promptError = runPasswordPrompt(
-				backdrop, palette, paletteBrightness, flow, true, password, accepted);
-			if (promptError.getCode() != Common::kNoError) {
-				debugC(1, kDebugGeneral,
-					"Harvester: parental password set prompt failed error=%d",
-					(int)promptError.getCode());
-				return;
-			}
-			if (accepted) {
-				script->setParentalPassword(password);
-				if (!persistConfig()) {
-					script->setParentalPassword(Common::String());
-					debugC(1, kDebugGeneral,
-						"Harvester: parental password enable failed");
-				} else {
-					debugC(1, kDebugGeneral,
-						"Harvester: parental password enabled");
-				}
-				needsRedraw = true;
-			} else {
-				debugC(2, kDebugGeneral,
-					"Harvester: parental password set prompt cancelled state=disabled");
-			}
-		} else {
-			const Common::String previousPassword = script->getParentalPassword();
-			script->setParentalPassword(Common::String());
-			if (!persistConfig()) {
-				script->setParentalPassword(previousPassword);
-				debugC(1, kDebugGeneral,
-					"Harvester: parental password disable failed");
-			} else {
-				debugC(1, kDebugGeneral,
-					"Harvester: parental password disabled");
-			}
-			needsRedraw = true;
-		}
-	};
-
-	auto activateSelectedItem = [&]() -> Common::Error {
-		switch (selectedItem) {
-		case 3:
-			cycleTextMode();
-			break;
-		case 4:
-			toggleGore();
-			break;
-		case 5:
-			if (flow.getQuickTipCount() != 0) {
-				if (!resolveQuickTipsLayout(_engine, config, quickTipsLayout))
-					return Common::kReadingFailed;
-				showingQuickTips = true;
-				needsRedraw = true;
-			}
-			break;
-		case 6:
-			togglePassword();
-			break;
-		default:
-			break;
-		}
-
-		return Common::kNoError;
-	};
-
-	Graphics::FrameLimiter limiter(g_system, 60);
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			if (showingQuickTips) {
-				MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderQuickTipsOverlay(
-					config, quickTipsLayout, flow.getQuickTip(quickTipIndex));
-			} else {
-				MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderOptionsMenu(
-					MenuRendering::FontPair(selectedFont, unselectedFont), *art, config,
-					MenuRendering::SliderBitmaps(volumeBar, indicator), selectedItem);
-			}
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (flow.handleSystemEvent(event, result))
-				return result;
-
-			if (showingQuickTips) {
-				switch (event.type) {
-				case Common::EVENT_MOUSEMOVE:
-					needsRedraw = true;
-					break;
-				case Common::EVENT_LBUTTONDOWN:
-					if (quickTipsLayout.exitRect.contains(_mousePos)) {
-						showingQuickTips = false;
-						needsRedraw = true;
-					} else if (quickTipsLayout.nextRect.contains(_mousePos)) {
-						quickTipIndex = (quickTipIndex + 1) % flow.getQuickTipCount();
-						needsRedraw = true;
-					} else if (quickTipsLayout.toggleRect.contains(_mousePos)) {
-						script->setQuickTipsEnabled(!script->isQuickTipsEnabled());
-						if (!resolveQuickTipsLayout(_engine, config, quickTipsLayout))
-							return Common::kReadingFailed;
-						persistConfig();
-						needsRedraw = true;
-					}
-					break;
-				case Common::EVENT_RBUTTONDOWN:
-					showingQuickTips = false;
-					needsRedraw = true;
-					break;
-				case Common::EVENT_KEYDOWN:
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						showingQuickTips = false;
-						needsRedraw = true;
-					}
-					break;
-				default:
-					break;
-				}
-				continue;
-			}
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE: {
-				if (capturedSlider != -1) {
-					selectedItem = capturedSlider;
-					updateSlider(capturedSlider);
-					needsRedraw = true;
-				} else {
-					const int hoveredItem = getNativeRoomMenuSelectionFromMouse(selectedFont,
-						config.optionItems.size(), _mousePos);
-					if (hoveredItem != -1 && hoveredItem != selectedItem) {
-						selectedItem = hoveredItem;
-						needsRedraw = true;
-					}
-				}
-				break;
-			}
-			case Common::EVENT_LBUTTONDOWN: {
-				bool handled = false;
-				for (int sliderIndex = 0; sliderIndex < 3; ++sliderIndex) {
-					if (optionsSliderRect(sliderIndex, lineHeight).contains(_mousePos)) {
-						selectedItem = sliderIndex;
-						capturedSlider = sliderIndex;
-						updateSlider(sliderIndex);
-						needsRedraw = true;
-						handled = true;
-						break;
-					}
-				}
-				if (handled)
-					break;
-
-				const int clickedItem = getNativeRoomMenuSelectionFromMouse(selectedFont,
-					config.optionItems.size(), _mousePos);
-				if (clickedItem == -1)
-					break;
-
-				selectedItem = clickedItem;
-				needsRedraw = true;
-				if (selectedItem >= 3) {
-					Common::Error activateError = activateSelectedItem();
-					if (activateError.getCode() != Common::kNoError)
-						return activateError;
-				}
-				break;
-			}
-			case Common::EVENT_LBUTTONUP:
-				capturedSlider = -1;
-				break;
-			case Common::EVENT_RBUTTONDOWN:
-				return Common::kNoError;
-			case Common::EVENT_KEYDOWN:
-				switch (event.kbd.keycode) {
-				case Common::KEYCODE_ESCAPE:
-					return Common::kNoError;
-				case Common::KEYCODE_UP:
-					selectedItem = (selectedItem + (int)config.optionItems.size() - 1) % (int)config.optionItems.size();
-					needsRedraw = true;
-					break;
-				case Common::KEYCODE_DOWN:
-					selectedItem = (selectedItem + 1) % (int)config.optionItems.size();
-					needsRedraw = true;
-					break;
-				case Common::KEYCODE_LEFT:
-					if (selectedItem < 3) {
-						_mousePos.x = MAX<int>(MenuRendering::kOptionsSliderMinX, _mousePos.x - MenuRendering::kOptionsSliderStep);
-						updateSlider(selectedItem);
-						needsRedraw = true;
-					}
-					break;
-				case Common::KEYCODE_RIGHT:
-					if (selectedItem < 3) {
-						_mousePos.x = MIN<int>(kOptionsSliderMaxX, _mousePos.x + MenuRendering::kOptionsSliderStep);
-						updateSlider(selectedItem);
-						needsRedraw = true;
-					}
-					break;
-				case Common::KEYCODE_RETURN:
-				case Common::KEYCODE_KP_ENTER: {
-					Common::Error activateError = activateSelectedItem();
-					if (activateError.getCode() != Common::kNoError)
-						return activateError;
-					break;
-				}
-				case Common::KEYCODE_t:
-					selectedItem = 3;
-					cycleTextMode();
-					break;
-				case Common::KEYCODE_g:
-					selectedItem = 4;
-					toggleGore();
-					break;
-				case Common::KEYCODE_q:
-					selectedItem = 5;
-					if (flow.getQuickTipCount() != 0) {
-						showingQuickTips = true;
-						needsRedraw = true;
-					}
-					break;
-				case Common::KEYCODE_p:
-					selectedItem = 6;
-					togglePassword();
-					break;
-				default:
-					break;
-				}
-				break;
-			default:
-				break;
-			}
-		}
-
-		EntityManager *entityManager = _engine.getRuntimeEntities();
-		if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
 Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBrightness, Flow &flow) {
 	ResourceManager *resources = _engine.getResources();
 	if (!resources)
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index b211ea9e6e3..20a3a401a01 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -100,6 +100,23 @@ public:
 		Flow &flow, bool &promptCompleted);
 
 private:
+	static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap);
+	static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
+		const Common::Point &mousePos);
+
+	struct OptionsMenu;
+	void updateOptionsSlider(OptionsMenu &menu, int sliderIndex);
+	void cycleOptionsTextMode(OptionsMenu &menu);
+	void toggleOptionsGore(OptionsMenu &menu);
+	void toggleOptionsPassword(OptionsMenu &menu);
+	Common::Error activateOptionsItem(OptionsMenu &menu);
+	Common::Error handleOptionsQuickTipsEvent(OptionsMenu &menu, const Common::Event &event);
+	void handleOptionsMouseMove(OptionsMenu &menu, const Graphics::Font &selectedFont);
+	Common::Error handleOptionsMouseDown(OptionsMenu &menu, const Graphics::Font &selectedFont);
+	Common::Error handleOptionsKey(OptionsMenu &menu, const Common::Event &event, bool &closed);
+	Common::Error handleOptionsEvent(OptionsMenu &menu, const Common::Event &event,
+		const Graphics::Font &selectedFont, bool &closed);
+
 	struct SaveSlotMenu;
 	enum SaveLoadMode { kSaveMode, kLoadMode };
 	enum SaveSlotCommand { kNoSlotCommand, kConfirmSlot, kCancelSlot };
diff --git a/engines/harvester/menu_options.cpp b/engines/harvester/menu_options.cpp
new file mode 100644
index 00000000000..14069aa293b
--- /dev/null
+++ b/engines/harvester/menu_options.cpp
@@ -0,0 +1,443 @@
+/* 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/menu.h"
+#include "harvester/menu_render.h"
+
+#include "common/algorithm.h"
+#include "common/config-manager.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/flow.h"
+#include "harvester/harvester.h"
+#include "harvester/script.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+static const char *const kOptionsVolumeBitmapPath = "1:/GRAPHIC/OTHER/VOLUME.BM";
+static const char *const kOptionsIndicatorBitmapPath = "1:/GRAPHIC/OTHER/INDICATR.BM";
+static const char *const kOptionsPreviewSoundPath = "1:/SOUND/EFFECTS/WHIP2.WAV";
+static const int kOptionsSliderMaxX = 0x22b;
+static const int kOptionsSliderHeight = 0x1e;
+static const int kStartupOptionMaxLevel = 9;
+
+static int clampStartupOptionLevel(int level) {
+	if (level < 0)
+		return 0;
+	if (level > kStartupOptionMaxLevel)
+		return kStartupOptionMaxLevel;
+
+	return level;
+}
+
+static Common::Rect optionsSliderRect(int sliderIndex, int lineHeight) {
+	const int y = MenuRendering::kOptionsIndicatorY + sliderIndex * lineHeight;
+	return Common::Rect(MenuRendering::kOptionsSliderMinX, y, kOptionsSliderMaxX + 1, y + kOptionsSliderHeight + 1);
+}
+
+static int resolveOptionsSliderIndexFromMouseX(int mouseX) {
+	const int relativeX = mouseX - MenuRendering::kOptionsSliderMinX;
+	return clampStartupOptionLevel(
+		(relativeX + (MenuRendering::kOptionsSliderStep / 2)) / MenuRendering::kOptionsSliderStep);
+}
+
+struct MenuSystem::OptionsMenu {
+	OptionsMenu(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness,
+		Flow &flow, Script &script, const MenuTextConfig &config)
+		: backdrop(backdrop), palette(palette), paletteBrightness(paletteBrightness),
+		  flow(flow), script(script), config(config) {}
+
+	const IndexedBitmap &backdrop;
+	const byte *palette;
+	float paletteBrightness;
+	Flow &flow;
+	Script &script;
+	const MenuTextConfig &config;
+	int lineHeight = 0;
+	int selectedItem = 0;
+	int capturedSlider = -1;
+	bool showingQuickTips = false;
+	bool needsRedraw = true;
+	uint quickTipIndex = 0;
+	QuickTipsLayout quickTipsLayout;
+};
+
+void MenuSystem::updateOptionsSlider(OptionsMenu &menu, int sliderIndex) {
+	const int newLevel = resolveOptionsSliderIndexFromMouseX(_mousePos.x);
+	switch (sliderIndex) {
+	case 0:
+		if (newLevel != menu.script.getFxVolumeLevel()) {
+			_engine.setFxVolumeLevel(newLevel);
+			_engine.playSingleSound(kOptionsPreviewSoundPath);
+			menu.script.saveConfig();
+			menu.needsRedraw = true;
+		}
+		break;
+	case 1:
+		if (newLevel != menu.script.getMusicVolumeLevel()) {
+			_engine.setMusicVolumeLevel(newLevel);
+			menu.script.saveConfig();
+			menu.needsRedraw = true;
+		}
+		break;
+	case 2:
+		if (newLevel != menu.script.getGammaLevel()) {
+			_engine.setGammaLevel(newLevel);
+			menu.script.saveConfig();
+			menu.needsRedraw = true;
+		}
+		break;
+	default:
+		break;
+	}
+}
+
+void MenuSystem::cycleOptionsTextMode(OptionsMenu &menu) {
+	switch (menu.script.getDialogueTextMode()) {
+	case kStartupDialogueTextNone:
+		menu.script.setDialogueTextMode(kStartupDialogueTextYes);
+		break;
+	case kStartupDialogueTextYes:
+		menu.script.setDialogueTextMode(kStartupDialogueTextClick);
+		break;
+	case kStartupDialogueTextClick:
+	default:
+		menu.script.setDialogueTextMode(kStartupDialogueTextNone);
+		break;
+	}
+	menu.script.saveConfig();
+	menu.needsRedraw = true;
+}
+
+void MenuSystem::toggleOptionsGore(OptionsMenu &menu) {
+	menu.script.setGoreEnabled(!menu.script.isGoreEnabled());
+	ConfMan.setBool("gore", menu.script.isGoreEnabled());
+	menu.script.saveConfig();
+	menu.needsRedraw = true;
+}
+
+void MenuSystem::toggleOptionsPassword(OptionsMenu &menu) {
+	if (menu.script.getParentalPassword().empty()) {
+		debugC(2, kDebugGeneral, "Harvester: parental password set prompt opened");
+		Common::String password;
+		bool accepted = false;
+		Common::Error promptError = runPasswordPrompt(
+			menu.backdrop, menu.palette, menu.paletteBrightness, menu.flow, true, password, accepted);
+		if (promptError.getCode() != Common::kNoError) {
+			debugC(1, kDebugGeneral,
+				"Harvester: parental password set prompt failed error=%d",
+				static_cast<int>(promptError.getCode()));
+			return;
+		}
+		if (accepted) {
+			menu.script.setParentalPassword(password);
+			if (!menu.script.saveConfig()) {
+				menu.script.setParentalPassword(Common::String());
+				debugC(1, kDebugGeneral,
+					"Harvester: parental password enable failed");
+			} else {
+				debugC(1, kDebugGeneral,
+					"Harvester: parental password enabled");
+			}
+			menu.needsRedraw = true;
+		} else {
+			debugC(2, kDebugGeneral,
+				"Harvester: parental password set prompt cancelled state=disabled");
+		}
+	} else {
+		const Common::String previousPassword = menu.script.getParentalPassword();
+		menu.script.setParentalPassword(Common::String());
+		if (!menu.script.saveConfig()) {
+			menu.script.setParentalPassword(previousPassword);
+			debugC(1, kDebugGeneral,
+				"Harvester: parental password disable failed");
+		} else {
+			debugC(1, kDebugGeneral,
+				"Harvester: parental password disabled");
+		}
+		menu.needsRedraw = true;
+	}
+}
+
+Common::Error MenuSystem::activateOptionsItem(OptionsMenu &menu) {
+	switch (menu.selectedItem) {
+	case 3:
+		cycleOptionsTextMode(menu);
+		break;
+	case 4:
+		toggleOptionsGore(menu);
+		break;
+	case 5:
+		if (menu.flow.getQuickTipCount() != 0) {
+			if (!resolveQuickTipsLayout(_engine, menu.config, menu.quickTipsLayout))
+				return Common::kReadingFailed;
+			menu.showingQuickTips = true;
+			menu.needsRedraw = true;
+		}
+		break;
+	case 6:
+		toggleOptionsPassword(menu);
+		break;
+	default:
+		break;
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::handleOptionsQuickTipsEvent(OptionsMenu &menu, const Common::Event &event) {
+	switch (event.type) {
+	case Common::EVENT_MOUSEMOVE:
+		menu.needsRedraw = true;
+		break;
+	case Common::EVENT_LBUTTONDOWN:
+		if (menu.quickTipsLayout.exitRect.contains(_mousePos)) {
+			menu.showingQuickTips = false;
+			menu.needsRedraw = true;
+		} else if (menu.quickTipsLayout.nextRect.contains(_mousePos)) {
+			menu.quickTipIndex = (menu.quickTipIndex + 1) % menu.flow.getQuickTipCount();
+			menu.needsRedraw = true;
+		} else if (menu.quickTipsLayout.toggleRect.contains(_mousePos)) {
+			menu.script.setQuickTipsEnabled(!menu.script.isQuickTipsEnabled());
+			if (!resolveQuickTipsLayout(_engine, menu.config, menu.quickTipsLayout))
+				return Common::kReadingFailed;
+			menu.script.saveConfig();
+			menu.needsRedraw = true;
+		}
+		break;
+	case Common::EVENT_RBUTTONDOWN:
+		menu.showingQuickTips = false;
+		menu.needsRedraw = true;
+		break;
+	case Common::EVENT_KEYDOWN:
+		if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+			menu.showingQuickTips = false;
+			menu.needsRedraw = true;
+		}
+		break;
+	default:
+		break;
+	}
+	return Common::kNoError;
+}
+
+void MenuSystem::handleOptionsMouseMove(OptionsMenu &menu, const Graphics::Font &selectedFont) {
+	if (menu.capturedSlider != -1) {
+		menu.selectedItem = menu.capturedSlider;
+		updateOptionsSlider(menu, menu.capturedSlider);
+		menu.needsRedraw = true;
+	} else {
+		const int hoveredItem = getNativeRoomMenuSelectionFromMouse(selectedFont,
+			menu.config.optionItems.size(), _mousePos);
+		if (hoveredItem != -1 && hoveredItem != menu.selectedItem) {
+			menu.selectedItem = hoveredItem;
+			menu.needsRedraw = true;
+		}
+	}
+}
+
+Common::Error MenuSystem::handleOptionsMouseDown(OptionsMenu &menu, const Graphics::Font &selectedFont) {
+	bool handled = false;
+	for (int sliderIndex = 0; sliderIndex < 3; ++sliderIndex) {
+		if (optionsSliderRect(sliderIndex, menu.lineHeight).contains(_mousePos)) {
+			menu.selectedItem = sliderIndex;
+			menu.capturedSlider = sliderIndex;
+			updateOptionsSlider(menu, sliderIndex);
+			menu.needsRedraw = true;
+			handled = true;
+			break;
+		}
+	}
+	if (handled)
+		return Common::kNoError;
+
+	const int clickedItem = getNativeRoomMenuSelectionFromMouse(selectedFont,
+		menu.config.optionItems.size(), _mousePos);
+	if (clickedItem == -1)
+		return Common::kNoError;
+
+	menu.selectedItem = clickedItem;
+	menu.needsRedraw = true;
+	if (menu.selectedItem >= 3) {
+		Common::Error activateError = activateOptionsItem(menu);
+		if (activateError.getCode() != Common::kNoError)
+			return activateError;
+	}
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::handleOptionsKey(OptionsMenu &menu, const Common::Event &event, bool &closed) {
+	switch (event.kbd.keycode) {
+	case Common::KEYCODE_ESCAPE:
+		closed = true;
+		return Common::kNoError;
+	case Common::KEYCODE_UP:
+		menu.selectedItem = (menu.selectedItem + static_cast<int>(menu.config.optionItems.size()) - 1)
+			% static_cast<int>(menu.config.optionItems.size());
+		menu.needsRedraw = true;
+		break;
+	case Common::KEYCODE_DOWN:
+		menu.selectedItem = (menu.selectedItem + 1) % static_cast<int>(menu.config.optionItems.size());
+		menu.needsRedraw = true;
+		break;
+	case Common::KEYCODE_LEFT:
+		if (menu.selectedItem < 3) {
+			_mousePos.x = MAX<int>(MenuRendering::kOptionsSliderMinX, _mousePos.x - MenuRendering::kOptionsSliderStep);
+			updateOptionsSlider(menu, menu.selectedItem);
+			menu.needsRedraw = true;
+		}
+		break;
+	case Common::KEYCODE_RIGHT:
+		if (menu.selectedItem < 3) {
+			_mousePos.x = MIN<int>(kOptionsSliderMaxX, _mousePos.x + MenuRendering::kOptionsSliderStep);
+			updateOptionsSlider(menu, menu.selectedItem);
+			menu.needsRedraw = true;
+		}
+		break;
+	case Common::KEYCODE_RETURN:
+	case Common::KEYCODE_KP_ENTER: {
+		Common::Error activateError = activateOptionsItem(menu);
+		if (activateError.getCode() != Common::kNoError)
+			return activateError;
+		break;
+	}
+	case Common::KEYCODE_t:
+		menu.selectedItem = 3;
+		cycleOptionsTextMode(menu);
+		break;
+	case Common::KEYCODE_g:
+		menu.selectedItem = 4;
+		toggleOptionsGore(menu);
+		break;
+	case Common::KEYCODE_q:
+		menu.selectedItem = 5;
+		if (menu.flow.getQuickTipCount() != 0) {
+			menu.showingQuickTips = true;
+			menu.needsRedraw = true;
+		}
+		break;
+	case Common::KEYCODE_p:
+		menu.selectedItem = 6;
+		toggleOptionsPassword(menu);
+		break;
+	default:
+		break;
+	}
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::handleOptionsEvent(OptionsMenu &menu, const Common::Event &event,
+		const Graphics::Font &selectedFont, bool &closed) {
+	if (menu.showingQuickTips)
+		return handleOptionsQuickTipsEvent(menu, event);
+
+	switch (event.type) {
+	case Common::EVENT_MOUSEMOVE:
+		handleOptionsMouseMove(menu, selectedFont);
+		break;
+	case Common::EVENT_LBUTTONDOWN:
+		return handleOptionsMouseDown(menu, selectedFont);
+	case Common::EVENT_LBUTTONUP:
+		menu.capturedSlider = -1;
+		break;
+	case Common::EVENT_RBUTTONDOWN:
+		closed = true;
+		return Common::kNoError;
+	case Common::EVENT_KEYDOWN:
+		return handleOptionsKey(menu, event, closed);
+	default:
+		break;
+	}
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &flow) {
+	const Art *art = _engine.getArt();
+	Script *script = _engine.getScript();
+	ResourceManager *resources = _engine.getResources();
+	const Text *text = _engine.getText();
+	const CftFontResource *selectedFontResource = text ? text->findFont("HARVFONT") : nullptr;
+	const CftFontResource *unselectedFontResource = text ? text->findFont("HARVFNT2") : nullptr;
+	if (!art || !script || !resources || !selectedFontResource || !unselectedFontResource)
+		return Common::kReadingFailed;
+
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	HarvesterCftFont unselectedFont(*unselectedFontResource);
+	if (!selectedFont.isValid() || !unselectedFont.isValid())
+		return Common::kReadingFailed;
+
+	const MenuTextConfig &config = flow.getMenuTextConfig();
+
+	IndexedBitmap volumeBar;
+	IndexedBitmap indicator;
+	if (!loadBitmapResource(*resources, kOptionsVolumeBitmapPath, volumeBar) ||
+			!loadBitmapResource(*resources, kOptionsIndicatorBitmapPath, indicator)) {
+		return Common::kReadingFailed;
+	}
+
+	OptionsMenu menu(backdrop, palette, paletteBrightness, flow, *script, config);
+	menu.lineHeight = MenuRendering::getNativeRoomMenuLineHeight(selectedFont);
+	menu.quickTipIndex = flow.getQuickTipCount() == 0 ? 0 : _engine.getRandomNumber(flow.getQuickTipCount() - 1);
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!_engine.shouldQuit()) {
+		if (menu.needsRedraw) {
+			if (menu.showingQuickTips) {
+				MenuRendering::Renderer(_engine, menu.backdrop, menu.palette, menu.paletteBrightness).renderQuickTipsOverlay(
+					menu.config, menu.quickTipsLayout, menu.flow.getQuickTip(menu.quickTipIndex));
+			} else {
+				MenuRendering::Renderer(_engine, menu.backdrop, menu.palette, menu.paletteBrightness).renderOptionsMenu(
+					MenuRendering::FontPair(selectedFont, unselectedFont), *art, menu.config,
+					MenuRendering::SliderBitmaps(volumeBar, indicator), menu.selectedItem);
+			}
+			menu.needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (menu.flow.handleSystemEvent(event, result))
+				return result;
+
+			bool closed = false;
+			Common::Error eventError = handleOptionsEvent(menu, event, selectedFont, closed);
+			if (eventError.getCode() != Common::kNoError || closed)
+				return eventError;
+		}
+
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+		if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
+			menu.needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 6c630d2bb7e..bd4adfe4416 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -14,6 +14,7 @@ MODULE_OBJS = \
 	media_manager.o \
 	menu.o \
 	menu_config.o \
+	menu_options.o \
 	menu_render.o \
 	metaengine.o \
 	monster.o \


Commit: 981f40449904bc1783946a5d31e3d5e89896cc21
    https://github.com/scummvm/scummvm/commit/981f40449904bc1783946a5d31e3d5e89896cc21
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: reset hidden-to-visible ANIMs

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/script_commands.cpp


diff --git a/engines/harvester/script_commands.cpp b/engines/harvester/script_commands.cpp
index ded7902ee1c..5452e162e95 100644
--- a/engines/harvester/script_commands.cpp
+++ b/engines/harvester/script_commands.cpp
@@ -249,8 +249,7 @@ Script::CommandStepResult Script::executeAnimationCommand(const CommandRecord &c
 		const bool active = isTruthy(command.arg2);
 		const bool visible = isTruthy(command.arg3);
 		int runtimeState = currentAnimation->runtimeState;
-		if (active && visible &&
-				(!wasVisible || !currentAnimation->runtimeActive || runtimeState < 0)) {
+		if (visible && (!wasVisible || (active && runtimeState < 0))) {
 			runtimeState = 0;
 		}
 		const bool changed = currentAnimation->active != active ||


Commit: f73b7904605c447edf44b5af257efdb087a44ec3
    https://github.com/scummvm/scummvm/commit/f73b7904605c447edf44b5af257efdb087a44ec3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Retry missed melee contact until the attack ends

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/combat.cpp


diff --git a/engines/harvester/combat.cpp b/engines/harvester/combat.cpp
index 64451dbcc14..cb7ad589a40 100644
--- a/engines/harvester/combat.cpp
+++ b/engines/harvester/combat.cpp
@@ -88,7 +88,7 @@ static NpcRecord *findRoomNpcRecordByName(RoomSetupState &state, const Common::S
 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 static_cast<int>(i);
 	}
 
 	return -1;
@@ -97,7 +97,7 @@ static int findRoomNpcIndexByName(const RoomSetupState &state, const Common::Str
 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 static_cast<int>(i);
 	}
 
 	return -1;
@@ -166,10 +166,10 @@ static byte findNearestPaletteColor(const byte *palette, byte red, byte green, b
 	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);
+		const int dr = static_cast<int>(palette[paletteOffset]) - red;
+		const int dg = static_cast<int>(palette[paletteOffset + 1]) - green;
+		const int db = static_cast<int>(palette[paletteOffset + 2]) - blue;
+		const uint32 distance = static_cast<uint32>(dr * dr + dg * dg + db * db);
 		if (distance < bestDistance) {
 			bestDistance = distance;
 			bestIndex = (byte)i;
@@ -262,8 +262,8 @@ int computeRectAxisGap(int minA, int maxA, int minB, int maxB) {
 
 float clampRoomDepthForEvent(const RoomSetupState &state, float z) {
 	return CLIP<float>(z,
-		(float)MIN(state.roomMinZ, state.roomMaxZ),
-		(float)MAX(state.roomMinZ, state.roomMaxZ));
+		static_cast<float>(MIN(state.roomMinZ, state.roomMaxZ)),
+		static_cast<float>(MAX(state.roomMinZ, state.roomMaxZ)));
 }
 
 bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc) {
@@ -354,7 +354,7 @@ bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFr
 	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
 		return false;
 
-	return (uint)lastFrame < entity.getFrameCount();
+	return static_cast<uint>(lastFrame) < entity.getFrameCount();
 }
 
 bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
@@ -561,7 +561,7 @@ bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
 }
 
 int roundRoomCombatFloat(float value) {
-	return value >= 0.0f ? (int)(value + 0.5f) : (int)(value - 0.5f);
+	return value >= 0.0f ? static_cast<int>(value + 0.5f) : static_cast<int>(value - 0.5f);
 }
 
 int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY) {
@@ -571,9 +571,9 @@ int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fal
 		return CLIP<int>(fallbackY, state.roomMaxZScreenY, state.roomMinZScreenY);
 
 	const float clampedZ = clampRoomDepthForEvent(state, z);
-	const float offset = ((float)state.roomMaxZ - clampedZ) *
-		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
-		(float)(state.roomMaxZ - state.roomMinZ);
+	const float offset = (static_cast<float>(state.roomMaxZ) - clampedZ) *
+		static_cast<float>(state.roomMinZScreenY - state.roomMaxZScreenY) /
+		static_cast<float>(state.roomMaxZ - state.roomMinZ);
 	return CLIP<int>(state.roomMaxZScreenY + roundRoomCombatFloat(offset),
 		state.roomMaxZScreenY, state.roomMinZScreenY);
 }
@@ -643,7 +643,7 @@ MonsterRecord *Combat::findOverlappingMonsterTarget() const {
 
 		Entity *entity = findSceneRuntimeEntity(_entityManager, monster.monsterName);
 		if (entity && areCombatantsWithinRoomCombatReach(_scene.state,
-				*playerState.entity, playerState.z, *entity, (float)monster.posZ, monster.engageDistance))
+				*playerState.entity, playerState.z, *entity, static_cast<float>(monster.posZ), monster.engageDistance))
 			return &monster;
 	}
 
@@ -1210,8 +1210,8 @@ Common::Error Combat::killDebugActiveMonster(const Common::Point &mousePos, bool
 
 	const int monsterIndex = findRoomMonsterIndexByName(_scene.state, monster->monsterName);
 	MonsterCombatState *combatState =
-		monsterIndex >= 0 && monsterIndex < (int)_monsterCombatStates.size()
-			? &_monsterCombatStates[(uint)monsterIndex]
+		monsterIndex >= 0 && monsterIndex < static_cast<int>(_monsterCombatStates.size())
+			? &_monsterCombatStates[static_cast<uint>(monsterIndex)]
 			: nullptr;
 	if (combatState)
 		clearRoomMonsterCombatState(*combatState);
@@ -1260,7 +1260,7 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 
 	Script &script = *_engine.getScript();
 	const uint32 now = Player::getRuntimeClockTicks();
-	const uint32 moveInterval = MAX<uint32>(1, 100U / (uint32)kRoomMonsterAnimationRate);
+	const uint32 moveInterval = MAX<uint32>(1, 100U / static_cast<uint32>(kRoomMonsterAnimationRate));
 	const int horizontalStepBase = 8;
 	const int depthStep = MAX<int>(1, roundRoomCombatFloat(
 		_scene.state.roomZVelocityStep > 0.0f ? _scene.state.roomZVelocityStep : 1.0f));
@@ -1351,7 +1351,7 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 					monster.posX + knockbackStep, monster.screenMinXBound, monster.screenMaxXBound);
 				if (monster.posX != previousX) {
 					(void)applyRoomActorPlacement(
-						_scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
+						_scene.state, *entity, monster.posX, monster.posY, static_cast<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",
@@ -1454,19 +1454,19 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 		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 zDelta = playerState.z - static_cast<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);
+				MIN<float>(playerState.z, static_cast<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);
+			monster.posZ = static_cast<int>(clampRoomDepthForEvent(_scene.state, static_cast<float>(monster.posZ)));
+			monster.posY = mapRoomDepthToScreenYForCombat(_scene.state, static_cast<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
@@ -1475,7 +1475,7 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 			if (ABS(monsterRect.left - desiredFrameLeftX) > liveWaypointTolerance) {
 				attemptedMoveX = true;
 				const int step = MAX<int>(1, roundRoomCombatFloat(
-					Player::computeDepthScale(_scene.state, (float)monster.posZ) * (float)horizontalStepBase));
+					Player::computeDepthScale(_scene.state, static_cast<float>(monster.posZ)) * static_cast<float>(horizontalStepBase)));
 				const int monsterFrameLeftOffset = monsterRect.left - monster.posX;
 				const int desiredLogicalCenterX = desiredFrameLeftX - monsterFrameLeftOffset;
 				monster.posX = stepTowardsRoomCombatInt(monster.posX, desiredLogicalCenterX, step);
@@ -1494,13 +1494,13 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 				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);
+			(void)applyRoomActorPlacement(_scene.state, *entity, monster.posX, monster.posY, static_cast<float>(monster.posZ));
 			if (monster.posZ != previousZ)
 				_entityManager->reinsertSceneEntity(entity);
 			(void)script.syncRuntimeMonsterRecord(monster);
 			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,
+				monster.monsterName.c_str(), liveCenterDx, static_cast<double>(zDelta), engageDistance, liveWaypointTolerance,
 				previousX, previousY, previousZ, monster.posX, monster.posY, monster.posZ, monster.facing);
 			needsRedraw = true;
 			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, true) || needsRedraw;
@@ -1510,13 +1510,13 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 		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.monsterName.c_str(), liveCenterDx, static_cast<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);
+			*entity, static_cast<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.
@@ -1560,7 +1560,7 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 		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,
+			combatState.attackContactFrame, range.resumeFacing, liveCenterDx, static_cast<double>(zDelta),
 			engageDistance, kNativeMonsterAttackAnimationRate, combatState.attackCooldownSeedTick,
 			closeRangeBypassDistance);
 		needsRedraw = true;
@@ -1577,15 +1577,11 @@ Common::Error Combat::resolvePlayerAttackContact(bool &needsRedraw) {
 		return Common::kNoError;
 	}
 
-	_player.markAttackContactResolved();
+	const bool projectile = Player::isProjectileCombatLoadout(playerState.combatLoadout);
+	if (projectile)
+		_player.markAttackContactResolved();
 	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;
@@ -1595,12 +1591,15 @@ Common::Error Combat::resolvePlayerAttackContact(bool &needsRedraw) {
 		Entity *npcEntity = nullptr;
 		NpcRecord *npc = selectNpcAttackContactTarget(npcEntity);
 		if (!npc) {
-			debugC(1, kDebugCombat,
-				"Harvester: combat player attack missed class='npc' target='%s' reason='no reachable npc'",
-				playerState.attackTargetName.c_str());
+			if (playerState.entity->didAnimationAdvanceLastTick()) {
+				debugC(3, kDebugCombat,
+					"Harvester: combat player attack missed class='npc' target='%s' frame=%d reason='no reachable npc'",
+					playerState.attackTargetName.c_str(), playerState.entity->getCurrentFrame());
+			}
 			return Common::kNoError;
 		}
 
+		_player.markAttackContactResolved();
 		const bool runtimeChanged =
 			script->queueRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
 		const bool jimmyFlagChanged = npc->npcName.equalsIgnoreCase("JIMMY")
@@ -1608,8 +1607,8 @@ Common::Error Combat::resolvePlayerAttackContact(bool &needsRedraw) {
 			: false;
 		const int npcIndex = findRoomNpcIndexByName(_scene.state, npc->npcName);
 		NpcCombatState *combatState =
-			npcIndex >= 0 && npcIndex < (int)_npcCombatStates.size()
-				? &_npcCombatStates[(uint)npcIndex]
+			npcIndex >= 0 && npcIndex < static_cast<int>(_npcCombatStates.size())
+				? &_npcCombatStates[static_cast<uint>(npcIndex)]
 				: nullptr;
 		if (runtimeChanged) {
 			npc->deathDamageType = damageType;
@@ -1644,20 +1643,24 @@ Common::Error Combat::resolvePlayerAttackContact(bool &needsRedraw) {
 	Entity *monsterEntity = nullptr;
 	MonsterRecord *monster = selectMonsterAttackContactTarget(monsterEntity);
 	if (!monster || !monsterEntity) {
-		debugC(1, kDebugCombat,
-			"Harvester: combat player attack missed class='monster' target='%s' reason='no reachable monster'",
-			playerState.attackTargetName.c_str());
+		if (playerState.entity->didAnimationAdvanceLastTick()) {
+			debugC(3, kDebugCombat,
+				"Harvester: combat player attack missed class='monster' target='%s' frame=%d reason='no reachable monster'",
+				playerState.attackTargetName.c_str(), playerState.entity->getCurrentFrame());
+		}
 		return Common::kNoError;
 	}
 
+	// update_actor_runtime_state (0x4d750) latches melee contact only after a hit.
+	_player.markAttackContactResolved();
 	const int hitPointsBefore = monster->currentHitPoints;
 	monster->currentHitPoints = MAX(0,
 		monster->currentHitPoints - damageAmount);
 	const int damageLanded = hitPointsBefore - monster->currentHitPoints;
 	const int monsterIndex = findRoomMonsterIndexByName(_scene.state, monster->monsterName);
 	MonsterCombatState *combatState =
-		monsterIndex >= 0 && monsterIndex < (int)_monsterCombatStates.size()
-			? &_monsterCombatStates[(uint)monsterIndex]
+		monsterIndex >= 0 && monsterIndex < static_cast<int>(_monsterCombatStates.size())
+			? &_monsterCombatStates[static_cast<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",
@@ -1808,7 +1811,7 @@ CombatEffects::CombatEffects(EntityManager *entityManager) : _entityManager(enti
 
 void CombatEffects::clearHitEffects() {
 	debugC(2, kDebugCombat, "Harvester: resetting room blood effects records=%u next_id=%u",
-		(uint)_hitEffectStates.size(), _nextCombatEffectId);
+		static_cast<uint>(_hitEffectStates.size()), _nextCombatEffectId);
 	_hitEffectStates.clear();
 	_nextCombatEffectId = 0;
 }
@@ -1915,8 +1918,8 @@ void CombatEffects::drawCombatDamagePopups(Graphics::Screen &screen, bool combat
 
 		const uint32 elapsed = now - popupState.startTick;
 		const uint32 clampedElapsed = MIN<uint32>(elapsed, kCombatDebugDamagePopupDurationMs);
-		const int rise = (int)(
-			clampedElapsed * (uint32)kCombatDebugDamagePopupRisePixels /
+		const int rise = static_cast<int>(
+			clampedElapsed * static_cast<uint32>(kCombatDebugDamagePopupRisePixels) /
 			kCombatDebugDamagePopupDurationMs);
 		const Common::String popupText = Common::String::format("+%d", popupState.damageAmount);
 		const int textWidth = font->getStringWidth(popupText);


Commit: e1d97984411ae275054d00b7406c0b24abb93a27
    https://github.com/scummvm/scummvm/commit/e1d97984411ae275054d00b7406c0b24abb93a27
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Correct player depth walking and room limits

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 cbec6ab7dc2..1ec1826d225 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -44,7 +44,6 @@ static const float kRoomPlayerDepthTargetSlack = 8.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
-static const int kRoomPlayerVerticalScreenStep = 1;
 static const int kRoomPlayerFastAnimationRate = 30;
 static const int kRoomPlayerHitAnimationRate = 4;
 static const int kNativePlayerDeathEntryAnimationRate = 14;
@@ -553,21 +552,6 @@ static int mapRoomDepthToScreenY(const RoomSetupState &state, float z) {
 	return clampRoomMovementY(state, state.roomMaxZScreenY + roundToInt(offset));
 }
 
-static int resolveFacingFromMovementDelta(int dx, int dy) {
-	if (ABS(dx) > ABS(dy))
-		return dx < 0 ? 1 : 2;
-	if (dy < 0)
-		return 3;
-	return 0;
-}
-
-static int resolveFacingFromRoomMovement(
-		int fromCenterX, int fromBottomY, int toCenterX, int toBottomY) {
-	return resolveFacingFromMovementDelta(
-		toCenterX - fromCenterX,
-		toBottomY - fromBottomY);
-}
-
 static int computeRoomPlayerHorizontalStep(const RoomSetupState &state, float z) {
 	return MAX<int>(1, (int)(Player::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
 }
@@ -576,10 +560,6 @@ static float computeRoomPlayerDepthStep(const RoomSetupState &state) {
 	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
 }
 
-static int computeRoomPlayerVerticalScreenStep() {
-	return kRoomPlayerVerticalScreenStep;
-}
-
 static int computePlayerFrameLeftX(const RoomPlayerState &playerState) {
 	return playerState.entity ? playerState.entity->getScreenRect().left : playerState.centerX;
 }
@@ -588,6 +568,12 @@ static float computePlayerRenderZ(const RoomPlayerState &playerState) {
 	return playerState.entity ? playerState.entity->getZ() : playerState.z;
 }
 
+static float clampPlayerMovementDepth(const RoomSetupState &state,
+		const RoomPlayerState &playerState, float z) {
+	const float placementOffset = playerState.z - computePlayerRenderZ(playerState);
+	return clampRoomDepth(state, z - placementOffset) + placementOffset;
+}
+
 static void resetPlayerMoveTargetProgress(RoomPlayerState &playerState) {
 	playerState.moveTargetXReached = false;
 	playerState.moveTargetZReached = false;
@@ -2066,22 +2052,20 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 		return false;
 
 	int candidateCenterX = playerState.centerX;
-	int candidateBottomY = playerState.bottomY;
+	const int candidateBottomY = playerState.bottomY;
 	float candidateZ = playerState.z;
 	if (horizontalDirection != 0) {
 		const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
 		candidateCenterX = clampPlayerCenterXToNativeBounds(
 			playerState, playerState.centerX + horizontalDirection * horizontalStep);
-		candidateZ = clampRoomDepth(state, playerState.z - depthDirection);
+		candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - depthDirection);
 	} else if (depthDirection != 0) {
-		const int verticalStep = computeRoomPlayerVerticalScreenStep();
 		const float depthStep = computeRoomPlayerDepthStep(state);
 		const bool depthBoundaryReached = depthDirection < 0
-			? playerState.z >= (float)state.roomMaxZ
-			: playerState.z <= (float)state.roomMinZ;
+			? computePlayerRenderZ(playerState) >= state.roomMaxZ
+			: computePlayerRenderZ(playerState) <= state.roomMinZ;
 		if (!depthBoundaryReached) {
-			candidateBottomY = playerState.bottomY + depthDirection * verticalStep;
-			candidateZ = clampRoomDepth(state, playerState.z - depthDirection * depthStep);
+			candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - depthDirection * depthStep);
 		}
 	}
 
@@ -2191,24 +2175,20 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 		return setIdleAnimation(playerState.facing);
 	}
 
-	const int previousBaseX = playerState.entity->getX();
-	const int previousBaseY = playerState.entity->getY();
 	const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
-	const int verticalStep = computeRoomPlayerVerticalScreenStep();
 	const float depthStep = computeRoomPlayerDepthStep(state);
 	int candidateCenterX = clampPlayerCenterXToNativeBounds(
 		playerState, playerState.centerX + horizontalInput * horizontalStep);
-	int candidateBottomY = playerState.bottomY;
+	const int candidateBottomY = playerState.bottomY;
 	float candidateZ = playerState.z;
 	bool depthBoundaryReached = false;
 	if (verticalInput != 0) {
 
 		depthBoundaryReached = verticalInput < 0
-			? playerState.z >= (float)state.roomMaxZ
-			: playerState.z <= (float)state.roomMinZ;
+			? computePlayerRenderZ(playerState) >= state.roomMaxZ
+			: computePlayerRenderZ(playerState) <= state.roomMinZ;
 		if (!depthBoundaryReached) {
-			candidateBottomY = playerState.bottomY + verticalInput * verticalStep;
-			candidateZ = clampRoomDepth(state, playerState.z - verticalInput * depthStep);
+			candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - verticalInput * depthStep);
 		}
 	}
 	if (candidateCenterX == playerState.centerX &&
@@ -2229,8 +2209,8 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
 	}
 
-	const int desiredFacing = resolveFacingFromRoomMovement(
-		playerState.centerX, playerState.bottomY, candidateCenterX, candidateBottomY);
+	const int desiredFacing = resolveFacingFromMoveDirection(
+		horizontalInput, verticalInput, playerState.facing);
 	if (desiredFacing != playerState.facing && startTurnAnimation(desiredFacing)) {
 		playerState.keyboardWalkRequest = -1;
 		return true;
@@ -2246,8 +2226,7 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 	if (!moved)
 		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
 
-	const int actualFacing = resolveFacingFromRoomMovement(
-		previousBaseX, previousBaseY, playerState.entity->getX(), playerState.entity->getY());
+	const int actualFacing = desiredFacing;
 	playerState.walkHorizontalDirection = actualFacing == 1 ? -1 : actualFacing == 2 ? 1 : 0;
 	playerState.walkDepthDirection = actualFacing == 3 ? -1 : actualFacing == 0 ? 1 : 0;
 	(void)setPlayerWalkAnimation(playerState, actualFacing);


Commit: cfeac713a68e820676e5b07c18e9a1f4a9450a63
    https://github.com/scummvm/scummvm/commit/cfeac713a68e820676e5b07c18e9a1f4a9450a63
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Separate main and room menu navigation

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/menu_navigation.cpp
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/module.mk


diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 15de0f22985..3d0e8849f63 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -28,7 +28,6 @@
 #include "common/system.h"
 #include "engines/metaengine.h"
 #include "graphics/font.h"
-#include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
@@ -50,9 +49,6 @@ static const char *const kLoadGameBitmapPath = "1:/GRAPHIC/OTHER/LOADGAME.BM";
 static const char *const kLoadGamePalettePath = "1:/GRAPHIC/PAL/LOADGAME.PAL";
 static const char *const kSaveGameBitmapPath = "1:/GRAPHIC/OTHER/SAVEGAME.BM";
 static const char *const kSaveGamePalettePath = "1:/GRAPHIC/PAL/SAVEGAME.PAL";
-static const char *const kGameOverBitmapPath = "1:/GRAPHIC/OTHER/GAMEOVER.BM";
-static const char *const kGameOverPalettePath = "1:/GRAPHIC/PAL/GAMEOVER.PAL";
-static const char *const kGameOverMusicPath = "SOUND/MUSIC/ANXIETY.CMP";
 static const int kSaveSlotCount = 25;
 static const int kSaveSlotLabelX = 8;
 static const int kSaveSlotNameX = 0x50;
@@ -67,39 +63,6 @@ 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;
-static const char *const kBlankMenuSlot = " ";
-
-class ScopedSceneTimerPause {
-public:
-	explicit ScopedSceneTimerPause(HarvesterEngine &engine) : _engine(engine) {
-		pause();
-	}
-
-	~ScopedSceneTimerPause() {
-		resume();
-	}
-
-	void pause() {
-		if (_paused)
-			return;
-		if (EntityManager *entityManager = _engine.getRuntimeEntities()) {
-			entityManager->pauseTimerCountdowns();
-			_paused = true;
-		}
-	}
-
-	void resume() {
-		if (!_paused)
-			return;
-		if (EntityManager *entityManager = _engine.getRuntimeEntities())
-			entityManager->resumeTimerCountdowns();
-		_paused = false;
-	}
-
-private:
-	HarvesterEngine &_engine;
-	bool _paused = false;
-};
 
 static Common::Rect saveSlotRect(int slotIndex) {
 	const int top = kSaveSlotStartY + slotIndex * kSaveSlotStride;
@@ -126,25 +89,13 @@ static Common::Rect quitConfirmNoRect() {
 
 namespace {
 
-enum MainMenuItem {
-	kMainMenuItemNewGame = 0,
-	kMainMenuItemSaveGame,
-	kMainMenuItemLoadGame,
-	kMainMenuItemOptions,
-	kMainMenuItemHelp,
-	kMainMenuItemQuitGame
-};
-
-static void buildDisplayMainMenuItems(const Common::Array<Common::String> &source,
-		bool canSaveGame, bool canLoadGame, Common::Array<Common::String> &dest) {
-	dest = source;
-	if (dest.size() > 1 && !canSaveGame)
-		dest[1] = kBlankMenuSlot;
-	if (dest.size() > 2 && !canLoadGame)
-		dest[2] = kBlankMenuSlot;
+static Common::String buildDefaultSaveSlotLabel(int slotIndex) {
+	return Common::String::format("GAME %02d", slotIndex + 1);
 }
 
-static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette) {
+} // End of anonymous namespace
+
+bool MenuSystem::loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
 		return false;
@@ -153,12 +104,6 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
-static Common::String buildDefaultSaveSlotLabel(int slotIndex) {
-	return Common::String::format("GAME %02d", slotIndex + 1);
-}
-
-} // End of anonymous namespace
-
 bool MenuSystem::loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 12)
@@ -260,477 +205,6 @@ Common::Error MenuSystem::showCdChangePrompt(int discNumber, ResourceManager &re
 	return Common::kNoError;
 }
 
-Common::Error MenuSystem::runMainMenu(Flow &flow) {
-	const Art *art = _engine.getArt();
-	if (!art)
-		return Common::kReadingFailed;
-	ScopedSceneTimerPause pausedTimers(_engine);
-
-	Common::Array<Common::String> mainMenuItems;
-	buildDisplayMainMenuItems(_menuItems,
-		_engine.canSaveGameStateCurrently(),
-		_engine.canLoadGameStateCurrently(),
-		mainMenuItems);
-	if (flow.takePendingGameOverReturn()) {
-		Common::Error gameOverError = showGameOverBackdrop(flow);
-		if (gameOverError.getCode() != Common::kNoError)
-			return gameOverError;
-	} else {
-		clearMainMenuBackdrop();
-	}
-
-	Graphics::FrameLimiter limiter(g_system, 60);
-	int selectedItem = mainMenuItems.empty() ? -1 : 0;
-	Common::String statusMessage;
-	bool needsRedraw = true;
-
-	auto captureMenuBackdrop = [&](IndexedBitmap &backdrop) -> bool {
-		if (_hasMainMenuBackdrop) {
-			renderBackdropMenuScreen(_mainMenuBackdrop, _mainMenuBackdropPalette, 1.0f,
-				mainMenuItems, selectedItem);
-		} else {
-			renderMainMenuScreen(selectedItem, statusMessage, false, mainMenuItems);
-		}
-		Graphics::Screen *screen = _engine.getScreen();
-		return screen && captureScreenBackdrop(*screen, backdrop);
-	};
-	auto runSelectedRoomLoop = [&](const Common::String &targetName) -> Common::Error {
-		pausedTimers.resume();
-		Common::Error roomError = flow.runRoomLoop(targetName);
-		pausedTimers.pause();
-		_engine.stopMusic();
-		_engine.stopSound();
-		if (roomError.getCode() == Common::kReadingFailed) {
-			statusMessage = Common::String::format(
-				"Unable to resolve %s room setup from HARVEST.SCR.", targetName.c_str());
-			needsRedraw = true;
-			return Common::kNoError;
-		}
-		if (roomError.getCode() != Common::kNoError)
-			return roomError;
-
-		statusMessage.clear();
-		needsRedraw = true;
-		(void)flow.takePendingMainMenuReturn();
-		if (flow.takePendingGameOverReturn()) {
-			Common::Error gameOverError = showGameOverBackdrop(flow);
-			if (gameOverError.getCode() != Common::kNoError)
-				return gameOverError;
-		} else {
-			clearMainMenuBackdrop();
-		}
-		return Common::kNoError;
-	};
-	auto activateSelectedItem = [&]() -> Common::Error {
-		if (selectedItem < 0 || selectedItem >= (int)mainMenuItems.size())
-			return Common::kNoError;
-
-		const Common::String &item = mainMenuItems[selectedItem];
-		const byte *menuPalette = _hasMainMenuBackdrop ? _mainMenuBackdropPalette : art->getWaitPalette();
-		statusMessage.clear();
-		if (item.empty() || item == kBlankMenuSlot)
-			return Common::kNoError;
-		debugC(2, kDebugGeneral, "Harvester: main menu selected index=%d label='%s'",
-			selectedItem, item.c_str());
-
-		if (selectedItem == kMainMenuItemNewGame) {
-			const MenuTextConfig &config = flow.getMenuTextConfig();
-
-			IndexedBitmap menuBackdrop;
-			if (!captureMenuBackdrop(menuBackdrop))
-				return Common::kReadingFailed;
-
-			bool confirmed = false;
-			Common::Error confirmError = runConfirmPrompt(
-				menuBackdrop, menuPalette, 1.0f, flow, config.newGamePrompt, confirmed);
-			if (confirmError.getCode() != Common::kNoError)
-				return confirmError;
-			needsRedraw = true;
-			if (!confirmed)
-				return Common::kNoError;
-
-			flow.prepareForNewGame();
-			return runSelectedRoomLoop("START");
-		}
-
-		if (selectedItem == kMainMenuItemLoadGame) {
-			bool loadedGame = false;
-			Common::Error loadError = runLoadGameMenu(menuPalette, 1.0f, flow, loadedGame);
-			if (loadError.getCode() != Common::kNoError)
-				return loadError;
-			needsRedraw = true;
-			if (!loadedGame)
-				return Common::kNoError;
-
-			Common::String targetName = _engine.getPendingLoadedSaveRoomState().entranceName;
-			if (targetName.empty())
-				targetName = _engine.getPendingLoadedSaveRoomState().roomName;
-			if (targetName.empty()) {
-				statusMessage = "Loaded save is missing its room target.";
-				return Common::kNoError;
-			}
-
-			return runSelectedRoomLoop(targetName);
-		}
-
-		if (selectedItem == kMainMenuItemSaveGame) {
-			bool savedGame = false;
-			Common::Error saveError = runSaveGameMenu(menuPalette, 1.0f, flow, savedGame);
-			(void)savedGame;
-			needsRedraw = true;
-			return saveError;
-		}
-
-		if (selectedItem == kMainMenuItemOptions) {
-			IndexedBitmap menuBackdrop;
-			if (_hasMainMenuBackdrop) {
-				menuBackdrop = _mainMenuBackdrop;
-			} else if (!captureMenuBackdrop(menuBackdrop)) {
-				return Common::kReadingFailed;
-			}
-			Common::Error optionsError = runOptionsMenu(menuBackdrop, menuPalette, 1.0f, flow);
-			needsRedraw = true;
-			return optionsError;
-		}
-
-		if (selectedItem == kMainMenuItemHelp) {
-			Common::Error helpError = runHelpScreen(menuPalette, 1.0f, flow);
-			needsRedraw = true;
-			return helpError;
-		}
-
-		if (selectedItem == kMainMenuItemQuitGame) {
-			IndexedBitmap menuBackdrop;
-			if (!captureMenuBackdrop(menuBackdrop))
-				return Common::kReadingFailed;
-			Common::Error quitError = runQuitGameConfirm(menuBackdrop, menuPalette, 1.0f, flow);
-			needsRedraw = true;
-			return quitError;
-		}
-
-		return Common::kNoError;
-	};
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			if (_hasMainMenuBackdrop)
-				renderBackdropMenuScreen(_mainMenuBackdrop, _mainMenuBackdropPalette, 1.0f, mainMenuItems, selectedItem);
-			else
-				renderMainMenuScreen(selectedItem, statusMessage, true, mainMenuItems);
-			needsRedraw = false;
-		}
-
-		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: {
-				const int hoveredItem = _hasMainMenuBackdrop
-					? getBackdropMenuItemAt(_mousePos, mainMenuItems)
-					: getMenuItemAt(_mousePos, mainMenuItems);
-				if (hoveredItem != -1 && hoveredItem != selectedItem) {
-					selectedItem = hoveredItem;
-					needsRedraw = true;
-				}
-				break;
-			}
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return Common::kNoError;
-
-				if (mainMenuItems.empty())
-					break;
-
-				if (event.kbd.keycode == Common::KEYCODE_UP) {
-					selectedItem = (selectedItem + mainMenuItems.size() - 1) % mainMenuItems.size();
-					needsRedraw = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
-					selectedItem = (selectedItem + 1) % mainMenuItems.size();
-					needsRedraw = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					Common::Error activateError = activateSelectedItem();
-					if (activateError.getCode() != Common::kNoError)
-						return activateError;
-				}
-				break;
-			case Common::EVENT_LBUTTONDOWN: {
-				if (mainMenuItems.empty())
-					break;
-
-				selectedItem = _hasMainMenuBackdrop
-					? getBackdropMenuItemAt(_mousePos, mainMenuItems)
-					: getMenuItemAt(_mousePos, mainMenuItems);
-				if (selectedItem == -1)
-					break;
-
-				Common::Error activateError = activateSelectedItem();
-				if (activateError.getCode() != Common::kNoError)
-					return activateError;
-				break;
-			}
-			default:
-				break;
-			}
-		}
-
-		if (flow.tickRuntimeEntities())
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
-Common::Error MenuSystem::showGameOverBackdrop(Flow &flow) {
-	ResourceManager *resources = _engine.getResources();
-	Graphics::Screen *screen = _engine.getScreen();
-	if (!resources || !screen)
-		return Common::kReadingFailed;
-
-	IndexedBitmap backdrop;
-	byte palette[256 * 3];
-	if (!loadBitmapResource(*resources, kGameOverBitmapPath, backdrop) ||
-			!loadPaletteResource(*resources, kGameOverPalettePath, palette)) {
-		return Common::kReadingFailed;
-	}
-
-	_mainMenuBackdrop = Common::move(backdrop);
-	memcpy(_mainMenuBackdropPalette, palette, sizeof(_mainMenuBackdropPalette));
-	_hasMainMenuBackdrop = true;
-	flow.resetCursorAnimationSequence();
-	(void)_engine.playMusic(kGameOverMusicPath);
-
-	bool needsRedraw = true;
-	Graphics::FrameLimiter limiter(g_system, 60);
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			MenuRendering::applyMenuPalette(*screen, _engine, _mainMenuBackdropPalette, 1.0f);
-			screen->fillRect(screen->getBounds(), 0);
-			MenuRendering::blitBitmap(*screen, _mainMenuBackdrop, 0, 0);
-			if (_engine.getRuntimeEntities())
-				_engine.getRuntimeEntities()->drawCursor(*screen);
-			screen->makeAllDirty();
-			screen->update();
-			needsRedraw = false;
-		}
-
-		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:
-				needsRedraw = true;
-				break;
-			case Common::EVENT_LBUTTONDOWN:
-			case Common::EVENT_RBUTTONDOWN:
-			case Common::EVENT_KEYDOWN:
-				return Common::kNoError;
-			default:
-				break;
-			}
-		}
-
-		if (EntityManager *entityManager = _engine.getRuntimeEntities()) {
-			if (entityManager->syncCursorEntityPosition(_mousePos))
-				needsRedraw = true;
-		}
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
-void MenuSystem::clearMainMenuBackdrop() {
-	_mainMenuBackdrop = IndexedBitmap();
-	memset(_mainMenuBackdropPalette, 0, sizeof(_mainMenuBackdropPalette));
-	_hasMainMenuBackdrop = false;
-}
-
-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;
-	buildDisplayMainMenuItems(_menuItems,
-		canSaveGame && _engine.canSaveGameStateCurrently(),
-		_engine.canLoadGameStateCurrently(),
-		roomMenuItems);
-	int selectedItem = roomMenuItems.empty() ? -1 : 0;
-	bool needsRedraw = true;
-	flow.resetCursorAnimationSequence();
-
-	struct RoomMenuActivationResult {
-		Common::Error error;
-		bool closeMenu;
-
-		RoomMenuActivationResult(Common::Error e, bool c) : error(e), closeMenu(c) {}
-	};
-	auto activateSelectedItem = [&]() -> RoomMenuActivationResult {
-		if (selectedItem < 0 || selectedItem >= (int)roomMenuItems.size())
-			return RoomMenuActivationResult(Common::kNoError, false);
-
-		const Common::String &item = roomMenuItems[selectedItem];
-		if (item.empty() || item == kBlankMenuSlot)
-			return RoomMenuActivationResult(Common::kNoError, false);
-		debugC(2, kDebugGeneral, "Harvester: room menu selected index=%d label='%s'",
-			selectedItem, item.c_str());
-
-		if (selectedItem == kMainMenuItemNewGame) {
-			const MenuTextConfig &config = flow.getMenuTextConfig();
-
-			bool confirmed = false;
-			Common::Error confirmError = runConfirmPrompt(
-				backdrop, palette, paletteBrightness, flow, config.newGamePrompt, confirmed);
-			if (confirmError.getCode() != Common::kNoError)
-				return RoomMenuActivationResult(confirmError, false);
-			needsRedraw = true;
-			if (confirmed) {
-				flow.requestNewGameRestart();
-				return RoomMenuActivationResult(Common::kNoError, true);
-			}
-			return RoomMenuActivationResult(Common::kNoError, false);
-		}
-
-		if (selectedItem == kMainMenuItemLoadGame) {
-			bool loadedGame = false;
-			Common::Error loadError = runLoadGameMenu(palette, paletteBrightness, flow, loadedGame);
-			if (loadError.getCode() != Common::kNoError)
-				return RoomMenuActivationResult(loadError, false);
-			needsRedraw = true;
-			return RoomMenuActivationResult(Common::kNoError, loadedGame);
-		}
-
-		if (selectedItem == kMainMenuItemOptions) {
-			Common::Error optionsError = runOptionsMenu(backdrop, palette, paletteBrightness, flow);
-			if (optionsError.getCode() != Common::kNoError)
-				return RoomMenuActivationResult(optionsError, false);
-			needsRedraw = true;
-			return RoomMenuActivationResult(Common::kNoError, false);
-		}
-
-		if (selectedItem == kMainMenuItemHelp) {
-			Common::Error helpError = runHelpScreen(palette, paletteBrightness, flow);
-			if (helpError.getCode() != Common::kNoError)
-				return RoomMenuActivationResult(helpError, false);
-			needsRedraw = true;
-			return RoomMenuActivationResult(Common::kNoError, false);
-		}
-
-		if (selectedItem == kMainMenuItemSaveGame) {
-			bool savedGame = false;
-			Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, flow, savedGame);
-			if (saveError.getCode() != Common::kNoError)
-				return RoomMenuActivationResult(saveError, false);
-			if (savedGame) {
-				// Native exits the in-room ESC menu after a successful save and restores the room view.
-				Common::Error restoreError = restoreRoomBackdropAfterSave(
-					backdrop, palette, paletteBrightness, flow);
-				if (restoreError.getCode() != Common::kNoError)
-					return RoomMenuActivationResult(restoreError, false);
-				return RoomMenuActivationResult(Common::kNoError, true);
-			}
-			needsRedraw = true;
-			return RoomMenuActivationResult(Common::kNoError, false);
-		}
-
-		if (selectedItem == kMainMenuItemQuitGame) {
-			Common::Error quitError = runQuitGameConfirm(backdrop, palette, paletteBrightness, flow);
-			if (quitError.getCode() != Common::kNoError)
-				return RoomMenuActivationResult(quitError, false);
-			needsRedraw = true;
-			return RoomMenuActivationResult(Common::kNoError, false);
-		}
-
-		debugC(1, kDebugGeneral,
-			"Harvester: room menu index=%d label='%s' selected but not implemented",
-			selectedItem, item.c_str());
-		return RoomMenuActivationResult(Common::kNoError, false);
-	};
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			renderBackdropMenuScreen(backdrop, palette, paletteBrightness, roomMenuItems, selectedItem);
-			needsRedraw = false;
-		}
-
-		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: {
-				const int hoveredItem = getBackdropMenuItemAt(_mousePos, roomMenuItems);
-				if (hoveredItem != -1 && hoveredItem != selectedItem) {
-					selectedItem = hoveredItem;
-					needsRedraw = true;
-				}
-				break;
-			}
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return Common::kNoError;
-
-				if (roomMenuItems.empty())
-					break;
-
-				if (event.kbd.keycode == Common::KEYCODE_UP) {
-					selectedItem = (selectedItem + roomMenuItems.size() - 1) % roomMenuItems.size();
-					needsRedraw = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
-					selectedItem = (selectedItem + 1) % roomMenuItems.size();
-					needsRedraw = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					RoomMenuActivationResult activationResult = activateSelectedItem();
-					if (activationResult.error.getCode() != Common::kNoError)
-						return activationResult.error;
-					if (activationResult.closeMenu)
-						return Common::kNoError;
-				}
-				break;
-			case Common::EVENT_LBUTTONDOWN: {
-				if (roomMenuItems.empty())
-					break;
-
-				selectedItem = getBackdropMenuItemAt(_mousePos, roomMenuItems);
-				if (selectedItem == -1)
-					break;
-
-				RoomMenuActivationResult activationResult = activateSelectedItem();
-				if (activationResult.error.getCode() != Common::kNoError)
-					return activationResult.error;
-				if (activationResult.closeMenu)
-					return Common::kNoError;
-				break;
-			}
-			default:
-				break;
-			}
-		}
-
-		EntityManager *entityManager = _engine.getRuntimeEntities();
-		if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
 struct MenuSystem::SaveSlotMenu {
 	SaveSlotMenu(MetaEngine &meta, const CftFontResource &nameFont, const CftFontResource &labelFont)
 		: metaEngine(meta), slotNameFont(nameFont), slotLabelFont(labelFont) {}
@@ -1376,36 +850,4 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 	return Common::kNoError;
 }
 
-int MenuSystem::getMenuItemAt(const Common::Point &mousePos,
-		const Common::Array<Common::String> &menuItems) const {
-	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!font)
-		return -1;
-
-	for (uint i = 0; i < menuItems.size(); ++i) {
-		const int width = font->getStringWidth(menuItems[i]);
-		const int x = (640 - width) / 2;
-		const int y = MenuRendering::kMenuStartY + (int)i * MenuRendering::kMenuLineSpacing;
-		const Common::Rect bounds(x - 12, y - 2, x + width + 12, y + font->getFontHeight() + 2);
-		if (bounds.contains(mousePos))
-			return (int)i;
-	}
-
-	return -1;
-}
-
-int MenuSystem::getBackdropMenuItemAt(const Common::Point &mousePos,
-		const Common::Array<Common::String> &menuItems) const {
-	const Text *text = _engine.getText();
-	const CftFontResource *selectedFontResource = text ? text->findFont("HARVFONT") : nullptr;
-	if (!selectedFontResource)
-		return getMenuItemAt(mousePos, menuItems);
-
-	HarvesterCftFont selectedFont(*selectedFontResource);
-	if (!selectedFont.isValid())
-		return getMenuItemAt(mousePos, menuItems);
-
-	return getNativeRoomMenuSelectionFromMouse(selectedFont, menuItems.size(), mousePos);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index 20a3a401a01..71fb256e6e0 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -100,6 +100,23 @@ public:
 		Flow &flow, bool &promptCompleted);
 
 private:
+	static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette);
+	struct MenuSelection;
+	struct MainMenu;
+	struct RoomMenu;
+	struct RoomMenuActivationResult;
+	enum MenuNavigationCommand { kNoMenuCommand, kActivateMenuItem, kCloseMenu };
+	MenuNavigationCommand handleMenuNavigationEvent(MenuSelection &selection,
+		const Common::Event &event, bool useBackdrop) const;
+	bool captureMainMenuBackdrop(MainMenu &menu, IndexedBitmap &backdrop);
+	Common::Error runMainMenuRoomLoop(MainMenu &menu, const Common::String &targetName);
+	Common::Error startNewGameFromMainMenu(MainMenu &menu, const byte *menuPalette);
+	Common::Error loadGameFromMainMenu(MainMenu &menu, const byte *menuPalette);
+	Common::Error activateMainMenuItem(MainMenu &menu);
+	RoomMenuActivationResult startNewGameFromRoomMenu(RoomMenu &menu);
+	RoomMenuActivationResult saveGameFromRoomMenu(RoomMenu &menu);
+	RoomMenuActivationResult activateRoomMenuItem(RoomMenu &menu);
+
 	static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap);
 	static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
 		const Common::Point &mousePos);
diff --git a/engines/harvester/menu_navigation.cpp b/engines/harvester/menu_navigation.cpp
new file mode 100644
index 00000000000..bae825e2ea7
--- /dev/null
+++ b/engines/harvester/menu_navigation.cpp
@@ -0,0 +1,611 @@
+/* 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/menu.h"
+#include "harvester/menu_render.h"
+
+#include "common/algorithm.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/fontman.h"
+#include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/flow.h"
+#include "harvester/harvester.h"
+#include "harvester/room_support.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kGameOverBitmapPath = "1:/GRAPHIC/OTHER/GAMEOVER.BM";
+static const char *const kGameOverPalettePath = "1:/GRAPHIC/PAL/GAMEOVER.PAL";
+static const char *const kGameOverMusicPath = "SOUND/MUSIC/ANXIETY.CMP";
+static const char *const kBlankMenuSlot = " ";
+
+class ScopedSceneTimerPause {
+public:
+	explicit ScopedSceneTimerPause(HarvesterEngine &engine) : _engine(engine) {
+		pause();
+	}
+
+	~ScopedSceneTimerPause() {
+		resume();
+	}
+
+	void pause() {
+		if (_paused)
+			return;
+		if (EntityManager *entityManager = _engine.getRuntimeEntities()) {
+			entityManager->pauseTimerCountdowns();
+			_paused = true;
+		}
+	}
+
+	void resume() {
+		if (!_paused)
+			return;
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			entityManager->resumeTimerCountdowns();
+		_paused = false;
+	}
+
+private:
+	HarvesterEngine &_engine;
+	bool _paused = false;
+};
+
+enum MainMenuItem {
+	kMainMenuItemNewGame = 0,
+	kMainMenuItemSaveGame,
+	kMainMenuItemLoadGame,
+	kMainMenuItemOptions,
+	kMainMenuItemHelp,
+	kMainMenuItemQuitGame
+};
+
+static void buildDisplayMainMenuItems(const Common::Array<Common::String> &source,
+		bool canSaveGame, bool canLoadGame, Common::Array<Common::String> &dest) {
+	dest = source;
+	if (dest.size() > 1 && !canSaveGame)
+		dest[1] = kBlankMenuSlot;
+	if (dest.size() > 2 && !canLoadGame)
+		dest[2] = kBlankMenuSlot;
+}
+
+} // End of anonymous namespace
+
+struct MenuSystem::MenuSelection {
+	Common::Array<Common::String> items;
+	int selectedItem = -1;
+	bool needsRedraw = true;
+};
+
+struct MenuSystem::MainMenu {
+	MainMenu(HarvesterEngine &engine, const Art &art, Flow &flow)
+		: art(art), flow(flow), pausedTimers(engine) {}
+
+	const Art &art;
+	Flow &flow;
+	ScopedSceneTimerPause pausedTimers;
+	MenuSelection selection;
+	Common::String statusMessage;
+};
+
+struct MenuSystem::RoomMenu {
+	RoomMenu(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness, Flow &flow)
+		: backdrop(backdrop), palette(palette), paletteBrightness(paletteBrightness), flow(flow) {}
+
+	const IndexedBitmap &backdrop;
+	const byte *palette;
+	float paletteBrightness;
+	Flow &flow;
+	MenuSelection selection;
+};
+
+struct MenuSystem::RoomMenuActivationResult {
+	Common::Error error;
+	bool closeMenu;
+
+	RoomMenuActivationResult(Common::Error e, bool c) : error(e), closeMenu(c) {}
+};
+
+MenuSystem::MenuNavigationCommand MenuSystem::handleMenuNavigationEvent(MenuSelection &selection,
+		const Common::Event &event, bool useBackdrop) const {
+	switch (event.type) {
+	case Common::EVENT_MOUSEMOVE: {
+		const int hoveredItem = useBackdrop
+			? getBackdropMenuItemAt(_mousePos, selection.items)
+			: getMenuItemAt(_mousePos, selection.items);
+		if (hoveredItem != -1 && hoveredItem != selection.selectedItem) {
+			selection.selectedItem = hoveredItem;
+			selection.needsRedraw = true;
+		}
+		break;
+	}
+	case Common::EVENT_KEYDOWN:
+		if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+			return kCloseMenu;
+
+		if (selection.items.empty())
+			break;
+
+		if (event.kbd.keycode == Common::KEYCODE_UP) {
+			selection.selectedItem = (selection.selectedItem + selection.items.size() - 1) % selection.items.size();
+			selection.needsRedraw = true;
+		} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
+			selection.selectedItem = (selection.selectedItem + 1) % selection.items.size();
+			selection.needsRedraw = true;
+		} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+				event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+			return kActivateMenuItem;
+		}
+		break;
+	case Common::EVENT_LBUTTONDOWN:
+		if (selection.items.empty())
+			break;
+
+		selection.selectedItem = useBackdrop
+			? getBackdropMenuItemAt(_mousePos, selection.items)
+			: getMenuItemAt(_mousePos, selection.items);
+		if (selection.selectedItem == -1)
+			break;
+
+		return kActivateMenuItem;
+	default:
+		break;
+	}
+	return kNoMenuCommand;
+}
+
+bool MenuSystem::captureMainMenuBackdrop(MainMenu &menu, IndexedBitmap &backdrop) {
+	if (_hasMainMenuBackdrop) {
+		renderBackdropMenuScreen(_mainMenuBackdrop, _mainMenuBackdropPalette, 1.0f,
+			menu.selection.items, menu.selection.selectedItem);
+	} else {
+		renderMainMenuScreen(menu.selection.selectedItem, menu.statusMessage, false, menu.selection.items);
+	}
+	Graphics::Screen *screen = _engine.getScreen();
+	return screen && captureScreenBackdrop(*screen, backdrop);
+}
+
+Common::Error MenuSystem::runMainMenuRoomLoop(MainMenu &menu, const Common::String &targetName) {
+	menu.pausedTimers.resume();
+	Common::Error roomError = menu.flow.runRoomLoop(targetName);
+	menu.pausedTimers.pause();
+	_engine.stopMusic();
+	_engine.stopSound();
+	if (roomError.getCode() == Common::kReadingFailed) {
+		menu.statusMessage = Common::String::format(
+			"Unable to resolve %s room setup from HARVEST.SCR.", targetName.c_str());
+		menu.selection.needsRedraw = true;
+		return Common::kNoError;
+	}
+	if (roomError.getCode() != Common::kNoError)
+		return roomError;
+
+	menu.statusMessage.clear();
+	menu.selection.needsRedraw = true;
+	(void)menu.flow.takePendingMainMenuReturn();
+	if (menu.flow.takePendingGameOverReturn()) {
+		Common::Error gameOverError = showGameOverBackdrop(menu.flow);
+		if (gameOverError.getCode() != Common::kNoError)
+			return gameOverError;
+	} else {
+		clearMainMenuBackdrop();
+	}
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::startNewGameFromMainMenu(MainMenu &menu, const byte *menuPalette) {
+	const MenuTextConfig &config = menu.flow.getMenuTextConfig();
+
+	IndexedBitmap menuBackdrop;
+	if (!captureMainMenuBackdrop(menu, menuBackdrop))
+		return Common::kReadingFailed;
+
+	bool confirmed = false;
+	Common::Error confirmError = runConfirmPrompt(
+		menuBackdrop, menuPalette, 1.0f, menu.flow, config.newGamePrompt, confirmed);
+	if (confirmError.getCode() != Common::kNoError)
+		return confirmError;
+	menu.selection.needsRedraw = true;
+	if (!confirmed)
+		return Common::kNoError;
+
+	menu.flow.prepareForNewGame();
+	return runMainMenuRoomLoop(menu, "START");
+}
+
+Common::Error MenuSystem::loadGameFromMainMenu(MainMenu &menu, const byte *menuPalette) {
+	bool loadedGame = false;
+	Common::Error loadError = runLoadGameMenu(menuPalette, 1.0f, menu.flow, loadedGame);
+	if (loadError.getCode() != Common::kNoError)
+		return loadError;
+	menu.selection.needsRedraw = true;
+	if (!loadedGame)
+		return Common::kNoError;
+
+	Common::String targetName = _engine.getPendingLoadedSaveRoomState().entranceName;
+	if (targetName.empty())
+		targetName = _engine.getPendingLoadedSaveRoomState().roomName;
+	if (targetName.empty()) {
+		menu.statusMessage = "Loaded save is missing its room target.";
+		return Common::kNoError;
+	}
+
+	return runMainMenuRoomLoop(menu, targetName);
+}
+
+MenuSystem::RoomMenuActivationResult MenuSystem::startNewGameFromRoomMenu(RoomMenu &menu) {
+	const MenuTextConfig &config = menu.flow.getMenuTextConfig();
+
+	bool confirmed = false;
+	Common::Error confirmError = runConfirmPrompt(
+		menu.backdrop, menu.palette, menu.paletteBrightness, menu.flow, config.newGamePrompt, confirmed);
+	if (confirmError.getCode() != Common::kNoError)
+		return RoomMenuActivationResult(confirmError, false);
+	menu.selection.needsRedraw = true;
+	if (confirmed) {
+		menu.flow.requestNewGameRestart();
+		return RoomMenuActivationResult(Common::kNoError, true);
+	}
+	return RoomMenuActivationResult(Common::kNoError, false);
+}
+
+MenuSystem::RoomMenuActivationResult MenuSystem::saveGameFromRoomMenu(RoomMenu &menu) {
+	bool savedGame = false;
+	Common::Error saveError = runSaveGameMenu(menu.palette, menu.paletteBrightness, menu.flow, savedGame);
+	if (saveError.getCode() != Common::kNoError)
+		return RoomMenuActivationResult(saveError, false);
+	if (savedGame) {
+		// Native exits the in-room ESC menu after a successful save and restores the room view.
+		Common::Error restoreError = restoreRoomBackdropAfterSave(
+			menu.backdrop, menu.palette, menu.paletteBrightness, menu.flow);
+		if (restoreError.getCode() != Common::kNoError)
+			return RoomMenuActivationResult(restoreError, false);
+		return RoomMenuActivationResult(Common::kNoError, true);
+	}
+	menu.selection.needsRedraw = true;
+	return RoomMenuActivationResult(Common::kNoError, false);
+}
+
+Common::Error MenuSystem::activateMainMenuItem(MainMenu &menu) {
+	if (menu.selection.selectedItem < 0 || menu.selection.selectedItem >= (int)menu.selection.items.size())
+		return Common::kNoError;
+
+	const Common::String &item = menu.selection.items[menu.selection.selectedItem];
+	const byte *menuPalette = _hasMainMenuBackdrop ? _mainMenuBackdropPalette : menu.art.getWaitPalette();
+	menu.statusMessage.clear();
+	if (item.empty() || item == kBlankMenuSlot)
+		return Common::kNoError;
+	debugC(2, kDebugGeneral, "Harvester: main menu selected index=%d label='%s'",
+		menu.selection.selectedItem, item.c_str());
+
+	if (menu.selection.selectedItem == kMainMenuItemNewGame)
+		return startNewGameFromMainMenu(menu, menuPalette);
+
+	if (menu.selection.selectedItem == kMainMenuItemLoadGame)
+		return loadGameFromMainMenu(menu, menuPalette);
+
+	if (menu.selection.selectedItem == kMainMenuItemSaveGame) {
+		bool savedGame = false;
+		Common::Error saveError = runSaveGameMenu(menuPalette, 1.0f, menu.flow, savedGame);
+		(void)savedGame;
+		menu.selection.needsRedraw = true;
+		return saveError;
+	}
+
+	if (menu.selection.selectedItem == kMainMenuItemOptions) {
+		IndexedBitmap menuBackdrop;
+		if (_hasMainMenuBackdrop) {
+			menuBackdrop = _mainMenuBackdrop;
+		} else if (!captureMainMenuBackdrop(menu, menuBackdrop)) {
+			return Common::kReadingFailed;
+		}
+		Common::Error optionsError = runOptionsMenu(menuBackdrop, menuPalette, 1.0f, menu.flow);
+		menu.selection.needsRedraw = true;
+		return optionsError;
+	}
+
+	if (menu.selection.selectedItem == kMainMenuItemHelp) {
+		Common::Error helpError = runHelpScreen(menuPalette, 1.0f, menu.flow);
+		menu.selection.needsRedraw = true;
+		return helpError;
+	}
+
+	if (menu.selection.selectedItem == kMainMenuItemQuitGame) {
+		IndexedBitmap menuBackdrop;
+		if (!captureMainMenuBackdrop(menu, menuBackdrop))
+			return Common::kReadingFailed;
+		Common::Error quitError = runQuitGameConfirm(menuBackdrop, menuPalette, 1.0f, menu.flow);
+		menu.selection.needsRedraw = true;
+		return quitError;
+	}
+
+	return Common::kNoError;
+}
+
+MenuSystem::RoomMenuActivationResult MenuSystem::activateRoomMenuItem(RoomMenu &menu) {
+	if (menu.selection.selectedItem < 0 || menu.selection.selectedItem >= (int)menu.selection.items.size())
+		return RoomMenuActivationResult(Common::kNoError, false);
+
+	const Common::String &item = menu.selection.items[menu.selection.selectedItem];
+	if (item.empty() || item == kBlankMenuSlot)
+		return RoomMenuActivationResult(Common::kNoError, false);
+	debugC(2, kDebugGeneral, "Harvester: room menu selected index=%d label='%s'",
+		menu.selection.selectedItem, item.c_str());
+
+	if (menu.selection.selectedItem == kMainMenuItemNewGame)
+		return startNewGameFromRoomMenu(menu);
+
+	if (menu.selection.selectedItem == kMainMenuItemLoadGame) {
+		bool loadedGame = false;
+		Common::Error loadError = runLoadGameMenu(menu.palette, menu.paletteBrightness, menu.flow, loadedGame);
+		if (loadError.getCode() != Common::kNoError)
+			return RoomMenuActivationResult(loadError, false);
+		menu.selection.needsRedraw = true;
+		return RoomMenuActivationResult(Common::kNoError, loadedGame);
+	}
+
+	if (menu.selection.selectedItem == kMainMenuItemOptions) {
+		Common::Error optionsError = runOptionsMenu(menu.backdrop, menu.palette, menu.paletteBrightness, menu.flow);
+		if (optionsError.getCode() != Common::kNoError)
+			return RoomMenuActivationResult(optionsError, false);
+		menu.selection.needsRedraw = true;
+		return RoomMenuActivationResult(Common::kNoError, false);
+	}
+
+	if (menu.selection.selectedItem == kMainMenuItemHelp) {
+		Common::Error helpError = runHelpScreen(menu.palette, menu.paletteBrightness, menu.flow);
+		if (helpError.getCode() != Common::kNoError)
+			return RoomMenuActivationResult(helpError, false);
+		menu.selection.needsRedraw = true;
+		return RoomMenuActivationResult(Common::kNoError, false);
+	}
+
+	if (menu.selection.selectedItem == kMainMenuItemSaveGame)
+		return saveGameFromRoomMenu(menu);
+
+	if (menu.selection.selectedItem == kMainMenuItemQuitGame) {
+		Common::Error quitError = runQuitGameConfirm(menu.backdrop, menu.palette, menu.paletteBrightness, menu.flow);
+		if (quitError.getCode() != Common::kNoError)
+			return RoomMenuActivationResult(quitError, false);
+		menu.selection.needsRedraw = true;
+		return RoomMenuActivationResult(Common::kNoError, false);
+	}
+
+	debugC(1, kDebugGeneral,
+		"Harvester: room menu index=%d label='%s' selected but not implemented",
+		menu.selection.selectedItem, item.c_str());
+	return RoomMenuActivationResult(Common::kNoError, false);
+}
+
+Common::Error MenuSystem::runMainMenu(Flow &flow) {
+	const Art *art = _engine.getArt();
+	if (!art)
+		return Common::kReadingFailed;
+	MainMenu menu(_engine, *art, flow);
+
+	buildDisplayMainMenuItems(_menuItems,
+		_engine.canSaveGameStateCurrently(),
+		_engine.canLoadGameStateCurrently(),
+		menu.selection.items);
+	if (menu.flow.takePendingGameOverReturn()) {
+		Common::Error gameOverError = showGameOverBackdrop(menu.flow);
+		if (gameOverError.getCode() != Common::kNoError)
+			return gameOverError;
+	} else {
+		clearMainMenuBackdrop();
+	}
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	menu.selection.selectedItem = menu.selection.items.empty() ? -1 : 0;
+
+	while (!_engine.shouldQuit()) {
+		if (menu.selection.needsRedraw) {
+			if (_hasMainMenuBackdrop)
+				renderBackdropMenuScreen(_mainMenuBackdrop, _mainMenuBackdropPalette, 1.0f,
+					menu.selection.items, menu.selection.selectedItem);
+			else
+				renderMainMenuScreen(menu.selection.selectedItem, menu.statusMessage, true, menu.selection.items);
+			menu.selection.needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (menu.flow.handleSystemEvent(event, result))
+				return result;
+
+			const MenuNavigationCommand command = handleMenuNavigationEvent(menu.selection, event, _hasMainMenuBackdrop);
+			if (command == kCloseMenu)
+				return Common::kNoError;
+			if (command == kActivateMenuItem) {
+				Common::Error activateError = activateMainMenuItem(menu);
+				if (activateError.getCode() != Common::kNoError)
+					return activateError;
+			}
+		}
+
+		if (menu.flow.tickRuntimeEntities())
+			menu.selection.needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::runRoomMenu(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &flow, bool canSaveGame) {
+	Graphics::FrameLimiter limiter(g_system, 60);
+	RoomMenu menu(backdrop, palette, paletteBrightness, flow);
+	buildDisplayMainMenuItems(_menuItems,
+		canSaveGame && _engine.canSaveGameStateCurrently(),
+		_engine.canLoadGameStateCurrently(),
+		menu.selection.items);
+	menu.selection.selectedItem = menu.selection.items.empty() ? -1 : 0;
+	menu.flow.resetCursorAnimationSequence();
+
+	while (!_engine.shouldQuit()) {
+		if (menu.selection.needsRedraw) {
+			renderBackdropMenuScreen(menu.backdrop, menu.palette, menu.paletteBrightness,
+				menu.selection.items, menu.selection.selectedItem);
+			menu.selection.needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (menu.flow.handleSystemEvent(event, result))
+				return result;
+
+			const MenuNavigationCommand command = handleMenuNavigationEvent(menu.selection, event, true);
+			if (command == kCloseMenu)
+				return Common::kNoError;
+			if (command == kActivateMenuItem) {
+				RoomMenuActivationResult activationResult = activateRoomMenuItem(menu);
+				if (activationResult.error.getCode() != Common::kNoError)
+					return activationResult.error;
+				if (activationResult.closeMenu)
+					return Common::kNoError;
+			}
+		}
+
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+		if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
+			menu.selection.needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::showGameOverBackdrop(Flow &flow) {
+	ResourceManager *resources = _engine.getResources();
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!resources || !screen)
+		return Common::kReadingFailed;
+
+	IndexedBitmap backdrop;
+	byte palette[256 * 3];
+	if (!loadBitmapResource(*resources, kGameOverBitmapPath, backdrop) ||
+			!loadPaletteResource(*resources, kGameOverPalettePath, palette)) {
+		return Common::kReadingFailed;
+	}
+
+	_mainMenuBackdrop = Common::move(backdrop);
+	memcpy(_mainMenuBackdropPalette, palette, sizeof(_mainMenuBackdropPalette));
+	_hasMainMenuBackdrop = true;
+	flow.resetCursorAnimationSequence();
+	(void)_engine.playMusic(kGameOverMusicPath);
+
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			MenuRendering::applyMenuPalette(*screen, _engine, _mainMenuBackdropPalette, 1.0f);
+			screen->fillRect(screen->getBounds(), 0);
+			MenuRendering::blitBitmap(*screen, _mainMenuBackdrop, 0, 0);
+			if (_engine.getRuntimeEntities())
+				_engine.getRuntimeEntities()->drawCursor(*screen);
+			screen->makeAllDirty();
+			screen->update();
+			needsRedraw = false;
+		}
+
+		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:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+			case Common::EVENT_RBUTTONDOWN:
+			case Common::EVENT_KEYDOWN:
+				return Common::kNoError;
+			default:
+				break;
+			}
+		}
+
+		if (EntityManager *entityManager = _engine.getRuntimeEntities()) {
+			if (entityManager->syncCursorEntityPosition(_mousePos))
+				needsRedraw = true;
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+void MenuSystem::clearMainMenuBackdrop() {
+	_mainMenuBackdrop = IndexedBitmap();
+	memset(_mainMenuBackdropPalette, 0, sizeof(_mainMenuBackdropPalette));
+	_hasMainMenuBackdrop = false;
+}
+
+int MenuSystem::getMenuItemAt(const Common::Point &mousePos,
+		const Common::Array<Common::String> &menuItems) const {
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!font)
+		return -1;
+
+	for (uint i = 0; i < menuItems.size(); ++i) {
+		const int width = font->getStringWidth(menuItems[i]);
+		const int x = (640 - width) / 2;
+		const int y = MenuRendering::kMenuStartY + (int)i * MenuRendering::kMenuLineSpacing;
+		const Common::Rect bounds(x - 12, y - 2, x + width + 12, y + font->getFontHeight() + 2);
+		if (bounds.contains(mousePos))
+			return (int)i;
+	}
+
+	return -1;
+}
+
+int MenuSystem::getBackdropMenuItemAt(const Common::Point &mousePos,
+		const Common::Array<Common::String> &menuItems) const {
+	const Text *text = _engine.getText();
+	const CftFontResource *selectedFontResource = text ? text->findFont("HARVFONT") : nullptr;
+	if (!selectedFontResource)
+		return getMenuItemAt(mousePos, menuItems);
+
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	if (!selectedFont.isValid())
+		return getMenuItemAt(mousePos, menuItems);
+
+	return getNativeRoomMenuSelectionFromMouse(selectedFont, menuItems.size(), mousePos);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index bd4adfe4416..237c743e3bf 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -14,6 +14,7 @@ MODULE_OBJS = \
 	media_manager.o \
 	menu.o \
 	menu_config.o \
+	menu_navigation.o \
 	menu_options.o \
 	menu_render.o \
 	metaengine.o \


Commit: 78e23bd564ea0bdceed2fb31b085fe76305c578b
    https://github.com/scummvm/scummvm/commit/78e23bd564ea0bdceed2fb31b085fe76305c578b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Separate save menus and modal prompts

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/menu_prompts.cpp
  A engines/harvester/menu_save.cpp
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/menu_options.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 3d0e8849f63..67db206a4ef 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -23,75 +23,22 @@
 #include "harvester/menu_render.h"
 
 #include "common/algorithm.h"
-#include "common/config-manager.h"
 #include "common/events.h"
 #include "common/system.h"
-#include "engines/metaengine.h"
 #include "graphics/font.h"
 #include "graphics/framelimiter.h"
-#include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
-#include "harvester/palette_utils.h"
 #include "harvester/resources.h"
-#include "harvester/room_support.h"
 #include "harvester/entity.h"
 #include "harvester/art.h"
 #include "harvester/flow.h"
-#include "harvester/script.h"
-#include "harvester/text.h"
 
 namespace Harvester {
 
 namespace {
 
-static const char *const kLoadGameBitmapPath = "1:/GRAPHIC/OTHER/LOADGAME.BM";
-static const char *const kLoadGamePalettePath = "1:/GRAPHIC/PAL/LOADGAME.PAL";
-static const char *const kSaveGameBitmapPath = "1:/GRAPHIC/OTHER/SAVEGAME.BM";
-static const char *const kSaveGamePalettePath = "1:/GRAPHIC/PAL/SAVEGAME.PAL";
-static const int kSaveSlotCount = 25;
-static const int kSaveSlotLabelX = 8;
-static const int kSaveSlotNameX = 0x50;
-static const int kSaveSlotStartY = 0x19;
-static const int kSaveSlotStride = 0x11;
-static const int kSaveSlotTextWidth = 0x27b - kSaveSlotNameX;
-static const int kSaveStatusTextY = 0x1b8;
-static const int kSaveSlotMaxCharacters = 0xf6;
-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;
-
-static Common::Rect saveSlotRect(int slotIndex) {
-	const int top = kSaveSlotStartY + slotIndex * kSaveSlotStride;
-	return Common::Rect(8, top, 0x27b + 1, top + kSaveSlotStride);
-}
-
-static Common::Rect saveConfirmRect() {
-	return Common::Rect(3, 0x1cc, 0x13d + 1, 0x1dc + 1);
-}
-
-static Common::Rect saveCancelRect() {
-	return Common::Rect(0x141, 0x1cc, 0x27b + 1, 0x1dc + 1);
-}
-
-static Common::Rect quitConfirmYesRect() {
-	return Common::Rect(0xd2, 0xee, 0x104 + 1, 0x108 + 1);
-}
-
-static Common::Rect quitConfirmNoRect() {
-	return Common::Rect(0x172, 0xee, 0x19a + 1, 0x108 + 1);
-}
-
-} // End of anonymous namespace
-
-namespace {
-
-static Common::String buildDefaultSaveSlotLabel(int slotIndex) {
-	return Common::String::format("GAME %02d", slotIndex + 1);
-}
 
 } // End of anonymous namespace
 
@@ -205,577 +152,6 @@ Common::Error MenuSystem::showCdChangePrompt(int discNumber, ResourceManager &re
 	return Common::kNoError;
 }
 
-struct MenuSystem::SaveSlotMenu {
-	SaveSlotMenu(MetaEngine &meta, const CftFontResource &nameFont, const CftFontResource &labelFont)
-		: metaEngine(meta), slotNameFont(nameFont), slotLabelFont(labelFont) {}
-
-	MetaEngine &metaEngine;
-	HarvesterCftFont slotNameFont;
-	HarvesterCftFont slotLabelFont;
-	IndexedBitmap background;
-	byte palette[256 * 3];
-	Common::Array<Common::String> slotTitles;
-	int activeSlot = 0;
-	Common::String statusMessage;
-	bool needsRedraw = true;
-};
-
-void MenuSystem::reloadSaveSlotTitles(SaveSlotMenu &menu) const {
-	menu.slotTitles.clear();
-	menu.slotTitles.resize(kSaveSlotCount);
-	const SaveStateList saves = menu.metaEngine.listSaves(ConfMan.getActiveDomainName().c_str());
-	for (const SaveStateDescriptor &save : saves) {
-		const int slot = save.getSaveSlot();
-		if (slot >= 0 && slot < kSaveSlotCount)
-			menu.slotTitles[slot] = save.getDescription();
-	}
-}
-
-int MenuSystem::getSaveSlotAt(const Common::Point &mousePos) const {
-	for (int slot = 0; slot < kSaveSlotCount; ++slot) {
-		if (saveSlotRect(slot).contains(mousePos))
-			return slot;
-	}
-	return -1;
-}
-
-MenuSystem::SaveSlotCommand MenuSystem::getSaveSlotCommand(const Common::Event &event) const {
-	if (event.type == Common::EVENT_KEYDOWN) {
-		if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-			return kCancelSlot;
-		if (event.kbd.keycode == Common::KEYCODE_RETURN || event.kbd.keycode == Common::KEYCODE_KP_ENTER)
-			return kConfirmSlot;
-	} else if (event.type == Common::EVENT_LBUTTONDOWN) {
-		if (saveConfirmRect().contains(_mousePos))
-			return kConfirmSlot;
-		if (saveCancelRect().contains(_mousePos))
-			return kCancelSlot;
-	}
-	return kNoSlotCommand;
-}
-
-void MenuSystem::renderSaveSlotMenu(const SaveSlotMenu &menu, const Common::String *editingText) const {
-	Graphics::Screen *screen = _engine.getScreen();
-	if (!screen)
-		return;
-
-	MenuRendering::applyMenuPalette(*screen, _engine, menu.palette, 1.0f);
-	MenuRendering::blitBitmap(*screen, menu.background, 0, 0);
-
-	for (int i = 0; i < kSaveSlotCount; ++i) {
-		const Graphics::Font &labelFont = (i == menu.activeSlot) ? menu.slotNameFont : menu.slotLabelFont;
-		const int y = kSaveSlotStartY + i * kSaveSlotStride;
-		const Common::String slotLabel = buildDefaultSaveSlotLabel(i);
-		labelFont.drawString(screen, slotLabel, kSaveSlotLabelX, y,
-			labelFont.getStringWidth(slotLabel), 0);
-
-		Common::String displayTitle = menu.slotTitles[i];
-		if (i == menu.activeSlot && editingText)
-			displayTitle = *editingText + "_";
-		menu.slotNameFont.drawString(screen, displayTitle, kSaveSlotNameX, y, kSaveSlotTextWidth, 0);
-	}
-
-	if (!editingText && !menu.statusMessage.empty()) {
-		MenuRendering::drawShadowedString(*screen, menu.slotNameFont, menu.statusMessage,
-			MenuRendering::TextLayout(20, kSaveStatusTextY, screen->w - 40),
-			MenuRendering::kTextColorNormal, Graphics::kTextAlignCenter);
-	}
-
-	if (_engine.getRuntimeEntities())
-		_engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
-Common::Error MenuSystem::runSaveSlotEditor(SaveSlotMenu &menu, Flow &flow,
-		Common::String &editedTitle, bool &confirmed) {
-	confirmed = false;
-	bool needsRedraw = true;
-	flow.resetCursorAnimationSequence();
-	Graphics::FrameLimiter limiter(g_system, 60);
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			renderSaveSlotMenu(menu, &editedTitle);
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (flow.handleSystemEvent(event, result))
-				return result;
-
-			const SaveSlotCommand command = getSaveSlotCommand(event);
-			if (command != kNoSlotCommand) {
-				confirmed = command == kConfirmSlot;
-				return Common::kNoError;
-			}
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE:
-				needsRedraw = true;
-				break;
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
-					needsRedraw |= deleteTextEntryCharacter(editedTitle);
-					break;
-				}
-				if (event.kbd.ascii >= 32 && event.kbd.ascii <= 126 && event.kbd.ascii != '~') {
-					needsRedraw |= appendTextEntryCharacter(editedTitle, menu.slotNameFont,
-						event.kbd.ascii, kSaveSlotMaxCharacters, kSaveSlotTextWidth);
-				}
-				break;
-			default:
-				break;
-			}
-		}
-
-		if (EntityManager *entityManager = _engine.getRuntimeEntities())
-			(void)entityManager->syncCursorEntityPosition(_mousePos);
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
-void MenuSystem::loadSelectedSlot(SaveSlotMenu &menu, bool &loaded) {
-	if (menu.slotTitles[menu.activeSlot].empty())
-		return;
-
-	Common::Error loadError = _engine.loadGameState(menu.activeSlot);
-	if (loadError.getCode() == Common::kNoError) {
-		loaded = true;
-		return;
-	}
-
-	menu.statusMessage = loadError.getDesc();
-	menu.needsRedraw = true;
-}
-
-Common::Error MenuSystem::saveSelectedSlot(SaveSlotMenu &menu, Flow &flow, bool &saved) {
-	saved = false;
-	Common::String editedTitle = menu.slotTitles[menu.activeSlot];
-	bool confirmed = false;
-	Common::Error entryError = runSaveSlotEditor(menu, flow, editedTitle, confirmed);
-	if (entryError.getCode() != Common::kNoError)
-		return entryError;
-	if (!confirmed)
-		return Common::kNoError;
-
-	Common::Error saveError = _engine.saveGameState(menu.activeSlot, editedTitle);
-	if (saveError.getCode() == Common::kNoError) {
-		saved = true;
-		return Common::kNoError;
-	}
-
-	menu.statusMessage = saveError.getDesc();
-	reloadSaveSlotTitles(menu);
-	menu.needsRedraw = true;
-	return Common::kNoError;
-}
-
-Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrightness,
-		Flow &flow, bool &loadedGame) {
-	return runSaveSlotMenu(flow, kLoadMode, loadedGame);
-}
-
-Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrightness,
-		Flow &flow, bool &savedGame) {
-	return runSaveSlotMenu(flow, kSaveMode, savedGame);
-}
-
-Common::Error MenuSystem::runSaveSlotMenu(Flow &flow, SaveLoadMode mode, bool &completed) {
-	completed = false;
-	ResourceManager *resources = _engine.getResources();
-	MetaEngine *metaEngine = _engine.getMetaEngine();
-	const Text *text = _engine.getText();
-	const CftFontResource *slotNameFontResource = text ? text->findFont("MEDFONT1") : nullptr;
-	const CftFontResource *slotLabelFontResource = text ? text->findFont("MEDFONT2") : nullptr;
-	if (!resources || !metaEngine || !slotNameFontResource || !slotLabelFontResource)
-		return Common::kReadingFailed;
-	if (mode == kSaveMode ? !_engine.canSaveGameStateCurrently() : !_engine.canLoadGameStateCurrently())
-		return Common::kNoError;
-
-	SaveSlotMenu menu(*metaEngine, *slotNameFontResource, *slotLabelFontResource);
-	if (!menu.slotNameFont.isValid() || !menu.slotLabelFont.isValid())
-		return Common::kReadingFailed;
-
-	if (!loadBitmapResource(*resources, mode == kSaveMode ? kSaveGameBitmapPath : kLoadGameBitmapPath, menu.background) ||
-			!loadPaletteResource(*resources, mode == kSaveMode ? kSaveGamePalettePath : kLoadGamePalettePath, menu.palette)) {
-		return Common::kReadingFailed;
-	}
-
-	reloadSaveSlotTitles(menu);
-	if (mode == kLoadMode) {
-		for (int slot = 0; slot < kSaveSlotCount; ++slot) {
-			if (!menu.slotTitles[slot].empty()) {
-				menu.activeSlot = slot;
-				break;
-			}
-		}
-	}
-
-	flow.resetCursorAnimationSequence();
-	Graphics::FrameLimiter limiter(g_system, 60);
-
-	while (!_engine.shouldQuit()) {
-		if (menu.needsRedraw) {
-			renderSaveSlotMenu(menu);
-			menu.needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (flow.handleSystemEvent(event, result))
-				return result;
-
-			bool activateSlot = false;
-			bool redrawAfterActivation = false;
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE: {
-				const int hoveredSlot = getSaveSlotAt(_mousePos);
-				if (hoveredSlot != -1 && hoveredSlot != menu.activeSlot)
-					menu.activeSlot = hoveredSlot;
-				menu.needsRedraw = true;
-				break;
-			}
-			case Common::EVENT_LBUTTONDOWN: {
-				const int hoveredSlot = getSaveSlotAt(_mousePos);
-				menu.statusMessage.clear();
-				if (hoveredSlot != -1) {
-					menu.activeSlot = hoveredSlot;
-					activateSlot = true;
-					// Cancelling a row-opened save editor does not force a browser redraw.
-					redrawAfterActivation = mode == kLoadMode;
-				} else {
-					const SaveSlotCommand command = getSaveSlotCommand(event);
-					if (command == kCancelSlot)
-						return Common::kNoError;
-					activateSlot = command == kConfirmSlot;
-					redrawAfterActivation = true;
-				}
-				break;
-			}
-			case Common::EVENT_KEYDOWN:
-				if (getSaveSlotCommand(event) == kCancelSlot)
-					return Common::kNoError;
-				if (event.kbd.keycode == Common::KEYCODE_UP || event.kbd.keycode == Common::KEYCODE_DOWN) {
-					const int step = event.kbd.keycode == Common::KEYCODE_UP ? kSaveSlotCount - 1 : 1;
-					menu.activeSlot = (menu.activeSlot + step) % kSaveSlotCount;
-					menu.statusMessage.clear();
-					menu.needsRedraw = true;
-				} else if (getSaveSlotCommand(event) == kConfirmSlot) {
-					menu.statusMessage.clear();
-					activateSlot = true;
-					redrawAfterActivation = true;
-				}
-				break;
-			default:
-				break;
-			}
-
-			if (activateSlot) {
-				Common::Error actionError = Common::kNoError;
-				if (mode == kSaveMode)
-					actionError = saveSelectedSlot(menu, flow, completed);
-				else
-					loadSelectedSlot(menu, completed);
-				if (actionError.getCode() != Common::kNoError)
-					return actionError;
-				if (completed)
-					return Common::kNoError;
-			}
-			if (redrawAfterActivation)
-				menu.needsRedraw = true;
-		}
-
-		if (EntityManager *entityManager = _engine.getRuntimeEntities())
-			(void)entityManager->syncCursorEntityPosition(_mousePos);
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
-Common::Error MenuSystem::restoreRoomBackdropAfterSave(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &flow) const {
-	Graphics::Screen *screen = _engine.getScreen();
-	if (!screen || !backdrop.isValid())
-		return Common::kNoError;
-
-	byte displayPalette[256 * 3];
-	screen->getPalette(displayPalette);
-
-	bool hasVisiblePalette = false;
-	for (byte value : displayPalette) {
-		if (value != 0) {
-			hasVisiblePalette = true;
-			break;
-		}
-	}
-
-	if (hasVisiblePalette) {
-		for (float brightness = 1.0f - kPaletteFadeStep;
-				brightness > kPaletteBrightnessBlack; brightness -= kPaletteFadeStep) {
-			byte fadedPalette[256 * 3];
-			buildHarvesterDisplayPalette(displayPalette, brightness, fadedPalette);
-			screen->setPalette(fadedPalette);
-			screen->makeAllDirty();
-			screen->update();
-
-			const uint32 nextTick = g_system->getMillis() + kPaletteFadeTickMs;
-			while ((int32)(nextTick - g_system->getMillis()) > 0) {
-				Common::Event event;
-				while (g_system->getEventManager()->pollEvent(event)) {
-					Common::Error result = Common::kNoError;
-					if (flow.handleSystemEvent(event, result))
-						return result;
-				}
-				g_system->delayMillis(1);
-			}
-		}
-
-		byte blackPalette[256 * 3] = { 0 };
-		screen->setPalette(blackPalette);
-		screen->makeAllDirty();
-		screen->update();
-	}
-
-	screen->fillRect(screen->getBounds(), 0);
-	MenuRendering::blitBitmap(*screen, backdrop, 0, 0);
-	MenuRendering::applyMenuPalette(*screen, _engine, palette, kPaletteBrightnessBlack);
-	screen->makeAllDirty();
-	screen->update();
-
-	return flow.fadeInRoomScene(palette, paletteBrightness);
-}
-
-Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &flow, const Common::String &promptText,
-		bool &confirmed) {
-	const Art *art = _engine.getArt();
-	const Text *text = _engine.getText();
-	const CftFontResource *promptFontResource = text ? text->findFont("MEDFONT1") : nullptr;
-	const CftFontResource *choiceFontResource = text ? text->findFont("MEDFONT2") : nullptr;
-	if (!art || !promptFontResource || !choiceFontResource)
-		return Common::kReadingFailed;
-
-	HarvesterCftFont promptFont(*promptFontResource);
-	HarvesterCftFont choiceFont(*choiceFontResource);
-	if (!promptFont.isValid() || !choiceFont.isValid())
-		return Common::kReadingFailed;
-
-	const MenuTextConfig &config = flow.getMenuTextConfig();
-	const IndexedBitmap *textbox = art->getTextboxBitmap(3);
-	if (!textbox || !textbox->isValid())
-		return Common::kReadingFailed;
-
-	confirmed = false;
-	flow.resetCursorAnimationSequence();
-	bool needsRedraw = true;
-	Graphics::FrameLimiter limiter(g_system, 60);
-
-	while (!_engine.shouldQuit()) {
-		const bool hoverYes = quitConfirmYesRect().contains(_mousePos);
-		const bool hoverNo = quitConfirmNoRect().contains(_mousePos);
-		if (needsRedraw) {
-			const Graphics::Font &yesFont = hoverYes ? static_cast<const Graphics::Font &>(promptFont)
-				: static_cast<const Graphics::Font &>(choiceFont);
-			const Graphics::Font &noFont = hoverNo ? static_cast<const Graphics::Font &>(promptFont)
-				: static_cast<const Graphics::Font &>(choiceFont);
-			MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderConfirmPrompt(
-				promptFont, yesFont, noFont, *textbox, promptText, config);
-			needsRedraw = false;
-		}
-
-		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:
-				needsRedraw = true;
-				break;
-			case Common::EVENT_RBUTTONDOWN:
-				return Common::kNoError;
-			case Common::EVENT_LBUTTONDOWN:
-				if (quitConfirmYesRect().contains(_mousePos)) {
-					confirmed = true;
-					return Common::kNoError;
-				}
-				if (quitConfirmNoRect().contains(_mousePos))
-					return Common::kNoError;
-				break;
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return Common::kNoError;
-				if (event.kbd.keycode == Common::KEYCODE_y || event.kbd.ascii == 'y' || event.kbd.ascii == 'Y') {
-					confirmed = true;
-					return Common::kNoError;
-				}
-				if (event.kbd.keycode == Common::KEYCODE_n || event.kbd.ascii == 'n' || event.kbd.ascii == 'N')
-					return Common::kNoError;
-				break;
-			default:
-				break;
-			}
-		}
-
-		if (EntityManager *entityManager = _engine.getRuntimeEntities())
-			(void)entityManager->syncCursorEntityPosition(_mousePos);
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
-Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &flow) {
-	const MenuTextConfig &config = flow.getMenuTextConfig();
-
-	bool confirmed = false;
-	Common::Error confirmError = runConfirmPrompt(
-		backdrop, palette, paletteBrightness, flow, config.quitGamePrompt, confirmed);
-	if (confirmError.getCode() != Common::kNoError)
-		return confirmError;
-	if (!confirmed)
-		return Common::kNoError;
-
-	_engine.stopMusic();
-	_engine.stopSound();
-	_engine.quitGame();
-	return Common::kNoError;
-}
-
-Common::Error MenuSystem::runPasswordPrompt(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &flow, bool drawLogo, Common::String &password,
-		bool &accepted) const {
-	const Art *art = _engine.getArt();
-	const Text *text = _engine.getText();
-	const CftFontResource *titleFontResource = text ? text->findFont("HARVFONT") : nullptr;
-	const CftFontResource *entryFontResource = text ? text->findFont("HARVFNT2") : nullptr;
-	if (!art || !titleFontResource || !entryFontResource)
-		return Common::kReadingFailed;
-
-	HarvesterCftFont titleFont(*titleFontResource);
-	HarvesterCftFont entryFont(*entryFontResource);
-	if (!titleFont.isValid() || !entryFont.isValid())
-		return Common::kReadingFailed;
-
-	password.clear();
-	accepted = false;
-	bool needsRedraw = true;
-	bool cursorVisible = true;
-	uint32 cursorToggleTicks = g_system->getMillis() + kPasswordCursorBlinkMs;
-	Graphics::FrameLimiter limiter(g_system, 60);
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderPasswordPrompt(
-				MenuRendering::FontPair(titleFont, entryFont), *art,
-				flow.getMenuTextConfig().enterPasswordLabel, password, cursorVisible, drawLogo);
-			needsRedraw = false;
-		}
-
-		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_RBUTTONDOWN:
-				return Common::kNoError;
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return Common::kNoError;
-				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					accepted = !password.empty();
-					return Common::kNoError;
-				}
-				if (event.kbd.keycode == Common::KEYCODE_BACKSPACE ||
-						event.kbd.keycode == Common::KEYCODE_LEFT) {
-					needsRedraw |= deleteTextEntryCharacter(password);
-					break;
-				}
-				if (event.kbd.keycode == Common::KEYCODE_HOME) {
-					if (!password.empty()) {
-						password.clear();
-						needsRedraw = true;
-					}
-					break;
-				}
-				if (event.kbd.ascii >= 32 && event.kbd.ascii <= 126 && event.kbd.ascii != '~') {
-					needsRedraw |= appendTextEntryCharacter(password, entryFont, event.kbd.ascii,
-						kPasswordMaxCharacters, MenuRendering::kPasswordEntryWidth);
-				}
-				break;
-			default:
-				break;
-			}
-		}
-
-		const uint32 now = g_system->getMillis();
-		if ((int32)(now - cursorToggleTicks) >= 0) {
-			cursorVisible = !cursorVisible;
-			cursorToggleTicks = now + kPasswordCursorBlinkMs;
-			needsRedraw = true;
-		}
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
-Common::Error MenuSystem::validateParentalPassword(Flow &flow) {
-	Script *script = _engine.getScript();
-	Graphics::Screen *screen = _engine.getScreen();
-	if (!script || !screen)
-		return Common::kReadingFailed;
-
-	const Common::String &configuredPassword = script->getParentalPassword();
-	if (configuredPassword.empty()) {
-		debugC(2, kDebugGeneral, "Harvester: parental password startup gate bypassed state=disabled");
-		return Common::kNoError;
-	}
-
-	IndexedBitmap backdrop;
-	if (!captureScreenBackdrop(*screen, backdrop))
-		return Common::kReadingFailed;
-
-	debugC(1, kDebugGeneral, "Harvester: parental password startup gate active");
-	Common::String enteredPassword;
-	bool accepted = false;
-	Common::Error promptError = runPasswordPrompt(
-		backdrop, nullptr, 1.0f, flow, false, enteredPassword, accepted);
-	if (promptError.getCode() != Common::kNoError || _engine.shouldQuit())
-		return promptError;
-
-	if (!accepted || !configuredPassword.equalsIgnoreCase(enteredPassword)) {
-		debugC(1, kDebugGeneral,
-			"Harvester: parental password blocked startup reason=%s",
-			accepted ? "mismatch" : "empty-or-cancelled");
-		_engine.quitGame();
-		return Common::kNoError;
-	}
-
-	debugC(1, kDebugGeneral, "Harvester: parental password accepted; startup permitted");
-	return Common::kNoError;
-}
-
 Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBrightness, Flow &flow) {
 	ResourceManager *resources = _engine.getResources();
 	if (!resources)
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index 71fb256e6e0..a1df12c29fb 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -135,9 +135,12 @@ private:
 		const Graphics::Font &selectedFont, bool &closed);
 
 	struct SaveSlotMenu;
+	struct SaveSlotEventResult;
 	enum SaveLoadMode { kSaveMode, kLoadMode };
 	enum SaveSlotCommand { kNoSlotCommand, kConfirmSlot, kCancelSlot };
 
+	SaveSlotEventResult handleSaveSlotEvent(SaveSlotMenu &menu, SaveLoadMode mode,
+		const Common::Event &event) const;
 	Common::Error runSaveSlotMenu(Flow &flow, SaveLoadMode mode, bool &completed);
 	void reloadSaveSlotTitles(SaveSlotMenu &menu) const;
 	int getSaveSlotAt(const Common::Point &mousePos) const;
@@ -162,9 +165,12 @@ private:
 		bool &confirmed);
 	Common::Error runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &flow);
+	struct PasswordPromptResult {
+		Common::String password;
+		bool accepted = false;
+	};
 	Common::Error runPasswordPrompt(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &flow, bool drawLogo, Common::String &password,
-		bool &accepted) const;
+		float paletteBrightness, Flow &flow, bool drawLogo, PasswordPromptResult &prompt) const;
 	Common::Error showGameOverBackdrop(Flow &flow);
 	void clearMainMenuBackdrop();
 	void renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
diff --git a/engines/harvester/menu_options.cpp b/engines/harvester/menu_options.cpp
index 14069aa293b..743ef42f384 100644
--- a/engines/harvester/menu_options.cpp
+++ b/engines/harvester/menu_options.cpp
@@ -142,18 +142,17 @@ void MenuSystem::toggleOptionsGore(OptionsMenu &menu) {
 void MenuSystem::toggleOptionsPassword(OptionsMenu &menu) {
 	if (menu.script.getParentalPassword().empty()) {
 		debugC(2, kDebugGeneral, "Harvester: parental password set prompt opened");
-		Common::String password;
-		bool accepted = false;
+		PasswordPromptResult prompt;
 		Common::Error promptError = runPasswordPrompt(
-			menu.backdrop, menu.palette, menu.paletteBrightness, menu.flow, true, password, accepted);
+			menu.backdrop, menu.palette, menu.paletteBrightness, menu.flow, true, prompt);
 		if (promptError.getCode() != Common::kNoError) {
 			debugC(1, kDebugGeneral,
 				"Harvester: parental password set prompt failed error=%d",
 				static_cast<int>(promptError.getCode()));
 			return;
 		}
-		if (accepted) {
-			menu.script.setParentalPassword(password);
+		if (prompt.accepted) {
+			menu.script.setParentalPassword(prompt.password);
 			if (!menu.script.saveConfig()) {
 				menu.script.setParentalPassword(Common::String());
 				debugC(1, kDebugGeneral,
diff --git a/engines/harvester/menu_prompts.cpp b/engines/harvester/menu_prompts.cpp
new file mode 100644
index 00000000000..e15a4d85f7c
--- /dev/null
+++ b/engines/harvester/menu_prompts.cpp
@@ -0,0 +1,268 @@
+/* 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/menu.h"
+#include "harvester/menu_render.h"
+
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/flow.h"
+#include "harvester/harvester.h"
+#include "harvester/room_support.h"
+#include "harvester/script.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+static const int kPasswordMaxCharacters = 8;
+static const uint32 kPasswordCursorBlinkMs = 480;
+
+static Common::Rect quitConfirmYesRect() {
+	return Common::Rect(0xd2, 0xee, 0x104 + 1, 0x108 + 1);
+}
+
+static Common::Rect quitConfirmNoRect() {
+	return Common::Rect(0x172, 0xee, 0x19a + 1, 0x108 + 1);
+}
+
+Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &flow, const Common::String &promptText,
+		bool &confirmed) {
+	const Art *art = _engine.getArt();
+	const Text *text = _engine.getText();
+	const CftFontResource *promptFontResource = text ? text->findFont("MEDFONT1") : nullptr;
+	const CftFontResource *choiceFontResource = text ? text->findFont("MEDFONT2") : nullptr;
+	if (!art || !promptFontResource || !choiceFontResource)
+		return Common::kReadingFailed;
+
+	HarvesterCftFont promptFont(*promptFontResource);
+	HarvesterCftFont choiceFont(*choiceFontResource);
+	if (!promptFont.isValid() || !choiceFont.isValid())
+		return Common::kReadingFailed;
+
+	const MenuTextConfig &config = flow.getMenuTextConfig();
+	const IndexedBitmap *textbox = art->getTextboxBitmap(3);
+	if (!textbox || !textbox->isValid())
+		return Common::kReadingFailed;
+
+	confirmed = false;
+	flow.resetCursorAnimationSequence();
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		const bool hoverYes = quitConfirmYesRect().contains(_mousePos);
+		const bool hoverNo = quitConfirmNoRect().contains(_mousePos);
+		if (needsRedraw) {
+			const Graphics::Font &yesFont = hoverYes ? static_cast<const Graphics::Font &>(promptFont)
+				: static_cast<const Graphics::Font &>(choiceFont);
+			const Graphics::Font &noFont = hoverNo ? static_cast<const Graphics::Font &>(promptFont)
+				: static_cast<const Graphics::Font &>(choiceFont);
+			MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderConfirmPrompt(
+				promptFont, yesFont, noFont, *textbox, promptText, config);
+			needsRedraw = false;
+		}
+
+		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:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_RBUTTONDOWN:
+				return Common::kNoError;
+			case Common::EVENT_LBUTTONDOWN:
+				if (quitConfirmYesRect().contains(_mousePos)) {
+					confirmed = true;
+					return Common::kNoError;
+				}
+				if (quitConfirmNoRect().contains(_mousePos))
+					return Common::kNoError;
+				break;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+				if (event.kbd.keycode == Common::KEYCODE_y || event.kbd.ascii == 'y' || event.kbd.ascii == 'Y') {
+					confirmed = true;
+					return Common::kNoError;
+				}
+				if (event.kbd.keycode == Common::KEYCODE_n || event.kbd.ascii == 'n' || event.kbd.ascii == 'N')
+					return Common::kNoError;
+				break;
+			default:
+				break;
+			}
+		}
+
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			(void)entityManager->syncCursorEntityPosition(_mousePos);
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &flow) {
+	const MenuTextConfig &config = flow.getMenuTextConfig();
+
+	bool confirmed = false;
+	Common::Error confirmError = runConfirmPrompt(
+		backdrop, palette, paletteBrightness, flow, config.quitGamePrompt, confirmed);
+	if (confirmError.getCode() != Common::kNoError)
+		return confirmError;
+	if (!confirmed)
+		return Common::kNoError;
+
+	_engine.stopMusic();
+	_engine.stopSound();
+	_engine.quitGame();
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::runPasswordPrompt(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &flow, bool drawLogo, PasswordPromptResult &prompt) const {
+	const Art *art = _engine.getArt();
+	const Text *text = _engine.getText();
+	const CftFontResource *titleFontResource = text ? text->findFont("HARVFONT") : nullptr;
+	const CftFontResource *entryFontResource = text ? text->findFont("HARVFNT2") : nullptr;
+	if (!art || !titleFontResource || !entryFontResource)
+		return Common::kReadingFailed;
+
+	HarvesterCftFont titleFont(*titleFontResource);
+	HarvesterCftFont entryFont(*entryFontResource);
+	if (!titleFont.isValid() || !entryFont.isValid())
+		return Common::kReadingFailed;
+
+	prompt.password.clear();
+	prompt.accepted = false;
+	bool needsRedraw = true;
+	bool cursorVisible = true;
+	uint32 cursorToggleTicks = g_system->getMillis() + kPasswordCursorBlinkMs;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			MenuRendering::Renderer(_engine, backdrop, palette, paletteBrightness).renderPasswordPrompt(
+				MenuRendering::FontPair(titleFont, entryFont), *art,
+				flow.getMenuTextConfig().enterPasswordLabel, prompt.password, cursorVisible, drawLogo);
+			needsRedraw = false;
+		}
+
+		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_RBUTTONDOWN:
+				return Common::kNoError;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					prompt.accepted = !prompt.password.empty();
+					return Common::kNoError;
+				}
+				if (event.kbd.keycode == Common::KEYCODE_BACKSPACE ||
+						event.kbd.keycode == Common::KEYCODE_LEFT) {
+					needsRedraw |= deleteTextEntryCharacter(prompt.password);
+					break;
+				}
+				if (event.kbd.keycode == Common::KEYCODE_HOME) {
+					if (!prompt.password.empty()) {
+						prompt.password.clear();
+						needsRedraw = true;
+					}
+					break;
+				}
+				if (event.kbd.ascii >= 32 && event.kbd.ascii <= 126 && event.kbd.ascii != '~') {
+					needsRedraw |= appendTextEntryCharacter(prompt.password, entryFont, event.kbd.ascii,
+						kPasswordMaxCharacters, MenuRendering::kPasswordEntryWidth);
+				}
+				break;
+			default:
+				break;
+			}
+		}
+
+		const uint32 now = g_system->getMillis();
+		if ((int32)(now - cursorToggleTicks) >= 0) {
+			cursorVisible = !cursorVisible;
+			cursorToggleTicks = now + kPasswordCursorBlinkMs;
+			needsRedraw = true;
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::validateParentalPassword(Flow &flow) {
+	Script *script = _engine.getScript();
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!script || !screen)
+		return Common::kReadingFailed;
+
+	const Common::String &configuredPassword = script->getParentalPassword();
+	if (configuredPassword.empty()) {
+		debugC(2, kDebugGeneral, "Harvester: parental password startup gate bypassed state=disabled");
+		return Common::kNoError;
+	}
+
+	IndexedBitmap backdrop;
+	if (!captureScreenBackdrop(*screen, backdrop))
+		return Common::kReadingFailed;
+
+	debugC(1, kDebugGeneral, "Harvester: parental password startup gate active");
+	PasswordPromptResult prompt;
+	Common::Error promptError = runPasswordPrompt(
+		backdrop, nullptr, 1.0f, flow, false, prompt);
+	if (promptError.getCode() != Common::kNoError || _engine.shouldQuit())
+		return promptError;
+
+	if (!prompt.accepted || !configuredPassword.equalsIgnoreCase(prompt.password)) {
+		debugC(1, kDebugGeneral,
+			"Harvester: parental password blocked startup reason=%s",
+			prompt.accepted ? "mismatch" : "empty-or-cancelled");
+		_engine.quitGame();
+		return Common::kNoError;
+	}
+
+	debugC(1, kDebugGeneral, "Harvester: parental password accepted; startup permitted");
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/menu_save.cpp b/engines/harvester/menu_save.cpp
new file mode 100644
index 00000000000..607f52d3213
--- /dev/null
+++ b/engines/harvester/menu_save.cpp
@@ -0,0 +1,438 @@
+/* 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/menu.h"
+#include "harvester/menu_render.h"
+
+#include "common/algorithm.h"
+#include "common/config-manager.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "engines/metaengine.h"
+#include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
+#include "harvester/entity.h"
+#include "harvester/flow.h"
+#include "harvester/harvester.h"
+#include "harvester/palette_utils.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+static const char *const kLoadGameBitmapPath = "1:/GRAPHIC/OTHER/LOADGAME.BM";
+static const char *const kLoadGamePalettePath = "1:/GRAPHIC/PAL/LOADGAME.PAL";
+static const char *const kSaveGameBitmapPath = "1:/GRAPHIC/OTHER/SAVEGAME.BM";
+static const char *const kSaveGamePalettePath = "1:/GRAPHIC/PAL/SAVEGAME.PAL";
+static const int kSaveSlotCount = 25;
+static const int kSaveSlotLabelX = 8;
+static const int kSaveSlotNameX = 0x50;
+static const int kSaveSlotStartY = 0x19;
+static const int kSaveSlotStride = 0x11;
+static const int kSaveSlotTextWidth = 0x27b - kSaveSlotNameX;
+static const int kSaveStatusTextY = 0x1b8;
+static const int kSaveSlotMaxCharacters = 0xf6;
+static const uint32 kPaletteFadeTickMs = 4;
+static const float kPaletteFadeStep = 0.1f;
+static const float kPaletteBrightnessBlack = 0.0f;
+
+static Common::Rect saveSlotRect(int slotIndex) {
+	const int top = kSaveSlotStartY + slotIndex * kSaveSlotStride;
+	return Common::Rect(8, top, 0x27b + 1, top + kSaveSlotStride);
+}
+
+static Common::Rect saveConfirmRect() {
+	return Common::Rect(3, 0x1cc, 0x13d + 1, 0x1dc + 1);
+}
+
+static Common::Rect saveCancelRect() {
+	return Common::Rect(0x141, 0x1cc, 0x27b + 1, 0x1dc + 1);
+}
+
+static Common::String buildDefaultSaveSlotLabel(int slotIndex) {
+	return Common::String::format("GAME %02d", slotIndex + 1);
+}
+
+struct MenuSystem::SaveSlotMenu {
+	SaveSlotMenu(MetaEngine &meta, const CftFontResource &nameFont, const CftFontResource &labelFont)
+		: metaEngine(meta), slotNameFont(nameFont), slotLabelFont(labelFont) {}
+
+	MetaEngine &metaEngine;
+	HarvesterCftFont slotNameFont;
+	HarvesterCftFont slotLabelFont;
+	IndexedBitmap background;
+	byte palette[256 * 3];
+	Common::Array<Common::String> slotTitles;
+	int activeSlot = 0;
+	Common::String statusMessage;
+	bool needsRedraw = true;
+};
+
+struct MenuSystem::SaveSlotEventResult {
+	explicit SaveSlotEventResult(SaveSlotCommand command = kNoSlotCommand) : command(command) {}
+
+	SaveSlotCommand command;
+	bool redrawAfterActivation = false;
+};
+
+void MenuSystem::reloadSaveSlotTitles(SaveSlotMenu &menu) const {
+	menu.slotTitles.clear();
+	menu.slotTitles.resize(kSaveSlotCount);
+	const SaveStateList saves = menu.metaEngine.listSaves(ConfMan.getActiveDomainName().c_str());
+	for (const SaveStateDescriptor &save : saves) {
+		const int slot = save.getSaveSlot();
+		if (slot >= 0 && slot < kSaveSlotCount)
+			menu.slotTitles[slot] = save.getDescription();
+	}
+}
+
+int MenuSystem::getSaveSlotAt(const Common::Point &mousePos) const {
+	for (int slot = 0; slot < kSaveSlotCount; ++slot) {
+		if (saveSlotRect(slot).contains(mousePos))
+			return slot;
+	}
+	return -1;
+}
+
+MenuSystem::SaveSlotCommand MenuSystem::getSaveSlotCommand(const Common::Event &event) const {
+	if (event.type == Common::EVENT_KEYDOWN) {
+		if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+			return kCancelSlot;
+		if (event.kbd.keycode == Common::KEYCODE_RETURN || event.kbd.keycode == Common::KEYCODE_KP_ENTER)
+			return kConfirmSlot;
+	} else if (event.type == Common::EVENT_LBUTTONDOWN) {
+		if (saveConfirmRect().contains(_mousePos))
+			return kConfirmSlot;
+		if (saveCancelRect().contains(_mousePos))
+			return kCancelSlot;
+	}
+	return kNoSlotCommand;
+}
+
+void MenuSystem::renderSaveSlotMenu(const SaveSlotMenu &menu, const Common::String *editingText) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen)
+		return;
+
+	MenuRendering::applyMenuPalette(*screen, _engine, menu.palette, 1.0f);
+	MenuRendering::blitBitmap(*screen, menu.background, 0, 0);
+
+	for (int i = 0; i < kSaveSlotCount; ++i) {
+		const Graphics::Font &labelFont = (i == menu.activeSlot) ? menu.slotNameFont : menu.slotLabelFont;
+		const int y = kSaveSlotStartY + i * kSaveSlotStride;
+		const Common::String slotLabel = buildDefaultSaveSlotLabel(i);
+		labelFont.drawString(screen, slotLabel, kSaveSlotLabelX, y,
+			labelFont.getStringWidth(slotLabel), 0);
+
+		Common::String displayTitle = menu.slotTitles[i];
+		if (i == menu.activeSlot && editingText)
+			displayTitle = *editingText + "_";
+		menu.slotNameFont.drawString(screen, displayTitle, kSaveSlotNameX, y, kSaveSlotTextWidth, 0);
+	}
+
+	if (!editingText && !menu.statusMessage.empty()) {
+		MenuRendering::drawShadowedString(*screen, menu.slotNameFont, menu.statusMessage,
+			MenuRendering::TextLayout(20, kSaveStatusTextY, screen->w - 40),
+			MenuRendering::kTextColorNormal, Graphics::kTextAlignCenter);
+	}
+
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+Common::Error MenuSystem::runSaveSlotEditor(SaveSlotMenu &menu, Flow &flow,
+		Common::String &editedTitle, bool &confirmed) {
+	confirmed = false;
+	bool needsRedraw = true;
+	flow.resetCursorAnimationSequence();
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderSaveSlotMenu(menu, &editedTitle);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (flow.handleSystemEvent(event, result))
+				return result;
+
+			const SaveSlotCommand command = getSaveSlotCommand(event);
+			if (command != kNoSlotCommand) {
+				confirmed = command == kConfirmSlot;
+				return Common::kNoError;
+			}
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
+					needsRedraw |= deleteTextEntryCharacter(editedTitle);
+					break;
+				}
+				if (event.kbd.ascii >= 32 && event.kbd.ascii <= 126 && event.kbd.ascii != '~') {
+					needsRedraw |= appendTextEntryCharacter(editedTitle, menu.slotNameFont,
+						event.kbd.ascii, kSaveSlotMaxCharacters, kSaveSlotTextWidth);
+				}
+				break;
+			default:
+				break;
+			}
+		}
+
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			(void)entityManager->syncCursorEntityPosition(_mousePos);
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+void MenuSystem::loadSelectedSlot(SaveSlotMenu &menu, bool &loaded) {
+	if (menu.slotTitles[menu.activeSlot].empty())
+		return;
+
+	Common::Error loadError = _engine.loadGameState(menu.activeSlot);
+	if (loadError.getCode() == Common::kNoError) {
+		loaded = true;
+		return;
+	}
+
+	menu.statusMessage = loadError.getDesc();
+	menu.needsRedraw = true;
+}
+
+Common::Error MenuSystem::saveSelectedSlot(SaveSlotMenu &menu, Flow &flow, bool &saved) {
+	saved = false;
+	Common::String editedTitle = menu.slotTitles[menu.activeSlot];
+	bool confirmed = false;
+	Common::Error entryError = runSaveSlotEditor(menu, flow, editedTitle, confirmed);
+	if (entryError.getCode() != Common::kNoError)
+		return entryError;
+	if (!confirmed)
+		return Common::kNoError;
+
+	Common::Error saveError = _engine.saveGameState(menu.activeSlot, editedTitle);
+	if (saveError.getCode() == Common::kNoError) {
+		saved = true;
+		return Common::kNoError;
+	}
+
+	menu.statusMessage = saveError.getDesc();
+	reloadSaveSlotTitles(menu);
+	menu.needsRedraw = true;
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrightness,
+		Flow &flow, bool &loadedGame) {
+	return runSaveSlotMenu(flow, kLoadMode, loadedGame);
+}
+
+Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrightness,
+		Flow &flow, bool &savedGame) {
+	return runSaveSlotMenu(flow, kSaveMode, savedGame);
+}
+
+MenuSystem::SaveSlotEventResult MenuSystem::handleSaveSlotEvent(SaveSlotMenu &menu,
+		SaveLoadMode mode, const Common::Event &event) const {
+	SaveSlotEventResult result;
+	switch (event.type) {
+	case Common::EVENT_MOUSEMOVE: {
+		const int hoveredSlot = getSaveSlotAt(_mousePos);
+		if (hoveredSlot != -1 && hoveredSlot != menu.activeSlot)
+			menu.activeSlot = hoveredSlot;
+		menu.needsRedraw = true;
+		break;
+	}
+	case Common::EVENT_LBUTTONDOWN: {
+		const int hoveredSlot = getSaveSlotAt(_mousePos);
+		menu.statusMessage.clear();
+		if (hoveredSlot != -1) {
+			menu.activeSlot = hoveredSlot;
+			result.command = kConfirmSlot;
+			// Cancelling a row-opened save editor does not force a browser redraw.
+			result.redrawAfterActivation = mode == kLoadMode;
+		} else {
+			const SaveSlotCommand command = getSaveSlotCommand(event);
+			if (command == kCancelSlot)
+				return SaveSlotEventResult(kCancelSlot);
+			result.command = command;
+			result.redrawAfterActivation = true;
+		}
+		break;
+	}
+	case Common::EVENT_KEYDOWN:
+		if (getSaveSlotCommand(event) == kCancelSlot)
+			return SaveSlotEventResult(kCancelSlot);
+		if (event.kbd.keycode == Common::KEYCODE_UP || event.kbd.keycode == Common::KEYCODE_DOWN) {
+			const int step = event.kbd.keycode == Common::KEYCODE_UP ? kSaveSlotCount - 1 : 1;
+			menu.activeSlot = (menu.activeSlot + step) % kSaveSlotCount;
+			menu.statusMessage.clear();
+			menu.needsRedraw = true;
+		} else if (getSaveSlotCommand(event) == kConfirmSlot) {
+			menu.statusMessage.clear();
+			result.command = kConfirmSlot;
+			result.redrawAfterActivation = true;
+		}
+		break;
+	default:
+		break;
+	}
+	return result;
+}
+
+Common::Error MenuSystem::runSaveSlotMenu(Flow &flow, SaveLoadMode mode, bool &completed) {
+	completed = false;
+	ResourceManager *resources = _engine.getResources();
+	MetaEngine *metaEngine = _engine.getMetaEngine();
+	const Text *text = _engine.getText();
+	const CftFontResource *slotNameFontResource = text ? text->findFont("MEDFONT1") : nullptr;
+	const CftFontResource *slotLabelFontResource = text ? text->findFont("MEDFONT2") : nullptr;
+	if (!resources || !metaEngine || !slotNameFontResource || !slotLabelFontResource)
+		return Common::kReadingFailed;
+	if (mode == kSaveMode ? !_engine.canSaveGameStateCurrently() : !_engine.canLoadGameStateCurrently())
+		return Common::kNoError;
+
+	SaveSlotMenu menu(*metaEngine, *slotNameFontResource, *slotLabelFontResource);
+	if (!menu.slotNameFont.isValid() || !menu.slotLabelFont.isValid())
+		return Common::kReadingFailed;
+
+	if (!loadBitmapResource(*resources, mode == kSaveMode ? kSaveGameBitmapPath : kLoadGameBitmapPath, menu.background) ||
+			!loadPaletteResource(*resources, mode == kSaveMode ? kSaveGamePalettePath : kLoadGamePalettePath, menu.palette)) {
+		return Common::kReadingFailed;
+	}
+
+	reloadSaveSlotTitles(menu);
+	if (mode == kLoadMode) {
+		for (int slot = 0; slot < kSaveSlotCount; ++slot) {
+			if (!menu.slotTitles[slot].empty()) {
+				menu.activeSlot = slot;
+				break;
+			}
+		}
+	}
+
+	flow.resetCursorAnimationSequence();
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		if (menu.needsRedraw) {
+			renderSaveSlotMenu(menu);
+			menu.needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (flow.handleSystemEvent(event, result))
+				return result;
+
+			const SaveSlotEventResult selection = handleSaveSlotEvent(menu, mode, event);
+			if (selection.command == kCancelSlot)
+				return Common::kNoError;
+
+			if (selection.command == kConfirmSlot) {
+				Common::Error actionError = Common::kNoError;
+				if (mode == kSaveMode)
+					actionError = saveSelectedSlot(menu, flow, completed);
+				else
+					loadSelectedSlot(menu, completed);
+				if (actionError.getCode() != Common::kNoError)
+					return actionError;
+				if (completed)
+					return Common::kNoError;
+			}
+			if (selection.redrawAfterActivation)
+				menu.needsRedraw = true;
+		}
+
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			(void)entityManager->syncCursorEntityPosition(_mousePos);
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::restoreRoomBackdropAfterSave(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &flow) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen || !backdrop.isValid())
+		return Common::kNoError;
+
+	byte displayPalette[256 * 3];
+	screen->getPalette(displayPalette);
+
+	bool hasVisiblePalette = false;
+	for (byte value : displayPalette) {
+		if (value != 0) {
+			hasVisiblePalette = true;
+			break;
+		}
+	}
+
+	if (hasVisiblePalette) {
+		for (float brightness = 1.0f - kPaletteFadeStep;
+				brightness > kPaletteBrightnessBlack; brightness -= kPaletteFadeStep) {
+			byte fadedPalette[256 * 3];
+			buildHarvesterDisplayPalette(displayPalette, brightness, fadedPalette);
+			screen->setPalette(fadedPalette);
+			screen->makeAllDirty();
+			screen->update();
+
+			const uint32 nextTick = g_system->getMillis() + kPaletteFadeTickMs;
+			while ((int32)(nextTick - g_system->getMillis()) > 0) {
+				Common::Event event;
+				while (g_system->getEventManager()->pollEvent(event)) {
+					Common::Error result = Common::kNoError;
+					if (flow.handleSystemEvent(event, result))
+						return result;
+				}
+				g_system->delayMillis(1);
+			}
+		}
+
+		byte blackPalette[256 * 3] = { 0 };
+		screen->setPalette(blackPalette);
+		screen->makeAllDirty();
+		screen->update();
+	}
+
+	screen->fillRect(screen->getBounds(), 0);
+	MenuRendering::blitBitmap(*screen, backdrop, 0, 0);
+	MenuRendering::applyMenuPalette(*screen, _engine, palette, kPaletteBrightnessBlack);
+	screen->makeAllDirty();
+	screen->update();
+
+	return flow.fadeInRoomScene(palette, paletteBrightness);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 237c743e3bf..6342e00f125 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -16,7 +16,9 @@ MODULE_OBJS = \
 	menu_config.o \
 	menu_navigation.o \
 	menu_options.o \
+	menu_prompts.o \
 	menu_render.o \
+	menu_save.o \
 	metaengine.o \
 	monster.o \
 	player.o \


Commit: 7dbf808e6edc4dbaf614088c61962212426c7e88
    https://github.com/scummvm/scummvm/commit/7dbf808e6edc4dbaf614088c61962212426c7e88
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Separate combat targeting and reach geometry

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/combat_geometry.cpp
  A engines/harvester/combat_targeting.cpp
    engines/harvester/combat.cpp
    engines/harvester/combat.h
    engines/harvester/module.mk


diff --git a/engines/harvester/combat.cpp b/engines/harvester/combat.cpp
index cb7ad589a40..a7c64505576 100644
--- a/engines/harvester/combat.cpp
+++ b/engines/harvester/combat.cpp
@@ -48,7 +48,6 @@ const int kMuckeyAttackContactFrameOffset = 7;
 const float kNativeMonsterPursuitZTolerance = 2.0f;
 const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
 const uint32 kNativeKeyboardAttackRepeatTicks = 25;
-const int kNativeAttackSideWindow = 30;
 const int kNativeCombatHitKnockbackDistance = 18;
 const int kNativeCombatHitKnockbackDecayStep = 3;
 const char *const kNativeHitEffectResourcePath = "blood.abm";
@@ -65,26 +64,6 @@ 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 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))
@@ -103,61 +82,6 @@ static int findRoomMonsterIndexByName(const RoomSetupState &state, const Common:
 	return -1;
 }
 
-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;
@@ -250,22 +174,8 @@ RoomMonsterFacingAnimationRange resolveRoomMonsterFacingAnimationRange(int facin
 	}
 }
 
-int computeRectAxisGap(int minA, int maxA, int minB, int maxB) {
-	if (maxA <= minB)
-		return minB - maxA;
-	if (maxB <= minA)
-		return minA - maxB;
-	return 0;
-}
-
 } // End of anonymous namespace
 
-float clampRoomDepthForEvent(const RoomSetupState &state, float z) {
-	return CLIP<float>(z,
-		static_cast<float>(MIN(state.roomMinZ, state.roomMaxZ)),
-		static_cast<float>(MAX(state.roomMinZ, state.roomMaxZ)));
-}
-
 bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc) {
 	return npc.monsterfyTargetName.empty();
 }
@@ -465,49 +375,6 @@ int stepTowardsRoomCombatInt(int current, int target, int step) {
 	return MAX(current - step, target);
 }
 
-int computeRuntimeEntityHorizontalGap(const Entity &left, const Entity &right) {
-	const Common::Rect leftRect = left.getScreenRect();
-	const Common::Rect rightRect = right.getScreenRect();
-	return computeRectAxisGap(leftRect.left, leftRect.right, rightRect.left, rightRect.right);
-}
-
-bool doRuntimeEntityDepthExtentsOverlap(const Entity &first, const Entity &second) {
-	const float firstZMin = first.getZ();
-	const float firstZMax = firstZMin + first.getZExtent();
-	const float secondZMin = second.getZ();
-	const float secondZMax = secondZMin + second.getZExtent();
-	return !(firstZMax < secondZMin || secondZMax < firstZMin);
-}
-
-bool areCombatantsWithinRoomCombatReach(const RoomSetupState &state,
-		const Entity &attacker, float attackerZ,
-		const Entity &target, float targetZ, int engageDistance) {
-	if (attacker.overlapsEntity(target))
-		return true;
-
-	const float zDelta = attackerZ >= targetZ ? attackerZ - targetZ : targetZ - attackerZ;
-	if (zDelta > kNativeMonsterPursuitZTolerance)
-		return false;
-
-	const float nearZ = attackerZ < targetZ ? attackerZ : targetZ;
-	const int nativeTolerance = roundRoomCombatFloat(
-		Player::computeDepthScale(state, nearZ) * kNativeMonsterHorizontalWaypointTolerance);
-	return computeRuntimeEntityHorizontalGap(attacker, target) <= MAX(MAX(0, engageDistance), nativeTolerance);
-}
-
-bool isWithinNativeMonsterAttackEntryRange(const Entity &monster, float monsterZ,
-		const Entity &player, float playerZ, int engageDistance) {
-	const float zDelta = monsterZ >= playerZ ? monsterZ - playerZ : playerZ - monsterZ;
-	if (zDelta > kNativeMonsterPursuitZTolerance)
-		return false;
-
-	const Common::Rect monsterRect = monster.getScreenRect();
-	const Common::Rect playerRect = player.getScreenRect();
-	const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
-	const int playerCenterX = playerRect.left + playerRect.width() / 2;
-	return ABS(playerCenterX - monsterCenterX) <= MAX(0, engageDistance);
-}
-
 bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
 		const Common::String &sound2, const Common::String &sound3) {
 	const Common::String *availableSounds[3];
@@ -560,10 +427,6 @@ bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
 	return true;
 }
 
-int roundRoomCombatFloat(float value) {
-	return value >= 0.0f ? static_cast<int>(value + 0.5f) : static_cast<int>(value - 0.5f);
-}
-
 int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY) {
 	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
 		return fallbackY;
@@ -605,374 +468,6 @@ Combat::Combat(HarvesterEngine &engine, SceneState &scene, Player &player,
 	  _callbacks(callbacks), _interactionProcessor(interactionProcessor) {
 }
 
-MonsterRecord *Combat::findMonsterTargetAtPoint(const Common::Point &point) const {
-	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(_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 *Combat::findOverlappingMonsterTarget() const {
-	const RoomPlayerState &playerState = _player.getState();
-	if (!playerState.entity)
-		return nullptr;
-
-	for (MonsterRecord &monster : _scene.state.roomMonsters) {
-		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
-			continue;
-
-		Entity *entity = findSceneRuntimeEntity(_entityManager, monster.monsterName);
-		if (entity && areCombatantsWithinRoomCombatReach(_scene.state,
-				*playerState.entity, playerState.z, *entity, static_cast<float>(monster.posZ), monster.engageDistance))
-			return &monster;
-	}
-
-	return nullptr;
-}
-
-NpcRecord *Combat::findOverlappingNpcTarget() const {
-	const RoomPlayerState &playerState = _player.getState();
-	if (!playerState.entity)
-		return nullptr;
-
-	for (NpcRecord &npc : _scene.state.roomNpcs) {
-		if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
-			continue;
-
-		Entity *entity = findSceneRuntimeEntity(_entityManager, npc.npcName);
-		if (entity && areCombatantsWithinRoomCombatReach(_scene.state,
-				*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
-			return &npc;
-	}
-
-	return nullptr;
-}
-
-bool Combat::playerAttackCanReachTarget(Entity *targetEntity, int engageDistance) const {
-	const RoomPlayerState &playerState = _player.getState();
-	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);
-}
-
-MonsterRecord *Combat::findProjectileContactMonsterTarget() const {
-	const RoomPlayerState &playerState = _player.getState();
-	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(_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 Combat::capturePlayerAttackTargetForKeyboard(bool attackLeft, bool attackRight, bool attackUp, bool attackDown) const {
-	const RoomPlayerState &playerState = _player.getState();
-	_player.clearAttackTarget();
-	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 : _scene.state.roomNpcs) {
-		if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
-			continue;
-		considerTarget(findSceneRuntimeEntity(_entityManager, npc.npcName), npc.npcName, kEntityClassNpc);
-	}
-	for (MonsterRecord &monster : _scene.state.roomMonsters) {
-		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
-			continue;
-		considerTarget(findSceneRuntimeEntity(_entityManager, monster.monsterName), monster.monsterName, kEntityClassMonster);
-	}
-
-	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;
-	}
-
-	_player.captureAttackTarget(bestName, 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 *Combat::resolveKillTarget(const Common::Point &mousePos) const {
-	const RoomPlayerState &playerState = _player.getState();
-	if (playerState.attackTargetClassId == kEntityClassMonster) {
-		MonsterRecord *monster = findRoomMonsterRecordByName(_scene.state, playerState.attackTargetName);
-		if (isKillableMonster(monster) && selectMonsterEntity(_entityManager, *monster))
-			return monster;
-	}
-
-	if (MonsterRecord *monster = findMonsterTargetAtPoint(mousePos)) {
-		if (isKillableMonster(monster) && selectMonsterEntity(_entityManager, *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 MonsterCombatState &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 : _scene.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 Combat::capturePlayerAttackTarget(const Common::Point &mousePos, const NpcRecord *hoverNpc) const {
-	const RoomPlayerState &playerState = _player.getState();
-	if (hoverNpc) {
-		_player.captureAttackTarget(hoverNpc->npcName, kEntityClassNpc);
-		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)) {
-		_player.captureAttackTarget(monster->monsterName, kEntityClassMonster);
-		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 Combat::captureOverlappingNpcAttackTarget() const {
-	const RoomPlayerState &playerState = _player.getState();
-	if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
-		if (NpcRecord *fallbackNpc = findOverlappingNpcTarget()) {
-			_player.captureAttackTarget(fallbackNpc->npcName, kEntityClassNpc);
-		}
-	}
-}
-
-NpcRecord *Combat::selectNpcAttackContactTarget(Entity *&npcEntity) const {
-	const RoomPlayerState &playerState = _player.getState();
-	NpcRecord *npc = findRoomNpcRecordByName(_scene.state, playerState.attackTargetName);
-	npcEntity = npc ? findSceneRuntimeEntity(_entityManager, npc->npcName) : nullptr;
-	if (!npc || !npcEntity || !playerAttackCanReachTarget(npcEntity))
-		npc = Player::isProjectileCombatLoadout(playerState.combatLoadout) ? nullptr : findOverlappingNpcTarget();
-	npcEntity = npc ? findSceneRuntimeEntity(_entityManager, npc->npcName) : nullptr;
-	return npc;
-}
-
-MonsterRecord *Combat::selectMonsterAttackContactTarget(Entity *&monsterEntity) const {
-	const RoomPlayerState &playerState = _player.getState();
-	MonsterRecord *monster = nullptr;
-	if (playerState.attackTargetClassId == kEntityClassMonster)
-		monster = findRoomMonsterRecordByName(_scene.state, playerState.attackTargetName);
-	monsterEntity = monster ? findSceneRuntimeEntity(_entityManager, 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) {
-				_player.captureAttackTarget(monster->monsterName, kEntityClassMonster);
-			}
-		} else {
-			monster = nullptr;
-		}
-		monsterEntity = monster ? findSceneRuntimeEntity(_entityManager, monster->monsterName) : nullptr;
-	}
-	return monster;
-}
-
 NpcCombatUpdateResult Combat::updateRoomNpcCombat(bool &needsRedraw) {
 	NpcCombatUpdateResult result;
 	for (uint i = 0; i < _scene.state.roomNpcs.size(); ++i) {
diff --git a/engines/harvester/combat.h b/engines/harvester/combat.h
index 95c6008eeb5..5d49b6c6f9f 100644
--- a/engines/harvester/combat.h
+++ b/engines/harvester/combat.h
@@ -170,6 +170,10 @@ private:
 	void startMonsterHitReaction(MonsterRecord &monster, Entity &monsterEntity, MonsterCombatState *combatState,
 		int attackerAttackFirstFrame, bool &needsRedraw) const;
 
+	static Entity *findSceneRuntimeEntity(EntityManager *entityManager, const Common::String &name);
+	static NpcRecord *findRoomNpcRecordByName(RoomSetupState &state, const Common::String &name);
+	static Entity *selectMonsterEntity(EntityManager *entityManager, const MonsterRecord &monster);
+
 	MonsterRecord *findMonsterTargetAtPoint(const Common::Point &point) const;
 	MonsterRecord *findOverlappingMonsterTarget() const;
 	NpcRecord *findOverlappingNpcTarget() const;
diff --git a/engines/harvester/combat_geometry.cpp b/engines/harvester/combat_geometry.cpp
new file mode 100644
index 00000000000..4a472c28e8d
--- /dev/null
+++ b/engines/harvester/combat_geometry.cpp
@@ -0,0 +1,95 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/combat.h"
+
+#include "common/util.h"
+#include "harvester/player.h"
+#include "harvester/entity.h"
+
+namespace Harvester {
+
+namespace {
+
+int computeRectAxisGap(int minA, int maxA, int minB, int maxB) {
+	if (maxA <= minB)
+		return minB - maxA;
+	if (maxB <= minA)
+		return minA - maxB;
+	return 0;
+}
+
+} // End of anonymous namespace
+
+float clampRoomDepthForEvent(const RoomSetupState &state, float z) {
+	return CLIP<float>(z,
+		static_cast<float>(MIN(state.roomMinZ, state.roomMaxZ)),
+		static_cast<float>(MAX(state.roomMinZ, state.roomMaxZ)));
+}
+
+int computeRuntimeEntityHorizontalGap(const Entity &left, const Entity &right) {
+	const Common::Rect leftRect = left.getScreenRect();
+	const Common::Rect rightRect = right.getScreenRect();
+	return computeRectAxisGap(leftRect.left, leftRect.right, rightRect.left, rightRect.right);
+}
+
+bool doRuntimeEntityDepthExtentsOverlap(const Entity &first, const Entity &second) {
+	const float firstZMin = first.getZ();
+	const float firstZMax = firstZMin + first.getZExtent();
+	const float secondZMin = second.getZ();
+	const float secondZMax = secondZMin + second.getZExtent();
+	return !(firstZMax < secondZMin || secondZMax < firstZMin);
+}
+
+bool areCombatantsWithinRoomCombatReach(const RoomSetupState &state,
+		const Entity &attacker, float attackerZ,
+		const Entity &target, float targetZ, int engageDistance) {
+	if (attacker.overlapsEntity(target))
+		return true;
+
+	const float zDelta = attackerZ >= targetZ ? attackerZ - targetZ : targetZ - attackerZ;
+	if (zDelta > kNativeMonsterPursuitZTolerance)
+		return false;
+
+	const float nearZ = attackerZ < targetZ ? attackerZ : targetZ;
+	const int nativeTolerance = roundRoomCombatFloat(
+		Player::computeDepthScale(state, nearZ) * kNativeMonsterHorizontalWaypointTolerance);
+	return computeRuntimeEntityHorizontalGap(attacker, target) <= MAX(MAX(0, engageDistance), nativeTolerance);
+}
+
+bool isWithinNativeMonsterAttackEntryRange(const Entity &monster, float monsterZ,
+		const Entity &player, float playerZ, int engageDistance) {
+	const float zDelta = monsterZ >= playerZ ? monsterZ - playerZ : playerZ - monsterZ;
+	if (zDelta > kNativeMonsterPursuitZTolerance)
+		return false;
+
+	const Common::Rect monsterRect = monster.getScreenRect();
+	const Common::Rect playerRect = player.getScreenRect();
+	const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
+	const int playerCenterX = playerRect.left + playerRect.width() / 2;
+	return ABS(playerCenterX - monsterCenterX) <= MAX(0, engageDistance);
+}
+
+int roundRoomCombatFloat(float value) {
+	return value >= 0.0f ? static_cast<int>(value + 0.5f) : static_cast<int>(value - 0.5f);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/combat_targeting.cpp b/engines/harvester/combat_targeting.cpp
new file mode 100644
index 00000000000..93ac3fb9423
--- /dev/null
+++ b/engines/harvester/combat_targeting.cpp
@@ -0,0 +1,485 @@
+/* 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/combat.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/player.h"
+#include "harvester/entity.h"
+
+namespace Harvester {
+
+const int kNativeAttackSideWindow = 30;
+
+namespace {
+
+static MonsterRecord *findRoomMonsterRecordByName(RoomSetupState &state, const Common::String &name) {
+	for (MonsterRecord &monster : state.roomMonsters) {
+		if (monster.monsterName.equalsIgnoreCase(name))
+			return &monster;
+	}
+	return nullptr;
+}
+
+static bool isKillableMonster(const MonsterRecord *monster) {
+	return monster && monster->active && monster->visible && monster->currentHitPoints > 0;
+}
+
+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;
+	}
+}
+
+struct NativeAttackGeometry {
+	NativeAttackSide side = kNativeAttackSideNone;
+	NativeAttackBand band = kNativeAttackBandMid;
+};
+
+static NativeAttackGeometry resolveKeyboardAttackGeometry(int facing,
+		bool attackLeft, bool attackRight, bool attackUp, bool attackDown) {
+	NativeAttackGeometry geometry;
+	if (attackRight) {
+		geometry.side = kNativeAttackSideRight;
+		geometry.band = kNativeAttackBandMid;
+	} else if (attackLeft) {
+		geometry.side = kNativeAttackSideLeft;
+		geometry.band = kNativeAttackBandMid;
+	} else if (attackUp) {
+		if (facing == 2) {
+			geometry.side = kNativeAttackSideRight;
+			geometry.band = kNativeAttackBandUpper;
+		} else if (facing == 1) {
+			geometry.side = kNativeAttackSideLeft;
+			geometry.band = kNativeAttackBandUpper;
+		}
+	} else if (attackDown) {
+		if (facing == 2) {
+			geometry.side = kNativeAttackSideRight;
+			geometry.band = kNativeAttackBandLower;
+		} else if (facing == 1) {
+			geometry.side = kNativeAttackSideLeft;
+			geometry.band = kNativeAttackBandLower;
+		}
+	}
+	return geometry;
+}
+
+static bool isBetterDirectionalAttackTarget(const Common::Rect &playerRect, int playerCenterX,
+		const Common::Rect &targetRect, NativeAttackSide attackSide, NativeAttackBand attackBand,
+		int bestLeadingEdge) {
+	const int playerLeft = playerRect.left;
+	const int playerRight = playerRect.right;
+	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 false;
+	} else {
+		if (attackBand == kNativeAttackBandMid)
+			matchesDirection = playerCenterX < targetLeft;
+		else
+			matchesDirection =
+				playerCenterX < targetLeft && targetCenterX < playerRight + kNativeAttackSideWindow;
+		if (!matchesDirection || targetLeft >= bestLeadingEdge)
+			return false;
+	}
+	return true;
+}
+
+} // End of anonymous namespace
+
+Entity *Combat::findSceneRuntimeEntity(EntityManager *entityManager, const Common::String &name) {
+	return entityManager ? entityManager->findSceneEntityByName(name) : nullptr;
+}
+
+NpcRecord *Combat::findRoomNpcRecordByName(RoomSetupState &state, const Common::String &name) {
+	for (NpcRecord &npc : state.roomNpcs) {
+		if (npc.npcName.equalsIgnoreCase(name))
+			return &npc;
+	}
+	return nullptr;
+}
+
+Entity *Combat::selectMonsterEntity(EntityManager *entityManager, const MonsterRecord &monster) {
+	Entity *entity = findSceneRuntimeEntity(entityManager, monster.monsterName);
+	return (entity && entity->isVisible()) ? entity : nullptr;
+}
+
+MonsterRecord *Combat::findMonsterTargetAtPoint(const Common::Point &point) const {
+	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(_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 *Combat::findOverlappingMonsterTarget() const {
+	const RoomPlayerState &playerState = _player.getState();
+	if (!playerState.entity)
+		return nullptr;
+
+	for (MonsterRecord &monster : _scene.state.roomMonsters) {
+		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+			continue;
+
+		Entity *entity = findSceneRuntimeEntity(_entityManager, monster.monsterName);
+		if (entity && areCombatantsWithinRoomCombatReach(_scene.state,
+				*playerState.entity, playerState.z, *entity, static_cast<float>(monster.posZ), monster.engageDistance))
+			return &monster;
+	}
+
+	return nullptr;
+}
+
+NpcRecord *Combat::findOverlappingNpcTarget() const {
+	const RoomPlayerState &playerState = _player.getState();
+	if (!playerState.entity)
+		return nullptr;
+
+	for (NpcRecord &npc : _scene.state.roomNpcs) {
+		if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
+			continue;
+
+		Entity *entity = findSceneRuntimeEntity(_entityManager, npc.npcName);
+		if (entity && areCombatantsWithinRoomCombatReach(_scene.state,
+				*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
+			return &npc;
+	}
+
+	return nullptr;
+}
+
+bool Combat::playerAttackCanReachTarget(Entity *targetEntity, int engageDistance) const {
+	const RoomPlayerState &playerState = _player.getState();
+	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);
+}
+
+MonsterRecord *Combat::findProjectileContactMonsterTarget() const {
+	const RoomPlayerState &playerState = _player.getState();
+	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 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(_entityManager, monster.monsterName);
+		if (!entity || !entity->isVisible() ||
+				!doRuntimeEntityDepthExtentsOverlap(*playerState.entity, *entity)) {
+			continue;
+		}
+
+		const Common::Rect targetRect = entity->getScreenRect();
+		const int targetLeft = targetRect.left;
+		if (!isBetterDirectionalAttackTarget(playerRect, playerCenterX,
+				targetRect, attackSide, attackBand, 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 Combat::capturePlayerAttackTargetForKeyboard(bool attackLeft, bool attackRight, bool attackUp, bool attackDown) const {
+	const RoomPlayerState &playerState = _player.getState();
+	_player.clearAttackTarget();
+	if (!playerState.entity)
+		return;
+
+	const NativeAttackGeometry geometry = resolveKeyboardAttackGeometry(
+		playerState.facing, attackLeft, attackRight, attackUp, attackDown);
+	if (geometry.side == 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();
+	// 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 = geometry.side == 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;
+		if (!isBetterDirectionalAttackTarget(playerRect, playerCenterX,
+				targetRect, geometry.side, geometry.band, 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(_entityManager, npc.npcName), npc.npcName, kEntityClassNpc);
+	}
+	for (MonsterRecord &monster : _scene.state.roomMonsters) {
+		if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+			continue;
+		considerTarget(findSceneRuntimeEntity(_entityManager, monster.monsterName), monster.monsterName, kEntityClassMonster);
+	}
+
+	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;
+	}
+
+	_player.captureAttackTarget(bestName, 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 *Combat::resolveKillTarget(const Common::Point &mousePos) const {
+	const RoomPlayerState &playerState = _player.getState();
+	if (playerState.attackTargetClassId == kEntityClassMonster) {
+		MonsterRecord *monster = findRoomMonsterRecordByName(_scene.state, playerState.attackTargetName);
+		if (isKillableMonster(monster) && selectMonsterEntity(_entityManager, *monster))
+			return monster;
+	}
+
+	if (MonsterRecord *monster = findMonsterTargetAtPoint(mousePos)) {
+		if (isKillableMonster(monster) && selectMonsterEntity(_entityManager, *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 MonsterCombatState &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 : _scene.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 Combat::capturePlayerAttackTarget(const Common::Point &mousePos, const NpcRecord *hoverNpc) const {
+	const RoomPlayerState &playerState = _player.getState();
+	if (hoverNpc) {
+		_player.captureAttackTarget(hoverNpc->npcName, kEntityClassNpc);
+		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)) {
+		_player.captureAttackTarget(monster->monsterName, kEntityClassMonster);
+		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 Combat::captureOverlappingNpcAttackTarget() const {
+	const RoomPlayerState &playerState = _player.getState();
+	if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
+		if (NpcRecord *fallbackNpc = findOverlappingNpcTarget()) {
+			_player.captureAttackTarget(fallbackNpc->npcName, kEntityClassNpc);
+		}
+	}
+}
+
+NpcRecord *Combat::selectNpcAttackContactTarget(Entity *&npcEntity) const {
+	const RoomPlayerState &playerState = _player.getState();
+	NpcRecord *npc = findRoomNpcRecordByName(_scene.state, playerState.attackTargetName);
+	npcEntity = npc ? findSceneRuntimeEntity(_entityManager, npc->npcName) : nullptr;
+	if (!npc || !npcEntity || !playerAttackCanReachTarget(npcEntity))
+		npc = Player::isProjectileCombatLoadout(playerState.combatLoadout) ? nullptr : findOverlappingNpcTarget();
+	npcEntity = npc ? findSceneRuntimeEntity(_entityManager, npc->npcName) : nullptr;
+	return npc;
+}
+
+MonsterRecord *Combat::selectMonsterAttackContactTarget(Entity *&monsterEntity) const {
+	const RoomPlayerState &playerState = _player.getState();
+	MonsterRecord *monster = nullptr;
+	if (playerState.attackTargetClassId == kEntityClassMonster)
+		monster = findRoomMonsterRecordByName(_scene.state, playerState.attackTargetName);
+	monsterEntity = monster ? findSceneRuntimeEntity(_entityManager, 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) {
+				_player.captureAttackTarget(monster->monsterName, kEntityClassMonster);
+			}
+		} else {
+			monster = nullptr;
+		}
+		monsterEntity = monster ? findSceneRuntimeEntity(_entityManager, monster->monsterName) : nullptr;
+	}
+	return monster;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 6342e00f125..47eb793b898 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -24,6 +24,8 @@ MODULE_OBJS = \
 	player.o \
 	resources.o \
 	combat.o \
+	combat_geometry.o \
+	combat_targeting.o \
 	interaction.o \
 	input.o \
 	room_presentation.o \


Commit: 211040816241f526044f8c84bf6f3a4c8f2c3c35
    https://github.com/scummvm/scummvm/commit/211040816241f526044f8c84bf6f3a4c8f2c3c35
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Separate combat hit and death transitions

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/combat_reactions.cpp
    engines/harvester/combat.cpp
    engines/harvester/combat.h
    engines/harvester/module.mk


diff --git a/engines/harvester/combat.cpp b/engines/harvester/combat.cpp
index a7c64505576..d5e69c0be80 100644
--- a/engines/harvester/combat.cpp
+++ b/engines/harvester/combat.cpp
@@ -36,10 +36,7 @@
 namespace Harvester {
 
 const int kRoomMonsterAnimationRate = 17;
-// update_actor_runtime_state (0x4d750), states 0x16..0x1b: the block at
-// 0x53525 forces a three-centisecond interval by assigning animation rate 0x1e.
 const int kNativeMonsterAttackAnimationRate = 30;
-const int kNativeMonsterHitAnimationRate = 5;
 const uint32 kNativeMonsterAttackCooldownBaseTicks = 50;
 const float kNativeNpcMonsterZExtent = 5.0f;
 const int kRoomNpcAmbientLastFrame = 0x3b;
@@ -48,19 +45,12 @@ const int kMuckeyAttackContactFrameOffset = 7;
 const float kNativeMonsterPursuitZTolerance = 2.0f;
 const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
 const uint32 kNativeKeyboardAttackRepeatTicks = 25;
-const int kNativeCombatHitKnockbackDistance = 18;
-const int kNativeCombatHitKnockbackDecayStep = 3;
 const char *const kNativeHitEffectResourcePath = "blood.abm";
 const int kNativeHitEffectAnimationRate = 10;
 const float kNativeHitEffectRenderZBias = 0.01f;
 const uint32 kCombatDebugDamagePopupDurationMs = 750;
 const int kCombatDebugDamagePopupRisePixels = 18;
 const int kCombatDebugDamagePopupVerticalGap = 6;
-const char *const kInnerSanctumRoomName = "INNERSANCTUM";
-const char *const kHerrillLogNpcName = "HERRILL_LOG";
-const char *const kMuckeyMonsterName = "MUCKEY";
-const char *const kActivateInnerSanctumDoorActionTag = "ACTV_INNERSAN_DOOR";
-const int kNativeDefaultNpcDeathDamageType = 1;
 
 namespace {
 
@@ -176,17 +166,6 @@ RoomMonsterFacingAnimationRange resolveRoomMonsterFacingAnimationRange(int facin
 
 } // End of anonymous namespace
 
-bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc) {
-	return npc.monsterfyTargetName.empty();
-}
-
-bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc) {
-	return npc.deathOrMonsterfyFlag &&
-		npc.monsterfyTargetName.empty() &&
-		npc.deathDamageType != 0 &&
-		npc.runtimeState >= 0;
-}
-
 bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info) {
 	switch (loadout) {
 	case 2:
@@ -260,18 +239,6 @@ bool setRoomMonsterAnimation(Entity &entity, int facing, bool walking) {
 	return changed;
 }
 
-bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
-	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
-		return false;
-
-	return static_cast<uint>(lastFrame) < entity.getFrameCount();
-}
-
-bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
-	return runtimeEntityHasFrameRange(entity, firstFrame, lastFrame) &&
-		entity.hasOpaqueFramesInRange(firstFrame, lastFrame);
-}
-
 bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 		const Entity &entity, int actorCenterX, int targetCenterX,
 		AttackAnimationRange &range) {
@@ -307,66 +274,6 @@ bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 	return true;
 }
 
-bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
-		int deathDamageType, bool goreEnabled, DeathAnimationRange &range) {
-	const bool preferLeftBank = facing == 1;
-	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;
-		return true;
-	};
-
-	if ((!goreEnabled || deathDamageType == 1) &&
-			(chooseIfAvailable(primaryBludge) || chooseIfAvailable(fallbackBludge)))
-		return true;
-	if (deathDamageType == 2 &&
-			(chooseIfAvailable(primarySlash) || chooseIfAvailable(fallbackSlash)))
-		return true;
-	if (deathDamageType == 4 &&
-			(chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackProjectile)))
-		return true;
-
-	return chooseIfAvailable(primaryBludge) || chooseIfAvailable(primarySlash) ||
-		chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackBludge) ||
-		chooseIfAvailable(fallbackSlash) || chooseIfAvailable(fallbackProjectile);
-}
-
-bool resolveNpcDeathAnimationRange(const Entity &entity, bool hasMonsterfyTarget,
-		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 DeathAnimationRange &candidate) {
-		if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
-			return false;
-		range = candidate;
-		return true;
-	};
-
-	if (preferGoreBank && chooseIfAvailable(goreBank))
-		return true;
-	if (chooseIfAvailable(bludgeBank))
-		return true;
-
-	return chooseIfAvailable(goreBank);
-}
-
 int stepTowardsRoomCombatInt(int current, int target, int step) {
 	if (step <= 0 || current == target)
 		return current;
@@ -394,39 +301,6 @@ bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &so
 	return engine.playSound(*availableSounds[soundIndex]);
 }
 
-bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
-		int attackerAttackFirstFrame, HitAnimationRange &range) {
-	HitAnimationRange candidate;
-	switch (attackerAttackFirstFrame) {
-	case 0x64:
-		candidate = HitAnimationRange(0x92, 0x94, 2, -kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x5a:
-		candidate = HitAnimationRange(0x8f, 0x91, 2, -kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x50:
-		candidate = HitAnimationRange(0x8c, 0x8e, 2, -kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x82:
-		candidate = HitAnimationRange(0x9b, 0x9d, 1, kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x78:
-		candidate = HitAnimationRange(0x98, 0x9a, 1, kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x6e:
-		candidate = HitAnimationRange(0x95, 0x97, 1, kNativeCombatHitKnockbackDistance);
-		break;
-	default:
-		return false;
-	}
-
-	if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
-		return false;
-
-	range = candidate;
-	return true;
-}
-
 int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY) {
 	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
 		return fallbackY;
@@ -468,205 +342,6 @@ Combat::Combat(HarvesterEngine &engine, SceneState &scene, Player &player,
 	  _callbacks(callbacks), _interactionProcessor(interactionProcessor) {
 }
 
-NpcCombatUpdateResult Combat::updateRoomNpcCombat(bool &needsRedraw) {
-	NpcCombatUpdateResult result;
-	for (uint i = 0; i < _scene.state.roomNpcs.size(); ++i) {
-		NpcRecord &npc = _scene.state.roomNpcs[i];
-		NpcCombatState &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(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(npc, combatState, 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(npc, combatState, needsRedraw);
-		return result;
-	}
-	return result;
-}
-
-bool Combat::queueInnerSanctumHerrillDeathAfterMuck(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(_scene.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 Combat::beginNpcDeathTransition(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;
-
-	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 Combat::finalizeNpcDeathTransition(NpcRecord &npc, NpcCombatState &combatState, 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(kEntityHitTestNone);
-		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,
-				ScriptExecutionContext::forNpcDeath(npc))) {
-			interaction = Common::move(deathInteraction);
-			interaction.mutatedRuntimeState = true;
-			interaction.visualRuntimeStateChanged = true;
-		}
-	}
-
-	return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
-}
-
-void Combat::startMonsterHitReaction(MonsterRecord &monster, Entity &monsterEntity, MonsterCombatState *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;
-	}
-
-	HitAnimationRange 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 Combat::killDebugActiveMonster(const Common::Point &mousePos, bool &needsRedraw) {
 	const RoomPlayerState &playerState = _player.getState();
 	if (!_engine.isCombatDebugEnabled())
@@ -715,17 +390,7 @@ Common::Error Combat::killDebugActiveMonster(const Common::Point &mousePos, bool
 	DeathAnimationRange 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);
+		startMonsterDeathAnimation(*monster, *monsterEntity, *combatState, deathRange, debugDamageType);
 		needsRedraw = true;
 		return Common::kNoError;
 	}
@@ -776,44 +441,8 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 			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(monster);
-
-				InteractionResult interaction;
-				interaction.mutatedRuntimeState = true;
-				interaction.visualRuntimeStateChanged = true;
-				if (!monster.onDeathActionTag.empty()) {
-					InteractionResult deathInteraction;
-					if (script.executeActionTag(
-							monster.onDeathActionTag, deathInteraction, true, ScriptExecutionContext::forMonsterDeath(monster))) {
-						interaction = Common::move(deathInteraction);
-						interaction.mutatedRuntimeState = true;
-						interaction.visualRuntimeStateChanged = true;
-					}
-				}
-				if (herrillDeathQueued) {
-					interaction.mutatedRuntimeState = true;
-					interaction.visualRuntimeStateChanged = true;
-				}
-
-				return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
-			}
+			if (entity->getCurrentFrame() == combatState.deathLastFrame)
+				return finalizeMonsterDeathTransition(script, monster, *entity, combatState, needsRedraw);
 			continue;
 		}
 		if (monster.currentHitPoints <= 0) {
@@ -828,40 +457,7 @@ Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
 		}
 
 		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, static_cast<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;
-			}
+			updateMonsterHitReaction(script, monster, *entity, combatState, needsRedraw);
 			continue;
 		}
 
@@ -1178,17 +774,7 @@ Common::Error Combat::resolvePlayerAttackContact(bool &needsRedraw) {
 	DeathAnimationRange 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);
+		startMonsterDeathAnimation(*monster, *monsterEntity, *combatState, deathRange, damageType);
 		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,
diff --git a/engines/harvester/combat.h b/engines/harvester/combat.h
index 5d49b6c6f9f..567c6e09fa2 100644
--- a/engines/harvester/combat.h
+++ b/engines/harvester/combat.h
@@ -170,6 +170,13 @@ private:
 	void startMonsterHitReaction(MonsterRecord &monster, Entity &monsterEntity, MonsterCombatState *combatState,
 		int attackerAttackFirstFrame, bool &needsRedraw) const;
 
+	void updateMonsterHitReaction(Script &script, MonsterRecord &monster,
+		Entity &entity, MonsterCombatState &combatState, bool &needsRedraw) const;
+	void startMonsterDeathAnimation(const MonsterRecord &monster, Entity &monsterEntity,
+		MonsterCombatState &combatState, const DeathAnimationRange &deathRange, int deathDamageType) const;
+	Common::Error finalizeMonsterDeathTransition(Script &script, MonsterRecord &monster,
+		Entity &entity, MonsterCombatState &combatState, bool &needsRedraw) const;
+
 	static Entity *findSceneRuntimeEntity(EntityManager *entityManager, const Common::String &name);
 	static NpcRecord *findRoomNpcRecordByName(RoomSetupState &state, const Common::String &name);
 	static Entity *selectMonsterEntity(EntityManager *entityManager, const MonsterRecord &monster);
diff --git a/engines/harvester/combat_reactions.cpp b/engines/harvester/combat_reactions.cpp
new file mode 100644
index 00000000000..e6704967707
--- /dev/null
+++ b/engines/harvester/combat_reactions.cpp
@@ -0,0 +1,451 @@
+/* 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/combat.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/interaction.h"
+#include "harvester/scene_entities.h"
+
+namespace Harvester {
+
+const int kNativeMonsterHitAnimationRate = 5;
+const int kNativeCombatHitKnockbackDistance = 18;
+const int kNativeCombatHitKnockbackDecayStep = 3;
+const char *const kInnerSanctumRoomName = "INNERSANCTUM";
+const char *const kHerrillLogNpcName = "HERRILL_LOG";
+const char *const kMuckeyMonsterName = "MUCKEY";
+const char *const kActivateInnerSanctumDoorActionTag = "ACTV_INNERSAN_DOOR";
+const int kNativeDefaultNpcDeathDamageType = 1;
+
+bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc) {
+	return npc.monsterfyTargetName.empty();
+}
+
+bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc) {
+	return npc.deathOrMonsterfyFlag &&
+		npc.monsterfyTargetName.empty() &&
+		npc.deathDamageType != 0 &&
+		npc.runtimeState >= 0;
+}
+
+bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
+	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
+		return false;
+
+	return static_cast<uint>(lastFrame) < entity.getFrameCount();
+}
+
+bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
+	return runtimeEntityHasFrameRange(entity, firstFrame, lastFrame) &&
+		entity.hasOpaqueFramesInRange(firstFrame, lastFrame);
+}
+
+bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
+		int deathDamageType, bool goreEnabled, DeathAnimationRange &range) {
+	const bool preferLeftBank = facing == 1;
+	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;
+		return true;
+	};
+
+	if ((!goreEnabled || deathDamageType == 1) &&
+			(chooseIfAvailable(primaryBludge) || chooseIfAvailable(fallbackBludge)))
+		return true;
+	if (deathDamageType == 2 &&
+			(chooseIfAvailable(primarySlash) || chooseIfAvailable(fallbackSlash)))
+		return true;
+	if (deathDamageType == 4 &&
+			(chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackProjectile)))
+		return true;
+
+	return chooseIfAvailable(primaryBludge) || chooseIfAvailable(primarySlash) ||
+		chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackBludge) ||
+		chooseIfAvailable(fallbackSlash) || chooseIfAvailable(fallbackProjectile);
+}
+
+bool resolveNpcDeathAnimationRange(const Entity &entity, bool hasMonsterfyTarget,
+		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 DeathAnimationRange &candidate) {
+		if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+			return false;
+		range = candidate;
+		return true;
+	};
+
+	if (preferGoreBank && chooseIfAvailable(goreBank))
+		return true;
+	if (chooseIfAvailable(bludgeBank))
+		return true;
+
+	return chooseIfAvailable(goreBank);
+}
+
+bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
+		int attackerAttackFirstFrame, HitAnimationRange &range) {
+	HitAnimationRange candidate;
+	switch (attackerAttackFirstFrame) {
+	case 0x64:
+		candidate = HitAnimationRange(0x92, 0x94, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x5a:
+		candidate = HitAnimationRange(0x8f, 0x91, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x50:
+		candidate = HitAnimationRange(0x8c, 0x8e, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x82:
+		candidate = HitAnimationRange(0x9b, 0x9d, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x78:
+		candidate = HitAnimationRange(0x98, 0x9a, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x6e:
+		candidate = HitAnimationRange(0x95, 0x97, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	default:
+		return false;
+	}
+
+	if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+		return false;
+
+	range = candidate;
+	return true;
+}
+
+NpcCombatUpdateResult Combat::updateRoomNpcCombat(bool &needsRedraw) {
+	NpcCombatUpdateResult result;
+	for (uint i = 0; i < _scene.state.roomNpcs.size(); ++i) {
+		NpcRecord &npc = _scene.state.roomNpcs[i];
+		NpcCombatState &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(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(npc, combatState, 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(npc, combatState, needsRedraw);
+		return result;
+	}
+	return result;
+}
+
+bool Combat::queueInnerSanctumHerrillDeathAfterMuck(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(_scene.state, kHerrillLogNpcName);
+	if (!script || !herrill || !herrill->visible ||
+			herrill->deathOrMonsterfyFlag || herrill->deathDamageType != 0) {
+		return false;
+	}
+
+	// There's 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 Combat::beginNpcDeathTransition(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;
+
+	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 Combat::finalizeNpcDeathTransition(NpcRecord &npc, NpcCombatState &combatState, bool &needsRedraw) const {
+	Script *script = _engine.getScript();
+	Entity *npcEntity = findSceneRuntimeEntity(_entityManager, npc.npcName);
+	// 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(kEntityHitTestNone);
+		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,
+				ScriptExecutionContext::forNpcDeath(npc))) {
+			interaction = Common::move(deathInteraction);
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
+	}
+
+	return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
+}
+
+void Combat::startMonsterHitReaction(MonsterRecord &monster, Entity &monsterEntity, MonsterCombatState *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;
+	}
+
+	HitAnimationRange 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;
+}
+
+void Combat::startMonsterDeathAnimation(const MonsterRecord &monster, Entity &monsterEntity,
+		MonsterCombatState &combatState, const DeathAnimationRange &deathRange, int deathDamageType) const {
+	combatState.deathActive = true;
+	combatState.deathFirstFrame = deathRange.firstFrame;
+	combatState.deathLastFrame = deathRange.lastFrame;
+	combatState.deathDamageType = deathDamageType;
+	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);
+}
+
+Common::Error Combat::finalizeMonsterDeathTransition(Script &script, MonsterRecord &monster,
+		Entity &entity, MonsterCombatState &combatState, bool &needsRedraw) const {
+	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(monster);
+
+	InteractionResult interaction;
+	interaction.mutatedRuntimeState = true;
+	interaction.visualRuntimeStateChanged = true;
+	if (!monster.onDeathActionTag.empty()) {
+		InteractionResult deathInteraction;
+		if (script.executeActionTag(
+				monster.onDeathActionTag, deathInteraction, true, ScriptExecutionContext::forMonsterDeath(monster))) {
+			interaction = Common::move(deathInteraction);
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
+	}
+	if (herrillDeathQueued) {
+		interaction.mutatedRuntimeState = true;
+		interaction.visualRuntimeStateChanged = true;
+	}
+
+	return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
+}
+
+void Combat::updateMonsterHitReaction(Script &script, MonsterRecord &monster,
+		Entity &entity, MonsterCombatState &combatState, bool &needsRedraw) const {
+	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, static_cast<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;
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 47eb793b898..781e96f3e6f 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -25,6 +25,7 @@ MODULE_OBJS = \
 	resources.o \
 	combat.o \
 	combat_geometry.o \
+	combat_reactions.o \
 	combat_targeting.o \
 	interaction.o \
 	input.o \


Commit: ab8709bc895d782cc8ff7423e83b0af63ee79423
    https://github.com/scummvm/scummvm/commit/ab8709bc895d782cc8ff7423e83b0af63ee79423
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Separate monster pursuit and attack progression

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/combat_monsters.cpp
    engines/harvester/combat.cpp
    engines/harvester/combat.h
    engines/harvester/module.mk


diff --git a/engines/harvester/combat.cpp b/engines/harvester/combat.cpp
index d5e69c0be80..02f6d46c8f4 100644
--- a/engines/harvester/combat.cpp
+++ b/engines/harvester/combat.cpp
@@ -35,15 +35,8 @@
 
 namespace Harvester {
 
-const int kRoomMonsterAnimationRate = 17;
-const int kNativeMonsterAttackAnimationRate = 30;
-const uint32 kNativeMonsterAttackCooldownBaseTicks = 50;
 const float kNativeNpcMonsterZExtent = 5.0f;
 const int kRoomNpcAmbientLastFrame = 0x3b;
-const int kDefaultMonsterAttackContactFrameOffset = 2;
-const int kMuckeyAttackContactFrameOffset = 7;
-const float kNativeMonsterPursuitZTolerance = 2.0f;
-const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
 const uint32 kNativeKeyboardAttackRepeatTicks = 25;
 const char *const kNativeHitEffectResourcePath = "blood.abm";
 const int kNativeHitEffectAnimationRate = 10;
@@ -104,16 +97,6 @@ static void drawShadowedRoomText(Graphics::Screen &screen, const Graphics::Font
 }
 
 
-struct RoomMonsterFacingAnimationRange {
-	RoomMonsterFacingAnimationRange() {}
-	RoomMonsterFacingAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
-		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
-
-	int walkFirstFrame = 0;
-	int walkLastFrame = 0;
-	int idleFrame = 0;
-};
-
 Common::String resolveCombatLoadoutStatusLabel(Script &script, const ObjectRecord &object) {
 	Common::String label = script.resolveInventoryTooltipText(object);
 	if (!label.empty()) {
@@ -149,21 +132,6 @@ Common::String resolveCombatLoadoutStatusLabel(Script &script, const ObjectRecor
 	return label;
 }
 
-RoomMonsterFacingAnimationRange resolveRoomMonsterFacingAnimationRange(int facing) {
-	switch (facing) {
-	case 0:
-		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
-	case 1:
-		return RoomMonsterFacingAnimationRange(0x0f, 0x18, 0x0e);
-	case 2:
-		return RoomMonsterFacingAnimationRange(0x2d, 0x36, 0x2c);
-	case 3:
-		return RoomMonsterFacingAnimationRange(0x1e, 0x27, 0x28);
-	default:
-		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
-	}
-}
-
 } // End of anonymous namespace
 
 bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info) {
@@ -215,112 +183,6 @@ Common::String buildCombatLoadoutStatusMessage(Script &script, const ObjectRecor
 	return Common::String::format("Arming %s...", label.c_str());
 }
 
-bool setRoomMonsterAnimation(Entity &entity, int facing, bool walking) {
-	const RoomMonsterFacingAnimationRange range = resolveRoomMonsterFacingAnimationRange(facing);
-	if (walking) {
-		const bool changed = entity.getAnimationRate() != kRoomMonsterAnimationRate ||
-			entity.getCurrentFrame() < range.walkFirstFrame ||
-			entity.getCurrentFrame() > range.walkLastFrame;
-		entity.setAnimationRate(kRoomMonsterAnimationRate);
-		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
-		entity.setAnimationEnabled(true);
-		return changed;
-	}
-
-	const int standingFrame = entity.hasOpaqueFramesInRange(range.idleFrame, range.idleFrame)
-		? range.idleFrame
-		: range.walkFirstFrame;
-	const bool changed = entity.getAnimationRate() != 0 ||
-		entity.getCurrentFrame() != standingFrame;
-	entity.setAnimationRate(0);
-	entity.setAnimationFrameRange(standingFrame, standingFrame, false);
-	entity.setCurrentFrame(standingFrame);
-	entity.setAnimationEnabled(false);
-	return changed;
-}
-
-bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
-		const Entity &entity, int actorCenterX, int targetCenterX,
-		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 AttackAnimationRange kLeftAttackRanges[3] = {
-		AttackAnimationRange(0x64, 0x6d, 1),
-		AttackAnimationRange(0x5a, 0x63, 1),
-		AttackAnimationRange(0x50, 0x59, 1)
-	};
-	static const AttackAnimationRange kRightAttackRanges[3] = {
-		AttackAnimationRange(0x82, 0x8b, 2),
-		AttackAnimationRange(0x78, 0x81, 2),
-		AttackAnimationRange(0x6e, 0x77, 2)
-	};
-
-	const AttackAnimationRange *candidates =
-		targetCenterX < actorCenterX ? kLeftAttackRanges : kRightAttackRanges;
-	int availableCandidateIndices[3];
-	int availableCandidateCount = 0;
-	for (int i = 0; i < 3; ++i) {
-		if (!runtimeEntityHasDrawableFrameRange(entity, candidates[i].firstFrame, candidates[i].lastFrame))
-			continue;
-		availableCandidateIndices[availableCandidateCount++] = i;
-	}
-
-	if (availableCandidateCount == 0)
-		return false;
-
-	const int selectedIndex = availableCandidateIndices[
-		availableCandidateCount > 1 ? engine.getRandomNumber(availableCandidateCount - 1) : 0];
-	range = candidates[selectedIndex];
-	return true;
-}
-
-int stepTowardsRoomCombatInt(int current, int target, int step) {
-	if (step <= 0 || current == target)
-		return current;
-	if (current < target)
-		return MIN(current + step, target);
-	return MAX(current - step, target);
-}
-
-bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
-		const Common::String &sound2, const Common::String &sound3) {
-	const Common::String *availableSounds[3];
-	uint availableCount = 0;
-	if (!sound1.empty())
-		availableSounds[availableCount++] = &sound1;
-	if (!sound2.empty())
-		availableSounds[availableCount++] = &sound2;
-	if (!sound3.empty())
-		availableSounds[availableCount++] = &sound3;
-	if (availableCount == 0)
-		return false;
-
-	const uint soundIndex = availableCount > 1
-		? engine.getRandomNumber(availableCount - 1)
-		: 0;
-	return engine.playSound(*availableSounds[soundIndex]);
-}
-
-int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY) {
-	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
-		return fallbackY;
-	if (state.roomMaxZ == state.roomMinZ)
-		return CLIP<int>(fallbackY, state.roomMaxZScreenY, state.roomMinZScreenY);
-
-	const float clampedZ = clampRoomDepthForEvent(state, z);
-	const float offset = (static_cast<float>(state.roomMaxZ) - clampedZ) *
-		static_cast<float>(state.roomMinZScreenY - state.roomMaxZScreenY) /
-		static_cast<float>(state.roomMaxZ - state.roomMinZ);
-	return CLIP<int>(state.roomMaxZScreenY + roundRoomCombatFloat(offset),
-		state.roomMaxZScreenY, state.roomMinZScreenY);
-}
-
-int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
-	return monster.monsterName.equalsIgnoreCase("MUCKEY")
-		? kMuckeyAttackContactFrameOffset
-		: kDefaultMonsterAttackContactFrameOffset;
-}
-
 const char *describeCombatTargetClass(int classId) {
 	switch (classId) {
 	case kEntityClassNpc:
@@ -413,253 +275,6 @@ Common::Error Combat::killDebugActiveMonster(const Common::Point &mousePos, bool
 	return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
 }
 
-Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
-	const RoomPlayerState &playerState = _player.getState();
-	if (!_entityManager)
-		return Common::kNoError;
-
-	Script &script = *_engine.getScript();
-	const uint32 now = Player::getRuntimeClockTicks();
-	const uint32 moveInterval = MAX<uint32>(1, 100U / static_cast<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;
-
-	resizeCombatStates();
-
-	const NpcCombatUpdateResult npcResult = updateRoomNpcCombat(needsRedraw);
-	if (npcResult.finalizedDeath)
-		return npcResult.error;
-
-	for (uint i = 0; i < _scene.state.roomMonsters.size(); ++i) {
-		MonsterRecord &monster = _scene.state.roomMonsters[i];
-		MonsterCombatState &combatState = _monsterCombatStates[i];
-		Entity *entity = findSceneRuntimeEntity(_entityManager, monster.monsterName);
-		if (!entity || !monster.visible) {
-			clearRoomMonsterCombatState(combatState);
-			continue;
-		}
-		if (combatState.deathActive) {
-			if (entity->getCurrentFrame() == combatState.deathLastFrame)
-				return finalizeMonsterDeathTransition(script, monster, *entity, combatState, 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) {
-			updateMonsterHitReaction(script, monster, *entity, combatState, 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);
-						_player.cancelIdleAnimation();
-						if (playerHitPointsAfter > 0 &&
-								_player.startHitAnimation(_engine, 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 - static_cast<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, static_cast<float>(monster.posZ))) * kNativeMonsterHorizontalWaypointTolerance);
-		bool attemptedMoveX = false;
-		bool attemptedMoveZ = false;
-		if (absZDelta > kNativeMonsterPursuitZTolerance) {
-			attemptedMoveZ = true;
-			monster.posZ = stepTowardsRoomCombatInt(monster.posZ, playerTargetZ, depthStep);
-			monster.posZ = static_cast<int>(clampRoomDepthForEvent(_scene.state, static_cast<float>(monster.posZ)));
-			monster.posY = mapRoomDepthToScreenYForCombat(_scene.state, static_cast<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, static_cast<float>(monster.posZ)) * static_cast<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;
-		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;
-			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, static_cast<float>(monster.posZ));
-			if (monster.posZ != previousZ)
-				_entityManager->reinsertSceneEntity(entity);
-			(void)script.syncRuntimeMonsterRecord(monster);
-			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, static_cast<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 (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, static_cast<double>(zDelta), engageDistance, liveWaypointTolerance,
-				monster.posX, monster.posY, monster.posZ, attemptedMoveX, attemptedMoveZ);
-		}
-
-		needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
-		const bool closeEnoughForAttack = isWithinNativeMonsterAttackEntryRange(
-			*entity, static_cast<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;
-
-		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",
-				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);
-		combatState.loggedPursuitStallDirections = 0;
-		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, static_cast<double>(zDelta),
-			engageDistance, kNativeMonsterAttackAnimationRate, combatState.attackCooldownSeedTick,
-			closeRangeBypassDistance);
-		needsRedraw = true;
-	}
-
-	return Common::kNoError;
-}
-
 Common::Error Combat::resolvePlayerAttackContact(bool &needsRedraw) {
 	const RoomPlayerState &playerState = _player.getState();
 	if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
diff --git a/engines/harvester/combat.h b/engines/harvester/combat.h
index 567c6e09fa2..722d279cca7 100644
--- a/engines/harvester/combat.h
+++ b/engines/harvester/combat.h
@@ -158,6 +158,16 @@ public:
 	Common::Error resolvePlayerAttackContact(bool &needsRedraw);
 
 private:
+	struct MonsterPursuitResult;
+
+	// Returns true when player defeat must end the entire combat update.
+	bool updateMonsterAttack(Script &script, const MonsterRecord &monster,
+		Entity &entity, MonsterCombatState &combatState, bool &playerAlive, bool &needsRedraw);
+	MonsterPursuitResult updateMonsterPursuit(Script &script, MonsterRecord &monster,
+		Entity &entity, MonsterCombatState &combatState, int depthStep, bool &needsRedraw) const;
+	void beginMonsterAttack(MonsterRecord &monster, Entity &entity, MonsterCombatState &combatState,
+		const MonsterPursuitResult &pursuit, uint32 now, bool &needsRedraw) const;
+
 	void resizeCombatStates();
 	bool queueInnerSanctumHerrillDeathAfterMuck(const MonsterRecord &monster) const;
 	// Called after resizeCombatStates; NPC finalization ends the entire combat update.
diff --git a/engines/harvester/combat_monsters.cpp b/engines/harvester/combat_monsters.cpp
new file mode 100644
index 00000000000..71cc0853b42
--- /dev/null
+++ b/engines/harvester/combat_monsters.cpp
@@ -0,0 +1,461 @@
+/* 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/combat.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/cheats.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/interaction.h"
+#include "harvester/player.h"
+
+namespace Harvester {
+
+const int kRoomMonsterAnimationRate = 17;
+const int kNativeMonsterAttackAnimationRate = 30;
+const uint32 kNativeMonsterAttackCooldownBaseTicks = 50;
+const int kDefaultMonsterAttackContactFrameOffset = 2;
+const int kMuckeyAttackContactFrameOffset = 7;
+const float kNativeMonsterPursuitZTolerance = 2.0f;
+const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
+
+namespace {
+
+struct RoomMonsterFacingAnimationRange {
+	RoomMonsterFacingAnimationRange() {}
+	RoomMonsterFacingAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
+		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
+
+	int walkFirstFrame = 0;
+	int walkLastFrame = 0;
+	int idleFrame = 0;
+};
+
+RoomMonsterFacingAnimationRange resolveRoomMonsterFacingAnimationRange(int facing) {
+	switch (facing) {
+	case 0:
+		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
+	case 1:
+		return RoomMonsterFacingAnimationRange(0x0f, 0x18, 0x0e);
+	case 2:
+		return RoomMonsterFacingAnimationRange(0x2d, 0x36, 0x2c);
+	case 3:
+		return RoomMonsterFacingAnimationRange(0x1e, 0x27, 0x28);
+	default:
+		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
+	}
+}
+
+} // End of anonymous namespace
+
+struct Combat::MonsterPursuitResult {
+	bool moved;
+	int playerCenterX;
+	int monsterCenterX;
+	int liveCenterDx;
+	int absLiveCenterDx;
+	float zDelta;
+	int engageDistance;
+};
+
+bool setRoomMonsterAnimation(Entity &entity, int facing, bool walking) {
+	const RoomMonsterFacingAnimationRange range = resolveRoomMonsterFacingAnimationRange(facing);
+	if (walking) {
+		const bool changed = entity.getAnimationRate() != kRoomMonsterAnimationRate ||
+			entity.getCurrentFrame() < range.walkFirstFrame ||
+			entity.getCurrentFrame() > range.walkLastFrame;
+		entity.setAnimationRate(kRoomMonsterAnimationRate);
+		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
+		entity.setAnimationEnabled(true);
+		return changed;
+	}
+
+	const int standingFrame = entity.hasOpaqueFramesInRange(range.idleFrame, range.idleFrame)
+		? range.idleFrame
+		: range.walkFirstFrame;
+	const bool changed = entity.getAnimationRate() != 0 ||
+		entity.getCurrentFrame() != standingFrame;
+	entity.setAnimationRate(0);
+	entity.setAnimationFrameRange(standingFrame, standingFrame, false);
+	entity.setCurrentFrame(standingFrame);
+	entity.setAnimationEnabled(false);
+	return changed;
+}
+
+bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
+		const Entity &entity, int actorCenterX, int targetCenterX,
+		AttackAnimationRange &range) {
+	// 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 AttackAnimationRange kLeftAttackRanges[3] = {
+		AttackAnimationRange(0x64, 0x6d, 1),
+		AttackAnimationRange(0x5a, 0x63, 1),
+		AttackAnimationRange(0x50, 0x59, 1)
+	};
+	static const AttackAnimationRange kRightAttackRanges[3] = {
+		AttackAnimationRange(0x82, 0x8b, 2),
+		AttackAnimationRange(0x78, 0x81, 2),
+		AttackAnimationRange(0x6e, 0x77, 2)
+	};
+
+	const AttackAnimationRange *candidates =
+		targetCenterX < actorCenterX ? kLeftAttackRanges : kRightAttackRanges;
+	int availableCandidateIndices[3];
+	int availableCandidateCount = 0;
+	for (int i = 0; i < 3; ++i) {
+		if (!runtimeEntityHasDrawableFrameRange(entity, candidates[i].firstFrame, candidates[i].lastFrame))
+			continue;
+		availableCandidateIndices[availableCandidateCount++] = i;
+	}
+
+	if (availableCandidateCount == 0)
+		return false;
+
+	const int selectedIndex = availableCandidateIndices[
+		availableCandidateCount > 1 ? engine.getRandomNumber(availableCandidateCount - 1) : 0];
+	range = candidates[selectedIndex];
+	return true;
+}
+
+int stepTowardsRoomCombatInt(int current, int target, int step) {
+	if (step <= 0 || current == target)
+		return current;
+	if (current < target)
+		return MIN(current + step, target);
+	return MAX(current - step, target);
+}
+
+bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
+		const Common::String &sound2, const Common::String &sound3) {
+	const Common::String *availableSounds[3];
+	uint availableCount = 0;
+	if (!sound1.empty())
+		availableSounds[availableCount++] = &sound1;
+	if (!sound2.empty())
+		availableSounds[availableCount++] = &sound2;
+	if (!sound3.empty())
+		availableSounds[availableCount++] = &sound3;
+	if (availableCount == 0)
+		return false;
+
+	const uint soundIndex = availableCount > 1
+		? engine.getRandomNumber(availableCount - 1)
+		: 0;
+	return engine.playSound(*availableSounds[soundIndex]);
+}
+
+int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY) {
+	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
+		return fallbackY;
+	if (state.roomMaxZ == state.roomMinZ)
+		return CLIP<int>(fallbackY, state.roomMaxZScreenY, state.roomMinZScreenY);
+
+	const float clampedZ = clampRoomDepthForEvent(state, z);
+	const float offset = (static_cast<float>(state.roomMaxZ) - clampedZ) *
+		static_cast<float>(state.roomMinZScreenY - state.roomMaxZScreenY) /
+		static_cast<float>(state.roomMaxZ - state.roomMinZ);
+	return CLIP<int>(state.roomMaxZScreenY + roundRoomCombatFloat(offset),
+		state.roomMaxZScreenY, state.roomMinZScreenY);
+}
+
+int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
+	return monster.monsterName.equalsIgnoreCase("MUCKEY")
+		? kMuckeyAttackContactFrameOffset
+		: kDefaultMonsterAttackContactFrameOffset;
+}
+
+Common::Error Combat::updateRoomMonsterCombat(bool &needsRedraw) {
+	const RoomPlayerState &playerState = _player.getState();
+	if (!_entityManager)
+		return Common::kNoError;
+
+	Script &script = *_engine.getScript();
+	const uint32 now = Player::getRuntimeClockTicks();
+	const uint32 moveInterval = MAX<uint32>(1, 100U / static_cast<uint32>(kRoomMonsterAnimationRate));
+	const int depthStep = MAX<int>(1, roundRoomCombatFloat(
+		_scene.state.roomZVelocityStep > 0.0f ? _scene.state.roomZVelocityStep : 1.0f));
+	bool playerAlive = script.getPlayerCurrentHitPoints() > 0;
+
+	resizeCombatStates();
+
+	const NpcCombatUpdateResult npcResult = updateRoomNpcCombat(needsRedraw);
+	if (npcResult.finalizedDeath)
+		return npcResult.error;
+
+	for (uint i = 0; i < _scene.state.roomMonsters.size(); ++i) {
+		MonsterRecord &monster = _scene.state.roomMonsters[i];
+		MonsterCombatState &combatState = _monsterCombatStates[i];
+		Entity *entity = findSceneRuntimeEntity(_entityManager, monster.monsterName);
+		if (!entity || !monster.visible) {
+			clearRoomMonsterCombatState(combatState);
+			continue;
+		}
+		if (combatState.deathActive) {
+			if (entity->getCurrentFrame() == combatState.deathLastFrame)
+				return finalizeMonsterDeathTransition(script, monster, *entity, combatState, 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) {
+			updateMonsterHitReaction(script, monster, *entity, combatState, needsRedraw);
+			continue;
+		}
+
+		if (combatState.attackActive) {
+			if (updateMonsterAttack(script, monster, *entity, combatState, playerAlive, needsRedraw))
+				return Common::kNoError;
+			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 MonsterPursuitResult pursuit = updateMonsterPursuit(
+			script, monster, *entity, combatState, depthStep, needsRedraw);
+		if (pursuit.moved)
+			continue;
+		beginMonsterAttack(monster, *entity, combatState, pursuit, now, needsRedraw);
+	}
+
+	return Common::kNoError;
+}
+
+bool Combat::updateMonsterAttack(Script &script, const MonsterRecord &monster,
+		Entity &entity, MonsterCombatState &combatState, bool &playerAlive, bool &needsRedraw) {
+	const RoomPlayerState &playerState = _player.getState();
+	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);
+				_player.cancelIdleAnimation();
+				if (playerHitPointsAfter > 0 &&
+						_player.startHitAnimation(_engine, combatState.attackFirstFrame)) {
+					_callbacks.notePlayerActivity();
+				}
+			}
+			if (changed && playerHitPointsAfter <= 0) {
+				playerAlive = false;
+				_callbacks.stopPlayerRegionInteraction();
+				_callbacks.startPlayerDefeatSequence(
+					"combat_monster_hit", monster.monsterName, monster.damageType);
+				return true;
+			}
+		}
+		needsRedraw = true;
+	}
+	if (currentFrame == combatState.attackLastFrame) {
+		clearRoomMonsterAttackState(combatState);
+		needsRedraw = setRoomMonsterAnimation(entity, monster.facing, false) || needsRedraw;
+	}
+	return false;
+}
+
+Combat::MonsterPursuitResult Combat::updateMonsterPursuit(Script &script, MonsterRecord &monster,
+		Entity &entity, MonsterCombatState &combatState, int depthStep, bool &needsRedraw) const {
+	const RoomPlayerState &playerState = _player.getState();
+	const int horizontalStepBase = 8;
+	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 - static_cast<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, static_cast<float>(monster.posZ))) * kNativeMonsterHorizontalWaypointTolerance);
+	bool attemptedMoveX = false;
+	bool attemptedMoveZ = false;
+	if (absZDelta > kNativeMonsterPursuitZTolerance) {
+		attemptedMoveZ = true;
+		monster.posZ = stepTowardsRoomCombatInt(monster.posZ, playerTargetZ, depthStep);
+		monster.posZ = static_cast<int>(clampRoomDepthForEvent(_scene.state, static_cast<float>(monster.posZ)));
+		monster.posY = mapRoomDepthToScreenYForCombat(_scene.state, static_cast<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, static_cast<float>(monster.posZ)) * static_cast<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;
+	const MonsterPursuitResult result = {
+		moved, playerCenterX, monsterCenterX, liveCenterDx, absLiveCenterDx, zDelta, engageDistance
+	};
+	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;
+		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, static_cast<float>(monster.posZ));
+		if (monster.posZ != previousZ)
+			_entityManager->reinsertSceneEntity(&entity);
+		(void)script.syncRuntimeMonsterRecord(monster);
+		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, static_cast<double>(zDelta), engageDistance, liveWaypointTolerance,
+			previousX, previousY, previousZ, monster.posX, monster.posY, monster.posZ, monster.facing);
+		needsRedraw = true;
+		needsRedraw = setRoomMonsterAnimation(entity, monster.facing, true) || needsRedraw;
+		return result;
+	}
+
+	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, static_cast<double>(zDelta), engageDistance, liveWaypointTolerance,
+			monster.posX, monster.posY, monster.posZ, attemptedMoveX, attemptedMoveZ);
+	}
+	return result;
+}
+
+void Combat::beginMonsterAttack(MonsterRecord &monster, Entity &entity, MonsterCombatState &combatState,
+		const MonsterPursuitResult &pursuit, uint32 now, bool &needsRedraw) const {
+	const RoomPlayerState &playerState = _player.getState();
+	needsRedraw = setRoomMonsterAnimation(entity, monster.facing, false) || needsRedraw;
+	const bool closeEnoughForAttack = isWithinNativeMonsterAttackEntryRange(
+		entity, static_cast<float>(monster.posZ), *playerState.entity, playerState.z, pursuit.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, pursuit.engageDistance / 3);
+	if (!closeEnoughForAttack)
+		return;
+	if (!attackCooldownExpired && pursuit.absLiveCenterDx > closeRangeBypassDistance)
+		return;
+
+	AttackAnimationRange range;
+	if (!resolveMonsterAttackAnimationRange(_engine, entity, pursuit.monsterCenterX, pursuit.playerCenterX, range)) {
+		debugC(1, kDebugCombat,
+			"Harvester: combat monster attack skipped monster='%s' reason='no native attack bank' target_side=%s",
+			monster.monsterName.c_str(), pursuit.playerCenterX < pursuit.monsterCenterX ? "left" : "right");
+		return;
+	}
+	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);
+	combatState.loggedPursuitStallDirections = 0;
+	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, pursuit.liveCenterDx, static_cast<double>(pursuit.zDelta),
+		pursuit.engageDistance, kNativeMonsterAttackAnimationRate, combatState.attackCooldownSeedTick,
+		closeRangeBypassDistance);
+	needsRedraw = true;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 781e96f3e6f..a9a24c3dacb 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -25,6 +25,7 @@ MODULE_OBJS = \
 	resources.o \
 	combat.o \
 	combat_geometry.o \
+	combat_monsters.o \
 	combat_reactions.o \
 	combat_targeting.o \
 	interaction.o \


Commit: edf006a38103d5f6c21e8a4e906909113bff6af3
    https://github.com/scummvm/scummvm/commit/edf006a38103d5f6c21e8a4e906909113bff6af3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Separate player attack contact handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/combat_contact.cpp
    engines/harvester/combat.cpp
    engines/harvester/combat.h
    engines/harvester/module.mk


diff --git a/engines/harvester/combat.cpp b/engines/harvester/combat.cpp
index 02f6d46c8f4..8b7e3ba8013 100644
--- a/engines/harvester/combat.cpp
+++ b/engines/harvester/combat.cpp
@@ -47,24 +47,6 @@ const int kCombatDebugDamagePopupVerticalGap = 6;
 
 namespace {
 
-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 static_cast<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 static_cast<int>(i);
-	}
-
-	return -1;
-}
-
 static byte findNearestPaletteColor(const byte *palette, byte red, byte green, byte blue) {
 	if (!palette)
 		return 0;
@@ -204,227 +186,6 @@ Combat::Combat(HarvesterEngine &engine, SceneState &scene, Player &player,
 	  _callbacks(callbacks), _interactionProcessor(interactionProcessor) {
 }
 
-Common::Error Combat::killDebugActiveMonster(const Common::Point &mousePos, bool &needsRedraw) {
-	const RoomPlayerState &playerState = _player.getState();
-	if (!_engine.isCombatDebugEnabled())
-		return Common::kNoError;
-	MonsterRecord *monster = resolveKillTarget(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 == kEntityClassMonster &&
-			playerState.attackTargetName.equalsIgnoreCase(monster->monsterName)) {
-		_player.clearAttackTarget();
-	}
-
-	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(_scene.state, monster->monsterName);
-	MonsterCombatState *combatState =
-		monsterIndex >= 0 && monsterIndex < static_cast<int>(_monsterCombatStates.size())
-			? &_monsterCombatStates[static_cast<uint>(monsterIndex)]
-			: nullptr;
-	if (combatState)
-		clearRoomMonsterCombatState(*combatState);
-	(void)script->syncRuntimeMonsterRecord(*monster);
-
-	DeathAnimationRange deathRange;
-	if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
-			debugDamageType, _engine.isGoreEnabled(), deathRange)) {
-		startMonsterDeathAnimation(*monster, *monsterEntity, *combatState, deathRange, debugDamageType);
-		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, ScriptExecutionContext::forMonsterDeath(*monster))) {
-			interaction = Common::move(deathInteraction);
-			interaction.mutatedRuntimeState = true;
-			interaction.visualRuntimeStateChanged = true;
-		}
-	}
-
-	return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
-}
-
-Common::Error Combat::resolvePlayerAttackContact(bool &needsRedraw) {
-	const RoomPlayerState &playerState = _player.getState();
-	if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
-			playerState.attackContactFrame < 0 ||
-			playerState.entity->getCurrentFrame() < playerState.attackContactFrame) {
-		return Common::kNoError;
-	}
-
-	const bool projectile = Player::isProjectileCombatLoadout(playerState.combatLoadout);
-	if (projectile)
-		_player.markAttackContactResolved();
-	const int damageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
-	const int damageAmount = Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout);
-	Script *script = _engine.getScript();
-	if (!script)
-		return Common::kNoError;
-
-	captureOverlappingNpcAttackTarget();
-	if (playerState.attackTargetClassId == kEntityClassNpc) {
-		Entity *npcEntity = nullptr;
-		NpcRecord *npc = selectNpcAttackContactTarget(npcEntity);
-		if (!npc) {
-			if (playerState.entity->didAnimationAdvanceLastTick()) {
-				debugC(3, kDebugCombat,
-					"Harvester: combat player attack missed class='npc' target='%s' frame=%d reason='no reachable npc'",
-					playerState.attackTargetName.c_str(), playerState.entity->getCurrentFrame());
-			}
-			return Common::kNoError;
-		}
-
-		_player.markAttackContactResolved();
-		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(_scene.state, npc->npcName);
-		NpcCombatState *combatState =
-			npcIndex >= 0 && npcIndex < static_cast<int>(_npcCombatStates.size())
-				? &_npcCombatStates[static_cast<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, 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());
-			NpcCombatState fallbackState;
-			fallbackState.deathDamageType = damageType;
-			return finalizeNpcDeathTransition(*npc, combatState ? *combatState : fallbackState, needsRedraw);
-		}
-
-		return Common::kNoError;
-	}
-
-	Entity *monsterEntity = nullptr;
-	MonsterRecord *monster = selectMonsterAttackContactTarget(monsterEntity);
-	if (!monster || !monsterEntity) {
-		if (playerState.entity->didAnimationAdvanceLastTick()) {
-			debugC(3, kDebugCombat,
-				"Harvester: combat player attack missed class='monster' target='%s' frame=%d reason='no reachable monster'",
-				playerState.attackTargetName.c_str(), playerState.entity->getCurrentFrame());
-		}
-		return Common::kNoError;
-	}
-
-	// update_actor_runtime_state (0x4d750) latches melee contact only after a hit.
-	_player.markAttackContactResolved();
-	const int hitPointsBefore = monster->currentHitPoints;
-	monster->currentHitPoints = MAX(0,
-		monster->currentHitPoints - damageAmount);
-	const int damageLanded = hitPointsBefore - monster->currentHitPoints;
-	const int monsterIndex = findRoomMonsterIndexByName(_scene.state, monster->monsterName);
-	MonsterCombatState *combatState =
-		monsterIndex >= 0 && monsterIndex < static_cast<int>(_monsterCombatStates.size())
-			? &_monsterCombatStates[static_cast<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, needsRedraw);
-	}
-	if (monster->currentHitPoints > 0) {
-		(void)script->syncRuntimeMonsterRecord(*monster);
-		needsRedraw = true;
-		return Common::kNoError;
-	}
-	if (combatState)
-		clearRoomMonsterCombatState(*combatState);
-	(void)script->syncRuntimeMonsterRecord(*monster);
-
-	DeathAnimationRange deathRange;
-	if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
-			damageType, _engine.isGoreEnabled(), deathRange)) {
-		startMonsterDeathAnimation(*monster, *monsterEntity, *combatState, deathRange, damageType);
-		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, ScriptExecutionContext::forMonsterDeath(*monster))) {
-			interaction = Common::move(deathInteraction);
-			interaction.mutatedRuntimeState = true;
-			interaction.visualRuntimeStateChanged = true;
-		}
-	}
-	if (herrillDeathQueued) {
-		interaction.mutatedRuntimeState = true;
-		interaction.visualRuntimeStateChanged = true;
-	}
-
-	return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
-}
-
 void Combat::initializeCombatStates() {
 	_monsterCombatStates.resize(_scene.state.roomMonsters.size());
 	_npcCombatStates.resize(_scene.state.roomNpcs.size());
diff --git a/engines/harvester/combat.h b/engines/harvester/combat.h
index 722d279cca7..af7c5fd8799 100644
--- a/engines/harvester/combat.h
+++ b/engines/harvester/combat.h
@@ -158,6 +158,10 @@ public:
 	Common::Error resolvePlayerAttackContact(bool &needsRedraw);
 
 private:
+	Common::Error resolvePlayerNpcAttackContact(Script &script, int damageType, bool &needsRedraw);
+	Common::Error resolvePlayerMonsterAttackContact(Script &script,
+		int damageType, int damageAmount, bool &needsRedraw);
+
 	struct MonsterPursuitResult;
 
 	// Returns true when player defeat must end the entire combat update.
diff --git a/engines/harvester/combat_contact.cpp b/engines/harvester/combat_contact.cpp
new file mode 100644
index 00000000000..b2fb878f4a2
--- /dev/null
+++ b/engines/harvester/combat_contact.cpp
@@ -0,0 +1,285 @@
+/* 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/combat.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/interaction.h"
+#include "harvester/player.h"
+
+namespace Harvester {
+
+namespace {
+
+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 static_cast<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 static_cast<int>(i);
+	}
+
+	return -1;
+}
+
+} // End of anonymous namespace
+
+Common::Error Combat::resolvePlayerAttackContact(bool &needsRedraw) {
+	const RoomPlayerState &playerState = _player.getState();
+	if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
+			playerState.attackContactFrame < 0 ||
+			playerState.entity->getCurrentFrame() < playerState.attackContactFrame) {
+		return Common::kNoError;
+	}
+
+	const bool projectile = Player::isProjectileCombatLoadout(playerState.combatLoadout);
+	if (projectile)
+		_player.markAttackContactResolved();
+	const int damageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
+	const int damageAmount = Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout);
+	Script *script = _engine.getScript();
+	if (!script)
+		return Common::kNoError;
+
+	captureOverlappingNpcAttackTarget();
+	if (playerState.attackTargetClassId == kEntityClassNpc)
+		return resolvePlayerNpcAttackContact(*script, damageType, needsRedraw);
+	return resolvePlayerMonsterAttackContact(*script, damageType, damageAmount, needsRedraw);
+}
+
+Common::Error Combat::resolvePlayerNpcAttackContact(Script &script, int damageType, bool &needsRedraw) {
+	const RoomPlayerState &playerState = _player.getState();
+	Entity *npcEntity = nullptr;
+	NpcRecord *npc = selectNpcAttackContactTarget(npcEntity);
+	if (!npc) {
+		if (playerState.entity->didAnimationAdvanceLastTick()) {
+			debugC(3, kDebugCombat,
+				"Harvester: combat player attack missed class='npc' target='%s' frame=%d reason='no reachable npc'",
+				playerState.attackTargetName.c_str(), playerState.entity->getCurrentFrame());
+		}
+		return Common::kNoError;
+	}
+
+	_player.markAttackContactResolved();
+	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(_scene.state, npc->npcName);
+	NpcCombatState *combatState =
+		npcIndex >= 0 && npcIndex < static_cast<int>(_npcCombatStates.size())
+			? &_npcCombatStates[static_cast<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, 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());
+		NpcCombatState fallbackState;
+		fallbackState.deathDamageType = damageType;
+		return finalizeNpcDeathTransition(*npc, combatState ? *combatState : fallbackState, needsRedraw);
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error Combat::resolvePlayerMonsterAttackContact(Script &script,
+		int damageType, int damageAmount, bool &needsRedraw) {
+	const RoomPlayerState &playerState = _player.getState();
+	Entity *monsterEntity = nullptr;
+	MonsterRecord *monster = selectMonsterAttackContactTarget(monsterEntity);
+	if (!monster || !monsterEntity) {
+		if (playerState.entity->didAnimationAdvanceLastTick()) {
+			debugC(3, kDebugCombat,
+				"Harvester: combat player attack missed class='monster' target='%s' frame=%d reason='no reachable monster'",
+				playerState.attackTargetName.c_str(), playerState.entity->getCurrentFrame());
+		}
+		return Common::kNoError;
+	}
+
+	_player.markAttackContactResolved();
+	const int hitPointsBefore = monster->currentHitPoints;
+	monster->currentHitPoints = MAX(0,
+		monster->currentHitPoints - damageAmount);
+	const int damageLanded = hitPointsBefore - monster->currentHitPoints;
+	const int monsterIndex = findRoomMonsterIndexByName(_scene.state, monster->monsterName);
+	MonsterCombatState *combatState =
+		monsterIndex >= 0 && monsterIndex < static_cast<int>(_monsterCombatStates.size())
+			? &_monsterCombatStates[static_cast<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, needsRedraw);
+	}
+	if (monster->currentHitPoints > 0) {
+		(void)script.syncRuntimeMonsterRecord(*monster);
+		needsRedraw = true;
+		return Common::kNoError;
+	}
+	if (combatState)
+		clearRoomMonsterCombatState(*combatState);
+	(void)script.syncRuntimeMonsterRecord(*monster);
+
+	DeathAnimationRange deathRange;
+	if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
+			damageType, _engine.isGoreEnabled(), deathRange)) {
+		startMonsterDeathAnimation(*monster, *monsterEntity, *combatState, deathRange, damageType);
+		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, ScriptExecutionContext::forMonsterDeath(*monster))) {
+			interaction = Common::move(deathInteraction);
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
+	}
+	if (herrillDeathQueued) {
+		interaction.mutatedRuntimeState = true;
+		interaction.visualRuntimeStateChanged = true;
+	}
+
+	return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
+}
+
+Common::Error Combat::killDebugActiveMonster(const Common::Point &mousePos, bool &needsRedraw) {
+	const RoomPlayerState &playerState = _player.getState();
+	if (!_engine.isCombatDebugEnabled())
+		return Common::kNoError;
+	MonsterRecord *monster = resolveKillTarget(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 == kEntityClassMonster &&
+			playerState.attackTargetName.equalsIgnoreCase(monster->monsterName)) {
+		_player.clearAttackTarget();
+	}
+
+	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(_scene.state, monster->monsterName);
+	MonsterCombatState *combatState =
+		monsterIndex >= 0 && monsterIndex < static_cast<int>(_monsterCombatStates.size())
+			? &_monsterCombatStates[static_cast<uint>(monsterIndex)]
+			: nullptr;
+	if (combatState)
+		clearRoomMonsterCombatState(*combatState);
+	(void)script->syncRuntimeMonsterRecord(*monster);
+
+	DeathAnimationRange deathRange;
+	if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
+			debugDamageType, _engine.isGoreEnabled(), deathRange)) {
+		startMonsterDeathAnimation(*monster, *monsterEntity, *combatState, deathRange, debugDamageType);
+		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, ScriptExecutionContext::forMonsterDeath(*monster))) {
+			interaction = Common::move(deathInteraction);
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
+	}
+
+	return _interactionProcessor.handleCombatInteraction(interaction, needsRedraw);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index a9a24c3dacb..8e1c23ccab5 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -24,6 +24,7 @@ MODULE_OBJS = \
 	player.o \
 	resources.o \
 	combat.o \
+	combat_contact.o \
 	combat_geometry.o \
 	combat_monsters.o \
 	combat_reactions.o \


Commit: 00f6720ad46cf410107066c64e2a1fe3e98fe1dd
    https://github.com/scummvm/scummvm/commit/00f6720ad46cf410107066c64e2a1fe3e98fe1dd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:02-04:00

Commit Message:
HARVESTER: Separate combat effects and HUD text

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/combat_effects.cpp
  A engines/harvester/combat_hud.cpp
    engines/harvester/combat.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/combat.cpp b/engines/harvester/combat.cpp
index 8b7e3ba8013..efe589c4955 100644
--- a/engines/harvester/combat.cpp
+++ b/engines/harvester/combat.cpp
@@ -21,149 +21,17 @@
 
 #include "harvester/combat.h"
 
+#include "common/debug.h"
 #include "common/util.h"
-#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"
-#include "harvester/interaction.h"
-#include "harvester/scene_entities.h"
 #include "harvester/entity.h"
+#include "harvester/scene_entities.h"
 
 namespace Harvester {
 
 const float kNativeNpcMonsterZExtent = 5.0f;
 const int kRoomNpcAmbientLastFrame = 0x3b;
 const uint32 kNativeKeyboardAttackRepeatTicks = 25;
-const char *const kNativeHitEffectResourcePath = "blood.abm";
-const int kNativeHitEffectAnimationRate = 10;
-const float kNativeHitEffectRenderZBias = 0.01f;
-const uint32 kCombatDebugDamagePopupDurationMs = 750;
-const int kCombatDebugDamagePopupRisePixels = 18;
-const int kCombatDebugDamagePopupVerticalGap = 6;
-
-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 = static_cast<int>(palette[paletteOffset]) - red;
-		const int dg = static_cast<int>(palette[paletteOffset + 1]) - green;
-		const int db = static_cast<int>(palette[paletteOffset + 2]) - blue;
-		const uint32 distance = static_cast<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);
-}
-
-
-Common::String resolveCombatLoadoutStatusLabel(Script &script, const ObjectRecord &object) {
-	Common::String label = script.resolveInventoryTooltipText(object);
-	if (!label.empty()) {
-		for (uint i = 0; i < label.size(); ++i) {
-			if (label[i] == '_')
-				label.setChar(' ', i);
-		}
-
-		uint trimAt = label.size();
-		for (uint i = 0; i < label.size(); ++i) {
-			if (label[i] == ',' || label[i] == ';') {
-				trimAt = i;
-				break;
-			}
-		}
-		label = label.substr(0, trimAt);
-		while (!label.empty() && label.lastChar() == ' ')
-			label.deleteLastChar();
-		if (!label.empty())
-			return label;
-	}
-
-	label = script.resolveObjectLabel(object);
-	if (!label.empty())
-		return label;
-
-	label = object.objectName;
-	for (uint i = 0; i < label.size(); ++i) {
-		if (label[i] == '_')
-			label.setChar(' ', i);
-	}
-
-	return label;
-}
-
-} // End of anonymous namespace
-
-bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info) {
-	switch (loadout) {
-	case 2:
-		info.iconIndex = 2;
-		info.maxVisibleCount = 16;
-		info.unitLabel = "nails";
-		return true;
-	case 3:
-		info.iconIndex = 1;
-		info.maxVisibleCount = 16;
-		info.unitLabel = "shells";
-		return true;
-	case 4:
-		info.iconIndex = 0;
-		info.maxVisibleCount = 8;
-		info.unitLabel = "bullets";
-		return true;
-	case 5:
-		info.iconIndex = 0;
-		info.maxVisibleCount = 6;
-		info.unitLabel = "bullets";
-		return true;
-	case 14:
-		info.iconIndex = 3;
-		info.maxVisibleCount = 16;
-		info.unitLabel = "gas";
-		return true;
-	default:
-		return false;
-	}
-}
-
-Common::String buildCombatLoadoutStatusMessage(Script &script, const ObjectRecord &object,
-		int previousLoadout, int currentLoadout) {
-	const Common::String label = resolveCombatLoadoutStatusLabel(script, object);
-	if (label.empty())
-		return Common::String();
-
-	if (currentLoadout == 0 && previousLoadout != 0)
-		return Common::String::format("Disarming %s...", label.c_str());
-
-	CombatLoadoutHudInfo info;
-	if (resolveCombatLoadoutHudInfo(currentLoadout, info))
-		return Common::String::format("Arming %s, %d %s...", label.c_str(),
-			script.getPlayerCombatResourceCount(currentLoadout), info.unitLabel);
-
-	return Common::String::format("Arming %s...", label.c_str());
-}
 
 const char *describeCombatTargetClass(int classId) {
 	switch (classId) {
@@ -262,177 +130,4 @@ void clearRoomMonsterHitState(MonsterCombatState &combatState) {
 	combatState.hitSoundPlayed = false;
 }
 
-CombatEffects::CombatEffects(EntityManager *entityManager) : _entityManager(entityManager) {
-	debugC(1, kDebugCombat, "Harvester: created room combat effects entities=%d", _entityManager != nullptr);
-}
-
-void CombatEffects::clearHitEffects() {
-	debugC(2, kDebugCombat, "Harvester: resetting room blood effects records=%u next_id=%u",
-		static_cast<uint>(_hitEffectStates.size()), _nextCombatEffectId);
-	_hitEffectStates.clear();
-	_nextCombatEffectId = 0;
-}
-
-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 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 CombatEffects::spawnCombatHitEffect(Entity &sourceEntity, const Common::String &followTargetName) {
-	if (!_entityManager)
-		return false;
-
-	HitEffectState 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 CombatEffects::spawnCombatDamagePopup(Entity &targetEntity, const Common::String &followTargetName, int damageAmount) {
-	if (damageAmount <= 0)
-		return;
-
-	CombatDamagePopupState popupState;
-	popupState.followTargetName = followTargetName;
-	popupState.startTick = Player::getRuntimeClockTicks();
-	popupState.damageAmount = damageAmount;
-	resolveCombatDamagePopupAnchor(targetEntity, popupState.anchorPoint);
-	_damagePopupStates.push_back(Common::move(popupState));
-}
-
-bool CombatEffects::syncCombatHitEffects() {
-	if (!_entityManager)
-		return false;
-
-	bool changed = false;
-	for (HitEffectState &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 CombatEffects::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 (CombatDamagePopupState &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 = static_cast<int>(
-			clampedElapsed * static_cast<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 CombatEffects::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 CombatEffects::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/combat_effects.cpp b/engines/harvester/combat_effects.cpp
new file mode 100644
index 00000000000..7ba3eb0f699
--- /dev/null
+++ b/engines/harvester/combat_effects.cpp
@@ -0,0 +1,250 @@
+/* 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/combat.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "graphics/font.h"
+#include "graphics/fontman.h"
+#include "graphics/screen.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/player.h"
+
+namespace Harvester {
+
+const char *const kNativeHitEffectResourcePath = "blood.abm";
+const int kNativeHitEffectAnimationRate = 10;
+const float kNativeHitEffectRenderZBias = 0.01f;
+const uint32 kCombatDebugDamagePopupDurationMs = 750;
+const int kCombatDebugDamagePopupRisePixels = 18;
+const int kCombatDebugDamagePopupVerticalGap = 6;
+
+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 = static_cast<int>(palette[paletteOffset]) - red;
+		const int dg = static_cast<int>(palette[paletteOffset + 1]) - green;
+		const int db = static_cast<int>(palette[paletteOffset + 2]) - blue;
+		const uint32 distance = static_cast<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, const Common::Point32 &position, byte textColor, byte shadowColor) {
+	if (text.empty())
+		return;
+
+	const int width = font.getStringWidth(text);
+	font.drawString(&screen, text, position.x + 1, position.y + 1, width, shadowColor);
+	font.drawString(&screen, text, position.x, position.y, width, textColor);
+}
+
+} // End of anonymous namespace
+
+CombatEffects::CombatEffects(EntityManager *entityManager) : _entityManager(entityManager) {
+	debugC(1, kDebugCombat, "Harvester: created room combat effects entities=%d", _entityManager != nullptr);
+}
+
+void CombatEffects::clearHitEffects() {
+	debugC(2, kDebugCombat, "Harvester: resetting room blood effects records=%u next_id=%u",
+		static_cast<uint>(_hitEffectStates.size()), _nextCombatEffectId);
+	_hitEffectStates.clear();
+	_nextCombatEffectId = 0;
+}
+
+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 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 CombatEffects::spawnCombatHitEffect(Entity &sourceEntity, const Common::String &followTargetName) {
+	if (!_entityManager)
+		return false;
+
+	HitEffectState 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 CombatEffects::spawnCombatDamagePopup(Entity &targetEntity, const Common::String &followTargetName, int damageAmount) {
+	if (damageAmount <= 0)
+		return;
+
+	CombatDamagePopupState popupState;
+	popupState.followTargetName = followTargetName;
+	popupState.startTick = Player::getRuntimeClockTicks();
+	popupState.damageAmount = damageAmount;
+	resolveCombatDamagePopupAnchor(targetEntity, popupState.anchorPoint);
+	_damagePopupStates.push_back(Common::move(popupState));
+}
+
+bool CombatEffects::syncCombatHitEffects() {
+	if (!_entityManager)
+		return false;
+
+	bool changed = false;
+	for (HitEffectState &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 CombatEffects::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 (CombatDamagePopupState &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 = static_cast<int>(
+			clampedElapsed * static_cast<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, Common::Point32(drawX, drawY), white, black);
+	}
+}
+
+bool CombatEffects::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 CombatEffects::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/combat_hud.cpp b/engines/harvester/combat_hud.cpp
new file mode 100644
index 00000000000..a1e4c63ea6d
--- /dev/null
+++ b/engines/harvester/combat_hud.cpp
@@ -0,0 +1,116 @@
+/* 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/combat.h"
+
+#include "harvester/script.h"
+
+namespace Harvester {
+
+namespace {
+
+Common::String resolveCombatLoadoutStatusLabel(Script &script, const ObjectRecord &object) {
+	Common::String label = script.resolveInventoryTooltipText(object);
+	if (!label.empty()) {
+		for (uint i = 0; i < label.size(); ++i) {
+			if (label[i] == '_')
+				label.setChar(' ', i);
+		}
+
+		uint trimAt = label.size();
+		for (uint i = 0; i < label.size(); ++i) {
+			if (label[i] == ',' || label[i] == ';') {
+				trimAt = i;
+				break;
+			}
+		}
+		label = label.substr(0, trimAt);
+		while (!label.empty() && label.lastChar() == ' ')
+			label.deleteLastChar();
+		if (!label.empty())
+			return label;
+	}
+
+	label = script.resolveObjectLabel(object);
+	if (!label.empty())
+		return label;
+
+	label = object.objectName;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return label;
+}
+
+} // End of anonymous namespace
+
+bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info) {
+	switch (loadout) {
+	case 2:
+		info.iconIndex = 2;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "nails";
+		return true;
+	case 3:
+		info.iconIndex = 1;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "shells";
+		return true;
+	case 4:
+		info.iconIndex = 0;
+		info.maxVisibleCount = 8;
+		info.unitLabel = "bullets";
+		return true;
+	case 5:
+		info.iconIndex = 0;
+		info.maxVisibleCount = 6;
+		info.unitLabel = "bullets";
+		return true;
+	case 14:
+		info.iconIndex = 3;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "gas";
+		return true;
+	default:
+		return false;
+	}
+}
+
+Common::String buildCombatLoadoutStatusMessage(Script &script, const ObjectRecord &object,
+		int previousLoadout, int currentLoadout) {
+	const Common::String label = resolveCombatLoadoutStatusLabel(script, object);
+	if (label.empty())
+		return Common::String();
+
+	if (currentLoadout == 0 && previousLoadout != 0)
+		return Common::String::format("Disarming %s...", label.c_str());
+
+	CombatLoadoutHudInfo info;
+	if (resolveCombatLoadoutHudInfo(currentLoadout, info))
+		return Common::String::format("Arming %s, %d %s...", label.c_str(),
+			script.getPlayerCombatResourceCount(currentLoadout), info.unitLabel);
+
+	return Common::String::format("Arming %s...", label.c_str());
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 8e1c23ccab5..39bf20e2166 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -25,7 +25,9 @@ MODULE_OBJS = \
 	resources.o \
 	combat.o \
 	combat_contact.o \
+	combat_effects.o \
 	combat_geometry.o \
+	combat_hud.o \
 	combat_monsters.o \
 	combat_reactions.o \
 	combat_targeting.o \


Commit: a56f1982e47baff9b3705b95c831a9783d6b6af3
    https://github.com/scummvm/scummvm/commit/a56f1982e47baff9b3705b95c831a9783d6b6af3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate player movement and collision helpers

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/player_movement.cpp
  A engines/harvester/player_movement.h
    engines/harvester/module.mk
    engines/harvester/player.cpp


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 39bf20e2166..048ee050d48 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -22,6 +22,7 @@ MODULE_OBJS = \
 	metaengine.o \
 	monster.o \
 	player.o \
+	player_movement.o \
 	resources.o \
 	combat.o \
 	combat_contact.o \
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 1ec1826d225..c96dee572f0 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "harvester/player.h"
+#include "harvester/player_movement.h"
 #include "harvester/combat.h"
 
 #include "common/debug.h"
@@ -31,24 +32,21 @@
 
 namespace Harvester {
 
+using namespace PlayerMovement;
+
 namespace {
 
-static void syncPlayerAnimationPlacement(RoomPlayerState &playerState);
 static bool setPlayerIdleAnimation(RoomPlayerState &playerState, int facing);
 static void updatePlayerIdleTrigger(RoomIdleAnimationState &idleState);
 
 static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
-static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const float kRoomPlayerHorizontalTargetSlackBase = 50.0f;
 static const float kRoomPlayerDepthTargetSlack = 8.0f;
 static const int kRoomRegionTargetXBias = 10;
-static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
 static const int kRoomPlayerFastAnimationRate = 30;
 static const int kRoomPlayerHitAnimationRate = 4;
 static const int kNativePlayerDeathEntryAnimationRate = 14;
-static const int kRoomPlayerMinOpaqueLeftX = 4;
-static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
 static const float kRoomPlayerAttackUpperYOffset = 144.44f;
 static const float kRoomPlayerAttackMidYOffset = 75.36f;
 static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
@@ -62,16 +60,6 @@ static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
 static const int kRoomPlayerIdleExitLastFrame = 0xbf;
 static const int kRoomPlayerIdleYOffset = 4;
 
-struct PlayerAnimationRange {
-	PlayerAnimationRange() {}
-	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
-		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
-
-	int walkFirstFrame = 0;
-	int walkLastFrame = 0;
-	int idleFrame = 0;
-};
-
 struct PlayerTurnAnimationRange {
 	PlayerTurnAnimationRange() {}
 	PlayerTurnAnimationRange(int firstFrame, int lastFrame, bool playBackwards)
@@ -179,25 +167,6 @@ static const PlayerCombatLoadoutTuning kPlayerCombatLoadoutTunings[] = {
 	{ "POOLSTICK", 1, 2, 2 }
 };
 
-static int roundToInt(float value) {
-	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
-}
-
-static PlayerAnimationRange resolvePlayerAnimationRange(int facing) {
-	switch (facing) {
-	case 0:
-		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
-	case 1:
-		return PlayerAnimationRange{ 0x0f, 0x18, 0x0e };
-	case 2:
-		return PlayerAnimationRange{ 0x2d, 0x36, 0x2c };
-	case 3:
-		return PlayerAnimationRange{ 0x1e, 0x27, 0x28 };
-	default:
-		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
-	}
-}
-
 static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, PlayerTurnAnimationRange &range) {
 	// Native desired-state transitions play these banks forward or backward depending on the source facing.
 	if (fromFacing == 0 && toFacing == 1) {
@@ -508,72 +477,6 @@ static bool startResolvedAttackAnimation(RoomPlayerState &playerState,
 	return true;
 }
 
-static int clampRoomMovementY(const RoomSetupState &state, int screenY) {
-	if (!Player::supportsMovementBand(state))
-		return screenY;
-
-	return CLIP<int>(screenY, state.roomMaxZScreenY, state.roomMinZScreenY);
-}
-
-static float mapRoomScreenYToDepth(const RoomSetupState &state, int screenY) {
-	if (!Player::supportsMovementBand(state))
-		return (float)state.playerSpawnZ;
-
-	if (screenY <= state.roomMaxZScreenY)
-		return (float)state.roomMaxZ;
-	if (screenY >= state.roomMinZScreenY)
-		return (float)state.roomMinZ;
-
-	return (float)(state.roomMaxZ - ((state.roomMaxZ - state.roomMinZ) *
-		(screenY - state.roomMaxZScreenY)) /
-		(state.roomMinZScreenY - state.roomMaxZScreenY));
-}
-
-static float clampRoomDepth(const RoomSetupState &state, float z) {
-	if (!Player::supportsMovementBand(state))
-		return z;
-
-	return CLIP<float>(z,
-		(float)MIN(state.roomMinZ, state.roomMaxZ),
-		(float)MAX(state.roomMinZ, state.roomMaxZ));
-}
-
-static int mapRoomDepthToScreenY(const RoomSetupState &state, float z) {
-	if (!Player::supportsMovementBand(state))
-		return state.playerSpawnY;
-
-	if (state.roomMaxZ == state.roomMinZ)
-		return clampRoomMovementY(state, state.playerSpawnY);
-
-	const float clampedZ = clampRoomDepth(state, z);
-	const float offset = ((float)state.roomMaxZ - clampedZ) *
-		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
-		(float)(state.roomMaxZ - state.roomMinZ);
-	return clampRoomMovementY(state, state.roomMaxZScreenY + roundToInt(offset));
-}
-
-static int computeRoomPlayerHorizontalStep(const RoomSetupState &state, float z) {
-	return MAX<int>(1, (int)(Player::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
-}
-
-static float computeRoomPlayerDepthStep(const RoomSetupState &state) {
-	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
-}
-
-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 float clampPlayerMovementDepth(const RoomSetupState &state,
-		const RoomPlayerState &playerState, float z) {
-	const float placementOffset = playerState.z - computePlayerRenderZ(playerState);
-	return clampRoomDepth(state, z - placementOffset) + placementOffset;
-}
-
 static void resetPlayerMoveTargetProgress(RoomPlayerState &playerState) {
 	playerState.moveTargetXReached = false;
 	playerState.moveTargetZReached = false;
@@ -618,200 +521,6 @@ static void updatePlayerMoveTargetProgress(const RoomSetupState &state,
 	}
 }
 
-static int clampPlayerCenterXToNativeBounds(const RoomPlayerState &playerState, int centerX) {
-	if (!playerState.entity)
-		return CLIP<int>(centerX, 0, 639);
-
-	int width = 0;
-	int height = 0;
-	int xOffset = 0;
-	int yOffset = 0;
-	if (!playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset))
-		return CLIP<int>(centerX, 0, 639);
-
-	const int minCenterX = kRoomPlayerMinOpaqueLeftX + width / 2;
-	const int maxCenterX = MAX(minCenterX,
-		kRoomPlayerMaxOpaqueRightX - (width - width / 2));
-	return CLIP<int>(centerX, minCenterX, maxCenterX);
-}
-
-static uint32 computeAnimationTickInterval(int rate) {
-	return rate > 0 ? MAX<uint32>(1, 100U / (uint32)rate) : 0;
-}
-
-static bool consumePlayerMovementTick(RoomPlayerState &playerState) {
-	if (playerState.entity &&
-			playerState.entity->isAnimationEnabled() &&
-			playerState.entity->getAnimationRate() != 0) {
-		playerState.nextMovementTick = 0;
-		return playerState.entity->didAnimationAdvanceLastTick();
-	}
-
-	const uint32 interval = computeAnimationTickInterval(playerState.animationRate);
-	if (interval == 0) {
-		playerState.nextMovementTick = 0;
-		return true;
-	}
-
-	const uint32 now = Player::getRuntimeClockTicks();
-	if (playerState.nextMovementTick != 0 &&
-			(int32)(now - playerState.nextMovementTick) < 0) {
-		return false;
-	}
-
-	playerState.nextMovementTick = now + interval;
-	return true;
-}
-
-static bool shouldIgnoreMovementBlocker(const Entity &entity, const Common::String *ignoredBlockerName) {
-	return ignoredBlockerName && !ignoredBlockerName->empty() &&
-		entity.getName().equalsIgnoreCase(*ignoredBlockerName);
-}
-
-static bool isSameBlockerName(const Common::String &history, const Entity &blocker) {
-	return !history.empty() && history.equalsIgnoreCase(blocker.getName());
-}
-
-static void clearPlayerBlockerHistory(RoomPlayerState &playerState) {
-	playerState.currentBlockerName.clear();
-	playerState.upwardBlockerHistory.clear();
-	playerState.downwardBlockerHistory.clear();
-	playerState.leftwardBlockerHistory.clear();
-	playerState.rightwardBlockerHistory.clear();
-}
-
-static void setPlayerCurrentBlocker(RoomPlayerState &playerState, const Entity *blocker) {
-	if (blocker) {
-		playerState.currentBlockerName = blocker->getName();
-		return;
-	}
-
-	clearPlayerBlockerHistory(playerState);
-}
-
-static const Entity *findPlayerRoomBlocker(HarvesterEngine &engine,
-		const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations, const Entity &playerEntity,
-		const Common::String *ignoredBlockerName) {
-	EntityManager *entityManager = engine.getRuntimeEntities();
-	if (!entityManager)
-		return nullptr;
-
-	for (const ObjectRecord &object : sceneObjects) {
-		const Entity *entity = entityManager->findSceneEntityByName(object.objectName);
-		if (entity && !shouldIgnoreMovementBlocker(*entity, ignoredBlockerName) &&
-				playerEntity.overlapsEntity(*entity)) {
-			return entity;
-		}
-	}
-
-	for (const AnimRecord &anim : sceneAnimations) {
-		const Entity *entity = entityManager->findSceneEntityByName(anim.animName);
-		if (entity && !shouldIgnoreMovementBlocker(*entity, ignoredBlockerName) &&
-				playerEntity.overlapsEntity(*entity)) {
-			return entity;
-		}
-	}
-
-	return nullptr;
-}
-
-static void refreshPlayerCurrentBlocker(HarvesterEngine &engine,
-		const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState) {
-	if (!playerState.entity) {
-		clearPlayerBlockerHistory(playerState);
-		return;
-	}
-
-	setPlayerCurrentBlocker(playerState, findPlayerRoomBlocker(engine, sceneObjects,
-		sceneAnimations, *playerState.entity, nullptr));
-}
-
-static void reinsertPlayerSceneEntity(HarvesterEngine &engine, const RoomPlayerState &playerState) {
-	if (!playerState.entity)
-		return;
-
-	EntityManager *entityManager = engine.getRuntimeEntities();
-	if (entityManager &&
-			entityManager->findSceneEntityByName(playerState.entity->getName()) == playerState.entity) {
-		entityManager->reinsertSceneEntity(playerState.entity);
-	}
-}
-
-static void applyPlayerMovementPlacement(const RoomSetupState &state, RoomPlayerState &playerState,
-		int candidateCenterX, int candidateBottomY, float candidateZ) {
-	Entity &entity = *playerState.entity;
-	const float renderZ = candidateZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
-	entity.setPosition(entity.getX() + candidateCenterX - playerState.centerX,
-		entity.getY() + candidateBottomY - playerState.bottomY, renderZ);
-	entity.setDepthScale(Player::computeDepthScale(state, renderZ));
-}
-
-static bool isPlayerMovementBlocked(HarvesterEngine &engine, const RoomSetupState &state,
-		const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState, int candidateCenterX, int candidateBottomY, float candidateZ,
-		const Common::String *ignoredBlockerName = nullptr, const Entity **blockerOut = nullptr) {
-	if (!playerState.entity)
-		return true;
-
-	if (blockerOut)
-		*blockerOut = nullptr;
-
-	Entity &entity = *playerState.entity;
-	const int oldX = entity.getX();
-	const int oldY = entity.getY();
-	const float oldZ = entity.getZ();
-	const float oldScale = entity.getDepthScale();
-	applyPlayerMovementPlacement(state, playerState, candidateCenterX, candidateBottomY, candidateZ);
-
-	const Entity *blocker = findPlayerRoomBlocker(engine, sceneObjects, sceneAnimations,
-		entity, ignoredBlockerName);
-	entity.setPosition(oldX, oldY, oldZ);
-	entity.setDepthScale(oldScale);
-	if (blockerOut)
-		*blockerOut = blocker;
-	return blocker != nullptr;
-}
-
-static bool tryApplyPlayerMovement(HarvesterEngine &engine, const RoomSetupState &state,
-		const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState, int candidateCenterX, int candidateBottomY, float candidateZ,
-		const Common::String *ignoredBlockerName = nullptr, const Entity **blockerOut = nullptr) {
-	if (!playerState.entity)
-		return false;
-
-	if (blockerOut)
-		*blockerOut = nullptr;
-
-	candidateCenterX = clampPlayerCenterXToNativeBounds(playerState, candidateCenterX);
-	candidateZ = clampRoomDepth(state, candidateZ);
-	if (candidateCenterX == playerState.centerX &&
-			candidateBottomY == playerState.bottomY &&
-			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
-		return false;
-	}
-	if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, candidateBottomY, candidateZ,
-			ignoredBlockerName, blockerOut)) {
-		return false;
-	}
-
-	applyPlayerMovementPlacement(state, playerState, candidateCenterX, candidateBottomY, candidateZ);
-	playerState.z = candidateZ;
-	syncPlayerAnimationPlacement(playerState);
-
-	reinsertPlayerSceneEntity(engine, playerState);
-	return true;
-}
-
-static bool isPathfindingDebugEnabled() {
-	return g_engine && g_engine->isPathfindingDebugEnabled();
-}
-
 static bool isHorizontalDirectionBlockedByHistory(const RoomPlayerState &playerState,
 		int horizontalDirection) {
 	if (horizontalDirection < 0)
@@ -855,137 +564,6 @@ static int resolveFacingFromMoveDirection(int horizontalDirection, int depthDire
 	return fallbackFacing >= 0 ? fallbackFacing : 0;
 }
 
-static void logPlayerBlockerHistory(const RoomSetupState &state,
-		const RoomPlayerState &playerState, const Entity &blocker,
-		const char *direction) {
-	if (!isPathfindingDebugEnabled())
-		return;
-
-	debugC(1, kDebugPathfinding,
-		"Harvester: pathfinding blocker history room='%s' blocker='%s' direction='%s' histories=(up='%s',down='%s',left='%s',right='%s')",
-		state.roomName.c_str(), blocker.getName().c_str(), direction,
-		playerState.upwardBlockerHistory.c_str(),
-		playerState.downwardBlockerHistory.c_str(),
-		playerState.leftwardBlockerHistory.c_str(),
-		playerState.rightwardBlockerHistory.c_str());
-}
-
-static bool recordNativePrimaryWalkBlockerHistory(const RoomSetupState &state,
-		RoomPlayerState &playerState, const Entity &blocker,
-		int horizontalDirection, int depthDirection) {
-	if (horizontalDirection < 0) {
-		const int frameLeftX = computePlayerFrameLeftX(playerState);
-		const Common::Rect blockerRect = blocker.getScreenRect();
-		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
-				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
-				isSameBlockerName(playerState.downwardBlockerHistory, blocker) ||
-				frameLeftX <= blockerRect.left) {
-			return false;
-		}
-
-		playerState.leftwardBlockerHistory = blocker.getName();
-		playerState.moveTargetXReached = true;
-		logPlayerBlockerHistory(state, playerState, blocker, "left");
-		return true;
-	}
-
-	if (horizontalDirection > 0) {
-		const int frameLeftX = computePlayerFrameLeftX(playerState);
-		const Common::Rect blockerRect = blocker.getScreenRect();
-		if (isSameBlockerName(playerState.leftwardBlockerHistory, blocker) ||
-				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
-				isSameBlockerName(playerState.downwardBlockerHistory, blocker) ||
-				blockerRect.left <= frameLeftX) {
-			return false;
-		}
-
-		playerState.rightwardBlockerHistory = blocker.getName();
-		playerState.moveTargetXReached = true;
-		logPlayerBlockerHistory(state, playerState, blocker, "right");
-		return true;
-	}
-
-	if (depthDirection > 0) {
-		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
-				isSameBlockerName(playerState.leftwardBlockerHistory, blocker) ||
-				isSameBlockerName(playerState.downwardBlockerHistory, blocker)) {
-			return false;
-		}
-
-		playerState.upwardBlockerHistory = blocker.getName();
-		playerState.moveTargetZReached = true;
-		logPlayerBlockerHistory(state, playerState, blocker, "upward");
-		return true;
-	}
-
-	if (depthDirection < 0) {
-		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
-				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
-				isSameBlockerName(playerState.leftwardBlockerHistory, blocker)) {
-			return false;
-		}
-
-		playerState.downwardBlockerHistory = blocker.getName();
-		playerState.moveTargetZReached = true;
-		logPlayerBlockerHistory(state, playerState, blocker, "downward");
-		return true;
-	}
-
-	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() != playerState.animationRate ||
-			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,
-		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)) {
-		playPlayerBlockedWalkSounds(engine, state, playerState, *blocker);
-		if (blockerOut)
-			*blockerOut = blocker;
-		return false;
-	}
-
-	if (candidateCenterX == playerState.centerX &&
-			candidateBottomY == playerState.bottomY &&
-			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon)
-		return false;
-
-	applyPlayerMovementPlacement(state, playerState, candidateCenterX, candidateBottomY, candidateZ);
-	playerState.z = candidateZ;
-	syncPlayerAnimationPlacement(playerState);
-	reinsertPlayerSceneEntity(engine, playerState);
-	return true;
-}
-
 static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &playerState,
 		int targetX, float targetZ, int targetBottomY) {
 	playerState.hasMoveTarget = true;
@@ -1099,22 +677,6 @@ static bool finishPlayerIdleAnimation(const RoomSetupState &state, RoomPlayerSta
 	return true;
 }
 
-static void syncPlayerAnimationPlacement(RoomPlayerState &playerState) {
-	if (!playerState.entity)
-		return;
-
-	const Common::Rect rect = playerState.entity->getScreenRect();
-	const int centerX = rect.left + rect.width() / 2;
-	if (playerState.centerX != centerX || playerState.bottomY != rect.bottom) {
-		debugC(3, kDebugPlayer,
-			"Harvester: player frame bounds frame=%d base=(%d,%d) rect=(%d,%d)-(%d,%d) z=%.2f",
-			playerState.entity->getCurrentFrame(), playerState.entity->getX(), playerState.entity->getY(),
-			rect.left, rect.top, rect.right, rect.bottom, (double)playerState.z);
-	}
-	playerState.centerX = centerX;
-	playerState.bottomY = rect.bottom;
-}
-
 static void updatePlayerIdleTrigger(RoomIdleAnimationState &idleState) {
 	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
 }
@@ -1442,57 +1004,6 @@ void Player::setMoveTargetFromScreenPoint(const RoomSetupState &state, int targe
 		mapRoomScreenYToDepth(state, clampedTargetBottomY), clampedTargetBottomY);
 }
 
-bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetupState &state,
-		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity)
-		return false;
-
-	if (!isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-			playerState, playerState.centerX, playerState.bottomY, playerState.z)) {
-		return false;
-	}
-
-	const int originalCenterX = playerState.centerX;
-	const int originalBottomY = playerState.bottomY;
-	const int preferredDirection = playerState.facing == 1 ? -1 : 1;
-	for (int distance = 1; distance < 640; ++distance) {
-		int previousCandidateX = originalCenterX;
-		for (int pass = 0; pass < 2; ++pass) {
-			const int direction = pass == 0 ? preferredDirection : -preferredDirection;
-			const int candidateX = clampPlayerCenterXToNativeBounds(
-				playerState, originalCenterX + direction * distance);
-			if (candidateX == originalCenterX || candidateX == previousCandidateX)
-				continue;
-			previousCandidateX = candidateX;
-			if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-					playerState, candidateX, playerState.bottomY, playerState.z)) {
-				continue;
-			}
-
-			playerState.centerX = candidateX;
-			if (!applyRoomActorPlacement(state, *playerState.entity,
-					playerState.centerX, playerState.bottomY, playerState.z)) {
-				return false;
-			}
-			reinsertPlayerSceneEntity(engine, playerState);
-
-			debugC(1, kDebugPlayer,
-				"Harvester: adjusted blocked startup spawn room='%s' facing=%d from=(%d,%d,z=%.2f) to=(%d,%d,z=%.2f)",
-				state.roomName.c_str(), playerState.facing,
-				originalCenterX, originalBottomY, (double)playerState.z,
-				playerState.centerX, playerState.bottomY, (double)playerState.z);
-			return true;
-		}
-	}
-
-	debugC(1, kDebugPlayer,
-		"Harvester: startup spawn remains blocked room='%s' pos=(%d,%d,z=%.2f)",
-		state.roomName.c_str(), playerState.centerX, playerState.bottomY,
-		(double)playerState.z);
-	return false;
-}
-
 int Player::resolveRegionTargetX(const RoomSetupState &state, const RegionRecord &region,
 		const RoomPlayerState &playerState) {
 	const Common::Rect bounds(region.left, region.top, region.right, region.bottom);
@@ -1809,6 +1320,7 @@ bool Player::updateHitAnimationState(HarvesterEngine &engine, const RoomSetupSta
 	if (!playerState.hitActive || !playerState.entity)
 		return false;
 
+	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
 	bool changed = false;
 	if (playerState.hitKnockbackRemainingX != 0) {
 		const int step = MAX(1, ABS(playerState.hitKnockbackDecayStep));
@@ -1816,8 +1328,8 @@ bool Player::updateHitAnimationState(HarvesterEngine &engine, const RoomSetupSta
 			? MIN(playerState.hitKnockbackRemainingX, step)
 			: MAX(playerState.hitKnockbackRemainingX, -step);
 		if (knockbackStep != 0) {
-			if (!tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-					playerState, playerState.centerX + knockbackStep, playerState.bottomY, playerState.z)) {
+			if (!tryApplyPlayerMovement(movementContext,
+					{ playerState.centerX + knockbackStep, playerState.bottomY, playerState.z })) {
 				playerState.hitKnockbackRemainingX = 0;
 			} else {
 				changed = true;
@@ -1988,7 +1500,8 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 			playerState.hitActive || playerState.evasiveState >= 0)
 		return false;
 
-	refreshPlayerCurrentBlocker(engine, sceneObjects, sceneAnimations, playerState);
+	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
+	refreshPlayerCurrentBlocker(movementContext);
 
 	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
 	const int currentFrame = playerState.entity->getCurrentFrame();
@@ -2080,8 +1593,8 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	}
 
 	const Entity *blocker = nullptr;
-	bool moved = tryApplyPlayerWalkMovement(engine, state, sceneObjects, sceneAnimations,
-		playerState, candidateCenterX, candidateBottomY, candidateZ,
+	bool moved = tryApplyPlayerWalkMovement(movementContext,
+		{ candidateCenterX, candidateBottomY, candidateZ },
 		horizontalDirection, depthDirection, &blocker);
 	if (!moved) {
 		if (engine.isPathfindingDebugEnabled()) {
@@ -2123,7 +1636,7 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 			horizontalDirection, depthDirection);
 	}
 	syncAnimationPlacement();
-	refreshPlayerCurrentBlocker(engine, sceneObjects, sceneAnimations, playerState);
+	refreshPlayerCurrentBlocker(movementContext);
 	if (engine.isPathfindingDebugEnabled()) {
 		debugC(1, kDebugPathfinding,
 			"Harvester: pathfinding applied room='%s' previous=(%d,%d) current=(%d,%d,z=%.2f) facing=%d",
@@ -2149,6 +1662,7 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 	if (playerState.turnActive || playerState.hitActive || playerState.evasiveState >= 0)
 		return false;
 
+	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
 	int horizontalInput = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
 	int verticalInput = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
 	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
@@ -2220,8 +1734,8 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 		return false;
 
 	playerState.hasMoveTarget = false;
-	bool moved = tryApplyPlayerWalkMovement(engine, state, sceneObjects, sceneAnimations,
-		playerState, candidateCenterX, candidateBottomY, candidateZ,
+	bool moved = tryApplyPlayerWalkMovement(movementContext,
+		{ candidateCenterX, candidateBottomY, candidateZ },
 		horizontalInput, verticalInput);
 	if (!moved)
 		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
diff --git a/engines/harvester/player_movement.cpp b/engines/harvester/player_movement.cpp
new file mode 100644
index 00000000000..b19cf29b5d4
--- /dev/null
+++ b/engines/harvester/player_movement.cpp
@@ -0,0 +1,508 @@
+/* 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/player_movement.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+
+namespace Harvester {
+namespace PlayerMovement {
+
+static const float kRoomPlayerHorizontalMoveBase = 8.0f;
+const float kRoomDepthCompareEpsilon = 0.01f;
+static const int kRoomPlayerMinOpaqueLeftX = 4;
+static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
+
+static int roundToInt(float value) {
+	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
+}
+
+PlayerAnimationRange resolvePlayerAnimationRange(int facing) {
+	switch (facing) {
+	case 0:
+		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
+	case 1:
+		return PlayerAnimationRange{ 0x0f, 0x18, 0x0e };
+	case 2:
+		return PlayerAnimationRange{ 0x2d, 0x36, 0x2c };
+	case 3:
+		return PlayerAnimationRange{ 0x1e, 0x27, 0x28 };
+	default:
+		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
+	}
+}
+
+int clampRoomMovementY(const RoomSetupState &state, int screenY) {
+	if (!Player::supportsMovementBand(state))
+		return screenY;
+
+	return CLIP<int>(screenY, state.roomMaxZScreenY, state.roomMinZScreenY);
+}
+
+float mapRoomScreenYToDepth(const RoomSetupState &state, int screenY) {
+	if (!Player::supportsMovementBand(state))
+		return (float)state.playerSpawnZ;
+
+	if (screenY <= state.roomMaxZScreenY)
+		return (float)state.roomMaxZ;
+	if (screenY >= state.roomMinZScreenY)
+		return (float)state.roomMinZ;
+
+	return (float)(state.roomMaxZ - ((state.roomMaxZ - state.roomMinZ) *
+		(screenY - state.roomMaxZScreenY)) /
+		(state.roomMinZScreenY - state.roomMaxZScreenY));
+}
+
+float clampRoomDepth(const RoomSetupState &state, float z) {
+	if (!Player::supportsMovementBand(state))
+		return z;
+
+	return CLIP<float>(z,
+		(float)MIN(state.roomMinZ, state.roomMaxZ),
+		(float)MAX(state.roomMinZ, state.roomMaxZ));
+}
+
+int mapRoomDepthToScreenY(const RoomSetupState &state, float z) {
+	if (!Player::supportsMovementBand(state))
+		return state.playerSpawnY;
+
+	if (state.roomMaxZ == state.roomMinZ)
+		return clampRoomMovementY(state, state.playerSpawnY);
+
+	const float clampedZ = clampRoomDepth(state, z);
+	const float offset = ((float)state.roomMaxZ - clampedZ) *
+		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
+		(float)(state.roomMaxZ - state.roomMinZ);
+	return clampRoomMovementY(state, state.roomMaxZScreenY + roundToInt(offset));
+}
+
+int computeRoomPlayerHorizontalStep(const RoomSetupState &state, float z) {
+	return MAX<int>(1, (int)(Player::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
+}
+
+float computeRoomPlayerDepthStep(const RoomSetupState &state) {
+	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
+}
+
+int computePlayerFrameLeftX(const RoomPlayerState &playerState) {
+	return playerState.entity ? playerState.entity->getScreenRect().left : playerState.centerX;
+}
+
+float computePlayerRenderZ(const RoomPlayerState &playerState) {
+	return playerState.entity ? playerState.entity->getZ() : playerState.z;
+}
+
+float clampPlayerMovementDepth(const RoomSetupState &state,
+		const RoomPlayerState &playerState, float z) {
+	const float placementOffset = playerState.z - computePlayerRenderZ(playerState);
+	return clampRoomDepth(state, z - placementOffset) + placementOffset;
+}
+
+int clampPlayerCenterXToNativeBounds(const RoomPlayerState &playerState, int centerX) {
+	if (!playerState.entity)
+		return CLIP<int>(centerX, 0, 639);
+
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	if (!playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset))
+		return CLIP<int>(centerX, 0, 639);
+
+	const int minCenterX = kRoomPlayerMinOpaqueLeftX + width / 2;
+	const int maxCenterX = MAX(minCenterX,
+		kRoomPlayerMaxOpaqueRightX - (width - width / 2));
+	return CLIP<int>(centerX, minCenterX, maxCenterX);
+}
+
+static uint32 computeAnimationTickInterval(int rate) {
+	return rate > 0 ? MAX<uint32>(1, 100U / (uint32)rate) : 0;
+}
+
+bool consumePlayerMovementTick(RoomPlayerState &playerState) {
+	if (playerState.entity &&
+			playerState.entity->isAnimationEnabled() &&
+			playerState.entity->getAnimationRate() != 0) {
+		playerState.nextMovementTick = 0;
+		return playerState.entity->didAnimationAdvanceLastTick();
+	}
+
+	const uint32 interval = computeAnimationTickInterval(playerState.animationRate);
+	if (interval == 0) {
+		playerState.nextMovementTick = 0;
+		return true;
+	}
+
+	const uint32 now = Player::getRuntimeClockTicks();
+	if (playerState.nextMovementTick != 0 &&
+			(int32)(now - playerState.nextMovementTick) < 0) {
+		return false;
+	}
+
+	playerState.nextMovementTick = now + interval;
+	return true;
+}
+
+static bool shouldIgnoreMovementBlocker(const Entity &entity, const Common::String *ignoredBlockerName) {
+	return ignoredBlockerName && !ignoredBlockerName->empty() &&
+		entity.getName().equalsIgnoreCase(*ignoredBlockerName);
+}
+
+static bool isSameBlockerName(const Common::String &history, const Entity &blocker) {
+	return !history.empty() && history.equalsIgnoreCase(blocker.getName());
+}
+
+static void clearPlayerBlockerHistory(RoomPlayerState &playerState) {
+	playerState.currentBlockerName.clear();
+	playerState.upwardBlockerHistory.clear();
+	playerState.downwardBlockerHistory.clear();
+	playerState.leftwardBlockerHistory.clear();
+	playerState.rightwardBlockerHistory.clear();
+}
+
+void setPlayerCurrentBlocker(RoomPlayerState &playerState, const Entity *blocker) {
+	if (blocker) {
+		playerState.currentBlockerName = blocker->getName();
+		return;
+	}
+
+	clearPlayerBlockerHistory(playerState);
+}
+
+static const Entity *findPlayerRoomBlocker(const MovementContext &context, const Entity &playerEntity,
+		const Common::String *ignoredBlockerName) {
+	EntityManager *entityManager = context.engine.getRuntimeEntities();
+	if (!entityManager)
+		return nullptr;
+
+	for (const ObjectRecord &object : context.sceneObjects) {
+		const Entity *entity = entityManager->findSceneEntityByName(object.objectName);
+		if (entity && !shouldIgnoreMovementBlocker(*entity, ignoredBlockerName) &&
+				playerEntity.overlapsEntity(*entity)) {
+			return entity;
+		}
+	}
+
+	for (const AnimRecord &anim : context.sceneAnimations) {
+		const Entity *entity = entityManager->findSceneEntityByName(anim.animName);
+		if (entity && !shouldIgnoreMovementBlocker(*entity, ignoredBlockerName) &&
+				playerEntity.overlapsEntity(*entity)) {
+			return entity;
+		}
+	}
+
+	return nullptr;
+}
+
+void refreshPlayerCurrentBlocker(const MovementContext &context) {
+	RoomPlayerState &playerState = context.playerState;
+	if (!playerState.entity) {
+		clearPlayerBlockerHistory(playerState);
+		return;
+	}
+
+	setPlayerCurrentBlocker(playerState, findPlayerRoomBlocker(context, *playerState.entity, nullptr));
+}
+
+void reinsertPlayerSceneEntity(HarvesterEngine &engine, const RoomPlayerState &playerState) {
+	if (!playerState.entity)
+		return;
+
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (entityManager &&
+			entityManager->findSceneEntityByName(playerState.entity->getName()) == playerState.entity) {
+		entityManager->reinsertSceneEntity(playerState.entity);
+	}
+}
+
+static void applyPlayerMovementPlacement(const RoomSetupState &state, RoomPlayerState &playerState,
+		int candidateCenterX, int candidateBottomY, float candidateZ) {
+	Entity &entity = *playerState.entity;
+	const float renderZ = candidateZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
+	entity.setPosition(entity.getX() + candidateCenterX - playerState.centerX,
+		entity.getY() + candidateBottomY - playerState.bottomY, renderZ);
+	entity.setDepthScale(Player::computeDepthScale(state, renderZ));
+}
+
+bool isPlayerMovementBlocked(const MovementContext &context, const MovementPosition &candidate,
+		const Common::String *ignoredBlockerName, const Entity **blockerOut) {
+	const RoomSetupState &state = context.state;
+	RoomPlayerState &playerState = context.playerState;
+	if (!playerState.entity)
+		return true;
+
+	if (blockerOut)
+		*blockerOut = nullptr;
+
+	Entity &entity = *playerState.entity;
+	const int oldX = entity.getX();
+	const int oldY = entity.getY();
+	const float oldZ = entity.getZ();
+	const float oldScale = entity.getDepthScale();
+	applyPlayerMovementPlacement(state, playerState, candidate.centerX, candidate.bottomY, candidate.z);
+
+	const Entity *blocker = findPlayerRoomBlocker(context, entity, ignoredBlockerName);
+	entity.setPosition(oldX, oldY, oldZ);
+	entity.setDepthScale(oldScale);
+	if (blockerOut)
+		*blockerOut = blocker;
+	return blocker != nullptr;
+}
+
+bool tryApplyPlayerMovement(const MovementContext &context, MovementPosition candidate,
+		const Common::String *ignoredBlockerName, const Entity **blockerOut) {
+	HarvesterEngine &engine = context.engine;
+	const RoomSetupState &state = context.state;
+	RoomPlayerState &playerState = context.playerState;
+	if (!playerState.entity)
+		return false;
+
+	if (blockerOut)
+		*blockerOut = nullptr;
+
+	candidate.centerX = clampPlayerCenterXToNativeBounds(playerState, candidate.centerX);
+	candidate.z = clampRoomDepth(state, candidate.z);
+	if (candidate.centerX == playerState.centerX &&
+			candidate.bottomY == playerState.bottomY &&
+			fabsf(candidate.z - playerState.z) <= kRoomDepthCompareEpsilon) {
+		return false;
+	}
+	if (isPlayerMovementBlocked(context, candidate, ignoredBlockerName, blockerOut)) {
+		return false;
+	}
+
+	applyPlayerMovementPlacement(state, playerState, candidate.centerX, candidate.bottomY, candidate.z);
+	playerState.z = candidate.z;
+	syncPlayerAnimationPlacement(playerState);
+
+	reinsertPlayerSceneEntity(engine, playerState);
+	return true;
+}
+
+bool isPathfindingDebugEnabled() {
+	return g_engine && g_engine->isPathfindingDebugEnabled();
+}
+
+static void logPlayerBlockerHistory(const RoomSetupState &state,
+		const RoomPlayerState &playerState, const Entity &blocker,
+		const char *direction) {
+	if (!isPathfindingDebugEnabled())
+		return;
+
+	debugC(1, kDebugPathfinding,
+		"Harvester: pathfinding blocker history room='%s' blocker='%s' direction='%s' histories=(up='%s',down='%s',left='%s',right='%s')",
+		state.roomName.c_str(), blocker.getName().c_str(), direction,
+		playerState.upwardBlockerHistory.c_str(),
+		playerState.downwardBlockerHistory.c_str(),
+		playerState.leftwardBlockerHistory.c_str(),
+		playerState.rightwardBlockerHistory.c_str());
+}
+
+static bool recordNativePrimaryWalkBlockerHistory(const RoomSetupState &state,
+		RoomPlayerState &playerState, const Entity &blocker,
+		int horizontalDirection, int depthDirection) {
+	if (horizontalDirection < 0) {
+		const int frameLeftX = computePlayerFrameLeftX(playerState);
+		const Common::Rect blockerRect = blocker.getScreenRect();
+		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.downwardBlockerHistory, blocker) ||
+				frameLeftX <= blockerRect.left) {
+			return false;
+		}
+
+		playerState.leftwardBlockerHistory = blocker.getName();
+		playerState.moveTargetXReached = true;
+		logPlayerBlockerHistory(state, playerState, blocker, "left");
+		return true;
+	}
+
+	if (horizontalDirection > 0) {
+		const int frameLeftX = computePlayerFrameLeftX(playerState);
+		const Common::Rect blockerRect = blocker.getScreenRect();
+		if (isSameBlockerName(playerState.leftwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.downwardBlockerHistory, blocker) ||
+				blockerRect.left <= frameLeftX) {
+			return false;
+		}
+
+		playerState.rightwardBlockerHistory = blocker.getName();
+		playerState.moveTargetXReached = true;
+		logPlayerBlockerHistory(state, playerState, blocker, "right");
+		return true;
+	}
+
+	if (depthDirection > 0) {
+		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.leftwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.downwardBlockerHistory, blocker)) {
+			return false;
+		}
+
+		playerState.upwardBlockerHistory = blocker.getName();
+		playerState.moveTargetZReached = true;
+		logPlayerBlockerHistory(state, playerState, blocker, "upward");
+		return true;
+	}
+
+	if (depthDirection < 0) {
+		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.leftwardBlockerHistory, blocker)) {
+			return false;
+		}
+
+		playerState.downwardBlockerHistory = blocker.getName();
+		playerState.moveTargetZReached = true;
+		logPlayerBlockerHistory(state, playerState, blocker, "downward");
+		return true;
+	}
+
+	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() != playerState.animationRate ||
+			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);
+}
+
+bool tryApplyPlayerWalkMovement(const MovementContext &context, const MovementPosition &candidate,
+		int horizontalDirection, int depthDirection, const Entity **blockerOut) {
+	HarvesterEngine &engine = context.engine;
+	const RoomSetupState &state = context.state;
+	RoomPlayerState &playerState = context.playerState;
+	const Entity *blocker = findPlayerRoomBlocker(context, *playerState.entity, nullptr);
+	setPlayerCurrentBlocker(playerState, blocker);
+	if (blockerOut)
+		*blockerOut = nullptr;
+	if (blocker && recordNativePrimaryWalkBlockerHistory(state, playerState, *blocker,
+			horizontalDirection, depthDirection)) {
+		playPlayerBlockedWalkSounds(engine, state, playerState, *blocker);
+		if (blockerOut)
+			*blockerOut = blocker;
+		return false;
+	}
+
+	if (candidate.centerX == playerState.centerX &&
+			candidate.bottomY == playerState.bottomY &&
+			fabsf(candidate.z - playerState.z) <= kRoomDepthCompareEpsilon)
+		return false;
+
+	applyPlayerMovementPlacement(state, playerState, candidate.centerX, candidate.bottomY, candidate.z);
+	playerState.z = candidate.z;
+	syncPlayerAnimationPlacement(playerState);
+	reinsertPlayerSceneEntity(engine, playerState);
+	return true;
+}
+
+void syncPlayerAnimationPlacement(RoomPlayerState &playerState) {
+	if (!playerState.entity)
+		return;
+
+	const Common::Rect rect = playerState.entity->getScreenRect();
+	const int centerX = rect.left + rect.width() / 2;
+	if (playerState.centerX != centerX || playerState.bottomY != rect.bottom) {
+		debugC(3, kDebugPlayer,
+			"Harvester: player frame bounds frame=%d base=(%d,%d) rect=(%d,%d)-(%d,%d) z=%.2f",
+			playerState.entity->getCurrentFrame(), playerState.entity->getX(), playerState.entity->getY(),
+			rect.left, rect.top, rect.right, rect.bottom, (double)playerState.z);
+	}
+	playerState.centerX = centerX;
+	playerState.bottomY = rect.bottom;
+}
+
+} // End of namespace PlayerMovement
+
+using namespace PlayerMovement;
+
+bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity)
+		return false;
+
+	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
+	if (!isPlayerMovementBlocked(movementContext, { playerState.centerX, playerState.bottomY, playerState.z })) {
+		return false;
+	}
+
+	const int originalCenterX = playerState.centerX;
+	const int originalBottomY = playerState.bottomY;
+	const int preferredDirection = playerState.facing == 1 ? -1 : 1;
+	for (int distance = 1; distance < 640; ++distance) {
+		int previousCandidateX = originalCenterX;
+		for (int pass = 0; pass < 2; ++pass) {
+			const int direction = pass == 0 ? preferredDirection : -preferredDirection;
+			const int candidateX = clampPlayerCenterXToNativeBounds(
+				playerState, originalCenterX + direction * distance);
+			if (candidateX == originalCenterX || candidateX == previousCandidateX)
+				continue;
+			previousCandidateX = candidateX;
+			if (isPlayerMovementBlocked(movementContext, { candidateX, playerState.bottomY, playerState.z })) {
+				continue;
+			}
+
+			playerState.centerX = candidateX;
+			if (!applyRoomActorPlacement(state, *playerState.entity,
+					playerState.centerX, playerState.bottomY, playerState.z)) {
+				return false;
+			}
+			reinsertPlayerSceneEntity(engine, playerState);
+
+			debugC(1, kDebugPlayer,
+				"Harvester: adjusted blocked startup spawn room='%s' facing=%d from=(%d,%d,z=%.2f) to=(%d,%d,z=%.2f)",
+				state.roomName.c_str(), playerState.facing,
+				originalCenterX, originalBottomY, (double)playerState.z,
+				playerState.centerX, playerState.bottomY, (double)playerState.z);
+			return true;
+		}
+	}
+
+	debugC(1, kDebugPlayer,
+		"Harvester: startup spawn remains blocked room='%s' pos=(%d,%d,z=%.2f)",
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY,
+		(double)playerState.z);
+	return false;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/player_movement.h b/engines/harvester/player_movement.h
new file mode 100644
index 00000000000..6802908f189
--- /dev/null
+++ b/engines/harvester/player_movement.h
@@ -0,0 +1,84 @@
+/* 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_PLAYER_MOVEMENT_H
+#define HARVESTER_PLAYER_MOVEMENT_H
+
+#include "harvester/player.h"
+
+namespace Harvester {
+namespace PlayerMovement {
+
+struct MovementContext {
+	HarvesterEngine &engine;
+	const RoomSetupState &state;
+	const Common::Array<ObjectRecord> &sceneObjects;
+	const Common::Array<AnimRecord> &sceneAnimations;
+	RoomPlayerState &playerState;
+};
+
+struct MovementPosition {
+	int centerX;
+	int bottomY;
+	float z;
+};
+
+struct PlayerAnimationRange {
+	PlayerAnimationRange() {}
+	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
+		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
+
+	int walkFirstFrame = 0;
+	int walkLastFrame = 0;
+	int idleFrame = 0;
+};
+
+extern const float kRoomDepthCompareEpsilon;
+
+PlayerAnimationRange resolvePlayerAnimationRange(int facing);
+int clampRoomMovementY(const RoomSetupState &state, int screenY);
+float mapRoomScreenYToDepth(const RoomSetupState &state, int screenY);
+float clampRoomDepth(const RoomSetupState &state, float z);
+int mapRoomDepthToScreenY(const RoomSetupState &state, float z);
+int computeRoomPlayerHorizontalStep(const RoomSetupState &state, float z);
+float computeRoomPlayerDepthStep(const RoomSetupState &state);
+int computePlayerFrameLeftX(const RoomPlayerState &playerState);
+float computePlayerRenderZ(const RoomPlayerState &playerState);
+float clampPlayerMovementDepth(const RoomSetupState &state,
+		const RoomPlayerState &playerState, float z);
+int clampPlayerCenterXToNativeBounds(const RoomPlayerState &playerState, int centerX);
+bool consumePlayerMovementTick(RoomPlayerState &playerState);
+void setPlayerCurrentBlocker(RoomPlayerState &playerState, const Entity *blocker);
+void refreshPlayerCurrentBlocker(const MovementContext &context);
+void reinsertPlayerSceneEntity(HarvesterEngine &engine, const RoomPlayerState &playerState);
+bool isPlayerMovementBlocked(const MovementContext &context, const MovementPosition &candidate,
+		const Common::String *ignoredBlockerName = nullptr, const Entity **blockerOut = nullptr);
+bool tryApplyPlayerMovement(const MovementContext &context, MovementPosition candidate,
+		const Common::String *ignoredBlockerName = nullptr, const Entity **blockerOut = nullptr);
+bool tryApplyPlayerWalkMovement(const MovementContext &context, const MovementPosition &candidate,
+		int horizontalDirection, int depthDirection, const Entity **blockerOut = nullptr);
+bool isPathfindingDebugEnabled();
+void syncPlayerAnimationPlacement(RoomPlayerState &playerState);
+
+} // End of namespace PlayerMovement
+} // End of namespace Harvester
+
+#endif // HARVESTER_PLAYER_MOVEMENT_H


Commit: 6be6653618f1cca75834a73594da321f92bfff4f
    https://github.com/scummvm/scummvm/commit/6be6653618f1cca75834a73594da321f92bfff4f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate player target walking

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/player_target.cpp
    engines/harvester/module.mk
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/player_movement.cpp
    engines/harvester/player_movement.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 048ee050d48..80bfb1457be 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -23,6 +23,7 @@ MODULE_OBJS = \
 	monster.o \
 	player.o \
 	player_movement.o \
+	player_target.o \
 	resources.o \
 	combat.o \
 	combat_contact.o \
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index c96dee572f0..89d692bc8de 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -40,9 +40,6 @@ static bool setPlayerIdleAnimation(RoomPlayerState &playerState, int facing);
 static void updatePlayerIdleTrigger(RoomIdleAnimationState &idleState);
 
 static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
-static const float kRoomPlayerHorizontalTargetSlackBase = 50.0f;
-static const float kRoomPlayerDepthTargetSlack = 8.0f;
-static const int kRoomRegionTargetXBias = 10;
 static const int kRoomPlayerWalkAnimationRate = 17;
 static const int kRoomPlayerFastAnimationRate = 30;
 static const int kRoomPlayerHitAnimationRate = 4;
@@ -477,141 +474,11 @@ static bool startResolvedAttackAnimation(RoomPlayerState &playerState,
 	return true;
 }
 
-static void resetPlayerMoveTargetProgress(RoomPlayerState &playerState) {
-	playerState.moveTargetXReached = false;
-	playerState.moveTargetZReached = false;
-}
-
-static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
-		const RoomPlayerState &playerState, int regionFacing) {
-	const float slack = Player::computeDepthScale(state, computePlayerRenderZ(playerState)) *
-		kRoomPlayerHorizontalTargetSlackBase;
-	const float frameLeftX = (float)computePlayerFrameLeftX(playerState);
-	if (frameLeftX < (float)playerState.targetX - slack || regionFacing == 2)
-		return 1;
-	if ((float)playerState.targetX + slack < frameLeftX || regionFacing == 1)
-		return -1;
-
-	return 0;
-}
-
-static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState, int regionFacing) {
-	const float z = computePlayerRenderZ(playerState);
-	if (playerState.targetZ < z - kRoomPlayerDepthTargetSlack || regionFacing == 0)
-		return 1;
-	if (z + kRoomPlayerDepthTargetSlack < playerState.targetZ || regionFacing == 3)
-		return -1;
-
-	return 0;
-}
-
-static bool hasPlayerReachedMoveTarget(const RoomPlayerState &playerState) {
-	return playerState.moveTargetXReached && playerState.moveTargetZReached;
-}
-
-static void updatePlayerMoveTargetProgress(const RoomSetupState &state,
-		RoomPlayerState &playerState, int regionFacing) {
-	if (!playerState.moveTargetXReached &&
-			resolveMoveTargetHorizontalDirection(state, playerState, regionFacing) == 0) {
-		playerState.moveTargetXReached = true;
-	}
-	if (!playerState.moveTargetZReached &&
-			resolveMoveTargetDepthDirection(playerState, regionFacing) == 0) {
-		playerState.moveTargetZReached = true;
-	}
-}
-
-static bool isHorizontalDirectionBlockedByHistory(const RoomPlayerState &playerState,
-		int horizontalDirection) {
-	if (horizontalDirection < 0)
-		return !playerState.leftwardBlockerHistory.empty();
-	if (horizontalDirection > 0)
-		return !playerState.rightwardBlockerHistory.empty();
-	return false;
-}
-
-static bool isDepthDirectionBlockedByHistory(const RoomPlayerState &playerState,
-		int depthDirection) {
-	if (depthDirection > 0)
-		return !playerState.upwardBlockerHistory.empty();
-	if (depthDirection < 0)
-		return !playerState.downwardBlockerHistory.empty();
-	return false;
-}
-
-static const char *describeBlockedDirection(int horizontalDirection, int depthDirection) {
-	if (horizontalDirection < 0)
-		return "left";
-	if (horizontalDirection > 0)
-		return "right";
-	if (depthDirection > 0)
-		return "upward";
-	if (depthDirection < 0)
-		return "downward";
-	return "";
-}
-
-static int resolveFacingFromMoveDirection(int horizontalDirection, int depthDirection,
-		int fallbackFacing) {
-	if (horizontalDirection < 0)
-		return 1;
-	if (horizontalDirection > 0)
-		return 2;
-	if (depthDirection < 0)
-		return 3;
-	if (depthDirection > 0)
-		return 0;
-	return fallbackFacing >= 0 ? fallbackFacing : 0;
-}
-
-static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &playerState,
-		int targetX, float targetZ, int targetBottomY) {
-	playerState.hasMoveTarget = true;
-	playerState.nextMovementTick = 0;
-	playerState.targetX = CLIP<int>(targetX, 0, 639);
-	playerState.targetBottomY = clampRoomMovementY(state, targetBottomY);
-	playerState.targetZ = targetZ;
-	resetPlayerMoveTargetProgress(playerState);
-	debugC(1, kDebugPlayer,
-		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
-		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
-	if (isPathfindingDebugEnabled()) {
-		debugC(1, kDebugPathfinding,
-			"Harvester: pathfinding target set room='%s' movement_band=(y=%d..%d,z=%d..%d) current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
-			state.roomName.c_str(), state.roomMaxZScreenY, state.roomMinZScreenY,
-			state.roomMinZ, state.roomMaxZ,
-			playerState.centerX, playerState.bottomY, (double)playerState.z,
-			playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
-	}
-}
-
 static void notePlayerIdleReset(RoomIdleAnimationState &idleState) {
 	idleState.resetTick = Player::getRuntimeClockTicks();
 	updatePlayerIdleTrigger(idleState);
 }
 
-static bool setPlayerWalkAnimation(RoomPlayerState &playerState, int facing) {
-	if (!playerState.entity)
-		return false;
-
-	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
-	const int currentFrame = playerState.entity->getCurrentFrame();
-	const bool alreadyWalking = playerState.facing == facing &&
-		playerState.entity->getAnimationRate() == playerState.animationRate &&
-		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
-	if (alreadyWalking)
-		return false;
-
-	playerState.facing = facing;
-	playerState.entity->setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
-	playerState.entity->setAnimationRate(playerState.animationRate);
-	debugC(1, kDebugPlayer,
-		"Harvester: player walk animation facing=%d frames=%d..%d rate=%d",
-		facing, range.walkFirstFrame, range.walkLastFrame, playerState.animationRate);
-	return true;
-}
-
 static void positionPlayerIdleAnimationEntity(const RoomSetupState &state,
 		const RoomPlayerState &playerState, Entity &idleEntity) {
 	if (!playerState.entity)
@@ -985,48 +852,6 @@ bool Player::isIdleAnimationExcludedRoom(const Common::String &roomName) {
 }
 
 
-void Player::setMoveTarget(const RoomSetupState &state, int targetX, float targetZ) {
-	RoomPlayerState &playerState = _state;
-	setMoveTargetInternal(state, playerState, targetX, clampRoomDepth(state, targetZ),
-		mapRoomDepthToScreenY(state, targetZ));
-}
-
-void Player::setRegionMoveTarget(const RoomSetupState &state, int targetX, float targetZ) {
-	RoomPlayerState &playerState = _state;
-	setMoveTargetInternal(state, playerState, targetX, targetZ,
-		mapRoomDepthToScreenY(state, targetZ));
-}
-
-void Player::setMoveTargetFromScreenPoint(const RoomSetupState &state, int targetX, int targetBottomY) {
-	RoomPlayerState &playerState = _state;
-	const int clampedTargetBottomY = clampRoomMovementY(state, targetBottomY);
-	setMoveTargetInternal(state, playerState, targetX,
-		mapRoomScreenYToDepth(state, clampedTargetBottomY), clampedTargetBottomY);
-}
-
-int Player::resolveRegionTargetX(const RoomSetupState &state, const RegionRecord &region,
-		const RoomPlayerState &playerState) {
-	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);
-
-	const int frameLeftX = computePlayerFrameLeftX(playerState);
-	if (targetX + kRoomRegionTargetXBias < frameLeftX)
-		targetX -= kRoomRegionTargetXBias;
-
-	return CLIP<int>(targetX, 0, 639);
-}
-
-float Player::resolveRegionTargetZ(const RegionRecord &region) {
-	if (region.desiredFacing == 0)
-		return (float)region.maxZ;
-	if (region.desiredFacing == 3)
-		return (float)region.minZ;
-
-	return (float)(region.minZ + (region.maxZ - region.minZ) / 2);
-}
-
 void Player::syncCombatLoadout(HarvesterEngine &engine, const RoomSetupState &state) {
 	if (_state.entity) {
 		(void)syncCombatLoadoutVisual(engine, state, engine.getScript()->getPlayerCombatLoadout());
@@ -1492,167 +1317,6 @@ bool Player::updateTurnAnimationState() {
 	return true;
 }
 
-bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state,
-		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations,
-		int regionFacing) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive ||
-			playerState.hitActive || playerState.evasiveState >= 0)
-		return false;
-
-	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
-	refreshPlayerCurrentBlocker(movementContext);
-
-	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
-	const int currentFrame = playerState.entity->getCurrentFrame();
-	const bool walking = playerState.entity->getAnimationRate() == playerState.animationRate &&
-		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
-	const bool updateTarget = !walking || currentFrame == range.walkLastFrame;
-	if (updateTarget)
-		updatePlayerMoveTargetProgress(state, playerState, regionFacing);
-	if (hasPlayerReachedMoveTarget(playerState)) {
-		playerState.hasMoveTarget = false;
-		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
-	}
-
-	const int previousCenterX = playerState.centerX;
-	const int previousBottomY = playerState.bottomY;
-	int horizontalDirection = updateTarget
-		? (playerState.moveTargetXReached ? 0 : resolveMoveTargetHorizontalDirection(state, playerState, regionFacing))
-		: playerState.walkHorizontalDirection;
-	if (horizontalDirection != 0 && isHorizontalDirectionBlockedByHistory(playerState, horizontalDirection)) {
-		if (engine.isPathfindingDebugEnabled()) {
-			debugC(1, kDebugPathfinding,
-				"Harvester: pathfinding direction held by blocker history room='%s' direction='%s' history='%s'",
-				state.roomName.c_str(),
-				describeBlockedDirection(horizontalDirection, 0),
-				horizontalDirection < 0
-					? playerState.leftwardBlockerHistory.c_str()
-					: playerState.rightwardBlockerHistory.c_str());
-		}
-		horizontalDirection = 0;
-	}
-	int depthDirection = updateTarget
-		? (playerState.moveTargetZReached ? 0 : resolveMoveTargetDepthDirection(playerState, regionFacing))
-		: playerState.walkDepthDirection;
-	if (depthDirection != 0 && isDepthDirectionBlockedByHistory(playerState, depthDirection)) {
-		if (engine.isPathfindingDebugEnabled()) {
-			debugC(1, kDebugPathfinding,
-				"Harvester: pathfinding direction held by blocker history room='%s' direction='%s' history='%s'",
-				state.roomName.c_str(),
-				describeBlockedDirection(0, depthDirection),
-				depthDirection > 0
-					? playerState.upwardBlockerHistory.c_str()
-					: playerState.downwardBlockerHistory.c_str());
-		}
-		depthDirection = 0;
-	}
-	if (horizontalDirection == 0 && depthDirection == 0) {
-		playerState.hasMoveTarget = false;
-		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
-	}
-	const int desiredFacing = resolveFacingFromMoveDirection(
-		horizontalDirection, depthDirection, playerState.facing);
-	if (regionFacing >= 0 && desiredFacing != playerState.facing &&
-			startTurnAnimation(desiredFacing)) {
-		debugC(2, kDebugPlayer,
-			"Harvester: player region target turn room='%s' required_facing=%d movement_facing=%d target=(%d,z=%.2f)",
-			state.roomName.c_str(), regionFacing, desiredFacing,
-			playerState.targetX, (double)playerState.targetZ);
-		return true;
-	}
-	if (!consumePlayerMovementTick(playerState))
-		return false;
-
-	int candidateCenterX = playerState.centerX;
-	const int candidateBottomY = playerState.bottomY;
-	float candidateZ = playerState.z;
-	if (horizontalDirection != 0) {
-		const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
-		candidateCenterX = clampPlayerCenterXToNativeBounds(
-			playerState, playerState.centerX + horizontalDirection * horizontalStep);
-		candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - depthDirection);
-	} else if (depthDirection != 0) {
-		const float depthStep = computeRoomPlayerDepthStep(state);
-		const bool depthBoundaryReached = depthDirection < 0
-			? computePlayerRenderZ(playerState) >= state.roomMaxZ
-			: computePlayerRenderZ(playerState) <= state.roomMinZ;
-		if (!depthBoundaryReached) {
-			candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - depthDirection * depthStep);
-		}
-	}
-
-	if (engine.isPathfindingDebugEnabled()) {
-		debugC(1, kDebugPathfinding,
-			"Harvester: pathfinding step room='%s' current=(%d,%d,z=%.2f) target=(x=%d,y=%d,z=%.2f) progress=(x=%d,z=%d) direction=(x=%d,z=%d) candidate=(%d,%d,z=%.2f)",
-			state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-			playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
-			playerState.moveTargetXReached, playerState.moveTargetZReached,
-			horizontalDirection, depthDirection,
-			candidateCenterX, candidateBottomY, (double)candidateZ);
-	}
-
-	const Entity *blocker = nullptr;
-	bool moved = tryApplyPlayerWalkMovement(movementContext,
-		{ candidateCenterX, candidateBottomY, candidateZ },
-		horizontalDirection, depthDirection, &blocker);
-	if (!moved) {
-		if (engine.isPathfindingDebugEnabled()) {
-			const Common::Rect blockerRect = blocker ? blocker->getScreenRect() : Common::Rect();
-			debugC(1, kDebugPathfinding,
-				"Harvester: pathfinding blocked room='%s' candidate=(%d,%d,z=%.2f) blocker='%s' class=0x%x rect=(%d,%d)-(%d,%d) z=%.2f z_extent=%.2f",
-				state.roomName.c_str(), candidateCenterX, candidateBottomY, (double)candidateZ,
-				blocker ? blocker->getName().c_str() : "",
-				blocker ? blocker->getClassId() : -1,
-				blockerRect.left, blockerRect.top, blockerRect.right, blockerRect.bottom,
-				blocker ? (double)blocker->getZ() : 0.0,
-				blocker ? (double)blocker->getZExtent() : 0.0);
-		}
-		setPlayerCurrentBlocker(playerState, blocker);
-		if (blocker) {
-			return setIdleAnimation(resolveFacingFromMoveDirection(horizontalDirection, depthDirection, playerState.facing));
-		}
-
-		playerState.hasMoveTarget = false;
-		int idleFacing = playerState.facing;
-		if (idleFacing < 0) {
-			if (horizontalDirection != 0)
-				idleFacing = horizontalDirection < 0 ? 1 : 2;
-			else
-				idleFacing = depthDirection < 0 ? 3 : 0;
-		}
-		return setIdleAnimation(idleFacing);
-	}
-
-	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();
-	refreshPlayerCurrentBlocker(movementContext);
-	if (engine.isPathfindingDebugEnabled()) {
-		debugC(1, kDebugPathfinding,
-			"Harvester: pathfinding applied room='%s' previous=(%d,%d) current=(%d,%d,z=%.2f) facing=%d",
-			state.roomName.c_str(), previousCenterX, previousBottomY,
-			playerState.centerX, playerState.bottomY, (double)playerState.z,
-			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,
-		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
-		playerState.facing, playerState.entity->getCurrentFrame(),
-		playerState.hasMoveTarget, moved);
-	return true;
-}
-
 bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState &state,
 		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations,
 		bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index d9d81b80abd..355095ca416 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -28,6 +28,11 @@ namespace Harvester {
 
 class EntityManager;
 
+namespace PlayerMovement {
+struct MovementContext;
+struct MovementPosition;
+}
+
 struct RoomPlayerState {
 	Entity *entity = nullptr;
 	int centerX = 0;
@@ -168,6 +173,10 @@ public:
 	bool updateIdleAnimation(const RoomSetupState &state);
 
 private:
+	bool stopMoveTargetStep(const PlayerMovement::MovementContext &context,
+		const PlayerMovement::MovementPosition &candidate, int horizontalDirection, int depthDirection,
+		const Entity *blocker);
+
 	RoomPlayerState _state;
 	RoomIdleAnimationState _idleState;
 };
diff --git a/engines/harvester/player_movement.cpp b/engines/harvester/player_movement.cpp
index b19cf29b5d4..b60a0eafcb2 100644
--- a/engines/harvester/player_movement.cpp
+++ b/engines/harvester/player_movement.cpp
@@ -451,6 +451,40 @@ void syncPlayerAnimationPlacement(RoomPlayerState &playerState) {
 	playerState.bottomY = rect.bottom;
 }
 
+int resolveFacingFromMoveDirection(int horizontalDirection, int depthDirection,
+		int fallbackFacing) {
+	if (horizontalDirection < 0)
+		return 1;
+	if (horizontalDirection > 0)
+		return 2;
+	if (depthDirection < 0)
+		return 3;
+	if (depthDirection > 0)
+		return 0;
+	return fallbackFacing >= 0 ? fallbackFacing : 0;
+}
+
+bool setPlayerWalkAnimation(RoomPlayerState &playerState, int facing) {
+	if (!playerState.entity)
+		return false;
+
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
+	const int currentFrame = playerState.entity->getCurrentFrame();
+	const bool alreadyWalking = playerState.facing == facing &&
+		playerState.entity->getAnimationRate() == playerState.animationRate &&
+		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
+	if (alreadyWalking)
+		return false;
+
+	playerState.facing = facing;
+	playerState.entity->setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
+	playerState.entity->setAnimationRate(playerState.animationRate);
+	debugC(1, kDebugPlayer,
+		"Harvester: player walk animation facing=%d frames=%d..%d rate=%d",
+		facing, range.walkFirstFrame, range.walkLastFrame, playerState.animationRate);
+	return true;
+}
+
 } // End of namespace PlayerMovement
 
 using namespace PlayerMovement;
diff --git a/engines/harvester/player_movement.h b/engines/harvester/player_movement.h
index 6802908f189..a2304296de9 100644
--- a/engines/harvester/player_movement.h
+++ b/engines/harvester/player_movement.h
@@ -78,6 +78,9 @@ bool tryApplyPlayerWalkMovement(const MovementContext &context, const MovementPo
 bool isPathfindingDebugEnabled();
 void syncPlayerAnimationPlacement(RoomPlayerState &playerState);
 
+int resolveFacingFromMoveDirection(int horizontalDirection, int depthDirection, int fallbackFacing);
+bool setPlayerWalkAnimation(RoomPlayerState &playerState, int facing);
+
 } // End of namespace PlayerMovement
 } // End of namespace Harvester
 
diff --git a/engines/harvester/player_target.cpp b/engines/harvester/player_target.cpp
new file mode 100644
index 00000000000..3428f81cfeb
--- /dev/null
+++ b/engines/harvester/player_target.cpp
@@ -0,0 +1,376 @@
+/* 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/player.h"
+#include "harvester/player_movement.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+
+namespace Harvester {
+
+using namespace PlayerMovement;
+
+namespace {
+
+static const float kRoomPlayerHorizontalTargetSlackBase = 50.0f;
+static const float kRoomPlayerDepthTargetSlack = 8.0f;
+static const int kRoomRegionTargetXBias = 10;
+
+static void resetPlayerMoveTargetProgress(RoomPlayerState &playerState) {
+	playerState.moveTargetXReached = false;
+	playerState.moveTargetZReached = false;
+}
+
+static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
+		const RoomPlayerState &playerState, int regionFacing) {
+	const float slack = Player::computeDepthScale(state, computePlayerRenderZ(playerState)) *
+		kRoomPlayerHorizontalTargetSlackBase;
+	const float frameLeftX = (float)computePlayerFrameLeftX(playerState);
+	if (frameLeftX < (float)playerState.targetX - slack || regionFacing == 2)
+		return 1;
+	if ((float)playerState.targetX + slack < frameLeftX || regionFacing == 1)
+		return -1;
+
+	return 0;
+}
+
+static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState, int regionFacing) {
+	const float z = computePlayerRenderZ(playerState);
+	if (playerState.targetZ < z - kRoomPlayerDepthTargetSlack || regionFacing == 0)
+		return 1;
+	if (z + kRoomPlayerDepthTargetSlack < playerState.targetZ || regionFacing == 3)
+		return -1;
+
+	return 0;
+}
+
+static bool hasPlayerReachedMoveTarget(const RoomPlayerState &playerState) {
+	return playerState.moveTargetXReached && playerState.moveTargetZReached;
+}
+
+static void updatePlayerMoveTargetProgress(const RoomSetupState &state,
+		RoomPlayerState &playerState, int regionFacing) {
+	if (!playerState.moveTargetXReached &&
+			resolveMoveTargetHorizontalDirection(state, playerState, regionFacing) == 0) {
+		playerState.moveTargetXReached = true;
+	}
+	if (!playerState.moveTargetZReached &&
+			resolveMoveTargetDepthDirection(playerState, regionFacing) == 0) {
+		playerState.moveTargetZReached = true;
+	}
+}
+
+static bool isHorizontalDirectionBlockedByHistory(const RoomPlayerState &playerState,
+		int horizontalDirection) {
+	if (horizontalDirection < 0)
+		return !playerState.leftwardBlockerHistory.empty();
+	if (horizontalDirection > 0)
+		return !playerState.rightwardBlockerHistory.empty();
+	return false;
+}
+
+static bool isDepthDirectionBlockedByHistory(const RoomPlayerState &playerState,
+		int depthDirection) {
+	if (depthDirection > 0)
+		return !playerState.upwardBlockerHistory.empty();
+	if (depthDirection < 0)
+		return !playerState.downwardBlockerHistory.empty();
+	return false;
+}
+
+static const char *describeBlockedDirection(int horizontalDirection, int depthDirection) {
+	if (horizontalDirection < 0)
+		return "left";
+	if (horizontalDirection > 0)
+		return "right";
+	if (depthDirection > 0)
+		return "upward";
+	if (depthDirection < 0)
+		return "downward";
+	return "";
+}
+
+static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &playerState,
+		int targetX, float targetZ, int targetBottomY) {
+	playerState.hasMoveTarget = true;
+	playerState.nextMovementTick = 0;
+	playerState.targetX = CLIP<int>(targetX, 0, 639);
+	playerState.targetBottomY = clampRoomMovementY(state, targetBottomY);
+	playerState.targetZ = targetZ;
+	resetPlayerMoveTargetProgress(playerState);
+	debugC(1, kDebugPlayer,
+		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
+	if (isPathfindingDebugEnabled()) {
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding target set room='%s' movement_band=(y=%d..%d,z=%d..%d) current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
+			state.roomName.c_str(), state.roomMaxZScreenY, state.roomMinZScreenY,
+			state.roomMinZ, state.roomMaxZ,
+			playerState.centerX, playerState.bottomY, (double)playerState.z,
+			playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
+	}
+}
+
+struct TargetWalkDirection {
+	int horizontal;
+	int depth;
+};
+
+static TargetWalkDirection resolveTargetWalkDirection(HarvesterEngine &engine, const RoomSetupState &state,
+		const RoomPlayerState &playerState, int regionFacing, bool updateTarget) {
+	int horizontalDirection = updateTarget
+		? (playerState.moveTargetXReached ? 0 : resolveMoveTargetHorizontalDirection(state, playerState, regionFacing))
+		: playerState.walkHorizontalDirection;
+	if (horizontalDirection != 0 && isHorizontalDirectionBlockedByHistory(playerState, horizontalDirection)) {
+		if (engine.isPathfindingDebugEnabled()) {
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding direction held by blocker history room='%s' direction='%s' history='%s'",
+				state.roomName.c_str(),
+				describeBlockedDirection(horizontalDirection, 0),
+				horizontalDirection < 0
+					? playerState.leftwardBlockerHistory.c_str()
+					: playerState.rightwardBlockerHistory.c_str());
+		}
+		horizontalDirection = 0;
+	}
+	int depthDirection = updateTarget
+		? (playerState.moveTargetZReached ? 0 : resolveMoveTargetDepthDirection(playerState, regionFacing))
+		: playerState.walkDepthDirection;
+	if (depthDirection != 0 && isDepthDirectionBlockedByHistory(playerState, depthDirection)) {
+		if (engine.isPathfindingDebugEnabled()) {
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding direction held by blocker history room='%s' direction='%s' history='%s'",
+				state.roomName.c_str(),
+				describeBlockedDirection(0, depthDirection),
+				depthDirection > 0
+					? playerState.upwardBlockerHistory.c_str()
+					: playerState.downwardBlockerHistory.c_str());
+		}
+		depthDirection = 0;
+	}
+	return { horizontalDirection, depthDirection };
+}
+
+static MovementPosition computeMoveTargetStep(const RoomSetupState &state,
+		const RoomPlayerState &playerState, const TargetWalkDirection &direction) {
+	int candidateCenterX = playerState.centerX;
+	const int candidateBottomY = playerState.bottomY;
+	float candidateZ = playerState.z;
+	if (direction.horizontal != 0) {
+		const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
+		candidateCenterX = clampPlayerCenterXToNativeBounds(
+			playerState, playerState.centerX + direction.horizontal * horizontalStep);
+		candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - direction.depth);
+	} else if (direction.depth != 0) {
+		const float depthStep = computeRoomPlayerDepthStep(state);
+		const bool depthBoundaryReached = direction.depth < 0
+			? computePlayerRenderZ(playerState) >= state.roomMaxZ
+			: computePlayerRenderZ(playerState) <= state.roomMinZ;
+		if (!depthBoundaryReached) {
+			candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - direction.depth * depthStep);
+		}
+	}
+
+	return { candidateCenterX, candidateBottomY, candidateZ };
+}
+
+static void logAppliedMoveTargetStep(const MovementContext &context,
+		int previousCenterX, int previousBottomY, int actualFacing, bool moved) {
+	HarvesterEngine &engine = context.engine;
+	const RoomSetupState &state = context.state;
+	const RoomPlayerState &playerState = context.playerState;
+	if (engine.isPathfindingDebugEnabled()) {
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding applied room='%s' previous=(%d,%d) current=(%d,%d,z=%.2f) facing=%d",
+			state.roomName.c_str(), previousCenterX, previousBottomY,
+			playerState.centerX, playerState.bottomY, (double)playerState.z,
+			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,
+		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
+		playerState.facing, playerState.entity->getCurrentFrame(),
+		playerState.hasMoveTarget, moved);
+}
+
+} // End of anonymous namespace
+
+void Player::setMoveTarget(const RoomSetupState &state, int targetX, float targetZ) {
+	RoomPlayerState &playerState = _state;
+	setMoveTargetInternal(state, playerState, targetX, clampRoomDepth(state, targetZ),
+		mapRoomDepthToScreenY(state, targetZ));
+}
+
+void Player::setRegionMoveTarget(const RoomSetupState &state, int targetX, float targetZ) {
+	RoomPlayerState &playerState = _state;
+	setMoveTargetInternal(state, playerState, targetX, targetZ,
+		mapRoomDepthToScreenY(state, targetZ));
+}
+
+void Player::setMoveTargetFromScreenPoint(const RoomSetupState &state, int targetX, int targetBottomY) {
+	RoomPlayerState &playerState = _state;
+	const int clampedTargetBottomY = clampRoomMovementY(state, targetBottomY);
+	setMoveTargetInternal(state, playerState, targetX,
+		mapRoomScreenYToDepth(state, clampedTargetBottomY), clampedTargetBottomY);
+}
+
+int Player::resolveRegionTargetX(const RoomSetupState &state, const RegionRecord &region,
+		const RoomPlayerState &playerState) {
+	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);
+
+	const int frameLeftX = computePlayerFrameLeftX(playerState);
+	if (targetX + kRoomRegionTargetXBias < frameLeftX)
+		targetX -= kRoomRegionTargetXBias;
+
+	return CLIP<int>(targetX, 0, 639);
+}
+
+float Player::resolveRegionTargetZ(const RegionRecord &region) {
+	if (region.desiredFacing == 0)
+		return (float)region.maxZ;
+	if (region.desiredFacing == 3)
+		return (float)region.minZ;
+
+	return (float)(region.minZ + (region.maxZ - region.minZ) / 2);
+}
+
+bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations,
+		int regionFacing) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive ||
+			playerState.hitActive || playerState.evasiveState >= 0)
+		return false;
+
+	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
+	refreshPlayerCurrentBlocker(movementContext);
+
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
+	const int currentFrame = playerState.entity->getCurrentFrame();
+	const bool walking = playerState.entity->getAnimationRate() == playerState.animationRate &&
+		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
+	const bool updateTarget = !walking || currentFrame == range.walkLastFrame;
+	if (updateTarget)
+		updatePlayerMoveTargetProgress(state, playerState, regionFacing);
+	if (hasPlayerReachedMoveTarget(playerState)) {
+		playerState.hasMoveTarget = false;
+		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
+	}
+
+	const int previousCenterX = playerState.centerX;
+	const int previousBottomY = playerState.bottomY;
+	const TargetWalkDirection direction = resolveTargetWalkDirection(
+		engine, state, playerState, regionFacing, updateTarget);
+	if (direction.horizontal == 0 && direction.depth == 0) {
+		playerState.hasMoveTarget = false;
+		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
+	}
+	const int desiredFacing = resolveFacingFromMoveDirection(
+		direction.horizontal, direction.depth, playerState.facing);
+	if (regionFacing >= 0 && desiredFacing != playerState.facing &&
+			startTurnAnimation(desiredFacing)) {
+		debugC(2, kDebugPlayer,
+			"Harvester: player region target turn room='%s' required_facing=%d movement_facing=%d target=(%d,z=%.2f)",
+			state.roomName.c_str(), regionFacing, desiredFacing,
+			playerState.targetX, (double)playerState.targetZ);
+		return true;
+	}
+	if (!consumePlayerMovementTick(playerState))
+		return false;
+
+	const MovementPosition candidate = computeMoveTargetStep(state, playerState, direction);
+
+	if (engine.isPathfindingDebugEnabled()) {
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding step room='%s' current=(%d,%d,z=%.2f) target=(x=%d,y=%d,z=%.2f) progress=(x=%d,z=%d) direction=(x=%d,z=%d) candidate=(%d,%d,z=%.2f)",
+			state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+			playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
+			playerState.moveTargetXReached, playerState.moveTargetZReached,
+			direction.horizontal, direction.depth,
+			candidate.centerX, candidate.bottomY, (double)candidate.z);
+	}
+
+	const Entity *blocker = nullptr;
+	bool moved = tryApplyPlayerWalkMovement(movementContext, candidate,
+		direction.horizontal, direction.depth, &blocker);
+	if (!moved)
+		return stopMoveTargetStep(movementContext, candidate, direction.horizontal, direction.depth, blocker);
+
+	const int actualFacing = desiredFacing;
+	playerState.walkHorizontalDirection = direction.horizontal;
+	playerState.walkDepthDirection = direction.depth;
+	(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),
+			direction.horizontal, direction.depth);
+	}
+	syncAnimationPlacement();
+	refreshPlayerCurrentBlocker(movementContext);
+	logAppliedMoveTargetStep(movementContext, previousCenterX, previousBottomY, actualFacing, moved);
+	return true;
+}
+
+bool Player::stopMoveTargetStep(const PlayerMovement::MovementContext &context,
+		const PlayerMovement::MovementPosition &candidate, int horizontalDirection, int depthDirection,
+		const Entity *blocker) {
+	HarvesterEngine &engine = context.engine;
+	const RoomSetupState &state = context.state;
+	RoomPlayerState &playerState = _state;
+	if (engine.isPathfindingDebugEnabled()) {
+		const Common::Rect blockerRect = blocker ? blocker->getScreenRect() : Common::Rect();
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding blocked room='%s' candidate=(%d,%d,z=%.2f) blocker='%s' class=0x%x rect=(%d,%d)-(%d,%d) z=%.2f z_extent=%.2f",
+			state.roomName.c_str(), candidate.centerX, candidate.bottomY, (double)candidate.z,
+			blocker ? blocker->getName().c_str() : "",
+			blocker ? blocker->getClassId() : -1,
+			blockerRect.left, blockerRect.top, blockerRect.right, blockerRect.bottom,
+			blocker ? (double)blocker->getZ() : 0.0,
+			blocker ? (double)blocker->getZExtent() : 0.0);
+	}
+	setPlayerCurrentBlocker(playerState, blocker);
+	if (blocker) {
+		return setIdleAnimation(resolveFacingFromMoveDirection(horizontalDirection, depthDirection, playerState.facing));
+	}
+
+	playerState.hasMoveTarget = false;
+	int idleFacing = playerState.facing;
+	if (idleFacing < 0) {
+		if (horizontalDirection != 0)
+			idleFacing = horizontalDirection < 0 ? 1 : 2;
+		else
+			idleFacing = depthDirection < 0 ? 3 : 0;
+	}
+	return setIdleAnimation(idleFacing);
+}
+
+} // End of namespace Harvester


Commit: 3d8586a32d3e47ebb48933e150c3f7c0890caff3
    https://github.com/scummvm/scummvm/commit/3d8586a32d3e47ebb48933e150c3f7c0890caff3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate player keyboard walking

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/player_keyboard.cpp
    engines/harvester/module.mk
    engines/harvester/player.cpp


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 80bfb1457be..608e4ea3127 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -22,6 +22,7 @@ MODULE_OBJS = \
 	metaengine.o \
 	monster.o \
 	player.o \
+	player_keyboard.o \
 	player_movement.o \
 	player_target.o \
 	resources.o \
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 89d692bc8de..1238f3aec50 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -1317,114 +1317,6 @@ bool Player::updateTurnAnimationState() {
 	return true;
 }
 
-bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState &state,
-		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations,
-		bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || !supportsMovementBand(state))
-		return false;
-	if (playerState.turnActive || playerState.hitActive || playerState.evasiveState >= 0)
-		return false;
-
-	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
-	int horizontalInput = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
-	int verticalInput = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
-	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
-	const int currentFrame = playerState.entity->getCurrentFrame();
-	const bool keyboardWalking = !playerState.hasMoveTarget &&
-		playerState.entity->getAnimationRate() == playerState.animationRate &&
-		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
-	if (keyboardWalking) {
-		if (horizontalInput != 0 || verticalInput != 0)
-			playerState.keyboardWalkRequest = resolveFacingFromMoveDirection(
-				horizontalInput, verticalInput, playerState.facing);
-		if (!playerState.entity->didAnimationAdvanceLastTick())
-			return false;
-		const int stepFacing = currentFrame < range.walkLastFrame
-			? playerState.facing : playerState.keyboardWalkRequest;
-		horizontalInput = stepFacing == 1 ? -1 : stepFacing == 2 ? 1 : 0;
-		verticalInput = stepFacing == 3 ? -1 : stepFacing == 0 ? 1 : 0;
-	} else {
-		playerState.keyboardWalkRequest = -1;
-	}
-	if (horizontalInput == 0 && verticalInput == 0) {
-		if (playerState.hasMoveTarget || playerState.facing < 0)
-			return false;
-		return setIdleAnimation(playerState.facing);
-	}
-
-	const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
-	const float depthStep = computeRoomPlayerDepthStep(state);
-	int candidateCenterX = clampPlayerCenterXToNativeBounds(
-		playerState, playerState.centerX + horizontalInput * horizontalStep);
-	const int candidateBottomY = playerState.bottomY;
-	float candidateZ = playerState.z;
-	bool depthBoundaryReached = false;
-	if (verticalInput != 0) {
-
-		depthBoundaryReached = verticalInput < 0
-			? computePlayerRenderZ(playerState) >= state.roomMaxZ
-			: computePlayerRenderZ(playerState) <= state.roomMinZ;
-		if (!depthBoundaryReached) {
-			candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - verticalInput * depthStep);
-		}
-	}
-	if (candidateCenterX == playerState.centerX &&
-			candidateBottomY == playerState.bottomY &&
-			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
-		playerState.hasMoveTarget = false;
-		if (depthBoundaryReached) {
-			const bool stopped = setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
-			if (stopped) {
-				debugC(3, kDebugPathfinding,
-					"Harvester: player keyboard depth boundary room='%s' input_y=%d pos=(%d,%d,z=%.2f) bounds=(%d,%d)",
-					state.roomName.c_str(), verticalInput,
-					playerState.centerX, playerState.bottomY, (double)playerState.z,
-					state.roomMinZ, state.roomMaxZ);
-			}
-			return stopped;
-		}
-		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
-	}
-
-	const int desiredFacing = resolveFacingFromMoveDirection(
-		horizontalInput, verticalInput, playerState.facing);
-	if (desiredFacing != playerState.facing && startTurnAnimation(desiredFacing)) {
-		playerState.keyboardWalkRequest = -1;
-		return true;
-	}
-
-	if (!consumePlayerMovementTick(playerState))
-		return false;
-
-	playerState.hasMoveTarget = false;
-	bool moved = tryApplyPlayerWalkMovement(movementContext,
-		{ candidateCenterX, candidateBottomY, candidateZ },
-		horizontalInput, verticalInput);
-	if (!moved)
-		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
-
-	const int actualFacing = desiredFacing;
-	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;
-		playerState.entity->setCurrentFrame(resolvePlayerAnimationRange(actualFacing).walkFirstFrame);
-		debugC(2, kDebugPlayer,
-			"Harvester: player keyboard walk bank restart facing=%d frame=%d",
-			actualFacing, playerState.entity->getCurrentFrame());
-	}
-	syncAnimationPlacement();
-	debugC(2, kDebugPlayer,
-		"Harvester: player keyboard move room='%s' input=(%d,%d) step=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d moved=%d",
-		state.roomName.c_str(), (moveRight ? 1 : 0) - (moveLeft ? 1 : 0),
-		(moveDown ? 1 : 0) - (moveUp ? 1 : 0), horizontalInput, verticalInput,
-		playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.facing, playerState.entity->getCurrentFrame(), moved);
-	return true;
-}
-
 bool Player::requestIdleAnimationExit(const RoomSetupState &state) {
 	RoomPlayerState &playerState = _state;
 	RoomIdleAnimationState &idleState = _idleState;
diff --git a/engines/harvester/player_keyboard.cpp b/engines/harvester/player_keyboard.cpp
new file mode 100644
index 00000000000..47ee1b7bd23
--- /dev/null
+++ b/engines/harvester/player_keyboard.cpp
@@ -0,0 +1,167 @@
+/* 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/player.h"
+#include "harvester/player_movement.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+
+namespace Harvester {
+
+using namespace PlayerMovement;
+
+namespace {
+
+static bool resolveKeyboardWalkInput(RoomPlayerState &playerState, const PlayerAnimationRange &range,
+		int currentFrame, bool keyboardWalking, int &horizontalInput, int &verticalInput) {
+	if (keyboardWalking) {
+		if (horizontalInput != 0 || verticalInput != 0)
+			playerState.keyboardWalkRequest = resolveFacingFromMoveDirection(
+				horizontalInput, verticalInput, playerState.facing);
+		if (!playerState.entity->didAnimationAdvanceLastTick())
+			return false;
+		const int stepFacing = currentFrame < range.walkLastFrame
+			? playerState.facing : playerState.keyboardWalkRequest;
+		horizontalInput = stepFacing == 1 ? -1 : stepFacing == 2 ? 1 : 0;
+		verticalInput = stepFacing == 3 ? -1 : stepFacing == 0 ? 1 : 0;
+	} else {
+		playerState.keyboardWalkRequest = -1;
+	}
+	return true;
+}
+
+struct KeyboardWalkStep {
+	KeyboardWalkStep(const MovementPosition &candidate, bool depthBoundaryReached)
+		: candidate(candidate), depthBoundaryReached(depthBoundaryReached) {}
+
+	MovementPosition candidate;
+	bool depthBoundaryReached;
+};
+
+static KeyboardWalkStep computeKeyboardWalkStep(const RoomSetupState &state, const RoomPlayerState &playerState,
+		int horizontalInput, int verticalInput) {
+	const int horizontalStep = computeRoomPlayerHorizontalStep(state, computePlayerRenderZ(playerState));
+	const float depthStep = computeRoomPlayerDepthStep(state);
+	int candidateCenterX = clampPlayerCenterXToNativeBounds(
+		playerState, playerState.centerX + horizontalInput * horizontalStep);
+	const int candidateBottomY = playerState.bottomY;
+	float candidateZ = playerState.z;
+	bool depthBoundaryReached = false;
+	if (verticalInput != 0) {
+
+		depthBoundaryReached = verticalInput < 0
+			? computePlayerRenderZ(playerState) >= state.roomMaxZ
+			: computePlayerRenderZ(playerState) <= state.roomMinZ;
+		if (!depthBoundaryReached) {
+			candidateZ = clampPlayerMovementDepth(state, playerState, playerState.z - verticalInput * depthStep);
+		}
+	}
+	return { { candidateCenterX, candidateBottomY, candidateZ }, depthBoundaryReached };
+}
+
+} // End of anonymous namespace
+
+bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations,
+		bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || !supportsMovementBand(state))
+		return false;
+	if (playerState.turnActive || playerState.hitActive || playerState.evasiveState >= 0)
+		return false;
+
+	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
+	int horizontalInput = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
+	int verticalInput = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(playerState.facing);
+	const int currentFrame = playerState.entity->getCurrentFrame();
+	const bool keyboardWalking = !playerState.hasMoveTarget &&
+		playerState.entity->getAnimationRate() == playerState.animationRate &&
+		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
+	if (!resolveKeyboardWalkInput(playerState, range, currentFrame, keyboardWalking, horizontalInput, verticalInput))
+		return false;
+	if (horizontalInput == 0 && verticalInput == 0) {
+		if (playerState.hasMoveTarget || playerState.facing < 0)
+			return false;
+		return setIdleAnimation(playerState.facing);
+	}
+
+	const KeyboardWalkStep step = computeKeyboardWalkStep(state, playerState, horizontalInput, verticalInput);
+	if (step.candidate.centerX == playerState.centerX &&
+			step.candidate.bottomY == playerState.bottomY &&
+			fabsf(step.candidate.z - playerState.z) <= kRoomDepthCompareEpsilon) {
+		playerState.hasMoveTarget = false;
+		if (step.depthBoundaryReached) {
+			const bool stopped = setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
+			if (stopped) {
+				debugC(3, kDebugPathfinding,
+					"Harvester: player keyboard depth boundary room='%s' input_y=%d pos=(%d,%d,z=%.2f) bounds=(%d,%d)",
+					state.roomName.c_str(), verticalInput,
+					playerState.centerX, playerState.bottomY, (double)playerState.z,
+					state.roomMinZ, state.roomMaxZ);
+			}
+			return stopped;
+		}
+		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
+	}
+
+	const int desiredFacing = resolveFacingFromMoveDirection(
+		horizontalInput, verticalInput, playerState.facing);
+	if (desiredFacing != playerState.facing && startTurnAnimation(desiredFacing)) {
+		playerState.keyboardWalkRequest = -1;
+		return true;
+	}
+
+	if (!consumePlayerMovementTick(playerState))
+		return false;
+
+	playerState.hasMoveTarget = false;
+	bool moved = tryApplyPlayerWalkMovement(movementContext,
+		step.candidate,
+		horizontalInput, verticalInput);
+	if (!moved)
+		return setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
+
+	const int actualFacing = desiredFacing;
+	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;
+		playerState.entity->setCurrentFrame(resolvePlayerAnimationRange(actualFacing).walkFirstFrame);
+		debugC(2, kDebugPlayer,
+			"Harvester: player keyboard walk bank restart facing=%d frame=%d",
+			actualFacing, playerState.entity->getCurrentFrame());
+	}
+	syncAnimationPlacement();
+	debugC(2, kDebugPlayer,
+		"Harvester: player keyboard move room='%s' input=(%d,%d) step=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d moved=%d",
+		state.roomName.c_str(), (moveRight ? 1 : 0) - (moveLeft ? 1 : 0),
+		(moveDown ? 1 : 0) - (moveUp ? 1 : 0), horizontalInput, verticalInput,
+		playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.facing, playerState.entity->getCurrentFrame(), moved);
+	return true;
+}
+
+} // End of namespace Harvester


Commit: 19a46f3107d80d6d864ca4967a32e5cc55175b2d
    https://github.com/scummvm/scummvm/commit/19a46f3107d80d6d864ca4967a32e5cc55175b2d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate player combat animation

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/player_animation.cpp
  A engines/harvester/player_attack.cpp
  A engines/harvester/player_reactions.cpp
    engines/harvester/module.mk
    engines/harvester/player.cpp


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 608e4ea3127..40f096ed98d 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -22,8 +22,11 @@ MODULE_OBJS = \
 	metaengine.o \
 	monster.o \
 	player.o \
+	player_animation.o \
+	player_attack.o \
 	player_keyboard.o \
 	player_movement.o \
+	player_reactions.o \
 	player_target.o \
 	resources.o \
 	combat.o \
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 1238f3aec50..7aabf1295d5 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -39,441 +39,15 @@ namespace {
 static bool setPlayerIdleAnimation(RoomPlayerState &playerState, int facing);
 static void updatePlayerIdleTrigger(RoomIdleAnimationState &idleState);
 
-static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
-static const int kRoomPlayerWalkAnimationRate = 17;
-static const int kRoomPlayerFastAnimationRate = 30;
-static const int kRoomPlayerHitAnimationRate = 4;
-static const int kNativePlayerDeathEntryAnimationRate = 14;
-static const float kRoomPlayerAttackUpperYOffset = 144.44f;
-static const float kRoomPlayerAttackMidYOffset = 75.36f;
 static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
 static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM/PCLOUN02.ABM";
 static const uint32 kRoomPlayerIdleDelayTicks = 3000;
-static const int kPlayerHitKnockbackDistance = 18;
-static const int kPlayerHitKnockbackDecayStep = 3;
 static const int kRoomPlayerIdleAnimationRate = 14;
 static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
 static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
 static const int kRoomPlayerIdleExitLastFrame = 0xbf;
 static const int kRoomPlayerIdleYOffset = 4;
 
-struct PlayerTurnAnimationRange {
-	PlayerTurnAnimationRange() {}
-	PlayerTurnAnimationRange(int firstFrame, int lastFrame, bool playBackwards)
-		: firstFrame(firstFrame), lastFrame(lastFrame), playBackwards(playBackwards) {}
-
-	int firstFrame = 0;
-	int lastFrame = 0;
-	bool playBackwards = false;
-};
-
-struct PlayerAttackAnimationRange {
-	PlayerAttackAnimationRange() {}
-	PlayerAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
-		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
-
-	int firstFrame = 0;
-	int lastFrame = 0;
-	int resumeFacing = 0;
-};
-
-struct PlayerDeathAnimationRange {
-	PlayerDeathAnimationRange() {}
-	PlayerDeathAnimationRange(int firstFrame, int lastFrame)
-		: firstFrame(firstFrame), lastFrame(lastFrame) {}
-
-	int firstFrame = -1;
-	int lastFrame = -1;
-};
-
-struct PlayerHitAnimationRange {
-	PlayerHitAnimationRange() {}
-	PlayerHitAnimationRange(int firstFrame, int lastFrame, int resumeFacing, int knockbackX)
-		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing), knockbackX(knockbackX) {}
-
-	int firstFrame = -1;
-	int lastFrame = -1;
-	int resumeFacing = -1;
-	int knockbackX = 0;
-};
-
-struct PlayerAttackSoundSet {
-	uint soundCount;
-	const char *soundPaths[3];
-};
-
-struct PlayerCombatLoadoutTuning {
-	const char *label;
-	int damageType;
-	int damageAmount;
-	int contactFrameOffset;
-};
-
-static const int kPlayerAttackSoundTriggerFrameOffset = 1;
-static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
-	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 1, { "2:/SOUND/EFFECTS/NAILGUN1.wav", nullptr, nullptr } },
-	{ 1, { "1:/sound/effects/shotgun2.wav", nullptr, nullptr } },
-	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
-	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
-	{ 1, { "1:/sound/effects/CLN_atk0.wav", nullptr, nullptr } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh3.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } }
-};
-
-static const char *const kPlayerHitSoundPaths[] = {
-	"1:/sound/effects/PC0_hit0.wav",
-	"1:/sound/effects/PC0_hit1.wav",
-	"1:/sound/effects/PC0_hit2.wav"
-};
-
-static const PlayerCombatLoadoutTuning kPlayerCombatLoadoutTunings[] = {
-	{ "FISTS", 1, 1, 5 },
-	{ "CLEAVER", 2, 4, 2 },
-	{ "NAILGUN", 4, 2, 2 },
-	{ "SHOTGUN", 4, 7, 2 },
-	{ "9GUN", 4, 6, 2 },
-	{ "38GUN", 4, 6, 2 },
-	{ "TOMAHAWK", 2, 3, 2 },
-	{ "KNIFE", 2, 3, 2 },
-	{ "FLAIL", 2, 3, 2 },
-	{ "HANDAXE", 2, 3, 2 },
-	{ "WRENCH", 1, 3, 2 },
-	{ "PITCHFORK", 2, 3, 2 },
-	{ "SCYTHE", 2, 4, 2 },
-	{ "SWORD", 2, 5, 2 },
-	{ "CHAINSAW", 2, 8, 2 },
-	{ "HARVEST_BLADE", 2, 3, 2 },
-	{ "SHOVEL", 1, 3, 2 },
-	{ "FIREAXE", 2, 3, 2 },
-	{ "BAT", 1, 3, 2 },
-	{ "RAZOR", 2, 3, 2 },
-	{ "POOLSTICK", 1, 2, 2 }
-};
-
-static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, PlayerTurnAnimationRange &range) {
-	// Native desired-state transitions play these banks forward or backward depending on the source facing.
-	if (fromFacing == 0 && toFacing == 1) {
-		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, false };
-		return true;
-	}
-	if (fromFacing == 1 && toFacing == 0) {
-		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, true };
-		return true;
-	}
-	if (fromFacing == 1 && toFacing == 3) {
-		range = PlayerTurnAnimationRange{ 0x19, 0x1d, false };
-		return true;
-	}
-	if (fromFacing == 3 && toFacing == 1) {
-		range = PlayerTurnAnimationRange{ 0x19, 0x1d, true };
-		return true;
-	}
-	if (fromFacing == 3 && toFacing == 2) {
-		range = PlayerTurnAnimationRange{ 0x28, 0x2c, false };
-		return true;
-	}
-	if (fromFacing == 2 && toFacing == 3) {
-		range = PlayerTurnAnimationRange{ 0x28, 0x2c, true };
-		return true;
-	}
-	if (fromFacing == 2 && toFacing == 0) {
-		range = PlayerTurnAnimationRange{ 0x37, 0x3b, false };
-		return true;
-	}
-	if (fromFacing == 0 && toFacing == 2) {
-		range = PlayerTurnAnimationRange{ 0x37, 0x3b, true };
-		return true;
-	}
-
-	return false;
-}
-
-static const PlayerAttackSoundSet *resolvePlayerAttackSoundSet(int loadout) {
-	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerAttackSoundSets))
-		return nullptr;
-
-	if (kPlayerAttackSoundSets[loadout].soundCount == 0)
-		return nullptr;
-
-	return &kPlayerAttackSoundSets[loadout];
-}
-
-static const PlayerCombatLoadoutTuning &resolvePlayerCombatLoadoutTuning(int loadout) {
-	static const PlayerCombatLoadoutTuning kDefaultTuning = { "FISTS", 1, 1, 5 };
-
-	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerCombatLoadoutTunings))
-		return kDefaultTuning;
-
-	return kPlayerCombatLoadoutTunings[loadout];
-}
-
-static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
-	const PlayerAttackSoundSet *soundSet = resolvePlayerAttackSoundSet(loadout);
-	if (!soundSet)
-		return false;
-
-	uint soundIndex = 0;
-	if (soundSet->soundCount > 1)
-		soundIndex = engine.getRandomNumber(soundSet->soundCount - 1);
-
-	const char *soundPath = soundSet->soundPaths[soundIndex];
-	return soundPath && engine.playSound(soundPath);
-}
-
-static bool loadoutUsesConsumableCombatResource(int loadout) {
-	switch (loadout) {
-	case 2:
-	case 3:
-	case 4:
-	case 5:
-	case 14:
-		return true;
-	default:
-		return false;
-	}
-}
-
-static bool playPlayerHitSound(HarvesterEngine &engine) {
-	const uint soundIndex = ARRAYSIZE(kPlayerHitSoundPaths) > 1
-		? engine.getRandomNumber(ARRAYSIZE(kPlayerHitSoundPaths) - 1)
-		: 0;
-	return engine.playSound(kPlayerHitSoundPaths[soundIndex]);
-}
-
-static bool playerEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
-	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
-		return false;
-
-	return (uint)lastFrame < entity.getFrameCount();
-}
-
-static bool choosePlayerDeathAnimationRange(const Entity &entity, int firstFrame, int lastFrame,
-		PlayerDeathAnimationRange &range) {
-	if (!playerEntityHasFrameRange(entity, firstFrame, lastFrame))
-		return false;
-
-	range = PlayerDeathAnimationRange(firstFrame, lastFrame);
-	return true;
-}
-
-static bool resolvePlayerDeathAnimationRange(const RoomPlayerState &playerState,
-		int damageType, bool goreEnabled, PlayerDeathAnimationRange &range) {
-	if (!playerState.entity)
-		return false;
-
-	enum PlayerDeathBankFamily {
-		kPlayerDeathBankHorizontalRight,
-		kPlayerDeathBankHorizontalLeft,
-		kPlayerDeathBankVerticalDown,
-		kPlayerDeathBankVerticalUp
-	};
-
-	PlayerDeathBankFamily family = kPlayerDeathBankHorizontalLeft;
-	if (playerState.attackActive) {
-		if (playerState.attackFirstFrame >= 0x6e && playerState.attackFirstFrame <= 0x8b)
-			family = kPlayerDeathBankHorizontalRight;
-		else
-			family = kPlayerDeathBankHorizontalLeft;
-	} else if (playerState.turnActive) {
-		if (playerState.turnFirstFrame >= 0x28)
-			family = kPlayerDeathBankHorizontalRight;
-		else
-			family = kPlayerDeathBankHorizontalLeft;
-	} else {
-		switch (playerState.facing) {
-		case 0:
-			family = kPlayerDeathBankVerticalDown;
-			break;
-		case 2:
-			family = kPlayerDeathBankHorizontalRight;
-			break;
-		case 3:
-			family = kPlayerDeathBankVerticalUp;
-			break;
-		case 1:
-		default:
-			family = kPlayerDeathBankHorizontalLeft;
-			break;
-		}
-	}
-
-	const auto selectByFamily = [&](int bludgeFirst, int bludgeLast, int slashFirst, int slashLast,
-			int projectileFirst, int projectileLast) {
-		if ((!goreEnabled || damageType == 1) &&
-				choosePlayerDeathAnimationRange(*playerState.entity, bludgeFirst, bludgeLast, range))
-			return true;
-		if (damageType == 4 &&
-				choosePlayerDeathAnimationRange(*playerState.entity, projectileFirst, projectileLast, range))
-			return true;
-		if (damageType == 2 &&
-				choosePlayerDeathAnimationRange(*playerState.entity, slashFirst, slashLast, range))
-			return true;
-
-		return choosePlayerDeathAnimationRange(*playerState.entity, bludgeFirst, bludgeLast, range) ||
-			choosePlayerDeathAnimationRange(*playerState.entity, slashFirst, slashLast, range) ||
-			choosePlayerDeathAnimationRange(*playerState.entity, projectileFirst, projectileLast, range);
-	};
-
-	switch (family) {
-	case kPlayerDeathBankHorizontalRight:
-		return selectByFamily(0xb0, 0xb9, 0x100, 0x109, 0x114, 0x11d);
-	case kPlayerDeathBankVerticalDown:
-		return selectByFamily(0xc4, 0xcd, 0xd8, 0xe1, 0xec, 0xf5);
-	case kPlayerDeathBankVerticalUp:
-		return selectByFamily(0xce, 0xd7, 0xe2, 0xeb, 0xf6, 0xff);
-	case kPlayerDeathBankHorizontalLeft:
-	default:
-		return selectByFamily(0xba, 0xc3, 0x10a, 0x113, 0x11e, 0x127);
-	}
-}
-
-static bool resolvePlayerAttackAnimationRange(const RoomSetupState &state,
-		const RoomPlayerState &playerState, const Common::Point &mousePos,
-		PlayerAttackAnimationRange &range) {
-	if (!playerState.entity)
-		return false;
-
-	const float depthScale = Player::computeDepthScale(state, playerState.z);
-	const float upperThresholdY =
-		(float)playerState.bottomY - depthScale * kRoomPlayerAttackUpperYOffset;
-	const float midThresholdY =
-		(float)playerState.bottomY - depthScale * kRoomPlayerAttackMidYOffset;
-
-	if (mousePos.x < playerState.centerX) {
-		if ((float)mousePos.y < upperThresholdY)
-			range = PlayerAttackAnimationRange(0x50, 0x59, 1);
-		else if ((float)mousePos.y < midThresholdY)
-			range = PlayerAttackAnimationRange(0x5a, 0x63, 1);
-		else
-			range = PlayerAttackAnimationRange(0x64, 0x6d, 1);
-	} else {
-		if ((float)mousePos.y < upperThresholdY)
-			range = PlayerAttackAnimationRange(0x6e, 0x77, 2);
-		else if ((float)mousePos.y < midThresholdY)
-			range = PlayerAttackAnimationRange(0x78, 0x81, 2);
-		else
-			range = PlayerAttackAnimationRange(0x82, 0x8b, 2);
-	}
-
-	return true;
-}
-
-static bool resolveKeyboardAttackAnimationRange(const RoomPlayerState &playerState,
-		bool attackLeft, bool attackRight, bool attackUp, bool attackDown,
-		PlayerAttackAnimationRange &range) {
-	if (attackRight) {
-		range = PlayerAttackAnimationRange(0x78, 0x81, 2);
-		return true;
-	}
-	if (attackLeft) {
-		range = PlayerAttackAnimationRange(0x5a, 0x63, 1);
-		return true;
-	}
-	if (attackUp) {
-		if (playerState.facing == 2) {
-			range = PlayerAttackAnimationRange(0x6e, 0x77, 2);
-			return true;
-		}
-		if (playerState.facing == 1) {
-			range = PlayerAttackAnimationRange(0x50, 0x59, 1);
-			return true;
-		}
-		return false;
-	}
-	if (attackDown) {
-		if (playerState.facing == 2) {
-			range = PlayerAttackAnimationRange(0x82, 0x8b, 2);
-			return true;
-		}
-		if (playerState.facing == 1) {
-			range = PlayerAttackAnimationRange(0x64, 0x6d, 1);
-			return true;
-		}
-		return false;
-	}
-
-	return false;
-}
-
-static bool resolvePlayerHitAnimationRange(const Entity &entity,
-		int monsterAttackFirstFrame, PlayerHitAnimationRange &range) {
-	PlayerHitAnimationRange candidate;
-	switch (monsterAttackFirstFrame) {
-	case 0x64:
-		candidate = PlayerHitAnimationRange(0x92, 0x94, 2, -kPlayerHitKnockbackDistance);
-		break;
-	case 0x5a:
-		candidate = PlayerHitAnimationRange(0x8f, 0x91, 2, -kPlayerHitKnockbackDistance);
-		break;
-	case 0x50:
-		candidate = PlayerHitAnimationRange(0x8c, 0x8e, 2, -kPlayerHitKnockbackDistance);
-		break;
-	case 0x82:
-		candidate = PlayerHitAnimationRange(0x9b, 0x9d, 1, kPlayerHitKnockbackDistance);
-		break;
-	case 0x78:
-		candidate = PlayerHitAnimationRange(0x98, 0x9a, 1, kPlayerHitKnockbackDistance);
-		break;
-	case 0x6e:
-		candidate = PlayerHitAnimationRange(0x95, 0x97, 1, kPlayerHitKnockbackDistance);
-		break;
-	default:
-		return false;
-	}
-
-	if (!playerEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
-		return false;
-
-	range = candidate;
-	return true;
-}
-
-static bool startResolvedAttackAnimation(RoomPlayerState &playerState,
-		const PlayerAttackAnimationRange &range) {
-	if (!playerState.entity || playerState.attackActive || playerState.hitActive)
-		return false;
-
-	playerState.hasMoveTarget = false;
-	playerState.evasiveState = -1;
-	playerState.evasiveXStep = 0;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	playerState.turnFirstFrame = -1;
-	playerState.turnLastFrame = -1;
-	playerState.turnEndFrame = -1;
-	playerState.turnPlayBackwards = false;
-	playerState.attackActive = true;
-	playerState.attackFirstFrame = range.firstFrame;
-	playerState.attackLastFrame = range.lastFrame;
-	playerState.attackContactFrame =
-		range.firstFrame + Player::resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout);
-	playerState.attackResumeFacing = range.resumeFacing;
-	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
-	playerState.attackSoundPlaybackFrame = playerState.attackSoundPlayed
-		? -1
-		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
-	playerState.attackContactResolved = false;
-	playerState.nextMovementTick = 0;
-	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-	playerState.entity->setAnimationRate(playerState.animationRate);
-	playerState.entity->setCurrentFrame(range.firstFrame);
-	return true;
-}
-
 static void notePlayerIdleReset(RoomIdleAnimationState &idleState) {
 	idleState.resetTick = Player::getRuntimeClockTicks();
 	updatePlayerIdleTrigger(idleState);
@@ -602,51 +176,6 @@ void Player::cancelMovement() {
 	_state.turnTargetFacing = -1;
 }
 
-void Player::cancelMovementForAttack() {
-	cancelMovement();
-	_state.turnFirstFrame = -1;
-	_state.turnLastFrame = -1;
-	_state.turnEndFrame = -1;
-	_state.turnPlayBackwards = false;
-}
-
-bool Player::cancelAttackAnimation() {
-	RoomPlayerState &playerState = _state;
-	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 setIdleAnimation(resumeFacing);
-}
-
-void Player::clearAttackTarget() {
-	_state.attackTargetName.clear();
-	_state.attackTargetClassId = -1;
-}
-
-void Player::captureAttackTarget(const Common::String &name, int classId) {
-	_state.attackTargetName = name;
-	_state.attackTargetClassId = classId;
-}
-
-void Player::markAttackContactResolved() {
-	_state.attackContactResolved = true;
-}
-
 void Player::resetIdleState() {
 	RoomIdleAnimationState &idleState = _idleState;
 	idleState = RoomIdleAnimationState();
@@ -782,46 +311,6 @@ int Player::resolveFacingFrame(int facing) {
 	}
 }
 
-Common::String Player::resolveCombatLoadoutResourcePath(int loadout) {
-	if (loadout <= 0)
-		return Common::String(kPlayerActorResourcePath);
-
-	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
-}
-
-const char *Player::describeCombatLoadout(int loadout) {
-	return resolvePlayerCombatLoadoutTuning(loadout).label;
-}
-
-int Player::resolveCombatLoadoutDamageAmount(int loadout) {
-	return resolvePlayerCombatLoadoutTuning(loadout).damageAmount;
-}
-
-int Player::resolveCombatLoadoutDamageType(int loadout) {
-	return resolvePlayerCombatLoadoutTuning(loadout).damageType;
-}
-
-const char *Player::describeCombatDamageType(int damageType) {
-	switch (damageType) {
-	case 1:
-		return "BLUDGE";
-	case 2:
-		return "SLASH";
-	case 4:
-		return "PROJ";
-	default:
-		return "UNKNOWN";
-	}
-}
-
-int Player::resolveCombatLoadoutContactFrameOffset(int loadout) {
-	return resolvePlayerCombatLoadoutTuning(loadout).contactFrameOffset;
-}
-
-bool Player::isProjectileCombatLoadout(int loadout) {
-	return resolveCombatLoadoutDamageType(loadout) == 4;
-}
-
 bool Player::supportsMovementBand(const RoomSetupState &state) {
 	return state.roomMaxZScreenY >= 0 &&
 		state.roomMinZScreenY >= state.roomMaxZScreenY;
@@ -852,471 +341,10 @@ bool Player::isIdleAnimationExcludedRoom(const Common::String &roomName) {
 }
 
 
-void Player::syncCombatLoadout(HarvesterEngine &engine, const RoomSetupState &state) {
-	if (_state.entity) {
-		(void)syncCombatLoadoutVisual(engine, state, engine.getScript()->getPlayerCombatLoadout());
-	} else if (engine.getScript()) {
-		_state.combatLoadout = engine.getScript()->getPlayerCombatLoadout();
-	}
-}
-
-bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const RoomSetupState &state, int loadout) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity)
-		return false;
-
-	ResourceManager *resources = engine.getResources();
-	if (!resources)
-		return false;
-
-	const int clampedLoadout = CLIP<int>(loadout, 0, 0x14);
-	if (playerState.combatLoadout == clampedLoadout)
-		return false;
-
-	const Common::String resourcePath = resolveCombatLoadoutResourcePath(clampedLoadout);
-	if (!playerState.entity->loadAbmResource(*resources, resourcePath))
-		return false;
-
-	playerState.combatLoadout = clampedLoadout;
-	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;
-	(void)setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
-	if (applyRoomActorPlacement(state, *playerState.entity,
-			playerState.centerX, playerState.bottomY, playerState.z)) {
-		reinsertPlayerSceneEntity(engine, playerState);
-	}
-	debugC(1, kDebugPlayer,
-		"Harvester: player combat loadout visual loadout=%d weapon='%s' damage=%d damage_type='%s' contact=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
-		playerState.combatLoadout, describeCombatLoadout(playerState.combatLoadout),
-		resolveCombatLoadoutDamageAmount(playerState.combatLoadout),
-		describeCombatDamageType(resolveCombatLoadoutDamageType(playerState.combatLoadout)),
-		resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout), resourcePath.c_str(), playerState.facing,
-		playerState.centerX, playerState.bottomY, (double)playerState.z);
-	return true;
-}
-
 bool Player::setIdleAnimation(int facing) {
 	return setPlayerIdleAnimation(_state, facing);
 }
 
-bool Player::updateAnimationRate(bool speedUp) {
-	RoomPlayerState &playerState = _state;
-	const int rate = speedUp ? kRoomPlayerFastAnimationRate : kRoomPlayerWalkAnimationRate;
-	if (playerState.animationRate == rate)
-		return false;
-	playerState.animationRate = rate;
-	if (playerState.entity && !playerState.hitActive && !playerState.deathActive &&
-			playerState.entity->getAnimationRate() != 0)
-		playerState.entity->setAnimationRate(rate);
-	debugC(2, kDebugPlayer, "Harvester: player actor rate=%d", rate);
-	return true;
-}
-
-bool Player::startEvasiveAnimation(bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || playerState.attackActive || playerState.hitActive || playerState.deathActive ||
-			playerState.evasiveState >= 0)
-		return false;
-	playerState.hasMoveTarget = false;
-	playerState.keyboardWalkRequest = -1;
-	if (playerState.turnActive)
-		return false;
-	const PlayerAnimationRange walk = resolvePlayerAnimationRange(playerState.facing);
-	const int frame = playerState.entity->getCurrentFrame();
-	if (playerState.entity->getAnimationRate() != 0 &&
-			frame >= walk.walkFirstFrame && frame < walk.walkLastFrame)
-		return false;
-
-	// Right Shift branch:
-	// vertical requests select duck/leap banks; horizontal requests can override them.
-	int actorState = -1;
-	if (playerState.facing == 1 || playerState.facing == 2) {
-		if (moveUp)
-			actorState = playerState.facing == 2 ? 0x12 : 0x13;
-		else if (moveDown)
-			actorState = playerState.facing == 2 ? 0x14 : 0x15;
-		if (moveLeft) {
-			if (playerState.facing == 2)
-				actorState = 0x08;
-		} else if (moveRight && playerState.facing == 1) {
-			actorState = 0x0f;
-		}
-	}
-	if (actorState < 0)
-		return false;
-
-	const bool backwards = actorState == 0x08 || actorState == 0x0f;
-	const int firstFrame = actorState == 0x08 ? 0x2d : actorState == 0x0f ? 0x0f :
-		0x3c + (actorState - 0x12) * 5;
-	const int lastFrame = firstFrame + (backwards ? 9 : 4);
-	if (!playerEntityHasFrameRange(*playerState.entity, firstFrame, lastFrame))
-		return false;
-	playerState.evasiveState = actorState;
-	playerState.evasiveFirstFrame = firstFrame;
-	playerState.evasiveLastFrame = lastFrame;
-	playerState.evasiveXStep = backwards
-		? (int)(playerState.walkHorizontalDirection * playerState.entity->getDepthScale() * 8.0f) : 0;
-	playerState.walkHorizontalDirection = 0;
-	playerState.walkDepthDirection = 0;
-	playerState.nextMovementTick = 0;
-	playerState.entity->setAnimationFrameRange(firstFrame, lastFrame, false);
-	playerState.entity->setPlayBackwards(backwards);
-	playerState.entity->setAnimationRate(playerState.animationRate);
-	playerState.entity->setCurrentFrame(backwards ? lastFrame : firstFrame);
-	debugC(2, kDebugPlayer, "Harvester: player evasive state=0x%x facing=%d frames=%d..%d backwards=%d",
-		actorState, playerState.facing, firstFrame, lastFrame, backwards);
-	return true;
-}
-
-bool Player::updateEvasiveAnimation(HarvesterEngine &engine) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || playerState.evasiveState < 0 ||
-			!playerState.entity->didAnimationAdvanceLastTick())
-		return false;
-
-	Entity &entity = *playerState.entity;
-	const int actorState = playerState.evasiveState;
-	if (playerState.evasiveXStep != 0) {
-		entity.setPosition(entity.getX() + playerState.evasiveXStep, entity.getY(), entity.getZ());
-		syncAnimationPlacement();
-		reinsertPlayerSceneEntity(engine, playerState);
-		debugC(3, kDebugPlayer, "Harvester: player evasive step state=0x%x frame=%d dx=%d",
-			actorState, entity.getCurrentFrame(), playerState.evasiveXStep);
-	}
-	const bool backwards = actorState == 0x08 || actorState == 0x0f;
-	if (entity.getCurrentFrame() == (backwards ? playerState.evasiveFirstFrame : playerState.evasiveLastFrame)) {
-		debugC(2, kDebugPlayer, "Harvester: player evasive complete state=0x%x facing=%d",
-			actorState, playerState.facing);
-		return setIdleAnimation(playerState.facing);
-	}
-
-	const int frameLeft = computePlayerFrameLeftX(playerState);
-	if ((actorState == 0x08 && frameLeft > 20) || (actorState == 0x0f && frameLeft < 620)) {
-		playerState.evasiveXStep = (int)(entity.getDepthScale() * (actorState == 0x08 ? -8.0f : 8.0f));
-	} else if (entity.getCurrentFrame() > playerState.evasiveFirstFrame + 2 &&
-			((actorState == 0x12 && frameLeft > 36) || (actorState == 0x13 && frameLeft < 616))) {
-		playerState.evasiveXStep = (int)(entity.getDepthScale() * (actorState == 0x12 ? -16.0f : 16.0f));
-	}
-	return true;
-}
-
-bool Player::startAttackAnimation(const RoomSetupState &state, const Common::Point &mousePos) {
-	RoomPlayerState &playerState = _state;
-	PlayerAttackAnimationRange range;
-	if (!resolvePlayerAttackAnimationRange(state, playerState, mousePos, range))
-		return false;
-	if (!startResolvedAttackAnimation(playerState, range))
-		return false;
-	debugC(1, kDebugPlayer,
-		"Harvester: player attack animation frames=%d..%d resume_facing=%d cursor=(%d,%d)",
-		range.firstFrame, range.lastFrame, range.resumeFacing, mousePos.x, mousePos.y);
-	return true;
-}
-
-bool Player::startKeyboardAttackAnimation(const RoomSetupState &state,
-		bool attackLeft, bool attackRight, bool attackUp, bool attackDown) {
-	RoomPlayerState &playerState = _state;
-	(void)state;
-
-	PlayerAttackAnimationRange range;
-	if (!resolveKeyboardAttackAnimationRange(playerState, attackLeft, attackRight, attackUp, attackDown, range))
-		return false;
-	if (!startResolvedAttackAnimation(playerState, range))
-		return false;
-	debugC(1, kDebugPlayer,
-		"Harvester: player keyboard attack animation frames=%d..%d resume_facing=%d input=(L=%d R=%d U=%d D=%d) facing=%d",
-		range.firstFrame, range.lastFrame, range.resumeFacing,
-		attackLeft, attackRight, attackUp, attackDown, playerState.facing);
-	return true;
-}
-
-bool Player::updateAttackAnimationState(HarvesterEngine &engine) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.attackActive || !playerState.entity)
-		return false;
-	bool changed = false;
-	if (!playerState.attackSoundPlayed &&
-			playerState.attackSoundPlaybackFrame >= 0 &&
-			playerState.entity->getCurrentFrame() >= playerState.attackSoundPlaybackFrame) {
-		if (loadoutUsesConsumableCombatResource(playerState.combatLoadout)) {
-			Script *script = engine.getScript();
-			if (!script || !script->consumePlayerCombatResourceUnit(playerState.combatLoadout)) {
-				debugC(1, kDebugCombat,
-					"Harvester: combat player attack dry fire loadout=%d weapon='%s'",
-					playerState.combatLoadout,
-					describeCombatLoadout(playerState.combatLoadout));
-				playerState.attackContactResolved = true;
-				playerState.attackContactFrame = -1;
-				playerState.attackTargetName.clear();
-				playerState.attackTargetClassId = -1;
-			} else {
-				changed = true;
-				(void)playPlayerAttackSound(engine, playerState.combatLoadout);
-			}
-		} else {
-			(void)playPlayerAttackSound(engine, playerState.combatLoadout);
-		}
-		playerState.attackSoundPlayed = true;
-	}
-	if (playerState.entity->getCurrentFrame() != playerState.attackLastFrame)
-		return changed;
-
-	const int resumeFacing = playerState.attackResumeFacing >= 0
-		? playerState.attackResumeFacing
-		: (playerState.facing >= 0 ? playerState.facing : 0);
-	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;
-	return setIdleAnimation(resumeFacing) || changed;
-}
-
-bool Player::startHitAnimation(HarvesterEngine &engine, int monsterAttackFirstFrame) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || playerState.deathActive)
-		return false;
-
-	PlayerHitAnimationRange range;
-	if (!resolvePlayerHitAnimationRange(*playerState.entity, monsterAttackFirstFrame, range))
-		return false;
-
-	playerState.hasMoveTarget = false;
-	playerState.evasiveState = -1;
-	playerState.evasiveXStep = 0;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	playerState.turnFirstFrame = -1;
-	playerState.turnLastFrame = -1;
-	playerState.turnEndFrame = -1;
-	playerState.turnPlayBackwards = false;
-	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.nextMovementTick = 0;
-	playerState.hitActive = true;
-	playerState.hitFirstFrame = range.firstFrame;
-	playerState.hitLastFrame = range.lastFrame;
-	playerState.hitResumeFacing = range.resumeFacing;
-	playerState.hitKnockbackRemainingX = range.knockbackX;
-	playerState.hitKnockbackDecayStep = kPlayerHitKnockbackDecayStep;
-	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-	playerState.entity->setAnimationRate(kRoomPlayerHitAnimationRate);
-	playerState.entity->setCurrentFrame(range.firstFrame);
-	playerState.entity->setAnimationEnabled(true);
-	playerState.entity->setVisible(true);
-	(void)playPlayerHitSound(engine);
-	debugC(1, kDebugCombat,
-		"Harvester: player hit animation attack_frame=%d frames=%d..%d resume_facing=%d knockback=%d rate=%d",
-		monsterAttackFirstFrame, range.firstFrame, range.lastFrame, range.resumeFacing,
-		range.knockbackX, kRoomPlayerHitAnimationRate);
-	return true;
-}
-
-bool Player::updateHitAnimationState(HarvesterEngine &engine, const RoomSetupState &state,
-		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.hitActive || !playerState.entity)
-		return false;
-
-	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
-	bool changed = false;
-	if (playerState.hitKnockbackRemainingX != 0) {
-		const int step = MAX(1, ABS(playerState.hitKnockbackDecayStep));
-		const int knockbackStep = playerState.hitKnockbackRemainingX > 0
-			? MIN(playerState.hitKnockbackRemainingX, step)
-			: MAX(playerState.hitKnockbackRemainingX, -step);
-		if (knockbackStep != 0) {
-			if (!tryApplyPlayerMovement(movementContext,
-					{ playerState.centerX + knockbackStep, playerState.bottomY, playerState.z })) {
-				playerState.hitKnockbackRemainingX = 0;
-			} else {
-				changed = true;
-				playerState.hitKnockbackRemainingX -= knockbackStep;
-			}
-		}
-	}
-
-	if (playerState.entity->getCurrentFrame() != playerState.hitLastFrame)
-		return changed;
-
-	const int resumeFacing = playerState.hitResumeFacing >= 0
-		? playerState.hitResumeFacing
-		: (playerState.facing >= 0 ? playerState.facing : 0);
-	playerState.hitActive = false;
-	playerState.hitFirstFrame = -1;
-	playerState.hitLastFrame = -1;
-	playerState.hitResumeFacing = -1;
-	playerState.hitKnockbackRemainingX = 0;
-	playerState.hitKnockbackDecayStep = 0;
-	return setIdleAnimation(resumeFacing) || changed;
-}
-
-bool Player::startDeathAnimation(int damageType, bool goreEnabled) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || playerState.deathActive)
-		return false;
-
-	PlayerDeathAnimationRange range;
-	if (!resolvePlayerDeathAnimationRange(playerState, damageType, goreEnabled, range))
-		return false;
-
-	playerState.hasMoveTarget = false;
-	playerState.evasiveState = -1;
-	playerState.evasiveXStep = 0;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	playerState.turnFirstFrame = -1;
-	playerState.turnLastFrame = -1;
-	playerState.turnEndFrame = -1;
-	playerState.turnPlayBackwards = false;
-	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.nextMovementTick = 0;
-	playerState.deathActive = true;
-	playerState.deathFirstFrame = range.firstFrame;
-	playerState.deathLastFrame = range.lastFrame;
-	playerState.deathDamageType = damageType;
-	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-	playerState.entity->setAnimationRate(kNativePlayerDeathEntryAnimationRate);
-	playerState.entity->setCurrentFrame(range.firstFrame);
-	playerState.entity->setVisible(true);
-	debugC(2, kDebugCombat,
-		"Harvester: player death animation start damage_type=%d gore=%d facing=%d frames=%d..%d",
-		damageType, goreEnabled, playerState.facing, range.firstFrame, range.lastFrame);
-	debugC(3, kDebugCombat,
-		"Harvester: player death animation timing frames=%d..%d entry_rate=%d entry_interval_ticks=%d running_rate=%d running_interval_ticks=%d",
-		range.firstFrame, range.lastFrame,
-		kNativePlayerDeathEntryAnimationRate, 100 / kNativePlayerDeathEntryAnimationRate,
-		playerState.animationRate, 100 / playerState.animationRate);
-	return true;
-}
-
-bool Player::updateDeathAnimationState() {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.deathActive || !playerState.entity)
-		return false;
-
-	const int currentFrame = playerState.entity->getCurrentFrame();
-	if (currentFrame > playerState.deathFirstFrame &&
-			currentFrame < playerState.deathLastFrame &&
-			playerState.entity->getAnimationRate() != playerState.animationRate) {
-		playerState.entity->setAnimationRate(playerState.animationRate);
-		debugC(3, kDebugCombat,
-			"Harvester: player death animation running frame=%d rate=%d interval_ticks=%d",
-			currentFrame, playerState.animationRate,
-			100 / playerState.animationRate);
-	}
-
-	if (currentFrame < playerState.deathLastFrame)
-		return false;
-
-	playerState.deathActive = false;
-	debugC(2, kDebugCombat,
-		"Harvester: player death animation complete damage_type=%d frame=%d",
-		playerState.deathDamageType, currentFrame);
-	return true;
-}
-
-bool Player::startTurnAnimation(int targetFacing) {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.entity || playerState.hitActive || playerState.evasiveState >= 0 ||
-			playerState.facing < 0 || playerState.facing == targetFacing)
-		return false;
-
-	int nextFacing = targetFacing;
-	if ((playerState.facing == 1 && targetFacing == 2) ||
-			(playerState.facing == 2 && targetFacing == 1))
-		nextFacing = 0;
-	else if ((playerState.facing == 0 && targetFacing == 3) ||
-			(playerState.facing == 3 && targetFacing == 0))
-		nextFacing = 2;
-	PlayerTurnAnimationRange range;
-	if (playerState.facing == 3 && targetFacing == 0) {
-		range = PlayerTurnAnimationRange{ 0x28, 0x28, false };
-	} else if (!resolvePlayerTurnAnimationRange(playerState.facing, nextFacing, range)) {
-		return false;
-	}
-
-	playerState.turnActive = true;
-	playerState.turnTargetFacing = nextFacing;
-	playerState.turnFirstFrame = range.firstFrame;
-	playerState.turnLastFrame = range.lastFrame;
-	playerState.turnEndFrame = range.playBackwards ? range.firstFrame : range.lastFrame;
-	playerState.turnPlayBackwards = range.playBackwards;
-	playerState.nextMovementTick = 0;
-	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-	playerState.entity->setPlayBackwards(range.playBackwards);
-	playerState.entity->setAnimationRate(playerState.animationRate);
-	playerState.entity->setCurrentFrame(range.playBackwards ? range.lastFrame : range.firstFrame);
-	debugC(1, kDebugPlayer,
-		"Harvester: player turn animation from=%d to=%d frames=%d..%d backwards=%d rate=%d requested_facing=%d",
-		playerState.facing, nextFacing, range.firstFrame, range.lastFrame,
-		range.playBackwards, playerState.animationRate, targetFacing);
-	return true;
-}
-
-bool Player::updateTurnAnimationState() {
-	RoomPlayerState &playerState = _state;
-	if (!playerState.turnActive || !playerState.entity)
-		return false;
-	if (playerState.entity->getCurrentFrame() != playerState.turnEndFrame)
-		return false;
-
-	playerState.turnActive = false;
-	playerState.facing = playerState.turnTargetFacing;
-	playerState.turnTargetFacing = -1;
-	playerState.turnFirstFrame = -1;
-	playerState.turnLastFrame = -1;
-	playerState.turnEndFrame = -1;
-	playerState.turnPlayBackwards = false;
-	playerState.entity->setAnimationRate(0);
-	debugC(1, kDebugPlayer,
-		"Harvester: player turn complete facing=%d frame=%d",
-		playerState.facing, playerState.entity->getCurrentFrame());
-	return true;
-}
-
 bool Player::requestIdleAnimationExit(const RoomSetupState &state) {
 	RoomPlayerState &playerState = _state;
 	RoomIdleAnimationState &idleState = _idleState;
diff --git a/engines/harvester/player_animation.cpp b/engines/harvester/player_animation.cpp
new file mode 100644
index 00000000000..d72323859fe
--- /dev/null
+++ b/engines/harvester/player_animation.cpp
@@ -0,0 +1,251 @@
+/* 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/player.h"
+#include "harvester/player_movement.h"
+#include "harvester/combat.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+
+namespace Harvester {
+
+using namespace PlayerMovement;
+
+namespace {
+
+static const int kRoomPlayerWalkAnimationRate = 17;
+static const int kRoomPlayerFastAnimationRate = 30;
+
+struct PlayerTurnAnimationRange {
+	PlayerTurnAnimationRange() {}
+	PlayerTurnAnimationRange(int firstFrame, int lastFrame, bool playBackwards)
+		: firstFrame(firstFrame), lastFrame(lastFrame), playBackwards(playBackwards) {}
+
+	int firstFrame = 0;
+	int lastFrame = 0;
+	bool playBackwards = false;
+};
+
+static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, PlayerTurnAnimationRange &range) {
+	// Desired-state transitions play these banks forward or backward depending on the source facing.
+	if (fromFacing == 0 && toFacing == 1) {
+		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, false };
+		return true;
+	}
+	if (fromFacing == 1 && toFacing == 0) {
+		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, true };
+		return true;
+	}
+	if (fromFacing == 1 && toFacing == 3) {
+		range = PlayerTurnAnimationRange{ 0x19, 0x1d, false };
+		return true;
+	}
+	if (fromFacing == 3 && toFacing == 1) {
+		range = PlayerTurnAnimationRange{ 0x19, 0x1d, true };
+		return true;
+	}
+	if (fromFacing == 3 && toFacing == 2) {
+		range = PlayerTurnAnimationRange{ 0x28, 0x2c, false };
+		return true;
+	}
+	if (fromFacing == 2 && toFacing == 3) {
+		range = PlayerTurnAnimationRange{ 0x28, 0x2c, true };
+		return true;
+	}
+	if (fromFacing == 2 && toFacing == 0) {
+		range = PlayerTurnAnimationRange{ 0x37, 0x3b, false };
+		return true;
+	}
+	if (fromFacing == 0 && toFacing == 2) {
+		range = PlayerTurnAnimationRange{ 0x37, 0x3b, true };
+		return true;
+	}
+
+	return false;
+}
+
+} // End of anonymous namespace
+
+bool Player::updateAnimationRate(bool speedUp) {
+	RoomPlayerState &playerState = _state;
+	const int rate = speedUp ? kRoomPlayerFastAnimationRate : kRoomPlayerWalkAnimationRate;
+	if (playerState.animationRate == rate)
+		return false;
+	playerState.animationRate = rate;
+	if (playerState.entity && !playerState.hitActive && !playerState.deathActive &&
+			playerState.entity->getAnimationRate() != 0)
+		playerState.entity->setAnimationRate(rate);
+	debugC(2, kDebugPlayer, "Harvester: player actor rate=%d", rate);
+	return true;
+}
+
+bool Player::startEvasiveAnimation(bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || playerState.attackActive || playerState.hitActive || playerState.deathActive ||
+			playerState.evasiveState >= 0)
+		return false;
+	playerState.hasMoveTarget = false;
+	playerState.keyboardWalkRequest = -1;
+	if (playerState.turnActive)
+		return false;
+	const PlayerAnimationRange walk = resolvePlayerAnimationRange(playerState.facing);
+	const int frame = playerState.entity->getCurrentFrame();
+	if (playerState.entity->getAnimationRate() != 0 &&
+			frame >= walk.walkFirstFrame && frame < walk.walkLastFrame)
+		return false;
+
+	// Right Shift branch:
+	// vertical requests select duck/leap banks; horizontal requests can override them.
+	int actorState = -1;
+	if (playerState.facing == 1 || playerState.facing == 2) {
+		if (moveUp)
+			actorState = playerState.facing == 2 ? 0x12 : 0x13;
+		else if (moveDown)
+			actorState = playerState.facing == 2 ? 0x14 : 0x15;
+		if (moveLeft) {
+			if (playerState.facing == 2)
+				actorState = 0x08;
+		} else if (moveRight && playerState.facing == 1) {
+			actorState = 0x0f;
+		}
+	}
+	if (actorState < 0)
+		return false;
+
+	const bool backwards = actorState == 0x08 || actorState == 0x0f;
+	const int firstFrame = actorState == 0x08 ? 0x2d : actorState == 0x0f ? 0x0f :
+		0x3c + (actorState - 0x12) * 5;
+	const int lastFrame = firstFrame + (backwards ? 9 : 4);
+	if (!runtimeEntityHasFrameRange(*playerState.entity, firstFrame, lastFrame))
+		return false;
+	playerState.evasiveState = actorState;
+	playerState.evasiveFirstFrame = firstFrame;
+	playerState.evasiveLastFrame = lastFrame;
+	playerState.evasiveXStep = backwards
+		? (int)(playerState.walkHorizontalDirection * playerState.entity->getDepthScale() * 8.0f) : 0;
+	playerState.walkHorizontalDirection = 0;
+	playerState.walkDepthDirection = 0;
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationFrameRange(firstFrame, lastFrame, false);
+	playerState.entity->setPlayBackwards(backwards);
+	playerState.entity->setAnimationRate(playerState.animationRate);
+	playerState.entity->setCurrentFrame(backwards ? lastFrame : firstFrame);
+	debugC(2, kDebugPlayer, "Harvester: player evasive state=0x%x facing=%d frames=%d..%d backwards=%d",
+		actorState, playerState.facing, firstFrame, lastFrame, backwards);
+	return true;
+}
+
+bool Player::updateEvasiveAnimation(HarvesterEngine &engine) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || playerState.evasiveState < 0 ||
+			!playerState.entity->didAnimationAdvanceLastTick())
+		return false;
+
+	Entity &entity = *playerState.entity;
+	const int actorState = playerState.evasiveState;
+	if (playerState.evasiveXStep != 0) {
+		entity.setPosition(entity.getX() + playerState.evasiveXStep, entity.getY(), entity.getZ());
+		syncAnimationPlacement();
+		reinsertPlayerSceneEntity(engine, playerState);
+		debugC(3, kDebugPlayer, "Harvester: player evasive step state=0x%x frame=%d dx=%d",
+			actorState, entity.getCurrentFrame(), playerState.evasiveXStep);
+	}
+	const bool backwards = actorState == 0x08 || actorState == 0x0f;
+	if (entity.getCurrentFrame() == (backwards ? playerState.evasiveFirstFrame : playerState.evasiveLastFrame)) {
+		debugC(2, kDebugPlayer, "Harvester: player evasive complete state=0x%x facing=%d",
+			actorState, playerState.facing);
+		return setIdleAnimation(playerState.facing);
+	}
+
+	const int frameLeft = computePlayerFrameLeftX(playerState);
+	if ((actorState == 0x08 && frameLeft > 20) || (actorState == 0x0f && frameLeft < 620)) {
+		playerState.evasiveXStep = (int)(entity.getDepthScale() * (actorState == 0x08 ? -8.0f : 8.0f));
+	} else if (entity.getCurrentFrame() > playerState.evasiveFirstFrame + 2 &&
+			((actorState == 0x12 && frameLeft > 36) || (actorState == 0x13 && frameLeft < 616))) {
+		playerState.evasiveXStep = (int)(entity.getDepthScale() * (actorState == 0x12 ? -16.0f : 16.0f));
+	}
+	return true;
+}
+
+bool Player::startTurnAnimation(int targetFacing) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || playerState.hitActive || playerState.evasiveState >= 0 ||
+			playerState.facing < 0 || playerState.facing == targetFacing)
+		return false;
+
+	int nextFacing = targetFacing;
+	if ((playerState.facing == 1 && targetFacing == 2) ||
+			(playerState.facing == 2 && targetFacing == 1))
+		nextFacing = 0;
+	else if ((playerState.facing == 0 && targetFacing == 3) ||
+			(playerState.facing == 3 && targetFacing == 0))
+		nextFacing = 2;
+	PlayerTurnAnimationRange range;
+	if (playerState.facing == 3 && targetFacing == 0) {
+		range = PlayerTurnAnimationRange{ 0x28, 0x28, false };
+	} else if (!resolvePlayerTurnAnimationRange(playerState.facing, nextFacing, range)) {
+		return false;
+	}
+
+	playerState.turnActive = true;
+	playerState.turnTargetFacing = nextFacing;
+	playerState.turnFirstFrame = range.firstFrame;
+	playerState.turnLastFrame = range.lastFrame;
+	playerState.turnEndFrame = range.playBackwards ? range.firstFrame : range.lastFrame;
+	playerState.turnPlayBackwards = range.playBackwards;
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setPlayBackwards(range.playBackwards);
+	playerState.entity->setAnimationRate(playerState.animationRate);
+	playerState.entity->setCurrentFrame(range.playBackwards ? range.lastFrame : range.firstFrame);
+	debugC(1, kDebugPlayer,
+		"Harvester: player turn animation from=%d to=%d frames=%d..%d backwards=%d rate=%d requested_facing=%d",
+		playerState.facing, nextFacing, range.firstFrame, range.lastFrame,
+		range.playBackwards, playerState.animationRate, targetFacing);
+	return true;
+}
+
+bool Player::updateTurnAnimationState() {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.turnActive || !playerState.entity)
+		return false;
+	if (playerState.entity->getCurrentFrame() != playerState.turnEndFrame)
+		return false;
+
+	playerState.turnActive = false;
+	playerState.facing = playerState.turnTargetFacing;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.entity->setAnimationRate(0);
+	debugC(1, kDebugPlayer,
+		"Harvester: player turn complete facing=%d frame=%d",
+		playerState.facing, playerState.entity->getCurrentFrame());
+	return true;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/player_attack.cpp b/engines/harvester/player_attack.cpp
new file mode 100644
index 00000000000..e4698488be5
--- /dev/null
+++ b/engines/harvester/player_attack.cpp
@@ -0,0 +1,480 @@
+/* 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/player.h"
+#include "harvester/player_movement.h"
+#include "harvester/combat.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+using namespace PlayerMovement;
+
+namespace {
+
+static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
+static const float kRoomPlayerAttackUpperYOffset = 144.44f;
+static const float kRoomPlayerAttackMidYOffset = 75.36f;
+static const int kPlayerAttackSoundTriggerFrameOffset = 1;
+
+struct PlayerAttackAnimationRange {
+	PlayerAttackAnimationRange() {}
+	PlayerAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
+
+	int firstFrame = 0;
+	int lastFrame = 0;
+	int resumeFacing = 0;
+};
+
+struct PlayerAttackSoundSet {
+	uint soundCount;
+	const char *soundPaths[3];
+};
+
+struct PlayerCombatLoadoutTuning {
+	const char *label;
+	int damageType;
+	int damageAmount;
+	int contactFrameOffset;
+};
+
+static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
+	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 1, { "2:/SOUND/EFFECTS/NAILGUN1.wav", nullptr, nullptr } },
+	{ 1, { "1:/sound/effects/shotgun2.wav", nullptr, nullptr } },
+	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
+	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
+	{ 1, { "1:/sound/effects/CLN_atk0.wav", nullptr, nullptr } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh3.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } }
+};
+
+static const PlayerCombatLoadoutTuning kPlayerCombatLoadoutTunings[] = {
+	{ "FISTS", 1, 1, 5 },
+	{ "CLEAVER", 2, 4, 2 },
+	{ "NAILGUN", 4, 2, 2 },
+	{ "SHOTGUN", 4, 7, 2 },
+	{ "9GUN", 4, 6, 2 },
+	{ "38GUN", 4, 6, 2 },
+	{ "TOMAHAWK", 2, 3, 2 },
+	{ "KNIFE", 2, 3, 2 },
+	{ "FLAIL", 2, 3, 2 },
+	{ "HANDAXE", 2, 3, 2 },
+	{ "WRENCH", 1, 3, 2 },
+	{ "PITCHFORK", 2, 3, 2 },
+	{ "SCYTHE", 2, 4, 2 },
+	{ "SWORD", 2, 5, 2 },
+	{ "CHAINSAW", 2, 8, 2 },
+	{ "HARVEST_BLADE", 2, 3, 2 },
+	{ "SHOVEL", 1, 3, 2 },
+	{ "FIREAXE", 2, 3, 2 },
+	{ "BAT", 1, 3, 2 },
+	{ "RAZOR", 2, 3, 2 },
+	{ "POOLSTICK", 1, 2, 2 }
+};
+
+static const PlayerAttackSoundSet *resolvePlayerAttackSoundSet(int loadout) {
+	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerAttackSoundSets))
+		return nullptr;
+
+	if (kPlayerAttackSoundSets[loadout].soundCount == 0)
+		return nullptr;
+
+	return &kPlayerAttackSoundSets[loadout];
+}
+
+static const PlayerCombatLoadoutTuning &resolvePlayerCombatLoadoutTuning(int loadout) {
+	static const PlayerCombatLoadoutTuning kDefaultTuning = { "FISTS", 1, 1, 5 };
+
+	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerCombatLoadoutTunings))
+		return kDefaultTuning;
+
+	return kPlayerCombatLoadoutTunings[loadout];
+}
+
+static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
+	const PlayerAttackSoundSet *soundSet = resolvePlayerAttackSoundSet(loadout);
+	if (!soundSet)
+		return false;
+
+	uint soundIndex = 0;
+	if (soundSet->soundCount > 1)
+		soundIndex = engine.getRandomNumber(soundSet->soundCount - 1);
+
+	const char *soundPath = soundSet->soundPaths[soundIndex];
+	return soundPath && engine.playSound(soundPath);
+}
+
+static bool loadoutUsesConsumableCombatResource(int loadout) {
+	switch (loadout) {
+	case 2:
+	case 3:
+	case 4:
+	case 5:
+	case 14:
+		return true;
+	default:
+		return false;
+	}
+}
+
+static bool resolvePlayerAttackAnimationRange(const RoomSetupState &state,
+		const RoomPlayerState &playerState, const Common::Point &mousePos,
+		PlayerAttackAnimationRange &range) {
+	if (!playerState.entity)
+		return false;
+
+	const float depthScale = Player::computeDepthScale(state, playerState.z);
+	const float upperThresholdY =
+		(float)playerState.bottomY - depthScale * kRoomPlayerAttackUpperYOffset;
+	const float midThresholdY =
+		(float)playerState.bottomY - depthScale * kRoomPlayerAttackMidYOffset;
+
+	if (mousePos.x < playerState.centerX) {
+		if ((float)mousePos.y < upperThresholdY)
+			range = PlayerAttackAnimationRange(0x50, 0x59, 1);
+		else if ((float)mousePos.y < midThresholdY)
+			range = PlayerAttackAnimationRange(0x5a, 0x63, 1);
+		else
+			range = PlayerAttackAnimationRange(0x64, 0x6d, 1);
+	} else {
+		if ((float)mousePos.y < upperThresholdY)
+			range = PlayerAttackAnimationRange(0x6e, 0x77, 2);
+		else if ((float)mousePos.y < midThresholdY)
+			range = PlayerAttackAnimationRange(0x78, 0x81, 2);
+		else
+			range = PlayerAttackAnimationRange(0x82, 0x8b, 2);
+	}
+
+	return true;
+}
+
+static bool resolveKeyboardAttackAnimationRange(const RoomPlayerState &playerState,
+		bool attackLeft, bool attackRight, bool attackUp, bool attackDown,
+		PlayerAttackAnimationRange &range) {
+	if (attackRight) {
+		range = PlayerAttackAnimationRange(0x78, 0x81, 2);
+		return true;
+	}
+	if (attackLeft) {
+		range = PlayerAttackAnimationRange(0x5a, 0x63, 1);
+		return true;
+	}
+	if (attackUp) {
+		if (playerState.facing == 2) {
+			range = PlayerAttackAnimationRange(0x6e, 0x77, 2);
+			return true;
+		}
+		if (playerState.facing == 1) {
+			range = PlayerAttackAnimationRange(0x50, 0x59, 1);
+			return true;
+		}
+		return false;
+	}
+	if (attackDown) {
+		if (playerState.facing == 2) {
+			range = PlayerAttackAnimationRange(0x82, 0x8b, 2);
+			return true;
+		}
+		if (playerState.facing == 1) {
+			range = PlayerAttackAnimationRange(0x64, 0x6d, 1);
+			return true;
+		}
+		return false;
+	}
+
+	return false;
+}
+
+static bool startResolvedAttackAnimation(RoomPlayerState &playerState,
+		const PlayerAttackAnimationRange &range) {
+	if (!playerState.entity || playerState.attackActive || playerState.hitActive)
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.attackActive = true;
+	playerState.attackFirstFrame = range.firstFrame;
+	playerState.attackLastFrame = range.lastFrame;
+	playerState.attackContactFrame =
+		range.firstFrame + Player::resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout);
+	playerState.attackResumeFacing = range.resumeFacing;
+	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
+	playerState.attackSoundPlaybackFrame = playerState.attackSoundPlayed
+		? -1
+		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
+	playerState.attackContactResolved = false;
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setAnimationRate(playerState.animationRate);
+	playerState.entity->setCurrentFrame(range.firstFrame);
+	return true;
+}
+
+} // End of anonymous namespace
+
+void Player::cancelMovementForAttack() {
+	cancelMovement();
+	_state.turnFirstFrame = -1;
+	_state.turnLastFrame = -1;
+	_state.turnEndFrame = -1;
+	_state.turnPlayBackwards = false;
+}
+
+bool Player::cancelAttackAnimation() {
+	RoomPlayerState &playerState = _state;
+	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 setIdleAnimation(resumeFacing);
+}
+
+void Player::clearAttackTarget() {
+	_state.attackTargetName.clear();
+	_state.attackTargetClassId = -1;
+}
+
+void Player::captureAttackTarget(const Common::String &name, int classId) {
+	_state.attackTargetName = name;
+	_state.attackTargetClassId = classId;
+}
+
+void Player::markAttackContactResolved() {
+	_state.attackContactResolved = true;
+}
+
+Common::String Player::resolveCombatLoadoutResourcePath(int loadout) {
+	if (loadout <= 0)
+		return Common::String(kPlayerActorResourcePath);
+
+	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
+}
+
+const char *Player::describeCombatLoadout(int loadout) {
+	return resolvePlayerCombatLoadoutTuning(loadout).label;
+}
+
+int Player::resolveCombatLoadoutDamageAmount(int loadout) {
+	return resolvePlayerCombatLoadoutTuning(loadout).damageAmount;
+}
+
+int Player::resolveCombatLoadoutDamageType(int loadout) {
+	return resolvePlayerCombatLoadoutTuning(loadout).damageType;
+}
+
+const char *Player::describeCombatDamageType(int damageType) {
+	switch (damageType) {
+	case 1:
+		return "BLUDGE";
+	case 2:
+		return "SLASH";
+	case 4:
+		return "PROJ";
+	default:
+		return "UNKNOWN";
+	}
+}
+
+int Player::resolveCombatLoadoutContactFrameOffset(int loadout) {
+	return resolvePlayerCombatLoadoutTuning(loadout).contactFrameOffset;
+}
+
+bool Player::isProjectileCombatLoadout(int loadout) {
+	return resolveCombatLoadoutDamageType(loadout) == 4;
+}
+
+void Player::syncCombatLoadout(HarvesterEngine &engine, const RoomSetupState &state) {
+	if (_state.entity) {
+		(void)syncCombatLoadoutVisual(engine, state, engine.getScript()->getPlayerCombatLoadout());
+	} else if (engine.getScript()) {
+		_state.combatLoadout = engine.getScript()->getPlayerCombatLoadout();
+	}
+}
+
+bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const RoomSetupState &state, int loadout) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity)
+		return false;
+
+	ResourceManager *resources = engine.getResources();
+	if (!resources)
+		return false;
+
+	const int clampedLoadout = CLIP<int>(loadout, 0, 0x14);
+	if (playerState.combatLoadout == clampedLoadout)
+		return false;
+
+	const Common::String resourcePath = resolveCombatLoadoutResourcePath(clampedLoadout);
+	if (!playerState.entity->loadAbmResource(*resources, resourcePath))
+		return false;
+
+	playerState.combatLoadout = clampedLoadout;
+	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;
+	(void)setIdleAnimation(playerState.facing >= 0 ? playerState.facing : 0);
+	if (applyRoomActorPlacement(state, *playerState.entity,
+			playerState.centerX, playerState.bottomY, playerState.z)) {
+		reinsertPlayerSceneEntity(engine, playerState);
+	}
+	debugC(1, kDebugPlayer,
+		"Harvester: player combat loadout visual loadout=%d weapon='%s' damage=%d damage_type='%s' contact=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
+		playerState.combatLoadout, describeCombatLoadout(playerState.combatLoadout),
+		resolveCombatLoadoutDamageAmount(playerState.combatLoadout),
+		describeCombatDamageType(resolveCombatLoadoutDamageType(playerState.combatLoadout)),
+		resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout), resourcePath.c_str(), playerState.facing,
+		playerState.centerX, playerState.bottomY, (double)playerState.z);
+	return true;
+}
+
+bool Player::startAttackAnimation(const RoomSetupState &state, const Common::Point &mousePos) {
+	RoomPlayerState &playerState = _state;
+	PlayerAttackAnimationRange range;
+	if (!resolvePlayerAttackAnimationRange(state, playerState, mousePos, range))
+		return false;
+	if (!startResolvedAttackAnimation(playerState, range))
+		return false;
+	debugC(1, kDebugPlayer,
+		"Harvester: player attack animation frames=%d..%d resume_facing=%d cursor=(%d,%d)",
+		range.firstFrame, range.lastFrame, range.resumeFacing, mousePos.x, mousePos.y);
+	return true;
+}
+
+bool Player::startKeyboardAttackAnimation(const RoomSetupState &state,
+		bool attackLeft, bool attackRight, bool attackUp, bool attackDown) {
+	RoomPlayerState &playerState = _state;
+	(void)state;
+
+	PlayerAttackAnimationRange range;
+	if (!resolveKeyboardAttackAnimationRange(playerState, attackLeft, attackRight, attackUp, attackDown, range))
+		return false;
+	if (!startResolvedAttackAnimation(playerState, range))
+		return false;
+	debugC(1, kDebugPlayer,
+		"Harvester: player keyboard attack animation frames=%d..%d resume_facing=%d input=(L=%d R=%d U=%d D=%d) facing=%d",
+		range.firstFrame, range.lastFrame, range.resumeFacing,
+		attackLeft, attackRight, attackUp, attackDown, playerState.facing);
+	return true;
+}
+
+bool Player::updateAttackAnimationState(HarvesterEngine &engine) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.attackActive || !playerState.entity)
+		return false;
+	bool changed = false;
+	if (!playerState.attackSoundPlayed &&
+			playerState.attackSoundPlaybackFrame >= 0 &&
+			playerState.entity->getCurrentFrame() >= playerState.attackSoundPlaybackFrame) {
+		if (loadoutUsesConsumableCombatResource(playerState.combatLoadout)) {
+			Script *script = engine.getScript();
+			if (!script || !script->consumePlayerCombatResourceUnit(playerState.combatLoadout)) {
+				debugC(1, kDebugCombat,
+					"Harvester: combat player attack dry fire loadout=%d weapon='%s'",
+					playerState.combatLoadout,
+					describeCombatLoadout(playerState.combatLoadout));
+				playerState.attackContactResolved = true;
+				playerState.attackContactFrame = -1;
+				playerState.attackTargetName.clear();
+				playerState.attackTargetClassId = -1;
+			} else {
+				changed = true;
+				(void)playPlayerAttackSound(engine, playerState.combatLoadout);
+			}
+		} else {
+			(void)playPlayerAttackSound(engine, playerState.combatLoadout);
+		}
+		playerState.attackSoundPlayed = true;
+	}
+	if (playerState.entity->getCurrentFrame() != playerState.attackLastFrame)
+		return changed;
+
+	const int resumeFacing = playerState.attackResumeFacing >= 0
+		? playerState.attackResumeFacing
+		: (playerState.facing >= 0 ? playerState.facing : 0);
+	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;
+	return setIdleAnimation(resumeFacing) || changed;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/player_reactions.cpp b/engines/harvester/player_reactions.cpp
new file mode 100644
index 00000000000..57bdf929e03
--- /dev/null
+++ b/engines/harvester/player_reactions.cpp
@@ -0,0 +1,358 @@
+/* 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/player.h"
+#include "harvester/player_movement.h"
+#include "harvester/combat.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+
+namespace Harvester {
+
+using namespace PlayerMovement;
+
+namespace {
+
+static const int kRoomPlayerHitAnimationRate = 4;
+static const int kNativePlayerDeathEntryAnimationRate = 14;
+static const int kPlayerHitKnockbackDistance = 18;
+static const int kPlayerHitKnockbackDecayStep = 3;
+
+struct PlayerDeathAnimationRange {
+	PlayerDeathAnimationRange() {}
+	PlayerDeathAnimationRange(int firstFrame, int lastFrame)
+		: firstFrame(firstFrame), lastFrame(lastFrame) {}
+
+	int firstFrame = -1;
+	int lastFrame = -1;
+};
+
+struct PlayerHitAnimationRange {
+	PlayerHitAnimationRange() {}
+	PlayerHitAnimationRange(int firstFrame, int lastFrame, int resumeFacing, int knockbackX)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing), knockbackX(knockbackX) {}
+
+	int firstFrame = -1;
+	int lastFrame = -1;
+	int resumeFacing = -1;
+	int knockbackX = 0;
+};
+
+static const char *const kPlayerHitSoundPaths[] = {
+	"1:/sound/effects/PC0_hit0.wav",
+	"1:/sound/effects/PC0_hit1.wav",
+	"1:/sound/effects/PC0_hit2.wav"
+};
+
+static bool playPlayerHitSound(HarvesterEngine &engine) {
+	const uint soundIndex = ARRAYSIZE(kPlayerHitSoundPaths) > 1
+		? engine.getRandomNumber(ARRAYSIZE(kPlayerHitSoundPaths) - 1)
+		: 0;
+	return engine.playSound(kPlayerHitSoundPaths[soundIndex]);
+}
+
+static bool choosePlayerDeathAnimationRange(const Entity &entity, int firstFrame, int lastFrame,
+		PlayerDeathAnimationRange &range) {
+	if (!runtimeEntityHasFrameRange(entity, firstFrame, lastFrame))
+		return false;
+
+	range = PlayerDeathAnimationRange(firstFrame, lastFrame);
+	return true;
+}
+
+static bool resolvePlayerDeathAnimationRange(const RoomPlayerState &playerState,
+		int damageType, bool goreEnabled, PlayerDeathAnimationRange &range) {
+	if (!playerState.entity)
+		return false;
+
+	enum PlayerDeathBankFamily {
+		kPlayerDeathBankHorizontalRight,
+		kPlayerDeathBankHorizontalLeft,
+		kPlayerDeathBankVerticalDown,
+		kPlayerDeathBankVerticalUp
+	};
+
+	PlayerDeathBankFamily family = kPlayerDeathBankHorizontalLeft;
+	if (playerState.attackActive) {
+		if (playerState.attackFirstFrame >= 0x6e && playerState.attackFirstFrame <= 0x8b)
+			family = kPlayerDeathBankHorizontalRight;
+		else
+			family = kPlayerDeathBankHorizontalLeft;
+	} else if (playerState.turnActive) {
+		if (playerState.turnFirstFrame >= 0x28)
+			family = kPlayerDeathBankHorizontalRight;
+		else
+			family = kPlayerDeathBankHorizontalLeft;
+	} else {
+		switch (playerState.facing) {
+		case 0:
+			family = kPlayerDeathBankVerticalDown;
+			break;
+		case 2:
+			family = kPlayerDeathBankHorizontalRight;
+			break;
+		case 3:
+			family = kPlayerDeathBankVerticalUp;
+			break;
+		case 1:
+		default:
+			family = kPlayerDeathBankHorizontalLeft;
+			break;
+		}
+	}
+
+	const auto selectByFamily = [&](int bludgeFirst, int bludgeLast, int slashFirst, int slashLast,
+			int projectileFirst, int projectileLast) {
+		if ((!goreEnabled || damageType == 1) &&
+				choosePlayerDeathAnimationRange(*playerState.entity, bludgeFirst, bludgeLast, range))
+			return true;
+		if (damageType == 4 &&
+				choosePlayerDeathAnimationRange(*playerState.entity, projectileFirst, projectileLast, range))
+			return true;
+		if (damageType == 2 &&
+				choosePlayerDeathAnimationRange(*playerState.entity, slashFirst, slashLast, range))
+			return true;
+
+		return choosePlayerDeathAnimationRange(*playerState.entity, bludgeFirst, bludgeLast, range) ||
+			choosePlayerDeathAnimationRange(*playerState.entity, slashFirst, slashLast, range) ||
+			choosePlayerDeathAnimationRange(*playerState.entity, projectileFirst, projectileLast, range);
+	};
+
+	switch (family) {
+	case kPlayerDeathBankHorizontalRight:
+		return selectByFamily(0xb0, 0xb9, 0x100, 0x109, 0x114, 0x11d);
+	case kPlayerDeathBankVerticalDown:
+		return selectByFamily(0xc4, 0xcd, 0xd8, 0xe1, 0xec, 0xf5);
+	case kPlayerDeathBankVerticalUp:
+		return selectByFamily(0xce, 0xd7, 0xe2, 0xeb, 0xf6, 0xff);
+	case kPlayerDeathBankHorizontalLeft:
+	default:
+		return selectByFamily(0xba, 0xc3, 0x10a, 0x113, 0x11e, 0x127);
+	}
+}
+
+static bool resolvePlayerHitAnimationRange(const Entity &entity,
+		int monsterAttackFirstFrame, PlayerHitAnimationRange &range) {
+	PlayerHitAnimationRange candidate;
+	switch (monsterAttackFirstFrame) {
+	case 0x64:
+		candidate = PlayerHitAnimationRange(0x92, 0x94, 2, -kPlayerHitKnockbackDistance);
+		break;
+	case 0x5a:
+		candidate = PlayerHitAnimationRange(0x8f, 0x91, 2, -kPlayerHitKnockbackDistance);
+		break;
+	case 0x50:
+		candidate = PlayerHitAnimationRange(0x8c, 0x8e, 2, -kPlayerHitKnockbackDistance);
+		break;
+	case 0x82:
+		candidate = PlayerHitAnimationRange(0x9b, 0x9d, 1, kPlayerHitKnockbackDistance);
+		break;
+	case 0x78:
+		candidate = PlayerHitAnimationRange(0x98, 0x9a, 1, kPlayerHitKnockbackDistance);
+		break;
+	case 0x6e:
+		candidate = PlayerHitAnimationRange(0x95, 0x97, 1, kPlayerHitKnockbackDistance);
+		break;
+	default:
+		return false;
+	}
+
+	if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+		return false;
+
+	range = candidate;
+	return true;
+}
+
+} // End of anonymous namespace
+
+bool Player::startHitAnimation(HarvesterEngine &engine, int monsterAttackFirstFrame) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || playerState.deathActive)
+		return false;
+
+	PlayerHitAnimationRange range;
+	if (!resolvePlayerHitAnimationRange(*playerState.entity, monsterAttackFirstFrame, range))
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	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.nextMovementTick = 0;
+	playerState.hitActive = true;
+	playerState.hitFirstFrame = range.firstFrame;
+	playerState.hitLastFrame = range.lastFrame;
+	playerState.hitResumeFacing = range.resumeFacing;
+	playerState.hitKnockbackRemainingX = range.knockbackX;
+	playerState.hitKnockbackDecayStep = kPlayerHitKnockbackDecayStep;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setAnimationRate(kRoomPlayerHitAnimationRate);
+	playerState.entity->setCurrentFrame(range.firstFrame);
+	playerState.entity->setAnimationEnabled(true);
+	playerState.entity->setVisible(true);
+	(void)playPlayerHitSound(engine);
+	debugC(1, kDebugCombat,
+		"Harvester: player hit animation attack_frame=%d frames=%d..%d resume_facing=%d knockback=%d rate=%d",
+		monsterAttackFirstFrame, range.firstFrame, range.lastFrame, range.resumeFacing,
+		range.knockbackX, kRoomPlayerHitAnimationRate);
+	return true;
+}
+
+bool Player::updateHitAnimationState(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects, const Common::Array<AnimRecord> &sceneAnimations) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.hitActive || !playerState.entity)
+		return false;
+
+	const MovementContext movementContext = { engine, state, sceneObjects, sceneAnimations, playerState };
+	bool changed = false;
+	if (playerState.hitKnockbackRemainingX != 0) {
+		const int step = MAX(1, ABS(playerState.hitKnockbackDecayStep));
+		const int knockbackStep = playerState.hitKnockbackRemainingX > 0
+			? MIN(playerState.hitKnockbackRemainingX, step)
+			: MAX(playerState.hitKnockbackRemainingX, -step);
+		if (knockbackStep != 0) {
+			if (!tryApplyPlayerMovement(movementContext,
+					{ playerState.centerX + knockbackStep, playerState.bottomY, playerState.z })) {
+				playerState.hitKnockbackRemainingX = 0;
+			} else {
+				changed = true;
+				playerState.hitKnockbackRemainingX -= knockbackStep;
+			}
+		}
+	}
+
+	if (playerState.entity->getCurrentFrame() != playerState.hitLastFrame)
+		return changed;
+
+	const int resumeFacing = playerState.hitResumeFacing >= 0
+		? playerState.hitResumeFacing
+		: (playerState.facing >= 0 ? playerState.facing : 0);
+	playerState.hitActive = false;
+	playerState.hitFirstFrame = -1;
+	playerState.hitLastFrame = -1;
+	playerState.hitResumeFacing = -1;
+	playerState.hitKnockbackRemainingX = 0;
+	playerState.hitKnockbackDecayStep = 0;
+	return setIdleAnimation(resumeFacing) || changed;
+}
+
+bool Player::startDeathAnimation(int damageType, bool goreEnabled) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity || playerState.deathActive)
+		return false;
+
+	PlayerDeathAnimationRange range;
+	if (!resolvePlayerDeathAnimationRange(playerState, damageType, goreEnabled, range))
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	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.nextMovementTick = 0;
+	playerState.deathActive = true;
+	playerState.deathFirstFrame = range.firstFrame;
+	playerState.deathLastFrame = range.lastFrame;
+	playerState.deathDamageType = damageType;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setAnimationRate(kNativePlayerDeathEntryAnimationRate);
+	playerState.entity->setCurrentFrame(range.firstFrame);
+	playerState.entity->setVisible(true);
+	debugC(2, kDebugCombat,
+		"Harvester: player death animation start damage_type=%d gore=%d facing=%d frames=%d..%d",
+		damageType, goreEnabled, playerState.facing, range.firstFrame, range.lastFrame);
+	debugC(3, kDebugCombat,
+		"Harvester: player death animation timing frames=%d..%d entry_rate=%d entry_interval_ticks=%d running_rate=%d running_interval_ticks=%d",
+		range.firstFrame, range.lastFrame,
+		kNativePlayerDeathEntryAnimationRate, 100 / kNativePlayerDeathEntryAnimationRate,
+		playerState.animationRate, 100 / playerState.animationRate);
+	return true;
+}
+
+bool Player::updateDeathAnimationState() {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.deathActive || !playerState.entity)
+		return false;
+
+	const int currentFrame = playerState.entity->getCurrentFrame();
+	if (currentFrame > playerState.deathFirstFrame &&
+			currentFrame < playerState.deathLastFrame &&
+			playerState.entity->getAnimationRate() != playerState.animationRate) {
+		playerState.entity->setAnimationRate(playerState.animationRate);
+		debugC(3, kDebugCombat,
+			"Harvester: player death animation running frame=%d rate=%d interval_ticks=%d",
+			currentFrame, playerState.animationRate,
+			100 / playerState.animationRate);
+	}
+
+	if (currentFrame < playerState.deathLastFrame)
+		return false;
+
+	playerState.deathActive = false;
+	debugC(2, kDebugCombat,
+		"Harvester: player death animation complete damage_type=%d frame=%d",
+		playerState.deathDamageType, currentFrame);
+	return true;
+}
+
+} // End of namespace Harvester


Commit: 58a5bca82ed76e608e2199794c6413d46f63c8e2
    https://github.com/scummvm/scummvm/commit/58a5bca82ed76e608e2199794c6413d46f63c8e2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate player lifecycle and idle animation

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/player_idle.cpp
    engines/harvester/module.mk
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/player_animation.cpp


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 40f096ed98d..55da1d403ab 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -24,6 +24,7 @@ MODULE_OBJS = \
 	player.o \
 	player_animation.o \
 	player_attack.o \
+	player_idle.o \
 	player_keyboard.o \
 	player_movement.o \
 	player_reactions.o \
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 7aabf1295d5..867739a4113 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -24,132 +24,14 @@
 #include "harvester/combat.h"
 
 #include "common/debug.h"
-#include "common/system.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
-#include "harvester/resources.h"
 #include "harvester/entity.h"
 
 namespace Harvester {
 
 using namespace PlayerMovement;
 
-namespace {
-
-static bool setPlayerIdleAnimation(RoomPlayerState &playerState, int facing);
-static void updatePlayerIdleTrigger(RoomIdleAnimationState &idleState);
-
-static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
-static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM/PCLOUN02.ABM";
-static const uint32 kRoomPlayerIdleDelayTicks = 3000;
-static const int kRoomPlayerIdleAnimationRate = 14;
-static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
-static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
-static const int kRoomPlayerIdleExitLastFrame = 0xbf;
-static const int kRoomPlayerIdleYOffset = 4;
-
-static void notePlayerIdleReset(RoomIdleAnimationState &idleState) {
-	idleState.resetTick = Player::getRuntimeClockTicks();
-	updatePlayerIdleTrigger(idleState);
-}
-
-static void positionPlayerIdleAnimationEntity(const RoomSetupState &state,
-		const RoomPlayerState &playerState, Entity &idleEntity) {
-	if (!playerState.entity)
-		return;
-
-	idleEntity.setAnchorMode(kEntityAnchorTopLeft);
-	idleEntity.setDepthScale(Player::computeDepthScale(state, playerState.z));
-	idleEntity.setPosition(playerState.entity->getX(),
-		playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
-}
-
-static Entity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
-		const RoomSetupState &state, const RoomPlayerState &playerState,
-		RoomIdleAnimationState &idleState) {
-	if (idleState.entity || !playerState.entity)
-		return idleState.entity;
-
-	EntityManager *entityManager = engine.getRuntimeEntities();
-	if (!entityManager)
-		return nullptr;
-
-	idleState.entity = entityManager->spawnSceneAnimationEntity(
-		kPlayerIdleAnimationEntityName, kPlayerIdleAnimationResourcePath,
-		Common::Point(playerState.entity->getX(),
-			playerState.entity->getY() + kRoomPlayerIdleYOffset),
-		playerState.z, 0, false, false, false, false, false);
-	if (!idleState.entity)
-		return nullptr;
-
-	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
-	idleState.entity->setVisible(false);
-	debugC(1, kDebugPlayer,
-		"Harvester: spawned player idle animation '%s' room='%s' frames=0..%d",
-		kPlayerIdleAnimationResourcePath, state.roomName.c_str(), idleState.entity->getLastFrame());
-	return idleState.entity;
-}
-
-static bool finishPlayerIdleAnimation(const RoomSetupState &state, RoomPlayerState &playerState,
-		RoomIdleAnimationState &idleState) {
-	if (!idleState.active)
-		return false;
-
-	if (idleState.entity) {
-		idleState.entity->setVisible(false);
-		idleState.entity->setAnimationRate(0);
-		idleState.entity->setCurrentFrame(0);
-		positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
-	}
-	if (playerState.entity) {
-		playerState.entity->setVisible(true);
-		if (idleState.restoreFacing >= 0)
-			(void)setPlayerIdleAnimation(playerState, idleState.restoreFacing);
-	}
-
-	idleState.active = false;
-	idleState.loopStarted = false;
-	idleState.exiting = false;
-	idleState.restoreFacing = -1;
-	notePlayerIdleReset(idleState);
-	debugC(1, kDebugPlayer,
-		"Harvester: player idle animation finished room='%s' facing=%d",
-		state.roomName.c_str(), playerState.facing);
-	return true;
-}
-
-static void updatePlayerIdleTrigger(RoomIdleAnimationState &idleState) {
-	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
-}
-
-static bool setPlayerIdleAnimation(RoomPlayerState &playerState, int facing) {
-	if (!playerState.entity)
-		return false;
-
-	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
-	const bool changed = playerState.facing != facing ||
-		playerState.entity->getCurrentFrame() != range.idleFrame ||
-		playerState.entity->getAnimationRate() != 0;
-	playerState.facing = facing;
-	playerState.evasiveState = -1;
-	playerState.evasiveXStep = 0;
-	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);
-	if (changed) {
-		debugC(1, kDebugPlayer,
-			"Harvester: player idle animation facing=%d frame=%d",
-			facing, range.idleFrame);
-	}
-	return changed;
-}
-
-} // End of anonymous namespace
-
 void Player::initialize(const RoomSetupState &state, Entity *entity, int combatLoadout) {
 	RoomPlayerState &playerState = _state;
 	playerState.entity = entity;
@@ -176,35 +58,6 @@ void Player::cancelMovement() {
 	_state.turnTargetFacing = -1;
 }
 
-void Player::resetIdleState() {
-	RoomIdleAnimationState &idleState = _idleState;
-	idleState = RoomIdleAnimationState();
-	idleState.activityTick = Player::getRuntimeClockTicks();
-	idleState.resetTick = idleState.activityTick;
-	updatePlayerIdleTrigger(idleState);
-}
-
-void Player::noteActivity() {
-	RoomIdleAnimationState &idleState = _idleState;
-	idleState.activityTick = Player::getRuntimeClockTicks();
-	updatePlayerIdleTrigger(idleState);
-}
-
-void Player::cancelIdleAnimation() {
-	RoomPlayerState &playerState = _state;
-	RoomIdleAnimationState &idleState = _idleState;
-	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);
-}
-
 void Player::applyGotoXZ(const RoomSetupState &state, EntityManager *entityManager, int x, int z) {
 	RoomPlayerState &playerState = _state;
 	playerState.hasMoveTarget = false;
@@ -320,119 +173,4 @@ uint32 Player::getRuntimeClockTicks() {
 	return Harvester::getRuntimeClockTicks();
 }
 
-bool Player::isIdleAnimationExcludedRoom(const Common::String &roomName) {
-	static const char *const kExcludedRooms[] = {
-		"JAWS",
-		"SUPPLY1",
-		"SUPPLY2",
-		"SUPPLY3",
-		"LAVAPIT",
-		"RMNBATH",
-		"SERGENTRM",
-		"CHAP2"
-	};
-
-	for (const char *excludedRoom : kExcludedRooms) {
-		if (roomName.equalsIgnoreCase(excludedRoom))
-			return true;
-	}
-
-	return false;
-}
-
-
-bool Player::setIdleAnimation(int facing) {
-	return setPlayerIdleAnimation(_state, facing);
-}
-
-bool Player::requestIdleAnimationExit(const RoomSetupState &state) {
-	RoomPlayerState &playerState = _state;
-	RoomIdleAnimationState &idleState = _idleState;
-	if (!idleState.active || idleState.exiting || !idleState.entity)
-		return false;
-
-	const int lastFrame = idleState.entity->getLastFrame();
-	const int exitStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
-	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
-	if (exitLastFrame <= exitStartFrame)
-		return finishPlayerIdleAnimation(state, playerState, idleState);
-
-	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
-	idleState.exiting = true;
-	idleState.entity->setAnimationFrameRange(exitStartFrame, exitLastFrame, false);
-	idleState.entity->setCurrentFrame(exitStartFrame);
-	idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
-	debugC(1, kDebugPlayer,
-		"Harvester: player idle animation exit room='%s' frames=%d..%d rate=%d",
-		state.roomName.c_str(), exitStartFrame, exitLastFrame, kRoomPlayerIdleAnimationRate);
-	return true;
-}
-
-bool Player::startIdleAnimation(HarvesterEngine &engine, const RoomSetupState &state) {
-	RoomPlayerState &playerState = _state;
-	RoomIdleAnimationState &idleState = _idleState;
-	if (!playerState.entity || idleState.active || isIdleAnimationExcludedRoom(state.roomName))
-		return false;
-
-	Entity *idleEntity = ensurePlayerIdleAnimationEntity(engine, state, playerState, idleState);
-	if (!idleEntity)
-		return false;
-
-	const int lastFrame = idleEntity->getLastFrame();
-	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
-	positionPlayerIdleAnimationEntity(state, playerState, *idleEntity);
-	idleState.restoreFacing = playerState.facing;
-	idleState.active = true;
-	idleState.loopStarted = false;
-	idleState.exiting = false;
-	idleEntity->setVisible(true);
-	idleEntity->setAnimationFrameRange(0, loopLastFrame, false);
-	idleEntity->setCurrentFrame(0);
-	idleEntity->setAnimationRate(kRoomPlayerIdleAnimationRate);
-	playerState.hasMoveTarget = false;
-	playerState.evasiveState = -1;
-	playerState.evasiveXStep = 0;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	playerState.entity->setVisible(false);
-	debugC(1, kDebugPlayer,
-		"Harvester: player idle animation start room='%s' facing=%d frames=%d..%d rate=%d trigger_tick=%u",
-		state.roomName.c_str(), idleState.restoreFacing, 0, loopLastFrame,
-		kRoomPlayerIdleAnimationRate, idleState.triggerTick);
-	return true;
-}
-
-bool Player::updateIdleAnimation(const RoomSetupState &state) {
-	RoomPlayerState &playerState = _state;
-	RoomIdleAnimationState &idleState = _idleState;
-	if (!idleState.active || !idleState.entity)
-		return false;
-
-	const int lastFrame = idleState.entity->getLastFrame();
-	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
-	if (!idleState.exiting && !idleState.loopStarted &&
-			idleState.entity->getCurrentFrame() >= loopLastFrame) {
-		const int loopStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopStartFrame);
-		idleState.loopStarted = true;
-		if (loopStartFrame < loopLastFrame) {
-			idleState.entity->setAnimationFrameRange(loopStartFrame, loopLastFrame, true);
-			idleState.entity->setCurrentFrame(loopStartFrame);
-			idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
-		}
-		debugC(1, kDebugPlayer,
-			"Harvester: player idle animation loop room='%s' frames=%d..%d rate=%d",
-			state.roomName.c_str(), loopStartFrame, loopLastFrame, kRoomPlayerIdleAnimationRate);
-		return true;
-	}
-
-	if (!idleState.exiting)
-		return false;
-
-	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
-	if (idleState.entity->getCurrentFrame() < exitLastFrame)
-		return false;
-
-	return finishPlayerIdleAnimation(state, playerState, idleState);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index 355095ca416..ef0dcd81b1d 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -173,6 +173,8 @@ public:
 	bool updateIdleAnimation(const RoomSetupState &state);
 
 private:
+	bool finishIdleAnimation(const RoomSetupState &state);
+
 	bool stopMoveTargetStep(const PlayerMovement::MovementContext &context,
 		const PlayerMovement::MovementPosition &candidate, int horizontalDirection, int depthDirection,
 		const Entity *blocker);
diff --git a/engines/harvester/player_animation.cpp b/engines/harvester/player_animation.cpp
index d72323859fe..f6afcca8e94 100644
--- a/engines/harvester/player_animation.cpp
+++ b/engines/harvester/player_animation.cpp
@@ -88,6 +88,33 @@ static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, Player
 
 } // End of anonymous namespace
 
+bool Player::setIdleAnimation(int facing) {
+	RoomPlayerState &playerState = _state;
+	if (!playerState.entity)
+		return false;
+
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
+	const bool changed = playerState.facing != facing ||
+		playerState.entity->getCurrentFrame() != range.idleFrame ||
+		playerState.entity->getAnimationRate() != 0;
+	playerState.facing = facing;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
+	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);
+	if (changed) {
+		debugC(1, kDebugPlayer,
+			"Harvester: player idle animation facing=%d frame=%d",
+			facing, range.idleFrame);
+	}
+	return changed;
+}
+
 bool Player::updateAnimationRate(bool speedUp) {
 	RoomPlayerState &playerState = _state;
 	const int rate = speedUp ? kRoomPlayerFastAnimationRate : kRoomPlayerWalkAnimationRate;
diff --git a/engines/harvester/player_idle.cpp b/engines/harvester/player_idle.cpp
new file mode 100644
index 00000000000..c2e2e63e77f
--- /dev/null
+++ b/engines/harvester/player_idle.cpp
@@ -0,0 +1,258 @@
+/* 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/player.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
+static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM/PCLOUN02.ABM";
+static const uint32 kRoomPlayerIdleDelayTicks = 3000;
+static const int kRoomPlayerIdleAnimationRate = 14;
+static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
+static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
+static const int kRoomPlayerIdleExitLastFrame = 0xbf;
+static const int kRoomPlayerIdleYOffset = 4;
+
+static void updatePlayerIdleTrigger(RoomIdleAnimationState &idleState) {
+	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
+}
+
+static void notePlayerIdleReset(RoomIdleAnimationState &idleState) {
+	idleState.resetTick = Player::getRuntimeClockTicks();
+	updatePlayerIdleTrigger(idleState);
+}
+
+static void positionPlayerIdleAnimationEntity(const RoomSetupState &state,
+		const RoomPlayerState &playerState, Entity &idleEntity) {
+	if (!playerState.entity)
+		return;
+
+	idleEntity.setAnchorMode(kEntityAnchorTopLeft);
+	idleEntity.setDepthScale(Player::computeDepthScale(state, playerState.z));
+	idleEntity.setPosition(playerState.entity->getX(),
+		playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
+}
+
+static Entity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
+		const RoomSetupState &state, const RoomPlayerState &playerState,
+		RoomIdleAnimationState &idleState) {
+	if (idleState.entity || !playerState.entity)
+		return idleState.entity;
+
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!entityManager)
+		return nullptr;
+
+	idleState.entity = entityManager->spawnSceneAnimationEntity(
+		kPlayerIdleAnimationEntityName, kPlayerIdleAnimationResourcePath,
+		Common::Point(playerState.entity->getX(),
+			playerState.entity->getY() + kRoomPlayerIdleYOffset),
+		playerState.z, 0, false, false, false, false, false);
+	if (!idleState.entity)
+		return nullptr;
+
+	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	idleState.entity->setVisible(false);
+	debugC(1, kDebugPlayer,
+		"Harvester: spawned player idle animation '%s' room='%s' frames=0..%d",
+		kPlayerIdleAnimationResourcePath, state.roomName.c_str(), idleState.entity->getLastFrame());
+	return idleState.entity;
+}
+
+} // End of anonymous namespace
+
+bool Player::finishIdleAnimation(const RoomSetupState &state) {
+	RoomPlayerState &playerState = _state;
+	RoomIdleAnimationState &idleState = _idleState;
+	if (!idleState.active)
+		return false;
+
+	if (idleState.entity) {
+		idleState.entity->setVisible(false);
+		idleState.entity->setAnimationRate(0);
+		idleState.entity->setCurrentFrame(0);
+		positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	}
+	if (playerState.entity) {
+		playerState.entity->setVisible(true);
+		if (idleState.restoreFacing >= 0)
+			(void)setIdleAnimation(idleState.restoreFacing);
+	}
+
+	idleState.active = false;
+	idleState.loopStarted = false;
+	idleState.exiting = false;
+	idleState.restoreFacing = -1;
+	notePlayerIdleReset(idleState);
+	debugC(1, kDebugPlayer,
+		"Harvester: player idle animation finished room='%s' facing=%d",
+		state.roomName.c_str(), playerState.facing);
+	return true;
+}
+
+void Player::resetIdleState() {
+	RoomIdleAnimationState &idleState = _idleState;
+	idleState = RoomIdleAnimationState();
+	idleState.activityTick = Player::getRuntimeClockTicks();
+	idleState.resetTick = idleState.activityTick;
+	updatePlayerIdleTrigger(idleState);
+}
+
+void Player::noteActivity() {
+	RoomIdleAnimationState &idleState = _idleState;
+	idleState.activityTick = Player::getRuntimeClockTicks();
+	updatePlayerIdleTrigger(idleState);
+}
+
+void Player::cancelIdleAnimation() {
+	RoomPlayerState &playerState = _state;
+	RoomIdleAnimationState &idleState = _idleState;
+	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);
+}
+
+bool Player::isIdleAnimationExcludedRoom(const Common::String &roomName) {
+	static const char *const kExcludedRooms[] = {
+		"JAWS",
+		"SUPPLY1",
+		"SUPPLY2",
+		"SUPPLY3",
+		"LAVAPIT",
+		"RMNBATH",
+		"SERGENTRM",
+		"CHAP2"
+	};
+
+	for (const char *excludedRoom : kExcludedRooms) {
+		if (roomName.equalsIgnoreCase(excludedRoom))
+			return true;
+	}
+
+	return false;
+}
+
+bool Player::requestIdleAnimationExit(const RoomSetupState &state) {
+	RoomPlayerState &playerState = _state;
+	RoomIdleAnimationState &idleState = _idleState;
+	if (!idleState.active || idleState.exiting || !idleState.entity)
+		return false;
+
+	const int lastFrame = idleState.entity->getLastFrame();
+	const int exitStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
+	if (exitLastFrame <= exitStartFrame)
+		return finishIdleAnimation(state);
+
+	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	idleState.exiting = true;
+	idleState.entity->setAnimationFrameRange(exitStartFrame, exitLastFrame, false);
+	idleState.entity->setCurrentFrame(exitStartFrame);
+	idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+	debugC(1, kDebugPlayer,
+		"Harvester: player idle animation exit room='%s' frames=%d..%d rate=%d",
+		state.roomName.c_str(), exitStartFrame, exitLastFrame, kRoomPlayerIdleAnimationRate);
+	return true;
+}
+
+bool Player::startIdleAnimation(HarvesterEngine &engine, const RoomSetupState &state) {
+	RoomPlayerState &playerState = _state;
+	RoomIdleAnimationState &idleState = _idleState;
+	if (!playerState.entity || idleState.active || isIdleAnimationExcludedRoom(state.roomName))
+		return false;
+
+	Entity *idleEntity = ensurePlayerIdleAnimationEntity(engine, state, playerState, idleState);
+	if (!idleEntity)
+		return false;
+
+	const int lastFrame = idleEntity->getLastFrame();
+	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	positionPlayerIdleAnimationEntity(state, playerState, *idleEntity);
+	idleState.restoreFacing = playerState.facing;
+	idleState.active = true;
+	idleState.loopStarted = false;
+	idleState.exiting = false;
+	idleEntity->setVisible(true);
+	idleEntity->setAnimationFrameRange(0, loopLastFrame, false);
+	idleEntity->setCurrentFrame(0);
+	idleEntity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+	playerState.hasMoveTarget = false;
+	playerState.evasiveState = -1;
+	playerState.evasiveXStep = 0;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.entity->setVisible(false);
+	debugC(1, kDebugPlayer,
+		"Harvester: player idle animation start room='%s' facing=%d frames=%d..%d rate=%d trigger_tick=%u",
+		state.roomName.c_str(), idleState.restoreFacing, 0, loopLastFrame,
+		kRoomPlayerIdleAnimationRate, idleState.triggerTick);
+	return true;
+}
+
+bool Player::updateIdleAnimation(const RoomSetupState &state) {
+	RoomIdleAnimationState &idleState = _idleState;
+	if (!idleState.active || !idleState.entity)
+		return false;
+
+	const int lastFrame = idleState.entity->getLastFrame();
+	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	if (!idleState.exiting && !idleState.loopStarted &&
+			idleState.entity->getCurrentFrame() >= loopLastFrame) {
+		const int loopStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopStartFrame);
+		idleState.loopStarted = true;
+		if (loopStartFrame < loopLastFrame) {
+			idleState.entity->setAnimationFrameRange(loopStartFrame, loopLastFrame, true);
+			idleState.entity->setCurrentFrame(loopStartFrame);
+			idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+		}
+		debugC(1, kDebugPlayer,
+			"Harvester: player idle animation loop room='%s' frames=%d..%d rate=%d",
+			state.roomName.c_str(), loopStartFrame, loopLastFrame, kRoomPlayerIdleAnimationRate);
+		return true;
+	}
+
+	if (!idleState.exiting)
+		return false;
+
+	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
+	if (idleState.entity->getCurrentFrame() < exitLastFrame)
+		return false;
+
+	return finishIdleAnimation(state);
+}
+
+} // End of namespace Harvester


Commit: b8478f45224c349ce0572ec4d5aab426d25dead9
    https://github.com/scummvm/scummvm/commit/b8478f45224c349ce0572ec4d5aab426d25dead9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate flow startup diagnostics

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/flow_debug.cpp
    engines/harvester/flow.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 26c6cfe1994..3487e98b744 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -22,7 +22,6 @@
 #include "harvester/flow.h"
 
 #include "common/algorithm.h"
-#include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/events.h"
 #include "common/formats/ini-file.h"
@@ -75,7 +74,6 @@ static const char *const kTownMapBitmapPaths[] = {
 static const int kCursorSequence7 = 7;
 static const int kNativeIdentTextLineSpacing = 3;
 static const char *const kPlayerActorEntityName = "PLAYER";
-// ramp_palette_brightness (0x23a30) waits four DOS centiseconds per step.
 static const uint32 kPaletteFadeIntervalTicks = 4;
 static const float kPaletteBrightnessBlack = 0.0f;
 static const float kPaletteBrightnessFull = 1.0f;
@@ -92,14 +90,13 @@ static const char *const kDemoIntroductionText =
 	"the Order of the Harvest Moon, a bizarre institution of secretive knowledge.  "
 	"However, the only thing you really care about is escaping from this hellish reality...";
 static const float kTownMapNightPaletteBrightness = 0.6f;
-// Native keeps WAIT_BM visible while room construction runs; fast local loads need a short floor.
+// Keep WAIT_BM visible while room construction runs
 static const uint32 kTransitionWaitFrameMinMs = 120;
 static const int kTownMapEdgeThreshold = 9;
 static const int kTownMapCursorHitExtent = 5;
 
 static const byte kTownMapLabelColor = 0x28;
 
-static const int kCursorSequenceWalk = 0;
 } // End of anonymous namespace
 
 static int clampTownMapPanelIndex(int panelIndex) {
@@ -234,11 +231,6 @@ static bool applyStartupActorPlacement(const RoomSetupState &state, Entity &enti
 		state.playerSpawnX, state.playerSpawnY, (float)state.playerSpawnZ, &state.entranceName);
 }
 
-void logScenePaletteSummary(const char *label, const SceneState &scene, float brightness) {
-	debugC(2, kDebugRoom, "Harvester: %s room='%s' palette='%s' brightness=%.2f",
-		label, scene.state.roomName.c_str(), scene.state.palettePath.c_str(), (double)brightness);
-}
-
 bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap) {
 	if (screen.w <= 0 || screen.h <= 0 || screen.format.bytesPerPixel != 1)
 		return false;
@@ -283,11 +275,6 @@ bool loadBitmapResource(ResourceManager &resources, const Common::String &path,
 	return decodeBitmap(data, bitmap);
 }
 
-bool shouldRunStartupRoomProbe() {
-	return ConfMan.hasKey("harvester_debug_probe_startup_room") &&
-		ConfMan.getBool("harvester_debug_probe_startup_room");
-}
-
 bool doesPlayerFacingMatchRegion(int playerFacing, const RegionRecord &region) {
 	return region.desiredFacing < 0 || playerFacing == region.desiredFacing;
 }
@@ -297,10 +284,6 @@ bool doesPlayerOverlapRegion(const Entity &playerEntity, const RegionRecord &reg
 	if (regionBounds.isEmpty())
 		return false;
 
-	// Native do_entity_screen_bounds_overlap (0x4b700) compares each entity's
-	// origin plus width/height and rejects only when one edge is strictly before
-	// the other. Common::Rect uses exclusive right/bottom edges, so intersects()
-	// would incorrectly reject the edge contact that activates narrow exits.
 	const Common::Rect playerBounds = playerEntity.getScreenRect();
 	if (playerBounds.right < regionBounds.left ||
 			regionBounds.right < playerBounds.left ||
@@ -318,153 +301,6 @@ RoomHoverState Flow::getRoomHoverState(const SceneState &scene) const {
 		scene.state.roomNpcs, scene.sceneRegions, _mousePos, _menuTextConfig, &_dialogue);
 }
 
-static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
-		const ObjectRecord &object, Common::Point &probePoint) {
-	const Common::Rect bounds = SceneEntities::getSceneObjectBounds(object);
-	if (bounds.isEmpty())
-		return false;
-
-	const Common::Point center((bounds.left + bounds.right - 1) / 2, (bounds.top + bounds.bottom - 1) / 2);
-	const Common::Point corners[] = {
-		center,
-		Common::Point(bounds.left, bounds.top),
-		Common::Point(bounds.right - 1, bounds.top),
-		Common::Point(bounds.left, bounds.bottom - 1),
-		Common::Point(bounds.right - 1, bounds.bottom - 1)
-	};
-	for (const Common::Point &candidate : corners) {
-		const ObjectRecord *hit = Input::findRoomObjectAtPoint(engine, sceneObjects, candidate);
-		if (hit && hit->objectName.equalsIgnoreCase(object.objectName)) {
-			probePoint = candidate;
-			return true;
-		}
-	}
-
-	const int xStep = MAX<int>(1, bounds.width() / 6);
-	const int yStep = MAX<int>(1, bounds.height() / 6);
-	for (int y = bounds.top; y < bounds.bottom; y += yStep) {
-		for (int x = bounds.left; x < bounds.right; x += xStep) {
-			const Common::Point candidate(x, y);
-			const ObjectRecord *hit = Input::findRoomObjectAtPoint(engine, sceneObjects, candidate);
-			if (hit && hit->objectName.equalsIgnoreCase(object.objectName)) {
-				probePoint = candidate;
-				return true;
-			}
-		}
-	}
-
-	return false;
-}
-
-void Flow::logStartupRoomProbe(const SceneState &scene, const Common::String &entranceName) {
-	EntityManager *entityManager = _engine.getRuntimeEntities();
-	Script *script = _engine.getScript();
-	if (!entityManager || !script)
-		return;
-
-	if (const Entity *cursor = entityManager->getCursorEntity()) {
-		uint32 framePixels = 0;
-		uint32 transparentPixels = 0;
-		uint32 preservedPixels = 0;
-		if (cursor->measureCurrentFrameTransparency(framePixels, transparentPixels, preservedPixels)) {
-			debugC(1, kDebugCursor,
-				"Harvester: startup probe cursor sequence=%d frame=%d pixels=%u transparent_pixels=%u preserved_pixels=%u",
-				cursor->getAnimationSequence(), cursor->getCurrentFrame(), framePixels, transparentPixels, preservedPixels);
-		}
-	}
-
-	if (const Entity *player = entityManager->findSceneEntityByName(kPlayerActorEntityName)) {
-		uint32 framePixels = 0;
-		uint32 transparentPixels = 0;
-		uint32 preservedPixels = 0;
-		(void)player->measureCurrentFrameTransparency(framePixels, transparentPixels, preservedPixels);
-		const Common::Rect playerRect = player->getScreenRect();
-		const Common::Rect screenRect(0, 0, 640, 480);
-		debugC(1, kDebugPlayer,
-			"Harvester: startup probe player room='%s' entrance='%s' frame=%d rect=(%d,%d)-(%d,%d) visible=%d intersects_screen=%d opaque_pixels=%u",
-			scene.state.roomName.c_str(), entranceName.c_str(), player->getCurrentFrame(),
-			playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
-			player->isVisible(), playerRect.intersects(screenRect), framePixels - transparentPixels);
-	}
-
-	for (const ObjectRecord &object : scene.sceneObjects) {
-		if (SceneEntities::getSceneObjectBounds(object).isEmpty())
-			continue;
-
-		Common::Point probePoint;
-		if (!findRoomObjectProbePoint(_engine, scene.sceneObjects, object, probePoint))
-			continue;
-
-		const ObjectRecord *hoveredObject = Input::findRoomObjectAtPoint(_engine, scene.sceneObjects, probePoint);
-		if (!hoveredObject)
-			continue;
-
-		_mousePos = probePoint;
-		const Common::String objectLabel = script->resolveObjectLabel(*hoveredObject);
-		ResolvedText inspectText;
-		const RoomHoverState hoverState = Input::resolveRoomHoverState(
-			_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
-			probePoint, _menuTextConfig);
-		const bool hasInteraction = script->hasObjectInteraction(*hoveredObject);
-		const bool hasInspectText = script->resolveObjectInspectText(*hoveredObject, inspectText);
-		debugC(1, kDebugRoom,
-			"Harvester: startup probe hotspot room='%s' object='%s' point=(%d,%d) label='%s' prompt='%s' cursor_sequence=%d action_tag='%s' interaction=%d next_room='%s' inspect=%d",
-			scene.state.roomName.c_str(), hoveredObject->objectName.c_str(), probePoint.x, probePoint.y,
-			objectLabel.c_str(), hoverState.promptText.c_str(), hoverState.cursorSequence,
-			hoveredObject->actionTag.c_str(), hasInteraction,
-			"", hasInspectText);
-
-		if (Player::supportsMovementBand(scene.state)) {
-			Common::Point floorProbe;
-			bool foundFloorProbe = false;
-			for (int y = scene.state.roomMaxZScreenY; y <= scene.state.roomMinZScreenY && !foundFloorProbe; y += 12) {
-				for (int x = 48; x < 592; x += 16) {
-					const RoomHoverState candidateHover = Input::resolveRoomHoverState(
-						_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
-						Common::Point(x, y), _menuTextConfig);
-					if (candidateHover.cursorSequence == kCursorSequenceWalk) {
-						floorProbe = Common::Point(x, y);
-						foundFloorProbe = true;
-						break;
-					}
-				}
-			}
-
-			const RoomHoverState floorHover = foundFloorProbe
-				? Input::resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
-					scene.sceneRegions, floorProbe, _menuTextConfig)
-				: RoomHoverState();
-			debugC(1, kDebugRoom,
-				"Harvester: startup probe floor room='%s' point=(%d,%d) found=%d cursor_sequence=%d prompt='%s'",
-				scene.state.roomName.c_str(), floorProbe.x, floorProbe.y, foundFloorProbe,
-				floorHover.cursorSequence, floorHover.promptText.c_str());
-
-			Entity *player = entityManager->findSceneEntityByName(kPlayerActorEntityName);
-			if (player && foundFloorProbe && floorHover.cursorSequence == kCursorSequenceWalk) {
-				Player probePlayer;
-				probePlayer.initialize(scene.state, player, 0);
-				probePlayer.setMoveTargetFromScreenPoint(scene.state, floorProbe.x, floorProbe.y);
-				for (int i = 0; i < 32 && probePlayer.getState().hasMoveTarget; ++i)
-					(void)probePlayer.stepMoveTarget(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations);
-
-				const Common::Rect movedRect = player->getScreenRect();
-				debugC(1, kDebugPlayer,
-					"Harvester: startup probe movement room='%s' target=(%d,%d) final_rect=(%d,%d)-(%d,%d) z=%.2f",
-					scene.state.roomName.c_str(), floorProbe.x, floorProbe.y,
-					movedRect.left, movedRect.top, movedRect.right, movedRect.bottom, (double)probePlayer.getState().z);
-
-				player->setCurrentFrame(Player::resolveFacingFrame(scene.state.playerFacing));
-				(void)applyRoomActorPlacement(scene.state, *player,
-					scene.state.playerSpawnX, scene.state.playerSpawnY, (float)scene.state.playerSpawnZ);
-			}
-		}
-		return;
-	}
-
-	debugC(1, kDebugRoom, "Harvester: startup probe found no interactive hotspot in room '%s'",
-		scene.state.roomName.c_str());
-}
-
 static Common::String trimAsciiLine(const Common::String &value) {
 	uint start = 0;
 	uint end = value.size();
diff --git a/engines/harvester/flow_debug.cpp b/engines/harvester/flow_debug.cpp
new file mode 100644
index 00000000000..82f382fd813
--- /dev/null
+++ b/engines/harvester/flow_debug.cpp
@@ -0,0 +1,210 @@
+/* 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/flow.h"
+
+#include "common/config-manager.h"
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/input.h"
+#include "harvester/player.h"
+#include "harvester/scene_entities.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kPlayerActorEntityName = "PLAYER";
+static const int kCursorSequenceWalk = 0;
+
+static void logStartupRoomEntities(EntityManager &entityManager, const SceneState &scene,
+		const Common::String &entranceName) {
+	if (const Entity *cursor = entityManager.getCursorEntity()) {
+		uint32 framePixels = 0;
+		uint32 transparentPixels = 0;
+		uint32 preservedPixels = 0;
+		if (cursor->measureCurrentFrameTransparency(framePixels, transparentPixels, preservedPixels)) {
+			debugC(1, kDebugCursor,
+				"Harvester: startup probe cursor sequence=%d frame=%d pixels=%u transparent_pixels=%u preserved_pixels=%u",
+				cursor->getAnimationSequence(), cursor->getCurrentFrame(), framePixels, transparentPixels, preservedPixels);
+		}
+	}
+
+	if (const Entity *player = entityManager.findSceneEntityByName(kPlayerActorEntityName)) {
+		uint32 framePixels = 0;
+		uint32 transparentPixels = 0;
+		uint32 preservedPixels = 0;
+		(void)player->measureCurrentFrameTransparency(framePixels, transparentPixels, preservedPixels);
+		const Common::Rect playerRect = player->getScreenRect();
+		const Common::Rect screenRect(0, 0, 640, 480);
+		debugC(1, kDebugPlayer,
+			"Harvester: startup probe player room='%s' entrance='%s' frame=%d rect=(%d,%d)-(%d,%d) visible=%d intersects_screen=%d opaque_pixels=%u",
+			scene.state.roomName.c_str(), entranceName.c_str(), player->getCurrentFrame(),
+			playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
+			player->isVisible(), playerRect.intersects(screenRect), framePixels - transparentPixels);
+	}
+}
+
+static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
+		const ObjectRecord &object, Common::Point &probePoint) {
+	const Common::Rect bounds = SceneEntities::getSceneObjectBounds(object);
+	if (bounds.isEmpty())
+		return false;
+
+	const Common::Point center((bounds.left + bounds.right - 1) / 2, (bounds.top + bounds.bottom - 1) / 2);
+	const Common::Point corners[] = {
+		center,
+		Common::Point(bounds.left, bounds.top),
+		Common::Point(bounds.right - 1, bounds.top),
+		Common::Point(bounds.left, bounds.bottom - 1),
+		Common::Point(bounds.right - 1, bounds.bottom - 1)
+	};
+	for (const Common::Point &candidate : corners) {
+		const ObjectRecord *hit = Input::findRoomObjectAtPoint(engine, sceneObjects, candidate);
+		if (hit && hit->objectName.equalsIgnoreCase(object.objectName)) {
+			probePoint = candidate;
+			return true;
+		}
+	}
+
+	const int xStep = MAX<int>(1, bounds.width() / 6);
+	const int yStep = MAX<int>(1, bounds.height() / 6);
+	for (int y = bounds.top; y < bounds.bottom; y += yStep) {
+		for (int x = bounds.left; x < bounds.right; x += xStep) {
+			const Common::Point candidate(x, y);
+			const ObjectRecord *hit = Input::findRoomObjectAtPoint(engine, sceneObjects, candidate);
+			if (hit && hit->objectName.equalsIgnoreCase(object.objectName)) {
+				probePoint = candidate;
+				return true;
+			}
+		}
+	}
+
+	return false;
+}
+
+static void logStartupRoomMovement(HarvesterEngine &engine, const SceneState &scene,
+		const MenuTextConfig &menuTextConfig, EntityManager &entityManager) {
+	if (Player::supportsMovementBand(scene.state)) {
+		Common::Point floorProbe;
+		bool foundFloorProbe = false;
+		for (int y = scene.state.roomMaxZScreenY; y <= scene.state.roomMinZScreenY && !foundFloorProbe; y += 12) {
+			for (int x = 48; x < 592; x += 16) {
+				const RoomHoverState candidateHover = Input::resolveRoomHoverState(
+					engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
+					Common::Point(x, y), menuTextConfig);
+				if (candidateHover.cursorSequence == kCursorSequenceWalk) {
+					floorProbe = Common::Point(x, y);
+					foundFloorProbe = true;
+					break;
+				}
+			}
+		}
+
+		const RoomHoverState floorHover = foundFloorProbe
+			? Input::resolveRoomHoverState(engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
+				scene.sceneRegions, floorProbe, menuTextConfig)
+			: RoomHoverState();
+		debugC(1, kDebugRoom,
+			"Harvester: startup probe floor room='%s' point=(%d,%d) found=%d cursor_sequence=%d prompt='%s'",
+			scene.state.roomName.c_str(), floorProbe.x, floorProbe.y, foundFloorProbe,
+			floorHover.cursorSequence, floorHover.promptText.c_str());
+
+		Entity *player = entityManager.findSceneEntityByName(kPlayerActorEntityName);
+		if (player && foundFloorProbe && floorHover.cursorSequence == kCursorSequenceWalk) {
+			Player probePlayer;
+			probePlayer.initialize(scene.state, player, 0);
+			probePlayer.setMoveTargetFromScreenPoint(scene.state, floorProbe.x, floorProbe.y);
+			for (int i = 0; i < 32 && probePlayer.getState().hasMoveTarget; ++i)
+				(void)probePlayer.stepMoveTarget(engine, scene.state, scene.sceneObjects, scene.sceneAnimations);
+
+			const Common::Rect movedRect = player->getScreenRect();
+			debugC(1, kDebugPlayer,
+				"Harvester: startup probe movement room='%s' target=(%d,%d) final_rect=(%d,%d)-(%d,%d) z=%.2f",
+				scene.state.roomName.c_str(), floorProbe.x, floorProbe.y,
+				movedRect.left, movedRect.top, movedRect.right, movedRect.bottom, (double)probePlayer.getState().z);
+
+			player->setCurrentFrame(Player::resolveFacingFrame(scene.state.playerFacing));
+			(void)applyRoomActorPlacement(scene.state, *player,
+				scene.state.playerSpawnX, scene.state.playerSpawnY, (float)scene.state.playerSpawnZ);
+		}
+	}
+}
+
+} // End of anonymous namespace
+
+void logScenePaletteSummary(const char *label, const SceneState &scene, float brightness) {
+	debugC(2, kDebugRoom, "Harvester: %s room='%s' palette='%s' brightness=%.2f",
+		label, scene.state.roomName.c_str(), scene.state.palettePath.c_str(), (double)brightness);
+}
+
+bool shouldRunStartupRoomProbe() {
+	return ConfMan.hasKey("harvester_debug_probe_startup_room") &&
+		ConfMan.getBool("harvester_debug_probe_startup_room");
+}
+
+void Flow::logStartupRoomProbe(const SceneState &scene, const Common::String &entranceName) {
+	EntityManager *entityManager = _engine.getRuntimeEntities();
+	Script *script = _engine.getScript();
+	if (!entityManager || !script)
+		return;
+
+	logStartupRoomEntities(*entityManager, scene, entranceName);
+
+	for (const ObjectRecord &object : scene.sceneObjects) {
+		if (SceneEntities::getSceneObjectBounds(object).isEmpty())
+			continue;
+
+		Common::Point probePoint;
+		if (!findRoomObjectProbePoint(_engine, scene.sceneObjects, object, probePoint))
+			continue;
+
+		const ObjectRecord *hoveredObject = Input::findRoomObjectAtPoint(_engine, scene.sceneObjects, probePoint);
+		if (!hoveredObject)
+			continue;
+
+		_mousePos = probePoint;
+		const Common::String objectLabel = script->resolveObjectLabel(*hoveredObject);
+		ResolvedText inspectText;
+		const RoomHoverState hoverState = Input::resolveRoomHoverState(
+			_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
+			probePoint, _menuTextConfig);
+		const bool hasInteraction = script->hasObjectInteraction(*hoveredObject);
+		const bool hasInspectText = script->resolveObjectInspectText(*hoveredObject, inspectText);
+		debugC(1, kDebugRoom,
+			"Harvester: startup probe hotspot room='%s' object='%s' point=(%d,%d) label='%s' prompt='%s' cursor_sequence=%d action_tag='%s' interaction=%d next_room='%s' inspect=%d",
+			scene.state.roomName.c_str(), hoveredObject->objectName.c_str(), probePoint.x, probePoint.y,
+			objectLabel.c_str(), hoverState.promptText.c_str(), hoverState.cursorSequence,
+			hoveredObject->actionTag.c_str(), hasInteraction,
+			"", hasInspectText);
+
+		logStartupRoomMovement(_engine, scene, _menuTextConfig, *entityManager);
+		return;
+	}
+
+	debugC(1, kDebugRoom, "Harvester: startup probe found no interactive hotspot in room '%s'",
+		scene.state.roomName.c_str());
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 55da1d403ab..eee964e72e2 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -8,6 +8,7 @@ MODULE_OBJS = \
 	console.o \
 	dialogue.o \
 	flow.o \
+	flow_debug.o \
 	fst_player.o \
 	harvester.o \
 	inventory.o \


Commit: bef83f3ebf9622e98e59db1350fec49632bd105d
    https://github.com/scummvm/scummvm/commit/bef83f3ebf9622e98e59db1350fec49632bd105d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate town map selection and event handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/flow_map.cpp
  A engines/harvester/flow_support.h
    engines/harvester/flow.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 3487e98b744..2e666ecce88 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "harvester/flow.h"
+#include "harvester/flow_support.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
@@ -62,14 +63,6 @@ static const char *const kBuiltInMenuItems[] = {
 	"HELP",
 	"QUIT GAME"
 };
-static const char *const kTownMapPalettePath = "1:/GRAPHIC/PAL/HARVMAP.PAL";
-static const char *const kTownMapMusicPath = "SOUND/MUSIC/MENACE.CMP";
-static const char *const kTownMapBitmapPaths[] = {
-	"1:/GRAPHIC/TOWN/HARVMAP1.BM",
-	"1:/GRAPHIC/TOWN/HARVMAP2.BM",
-	"1:/GRAPHIC/TOWN/HARVMAP3.BM",
-	"1:/GRAPHIC/TOWN/HARVMAP4.BM"
-};
 
 static const int kCursorSequence7 = 7;
 static const int kNativeIdentTextLineSpacing = 3;
@@ -89,101 +82,11 @@ static const char *const kDemoIntroductionText =
 	"After solving the enigmas of the Harvest town, you are thrust into the realm of "
 	"the Order of the Harvest Moon, a bizarre institution of secretive knowledge.  "
 	"However, the only thing you really care about is escaping from this hellish reality...";
-static const float kTownMapNightPaletteBrightness = 0.6f;
 // Keep WAIT_BM visible while room construction runs
 static const uint32 kTransitionWaitFrameMinMs = 120;
-static const int kTownMapEdgeThreshold = 9;
-static const int kTownMapCursorHitExtent = 5;
-
-static const byte kTownMapLabelColor = 0x28;
 
 } // End of anonymous namespace
 
-static int clampTownMapPanelIndex(int panelIndex) {
-	return CLIP<int>(panelIndex, 0, ARRAYSIZE(kTownMapBitmapPaths) - 1);
-}
-
-static int resolveTownMapEdgePanel(int currentPanel, const Common::Point &mousePos, int width, int height) {
-	if (mousePos.x <= kTownMapEdgeThreshold) {
-		if (currentPanel == 1)
-			return 0;
-		if (currentPanel == 3)
-			return 2;
-	} else if (mousePos.x >= width - 1 - kTownMapEdgeThreshold) {
-		if (currentPanel == 0)
-			return 1;
-		if (currentPanel == 2)
-			return 3;
-	} else if (mousePos.y <= kTownMapEdgeThreshold) {
-		if (currentPanel == 2)
-			return 0;
-		if (currentPanel == 3)
-			return 1;
-	} else if (mousePos.y >= height - 1 - kTownMapEdgeThreshold) {
-		if (currentPanel == 0)
-			return 2;
-		if (currentPanel == 1)
-			return 3;
-	}
-
-	return currentPanel;
-}
-
-static int resolveTownMapKeyPanel(int currentPanel, Common::KeyCode keycode) {
-	switch (keycode) {
-	case Common::KEYCODE_LEFT:
-		if (currentPanel == 1)
-			return 0;
-		if (currentPanel == 3)
-			return 2;
-		break;
-	case Common::KEYCODE_RIGHT:
-		if (currentPanel == 0)
-			return 1;
-		if (currentPanel == 2)
-			return 3;
-		break;
-	case Common::KEYCODE_UP:
-		if (currentPanel == 2)
-			return 0;
-		if (currentPanel == 3)
-			return 1;
-		break;
-	case Common::KEYCODE_DOWN:
-		if (currentPanel == 0)
-			return 2;
-		if (currentPanel == 1)
-			return 3;
-		break;
-	default:
-		break;
-	}
-
-	return currentPanel;
-}
-
-static bool isTownMapLocationHovered(const MapLocationRecord &location, int currentPanel,
-		const Common::Point &mousePos) {
-	if (location.panelIndex != currentPanel)
-		return false;
-
-	const int cursorRight = mousePos.x + kTownMapCursorHitExtent;
-	const int cursorBottom = mousePos.y + kTownMapCursorHitExtent;
-	return mousePos.x <= location.maxX && cursorRight >= location.minX &&
-		mousePos.y <= location.maxY && cursorBottom >= location.minY;
-}
-
-static const MapLocationRecord *findTownMapLocationAt(
-		const Common::Array<MapLocationRecord> &locations, int currentPanel,
-		const Common::Point &mousePos) {
-	for (const MapLocationRecord &location : locations) {
-		if (isTownMapLocationHovered(location, currentPanel, mousePos))
-			return &location;
-	}
-
-	return nullptr;
-}
-
 static void setRoomActorScreenPosition(Entity &entity, int centerX, int bottomY, float z,
 		int width, int height, int xOffset, int yOffset) {
 	entity.setPosition(centerX - xOffset - width / 2, bottomY - height - yOffset, z);
@@ -244,7 +147,7 @@ bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap
 	return true;
 }
 
-static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
+void setScaledPalette(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);
@@ -780,159 +683,6 @@ Common::Error Flow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte
 		executionContext, npc, usedItemName, *this);
 }
 
-Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
-		Common::String &destinationEntranceName) {
-	destinationEntranceName.clear();
-
-	Script *script = _engine.getScript();
-	ResourceManager *resources = _engine.getResources();
-	Graphics::Screen *screen = _engine.getScreen();
-	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	Common::ScopedPtr<HarvesterCftFont> townMapFont;
-	const Text *text = _engine.getText();
-	if (const CftFontResource *townMapFontResource = text ? text->findFont("TEXTFONT") : nullptr) {
-		townMapFont.reset(new HarvesterCftFont(*townMapFontResource));
-		if (townMapFont->isValid())
-			font = townMapFont.get();
-		else
-			townMapFont.reset();
-	}
-	if (!script || !resources || !screen || !font)
-		return Common::kReadingFailed;
-
-	const MapEntranceRecord *mapEntrance = script->findMapEntranceRecord(mapEntryName);
-	if (!mapEntrance) {
-		warning("Harvester: unresolved town map entry '%s'", mapEntryName.c_str());
-		return Common::kReadingFailed;
-	}
-	const float paletteBrightness = script->getFlagValue("DAY_FLAG")
-		? 1.0f
-		: kTownMapNightPaletteBrightness;
-
-	byte palette[256 * 3];
-	if (!Art::loadPaletteResource(*resources, kTownMapPalettePath, palette))
-		return Common::kReadingFailed;
-
-	Common::Array<IndexedBitmap> panels;
-	panels.resize(ARRAYSIZE(kTownMapBitmapPaths));
-	for (uint i = 0; i < ARRAYSIZE(kTownMapBitmapPaths); ++i) {
-		if (!loadBitmapResource(*resources, kTownMapBitmapPaths[i], panels[i]))
-			return Common::kReadingFailed;
-	}
-
-	const Common::String previousMusicPath = _engine.getMusicPath();
-	(void)_engine.playMusic(kTownMapMusicPath);
-
-	if (!ensureCursorEntity())
-		return Common::kReadingFailed;
-	resetCursorAnimationSequence();
-
-	if (_mousePos.x < 0 || _mousePos.y < 0 || _mousePos.x >= screen->w || _mousePos.y >= screen->h)
-		_mousePos = Common::Point(screen->w / 2, screen->h / 2);
-	if (EntityManager *entityManager = _engine.getRuntimeEntities())
-		(void)entityManager->syncCursorEntityPosition(_mousePos);
-
-	int currentPanel = clampTownMapPanelIndex(mapEntrance->initialPanelIndex);
-	bool needsRedraw = true;
-	Graphics::FrameLimiter limiter(g_system, 60);
-
-	auto centerCursor = [&]() {
-		_mousePos = Common::Point(screen->w / 2, screen->h / 2);
-		if (EntityManager *entityManager = _engine.getRuntimeEntities())
-			(void)entityManager->syncCursorEntityPosition(_mousePos);
-	};
-	auto restorePreviousMusic = [&]() {
-		if (!destinationEntranceName.empty())
-			return;
-		if (!previousMusicPath.empty())
-			(void)_engine.playMusic(previousMusicPath);
-		else
-			_engine.stopMusic();
-	};
-
-	while (!_engine.shouldQuit()) {
-		const int edgePanel = resolveTownMapEdgePanel(currentPanel, _mousePos, screen->w, screen->h);
-		if (edgePanel != currentPanel) {
-			currentPanel = edgePanel;
-			centerCursor();
-			needsRedraw = true;
-		}
-
-		const MapLocationRecord *hoveredLocation =
-			findTownMapLocationAt(script->getMapLocations(), currentPanel, _mousePos);
-		if (needsRedraw) {
-			setScaledPalette(*screen, palette, paletteBrightness);
-			screen->fillRect(screen->getBounds(), 0);
-			RoomPresentation::blitBitmap(*screen, panels[(uint)currentPanel], 0, 0);
-			if (hoveredLocation) {
-				RoomPresentation::drawShadowedString(*screen, *font, hoveredLocation->labelText,
-					hoveredLocation->labelX, hoveredLocation->labelY, screen->w, kTownMapLabelColor);
-			}
-			if (EntityManager *entityManager = _engine.getRuntimeEntities())
-				entityManager->drawCursor(*screen);
-			screen->makeAllDirty();
-			screen->update();
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (handleSystemEvent(event, result)) {
-				restorePreviousMusic();
-				return result;
-			}
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE:
-				needsRedraw = true;
-				break;
-			case Common::EVENT_LBUTTONDOWN: {
-				const MapLocationRecord *clickedLocation =
-					findTownMapLocationAt(script->getMapLocations(), currentPanel, _mousePos);
-				if (!clickedLocation)
-					break;
-
-				const EntranceRecord *destinationEntrance =
-					script->findEntranceRecord(clickedLocation->destinationEntranceName);
-				if (!destinationEntrance) {
-					warning("Harvester: unresolved town map destination '%s' from '%s'",
-						clickedLocation->destinationEntranceName.c_str(), mapEntryName.c_str());
-					break;
-				}
-
-				destinationEntranceName = destinationEntrance->entranceName;
-				debugC(1, kDebugGeneral,
-					"Harvester: town map selection entry='%s' panel=%d label='%s' destination='%s'",
-					mapEntryName.c_str(), currentPanel, clickedLocation->labelText.c_str(),
-					destinationEntranceName.c_str());
-				return Common::kNoError;
-			}
-			case Common::EVENT_KEYDOWN: {
-				const int nextPanel = resolveTownMapKeyPanel(currentPanel, event.kbd.keycode);
-				if (nextPanel != currentPanel) {
-					currentPanel = nextPanel;
-					centerCursor();
-					needsRedraw = true;
-				}
-				break;
-			}
-			default:
-				break;
-			}
-		}
-
-		if (tickRuntimeEntities())
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	restorePreviousMusic();
-	return Common::kNoError;
-}
-
 Common::Error Flow::resolveRoomTransitionTarget(const Common::String &targetName,
 		Common::String &resolvedTargetName) {
 	resolvedTargetName = targetName;
diff --git a/engines/harvester/flow_map.cpp b/engines/harvester/flow_map.cpp
new file mode 100644
index 00000000000..fc3eaa61859
--- /dev/null
+++ b/engines/harvester/flow_map.cpp
@@ -0,0 +1,352 @@
+/* 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/flow.h"
+#include "harvester/flow_support.h"
+
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/ptr.h"
+#include "common/system.h"
+#include "common/util.h"
+#include "graphics/font.h"
+#include "graphics/fontman.h"
+#include "graphics/framelimiter.h"
+#include "harvester/art.h"
+#include "harvester/cft_font.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/room_presentation.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kTownMapPalettePath = "1:/GRAPHIC/PAL/HARVMAP.PAL";
+static const char *const kTownMapMusicPath = "SOUND/MUSIC/MENACE.CMP";
+static const char *const kTownMapBitmapPaths[] = {
+	"1:/GRAPHIC/TOWN/HARVMAP1.BM",
+	"1:/GRAPHIC/TOWN/HARVMAP2.BM",
+	"1:/GRAPHIC/TOWN/HARVMAP3.BM",
+	"1:/GRAPHIC/TOWN/HARVMAP4.BM"
+};
+static const float kTownMapNightPaletteBrightness = 0.6f;
+static const int kTownMapEdgeThreshold = 9;
+static const int kTownMapCursorHitExtent = 5;
+static const byte kTownMapLabelColor = 0x28;
+
+static int clampTownMapPanelIndex(int panelIndex) {
+	return CLIP<int>(panelIndex, 0, ARRAYSIZE(kTownMapBitmapPaths) - 1);
+}
+
+static int resolveTownMapEdgePanel(int currentPanel, const Common::Point &mousePos, int width, int height) {
+	if (mousePos.x <= kTownMapEdgeThreshold) {
+		if (currentPanel == 1)
+			return 0;
+		if (currentPanel == 3)
+			return 2;
+	} else if (mousePos.x >= width - 1 - kTownMapEdgeThreshold) {
+		if (currentPanel == 0)
+			return 1;
+		if (currentPanel == 2)
+			return 3;
+	} else if (mousePos.y <= kTownMapEdgeThreshold) {
+		if (currentPanel == 2)
+			return 0;
+		if (currentPanel == 3)
+			return 1;
+	} else if (mousePos.y >= height - 1 - kTownMapEdgeThreshold) {
+		if (currentPanel == 0)
+			return 2;
+		if (currentPanel == 1)
+			return 3;
+	}
+
+	return currentPanel;
+}
+
+static int resolveTownMapKeyPanel(int currentPanel, Common::KeyCode keycode) {
+	switch (keycode) {
+	case Common::KEYCODE_LEFT:
+		if (currentPanel == 1)
+			return 0;
+		if (currentPanel == 3)
+			return 2;
+		break;
+	case Common::KEYCODE_RIGHT:
+		if (currentPanel == 0)
+			return 1;
+		if (currentPanel == 2)
+			return 3;
+		break;
+	case Common::KEYCODE_UP:
+		if (currentPanel == 2)
+			return 0;
+		if (currentPanel == 3)
+			return 1;
+		break;
+	case Common::KEYCODE_DOWN:
+		if (currentPanel == 0)
+			return 2;
+		if (currentPanel == 1)
+			return 3;
+		break;
+	default:
+		break;
+	}
+
+	return currentPanel;
+}
+
+static bool isTownMapLocationHovered(const MapLocationRecord &location, int currentPanel,
+		const Common::Point &mousePos) {
+	if (location.panelIndex != currentPanel)
+		return false;
+
+	const int cursorRight = mousePos.x + kTownMapCursorHitExtent;
+	const int cursorBottom = mousePos.y + kTownMapCursorHitExtent;
+	return mousePos.x <= location.maxX && cursorRight >= location.minX &&
+		mousePos.y <= location.maxY && cursorBottom >= location.minY;
+}
+
+static const MapLocationRecord *findTownMapLocationAt(
+		const Common::Array<MapLocationRecord> &locations, int currentPanel,
+		const Common::Point &mousePos) {
+	for (const MapLocationRecord &location : locations) {
+		if (isTownMapLocationHovered(location, currentPanel, mousePos))
+			return &location;
+	}
+
+	return nullptr;
+}
+
+class TownMapSelector {
+public:
+	TownMapSelector(HarvesterEngine &engine, Flow &flow, Common::Point &mousePos,
+		const Common::String &mapEntryName, Common::String &destinationEntranceName)
+		: _engine(engine), _flow(flow), _mousePos(mousePos), _mapEntryName(mapEntryName),
+		  _destinationEntranceName(destinationEntranceName) {}
+
+	bool load();
+	Common::Error run();
+
+private:
+	void centerCursor();
+	void restorePreviousMusic();
+	void draw(const MapLocationRecord *hoveredLocation);
+	bool handleEvent(const Common::Event &event);
+
+	HarvesterEngine &_engine;
+	Flow &_flow;
+	Common::Point &_mousePos;
+	const Common::String &_mapEntryName;
+	Common::String &_destinationEntranceName;
+	Script *_script = nullptr;
+	Graphics::Screen *_screen = nullptr;
+	const Graphics::Font *_font = nullptr;
+	Common::ScopedPtr<HarvesterCftFont> _townMapFont;
+	const MapEntranceRecord *_mapEntrance = nullptr;
+	float _paletteBrightness = 1.0f;
+	byte _palette[256 * 3];
+	Common::Array<IndexedBitmap> _panels;
+	Common::String _previousMusicPath;
+	int _currentPanel = 0;
+	bool _needsRedraw = true;
+};
+
+bool TownMapSelector::load() {
+	_script = _engine.getScript();
+	ResourceManager *resources = _engine.getResources();
+	_screen = _engine.getScreen();
+	_font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	const Text *text = _engine.getText();
+	if (const CftFontResource *townMapFontResource = text ? text->findFont("TEXTFONT") : nullptr) {
+		_townMapFont.reset(new HarvesterCftFont(*townMapFontResource));
+		if (_townMapFont->isValid())
+			_font = _townMapFont.get();
+		else
+			_townMapFont.reset();
+	}
+	if (!_script || !resources || !_screen || !_font)
+		return false;
+
+	_mapEntrance = _script->findMapEntranceRecord(_mapEntryName);
+	if (!_mapEntrance) {
+		warning("Harvester: unresolved town map entry '%s'", _mapEntryName.c_str());
+		return false;
+	}
+	_paletteBrightness = _script->getFlagValue("DAY_FLAG")
+		? 1.0f
+		: kTownMapNightPaletteBrightness;
+
+	if (!Art::loadPaletteResource(*resources, kTownMapPalettePath, _palette))
+		return false;
+
+	_panels.resize(ARRAYSIZE(kTownMapBitmapPaths));
+	for (uint i = 0; i < ARRAYSIZE(kTownMapBitmapPaths); ++i) {
+		if (!loadBitmapResource(*resources, kTownMapBitmapPaths[i], _panels[i]))
+			return false;
+	}
+
+	_previousMusicPath = _engine.getMusicPath();
+	(void)_engine.playMusic(kTownMapMusicPath);
+
+	return true;
+}
+
+void TownMapSelector::centerCursor() {
+	_mousePos = Common::Point(_screen->w / 2, _screen->h / 2);
+	if (EntityManager *entityManager = _engine.getRuntimeEntities())
+		(void)entityManager->syncCursorEntityPosition(_mousePos);
+}
+
+void TownMapSelector::restorePreviousMusic() {
+	if (!_destinationEntranceName.empty())
+		return;
+	if (!_previousMusicPath.empty())
+		(void)_engine.playMusic(_previousMusicPath);
+	else
+		_engine.stopMusic();
+}
+
+void TownMapSelector::draw(const MapLocationRecord *hoveredLocation) {
+	setScaledPalette(*_screen, _palette, _paletteBrightness);
+	_screen->fillRect(_screen->getBounds(), 0);
+	RoomPresentation::blitBitmap(*_screen, _panels[(uint)_currentPanel], 0, 0);
+	if (hoveredLocation) {
+		RoomPresentation::drawShadowedString(*_screen, *_font, hoveredLocation->labelText,
+			hoveredLocation->labelX, hoveredLocation->labelY, _screen->w, kTownMapLabelColor);
+	}
+	if (EntityManager *entityManager = _engine.getRuntimeEntities())
+		entityManager->drawCursor(*_screen);
+	_screen->makeAllDirty();
+	_screen->update();
+}
+
+bool TownMapSelector::handleEvent(const Common::Event &event) {
+	switch (event.type) {
+	case Common::EVENT_MOUSEMOVE:
+		_needsRedraw = true;
+		break;
+	case Common::EVENT_LBUTTONDOWN: {
+		const MapLocationRecord *clickedLocation =
+			findTownMapLocationAt(_script->getMapLocations(), _currentPanel, _mousePos);
+		if (!clickedLocation)
+			break;
+
+		const EntranceRecord *destinationEntrance =
+			_script->findEntranceRecord(clickedLocation->destinationEntranceName);
+		if (!destinationEntrance) {
+			warning("Harvester: unresolved town map destination '%s' from '%s'",
+				clickedLocation->destinationEntranceName.c_str(), _mapEntryName.c_str());
+			break;
+		}
+
+		_destinationEntranceName = destinationEntrance->entranceName;
+		debugC(1, kDebugGeneral,
+			"Harvester: town map selection entry='%s' panel=%d label='%s' destination='%s'",
+			_mapEntryName.c_str(), _currentPanel, clickedLocation->labelText.c_str(),
+			_destinationEntranceName.c_str());
+		return true;
+	}
+	case Common::EVENT_KEYDOWN: {
+		const int nextPanel = resolveTownMapKeyPanel(_currentPanel, event.kbd.keycode);
+		if (nextPanel != _currentPanel) {
+			_currentPanel = nextPanel;
+			centerCursor();
+			_needsRedraw = true;
+		}
+		break;
+	}
+	default:
+		break;
+	}
+	return false;
+}
+
+Common::Error TownMapSelector::run() {
+	if (_mousePos.x < 0 || _mousePos.y < 0 || _mousePos.x >= _screen->w || _mousePos.y >= _screen->h)
+		_mousePos = Common::Point(_screen->w / 2, _screen->h / 2);
+	if (EntityManager *entityManager = _engine.getRuntimeEntities())
+		(void)entityManager->syncCursorEntityPosition(_mousePos);
+
+	_currentPanel = clampTownMapPanelIndex(_mapEntrance->initialPanelIndex);
+	_needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		const int edgePanel = resolveTownMapEdgePanel(_currentPanel, _mousePos, _screen->w, _screen->h);
+		if (edgePanel != _currentPanel) {
+			_currentPanel = edgePanel;
+			centerCursor();
+			_needsRedraw = true;
+		}
+
+		const MapLocationRecord *hoveredLocation =
+			findTownMapLocationAt(_script->getMapLocations(), _currentPanel, _mousePos);
+		if (_needsRedraw) {
+			draw(hoveredLocation);
+			_needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result)) {
+				restorePreviousMusic();
+				return result;
+			}
+
+			if (handleEvent(event))
+				return Common::kNoError;
+		}
+
+		if (_flow.tickRuntimeEntities())
+			_needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	restorePreviousMusic();
+	return Common::kNoError;
+}
+
+} // End of anonymous namespace
+
+Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
+		Common::String &destinationEntranceName) {
+	destinationEntranceName.clear();
+
+	TownMapSelector selector(_engine, *this, _mousePos, mapEntryName, destinationEntranceName);
+	if (!selector.load())
+		return Common::kReadingFailed;
+
+	if (!ensureCursorEntity())
+		return Common::kReadingFailed;
+	resetCursorAnimationSequence();
+
+	return selector.run();
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/flow_support.h b/engines/harvester/flow_support.h
new file mode 100644
index 00000000000..3693f99a901
--- /dev/null
+++ b/engines/harvester/flow_support.h
@@ -0,0 +1,41 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_FLOW_SUPPORT_H
+#define HARVESTER_FLOW_SUPPORT_H
+
+#include "common/str.h"
+
+namespace Graphics {
+class Screen;
+}
+
+namespace Harvester {
+
+class ResourceManager;
+struct IndexedBitmap;
+
+void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness);
+bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap);
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_FLOW_SUPPORT_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index eee964e72e2..88572fb12af 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -9,6 +9,7 @@ MODULE_OBJS = \
 	dialogue.o \
 	flow.o \
 	flow_debug.o \
+	flow_map.o \
 	fst_player.o \
 	harvester.o \
 	inventory.o \


Commit: 998e763b93043cdd3ca7b911d03011dfbdb559ae
    https://github.com/scummvm/scummvm/commit/998e763b93043cdd3ca7b911d03011dfbdb559ae
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate startup overlays and transitions

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/flow_startup.cpp
  A engines/harvester/flow_transition.cpp
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/module.mk


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 2e666ecce88..b758f609e27 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -20,31 +20,18 @@
  */
 
 #include "harvester/flow.h"
-#include "harvester/flow_support.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
 #include "common/events.h"
-#include "common/formats/ini-file.h"
 #include "common/memstream.h"
-#include "common/ptr.h"
 #include "common/serializer.h"
-#include "common/system.h"
-#include "graphics/font.h"
-#include "graphics/fontman.h"
-#include "graphics/framelimiter.h"
-#include "harvester/cft_font.h"
 #include "harvester/detection.h"
-#include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/input.h"
-#include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/entity.h"
 #include "harvester/scene_entities.h"
-#include "harvester/room_presentation.h"
-#include "harvester/art.h"
-#include "harvester/monster.h"
 #include "harvester/player.h"
 #include "harvester/script.h"
 
@@ -52,38 +39,8 @@ namespace Harvester {
 
 namespace {
 
-static const char *const kQuickTipsPath = "ADJHEAD.RCS";
-static const char *const kMenuPath = "MENU.INI";
-static const char *const kMenuSectionName = "menu";
-static const char *const kBuiltInMenuItems[] = {
-	"NEW GAME",
-	"SAVE GAME",
-	"LOAD GAME",
-	"OPTIONS",
-	"HELP",
-	"QUIT GAME"
-};
-
 static const int kCursorSequence7 = 7;
-static const int kNativeIdentTextLineSpacing = 3;
 static const char *const kPlayerActorEntityName = "PLAYER";
-static const uint32 kPaletteFadeIntervalTicks = 4;
-static const float kPaletteBrightnessBlack = 0.0f;
-static const float kPaletteBrightnessFull = 1.0f;
-static const uint32 kDemoEndingScreenDurationMs = 10000;
-static const int kDemoEndingScreenNumbers[] = { 1, 2, 3, 4, 6 };
-static const char *const kDemoEndingMusicPath = "SOUND/MUSIC/ROCKFITE.CMP";
-static const uint32 kDemoIntroductionDurationMs = 17000;
-static const char *const kDemoIntroductionBitmapPath = "1:/GRAPHIC/OTHER/MAINMENU.BM";
-static const char *const kDemoIntroductionPalettePath = "1:/GRAPHIC/PAL/MAINMENU.PAL";
-static const char *const kDemoIntroductionMusicPath = "SOUND/MUSIC/LODGERB.CMP";
-static const char *const kDemoIntroductionFstPath = "GRAPHIC/FST/PCFALL.FST";
-static const char *const kDemoIntroductionText =
-	"After solving the enigmas of the Harvest town, you are thrust into the realm of "
-	"the Order of the Harvest Moon, a bizarre institution of secretive knowledge.  "
-	"However, the only thing you really care about is escaping from this hellish reality...";
-// Keep WAIT_BM visible while room construction runs
-static const uint32 kTransitionWaitFrameMinMs = 120;
 
 } // End of anonymous namespace
 
@@ -147,21 +104,6 @@ bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap
 	return true;
 }
 
-void setScaledPalette(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);
-}
-
-static void setScaledDisplayPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
-	byte scaledPalette[256 * 3];
-	const float clampedBrightness = CLIP<float>(brightness, 0.0f, 1.0f);
-	for (uint i = 0; i < ARRAYSIZE(scaledPalette); ++i)
-		scaledPalette[i] = (byte)CLIP<int>((int)(palette[i] * clampedBrightness + 0.5f), 0, 255);
-	screen.setPalette(scaledPalette);
-}
-
 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, region.bottom);
@@ -169,15 +111,6 @@ static Common::Rect getRegionBounds(const RegionRecord &region) {
 	return Common::Rect();
 }
 
-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);
-}
-
 bool doesPlayerFacingMatchRegion(int playerFacing, const RegionRecord &region) {
 	return region.desiredFacing < 0 || playerFacing == region.desiredFacing;
 }
@@ -204,54 +137,12 @@ RoomHoverState Flow::getRoomHoverState(const SceneState &scene) const {
 		scene.state.roomNpcs, scene.sceneRegions, _mousePos, _menuTextConfig, &_dialogue);
 }
 
-static Common::String trimAsciiLine(const Common::String &value) {
-	uint start = 0;
-	uint end = value.size();
-
-	while (start < end && (value[start] == ' ' || value[start] == '\t'))
-		++start;
-	while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t' || value[end - 1] == '\r'))
-		--end;
-
-	return value.substr(start, end - start);
-}
-
-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()))
-		return false;
-
-	return SceneEntities::loadRoomSceneResources(state, *engine.getResources(), scene);
-}
-
-static void renderQuickTipsScreen(HarvesterEngine &engine, const SceneState &scene,
-		const MenuTextConfig &config, const QuickTipsLayout &layout,
-		const Common::String &tipText) {
-	Graphics::Screen *screen = engine.getScreen();
-	if (!screen)
-		return;
-
-	RoomPresentation::drawRoomScene(engine, *screen, scene, scene.targetPaletteBrightness);
-	drawQuickTipsPanel(engine, config, layout, tipText);
-
-	if (engine.getRuntimeEntities())
-		engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
 Flow::Flow(HarvesterEngine &engine)
 	: _engine(engine), _mousePos(320, 200), _dialogue(engine, _mousePos), _inventory(engine),
 	  _menu(engine, _mousePos, _menuItems), _cheats(engine),
 	  _room(engine, _inventory, _cheats) {
 }
 
-bool Flow::load() {
-	(void)loadMenuTextConfig(_engine, _menuTextConfig);
-	return loadQuickTips() && loadMenuItems();
-}
-
 bool Flow::buildDialogueSaveStateBlob(Common::Array<byte> &blob, uint32 saveVersion) {
 	Common::MemoryWriteStreamDynamic stream(DisposeAfterUse::NO);
 	Common::Serializer serializer(nullptr, &stream);
@@ -322,353 +213,10 @@ Common::Error Flow::run() {
 	return runMainMenu();
 }
 
-bool Flow::loadQuickTips() {
-	_quickTips.clear();
-
-	Common::Array<byte> data;
-	if (!_engine.getResources()->loadFile(kQuickTipsPath, data)) {
-		warning("Harvester: unable to load quick tips '%s'", kQuickTipsPath);
-		return true;
-	}
-
-	Common::String currentLine;
-	for (uint i = 0; i < data.size(); ++i) {
-		const char ch = (char)data[i];
-		if (ch == '\r')
-			continue;
-		if (ch == '\n') {
-			Common::String trimmed = trimAsciiLine(currentLine);
-			if (!trimmed.empty())
-				_quickTips.push_back(Common::move(trimmed));
-			currentLine.clear();
-			continue;
-		}
-
-		currentLine += ch;
-	}
-
-	Common::String trimmed = trimAsciiLine(currentLine);
-	if (!trimmed.empty())
-		_quickTips.push_back(Common::move(trimmed));
-
-	debugC(1, kDebugGeneral, "Harvester: loaded %u quick tips from '%s'", (uint)_quickTips.size(), kQuickTipsPath);
-	return true;
-}
-
-bool Flow::loadMenuItems() {
-	_menuItems.clear();
-
-	Common::Array<byte> data;
-	if (!_engine.getResources()->loadFile(kMenuPath, data)) {
-		for (const char *item : kBuiltInMenuItems)
-			_menuItems.push_back(item);
-		debugC(2, kDebugGeneral,
-			"Harvester: using %u built-in startup menu items because '%s' is unavailable",
-			(uint)_menuItems.size(), kMenuPath);
-		return true;
-	}
-
-	Common::MemoryReadStream stream(data.data(), data.size());
-	Common::INIFile menu;
-	menu.setDefaultSectionName(kMenuSectionName);
-	menu.requireKeyValueDelimiter();
-	menu.suppressValuelessLineWarning();
-	if (!menu.loadFromStream(stream)) {
-		warning("Harvester: unable to parse startup menu '%s'", kMenuPath);
-		return true;
-	}
-
-	for (uint i = 1; i <= 6; ++i) {
-		Common::String key = Common::String::format("main_menu_%u", i);
-		Common::String value;
-		if (menu.getKey(key, kMenuSectionName, value) && !value.empty())
-			_menuItems.push_back(Common::move(value));
-	}
-
-	debugC(1, kDebugGeneral, "Harvester: loaded %u startup menu items from '%s'", (uint)_menuItems.size(), kMenuPath);
-	return true;
-}
-
-Common::Error Flow::runQuickTips() {
-	if (!_engine.getScript()->isQuickTipsEnabled() || _quickTips.empty())
-		return Common::kNoError;
-
-	Common::Error transitionError = beginRoomSetupTransition();
-	if (transitionError.getCode() != Common::kNoError)
-		return transitionError;
-
-	SceneState scene;
-	if (!loadQuickTipsScene(_engine, scene))
-		return Common::kReadingFailed;
-
-	Graphics::Screen *screen = _engine.getScreen();
-	if (screen) {
-		if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
-			return Common::kReadingFailed;
-
-		transitionError = waitForRoomSetupTransitionHold();
-		if (transitionError.getCode() != Common::kNoError)
-			return transitionError;
-
-		logScenePaletteSummary("quick tips scene palette", scene, kPaletteBrightnessBlack);
-		RoomPresentation::drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
-		screen->makeAllDirty();
-		screen->update();
-
-		logScenePaletteSummary("quick tips fade target", scene, scene.targetPaletteBrightness);
-		transitionError = fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
-		if (transitionError.getCode() != Common::kNoError)
-			return transitionError;
-	}
-
-	resetCursorAnimationSequence();
-
-	QuickTipsLayout quickTipsLayout;
-	if (!resolveQuickTipsLayout(_engine, _menuTextConfig, quickTipsLayout))
-		return Common::kReadingFailed;
-	const Common::String &toggleLabel = _engine.getScript()->isQuickTipsEnabled()
-		? _menuTextConfig.quickTipsOnLabel : _menuTextConfig.quickTipsOffLabel;
-	debugC(1, kDebugGeneral,
-		"Harvester: quick tips panel='%s' header='%s' exit='%s' next='%s' toggle='%s'",
-		_menuTextConfig.hasQuickTipsHeader() ? "TEXTBOX6.BM" : "TIPS.BM",
-		_menuTextConfig.quickTipsHeader.c_str(), _menuTextConfig.quickTipsExitLabel.c_str(),
-		_menuTextConfig.quickTipsNextLabel.c_str(), toggleLabel.c_str());
-
-	uint tipIndex = _engine.getRandomNumber(_quickTips.size() - 1);
-	bool needsRedraw = true;
-	Graphics::FrameLimiter limiter(g_system, 60);
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			renderQuickTipsScreen(_engine, scene, _menuTextConfig, quickTipsLayout, _quickTips[tipIndex]);
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (handleSystemEvent(event, result))
-				return result;
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE:
-				needsRedraw = true;
-				break;
-			case Common::EVENT_LBUTTONDOWN:
-				if (quickTipsLayout.exitRect.contains(_mousePos))
-					return Common::kNoError;
-
-				if (quickTipsLayout.nextRect.contains(_mousePos)) {
-					tipIndex = (tipIndex + 1) % _quickTips.size();
-					needsRedraw = true;
-				} else if (quickTipsLayout.toggleRect.contains(_mousePos)) {
-					_engine.getScript()->setQuickTipsEnabled(!_engine.getScript()->isQuickTipsEnabled());
-					if (!resolveQuickTipsLayout(_engine, _menuTextConfig, quickTipsLayout))
-						return Common::kReadingFailed;
-					needsRedraw = true;
-				}
-				break;
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return Common::kNoError;
-				break;
-			default:
-				break;
-			}
-		}
-
-		if (tickRuntimeEntities())
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
-Common::Error Flow::runDemoEnding() {
-	Graphics::Screen *screen = _engine.getScreen();
-	ResourceManager *resources = _engine.getResources();
-	EntityManager *entityManager = _engine.getRuntimeEntities();
-	if (!screen || !resources)
-		return Common::kReadingFailed;
-
-	if (entityManager)
-		entityManager->hideCursor();
-	_engine.stopSound();
-	if (!_engine.playMusic(kDemoEndingMusicPath))
-		return Common::kReadingFailed;
-
-	for (uint i = 0; i < ARRAYSIZE(kDemoEndingScreenNumbers) && !_engine.shouldQuit(); ++i) {
-		const int screenNumber = kDemoEndingScreenNumbers[i];
-		const Common::String bitmapPath = Common::String::format(
-			"1:/GRAPHIC/OTHER/SCREEN%d.BM", screenNumber);
-		const Common::String palettePath = Common::String::format(
-			"1:/GRAPHIC/PAL/SCREEN%d.PAL", screenNumber);
-		IndexedBitmap bitmap;
-		byte palette[256 * 3];
-		if (!loadBitmapResource(*resources, bitmapPath, bitmap) ||
-				!Art::loadPaletteResource(*resources, palettePath, palette)) {
-			warning("Harvester: unable to load DOS demo ending screen %d", screenNumber);
-			return Common::kReadingFailed;
-		}
-
-		setScaledPalette(*screen, palette, kPaletteBrightnessBlack);
-		screen->fillRect(Common::Rect(0, 0, screen->w, screen->h), 0);
-		RoomPresentation::blitBitmap(*screen, bitmap, 0, 0);
-		screen->makeAllDirty();
-		screen->update();
-
-		Common::Error fadeError = fadePalette(
-			palette, kPaletteBrightnessBlack, kPaletteBrightnessFull);
-		if (fadeError.getCode() != Common::kNoError)
-			return fadeError;
-
-		const uint32 deadline = g_system->getMillis() + kDemoEndingScreenDurationMs;
-		bool skipScreen = false;
-		while (!skipScreen && !_engine.shouldQuit() &&
-				(int32)(deadline - g_system->getMillis()) > 0) {
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error eventError = Common::kNoError;
-				if (handleSystemEvent(event, eventError))
-					return eventError;
-				if (event.type == Common::EVENT_LBUTTONDOWN ||
-						(event.type == Common::EVENT_KEYDOWN &&
-						(event.kbd.keycode == Common::KEYCODE_SPACE ||
-						event.kbd.keycode == Common::KEYCODE_ESCAPE ||
-						event.kbd.keycode == Common::KEYCODE_RETURN))) {
-					skipScreen = true;
-				}
-			}
-			g_system->delayMillis(1);
-		}
-
-		if (i + 1 == ARRAYSIZE(kDemoEndingScreenNumbers))
-			_engine.stopMusic();
-		fadeError = fadePalette(
-			palette, kPaletteBrightnessFull, kPaletteBrightnessBlack);
-		if (fadeError.getCode() != Common::kNoError)
-			return fadeError;
-	}
-
-	_engine.quitGame();
-	return Common::kNoError;
-}
-
-Common::Error Flow::runDemoIntroduction() {
-	Graphics::Screen *screen = _engine.getScreen();
-	ResourceManager *resources = _engine.getResources();
-	EntityManager *entityManager = _engine.getRuntimeEntities();
-	const Art *art = _engine.getArt();
-	const Text *text = _engine.getText();
-	const CftFontResource *fontResource = text ? text->findFont("TEXTFONT") : nullptr;
-	if (!screen || !resources || !art || !fontResource)
-		return Common::kReadingFailed;
-
-	IndexedBitmap bitmap;
-	byte palette[256 * 3];
-	if (!loadBitmapResource(*resources, kDemoIntroductionBitmapPath, bitmap) ||
-			!Art::loadPaletteResource(*resources, kDemoIntroductionPalettePath, palette)) {
-		return Common::kReadingFailed;
-	}
-
-	HarvesterCftFont font(*fontResource);
-	if (!font.isValid())
-		return Common::kReadingFailed;
-	debugC(1, kDebugGeneral,
-		"Harvester: showing DOS demo introduction for %u ms",
-		kDemoIntroductionDurationMs);
-
-	if (entityManager)
-		entityManager->hideCursor();
-	setScaledPalette(*screen, palette, kPaletteBrightnessBlack);
-	screen->fillRect(Common::Rect(0, 0, screen->w, screen->h), 0);
-	RoomPresentation::blitBitmap(*screen, bitmap, 0, 0);
-	blitTransparentBitmap(*screen, art->getLogoBitmap(), 160, 0);
-	RoomPresentation::drawWrappedText(*screen, font, kDemoIntroductionText, 90, 200, 420, 0xce,
-		kNativeIdentTextLineSpacing);
-	screen->makeAllDirty();
-	screen->update();
-
-	if (!_engine.playMusic(kDemoIntroductionMusicPath))
-		return Common::kReadingFailed;
-	Common::Error fadeError = fadePalette(
-		palette, kPaletteBrightnessBlack, kPaletteBrightnessFull);
-	if (fadeError.getCode() != Common::kNoError)
-		return fadeError;
-
-	const uint32 deadline = g_system->getMillis() + kDemoIntroductionDurationMs;
-	bool skipIntroduction = false;
-	while (!skipIntroduction && !_engine.shouldQuit() &&
-			(int32)(deadline - g_system->getMillis()) > 0) {
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error eventError = Common::kNoError;
-			if (handleSystemEvent(event, eventError))
-				return eventError;
-			if (event.type == Common::EVENT_LBUTTONDOWN ||
-					event.type == Common::EVENT_RBUTTONDOWN ||
-					(event.type == Common::EVENT_KEYDOWN &&
-					(event.kbd.keycode == Common::KEYCODE_SPACE ||
-					event.kbd.keycode == Common::KEYCODE_ESCAPE ||
-					event.kbd.keycode == Common::KEYCODE_RETURN))) {
-				skipIntroduction = true;
-			}
-		}
-		g_system->delayMillis(1);
-	}
-
-	fadeError = fadePalette(palette, kPaletteBrightnessFull, kPaletteBrightnessBlack);
-	if (fadeError.getCode() != Common::kNoError)
-		return fadeError;
-	_engine.stopMusic();
-
-	FstPlayer fstPlayer(_engine);
-	if (!fstPlayer.play(kDemoIntroductionFstPath))
-		return Common::kReadingFailed;
-	return Common::kNoError;
-}
-
 Common::Error Flow::runMainMenu() {
 	return _menu.runMainMenu(*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::runRoomMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, bool canSaveGame) {
 	return _menu.runRoomMenu(backdrop, palette, paletteBrightness, *this, canSaveGame);
@@ -976,140 +524,6 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 	return true;
 }
 
-Common::Error Flow::beginRoomSetupTransition() {
-	Graphics::Screen *screen = _engine.getScreen();
-	if (screen) {
-		byte displayPalette[256 * 3];
-		screen->getPalette(displayPalette);
-		bool hasVisiblePalette = false;
-		for (byte value : displayPalette) {
-			if (value != 0) {
-				hasVisiblePalette = true;
-				break;
-			}
-		}
-
-		if (hasVisiblePalette) {
-			for (double brightness = 1.0;
-					brightness > kPaletteBrightnessBlack; brightness -= 0.1) {
-				const uint32 startTick = getRuntimeClockTicks();
-				setScaledDisplayPalette(*screen, displayPalette, brightness);
-				screen->makeAllDirty();
-				screen->update();
-
-				Common::Error fadeError = Common::kNoError;
-				if (waitForPaletteFadeTick(startTick, fadeError))
-					return fadeError;
-			}
-
-			setScaledDisplayPalette(*screen, displayPalette, kPaletteBrightnessBlack);
-			screen->makeAllDirty();
-			screen->update();
-		}
-	}
-
-	if (_engine.getRuntimeEntities())
-		_engine.getRuntimeEntities()->hideCursor();
-
-	const Art *art = _engine.getArt();
-	if (art && screen)
-		art->drawWaitFrame(*screen);
-	_roomSetupTransitionShownTick = g_system ? g_system->getMillis() : 0;
-
-	Common::Error result = Common::kNoError;
-	if (pumpTransitionEvents(result))
-		return result;
-
-	return Common::kNoError;
-}
-
-Common::Error Flow::waitForRoomSetupTransitionHold() {
-	if (_roomSetupTransitionShownTick == 0 || !g_system)
-		return Common::kNoError;
-
-	const uint32 deadline = _roomSetupTransitionShownTick + kTransitionWaitFrameMinMs;
-	while ((int32)(deadline - g_system->getMillis()) > 0) {
-		Common::Error result = Common::kNoError;
-		if (pumpTransitionEvents(result))
-			return result;
-		g_system->delayMillis(1);
-	}
-
-	_roomSetupTransitionShownTick = 0;
-	return Common::kNoError;
-}
-
-Common::Error Flow::fadeInRoomScene(const byte *palette, float targetBrightness) {
-	Graphics::Screen *screen = _engine.getScreen();
-	if (!screen || !palette)
-		return Common::kNoError;
-
-	for (double brightness = 0.0; brightness < targetBrightness; brightness += 0.1) {
-		const uint32 startTick = getRuntimeClockTicks();
-		setScaledPalette(*screen, palette, brightness);
-		screen->makeAllDirty();
-		screen->update();
-
-		Common::Error fadeError = Common::kNoError;
-		if (waitForPaletteFadeTick(startTick, fadeError))
-			return fadeError;
-	}
-
-	setScaledPalette(*screen, palette, targetBrightness);
-	screen->makeAllDirty();
-	screen->update();
-	return Common::kNoError;
-}
-
-Common::Error Flow::fadePalette(const byte *palette, float fromBrightness,
-		float toBrightness) {
-	Graphics::Screen *screen = _engine.getScreen();
-	if (!screen || !palette)
-		return Common::kNoError;
-
-	const double step = fromBrightness < toBrightness ? 0.1 : -0.1;
-	for (double brightness = fromBrightness;
-			step > 0.0f ? brightness < toBrightness : brightness > toBrightness;
-			brightness += step) {
-		const uint32 startTick = getRuntimeClockTicks();
-		setScaledPalette(*screen, palette, brightness);
-		screen->makeAllDirty();
-		screen->update();
-
-		Common::Error fadeError = Common::kNoError;
-		if (waitForPaletteFadeTick(startTick, fadeError))
-			return fadeError;
-	}
-
-	setScaledPalette(*screen, palette, toBrightness);
-	screen->makeAllDirty();
-	screen->update();
-	return Common::kNoError;
-}
-
-bool Flow::waitForPaletteFadeTick(uint32 startTick, Common::Error &result) {
-	const uint32 deadline = startTick + kPaletteFadeIntervalTicks;
-	while ((int32)(deadline - getRuntimeClockTicks()) > 0) {
-		if (pumpTransitionEvents(result))
-			return true;
-		g_system->delayMillis(1);
-	}
-	debugC(4, kDebugScene,
-		"Harvester: palette fade step tick=%u->%u interval=%u",
-		startTick, getRuntimeClockTicks(), kPaletteFadeIntervalTicks);
-	return false;
-}
-
-bool Flow::pumpTransitionEvents(Common::Error &result) {
-	Common::Event event;
-	while (g_system->getEventManager()->pollEvent(event)) {
-		if (handleSystemEvent(event, result))
-			return true;
-	}
-
-	return false;
-}
-
 void Flow::executeStartupAudioCommands(const Common::Array<AudioCommand> &commands) {
 	for (const AudioCommand &command : commands)
 		(void)_engine.executeAudioCommand(command);
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index ca464b9535c..f3d7b35917d 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -107,6 +107,7 @@ private:
 	bool loadQuickTips();
 	bool loadMenuItems();
 	Common::Error runQuickTips();
+	Common::Error runQuickTipsOverlay(const SceneState &scene);
 	Common::Error runDemoIntroduction();
 	Common::Error runMainMenu();
 	Common::Error runTownMapSelector(const Common::String &mapEntryName, Common::String &destinationEntranceName);
diff --git a/engines/harvester/flow_startup.cpp b/engines/harvester/flow_startup.cpp
new file mode 100644
index 00000000000..46c8ebe7339
--- /dev/null
+++ b/engines/harvester/flow_startup.cpp
@@ -0,0 +1,441 @@
+/* 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/flow.h"
+#include "harvester/flow_support.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/formats/ini-file.h"
+#include "common/memstream.h"
+#include "common/system.h"
+#include "common/util.h"
+#include "graphics/framelimiter.h"
+#include "harvester/art.h"
+#include "harvester/cft_font.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/fst_player.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+#include "harvester/room_presentation.h"
+#include "harvester/scene_entities.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kQuickTipsPath = "ADJHEAD.RCS";
+static const char *const kMenuPath = "MENU.INI";
+static const char *const kMenuSectionName = "menu";
+static const char *const kBuiltInMenuItems[] = {
+	"NEW GAME",
+	"SAVE GAME",
+	"LOAD GAME",
+	"OPTIONS",
+	"HELP",
+	"QUIT GAME"
+};
+static const int kNativeIdentTextLineSpacing = 3;
+static const float kPaletteBrightnessBlack = 0.0f;
+static const float kPaletteBrightnessFull = 1.0f;
+static const uint32 kDemoEndingScreenDurationMs = 10000;
+static const int kDemoEndingScreenNumbers[] = { 1, 2, 3, 4, 6 };
+static const char *const kDemoEndingMusicPath = "SOUND/MUSIC/ROCKFITE.CMP";
+static const uint32 kDemoIntroductionDurationMs = 17000;
+static const char *const kDemoIntroductionBitmapPath = "1:/GRAPHIC/OTHER/MAINMENU.BM";
+static const char *const kDemoIntroductionPalettePath = "1:/GRAPHIC/PAL/MAINMENU.PAL";
+static const char *const kDemoIntroductionMusicPath = "SOUND/MUSIC/LODGERB.CMP";
+static const char *const kDemoIntroductionFstPath = "GRAPHIC/FST/PCFALL.FST";
+static const char *const kDemoIntroductionText =
+	"After solving the enigmas of the Harvest town, you are thrust into the realm of "
+	"the Order of the Harvest Moon, a bizarre institution of secretive knowledge.  "
+	"However, the only thing you really care about is escaping from this hellish reality...";
+
+static Common::String trimAsciiLine(const Common::String &value) {
+	uint start = 0;
+	uint end = value.size();
+
+	while (start < end && (value[start] == ' ' || value[start] == '\t'))
+		++start;
+	while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t' || value[end - 1] == '\r'))
+		--end;
+
+	return value.substr(start, end - start);
+}
+
+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()))
+		return false;
+
+	return SceneEntities::loadRoomSceneResources(state, *engine.getResources(), scene);
+}
+
+static void renderQuickTipsScreen(HarvesterEngine &engine, const SceneState &scene,
+		const MenuTextConfig &config, const QuickTipsLayout &layout,
+		const Common::String &tipText) {
+	Graphics::Screen *screen = engine.getScreen();
+	if (!screen)
+		return;
+
+	RoomPresentation::drawRoomScene(engine, *screen, scene, scene.targetPaletteBrightness);
+	drawQuickTipsPanel(engine, config, layout, tipText);
+
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+} // End of anonymous namespace
+
+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);
+}
+
+bool Flow::load() {
+	(void)loadMenuTextConfig(_engine, _menuTextConfig);
+	return loadQuickTips() && loadMenuItems();
+}
+
+bool Flow::loadQuickTips() {
+	_quickTips.clear();
+
+	Common::Array<byte> data;
+	if (!_engine.getResources()->loadFile(kQuickTipsPath, data)) {
+		warning("Harvester: unable to load quick tips '%s'", kQuickTipsPath);
+		return true;
+	}
+
+	Common::String currentLine;
+	for (uint i = 0; i < data.size(); ++i) {
+		const char ch = (char)data[i];
+		if (ch == '\r')
+			continue;
+		if (ch == '\n') {
+			Common::String trimmed = trimAsciiLine(currentLine);
+			if (!trimmed.empty())
+				_quickTips.push_back(Common::move(trimmed));
+			currentLine.clear();
+			continue;
+		}
+
+		currentLine += ch;
+	}
+
+	Common::String trimmed = trimAsciiLine(currentLine);
+	if (!trimmed.empty())
+		_quickTips.push_back(Common::move(trimmed));
+
+	debugC(1, kDebugGeneral, "Harvester: loaded %u quick tips from '%s'", (uint)_quickTips.size(), kQuickTipsPath);
+	return true;
+}
+
+bool Flow::loadMenuItems() {
+	_menuItems.clear();
+
+	Common::Array<byte> data;
+	if (!_engine.getResources()->loadFile(kMenuPath, data)) {
+		for (const char *item : kBuiltInMenuItems)
+			_menuItems.push_back(item);
+		debugC(2, kDebugGeneral,
+			"Harvester: using %u built-in startup menu items because '%s' is unavailable",
+			(uint)_menuItems.size(), kMenuPath);
+		return true;
+	}
+
+	Common::MemoryReadStream stream(data.data(), data.size());
+	Common::INIFile menu;
+	menu.setDefaultSectionName(kMenuSectionName);
+	menu.requireKeyValueDelimiter();
+	menu.suppressValuelessLineWarning();
+	if (!menu.loadFromStream(stream)) {
+		warning("Harvester: unable to parse startup menu '%s'", kMenuPath);
+		return true;
+	}
+
+	for (uint i = 1; i <= 6; ++i) {
+		Common::String key = Common::String::format("main_menu_%u", i);
+		Common::String value;
+		if (menu.getKey(key, kMenuSectionName, value) && !value.empty())
+			_menuItems.push_back(Common::move(value));
+	}
+
+	debugC(1, kDebugGeneral, "Harvester: loaded %u startup menu items from '%s'", (uint)_menuItems.size(), kMenuPath);
+	return true;
+}
+
+Common::Error Flow::runQuickTips() {
+	if (!_engine.getScript()->isQuickTipsEnabled() || _quickTips.empty())
+		return Common::kNoError;
+
+	Common::Error transitionError = beginRoomSetupTransition();
+	if (transitionError.getCode() != Common::kNoError)
+		return transitionError;
+
+	SceneState scene;
+	if (!loadQuickTipsScene(_engine, scene))
+		return Common::kReadingFailed;
+
+	Graphics::Screen *screen = _engine.getScreen();
+	if (screen) {
+		if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+			return Common::kReadingFailed;
+
+		transitionError = waitForRoomSetupTransitionHold();
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+
+		logScenePaletteSummary("quick tips scene palette", scene, kPaletteBrightnessBlack);
+		RoomPresentation::drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
+		screen->makeAllDirty();
+		screen->update();
+
+		logScenePaletteSummary("quick tips fade target", scene, scene.targetPaletteBrightness);
+		transitionError = fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+	}
+
+	return runQuickTipsOverlay(scene);
+}
+
+Common::Error Flow::runQuickTipsOverlay(const SceneState &scene) {
+	resetCursorAnimationSequence();
+
+	QuickTipsLayout quickTipsLayout;
+	if (!resolveQuickTipsLayout(_engine, _menuTextConfig, quickTipsLayout))
+		return Common::kReadingFailed;
+	const Common::String &toggleLabel = _engine.getScript()->isQuickTipsEnabled()
+		? _menuTextConfig.quickTipsOnLabel : _menuTextConfig.quickTipsOffLabel;
+	debugC(1, kDebugGeneral,
+		"Harvester: quick tips panel='%s' header='%s' exit='%s' next='%s' toggle='%s'",
+		_menuTextConfig.hasQuickTipsHeader() ? "TEXTBOX6.BM" : "TIPS.BM",
+		_menuTextConfig.quickTipsHeader.c_str(), _menuTextConfig.quickTipsExitLabel.c_str(),
+		_menuTextConfig.quickTipsNextLabel.c_str(), toggleLabel.c_str());
+
+	uint tipIndex = _engine.getRandomNumber(_quickTips.size() - 1);
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderQuickTipsScreen(_engine, scene, _menuTextConfig, quickTipsLayout, _quickTips[tipIndex]);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+				if (quickTipsLayout.exitRect.contains(_mousePos))
+					return Common::kNoError;
+
+				if (quickTipsLayout.nextRect.contains(_mousePos)) {
+					tipIndex = (tipIndex + 1) % _quickTips.size();
+					needsRedraw = true;
+				} else if (quickTipsLayout.toggleRect.contains(_mousePos)) {
+					_engine.getScript()->setQuickTipsEnabled(!_engine.getScript()->isQuickTipsEnabled());
+					if (!resolveQuickTipsLayout(_engine, _menuTextConfig, quickTipsLayout))
+						return Common::kReadingFailed;
+					needsRedraw = true;
+				}
+				break;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+				break;
+			default:
+				break;
+			}
+		}
+
+		if (tickRuntimeEntities())
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error Flow::runDemoEnding() {
+	Graphics::Screen *screen = _engine.getScreen();
+	ResourceManager *resources = _engine.getResources();
+	EntityManager *entityManager = _engine.getRuntimeEntities();
+	if (!screen || !resources)
+		return Common::kReadingFailed;
+
+	if (entityManager)
+		entityManager->hideCursor();
+	_engine.stopSound();
+	if (!_engine.playMusic(kDemoEndingMusicPath))
+		return Common::kReadingFailed;
+
+	for (uint i = 0; i < ARRAYSIZE(kDemoEndingScreenNumbers) && !_engine.shouldQuit(); ++i) {
+		const int screenNumber = kDemoEndingScreenNumbers[i];
+		const Common::String bitmapPath = Common::String::format(
+			"1:/GRAPHIC/OTHER/SCREEN%d.BM", screenNumber);
+		const Common::String palettePath = Common::String::format(
+			"1:/GRAPHIC/PAL/SCREEN%d.PAL", screenNumber);
+		IndexedBitmap bitmap;
+		byte palette[256 * 3];
+		if (!loadBitmapResource(*resources, bitmapPath, bitmap) ||
+				!Art::loadPaletteResource(*resources, palettePath, palette)) {
+			warning("Harvester: unable to load DOS demo ending screen %d", screenNumber);
+			return Common::kReadingFailed;
+		}
+
+		setScaledPalette(*screen, palette, kPaletteBrightnessBlack);
+		screen->fillRect(Common::Rect(0, 0, screen->w, screen->h), 0);
+		RoomPresentation::blitBitmap(*screen, bitmap, 0, 0);
+		screen->makeAllDirty();
+		screen->update();
+
+		Common::Error fadeError = fadePalette(
+			palette, kPaletteBrightnessBlack, kPaletteBrightnessFull);
+		if (fadeError.getCode() != Common::kNoError)
+			return fadeError;
+
+		const uint32 deadline = g_system->getMillis() + kDemoEndingScreenDurationMs;
+		bool skipScreen = false;
+		while (!skipScreen && !_engine.shouldQuit() &&
+				(int32)(deadline - g_system->getMillis()) > 0) {
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error eventError = Common::kNoError;
+				if (handleSystemEvent(event, eventError))
+					return eventError;
+				if (event.type == Common::EVENT_LBUTTONDOWN ||
+						(event.type == Common::EVENT_KEYDOWN &&
+						(event.kbd.keycode == Common::KEYCODE_SPACE ||
+						event.kbd.keycode == Common::KEYCODE_ESCAPE ||
+						event.kbd.keycode == Common::KEYCODE_RETURN))) {
+					skipScreen = true;
+				}
+			}
+			g_system->delayMillis(1);
+		}
+
+		if (i + 1 == ARRAYSIZE(kDemoEndingScreenNumbers))
+			_engine.stopMusic();
+		fadeError = fadePalette(
+			palette, kPaletteBrightnessFull, kPaletteBrightnessBlack);
+		if (fadeError.getCode() != Common::kNoError)
+			return fadeError;
+	}
+
+	_engine.quitGame();
+	return Common::kNoError;
+}
+
+Common::Error Flow::runDemoIntroduction() {
+	Graphics::Screen *screen = _engine.getScreen();
+	ResourceManager *resources = _engine.getResources();
+	EntityManager *entityManager = _engine.getRuntimeEntities();
+	const Art *art = _engine.getArt();
+	const Text *text = _engine.getText();
+	const CftFontResource *fontResource = text ? text->findFont("TEXTFONT") : nullptr;
+	if (!screen || !resources || !art || !fontResource)
+		return Common::kReadingFailed;
+
+	IndexedBitmap bitmap;
+	byte palette[256 * 3];
+	if (!loadBitmapResource(*resources, kDemoIntroductionBitmapPath, bitmap) ||
+			!Art::loadPaletteResource(*resources, kDemoIntroductionPalettePath, palette)) {
+		return Common::kReadingFailed;
+	}
+
+	HarvesterCftFont font(*fontResource);
+	if (!font.isValid())
+		return Common::kReadingFailed;
+	debugC(1, kDebugGeneral,
+		"Harvester: showing DOS demo introduction for %u ms",
+		kDemoIntroductionDurationMs);
+
+	if (entityManager)
+		entityManager->hideCursor();
+	setScaledPalette(*screen, palette, kPaletteBrightnessBlack);
+	screen->fillRect(Common::Rect(0, 0, screen->w, screen->h), 0);
+	RoomPresentation::blitBitmap(*screen, bitmap, 0, 0);
+	blitTransparentBitmap(*screen, art->getLogoBitmap(), 160, 0);
+	RoomPresentation::drawWrappedText(*screen, font, kDemoIntroductionText, 90, 200, 420, 0xce,
+		kNativeIdentTextLineSpacing);
+	screen->makeAllDirty();
+	screen->update();
+
+	if (!_engine.playMusic(kDemoIntroductionMusicPath))
+		return Common::kReadingFailed;
+	Common::Error fadeError = fadePalette(
+		palette, kPaletteBrightnessBlack, kPaletteBrightnessFull);
+	if (fadeError.getCode() != Common::kNoError)
+		return fadeError;
+
+	const uint32 deadline = g_system->getMillis() + kDemoIntroductionDurationMs;
+	bool skipIntroduction = false;
+	while (!skipIntroduction && !_engine.shouldQuit() &&
+			(int32)(deadline - g_system->getMillis()) > 0) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error eventError = Common::kNoError;
+			if (handleSystemEvent(event, eventError))
+				return eventError;
+			if (event.type == Common::EVENT_LBUTTONDOWN ||
+					event.type == Common::EVENT_RBUTTONDOWN ||
+					(event.type == Common::EVENT_KEYDOWN &&
+					(event.kbd.keycode == Common::KEYCODE_SPACE ||
+					event.kbd.keycode == Common::KEYCODE_ESCAPE ||
+					event.kbd.keycode == Common::KEYCODE_RETURN))) {
+				skipIntroduction = true;
+			}
+		}
+		g_system->delayMillis(1);
+	}
+
+	fadeError = fadePalette(palette, kPaletteBrightnessFull, kPaletteBrightnessBlack);
+	if (fadeError.getCode() != Common::kNoError)
+		return fadeError;
+	_engine.stopMusic();
+
+	FstPlayer fstPlayer(_engine);
+	if (!fstPlayer.play(kDemoIntroductionFstPath))
+		return Common::kReadingFailed;
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/flow_transition.cpp b/engines/harvester/flow_transition.cpp
new file mode 100644
index 00000000000..6bb412eb0e5
--- /dev/null
+++ b/engines/harvester/flow_transition.cpp
@@ -0,0 +1,230 @@
+/* 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/flow.h"
+#include "harvester/flow_support.h"
+
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "common/util.h"
+#include "harvester/art.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/palette_utils.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+namespace {
+
+static const uint32 kPaletteFadeIntervalTicks = 4;
+static const float kPaletteBrightnessBlack = 0.0f;
+// Keep WAIT_BM visible while room construction runs
+static const uint32 kTransitionWaitFrameMinMs = 120;
+
+static void setScaledDisplayPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
+	byte scaledPalette[256 * 3];
+	const float clampedBrightness = CLIP<float>(brightness, 0.0f, 1.0f);
+	for (uint i = 0; i < ARRAYSIZE(scaledPalette); ++i)
+		scaledPalette[i] = (byte)CLIP<int>((int)(palette[i] * clampedBrightness + 0.5f), 0, 255);
+	screen.setPalette(scaledPalette);
+}
+
+} // End of anonymous namespace
+
+void setScaledPalette(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);
+}
+
+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::beginRoomSetupTransition() {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (screen) {
+		byte displayPalette[256 * 3];
+		screen->getPalette(displayPalette);
+		bool hasVisiblePalette = false;
+		for (byte value : displayPalette) {
+			if (value != 0) {
+				hasVisiblePalette = true;
+				break;
+			}
+		}
+
+		if (hasVisiblePalette) {
+			for (double brightness = 1.0;
+					brightness > kPaletteBrightnessBlack; brightness -= 0.1) {
+				const uint32 startTick = getRuntimeClockTicks();
+				setScaledDisplayPalette(*screen, displayPalette, brightness);
+				screen->makeAllDirty();
+				screen->update();
+
+				Common::Error fadeError = Common::kNoError;
+				if (waitForPaletteFadeTick(startTick, fadeError))
+					return fadeError;
+			}
+
+			setScaledDisplayPalette(*screen, displayPalette, kPaletteBrightnessBlack);
+			screen->makeAllDirty();
+			screen->update();
+		}
+	}
+
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->hideCursor();
+
+	const Art *art = _engine.getArt();
+	if (art && screen)
+		art->drawWaitFrame(*screen);
+	_roomSetupTransitionShownTick = g_system ? g_system->getMillis() : 0;
+
+	Common::Error result = Common::kNoError;
+	if (pumpTransitionEvents(result))
+		return result;
+
+	return Common::kNoError;
+}
+
+Common::Error Flow::waitForRoomSetupTransitionHold() {
+	if (_roomSetupTransitionShownTick == 0 || !g_system)
+		return Common::kNoError;
+
+	const uint32 deadline = _roomSetupTransitionShownTick + kTransitionWaitFrameMinMs;
+	while ((int32)(deadline - g_system->getMillis()) > 0) {
+		Common::Error result = Common::kNoError;
+		if (pumpTransitionEvents(result))
+			return result;
+		g_system->delayMillis(1);
+	}
+
+	_roomSetupTransitionShownTick = 0;
+	return Common::kNoError;
+}
+
+Common::Error Flow::fadeInRoomScene(const byte *palette, float targetBrightness) {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen || !palette)
+		return Common::kNoError;
+
+	for (double brightness = 0.0; brightness < targetBrightness; brightness += 0.1) {
+		const uint32 startTick = getRuntimeClockTicks();
+		setScaledPalette(*screen, palette, brightness);
+		screen->makeAllDirty();
+		screen->update();
+
+		Common::Error fadeError = Common::kNoError;
+		if (waitForPaletteFadeTick(startTick, fadeError))
+			return fadeError;
+	}
+
+	setScaledPalette(*screen, palette, targetBrightness);
+	screen->makeAllDirty();
+	screen->update();
+	return Common::kNoError;
+}
+
+Common::Error Flow::fadePalette(const byte *palette, float fromBrightness,
+		float toBrightness) {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen || !palette)
+		return Common::kNoError;
+
+	const double step = fromBrightness < toBrightness ? 0.1 : -0.1;
+	for (double brightness = fromBrightness;
+			step > 0.0f ? brightness < toBrightness : brightness > toBrightness;
+			brightness += step) {
+		const uint32 startTick = getRuntimeClockTicks();
+		setScaledPalette(*screen, palette, brightness);
+		screen->makeAllDirty();
+		screen->update();
+
+		Common::Error fadeError = Common::kNoError;
+		if (waitForPaletteFadeTick(startTick, fadeError))
+			return fadeError;
+	}
+
+	setScaledPalette(*screen, palette, toBrightness);
+	screen->makeAllDirty();
+	screen->update();
+	return Common::kNoError;
+}
+
+bool Flow::waitForPaletteFadeTick(uint32 startTick, Common::Error &result) {
+	const uint32 deadline = startTick + kPaletteFadeIntervalTicks;
+	while ((int32)(deadline - getRuntimeClockTicks()) > 0) {
+		if (pumpTransitionEvents(result))
+			return true;
+		g_system->delayMillis(1);
+	}
+	debugC(4, kDebugScene,
+		"Harvester: palette fade step tick=%u->%u interval=%u",
+		startTick, getRuntimeClockTicks(), kPaletteFadeIntervalTicks);
+	return false;
+}
+
+bool Flow::pumpTransitionEvents(Common::Error &result) {
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		if (handleSystemEvent(event, result))
+			return true;
+	}
+
+	return false;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 88572fb12af..9b153d3d27c 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -10,6 +10,8 @@ MODULE_OBJS = \
 	flow.o \
 	flow_debug.o \
 	flow_map.o \
+	flow_startup.o \
+	flow_transition.o \
 	fst_player.o \
 	harvester.o \
 	inventory.o \


Commit: 1e5757bcf2d467028bf3735d3afa6f279f79986f
    https://github.com/scummvm/scummvm/commit/1e5757bcf2d467028bf3735d3afa6f279f79986f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Separate room population and actor placement

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/flow_scene.cpp
    engines/harvester/flow.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index b758f609e27..ac67db0b40e 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -22,17 +22,12 @@
 #include "harvester/flow.h"
 
 #include "common/algorithm.h"
-#include "common/debug.h"
 #include "common/events.h"
 #include "common/memstream.h"
 #include "common/serializer.h"
-#include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/input.h"
-#include "harvester/resources.h"
 #include "harvester/entity.h"
-#include "harvester/scene_entities.h"
-#include "harvester/player.h"
 #include "harvester/script.h"
 
 namespace Harvester {
@@ -40,57 +35,9 @@ namespace Harvester {
 namespace {
 
 static const int kCursorSequence7 = 7;
-static const char *const kPlayerActorEntityName = "PLAYER";
 
 } // End of anonymous namespace
 
-static void setRoomActorScreenPosition(Entity &entity, int centerX, int bottomY, float z,
-		int width, int height, int xOffset, int yOffset) {
-	entity.setPosition(centerX - xOffset - width / 2, bottomY - height - yOffset, z);
-}
-
-static float computeRoomActorRenderZ(float z, const Entity &entity) {
-	// Native actor spawns lower the render-list depth anchor by half the z extent
-	// before insertion. Without that adjustment, cemetery Karin lands one layer
-	// too far back and gets occluded by the grave mask in CEM10.
-	return z - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
-}
-
-static bool applyRoomActorPlacementInternal(const RoomSetupState &state, Entity &entity,
-		int centerX, int bottomY, float z, const Common::String *entranceName, bool applyDepthScale) {
-	int width = 0;
-	int height = 0;
-	int xOffset = 0;
-	int yOffset = 0;
-	if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
-		return false;
-
-	const float renderZ = computeRoomActorRenderZ(z, entity);
-	entity.setAnchorMode(kEntityAnchorTopLeft);
-	setRoomActorScreenPosition(entity, centerX, bottomY, renderZ, width, height, xOffset, yOffset);
-
-	float depthScale = 1.0f;
-	if (applyDepthScale) {
-		depthScale = Player::computeDepthScale(state, renderZ);
-		entity.setDepthScale(depthScale);
-		if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
-			return false;
-		setRoomActorScreenPosition(entity, centerX, bottomY, renderZ, width, height, xOffset, yOffset);
-	}
-
-	return true;
-}
-
-bool applyRoomActorPlacement(const RoomSetupState &state, Entity &entity,
-		int centerX, int bottomY, float z, const Common::String *entranceName) {
-	return applyRoomActorPlacementInternal(state, entity, centerX, bottomY, z, entranceName, true);
-}
-
-static bool applyStartupActorPlacement(const RoomSetupState &state, Entity &entity) {
-	return applyRoomActorPlacement(state, entity,
-		state.playerSpawnX, state.playerSpawnY, (float)state.playerSpawnZ, &state.entranceName);
-}
-
 bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap) {
 	if (screen.w <= 0 || screen.h <= 0 || screen.format.bytesPerPixel != 1)
 		return false;
@@ -421,109 +368,6 @@ bool Flow::ensureCursorEntity() {
 	return entityManager->spawnCursorEntity(_mousePos) != nullptr;
 }
 
-bool Flow::populateRoomSceneEntities(RoomSetupState &state,
-		const Common::Array<ObjectRecord> &drawableObjects,
-		const Common::Array<AnimRecord> &drawableAnimations) {
-	EntityManager *entityManager = _engine.getRuntimeEntities();
-	if (!entityManager)
-		return false;
-
-	SceneEntities sceneRuntime(state, _engine.getScript(), _engine.getResources(), entityManager);
-	Entity *preservedPlayer = entityManager->detachSceneEntityByName(kPlayerActorEntityName);
-	entityManager->clearSceneEntities(true);
-	sceneRuntime.populateRoomRegions();
-	for (const TimerRecord &timer : state.roomTimers) {
-		Entity *existingTimer = entityManager->findSceneEntityByName(timer.timerName);
-		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(),
-				existingTimer->getTimerInitialValue(), existingTimer->isTimerEnabled(),
-				existingTimer->isTimerLooping(), existingTimer->isTimerGlobal());
-			continue;
-		}
-
-		if (!entityManager->spawnSceneTimerEntity(timer.timerName,
-				timer.initialValue, timer.currentValue, timer.enabled, timer.looping, timer.global)) {
-			debug(1, "Harvester: unable to spawn room timer entity '%s'",
-				timer.timerName.c_str());
-			continue;
-		}
-
-		debugC(1, kDebugRoom,
-			"Harvester: scene timer spawned 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);
-	}
-	sceneRuntime.populateRoomObjects(drawableObjects);
-	for (const AnimRecord &anim : drawableAnimations) {
-		const bool displayVisible = shouldDisplaySceneAnimation(anim);
-		if (!entityManager->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
-				Common::Point(anim.x, anim.y), (float)anim.z, anim.frameDelay, anim.active, displayVisible,
-				anim.looping, anim.backward, anim.pingPong, anim.runtimeState)) {
-			debug(1, "Harvester: unable to spawn room anim entity '%s' from '%s'",
-				anim.animName.c_str(), anim.resourcePath.c_str());
-		}
-	}
-	sceneRuntime.populateRoomNpcs();
-	sceneRuntime.populateRoomMonsters();
-	if (state.hasEntrance) {
-		const int playerFrame = Player::resolveFacingFrame(state.playerFacing);
-		Script *script = _engine.getScript();
-		const int playerCombatLoadout = script ? script->getPlayerCombatLoadout() : 0;
-		const Common::String playerResourcePath =
-			Player::resolveCombatLoadoutResourcePath(playerCombatLoadout);
-		const bool reusedPlayer = preservedPlayer != nullptr;
-		Entity *player = preservedPlayer;
-		if (!reusedPlayer) {
-			player = entityManager->spawnSceneActorEntity(kPlayerActorEntityName,
-				playerResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
-				(float)state.playerSpawnZ, playerFrame);
-		}
-		if (!player) {
-			debug(1, "Harvester: unable to spawn startup player actor from '%s'", playerResourcePath.c_str());
-		} else {
-			player->setClassId(kEntityClassPlayer);
-			player->setAnchorMode(kEntityAnchorTopLeft);
-			player->setHitTestMode(kEntityHitTestOpaquePixels);
-			player->setVisible(true);
-			player->setAnimationRate(0);
-			player->setAnimationFrameRange(playerFrame, playerFrame, false);
-			player->setCurrentFrame(playerFrame);
-			if (!applyStartupActorPlacement(state, *player)) {
-				debug(1, "Harvester: unable to apply startup player placement for entrance '%s'",
-					state.entranceName.c_str());
-			}
-			if (reusedPlayer) {
-				Player loadoutPlayer;
-				loadoutPlayer.initialize(state, player, -1);
-				(void)loadoutPlayer.syncCombatLoadoutVisual(_engine, state, playerCombatLoadout);
-			}
-			Player spawnPlayer;
-			spawnPlayer.initialize(state, player, 0);
-			if (spawnPlayer.resolveBlockedStartupSpawn(_engine, state, drawableObjects, drawableAnimations)) {
-				state.playerSpawnX = spawnPlayer.getState().centerX;
-				state.playerSpawnY = spawnPlayer.getState().bottomY;
-				state.playerSpawnZ = (int)spawnPlayer.getState().z;
-			}
-			if (reusedPlayer)
-				entityManager->adoptSceneEntity(player);
-			else
-				entityManager->reinsertSceneEntity(player);
-			debugC(1, kDebugPlayer,
-				"Harvester: %s startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
-				reusedPlayer ? "reused" : "spawned", playerResourcePath.c_str(), state.entranceName.c_str(),
-				state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ,
-				state.playerFacing, playerFrame);
-		}
-		preservedPlayer = nullptr;
-	} else if (preservedPlayer) {
-		delete preservedPlayer;
-	}
-
-	return true;
-}
-
 void Flow::executeStartupAudioCommands(const Common::Array<AudioCommand> &commands) {
 	for (const AudioCommand &command : commands)
 		(void)_engine.executeAudioCommand(command);
diff --git a/engines/harvester/flow_scene.cpp b/engines/harvester/flow_scene.cpp
new file mode 100644
index 00000000000..86adc616662
--- /dev/null
+++ b/engines/harvester/flow_scene.cpp
@@ -0,0 +1,200 @@
+/* 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/flow.h"
+
+#include "common/debug.h"
+#include "common/util.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/harvester.h"
+#include "harvester/player.h"
+#include "harvester/scene_entities.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kPlayerActorEntityName = "PLAYER";
+
+struct RoomActorFrameMetrics {
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+};
+
+static void setRoomActorScreenPosition(Entity &entity, int centerX, int bottomY, float z,
+		const RoomActorFrameMetrics &metrics) {
+	entity.setPosition(centerX - metrics.xOffset - metrics.width / 2, bottomY - metrics.height - metrics.yOffset, z);
+}
+
+static float computeRoomActorRenderZ(float z, const Entity &entity) {
+	// Native actor spawns lower the render-list depth anchor by half the z extent
+	// before insertion. Without that adjustment, cemetery Karin lands one layer
+	// too far back and gets occluded by the grave mask in CEM10.
+	return z - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
+}
+
+static bool applyStartupActorPlacement(const RoomSetupState &state, Entity &entity) {
+	return applyRoomActorPlacement(state, entity,
+		state.playerSpawnX, state.playerSpawnY, (float)state.playerSpawnZ, &state.entranceName);
+}
+
+static void populateRoomTimers(const RoomSetupState &state, EntityManager &entityManager) {
+	for (const TimerRecord &timer : state.roomTimers) {
+		Entity *existingTimer = entityManager.findSceneEntityByName(timer.timerName);
+		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(),
+				existingTimer->getTimerInitialValue(), existingTimer->isTimerEnabled(),
+				existingTimer->isTimerLooping(), existingTimer->isTimerGlobal());
+			continue;
+		}
+
+		if (!entityManager.spawnSceneTimerEntity(timer.timerName,
+				timer.initialValue, timer.currentValue, timer.enabled, timer.looping, timer.global)) {
+			debug(1, "Harvester: unable to spawn room timer entity '%s'",
+				timer.timerName.c_str());
+			continue;
+		}
+
+		debugC(1, kDebugRoom,
+			"Harvester: scene timer spawned 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);
+	}
+}
+
+static void populateRoomAnimations(EntityManager &entityManager,
+		const Common::Array<AnimRecord> &drawableAnimations) {
+	for (const AnimRecord &anim : drawableAnimations) {
+		const bool displayVisible = shouldDisplaySceneAnimation(anim);
+		if (!entityManager.spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
+				Common::Point(anim.x, anim.y), (float)anim.z, anim.frameDelay, anim.active, displayVisible,
+				anim.looping, anim.backward, anim.pingPong, anim.runtimeState)) {
+			debug(1, "Harvester: unable to spawn room anim entity '%s' from '%s'",
+				anim.animName.c_str(), anim.resourcePath.c_str());
+		}
+	}
+}
+
+static void populateRoomPlayer(HarvesterEngine &engine, RoomSetupState &state,
+		EntityManager &entityManager, Entity *preservedPlayer,
+		const Common::Array<ObjectRecord> &drawableObjects,
+		const Common::Array<AnimRecord> &drawableAnimations) {
+	if (state.hasEntrance) {
+		const int playerFrame = Player::resolveFacingFrame(state.playerFacing);
+		Script *script = engine.getScript();
+		const int playerCombatLoadout = script ? script->getPlayerCombatLoadout() : 0;
+		const Common::String playerResourcePath =
+			Player::resolveCombatLoadoutResourcePath(playerCombatLoadout);
+		const bool reusedPlayer = preservedPlayer != nullptr;
+		Entity *player = preservedPlayer;
+		if (!reusedPlayer) {
+			player = entityManager.spawnSceneActorEntity(kPlayerActorEntityName,
+				playerResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
+				(float)state.playerSpawnZ, playerFrame);
+		}
+		if (!player) {
+			debug(1, "Harvester: unable to spawn startup player actor from '%s'", playerResourcePath.c_str());
+		} else {
+			player->setClassId(kEntityClassPlayer);
+			player->setAnchorMode(kEntityAnchorTopLeft);
+			player->setHitTestMode(kEntityHitTestOpaquePixels);
+			player->setVisible(true);
+			player->setAnimationRate(0);
+			player->setAnimationFrameRange(playerFrame, playerFrame, false);
+			player->setCurrentFrame(playerFrame);
+			if (!applyStartupActorPlacement(state, *player)) {
+				debug(1, "Harvester: unable to apply startup player placement for entrance '%s'",
+					state.entranceName.c_str());
+			}
+			if (reusedPlayer) {
+				Player loadoutPlayer;
+				loadoutPlayer.initialize(state, player, -1);
+				(void)loadoutPlayer.syncCombatLoadoutVisual(engine, state, playerCombatLoadout);
+			}
+			Player spawnPlayer;
+			spawnPlayer.initialize(state, player, 0);
+			if (spawnPlayer.resolveBlockedStartupSpawn(engine, state, drawableObjects, drawableAnimations)) {
+				state.playerSpawnX = spawnPlayer.getState().centerX;
+				state.playerSpawnY = spawnPlayer.getState().bottomY;
+				state.playerSpawnZ = (int)spawnPlayer.getState().z;
+			}
+			if (reusedPlayer)
+				entityManager.adoptSceneEntity(player);
+			else
+				entityManager.reinsertSceneEntity(player);
+			debugC(1, kDebugPlayer,
+				"Harvester: %s startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
+				reusedPlayer ? "reused" : "spawned", playerResourcePath.c_str(), state.entranceName.c_str(),
+				state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ,
+				state.playerFacing, playerFrame);
+		}
+	} else if (preservedPlayer) {
+		delete preservedPlayer;
+	}
+}
+
+} // End of anonymous namespace
+
+bool applyRoomActorPlacement(const RoomSetupState &state, Entity &entity,
+		int centerX, int bottomY, float z, const Common::String *entranceName) {
+	RoomActorFrameMetrics metrics;
+	if (!entity.getCurrentFrameMetrics(metrics.width, metrics.height, metrics.xOffset, metrics.yOffset))
+		return false;
+
+	const float renderZ = computeRoomActorRenderZ(z, entity);
+	entity.setAnchorMode(kEntityAnchorTopLeft);
+	setRoomActorScreenPosition(entity, centerX, bottomY, renderZ, metrics);
+
+	entity.setDepthScale(Player::computeDepthScale(state, renderZ));
+	if (!entity.getCurrentFrameMetrics(metrics.width, metrics.height, metrics.xOffset, metrics.yOffset))
+		return false;
+	setRoomActorScreenPosition(entity, centerX, bottomY, renderZ, metrics);
+
+	return true;
+}
+
+bool Flow::populateRoomSceneEntities(RoomSetupState &state,
+		const Common::Array<ObjectRecord> &drawableObjects,
+		const Common::Array<AnimRecord> &drawableAnimations) {
+	EntityManager *entityManager = _engine.getRuntimeEntities();
+	if (!entityManager)
+		return false;
+
+	SceneEntities sceneRuntime(state, _engine.getScript(), _engine.getResources(), entityManager);
+	Entity *preservedPlayer = entityManager->detachSceneEntityByName(kPlayerActorEntityName);
+	entityManager->clearSceneEntities(true);
+	sceneRuntime.populateRoomRegions();
+	populateRoomTimers(state, *entityManager);
+	sceneRuntime.populateRoomObjects(drawableObjects);
+	populateRoomAnimations(*entityManager, drawableAnimations);
+	sceneRuntime.populateRoomNpcs();
+	sceneRuntime.populateRoomMonsters();
+	populateRoomPlayer(_engine, state, *entityManager, preservedPlayer, drawableObjects, drawableAnimations);
+
+	return true;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 9b153d3d27c..5371cde46e3 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -10,6 +10,7 @@ MODULE_OBJS = \
 	flow.o \
 	flow_debug.o \
 	flow_map.o \
+	flow_scene.o \
 	flow_startup.o \
 	flow_transition.o \
 	fst_player.o \


Commit: c2412793813a3aacc1023ee63b25cdb44ba028cd
    https://github.com/scummvm/scummvm/commit/c2412793813a3aacc1023ee63b25cdb44ba028cd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-19T19:03:03-04:00

Commit Message:
HARVESTER: Organize subsystem sources into directories

Changed paths:
  A engines/harvester/combat/combat.cpp
  A engines/harvester/combat/combat.h
  A engines/harvester/combat/combat_contact.cpp
  A engines/harvester/combat/combat_effects.cpp
  A engines/harvester/combat/combat_geometry.cpp
  A engines/harvester/combat/combat_hud.cpp
  A engines/harvester/combat/combat_monsters.cpp
  A engines/harvester/combat/combat_reactions.cpp
  A engines/harvester/combat/combat_targeting.cpp
  A engines/harvester/flow/flow.cpp
  A engines/harvester/flow/flow.h
  A engines/harvester/flow/flow_debug.cpp
  A engines/harvester/flow/flow_map.cpp
  A engines/harvester/flow/flow_scene.cpp
  A engines/harvester/flow/flow_startup.cpp
  A engines/harvester/flow/flow_support.h
  A engines/harvester/flow/flow_transition.cpp
  A engines/harvester/menu/menu.cpp
  A engines/harvester/menu/menu.h
  A engines/harvester/menu/menu_config.cpp
  A engines/harvester/menu/menu_navigation.cpp
  A engines/harvester/menu/menu_options.cpp
  A engines/harvester/menu/menu_prompts.cpp
  A engines/harvester/menu/menu_render.cpp
  A engines/harvester/menu/menu_render.h
  A engines/harvester/menu/menu_save.cpp
  A engines/harvester/player/player.cpp
  A engines/harvester/player/player.h
  A engines/harvester/player/player_animation.cpp
  A engines/harvester/player/player_attack.cpp
  A engines/harvester/player/player_idle.cpp
  A engines/harvester/player/player_keyboard.cpp
  A engines/harvester/player/player_movement.cpp
  A engines/harvester/player/player_movement.h
  A engines/harvester/player/player_reactions.cpp
  A engines/harvester/player/player_target.cpp
  A engines/harvester/scene/input.cpp
  A engines/harvester/scene/input.h
  A engines/harvester/scene/interaction.cpp
  A engines/harvester/scene/interaction.h
  A engines/harvester/scene/room_presentation.cpp
  A engines/harvester/scene/room_presentation.h
  A engines/harvester/scene/room_support.h
  A engines/harvester/scene/scene.cpp
  A engines/harvester/scene/scene.h
  A engines/harvester/scene/scene_entities.cpp
  A engines/harvester/scene/scene_entities.h
  A engines/harvester/script/script.cpp
  A engines/harvester/script/script.h
  A engines/harvester/script/script_actors.cpp
  A engines/harvester/script/script_commands.cpp
  A engines/harvester/script/script_records.cpp
  A engines/harvester/script/script_room.cpp
  A engines/harvester/script/script_runtime.cpp
  A engines/harvester/script/script_save.cpp
  R engines/harvester/combat.cpp
  R engines/harvester/combat.h
  R engines/harvester/combat_contact.cpp
  R engines/harvester/combat_effects.cpp
  R engines/harvester/combat_geometry.cpp
  R engines/harvester/combat_hud.cpp
  R engines/harvester/combat_monsters.cpp
  R engines/harvester/combat_reactions.cpp
  R engines/harvester/combat_targeting.cpp
  R engines/harvester/flow.cpp
  R engines/harvester/flow.h
  R engines/harvester/flow_debug.cpp
  R engines/harvester/flow_map.cpp
  R engines/harvester/flow_scene.cpp
  R engines/harvester/flow_startup.cpp
  R engines/harvester/flow_support.h
  R engines/harvester/flow_transition.cpp
  R engines/harvester/input.cpp
  R engines/harvester/input.h
  R engines/harvester/interaction.cpp
  R engines/harvester/interaction.h
  R engines/harvester/menu.cpp
  R engines/harvester/menu.h
  R engines/harvester/menu_config.cpp
  R engines/harvester/menu_navigation.cpp
  R engines/harvester/menu_options.cpp
  R engines/harvester/menu_prompts.cpp
  R engines/harvester/menu_render.cpp
  R engines/harvester/menu_render.h
  R engines/harvester/menu_save.cpp
  R engines/harvester/player.cpp
  R engines/harvester/player.h
  R engines/harvester/player_animation.cpp
  R engines/harvester/player_attack.cpp
  R engines/harvester/player_idle.cpp
  R engines/harvester/player_keyboard.cpp
  R engines/harvester/player_movement.cpp
  R engines/harvester/player_movement.h
  R engines/harvester/player_reactions.cpp
  R engines/harvester/player_target.cpp
  R engines/harvester/room_presentation.cpp
  R engines/harvester/room_presentation.h
  R engines/harvester/room_support.h
  R engines/harvester/scene.cpp
  R engines/harvester/scene.h
  R engines/harvester/scene_entities.cpp
  R engines/harvester/scene_entities.h
  R engines/harvester/script.cpp
  R engines/harvester/script.h
  R engines/harvester/script_actors.cpp
  R engines/harvester/script_commands.cpp
  R engines/harvester/script_records.cpp
  R engines/harvester/script_room.cpp
  R engines/harvester/script_runtime.cpp
  R engines/harvester/script_save.cpp
    engines/harvester/cheats.cpp
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/inventory.cpp
    engines/harvester/inventory.h
    engines/harvester/media_manager.cpp
    engines/harvester/metaengine.cpp
    engines/harvester/module.mk
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/saveload.cpp


diff --git a/engines/harvester/cheats.cpp b/engines/harvester/cheats.cpp
index 8f315166d1d..6f18ee1fdb7 100644
--- a/engines/harvester/cheats.cpp
+++ b/engines/harvester/cheats.cpp
@@ -25,7 +25,7 @@
 #include "common/util.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/combat.cpp b/engines/harvester/combat/combat.cpp
similarity index 98%
rename from engines/harvester/combat.cpp
rename to engines/harvester/combat/combat.cpp
index efe589c4955..c3704778b74 100644
--- a/engines/harvester/combat.cpp
+++ b/engines/harvester/combat/combat.cpp
@@ -19,13 +19,13 @@
  *
  */
 
-#include "harvester/combat.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
-#include "harvester/scene_entities.h"
+#include "harvester/scene/scene_entities.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/combat.h b/engines/harvester/combat/combat.h
similarity index 99%
rename from engines/harvester/combat.h
rename to engines/harvester/combat/combat.h
index af7c5fd8799..d5cf6e2e745 100644
--- a/engines/harvester/combat.h
+++ b/engines/harvester/combat/combat.h
@@ -23,7 +23,7 @@
 #define HARVESTER_COMBAT_H
 
 #include "common/error.h"
-#include "harvester/room_support.h"
+#include "harvester/scene/room_support.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/combat_contact.cpp b/engines/harvester/combat/combat_contact.cpp
similarity index 99%
rename from engines/harvester/combat_contact.cpp
rename to engines/harvester/combat/combat_contact.cpp
index b2fb878f4a2..b31f3a6a20a 100644
--- a/engines/harvester/combat_contact.cpp
+++ b/engines/harvester/combat/combat_contact.cpp
@@ -19,15 +19,15 @@
  *
  */
 
-#include "harvester/combat.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
 #include "harvester/harvester.h"
-#include "harvester/interaction.h"
-#include "harvester/player.h"
+#include "harvester/scene/interaction.h"
+#include "harvester/player/player.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/combat_effects.cpp b/engines/harvester/combat/combat_effects.cpp
similarity index 99%
rename from engines/harvester/combat_effects.cpp
rename to engines/harvester/combat/combat_effects.cpp
index 7ba3eb0f699..361f38df1c5 100644
--- a/engines/harvester/combat_effects.cpp
+++ b/engines/harvester/combat/combat_effects.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/combat.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
@@ -28,7 +28,7 @@
 #include "graphics/screen.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
-#include "harvester/player.h"
+#include "harvester/player/player.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/combat_geometry.cpp b/engines/harvester/combat/combat_geometry.cpp
similarity index 97%
rename from engines/harvester/combat_geometry.cpp
rename to engines/harvester/combat/combat_geometry.cpp
index 4a472c28e8d..b4baec4d5d4 100644
--- a/engines/harvester/combat_geometry.cpp
+++ b/engines/harvester/combat/combat_geometry.cpp
@@ -19,10 +19,10 @@
  *
  */
 
-#include "harvester/combat.h"
+#include "harvester/combat/combat.h"
 
 #include "common/util.h"
-#include "harvester/player.h"
+#include "harvester/player/player.h"
 #include "harvester/entity.h"
 
 namespace Harvester {
diff --git a/engines/harvester/combat_hud.cpp b/engines/harvester/combat/combat_hud.cpp
similarity index 97%
rename from engines/harvester/combat_hud.cpp
rename to engines/harvester/combat/combat_hud.cpp
index a1e4c63ea6d..5b7204cd79e 100644
--- a/engines/harvester/combat_hud.cpp
+++ b/engines/harvester/combat/combat_hud.cpp
@@ -19,9 +19,9 @@
  *
  */
 
-#include "harvester/combat.h"
+#include "harvester/combat/combat.h"
 
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/combat_monsters.cpp b/engines/harvester/combat/combat_monsters.cpp
similarity index 99%
rename from engines/harvester/combat_monsters.cpp
rename to engines/harvester/combat/combat_monsters.cpp
index 71cc0853b42..519ec555df3 100644
--- a/engines/harvester/combat_monsters.cpp
+++ b/engines/harvester/combat/combat_monsters.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/combat.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
@@ -27,8 +27,8 @@
 #include "harvester/detection.h"
 #include "harvester/entity.h"
 #include "harvester/harvester.h"
-#include "harvester/interaction.h"
-#include "harvester/player.h"
+#include "harvester/scene/interaction.h"
+#include "harvester/player/player.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/combat_reactions.cpp b/engines/harvester/combat/combat_reactions.cpp
similarity index 99%
rename from engines/harvester/combat_reactions.cpp
rename to engines/harvester/combat/combat_reactions.cpp
index e6704967707..a646700eb05 100644
--- a/engines/harvester/combat_reactions.cpp
+++ b/engines/harvester/combat/combat_reactions.cpp
@@ -19,15 +19,15 @@
  *
  */
 
-#include "harvester/combat.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
 #include "harvester/harvester.h"
-#include "harvester/interaction.h"
-#include "harvester/scene_entities.h"
+#include "harvester/scene/interaction.h"
+#include "harvester/scene/scene_entities.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/combat_targeting.cpp b/engines/harvester/combat/combat_targeting.cpp
similarity index 99%
rename from engines/harvester/combat_targeting.cpp
rename to engines/harvester/combat/combat_targeting.cpp
index 93ac3fb9423..a70d64de389 100644
--- a/engines/harvester/combat_targeting.cpp
+++ b/engines/harvester/combat/combat_targeting.cpp
@@ -19,12 +19,12 @@
  *
  */
 
-#include "harvester/combat.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
 #include "harvester/detection.h"
-#include "harvester/player.h"
+#include "harvester/player/player.h"
 #include "harvester/entity.h"
 
 namespace Harvester {
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index a67dc6c09ac..2a687180268 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -87,7 +87,7 @@
 #include "harvester/resources.h"
 #include "harvester/entity.h"
 #include "harvester/art.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/text.h"
 
 namespace Harvester {
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index 5ef2c8b8664..064cda3a253 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -27,7 +27,7 @@
 #include "common/rect.h"
 #include "common/str.h"
 #include "harvester/npc/dialogue_handler.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Common {
 struct Event;
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow/flow.cpp
similarity index 99%
rename from engines/harvester/flow.cpp
rename to engines/harvester/flow/flow.cpp
index ac67db0b40e..db68aea317a 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow/flow.cpp
@@ -19,16 +19,16 @@
  *
  */
 
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 
 #include "common/algorithm.h"
 #include "common/events.h"
 #include "common/memstream.h"
 #include "common/serializer.h"
 #include "harvester/harvester.h"
-#include "harvester/input.h"
+#include "harvester/scene/input.h"
 #include "harvester/entity.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/flow.h b/engines/harvester/flow/flow.h
similarity index 98%
rename from engines/harvester/flow.h
rename to engines/harvester/flow/flow.h
index f3d7b35917d..b056fe15f08 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow/flow.h
@@ -29,9 +29,9 @@
 #include "harvester/cheats.h"
 #include "harvester/dialogue.h"
 #include "harvester/inventory.h"
-#include "harvester/menu.h"
-#include "harvester/scene.h"
-#include "harvester/script.h"
+#include "harvester/menu/menu.h"
+#include "harvester/scene/scene.h"
+#include "harvester/script/script.h"
 
 namespace Common {
 struct Event;
diff --git a/engines/harvester/flow_debug.cpp b/engines/harvester/flow/flow_debug.cpp
similarity index 98%
rename from engines/harvester/flow_debug.cpp
rename to engines/harvester/flow/flow_debug.cpp
index 82f382fd813..51f3678ba90 100644
--- a/engines/harvester/flow_debug.cpp
+++ b/engines/harvester/flow/flow_debug.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 
 #include "common/config-manager.h"
 #include "common/debug.h"
@@ -27,9 +27,9 @@
 #include "harvester/detection.h"
 #include "harvester/entity.h"
 #include "harvester/harvester.h"
-#include "harvester/input.h"
-#include "harvester/player.h"
-#include "harvester/scene_entities.h"
+#include "harvester/scene/input.h"
+#include "harvester/player/player.h"
+#include "harvester/scene/scene_entities.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/flow_map.cpp b/engines/harvester/flow/flow_map.cpp
similarity index 98%
rename from engines/harvester/flow_map.cpp
rename to engines/harvester/flow/flow_map.cpp
index fc3eaa61859..4571657cb74 100644
--- a/engines/harvester/flow_map.cpp
+++ b/engines/harvester/flow/flow_map.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/flow.h"
-#include "harvester/flow_support.h"
+#include "harvester/flow/flow.h"
+#include "harvester/flow/flow_support.h"
 
 #include "common/debug.h"
 #include "common/events.h"
@@ -35,7 +35,7 @@
 #include "harvester/detection.h"
 #include "harvester/entity.h"
 #include "harvester/harvester.h"
-#include "harvester/room_presentation.h"
+#include "harvester/scene/room_presentation.h"
 #include "harvester/text.h"
 
 namespace Harvester {
diff --git a/engines/harvester/flow_scene.cpp b/engines/harvester/flow/flow_scene.cpp
similarity index 98%
rename from engines/harvester/flow_scene.cpp
rename to engines/harvester/flow/flow_scene.cpp
index 86adc616662..c33218013e0 100644
--- a/engines/harvester/flow_scene.cpp
+++ b/engines/harvester/flow/flow_scene.cpp
@@ -19,15 +19,15 @@
  *
  */
 
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 
 #include "common/debug.h"
 #include "common/util.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
 #include "harvester/harvester.h"
-#include "harvester/player.h"
-#include "harvester/scene_entities.h"
+#include "harvester/player/player.h"
+#include "harvester/scene/scene_entities.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/flow_startup.cpp b/engines/harvester/flow/flow_startup.cpp
similarity index 98%
rename from engines/harvester/flow_startup.cpp
rename to engines/harvester/flow/flow_startup.cpp
index 46c8ebe7339..d0fef9a71c1 100644
--- a/engines/harvester/flow_startup.cpp
+++ b/engines/harvester/flow/flow_startup.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/flow.h"
-#include "harvester/flow_support.h"
+#include "harvester/flow/flow.h"
+#include "harvester/flow/flow_support.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
@@ -37,8 +37,8 @@
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
-#include "harvester/room_presentation.h"
-#include "harvester/scene_entities.h"
+#include "harvester/scene/room_presentation.h"
+#include "harvester/scene/scene_entities.h"
 #include "harvester/text.h"
 
 namespace Harvester {
diff --git a/engines/harvester/flow_support.h b/engines/harvester/flow/flow_support.h
similarity index 100%
rename from engines/harvester/flow_support.h
rename to engines/harvester/flow/flow_support.h
diff --git a/engines/harvester/flow_transition.cpp b/engines/harvester/flow/flow_transition.cpp
similarity index 98%
rename from engines/harvester/flow_transition.cpp
rename to engines/harvester/flow/flow_transition.cpp
index 6bb412eb0e5..00e226a1f47 100644
--- a/engines/harvester/flow_transition.cpp
+++ b/engines/harvester/flow/flow_transition.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/flow.h"
-#include "harvester/flow_support.h"
+#include "harvester/flow/flow.h"
+#include "harvester/flow/flow_support.h"
 
 #include "common/debug.h"
 #include "common/events.h"
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 6deb9bf7309..8969ce3153c 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -28,8 +28,8 @@
 #include "harvester/fst_player.h"
 #include "harvester/media_manager.h"
 #include "harvester/resources.h"
-#include "harvester/flow.h"
-#include "harvester/script.h"
+#include "harvester/flow/flow.h"
+#include "harvester/script/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index b2d9c066a33..761205cbcad 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -30,7 +30,7 @@
 #include "engines/engine.h"
 #include "graphics/screen.h"
 #include "harvester/saveload.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 struct ADGameDescription;
 
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 5670dc0a88c..a2a0405b283 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -27,8 +27,8 @@
 #include "graphics/screen.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
-#include "harvester/menu.h"
-#include "harvester/player.h"
+#include "harvester/menu/menu.h"
+#include "harvester/player/player.h"
 #include "harvester/resources.h"
 #include "harvester/art.h"
 
diff --git a/engines/harvester/inventory.h b/engines/harvester/inventory.h
index b7e5f590b3a..416630e0199 100644
--- a/engines/harvester/inventory.h
+++ b/engines/harvester/inventory.h
@@ -28,7 +28,7 @@
 #include "common/rect.h"
 #include "common/str.h"
 #include "harvester/art.h"
-#include "harvester/room_support.h"
+#include "harvester/scene/room_support.h"
 
 namespace Graphics {
 class Screen;
diff --git a/engines/harvester/media_manager.cpp b/engines/harvester/media_manager.cpp
index 7e4980b3cec..e7d2b2c8a77 100644
--- a/engines/harvester/media_manager.cpp
+++ b/engines/harvester/media_manager.cpp
@@ -32,7 +32,7 @@
 #include "harvester/art.h"
 #include "harvester/resources.h"
 #include "harvester/entity.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 #include "harvester/text.h"
 
 namespace Harvester {
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu/menu.cpp
similarity index 98%
rename from engines/harvester/menu.cpp
rename to engines/harvester/menu/menu.cpp
index 67db206a4ef..8c36537d174 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu/menu.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/menu.h"
-#include "harvester/menu_render.h"
+#include "harvester/menu/menu.h"
+#include "harvester/menu/menu_render.h"
 
 #include "common/algorithm.h"
 #include "common/events.h"
@@ -32,7 +32,7 @@
 #include "harvester/resources.h"
 #include "harvester/entity.h"
 #include "harvester/art.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/menu.h b/engines/harvester/menu/menu.h
similarity index 100%
rename from engines/harvester/menu.h
rename to engines/harvester/menu/menu.h
diff --git a/engines/harvester/menu_config.cpp b/engines/harvester/menu/menu_config.cpp
similarity index 99%
rename from engines/harvester/menu_config.cpp
rename to engines/harvester/menu/menu_config.cpp
index 11c7f086e28..86ad8668c7a 100644
--- a/engines/harvester/menu_config.cpp
+++ b/engines/harvester/menu/menu_config.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/menu.h"
+#include "harvester/menu/menu.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
diff --git a/engines/harvester/menu_navigation.cpp b/engines/harvester/menu/menu_navigation.cpp
similarity index 99%
rename from engines/harvester/menu_navigation.cpp
rename to engines/harvester/menu/menu_navigation.cpp
index bae825e2ea7..2c6e1f69e94 100644
--- a/engines/harvester/menu_navigation.cpp
+++ b/engines/harvester/menu/menu_navigation.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/menu.h"
-#include "harvester/menu_render.h"
+#include "harvester/menu/menu.h"
+#include "harvester/menu/menu_render.h"
 
 #include "common/algorithm.h"
 #include "common/events.h"
@@ -30,9 +30,9 @@
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/harvester.h"
-#include "harvester/room_support.h"
+#include "harvester/scene/room_support.h"
 #include "harvester/text.h"
 
 namespace Harvester {
diff --git a/engines/harvester/menu_options.cpp b/engines/harvester/menu/menu_options.cpp
similarity index 99%
rename from engines/harvester/menu_options.cpp
rename to engines/harvester/menu/menu_options.cpp
index 743ef42f384..a48b6469d56 100644
--- a/engines/harvester/menu_options.cpp
+++ b/engines/harvester/menu/menu_options.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/menu.h"
-#include "harvester/menu_render.h"
+#include "harvester/menu/menu.h"
+#include "harvester/menu/menu_render.h"
 
 #include "common/algorithm.h"
 #include "common/config-manager.h"
@@ -30,9 +30,9 @@
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/harvester.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 #include "harvester/text.h"
 
 namespace Harvester {
diff --git a/engines/harvester/menu_prompts.cpp b/engines/harvester/menu/menu_prompts.cpp
similarity index 97%
rename from engines/harvester/menu_prompts.cpp
rename to engines/harvester/menu/menu_prompts.cpp
index e15a4d85f7c..2bb0f9482f4 100644
--- a/engines/harvester/menu_prompts.cpp
+++ b/engines/harvester/menu/menu_prompts.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/menu.h"
-#include "harvester/menu_render.h"
+#include "harvester/menu/menu.h"
+#include "harvester/menu/menu_render.h"
 
 #include "common/events.h"
 #include "common/system.h"
@@ -28,10 +28,10 @@
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/harvester.h"
-#include "harvester/room_support.h"
-#include "harvester/script.h"
+#include "harvester/scene/room_support.h"
+#include "harvester/script/script.h"
 #include "harvester/text.h"
 
 namespace Harvester {
diff --git a/engines/harvester/menu_render.cpp b/engines/harvester/menu/menu_render.cpp
similarity index 99%
rename from engines/harvester/menu_render.cpp
rename to engines/harvester/menu/menu_render.cpp
index 4de241de250..54aecee7c9d 100644
--- a/engines/harvester/menu_render.cpp
+++ b/engines/harvester/menu/menu_render.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/menu_render.h"
+#include "harvester/menu/menu_render.h"
 
 #include "common/algorithm.h"
 #include "graphics/fontman.h"
@@ -27,7 +27,7 @@
 #include "harvester/entity.h"
 #include "harvester/harvester.h"
 #include "harvester/palette_utils.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 #include "harvester/text.h"
 
 namespace Harvester {
diff --git a/engines/harvester/menu_render.h b/engines/harvester/menu/menu_render.h
similarity index 99%
rename from engines/harvester/menu_render.h
rename to engines/harvester/menu/menu_render.h
index b64ab5280af..5ec5dfe9ca7 100644
--- a/engines/harvester/menu_render.h
+++ b/engines/harvester/menu/menu_render.h
@@ -23,7 +23,7 @@
 #define HARVESTER_MENU_RENDER_H
 
 #include "graphics/font.h"
-#include "harvester/menu.h"
+#include "harvester/menu/menu.h"
 
 namespace Graphics {
 class Screen;
diff --git a/engines/harvester/menu_save.cpp b/engines/harvester/menu/menu_save.cpp
similarity index 99%
rename from engines/harvester/menu_save.cpp
rename to engines/harvester/menu/menu_save.cpp
index 607f52d3213..c32694aaab6 100644
--- a/engines/harvester/menu_save.cpp
+++ b/engines/harvester/menu/menu_save.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/menu.h"
-#include "harvester/menu_render.h"
+#include "harvester/menu/menu.h"
+#include "harvester/menu/menu_render.h"
 
 #include "common/algorithm.h"
 #include "common/config-manager.h"
@@ -30,7 +30,7 @@
 #include "graphics/framelimiter.h"
 #include "harvester/cft_font.h"
 #include "harvester/entity.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/harvester.h"
 #include "harvester/palette_utils.h"
 #include "harvester/text.h"
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
index a51f6ff6be3..08ec130bde0 100644
--- a/engines/harvester/metaengine.cpp
+++ b/engines/harvester/metaengine.cpp
@@ -28,7 +28,7 @@
 #include "harvester/detection.h"
 #include "harvester/metaengine.h"
 #include "harvester/harvester.h"
-#include "harvester/input.h"
+#include "harvester/scene/input.h"
 
 static const ADExtraGuiOptionsMap gameGuiOptions[] = {
 	{
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 5371cde46e3..3faf628a78a 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -7,56 +7,56 @@ MODULE_OBJS = \
 	cheats.o \
 	console.o \
 	dialogue.o \
-	flow.o \
-	flow_debug.o \
-	flow_map.o \
-	flow_scene.o \
-	flow_startup.o \
-	flow_transition.o \
+	flow/flow.o \
+	flow/flow_debug.o \
+	flow/flow_map.o \
+	flow/flow_scene.o \
+	flow/flow_startup.o \
+	flow/flow_transition.o \
 	fst_player.o \
 	harvester.o \
 	inventory.o \
 	media_manager.o \
-	menu.o \
-	menu_config.o \
-	menu_navigation.o \
-	menu_options.o \
-	menu_prompts.o \
-	menu_render.o \
-	menu_save.o \
+	menu/menu.o \
+	menu/menu_config.o \
+	menu/menu_navigation.o \
+	menu/menu_options.o \
+	menu/menu_prompts.o \
+	menu/menu_render.o \
+	menu/menu_save.o \
 	metaengine.o \
 	monster.o \
-	player.o \
-	player_animation.o \
-	player_attack.o \
-	player_idle.o \
-	player_keyboard.o \
-	player_movement.o \
-	player_reactions.o \
-	player_target.o \
+	player/player.o \
+	player/player_animation.o \
+	player/player_attack.o \
+	player/player_idle.o \
+	player/player_keyboard.o \
+	player/player_movement.o \
+	player/player_reactions.o \
+	player/player_target.o \
 	resources.o \
-	combat.o \
-	combat_contact.o \
-	combat_effects.o \
-	combat_geometry.o \
-	combat_hud.o \
-	combat_monsters.o \
-	combat_reactions.o \
-	combat_targeting.o \
-	interaction.o \
-	input.o \
-	room_presentation.o \
-	scene_entities.o \
-	scene.o \
+	combat/combat.o \
+	combat/combat_contact.o \
+	combat/combat_effects.o \
+	combat/combat_geometry.o \
+	combat/combat_hud.o \
+	combat/combat_monsters.o \
+	combat/combat_reactions.o \
+	combat/combat_targeting.o \
+	scene/interaction.o \
+	scene/input.o \
+	scene/room_presentation.o \
+	scene/scene_entities.o \
+	scene/scene.o \
 	entity.o \
 	saveload.o \
-	script.o \
-	script_actors.o \
-	script_commands.o \
-	script_records.o \
-	script_room.o \
-	script_runtime.o \
-	script_save.o \
+	script/script.o \
+	script/script_actors.o \
+	script/script_commands.o \
+	script/script_records.o \
+	script/script_room.o \
+	script/script_runtime.o \
+	script/script_save.o \
 	text.o \
 	xfile_archive.o \
 	npc/authority_dialogue.o \
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index 2439d201869..7acca760b06 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -3,7 +3,7 @@
 
 #include "common/error.h"
 #include "common/str.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index be474d65cae..4f3e7a430db 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -24,7 +24,7 @@
 
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/player.cpp b/engines/harvester/player/player.cpp
similarity index 98%
rename from engines/harvester/player.cpp
rename to engines/harvester/player/player.cpp
index 867739a4113..0809297a20c 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player/player.cpp
@@ -19,9 +19,9 @@
  *
  */
 
-#include "harvester/player.h"
-#include "harvester/player_movement.h"
-#include "harvester/combat.h"
+#include "harvester/player/player.h"
+#include "harvester/player/player_movement.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "harvester/detection.h"
diff --git a/engines/harvester/player.h b/engines/harvester/player/player.h
similarity index 99%
rename from engines/harvester/player.h
rename to engines/harvester/player/player.h
index ef0dcd81b1d..23883e64a26 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player/player.h
@@ -22,7 +22,7 @@
 #ifndef HARVESTER_PLAYER_H
 #define HARVESTER_PLAYER_H
 
-#include "harvester/room_support.h"
+#include "harvester/scene/room_support.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/player_animation.cpp b/engines/harvester/player/player_animation.cpp
similarity index 98%
rename from engines/harvester/player_animation.cpp
rename to engines/harvester/player/player_animation.cpp
index f6afcca8e94..ab1d2ff48a3 100644
--- a/engines/harvester/player_animation.cpp
+++ b/engines/harvester/player/player_animation.cpp
@@ -19,9 +19,9 @@
  *
  */
 
-#include "harvester/player.h"
-#include "harvester/player_movement.h"
-#include "harvester/combat.h"
+#include "harvester/player/player.h"
+#include "harvester/player/player_movement.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
diff --git a/engines/harvester/player_attack.cpp b/engines/harvester/player/player_attack.cpp
similarity index 99%
rename from engines/harvester/player_attack.cpp
rename to engines/harvester/player/player_attack.cpp
index e4698488be5..2f1fd0191c2 100644
--- a/engines/harvester/player_attack.cpp
+++ b/engines/harvester/player/player_attack.cpp
@@ -19,9 +19,9 @@
  *
  */
 
-#include "harvester/player.h"
-#include "harvester/player_movement.h"
-#include "harvester/combat.h"
+#include "harvester/player/player.h"
+#include "harvester/player/player_movement.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
diff --git a/engines/harvester/player_idle.cpp b/engines/harvester/player/player_idle.cpp
similarity index 99%
rename from engines/harvester/player_idle.cpp
rename to engines/harvester/player/player_idle.cpp
index c2e2e63e77f..016ad972b98 100644
--- a/engines/harvester/player_idle.cpp
+++ b/engines/harvester/player/player_idle.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/player.h"
+#include "harvester/player/player.h"
 
 #include "common/debug.h"
 #include "common/util.h"
diff --git a/engines/harvester/player_keyboard.cpp b/engines/harvester/player/player_keyboard.cpp
similarity index 98%
rename from engines/harvester/player_keyboard.cpp
rename to engines/harvester/player/player_keyboard.cpp
index 47ee1b7bd23..265c1700e5d 100644
--- a/engines/harvester/player_keyboard.cpp
+++ b/engines/harvester/player/player_keyboard.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/player.h"
-#include "harvester/player_movement.h"
+#include "harvester/player/player.h"
+#include "harvester/player/player_movement.h"
 
 #include "common/debug.h"
 #include "common/util.h"
diff --git a/engines/harvester/player_movement.cpp b/engines/harvester/player/player_movement.cpp
similarity index 99%
rename from engines/harvester/player_movement.cpp
rename to engines/harvester/player/player_movement.cpp
index b60a0eafcb2..d124f267250 100644
--- a/engines/harvester/player_movement.cpp
+++ b/engines/harvester/player/player_movement.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/player_movement.h"
+#include "harvester/player/player_movement.h"
 
 #include "common/debug.h"
 #include "common/util.h"
diff --git a/engines/harvester/player_movement.h b/engines/harvester/player/player_movement.h
similarity index 98%
rename from engines/harvester/player_movement.h
rename to engines/harvester/player/player_movement.h
index a2304296de9..5d586ea6958 100644
--- a/engines/harvester/player_movement.h
+++ b/engines/harvester/player/player_movement.h
@@ -22,7 +22,7 @@
 #ifndef HARVESTER_PLAYER_MOVEMENT_H
 #define HARVESTER_PLAYER_MOVEMENT_H
 
-#include "harvester/player.h"
+#include "harvester/player/player.h"
 
 namespace Harvester {
 namespace PlayerMovement {
diff --git a/engines/harvester/player_reactions.cpp b/engines/harvester/player/player_reactions.cpp
similarity index 99%
rename from engines/harvester/player_reactions.cpp
rename to engines/harvester/player/player_reactions.cpp
index 57bdf929e03..9ca61143fcd 100644
--- a/engines/harvester/player_reactions.cpp
+++ b/engines/harvester/player/player_reactions.cpp
@@ -19,9 +19,9 @@
  *
  */
 
-#include "harvester/player.h"
-#include "harvester/player_movement.h"
-#include "harvester/combat.h"
+#include "harvester/player/player.h"
+#include "harvester/player/player_movement.h"
+#include "harvester/combat/combat.h"
 
 #include "common/debug.h"
 #include "common/util.h"
diff --git a/engines/harvester/player_target.cpp b/engines/harvester/player/player_target.cpp
similarity index 99%
rename from engines/harvester/player_target.cpp
rename to engines/harvester/player/player_target.cpp
index 3428f81cfeb..03c3da0a3a9 100644
--- a/engines/harvester/player_target.cpp
+++ b/engines/harvester/player/player_target.cpp
@@ -19,8 +19,8 @@
  *
  */
 
-#include "harvester/player.h"
-#include "harvester/player_movement.h"
+#include "harvester/player/player.h"
+#include "harvester/player/player_movement.h"
 
 #include "common/debug.h"
 #include "common/util.h"
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index d3ad83d8de2..748782e7b6d 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -24,9 +24,9 @@
 #include "common/algorithm.h"
 #include "common/serializer.h"
 #include "harvester/detection.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/resources.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/input.cpp b/engines/harvester/scene/input.cpp
similarity index 99%
rename from engines/harvester/input.cpp
rename to engines/harvester/scene/input.cpp
index 4aa4bca1928..2402be6c3ad 100644
--- a/engines/harvester/input.cpp
+++ b/engines/harvester/scene/input.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/input.h"
+#include "harvester/scene/input.h"
 
 #include "common/debug.h"
 #include "graphics/screen.h"
@@ -27,15 +27,15 @@
 #include "harvester/cheats.h"
 #include "harvester/detection.h"
 #include "harvester/dialogue.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
-#include "harvester/player.h"
-#include "harvester/interaction.h"
-#include "harvester/room_presentation.h"
-#include "harvester/scene.h"
-#include "harvester/room_support.h"
-#include "harvester/scene_entities.h"
+#include "harvester/player/player.h"
+#include "harvester/scene/interaction.h"
+#include "harvester/scene/room_presentation.h"
+#include "harvester/scene/scene.h"
+#include "harvester/scene/room_support.h"
+#include "harvester/scene/scene_entities.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/input.h b/engines/harvester/scene/input.h
similarity index 100%
rename from engines/harvester/input.h
rename to engines/harvester/scene/input.h
diff --git a/engines/harvester/interaction.cpp b/engines/harvester/scene/interaction.cpp
similarity index 99%
rename from engines/harvester/interaction.cpp
rename to engines/harvester/scene/interaction.cpp
index a5f33947652..37156a5d23f 100644
--- a/engines/harvester/interaction.cpp
+++ b/engines/harvester/scene/interaction.cpp
@@ -19,19 +19,19 @@
  *
  */
 
-#include "harvester/interaction.h"
+#include "harvester/scene/interaction.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
 #include "common/textconsole.h"
 #include "harvester/art.h"
 #include "harvester/detection.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
-#include "harvester/player.h"
-#include "harvester/scene_entities.h"
+#include "harvester/player/player.h"
+#include "harvester/scene/scene_entities.h"
 #include "harvester/resources.h"
 #include "harvester/entity.h"
 
diff --git a/engines/harvester/interaction.h b/engines/harvester/scene/interaction.h
similarity index 99%
rename from engines/harvester/interaction.h
rename to engines/harvester/scene/interaction.h
index 23150d27f58..16c1b125500 100644
--- a/engines/harvester/interaction.h
+++ b/engines/harvester/scene/interaction.h
@@ -24,7 +24,7 @@
 
 #include "common/error.h"
 #include "harvester/bitmap.h"
-#include "harvester/room_support.h"
+#include "harvester/scene/room_support.h"
 #include "harvester/saveload.h"
 
 namespace Harvester {
diff --git a/engines/harvester/room_presentation.cpp b/engines/harvester/scene/room_presentation.cpp
similarity index 99%
rename from engines/harvester/room_presentation.cpp
rename to engines/harvester/scene/room_presentation.cpp
index 936a77c136f..5f3fb7bd4dd 100644
--- a/engines/harvester/room_presentation.cpp
+++ b/engines/harvester/scene/room_presentation.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/room_presentation.h"
+#include "harvester/scene/room_presentation.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
@@ -33,12 +33,12 @@
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/entity.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/harvester.h"
 #include "harvester/palette_utils.h"
-#include "harvester/player.h"
+#include "harvester/player/player.h"
 #include "harvester/resources.h"
-#include "harvester/room_support.h"
+#include "harvester/scene/room_support.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/room_presentation.h b/engines/harvester/scene/room_presentation.h
similarity index 98%
rename from engines/harvester/room_presentation.h
rename to engines/harvester/scene/room_presentation.h
index 008531dbe41..65f73694c29 100644
--- a/engines/harvester/room_presentation.h
+++ b/engines/harvester/scene/room_presentation.h
@@ -24,7 +24,7 @@
 
 #include "common/error.h"
 #include "graphics/font.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Graphics {
 class Screen;
diff --git a/engines/harvester/room_support.h b/engines/harvester/scene/room_support.h
similarity index 98%
rename from engines/harvester/room_support.h
rename to engines/harvester/scene/room_support.h
index baeb8da57e0..2e6a217b1ab 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/scene/room_support.h
@@ -26,7 +26,7 @@
 #include "common/rect.h"
 #include "common/scummsys.h"
 #include "common/str.h"
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 namespace Graphics {
 class Font;
diff --git a/engines/harvester/scene.cpp b/engines/harvester/scene/scene.cpp
similarity index 99%
rename from engines/harvester/scene.cpp
rename to engines/harvester/scene/scene.cpp
index 3f61575dbae..55f0f82ab7f 100644
--- a/engines/harvester/scene.cpp
+++ b/engines/harvester/scene/scene.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/scene.h"
+#include "harvester/scene/scene.h"
 
 #include "backends/keymapper/keymapper.h"
 #include "common/algorithm.h"
@@ -30,11 +30,11 @@
 #include "harvester/art.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
-#include "harvester/flow.h"
+#include "harvester/flow/flow.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
-#include "harvester/player.h"
-#include "harvester/combat.h"
+#include "harvester/player/player.h"
+#include "harvester/combat/combat.h"
 #include "harvester/entity.h"
 
 #include "common/ptr.h"
@@ -44,11 +44,11 @@
 #include "harvester/cheats.h"
 #include "harvester/resources.h"
 #include "harvester/monster.h"
-#include "harvester/interaction.h"
-#include "harvester/input.h"
-#include "harvester/room_presentation.h"
-#include "harvester/room_support.h"
-#include "harvester/scene_entities.h"
+#include "harvester/scene/interaction.h"
+#include "harvester/scene/input.h"
+#include "harvester/scene/room_presentation.h"
+#include "harvester/scene/room_support.h"
+#include "harvester/scene/scene_entities.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/scene.h b/engines/harvester/scene/scene.h
similarity index 94%
rename from engines/harvester/scene.h
rename to engines/harvester/scene/scene.h
index f8a157b8d2f..76f25876ecc 100644
--- a/engines/harvester/scene.h
+++ b/engines/harvester/scene/scene.h
@@ -24,13 +24,13 @@
 
 #include "common/error.h"
 #include "common/str.h"
-#include "harvester/combat.h"
-#include "harvester/input.h"
-#include "harvester/player.h"
-#include "harvester/interaction.h"
-#include "harvester/room_presentation.h"
-#include "harvester/scene_entities.h"
-#include "harvester/room_support.h"
+#include "harvester/combat/combat.h"
+#include "harvester/scene/input.h"
+#include "harvester/player/player.h"
+#include "harvester/scene/interaction.h"
+#include "harvester/scene/room_presentation.h"
+#include "harvester/scene/scene_entities.h"
+#include "harvester/scene/room_support.h"
 #include "harvester/entity.h"
 
 namespace Harvester {
diff --git a/engines/harvester/scene_entities.cpp b/engines/harvester/scene/scene_entities.cpp
similarity index 99%
rename from engines/harvester/scene_entities.cpp
rename to engines/harvester/scene/scene_entities.cpp
index c01b91c5df5..632f83c362a 100644
--- a/engines/harvester/scene_entities.cpp
+++ b/engines/harvester/scene/scene_entities.cpp
@@ -19,14 +19,14 @@
  *
  */
 
-#include "harvester/scene_entities.h"
+#include "harvester/scene/scene_entities.h"
 
 #include "harvester/art.h"
 #include "harvester/detection.h"
 #include "harvester/resources.h"
 #include "harvester/monster.h"
-#include "harvester/player.h"
-#include "harvester/combat.h"
+#include "harvester/player/player.h"
+#include "harvester/combat/combat.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/scene_entities.h b/engines/harvester/scene/scene_entities.h
similarity index 98%
rename from engines/harvester/scene_entities.h
rename to engines/harvester/scene/scene_entities.h
index c6ef70c4154..0c348fa1165 100644
--- a/engines/harvester/scene_entities.h
+++ b/engines/harvester/scene/scene_entities.h
@@ -22,7 +22,7 @@
 #ifndef HARVESTER_SCENE_ENTITIES_H
 #define HARVESTER_SCENE_ENTITIES_H
 
-#include "harvester/room_support.h"
+#include "harvester/scene/room_support.h"
 #include "harvester/entity.h"
 
 namespace Harvester {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script/script.cpp
similarity index 99%
rename from engines/harvester/script.cpp
rename to engines/harvester/script/script.cpp
index 11523f22d04..02179c5641f 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script/script.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 #include "common/config-manager.h"
 #include "common/debug.h"
diff --git a/engines/harvester/script.h b/engines/harvester/script/script.h
similarity index 100%
rename from engines/harvester/script.h
rename to engines/harvester/script/script.h
diff --git a/engines/harvester/script_actors.cpp b/engines/harvester/script/script_actors.cpp
similarity index 99%
rename from engines/harvester/script_actors.cpp
rename to engines/harvester/script/script_actors.cpp
index 959de57a459..2486989ebe5 100644
--- a/engines/harvester/script_actors.cpp
+++ b/engines/harvester/script/script_actors.cpp
@@ -19,12 +19,12 @@
  *
  */
 
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
 #include "harvester/detection.h"
-#include "harvester/player.h"
+#include "harvester/player/player.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/script_commands.cpp b/engines/harvester/script/script_commands.cpp
similarity index 99%
rename from engines/harvester/script_commands.cpp
rename to engines/harvester/script/script_commands.cpp
index 5452e162e95..7308c12b5df 100644
--- a/engines/harvester/script_commands.cpp
+++ b/engines/harvester/script/script_commands.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
diff --git a/engines/harvester/script_records.cpp b/engines/harvester/script/script_records.cpp
similarity index 99%
rename from engines/harvester/script_records.cpp
rename to engines/harvester/script/script_records.cpp
index 28550c473d4..7033d7745a9 100644
--- a/engines/harvester/script_records.cpp
+++ b/engines/harvester/script/script_records.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
diff --git a/engines/harvester/script_room.cpp b/engines/harvester/script/script_room.cpp
similarity index 99%
rename from engines/harvester/script_room.cpp
rename to engines/harvester/script/script_room.cpp
index de311cdffa3..2f79d32839c 100644
--- a/engines/harvester/script_room.cpp
+++ b/engines/harvester/script/script_room.cpp
@@ -19,12 +19,12 @@
  *
  */
 
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 #include "common/debug.h"
 #include "common/endian.h"
 #include "harvester/detection.h"
-#include "harvester/player.h"
+#include "harvester/player/player.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
diff --git a/engines/harvester/script_runtime.cpp b/engines/harvester/script/script_runtime.cpp
similarity index 99%
rename from engines/harvester/script_runtime.cpp
rename to engines/harvester/script/script_runtime.cpp
index 70d529703d9..45e553f48da 100644
--- a/engines/harvester/script_runtime.cpp
+++ b/engines/harvester/script/script_runtime.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
diff --git a/engines/harvester/script_save.cpp b/engines/harvester/script/script_save.cpp
similarity index 99%
rename from engines/harvester/script_save.cpp
rename to engines/harvester/script/script_save.cpp
index 0af41d760d5..102e2d7707c 100644
--- a/engines/harvester/script_save.cpp
+++ b/engines/harvester/script/script_save.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/script.h"
+#include "harvester/script/script.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"




More information about the Scummvm-git-logs mailing list