[Scummvm-git-logs] scummvm master -> 2868a2a1a5f7a9eab5938147771c56f01c2b9cbb

bluegr noreply at scummvm.org
Thu Jul 30 00:05:21 UTC 2026


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

Summary:
937e0e6935 NANCY: NANCY10: Add more OneBuildPuzzle functionality for tuning forks
8874b0faee NANCY: NANCY10: Add more functionality for MultiBuildPuzzle
2868a2a1a5 NANCY: Add helper functions for subtitle handling in puzzles


Commit: 937e0e6935f92d90236e678f5295597183155dcd
    https://github.com/scummvm/scummvm/commit/937e0e6935f92d90236e678f5295597183155dcd
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-30T03:05:10+03:00

Commit Message:
NANCY: NANCY10: Add more OneBuildPuzzle functionality for tuning forks

- Add placement region
- Use the correct cursor when hovering over pieces
- Allow turning the crank before placing all forks correctly
- Fix caption appearing when it shouldn't

Fix #17030

Changed paths:
    engines/nancy/action/puzzle/onebuildpuzzle.cpp
    engines/nancy/action/puzzle/onebuildpuzzle.h


diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.cpp b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
index 23dc6d00f28..9c35a9c611e 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -21,6 +21,7 @@
 
 #include "common/random.h"
 #include "common/system.h"
+#include "common/config-manager.h"
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/graphics.h"
@@ -164,6 +165,7 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	_animSound1.readNormal(stream);         // 0x16d
 	_animSound2.readNormal(stream);         // 0x19e
 	_hasFinalAnim = !_animRectA.isEmpty();
+	_hasCrank = !_animRectB.isEmpty();
 
 	_solveScene.readData(stream);           // 0x1cf
 	_cancelScene.readData(stream);          // 0x1e8 (ends the 513-byte blob)
@@ -301,7 +303,11 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	}
 
 	if (isNancy10) {
-		stream.skip(32); // TODO: 32 post-piece bytes, layout undecoded.
+		// The 32-byte post-piece block holds two rects. The first is a
+		// bad-placement check region (unused here); the second is the region
+		// forks may be dragged onto and released in.
+		stream.skip(16);
+		readRect(stream, _placementZone);
 		readFilename(stream, _extraSoundName);
 		readRect(stream, _animRectA);
 		readRect(stream, _animRectB);
@@ -310,6 +316,7 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 		_animSound1.readNormal(stream);
 		_animSound2.readNormal(stream);
 		_hasFinalAnim = !_animRectA.isEmpty();
+		_hasCrank = !_animRectB.isEmpty();
 	}
 
 	_pickupSound.readNormal(stream);
@@ -337,7 +344,10 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	_solveScene.readData(stream);
 	_completionSound.readNormal(stream);
 
-	// Completion caption: AUTOTEXT key if known, else inline text.
+	// Completion caption. Only an AUTOTEXT key produces a textbox caption; the
+	// trailing inline string is a sound subtitle (e.g. "High pitched sound" for
+	// the tuning-fork puzzle), which the original never writes to the textbox.
+	// It is still read to keep the stream aligned.
 	Common::String completionKey;
 	char textBuf[200];
 	readFilename(stream, completionKey);
@@ -345,8 +355,6 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	_completionText.clear();
 	if (!completionKey.empty() && autotext->texts.contains(completionKey))
 		_completionText = autotext->texts[completionKey];
-	else
-		assembleTextLine(textBuf, _completionText, 200);
 
 	_cancelScene.readData(stream);
 	readRect(stream, _exitHotspot);
@@ -378,7 +386,10 @@ void OneBuildPuzzle::execute() {
 					// Pickup/rotate sound finished; return to idle (piece still dragging)
 					_solveState = kIdle;
 				} else if (_correctlyPlaced) {
-					checkAllPlaced();
+					// Crank puzzles never solve by placement alone; the player
+					// must turn the crank to finish (see finishCrankTurn()).
+					if (!_hasCrank)
+						checkAllPlaced();
 					if (!_isSolved)
 						playGoodPlacementSound();
 				} else {
@@ -405,7 +416,7 @@ void OneBuildPuzzle::execute() {
 			// Play completion sound/text, then wait for it to finish
 			g_nancy->_sound->loadSound(_completionSound);
 			g_nancy->_sound->playSound(_completionSound);
-			if (!_completionText.empty()) {
+			if (!_completionText.empty() && ConfMan.getBool("subtitles")) {
 				NancySceneState.getTextbox().clear();
 				NancySceneState.getTextbox().addTextLine(_completionText);
 			}
@@ -443,23 +454,16 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 	Common::Point mouseVP(input.mousePos.x - vpScreen.left,
 						  input.mousePos.y - vpScreen.top);
 
-	// Post-placement final-animation stage: once all pieces are placed on a
-	// puzzle that defines _animRectA, the puzzle waits here until the user
-	// clicks the hotspot (e.g. winding a music-box crank, throwing a lever).
-	if (_waitingForFinalAnim && _solveState == kIdle) {
-		if (_animRectA.contains(mouseVP)) {
-			g_nancy->_cursor->setCursorType(CursorManager::kPuzzleArrow);
-			if (input.input & NancyInput::kLeftMouseButtonUp)
-				startFinalAnimation();
-			return;
-		}
-		// Fall through so the exit hotspot still works while waiting.
-	}
-
 	if (_isDragging) {
 		// Always update drag position while carrying a piece
 		updateDragPosition(mouseVP);
-		setPieceCursor();
+
+		// The held fork shows the hotspot hand cursor while over the placement
+		// region, and the plain magnifying glass everywhere else.
+		if (_placementZone.isEmpty() || _placementZone.contains(mouseVP))
+			setPieceCursor();
+		else
+			g_nancy->_cursor->setCursorType(CursorManager::kNormal);
 
 		if (_solveState != kIdle)
 			return;
@@ -476,6 +480,11 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 
 		// Left click while dragging: attempt to place
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			// A fork can only be released inside the contraption region; a
+			// click outside it is ignored and the piece stays on the cursor.
+			if (!_placementZone.isEmpty() && !_placementZone.contains(mouseVP))
+				return;
+
 			Piece &piece = _pieces[_pickedUpPiece];
 
 			Common::Rect slot = piece.slotRect;
@@ -529,6 +538,17 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// Crank hotspot: on puzzles solved by a crank it can be turned at any time
+	// before the puzzle is solved. Turning it plays the winding animation, then
+	// either solves the puzzle or (if the forks aren't all correctly placed)
+	// makes a bad noise so the player can try again. See finishCrankTurn().
+	if (_hasCrank && _solveState == kIdle && _animRectB.contains(mouseVP)) {
+		g_nancy->_cursor->setCursorType(CursorManager::kPuzzleArrow);
+		if (input.input & NancyInput::kLeftMouseButtonUp)
+			startFinalAnimation();
+		return;
+	}
+
 	// Not dragging: find the topmost piece under the cursor. The hover cursor is
 	// refreshed even while a drop/placement sound plays (non-idle) so a piece put
 	// down off-target keeps the piece cursor; only clicks are gated on kIdle.
@@ -616,7 +636,8 @@ void OneBuildPuzzle::readPlacementTexts(Common::SeekableReadStream &stream, Comm
 
 void OneBuildPuzzle::setPieceCursor() {
 	if (g_nancy->getGameType() >= kGameTypeNancy10)
-		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_pieceCursorType, true, false);
+		// The piece hand uses the hotspot variant (blue hand with an outline).
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_pieceCursorType, true, true);
 	else
 		g_nancy->_cursor->setCursorType(CursorManager::kCustom1);
 }
@@ -751,13 +772,6 @@ void OneBuildPuzzle::checkAllPlaced() {
 		return;
 	}
 
-	// Puzzles with a post-placement animation (e.g. scene 3637's music-box
-	// crank) require the player to click _animRectA before the puzzle solves.
-	if (_hasFinalAnim && !_finalAnimDone) {
-		_waitingForFinalAnim = true;
-		return;
-	}
-
 	_isSolved = true;
 	_solveState = kTriggerCompletion;
 }
@@ -804,7 +818,7 @@ void OneBuildPuzzle::playGoodPlacementSound() {
 		idx = 0;
 	g_nancy->_sound->loadSound(_currentSound);
 	g_nancy->_sound->playSound(_currentSound);
-	if (!_goodTexts[idx].empty()) {
+	if (!_goodTexts[idx].empty() && ConfMan.getBool("subtitles")) {
 		NancySceneState.getTextbox().clear();
 		NancySceneState.getTextbox().addTextLine(_goodTexts[idx]);
 	}
@@ -823,7 +837,7 @@ void OneBuildPuzzle::playBadPlacementSound() {
 		idx = 0;
 	g_nancy->_sound->loadSound(_currentSound);
 	g_nancy->_sound->playSound(_currentSound);
-	if (!_badTexts[idx].empty()) {
+	if (!_badTexts[idx].empty() && ConfMan.getBool("subtitles")) {
 		NancySceneState.getTextbox().clear();
 		NancySceneState.getTextbox().addTextLine(_badTexts[idx]);
 	}
@@ -832,19 +846,17 @@ void OneBuildPuzzle::playBadPlacementSound() {
 }
 
 void OneBuildPuzzle::startFinalAnimation() {
-	_finalAnimDone = true;       // one-shot guard
-	_waitingForFinalAnim = false;
+	_finalAnimDone = true;
 	_animFrameCounter = 0;
 	_animRowCounter = 0;
 
-	// Without an animation image to step through, fall straight into completion.
+	// Without an animation image to step through, resolve the crank turn now.
 	if (_animImage.w == 0) {
 		if (_animSound1.name != "NO SOUND" && !_animSound1.name.empty()) {
 			g_nancy->_sound->loadSound(_animSound1);
 			g_nancy->_sound->playSound(_animSound1);
 		}
-		_isSolved = true;
-		_solveState = kTriggerCompletion;
+		finishCrankTurn();
 		return;
 	}
 
@@ -901,10 +913,31 @@ void OneBuildPuzzle::stepFinalAnimation() {
 		return;
 	}
 
-	// Animation finished: hide overlay and run the standard completion flow.
+	// Animation finished: solve the puzzle or make the bad noise.
+	finishCrankTurn();
+}
+
+void OneBuildPuzzle::finishCrankTurn() {
 	_finalAnimOverlay.setVisible(false);
-	_isSolved = true;
-	_solveState = kTriggerCompletion;
+
+	// checkAllPlaced() sets _isSolved and moves to kTriggerCompletion once every
+	// required fork is in place.
+	checkAllPlaced();
+	if (_isSolved)
+		return;
+
+	// The forks aren't all correctly placed yet: the contraption makes a bad
+	// noise and the player can turn the crank again.
+	if (_animSound2.name != "NO SOUND" && !_animSound2.name.empty()) {
+		g_nancy->_sound->loadSound(_animSound2);
+		g_nancy->_sound->playSound(_animSound2);
+		_currentSound = _animSound2;
+		_timerEnd = g_system->getMillis() + 800;
+		_solveState = kWaitPlaceSound;
+	} else {
+		_solveState = kIdle;
+	}
+	_finalAnimDone = false;
 }
 
 // static
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.h b/engines/nancy/action/puzzle/onebuildpuzzle.h
index d9bde97d369..f467ce45e92 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.h
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.h
@@ -109,8 +109,14 @@ protected:
 	Common::Rect _animRectB;
 	int16 _animLayout[6] = {}; // cols, framesPerStep, baseX, baseY, spacing, totalRows
 	SoundDescription _animSound1;
-	SoundDescription _animSound2;
-	bool _hasFinalAnim = false;   // true when _animRectA is non-empty
+	SoundDescription _animSound2;  // "bad" noise played when the crank is turned before the puzzle is solved
+	bool _hasFinalAnim = false;   // true when _animRectA is non-empty (the animation atlas region)
+	bool _hasCrank = false;       // true when _animRectB is non-empty; the puzzle is solved by turning the crank
+
+	// Forks can only be dragged onto / released inside this region (the
+	// contraption area), not the whole viewport. Empty when the puzzle has no
+	// such constraint.
+	Common::Rect _placementZone;
 
 	// Nancy12: pieces with an empty home rect start scattered inside this zone.
 	Common::Rect _scatterZone;
@@ -172,10 +178,6 @@ protected:
 	uint16 _piecesPlaced = 0;    // Number of pieces correctly placed so far
 	uint32 _timerEnd = 0;        // Millisecond timestamp when the current timer expires
 
-	// Final-animation gating: after all pieces are placed on a puzzle that
-	// has _animRectA defined, _waitingForFinalAnim is set and the puzzle
-	// stalls in kIdle until the user clicks _animRectA.
-	bool _waitingForFinalAnim = false;
 	bool _finalAnimDone = false;
 
 	// Final-animation runtime state (matches original `+0xc35`/`+0xc33` per-tick counters).
@@ -225,6 +227,9 @@ protected:
 	// Final-animation helpers.
 	void startFinalAnimation();
 	void stepFinalAnimation();
+	// After a crank turn finishes: solve if every fork is placed, otherwise
+	// play the "bad" noise and let the player try again.
+	void finishCrankTurn();
 };
 
 } // End of namespace Action


Commit: 8874b0faeebb88aa41c66717b64ebb4a9fc39269
    https://github.com/scummvm/scummvm/commit/8874b0faeebb88aa41c66717b64ebb4a9fc39269
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-30T03:05:11+03:00

Commit Message:
NANCY: NANCY10: Add more functionality for MultiBuildPuzzle

- Fix ingredient closeup position
- Handle exit hotspot when a closeup is shown
- Handle throwing ingredients away
- Check ingredients on scene cancel

Fix items 1-4 of bug #17024

Changed paths:
    engines/nancy/action/puzzle/multibuildpuzzle.cpp
    engines/nancy/action/puzzle/multibuildpuzzle.h


diff --git a/engines/nancy/action/puzzle/multibuildpuzzle.cpp b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
index ba1743d9832..e1b6aab1dae 100644
--- a/engines/nancy/action/puzzle/multibuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
@@ -33,10 +33,6 @@
 namespace Nancy {
 namespace Action {
 
-// Nancy 10 TODOs:
-//  - completion-animation playback (data read, never rendered)
-//  - _retainState save/restore via a PuzzleData entry
-//  - 4th cursor id, 7 × 33-byte string slots
 void MultiBuildPuzzle::init() {
 	g_nancy->_resource->loadImage(_primaryImageName, _primaryImage);
 	_primaryImage.setTransparentColor(_drawSurface.getTransparentColor());
@@ -133,7 +129,8 @@ void MultiBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 
 	readRect(stream, _targetZone);
 
-	_allowAltZoneSnap = stream.readByte() != 0;
+	_altZoneSnapMode = stream.readByte();
+	_allowAltZoneSnap = _altZoneSnapMode != 0;
 	_checkOverlapOnDrop = stream.readByte() != 0;
 
 	if (isNancy10) {
@@ -188,9 +185,14 @@ void MultiBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	_dropSound.readNormal(stream);
 
 	if (isNancy10) {
-		_animSound.readNormal(stream);
-		stream.skip(7 * 33);	// TODO: 7 unknown strings
-		stream.skip(2); // TODO: 4th cursor id at the front of the block; role TBD.
+		// "Missed" feedback when an ingredient is thrown away: a sound plus a
+		// caption laid out exactly like the solve caption (33-byte CONVO key +
+		// 200-byte raw fallback).
+		_missedSound.readNormal(stream);
+		readFilename(stream, _missedTextKey);
+		char missedBuf[200];
+		stream.read(missedBuf, 200);
+		assembleTextLine(missedBuf, _missedText, 200);
 	}
 
 	_dragCursorID  = stream.readSint16LE();
@@ -220,6 +222,7 @@ void MultiBuildPuzzle::execute() {
 		g_nancy->_sound->loadSound(_pickupSound);
 		g_nancy->_sound->loadSound(_dropSound);
 		g_nancy->_sound->loadSound(_solveSound);
+		g_nancy->_sound->loadSound(_missedSound);
 		_state = kRun;
 		// fall through
 	case kRun:
@@ -303,6 +306,7 @@ void MultiBuildPuzzle::execute() {
 		g_nancy->_sound->stopSound(_pickupSound);
 		g_nancy->_sound->stopSound(_dropSound);
 		g_nancy->_sound->stopSound(_solveSound);
+		g_nancy->_sound->stopSound(_missedSound);
 		if (_isCancelled) {
 			NancySceneState.changeScene(_cancelScene._sceneChange);
 			// Cancel flag is only set if at least one piece was placed (or
@@ -481,8 +485,10 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 		return;
 	Common::Rect vpScreen = viewData->screenPosition;
 
-	// Exit hotspots can sit below the viewport (cake mixing).
-	if (!_isDragging && _selectedPiece == -1 && !vpScreen.contains(input.mousePos)) {
+	// Exit hotspots can sit below the viewport (cake mixing). They remain
+	// reachable while an ingredient closeup is showing, so the player can back
+	// away at the bottom of the screen to cancel adding it.
+	if (!_isDragging && !vpScreen.contains(input.mousePos)) {
 		if (!checkExitHotspot(_exitHotspot, _exitCursorID1, input))
 			checkExitHotspot(_exitHotspot2, _exitCursorID2, input);
 		return;
@@ -528,25 +534,44 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 			int placedIdx = _pickedUpPiece;
 			_pickedUpPiece = -1;
 
+			const bool isNancy10 = g_nancy->getGameType() >= kGameTypeNancy10;
+			// "Add ingredient" mode (cake mixing): a valid drop just bumps the
+			// ingredient's placement count and returns the piece to the shelf;
+			// there is a single piece per ingredient (no counter-spawn), so the
+			// count is tracked purely by placeCount.
+			const bool addMode = isNancy10 && _altZoneSnapMode == 2;
+
 			if (validDrop) {
-				pp.isPlaced = true;
-				const bool isNancy10 = g_nancy->getGameType() >= kGameTypeNancy10;
-				if (isNancy10) {
-					// Clone placements bump the source piece's counter.
-					int srcIdx = (pp.typeIdx >= 0) ? pp.typeIdx : placedIdx;
-					if (_pieces[srcIdx].placeCount < 255)
-						_pieces[srcIdx].placeCount++;
+				int srcIdx = (pp.typeIdx >= 0) ? pp.typeIdx : placedIdx;
+				if (isNancy10 && _pieces[srcIdx].placeCount < 255)
+					_pieces[srcIdx].placeCount++;
+
+				if (addMode) {
+					// Ingredient goes back to its shelf slot, still pickable.
+					pp.isPlaced = false;
+					pp.gameRect = pp.homeRect;
+				} else {
+					pp.isPlaced = true;
+					g_nancy->_sound->playSound(_dropSound);
+					// Counter pieces respawn at home for unlimited supply.
+					if (pp.counterByte != 0)
+						spawnCounterPiece(placedIdx);
 				}
-				g_nancy->_sound->playSound(_dropSound);
-
-				// Counter pieces respawn at home for unlimited supply.
-				if (pp.counterByte != 0)
-					spawnCounterPiece(placedIdx);
 
 				if (isNancy10)
 					updateSolveFlags();
 			} else {
+				// Missed: the ingredient was thrown away. Return it to the shelf
+				// and play the "Missed." feedback (sound + caption).
+				pp.isPlaced = false;
 				pp.gameRect = pp.homeRect;
+				if (isNancy10 && _missedSound.name != "NO SOUND") {
+					g_nancy->_sound->playSound(_missedSound);
+					if (_solveState == kIdle) {
+						_solveState = kWaitTimer;
+						_timerEnd = g_system->getMillis() + 200;
+					}
+				}
 			}
 
 			updatePieceRender(placedIdx);
@@ -570,12 +595,22 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 	}
 
 	if (_selectedPiece != -1) {
+		Piece &pp = _pieces[_selectedPiece];
+
+		// Only the closeup itself is interactive: clicking it picks it up to
+		// drag. Anywhere else the exit hotspots stay live, so the player can back
+		// away at the bottom of the screen to cancel adding the ingredient.
+		if (!pp.gameRect.contains(mouseVP)) {
+			if (!checkExitHotspot(_exitHotspot, _exitCursorID1, input))
+				checkExitHotspot(_exitHotspot2, _exitCursorID2, input);
+			return;
+		}
+
 		g_nancy->_cursor->setCursorType(dragCursor, true);
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			int sel = _selectedPiece;
 			_selectedPiece = -1;
-			Piece &pp = _pieces[sel];
 			_pickedUpPiece = sel;
 			_isDragging = true;
 			pp.curRotation = 0;
@@ -650,18 +685,26 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 			pp.registerGraphics();
 
 			if (_hasCloseupImage && !pp.cuSrcRect.isEmpty()) {
-				// First click shows the closeup view centred on the piece.
+				// First click shows the closeup view. When the piece carries a
+				// fixed closeup destination (cake mixing), the closeup appears at
+				// that absolute, screen-centred position; otherwise it is centred
+				// on the piece (plant potting).
 				_selectedPiece = topmost;
 				const int cuW = pp.cuSrcRect.width();
 				const int cuH = pp.cuSrcRect.height();
-				const int pieceW = pp.rotateSurfaces[pp.curRotation].w;
-				const int pieceH = pp.rotateSurfaces[pp.curRotation].h;
-				const int centerX = pp.gameRect.left + pieceW / 2;
-				const int centerY = pp.gameRect.top  + pieceH / 2;
-				int cuLeft = centerX - cuW / 2;
-				int cuTop  = centerY - cuH / 2;
-				cuLeft = CLIP<int>(cuLeft, 0, MAX(0, vpScreen.width()  - cuW));
-				cuTop  = CLIP<int>(cuTop,  0, MAX(0, vpScreen.height() - cuH));
+				int cuLeft;
+				int cuTop;
+				if (!pp.placedDstRect.isEmpty()) {
+					cuLeft = pp.placedDstRect.left;
+					cuTop  = pp.placedDstRect.top;
+				} else {
+					const int pieceW = pp.rotateSurfaces[pp.curRotation].w;
+					const int pieceH = pp.rotateSurfaces[pp.curRotation].h;
+					cuLeft = pp.gameRect.left + pieceW / 2 - cuW / 2;
+					cuTop  = pp.gameRect.top  + pieceH / 2 - cuH / 2;
+					cuLeft = CLIP<int>(cuLeft, 0, MAX(0, vpScreen.width()  - cuW));
+					cuTop  = CLIP<int>(cuTop,  0, MAX(0, vpScreen.height() - cuH));
+				}
 				pp.gameRect = Common::Rect(cuLeft, cuTop, cuLeft + cuW, cuTop + cuH);
 			} else {
 				// Direct drag on first click.
@@ -710,24 +753,54 @@ bool MultiBuildPuzzle::updateSolveFlags() {
 	}
 	total += (uint16)(_pieces.size() - _numPieces);
 
+	// The cancel scene's flag is the "enough ingredients" flag that enables the
+	// BAKE option in the neighbouring scene. It is raised at/above the threshold.
+	// Below the threshold, Nancy 10 leaves it latched (so backing away from the
+	// counter and stepping back up keeps BAKE available), while Nancy 11+ clears
+	// it. Written only on a value change (see member comment).
+	if (_cancelScene._flag.label != kFlagNoLabel) {
+		const bool enough = total >= _requiredPieces;
+		if (enough || g_nancy->getGameType() >= kGameTypeNancy11) {
+			byte want = enough ? _cancelScene._flag.flag
+				: (_cancelScene._flag.flag == g_nancy->_false ? g_nancy->_true : g_nancy->_false);
+			if ((int)want != _minCountFlagLastValue) {
+				NancySceneState.setEventFlag(_cancelScene._flag.label, want);
+				_minCountFlagLastValue = want;
+			}
+		}
+	}
+
 	if (total < _requiredPieces)
 		return false;
 
+	// The solve scene's flag marks an exact recipe match. It is cleared until
+	// every ingredient count matches; only then is it raised to its true value.
+	bool exact = true;
 	for (uint i = 0; i < _numPieces; ++i) {
-		if (_pieces[i].placeCount > 0 && _pieces[i].mustNotPlace > 0)
-			return false;
+		if (_pieces[i].placeCount > 0 && _pieces[i].mustNotPlace > 0) {
+			exact = false;
+			break;
+		}
 		// mustPlace is an exact required count only when non-zero. A zero
 		// mustPlace means the piece has no count requirement (e.g. cake
 		// cooking, where the win rule is just "place enough good ingredients
 		// and no bad ones"); placing it must not fail the check.
-		if (_pieces[i].mustPlace > 0 && _pieces[i].placeCount != _pieces[i].mustPlace)
-			return false;
+		if (_pieces[i].mustPlace > 0 && _pieces[i].placeCount != _pieces[i].mustPlace) {
+			exact = false;
+			break;
+		}
 	}
 
-	// Cancel flag is owned by kActionTrigger so it doesn't retrigger per drop
-	NancySceneState.setEventFlag(_solveScene._flag);
+	if (_solveScene._flag.label != kFlagNoLabel) {
+		byte want = exact ? _solveScene._flag.flag
+			: (_solveScene._flag.flag == g_nancy->_false ? g_nancy->_true : g_nancy->_false);
+		if ((int)want != _solveFlagLastValue) {
+			NancySceneState.setEventFlag(_solveScene._flag.label, want);
+			_solveFlagLastValue = want;
+		}
+	}
 
-	return true;
+	return exact;
 }
 
 void MultiBuildPuzzle::checkIfSolved() {
diff --git a/engines/nancy/action/puzzle/multibuildpuzzle.h b/engines/nancy/action/puzzle/multibuildpuzzle.h
index 1078efadc09..0b368b7a0ec 100644
--- a/engines/nancy/action/puzzle/multibuildpuzzle.h
+++ b/engines/nancy/action/puzzle/multibuildpuzzle.h
@@ -88,12 +88,21 @@ protected:
 	bool _hasCloseupImage = false;
 
 	// Nancy 10 additions
-	bool _retainState = false;      // TODO: state-persistence not yet wired up
+	// Parsed for correct chunk alignment but not acted upon: no shipping N10/N11
+	// MultiBuild puzzle sets it (cake mixing and cake cooking both have it clear),
+	// so the save/restore of placement counts is deliberately not implemented.
+	bool _retainState = false;
 	Common::Path _animImageName;    // Completion animation sprite sheet
 	bool _hasAnimImage = false;
 	Common::Rect _animRect;
 	int16 _animLayout[4] = {};      // cols / framesPerStep / spacing / totalRows
-	SoundDescription _animSound;    // Sound played during the animation
+
+	// Played when an ingredient is dropped outside a valid area (thrown away).
+	// The caption uses the CONVO lookup of _missedTextKey, falling back to the
+	// raw _missedText.
+	SoundDescription _missedSound;
+	Common::String _missedTextKey;
+	Common::String _missedText;
 
 	uint16 _numPieces = 0;
 	uint16 _requiredPieces = 0;        // Minimum placed pieces (counterByte==0) for solve check
@@ -103,6 +112,7 @@ protected:
 	int16 _rotHotspotHeight = 0;
 	int16 _rotHotspotWidth = 0;
 	bool _allowAltZoneSnap = false;    // Allow drop outside target zone if stacking on a moved piece
+	uint8 _altZoneSnapMode = 0;        // Raw value: 2 = "add ingredient" mode (cake), count via placeCount, no counter-spawn
 	bool _checkOverlapOnDrop = false;  // Reject drop if it overlaps an already-placed piece
 
 	Common::Array<Piece> _pieces;
@@ -134,6 +144,12 @@ protected:
 	bool _isSolved = false;
 	bool _isCancelled = false;
 
+	// Event-flag write tracking: the original re-writes the solve flags on every
+	// drop, but re-writing an unchanged flag can re-trigger scene voice lines, so
+	// we only write on an actual value change.
+	int _minCountFlagLastValue = -1;  // last value written to the cancel-scene "enough ingredients" flag
+	int _solveFlagLastValue = -1;     // last value written to the solve-scene flag (-1 = never)
+
 	enum SolveState {
 		kIdle           = 0,
 		kWaitTimer      = 1,


Commit: 2868a2a1a5f7a9eab5938147771c56f01c2b9cbb
    https://github.com/scummvm/scummvm/commit/2868a2a1a5f7a9eab5938147771c56f01c2b9cbb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-30T03:05:12+03:00

Commit Message:
NANCY: Add helper functions for subtitle handling in puzzles

Changed paths:
    engines/nancy/action/puzzle/assemblypuzzle.cpp
    engines/nancy/action/puzzle/cardgamepuzzle.cpp
    engines/nancy/action/puzzle/cardgamepuzzle.h
    engines/nancy/action/puzzle/hamradiopuzzle.cpp
    engines/nancy/action/puzzle/multibuildpuzzle.cpp
    engines/nancy/action/puzzle/onebuildpuzzle.cpp
    engines/nancy/action/puzzle/quizpuzzle.cpp
    engines/nancy/action/puzzle/quizpuzzle.h
    engines/nancy/action/puzzle/riddlepuzzle.cpp
    engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
    engines/nancy/action/puzzle/sewingmachinepuzzle.h
    engines/nancy/action/puzzle/soundmatchpuzzle.cpp
    engines/nancy/util.cpp
    engines/nancy/util.h


diff --git a/engines/nancy/action/puzzle/assemblypuzzle.cpp b/engines/nancy/action/puzzle/assemblypuzzle.cpp
index c4fd6353294..bc143d3f089 100644
--- a/engines/nancy/action/puzzle/assemblypuzzle.cpp
+++ b/engines/nancy/action/puzzle/assemblypuzzle.cpp
@@ -137,8 +137,7 @@ void AssemblyPuzzle::execute() {
 
 		g_nancy->_sound->loadSound(_solveSound);
 		g_nancy->_sound->playSound(_solveSound);
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().addTextLine(_solveText);
+		showSubtitle(_solveText);
 		NancySceneState.setEventFlag(_solveScene._flag);
 		_completed = true;
 
diff --git a/engines/nancy/action/puzzle/cardgamepuzzle.cpp b/engines/nancy/action/puzzle/cardgamepuzzle.cpp
index f634c710cab..3dfb7cbe745 100644
--- a/engines/nancy/action/puzzle/cardgamepuzzle.cpp
+++ b/engines/nancy/action/puzzle/cardgamepuzzle.cpp
@@ -498,21 +498,8 @@ void CardGamePuzzle::playVoice(const Common::String &name) {
 	g_nancy->_sound->loadSound(_voiceSound);
 	g_nancy->_sound->playSound(_voiceSound);
 
-	showSubtitle(name);
-}
-
-// The card-game lines carry no inline caption; look the subtitle up by sound name in the Autotext table.
-void CardGamePuzzle::showSubtitle(const Common::String &soundName) {
-	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
-	if (!autotext) {
-		return;
-	}
-
-	Common::String text = autotext->texts.getValOrDefault(soundName, "");
-	if (!text.empty()) {
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().addTextLine(text);
-	}
+	// The card-game lines carry no inline caption; look the subtitle up by sound name.
+	showSubtitle(resolveSubtitleText(name));
 }
 
 void CardGamePuzzle::init() {
diff --git a/engines/nancy/action/puzzle/cardgamepuzzle.h b/engines/nancy/action/puzzle/cardgamepuzzle.h
index be2ce219dcb..1bda848e24d 100644
--- a/engines/nancy/action/puzzle/cardgamepuzzle.h
+++ b/engines/nancy/action/puzzle/cardgamepuzzle.h
@@ -81,7 +81,6 @@ protected:
 	// Compare side 1's grid against a pre-move snapshot and start sliding the changed cards.
 	void startMoveAnimation(const bool beforeGrid[kMaxRows][kMaxCols]);
 	void playVoice(const Common::String &name); // play a voiced line / SFX on the card-game channel
-	void showSubtitle(const Common::String &soundName); // push the line's AUTOTEXT caption to the textbox
 
 	Common::Path _imageName;
 
diff --git a/engines/nancy/action/puzzle/hamradiopuzzle.cpp b/engines/nancy/action/puzzle/hamradiopuzzle.cpp
index 41db1edcc2c..2d2eb10c8fc 100644
--- a/engines/nancy/action/puzzle/hamradiopuzzle.cpp
+++ b/engines/nancy/action/puzzle/hamradiopuzzle.cpp
@@ -174,11 +174,7 @@ void HamRadioPuzzle::CCSound::loadAndPlay() {
 	g_nancy->_sound->loadSound(sound);
 	g_nancy->_sound->playSound(sound);
 
-	if (text.size() && ConfMan.getBool("subtitles")) {
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().addTextLine(text);
-		NancySceneState.getTextbox().drawTextbox();
-	}
+	showSubtitle(text, true);
 }
 
 void HamRadioPuzzle::Frequency::readData(Common::SeekableReadStream &stream, uint16 numDigits) {
@@ -285,12 +281,7 @@ void HamRadioPuzzle::execute() {
 					_curMorseString.clear();
 					_badLetterSound.loadAndPlay();
 				} else {
-					if (ConfMan.getBool("subtitles")) {
-						NancySceneState.getTextbox().clear();
-						NancySceneState.getTextbox().addTextLine(_curMorseString);
-						NancySceneState.getTextbox().setOverrideFont(3); // Original engine pushes <f3> tag instead
-						NancySceneState.getTextbox().drawTextbox();
-					}
+					showSubtitle(_curMorseString, true, 3); // Original engine pushes <f3> tag instead
 				}
 
 				break;
diff --git a/engines/nancy/action/puzzle/multibuildpuzzle.cpp b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
index e1b6aab1dae..17031a3378c 100644
--- a/engines/nancy/action/puzzle/multibuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
@@ -567,6 +567,7 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 				pp.gameRect = pp.homeRect;
 				if (isNancy10 && _missedSound.name != "NO SOUND") {
 					g_nancy->_sound->playSound(_missedSound);
+					showSubtitle(resolveSubtitleText(_missedTextKey, _missedText, "CONVO"));
 					if (_solveState == kIdle) {
 						_solveState = kWaitTimer;
 						_timerEnd = g_system->getMillis() + 200;
@@ -834,26 +835,9 @@ void MultiBuildPuzzle::checkIfSolved() {
 
 	_isSolved = true;
 	g_nancy->_sound->playSound(_solveSound);
-
-	// Caption: prefer the CONVO lookup of the text key. An empty lookup
-	// result means audio-only — keep the textbox empty. Only fall back to
-	// the raw _solveText when the key isn't in CONVO.
-	Common::String textToShow;
-	bool useLookup = false;
-	if (!_solveTextKey.empty()) {
-		const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
-		if (convo && convo->texts.contains(_solveTextKey)) {
-			textToShow = convo->texts[_solveTextKey];
-			useLookup = true;
-		}
-	}
-	if (!useLookup)
-		textToShow = _solveText;
-	if (!textToShow.empty()) {
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().addTextLine(textToShow);
-	}
-
+	// A CONVO key that resolves to an empty string means audio-only; otherwise fall
+	// back to the raw caption when the key isn't in CONVO.
+	showSubtitle(resolveSubtitleText(_solveTextKey, _solveText, "CONVO"));
 	_solveState = kWaitSolveSound;
 }
 
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.cpp b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
index 9c35a9c611e..1a5d0bc6603 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -21,7 +21,6 @@
 
 #include "common/random.h"
 #include "common/system.h"
-#include "common/config-manager.h"
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/graphics.h"
@@ -325,9 +324,6 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _dropAlt1Filename);
 	readFilename(stream, _dropAlt2Filename);
 
-	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
-	assert(autotext);
-
 	_goodPlacementSound.readNormal(stream);
 	readFilename(stream, _goodAlt1Filename);
 	readFilename(stream, _goodAlt2Filename);
@@ -352,9 +348,7 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	char textBuf[200];
 	readFilename(stream, completionKey);
 	stream.read(textBuf, 200);
-	_completionText.clear();
-	if (!completionKey.empty() && autotext->texts.contains(completionKey))
-		_completionText = autotext->texts[completionKey];
+	_completionText = resolveSubtitleText(completionKey);
 
 	_cancelScene.readData(stream);
 	readRect(stream, _exitHotspot);
@@ -416,10 +410,7 @@ void OneBuildPuzzle::execute() {
 			// Play completion sound/text, then wait for it to finish
 			g_nancy->_sound->loadSound(_completionSound);
 			g_nancy->_sound->playSound(_completionSound);
-			if (!_completionText.empty() && ConfMan.getBool("subtitles")) {
-				NancySceneState.getTextbox().clear();
-				NancySceneState.getTextbox().addTextLine(_completionText);
-			}
+			showSubtitle(_completionText);
 			_solveState = kWaitCompletion;
 			break;
 		case kAnimateFinal:
@@ -616,9 +607,6 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 // --- Internal helpers ---
 
 void OneBuildPuzzle::readPlacementTexts(Common::SeekableReadStream &stream, Common::Array<Common::String> &out) {
-	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
-	assert(autotext);
-
 	Common::String keys[3];
 	for (uint i = 0; i < 3; ++i)
 		readFilename(stream, keys[i]);
@@ -627,10 +615,9 @@ void OneBuildPuzzle::readPlacementTexts(Common::SeekableReadStream &stream, Comm
 	out.resize(3);
 	for (uint i = 0; i < 3; ++i) {
 		stream.read(textBuf, 200);
-		if (!keys[i].empty() && autotext->texts.contains(keys[i]))
-			out[i] = autotext->texts[keys[i]];
-		else
-			assembleTextLine(textBuf, out[i], 200);
+		Common::String literal;
+		assembleTextLine(textBuf, literal, 200);
+		out[i] = resolveSubtitleText(keys[i], literal);
 	}
 }
 
@@ -818,10 +805,7 @@ void OneBuildPuzzle::playGoodPlacementSound() {
 		idx = 0;
 	g_nancy->_sound->loadSound(_currentSound);
 	g_nancy->_sound->playSound(_currentSound);
-	if (!_goodTexts[idx].empty() && ConfMan.getBool("subtitles")) {
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().addTextLine(_goodTexts[idx]);
-	}
+	showSubtitle(_goodTexts[idx]);
 	_solveState = kWaitPlaceSound;
 	_timerEnd = g_system->getMillis() + 1000;
 }
@@ -837,10 +821,7 @@ void OneBuildPuzzle::playBadPlacementSound() {
 		idx = 0;
 	g_nancy->_sound->loadSound(_currentSound);
 	g_nancy->_sound->playSound(_currentSound);
-	if (!_badTexts[idx].empty() && ConfMan.getBool("subtitles")) {
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().addTextLine(_badTexts[idx]);
-	}
+	showSubtitle(_badTexts[idx]);
 	_solveState = kWaitPlaceSound;
 	_timerEnd = g_system->getMillis() + 1000;
 }
diff --git a/engines/nancy/action/puzzle/quizpuzzle.cpp b/engines/nancy/action/puzzle/quizpuzzle.cpp
index 6ab64370cc9..e86f4dc367d 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.cpp
+++ b/engines/nancy/action/puzzle/quizpuzzle.cpp
@@ -55,30 +55,6 @@ void QuizPuzzle::init() {
 	RenderActionRecord::init();
 }
 
-Common::String QuizPuzzle::readSubtitle(Common::SeekableReadStream &stream) {
-	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
-	assert(autotext);
-
-	Common::String result;
-	char textBuf[30];
-
-	stream.read(textBuf, 30);
-	textBuf[29] = '\0';
-	result = textBuf;
-
-	if (!result.empty() && autotext->texts.contains(result))
-		result = autotext->texts[result];
-
-	return result;
-}
-
-void QuizPuzzle::showSubtitle(const Common::String &text) {
-	if (!text.empty()) {
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().addTextLine(text);
-	}
-}
-
 // ---- Nancy 8 data format ----
 // Offset  Size  Field
 // 0x000   2     fontID
@@ -114,14 +90,14 @@ void QuizPuzzle::readDataOld(Common::SeekableReadStream &stream) {
 	}
 
 	_correctSound.readNormal(stream);
-	_correctText = readSubtitle(stream);
+	_correctText = readSubtitleText(stream);
 
 	_wrongSound.readNormal(stream);
-	_wrongText = readSubtitle(stream);
+	_wrongText = readSubtitleText(stream);
 
 	_solveScene.readData(stream);
 	_doneSound.readNormal(stream);
-	_doneText = readSubtitle(stream);
+	_doneText = readSubtitleText(stream);
 
 	_cancelScene.readData(stream);
 }
@@ -153,9 +129,6 @@ void QuizPuzzle::readDataOld(Common::SeekableReadStream &stream) {
 // +0xB0  2   wrong sound volume
 // +0xB2  30  wrong subtitle (skip)
 void QuizPuzzle::readDataNew(Common::SeekableReadStream &stream) {
-	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
-	assert(autotext);
-
 	_fontID            = stream.readUint16LE();
 	_cursorBlinkInterval = stream.readUint16LE();
 	_cursorChar        = stream.readByte();
@@ -167,7 +140,7 @@ void QuizPuzzle::readDataNew(Common::SeekableReadStream &stream) {
 
 	_solveScene.readData(stream);
 	_doneSound.readNormal(stream);
-	_doneText = readSubtitle(stream);
+	_doneText = readSubtitleText(stream);
 
 	_cancelScene.readData(stream);
 	readRect(stream, _exitHotspot);
@@ -200,13 +173,13 @@ void QuizPuzzle::readDataNew(Common::SeekableReadStream &stream) {
 		soundNameBuf[32] = '\0';
 		_boxCorrectSoundName[i] = soundNameBuf;
 		_boxCorrectSoundVolume[i] = stream.readUint16LE();
-		_boxCorrectText[i] = readSubtitle(stream);
+		_boxCorrectText[i] = readSubtitleText(stream);
 
 		stream.read(soundNameBuf, 33);
 		soundNameBuf[32] = '\0';
 		_boxWrongSoundName[i] = soundNameBuf;
 		_boxWrongSoundVolume[i] = stream.readUint16LE();
-		_boxWrongText[i] = readSubtitle(stream);
+		_boxWrongText[i] = readSubtitleText(stream);
 
 		// Precompute max answer length for auto-check mode
 		_boxMaxLen[i] = 0;
diff --git a/engines/nancy/action/puzzle/quizpuzzle.h b/engines/nancy/action/puzzle/quizpuzzle.h
index 9d5371ebddb..d95a3d5bf30 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.h
+++ b/engines/nancy/action/puzzle/quizpuzzle.h
@@ -61,9 +61,6 @@ private:
 	bool checkAllSolved() const;
 	bool checkAnswerForCurrentBox(); // checks, marks correct, sets event flag
 
-	Common::String readSubtitle(Common::SeekableReadStream &stream);
-	void showSubtitle(const Common::String &text);
-
 	// ---- Data (Nancy 8) ----
 	uint16 _fontID = 0;
 	uint16 _cursorBlinkInterval = 500;
diff --git a/engines/nancy/action/puzzle/riddlepuzzle.cpp b/engines/nancy/action/puzzle/riddlepuzzle.cpp
index 76b5a586603..5ad797bae86 100644
--- a/engines/nancy/action/puzzle/riddlepuzzle.cpp
+++ b/engines/nancy/action/puzzle/riddlepuzzle.cpp
@@ -138,9 +138,7 @@ void RiddlePuzzle::execute() {
 
 		g_nancy->_sound->loadSound(_riddles[_riddleID].sound);
 		g_nancy->_sound->playSound(_riddles[_riddleID].sound);
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().setOverrideFont(_textboxTextFontID);
-		NancySceneState.getTextbox().addTextLine(_riddles[_riddleID].text);
+		showSubtitle(_riddles[_riddleID].text, false, _textboxTextFontID);
 		NancySceneState.setNoHeldItem();
 
 		_state = kRun;
diff --git a/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp b/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
index 5cde462aca9..03a0d54e298 100644
--- a/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
+++ b/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
@@ -101,30 +101,12 @@ void SewingMachinePuzzle::playSoundBlock(const RandomSoundBlock &block) {
 	g_nancy->_sound->loadSound(desc);
 	g_nancy->_sound->playSound(desc);
 
-	showSubtitle(name);
-}
-
-void SewingMachinePuzzle::showSubtitle(const Common::String &soundName) {
 	// The mistake lines carry no inline caption; look the subtitle up by sound name,
 	// first in the Autotext table, then in the conversation table.
-	Common::String text;
-
-	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
-	if (autotext) {
-		text = autotext->texts.getValOrDefault(soundName, "");
-	}
-
-	if (text.empty()) {
-		const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
-		if (convo) {
-			text = convo->texts.getValOrDefault(soundName, "");
-		}
-	}
-
-	if (!text.empty()) {
-		NancySceneState.getTextbox().clear();
-		NancySceneState.getTextbox().addTextLine(text);
-	}
+	Common::String text = resolveSubtitleText(name);
+	if (text.empty())
+		text = resolveSubtitleText(name, Common::String(), "CONVO");
+	showSubtitle(text);
 }
 
 Common::Point SewingMachinePuzzle::needleInStrip() const {
diff --git a/engines/nancy/action/puzzle/sewingmachinepuzzle.h b/engines/nancy/action/puzzle/sewingmachinepuzzle.h
index 0eb508b12a4..7fb43e7807a 100644
--- a/engines/nancy/action/puzzle/sewingmachinepuzzle.h
+++ b/engines/nancy/action/puzzle/sewingmachinepuzzle.h
@@ -56,9 +56,6 @@ protected:
 	void classifyZones();
 	// Plays one entry of a random-sound block (needle/stitch cues).
 	void playSoundBlock(const RandomSoundBlock &block);
-	// Shows a played voice line's subtitle, looked up by sound name in the CVTX text
-	// chunks (Autotext, then Convo).
-	void showSubtitle(const Common::String &soundName);
 	// Draws the visible part of the cloth strip plus the stitches sewn so far.
 	void drawCloth();
 
diff --git a/engines/nancy/action/puzzle/soundmatchpuzzle.cpp b/engines/nancy/action/puzzle/soundmatchpuzzle.cpp
index cfd6a343b3a..787337d73bb 100644
--- a/engines/nancy/action/puzzle/soundmatchpuzzle.cpp
+++ b/engines/nancy/action/puzzle/soundmatchpuzzle.cpp
@@ -217,9 +217,7 @@ void SoundMatchPuzzle::handleInput(NancyInput &input) {
 					g_nancy->_sound->playSound(_soundButtons[i].sound);
 				}
 
-				NancySceneState.getTextbox().clear();
-				if (!_soundButtons[i].text.empty())
-					NancySceneState.getTextbox().addTextLine(_soundButtons[i].text);
+				showSubtitle(_soundButtons[i].text);
 
 				_solveSubState = kSoundPlaying;
 				redraw();
diff --git a/engines/nancy/util.cpp b/engines/nancy/util.cpp
index 971d90a6df4..d5af5a7341c 100644
--- a/engines/nancy/util.cpp
+++ b/engines/nancy/util.cpp
@@ -21,6 +21,11 @@
 #include "engines/nancy/enginedata.h"
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/ui/textbox.h"
+
+#include "common/config-manager.h"
 #include "common/system.h"
 
 namespace Nancy {
@@ -344,6 +349,42 @@ void assembleTextLine(char *rawCaption, Common::String &output, uint size) {
 	}
 }
 
+Common::String resolveSubtitleText(const Common::String &keyOrText, const Common::String &fallback, const char *tableID) {
+	if (!keyOrText.empty()) {
+		const CVTX *table = (const CVTX *)g_nancy->getEngineData(tableID);
+		if (table && table->texts.contains(keyOrText)) {
+			return table->texts[keyOrText];
+		}
+	}
+
+	return fallback;
+}
+
+Common::String readSubtitleText(Common::SeekableReadStream &stream) {
+	char buf[30];
+	stream.read(buf, sizeof(buf));
+	buf[sizeof(buf) - 1] = '\0';
+	Common::String text(buf);
+
+	return resolveSubtitleText(text, text);
+}
+
+void showSubtitle(const Common::String &text, bool forceRedraw, int overrideFontID) {
+	if (text.empty() || !ConfMan.getBool("subtitles")) {
+		return;
+	}
+
+	UI::Textbox &textbox = NancySceneState.getTextbox();
+	textbox.clear();
+	if (overrideFontID >= 0) {
+		textbox.setOverrideFont(overrideFontID);
+	}
+	textbox.addTextLine(text);
+	if (forceRedraw) {
+		textbox.drawTextbox();
+	}
+}
+
 bool DeferredLoader::load(uint32 endTime) {
 	uint32 loopStartTime = g_system->getMillis();
 	uint32 loopTime = 0; // Stores the loop that took the longest time to complete
diff --git a/engines/nancy/util.h b/engines/nancy/util.h
index 2d62fde1269..0fe835a4c2b 100644
--- a/engines/nancy/util.h
+++ b/engines/nancy/util.h
@@ -60,6 +60,22 @@ void readFilenameArray(Common::Serializer &stream, Common::Array<Common::Path> &
 
 void assembleTextLine(char *rawCaption, Common::String &output, uint size);
 
+// Resolves a subtitle/caption string that may be a key into an engine-data CVTX text
+// table (AUTOTEXT by default, CONVO for some puzzles). Returns the table's entry for
+// `keyOrText` when the table exists and contains that key, otherwise returns `fallback`.
+Common::String resolveSubtitleText(const Common::String &keyOrText, const Common::String &fallback = Common::String(), const char *tableID = "AUTOTEXT");
+
+// Reads a 30-byte, NUL-terminated subtitle string from `stream` and resolves it as an
+// AUTOTEXT key, falling back to the literal text when the key is not present in the table.
+Common::String readSubtitleText(Common::SeekableReadStream &stream);
+
+// Shows `text` as a single line in the game textbox, replacing its current contents.
+// Does nothing when `text` is empty or when the player has subtitles disabled. A
+// non-negative `overrideFontID` selects a font other than the textbox default. When
+// `forceRedraw` is true, the textbox is redrawn immediately instead of on the next
+// render pass.
+void showSubtitle(const Common::String &text, bool forceRedraw = false, int overrideFontID = -1);
+
 void readUIButton(Common::SeekableReadStream &stream, UIButtonRecord &dst);
 void readUISlider(Common::SeekableReadStream &stream, UISliderRecord &dst);
 void readUIPopupHeader(Common::SeekableReadStream &stream, UIPopupHeader &dst);




More information about the Scummvm-git-logs mailing list