[Scummvm-git-logs] scummvm master -> 5e44c1d40fb49bd5db39e1a439c2e5d9081b9049

alexbevi noreply at scummvm.org
Wed Oct 21 01:18:17 UTC 2026


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

Summary:
22d4ac18b0 HARVESTER: Separate dialogue layout and shared drawing helpers
0d9611d093 HARVESTER: Separate dialogue sessions from handler coordination
4c6a23d829 HARVESTER: Separate dialogue speech and modal playback
41294db54f HARVESTER: Separate dialogue menus and text entry
2c315a9616 HARVESTER: Separate audio decoding and scope input ownership
eef97d3eb7 HARVESTER: Separate media playback and resource lifecycle
0e7664836f HARVESTER: Separate interaction interruption and terminal outcomes
36999a576f HARVESTER: Separate interaction transitions and restarts
dba8b8b2fd HARVESTER: Separate same-scene effects and continuations
5e92cc9095 HARVESTER: Scope entity and archive setup ownership
8c364fa324 HARVESTER: Scope FST audio queue ownership
4d488b07f2 HARVESTER: Separate inventory, keyboard and scene input handling
762eaeed53 HARVESTER: Extract Sergeant dialogue branches into methods
35463df436 HARVESTER: Extract Jimmy dialogue routines into methods
5e44c1d40f HARVESTER: Extract Buster fatal dialogue sequence


Commit: 22d4ac18b04f94d2a00cc9cb6002988ca0e0936c
    https://github.com/scummvm/scummvm/commit/22d4ac18b04f94d2a00cc9cb6002988ca0e0936c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:52-04:00

Commit Message:
HARVESTER: Separate dialogue layout and shared drawing helpers

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/dialogue/dialogue.cpp
  A engines/harvester/dialogue/dialogue.h
  A engines/harvester/dialogue/dialogue_render.cpp
  A engines/harvester/dialogue/dialogue_render.h
  R engines/harvester/dialogue.cpp
  R engines/harvester/dialogue.h
    engines/harvester/flow/flow.h
    engines/harvester/module.mk
    engines/harvester/scene/input.cpp


diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue/dialogue.cpp
similarity index 90%
rename from engines/harvester/dialogue.cpp
rename to engines/harvester/dialogue/dialogue.cpp
index 2a687180268..01c6c3cae7c 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue/dialogue.cpp
@@ -19,7 +19,8 @@
  *
  */
 
-#include "harvester/dialogue.h"
+#include "harvester/dialogue/dialogue.h"
+#include "harvester/dialogue/dialogue_render.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
@@ -75,7 +76,8 @@
 #include "harvester/npc/ryder_dialogue.h"
 #include "harvester/npc/sergeant_dialogue.h"
 #include "harvester/npc/nude_man_dialogue.h"
-#include "harvester/palette_utils.h"
+#include "harvester/flow/flow_support.h"
+#include "harvester/scene/room_presentation.h"
 #include "harvester/npc/stephanie_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/sparky_dialogue.h"
@@ -103,7 +105,6 @@ static const int kDialogueSubtitleTextX = 178;
 static const int kDialogueSubtitleTextY = 12;
 static const int kDialogueSubtitleTextWidth = 316;
 static const int kDialogueKeywordTextX = 178;
-static const int kDialogueKeywordTextY = 34;
 static const int kDialogueKeywordTextWidth = 300;
 static const int kDialogueOtherX = 171;
 static const int kDialogueOtherY = 183;
@@ -113,10 +114,6 @@ static const uint kDialogueTextEntryMaxLength = 26;
 static const int kDialogueTextEntryMaxWidth = 550;
 static const uint32 kDialogueTextEntryBlinkTicks = 0x30;
 static const int kDialogueOtherWidth = 120;
-static const int kDialogueTopicStartX = 168;
-static const int kDialogueTopicEndX = 482;
-static const int kDialogueOtherStartY = 170;
-static const int kDialogueOtherEndY = 193;
 static const int kDialogueKeywordTitleYOffset = 4;
 static const int kDialogueGenericByeResponseIndex = 13;
 static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
@@ -125,7 +122,6 @@ static const char *const kDialogueLocalizedKeywordBitmapPath = "GRAPHIC/OTHER/KE
 
 static const byte kTextColorNormal = 255;
 static const byte kTextColorHover = 251;
-static const byte kShadowColor = 0;
 
 static bool isDialogueVoiceInterruptEvent(const Common::Event &event) {
 	return event.type == Common::EVENT_LBUTTONDOWN ||
@@ -184,35 +180,6 @@ static void syncDialogueSharedState(Common::Serializer &s, DialogueSharedState &
 	syncDialogueInt(s, state.dialogueStateD2f30);
 }
 
-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 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 bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
-	Common::Array<byte> data;
-	if (!resources.loadFile(path, data) || data.size() < 12)
-		return false;
-
-	bitmap = IndexedBitmap();
-	return decodeBitmap(data, bitmap);
-}
-
 static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
@@ -232,63 +199,6 @@ static bool isSecondaryMouseDown() {
 		((g_system->getEventManager()->getButtonState() & 2) != 0);
 }
 
-static int getDialogueTextLineHeight(const Graphics::Font &font) {
-	return font.getFontHeight() + 2;
-}
-
-static int resolveDialogueTextboxIndex(uint lineCount) {
-	if (lineCount <= 1)
-		return 0;
-	if (lineCount < 3)
-		return 1;
-	if (lineCount < 4)
-		return 2;
-	if (lineCount < 6)
-		return 3;
-	if (lineCount < 7)
-		return 4;
-	if (lineCount < 9)
-		return 5;
-	if (lineCount < 11)
-		return 6;
-	return 7;
-}
-
-static int resolveDialogueResponseTextboxIndex(uint responseRowCount) {
-	if (responseRowCount == 0)
-		return 0;
-	if (responseRowCount < 3)
-		return 3;
-	if (responseRowCount < 4)
-		return 4;
-	if (responseRowCount < 6)
-		return 5;
-	return 6;
-}
-
-static void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font &font,
-		const Common::Array<Common::String> &lines, int x, int y, int width) {
-	const int lineHeight = getDialogueTextLineHeight(font);
-	for (uint i = 0; i < lines.size(); ++i)
-		font.drawString(&screen, lines[i], x, y + (int)i * lineHeight, width, 0);
-}
-
-static void wrapDialogueTextLikeNative(const Graphics::Font &font, bool usesCft,
-		const Common::String &text, int width, Common::Array<Common::String> &lines) {
-	lines.clear();
-	if (text.empty())
-		return;
-
-	if (!usesCft || width <= 0) {
-		font.wordWrapText(text, width, lines);
-		if (lines.empty())
-			lines.push_back(text);
-		return;
-	}
-
-	wrapCftTextByCharacterCount(font, text, width, lines);
-}
-
 static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
 	parts.clear();
 	if (line.empty())
@@ -352,24 +262,6 @@ static Common::String buildDialogueVoicePath(const Script &script, int wavId) {
 	return Common::String::format("%s%d.CMP", script.getVoicePath().c_str(), wavId);
 }
 
-static int getDialogueMenuItemAt(const Graphics::Font &font, uint itemCount, const Common::Point &mousePos) {
-	if (itemCount == 0)
-		return -1;
-	if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
-		return -1;
-	if (mousePos.y < kDialogueKeywordTextY)
-		return -1;
-
-	const int lineHeight = getDialogueTextLineHeight(font);
-	const int selection = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
-	return selection >= 0 && selection < (int)itemCount ? selection : -1;
-}
-
-static bool isDialogueOtherHit(const Common::Point &mousePos) {
-	return mousePos.x >= kDialogueTopicStartX && mousePos.x <= kDialogueTopicEndX &&
-		mousePos.y >= kDialogueOtherStartY && mousePos.y <= kDialogueOtherEndY;
-}
-
 class RoomNpcDialogueSession : public DialogueRuntimeCallbacks {
 public:
 	struct DialogueResponseOptionLayout {
@@ -463,9 +355,9 @@ public:
 		Common::Array<Common::String> subtitleLines;
 		const IndexedBitmap *textboxBitmap = nullptr;
 		if (textEnabled) {
-			wrapDialogueTextLikeNative(*_subtitleFont, _subtitleFontUsesCft,
+			DialogueRendering::wrapDialogueTextLikeNative(*_subtitleFont, _subtitleFontUsesCft,
 				subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
-			textboxBitmap = _art->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
+			textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueTextboxIndex(subtitleLines.size()));
 		}
 
 		const Common::String voicePath = buildDialogueVoicePath(*_script, wavId);
@@ -629,7 +521,7 @@ public:
 			while (!_engine.shouldQuit() && !activateRequestedDisc) {
 				if (needsRedraw) {
 					screen->fillRect(screen->getBounds(), 0);
-					blitBitmap(*screen, promptBitmap, 0, 0);
+					RoomPresentation::blitBitmap(*screen, promptBitmap, 0, 0);
 					setScaledPalette(*screen, promptPalette, 1.0f);
 					if (_entityManager)
 						_entityManager->drawCursor(*screen);
@@ -710,8 +602,8 @@ public:
 				textEntryCursorVisible = !textEntryCursorVisible;
 				textEntryBlinkTimestamp = now;
 			}
-			const int hoveredTopicIndex = editingOther ? -1 : getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
-			const bool hoverOther = editingOther || isDialogueOtherHit(_mousePos);
+			const int hoveredTopicIndex = editingOther ? -1 : DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
+			const bool hoverOther = editingOther || DialogueRendering::isDialogueOtherHit(_mousePos);
 			drawDialogueOverlay(&_keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
 				editingOther ? &typedTopic : nullptr, textEntryCursorVisible);
 
@@ -780,7 +672,7 @@ public:
 					}
 					break;
 				case Common::EVENT_LBUTTONDOWN: {
-					const int clickedTopic = getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
+					const int clickedTopic = DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
 					if (clickedTopic >= 0) {
 						selectedTopic = topics[(uint)clickedTopic];
 						selection.fromGenericBye = clickedTopic == (int)topics.size() - 1;
@@ -788,7 +680,7 @@ public:
 							clickedTopic + 1, selectedTopic.c_str());
 						return Common::kNoError;
 					}
-					if (isDialogueOtherHit(_mousePos)) {
+					if (DialogueRendering::isDialogueOtherHit(_mousePos)) {
 						typedTopic.clear();
 						editingOther = true;
 						textEntryCursorVisible = false;
@@ -929,7 +821,7 @@ private:
 		if (usesCft)
 			font.drawString(activeScreen, text, x, y, width, 0);
 		else
-			drawShadowedString(*activeScreen, font, text, x, y, width, color);
+			RoomPresentation::drawShadowedString(*activeScreen, font, text, x, y, width, color);
 	}
 
 	void ensureSpeakerPortrait(const Common::String &speakerId, int headVariant) {
@@ -960,7 +852,7 @@ private:
 			return;
 
 		setScaledPalette(*activeScreen, _palette, _paletteBrightness);
-		blitBitmap(*activeScreen, _backdrop, 0, 0);
+		RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
 		if (_leftHeadVisible && _leftHeadBitmap.isValid())
 			blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
 		if (_rightHeadVisible && _rightHeadBitmap.isValid())
@@ -970,12 +862,12 @@ private:
 
 		if (subtitleLines) {
 			if (_subtitleFontUsesCft) {
-				drawDialogueTextLines(*activeScreen, *_subtitleFont, *subtitleLines,
+				DialogueRendering::drawDialogueTextLines(*activeScreen, *_subtitleFont, *subtitleLines,
 					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
 			} else {
-				const int lineHeight = getDialogueTextLineHeight(*_subtitleFont);
+				const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_subtitleFont);
 				for (uint i = 0; i < subtitleLines->size(); ++i) {
-					drawShadowedString(*activeScreen, *_subtitleFont, (*subtitleLines)[i],
+					RoomPresentation::drawShadowedString(*activeScreen, *_subtitleFont, (*subtitleLines)[i],
 						kDialogueSubtitleTextX,
 						kDialogueSubtitleTextY + (int)i * lineHeight,
 						kDialogueSubtitleTextWidth, kTextColorNormal);
@@ -994,13 +886,13 @@ private:
 					kTextColorNormal);
 			}
 
-			const int lineHeight = getDialogueTextLineHeight(*_menuFont);
+			const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
 			for (uint i = 0; i < topics->size(); ++i) {
 				const bool highlighted = (int)i == hoveredTopicIndex;
 				const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
 				const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
 				drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
-					kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
+					DialogueRendering::kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
 					highlighted ? kTextColorNormal : kTextColorHover);
 			}
 
@@ -1047,7 +939,7 @@ private:
 			option.rowStart = (int)totalRows;
 			for (const Common::String &explicitLine : explicitLines) {
 				Common::Array<Common::String> wrappedExplicitLines;
-				wrapDialogueTextLikeNative(*_menuFont, _menuFontUsesCft,
+				DialogueRendering::wrapDialogueTextLikeNative(*_menuFont, _menuFontUsesCft,
 					explicitLine, kDialogueSubtitleTextWidth, wrappedExplicitLines);
 				if (wrappedExplicitLines.empty())
 					wrappedExplicitLines.push_back(explicitLine);
@@ -1098,13 +990,13 @@ private:
 
 	int getResponseMenuItemAt(const Common::Array<DialogueResponseOptionLayout> &options,
 			uint totalRows, const Common::Point &mousePos) {
-		if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
+		if (mousePos.x < DialogueRendering::kDialogueTopicStartX || mousePos.x > DialogueRendering::kDialogueTopicEndX)
 			return -1;
-		if (mousePos.y < kDialogueKeywordTextY)
+		if (mousePos.y < DialogueRendering::kDialogueKeywordTextY)
 			return -1;
 
-		const int lineHeight = getDialogueTextLineHeight(*_menuFont);
-		const int row = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
+		const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
+		const int row = (mousePos.y - DialogueRendering::kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
 		if (row < 0 || row >= (int)totalRows)
 			return -1;
 
@@ -1124,7 +1016,7 @@ private:
 			return;
 
 		setScaledPalette(*activeScreen, _palette, _paletteBrightness);
-		blitBitmap(*activeScreen, _backdrop, 0, 0);
+		RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
 		if (_leftHeadVisible && _leftHeadBitmap.isValid())
 			blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
 		if (_rightHeadVisible && _rightHeadBitmap.isValid())
@@ -1139,8 +1031,8 @@ private:
 		const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
 		drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
 
-		const int lineHeight = getDialogueTextLineHeight(*_menuFont);
-		int drawY = kDialogueKeywordTextY;
+		const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
+		int drawY = DialogueRendering::kDialogueKeywordTextY;
 		for (uint i = 0; i < options.size(); ++i) {
 			const bool highlighted = (int)i == hoveredOptionIndex;
 			const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
@@ -1195,7 +1087,7 @@ private:
 		}
 		logDialogueMenuItems("Response menu", responseLineIndex, responseLine, optionTexts);
 
-		const IndexedBitmap *textboxBitmap = _art->getTextboxBitmap(resolveDialogueResponseTextboxIndex(totalRows));
+		const IndexedBitmap *textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueResponseTextboxIndex(totalRows));
 		Common::Error releaseError = waitForPointerRelease();
 		if (releaseError.getCode() != Common::kNoError)
 			return releaseError;
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue/dialogue.h
similarity index 100%
rename from engines/harvester/dialogue.h
rename to engines/harvester/dialogue/dialogue.h
diff --git a/engines/harvester/dialogue/dialogue_render.cpp b/engines/harvester/dialogue/dialogue_render.cpp
new file mode 100644
index 00000000000..feee8b6ca5d
--- /dev/null
+++ b/engines/harvester/dialogue/dialogue_render.cpp
@@ -0,0 +1,108 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/dialogue/dialogue_render.h"
+
+#include "common/util.h"
+#include "graphics/font.h"
+#include "graphics/screen.h"
+#include "harvester/cft_font.h"
+
+namespace Harvester {
+namespace DialogueRendering {
+
+int getDialogueTextLineHeight(const Graphics::Font &font) {
+	return font.getFontHeight() + 2;
+}
+
+int resolveDialogueTextboxIndex(uint lineCount) {
+	if (lineCount <= 1)
+		return 0;
+	if (lineCount < 3)
+		return 1;
+	if (lineCount < 4)
+		return 2;
+	if (lineCount < 6)
+		return 3;
+	if (lineCount < 7)
+		return 4;
+	if (lineCount < 9)
+		return 5;
+	if (lineCount < 11)
+		return 6;
+	return 7;
+}
+
+int resolveDialogueResponseTextboxIndex(uint responseRowCount) {
+	if (responseRowCount == 0)
+		return 0;
+	if (responseRowCount < 3)
+		return 3;
+	if (responseRowCount < 4)
+		return 4;
+	if (responseRowCount < 6)
+		return 5;
+	return 6;
+}
+
+void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::Array<Common::String> &lines, int x, int y, int width) {
+	const int lineHeight = getDialogueTextLineHeight(font);
+	for (uint i = 0; i < lines.size(); ++i)
+		font.drawString(&screen, lines[i], x, y + (int)i * lineHeight, width, 0);
+}
+
+void wrapDialogueTextLikeNative(const Graphics::Font &font, bool usesCft,
+		const Common::String &text, int width, Common::Array<Common::String> &lines) {
+	lines.clear();
+	if (text.empty())
+		return;
+
+	if (!usesCft || width <= 0) {
+		font.wordWrapText(text, width, lines);
+		if (lines.empty())
+			lines.push_back(text);
+		return;
+	}
+
+	wrapCftTextByCharacterCount(font, text, width, lines);
+}
+
+int getDialogueMenuItemAt(const Graphics::Font &font, uint itemCount, const Common::Point &mousePos) {
+	if (itemCount == 0)
+		return -1;
+	if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
+		return -1;
+	if (mousePos.y < kDialogueKeywordTextY)
+		return -1;
+
+	const int lineHeight = getDialogueTextLineHeight(font);
+	const int selection = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
+	return selection >= 0 && selection < (int)itemCount ? selection : -1;
+}
+
+bool isDialogueOtherHit(const Common::Point &mousePos) {
+	return mousePos.x >= kDialogueTopicStartX && mousePos.x <= kDialogueTopicEndX &&
+		mousePos.y >= kDialogueOtherStartY && mousePos.y <= kDialogueOtherEndY;
+}
+
+} // End of namespace DialogueRendering
+} // End of namespace Harvester
diff --git a/engines/harvester/dialogue/dialogue_render.h b/engines/harvester/dialogue/dialogue_render.h
new file mode 100644
index 00000000000..44bec954daa
--- /dev/null
+++ b/engines/harvester/dialogue/dialogue_render.h
@@ -0,0 +1,56 @@
+/* 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_DIALOGUE_RENDER_H
+#define HARVESTER_DIALOGUE_RENDER_H
+
+#include "common/array.h"
+#include "common/rect.h"
+#include "common/str.h"
+
+namespace Graphics {
+class Font;
+class Screen;
+}
+
+namespace Harvester {
+namespace DialogueRendering {
+
+static const int kDialogueKeywordTextY = 34;
+static const int kDialogueTopicStartX = 168;
+static const int kDialogueTopicEndX = 482;
+static const int kDialogueOtherStartY = 170;
+static const int kDialogueOtherEndY = 193;
+
+int getDialogueTextLineHeight(const Graphics::Font &font);
+int resolveDialogueTextboxIndex(uint lineCount);
+int resolveDialogueResponseTextboxIndex(uint responseRowCount);
+void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::Array<Common::String> &lines, int x, int y, int width);
+void wrapDialogueTextLikeNative(const Graphics::Font &font, bool usesCft,
+		const Common::String &text, int width, Common::Array<Common::String> &lines);
+int getDialogueMenuItemAt(const Graphics::Font &font, uint itemCount, const Common::Point &mousePos);
+bool isDialogueOtherHit(const Common::Point &mousePos);
+
+} // End of namespace DialogueRendering
+} // End of namespace Harvester
+
+#endif // HARVESTER_DIALOGUE_RENDER_H
diff --git a/engines/harvester/flow/flow.h b/engines/harvester/flow/flow.h
index b056fe15f08..2d4a310d734 100644
--- a/engines/harvester/flow/flow.h
+++ b/engines/harvester/flow/flow.h
@@ -27,7 +27,7 @@
 #include "common/rect.h"
 #include "common/str.h"
 #include "harvester/cheats.h"
-#include "harvester/dialogue.h"
+#include "harvester/dialogue/dialogue.h"
 #include "harvester/inventory.h"
 #include "harvester/menu/menu.h"
 #include "harvester/scene/scene.h"
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 3faf628a78a..3c28b6bae8b 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -6,7 +6,8 @@ MODULE_OBJS = \
 	cft_font.o \
 	cheats.o \
 	console.o \
-	dialogue.o \
+	dialogue/dialogue.o \
+	dialogue/dialogue_render.o \
 	flow/flow.o \
 	flow/flow_debug.o \
 	flow/flow_map.o \
diff --git a/engines/harvester/scene/input.cpp b/engines/harvester/scene/input.cpp
index 2402be6c3ad..39fd88352a2 100644
--- a/engines/harvester/scene/input.cpp
+++ b/engines/harvester/scene/input.cpp
@@ -26,7 +26,7 @@
 #include "harvester/art.h"
 #include "harvester/cheats.h"
 #include "harvester/detection.h"
-#include "harvester/dialogue.h"
+#include "harvester/dialogue/dialogue.h"
 #include "harvester/flow/flow.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"


Commit: 0d9611d093bad086517c39ffb19533822ca38958
    https://github.com/scummvm/scummvm/commit/0d9611d093bad086517c39ffb19533822ca38958
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:52-04:00

Commit Message:
HARVESTER: Separate dialogue sessions from handler coordination

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/dialogue/dialogue_session.cpp
  A engines/harvester/dialogue/dialogue_session.h
    engines/harvester/dialogue/dialogue.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/dialogue/dialogue.cpp b/engines/harvester/dialogue/dialogue.cpp
index 01c6c3cae7c..627ad1c03d8 100644
--- a/engines/harvester/dialogue/dialogue.cpp
+++ b/engines/harvester/dialogue/dialogue.cpp
@@ -20,25 +20,16 @@
  */
 
 #include "harvester/dialogue/dialogue.h"
-#include "harvester/dialogue/dialogue_render.h"
+#include "harvester/dialogue/dialogue_session.h"
 
-#include "common/algorithm.h"
 #include "common/debug.h"
-#include "common/events.h"
-#include "common/ptr.h"
-#include "common/system.h"
-#include "graphics/font.h"
-#include "graphics/fontman.h"
-#include "graphics/framelimiter.h"
+#include "harvester/detection.h"
+#include "harvester/harvester.h"
 #include "harvester/npc/authority_dialogue.h"
 #include "harvester/npc/beggar_dialogue.h"
 #include "harvester/npc/boyle_dialogue.h"
 #include "harvester/npc/buster_dialogue.h"
 #include "harvester/npc/butcher_dialogue.h"
-#include "harvester/cft_font.h"
-#include "harvester/detection.h"
-#include "harvester/fst_player.h"
-#include "harvester/harvester.h"
 #include "harvester/npc/chessmaster_dialogue.h"
 #include "harvester/npc/cloak_atnd_dialogue.h"
 #include "harvester/npc/curator_dialogue.h"
@@ -76,8 +67,6 @@
 #include "harvester/npc/ryder_dialogue.h"
 #include "harvester/npc/sergeant_dialogue.h"
 #include "harvester/npc/nude_man_dialogue.h"
-#include "harvester/flow/flow_support.h"
-#include "harvester/scene/room_presentation.h"
 #include "harvester/npc/stephanie_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/sparky_dialogue.h"
@@ -86,57 +75,11 @@
 #include "harvester/npc/vet_dialogue.h"
 #include "harvester/npc/whaley_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
-#include "harvester/resources.h"
-#include "harvester/entity.h"
-#include "harvester/art.h"
-#include "harvester/flow/flow.h"
-#include "harvester/text.h"
 
 namespace Harvester {
 
 namespace {
 
-static const int kDialogueOverlayX = 167;
-static const int kDialogueOverlayY = 6;
-static const int kDialogueLeftHeadX = 6;
-static const int kDialogueRightHeadX = 474;
-static const int kDialogueHeadY = 6;
-static const int kDialogueSubtitleTextX = 178;
-static const int kDialogueSubtitleTextY = 12;
-static const int kDialogueSubtitleTextWidth = 316;
-static const int kDialogueKeywordTextX = 178;
-static const int kDialogueKeywordTextWidth = 300;
-static const int kDialogueOtherX = 171;
-static const int kDialogueOtherY = 183;
-static const int kDialogueTextEntryX = 208;
-static const int kDialogueTextEntryY = 184;
-static const uint kDialogueTextEntryMaxLength = 26;
-static const int kDialogueTextEntryMaxWidth = 550;
-static const uint32 kDialogueTextEntryBlinkTicks = 0x30;
-static const int kDialogueOtherWidth = 120;
-static const int kDialogueKeywordTitleYOffset = 4;
-static const int kDialogueGenericByeResponseIndex = 13;
-static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
-static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
-static const char *const kDialogueLocalizedKeywordBitmapPath = "GRAPHIC/OTHER/KEYWORD.BM";
-
-static const byte kTextColorNormal = 255;
-static const byte kTextColorHover = 251;
-
-static bool isDialogueVoiceInterruptEvent(const Common::Event &event) {
-	return event.type == Common::EVENT_LBUTTONDOWN ||
-		event.type == Common::EVENT_RBUTTONDOWN ||
-		(event.type == Common::EVENT_KEYDOWN && event.kbd.keycode == Common::KEYCODE_ESCAPE);
-}
-
-static bool isDialoguePointerPressEvent(const Common::Event &event) {
-	return event.type == Common::EVENT_LBUTTONDOWN || event.type == Common::EVENT_RBUTTONDOWN;
-}
-
-static bool isDialogueContinueEvent(const Common::Event &event) {
-	return isDialoguePointerPressEvent(event) || event.type == Common::EVENT_KEYDOWN;
-}
-
 static void syncDialogueSharedState(Common::Serializer &s, DialogueSharedState &state) {
 	syncDialogueBool(s, state.boyleGascanApplicationState);
 	syncDialogueBool(s, state.dialogueStateD2e98);
@@ -180,1011 +123,6 @@ static void syncDialogueSharedState(Common::Serializer &s, DialogueSharedState &
 	syncDialogueInt(s, state.dialogueStateD2f30);
 }
 
-static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
-	Common::Array<byte> data;
-	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
-		return false;
-
-	memcpy(dest, data.data(), 256 * 3);
-	return true;
-}
-
-static bool isPrimaryMouseDown() {
-	return g_system && g_system->getEventManager() &&
-		((g_system->getEventManager()->getButtonState() & 1) != 0);
-}
-
-static bool isSecondaryMouseDown() {
-	return g_system && g_system->getEventManager() &&
-		((g_system->getEventManager()->getButtonState() & 2) != 0);
-}
-
-static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
-	parts.clear();
-	if (line.empty())
-		return;
-
-	Common::String token;
-	for (uint i = 0; i < line.size(); ++i) {
-		if (line[i] == '/') {
-			parts.push_back(Common::move(token));
-			token.clear();
-			continue;
-		}
-
-		token += line[i];
-	}
-
-	parts.push_back(Common::move(token));
-}
-
-static void logDialogueMenuItems(const char *label, int sourceLineIndex,
-		const Common::String &rawLine, const Common::Array<Common::String> &items) {
-	if (sourceLineIndex >= 0) {
-		debugC(3, kDebugDialogue, "Harvester: %s source line 0x%x (%d) raw='%s'",
-			label, sourceLineIndex, sourceLineIndex + 1, rawLine.c_str());
-	} else {
-		debugC(3, kDebugDialogue, "Harvester: %s source raw='%s'", label, rawLine.c_str());
-	}
-
-	if (items.empty()) {
-		debugC(1, kDebugDialogue, "Harvester: %s has no explicit items", label);
-		return;
-	}
-
-	for (uint i = 0; i < items.size(); ++i)
-		debugC(3, kDebugDialogue, "Harvester: %s item[%u]='%s'", label, i + 1, items[i].c_str());
-}
-
-static Common::String buildDialogueHeadId(const Common::String &speakerId, int headVariant) {
-	return Common::String::format("%s%d", speakerId.c_str(), headVariant);
-}
-
-static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId, int headVariant,
-		IndexedBitmap &bitmap) {
-	Script *script = engine.getScript();
-	ResourceManager *resources = engine.getResources();
-	if (!script || !resources || speakerId.empty())
-		return false;
-
-	const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
-	const HeadRecord *head = script->findHeadRecord(headId);
-	if (!head)
-		return false;
-
-	return loadBitmapResource(*resources, head->portraitPath + ".BM", bitmap);
-}
-
-static Common::String buildDialogueVoicePath(const Script &script, int wavId) {
-	if (wavId <= 0)
-		return Common::String();
-
-	return Common::String::format("%s%d.CMP", script.getVoicePath().c_str(), wavId);
-}
-
-class RoomNpcDialogueSession : public DialogueRuntimeCallbacks {
-public:
-	struct DialogueResponseOptionLayout {
-		Common::String text;
-		Common::Array<Common::String> wrappedLines;
-		char hotkey = 0;
-		int rowStart = 0;
-		int rowCount = 0;
-	};
-
-	RoomNpcDialogueSession(HarvesterEngine &engine, Common::Point &mousePos, Flow &flow,
-			const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness,
-			const NpcRecord &npc)
-		: _engine(engine), _mousePos(mousePos), _flow(flow),
-		  _menuTextConfig(flow.getMenuTextConfig()), _backdrop(backdrop),
-		  _palette(palette), _paletteBrightness(paletteBrightness), _npc(npc),
-		  _script(engine.getScript()), _text(engine.getText()), _art(engine.getArt()),
-		  _entityManager(engine.getRuntimeEntities()),
-		  _fallbackFont(FontMan.getFontByUsage(Graphics::FontManager::kGUIFont)),
-		  _subtitleFont(nullptr), _menuFont(nullptr), _highlightFont(nullptr),
-		  _subtitleFontUsesCft(false), _menuFontUsesCft(false),
-		  _highlightFontUsesCft(false), _leftHeadVisible(false),
-		  _rightHeadVisible(false) {
-		ResourceManager *resources = _engine.getResources();
-		if (!_script || !_text || !_art || !_fallbackFont || !_backdrop.isValid() ||
-				!_palette || !resources) {
-			return;
-		}
-
-		if (const CftFontResource *subtitleFontResource = _text->findFont("TEXTFONT")) {
-			_subtitleCftFont.reset(new HarvesterCftFont(*subtitleFontResource));
-			if (!_subtitleCftFont->isValid())
-				_subtitleCftFont.reset();
-		}
-		if (const CftFontResource *menuFontResource = _text->findFont("TEXTFNT2")) {
-			_menuCftFont.reset(new HarvesterCftFont(*menuFontResource));
-			if (!_menuCftFont->isValid())
-				_menuCftFont.reset();
-		}
-
-		_subtitleFont = _subtitleCftFont
-			? static_cast<const Graphics::Font *>(_subtitleCftFont.get())
-			: _fallbackFont;
-		_menuFont = _menuCftFont
-			? static_cast<const Graphics::Font *>(_menuCftFont.get())
-			: _subtitleFont;
-		_highlightFont = _subtitleFont;
-		_subtitleFontUsesCft = _subtitleCftFont.get() != nullptr;
-		_menuFontUsesCft = _menuCftFont.get() != nullptr;
-		_highlightFontUsesCft = _subtitleFontUsesCft;
-
-		const char *keywordBitmapPath = _menuTextConfig.hasDialogueKeywordLabel()
-			? kDialogueLocalizedKeywordBitmapPath : kDialogueKeywordBitmapPath;
-		if (!loadBitmapResource(*resources, keywordBitmapPath, _keywordBitmap))
-			return;
-		debugC(2, kDebugDialogue,
-			"Harvester: dialogue keyword panel='%s' title='%s' other='%s' responses='%s'",
-			keywordBitmapPath, _menuTextConfig.dialogueKeywordLabel.c_str(),
-			_menuTextConfig.dialogueOtherLabel.c_str(),
-			_menuTextConfig.dialogueResponsesLabel.c_str());
-
-		_genericByeTopic = _text->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
-		if (_genericByeTopic.empty())
-			_genericByeTopic = "BYE";
-
-		_leftHeadSpeakerId = buildDialogueHeadId(_npc.npcName, 0);
-		_rightHeadSpeakerId = buildDialogueHeadId("PC", 0);
-		(void)loadDialogueHeadBitmap(_engine, _npc.npcName, 0, _leftHeadBitmap);
-		(void)loadDialogueHeadBitmap(_engine, "PC", 0, _rightHeadBitmap);
-		_leftHeadVisible = _leftHeadBitmap.isValid();
-		_rightHeadVisible = _rightHeadBitmap.isValid();
-	}
-
-	bool isValid() const {
-		return _script && _text && _art && _fallbackFont && _backdrop.isValid() &&
-			_palette && _keywordBitmap.isValid();
-	}
-
-	const Common::String &getGenericByeTopic() const {
-		return _genericByeTopic;
-	}
-
-	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
-			int headVariant) override {
-		setActiveSpeakerPortrait(speakerId, headVariant);
-
-		const StartupDialogueTextMode textMode = _script->getDialogueTextMode();
-		Common::String subtitleText;
-		const bool textEnabled = textMode != kStartupDialogueTextNone &&
-			_text->resolveDialogueSubtitle(wavId, subtitleText);
-		Common::Array<Common::String> subtitleLines;
-		const IndexedBitmap *textboxBitmap = nullptr;
-		if (textEnabled) {
-			DialogueRendering::wrapDialogueTextLikeNative(*_subtitleFont, _subtitleFontUsesCft,
-				subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
-			textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueTextboxIndex(subtitleLines.size()));
-		}
-
-		const Common::String voicePath = buildDialogueVoicePath(*_script, wavId);
-		const bool voiceStarted = !voicePath.empty() && _engine.playSpeech(voicePath);
-		debugC(2, kDebugDialogue,
-			"Harvester: dialogue line wav=0x%x speaker='%s' headVariant=%d voice='%s' textMode=%d subtitle='%s'",
-			wavId, speakerId.c_str(), headVariant, voicePath.c_str(), (int)textMode,
-			textEnabled ? subtitleText.c_str() : "");
-		Common::Error releaseError = waitForPointerRelease();
-		if (releaseError.getCode() != Common::kNoError) {
-			_engine.stopSpeech();
-			return releaseError;
-		}
-
-		bool voiceInterrupted = false;
-		bool pointerInterrupted = false;
-		Graphics::FrameLimiter limiter(g_system, 60);
-		for (;;) {
-			drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr, nullptr, -1, false, nullptr);
-
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (_flow.handleSystemEvent(event, result)) {
-					_engine.stopSpeech();
-					return result;
-				}
-
-				if (isDialogueVoiceInterruptEvent(event)) {
-					voiceInterrupted = true;
-					if (isDialoguePointerPressEvent(event))
-						pointerInterrupted = true;
-				}
-			}
-
-			if (_entityManager)
-				(void)_entityManager->syncCursorEntityPosition(_mousePos);
-			if (voiceInterrupted || (!voiceStarted || !_engine.isSpeechPlaying()))
-				break;
-
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-
-		_engine.stopSpeech();
-		if (pointerInterrupted) {
-			Common::Error releaseResult = waitForPointerRelease();
-			if (releaseResult.getCode() != Common::kNoError)
-				return releaseResult;
-		} else if (textMode == kStartupDialogueTextClick) {
-			Graphics::FrameLimiter clickLimiter(g_system, 60);
-			for (;;) {
-				drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr,
-					nullptr, -1, false, nullptr);
-
-				bool continuePressed = false;
-				Common::Event event;
-				while (g_system->getEventManager()->pollEvent(event)) {
-					Common::Error result = Common::kNoError;
-					if (_flow.handleSystemEvent(event, result))
-						return result;
-
-					if (isDialogueContinueEvent(event))
-						continuePressed = true;
-				}
-
-				if (_entityManager)
-					(void)_entityManager->syncCursorEntityPosition(_mousePos);
-				if (continuePressed)
-					break;
-
-				clickLimiter.delayBeforeSwap();
-				clickLimiter.startFrame();
-			}
-
-			Common::Error releaseResult = waitForPointerRelease();
-			if (releaseResult.getCode() != Common::kNoError)
-				return releaseResult;
-		}
-
-		return Common::kNoError;
-	}
-
-	Common::Error playDialogueLine(int wavId, const Common::String &speakerId) override {
-		return playDialogueLineWithVariant(wavId, speakerId, 0);
-	}
-
-	Common::Error playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) override {
-		for (uint i = 0; i < count; ++i) {
-			Common::Error lineError = playDialogueLineWithVariant(
-				lines[i].wavId, lines[i].speakerId, lines[i].headVariant);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		return Common::kNoError;
-	}
-
-	Common::Error playDialogueFst(const Common::String &path) override {
-		FstPlayer fstPlayer(_engine);
-		if (!fstPlayer.play(path))
-			return Common::kReadingFailed;
-
-		return Common::kNoError;
-	}
-
-	Common::Error clearScreenToBlack() override {
-		Graphics::Screen *screen = _engine.getScreen();
-		if (!screen)
-			return Common::kReadingFailed;
-
-		byte blackPalette[256 * 3];
-		memset(blackPalette, 0, sizeof(blackPalette));
-		screen->fillRect(screen->getBounds(), 0);
-		setScaledPalette(*screen, blackPalette, 1.0f);
-		screen->makeAllDirty();
-		screen->update();
-		return Common::kNoError;
-	}
-
-	Common::Error showCdChangePrompt(int discNumber) override {
-		if (discNumber <= 0)
-			return Common::kNoError;
-
-		ResourceManager *resources = _engine.getResources();
-		if (!resources)
-			return Common::kReadingFailed;
-		const int previousDisc = resources->getCurrentDisc();
-
-		if (_engine.shouldShowCdChangePrompts()) {
-			Graphics::Screen *screen = _engine.getScreen();
-			if (!screen)
-				return Common::kReadingFailed;
-
-			const Common::String bitmapPath =
-				Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
-			IndexedBitmap promptBitmap;
-			byte promptPalette[256 * 3];
-			if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
-					!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
-				warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
-				return Common::kReadingFailed;
-			}
-
-			_engine.stopMusic();
-
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (_flow.handleSystemEvent(event, result))
-					return result;
-				if (event.type == Common::EVENT_MOUSEMOVE)
-					_mousePos = event.mouse;
-			}
-
-			bool waitingForRelease = false;
-			bool needsRedraw = true;
-			bool activateRequestedDisc = false;
-			Graphics::FrameLimiter limiter(g_system, 60);
-
-			while (!_engine.shouldQuit() && !activateRequestedDisc) {
-				if (needsRedraw) {
-					screen->fillRect(screen->getBounds(), 0);
-					RoomPresentation::blitBitmap(*screen, promptBitmap, 0, 0);
-					setScaledPalette(*screen, promptPalette, 1.0f);
-					if (_entityManager)
-						_entityManager->drawCursor(*screen);
-					screen->makeAllDirty();
-					screen->update();
-					needsRedraw = false;
-				}
-
-				while (g_system->getEventManager()->pollEvent(event)) {
-					Common::Error result = Common::kNoError;
-					if (_flow.handleSystemEvent(event, result))
-						return result;
-
-					switch (event.type) {
-					case Common::EVENT_MOUSEMOVE:
-						_mousePos = event.mouse;
-						needsRedraw = true;
-						break;
-					case Common::EVENT_LBUTTONDOWN:
-						waitingForRelease = true;
-						break;
-					case Common::EVENT_LBUTTONUP:
-						if (waitingForRelease)
-							activateRequestedDisc = true;
-						break;
-					default:
-						break;
-					}
-				}
-
-				if (_entityManager && _entityManager->syncCursorEntityPosition(_mousePos))
-					needsRedraw = true;
-
-				limiter.delayBeforeSwap();
-				limiter.startFrame();
-			}
-		}
-
-		if (!_engine.activateDisc(discNumber)) {
-			warning("Harvester: unable to activate disc %d resources", discNumber);
-			return Common::kReadingFailed;
-		}
-		if ((discNumber == 1 || discNumber == 3) &&
-				previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
-			Script *script = _engine.getScript();
-			if (!script || !script->reloadTownWorld(*resources)) {
-				warning("Harvester: unable to reload town script after disc prompt %d -> %d",
-					previousDisc, discNumber);
-				return Common::kReadingFailed;
-			}
-		}
-
-		return Common::kNoError;
-	}
-
-	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
-			Common::String &selectedTopic,
-			DialogueKeywordMenuSelectionState &selection) override {
-		selectedTopic.clear();
-		selection = DialogueKeywordMenuSelectionState();
-		Common::Array<Common::String> topics;
-		splitDialogueMenuLine(topicBuffer, topics);
-		logDialogueMenuItems("Keyword menu", topicBufferLineIndex, topicBuffer, topics);
-		topics.push_back(_genericByeTopic);
-		debugC(1, kDebugDialogue, "Harvester: keyword menu default topic='%s'", _genericByeTopic.c_str());
-		bool editingOther = false;
-		bool textEntryCursorVisible = false;
-		uint32 textEntryBlinkTimestamp = 0;
-		Common::String typedTopic;
-		Common::Error releaseError = waitForPointerRelease();
-		if (releaseError.getCode() != Common::kNoError)
-			return releaseError;
-
-		Graphics::FrameLimiter limiter(g_system, 60);
-		for (;;) {
-			const uint32 now = getRuntimeClockTicks();
-			if (editingOther && now - textEntryBlinkTimestamp > kDialogueTextEntryBlinkTicks) {
-				textEntryCursorVisible = !textEntryCursorVisible;
-				textEntryBlinkTimestamp = now;
-			}
-			const int hoveredTopicIndex = editingOther ? -1 : DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
-			const bool hoverOther = editingOther || DialogueRendering::isDialogueOtherHit(_mousePos);
-			drawDialogueOverlay(&_keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
-				editingOther ? &typedTopic : nullptr, textEntryCursorVisible);
-
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (_flow.handleSystemEvent(event, result)) {
-					if (editingOther && _entityManager)
-						_entityManager->showCursor();
-					return result;
-				}
-
-				if (editingOther) {
-					bool acceptInput = false;
-					bool cancelInput = false;
-					if (event.type == Common::EVENT_KEYDOWN) {
-						if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-							cancelInput = true;
-						} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE ||
-								event.kbd.keycode == Common::KEYCODE_LEFT) {
-							deleteTextEntryCharacter(typedTopic);
-						} else if (event.kbd.keycode == Common::KEYCODE_HOME) {
-							typedTopic.clear();
-						} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-								event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-							acceptInput = true;
-						} else if (event.kbd.ascii >= 0x1f && event.kbd.ascii <= 0x80 &&
-								event.kbd.ascii != '~' &&
-								(!_subtitleCftFont || _subtitleCftFont->hasGlyph(event.kbd.ascii))) {
-							appendTextEntryCharacter(typedTopic, *_highlightFont, event.kbd.ascii,
-								kDialogueTextEntryMaxLength, kDialogueTextEntryMaxWidth);
-						}
-					} else if (event.type == Common::EVENT_LBUTTONDOWN) {
-						acceptInput = Common::Rect(3, 460, 318, 477).contains(event.mouse);
-						cancelInput = Common::Rect(321, 460, 636, 477).contains(event.mouse);
-						if (cancelInput)
-							g_system->delayMillis(400);
-					}
-					if (acceptInput || cancelInput) {
-						editingOther = false;
-						if (_entityManager)
-							_entityManager->showCursor();
-						if (acceptInput && !typedTopic.empty()) {
-							selectedTopic = typedTopic;
-							selection.fromTypedInput = true;
-							debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
-								selectedTopic.c_str());
-							return Common::kNoError;
-						}
-						typedTopic.clear();
-						debugC(1, kDebugDialogue, "Harvester: keyword menu cancelled Other input");
-					}
-					continue;
-				}
-
-				switch (event.type) {
-				case Common::EVENT_KEYDOWN:
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						selectedTopic = _genericByeTopic;
-						selection.fromEscape = true;
-						selection.fromGenericBye = true;
-						debugC(1, kDebugDialogue,
-							"Harvester: keyword menu selected default topic via escape '%s'",
-							selectedTopic.c_str());
-						return Common::kNoError;
-					}
-					break;
-				case Common::EVENT_LBUTTONDOWN: {
-					const int clickedTopic = DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
-					if (clickedTopic >= 0) {
-						selectedTopic = topics[(uint)clickedTopic];
-						selection.fromGenericBye = clickedTopic == (int)topics.size() - 1;
-						debugC(2, kDebugDialogue, "Harvester: keyword menu selected topic[%d]='%s' via click",
-							clickedTopic + 1, selectedTopic.c_str());
-						return Common::kNoError;
-					}
-					if (DialogueRendering::isDialogueOtherHit(_mousePos)) {
-						typedTopic.clear();
-						editingOther = true;
-						textEntryCursorVisible = false;
-						textEntryBlinkTimestamp = getRuntimeClockTicks();
-						debugC(1, kDebugDialogue, "Harvester: keyword menu entered Other input");
-						if (_entityManager)
-							_entityManager->hideCursor();
-					}
-					break;
-				}
-				default:
-					break;
-				}
-			}
-
-			if (_entityManager)
-				(void)_entityManager->syncCursorEntityPosition(_mousePos);
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-	}
-
-	Common::Error runResponseMenu(int responseLineIndex, int &selectedIndex) override {
-		const Common::String responseLine = _text->getDialogueResponseLine(responseLineIndex);
-		return runResponseMenuTextInternal(responseLine, responseLineIndex, selectedIndex);
-	}
-
-	Common::Error runResponseMenuText(const Common::String &responseLine, int &selectedIndex) override {
-		return runResponseMenuTextInternal(responseLine, -1, selectedIndex);
-	}
-
-	Common::Error runGameOverScreen() override {
-		// GOG run_game_over_screen (0x7c540) enters run_main_menu (0x67390)
-		// with GAMEOVER.BM and GAMEOVER.PAL retained as the menu backdrop.
-		debugC(1, kDebugDialogue, "Harvester: dialogue death requesting game-over menu");
-		_flow.requestGameOverReturn();
-		return Common::kNoError;
-	}
-
-	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
-			int responseLineIndex, const char *) override {
-		topicBuffer = _text->getDialogueResponseLine(responseLineIndex);
-		topicBufferLineIndex = responseLineIndex;
-	}
-
-	bool matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) override {
-		const Common::String topicText = _text->getDialogueResponseLine(responseLineIndex);
-		return !topicText.empty() && selectedTopic.equalsIgnoreCase(topicText);
-	}
-
-	bool matchesAnyResponseLine(const Common::String &selectedTopic,
-			const int *responseLineIndices, uint responseLineCount) override {
-		for (uint i = 0; i < responseLineCount; ++i) {
-			if (matchesResponseLine(selectedTopic, responseLineIndices[i]))
-				return true;
-		}
-
-		return false;
-	}
-
-	void queueDialogueInteractionIfNeeded(const InteractionResult &interaction) override {
-		if (interaction.abortRemainingCommandChain || interaction.mutatedRuntimeState ||
-				!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
-				!interaction.cutscenePath.empty() ||
-				!interaction.deathFlicPath.empty() || interaction.requestMainMenu ||
-				interaction.requestDemoEnding ||
-				interaction.requestRoomRestart ||
-				interaction.requestCloseupExit ||
-				interaction.cdChangeDisc > 0 ||
-				!interaction.dialogueNpcName.empty() ||
-				!interaction.dialogueContinuationTag.empty() ||
-				!interaction.continuationTag.empty() ||
-				!interaction.modalText.value.empty() ||
-				interaction.lightingCommand != kStartupLightingCommandNone ||
-				interaction.requestPlayerGotoXZ ||
-				!interaction.audioCommands.empty()) {
-			_flow.queueDialogueInteraction(interaction);
-		}
-	}
-
-	void applyImmediateDialogueInteractionEffects(InteractionResult &interaction) override {
-		if (!interaction.musicPath.empty())
-			(void)_engine.playMusic(interaction.musicPath);
-		if (!interaction.audioCommands.empty())
-			_flow.executeStartupAudioCommands(interaction.audioCommands);
-		interaction.musicPath.clear();
-		interaction.audioCommands.clear();
-	}
-
-	int getRandomNumber(int maxValue) override {
-		return _engine.getRandomNumber(maxValue);
-	}
-
-	void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) override {
-		if (speakerId.empty() || speakerId.equalsIgnoreCase("SPEECH"))
-			return;
-
-		ensureSpeakerPortrait(speakerId, headVariant);
-		if (speakerId.equalsIgnoreCase("PC")) {
-			_leftHeadVisible = false;
-			_rightHeadVisible = _rightHeadBitmap.isValid();
-		} else {
-			_leftHeadVisible = _leftHeadBitmap.isValid();
-			_rightHeadVisible = false;
-		}
-	}
-
-private:
-	Graphics::Screen *getActiveScreen() const {
-		return _engine.getScreen();
-	}
-
-	Common::Error waitForPointerRelease() {
-		Graphics::FrameLimiter limiter(g_system, 60);
-		while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (_flow.handleSystemEvent(event, result))
-					return result;
-			}
-
-			if (_entityManager)
-				(void)_entityManager->syncCursorEntityPosition(_mousePos);
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-
-		return Common::kNoError;
-	}
-
-	void drawFontString(const Graphics::Font &font, bool usesCft, const Common::String &text,
-			int x, int y, int width, byte color) {
-		Graphics::Screen *activeScreen = getActiveScreen();
-		if (!activeScreen)
-			return;
-
-		if (usesCft)
-			font.drawString(activeScreen, text, x, y, width, 0);
-		else
-			RoomPresentation::drawShadowedString(*activeScreen, font, text, x, y, width, color);
-	}
-
-	void ensureSpeakerPortrait(const Common::String &speakerId, int headVariant) {
-		if (speakerId.empty())
-			return;
-
-		const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
-		IndexedBitmap *targetBitmap = speakerId.equalsIgnoreCase("PC") ? &_rightHeadBitmap : &_leftHeadBitmap;
-		Common::String *targetSpeakerId = speakerId.equalsIgnoreCase("PC") ? &_rightHeadSpeakerId : &_leftHeadSpeakerId;
-		if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(headId))
-			return;
-
-		IndexedBitmap updatedBitmap;
-		if (loadDialogueHeadBitmap(_engine, speakerId, headVariant, updatedBitmap)) {
-			*targetBitmap = Common::move(updatedBitmap);
-			*targetSpeakerId = headId;
-		} else {
-			warning("Harvester: unable to load dialogue head for '%s'", headId.c_str());
-		}
-	}
-
-	void drawDialogueOverlay(const IndexedBitmap *overlayBitmap,
-			const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
-			int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue,
-			bool textEntryCursorVisible = false) {
-		Graphics::Screen *activeScreen = getActiveScreen();
-		if (!activeScreen)
-			return;
-
-		setScaledPalette(*activeScreen, _palette, _paletteBrightness);
-		RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
-		if (_leftHeadVisible && _leftHeadBitmap.isValid())
-			blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-		if (_rightHeadVisible && _rightHeadBitmap.isValid())
-			blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
-		if (overlayBitmap && overlayBitmap->isValid())
-			blitTransparentBitmap(*activeScreen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
-
-		if (subtitleLines) {
-			if (_subtitleFontUsesCft) {
-				DialogueRendering::drawDialogueTextLines(*activeScreen, *_subtitleFont, *subtitleLines,
-					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
-			} else {
-				const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_subtitleFont);
-				for (uint i = 0; i < subtitleLines->size(); ++i) {
-					RoomPresentation::drawShadowedString(*activeScreen, *_subtitleFont, (*subtitleLines)[i],
-						kDialogueSubtitleTextX,
-						kDialogueSubtitleTextY + (int)i * lineHeight,
-						kDialogueSubtitleTextWidth, kTextColorNormal);
-				}
-			}
-		}
-
-		if (topics) {
-			if (_menuTextConfig.hasDialogueKeywordLabel()) {
-				const Common::String &title = _menuTextConfig.dialogueKeywordLabel;
-				const int titleWidth = _highlightFont->getStringWidth(title);
-				const int titleX = kDialogueOverlayX +
-					MAX<int>(0, ((int)_keywordBitmap.width - titleWidth) / 2);
-				drawFontString(*_highlightFont, _highlightFontUsesCft, title, titleX,
-					kDialogueOverlayY + kDialogueKeywordTitleYOffset, titleWidth,
-					kTextColorNormal);
-			}
-
-			const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
-			for (uint i = 0; i < topics->size(); ++i) {
-				const bool highlighted = (int)i == hoveredTopicIndex;
-				const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
-				const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
-				drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
-					DialogueRendering::kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
-					highlighted ? kTextColorNormal : kTextColorHover);
-			}
-
-			const Graphics::Font &otherFont = hoverOther ? *_highlightFont : *_menuFont;
-			const bool otherUsesCft = hoverOther ? _highlightFontUsesCft : _menuFontUsesCft;
-			drawFontString(otherFont, otherUsesCft, _menuTextConfig.dialogueOtherLabel,
-				kDialogueOtherX, kDialogueOtherY,
-				kDialogueOtherWidth, hoverOther ? kTextColorNormal : kTextColorHover);
-		}
-
-		if (textEntryValue) {
-			drawFontString(*_highlightFont, _highlightFontUsesCft,
-				*textEntryValue + (textEntryCursorVisible ? "~" : ""),
-				kDialogueTextEntryX, kDialogueTextEntryY, kDialogueTextEntryMaxWidth,
-				kTextColorNormal);
-		}
-
-		if (_entityManager)
-			_entityManager->drawCursor(*activeScreen);
-		activeScreen->makeAllDirty();
-		activeScreen->update();
-	}
-
-	void parseResponseMenuLine(const Common::String &responseLine,
-			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
-		options.clear();
-		totalRows = 0;
-		if (responseLine.empty())
-			return;
-
-		Common::String segment;
-		Common::Array<Common::String> explicitLines;
-		Common::String optionText;
-
-		auto flushOption = [&]() {
-			if (explicitLines.empty())
-				return;
-
-			DialogueResponseOptionLayout option;
-			option.text = optionText;
-			if (optionText.size() > 1 && optionText[0] >= '1' && optionText[0] <= '9' &&
-					optionText[1] == '.')
-				option.hotkey = optionText[0];
-			option.rowStart = (int)totalRows;
-			for (const Common::String &explicitLine : explicitLines) {
-				Common::Array<Common::String> wrappedExplicitLines;
-				DialogueRendering::wrapDialogueTextLikeNative(*_menuFont, _menuFontUsesCft,
-					explicitLine, kDialogueSubtitleTextWidth, wrappedExplicitLines);
-				if (wrappedExplicitLines.empty())
-					wrappedExplicitLines.push_back(explicitLine);
-				for (const Common::String &wrappedExplicitLine : wrappedExplicitLines)
-					option.wrappedLines.push_back(wrappedExplicitLine);
-			}
-			option.rowCount = MAX<int>(1, option.wrappedLines.size());
-			totalRows += option.rowCount;
-			options.push_back(Common::move(option));
-
-			explicitLines.clear();
-			optionText.clear();
-		};
-
-		auto consumeSegment = [&](const Common::String &rawSegment) {
-			Common::String segmentText = rawSegment;
-			if (segmentText.empty())
-				return;
-
-			const bool startsNewOption = segmentText.size() > 1 && segmentText[0] >= '1' &&
-				segmentText[0] <= '9' && segmentText[1] == '.';
-			if (startsNewOption) {
-				flushOption();
-				optionText = segmentText;
-				explicitLines.push_back(Common::move(segmentText));
-				return;
-			}
-
-			if (!optionText.empty())
-				optionText += ' ';
-			optionText += segmentText;
-			explicitLines.push_back(Common::move(segmentText));
-		};
-
-		for (uint i = 0; i < responseLine.size(); ++i) {
-			if (responseLine[i] == '/') {
-				consumeSegment(segment);
-				segment.clear();
-				continue;
-			}
-
-			segment += responseLine[i];
-		}
-
-		consumeSegment(segment);
-		flushOption();
-	}
-
-	int getResponseMenuItemAt(const Common::Array<DialogueResponseOptionLayout> &options,
-			uint totalRows, const Common::Point &mousePos) {
-		if (mousePos.x < DialogueRendering::kDialogueTopicStartX || mousePos.x > DialogueRendering::kDialogueTopicEndX)
-			return -1;
-		if (mousePos.y < DialogueRendering::kDialogueKeywordTextY)
-			return -1;
-
-		const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
-		const int row = (mousePos.y - DialogueRendering::kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
-		if (row < 0 || row >= (int)totalRows)
-			return -1;
-
-		for (uint i = 0; i < options.size(); ++i) {
-			const DialogueResponseOptionLayout &option = options[i];
-			if (row >= option.rowStart && row < option.rowStart + option.rowCount)
-				return (int)i;
-		}
-
-		return -1;
-	}
-
-	void drawDialogueResponseMenu(const IndexedBitmap *textboxBitmap,
-			const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
-		Graphics::Screen *activeScreen = getActiveScreen();
-		if (!activeScreen)
-			return;
-
-		setScaledPalette(*activeScreen, _palette, _paletteBrightness);
-		RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
-		if (_leftHeadVisible && _leftHeadBitmap.isValid())
-			blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-		if (_rightHeadVisible && _rightHeadBitmap.isValid())
-			blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
-		if (textboxBitmap && textboxBitmap->isValid())
-			blitTransparentBitmap(*activeScreen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
-
-		const Common::String &title = _menuTextConfig.dialogueResponsesLabel;
-		const Graphics::Font &titleFont = *_highlightFont;
-		const bool titleUsesCft = _highlightFontUsesCft;
-		const int titleWidth = titleFont.getStringWidth(title);
-		const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
-		drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
-
-		const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
-		int drawY = DialogueRendering::kDialogueKeywordTextY;
-		for (uint i = 0; i < options.size(); ++i) {
-			const bool highlighted = (int)i == hoveredOptionIndex;
-			const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
-			const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
-			const byte color = highlighted ? kTextColorNormal : kTextColorHover;
-
-			for (const Common::String &wrappedLine : options[i].wrappedLines) {
-				drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
-					kDialogueSubtitleTextWidth, color);
-				drawY += lineHeight;
-			}
-		}
-
-		if (_entityManager)
-			_entityManager->drawCursor(*activeScreen);
-		activeScreen->makeAllDirty();
-		activeScreen->update();
-	}
-
-	Common::Error runResponseMenuTextInternal(const Common::String &responseLine, int responseLineIndex,
-			int &selectedIndex) {
-		selectedIndex = 0;
-
-		if (responseLine.empty()) {
-			if (responseLineIndex >= 0) {
-				debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) is empty",
-					responseLineIndex, responseLineIndex + 1);
-			} else {
-				debugC(1, kDebugDialogue, "Harvester: response menu text is empty");
-			}
-			return Common::kNoError;
-		}
-
-		Common::Array<DialogueResponseOptionLayout> options;
-		uint totalRows = 0;
-		parseResponseMenuLine(responseLine, options, totalRows);
-		if (options.empty()) {
-			if (responseLineIndex >= 0) {
-				debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) produced no options",
-					responseLineIndex, responseLineIndex + 1);
-			} else {
-				debugC(1, kDebugDialogue, "Harvester: response menu text produced no options");
-			}
-			return Common::kNoError;
-		}
-
-		Common::Array<Common::String> optionTexts;
-		for (const DialogueResponseOptionLayout &option : options) {
-			optionTexts.push_back(option.text);
-			debugC(3, kDebugDialogue, "Harvester: response menu option[%u] hotkey=%c rowStart=%d rowCount=%d",
-				optionTexts.size(), option.hotkey ? option.hotkey : '-', option.rowStart, option.rowCount);
-		}
-		logDialogueMenuItems("Response menu", responseLineIndex, responseLine, optionTexts);
-
-		const IndexedBitmap *textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueResponseTextboxIndex(totalRows));
-		Common::Error releaseError = waitForPointerRelease();
-		if (releaseError.getCode() != Common::kNoError)
-			return releaseError;
-
-		Graphics::FrameLimiter limiter(g_system, 60);
-		for (;;) {
-			const int hoveredOptionIndex = getResponseMenuItemAt(options, totalRows, _mousePos);
-			drawDialogueResponseMenu(textboxBitmap, options, hoveredOptionIndex);
-
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (_flow.handleSystemEvent(event, result))
-					return result;
-
-				if (event.type == Common::EVENT_KEYDOWN) {
-					debugC(3, kDebugDialogue, "Harvester: response menu keycode=%d ascii=%u",
-						(int)event.kbd.keycode, event.kbd.ascii);
-					if (event.kbd.keycode >= Common::KEYCODE_1 && event.kbd.keycode <= Common::KEYCODE_9) {
-						const char hotkey = '1' + event.kbd.keycode - Common::KEYCODE_1;
-						for (int optionIndex = (int)options.size() - 1; optionIndex >= 0; --optionIndex) {
-							const DialogueResponseOptionLayout &option = options[(uint)optionIndex];
-							if (option.hotkey != hotkey)
-								continue;
-							selectedIndex = optionIndex + 1;
-							if (responseLineIndex >= 0) {
-								debugC(2, kDebugDialogue,
-									"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via hotkey=%c result=%d",
-									responseLineIndex, responseLineIndex + 1,
-									optionIndex + 1, option.text.c_str(), hotkey, selectedIndex);
-							} else {
-								debugC(2, kDebugDialogue,
-									"Harvester: response menu text selected option[%d]='%s' via hotkey=%c result=%d",
-									optionIndex + 1, option.text.c_str(), hotkey, selectedIndex);
-							}
-							return waitForPointerRelease();
-						}
-					}
-				} else if (event.type == Common::EVENT_LBUTTONDOWN) {
-					const int clickedOptionIndex = getResponseMenuItemAt(options, totalRows, event.mouse);
-					if (clickedOptionIndex < 0)
-						continue;
-					selectedIndex = clickedOptionIndex + 1;
-					if (responseLineIndex >= 0) {
-						debugC(2, kDebugDialogue,
-							"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via click x=%d y=%d result=%d",
-							responseLineIndex, responseLineIndex + 1,
-							selectedIndex, options[(uint)clickedOptionIndex].text.c_str(),
-							event.mouse.x, event.mouse.y, selectedIndex);
-					} else {
-						debugC(2, kDebugDialogue,
-							"Harvester: response menu text selected option[%d]='%s' via click x=%d y=%d result=%d",
-							selectedIndex, options[(uint)clickedOptionIndex].text.c_str(),
-							event.mouse.x, event.mouse.y, selectedIndex);
-					}
-					return waitForPointerRelease();
-				}
-			}
-
-			if (_entityManager)
-				(void)_entityManager->syncCursorEntityPosition(_mousePos);
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-	}
-
-	HarvesterEngine &_engine;
-	Common::Point &_mousePos;
-	Flow &_flow;
-	const MenuTextConfig &_menuTextConfig;
-	const IndexedBitmap &_backdrop;
-	const byte *_palette;
-	const float _paletteBrightness;
-	const NpcRecord &_npc;
-	Script *_script;
-	Text *_text;
-	Art *_art;
-	EntityManager *_entityManager;
-	const Graphics::Font *_fallbackFont;
-	Common::ScopedPtr<HarvesterCftFont> _subtitleCftFont;
-	Common::ScopedPtr<HarvesterCftFont> _menuCftFont;
-	const Graphics::Font *_subtitleFont;
-	const Graphics::Font *_menuFont;
-	const Graphics::Font *_highlightFont;
-	bool _subtitleFontUsesCft;
-	bool _menuFontUsesCft;
-	bool _highlightFontUsesCft;
-	IndexedBitmap _keywordBitmap;
-	Common::String _genericByeTopic;
-	IndexedBitmap _leftHeadBitmap;
-	IndexedBitmap _rightHeadBitmap;
-	Common::String _leftHeadSpeakerId;
-	Common::String _rightHeadSpeakerId;
-	bool _leftHeadVisible;
-	bool _rightHeadVisible;
-};
-
 } // End of anonymous namespace
 
 DialogueSystem::DialogueSystem(HarvesterEngine &engine, Common::Point &mousePos)
diff --git a/engines/harvester/dialogue/dialogue_session.cpp b/engines/harvester/dialogue/dialogue_session.cpp
new file mode 100644
index 00000000000..551c0bfbf6a
--- /dev/null
+++ b/engines/harvester/dialogue/dialogue_session.cpp
@@ -0,0 +1,1052 @@
+/* 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/dialogue/dialogue_session.h"
+#include "harvester/dialogue/dialogue_render.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/fontman.h"
+#include "graphics/framelimiter.h"
+#include "harvester/art.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/flow/flow.h"
+#include "harvester/flow/flow_support.h"
+#include "harvester/fst_player.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+#include "harvester/scene/room_presentation.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int kDialogueOverlayX = 167;
+static const int kDialogueOverlayY = 6;
+static const int kDialogueLeftHeadX = 6;
+static const int kDialogueRightHeadX = 474;
+static const int kDialogueHeadY = 6;
+static const int kDialogueSubtitleTextX = 178;
+static const int kDialogueSubtitleTextY = 12;
+static const int kDialogueSubtitleTextWidth = 316;
+static const int kDialogueKeywordTextX = 178;
+static const int kDialogueKeywordTextWidth = 300;
+static const int kDialogueOtherX = 171;
+static const int kDialogueOtherY = 183;
+static const int kDialogueTextEntryX = 208;
+static const int kDialogueTextEntryY = 184;
+static const uint kDialogueTextEntryMaxLength = 26;
+static const int kDialogueTextEntryMaxWidth = 550;
+static const uint32 kDialogueTextEntryBlinkTicks = 0x30;
+static const int kDialogueOtherWidth = 120;
+static const int kDialogueKeywordTitleYOffset = 4;
+static const int kDialogueGenericByeResponseIndex = 13;
+static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
+static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
+static const char *const kDialogueLocalizedKeywordBitmapPath = "GRAPHIC/OTHER/KEYWORD.BM";
+
+static const byte kTextColorNormal = 255;
+static const byte kTextColorHover = 251;
+
+static bool isDialogueVoiceInterruptEvent(const Common::Event &event) {
+	return event.type == Common::EVENT_LBUTTONDOWN ||
+		event.type == Common::EVENT_RBUTTONDOWN ||
+		(event.type == Common::EVENT_KEYDOWN && event.kbd.keycode == Common::KEYCODE_ESCAPE);
+}
+
+static bool isDialoguePointerPressEvent(const Common::Event &event) {
+	return event.type == Common::EVENT_LBUTTONDOWN || event.type == Common::EVENT_RBUTTONDOWN;
+}
+
+static bool isDialogueContinueEvent(const Common::Event &event) {
+	return isDialoguePointerPressEvent(event) || event.type == Common::EVENT_KEYDOWN;
+}
+
+static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
+		return false;
+
+	memcpy(dest, data.data(), 256 * 3);
+	return true;
+}
+
+static bool isPrimaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 1) != 0);
+}
+
+static bool isSecondaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 2) != 0);
+}
+
+static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
+	parts.clear();
+	if (line.empty())
+		return;
+
+	Common::String token;
+	for (uint i = 0; i < line.size(); ++i) {
+		if (line[i] == '/') {
+			parts.push_back(Common::move(token));
+			token.clear();
+			continue;
+		}
+
+		token += line[i];
+	}
+
+	parts.push_back(Common::move(token));
+}
+
+static void logDialogueMenuItems(const char *label, int sourceLineIndex,
+		const Common::String &rawLine, const Common::Array<Common::String> &items) {
+	if (sourceLineIndex >= 0) {
+		debugC(3, kDebugDialogue, "Harvester: %s source line 0x%x (%d) raw='%s'",
+			label, sourceLineIndex, sourceLineIndex + 1, rawLine.c_str());
+	} else {
+		debugC(3, kDebugDialogue, "Harvester: %s source raw='%s'", label, rawLine.c_str());
+	}
+
+	if (items.empty()) {
+		debugC(1, kDebugDialogue, "Harvester: %s has no explicit items", label);
+		return;
+	}
+
+	for (uint i = 0; i < items.size(); ++i)
+		debugC(3, kDebugDialogue, "Harvester: %s item[%u]='%s'", label, i + 1, items[i].c_str());
+}
+
+static Common::String buildDialogueHeadId(const Common::String &speakerId, int headVariant) {
+	return Common::String::format("%s%d", speakerId.c_str(), headVariant);
+}
+
+static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId, int headVariant,
+		IndexedBitmap &bitmap) {
+	Script *script = engine.getScript();
+	ResourceManager *resources = engine.getResources();
+	if (!script || !resources || speakerId.empty())
+		return false;
+
+	const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
+	const HeadRecord *head = script->findHeadRecord(headId);
+	if (!head)
+		return false;
+
+	return loadBitmapResource(*resources, head->portraitPath + ".BM", bitmap);
+}
+
+static Common::String buildDialogueVoicePath(const Script &script, int wavId) {
+	if (wavId <= 0)
+		return Common::String();
+
+	return Common::String::format("%s%d.CMP", script.getVoicePath().c_str(), wavId);
+}
+
+} // End of anonymous namespace
+
+RoomNpcDialogueSession::RoomNpcDialogueSession(HarvesterEngine &engine, Common::Point &mousePos, Flow &flow,
+		const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness,
+		const NpcRecord &npc)
+	: _engine(engine), _mousePos(mousePos), _flow(flow),
+	  _menuTextConfig(flow.getMenuTextConfig()), _backdrop(backdrop),
+	  _palette(palette), _paletteBrightness(paletteBrightness), _npc(npc),
+	  _script(engine.getScript()), _text(engine.getText()), _art(engine.getArt()),
+	  _entityManager(engine.getRuntimeEntities()),
+	  _fallbackFont(FontMan.getFontByUsage(Graphics::FontManager::kGUIFont)),
+	  _subtitleFont(nullptr), _menuFont(nullptr), _highlightFont(nullptr),
+	  _subtitleFontUsesCft(false), _menuFontUsesCft(false),
+	  _highlightFontUsesCft(false), _leftHeadVisible(false),
+	  _rightHeadVisible(false) {
+	ResourceManager *resources = _engine.getResources();
+	if (!_script || !_text || !_art || !_fallbackFont || !_backdrop.isValid() ||
+			!_palette || !resources) {
+		return;
+	}
+
+	if (const CftFontResource *subtitleFontResource = _text->findFont("TEXTFONT")) {
+		_subtitleCftFont.reset(new HarvesterCftFont(*subtitleFontResource));
+		if (!_subtitleCftFont->isValid())
+			_subtitleCftFont.reset();
+	}
+	if (const CftFontResource *menuFontResource = _text->findFont("TEXTFNT2")) {
+		_menuCftFont.reset(new HarvesterCftFont(*menuFontResource));
+		if (!_menuCftFont->isValid())
+			_menuCftFont.reset();
+	}
+
+	_subtitleFont = _subtitleCftFont
+		? static_cast<const Graphics::Font *>(_subtitleCftFont.get())
+		: _fallbackFont;
+	_menuFont = _menuCftFont
+		? static_cast<const Graphics::Font *>(_menuCftFont.get())
+		: _subtitleFont;
+	_highlightFont = _subtitleFont;
+	_subtitleFontUsesCft = _subtitleCftFont.get() != nullptr;
+	_menuFontUsesCft = _menuCftFont.get() != nullptr;
+	_highlightFontUsesCft = _subtitleFontUsesCft;
+
+	const char *keywordBitmapPath = _menuTextConfig.hasDialogueKeywordLabel()
+		? kDialogueLocalizedKeywordBitmapPath : kDialogueKeywordBitmapPath;
+	if (!loadBitmapResource(*resources, keywordBitmapPath, _keywordBitmap))
+		return;
+	debugC(2, kDebugDialogue,
+		"Harvester: dialogue keyword panel='%s' title='%s' other='%s' responses='%s'",
+		keywordBitmapPath, _menuTextConfig.dialogueKeywordLabel.c_str(),
+		_menuTextConfig.dialogueOtherLabel.c_str(),
+		_menuTextConfig.dialogueResponsesLabel.c_str());
+
+	_genericByeTopic = _text->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
+	if (_genericByeTopic.empty())
+		_genericByeTopic = "BYE";
+
+	_leftHeadSpeakerId = buildDialogueHeadId(_npc.npcName, 0);
+	_rightHeadSpeakerId = buildDialogueHeadId("PC", 0);
+	(void)loadDialogueHeadBitmap(_engine, _npc.npcName, 0, _leftHeadBitmap);
+	(void)loadDialogueHeadBitmap(_engine, "PC", 0, _rightHeadBitmap);
+	_leftHeadVisible = _leftHeadBitmap.isValid();
+	_rightHeadVisible = _rightHeadBitmap.isValid();
+}
+
+bool RoomNpcDialogueSession::isValid() const {
+	return _script && _text && _art && _fallbackFont && _backdrop.isValid() &&
+		_palette && _keywordBitmap.isValid();
+}
+
+const Common::String &RoomNpcDialogueSession::getGenericByeTopic() const {
+	return _genericByeTopic;
+}
+
+Common::Error RoomNpcDialogueSession::playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
+		int headVariant) {
+	setActiveSpeakerPortrait(speakerId, headVariant);
+
+	const StartupDialogueTextMode textMode = _script->getDialogueTextMode();
+	Common::String subtitleText;
+	const bool textEnabled = textMode != kStartupDialogueTextNone &&
+		_text->resolveDialogueSubtitle(wavId, subtitleText);
+	Common::Array<Common::String> subtitleLines;
+	const IndexedBitmap *textboxBitmap = nullptr;
+	if (textEnabled) {
+		DialogueRendering::wrapDialogueTextLikeNative(*_subtitleFont, _subtitleFontUsesCft,
+			subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
+		textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueTextboxIndex(subtitleLines.size()));
+	}
+
+	const Common::String voicePath = buildDialogueVoicePath(*_script, wavId);
+	const bool voiceStarted = !voicePath.empty() && _engine.playSpeech(voicePath);
+	debugC(2, kDebugDialogue,
+		"Harvester: dialogue line wav=0x%x speaker='%s' headVariant=%d voice='%s' textMode=%d subtitle='%s'",
+		wavId, speakerId.c_str(), headVariant, voicePath.c_str(), (int)textMode,
+		textEnabled ? subtitleText.c_str() : "");
+	Common::Error releaseError = waitForPointerRelease();
+	if (releaseError.getCode() != Common::kNoError) {
+		_engine.stopSpeech();
+		return releaseError;
+	}
+
+	bool voiceInterrupted = false;
+	bool pointerInterrupted = false;
+	Graphics::FrameLimiter limiter(g_system, 60);
+	for (;;) {
+		drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr, nullptr, -1, false, nullptr);
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result)) {
+				_engine.stopSpeech();
+				return result;
+			}
+
+			if (isDialogueVoiceInterruptEvent(event)) {
+				voiceInterrupted = true;
+				if (isDialoguePointerPressEvent(event))
+					pointerInterrupted = true;
+			}
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		if (voiceInterrupted || (!voiceStarted || !_engine.isSpeechPlaying()))
+			break;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	_engine.stopSpeech();
+	if (pointerInterrupted) {
+		Common::Error releaseResult = waitForPointerRelease();
+		if (releaseResult.getCode() != Common::kNoError)
+			return releaseResult;
+	} else if (textMode == kStartupDialogueTextClick) {
+		Graphics::FrameLimiter clickLimiter(g_system, 60);
+		for (;;) {
+			drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr,
+				nullptr, -1, false, nullptr);
+
+			bool continuePressed = false;
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (_flow.handleSystemEvent(event, result))
+					return result;
+
+				if (isDialogueContinueEvent(event))
+					continuePressed = true;
+			}
+
+			if (_entityManager)
+				(void)_entityManager->syncCursorEntityPosition(_mousePos);
+			if (continuePressed)
+				break;
+
+			clickLimiter.delayBeforeSwap();
+			clickLimiter.startFrame();
+		}
+
+		Common::Error releaseResult = waitForPointerRelease();
+		if (releaseResult.getCode() != Common::kNoError)
+			return releaseResult;
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::playDialogueLine(int wavId, const Common::String &speakerId) {
+	return playDialogueLineWithVariant(wavId, speakerId, 0);
+}
+
+Common::Error RoomNpcDialogueSession::playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) {
+	for (uint i = 0; i < count; ++i) {
+		Common::Error lineError = playDialogueLineWithVariant(
+			lines[i].wavId, lines[i].speakerId, lines[i].headVariant);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::playDialogueFst(const Common::String &path) {
+	FstPlayer fstPlayer(_engine);
+	if (!fstPlayer.play(path))
+		return Common::kReadingFailed;
+
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::clearScreenToBlack() {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen)
+		return Common::kReadingFailed;
+
+	byte blackPalette[256 * 3];
+	memset(blackPalette, 0, sizeof(blackPalette));
+	screen->fillRect(screen->getBounds(), 0);
+	setScaledPalette(*screen, blackPalette, 1.0f);
+	screen->makeAllDirty();
+	screen->update();
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::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()) {
+		Graphics::Screen *screen = _engine.getScreen();
+		if (!screen)
+			return Common::kReadingFailed;
+
+		const Common::String bitmapPath =
+			Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
+		IndexedBitmap promptBitmap;
+		byte promptPalette[256 * 3];
+		if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
+				!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
+			warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
+			return Common::kReadingFailed;
+		}
+
+		_engine.stopMusic();
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result))
+				return result;
+			if (event.type == Common::EVENT_MOUSEMOVE)
+				_mousePos = event.mouse;
+		}
+
+		bool waitingForRelease = false;
+		bool needsRedraw = true;
+		bool activateRequestedDisc = false;
+		Graphics::FrameLimiter limiter(g_system, 60);
+
+		while (!_engine.shouldQuit() && !activateRequestedDisc) {
+			if (needsRedraw) {
+				screen->fillRect(screen->getBounds(), 0);
+				RoomPresentation::blitBitmap(*screen, promptBitmap, 0, 0);
+				setScaledPalette(*screen, promptPalette, 1.0f);
+				if (_entityManager)
+					_entityManager->drawCursor(*screen);
+				screen->makeAllDirty();
+				screen->update();
+				needsRedraw = false;
+			}
+
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (_flow.handleSystemEvent(event, result))
+					return result;
+
+				switch (event.type) {
+				case Common::EVENT_MOUSEMOVE:
+					_mousePos = event.mouse;
+					needsRedraw = true;
+					break;
+				case Common::EVENT_LBUTTONDOWN:
+					waitingForRelease = true;
+					break;
+				case Common::EVENT_LBUTTONUP:
+					if (waitingForRelease)
+						activateRequestedDisc = true;
+					break;
+				default:
+					break;
+				}
+			}
+
+			if (_entityManager && _entityManager->syncCursorEntityPosition(_mousePos))
+				needsRedraw = true;
+
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+	}
+
+	if (!_engine.activateDisc(discNumber)) {
+		warning("Harvester: unable to activate disc %d resources", discNumber);
+		return Common::kReadingFailed;
+	}
+	if ((discNumber == 1 || discNumber == 3) &&
+			previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
+		Script *script = _engine.getScript();
+		if (!script || !script->reloadTownWorld(*resources)) {
+			warning("Harvester: unable to reload town script after disc prompt %d -> %d",
+				previousDisc, discNumber);
+			return Common::kReadingFailed;
+		}
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
+		Common::String &selectedTopic,
+		DialogueKeywordMenuSelectionState &selection) {
+	selectedTopic.clear();
+	selection = DialogueKeywordMenuSelectionState();
+	Common::Array<Common::String> topics;
+	splitDialogueMenuLine(topicBuffer, topics);
+	logDialogueMenuItems("Keyword menu", topicBufferLineIndex, topicBuffer, topics);
+	topics.push_back(_genericByeTopic);
+	debugC(1, kDebugDialogue, "Harvester: keyword menu default topic='%s'", _genericByeTopic.c_str());
+	bool editingOther = false;
+	bool textEntryCursorVisible = false;
+	uint32 textEntryBlinkTimestamp = 0;
+	Common::String typedTopic;
+	Common::Error releaseError = waitForPointerRelease();
+	if (releaseError.getCode() != Common::kNoError)
+		return releaseError;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	for (;;) {
+		const uint32 now = getRuntimeClockTicks();
+		if (editingOther && now - textEntryBlinkTimestamp > kDialogueTextEntryBlinkTicks) {
+			textEntryCursorVisible = !textEntryCursorVisible;
+			textEntryBlinkTimestamp = now;
+		}
+		const int hoveredTopicIndex = editingOther ? -1 : DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
+		const bool hoverOther = editingOther || DialogueRendering::isDialogueOtherHit(_mousePos);
+		drawDialogueOverlay(&_keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
+			editingOther ? &typedTopic : nullptr, textEntryCursorVisible);
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result)) {
+				if (editingOther && _entityManager)
+					_entityManager->showCursor();
+				return result;
+			}
+
+			if (editingOther) {
+				bool acceptInput = false;
+				bool cancelInput = false;
+				if (event.type == Common::EVENT_KEYDOWN) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						cancelInput = true;
+					} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE ||
+							event.kbd.keycode == Common::KEYCODE_LEFT) {
+						deleteTextEntryCharacter(typedTopic);
+					} else if (event.kbd.keycode == Common::KEYCODE_HOME) {
+						typedTopic.clear();
+					} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+							event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+						acceptInput = true;
+					} else if (event.kbd.ascii >= 0x1f && event.kbd.ascii <= 0x80 &&
+							event.kbd.ascii != '~' &&
+							(!_subtitleCftFont || _subtitleCftFont->hasGlyph(event.kbd.ascii))) {
+						appendTextEntryCharacter(typedTopic, *_highlightFont, event.kbd.ascii,
+							kDialogueTextEntryMaxLength, kDialogueTextEntryMaxWidth);
+					}
+				} else if (event.type == Common::EVENT_LBUTTONDOWN) {
+					acceptInput = Common::Rect(3, 460, 318, 477).contains(event.mouse);
+					cancelInput = Common::Rect(321, 460, 636, 477).contains(event.mouse);
+					if (cancelInput)
+						g_system->delayMillis(400);
+				}
+				if (acceptInput || cancelInput) {
+					editingOther = false;
+					if (_entityManager)
+						_entityManager->showCursor();
+					if (acceptInput && !typedTopic.empty()) {
+						selectedTopic = typedTopic;
+						selection.fromTypedInput = true;
+						debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
+							selectedTopic.c_str());
+						return Common::kNoError;
+					}
+					typedTopic.clear();
+					debugC(1, kDebugDialogue, "Harvester: keyword menu cancelled Other input");
+				}
+				continue;
+			}
+
+			switch (event.type) {
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					selectedTopic = _genericByeTopic;
+					selection.fromEscape = true;
+					selection.fromGenericBye = true;
+					debugC(1, kDebugDialogue,
+						"Harvester: keyword menu selected default topic via escape '%s'",
+						selectedTopic.c_str());
+					return Common::kNoError;
+				}
+				break;
+			case Common::EVENT_LBUTTONDOWN: {
+				const int clickedTopic = DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
+				if (clickedTopic >= 0) {
+					selectedTopic = topics[(uint)clickedTopic];
+					selection.fromGenericBye = clickedTopic == (int)topics.size() - 1;
+					debugC(2, kDebugDialogue, "Harvester: keyword menu selected topic[%d]='%s' via click",
+						clickedTopic + 1, selectedTopic.c_str());
+					return Common::kNoError;
+				}
+				if (DialogueRendering::isDialogueOtherHit(_mousePos)) {
+					typedTopic.clear();
+					editingOther = true;
+					textEntryCursorVisible = false;
+					textEntryBlinkTimestamp = getRuntimeClockTicks();
+					debugC(1, kDebugDialogue, "Harvester: keyword menu entered Other input");
+					if (_entityManager)
+						_entityManager->hideCursor();
+				}
+				break;
+			}
+			default:
+				break;
+			}
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+}
+
+Common::Error RoomNpcDialogueSession::runResponseMenu(int responseLineIndex, int &selectedIndex) {
+	const Common::String responseLine = _text->getDialogueResponseLine(responseLineIndex);
+	return runResponseMenuTextInternal(responseLine, responseLineIndex, selectedIndex);
+}
+
+Common::Error RoomNpcDialogueSession::runResponseMenuText(const Common::String &responseLine, int &selectedIndex) {
+	return runResponseMenuTextInternal(responseLine, -1, selectedIndex);
+}
+
+Common::Error RoomNpcDialogueSession::runGameOverScreen() {
+	// GOG run_game_over_screen (0x7c540) enters run_main_menu (0x67390)
+	// with GAMEOVER.BM and GAMEOVER.PAL retained as the menu backdrop.
+	debugC(1, kDebugDialogue, "Harvester: dialogue death requesting game-over menu");
+	_flow.requestGameOverReturn();
+	return Common::kNoError;
+}
+
+void RoomNpcDialogueSession::assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
+		int responseLineIndex, const char *) {
+	topicBuffer = _text->getDialogueResponseLine(responseLineIndex);
+	topicBufferLineIndex = responseLineIndex;
+}
+
+bool RoomNpcDialogueSession::matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) {
+	const Common::String topicText = _text->getDialogueResponseLine(responseLineIndex);
+	return !topicText.empty() && selectedTopic.equalsIgnoreCase(topicText);
+}
+
+bool RoomNpcDialogueSession::matchesAnyResponseLine(const Common::String &selectedTopic,
+		const int *responseLineIndices, uint responseLineCount) {
+	for (uint i = 0; i < responseLineCount; ++i) {
+		if (matchesResponseLine(selectedTopic, responseLineIndices[i]))
+			return true;
+	}
+
+	return false;
+}
+
+void RoomNpcDialogueSession::queueDialogueInteractionIfNeeded(const InteractionResult &interaction) {
+	if (interaction.abortRemainingCommandChain || interaction.mutatedRuntimeState ||
+			!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
+			!interaction.cutscenePath.empty() ||
+			!interaction.deathFlicPath.empty() || interaction.requestMainMenu ||
+			interaction.requestDemoEnding ||
+			interaction.requestRoomRestart ||
+			interaction.requestCloseupExit ||
+			interaction.cdChangeDisc > 0 ||
+			!interaction.dialogueNpcName.empty() ||
+			!interaction.dialogueContinuationTag.empty() ||
+			!interaction.continuationTag.empty() ||
+			!interaction.modalText.value.empty() ||
+			interaction.lightingCommand != kStartupLightingCommandNone ||
+			interaction.requestPlayerGotoXZ ||
+			!interaction.audioCommands.empty()) {
+		_flow.queueDialogueInteraction(interaction);
+	}
+}
+
+void RoomNpcDialogueSession::applyImmediateDialogueInteractionEffects(InteractionResult &interaction) {
+	if (!interaction.musicPath.empty())
+		(void)_engine.playMusic(interaction.musicPath);
+	if (!interaction.audioCommands.empty())
+		_flow.executeStartupAudioCommands(interaction.audioCommands);
+	interaction.musicPath.clear();
+	interaction.audioCommands.clear();
+}
+
+int RoomNpcDialogueSession::getRandomNumber(int maxValue) {
+	return _engine.getRandomNumber(maxValue);
+}
+
+void RoomNpcDialogueSession::setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) {
+	if (speakerId.empty() || speakerId.equalsIgnoreCase("SPEECH"))
+		return;
+
+	ensureSpeakerPortrait(speakerId, headVariant);
+	if (speakerId.equalsIgnoreCase("PC")) {
+		_leftHeadVisible = false;
+		_rightHeadVisible = _rightHeadBitmap.isValid();
+	} else {
+		_leftHeadVisible = _leftHeadBitmap.isValid();
+		_rightHeadVisible = false;
+	}
+}
+
+Graphics::Screen *RoomNpcDialogueSession::getActiveScreen() const {
+	return _engine.getScreen();
+}
+
+Common::Error RoomNpcDialogueSession::waitForPointerRelease() {
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result))
+				return result;
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+void RoomNpcDialogueSession::drawFontString(const Graphics::Font &font, bool usesCft, const Common::String &text,
+		int x, int y, int width, byte color) {
+	Graphics::Screen *activeScreen = getActiveScreen();
+	if (!activeScreen)
+		return;
+
+	if (usesCft)
+		font.drawString(activeScreen, text, x, y, width, 0);
+	else
+		RoomPresentation::drawShadowedString(*activeScreen, font, text, x, y, width, color);
+}
+
+void RoomNpcDialogueSession::ensureSpeakerPortrait(const Common::String &speakerId, int headVariant) {
+	if (speakerId.empty())
+		return;
+
+	const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
+	IndexedBitmap *targetBitmap = speakerId.equalsIgnoreCase("PC") ? &_rightHeadBitmap : &_leftHeadBitmap;
+	Common::String *targetSpeakerId = speakerId.equalsIgnoreCase("PC") ? &_rightHeadSpeakerId : &_leftHeadSpeakerId;
+	if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(headId))
+		return;
+
+	IndexedBitmap updatedBitmap;
+	if (loadDialogueHeadBitmap(_engine, speakerId, headVariant, updatedBitmap)) {
+		*targetBitmap = Common::move(updatedBitmap);
+		*targetSpeakerId = headId;
+	} else {
+		warning("Harvester: unable to load dialogue head for '%s'", headId.c_str());
+	}
+}
+
+void RoomNpcDialogueSession::drawDialogueOverlay(const IndexedBitmap *overlayBitmap,
+		const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
+		int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue,
+		bool textEntryCursorVisible) {
+	Graphics::Screen *activeScreen = getActiveScreen();
+	if (!activeScreen)
+		return;
+
+	setScaledPalette(*activeScreen, _palette, _paletteBrightness);
+	RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
+	if (_leftHeadVisible && _leftHeadBitmap.isValid())
+		blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+	if (_rightHeadVisible && _rightHeadBitmap.isValid())
+		blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+	if (overlayBitmap && overlayBitmap->isValid())
+		blitTransparentBitmap(*activeScreen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+	if (subtitleLines) {
+		if (_subtitleFontUsesCft) {
+			DialogueRendering::drawDialogueTextLines(*activeScreen, *_subtitleFont, *subtitleLines,
+				kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
+		} else {
+			const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_subtitleFont);
+			for (uint i = 0; i < subtitleLines->size(); ++i) {
+				RoomPresentation::drawShadowedString(*activeScreen, *_subtitleFont, (*subtitleLines)[i],
+					kDialogueSubtitleTextX,
+					kDialogueSubtitleTextY + (int)i * lineHeight,
+					kDialogueSubtitleTextWidth, kTextColorNormal);
+			}
+		}
+	}
+
+	if (topics) {
+		if (_menuTextConfig.hasDialogueKeywordLabel()) {
+			const Common::String &title = _menuTextConfig.dialogueKeywordLabel;
+			const int titleWidth = _highlightFont->getStringWidth(title);
+			const int titleX = kDialogueOverlayX +
+				MAX<int>(0, ((int)_keywordBitmap.width - titleWidth) / 2);
+			drawFontString(*_highlightFont, _highlightFontUsesCft, title, titleX,
+				kDialogueOverlayY + kDialogueKeywordTitleYOffset, titleWidth,
+				kTextColorNormal);
+		}
+
+		const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
+		for (uint i = 0; i < topics->size(); ++i) {
+			const bool highlighted = (int)i == hoveredTopicIndex;
+			const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
+			const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
+			drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
+				DialogueRendering::kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
+				highlighted ? kTextColorNormal : kTextColorHover);
+		}
+
+		const Graphics::Font &otherFont = hoverOther ? *_highlightFont : *_menuFont;
+		const bool otherUsesCft = hoverOther ? _highlightFontUsesCft : _menuFontUsesCft;
+		drawFontString(otherFont, otherUsesCft, _menuTextConfig.dialogueOtherLabel,
+			kDialogueOtherX, kDialogueOtherY,
+			kDialogueOtherWidth, hoverOther ? kTextColorNormal : kTextColorHover);
+	}
+
+	if (textEntryValue) {
+		drawFontString(*_highlightFont, _highlightFontUsesCft,
+			*textEntryValue + (textEntryCursorVisible ? "~" : ""),
+			kDialogueTextEntryX, kDialogueTextEntryY, kDialogueTextEntryMaxWidth,
+			kTextColorNormal);
+	}
+
+	if (_entityManager)
+		_entityManager->drawCursor(*activeScreen);
+	activeScreen->makeAllDirty();
+	activeScreen->update();
+}
+
+void RoomNpcDialogueSession::parseResponseMenuLine(const Common::String &responseLine,
+		Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
+	options.clear();
+	totalRows = 0;
+	if (responseLine.empty())
+		return;
+
+	Common::String segment;
+	Common::Array<Common::String> explicitLines;
+	Common::String optionText;
+
+	auto flushOption = [&]() {
+		if (explicitLines.empty())
+			return;
+
+		DialogueResponseOptionLayout option;
+		option.text = optionText;
+		if (optionText.size() > 1 && optionText[0] >= '1' && optionText[0] <= '9' &&
+				optionText[1] == '.')
+			option.hotkey = optionText[0];
+		option.rowStart = (int)totalRows;
+		for (const Common::String &explicitLine : explicitLines) {
+			Common::Array<Common::String> wrappedExplicitLines;
+			DialogueRendering::wrapDialogueTextLikeNative(*_menuFont, _menuFontUsesCft,
+				explicitLine, kDialogueSubtitleTextWidth, wrappedExplicitLines);
+			if (wrappedExplicitLines.empty())
+				wrappedExplicitLines.push_back(explicitLine);
+			for (const Common::String &wrappedExplicitLine : wrappedExplicitLines)
+				option.wrappedLines.push_back(wrappedExplicitLine);
+		}
+		option.rowCount = MAX<int>(1, option.wrappedLines.size());
+		totalRows += option.rowCount;
+		options.push_back(Common::move(option));
+
+		explicitLines.clear();
+		optionText.clear();
+	};
+
+	auto consumeSegment = [&](const Common::String &rawSegment) {
+		Common::String segmentText = rawSegment;
+		if (segmentText.empty())
+			return;
+
+		const bool startsNewOption = segmentText.size() > 1 && segmentText[0] >= '1' &&
+			segmentText[0] <= '9' && segmentText[1] == '.';
+		if (startsNewOption) {
+			flushOption();
+			optionText = segmentText;
+			explicitLines.push_back(Common::move(segmentText));
+			return;
+		}
+
+		if (!optionText.empty())
+			optionText += ' ';
+		optionText += segmentText;
+		explicitLines.push_back(Common::move(segmentText));
+	};
+
+	for (uint i = 0; i < responseLine.size(); ++i) {
+		if (responseLine[i] == '/') {
+			consumeSegment(segment);
+			segment.clear();
+			continue;
+		}
+
+		segment += responseLine[i];
+	}
+
+	consumeSegment(segment);
+	flushOption();
+}
+
+int RoomNpcDialogueSession::getResponseMenuItemAt(const Common::Array<DialogueResponseOptionLayout> &options,
+		uint totalRows, const Common::Point &mousePos) {
+	if (mousePos.x < DialogueRendering::kDialogueTopicStartX || mousePos.x > DialogueRendering::kDialogueTopicEndX)
+		return -1;
+	if (mousePos.y < DialogueRendering::kDialogueKeywordTextY)
+		return -1;
+
+	const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
+	const int row = (mousePos.y - DialogueRendering::kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
+	if (row < 0 || row >= (int)totalRows)
+		return -1;
+
+	for (uint i = 0; i < options.size(); ++i) {
+		const DialogueResponseOptionLayout &option = options[i];
+		if (row >= option.rowStart && row < option.rowStart + option.rowCount)
+			return (int)i;
+	}
+
+	return -1;
+}
+
+void RoomNpcDialogueSession::drawDialogueResponseMenu(const IndexedBitmap *textboxBitmap,
+		const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
+	Graphics::Screen *activeScreen = getActiveScreen();
+	if (!activeScreen)
+		return;
+
+	setScaledPalette(*activeScreen, _palette, _paletteBrightness);
+	RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
+	if (_leftHeadVisible && _leftHeadBitmap.isValid())
+		blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+	if (_rightHeadVisible && _rightHeadBitmap.isValid())
+		blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+	if (textboxBitmap && textboxBitmap->isValid())
+		blitTransparentBitmap(*activeScreen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+	const Common::String &title = _menuTextConfig.dialogueResponsesLabel;
+	const Graphics::Font &titleFont = *_highlightFont;
+	const bool titleUsesCft = _highlightFontUsesCft;
+	const int titleWidth = titleFont.getStringWidth(title);
+	const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
+	drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
+
+	const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
+	int drawY = DialogueRendering::kDialogueKeywordTextY;
+	for (uint i = 0; i < options.size(); ++i) {
+		const bool highlighted = (int)i == hoveredOptionIndex;
+		const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
+		const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
+		const byte color = highlighted ? kTextColorNormal : kTextColorHover;
+
+		for (const Common::String &wrappedLine : options[i].wrappedLines) {
+			drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
+				kDialogueSubtitleTextWidth, color);
+			drawY += lineHeight;
+		}
+	}
+
+	if (_entityManager)
+		_entityManager->drawCursor(*activeScreen);
+	activeScreen->makeAllDirty();
+	activeScreen->update();
+}
+
+Common::Error RoomNpcDialogueSession::runResponseMenuTextInternal(const Common::String &responseLine, int responseLineIndex,
+		int &selectedIndex) {
+	selectedIndex = 0;
+
+	if (responseLine.empty()) {
+		if (responseLineIndex >= 0) {
+			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) is empty",
+				responseLineIndex, responseLineIndex + 1);
+		} else {
+			debugC(1, kDebugDialogue, "Harvester: response menu text is empty");
+		}
+		return Common::kNoError;
+	}
+
+	Common::Array<DialogueResponseOptionLayout> options;
+	uint totalRows = 0;
+	parseResponseMenuLine(responseLine, options, totalRows);
+	if (options.empty()) {
+		if (responseLineIndex >= 0) {
+			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) produced no options",
+				responseLineIndex, responseLineIndex + 1);
+		} else {
+			debugC(1, kDebugDialogue, "Harvester: response menu text produced no options");
+		}
+		return Common::kNoError;
+	}
+
+	Common::Array<Common::String> optionTexts;
+	for (const DialogueResponseOptionLayout &option : options) {
+		optionTexts.push_back(option.text);
+		debugC(3, kDebugDialogue, "Harvester: response menu option[%u] hotkey=%c rowStart=%d rowCount=%d",
+			optionTexts.size(), option.hotkey ? option.hotkey : '-', option.rowStart, option.rowCount);
+	}
+	logDialogueMenuItems("Response menu", responseLineIndex, responseLine, optionTexts);
+
+	const IndexedBitmap *textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueResponseTextboxIndex(totalRows));
+	Common::Error releaseError = waitForPointerRelease();
+	if (releaseError.getCode() != Common::kNoError)
+		return releaseError;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	for (;;) {
+		const int hoveredOptionIndex = getResponseMenuItemAt(options, totalRows, _mousePos);
+		drawDialogueResponseMenu(textboxBitmap, options, hoveredOptionIndex);
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result))
+				return result;
+
+			if (event.type == Common::EVENT_KEYDOWN) {
+				debugC(3, kDebugDialogue, "Harvester: response menu keycode=%d ascii=%u",
+					(int)event.kbd.keycode, event.kbd.ascii);
+				if (event.kbd.keycode >= Common::KEYCODE_1 && event.kbd.keycode <= Common::KEYCODE_9) {
+					const char hotkey = '1' + event.kbd.keycode - Common::KEYCODE_1;
+					for (int optionIndex = (int)options.size() - 1; optionIndex >= 0; --optionIndex) {
+						const DialogueResponseOptionLayout &option = options[(uint)optionIndex];
+						if (option.hotkey != hotkey)
+							continue;
+						selectedIndex = optionIndex + 1;
+						if (responseLineIndex >= 0) {
+							debugC(2, kDebugDialogue,
+								"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via hotkey=%c result=%d",
+								responseLineIndex, responseLineIndex + 1,
+								optionIndex + 1, option.text.c_str(), hotkey, selectedIndex);
+						} else {
+							debugC(2, kDebugDialogue,
+								"Harvester: response menu text selected option[%d]='%s' via hotkey=%c result=%d",
+								optionIndex + 1, option.text.c_str(), hotkey, selectedIndex);
+						}
+						return waitForPointerRelease();
+					}
+				}
+			} else if (event.type == Common::EVENT_LBUTTONDOWN) {
+				const int clickedOptionIndex = getResponseMenuItemAt(options, totalRows, event.mouse);
+				if (clickedOptionIndex < 0)
+					continue;
+				selectedIndex = clickedOptionIndex + 1;
+				if (responseLineIndex >= 0) {
+					debugC(2, kDebugDialogue,
+						"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via click x=%d y=%d result=%d",
+						responseLineIndex, responseLineIndex + 1,
+						selectedIndex, options[(uint)clickedOptionIndex].text.c_str(),
+						event.mouse.x, event.mouse.y, selectedIndex);
+				} else {
+					debugC(2, kDebugDialogue,
+						"Harvester: response menu text selected option[%d]='%s' via click x=%d y=%d result=%d",
+						selectedIndex, options[(uint)clickedOptionIndex].text.c_str(),
+						event.mouse.x, event.mouse.y, selectedIndex);
+				}
+				return waitForPointerRelease();
+			}
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/dialogue/dialogue_session.h b/engines/harvester/dialogue/dialogue_session.h
new file mode 100644
index 00000000000..7886a55f79f
--- /dev/null
+++ b/engines/harvester/dialogue/dialogue_session.h
@@ -0,0 +1,131 @@
+/* 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_DIALOGUE_SESSION_H
+#define HARVESTER_DIALOGUE_SESSION_H
+
+#include "common/ptr.h"
+#include "common/rect.h"
+#include "harvester/bitmap.h"
+#include "harvester/cft_font.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Graphics {
+class Screen;
+}
+
+namespace Harvester {
+
+class Art;
+class EntityManager;
+struct MenuTextConfig;
+
+class RoomNpcDialogueSession : public DialogueRuntimeCallbacks {
+public:
+	struct DialogueResponseOptionLayout {
+		Common::String text;
+		Common::Array<Common::String> wrappedLines;
+		char hotkey = 0;
+		int rowStart = 0;
+		int rowCount = 0;
+	};
+
+	RoomNpcDialogueSession(HarvesterEngine &engine, Common::Point &mousePos, Flow &flow,
+		const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness,
+		const NpcRecord &npc);
+	bool isValid() const;
+	const Common::String &getGenericByeTopic() const;
+	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
+		int headVariant) override;
+	Common::Error playDialogueLine(int wavId, const Common::String &speakerId) override;
+	Common::Error playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) override;
+	Common::Error playDialogueFst(const Common::String &path) override;
+	Common::Error clearScreenToBlack() override;
+	Common::Error showCdChangePrompt(int discNumber) override;
+	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
+		Common::String &selectedTopic,
+		DialogueKeywordMenuSelectionState &selection) override;
+	Common::Error runResponseMenu(int responseLineIndex, int &selectedIndex) override;
+	Common::Error runResponseMenuText(const Common::String &responseLine, int &selectedIndex) override;
+	Common::Error runGameOverScreen() override;
+	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
+		int responseLineIndex, const char *) override;
+	bool matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) override;
+	bool matchesAnyResponseLine(const Common::String &selectedTopic,
+		const int *responseLineIndices, uint responseLineCount) override;
+	void queueDialogueInteractionIfNeeded(const InteractionResult &interaction) override;
+	void applyImmediateDialogueInteractionEffects(InteractionResult &interaction) override;
+	int getRandomNumber(int maxValue) override;
+	void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) override;
+
+private:
+	Graphics::Screen *getActiveScreen() const;
+	Common::Error waitForPointerRelease();
+	void drawFontString(const Graphics::Font &font, bool usesCft, const Common::String &text,
+		int x, int y, int width, byte color);
+	void ensureSpeakerPortrait(const Common::String &speakerId, int headVariant);
+	void drawDialogueOverlay(const IndexedBitmap *overlayBitmap,
+		const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
+		int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue,
+		bool textEntryCursorVisible = false);
+	void parseResponseMenuLine(const Common::String &responseLine,
+		Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows);
+	int getResponseMenuItemAt(const Common::Array<DialogueResponseOptionLayout> &options,
+		uint totalRows, const Common::Point &mousePos);
+	void drawDialogueResponseMenu(const IndexedBitmap *textboxBitmap,
+		const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex);
+	Common::Error runResponseMenuTextInternal(const Common::String &responseLine, int responseLineIndex,
+		int &selectedIndex);
+
+	HarvesterEngine &_engine;
+	Common::Point &_mousePos;
+	Flow &_flow;
+	const MenuTextConfig &_menuTextConfig;
+	const IndexedBitmap &_backdrop;
+	const byte *_palette;
+	const float _paletteBrightness;
+	const NpcRecord &_npc;
+	Script *_script;
+	Text *_text;
+	Art *_art;
+	EntityManager *_entityManager;
+	const Graphics::Font *_fallbackFont;
+	Common::ScopedPtr<HarvesterCftFont> _subtitleCftFont;
+	Common::ScopedPtr<HarvesterCftFont> _menuCftFont;
+	const Graphics::Font *_subtitleFont;
+	const Graphics::Font *_menuFont;
+	const Graphics::Font *_highlightFont;
+	bool _subtitleFontUsesCft;
+	bool _menuFontUsesCft;
+	bool _highlightFontUsesCft;
+	IndexedBitmap _keywordBitmap;
+	Common::String _genericByeTopic;
+	IndexedBitmap _leftHeadBitmap;
+	IndexedBitmap _rightHeadBitmap;
+	Common::String _leftHeadSpeakerId;
+	Common::String _rightHeadSpeakerId;
+	bool _leftHeadVisible;
+	bool _rightHeadVisible;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_DIALOGUE_SESSION_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 3c28b6bae8b..8d2438f8499 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -8,6 +8,7 @@ MODULE_OBJS = \
 	console.o \
 	dialogue/dialogue.o \
 	dialogue/dialogue_render.o \
+	dialogue/dialogue_session.o \
 	flow/flow.o \
 	flow/flow_debug.o \
 	flow/flow_map.o \


Commit: 4c6a23d829f57ed703275848b079b140649f8164
    https://github.com/scummvm/scummvm/commit/4c6a23d829f57ed703275848b079b140649f8164
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:52-04:00

Commit Message:
HARVESTER: Separate dialogue speech and modal playback

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/dialogue/dialogue_playback.cpp
    engines/harvester/dialogue/dialogue_render.h
    engines/harvester/dialogue/dialogue_session.cpp
    engines/harvester/dialogue/dialogue_session.h
    engines/harvester/module.mk


diff --git a/engines/harvester/dialogue/dialogue_playback.cpp b/engines/harvester/dialogue/dialogue_playback.cpp
new file mode 100644
index 00000000000..5c69c8d3db5
--- /dev/null
+++ b/engines/harvester/dialogue/dialogue_playback.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/dialogue/dialogue_session.h"
+#include "harvester/dialogue/dialogue_render.h"
+
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/framelimiter.h"
+#include "harvester/art.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/flow/flow.h"
+#include "harvester/flow/flow_support.h"
+#include "harvester/fst_player.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+#include "harvester/scene/room_presentation.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
+
+static bool isDialogueVoiceInterruptEvent(const Common::Event &event) {
+	return event.type == Common::EVENT_LBUTTONDOWN ||
+		event.type == Common::EVENT_RBUTTONDOWN ||
+		(event.type == Common::EVENT_KEYDOWN && event.kbd.keycode == Common::KEYCODE_ESCAPE);
+}
+
+static bool isDialoguePointerPressEvent(const Common::Event &event) {
+	return event.type == Common::EVENT_LBUTTONDOWN || event.type == Common::EVENT_RBUTTONDOWN;
+}
+
+static bool isDialogueContinueEvent(const Common::Event &event) {
+	return isDialoguePointerPressEvent(event) || event.type == Common::EVENT_KEYDOWN;
+}
+
+static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
+		return false;
+
+	memcpy(dest, data.data(), 256 * 3);
+	return true;
+}
+
+static bool isPrimaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 1) != 0);
+}
+
+static bool isSecondaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 2) != 0);
+}
+
+static Common::String buildDialogueVoicePath(const Script &script, int wavId) {
+	if (wavId <= 0)
+		return Common::String();
+
+	return Common::String::format("%s%d.CMP", script.getVoicePath().c_str(), wavId);
+}
+
+} // End of anonymous namespace
+
+Common::Error RoomNpcDialogueSession::playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
+		int headVariant) {
+	setActiveSpeakerPortrait(speakerId, headVariant);
+
+	const StartupDialogueTextMode textMode = _script->getDialogueTextMode();
+	Common::String subtitleText;
+	const bool textEnabled = textMode != kStartupDialogueTextNone &&
+		_text->resolveDialogueSubtitle(wavId, subtitleText);
+	Common::Array<Common::String> subtitleLines;
+	const IndexedBitmap *textboxBitmap = nullptr;
+	if (textEnabled) {
+		DialogueRendering::wrapDialogueTextLikeNative(*_subtitleFont, _subtitleFontUsesCft,
+			subtitleText, DialogueRendering::kDialogueSubtitleTextWidth, subtitleLines);
+		textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueTextboxIndex(subtitleLines.size()));
+	}
+
+	const Common::String voicePath = buildDialogueVoicePath(*_script, wavId);
+	const bool voiceStarted = !voicePath.empty() && _engine.playSpeech(voicePath);
+	debugC(2, kDebugDialogue,
+		"Harvester: dialogue line wav=0x%x speaker='%s' headVariant=%d voice='%s' textMode=%d subtitle='%s'",
+		wavId, speakerId.c_str(), headVariant, voicePath.c_str(), (int)textMode,
+		textEnabled ? subtitleText.c_str() : "");
+	Common::Error releaseError = waitForPointerRelease();
+	if (releaseError.getCode() != Common::kNoError) {
+		_engine.stopSpeech();
+		return releaseError;
+	}
+
+	bool voiceInterrupted = false;
+	bool pointerInterrupted = false;
+	Graphics::FrameLimiter limiter(g_system, 60);
+	for (;;) {
+		drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr, nullptr, -1, false, nullptr);
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result)) {
+				_engine.stopSpeech();
+				return result;
+			}
+
+			if (isDialogueVoiceInterruptEvent(event)) {
+				voiceInterrupted = true;
+				if (isDialoguePointerPressEvent(event))
+					pointerInterrupted = true;
+			}
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		if (voiceInterrupted || (!voiceStarted || !_engine.isSpeechPlaying()))
+			break;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	_engine.stopSpeech();
+	if (pointerInterrupted) {
+		Common::Error releaseResult = waitForPointerRelease();
+		if (releaseResult.getCode() != Common::kNoError)
+			return releaseResult;
+	} else if (textMode == kStartupDialogueTextClick) {
+		return waitForDialogueContinue(textboxBitmap, textEnabled ? &subtitleLines : nullptr);
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::waitForDialogueContinue(const IndexedBitmap *textboxBitmap,
+		const Common::Array<Common::String> *subtitleLines) {
+	Graphics::FrameLimiter clickLimiter(g_system, 60);
+	for (;;) {
+		drawDialogueOverlay(textboxBitmap, subtitleLines,
+			nullptr, -1, false, nullptr);
+
+		bool continuePressed = false;
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result))
+				return result;
+
+			if (isDialogueContinueEvent(event))
+				continuePressed = true;
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		if (continuePressed)
+			break;
+
+		clickLimiter.delayBeforeSwap();
+		clickLimiter.startFrame();
+	}
+
+	Common::Error releaseResult = waitForPointerRelease();
+	if (releaseResult.getCode() != Common::kNoError)
+		return releaseResult;
+
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::playDialogueLine(int wavId, const Common::String &speakerId) {
+	return playDialogueLineWithVariant(wavId, speakerId, 0);
+}
+
+Common::Error RoomNpcDialogueSession::playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) {
+	for (uint i = 0; i < count; ++i) {
+		Common::Error lineError = playDialogueLineWithVariant(
+			lines[i].wavId, lines[i].speakerId, lines[i].headVariant);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::playDialogueFst(const Common::String &path) {
+	FstPlayer fstPlayer(_engine);
+	if (!fstPlayer.play(path))
+		return Common::kReadingFailed;
+
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::clearScreenToBlack() {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen)
+		return Common::kReadingFailed;
+
+	byte blackPalette[256 * 3];
+	memset(blackPalette, 0, sizeof(blackPalette));
+	screen->fillRect(screen->getBounds(), 0);
+	setScaledPalette(*screen, blackPalette, 1.0f);
+	screen->makeAllDirty();
+	screen->update();
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::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()) {
+		Graphics::Screen *screen = _engine.getScreen();
+		if (!screen)
+			return Common::kReadingFailed;
+
+		const Common::String bitmapPath =
+			Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
+		IndexedBitmap promptBitmap;
+		byte promptPalette[256 * 3];
+		if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
+				!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
+			warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
+			return Common::kReadingFailed;
+		}
+
+		_engine.stopMusic();
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result))
+				return result;
+			if (event.type == Common::EVENT_MOUSEMOVE)
+				_mousePos = event.mouse;
+		}
+
+		bool waitingForRelease = false;
+		bool needsRedraw = true;
+		bool activateRequestedDisc = false;
+		Graphics::FrameLimiter limiter(g_system, 60);
+
+		while (!_engine.shouldQuit() && !activateRequestedDisc) {
+			if (needsRedraw) {
+				screen->fillRect(screen->getBounds(), 0);
+				RoomPresentation::blitBitmap(*screen, promptBitmap, 0, 0);
+				setScaledPalette(*screen, promptPalette, 1.0f);
+				if (_entityManager)
+					_entityManager->drawCursor(*screen);
+				screen->makeAllDirty();
+				screen->update();
+				needsRedraw = false;
+			}
+
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (_flow.handleSystemEvent(event, result))
+					return result;
+
+				switch (event.type) {
+				case Common::EVENT_MOUSEMOVE:
+					_mousePos = event.mouse;
+					needsRedraw = true;
+					break;
+				case Common::EVENT_LBUTTONDOWN:
+					waitingForRelease = true;
+					break;
+				case Common::EVENT_LBUTTONUP:
+					if (waitingForRelease)
+						activateRequestedDisc = true;
+					break;
+				default:
+					break;
+				}
+			}
+
+			if (_entityManager && _entityManager->syncCursorEntityPosition(_mousePos))
+				needsRedraw = true;
+
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+	}
+
+	return activateDialogueDisc(*resources, discNumber, previousDisc);
+}
+
+Common::Error RoomNpcDialogueSession::activateDialogueDisc(ResourceManager &resources,
+		int discNumber, int previousDisc) {
+	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 RoomNpcDialogueSession::runGameOverScreen() {
+	debugC(1, kDebugDialogue, "Harvester: dialogue death requesting game-over menu");
+	_flow.requestGameOverReturn();
+	return Common::kNoError;
+}
+
+Common::Error RoomNpcDialogueSession::waitForPointerRelease() {
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result))
+				return result;
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/dialogue/dialogue_render.h b/engines/harvester/dialogue/dialogue_render.h
index 44bec954daa..99989403609 100644
--- a/engines/harvester/dialogue/dialogue_render.h
+++ b/engines/harvester/dialogue/dialogue_render.h
@@ -34,6 +34,7 @@ class Screen;
 namespace Harvester {
 namespace DialogueRendering {
 
+static const int kDialogueSubtitleTextWidth = 316;
 static const int kDialogueKeywordTextY = 34;
 static const int kDialogueTopicStartX = 168;
 static const int kDialogueTopicEndX = 482;
diff --git a/engines/harvester/dialogue/dialogue_session.cpp b/engines/harvester/dialogue/dialogue_session.cpp
index 551c0bfbf6a..74ffa79964d 100644
--- a/engines/harvester/dialogue/dialogue_session.cpp
+++ b/engines/harvester/dialogue/dialogue_session.cpp
@@ -33,7 +33,6 @@
 #include "harvester/entity.h"
 #include "harvester/flow/flow.h"
 #include "harvester/flow/flow_support.h"
-#include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
 #include "harvester/scene/room_presentation.h"
@@ -50,7 +49,6 @@ static const int kDialogueRightHeadX = 474;
 static const int kDialogueHeadY = 6;
 static const int kDialogueSubtitleTextX = 178;
 static const int kDialogueSubtitleTextY = 12;
-static const int kDialogueSubtitleTextWidth = 316;
 static const int kDialogueKeywordTextX = 178;
 static const int kDialogueKeywordTextWidth = 300;
 static const int kDialogueOtherX = 171;
@@ -63,46 +61,12 @@ static const uint32 kDialogueTextEntryBlinkTicks = 0x30;
 static const int kDialogueOtherWidth = 120;
 static const int kDialogueKeywordTitleYOffset = 4;
 static const int kDialogueGenericByeResponseIndex = 13;
-static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
 static const char *const kDialogueLocalizedKeywordBitmapPath = "GRAPHIC/OTHER/KEYWORD.BM";
 
 static const byte kTextColorNormal = 255;
 static const byte kTextColorHover = 251;
 
-static bool isDialogueVoiceInterruptEvent(const Common::Event &event) {
-	return event.type == Common::EVENT_LBUTTONDOWN ||
-		event.type == Common::EVENT_RBUTTONDOWN ||
-		(event.type == Common::EVENT_KEYDOWN && event.kbd.keycode == Common::KEYCODE_ESCAPE);
-}
-
-static bool isDialoguePointerPressEvent(const Common::Event &event) {
-	return event.type == Common::EVENT_LBUTTONDOWN || event.type == Common::EVENT_RBUTTONDOWN;
-}
-
-static bool isDialogueContinueEvent(const Common::Event &event) {
-	return isDialoguePointerPressEvent(event) || event.type == Common::EVENT_KEYDOWN;
-}
-
-static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
-	Common::Array<byte> data;
-	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
-		return false;
-
-	memcpy(dest, data.data(), 256 * 3);
-	return true;
-}
-
-static bool isPrimaryMouseDown() {
-	return g_system && g_system->getEventManager() &&
-		((g_system->getEventManager()->getButtonState() & 1) != 0);
-}
-
-static bool isSecondaryMouseDown() {
-	return g_system && g_system->getEventManager() &&
-		((g_system->getEventManager()->getButtonState() & 2) != 0);
-}
-
 static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
 	parts.clear();
 	if (line.empty())
@@ -159,13 +123,6 @@ static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String
 	return loadBitmapResource(*resources, head->portraitPath + ".BM", bitmap);
 }
 
-static Common::String buildDialogueVoicePath(const Script &script, int wavId) {
-	if (wavId <= 0)
-		return Common::String();
-
-	return Common::String::format("%s%d.CMP", script.getVoicePath().c_str(), wavId);
-}
-
 } // End of anonymous namespace
 
 RoomNpcDialogueSession::RoomNpcDialogueSession(HarvesterEngine &engine, Common::Point &mousePos, Flow &flow,
@@ -240,239 +197,6 @@ const Common::String &RoomNpcDialogueSession::getGenericByeTopic() const {
 	return _genericByeTopic;
 }
 
-Common::Error RoomNpcDialogueSession::playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
-		int headVariant) {
-	setActiveSpeakerPortrait(speakerId, headVariant);
-
-	const StartupDialogueTextMode textMode = _script->getDialogueTextMode();
-	Common::String subtitleText;
-	const bool textEnabled = textMode != kStartupDialogueTextNone &&
-		_text->resolveDialogueSubtitle(wavId, subtitleText);
-	Common::Array<Common::String> subtitleLines;
-	const IndexedBitmap *textboxBitmap = nullptr;
-	if (textEnabled) {
-		DialogueRendering::wrapDialogueTextLikeNative(*_subtitleFont, _subtitleFontUsesCft,
-			subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
-		textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueTextboxIndex(subtitleLines.size()));
-	}
-
-	const Common::String voicePath = buildDialogueVoicePath(*_script, wavId);
-	const bool voiceStarted = !voicePath.empty() && _engine.playSpeech(voicePath);
-	debugC(2, kDebugDialogue,
-		"Harvester: dialogue line wav=0x%x speaker='%s' headVariant=%d voice='%s' textMode=%d subtitle='%s'",
-		wavId, speakerId.c_str(), headVariant, voicePath.c_str(), (int)textMode,
-		textEnabled ? subtitleText.c_str() : "");
-	Common::Error releaseError = waitForPointerRelease();
-	if (releaseError.getCode() != Common::kNoError) {
-		_engine.stopSpeech();
-		return releaseError;
-	}
-
-	bool voiceInterrupted = false;
-	bool pointerInterrupted = false;
-	Graphics::FrameLimiter limiter(g_system, 60);
-	for (;;) {
-		drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr, nullptr, -1, false, nullptr);
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (_flow.handleSystemEvent(event, result)) {
-				_engine.stopSpeech();
-				return result;
-			}
-
-			if (isDialogueVoiceInterruptEvent(event)) {
-				voiceInterrupted = true;
-				if (isDialoguePointerPressEvent(event))
-					pointerInterrupted = true;
-			}
-		}
-
-		if (_entityManager)
-			(void)_entityManager->syncCursorEntityPosition(_mousePos);
-		if (voiceInterrupted || (!voiceStarted || !_engine.isSpeechPlaying()))
-			break;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	_engine.stopSpeech();
-	if (pointerInterrupted) {
-		Common::Error releaseResult = waitForPointerRelease();
-		if (releaseResult.getCode() != Common::kNoError)
-			return releaseResult;
-	} else if (textMode == kStartupDialogueTextClick) {
-		Graphics::FrameLimiter clickLimiter(g_system, 60);
-		for (;;) {
-			drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr,
-				nullptr, -1, false, nullptr);
-
-			bool continuePressed = false;
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (_flow.handleSystemEvent(event, result))
-					return result;
-
-				if (isDialogueContinueEvent(event))
-					continuePressed = true;
-			}
-
-			if (_entityManager)
-				(void)_entityManager->syncCursorEntityPosition(_mousePos);
-			if (continuePressed)
-				break;
-
-			clickLimiter.delayBeforeSwap();
-			clickLimiter.startFrame();
-		}
-
-		Common::Error releaseResult = waitForPointerRelease();
-		if (releaseResult.getCode() != Common::kNoError)
-			return releaseResult;
-	}
-
-	return Common::kNoError;
-}
-
-Common::Error RoomNpcDialogueSession::playDialogueLine(int wavId, const Common::String &speakerId) {
-	return playDialogueLineWithVariant(wavId, speakerId, 0);
-}
-
-Common::Error RoomNpcDialogueSession::playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) {
-	for (uint i = 0; i < count; ++i) {
-		Common::Error lineError = playDialogueLineWithVariant(
-			lines[i].wavId, lines[i].speakerId, lines[i].headVariant);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	return Common::kNoError;
-}
-
-Common::Error RoomNpcDialogueSession::playDialogueFst(const Common::String &path) {
-	FstPlayer fstPlayer(_engine);
-	if (!fstPlayer.play(path))
-		return Common::kReadingFailed;
-
-	return Common::kNoError;
-}
-
-Common::Error RoomNpcDialogueSession::clearScreenToBlack() {
-	Graphics::Screen *screen = _engine.getScreen();
-	if (!screen)
-		return Common::kReadingFailed;
-
-	byte blackPalette[256 * 3];
-	memset(blackPalette, 0, sizeof(blackPalette));
-	screen->fillRect(screen->getBounds(), 0);
-	setScaledPalette(*screen, blackPalette, 1.0f);
-	screen->makeAllDirty();
-	screen->update();
-	return Common::kNoError;
-}
-
-Common::Error RoomNpcDialogueSession::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()) {
-		Graphics::Screen *screen = _engine.getScreen();
-		if (!screen)
-			return Common::kReadingFailed;
-
-		const Common::String bitmapPath =
-			Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
-		IndexedBitmap promptBitmap;
-		byte promptPalette[256 * 3];
-		if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
-				!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
-			warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
-			return Common::kReadingFailed;
-		}
-
-		_engine.stopMusic();
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (_flow.handleSystemEvent(event, result))
-				return result;
-			if (event.type == Common::EVENT_MOUSEMOVE)
-				_mousePos = event.mouse;
-		}
-
-		bool waitingForRelease = false;
-		bool needsRedraw = true;
-		bool activateRequestedDisc = false;
-		Graphics::FrameLimiter limiter(g_system, 60);
-
-		while (!_engine.shouldQuit() && !activateRequestedDisc) {
-			if (needsRedraw) {
-				screen->fillRect(screen->getBounds(), 0);
-				RoomPresentation::blitBitmap(*screen, promptBitmap, 0, 0);
-				setScaledPalette(*screen, promptPalette, 1.0f);
-				if (_entityManager)
-					_entityManager->drawCursor(*screen);
-				screen->makeAllDirty();
-				screen->update();
-				needsRedraw = false;
-			}
-
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (_flow.handleSystemEvent(event, result))
-					return result;
-
-				switch (event.type) {
-				case Common::EVENT_MOUSEMOVE:
-					_mousePos = event.mouse;
-					needsRedraw = true;
-					break;
-				case Common::EVENT_LBUTTONDOWN:
-					waitingForRelease = true;
-					break;
-				case Common::EVENT_LBUTTONUP:
-					if (waitingForRelease)
-						activateRequestedDisc = true;
-					break;
-				default:
-					break;
-				}
-			}
-
-			if (_entityManager && _entityManager->syncCursorEntityPosition(_mousePos))
-				needsRedraw = true;
-
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-	}
-
-	if (!_engine.activateDisc(discNumber)) {
-		warning("Harvester: unable to activate disc %d resources", discNumber);
-		return Common::kReadingFailed;
-	}
-	if ((discNumber == 1 || discNumber == 3) &&
-			previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
-		Script *script = _engine.getScript();
-		if (!script || !script->reloadTownWorld(*resources)) {
-			warning("Harvester: unable to reload town script after disc prompt %d -> %d",
-				previousDisc, discNumber);
-			return Common::kReadingFailed;
-		}
-	}
-
-	return Common::kNoError;
-}
-
 Common::Error RoomNpcDialogueSession::runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
 		Common::String &selectedTopic,
 		DialogueKeywordMenuSelectionState &selection) {
@@ -608,14 +332,6 @@ Common::Error RoomNpcDialogueSession::runResponseMenuText(const Common::String &
 	return runResponseMenuTextInternal(responseLine, -1, selectedIndex);
 }
 
-Common::Error RoomNpcDialogueSession::runGameOverScreen() {
-	// GOG run_game_over_screen (0x7c540) enters run_main_menu (0x67390)
-	// with GAMEOVER.BM and GAMEOVER.PAL retained as the menu backdrop.
-	debugC(1, kDebugDialogue, "Harvester: dialogue death requesting game-over menu");
-	_flow.requestGameOverReturn();
-	return Common::kNoError;
-}
-
 void RoomNpcDialogueSession::assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
 		int responseLineIndex, const char *) {
 	topicBuffer = _text->getDialogueResponseLine(responseLineIndex);
@@ -688,25 +404,6 @@ Graphics::Screen *RoomNpcDialogueSession::getActiveScreen() const {
 	return _engine.getScreen();
 }
 
-Common::Error RoomNpcDialogueSession::waitForPointerRelease() {
-	Graphics::FrameLimiter limiter(g_system, 60);
-	while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (_flow.handleSystemEvent(event, result))
-				return result;
-		}
-
-		if (_entityManager)
-			(void)_entityManager->syncCursorEntityPosition(_mousePos);
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
-}
-
 void RoomNpcDialogueSession::drawFontString(const Graphics::Font &font, bool usesCft, const Common::String &text,
 		int x, int y, int width, byte color) {
 	Graphics::Screen *activeScreen = getActiveScreen();
@@ -758,14 +455,14 @@ void RoomNpcDialogueSession::drawDialogueOverlay(const IndexedBitmap *overlayBit
 	if (subtitleLines) {
 		if (_subtitleFontUsesCft) {
 			DialogueRendering::drawDialogueTextLines(*activeScreen, *_subtitleFont, *subtitleLines,
-				kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
+				kDialogueSubtitleTextX, kDialogueSubtitleTextY, DialogueRendering::kDialogueSubtitleTextWidth);
 		} else {
 			const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_subtitleFont);
 			for (uint i = 0; i < subtitleLines->size(); ++i) {
 				RoomPresentation::drawShadowedString(*activeScreen, *_subtitleFont, (*subtitleLines)[i],
 					kDialogueSubtitleTextX,
 					kDialogueSubtitleTextY + (int)i * lineHeight,
-					kDialogueSubtitleTextWidth, kTextColorNormal);
+					DialogueRendering::kDialogueSubtitleTextWidth, kTextColorNormal);
 			}
 		}
 	}
@@ -835,7 +532,7 @@ void RoomNpcDialogueSession::parseResponseMenuLine(const Common::String &respons
 		for (const Common::String &explicitLine : explicitLines) {
 			Common::Array<Common::String> wrappedExplicitLines;
 			DialogueRendering::wrapDialogueTextLikeNative(*_menuFont, _menuFontUsesCft,
-				explicitLine, kDialogueSubtitleTextWidth, wrappedExplicitLines);
+				explicitLine, DialogueRendering::kDialogueSubtitleTextWidth, wrappedExplicitLines);
 			if (wrappedExplicitLines.empty())
 				wrappedExplicitLines.push_back(explicitLine);
 			for (const Common::String &wrappedExplicitLine : wrappedExplicitLines)
@@ -923,7 +620,7 @@ void RoomNpcDialogueSession::drawDialogueResponseMenu(const IndexedBitmap *textb
 	const Graphics::Font &titleFont = *_highlightFont;
 	const bool titleUsesCft = _highlightFontUsesCft;
 	const int titleWidth = titleFont.getStringWidth(title);
-	const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
+	const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (DialogueRendering::kDialogueSubtitleTextWidth - titleWidth) / 2);
 	drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
 
 	const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
@@ -936,7 +633,7 @@ void RoomNpcDialogueSession::drawDialogueResponseMenu(const IndexedBitmap *textb
 
 		for (const Common::String &wrappedLine : options[i].wrappedLines) {
 			drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
-				kDialogueSubtitleTextWidth, color);
+				DialogueRendering::kDialogueSubtitleTextWidth, color);
 			drawY += lineHeight;
 		}
 	}
diff --git a/engines/harvester/dialogue/dialogue_session.h b/engines/harvester/dialogue/dialogue_session.h
index 7886a55f79f..9e76dca33fc 100644
--- a/engines/harvester/dialogue/dialogue_session.h
+++ b/engines/harvester/dialogue/dialogue_session.h
@@ -36,6 +36,7 @@ namespace Harvester {
 
 class Art;
 class EntityManager;
+class ResourceManager;
 struct MenuTextConfig;
 
 class RoomNpcDialogueSession : public DialogueRuntimeCallbacks {
@@ -79,6 +80,9 @@ public:
 private:
 	Graphics::Screen *getActiveScreen() const;
 	Common::Error waitForPointerRelease();
+	Common::Error waitForDialogueContinue(const IndexedBitmap *textboxBitmap,
+		const Common::Array<Common::String> *subtitleLines);
+	Common::Error activateDialogueDisc(ResourceManager &resources, int discNumber, int previousDisc);
 	void drawFontString(const Graphics::Font &font, bool usesCft, const Common::String &text,
 		int x, int y, int width, byte color);
 	void ensureSpeakerPortrait(const Common::String &speakerId, int headVariant);
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 8d2438f8499..cd7c62e0d4a 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -7,6 +7,7 @@ MODULE_OBJS = \
 	cheats.o \
 	console.o \
 	dialogue/dialogue.o \
+	dialogue/dialogue_playback.o \
 	dialogue/dialogue_render.o \
 	dialogue/dialogue_session.o \
 	flow/flow.o \


Commit: 41294db54f5a2a09e59cc84dcc0a9da4cf7504d9
    https://github.com/scummvm/scummvm/commit/41294db54f5a2a09e59cc84dcc0a9da4cf7504d9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:52-04:00

Commit Message:
HARVESTER: Separate dialogue menus and text entry

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/dialogue/dialogue_menu.cpp
    engines/harvester/dialogue/dialogue_render.cpp
    engines/harvester/dialogue/dialogue_render.h
    engines/harvester/dialogue/dialogue_session.cpp
    engines/harvester/dialogue/dialogue_session.h
    engines/harvester/module.mk


diff --git a/engines/harvester/dialogue/dialogue_menu.cpp b/engines/harvester/dialogue/dialogue_menu.cpp
new file mode 100644
index 00000000000..8018bae86c1
--- /dev/null
+++ b/engines/harvester/dialogue/dialogue_menu.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/dialogue/dialogue_session.h"
+#include "harvester/dialogue/dialogue_render.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/framelimiter.h"
+#include "harvester/art.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/flow/flow.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const uint kDialogueTextEntryMaxLength = 26;
+static const uint32 kDialogueTextEntryBlinkTicks = 0x30;
+
+static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
+	parts.clear();
+	if (line.empty())
+		return;
+
+	Common::String token;
+	for (uint i = 0; i < line.size(); ++i) {
+		if (line[i] == '/') {
+			parts.push_back(Common::move(token));
+			token.clear();
+			continue;
+		}
+
+		token += line[i];
+	}
+
+	parts.push_back(Common::move(token));
+}
+
+static void logDialogueMenuItems(const char *label, int sourceLineIndex,
+		const Common::String &rawLine, const Common::Array<Common::String> &items) {
+	if (sourceLineIndex >= 0) {
+		debugC(3, kDebugDialogue, "Harvester: %s source line 0x%x (%d) raw='%s'",
+			label, sourceLineIndex, sourceLineIndex + 1, rawLine.c_str());
+	} else {
+		debugC(3, kDebugDialogue, "Harvester: %s source raw='%s'", label, rawLine.c_str());
+	}
+
+	if (items.empty()) {
+		debugC(1, kDebugDialogue, "Harvester: %s has no explicit items", label);
+		return;
+	}
+
+	for (uint i = 0; i < items.size(); ++i)
+		debugC(3, kDebugDialogue, "Harvester: %s item[%u]='%s'", label, i + 1, items[i].c_str());
+}
+
+} // End of anonymous namespace
+
+Common::Error RoomNpcDialogueSession::runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
+		Common::String &selectedTopic,
+		DialogueKeywordMenuSelectionState &selection) {
+	selectedTopic.clear();
+	selection = DialogueKeywordMenuSelectionState();
+	Common::Array<Common::String> topics;
+	splitDialogueMenuLine(topicBuffer, topics);
+	logDialogueMenuItems("Keyword menu", topicBufferLineIndex, topicBuffer, topics);
+	topics.push_back(_genericByeTopic);
+	debugC(1, kDebugDialogue, "Harvester: keyword menu default topic='%s'", _genericByeTopic.c_str());
+	bool editingOther = false;
+	bool textEntryCursorVisible = false;
+	uint32 textEntryBlinkTimestamp = 0;
+	Common::String typedTopic;
+	Common::Error releaseError = waitForPointerRelease();
+	if (releaseError.getCode() != Common::kNoError)
+		return releaseError;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	for (;;) {
+		const uint32 now = getRuntimeClockTicks();
+		if (editingOther && now - textEntryBlinkTimestamp > kDialogueTextEntryBlinkTicks) {
+			textEntryCursorVisible = !textEntryCursorVisible;
+			textEntryBlinkTimestamp = now;
+		}
+		const int hoveredTopicIndex = editingOther ? -1 : DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
+		const bool hoverOther = editingOther || DialogueRendering::isDialogueOtherHit(_mousePos);
+		drawDialogueOverlay(&_keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
+			editingOther ? &typedTopic : nullptr, textEntryCursorVisible);
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result)) {
+				if (editingOther && _entityManager)
+					_entityManager->showCursor();
+				return result;
+			}
+
+			if (editingOther) {
+				if (handleKeywordTextEntry(event, typedTopic, editingOther, selectedTopic, selection))
+					return Common::kNoError;
+				continue;
+			}
+
+			switch (event.type) {
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					selectedTopic = _genericByeTopic;
+					selection.fromEscape = true;
+					selection.fromGenericBye = true;
+					debugC(1, kDebugDialogue,
+						"Harvester: keyword menu selected default topic via escape '%s'",
+						selectedTopic.c_str());
+					return Common::kNoError;
+				}
+				break;
+			case Common::EVENT_LBUTTONDOWN: {
+				const int clickedTopic = DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
+				if (clickedTopic >= 0) {
+					selectedTopic = topics[(uint)clickedTopic];
+					selection.fromGenericBye = clickedTopic == (int)topics.size() - 1;
+					debugC(2, kDebugDialogue, "Harvester: keyword menu selected topic[%d]='%s' via click",
+						clickedTopic + 1, selectedTopic.c_str());
+					return Common::kNoError;
+				}
+				if (DialogueRendering::isDialogueOtherHit(_mousePos)) {
+					typedTopic.clear();
+					editingOther = true;
+					textEntryCursorVisible = false;
+					textEntryBlinkTimestamp = getRuntimeClockTicks();
+					debugC(1, kDebugDialogue, "Harvester: keyword menu entered Other input");
+					if (_entityManager)
+						_entityManager->hideCursor();
+				}
+				break;
+			}
+			default:
+				break;
+			}
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+}
+
+bool RoomNpcDialogueSession::handleKeywordTextEntry(const Common::Event &event,
+		Common::String &typedTopic, bool &editingOther, Common::String &selectedTopic,
+		DialogueKeywordMenuSelectionState &selection) {
+	bool acceptInput = false;
+	bool cancelInput = false;
+	if (event.type == Common::EVENT_KEYDOWN) {
+		if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+			cancelInput = true;
+		} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE ||
+				event.kbd.keycode == Common::KEYCODE_LEFT) {
+			deleteTextEntryCharacter(typedTopic);
+		} else if (event.kbd.keycode == Common::KEYCODE_HOME) {
+			typedTopic.clear();
+		} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+				event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+			acceptInput = true;
+		} else if (event.kbd.ascii >= 0x1f && event.kbd.ascii <= 0x80 &&
+				event.kbd.ascii != '~' &&
+				(!_subtitleCftFont || _subtitleCftFont->hasGlyph(event.kbd.ascii))) {
+			appendTextEntryCharacter(typedTopic, *_highlightFont, event.kbd.ascii,
+				kDialogueTextEntryMaxLength, DialogueRendering::kDialogueTextEntryMaxWidth);
+		}
+	} else if (event.type == Common::EVENT_LBUTTONDOWN) {
+		acceptInput = Common::Rect(3, 460, 318, 477).contains(event.mouse);
+		cancelInput = Common::Rect(321, 460, 636, 477).contains(event.mouse);
+		if (cancelInput)
+			g_system->delayMillis(400);
+	}
+	if (acceptInput || cancelInput) {
+		editingOther = false;
+		if (_entityManager)
+			_entityManager->showCursor();
+		if (acceptInput && !typedTopic.empty()) {
+			selectedTopic = typedTopic;
+			selection.fromTypedInput = true;
+			debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
+				selectedTopic.c_str());
+			return true;
+		}
+		typedTopic.clear();
+		debugC(1, kDebugDialogue, "Harvester: keyword menu cancelled Other input");
+	}
+
+	return false;
+}
+
+Common::Error RoomNpcDialogueSession::runResponseMenu(int responseLineIndex, int &selectedIndex) {
+	const Common::String responseLine = _text->getDialogueResponseLine(responseLineIndex);
+	return runResponseMenuTextInternal(responseLine, responseLineIndex, selectedIndex);
+}
+
+Common::Error RoomNpcDialogueSession::runResponseMenuText(const Common::String &responseLine, int &selectedIndex) {
+	return runResponseMenuTextInternal(responseLine, -1, selectedIndex);
+}
+
+void RoomNpcDialogueSession::assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
+		int responseLineIndex, const char *) {
+	topicBuffer = _text->getDialogueResponseLine(responseLineIndex);
+	topicBufferLineIndex = responseLineIndex;
+}
+
+bool RoomNpcDialogueSession::matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) {
+	const Common::String topicText = _text->getDialogueResponseLine(responseLineIndex);
+	return !topicText.empty() && selectedTopic.equalsIgnoreCase(topicText);
+}
+
+bool RoomNpcDialogueSession::matchesAnyResponseLine(const Common::String &selectedTopic,
+		const int *responseLineIndices, uint responseLineCount) {
+	for (uint i = 0; i < responseLineCount; ++i) {
+		if (matchesResponseLine(selectedTopic, responseLineIndices[i]))
+			return true;
+	}
+
+	return false;
+}
+
+void RoomNpcDialogueSession::parseResponseMenuLine(const Common::String &responseLine,
+		Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
+	options.clear();
+	totalRows = 0;
+	if (responseLine.empty())
+		return;
+
+	Common::String segment;
+	Common::Array<Common::String> explicitLines;
+	Common::String optionText;
+
+	auto flushOption = [&]() {
+		if (explicitLines.empty())
+			return;
+
+		DialogueResponseOptionLayout option;
+		option.text = optionText;
+		if (optionText.size() > 1 && optionText[0] >= '1' && optionText[0] <= '9' &&
+				optionText[1] == '.')
+			option.hotkey = optionText[0];
+		option.rowStart = (int)totalRows;
+		for (const Common::String &explicitLine : explicitLines) {
+			Common::Array<Common::String> wrappedExplicitLines;
+			DialogueRendering::wrapDialogueTextLikeNative(*_menuFont, _menuFontUsesCft,
+				explicitLine, DialogueRendering::kDialogueSubtitleTextWidth, wrappedExplicitLines);
+			if (wrappedExplicitLines.empty())
+				wrappedExplicitLines.push_back(explicitLine);
+			for (const Common::String &wrappedExplicitLine : wrappedExplicitLines)
+				option.wrappedLines.push_back(wrappedExplicitLine);
+		}
+		option.rowCount = MAX<int>(1, option.wrappedLines.size());
+		totalRows += option.rowCount;
+		options.push_back(Common::move(option));
+
+		explicitLines.clear();
+		optionText.clear();
+	};
+
+	auto consumeSegment = [&](const Common::String &rawSegment) {
+		Common::String segmentText = rawSegment;
+		if (segmentText.empty())
+			return;
+
+		const bool startsNewOption = segmentText.size() > 1 && segmentText[0] >= '1' &&
+			segmentText[0] <= '9' && segmentText[1] == '.';
+		if (startsNewOption) {
+			flushOption();
+			optionText = segmentText;
+			explicitLines.push_back(Common::move(segmentText));
+			return;
+		}
+
+		if (!optionText.empty())
+			optionText += ' ';
+		optionText += segmentText;
+		explicitLines.push_back(Common::move(segmentText));
+	};
+
+	for (uint i = 0; i < responseLine.size(); ++i) {
+		if (responseLine[i] == '/') {
+			consumeSegment(segment);
+			segment.clear();
+			continue;
+		}
+
+		segment += responseLine[i];
+	}
+
+	consumeSegment(segment);
+	flushOption();
+}
+
+int RoomNpcDialogueSession::getResponseMenuItemAt(const Common::Array<DialogueResponseOptionLayout> &options,
+		uint totalRows, const Common::Point &mousePos) {
+	if (mousePos.x < DialogueRendering::kDialogueTopicStartX || mousePos.x > DialogueRendering::kDialogueTopicEndX)
+		return -1;
+	if (mousePos.y < DialogueRendering::kDialogueKeywordTextY)
+		return -1;
+
+	const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
+	const int row = (mousePos.y - DialogueRendering::kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
+	if (row < 0 || row >= (int)totalRows)
+		return -1;
+
+	for (uint i = 0; i < options.size(); ++i) {
+		const DialogueResponseOptionLayout &option = options[i];
+		if (row >= option.rowStart && row < option.rowStart + option.rowCount)
+			return (int)i;
+	}
+
+	return -1;
+}
+
+Common::Error RoomNpcDialogueSession::runResponseMenuTextInternal(const Common::String &responseLine, int responseLineIndex,
+		int &selectedIndex) {
+	selectedIndex = 0;
+
+	if (responseLine.empty()) {
+		if (responseLineIndex >= 0) {
+			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) is empty",
+				responseLineIndex, responseLineIndex + 1);
+		} else {
+			debugC(1, kDebugDialogue, "Harvester: response menu text is empty");
+		}
+		return Common::kNoError;
+	}
+
+	Common::Array<DialogueResponseOptionLayout> options;
+	uint totalRows = 0;
+	parseResponseMenuLine(responseLine, options, totalRows);
+	if (options.empty()) {
+		if (responseLineIndex >= 0) {
+			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) produced no options",
+				responseLineIndex, responseLineIndex + 1);
+		} else {
+			debugC(1, kDebugDialogue, "Harvester: response menu text produced no options");
+		}
+		return Common::kNoError;
+	}
+
+	Common::Array<Common::String> optionTexts;
+	for (const DialogueResponseOptionLayout &option : options) {
+		optionTexts.push_back(option.text);
+		debugC(3, kDebugDialogue, "Harvester: response menu option[%u] hotkey=%c rowStart=%d rowCount=%d",
+			optionTexts.size(), option.hotkey ? option.hotkey : '-', option.rowStart, option.rowCount);
+	}
+	logDialogueMenuItems("Response menu", responseLineIndex, responseLine, optionTexts);
+
+	const IndexedBitmap *textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueResponseTextboxIndex(totalRows));
+	Common::Error releaseError = waitForPointerRelease();
+	if (releaseError.getCode() != Common::kNoError)
+		return releaseError;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	for (;;) {
+		const int hoveredOptionIndex = getResponseMenuItemAt(options, totalRows, _mousePos);
+		drawDialogueResponseMenu(textboxBitmap, options, hoveredOptionIndex);
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (_flow.handleSystemEvent(event, result))
+				return result;
+
+			if (event.type == Common::EVENT_KEYDOWN) {
+				if (selectResponseMenuHotkey(event, options, responseLineIndex, selectedIndex))
+					return waitForPointerRelease();
+			} else if (event.type == Common::EVENT_LBUTTONDOWN) {
+				const int clickedOptionIndex = getResponseMenuItemAt(options, totalRows, event.mouse);
+				if (clickedOptionIndex < 0)
+					continue;
+				selectedIndex = clickedOptionIndex + 1;
+				if (responseLineIndex >= 0) {
+					debugC(2, kDebugDialogue,
+						"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via click x=%d y=%d result=%d",
+						responseLineIndex, responseLineIndex + 1,
+						selectedIndex, options[(uint)clickedOptionIndex].text.c_str(),
+						event.mouse.x, event.mouse.y, selectedIndex);
+				} else {
+					debugC(2, kDebugDialogue,
+						"Harvester: response menu text selected option[%d]='%s' via click x=%d y=%d result=%d",
+						selectedIndex, options[(uint)clickedOptionIndex].text.c_str(),
+						event.mouse.x, event.mouse.y, selectedIndex);
+				}
+				return waitForPointerRelease();
+			}
+		}
+
+		if (_entityManager)
+			(void)_entityManager->syncCursorEntityPosition(_mousePos);
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+}
+
+bool RoomNpcDialogueSession::selectResponseMenuHotkey(const Common::Event &event,
+		const Common::Array<DialogueResponseOptionLayout> &options, int responseLineIndex,
+		int &selectedIndex) {
+	debugC(3, kDebugDialogue, "Harvester: response menu keycode=%d ascii=%u",
+		(int)event.kbd.keycode, event.kbd.ascii);
+	if (event.kbd.keycode >= Common::KEYCODE_1 && event.kbd.keycode <= Common::KEYCODE_9) {
+		const char hotkey = '1' + event.kbd.keycode - Common::KEYCODE_1;
+		for (int optionIndex = (int)options.size() - 1; optionIndex >= 0; --optionIndex) {
+			const DialogueResponseOptionLayout &option = options[(uint)optionIndex];
+			if (option.hotkey != hotkey)
+				continue;
+			selectedIndex = optionIndex + 1;
+			if (responseLineIndex >= 0) {
+				debugC(2, kDebugDialogue,
+					"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via hotkey=%c result=%d",
+					responseLineIndex, responseLineIndex + 1,
+					optionIndex + 1, option.text.c_str(), hotkey, selectedIndex);
+			} else {
+				debugC(2, kDebugDialogue,
+					"Harvester: response menu text selected option[%d]='%s' via hotkey=%c result=%d",
+					optionIndex + 1, option.text.c_str(), hotkey, selectedIndex);
+			}
+			return true;
+		}
+	}
+
+	return false;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/dialogue/dialogue_render.cpp b/engines/harvester/dialogue/dialogue_render.cpp
index feee8b6ca5d..5d195b88ee9 100644
--- a/engines/harvester/dialogue/dialogue_render.cpp
+++ b/engines/harvester/dialogue/dialogue_render.cpp
@@ -20,11 +20,17 @@
  */
 
 #include "harvester/dialogue/dialogue_render.h"
+#include "harvester/dialogue/dialogue_session.h"
 
 #include "common/util.h"
 #include "graphics/font.h"
 #include "graphics/screen.h"
 #include "harvester/cft_font.h"
+#include "harvester/entity.h"
+#include "harvester/flow/flow.h"
+#include "harvester/flow/flow_support.h"
+#include "harvester/harvester.h"
+#include "harvester/scene/room_presentation.h"
 
 namespace Harvester {
 namespace DialogueRendering {
@@ -105,4 +111,159 @@ bool isDialogueOtherHit(const Common::Point &mousePos) {
 }
 
 } // End of namespace DialogueRendering
+
+namespace {
+
+static const int kDialogueOverlayX = 167;
+static const int kDialogueOverlayY = 6;
+static const int kDialogueLeftHeadX = 6;
+static const int kDialogueRightHeadX = 474;
+static const int kDialogueHeadY = 6;
+static const int kDialogueSubtitleTextX = 178;
+static const int kDialogueSubtitleTextY = 12;
+static const int kDialogueKeywordTextX = 178;
+static const int kDialogueKeywordTextWidth = 300;
+static const int kDialogueOtherX = 171;
+static const int kDialogueOtherY = 183;
+static const int kDialogueTextEntryX = 208;
+static const int kDialogueTextEntryY = 184;
+static const int kDialogueOtherWidth = 120;
+static const int kDialogueKeywordTitleYOffset = 4;
+static const byte kTextColorNormal = 255;
+static const byte kTextColorHover = 251;
+
+} // End of anonymous namespace
+
+Graphics::Screen *RoomNpcDialogueSession::getActiveScreen() const {
+	return _engine.getScreen();
+}
+
+void RoomNpcDialogueSession::drawFontString(const Graphics::Font &font, bool usesCft, const Common::String &text,
+		int x, int y, int width, byte color) {
+	Graphics::Screen *activeScreen = getActiveScreen();
+	if (!activeScreen)
+		return;
+
+	if (usesCft)
+		font.drawString(activeScreen, text, x, y, width, 0);
+	else
+		RoomPresentation::drawShadowedString(*activeScreen, font, text, x, y, width, color);
+}
+
+void RoomNpcDialogueSession::drawDialogueOverlay(const IndexedBitmap *overlayBitmap,
+		const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
+		int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue,
+		bool textEntryCursorVisible) {
+	Graphics::Screen *activeScreen = getActiveScreen();
+	if (!activeScreen)
+		return;
+
+	setScaledPalette(*activeScreen, _palette, _paletteBrightness);
+	RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
+	if (_leftHeadVisible && _leftHeadBitmap.isValid())
+		blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+	if (_rightHeadVisible && _rightHeadBitmap.isValid())
+		blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+	if (overlayBitmap && overlayBitmap->isValid())
+		blitTransparentBitmap(*activeScreen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+	if (subtitleLines) {
+		if (_subtitleFontUsesCft) {
+			DialogueRendering::drawDialogueTextLines(*activeScreen, *_subtitleFont, *subtitleLines,
+				kDialogueSubtitleTextX, kDialogueSubtitleTextY, DialogueRendering::kDialogueSubtitleTextWidth);
+		} else {
+			const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_subtitleFont);
+			for (uint i = 0; i < subtitleLines->size(); ++i) {
+				RoomPresentation::drawShadowedString(*activeScreen, *_subtitleFont, (*subtitleLines)[i],
+					kDialogueSubtitleTextX,
+					kDialogueSubtitleTextY + (int)i * lineHeight,
+					DialogueRendering::kDialogueSubtitleTextWidth, kTextColorNormal);
+			}
+		}
+	}
+
+	if (topics) {
+		if (_menuTextConfig.hasDialogueKeywordLabel()) {
+			const Common::String &title = _menuTextConfig.dialogueKeywordLabel;
+			const int titleWidth = _highlightFont->getStringWidth(title);
+			const int titleX = kDialogueOverlayX +
+				MAX<int>(0, ((int)_keywordBitmap.width - titleWidth) / 2);
+			drawFontString(*_highlightFont, _highlightFontUsesCft, title, titleX,
+				kDialogueOverlayY + kDialogueKeywordTitleYOffset, titleWidth,
+				kTextColorNormal);
+		}
+
+		const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
+		for (uint i = 0; i < topics->size(); ++i) {
+			const bool highlighted = (int)i == hoveredTopicIndex;
+			const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
+			const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
+			drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
+				DialogueRendering::kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
+				highlighted ? kTextColorNormal : kTextColorHover);
+		}
+
+		const Graphics::Font &otherFont = hoverOther ? *_highlightFont : *_menuFont;
+		const bool otherUsesCft = hoverOther ? _highlightFontUsesCft : _menuFontUsesCft;
+		drawFontString(otherFont, otherUsesCft, _menuTextConfig.dialogueOtherLabel,
+			kDialogueOtherX, kDialogueOtherY,
+			kDialogueOtherWidth, hoverOther ? kTextColorNormal : kTextColorHover);
+	}
+
+	if (textEntryValue) {
+		drawFontString(*_highlightFont, _highlightFontUsesCft,
+			*textEntryValue + (textEntryCursorVisible ? "~" : ""),
+			kDialogueTextEntryX, kDialogueTextEntryY, DialogueRendering::kDialogueTextEntryMaxWidth,
+			kTextColorNormal);
+	}
+
+	if (_entityManager)
+		_entityManager->drawCursor(*activeScreen);
+	activeScreen->makeAllDirty();
+	activeScreen->update();
+}
+
+void RoomNpcDialogueSession::drawDialogueResponseMenu(const IndexedBitmap *textboxBitmap,
+		const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
+	Graphics::Screen *activeScreen = getActiveScreen();
+	if (!activeScreen)
+		return;
+
+	setScaledPalette(*activeScreen, _palette, _paletteBrightness);
+	RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
+	if (_leftHeadVisible && _leftHeadBitmap.isValid())
+		blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+	if (_rightHeadVisible && _rightHeadBitmap.isValid())
+		blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+	if (textboxBitmap && textboxBitmap->isValid())
+		blitTransparentBitmap(*activeScreen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+	const Common::String &title = _menuTextConfig.dialogueResponsesLabel;
+	const Graphics::Font &titleFont = *_highlightFont;
+	const bool titleUsesCft = _highlightFontUsesCft;
+	const int titleWidth = titleFont.getStringWidth(title);
+	const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (DialogueRendering::kDialogueSubtitleTextWidth - titleWidth) / 2);
+	drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
+
+	const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
+	int drawY = DialogueRendering::kDialogueKeywordTextY;
+	for (uint i = 0; i < options.size(); ++i) {
+		const bool highlighted = (int)i == hoveredOptionIndex;
+		const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
+		const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
+		const byte color = highlighted ? kTextColorNormal : kTextColorHover;
+
+		for (const Common::String &wrappedLine : options[i].wrappedLines) {
+			drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
+				DialogueRendering::kDialogueSubtitleTextWidth, color);
+			drawY += lineHeight;
+		}
+	}
+
+	if (_entityManager)
+		_entityManager->drawCursor(*activeScreen);
+	activeScreen->makeAllDirty();
+	activeScreen->update();
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/dialogue/dialogue_render.h b/engines/harvester/dialogue/dialogue_render.h
index 99989403609..d979e6b3d3d 100644
--- a/engines/harvester/dialogue/dialogue_render.h
+++ b/engines/harvester/dialogue/dialogue_render.h
@@ -35,6 +35,7 @@ namespace Harvester {
 namespace DialogueRendering {
 
 static const int kDialogueSubtitleTextWidth = 316;
+static const int kDialogueTextEntryMaxWidth = 550;
 static const int kDialogueKeywordTextY = 34;
 static const int kDialogueTopicStartX = 168;
 static const int kDialogueTopicEndX = 482;
diff --git a/engines/harvester/dialogue/dialogue_session.cpp b/engines/harvester/dialogue/dialogue_session.cpp
index 74ffa79964d..ef0a47f70ae 100644
--- a/engines/harvester/dialogue/dialogue_session.cpp
+++ b/engines/harvester/dialogue/dialogue_session.cpp
@@ -20,90 +20,25 @@
  */
 
 #include "harvester/dialogue/dialogue_session.h"
-#include "harvester/dialogue/dialogue_render.h"
 
 #include "common/algorithm.h"
 #include "common/debug.h"
-#include "common/events.h"
-#include "common/system.h"
 #include "graphics/fontman.h"
-#include "graphics/framelimiter.h"
-#include "harvester/art.h"
 #include "harvester/detection.h"
-#include "harvester/entity.h"
 #include "harvester/flow/flow.h"
 #include "harvester/flow/flow_support.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
-#include "harvester/scene/room_presentation.h"
 #include "harvester/text.h"
 
 namespace Harvester {
 
 namespace {
 
-static const int kDialogueOverlayX = 167;
-static const int kDialogueOverlayY = 6;
-static const int kDialogueLeftHeadX = 6;
-static const int kDialogueRightHeadX = 474;
-static const int kDialogueHeadY = 6;
-static const int kDialogueSubtitleTextX = 178;
-static const int kDialogueSubtitleTextY = 12;
-static const int kDialogueKeywordTextX = 178;
-static const int kDialogueKeywordTextWidth = 300;
-static const int kDialogueOtherX = 171;
-static const int kDialogueOtherY = 183;
-static const int kDialogueTextEntryX = 208;
-static const int kDialogueTextEntryY = 184;
-static const uint kDialogueTextEntryMaxLength = 26;
-static const int kDialogueTextEntryMaxWidth = 550;
-static const uint32 kDialogueTextEntryBlinkTicks = 0x30;
-static const int kDialogueOtherWidth = 120;
-static const int kDialogueKeywordTitleYOffset = 4;
 static const int kDialogueGenericByeResponseIndex = 13;
 static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
 static const char *const kDialogueLocalizedKeywordBitmapPath = "GRAPHIC/OTHER/KEYWORD.BM";
 
-static const byte kTextColorNormal = 255;
-static const byte kTextColorHover = 251;
-
-static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
-	parts.clear();
-	if (line.empty())
-		return;
-
-	Common::String token;
-	for (uint i = 0; i < line.size(); ++i) {
-		if (line[i] == '/') {
-			parts.push_back(Common::move(token));
-			token.clear();
-			continue;
-		}
-
-		token += line[i];
-	}
-
-	parts.push_back(Common::move(token));
-}
-
-static void logDialogueMenuItems(const char *label, int sourceLineIndex,
-		const Common::String &rawLine, const Common::Array<Common::String> &items) {
-	if (sourceLineIndex >= 0) {
-		debugC(3, kDebugDialogue, "Harvester: %s source line 0x%x (%d) raw='%s'",
-			label, sourceLineIndex, sourceLineIndex + 1, rawLine.c_str());
-	} else {
-		debugC(3, kDebugDialogue, "Harvester: %s source raw='%s'", label, rawLine.c_str());
-	}
-
-	if (items.empty()) {
-		debugC(1, kDebugDialogue, "Harvester: %s has no explicit items", label);
-		return;
-	}
-
-	for (uint i = 0; i < items.size(); ++i)
-		debugC(3, kDebugDialogue, "Harvester: %s item[%u]='%s'", label, i + 1, items[i].c_str());
-}
-
 static Common::String buildDialogueHeadId(const Common::String &speakerId, int headVariant) {
 	return Common::String::format("%s%d", speakerId.c_str(), headVariant);
 }
@@ -197,162 +132,6 @@ const Common::String &RoomNpcDialogueSession::getGenericByeTopic() const {
 	return _genericByeTopic;
 }
 
-Common::Error RoomNpcDialogueSession::runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
-		Common::String &selectedTopic,
-		DialogueKeywordMenuSelectionState &selection) {
-	selectedTopic.clear();
-	selection = DialogueKeywordMenuSelectionState();
-	Common::Array<Common::String> topics;
-	splitDialogueMenuLine(topicBuffer, topics);
-	logDialogueMenuItems("Keyword menu", topicBufferLineIndex, topicBuffer, topics);
-	topics.push_back(_genericByeTopic);
-	debugC(1, kDebugDialogue, "Harvester: keyword menu default topic='%s'", _genericByeTopic.c_str());
-	bool editingOther = false;
-	bool textEntryCursorVisible = false;
-	uint32 textEntryBlinkTimestamp = 0;
-	Common::String typedTopic;
-	Common::Error releaseError = waitForPointerRelease();
-	if (releaseError.getCode() != Common::kNoError)
-		return releaseError;
-
-	Graphics::FrameLimiter limiter(g_system, 60);
-	for (;;) {
-		const uint32 now = getRuntimeClockTicks();
-		if (editingOther && now - textEntryBlinkTimestamp > kDialogueTextEntryBlinkTicks) {
-			textEntryCursorVisible = !textEntryCursorVisible;
-			textEntryBlinkTimestamp = now;
-		}
-		const int hoveredTopicIndex = editingOther ? -1 : DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
-		const bool hoverOther = editingOther || DialogueRendering::isDialogueOtherHit(_mousePos);
-		drawDialogueOverlay(&_keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
-			editingOther ? &typedTopic : nullptr, textEntryCursorVisible);
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (_flow.handleSystemEvent(event, result)) {
-				if (editingOther && _entityManager)
-					_entityManager->showCursor();
-				return result;
-			}
-
-			if (editingOther) {
-				bool acceptInput = false;
-				bool cancelInput = false;
-				if (event.type == Common::EVENT_KEYDOWN) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						cancelInput = true;
-					} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE ||
-							event.kbd.keycode == Common::KEYCODE_LEFT) {
-						deleteTextEntryCharacter(typedTopic);
-					} else if (event.kbd.keycode == Common::KEYCODE_HOME) {
-						typedTopic.clear();
-					} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-							event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-						acceptInput = true;
-					} else if (event.kbd.ascii >= 0x1f && event.kbd.ascii <= 0x80 &&
-							event.kbd.ascii != '~' &&
-							(!_subtitleCftFont || _subtitleCftFont->hasGlyph(event.kbd.ascii))) {
-						appendTextEntryCharacter(typedTopic, *_highlightFont, event.kbd.ascii,
-							kDialogueTextEntryMaxLength, kDialogueTextEntryMaxWidth);
-					}
-				} else if (event.type == Common::EVENT_LBUTTONDOWN) {
-					acceptInput = Common::Rect(3, 460, 318, 477).contains(event.mouse);
-					cancelInput = Common::Rect(321, 460, 636, 477).contains(event.mouse);
-					if (cancelInput)
-						g_system->delayMillis(400);
-				}
-				if (acceptInput || cancelInput) {
-					editingOther = false;
-					if (_entityManager)
-						_entityManager->showCursor();
-					if (acceptInput && !typedTopic.empty()) {
-						selectedTopic = typedTopic;
-						selection.fromTypedInput = true;
-						debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
-							selectedTopic.c_str());
-						return Common::kNoError;
-					}
-					typedTopic.clear();
-					debugC(1, kDebugDialogue, "Harvester: keyword menu cancelled Other input");
-				}
-				continue;
-			}
-
-			switch (event.type) {
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					selectedTopic = _genericByeTopic;
-					selection.fromEscape = true;
-					selection.fromGenericBye = true;
-					debugC(1, kDebugDialogue,
-						"Harvester: keyword menu selected default topic via escape '%s'",
-						selectedTopic.c_str());
-					return Common::kNoError;
-				}
-				break;
-			case Common::EVENT_LBUTTONDOWN: {
-				const int clickedTopic = DialogueRendering::getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
-				if (clickedTopic >= 0) {
-					selectedTopic = topics[(uint)clickedTopic];
-					selection.fromGenericBye = clickedTopic == (int)topics.size() - 1;
-					debugC(2, kDebugDialogue, "Harvester: keyword menu selected topic[%d]='%s' via click",
-						clickedTopic + 1, selectedTopic.c_str());
-					return Common::kNoError;
-				}
-				if (DialogueRendering::isDialogueOtherHit(_mousePos)) {
-					typedTopic.clear();
-					editingOther = true;
-					textEntryCursorVisible = false;
-					textEntryBlinkTimestamp = getRuntimeClockTicks();
-					debugC(1, kDebugDialogue, "Harvester: keyword menu entered Other input");
-					if (_entityManager)
-						_entityManager->hideCursor();
-				}
-				break;
-			}
-			default:
-				break;
-			}
-		}
-
-		if (_entityManager)
-			(void)_entityManager->syncCursorEntityPosition(_mousePos);
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-}
-
-Common::Error RoomNpcDialogueSession::runResponseMenu(int responseLineIndex, int &selectedIndex) {
-	const Common::String responseLine = _text->getDialogueResponseLine(responseLineIndex);
-	return runResponseMenuTextInternal(responseLine, responseLineIndex, selectedIndex);
-}
-
-Common::Error RoomNpcDialogueSession::runResponseMenuText(const Common::String &responseLine, int &selectedIndex) {
-	return runResponseMenuTextInternal(responseLine, -1, selectedIndex);
-}
-
-void RoomNpcDialogueSession::assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
-		int responseLineIndex, const char *) {
-	topicBuffer = _text->getDialogueResponseLine(responseLineIndex);
-	topicBufferLineIndex = responseLineIndex;
-}
-
-bool RoomNpcDialogueSession::matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) {
-	const Common::String topicText = _text->getDialogueResponseLine(responseLineIndex);
-	return !topicText.empty() && selectedTopic.equalsIgnoreCase(topicText);
-}
-
-bool RoomNpcDialogueSession::matchesAnyResponseLine(const Common::String &selectedTopic,
-		const int *responseLineIndices, uint responseLineCount) {
-	for (uint i = 0; i < responseLineCount; ++i) {
-		if (matchesResponseLine(selectedTopic, responseLineIndices[i]))
-			return true;
-	}
-
-	return false;
-}
-
 void RoomNpcDialogueSession::queueDialogueInteractionIfNeeded(const InteractionResult &interaction) {
 	if (interaction.abortRemainingCommandChain || interaction.mutatedRuntimeState ||
 			!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
@@ -400,22 +179,6 @@ void RoomNpcDialogueSession::setActiveSpeakerPortrait(const Common::String &spea
 	}
 }
 
-Graphics::Screen *RoomNpcDialogueSession::getActiveScreen() const {
-	return _engine.getScreen();
-}
-
-void RoomNpcDialogueSession::drawFontString(const Graphics::Font &font, bool usesCft, const Common::String &text,
-		int x, int y, int width, byte color) {
-	Graphics::Screen *activeScreen = getActiveScreen();
-	if (!activeScreen)
-		return;
-
-	if (usesCft)
-		font.drawString(activeScreen, text, x, y, width, 0);
-	else
-		RoomPresentation::drawShadowedString(*activeScreen, font, text, x, y, width, color);
-}
-
 void RoomNpcDialogueSession::ensureSpeakerPortrait(const Common::String &speakerId, int headVariant) {
 	if (speakerId.empty())
 		return;
@@ -435,315 +198,4 @@ void RoomNpcDialogueSession::ensureSpeakerPortrait(const Common::String &speaker
 	}
 }
 
-void RoomNpcDialogueSession::drawDialogueOverlay(const IndexedBitmap *overlayBitmap,
-		const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
-		int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue,
-		bool textEntryCursorVisible) {
-	Graphics::Screen *activeScreen = getActiveScreen();
-	if (!activeScreen)
-		return;
-
-	setScaledPalette(*activeScreen, _palette, _paletteBrightness);
-	RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
-	if (_leftHeadVisible && _leftHeadBitmap.isValid())
-		blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-	if (_rightHeadVisible && _rightHeadBitmap.isValid())
-		blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
-	if (overlayBitmap && overlayBitmap->isValid())
-		blitTransparentBitmap(*activeScreen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
-
-	if (subtitleLines) {
-		if (_subtitleFontUsesCft) {
-			DialogueRendering::drawDialogueTextLines(*activeScreen, *_subtitleFont, *subtitleLines,
-				kDialogueSubtitleTextX, kDialogueSubtitleTextY, DialogueRendering::kDialogueSubtitleTextWidth);
-		} else {
-			const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_subtitleFont);
-			for (uint i = 0; i < subtitleLines->size(); ++i) {
-				RoomPresentation::drawShadowedString(*activeScreen, *_subtitleFont, (*subtitleLines)[i],
-					kDialogueSubtitleTextX,
-					kDialogueSubtitleTextY + (int)i * lineHeight,
-					DialogueRendering::kDialogueSubtitleTextWidth, kTextColorNormal);
-			}
-		}
-	}
-
-	if (topics) {
-		if (_menuTextConfig.hasDialogueKeywordLabel()) {
-			const Common::String &title = _menuTextConfig.dialogueKeywordLabel;
-			const int titleWidth = _highlightFont->getStringWidth(title);
-			const int titleX = kDialogueOverlayX +
-				MAX<int>(0, ((int)_keywordBitmap.width - titleWidth) / 2);
-			drawFontString(*_highlightFont, _highlightFontUsesCft, title, titleX,
-				kDialogueOverlayY + kDialogueKeywordTitleYOffset, titleWidth,
-				kTextColorNormal);
-		}
-
-		const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
-		for (uint i = 0; i < topics->size(); ++i) {
-			const bool highlighted = (int)i == hoveredTopicIndex;
-			const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
-			const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
-			drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
-				DialogueRendering::kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
-				highlighted ? kTextColorNormal : kTextColorHover);
-		}
-
-		const Graphics::Font &otherFont = hoverOther ? *_highlightFont : *_menuFont;
-		const bool otherUsesCft = hoverOther ? _highlightFontUsesCft : _menuFontUsesCft;
-		drawFontString(otherFont, otherUsesCft, _menuTextConfig.dialogueOtherLabel,
-			kDialogueOtherX, kDialogueOtherY,
-			kDialogueOtherWidth, hoverOther ? kTextColorNormal : kTextColorHover);
-	}
-
-	if (textEntryValue) {
-		drawFontString(*_highlightFont, _highlightFontUsesCft,
-			*textEntryValue + (textEntryCursorVisible ? "~" : ""),
-			kDialogueTextEntryX, kDialogueTextEntryY, kDialogueTextEntryMaxWidth,
-			kTextColorNormal);
-	}
-
-	if (_entityManager)
-		_entityManager->drawCursor(*activeScreen);
-	activeScreen->makeAllDirty();
-	activeScreen->update();
-}
-
-void RoomNpcDialogueSession::parseResponseMenuLine(const Common::String &responseLine,
-		Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
-	options.clear();
-	totalRows = 0;
-	if (responseLine.empty())
-		return;
-
-	Common::String segment;
-	Common::Array<Common::String> explicitLines;
-	Common::String optionText;
-
-	auto flushOption = [&]() {
-		if (explicitLines.empty())
-			return;
-
-		DialogueResponseOptionLayout option;
-		option.text = optionText;
-		if (optionText.size() > 1 && optionText[0] >= '1' && optionText[0] <= '9' &&
-				optionText[1] == '.')
-			option.hotkey = optionText[0];
-		option.rowStart = (int)totalRows;
-		for (const Common::String &explicitLine : explicitLines) {
-			Common::Array<Common::String> wrappedExplicitLines;
-			DialogueRendering::wrapDialogueTextLikeNative(*_menuFont, _menuFontUsesCft,
-				explicitLine, DialogueRendering::kDialogueSubtitleTextWidth, wrappedExplicitLines);
-			if (wrappedExplicitLines.empty())
-				wrappedExplicitLines.push_back(explicitLine);
-			for (const Common::String &wrappedExplicitLine : wrappedExplicitLines)
-				option.wrappedLines.push_back(wrappedExplicitLine);
-		}
-		option.rowCount = MAX<int>(1, option.wrappedLines.size());
-		totalRows += option.rowCount;
-		options.push_back(Common::move(option));
-
-		explicitLines.clear();
-		optionText.clear();
-	};
-
-	auto consumeSegment = [&](const Common::String &rawSegment) {
-		Common::String segmentText = rawSegment;
-		if (segmentText.empty())
-			return;
-
-		const bool startsNewOption = segmentText.size() > 1 && segmentText[0] >= '1' &&
-			segmentText[0] <= '9' && segmentText[1] == '.';
-		if (startsNewOption) {
-			flushOption();
-			optionText = segmentText;
-			explicitLines.push_back(Common::move(segmentText));
-			return;
-		}
-
-		if (!optionText.empty())
-			optionText += ' ';
-		optionText += segmentText;
-		explicitLines.push_back(Common::move(segmentText));
-	};
-
-	for (uint i = 0; i < responseLine.size(); ++i) {
-		if (responseLine[i] == '/') {
-			consumeSegment(segment);
-			segment.clear();
-			continue;
-		}
-
-		segment += responseLine[i];
-	}
-
-	consumeSegment(segment);
-	flushOption();
-}
-
-int RoomNpcDialogueSession::getResponseMenuItemAt(const Common::Array<DialogueResponseOptionLayout> &options,
-		uint totalRows, const Common::Point &mousePos) {
-	if (mousePos.x < DialogueRendering::kDialogueTopicStartX || mousePos.x > DialogueRendering::kDialogueTopicEndX)
-		return -1;
-	if (mousePos.y < DialogueRendering::kDialogueKeywordTextY)
-		return -1;
-
-	const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
-	const int row = (mousePos.y - DialogueRendering::kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
-	if (row < 0 || row >= (int)totalRows)
-		return -1;
-
-	for (uint i = 0; i < options.size(); ++i) {
-		const DialogueResponseOptionLayout &option = options[i];
-		if (row >= option.rowStart && row < option.rowStart + option.rowCount)
-			return (int)i;
-	}
-
-	return -1;
-}
-
-void RoomNpcDialogueSession::drawDialogueResponseMenu(const IndexedBitmap *textboxBitmap,
-		const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
-	Graphics::Screen *activeScreen = getActiveScreen();
-	if (!activeScreen)
-		return;
-
-	setScaledPalette(*activeScreen, _palette, _paletteBrightness);
-	RoomPresentation::blitBitmap(*activeScreen, _backdrop, 0, 0);
-	if (_leftHeadVisible && _leftHeadBitmap.isValid())
-		blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-	if (_rightHeadVisible && _rightHeadBitmap.isValid())
-		blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
-	if (textboxBitmap && textboxBitmap->isValid())
-		blitTransparentBitmap(*activeScreen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
-
-	const Common::String &title = _menuTextConfig.dialogueResponsesLabel;
-	const Graphics::Font &titleFont = *_highlightFont;
-	const bool titleUsesCft = _highlightFontUsesCft;
-	const int titleWidth = titleFont.getStringWidth(title);
-	const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (DialogueRendering::kDialogueSubtitleTextWidth - titleWidth) / 2);
-	drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
-
-	const int lineHeight = DialogueRendering::getDialogueTextLineHeight(*_menuFont);
-	int drawY = DialogueRendering::kDialogueKeywordTextY;
-	for (uint i = 0; i < options.size(); ++i) {
-		const bool highlighted = (int)i == hoveredOptionIndex;
-		const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
-		const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
-		const byte color = highlighted ? kTextColorNormal : kTextColorHover;
-
-		for (const Common::String &wrappedLine : options[i].wrappedLines) {
-			drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
-				DialogueRendering::kDialogueSubtitleTextWidth, color);
-			drawY += lineHeight;
-		}
-	}
-
-	if (_entityManager)
-		_entityManager->drawCursor(*activeScreen);
-	activeScreen->makeAllDirty();
-	activeScreen->update();
-}
-
-Common::Error RoomNpcDialogueSession::runResponseMenuTextInternal(const Common::String &responseLine, int responseLineIndex,
-		int &selectedIndex) {
-	selectedIndex = 0;
-
-	if (responseLine.empty()) {
-		if (responseLineIndex >= 0) {
-			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) is empty",
-				responseLineIndex, responseLineIndex + 1);
-		} else {
-			debugC(1, kDebugDialogue, "Harvester: response menu text is empty");
-		}
-		return Common::kNoError;
-	}
-
-	Common::Array<DialogueResponseOptionLayout> options;
-	uint totalRows = 0;
-	parseResponseMenuLine(responseLine, options, totalRows);
-	if (options.empty()) {
-		if (responseLineIndex >= 0) {
-			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) produced no options",
-				responseLineIndex, responseLineIndex + 1);
-		} else {
-			debugC(1, kDebugDialogue, "Harvester: response menu text produced no options");
-		}
-		return Common::kNoError;
-	}
-
-	Common::Array<Common::String> optionTexts;
-	for (const DialogueResponseOptionLayout &option : options) {
-		optionTexts.push_back(option.text);
-		debugC(3, kDebugDialogue, "Harvester: response menu option[%u] hotkey=%c rowStart=%d rowCount=%d",
-			optionTexts.size(), option.hotkey ? option.hotkey : '-', option.rowStart, option.rowCount);
-	}
-	logDialogueMenuItems("Response menu", responseLineIndex, responseLine, optionTexts);
-
-	const IndexedBitmap *textboxBitmap = _art->getTextboxBitmap(DialogueRendering::resolveDialogueResponseTextboxIndex(totalRows));
-	Common::Error releaseError = waitForPointerRelease();
-	if (releaseError.getCode() != Common::kNoError)
-		return releaseError;
-
-	Graphics::FrameLimiter limiter(g_system, 60);
-	for (;;) {
-		const int hoveredOptionIndex = getResponseMenuItemAt(options, totalRows, _mousePos);
-		drawDialogueResponseMenu(textboxBitmap, options, hoveredOptionIndex);
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (_flow.handleSystemEvent(event, result))
-				return result;
-
-			if (event.type == Common::EVENT_KEYDOWN) {
-				debugC(3, kDebugDialogue, "Harvester: response menu keycode=%d ascii=%u",
-					(int)event.kbd.keycode, event.kbd.ascii);
-				if (event.kbd.keycode >= Common::KEYCODE_1 && event.kbd.keycode <= Common::KEYCODE_9) {
-					const char hotkey = '1' + event.kbd.keycode - Common::KEYCODE_1;
-					for (int optionIndex = (int)options.size() - 1; optionIndex >= 0; --optionIndex) {
-						const DialogueResponseOptionLayout &option = options[(uint)optionIndex];
-						if (option.hotkey != hotkey)
-							continue;
-						selectedIndex = optionIndex + 1;
-						if (responseLineIndex >= 0) {
-							debugC(2, kDebugDialogue,
-								"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via hotkey=%c result=%d",
-								responseLineIndex, responseLineIndex + 1,
-								optionIndex + 1, option.text.c_str(), hotkey, selectedIndex);
-						} else {
-							debugC(2, kDebugDialogue,
-								"Harvester: response menu text selected option[%d]='%s' via hotkey=%c result=%d",
-								optionIndex + 1, option.text.c_str(), hotkey, selectedIndex);
-						}
-						return waitForPointerRelease();
-					}
-				}
-			} else if (event.type == Common::EVENT_LBUTTONDOWN) {
-				const int clickedOptionIndex = getResponseMenuItemAt(options, totalRows, event.mouse);
-				if (clickedOptionIndex < 0)
-					continue;
-				selectedIndex = clickedOptionIndex + 1;
-				if (responseLineIndex >= 0) {
-					debugC(2, kDebugDialogue,
-						"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via click x=%d y=%d result=%d",
-						responseLineIndex, responseLineIndex + 1,
-						selectedIndex, options[(uint)clickedOptionIndex].text.c_str(),
-						event.mouse.x, event.mouse.y, selectedIndex);
-				} else {
-					debugC(2, kDebugDialogue,
-						"Harvester: response menu text selected option[%d]='%s' via click x=%d y=%d result=%d",
-						selectedIndex, options[(uint)clickedOptionIndex].text.c_str(),
-						event.mouse.x, event.mouse.y, selectedIndex);
-				}
-				return waitForPointerRelease();
-			}
-		}
-
-		if (_entityManager)
-			(void)_entityManager->syncCursorEntityPosition(_mousePos);
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/dialogue/dialogue_session.h b/engines/harvester/dialogue/dialogue_session.h
index 9e76dca33fc..a496374d7cd 100644
--- a/engines/harvester/dialogue/dialogue_session.h
+++ b/engines/harvester/dialogue/dialogue_session.h
@@ -28,6 +28,10 @@
 #include "harvester/cft_font.h"
 #include "harvester/npc/dialogue_runtime.h"
 
+namespace Common {
+struct Event;
+}
+
 namespace Graphics {
 class Screen;
 }
@@ -90,6 +94,11 @@ private:
 		const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
 		int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue,
 		bool textEntryCursorVisible = false);
+	bool handleKeywordTextEntry(const Common::Event &event, Common::String &typedTopic,
+		bool &editingOther, Common::String &selectedTopic, DialogueKeywordMenuSelectionState &selection);
+	bool selectResponseMenuHotkey(const Common::Event &event,
+		const Common::Array<DialogueResponseOptionLayout> &options, int responseLineIndex,
+		int &selectedIndex);
 	void parseResponseMenuLine(const Common::String &responseLine,
 		Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows);
 	int getResponseMenuItemAt(const Common::Array<DialogueResponseOptionLayout> &options,
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index cd7c62e0d4a..39f8009be96 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -7,6 +7,7 @@ MODULE_OBJS = \
 	cheats.o \
 	console.o \
 	dialogue/dialogue.o \
+	dialogue/dialogue_menu.o \
 	dialogue/dialogue_playback.o \
 	dialogue/dialogue_render.o \
 	dialogue/dialogue_session.o \


Commit: 2c315a9616abed2740757dacb4ed3752f5be767b
    https://github.com/scummvm/scummvm/commit/2c315a9616abed2740757dacb4ed3752f5be767b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Separate audio decoding and scope input ownership

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/media/audio_decoder.cpp
  A engines/harvester/media/audio_decoder.h
    engines/harvester/media_manager.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/media/audio_decoder.cpp b/engines/harvester/media/audio_decoder.cpp
new file mode 100644
index 00000000000..338661e1ac3
--- /dev/null
+++ b/engines/harvester/media/audio_decoder.cpp
@@ -0,0 +1,184 @@
+/* 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/media/audio_decoder.h"
+
+#include "audio/audiostream.h"
+#include "audio/decoders/raw.h"
+#include "audio/decoders/wave.h"
+#include "common/endian.h"
+#include "common/ptr.h"
+#include "common/stream.h"
+#include "common/util.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int8 kHarvesterImaIndexAdjustTable[16] = {
+	-1, -1, -1, -1, 2, 4, 6, 8,
+	-1, -1, -1, -1, 2, 4, 6, 8
+};
+
+static const uint16 kHarvesterImaStepTable[89] = {
+	7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
+	19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
+	50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
+	130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
+	337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
+	876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
+	2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
+	5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
+	15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
+};
+
+static uint32 decodeHarvesterFcmp(byte *dest, const byte *src, uint32 srcSize, uint16 bitsPerSample) {
+	const uint32 decodedBytesPerInputByte = bitsPerSample >> 2;
+	const uint32 decodedSize = srcSize * decodedBytesPerInputByte;
+	const uint32 sampleCount = (bitsPerSample == 16) ? (decodedSize >> 1) : decodedSize;
+	int predictor = 0;
+	int stepIndex = 0;
+	int stepSize = 7;
+	byte cachedByte = 0;
+	uint32 srcPos = 0;
+
+	for (uint32 sample = 0; sample < sampleCount; ++sample) {
+		const byte nibble = ((sample & 1) == 0)
+			? ((cachedByte = src[srcPos++]) & 0x0f)
+			: ((cachedByte >> 4) & 0x0f);
+		int delta = stepSize >> 3;
+		if (nibble & 4)
+			delta += stepSize;
+		if (nibble & 2)
+			delta += stepSize >> 1;
+		if (nibble & 1)
+			delta += stepSize >> 2;
+		if (nibble & 8)
+			delta = -delta;
+
+		predictor += delta;
+		if (predictor > 0x7fff)
+			predictor = 0x7fff;
+		else if (predictor < -0x8000)
+			predictor = -0x8000;
+
+		if (bitsPerSample == 16) {
+			WRITE_LE_INT16(dest, predictor);
+			dest += 2;
+		} else {
+			*dest++ = (byte)(((predictor >> 8) & 0xff) ^ 0x80);
+		}
+
+		stepIndex += kHarvesterImaIndexAdjustTable[nibble];
+		if (stepIndex < 0)
+			stepIndex = 0;
+		else if (stepIndex > 88)
+			stepIndex = 88;
+		stepSize = kHarvesterImaStepTable[stepIndex];
+	}
+
+	return decodedSize;
+}
+
+static void applyStartupFcmpWarmup(byte *decodedPcm, uint32 &decodedSize, FcmpWarmupMode warmupMode) {
+	if (!decodedPcm || decodedSize == 0)
+		return;
+
+	if (warmupMode == FcmpWarmupMode::kMusic) {
+		// Native start_music_stream zeros the first 0x64 decoded bytes of the first FCMP chunk.
+		memset(decodedPcm, 0, MIN<uint32>(decodedSize, 100));
+		return;
+	}
+
+	// Native load_sound_sample/load_dialogue_voice_sample queue from decoded_pcm + 0x1f4.
+	const uint32 trimBytes = MIN<uint32>(decodedSize, 500);
+	if (trimBytes == decodedSize) {
+		memset(decodedPcm, 0, decodedSize);
+		return;
+	}
+
+	memmove(decodedPcm, decodedPcm + trimBytes, decodedSize - trimBytes);
+	decodedSize -= trimBytes;
+}
+
+} // End of anonymous namespace
+
+Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream,
+		FcmpWarmupMode warmupMode) {
+	if (!stream)
+		return nullptr;
+
+	Common::ScopedPtr<Common::SeekableReadStream> input(stream);
+
+	if (input->size() >= 14) {
+		char magic[4];
+		input->read(magic, sizeof(magic));
+		if (!memcmp(magic, "FCMP", sizeof(magic))) {
+			const uint32 payloadSizeFromHeader = input->readUint32LE();
+			const uint32 sampleRate = input->readUint32LE();
+			const uint16 bitsPerSample = input->readUint16LE();
+			const uint32 availablePayloadSize = input->size() - 14;
+			const uint32 payloadSize = payloadSizeFromHeader != 0
+				? MIN<uint32>(payloadSizeFromHeader, availablePayloadSize)
+				: availablePayloadSize;
+
+			if (sampleRate == 0 || (bitsPerSample != 8 && bitsPerSample != 16) || payloadSize == 0)
+				return nullptr;
+
+			if (!input->seek(14))
+				return nullptr;
+
+			byte *compressedPayload = (byte *)malloc(payloadSize);
+			if (!compressedPayload)
+				return nullptr;
+			if (input->read(compressedPayload, payloadSize) != payloadSize) {
+				free(compressedPayload);
+				return nullptr;
+			}
+
+			uint32 decodedSize = payloadSize * (bitsPerSample >> 2);
+			byte *decodedPcm = (byte *)malloc(decodedSize);
+			if (!decodedPcm) {
+				free(compressedPayload);
+				return nullptr;
+			}
+
+			decodeHarvesterFcmp(decodedPcm, compressedPayload, payloadSize, bitsPerSample);
+			applyStartupFcmpWarmup(decodedPcm, decodedSize, warmupMode);
+			free(compressedPayload);
+			input.reset();
+			return Audio::makeRawStream(decodedPcm, decodedSize, sampleRate,
+				Audio::FLAG_LITTLE_ENDIAN | ((bitsPerSample == 16) ? Audio::FLAG_16BITS : 0),
+				DisposeAfterUse::YES);
+		}
+	}
+
+	input->seek(0);
+	return Audio::makeWAVStream(input.release(), DisposeAfterUse::YES);
+}
+
+Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources,
+		const Common::String &path, FcmpWarmupMode warmupMode) {
+	return decodeStartupAudioStream(resources.openFile(path), warmupMode);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/media/audio_decoder.h b/engines/harvester/media/audio_decoder.h
new file mode 100644
index 00000000000..cabd9337771
--- /dev/null
+++ b/engines/harvester/media/audio_decoder.h
@@ -0,0 +1,51 @@
+/* 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_AUDIO_DECODER_H
+#define HARVESTER_AUDIO_DECODER_H
+
+#include "common/str.h"
+
+namespace Audio {
+class SeekableAudioStream;
+}
+
+namespace Common {
+class SeekableReadStream;
+}
+
+namespace Harvester {
+
+class ResourceManager;
+
+enum class FcmpWarmupMode {
+	kMusic,
+	kSample
+};
+
+Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream,
+		FcmpWarmupMode warmupMode);
+Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources,
+		const Common::String &path, FcmpWarmupMode warmupMode);
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_AUDIO_DECODER_H
diff --git a/engines/harvester/media_manager.cpp b/engines/harvester/media_manager.cpp
index e7d2b2c8a77..842ac070f64 100644
--- a/engines/harvester/media_manager.cpp
+++ b/engines/harvester/media_manager.cpp
@@ -20,12 +20,10 @@
  */
 
 #include "harvester/media_manager.h"
+#include "harvester/media/audio_decoder.h"
 
 #include "audio/audiostream.h"
-#include "audio/decoders/raw.h"
-#include "audio/decoders/wave.h"
 #include "common/algorithm.h"
-#include "common/endian.h"
 #include "common/memstream.h"
 #include "common/system.h"
 #include "graphics/screen.h"
@@ -39,28 +37,6 @@ namespace Harvester {
 
 namespace {
 
-enum class FcmpWarmupMode {
-	kMusic,
-	kSample
-};
-
-static const int8 kHarvesterImaIndexAdjustTable[16] = {
-	-1, -1, -1, -1, 2, 4, 6, 8,
-	-1, -1, -1, -1, 2, 4, 6, 8
-};
-
-static const uint16 kHarvesterImaStepTable[89] = {
-	7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
-	19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
-	50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
-	130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
-	337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
-	876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
-	2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
-	5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
-	15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
-};
-
 static int clampStartupOptionLevel(int level) {
 	if (level < 0)
 		return 0;
@@ -74,140 +50,6 @@ static int mapStartupVolumeLevelToMixerVolume(int level) {
 	return (clampStartupOptionLevel(level) * Audio::Mixer::kMaxMixerVolume + 4) / 9;
 }
 
-static uint32 decodeHarvesterFcmp(byte *dest, const byte *src, uint32 srcSize, uint16 bitsPerSample) {
-	const uint32 decodedBytesPerInputByte = bitsPerSample >> 2;
-	const uint32 decodedSize = srcSize * decodedBytesPerInputByte;
-	const uint32 sampleCount = (bitsPerSample == 16) ? (decodedSize >> 1) : decodedSize;
-	int predictor = 0;
-	int stepIndex = 0;
-	int stepSize = 7;
-	byte cachedByte = 0;
-	uint32 srcPos = 0;
-
-	for (uint32 sample = 0; sample < sampleCount; ++sample) {
-		const byte nibble = ((sample & 1) == 0)
-			? ((cachedByte = src[srcPos++]) & 0x0f)
-			: ((cachedByte >> 4) & 0x0f);
-		int delta = stepSize >> 3;
-		if (nibble & 4)
-			delta += stepSize;
-		if (nibble & 2)
-			delta += stepSize >> 1;
-		if (nibble & 1)
-			delta += stepSize >> 2;
-		if (nibble & 8)
-			delta = -delta;
-
-		predictor += delta;
-		if (predictor > 0x7fff)
-			predictor = 0x7fff;
-		else if (predictor < -0x8000)
-			predictor = -0x8000;
-
-		if (bitsPerSample == 16) {
-			WRITE_LE_INT16(dest, predictor);
-			dest += 2;
-		} else {
-			*dest++ = (byte)(((predictor >> 8) & 0xff) ^ 0x80);
-		}
-
-		stepIndex += kHarvesterImaIndexAdjustTable[nibble];
-		if (stepIndex < 0)
-			stepIndex = 0;
-		else if (stepIndex > 88)
-			stepIndex = 88;
-		stepSize = kHarvesterImaStepTable[stepIndex];
-	}
-
-	return decodedSize;
-}
-
-static void applyStartupFcmpWarmup(byte *decodedPcm, uint32 &decodedSize, FcmpWarmupMode warmupMode) {
-	if (!decodedPcm || decodedSize == 0)
-		return;
-
-	if (warmupMode == FcmpWarmupMode::kMusic) {
-		// Native start_music_stream zeros the first 0x64 decoded bytes of the first FCMP chunk.
-		memset(decodedPcm, 0, MIN<uint32>(decodedSize, 100));
-		return;
-	}
-
-	// Native load_sound_sample/load_dialogue_voice_sample queue from decoded_pcm + 0x1f4.
-	const uint32 trimBytes = MIN<uint32>(decodedSize, 500);
-	if (trimBytes == decodedSize) {
-		memset(decodedPcm, 0, decodedSize);
-		return;
-	}
-
-	memmove(decodedPcm, decodedPcm + trimBytes, decodedSize - trimBytes);
-	decodedSize -= trimBytes;
-}
-
-static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream,
-		FcmpWarmupMode warmupMode) {
-	if (!stream)
-		return nullptr;
-
-	if (stream->size() >= 14) {
-		char magic[4];
-		stream->read(magic, sizeof(magic));
-		if (!memcmp(magic, "FCMP", sizeof(magic))) {
-			const uint32 payloadSizeFromHeader = stream->readUint32LE();
-			const uint32 sampleRate = stream->readUint32LE();
-			const uint16 bitsPerSample = stream->readUint16LE();
-			const uint32 availablePayloadSize = stream->size() - 14;
-			const uint32 payloadSize = payloadSizeFromHeader != 0
-				? MIN<uint32>(payloadSizeFromHeader, availablePayloadSize)
-				: availablePayloadSize;
-
-			if (sampleRate == 0 || (bitsPerSample != 8 && bitsPerSample != 16) || payloadSize == 0) {
-				delete stream;
-				return nullptr;
-			}
-
-			if (!stream->seek(14)) {
-				delete stream;
-				return nullptr;
-			}
-
-			byte *compressedPayload = (byte *)malloc(payloadSize);
-			if (!compressedPayload) {
-				delete stream;
-				return nullptr;
-			}
-			if (stream->read(compressedPayload, payloadSize) != payloadSize) {
-				free(compressedPayload);
-				delete stream;
-				return nullptr;
-			}
-
-			uint32 decodedSize = payloadSize * (bitsPerSample >> 2);
-			byte *decodedPcm = (byte *)malloc(decodedSize);
-			if (!decodedPcm) {
-				free(compressedPayload);
-				delete stream;
-				return nullptr;
-			}
-
-			decodeHarvesterFcmp(decodedPcm, compressedPayload, payloadSize, bitsPerSample);
-			applyStartupFcmpWarmup(decodedPcm, decodedSize, warmupMode);
-			free(compressedPayload);
-			delete stream;
-			return Audio::makeRawStream(decodedPcm, decodedSize, sampleRate,
-				Audio::FLAG_LITTLE_ENDIAN | ((bitsPerSample == 16) ? Audio::FLAG_16BITS : 0),
-				DisposeAfterUse::YES);
-		}
-	}
-
-	stream->seek(0);
-	return Audio::makeWAVStream(stream, DisposeAfterUse::YES);
-}
-
-static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources,
-		const Common::String &path, FcmpWarmupMode warmupMode) {
-	return decodeStartupAudioStream(resources.openFile(path), warmupMode);
-}
-
 } // End of anonymous namespace
 
 MediaManager::MediaManager(ResourceManager &resources) : _resources(resources) {
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 39f8009be96..ad054bc3968 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -20,6 +20,7 @@ MODULE_OBJS = \
 	fst_player.o \
 	harvester.o \
 	inventory.o \
+	media/audio_decoder.o \
 	media_manager.o \
 	menu/menu.o \
 	menu/menu_config.o \


Commit: eef97d3eb757b22c116657a0648b0c014f742b1e
    https://github.com/scummvm/scummvm/commit/eef97d3eb757b22c116657a0648b0c014f742b1e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Separate media playback and resource lifecycle

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/media/media_manager.cpp
  A engines/harvester/media/media_manager.h
  A engines/harvester/media/media_playback.cpp
  R engines/harvester/media_manager.cpp
  R engines/harvester/media_manager.h
    engines/harvester/harvester.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 8969ce3153c..408667a9bff 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -26,7 +26,7 @@
 #include "harvester/console.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
-#include "harvester/media_manager.h"
+#include "harvester/media/media_manager.h"
 #include "harvester/resources.h"
 #include "harvester/flow/flow.h"
 #include "harvester/script/script.h"
diff --git a/engines/harvester/media/media_manager.cpp b/engines/harvester/media/media_manager.cpp
new file mode 100644
index 00000000000..f6cc9be5cdb
--- /dev/null
+++ b/engines/harvester/media/media_manager.cpp
@@ -0,0 +1,117 @@
+/* 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/media/media_manager.h"
+
+#include "common/ptr.h"
+#include "common/system.h"
+#include "graphics/screen.h"
+#include "harvester/art.h"
+#include "harvester/resources.h"
+#include "harvester/entity.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static int clampStartupOptionLevel(int level) {
+	if (level < 0)
+		return 0;
+	if (level > 9)
+		return 9;
+
+	return level;
+}
+
+static int mapStartupVolumeLevelToMixerVolume(int level) {
+	return (clampStartupOptionLevel(level) * Audio::Mixer::kMaxMixerVolume + 4) / 9;
+}
+
+} // End of anonymous namespace
+
+MediaManager::MediaManager(ResourceManager &resources) : _resources(resources) {
+	_entityManager = new EntityManager(_resources);
+}
+
+MediaManager::~MediaManager() {
+	stopMusic();
+	stopSound();
+	delete _text;
+	delete _art;
+	delete _screen;
+	delete _entityManager;
+}
+
+float MediaManager::mapGammaLevelToBrightnessScale(int level) {
+	return 1.0f + 0.1f * clampStartupOptionLevel(level);
+}
+
+void MediaManager::resetScreen(int width, int height) {
+	delete _screen;
+	_screen = new Graphics::Screen();
+	_displayWidth = width;
+	_displayHeight = height;
+}
+
+bool MediaManager::loadArt() {
+	Common::ScopedPtr<Art> art(new Art());
+	if (!art->load(_resources))
+		return false;
+
+	delete _art;
+	_art = art.release();
+	return true;
+}
+
+bool MediaManager::loadText() {
+	Common::ScopedPtr<Text> text(new Text());
+	if (!text->load(_resources))
+		return false;
+
+	delete _text;
+	_text = text.release();
+	return true;
+}
+
+bool MediaManager::loadQuickTipsResources(bool useTextboxPanel) {
+	return _art && _art->loadQuickTipsResources(_resources, useTextboxPanel);
+}
+
+void MediaManager::drawWaitFrame() const {
+	if (_art && _screen)
+		_art->drawWaitFrame(*_screen);
+}
+
+void MediaManager::applyMixerLevels(int fxLevel, int musicLevel) {
+	if (!g_system || !g_system->getMixer())
+		return;
+
+	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kSFXSoundType,
+		mapStartupVolumeLevelToMixerVolume(fxLevel));
+	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kMusicSoundType,
+		mapStartupVolumeLevelToMixerVolume(musicLevel));
+
+	if (g_system->getMixer()->isSoundHandleActive(_musicHandle))
+		g_system->getMixer()->pauseHandle(_musicHandle, musicLevel == 0);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/media_manager.h b/engines/harvester/media/media_manager.h
similarity index 100%
rename from engines/harvester/media_manager.h
rename to engines/harvester/media/media_manager.h
diff --git a/engines/harvester/media_manager.cpp b/engines/harvester/media/media_playback.cpp
similarity index 77%
rename from engines/harvester/media_manager.cpp
rename to engines/harvester/media/media_playback.cpp
index 842ac070f64..3e6489a11c4 100644
--- a/engines/harvester/media_manager.cpp
+++ b/engines/harvester/media/media_playback.cpp
@@ -19,109 +19,18 @@
  *
  */
 
-#include "harvester/media_manager.h"
+#include "harvester/media/media_manager.h"
 #include "harvester/media/audio_decoder.h"
 
 #include "audio/audiostream.h"
 #include "common/algorithm.h"
 #include "common/memstream.h"
 #include "common/system.h"
-#include "graphics/screen.h"
-#include "harvester/art.h"
 #include "harvester/resources.h"
-#include "harvester/entity.h"
 #include "harvester/script/script.h"
-#include "harvester/text.h"
 
 namespace Harvester {
 
-namespace {
-
-static int clampStartupOptionLevel(int level) {
-	if (level < 0)
-		return 0;
-	if (level > 9)
-		return 9;
-
-	return level;
-}
-
-static int mapStartupVolumeLevelToMixerVolume(int level) {
-	return (clampStartupOptionLevel(level) * Audio::Mixer::kMaxMixerVolume + 4) / 9;
-}
-
-} // End of anonymous namespace
-
-MediaManager::MediaManager(ResourceManager &resources) : _resources(resources) {
-	_entityManager = new EntityManager(_resources);
-}
-
-MediaManager::~MediaManager() {
-	stopMusic();
-	stopSound();
-	delete _text;
-	delete _art;
-	delete _screen;
-	delete _entityManager;
-}
-
-float MediaManager::mapGammaLevelToBrightnessScale(int level) {
-	return 1.0f + 0.1f * clampStartupOptionLevel(level);
-}
-
-void MediaManager::resetScreen(int width, int height) {
-	delete _screen;
-	_screen = new Graphics::Screen();
-	_displayWidth = width;
-	_displayHeight = height;
-}
-
-bool MediaManager::loadArt() {
-	Art *art = new Art();
-	if (!art->load(_resources)) {
-		delete art;
-		return false;
-	}
-
-	delete _art;
-	_art = art;
-	return true;
-}
-
-bool MediaManager::loadText() {
-	Text *text = new Text();
-	if (!text->load(_resources)) {
-		delete text;
-		return false;
-	}
-
-	delete _text;
-	_text = text;
-	return true;
-}
-
-bool MediaManager::loadQuickTipsResources(bool useTextboxPanel) {
-	return _art && _art->loadQuickTipsResources(_resources, useTextboxPanel);
-}
-
-void MediaManager::drawWaitFrame() const {
-	if (_art && _screen)
-		_art->drawWaitFrame(*_screen);
-}
-
-void MediaManager::applyMixerLevels(int fxLevel, int musicLevel) {
-	if (!g_system || !g_system->getMixer())
-		return;
-
-	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kSFXSoundType,
-		mapStartupVolumeLevelToMixerVolume(fxLevel));
-	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kMusicSoundType,
-		mapStartupVolumeLevelToMixerVolume(musicLevel));
-
-	if (g_system->getMixer()->isSoundHandleActive(_musicHandle))
-		g_system->getMixer()->pauseHandle(_musicHandle, musicLevel == 0);
-}
-
 bool MediaManager::isMusicPlaying() const {
 	return g_system && g_system->getMixer() &&
 		g_system->getMixer()->isSoundHandleActive(_musicHandle);
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index ad054bc3968..b3cf08abc52 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -21,7 +21,8 @@ MODULE_OBJS = \
 	harvester.o \
 	inventory.o \
 	media/audio_decoder.o \
-	media_manager.o \
+	media/media_manager.o \
+	media/media_playback.o \
 	menu/menu.o \
 	menu/menu_config.o \
 	menu/menu_navigation.o \


Commit: 0e7664836f815e5d8af661749e24285820d93046
    https://github.com/scummvm/scummvm/commit/0e7664836f815e5d8af661749e24285820d93046
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Separate interaction interruption and terminal outcomes

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/scene/interaction_outcomes.cpp
    engines/harvester/module.mk
    engines/harvester/scene/interaction.cpp
    engines/harvester/scene/interaction.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index b3cf08abc52..0edac6ad7f7 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -50,6 +50,7 @@ MODULE_OBJS = \
 	combat/combat_reactions.o \
 	combat/combat_targeting.o \
 	scene/interaction.o \
+	scene/interaction_outcomes.o \
 	scene/input.o \
 	scene/room_presentation.o \
 	scene/scene_entities.o \
diff --git a/engines/harvester/scene/interaction.cpp b/engines/harvester/scene/interaction.cpp
index 37156a5d23f..6d6cac06bac 100644
--- a/engines/harvester/scene/interaction.cpp
+++ b/engines/harvester/scene/interaction.cpp
@@ -468,65 +468,17 @@ int Interaction::getPendingRegionFacing() const {
 	return pendingRegion && pendingRegion->startEnabled ? pendingRegion->desiredFacing : -1;
 }
 
-bool Interaction::hasPendingMainMenuReturn() const {
-	return _flow.hasPendingMainMenuReturn();
-}
-
-Common::Error Interaction::handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw) {
-	bool didTransition = false;
-	Common::Error interactionError =
-		handleInteractionResult(interaction, didTransition, Common::String());
-	if (interactionError.getCode() != Common::kNoError)
-		return interactionError;
-	if (!_flow.hasPendingMainMenuReturn() &&
-			_engine.getScript() &&
-			_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
-		_callbacks.stopPlayerRegionInteraction();
-		_callbacks.startPlayerDefeatSequence("combat_interaction", Common::String(), 1);
-	}
-
-	needsRedraw = true;
-	return Common::kNoError;
-}
-
 Common::Error Interaction::handleInteractionResult(const InteractionResult &interaction,
 		bool &didTransition, const Common::String &usedItemName, bool interruptPlayerMovement) {
 	const RoomPlayerState &playerState = _player.getState();
 	didTransition = false;
 
-	if (interruptPlayerMovement) {
-		_player.cancelMovement();
-		_pendingRegionName.clear();
-	} else if (playerState.hasMoveTarget || playerState.turnActive) {
-		debugC(2, kDebugPlayer,
-			"Harvester: background action preserving player movement room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) region='%s' turning=%d",
-			_scene.state.roomName.c_str(), playerState.centerX, playerState.bottomY,
-			playerState.z, playerState.targetX, playerState.targetBottomY,
-			playerState.targetZ, _pendingRegionName.c_str(), playerState.turnActive);
-	}
-	if (interaction.requestDemoEnding) {
-		Common::Error endingError = _flow.runDemoEnding();
-		if (endingError.getCode() != Common::kNoError)
-			return endingError;
-		didTransition = true;
-		return Common::kNoError;
-	}
+	preparePlayerForInteraction(playerState, interruptPlayerMovement);
+	if (interaction.requestDemoEnding)
+		return handleDemoEnding(didTransition);
 
-	if (interaction.requestMainMenu) {
-		_engine.stopMusic();
-		_engine.stopSound();
-		if (!interaction.deathFlicPath.empty()) {
-			FstPlayer fstPlayer(_engine);
-			if (!fstPlayer.play(interaction.deathFlicPath))
-				return Common::kReadingFailed;
-		}
-
-		if (!interaction.deathFlicPath.empty())
-			_flow.requestGameOverReturn();
-		else
-			_flow.requestMainMenuReturn();
-		return Common::kNoError;
-	}
+	if (interaction.requestMainMenu)
+		return handleMainMenuReturn(interaction.deathFlicPath);
 
 	Common::String restoreMusicPath = _engine.getMusicPath();
 	bool discChanged = false;
@@ -680,13 +632,7 @@ Common::Error Interaction::handleInteractionResult(const InteractionResult &inte
 	if (didTransition)
 		return Common::kNoError;
 
-	if (interaction.requestPlayerDeath &&
-			_engine.getScript() &&
-			_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
-		_callbacks.stopPlayerRegionInteraction();
-		_callbacks.startPlayerDefeatSequence("script_kill_pc", Common::String(),
-			interaction.playerDeathDamageType);
-	}
+	applyPlayerDeathRequest(interaction);
 
 	if (interaction.requestPlayerGotoXZ)
 		_callbacks.applyPlayerGotoXZ(interaction.playerGotoX, interaction.playerGotoZ);
diff --git a/engines/harvester/scene/interaction.h b/engines/harvester/scene/interaction.h
index 16c1b125500..19f2e18e0db 100644
--- a/engines/harvester/scene/interaction.h
+++ b/engines/harvester/scene/interaction.h
@@ -36,6 +36,7 @@ class Interaction;
 class SceneEntities;
 class Player;
 struct IndexedBitmap;
+struct RoomPlayerState;
 
 extern const char *const kExitCloseupPendingRoomChange;
 
@@ -123,6 +124,11 @@ public:
 		const Common::String &continuationTag, bool &didTransition);
 
 private:
+	void preparePlayerForInteraction(const RoomPlayerState &playerState, bool interruptPlayerMovement);
+	Common::Error handleDemoEnding(bool &didTransition);
+	Common::Error handleMainMenuReturn(const Common::String &deathFlicPath);
+	void applyPlayerDeathRequest(const InteractionResult &interaction);
+
 	HarvesterEngine &_engine;
 	Flow &_flow;
 	SceneState &_scene;
diff --git a/engines/harvester/scene/interaction_outcomes.cpp b/engines/harvester/scene/interaction_outcomes.cpp
new file mode 100644
index 00000000000..45c03f1a59b
--- /dev/null
+++ b/engines/harvester/scene/interaction_outcomes.cpp
@@ -0,0 +1,102 @@
+/* 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/scene/interaction.h"
+
+#include "common/debug.h"
+#include "harvester/detection.h"
+#include "harvester/flow/flow.h"
+#include "harvester/fst_player.h"
+#include "harvester/harvester.h"
+#include "harvester/player/player.h"
+
+namespace Harvester {
+
+void Interaction::preparePlayerForInteraction(const RoomPlayerState &playerState,
+		bool interruptPlayerMovement) {
+	if (interruptPlayerMovement) {
+		_player.cancelMovement();
+		_pendingRegionName.clear();
+	} else if (playerState.hasMoveTarget || playerState.turnActive) {
+		debugC(2, kDebugPlayer,
+			"Harvester: background action preserving player movement room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) region='%s' turning=%d",
+			_scene.state.roomName.c_str(), playerState.centerX, playerState.bottomY,
+			playerState.z, playerState.targetX, playerState.targetBottomY,
+			playerState.targetZ, _pendingRegionName.c_str(), playerState.turnActive);
+	}
+}
+
+Common::Error Interaction::handleDemoEnding(bool &didTransition) {
+	Common::Error endingError = _flow.runDemoEnding();
+	if (endingError.getCode() != Common::kNoError)
+		return endingError;
+	didTransition = true;
+	return Common::kNoError;
+}
+
+Common::Error Interaction::handleMainMenuReturn(const Common::String &deathFlicPath) {
+	_engine.stopMusic();
+	_engine.stopSound();
+	if (!deathFlicPath.empty()) {
+		FstPlayer fstPlayer(_engine);
+		if (!fstPlayer.play(deathFlicPath))
+			return Common::kReadingFailed;
+	}
+
+	if (!deathFlicPath.empty())
+		_flow.requestGameOverReturn();
+	else
+		_flow.requestMainMenuReturn();
+	return Common::kNoError;
+}
+
+void Interaction::applyPlayerDeathRequest(const InteractionResult &interaction) {
+	if (interaction.requestPlayerDeath &&
+			_engine.getScript() &&
+			_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
+		_callbacks.stopPlayerRegionInteraction();
+		_callbacks.startPlayerDefeatSequence("script_kill_pc", Common::String(),
+			interaction.playerDeathDamageType);
+	}
+}
+
+bool Interaction::hasPendingMainMenuReturn() const {
+	return _flow.hasPendingMainMenuReturn();
+}
+
+Common::Error Interaction::handleCombatInteraction(const InteractionResult &interaction, bool &needsRedraw) {
+	bool didTransition = false;
+	Common::Error interactionError =
+		handleInteractionResult(interaction, didTransition, Common::String());
+	if (interactionError.getCode() != Common::kNoError)
+		return interactionError;
+	if (!_flow.hasPendingMainMenuReturn() &&
+			_engine.getScript() &&
+			_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
+		_callbacks.stopPlayerRegionInteraction();
+		_callbacks.startPlayerDefeatSequence("combat_interaction", Common::String(), 1);
+	}
+
+	needsRedraw = true;
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester


Commit: 36999a576f4c59ee7b72b722f7ae66efd314bcfa
    https://github.com/scummvm/scummvm/commit/36999a576f4c59ee7b72b722f7ae66efd314bcfa
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Separate interaction transitions and restarts

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/scene/interaction_transitions.cpp
    engines/harvester/module.mk
    engines/harvester/scene/interaction.cpp
    engines/harvester/scene/interaction.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 0edac6ad7f7..603d3f67afa 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -51,6 +51,7 @@ MODULE_OBJS = \
 	combat/combat_targeting.o \
 	scene/interaction.o \
 	scene/interaction_outcomes.o \
+	scene/interaction_transitions.o \
 	scene/input.o \
 	scene/room_presentation.o \
 	scene/scene_entities.o \
diff --git a/engines/harvester/scene/interaction.cpp b/engines/harvester/scene/interaction.cpp
index 6d6cac06bac..989a3b73b83 100644
--- a/engines/harvester/scene/interaction.cpp
+++ b/engines/harvester/scene/interaction.cpp
@@ -499,63 +499,10 @@ Common::Error Interaction::handleInteractionResult(const InteractionResult &inte
 	if (interaction.mutatedRuntimeState)
 		_callbacks.syncGlobalTimerEntities(interaction.previousTimerRecords);
 
-	auto queueImplicitRoomRestart = [&]() -> Common::Error {
-		_pendingRoomChangeIsRoomName = false;
-		_pendingRoomChangeUsesSavedRoomState = false;
-		_pendingRoomChangeSavedRoomState.clear();
-		if (!interaction.roomRestartTargetName.empty()) {
-			_pendingRoomChange = interaction.roomRestartTargetName;
-		} else if (discChanged && interaction.cdChangeDisc == 3) {
-			// Native disc-3 prompt reloads re-enter room_setup through the startup target
-			// buffer, which remains "START" during ordinary play and resolves to CD3 RECEPTION.
-			_pendingRoomChange = "START";
-		} else if (!_scene.state.entranceName.empty()) {
-			_pendingRoomChange = _scene.state.entranceName;
-		} else {
-			_pendingRoomChange = _scene.state.roomName;
-			_pendingRoomChangeIsRoomName = true;
-		}
-		didTransition = !_pendingRoomChange.empty();
-		debugC(1, kDebugRoom,
-			"Harvester: implicit room restart target='%s' explicit='%s' discChanged=%d cdChangeDisc=%d sceneEntrance='%s' sceneRoom='%s'",
-			_pendingRoomChange.c_str(), interaction.roomRestartTargetName.c_str(),
-			discChanged ? 1 : 0, interaction.cdChangeDisc,
-			_scene.state.entranceName.c_str(), _scene.state.roomName.c_str());
-		return didTransition ? Common::kNoError : Common::kReadingFailed;
-	};
-	auto queueSavedRoomRestart = [&](const SaveRoomState &savedState) -> Common::Error {
-		_pendingRoomChange.clear();
-		_pendingRoomChangeIsRoomName = false;
-		_pendingRoomChangeUsesSavedRoomState = false;
-		_pendingRoomChangeSavedRoomState.clear();
-		_pendingRoomChange = !savedState.entranceName.empty()
-			? savedState.entranceName
-			: savedState.roomName;
-		_pendingRoomChangeIsRoomName = savedState.entranceName.empty();
-		_pendingRoomChangeUsesSavedRoomState = !_pendingRoomChange.empty();
-		_pendingRoomChangeSavedRoomState = savedState;
-		debugC(1, kDebugRoom,
-			"Harvester: queued saved room restart target='%s' room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
-			_pendingRoomChange.c_str(), savedState.roomName.c_str(),
-			savedState.entranceName.c_str(), savedState.playerX, savedState.playerY,
-			savedState.playerZ, savedState.playerFacing, savedState.musicPath.c_str());
-		didTransition = !_pendingRoomChange.empty();
-		return didTransition ? Common::kNoError : Common::kReadingFailed;
-	};
 	if (interaction.requestRoomRestart)
-		return queueImplicitRoomRestart();
-	if (interaction.requestCloseupExit) {
-		if (!_canExitCloseupToParent) {
-			debugC(1, kDebugRoom,
-				"Harvester: EXIT_CLOSEUP ignored for room='%s' without parent room loop",
-				_scene.state.roomName.c_str());
-			return Common::kNoError;
-		}
-
-		_pendingRoomChange = kExitCloseupPendingRoomChange;
-		didTransition = true;
-		return Common::kNoError;
-	}
+		return queueImplicitRoomRestart(interaction, discChanged, didTransition);
+	if (interaction.requestCloseupExit)
+		return queueCloseupExit(didTransition);
 
 	StartupRoomTransitionKind roomTransition = interaction.roomTransition;
 	if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
@@ -568,60 +515,13 @@ Common::Error Interaction::handleInteractionResult(const InteractionResult &inte
 		interaction.nextRoomName.empty() &&
 		!interaction.mutatedRuntimeState;
 	if (needsTownScriptDiscReload)
-		return queueImplicitRoomRestart();
+		return queueImplicitRoomRestart(interaction, discChanged, didTransition);
 
 	if (!interaction.nextRoomName.empty() &&
 			roomTransition == kStartupRoomTransitionChangeRoom) {
-		Common::String resolvedTransitionTarget = interaction.nextRoomName;
-		Common::Error resolveError = _flow.resolveRoomTransitionTarget(
-			interaction.nextRoomName, resolvedTransitionTarget);
-		if (resolveError.getCode() != Common::kNoError)
-			return resolveError;
-		if (resolvedTransitionTarget.empty())
-			return Common::kNoError;
-
-		Common::Error exitError = _callbacks.runRoomExitCommands();
-		if (exitError.getCode() != Common::kNoError)
-			return exitError;
-
-		// Native CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
-		_pendingRoomChange = Common::move(resolvedTransitionTarget);
-		didTransition = true;
+		return queueRoomChange(interaction.nextRoomName, didTransition);
 	} else if (!interaction.nextRoomName.empty()) {
-		Common::Error exitError = _callbacks.runRoomExitCommands();
-		if (exitError.getCode() != Common::kNoError)
-			return exitError;
-
-		SaveRoomState parentRoomState;
-		const bool hasParentRoomState = _engine.hasCurrentSaveRoomState();
-		if (hasParentRoomState)
-			parentRoomState = _engine.getCurrentSaveRoomState();
-		Common::Error roomError = _flow.runRoomLoop(interaction.nextRoomName);
-		if (_flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-		if (_flow.takePendingCloseupParentRestart()) {
-			if (hasParentRoomState && parentRoomState.valid)
-				return queueSavedRoomRestart(parentRoomState);
-			return queueImplicitRoomRestart();
-		}
-		if (_flow.hasPendingDebugRoomChange()) {
-			didTransition = true;
-			return Common::kNoError;
-		}
-		if (roomError.getCode() != Common::kReadingFailed &&
-				roomError.getCode() != Common::kNoError) {
-			return roomError;
-		}
-
-		if (!_callbacks.refreshCurrentScene(true))
-			return Common::kReadingFailed;
-
-		_flow.executeStartupAudioCommands(_scene.state.audioCommands);
-		if (!restoreMusicPath.empty())
-			(void)_engine.playMusic(restoreMusicPath);
-		else
-			_engine.stopMusic();
-		didTransition = true;
+		return runCloseupInteraction(interaction, discChanged, restoreMusicPath, didTransition);
 	} else if (interaction.visualRuntimeStateChanged) {
 		if (!_callbacks.applyCurrentRoomRuntimeMutationsInPlace(true) &&
 				!_callbacks.refreshCurrentScene(true)) {
diff --git a/engines/harvester/scene/interaction.h b/engines/harvester/scene/interaction.h
index 19f2e18e0db..fa011d3edcc 100644
--- a/engines/harvester/scene/interaction.h
+++ b/engines/harvester/scene/interaction.h
@@ -128,6 +128,13 @@ private:
 	Common::Error handleDemoEnding(bool &didTransition);
 	Common::Error handleMainMenuReturn(const Common::String &deathFlicPath);
 	void applyPlayerDeathRequest(const InteractionResult &interaction);
+	Common::Error queueImplicitRoomRestart(const InteractionResult &interaction,
+		bool discChanged, bool &didTransition);
+	Common::Error queueSavedRoomRestart(const SaveRoomState &savedState, bool &didTransition);
+	Common::Error queueRoomChange(const Common::String &nextRoomName, bool &didTransition);
+	Common::Error runCloseupInteraction(const InteractionResult &interaction, bool discChanged,
+		const Common::String &restoreMusicPath, bool &didTransition);
+	Common::Error queueCloseupExit(bool &didTransition);
 
 	HarvesterEngine &_engine;
 	Flow &_flow;
diff --git a/engines/harvester/scene/interaction_transitions.cpp b/engines/harvester/scene/interaction_transitions.cpp
new file mode 100644
index 00000000000..29d8d7a925c
--- /dev/null
+++ b/engines/harvester/scene/interaction_transitions.cpp
@@ -0,0 +1,150 @@
+/* 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/scene/interaction.h"
+
+#include "common/algorithm.h"
+#include "common/debug.h"
+#include "harvester/detection.h"
+#include "harvester/flow/flow.h"
+#include "harvester/harvester.h"
+
+namespace Harvester {
+
+Common::Error Interaction::queueImplicitRoomRestart(const InteractionResult &interaction,
+		bool discChanged, bool &didTransition) {
+	_pendingRoomChangeIsRoomName = false;
+	_pendingRoomChangeUsesSavedRoomState = false;
+	_pendingRoomChangeSavedRoomState.clear();
+	if (!interaction.roomRestartTargetName.empty()) {
+		_pendingRoomChange = interaction.roomRestartTargetName;
+	} else if (discChanged && interaction.cdChangeDisc == 3) {
+		// Disc-3 prompt reloads re-enter room_setup through the startup target
+		// buffer, which remains "START" during ordinary play and resolves to CD3 RECEPTION.
+		_pendingRoomChange = "START";
+	} else if (!_scene.state.entranceName.empty()) {
+		_pendingRoomChange = _scene.state.entranceName;
+	} else {
+		_pendingRoomChange = _scene.state.roomName;
+		_pendingRoomChangeIsRoomName = true;
+	}
+	didTransition = !_pendingRoomChange.empty();
+	debugC(1, kDebugRoom,
+		"Harvester: implicit room restart target='%s' explicit='%s' discChanged=%d cdChangeDisc=%d sceneEntrance='%s' sceneRoom='%s'",
+		_pendingRoomChange.c_str(), interaction.roomRestartTargetName.c_str(),
+		discChanged ? 1 : 0, interaction.cdChangeDisc,
+		_scene.state.entranceName.c_str(), _scene.state.roomName.c_str());
+	return didTransition ? Common::kNoError : Common::kReadingFailed;
+}
+
+Common::Error Interaction::queueSavedRoomRestart(const SaveRoomState &savedState, bool &didTransition) {
+	_pendingRoomChange.clear();
+	_pendingRoomChangeIsRoomName = false;
+	_pendingRoomChangeUsesSavedRoomState = false;
+	_pendingRoomChangeSavedRoomState.clear();
+	_pendingRoomChange = !savedState.entranceName.empty()
+		? savedState.entranceName
+		: savedState.roomName;
+	_pendingRoomChangeIsRoomName = savedState.entranceName.empty();
+	_pendingRoomChangeUsesSavedRoomState = !_pendingRoomChange.empty();
+	_pendingRoomChangeSavedRoomState = savedState;
+	debugC(1, kDebugRoom,
+		"Harvester: queued saved room restart target='%s' room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+		_pendingRoomChange.c_str(), savedState.roomName.c_str(),
+		savedState.entranceName.c_str(), savedState.playerX, savedState.playerY,
+		savedState.playerZ, savedState.playerFacing, savedState.musicPath.c_str());
+	didTransition = !_pendingRoomChange.empty();
+	return didTransition ? Common::kNoError : Common::kReadingFailed;
+}
+
+Common::Error Interaction::queueRoomChange(const Common::String &nextRoomName,
+		bool &didTransition) {
+	Common::String resolvedTransitionTarget = nextRoomName;
+	Common::Error resolveError = _flow.resolveRoomTransitionTarget(
+		nextRoomName, resolvedTransitionTarget);
+	if (resolveError.getCode() != Common::kNoError)
+		return resolveError;
+	if (resolvedTransitionTarget.empty())
+		return Common::kNoError;
+
+	Common::Error exitError = _callbacks.runRoomExitCommands();
+	if (exitError.getCode() != Common::kNoError)
+		return exitError;
+
+	// CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
+	_pendingRoomChange = Common::move(resolvedTransitionTarget);
+	didTransition = true;
+	return Common::kNoError;
+}
+
+Common::Error Interaction::runCloseupInteraction(const InteractionResult &interaction,
+		bool discChanged, const Common::String &restoreMusicPath, bool &didTransition) {
+	Common::Error exitError = _callbacks.runRoomExitCommands();
+	if (exitError.getCode() != Common::kNoError)
+		return exitError;
+
+	SaveRoomState parentRoomState;
+	const bool hasParentRoomState = _engine.hasCurrentSaveRoomState();
+	if (hasParentRoomState)
+		parentRoomState = _engine.getCurrentSaveRoomState();
+	Common::Error roomError = _flow.runRoomLoop(interaction.nextRoomName);
+	if (_flow.hasPendingMainMenuReturn())
+		return Common::kNoError;
+	if (_flow.takePendingCloseupParentRestart()) {
+		if (hasParentRoomState && parentRoomState.valid)
+			return queueSavedRoomRestart(parentRoomState, didTransition);
+		return queueImplicitRoomRestart(interaction, discChanged, didTransition);
+	}
+	if (_flow.hasPendingDebugRoomChange()) {
+		didTransition = true;
+		return Common::kNoError;
+	}
+	if (roomError.getCode() != Common::kReadingFailed &&
+			roomError.getCode() != Common::kNoError) {
+		return roomError;
+	}
+
+	if (!_callbacks.refreshCurrentScene(true))
+		return Common::kReadingFailed;
+
+	_flow.executeStartupAudioCommands(_scene.state.audioCommands);
+	if (!restoreMusicPath.empty())
+		(void)_engine.playMusic(restoreMusicPath);
+	else
+		_engine.stopMusic();
+	didTransition = true;
+	return Common::kNoError;
+}
+
+Common::Error Interaction::queueCloseupExit(bool &didTransition) {
+	if (!_canExitCloseupToParent) {
+		debugC(1, kDebugRoom,
+			"Harvester: EXIT_CLOSEUP ignored for room='%s' without parent room loop",
+			_scene.state.roomName.c_str());
+		return Common::kNoError;
+	}
+
+	_pendingRoomChange = kExitCloseupPendingRoomChange;
+	didTransition = true;
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester


Commit: dba8b8b2fd87e06b0b97fd6f038827555c505e48
    https://github.com/scummvm/scummvm/commit/dba8b8b2fd87e06b0b97fd6f038827555c505e48
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Separate same-scene effects and continuations

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/scene/interaction_effects.cpp
    engines/harvester/module.mk
    engines/harvester/scene/interaction.cpp
    engines/harvester/scene/interaction.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 603d3f67afa..19bff864123 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -50,6 +50,7 @@ MODULE_OBJS = \
 	combat/combat_reactions.o \
 	combat/combat_targeting.o \
 	scene/interaction.o \
+	scene/interaction_effects.o \
 	scene/interaction_outcomes.o \
 	scene/interaction_transitions.o \
 	scene/input.o \
diff --git a/engines/harvester/scene/interaction.cpp b/engines/harvester/scene/interaction.cpp
index 989a3b73b83..16bc9514bc7 100644
--- a/engines/harvester/scene/interaction.cpp
+++ b/engines/harvester/scene/interaction.cpp
@@ -27,7 +27,6 @@
 #include "harvester/art.h"
 #include "harvester/detection.h"
 #include "harvester/flow/flow.h"
-#include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/inventory.h"
 #include "harvester/player/player.h"
@@ -532,118 +531,8 @@ Common::Error Interaction::handleInteractionResult(const InteractionResult &inte
 	if (didTransition)
 		return Common::kNoError;
 
-	applyPlayerDeathRequest(interaction);
-
-	if (interaction.requestPlayerGotoXZ)
-		_callbacks.applyPlayerGotoXZ(interaction.playerGotoX, interaction.playerGotoZ);
-	else if (interaction.requestPlayerGotoZ)
-		_callbacks.applyPlayerGotoXZ(playerState.centerX, interaction.playerGotoZ);
-
-	if (!interaction.moveEntityToPlayerZName.empty()) {
-		EntityManager *entityManager = _engine.getRuntimeEntities();
-		Entity *entity = entityManager
-			? entityManager->findSceneEntityByName(interaction.moveEntityToPlayerZName)
-			: nullptr;
-		if (entity && playerState.entity) {
-			entity->setPosition(entity->getX(), entity->getY(), playerState.entity->getZ());
-			entityManager->reinsertSceneEntity(entity);
-			debugC(1, kDebugPlayer, "Harvester: MOVE_BM2PCZ entity='%s' z=%.2f",
-				interaction.moveEntityToPlayerZName.c_str(), entity->getZ());
-		}
-	}
-
-	if (interaction.lightingCommand != kStartupLightingCommandNone) {
-		Common::Error lightingError = _callbacks.applyLightingCommand(interaction.lightingCommand);
-		if (lightingError.getCode() != Common::kNoError)
-			return lightingError;
-	}
-
-	if (!interaction.cutscenePath.empty()) {
-		FstPlayer fstPlayer(_engine);
-		if (!fstPlayer.play(interaction.cutscenePath))
-			return Common::kReadingFailed;
-	}
-
-	if (!interaction.modalText.value.empty()) {
-		Common::Error modalError = _callbacks.runModalShowText(interaction.modalText);
-		if (modalError.getCode() != Common::kNoError)
-			return modalError;
-	}
-
-	if (!interaction.dialogueNpcName.empty()) {
-		Common::Error dialogueError = runScriptedDialogue(
-			interaction.dialogueNpcName, usedItemName, interaction.dialogueContinuationTag,
-			didTransition);
-		if (dialogueError.getCode() != Common::kNoError)
-			return dialogueError;
-	}
-
-	if (didTransition)
-		return Common::kNoError;
-
-	if (!interaction.continuationTag.empty()) {
-		InteractionResult continuationInteraction;
-		if (_engine.getScript()->executeActionTag(
-				interaction.continuationTag, continuationInteraction, true,
-				_scene.executionContext())) {
-			Common::Error interactionError =
-				handleInteractionResult(continuationInteraction, didTransition, usedItemName, interruptPlayerMovement);
-			if (interactionError.getCode() != Common::kNoError)
-				return interactionError;
-		}
-	}
-
-	return Common::kNoError;
-}
-
-Common::Error Interaction::runScriptedDialogue(const Common::String &npcName,
-		const Common::String &usedItemName, const Common::String &continuationTag,
-		bool &didTransition) {
-	didTransition = false;
-
-	const NpcRecord *dialogueNpc = _engine.getScript()->findRuntimeNpcRecord(npcName);
-	if (dialogueNpc) {
-		IndexedBitmap dialogueBackdrop;
-		if (!_callbacks.captureDialogueBackdrop(dialogueBackdrop))
-			return Common::kReadingFailed;
-
-		Common::Error dialogueError = _flow.runRoomNpcDialogue(
-			dialogueBackdrop, _scene.palette, _scene.targetPaletteBrightness,
-			_scene.executionContext(), *dialogueNpc, usedItemName);
-		if (dialogueError.getCode() != Common::kNoError)
-			return dialogueError;
-	} else {
-		warning("Harvester: unresolved startup dialogue npc '%s' while processing room dialogue",
-			npcName.c_str());
-	}
-
-	InteractionResult dialogueInteraction;
-	bool abortRemainingCommandChain = false;
-	if (_flow.takeQueuedDialogueInteraction(dialogueInteraction)) {
-		abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
-		Common::Error interactionError =
-			handleInteractionResult(dialogueInteraction, didTransition, usedItemName);
-		if (interactionError.getCode() != Common::kNoError)
-			return interactionError;
-		if (_flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-	}
-	if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
-		InteractionResult continuationInteraction;
-		if (_engine.getScript()->executeActionTag(
-				continuationTag, continuationInteraction, true, _scene.executionContext())) {
-			Common::Error interactionError =
-				handleInteractionResult(continuationInteraction, didTransition, usedItemName);
-			if (interactionError.getCode() != Common::kNoError)
-				return interactionError;
-			if (_flow.hasPendingMainMenuReturn())
-				return Common::kNoError;
-		}
-	}
-
-	_flow.resetCursorAnimationSequence();
-	_callbacks.resetIdleState();
-	return Common::kNoError;
+	return applySameSceneEffects(interaction, playerState, didTransition, usedItemName,
+		interruptPlayerMovement);
 }
 
 bool hasRoomEntryInteraction(const InteractionResult &interaction) {
diff --git a/engines/harvester/scene/interaction.h b/engines/harvester/scene/interaction.h
index fa011d3edcc..33f63b30c53 100644
--- a/engines/harvester/scene/interaction.h
+++ b/engines/harvester/scene/interaction.h
@@ -135,6 +135,9 @@ private:
 	Common::Error runCloseupInteraction(const InteractionResult &interaction, bool discChanged,
 		const Common::String &restoreMusicPath, bool &didTransition);
 	Common::Error queueCloseupExit(bool &didTransition);
+	Common::Error applySameSceneEffects(const InteractionResult &interaction,
+		const RoomPlayerState &playerState, bool &didTransition, const Common::String &usedItemName,
+		bool interruptPlayerMovement);
 
 	HarvesterEngine &_engine;
 	Flow &_flow;
diff --git a/engines/harvester/scene/interaction_effects.cpp b/engines/harvester/scene/interaction_effects.cpp
new file mode 100644
index 00000000000..7c564e4099a
--- /dev/null
+++ b/engines/harvester/scene/interaction_effects.cpp
@@ -0,0 +1,152 @@
+/* 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/scene/interaction.h"
+
+#include "common/debug.h"
+#include "common/textconsole.h"
+#include "harvester/detection.h"
+#include "harvester/entity.h"
+#include "harvester/flow/flow.h"
+#include "harvester/fst_player.h"
+#include "harvester/harvester.h"
+#include "harvester/player/player.h"
+
+namespace Harvester {
+
+Common::Error Interaction::applySameSceneEffects(const InteractionResult &interaction,
+		const RoomPlayerState &playerState, bool &didTransition, const Common::String &usedItemName,
+		bool interruptPlayerMovement) {
+	applyPlayerDeathRequest(interaction);
+
+	if (interaction.requestPlayerGotoXZ)
+		_callbacks.applyPlayerGotoXZ(interaction.playerGotoX, interaction.playerGotoZ);
+	else if (interaction.requestPlayerGotoZ)
+		_callbacks.applyPlayerGotoXZ(playerState.centerX, interaction.playerGotoZ);
+
+	if (!interaction.moveEntityToPlayerZName.empty()) {
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+		Entity *entity = entityManager
+			? entityManager->findSceneEntityByName(interaction.moveEntityToPlayerZName)
+			: nullptr;
+		if (entity && playerState.entity) {
+			entity->setPosition(entity->getX(), entity->getY(), playerState.entity->getZ());
+			entityManager->reinsertSceneEntity(entity);
+			debugC(1, kDebugPlayer, "Harvester: MOVE_BM2PCZ entity='%s' z=%.2f",
+				interaction.moveEntityToPlayerZName.c_str(), entity->getZ());
+		}
+	}
+
+	if (interaction.lightingCommand != kStartupLightingCommandNone) {
+		Common::Error lightingError = _callbacks.applyLightingCommand(interaction.lightingCommand);
+		if (lightingError.getCode() != Common::kNoError)
+			return lightingError;
+	}
+
+	if (!interaction.cutscenePath.empty()) {
+		FstPlayer fstPlayer(_engine);
+		if (!fstPlayer.play(interaction.cutscenePath))
+			return Common::kReadingFailed;
+	}
+
+	if (!interaction.modalText.value.empty()) {
+		Common::Error modalError = _callbacks.runModalShowText(interaction.modalText);
+		if (modalError.getCode() != Common::kNoError)
+			return modalError;
+	}
+
+	if (!interaction.dialogueNpcName.empty()) {
+		Common::Error dialogueError = runScriptedDialogue(
+			interaction.dialogueNpcName, usedItemName, interaction.dialogueContinuationTag,
+			didTransition);
+		if (dialogueError.getCode() != Common::kNoError)
+			return dialogueError;
+	}
+
+	if (didTransition)
+		return Common::kNoError;
+
+	if (!interaction.continuationTag.empty()) {
+		InteractionResult continuationInteraction;
+		if (_engine.getScript()->executeActionTag(
+				interaction.continuationTag, continuationInteraction, true,
+				_scene.executionContext())) {
+			Common::Error interactionError =
+				handleInteractionResult(continuationInteraction, didTransition, usedItemName, interruptPlayerMovement);
+			if (interactionError.getCode() != Common::kNoError)
+				return interactionError;
+		}
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error Interaction::runScriptedDialogue(const Common::String &npcName,
+		const Common::String &usedItemName, const Common::String &continuationTag,
+		bool &didTransition) {
+	didTransition = false;
+
+	const NpcRecord *dialogueNpc = _engine.getScript()->findRuntimeNpcRecord(npcName);
+	if (dialogueNpc) {
+		IndexedBitmap dialogueBackdrop;
+		if (!_callbacks.captureDialogueBackdrop(dialogueBackdrop))
+			return Common::kReadingFailed;
+
+		Common::Error dialogueError = _flow.runRoomNpcDialogue(
+			dialogueBackdrop, _scene.palette, _scene.targetPaletteBrightness,
+			_scene.executionContext(), *dialogueNpc, usedItemName);
+		if (dialogueError.getCode() != Common::kNoError)
+			return dialogueError;
+	} else {
+		warning("Harvester: unresolved startup dialogue npc '%s' while processing room dialogue",
+			npcName.c_str());
+	}
+
+	InteractionResult dialogueInteraction;
+	bool abortRemainingCommandChain = false;
+	if (_flow.takeQueuedDialogueInteraction(dialogueInteraction)) {
+		abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
+		Common::Error interactionError =
+			handleInteractionResult(dialogueInteraction, didTransition, usedItemName);
+		if (interactionError.getCode() != Common::kNoError)
+			return interactionError;
+		if (_flow.hasPendingMainMenuReturn())
+			return Common::kNoError;
+	}
+	if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
+		InteractionResult continuationInteraction;
+		if (_engine.getScript()->executeActionTag(
+				continuationTag, continuationInteraction, true, _scene.executionContext())) {
+			Common::Error interactionError =
+				handleInteractionResult(continuationInteraction, didTransition, usedItemName);
+			if (interactionError.getCode() != Common::kNoError)
+				return interactionError;
+			if (_flow.hasPendingMainMenuReturn())
+				return Common::kNoError;
+		}
+	}
+
+	_flow.resetCursorAnimationSequence();
+	_callbacks.resetIdleState();
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester


Commit: 5e92cc9095ab546555932ebd9d61a24b1d7bb8a0
    https://github.com/scummvm/scummvm/commit/5e92cc9095ab546555932ebd9d61a24b1d7bb8a0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Scope entity and archive setup ownership

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/entity.cpp
    engines/harvester/resources.cpp


diff --git a/engines/harvester/entity.cpp b/engines/harvester/entity.cpp
index 671cf7e3fd8..4fea5b5db22 100644
--- a/engines/harvester/entity.cpp
+++ b/engines/harvester/entity.cpp
@@ -23,6 +23,7 @@
 
 #include "common/algorithm.h"
 #include "common/debug.h"
+#include "common/ptr.h"
 #include "common/system.h"
 #include "graphics/blit.h"
 #include "graphics/screen.h"
@@ -788,11 +789,9 @@ void EntityManager::clearSceneEntities(bool preserveGlobalTimers) {
 Entity *EntityManager::spawnAbmEntityFromResource(const Common::String &name,
 		const Common::String &resourcePath, int classId, const Common::Point &position, float z,
 		int animationRate, bool looping, bool pingPong) {
-	Entity *entity = new Entity();
-	if (!entity->loadAbmResource(_resources, resourcePath)) {
-		delete entity;
+	Common::ScopedPtr<Entity> entity(new Entity());
+	if (!entity->loadAbmResource(_resources, resourcePath))
 		return nullptr;
-	}
 
 	entity->setName(name);
 	entity->setClassId(classId);
@@ -800,21 +799,19 @@ Entity *EntityManager::spawnAbmEntityFromResource(const Common::String &name,
 	entity->setLooping(looping);
 	entity->setPingPong(pingPong);
 	entity->setAnimationRate(animationRate);
-	return entity;
+	return entity.release();
 }
 
 Entity *EntityManager::spawnBitmapEntityFromResource(const Common::String &name,
 		const Common::String &resourcePath, int classId, const Common::Point &position, float z) {
-	Entity *entity = new Entity();
-	if (!entity->loadBitmapResource(_resources, resourcePath)) {
-		delete entity;
+	Common::ScopedPtr<Entity> entity(new Entity());
+	if (!entity->loadBitmapResource(_resources, resourcePath))
 		return nullptr;
-	}
 
 	entity->setName(name);
 	entity->setClassId(classId);
 	entity->setPosition(position.x, position.y, z);
-	return entity;
+	return entity.release();
 }
 
 Entity *EntityManager::spawnCursorEntity(const Common::Point &position) {
diff --git a/engines/harvester/resources.cpp b/engines/harvester/resources.cpp
index 4d97b57fe82..840be9576ae 100644
--- a/engines/harvester/resources.cpp
+++ b/engines/harvester/resources.cpp
@@ -22,6 +22,7 @@
 #include "harvester/resources.h"
 
 #include "common/debug.h"
+#include "common/ptr.h"
 #include "common/stream.h"
 #include "harvester/detection.h"
 #include "harvester/xfile_archive.h"
@@ -204,16 +205,15 @@ bool ResourceManager::ensureDiscMounted(int discNumber) {
 			continue;
 		}
 
-		XFileArchive *archive = new XFileArchive();
+		Common::ScopedPtr<XFileArchive> archive(new XFileArchive());
 		if (!archive->open(indexPath, dataPath)) {
 			debugC(1, kDebugResources,
 				"Harvester: failed to mount disc %d set %c from %s + %s",
 				discNumber, spec.archiveSetId, indexPath.c_str(), dataPath.c_str());
-			delete archive;
 			continue;
 		}
 
-		mountArchive(buildDiscArchiveName(discNumber, spec.archiveSetId), archive, spec.priority, true);
+		mountArchive(buildDiscArchiveName(discNumber, spec.archiveSetId), archive.release(), spec.priority, true);
 		debugC(1, kDebugResources,
 			"Harvester: mounted disc %d set %c from %s + %s",
 			discNumber, spec.archiveSetId, indexPath.c_str(), dataPath.c_str());


Commit: 8c364fa3247e1ecf3c669be8be39c2246a84e535
    https://github.com/scummvm/scummvm/commit/8c364fa3247e1ecf3c669be8be39c2246a84e535
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Scope FST audio queue ownership

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/fst_player.cpp


diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index e9406badb7b..22f26d01167 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -451,11 +451,11 @@ bool FstPlayer::play(const Common::String &path) {
 	uint nextCensorshipToggleIndex = 0;
 
 	Audio::SoundHandle audioHandle;
-	Audio::QueuingAudioStream *audioStream = nullptr;
+	Common::ScopedPtr<Audio::QueuingAudioStream> audioStream;
 	const bool hasPcmAudio = header.sampleRate != 0 && header.bitsPerSample == 16;
 	const uint32 trailingAudioFrameSkip = hasPcmAudio ? getTrailingFstAudioFrameSkip(frames) : 0;
 	if (hasPcmAudio)
-		audioStream = Audio::makeQueuingAudioStream(header.sampleRate, false);
+		audioStream.reset(Audio::makeQueuingAudioStream(header.sampleRate, false));
 	else if (header.sampleRate != 0 && header.bitsPerSample != 0)
 		warning("Harvester: unsupported FST audio format %u-bit in '%s'", header.bitsPerSample, path.c_str());
 
@@ -492,7 +492,7 @@ bool FstPlayer::play(const Common::String &path) {
 				Audio::FLAG_16BITS | Audio::FLAG_LITTLE_ENDIAN);
 
 			if (!audioStarted) {
-				mixer->playStream(Audio::Mixer::kSFXSoundType, &audioHandle, audioStream, -1,
+				mixer->playStream(Audio::Mixer::kSFXSoundType, &audioHandle, audioStream.get(), -1,
 					Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::NO);
 				audioStarted = true;
 			}
@@ -577,7 +577,7 @@ bool FstPlayer::play(const Common::String &path) {
 
 	if (audioStarted)
 		mixer->stopHandle(audioHandle);
-	delete audioStream;
+	audioStream.reset();
 	if (resumeMusicAfterPlayback)
 		_vm.pauseMusic(false);
 	if (shouldSwitchToMovieDisplay)


Commit: 4d488b07f27c306f18014079a5131c7a901ea224
    https://github.com/scummvm/scummvm/commit/4d488b07f27c306f18014079a5131c7a901ea224
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Separate inventory, keyboard and scene input handling

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
  A engines/harvester/scene/input_inventory.cpp
  A engines/harvester/scene/input_keyboard.cpp
    engines/harvester/module.mk
    engines/harvester/scene/input.cpp
    engines/harvester/scene/input.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 19bff864123..d978a7fb68e 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -54,6 +54,8 @@ MODULE_OBJS = \
 	scene/interaction_outcomes.o \
 	scene/interaction_transitions.o \
 	scene/input.o \
+	scene/input_inventory.o \
+	scene/input_keyboard.o \
 	scene/room_presentation.o \
 	scene/scene_entities.o \
 	scene/scene.o \
diff --git a/engines/harvester/scene/input.cpp b/engines/harvester/scene/input.cpp
index 39fd88352a2..6893301f7f1 100644
--- a/engines/harvester/scene/input.cpp
+++ b/engines/harvester/scene/input.cpp
@@ -22,9 +22,7 @@
 #include "harvester/scene/input.h"
 
 #include "common/debug.h"
-#include "graphics/screen.h"
 #include "harvester/art.h"
-#include "harvester/cheats.h"
 #include "harvester/detection.h"
 #include "harvester/dialogue/dialogue.h"
 #include "harvester/flow/flow.h"
@@ -48,43 +46,12 @@ static const int kCursorSequenceTransition = 6;
 static const int kCursorSequenceNeutral = 7;
 
 static const uint32 kRoomExitFastClickWindowTicks = 20;
-static const int kNativeInventoryDragCloseLeft = 0x45;
-static const int kNativeInventoryDragCloseTop = 0x4b;
-static const int kNativeInventoryDragCloseRight = 0x239;
-static const int kNativeInventoryDragCloseBottom = 0x1b4;
-static const uint32 kCombatLoadoutStatusDisplayTicks = 75;
 
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
 		!roomName.equalsIgnoreCase("BOWLSNTRY1");
 }
 
-static bool isOutsideNativeInventoryDragCloseBounds(const Common::Point &point) {
-	return point.x < kNativeInventoryDragCloseLeft ||
-		point.x >= kNativeInventoryDragCloseRight ||
-		point.y < kNativeInventoryDragCloseTop ||
-		point.y >= kNativeInventoryDragCloseBottom;
-}
-
-static void debugLogInventoryClick(const char *buttonLabel, const Common::Point &point,
-		const InventoryVisual *inventoryHover) {
-	if (!inventoryHover) {
-		debugC(1, kDebugInventory,
-			"Harvester: inventory %s click at (%d,%d) hit no item", buttonLabel, point.x, point.y);
-		return;
-	}
-
-	debugC(1, kDebugInventory,
-		"Harvester: inventory %s click at (%d,%d) object='%s' sprite='%s' alt='%s' action='%s' owner='%s' text='%s' bounds=(%d,%d)-(%d,%d) exit=%d status=%d",
-		buttonLabel, point.x, point.y, inventoryHover->object.objectName.c_str(),
-		inventoryHover->object.spritePath.c_str(), inventoryHover->object.altSpritePath.c_str(),
-		inventoryHover->object.actionTag.c_str(), inventoryHover->object.currentOwnerOrRoom.c_str(),
-		inventoryHover->object.inventoryTextKey.c_str(), inventoryHover->bounds.left,
-		inventoryHover->bounds.top, inventoryHover->bounds.right, inventoryHover->bounds.bottom,
-		InventorySystem::isExitObject(inventoryHover->object),
-		InventorySystem::isStatusObject(inventoryHover->object));
-}
-
 static bool canTalkToRoomNpc(HarvesterEngine &engine, const NpcRecord &npc,
 		const DialogueSystem *dialogue) {
 	Script *script = engine.getScript();
@@ -384,36 +351,6 @@ InputResult Input::applyObjectInteraction(InteractionResult &interaction, bool &
 	return InputResult();
 }
 
-void Input::queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw) {
-	if (message.empty()) {
-		const bool hadMessage = !_presentationState.combatLoadoutStatusText.value.empty();
-		_presentationState.combatLoadoutStatusText = ResolvedText();
-		_presentationState.combatLoadoutStatusHideTick = 0;
-		if (hadMessage)
-			needsRedraw = true;
-		return;
-	}
-
-	_presentationState.combatLoadoutStatusText.boxName = "BOX1";
-	_presentationState.combatLoadoutStatusText.value = message;
-	_presentationState.combatLoadoutStatusHideTick =
-		Player::getRuntimeClockTicks() + kCombatLoadoutStatusDisplayTicks;
-	needsRedraw = true;
-}
-
-bool Input::openInventoryOverlay() {
-	Player &player = _session.getPlayer();
-	InputState &inputState = _session.getInputState();
-
-	inputState.clearHeldActions();
-	_session.clearPendingRegionInteraction();
-	(void)_session.cancelPlayerAttackAnimation();
-	player.cancelMovement();
-	if (!_roomItems.stowCarriedRoomItemToInventory())
-		return false;
-	return _inventory.open();
-}
-
 void Input::capturePlayerAttackTarget() {
 	Player &player = _session.getPlayer();
 
@@ -423,19 +360,6 @@ void Input::capturePlayerAttackTarget() {
 	_session.capturePlayerAttackTarget(_flow.getMousePos(), hoverState.npc);
 }
 
-void Input::handleMouseMove(bool &needsRedraw) {
-	if (_inventory.isOpen() && _inventory.hasSelection()) {
-		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow.getMousePos());
-		// Native inventory handoff is driven by the latched selected item, not by
-		// a second click after the selection has already been made.
-		if (!inventoryHover && isOutsideNativeInventoryDragCloseBounds(_flow.getMousePos())) {
-			if (_inventory.close())
-				needsRedraw = true;
-		}
-	}
-	needsRedraw = true;
-}
-
 InputResult Input::handleRightButtonDown(bool &needsRedraw) {
 	Player &player = _session.getPlayer();
 	const RoomPlayerState &playerState = player.getState();
@@ -450,99 +374,9 @@ InputResult Input::handleRightButtonDown(bool &needsRedraw) {
 	if (_session.isPlayerCombatLocked())
 		return InputResult();
 	_session.clearPendingRegionInteraction();
-	if (_inventory.isOpen()) {
-		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow.getMousePos());
-		debugLogInventoryClick("right", _flow.getMousePos(), inventoryHover);
-		if (_inventory.hasSelection()) {
-			debugC(1, kDebugInventory,
-				"Harvester: inventory right click cleared active selection='%s'",
-				_inventory.getSelectedItemName().c_str());
-			if (_inventory.clearSelection())
-				needsRedraw = true;
-			return InputResult();
-		}
-		if (inventoryHover &&
-				!InventorySystem::isExitObject(inventoryHover->object) &&
-				!InventorySystem::isStatusObject(inventoryHover->object)) {
-			Script *script = _engine.getScript();
-			const int previousLoadout = playerState.combatLoadout >= 0
-				? playerState.combatLoadout
-				: (script ? script->getPlayerCombatLoadout() : 0);
-			bool loadoutChanged = false;
-			if (_inventory.toggleCombatLoadout(inventoryHover->object,
-					playerState.combatLoadout, loadoutChanged)) {
-				debugC(1, kDebugInventory,
-					"Harvester: inventory right click handled as combat toggle object='%s' changed=%d",
-					inventoryHover->object.objectName.c_str(), loadoutChanged);
-				if (loadoutChanged) {
-					if (script) {
-						const Common::String statusMessage = buildCombatLoadoutStatusMessage(
-							*script, inventoryHover->object, previousLoadout,
-							script->getPlayerCombatLoadout());
-						queueCombatLoadoutStatusMessage(statusMessage, needsRedraw);
-						_presentationState.closeInventoryAfterCombatLoadoutStatus = true;
-					}
-					_session.captureCurrentSaveState();
-					player.syncCombatLoadout(_engine, _scene.state);
-					if (!_inventory.refresh())
-						return InputResult::leaveRoom(Common::kReadingFailed);
-				}
-				needsRedraw = true;
-				return InputResult();
-			}
+	if (_inventory.isOpen())
+		return handleInventoryRightClick(needsRedraw);
 
-			InventorySecondaryAction secondaryAction;
-			if (_inventory.resolveSecondaryAction(inventoryHover->object, secondaryAction)) {
-				debugC(1, kDebugInventory,
-					"Harvester: inventory right click executing secondary action object='%s' action='%s' closeInventory=%d",
-					inventoryHover->object.objectName.c_str(),
-					secondaryAction.actionTag.c_str(), secondaryAction.closeInventory);
-				if (secondaryAction.closeInventory)
-					(void)_inventory.close();
-
-				InteractionResult interaction;
-				bool didTransition = false;
-				const bool executedActionTag = _engine.getScript()->executeActionTag(
-						secondaryAction.actionTag, interaction, true, _scene.executionContext());
-				debugC(1, kDebugInventory,
-					"Harvester: inventory right click executeActionTag('%s') -> %d music='%s' cutscene='%s' nextRoom='%s'",
-					secondaryAction.actionTag.c_str(), executedActionTag,
-					interaction.musicPath.c_str(), interaction.cutscenePath.c_str(),
-					interaction.nextRoomName.c_str());
-				if (executedActionTag) {
-					Common::Error interactionError =
-						_interactionProcessor.handleInteractionResult(
-							interaction, didTransition, Common::String());
-					debugC(1, kDebugInventory,
-						"Harvester: inventory right click action='%s' interaction result error=%d transitioned=%d pendingMainMenu=%d",
-						secondaryAction.actionTag.c_str(), interactionError.getCode(),
-						didTransition, _flow.hasPendingMainMenuReturn());
-					if (interactionError.getCode() != Common::kNoError)
-						return InputResult::leaveRoom(interactionError);
-					if (_flow.hasPendingMainMenuReturn())
-						return InputResult::leaveRoom(Common::kNoError);
-				}
-
-				if (_inventory.isOpen()) {
-					if (!_inventory.refresh())
-						return InputResult::leaveRoom(Common::kReadingFailed);
-				} else {
-					debugC(1, kDebugInventory,
-						"Harvester: inventory right click skipped overlay refresh after action='%s' transitioned=%d closeInventory=%d",
-						secondaryAction.actionTag.c_str(), didTransition, secondaryAction.closeInventory);
-				}
-				needsRedraw = true;
-				return InputResult();
-			}
-			debugC(1, kDebugInventory,
-				"Harvester: inventory right click object='%s' produced no combat toggle or secondary action",
-				inventoryHover->object.objectName.c_str());
-		}
-		debugC(1, kDebugInventory, "Harvester: inventory right click closing overlay");
-		if (_inventory.close())
-			needsRedraw = true;
-		return InputResult();
-	}
 	if (_inventory.hasSelection()) {
 		if (_inventory.clearSelection())
 			needsRedraw = true;
@@ -587,7 +421,6 @@ void Input::handleLeftButtonUp() {
 
 InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	Player &player = _session.getPlayer();
-	const RoomPlayerState &playerState = player.getState();
 	const RoomIdleAnimationState &idleState = player.getIdleState();
 	InputState &inputState = _session.getInputState();
 
@@ -610,56 +443,14 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	if (_session.isPlayerCombatLocked())
 		return InputResult();
 
-	const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
-	if (_inventory.isOpen()) {
-		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow.getMousePos());
-		debugLogInventoryClick("left", _flow.getMousePos(), inventoryHover);
-		if (inventoryHover) {
-			if (InventorySystem::isExitObject(inventoryHover->object)) {
-				debugC(1, kDebugInventory, "Harvester: inventory left click exit button");
-				const bool clearedSelection = _inventory.clearSelection();
-				const bool closedInventory = _inventory.close();
-				if (clearedSelection || closedInventory)
-					needsRedraw = true;
-				return InputResult();
-			}
-			if (InventorySystem::isStatusObject(inventoryHover->object))
-				return InputResult();
-
-			if (!_inventory.hasSelection()) {
-				debugC(1, kDebugInventory,
-					"Harvester: inventory left click selecting object='%s'",
-					inventoryHover->object.objectName.c_str());
-				_inventory.selectItem(inventoryHover->object.objectName);
-				_inventory.setPromptText(_inventory.buildSelectedPrompt(
-					Common::String(), _flow.getMenuTextConfig()));
-				needsRedraw = true;
-				return InputResult();
-			}
+	if (_inventory.isOpen())
+		return handleInventoryLeftClick(needsRedraw);
 
-			if (!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
-				debugC(1, kDebugInventory,
-					"Harvester: inventory left click using selected='%s' on target='%s'",
-					_inventory.getSelectedItemName().c_str(),
-					inventoryHover->object.objectName.c_str());
-				Common::Error interactionError =
-					_roomItems.handleSelectedItemTargetInteraction(inventoryHover->object, _interactionProcessor, needsRedraw);
-				if (interactionError.getCode() != Common::kNoError)
-					return InputResult::leaveRoom(interactionError);
-			}
-			needsRedraw = true;
-			return InputResult();
-		}
+	return handleSceneClick(isFastExitClick, needsRedraw);
+}
 
-		if (_inventory.hasSelection()) {
-			if (!inventoryPanelBounds.contains(_flow.getMousePos()) && _inventory.close())
-				needsRedraw = true;
-			return InputResult();
-		}
-		if (!inventoryPanelBounds.contains(_flow.getMousePos()) && _inventory.close())
-			needsRedraw = true;
-		return InputResult();
-	}
+InputResult Input::handleSceneClick(bool isFastExitClick, bool &needsRedraw) {
+	const RoomPlayerState &playerState = _session.getPlayer().getState();
 
 	const RoomHoverState hoverState = _flow.getRoomHoverState(_scene);
 	RoomHoverState clickHoverState = hoverState;
@@ -699,38 +490,54 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 			return InputResult();
 		}
 	}
-	if (_roomItems.hasSelectedItem()) {
-		const Common::String selectedItemName = _roomItems.getSelectedItemName();
-		if (clickHoverState.npc) {
-			inputState.clearHeldActions();
-			_roomItems.clearSelectedItem();
-			bool didTransition = false;
-			Common::Error dialogueError = _interactionProcessor.runScriptedDialogue(
-				clickHoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
-			if (dialogueError.getCode() != Common::kNoError)
-				return InputResult::leaveRoom(dialogueError);
-			if (_flow.hasPendingMainMenuReturn())
-				return InputResult::leaveRoom(Common::kNoError);
-			needsRedraw = true;
-			return InputResult();
-		}
+	if (_roomItems.hasSelectedItem())
+		return handleSelectedItemClick(clickHoverState, itemTarget, needsRedraw);
+	return handleSceneTargetClick(hoverState, isFastExitClick, needsRedraw);
+}
 
-		ObjectRecord *roomTarget = clickHoverState.object
-			? SceneEntities::findSceneObjectByName(_scene.sceneObjects, clickHoverState.object->objectName)
-			: nullptr;
-		if (roomTarget) {
-			Common::Error interactionError =
-				_roomItems.handleSelectedItemTargetInteraction(*roomTarget, _interactionProcessor, needsRedraw);
-			if (interactionError.getCode() != Common::kNoError)
-				return InputResult::leaveRoom(interactionError);
-			needsRedraw = true;
-		} else if (itemTarget) {
-			Common::Error interactionError = _roomItems.handleSelectedItemMiss(_interactionProcessor, needsRedraw);
-			if (interactionError.getCode() != Common::kNoError)
-				return InputResult::leaveRoom(interactionError);
-		}
+InputResult Input::handleSelectedItemClick(const RoomHoverState &clickHoverState,
+		const Entity *itemTarget, bool &needsRedraw) {
+	InputState &inputState = _session.getInputState();
+
+	const Common::String selectedItemName = _roomItems.getSelectedItemName();
+	if (clickHoverState.npc) {
+		inputState.clearHeldActions();
+		_roomItems.clearSelectedItem();
+		bool didTransition = false;
+		Common::Error dialogueError = _interactionProcessor.runScriptedDialogue(
+			clickHoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
+		if (dialogueError.getCode() != Common::kNoError)
+			return InputResult::leaveRoom(dialogueError);
+		if (_flow.hasPendingMainMenuReturn())
+			return InputResult::leaveRoom(Common::kNoError);
+		needsRedraw = true;
 		return InputResult();
 	}
+
+	ObjectRecord *roomTarget = clickHoverState.object
+		? SceneEntities::findSceneObjectByName(_scene.sceneObjects, clickHoverState.object->objectName)
+		: nullptr;
+	if (roomTarget) {
+		Common::Error interactionError =
+			_roomItems.handleSelectedItemTargetInteraction(*roomTarget, _interactionProcessor, needsRedraw);
+		if (interactionError.getCode() != Common::kNoError)
+			return InputResult::leaveRoom(interactionError);
+		needsRedraw = true;
+	} else if (itemTarget) {
+		Common::Error interactionError = _roomItems.handleSelectedItemMiss(_interactionProcessor, needsRedraw);
+		if (interactionError.getCode() != Common::kNoError)
+			return InputResult::leaveRoom(interactionError);
+	}
+	return InputResult();
+}
+
+InputResult Input::handleSceneTargetClick(const RoomHoverState &hoverState,
+		bool isFastExitClick, bool &needsRedraw) {
+	Player &player = _session.getPlayer();
+	const RoomPlayerState &playerState = player.getState();
+	const RoomIdleAnimationState &idleState = player.getIdleState();
+	InputState &inputState = _session.getInputState();
+
 	if (hoverState.playerEntity) {
 		if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
 			idleState.active || idleState.exiting ||
@@ -783,33 +590,46 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	ObjectRecord *clickedObject = hoverState.object
 		? SceneEntities::findSceneObjectByName(_scene.sceneObjects, hoverState.object->objectName)
 		: nullptr;
-	if (!clickedObject) {
-		_session.clearPendingRegionInteraction();
-		if (hoverState.cursorSequence == 0 && playerState.entity) {
-			if (_engine.isPathfindingDebugEnabled()) {
-				const Common::Rect playerRect = playerState.entity->getScreenRect();
-				debugC(1, kDebugPathfinding,
-					"Harvester: pathfinding click accepted room='%s' mouse=(%d,%d) movement_band=(y=%d..%d,z=%d..%d) player=(%d,%d,z=%.2f) player_rect=(%d,%d)-(%d,%d)",
-					_scene.state.roomName.c_str(), _flow.getMousePos().x, _flow.getMousePos().y,
-					_scene.state.roomMaxZScreenY, _scene.state.roomMinZScreenY,
-					_scene.state.roomMinZ, _scene.state.roomMaxZ,
-					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);
-			if (_engine.isPathfindingDebugEnabled()) {
-				debugC(1, kDebugPathfinding,
-					"Harvester: pathfinding click target room='%s' mouse=(%d,%d) target=(%d,%d,z=%.2f)",
-					_scene.state.roomName.c_str(), _flow.getMousePos().x, _flow.getMousePos().y,
-					playerState.targetX, playerState.targetBottomY, static_cast<double>(playerState.targetZ));
-			}
-			needsRedraw = true;
+	if (!clickedObject)
+		return handleWalkClick(hoverState, needsRedraw);
+	return handleObjectClick(*clickedObject, needsRedraw);
+}
+
+InputResult Input::handleWalkClick(const RoomHoverState &hoverState, bool &needsRedraw) {
+	Player &player = _session.getPlayer();
+	const RoomPlayerState &playerState = player.getState();
+
+	_session.clearPendingRegionInteraction();
+	if (hoverState.cursorSequence == 0 && playerState.entity) {
+		if (_engine.isPathfindingDebugEnabled()) {
+			const Common::Rect playerRect = playerState.entity->getScreenRect();
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding click accepted room='%s' mouse=(%d,%d) movement_band=(y=%d..%d,z=%d..%d) player=(%d,%d,z=%.2f) player_rect=(%d,%d)-(%d,%d)",
+				_scene.state.roomName.c_str(), _flow.getMousePos().x, _flow.getMousePos().y,
+				_scene.state.roomMaxZScreenY, _scene.state.roomMinZScreenY,
+				_scene.state.roomMinZ, _scene.state.roomMaxZ,
+				playerState.centerX, playerState.bottomY, static_cast<double>(playerState.z),
+				playerRect.left, playerRect.top, playerRect.right, playerRect.bottom);
 		}
-		return InputResult();
+		player.setMoveTargetFromScreenPoint(_scene.state, _flow.getMousePos().x, _flow.getMousePos().y);
+		if (_engine.isPathfindingDebugEnabled()) {
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding click target room='%s' mouse=(%d,%d) target=(%d,%d,z=%.2f)",
+				_scene.state.roomName.c_str(), _flow.getMousePos().x, _flow.getMousePos().y,
+				playerState.targetX, playerState.targetBottomY, static_cast<double>(playerState.targetZ));
+		}
+		needsRedraw = true;
 	}
+	return InputResult();
+}
+
+InputResult Input::handleObjectClick(ObjectRecord &object, bool &needsRedraw) {
+	Player &player = _session.getPlayer();
+	const RoomPlayerState &playerState = player.getState();
+
 	_session.clearPendingRegionInteraction();
-	if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
-		clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
+	if (object.objectName.equalsIgnoreCase("EXIT_BM") ||
+		object.objectName.equalsIgnoreCase("EXIT_HS")) {
 		Common::Error exitError = _session.runRoomExitCommands();
 		if (exitError.getCode() != Common::kNoError)
 			return InputResult::leaveRoom(exitError);
@@ -821,36 +641,36 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 
 	ResolvedText resolvedInspectText;
 	const bool hasInspectText =
-		_engine.getScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
+		_engine.getScript()->resolveObjectInspectText(object, resolvedInspectText);
 	const bool unlocksAfterInitialExamine =
-		unlocksRoomObjectInteractionAfterInitialExamine(*clickedObject, *_engine.getScript());
+		unlocksRoomObjectInteractionAfterInitialExamine(object, *_engine.getScript());
 	const bool canShowInspectText =
 		hasInspectText && RoomPresentation::resolveInspectTextboxBitmap(_art, resolvedInspectText);
-	if (!clickedObject->identShown && unlocksAfterInitialExamine) {
-		clickedObject->identShown = true;
-		_engine.getScript()->markObjectIdentShown(*clickedObject);
+	if (!object.identShown && unlocksAfterInitialExamine) {
+		object.identShown = true;
+		_engine.getScript()->markObjectIdentShown(object);
 		if (canShowInspectText) {
-			beginInspectText(*clickedObject, resolvedInspectText);
+			beginInspectText(object, resolvedInspectText);
 		} else if (hasInspectText) {
 			debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
-				resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
+				resolvedInspectText.boxName.c_str(), object.objectName.c_str());
 		}
 		player.cancelMovement();
 		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) &&
+	if (!object.identShown && !suppressesInitialObjectInspectGate(object) &&
 			!unlocksAfterInitialExamine && canShowInspectText) {
-		beginInspectText(*clickedObject, resolvedInspectText);
-		if (!clickedObject->actionTag.empty())
-			_pendingInspectObjectName = clickedObject->objectName;
+		beginInspectText(object, resolvedInspectText);
+		if (!object.actionTag.empty())
+			_pendingInspectObjectName = object.objectName;
 		player.cancelMovement();
 		needsRedraw = true;
 		return InputResult();
 	}
-	if (_engine.getScript()->isPickupObject(*clickedObject)) {
-		Common::Error pickupError = _roomItems.handleRoomItemPickup(*clickedObject, playerState.entity != nullptr,
+	if (_engine.getScript()->isPickupObject(object)) {
+		Common::Error pickupError = _roomItems.handleRoomItemPickup(object, playerState.entity != nullptr,
 			_interactionProcessor, _session, needsRedraw);
 		if (pickupError.getCode() != Common::kNoError)
 			return InputResult::leaveRoom(pickupError);
@@ -859,12 +679,12 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 
 	InteractionResult interaction;
 	if (!_engine.getScript()->resolveObjectInteraction(
-			*clickedObject, interaction, _scene.executionContext())) {
+			object, interaction, _scene.executionContext())) {
 		if (canShowInspectText) {
-			beginInspectText(*clickedObject, resolvedInspectText);
+			beginInspectText(object, resolvedInspectText);
 		} else if (hasInspectText) {
 			debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
-				resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
+				resolvedInspectText.boxName.c_str(), object.objectName.c_str());
 		}
 		needsRedraw = true;
 		return InputResult();
@@ -873,228 +693,6 @@ InputResult Input::handleLeftButtonDown(bool &needsRedraw) {
 	return applyObjectInteraction(interaction, 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,
-		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)
-			_inspectDismissPressed = true;
-		return InputResult();
-	}
-
-	_session.notePlayerActivity();
-	if (idleState.active || idleState.exiting) {
-		if (player.requestIdleAnimationExit(_scene.state))
-			needsRedraw = true;
-		return InputResult();
-	}
-
-	if (Script *script = _engine.getScript()) {
-		InteractionResult cheatInteraction;
-		CheatActivation cheatActivation;
-		const CheatInputResult cheatInputResult = _cheats.processKey(
-			key, *script, _scene.executionContext(),
-			cheatInteraction, cheatActivation);
-		if (cheatInputResult != kCheatInputIgnored) {
-			if (cheatInputResult == kCheatInputComplete && cheatActivation.activated) {
-				if (cheatActivation.clearInteractionState && _inventory.clearSelection())
-					needsRedraw = true;
-				if (cheatActivation.inventoryChanged && _inventory.isOpen() &&
-						!_inventory.refresh()) {
-					return InputResult::leaveRoom(Common::kReadingFailed);
-				}
-				if (cheatActivation.hasInteraction) {
-					if (_inventory.isOpen())
-						(void)_inventory.close();
-					bool didTransition = false;
-					Common::Error cheatError = _interactionProcessor.handleInteractionResult(
-						cheatInteraction, didTransition, Common::String());
-					if (cheatError.getCode() != Common::kNoError)
-						return InputResult::leaveRoom(cheatError);
-					if (_flow.hasPendingMainMenuReturn())
-						return InputResult::leaveRoom(Common::kNoError);
-				}
-				needsRedraw = true;
-			}
-			return InputResult();
-		}
-	}
-
-	if (_inventory.isOpen()) {
-		if (key.keycode == Common::KEYCODE_ESCAPE) {
-			const bool clearedSelection = _inventory.clearSelection();
-			const bool closedInventory = _inventory.close();
-			if (clearedSelection || closedInventory)
-				needsRedraw = true;
-		} else if (key.keycode == Common::KEYCODE_RETURN ||
-				key.keycode == Common::KEYCODE_KP_ENTER ||
-				key.keycode == Common::KEYCODE_i) {
-			if (_inventory.close())
-				needsRedraw = true;
-		}
-		return InputResult();
-	}
-	if (_inventory.hasSelection()) {
-		if (key.keycode == Common::KEYCODE_ESCAPE) {
-			if (_inventory.clearSelection())
-				needsRedraw = true;
-			return InputResult();
-		}
-		if (key.keycode == Common::KEYCODE_i) {
-			if (_inventory.clearSelection())
-				needsRedraw = true;
-			if (!openInventoryOverlay())
-				return InputResult::leaveRoom(Common::kReadingFailed);
-			needsRedraw = true;
-			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_k && _engine.isCombatDebugEnabled()) {
-		Common::Error debugKillError = _session.killDebugActiveMonster(_flow.getMousePos(), needsRedraw);
-		if (debugKillError.getCode() != Common::kNoError)
-			return InputResult::leaveRoom(debugKillError);
-		return InputResult();
-	} else if (gameAction && key.keycode == Common::KEYCODE_i) {
-		if (!openInventoryOverlay())
-			return InputResult::leaveRoom(Common::kReadingFailed);
-		needsRedraw = true;
-		return InputResult();
-	} else if (gameAction && key.keycode == Common::KEYCODE_ESCAPE) {
-		if (_session.cancelPlayerAttackAnimation())
-			needsRedraw = true;
-		if (!_roomItems.stowCarriedRoomItemToInventory())
-			return InputResult::leaveRoom(Common::kReadingFailed);
-		inputState.clearHeldActions();
-		_session.clearPendingRegionInteraction();
-		player.cancelMovement();
-		_session.captureCurrentSaveState();
-		Graphics::Screen *activeScreen = _engine.getScreen();
-		if (!activeScreen)
-			return InputResult::leaveRoom(Common::kNoError);
-		IndexedBitmap roomMenuBackdrop;
-		RoomPresentation::drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
-		if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
-			return InputResult::leaveRoom(Common::kReadingFailed);
-		Common::Error menuError = _flow.runRoomMenu(
-			roomMenuBackdrop, _scene.palette, _scene.targetPaletteBrightness,
-			_scene.state.hasEntrance);
-		if (menuError.getCode() != Common::kNoError)
-			return InputResult::leaveRoom(menuError);
-		_flow.resetCursorAnimationSequence();
-		_session.resetIdleState();
-		needsRedraw = true;
-		return InputResult();
-	}
-
-	if (key.keycode == Common::KEYCODE_RETURN ||
-		key.keycode == Common::KEYCODE_KP_ENTER) {
-		if (!_roomItems.stowCarriedRoomItemToInventory())
-			return InputResult::leaveRoom(Common::kReadingFailed);
-		Common::Error exitError = _session.runRoomExitCommands();
-		if (exitError.getCode() != Common::kNoError)
-			return InputResult::leaveRoom(exitError);
-		return InputResult::leaveRoom(Common::kNoError);
-	}
-	return InputResult();
-}
-
-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)
-		inputState.moveRight = false;
-	else if (key.keycode == Common::KEYCODE_UP)
-		inputState.moveUp = false;
-	else if (key.keycode == Common::KEYCODE_DOWN)
-		inputState.moveDown = false;
-}
-
 InputResult Input::handleEvent(const Common::Event &event, bool &needsRedraw) {
 	Common::Error result = Common::kNoError;
 	if (_flow.handleSystemEvent(event, result))
diff --git a/engines/harvester/scene/input.h b/engines/harvester/scene/input.h
index 136a47eae03..d3f58d24b83 100644
--- a/engines/harvester/scene/input.h
+++ b/engines/harvester/scene/input.h
@@ -33,13 +33,16 @@ namespace Harvester {
 class Art;
 class CheatSystem;
 class DialogueSystem;
+class Entity;
 class Flow;
 class HarvesterEngine;
 class InventorySystem;
 class Interaction;
 class ItemInteractions;
 class Scene;
+struct CheatActivation;
 struct InteractionResult;
+struct InventorySecondaryAction;
 struct MenuTextConfig;
 struct NpcRecord;
 struct ObjectRecord;
@@ -127,9 +130,25 @@ private:
 	InputResult handleRightButtonDown(bool &needsRedraw);
 	void handleLeftButtonUp();
 	InputResult handleLeftButtonDown(bool &needsRedraw);
+	InputResult handleSceneClick(bool isFastExitClick, bool &needsRedraw);
+	InputResult handleSelectedItemClick(const RoomHoverState &clickHoverState,
+		const Entity *itemTarget, bool &needsRedraw);
+	InputResult handleSceneTargetClick(const RoomHoverState &hoverState,
+		bool isFastExitClick, bool &needsRedraw);
+	InputResult handleWalkClick(const RoomHoverState &hoverState, bool &needsRedraw);
+	InputResult handleObjectClick(ObjectRecord &object, bool &needsRedraw);
+	InputResult handleInventoryLeftClick(bool &needsRedraw);
+	InputResult handleInventoryRightClick(bool &needsRedraw);
+	InputResult applyInventorySecondaryAction(const ObjectRecord &object,
+		const InventorySecondaryAction &secondaryAction, bool &needsRedraw);
+	InputResult handleInventoryKeyDown(const Common::KeyState &key, bool &needsRedraw);
 	InputResult handleKeyDown(const Common::KeyState &key, bool &needsRedraw, bool gameAction = false);
+	InputResult handleGameplayKeyDown(const Common::KeyState &key, bool &needsRedraw, bool gameAction);
 	void handleKeyUp(const Common::KeyState &key, bool gameAction = false);
 	InputResult handleGameAction(GameAction action, bool pressed, bool &needsRedraw);
+	InputResult applyCheatActivation(InteractionResult &cheatInteraction,
+		const CheatActivation &cheatActivation, bool &needsRedraw);
+	InputResult openRoomMenu(bool &needsRedraw);
 	void beginInspectText(const ObjectRecord &object, const ResolvedText &text);
 	InputResult finishInspectText(bool &needsRedraw);
 	InputResult applyObjectInteraction(InteractionResult &interaction, bool &needsRedraw);
diff --git a/engines/harvester/scene/input_inventory.cpp b/engines/harvester/scene/input_inventory.cpp
new file mode 100644
index 00000000000..522f7b15e81
--- /dev/null
+++ b/engines/harvester/scene/input_inventory.cpp
@@ -0,0 +1,273 @@
+/* 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/scene/input.h"
+
+#include "common/debug.h"
+#include "harvester/detection.h"
+#include "harvester/flow/flow.h"
+#include "harvester/harvester.h"
+#include "harvester/inventory.h"
+#include "harvester/player/player.h"
+#include "harvester/scene/interaction.h"
+#include "harvester/scene/room_support.h"
+#include "harvester/scene/scene.h"
+
+namespace Harvester {
+
+static const int kNativeInventoryDragCloseLeft = 0x45;
+static const int kNativeInventoryDragCloseTop = 0x4b;
+static const int kNativeInventoryDragCloseRight = 0x239;
+static const int kNativeInventoryDragCloseBottom = 0x1b4;
+static const uint32 kCombatLoadoutStatusDisplayTicks = 75;
+
+static bool isOutsideNativeInventoryDragCloseBounds(const Common::Point &point) {
+	return point.x < kNativeInventoryDragCloseLeft ||
+		point.x >= kNativeInventoryDragCloseRight ||
+		point.y < kNativeInventoryDragCloseTop ||
+		point.y >= kNativeInventoryDragCloseBottom;
+}
+
+static void debugLogInventoryClick(const char *buttonLabel, const Common::Point &point,
+		const InventoryVisual *inventoryHover) {
+	if (!inventoryHover) {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory %s click at (%d,%d) hit no item", buttonLabel, point.x, point.y);
+		return;
+	}
+
+	debugC(1, kDebugInventory,
+		"Harvester: inventory %s click at (%d,%d) object='%s' sprite='%s' alt='%s' action='%s' owner='%s' text='%s' bounds=(%d,%d)-(%d,%d) exit=%d status=%d",
+		buttonLabel, point.x, point.y, inventoryHover->object.objectName.c_str(),
+		inventoryHover->object.spritePath.c_str(), inventoryHover->object.altSpritePath.c_str(),
+		inventoryHover->object.actionTag.c_str(), inventoryHover->object.currentOwnerOrRoom.c_str(),
+		inventoryHover->object.inventoryTextKey.c_str(), inventoryHover->bounds.left,
+		inventoryHover->bounds.top, inventoryHover->bounds.right, inventoryHover->bounds.bottom,
+		InventorySystem::isExitObject(inventoryHover->object),
+		InventorySystem::isStatusObject(inventoryHover->object));
+}
+
+void Input::queueCombatLoadoutStatusMessage(const Common::String &message, bool &needsRedraw) {
+	if (message.empty()) {
+		const bool hadMessage = !_presentationState.combatLoadoutStatusText.value.empty();
+		_presentationState.combatLoadoutStatusText = ResolvedText();
+		_presentationState.combatLoadoutStatusHideTick = 0;
+		if (hadMessage)
+			needsRedraw = true;
+		return;
+	}
+
+	_presentationState.combatLoadoutStatusText.boxName = "BOX1";
+	_presentationState.combatLoadoutStatusText.value = message;
+	_presentationState.combatLoadoutStatusHideTick =
+		Player::getRuntimeClockTicks() + kCombatLoadoutStatusDisplayTicks;
+	needsRedraw = true;
+}
+
+bool Input::openInventoryOverlay() {
+	Player &player = _session.getPlayer();
+	InputState &inputState = _session.getInputState();
+
+	inputState.clearHeldActions();
+	_session.clearPendingRegionInteraction();
+	(void)_session.cancelPlayerAttackAnimation();
+	player.cancelMovement();
+	if (!_roomItems.stowCarriedRoomItemToInventory())
+		return false;
+	return _inventory.open();
+}
+
+void Input::handleMouseMove(bool &needsRedraw) {
+	if (_inventory.isOpen() && _inventory.hasSelection()) {
+		const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow.getMousePos());
+		// Native inventory handoff is driven by the latched selected item, not by
+		// a second click after the selection has already been made.
+		if (!inventoryHover && isOutsideNativeInventoryDragCloseBounds(_flow.getMousePos())) {
+			if (_inventory.close())
+				needsRedraw = true;
+		}
+	}
+	needsRedraw = true;
+}
+
+InputResult Input::handleInventoryLeftClick(bool &needsRedraw) {
+	const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
+	const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow.getMousePos());
+	debugLogInventoryClick("left", _flow.getMousePos(), inventoryHover);
+	if (inventoryHover) {
+		if (InventorySystem::isExitObject(inventoryHover->object)) {
+			debugC(1, kDebugInventory, "Harvester: inventory left click exit button");
+			const bool clearedSelection = _inventory.clearSelection();
+			const bool closedInventory = _inventory.close();
+			if (clearedSelection || closedInventory)
+				needsRedraw = true;
+			return InputResult();
+		}
+		if (InventorySystem::isStatusObject(inventoryHover->object))
+			return InputResult();
+
+		if (!_inventory.hasSelection()) {
+			debugC(1, kDebugInventory,
+				"Harvester: inventory left click selecting object='%s'",
+				inventoryHover->object.objectName.c_str());
+			_inventory.selectItem(inventoryHover->object.objectName);
+			_inventory.setPromptText(_inventory.buildSelectedPrompt(
+				Common::String(), _flow.getMenuTextConfig()));
+			needsRedraw = true;
+			return InputResult();
+		}
+
+		if (!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
+			debugC(1, kDebugInventory,
+				"Harvester: inventory left click using selected='%s' on target='%s'",
+				_inventory.getSelectedItemName().c_str(),
+				inventoryHover->object.objectName.c_str());
+			Common::Error interactionError =
+				_roomItems.handleSelectedItemTargetInteraction(inventoryHover->object, _interactionProcessor, needsRedraw);
+			if (interactionError.getCode() != Common::kNoError)
+				return InputResult::leaveRoom(interactionError);
+		}
+		needsRedraw = true;
+		return InputResult();
+	}
+
+	if (!inventoryPanelBounds.contains(_flow.getMousePos()) && _inventory.close())
+		needsRedraw = true;
+	return InputResult();
+}
+
+InputResult Input::handleInventoryRightClick(bool &needsRedraw) {
+	Player &player = _session.getPlayer();
+	const RoomPlayerState &playerState = player.getState();
+
+	const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_flow.getMousePos());
+	debugLogInventoryClick("right", _flow.getMousePos(), inventoryHover);
+	if (_inventory.hasSelection()) {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory right click cleared active selection='%s'",
+			_inventory.getSelectedItemName().c_str());
+		if (_inventory.clearSelection())
+			needsRedraw = true;
+		return InputResult();
+	}
+	if (inventoryHover &&
+			!InventorySystem::isExitObject(inventoryHover->object) &&
+			!InventorySystem::isStatusObject(inventoryHover->object)) {
+		Script *script = _engine.getScript();
+		const int previousLoadout = playerState.combatLoadout >= 0
+			? playerState.combatLoadout
+			: (script ? script->getPlayerCombatLoadout() : 0);
+		bool loadoutChanged = false;
+		if (_inventory.toggleCombatLoadout(inventoryHover->object,
+				playerState.combatLoadout, loadoutChanged)) {
+			debugC(1, kDebugInventory,
+				"Harvester: inventory right click handled as combat toggle object='%s' changed=%d",
+				inventoryHover->object.objectName.c_str(), loadoutChanged);
+			if (loadoutChanged) {
+				if (script) {
+					const Common::String statusMessage = buildCombatLoadoutStatusMessage(
+						*script, inventoryHover->object, previousLoadout,
+						script->getPlayerCombatLoadout());
+					queueCombatLoadoutStatusMessage(statusMessage, needsRedraw);
+					_presentationState.closeInventoryAfterCombatLoadoutStatus = true;
+				}
+				_session.captureCurrentSaveState();
+				player.syncCombatLoadout(_engine, _scene.state);
+				if (!_inventory.refresh())
+					return InputResult::leaveRoom(Common::kReadingFailed);
+			}
+			needsRedraw = true;
+			return InputResult();
+		}
+
+		InventorySecondaryAction secondaryAction;
+		if (_inventory.resolveSecondaryAction(inventoryHover->object, secondaryAction))
+			return applyInventorySecondaryAction(inventoryHover->object, secondaryAction, needsRedraw);
+		debugC(1, kDebugInventory,
+			"Harvester: inventory right click object='%s' produced no combat toggle or secondary action",
+			inventoryHover->object.objectName.c_str());
+	}
+	debugC(1, kDebugInventory, "Harvester: inventory right click closing overlay");
+	if (_inventory.close())
+		needsRedraw = true;
+	return InputResult();
+}
+
+InputResult Input::applyInventorySecondaryAction(const ObjectRecord &object,
+		const InventorySecondaryAction &secondaryAction, bool &needsRedraw) {
+	debugC(1, kDebugInventory,
+		"Harvester: inventory right click executing secondary action object='%s' action='%s' closeInventory=%d",
+		object.objectName.c_str(),
+		secondaryAction.actionTag.c_str(), secondaryAction.closeInventory);
+	if (secondaryAction.closeInventory)
+		(void)_inventory.close();
+
+	InteractionResult interaction;
+	bool didTransition = false;
+	const bool executedActionTag = _engine.getScript()->executeActionTag(
+			secondaryAction.actionTag, interaction, true, _scene.executionContext());
+	debugC(1, kDebugInventory,
+		"Harvester: inventory right click executeActionTag('%s') -> %d music='%s' cutscene='%s' nextRoom='%s'",
+		secondaryAction.actionTag.c_str(), executedActionTag,
+		interaction.musicPath.c_str(), interaction.cutscenePath.c_str(),
+		interaction.nextRoomName.c_str());
+	if (executedActionTag) {
+		Common::Error interactionError =
+			_interactionProcessor.handleInteractionResult(
+				interaction, didTransition, Common::String());
+		debugC(1, kDebugInventory,
+			"Harvester: inventory right click action='%s' interaction result error=%d transitioned=%d pendingMainMenu=%d",
+			secondaryAction.actionTag.c_str(), interactionError.getCode(),
+			didTransition, _flow.hasPendingMainMenuReturn());
+		if (interactionError.getCode() != Common::kNoError)
+			return InputResult::leaveRoom(interactionError);
+		if (_flow.hasPendingMainMenuReturn())
+			return InputResult::leaveRoom(Common::kNoError);
+	}
+
+	if (_inventory.isOpen()) {
+		if (!_inventory.refresh())
+			return InputResult::leaveRoom(Common::kReadingFailed);
+	} else {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory right click skipped overlay refresh after action='%s' transitioned=%d closeInventory=%d",
+			secondaryAction.actionTag.c_str(), didTransition, secondaryAction.closeInventory);
+	}
+	needsRedraw = true;
+	return InputResult();
+}
+
+InputResult Input::handleInventoryKeyDown(const Common::KeyState &key, bool &needsRedraw) {
+	if (key.keycode == Common::KEYCODE_ESCAPE) {
+		const bool clearedSelection = _inventory.clearSelection();
+		const bool closedInventory = _inventory.close();
+		if (clearedSelection || closedInventory)
+			needsRedraw = true;
+	} else if (key.keycode == Common::KEYCODE_RETURN ||
+			key.keycode == Common::KEYCODE_KP_ENTER ||
+			key.keycode == Common::KEYCODE_i) {
+		if (_inventory.close())
+			needsRedraw = true;
+	}
+	return InputResult();
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/scene/input_keyboard.cpp b/engines/harvester/scene/input_keyboard.cpp
new file mode 100644
index 00000000000..0c2fb74ccde
--- /dev/null
+++ b/engines/harvester/scene/input_keyboard.cpp
@@ -0,0 +1,267 @@
+/* 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/scene/input.h"
+
+#include "graphics/screen.h"
+#include "harvester/cheats.h"
+#include "harvester/flow/flow.h"
+#include "harvester/harvester.h"
+#include "harvester/inventory.h"
+#include "harvester/player/player.h"
+#include "harvester/scene/interaction.h"
+#include "harvester/scene/room_presentation.h"
+#include "harvester/scene/room_support.h"
+#include "harvester/scene/scene.h"
+
+namespace Harvester {
+
+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,
+		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)
+			_inspectDismissPressed = true;
+		return InputResult();
+	}
+
+	_session.notePlayerActivity();
+	if (idleState.active || idleState.exiting) {
+		if (player.requestIdleAnimationExit(_scene.state))
+			needsRedraw = true;
+		return InputResult();
+	}
+
+	if (Script *script = _engine.getScript()) {
+		InteractionResult cheatInteraction;
+		CheatActivation cheatActivation;
+		const CheatInputResult cheatInputResult = _cheats.processKey(
+			key, *script, _scene.executionContext(),
+			cheatInteraction, cheatActivation);
+		if (cheatInputResult != kCheatInputIgnored) {
+			if (cheatInputResult == kCheatInputComplete && cheatActivation.activated)
+				return applyCheatActivation(cheatInteraction, cheatActivation, needsRedraw);
+			return InputResult();
+		}
+	}
+
+	if (_inventory.isOpen())
+		return handleInventoryKeyDown(key, needsRedraw);
+
+	if (_inventory.hasSelection()) {
+		if (key.keycode == Common::KEYCODE_ESCAPE) {
+			if (_inventory.clearSelection())
+				needsRedraw = true;
+			return InputResult();
+		}
+		if (key.keycode == Common::KEYCODE_i) {
+			if (_inventory.clearSelection())
+				needsRedraw = true;
+			if (!openInventoryOverlay())
+				return InputResult::leaveRoom(Common::kReadingFailed);
+			needsRedraw = true;
+			return InputResult();
+		}
+	}
+	return handleGameplayKeyDown(key, needsRedraw, gameAction);
+}
+
+InputResult Input::handleGameplayKeyDown(const Common::KeyState &key,
+		bool &needsRedraw, bool gameAction) {
+	InputState &inputState = _session.getInputState();
+
+	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_k && _engine.isCombatDebugEnabled()) {
+		Common::Error debugKillError = _session.killDebugActiveMonster(_flow.getMousePos(), needsRedraw);
+		if (debugKillError.getCode() != Common::kNoError)
+			return InputResult::leaveRoom(debugKillError);
+		return InputResult();
+	} else if (gameAction && key.keycode == Common::KEYCODE_i) {
+		if (!openInventoryOverlay())
+			return InputResult::leaveRoom(Common::kReadingFailed);
+		needsRedraw = true;
+		return InputResult();
+	} else if (gameAction && key.keycode == Common::KEYCODE_ESCAPE) {
+		return openRoomMenu(needsRedraw);
+	}
+
+	if (key.keycode == Common::KEYCODE_RETURN ||
+		key.keycode == Common::KEYCODE_KP_ENTER) {
+		if (!_roomItems.stowCarriedRoomItemToInventory())
+			return InputResult::leaveRoom(Common::kReadingFailed);
+		Common::Error exitError = _session.runRoomExitCommands();
+		if (exitError.getCode() != Common::kNoError)
+			return InputResult::leaveRoom(exitError);
+		return InputResult::leaveRoom(Common::kNoError);
+	}
+	return InputResult();
+}
+
+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)
+		inputState.moveRight = false;
+	else if (key.keycode == Common::KEYCODE_UP)
+		inputState.moveUp = false;
+	else if (key.keycode == Common::KEYCODE_DOWN)
+		inputState.moveDown = false;
+}
+
+InputResult Input::applyCheatActivation(InteractionResult &cheatInteraction,
+		const CheatActivation &cheatActivation, bool &needsRedraw) {
+	if (cheatActivation.clearInteractionState && _inventory.clearSelection())
+		needsRedraw = true;
+	if (cheatActivation.inventoryChanged && _inventory.isOpen() &&
+			!_inventory.refresh()) {
+		return InputResult::leaveRoom(Common::kReadingFailed);
+	}
+	if (cheatActivation.hasInteraction) {
+		if (_inventory.isOpen())
+			(void)_inventory.close();
+		bool didTransition = false;
+		Common::Error cheatError = _interactionProcessor.handleInteractionResult(
+			cheatInteraction, didTransition, Common::String());
+		if (cheatError.getCode() != Common::kNoError)
+			return InputResult::leaveRoom(cheatError);
+		if (_flow.hasPendingMainMenuReturn())
+			return InputResult::leaveRoom(Common::kNoError);
+	}
+	needsRedraw = true;
+	return InputResult();
+}
+
+InputResult Input::openRoomMenu(bool &needsRedraw) {
+	Player &player = _session.getPlayer();
+	InputState &inputState = _session.getInputState();
+
+	if (_session.cancelPlayerAttackAnimation())
+		needsRedraw = true;
+	if (!_roomItems.stowCarriedRoomItemToInventory())
+		return InputResult::leaveRoom(Common::kReadingFailed);
+	inputState.clearHeldActions();
+	_session.clearPendingRegionInteraction();
+	player.cancelMovement();
+	_session.captureCurrentSaveState();
+	Graphics::Screen *activeScreen = _engine.getScreen();
+	if (!activeScreen)
+		return InputResult::leaveRoom(Common::kNoError);
+	IndexedBitmap roomMenuBackdrop;
+	RoomPresentation::drawRoomScene(_engine, *activeScreen, _scene, _scene.targetPaletteBrightness);
+	if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
+		return InputResult::leaveRoom(Common::kReadingFailed);
+	Common::Error menuError = _flow.runRoomMenu(
+		roomMenuBackdrop, _scene.palette, _scene.targetPaletteBrightness,
+		_scene.state.hasEntrance);
+	if (menuError.getCode() != Common::kNoError)
+		return InputResult::leaveRoom(menuError);
+	_flow.resetCursorAnimationSequence();
+	_session.resetIdleState();
+	needsRedraw = true;
+	return InputResult();
+}
+
+} // End of namespace Harvester


Commit: 762eaeed53ea8aadaf35e6c93e074d8413128fd3
    https://github.com/scummvm/scummvm/commit/762eaeed53ea8aadaf35e6c93e074d8413128fd3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Extract Sergeant dialogue branches into methods

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.h


diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index e84889529c5..6c4afa21c31 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -50,515 +50,535 @@ static const int kSergeantApplicationResponseLine = 0x2b8;
 static const int kSergeantAmnesiaResponseLine = 0x2b9;
 static const int kSergeantReminderResponseLine = 0x2bb;
 
-} // End of namespace
+static void resetAndSetObjectVisible(DialogueRuntime &runtime, const char *ownerOrRoom,
+		const char *objectName, bool visible) {
+	(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
+	(void)runtime.startupScript().setRuntimeObjectVisible(ownerOrRoom, objectName, visible);
+}
 
-Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &sharedState) {
-	SergeantRoomDialogueState &state = _state;
+static void returnItemToRah(DialogueRuntime &runtime, const char *objectName, bool visible) {
+	resetAndSetObjectVisible(runtime, kRahRoomName, objectName, visible);
+}
 
-	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<ObjectRecord> inventoryObjects;
-		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const ObjectRecord &inventoryObject : inventoryObjects) {
-			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
-				return true;
-		}
+static Common::Error executeActionTagIfSet(DialogueRuntime &runtime, const char *actionTag) {
+	runtime.executeAndQueueAction(actionTag);
+	return Common::kNoError;
+}
 
-		return false;
-	};
-	auto resetAndSetObjectVisible = [&](const char *ownerOrRoom, const char *objectName, bool visible) {
-		(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
-		(void)runtime.startupScript().setRuntimeObjectVisible(ownerOrRoom, objectName, visible);
-	};
-	auto returnItemToRah = [&](const char *objectName, bool visible) {
-		resetAndSetObjectVisible(kRahRoomName, objectName, visible);
-	};
-	auto executeActionTagIfSet = [&](const char *actionTag) -> Common::Error {
-		runtime.executeAndQueueAction(actionTag);
+static void queueNpcSlashTransition(DialogueRuntime &runtime, const char *npcName) {
+	InteractionResult interaction;
+	if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
+			npcName, kSlashDeathDamageType)) {
+		interaction.mutatedRuntimeState = true;
+	}
+	runtime.queueDialogueInteractionIfNeeded(interaction);
+}
+
+} // End of namespace
+
+Common::Error SergeantDialogueHandler::runDay5ExitIfNeeded(DialogueRuntime &runtime) {
+	if (!runtime.startupScript().getFlagValue("DAY_5") || _state.day5ExitLinePlayed)
 		return Common::kNoError;
+
+	_state.day5ExitLinePlayed = true;
+	return runtime.playDialogueLineWithVariant(0x4324, "SERGEANT", 1);
+}
+
+Common::Error SergeantDialogueHandler::handleRemainsBranch(DialogueRuntime &runtime,
+		DialogueSharedState &sharedState) {
+	sharedState.dialogueStateD2f08 = true;
+	sharedState.dialogueStateD2f00 = true;
+	_state.talkStateBlock = true;
+	_state.dialogueStateD2d50 = true;
+	_state.postApplicationBriefingCompleted = true;
+	_state.dialogueStateD2d58 = true;
+	_state.dialogueStateD2d5c = true;
+	_state.dialogueStateD2d60 = true;
+	_state.dialogueStateD2d64 = true;
+	_state.dialogueStateD2d68 = true;
+	_state.dialogueStateD2d6c = true;
+	returnItemToRah(runtime, "REMAINS", false);
+
+	Common::Error lineError = runtime.playDialogueLineWithVariant(0x4333, "SERGEANT", 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	lineError = runtime.playDialogueFst(kDialogueC150FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	const DialogueLineEntry remainsIntroLines[] = {
+		{ 0x433f, "SERGEANT", 0 },
+		{ 0x4344, "PC", 2 },
+		{ 0x4348, "SERGEANT", 0 },
+		{ 0x434f, "PC", 0 },
+		{ 0x4353, "SERGEANT", 1 }
 	};
-	auto queueNpcSlashTransition = [&](const char *npcName) {
-		InteractionResult interaction;
-		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
-				npcName, kSlashDeathDamageType)) {
-			interaction.mutatedRuntimeState = true;
-		}
-		runtime.queueDialogueInteractionIfNeeded(interaction);
+	lineError = runtime.playDialogueEntrySequence(remainsIntroLines, ARRAYSIZE(remainsIntroLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kSergeantRemainsResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x435f, "SERGEANT", 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x4364, "SERGEANT", 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	const DialogueLineEntry remainsExitLines[] = {
+		{ 0x4368, "SERGEANT", 0 },
+		{ 0x4372, "PC", 2 },
+		{ 0x4376, "SERGEANT", 0 },
+		{ 0x4381, "PC", 2 },
+		{ 0x4385, "SERGEANT", 0 }
 	};
-	auto runDay5ExitIfNeeded = [&]() -> Common::Error {
-		if (!runtime.startupScript().getFlagValue("DAY_5") || state.day5ExitLinePlayed)
-			return Common::kNoError;
+	lineError = runtime.playDialogueEntrySequence(remainsExitLines, ARRAYSIZE(remainsExitLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = runtime.clearScreenToBlack();
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	lineError = runtime.playDialogueFst(kDialogueC076FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	lineError = runtime.showCdChangePrompt(3);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	// Native prompt_for_cdrom_disc_change(3, 0) latches a main-loop room rebuild.
+	InteractionResult interaction;
+	interaction.requestRoomRestart = true;
+	interaction.roomRestartTargetName = "START";
+	runtime.queueDialogueInteractionIfNeeded(interaction);
+	return Common::kNoError;
+}
 
-		state.day5ExitLinePlayed = true;
-		return runtime.playDialogueLineWithVariant(0x4324, "SERGEANT", 1);
+Common::Error SergeantDialogueHandler::handleInviteBranch(DialogueRuntime &runtime,
+		DialogueSharedState &sharedState) {
+	sharedState.dialogueStateD2f08 = true;
+	sharedState.dialogueStateD2f00 = true;
+	_state.talkStateBlock = true;
+	_state.dialogueStateD2d50 = true;
+	_state.postApplicationBriefingCompleted = true;
+	_state.dialogueStateD2d58 = true;
+	_state.dialogueStateD2d5c = true;
+	_state.dialogueStateD2d60 = true;
+	_state.dialogueStateD2d64 = true;
+	_state.dialogueStateD2d68 = true;
+	resetAndSetObjectVisible(runtime, "ST_BEDRM", "STEFSKULL", true);
+	returnItemToRah(runtime, "INVITE", false);
+	(void)runtime.startupScript().setRuntimeFlagValue("TAKEN_INVITE_TO_SERGEANT", true);
+	(void)runtime.startupScript().setRuntimeFlagValue("NEED_REMAINS_FOR_LODGE", true);
+	(void)runtime.startupScript().setRuntimeNpcState("DWAYNE_ST_BEDRM", false, false);
+	(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS_ST_BEDRM", false, false);
+	(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS", true, true);
+
+	Common::Error lineError = runtime.playDialogueFst(kDialogueC149FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	const DialogueLineEntry inviteLines[] = {
+		{ 0x4305, "PC", 0 },
+		{ 0x4309, "SERGEANT", 2 }
 	};
-	auto handleRemainsBranch = [&]() -> Common::Error {
-		sharedState.dialogueStateD2f08 = true;
-		sharedState.dialogueStateD2f00 = true;
-		state.talkStateBlock = true;
-		state.dialogueStateD2d50 = true;
-		state.postApplicationBriefingCompleted = true;
-		state.dialogueStateD2d58 = true;
-		state.dialogueStateD2d5c = true;
-		state.dialogueStateD2d60 = true;
-		state.dialogueStateD2d64 = true;
-		state.dialogueStateD2d68 = true;
-		state.dialogueStateD2d6c = true;
-		returnItemToRah("REMAINS", false);
-
-		Common::Error lineError = runtime.playDialogueLineWithVariant(0x4333, "SERGEANT", 1);
+	lineError = runtime.playDialogueEntrySequence(inviteLines, ARRAYSIZE(inviteLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kSergeantInviteResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x4314, "SERGEANT", 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		lineError = runtime.playDialogueFst(kDialogueC150FstPath);
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x4319, "SERGEANT", 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		const DialogueLineEntry remainsIntroLines[] = {
-			{ 0x433f, "SERGEANT", 0 },
-			{ 0x4344, "PC", 2 },
-			{ 0x4348, "SERGEANT", 0 },
-			{ 0x434f, "PC", 0 },
-			{ 0x4353, "SERGEANT", 1 }
-		};
-		lineError = runtime.playDialogueEntrySequence(remainsIntroLines, ARRAYSIZE(remainsIntroLines));
+	}
+
+	lineError = runtime.playDialogueLineWithVariant(0x431d, "SERGEANT", 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	return runDay5ExitIfNeeded(runtime);
+}
+
+Common::Error SergeantDialogueHandler::handleBarberPoleBranch(DialogueRuntime &runtime,
+		DialogueSharedState &sharedState) {
+	sharedState.dialogueStateD2f08 = true;
+	sharedState.dialogueStateD2f00 = true;
+	_state.talkStateBlock = true;
+	_state.dialogueStateD2d50 = true;
+	_state.postApplicationBriefingCompleted = true;
+	_state.dialogueStateD2d58 = true;
+	_state.dialogueStateD2d5c = true;
+	_state.dialogueStateD2d60 = true;
+	returnItemToRah(runtime, "BARBER_POLE", false);
+	(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_DNA_TASK", true);
+
+	Common::Error lineError = runtime.playDialogueLineWithVariant(0x426f, "SERGEANT", 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	lineError = runtime.playDialogueLineWithVariant(0x4274, "SERGEANT", 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kSergeantBarberPoleResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x4281, "SERGEANT", 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x4289, "SERGEANT", 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantRemainsResponseLine, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLineWithVariant(0x435f, "SERGEANT", 3);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		} else if (responseIndex == 2) {
-			lineError = runtime.playDialogueLineWithVariant(0x4364, "SERGEANT", 0);
+	lineError = runtime.playDialogueLineWithVariant(0x428d, "SERGEANT", 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	lineError = runtime.playDialogueLineWithVariant(0x4299, "SERGEANT", 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(
+		kSergeantBarberPoleFollowupResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x42a4, "SERGEANT", 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.startupScript().getFlagValue("PC_KILLED_ANYONE")) {
+			lineError = runtime.playDialogueLineWithVariant(0x42a8, "SERGEANT", 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-
-		const DialogueLineEntry remainsExitLines[] = {
-			{ 0x4368, "SERGEANT", 0 },
-			{ 0x4372, "PC", 2 },
-			{ 0x4376, "SERGEANT", 0 },
-			{ 0x4381, "PC", 2 },
-			{ 0x4385, "SERGEANT", 0 }
-		};
-		lineError = runtime.playDialogueEntrySequence(remainsExitLines, ARRAYSIZE(remainsExitLines));
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x42ad, "SERGEANT", 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+	}
 
-		lineError = runtime.clearScreenToBlack();
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = runtime.playDialogueFst(kDialogueC076FstPath);
+	return runDay5ExitIfNeeded(runtime);
+}
+
+Common::Error SergeantDialogueHandler::handleBoltClothBranch(DialogueRuntime &runtime,
+		DialogueSharedState &sharedState) {
+	sharedState.dialogueStateD2f08 = true;
+	sharedState.dialogueStateD2f00 = true;
+	_state.talkStateBlock = true;
+	_state.dialogueStateD2d50 = true;
+	_state.postApplicationBriefingCompleted = true;
+	_state.dialogueStateD2d58 = true;
+	_state.dialogueStateD2d5c = true;
+
+	const DialogueLineEntry boltClothLines[] = {
+		{ 0x4241, "SERGEANT", 1 },
+		{ 0x4245, "SERGEANT", 1 },
+		{ 0x4252, "SERGEANT", 0 }
+	};
+	Common::Error lineError = runtime.playDialogueEntrySequence(boltClothLines, ARRAYSIZE(boltClothLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kSergeantBoltClothResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x425e, "SERGEANT", 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		lineError = runtime.showCdChangePrompt(3);
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x4266, "SERGEANT", 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+	}
 
-		// Native prompt_for_cdrom_disc_change(3, 0) latches a main-loop room rebuild.
-		InteractionResult interaction;
-		interaction.requestRoomRestart = true;
-		interaction.roomRestartTargetName = "START";
-		runtime.queueDialogueInteractionIfNeeded(interaction);
-		return Common::kNoError;
-	};
-	auto handleInviteBranch = [&]() -> Common::Error {
-		sharedState.dialogueStateD2f08 = true;
-		sharedState.dialogueStateD2f00 = true;
-		state.talkStateBlock = true;
-		state.dialogueStateD2d50 = true;
-		state.postApplicationBriefingCompleted = true;
-		state.dialogueStateD2d58 = true;
-		state.dialogueStateD2d5c = true;
-		state.dialogueStateD2d60 = true;
-		state.dialogueStateD2d64 = true;
-		state.dialogueStateD2d68 = true;
-		resetAndSetObjectVisible("ST_BEDRM", "STEFSKULL", true);
-		returnItemToRah("INVITE", false);
-		(void)runtime.startupScript().setRuntimeFlagValue("TAKEN_INVITE_TO_SERGEANT", true);
-		(void)runtime.startupScript().setRuntimeFlagValue("NEED_REMAINS_FOR_LODGE", true);
-		(void)runtime.startupScript().setRuntimeNpcState("DWAYNE_ST_BEDRM", false, false);
-		(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS_ST_BEDRM", false, false);
-		(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS", true, true);
-
-		Common::Error lineError = runtime.playDialogueFst(kDialogueC149FstPath);
+	returnItemToRah(runtime, "BOLTCLTH", false);
+	(void)runtime.startupScript().setRuntimeFlagValue("FIREMEN_SUICIDE", true);
+	(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_POLE_TASK", true);
+	queueNpcSlashTransition(runtime, "SPARKY");
+	queueNpcSlashTransition(runtime, "NUDE_MAN");
+	queueNpcSlashTransition(runtime, "FIREMAN2");
+	return runDay5ExitIfNeeded(runtime);
+}
+
+Common::Error SergeantDialogueHandler::handleCompletedFirstTaskBranch(DialogueRuntime &runtime,
+		DialogueSharedState &sharedState) {
+	sharedState.dialogueStateD2f08 = true;
+	sharedState.dialogueStateD2f00 = true;
+	sharedState.sergeantCompletedFirstTaskState = 1;
+	_state.talkStateBlock = true;
+	_state.dialogueStateD2d50 = true;
+	_state.postApplicationBriefingCompleted = true;
+	_state.completedFirstTaskState = true;
+
+	Common::Error lineError = runtime.playDialogueLineWithVariant(0x420d, "SERGEANT", 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	lineError = runtime.playDialogueLineWithVariant(0x421e, "SERGEANT", 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kSergeantSecondTaskResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x422a, "SERGEANT", 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		const DialogueLineEntry inviteLines[] = {
-			{ 0x4305, "PC", 0 },
-			{ 0x4309, "SERGEANT", 2 }
+	} else if (responseIndex == 2) {
+		const DialogueLineEntry reluctantTaskLines[] = {
+			{ 0x422f, "SERGEANT", 2 },
+			{ 0x4239, "SERGEANT", 3 }
 		};
-		lineError = runtime.playDialogueEntrySequence(inviteLines, ARRAYSIZE(inviteLines));
+		lineError = runtime.playDialogueEntrySequence(reluctantTaskLines, ARRAYSIZE(reluctantTaskLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+	}
 
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantInviteResponseLine, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLineWithVariant(0x4314, "SERGEANT", 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		} else if (responseIndex == 2) {
-			lineError = runtime.playDialogueLineWithVariant(0x4319, "SERGEANT", 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
+	if (!_state.dialogueStateD2d58) {
+		_state.dialogueStateD2d58 = true;
+		Common::Error actionError = executeActionTagIfSet(runtime, kGoDay3ActionTag);
+		if (actionError.getCode() != Common::kNoError)
+			return actionError;
+	}
+	return runDay5ExitIfNeeded(runtime);
+}
 
-		lineError = runtime.playDialogueLineWithVariant(0x431d, "SERGEANT", 0);
+Common::Error SergeantDialogueHandler::handleCompletedApplicationBranch(DialogueRuntime &runtime,
+		DialogueSharedState &sharedState) {
+	sharedState.dialogueStateD2f08 = true;
+	sharedState.dialogueStateD2f00 = true;
+	_state.talkStateBlock = true;
+	_state.dialogueStateD2d50 = true;
+	returnItemToRah(runtime, "COMPLETED_LODGE_APPLICATION", false);
+	if (!runtime.startupScript().getFlagValue("QUEST_1")) {
+		Common::Error lineError = runtime.playDialogueLineWithVariant(0x41cd, "SERGEANT", 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return runDay5ExitIfNeeded();
-	};
-	auto handleBarberPoleBranch = [&]() -> Common::Error {
-		sharedState.dialogueStateD2f08 = true;
-		sharedState.dialogueStateD2f00 = true;
-		state.talkStateBlock = true;
-		state.dialogueStateD2d50 = true;
-		state.postApplicationBriefingCompleted = true;
-		state.dialogueStateD2d58 = true;
-		state.dialogueStateD2d5c = true;
-		state.dialogueStateD2d60 = true;
-		returnItemToRah("BARBER_POLE", false);
-		(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_DNA_TASK", true);
-
-		Common::Error lineError = runtime.playDialogueLineWithVariant(0x426f, "SERGEANT", 1);
+		lineError = runtime.playDialogueLineWithVariant(0x4292, "SERGEANT", 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		lineError = runtime.playDialogueLineWithVariant(0x4274, "SERGEANT", 0);
+		(void)runtime.startupScript().setRuntimeFlagValue("QUEST_1", true);
+		return executeActionTagIfSet(runtime, kGoDay2ActionTag);
+	}
+	if (!_state.postApplicationBriefingCompleted) {
+		Common::Error lineError = runtime.playDialogueLineWithVariant(0x41d2, "SERGEANT", 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
 		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantBarberPoleResponseLine, responseIndex);
+			kSergeantCompletedApplicationResponseLine, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLineWithVariant(0x4281, "SERGEANT", 0);
+			const DialogueLineEntry acceptedTaskLines[] = {
+				{ 0x41db, "SERGEANT", 0 },
+				{ 0x41e2, "PC", 0 },
+				{ 0x41e6, "SERGEANT", 0 },
+				{ 0x41ec, "PC", 2 },
+				{ 0x41f0, "SERGEANT", 0 },
+				{ 0x41f6, "PC", 0 },
+				{ 0x41fa, "SERGEANT", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(
+				acceptedTaskLines, ARRAYSIZE(acceptedTaskLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			_state.postApplicationBriefingCompleted = true;
 		} else if (responseIndex == 2) {
-			lineError = runtime.playDialogueLineWithVariant(0x4289, "SERGEANT", 0);
+			const DialogueLineEntry declinedTaskLines[] = {
+				{ 0x4201, "SERGEANT", 3 },
+				{ 0x4202, "SERGEANT", 3 },
+				{ 0x4203, "SERGEANT", 2 }
+			};
+			lineError = runtime.playDialogueEntrySequence(
+				declinedTaskLines, ARRAYSIZE(declinedTaskLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			_state.postApplicationBriefingCompleted = false;
 		}
 
-		lineError = runtime.playDialogueLineWithVariant(0x428d, "SERGEANT", 0);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = runtime.playDialogueLineWithVariant(0x4299, "SERGEANT", 0);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		responseIndex = 0;
-		responseError = runtime.runResponseMenu(
-			kSergeantBarberPoleFollowupResponseLine, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLineWithVariant(0x42a4, "SERGEANT", 3);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			if (runtime.startupScript().getFlagValue("PC_KILLED_ANYONE")) {
-				lineError = runtime.playDialogueLineWithVariant(0x42a8, "SERGEANT", 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-		} else if (responseIndex == 2) {
-			lineError = runtime.playDialogueLineWithVariant(0x42ad, "SERGEANT", 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
+		return runDay5ExitIfNeeded(runtime);
+	}
 
-		return runDay5ExitIfNeeded();
+	const DialogueLineEntry repeatTaskLines[] = {
+		{ 0x41f0, "SERGEANT", 0 },
+		{ 0x41f6, "PC", 0 },
+		{ 0x41fa, "SERGEANT", 0 }
 	};
-	auto handleBoltClothBranch = [&]() -> Common::Error {
+	Common::Error lineError = runtime.playDialogueEntrySequence(
+		repeatTaskLines, ARRAYSIZE(repeatTaskLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	return runDay5ExitIfNeeded(runtime);
+}
+
+Common::Error SergeantDialogueHandler::runIntroOrReminderBranch(DialogueRuntime &runtime,
+		DialogueSharedState &sharedState) {
+	if (!_state.talkStateBlock) {
 		sharedState.dialogueStateD2f08 = true;
-		sharedState.dialogueStateD2f00 = true;
-		state.talkStateBlock = true;
-		state.dialogueStateD2d50 = true;
-		state.postApplicationBriefingCompleted = true;
-		state.dialogueStateD2d58 = true;
-		state.dialogueStateD2d5c = true;
-
-		const DialogueLineEntry boltClothLines[] = {
-			{ 0x4241, "SERGEANT", 1 },
-			{ 0x4245, "SERGEANT", 1 },
-			{ 0x4252, "SERGEANT", 0 }
+		_state.talkStateBlock = true;
+
+		const DialogueLineEntry introLines[] = {
+			{ 0x413f, "SERGEANT", 2 },
+			{ 0x4144, "PC", 0 },
+			{ 0x4145, "SERGEANT", 0 },
+			{ 0x4146, "PC", 0 },
+			{ 0x4148, "SERGEANT", 2 }
 		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(boltClothLines, ARRAYSIZE(boltClothLines));
+		Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
 		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantBoltClothResponseLine, responseIndex);
+			kSergeantIntroResponseLine, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLineWithVariant(0x425e, "SERGEANT", 1);
+			lineError = runtime.playDialogueLineWithVariant(0x4153, "SERGEANT", 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		} else if (responseIndex == 2) {
-			lineError = runtime.playDialogueLineWithVariant(0x4266, "SERGEANT", 1);
+			lineError = runtime.playDialogueLineWithVariant(0x4157, "SERGEANT", 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
-		returnItemToRah("BOLTCLTH", false);
-		(void)runtime.startupScript().setRuntimeFlagValue("FIREMEN_SUICIDE", true);
-		(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_POLE_TASK", true);
-		queueNpcSlashTransition("SPARKY");
-		queueNpcSlashTransition("NUDE_MAN");
-		queueNpcSlashTransition("FIREMAN2");
-		return runDay5ExitIfNeeded();
-	};
-	auto handleCompletedFirstTaskBranch = [&]() -> Common::Error {
-		sharedState.dialogueStateD2f08 = true;
-		sharedState.dialogueStateD2f00 = true;
-		sharedState.sergeantCompletedFirstTaskState = 1;
-		state.talkStateBlock = true;
-		state.dialogueStateD2d50 = true;
-		state.postApplicationBriefingCompleted = true;
-		state.completedFirstTaskState = true;
-
-		Common::Error lineError = runtime.playDialogueLineWithVariant(0x420d, "SERGEANT", 1);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = runtime.playDialogueLineWithVariant(0x421e, "SERGEANT", 0);
+		lineError = runtime.playDialogueLineWithVariant(0x415d, "SERGEANT", 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantSecondTaskResponseLine, responseIndex);
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(
+			kSergeantApplicationResponseLine, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLineWithVariant(0x422a, "SERGEANT", 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		} else if (responseIndex == 2) {
-			const DialogueLineEntry reluctantTaskLines[] = {
-				{ 0x422f, "SERGEANT", 2 },
-				{ 0x4239, "SERGEANT", 3 }
+			const DialogueLineEntry applicationLines[] = {
+				{ 0x4168, "SERGEANT", 1 },
+				{ 0x416c, "PC", 0 },
+				{ 0x4170, "SERGEANT", 1 }
 			};
-			lineError = runtime.playDialogueEntrySequence(reluctantTaskLines, ARRAYSIZE(reluctantTaskLines));
+			lineError = runtime.playDialogueEntrySequence(applicationLines, ARRAYSIZE(applicationLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-		}
 
-		if (!state.dialogueStateD2d58) {
-			state.dialogueStateD2d58 = true;
-			Common::Error actionError = executeActionTagIfSet(kGoDay3ActionTag);
-			if (actionError.getCode() != Common::kNoError)
-				return actionError;
-		}
-		return runDay5ExitIfNeeded();
-	};
-	auto handleCompletedApplicationBranch = [&]() -> Common::Error {
-		sharedState.dialogueStateD2f08 = true;
-		sharedState.dialogueStateD2f00 = true;
-		state.talkStateBlock = true;
-		state.dialogueStateD2d50 = true;
-		returnItemToRah("COMPLETED_LODGE_APPLICATION", false);
-		if (!runtime.startupScript().getFlagValue("QUEST_1")) {
-			Common::Error lineError = runtime.playDialogueLineWithVariant(0x41cd, "SERGEANT", 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = runtime.playDialogueLineWithVariant(0x4292, "SERGEANT", 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			(void)runtime.startupScript().setRuntimeFlagValue("QUEST_1", true);
-			return executeActionTagIfSet(kGoDay2ActionTag);
-		}
-		if (!state.postApplicationBriefingCompleted) {
-			Common::Error lineError = runtime.playDialogueLineWithVariant(0x41d2, "SERGEANT", 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(
-				kSergeantCompletedApplicationResponseLine, responseIndex);
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(
+				kSergeantAmnesiaResponseLine, responseIndex);
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 
 			if (responseIndex == 1) {
-				const DialogueLineEntry acceptedTaskLines[] = {
-					{ 0x41db, "SERGEANT", 0 },
-					{ 0x41e2, "PC", 0 },
-					{ 0x41e6, "SERGEANT", 0 },
-					{ 0x41ec, "PC", 2 },
-					{ 0x41f0, "SERGEANT", 0 },
-					{ 0x41f6, "PC", 0 },
-					{ 0x41fa, "SERGEANT", 0 }
-				};
-				lineError = runtime.playDialogueEntrySequence(
-					acceptedTaskLines, ARRAYSIZE(acceptedTaskLines));
+				lineError = runtime.playDialogueLineWithVariant(0x417c, "SERGEANT", 0);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				state.postApplicationBriefingCompleted = true;
 			} else if (responseIndex == 2) {
-				const DialogueLineEntry declinedTaskLines[] = {
-					{ 0x4201, "SERGEANT", 3 },
-					{ 0x4202, "SERGEANT", 3 },
-					{ 0x4203, "SERGEANT", 2 }
-				};
-				lineError = runtime.playDialogueEntrySequence(
-					declinedTaskLines, ARRAYSIZE(declinedTaskLines));
+				lineError = runtime.playDialogueLineWithVariant(0x4182, "SERGEANT", 2);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				state.postApplicationBriefingCompleted = false;
 			}
-
-			return runDay5ExitIfNeeded();
-		}
-
-		const DialogueLineEntry repeatTaskLines[] = {
-			{ 0x41f0, "SERGEANT", 0 },
-			{ 0x41f6, "PC", 0 },
-			{ 0x41fa, "SERGEANT", 0 }
-		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(
-			repeatTaskLines, ARRAYSIZE(repeatTaskLines));
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		return runDay5ExitIfNeeded();
-	};
-	auto runIntroOrReminderBranch = [&]() -> Common::Error {
-		if (!state.talkStateBlock) {
-			sharedState.dialogueStateD2f08 = true;
-			state.talkStateBlock = true;
-
-			const DialogueLineEntry introLines[] = {
-				{ 0x413f, "SERGEANT", 2 },
-				{ 0x4144, "PC", 0 },
-				{ 0x4145, "SERGEANT", 0 },
-				{ 0x4146, "PC", 0 },
-				{ 0x4148, "SERGEANT", 2 }
+		} else {
+			const DialogueLineEntry applicationLines[] = {
+				{ 0x4188, "SERGEANT", 0 },
+				{ 0x418e, "PC", 0 },
+				{ 0x4192, "SERGEANT", 0 }
 			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
+			lineError = runtime.playDialogueEntrySequence(applicationLines, ARRAYSIZE(applicationLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(
-				kSergeantIntroResponseLine, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-
-			if (responseIndex == 1) {
-				lineError = runtime.playDialogueLineWithVariant(0x4153, "SERGEANT", 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			} else if (responseIndex == 2) {
-				lineError = runtime.playDialogueLineWithVariant(0x4157, "SERGEANT", 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-
-			lineError = runtime.playDialogueLineWithVariant(0x415d, "SERGEANT", 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			responseIndex = 0;
-			responseError = runtime.runResponseMenu(
-				kSergeantApplicationResponseLine, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-
-			if (responseIndex == 1) {
-				const DialogueLineEntry applicationLines[] = {
-					{ 0x4168, "SERGEANT", 1 },
-					{ 0x416c, "PC", 0 },
-					{ 0x4170, "SERGEANT", 1 }
-				};
-				lineError = runtime.playDialogueEntrySequence(applicationLines, ARRAYSIZE(applicationLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-
-				responseIndex = 0;
-				responseError = runtime.runResponseMenu(
-					kSergeantAmnesiaResponseLine, responseIndex);
-				if (responseError.getCode() != Common::kNoError)
-					return responseError;
-
-				if (responseIndex == 1) {
-					lineError = runtime.playDialogueLineWithVariant(0x417c, "SERGEANT", 0);
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-				} else if (responseIndex == 2) {
-					lineError = runtime.playDialogueLineWithVariant(0x4182, "SERGEANT", 2);
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-				}
-			} else {
-				const DialogueLineEntry applicationLines[] = {
-					{ 0x4188, "SERGEANT", 0 },
-					{ 0x418e, "PC", 0 },
-					{ 0x4192, "SERGEANT", 0 }
-				};
-				lineError = runtime.playDialogueEntrySequence(applicationLines, ARRAYSIZE(applicationLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-
-			return runDay5ExitIfNeeded();
 		}
 
-		state.talkStateBlock = true;
-		Common::Error lineError = runtime.playDialogueLineWithVariant(0x41ac, "SERGEANT", 0);
+		return runDay5ExitIfNeeded(runtime);
+	}
+
+	_state.talkStateBlock = true;
+	Common::Error lineError = runtime.playDialogueLineWithVariant(0x41ac, "SERGEANT", 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	if (!runtime.startupScript().getFlagValue("HAVE_LODGE_APP")) {
+		lineError = runtime.playDialogueLineWithVariant(0x41b0, "PC", 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		if (!runtime.startupScript().getFlagValue("HAVE_LODGE_APP")) {
-			lineError = runtime.playDialogueLineWithVariant(0x41b0, "PC", 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		lineError = runtime.playDialogueLineWithVariant(0x41b4, "SERGEANT", 3);
+	}
+	lineError = runtime.playDialogueLineWithVariant(0x41b4, "SERGEANT", 3);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kSergeantReminderResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x41bf, "SERGEANT", 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		_state.dialogueStateD2d50 = true;
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x41c4, "SERGEANT", 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantReminderResponseLine, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
+	return runDay5ExitIfNeeded(runtime);
+}
 
-		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLineWithVariant(0x41bf, "SERGEANT", 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			state.dialogueStateD2d50 = true;
-		} else if (responseIndex == 2) {
-			lineError = runtime.playDialogueLineWithVariant(0x41c4, "SERGEANT", 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	SergeantRoomDialogueState &state = _state;
+
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<ObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const ObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
 		}
 
-		return runDay5ExitIfNeeded();
+		return false;
 	};
 
 	if (usedItemName.empty()) {
 		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE"))
-			return handleRemainsBranch();
+			return handleRemainsBranch(runtime, sharedState);
 		if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") &&
 				(hasInventoryItem("INVITE") ||
 					runtime.startupScript().getFlagValue("TAKEN_INVITE_TO_SERGEANT"))) {
-			return handleInviteBranch();
+			return handleInviteBranch(runtime, sharedState);
 		}
 		if (runtime.startupScript().getFlagValue("DINER_BURNED")) {
 			sharedState.dialogueStateD2f08 = true;
@@ -623,36 +643,36 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			resetAndSetObjectVisible("ST_BEDRM", "INVITE", true);
-			resetAndSetObjectVisible("ST_BEDRM", "STEFSKULL", true);
+			resetAndSetObjectVisible(runtime, "ST_BEDRM", "INVITE", true);
+			resetAndSetObjectVisible(runtime, "ST_BEDRM", "STEFSKULL", true);
 			(void)runtime.startupScript().setRuntimeNpcState("DWAYNE_ST_BEDRM", true, true);
 			(void)runtime.startupScript().setRuntimeFlagValue("STEPHANIE_IS_DEAD", true);
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				"STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE", true);
-			return runDay5ExitIfNeeded();
+			return runDay5ExitIfNeeded(runtime);
 		}
 		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN"))
-			return handleBarberPoleBranch();
+			return handleBarberPoleBranch(runtime, sharedState);
 		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN"))
-			return handleBoltClothBranch();
+			return handleBoltClothBranch(runtime, sharedState);
 		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER")) {
-			return handleCompletedFirstTaskBranch();
+			return handleCompletedFirstTaskBranch(runtime, sharedState);
 		}
 		if (runtime.startupScript().getFlagValue("HAVE_COMPLETED_LODGE_APP"))
-			return handleCompletedApplicationBranch();
-		return runIntroOrReminderBranch();
+			return handleCompletedApplicationBranch(runtime, sharedState);
+		return runIntroOrReminderBranch(runtime, sharedState);
 	}
 
 	if (usedItemName.equalsIgnoreCase("BARBER_POLE"))
-		return handleBarberPoleBranch();
+		return handleBarberPoleBranch(runtime, sharedState);
 	if (usedItemName.equalsIgnoreCase("BOLTCLTH"))
-		return handleBoltClothBranch();
+		return handleBoltClothBranch(runtime, sharedState);
 	if (usedItemName.equalsIgnoreCase("COMPLETED_LODGE_APPLICATION"))
-		return handleCompletedApplicationBranch();
+		return handleCompletedApplicationBranch(runtime, sharedState);
 	if (usedItemName.equalsIgnoreCase("INVITE"))
-		return handleInviteBranch();
+		return handleInviteBranch(runtime, sharedState);
 	if (usedItemName.equalsIgnoreCase("REMAINS"))
-		return handleRemainsBranch();
+		return handleRemainsBranch(runtime, sharedState);
 
 	return runtime.playDialogueLineWithVariant(0x42fa, "SERGEANT", 0);
 }
diff --git a/engines/harvester/npc/sergeant_dialogue.h b/engines/harvester/npc/sergeant_dialogue.h
index 41e95cd92b9..a4292564551 100644
--- a/engines/harvester/npc/sergeant_dialogue.h
+++ b/engines/harvester/npc/sergeant_dialogue.h
@@ -64,6 +64,15 @@ public:
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
 private:
+	Common::Error runDay5ExitIfNeeded(DialogueRuntime &runtime);
+	Common::Error handleRemainsBranch(DialogueRuntime &runtime, DialogueSharedState &sharedState);
+	Common::Error handleInviteBranch(DialogueRuntime &runtime, DialogueSharedState &sharedState);
+	Common::Error handleBarberPoleBranch(DialogueRuntime &runtime, DialogueSharedState &sharedState);
+	Common::Error handleBoltClothBranch(DialogueRuntime &runtime, DialogueSharedState &sharedState);
+	Common::Error handleCompletedFirstTaskBranch(DialogueRuntime &runtime, DialogueSharedState &sharedState);
+	Common::Error handleCompletedApplicationBranch(DialogueRuntime &runtime, DialogueSharedState &sharedState);
+	Common::Error runIntroOrReminderBranch(DialogueRuntime &runtime, DialogueSharedState &sharedState);
+
 	SergeantRoomDialogueState _state;
 };
 


Commit: 35463df4363ab4807eea1fa4e0b00823bd453d6e
    https://github.com/scummvm/scummvm/commit/35463df4363ab4807eea1fa4e0b00823bd453d6e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Extract Jimmy dialogue routines into methods

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/npc/jimmy_dialogue.cpp
    engines/harvester/npc/jimmy_dialogue.h


diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index 37f74137387..9c40930e56b 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -78,110 +78,112 @@ static const DialogueLineEntry kJimmyBlackmailEvidenceLines[] = {
 	{ 0x4b31, "JIMMY", 1 }
 };
 
-} // End of namespace
-
-Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &sharedState) {
-	JimmyRoomDialogueState &state = _state;
-	Common::Error lineError = Common::kNoError;
+static void restoreItemToRah(DialogueRuntime &runtime, const char *objectName) {
+	(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
+	(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoomName, objectName, true);
+}
 
-	auto hasInventoryItem = [&](const char *objectName) {
-		return runtime.startupScript().isObjectInInventory(objectName);
-	};
-
-	auto restoreItemToRah = [&](const char *objectName) {
-		(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
-		(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoomName, objectName, true);
-	};
-	auto completeSneakersTrade = [&]() {
-		restoreItemToRah("SNEAKERS");
-		(void)runtime.startupScript().addRuntimeObjectToInventory("BROOMKEY");
-	};
-	auto playPaperHandoffPreludeIfNeeded = [&]() -> Common::Error {
-		if (state.paperHandoffStateSet)
-			return Common::kNoError;
+static void completeSneakersTrade(DialogueRuntime &runtime) {
+	restoreItemToRah(runtime, "SNEAKERS");
+	(void)runtime.startupScript().addRuntimeObjectToInventory("BROOMKEY");
+}
 
-		Common::Error preludeError = runtime.playDialogueEntrySequence(kJimmyPaperHandoffLines,
-			ARRAYSIZE(kJimmyPaperHandoffLines));
-		if (preludeError.getCode() != Common::kNoError)
-			return preludeError;
+} // End of namespace
 
-		state.paperHandoffStateSet = true;
+Common::Error JimmyDialogueHandler::playPaperHandoffPreludeIfNeeded(DialogueRuntime &runtime) {
+	if (_state.paperHandoffStateSet)
 		return Common::kNoError;
-	};
-	auto runSneakersOfferMenu = [&]() -> Common::Error {
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(kJimmyThirdResponseLineIndex, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
 
-		if (responseIndex == 1) {
-			if (hasInventoryItem("SNEAKERS")) {
-				Common::Error lineError = runtime.playDialogueLineWithVariant(0x4a9e, "JIMMY", 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-
-				completeSneakersTrade();
-				return Common::kNoError;
-			}
+	Common::Error preludeError = runtime.playDialogueEntrySequence(kJimmyPaperHandoffLines,
+		ARRAYSIZE(kJimmyPaperHandoffLines));
+	if (preludeError.getCode() != Common::kNoError)
+		return preludeError;
 
-			return runtime.playDialogueEntrySequence(kJimmyNoSneakersLines, ARRAYSIZE(kJimmyNoSneakersLines));
-		}
-		if (responseIndex == 2)
-			return runtime.playDialogueLineWithVariant(0x4ab4, "JIMMY", 1);
-
-		return Common::kNoError;
-	};
-	auto runJimmySneakersPrompt = [&](int responseLineIndex) -> Common::Error {
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(responseLineIndex, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
+	_state.paperHandoffStateSet = true;
+	return Common::kNoError;
+}
 
-		if (responseIndex == 1) {
-			Common::Error lineError = runtime.playDialogueLineWithVariant(0x4a86, "JIMMY", 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+Common::Error JimmyDialogueHandler::runSneakersOfferMenu(DialogueRuntime &runtime) {
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(kJimmyThirdResponseLineIndex, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
 
-			lineError = runtime.playDialogueEntrySequence(kJimmySecondResponsePreludeLines,
-				ARRAYSIZE(kJimmySecondResponsePreludeLines));
+	if (responseIndex == 1) {
+		if (runtime.startupScript().isObjectInInventory("SNEAKERS")) {
+			Common::Error lineError = runtime.playDialogueLineWithVariant(0x4a9e, "JIMMY", 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			return runSneakersOfferMenu();
+			completeSneakersTrade(runtime);
+			return Common::kNoError;
 		}
-		if (responseIndex == 2)
-			return runtime.playDialogueLineWithVariant(0x4abc, "JIMMY", 0);
 
-		return Common::kNoError;
-	};
-	auto playJimmyNoItemFallback = [&]() -> Common::Error {
-		if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
-			Common::Error sneakersLineError = runtime.playDialogueLineWithVariant(0x4ac3, "JIMMY", 0);
-			if (sneakersLineError.getCode() != Common::kNoError)
-				return sneakersLineError;
-
-			Common::Error sneakersPromptError = runJimmySneakersPrompt(kJimmySneakersResponseLineIndex);
-			if (sneakersPromptError.getCode() != Common::kNoError)
-				return sneakersPromptError;
-		}
-		if (runtime.startupScript().getFlagValue("PAPER_CHK_4"))
-			return runtime.playDialogueLineWithVariant(0x4ae2, "JIMMY", 2);
-		if (runtime.startupScript().getFlagValue("PAPER_CHK_3"))
-			return runtime.playDialogueLineWithVariant(0x4adb, "JIMMY", 2);
-		if (runtime.startupScript().getFlagValue("PAPER_CHK_2"))
-			return runtime.playDialogueLineWithVariant(0x4ad4, "JIMMY", 2);
+		return runtime.playDialogueEntrySequence(kJimmyNoSneakersLines, ARRAYSIZE(kJimmyNoSneakersLines));
+	}
+	if (responseIndex == 2)
+		return runtime.playDialogueLineWithVariant(0x4ab4, "JIMMY", 1);
+
+	return Common::kNoError;
+}
+
+Common::Error JimmyDialogueHandler::runJimmySneakersPrompt(DialogueRuntime &runtime, int responseLineIndex) {
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(responseLineIndex, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		Common::Error lineError = runtime.playDialogueLineWithVariant(0x4a86, "JIMMY", 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runtime.playDialogueEntrySequence(kJimmySecondResponsePreludeLines,
+			ARRAYSIZE(kJimmySecondResponsePreludeLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
-		return runtime.playDialogueLineWithVariant(0x4b38, "JIMMY", 0);
-	};
+		return runSneakersOfferMenu(runtime);
+	}
+	if (responseIndex == 2)
+		return runtime.playDialogueLineWithVariant(0x4abc, "JIMMY", 0);
+
+	return Common::kNoError;
+}
+
+Common::Error JimmyDialogueHandler::playJimmyNoItemFallback(DialogueRuntime &runtime) {
+	if (runtime.startupScript().isObjectInInventory("SNEAKERS") &&
+			!runtime.startupScript().isObjectInInventory("BROOMKEY")) {
+		Common::Error sneakersLineError = runtime.playDialogueLineWithVariant(0x4ac3, "JIMMY", 0);
+		if (sneakersLineError.getCode() != Common::kNoError)
+			return sneakersLineError;
+
+		Common::Error sneakersPromptError = runJimmySneakersPrompt(runtime, kJimmySneakersResponseLineIndex);
+		if (sneakersPromptError.getCode() != Common::kNoError)
+			return sneakersPromptError;
+	}
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_4"))
+		return runtime.playDialogueLineWithVariant(0x4ae2, "JIMMY", 2);
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_3"))
+		return runtime.playDialogueLineWithVariant(0x4adb, "JIMMY", 2);
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_2"))
+		return runtime.playDialogueLineWithVariant(0x4ad4, "JIMMY", 2);
+
+	return runtime.playDialogueLineWithVariant(0x4b38, "JIMMY", 0);
+}
+
+Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	JimmyRoomDialogueState &state = _state;
+	Common::Error lineError = Common::kNoError;
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(kGivenPaperTodayFlag, true);
-			restoreItemToRah("NEWSPAPER");
+			restoreItemToRah(runtime, "NEWSPAPER");
 			runtime.executeAndQueueAction("ACTV_HOUSE_EXIT");
 
-			lineError = playPaperHandoffPreludeIfNeeded();
+			lineError = playPaperHandoffPreludeIfNeeded(runtime);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -226,7 +228,7 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			completeSneakersTrade();
+			completeSneakersTrade(runtime);
 			return Common::kNoError;
 		}
 
@@ -235,7 +237,7 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (state.firstNoItemLinePending) {
 		state.firstNoItemLinePending = false;
-		lineError = playPaperHandoffPreludeIfNeeded();
+		lineError = playPaperHandoffPreludeIfNeeded(runtime);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -266,12 +268,12 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		lineError = runJimmySneakersPrompt(kJimmySecondResponseLineIndex);
+		lineError = runJimmySneakersPrompt(runtime, kJimmySecondResponseLineIndex);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 
-	return playJimmyNoItemFallback();
+	return playJimmyNoItemFallback(runtime);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/jimmy_dialogue.h b/engines/harvester/npc/jimmy_dialogue.h
index 5d563ff962a..d0e6b770250 100644
--- a/engines/harvester/npc/jimmy_dialogue.h
+++ b/engines/harvester/npc/jimmy_dialogue.h
@@ -42,6 +42,11 @@ public:
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
 private:
+	Common::Error playPaperHandoffPreludeIfNeeded(DialogueRuntime &runtime);
+	Common::Error runSneakersOfferMenu(DialogueRuntime &runtime);
+	Common::Error runJimmySneakersPrompt(DialogueRuntime &runtime, int responseLineIndex);
+	Common::Error playJimmyNoItemFallback(DialogueRuntime &runtime);
+
 	JimmyRoomDialogueState _state;
 };
 


Commit: 5e44c1d40fb49bd5db39e1a439c2e5d9081b9049
    https://github.com/scummvm/scummvm/commit/5e44c1d40fb49bd5db39e1a439c2e5d9081b9049
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-10-20T21:17:53-04:00

Commit Message:
HARVESTER: Extract Buster fatal dialogue sequence

Assisted-by: Codex:gpt-6.1-sol

Changed paths:
    engines/harvester/npc/buster_dialogue.cpp


diff --git a/engines/harvester/npc/buster_dialogue.cpp b/engines/harvester/npc/buster_dialogue.cpp
index 598d4f425f1..5084013ea28 100644
--- a/engines/harvester/npc/buster_dialogue.cpp
+++ b/engines/harvester/npc/buster_dialogue.cpp
@@ -171,6 +171,23 @@ static const DialogueLineEntry kBusterTopic0x40Or0x41PreludeLines[] = {
 	{ 0x17f7, "BUSTER", 0 }
 };
 
+static Common::Error playFatalBusterBranch(DialogueRuntime &runtime, int wavId, int headVariant = 0) {
+	Common::Error lineError = runtime.playDialogueLineWithVariant(wavId, "BUSTER", headVariant);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	Graphics::Screen *screen = runtime.engine().getScreen();
+	if (screen) {
+		screen->fillRect(screen->getBounds(), 0);
+		screen->update();
+	}
+	lineError = runtime.playDialogueFst(kDialogueC086FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	return runtime.runGameOverScreen();
+}
+
 } // End of namespace
 
 Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
@@ -180,26 +197,6 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	DialogueTopicBuffer topics(runtime, topicBuffer, topicBufferLineIndex, "Buster topic buffer");
 
-	auto clearScreenToBlack = [&]() {
-		Graphics::Screen *screen = runtime.engine().getScreen();
-		if (!screen)
-			return;
-
-		screen->fillRect(screen->getBounds(), 0);
-		screen->update();
-	};
-	auto playFatalBusterBranch = [&](int wavId, int headVariant = 0) -> Common::Error {
-		Common::Error lineError = runtime.playDialogueLineWithVariant(wavId, "BUSTER", headVariant);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		clearScreenToBlack();
-		lineError = runtime.playDialogueFst(kDialogueC086FstPath);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		return runtime.runGameOverScreen();
-	};
 	auto isCorpsePhoto = [&](const Common::String &itemName) {
 		return itemName.equalsIgnoreCase("CASKET_PHOTO") ||
 			itemName.equalsIgnoreCase("CASKET_PHOTOCOPY");
@@ -278,7 +275,7 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return responseError;
 
 		if (responseIndex == 1)
-			return playFatalBusterBranch(0x183a, 2);
+			return playFatalBusterBranch(runtime, 0x183a, 2);
 		if (responseIndex == 2) {
 			lineError = runtime.playDialogueLineWithVariant(0x1841, "BUSTER", 0);
 			if (lineError.getCode() != Common::kNoError)
@@ -304,7 +301,7 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					return responseError;
 
 				if (followupResponseIndex == 1)
-					return playFatalBusterBranch(0x1850);
+					return playFatalBusterBranch(runtime, 0x1850);
 				if (followupResponseIndex == 2) {
 					lineError = runtime.playDialogueLineWithVariant(0x1858, "BUSTER", 0);
 					if (lineError.getCode() != Common::kNoError)
@@ -364,7 +361,7 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return responseError;
 
 			if (responseIndex == 1)
-				return playFatalBusterBranch(0x16d0);
+				return playFatalBusterBranch(runtime, 0x16d0);
 			if (responseIndex == 2) {
 				lineError = runtime.playDialogueLineWithVariant(0x16d6, "BUSTER", 0);
 				if (lineError.getCode() != Common::kNoError)
@@ -388,7 +385,7 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 						return responseError;
 
 					if (nestedResponseIndex == 1)
-						return playFatalBusterBranch(0x16ef);
+						return playFatalBusterBranch(runtime, 0x16ef);
 					if (nestedResponseIndex == 2) {
 						lineError = runtime.playDialogueLineWithVariant(0x16f6, "BUSTER", 0);
 						if (lineError.getCode() != Common::kNoError)
@@ -397,7 +394,7 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 						topics.assign(0x31);
 					}
 				} else if (followupResponseIndex == 2) {
-					return playFatalBusterBranch(0x1702);
+					return playFatalBusterBranch(runtime, 0x1702);
 				}
 			}
 			continue;
@@ -509,7 +506,7 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return responseError;
 
 			if (responseIndex == 1)
-				return playFatalBusterBranch(0x1804);
+				return playFatalBusterBranch(runtime, 0x1804);
 			if (responseIndex == 2) {
 				lineError = runtime.playDialogueLineWithVariant(0x180b, "BUSTER", 0);
 				if (lineError.getCode() != Common::kNoError)
@@ -531,7 +528,7 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 
 		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x48Line))
-			return playFatalBusterBranch(0x18df, 2);
+			return playFatalBusterBranch(runtime, 0x18df, 2);
 
 		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x49Line)) {
 			Common::Error lineError = runtime.playDialogueLineWithVariant(0x186f, "BUSTER", 0);




More information about the Scummvm-git-logs mailing list