[Scummvm-git-logs] scummvm master -> 7b6621918722eaa67d25b0c6b6da4f3fe4814412

bluegr noreply at scummvm.org
Mon Aug 31 02:11:48 UTC 2026


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

Summary:
6a7ab91626 NANCY: NANCY13: Implement new functionality in OneBuildPuzzle
ff197ccdfe NANCY: NANCY13: Fix solving and exiting the second ScalePuzzle
07fd83104e NANCY: NANCY14: Move the list of loaded movies inside NancyEngine
be9669a037 NANCY: NANCY13: Use the correct cursor variant in puzzles
c32bcb8f14 NANCY: NANCY13: Use the correct cursor variant for ScalePuzzle
250ca11aa9 NANCY: NANCY13: Fix solving TurningPuzzle
d52a834235 NANCY: NANCY14: Implement the CameraAction AR
7b66219187 NANCY: NANCY14: Skip playing known broken overlay animation


Commit: 6a7ab9162642b9d9613bebd89d6ece4c3f7929a1
    https://github.com/scummvm/scummvm/commit/6a7ab9162642b9d9613bebd89d6ece4c3f7929a1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-31T05:11:19+03:00

Commit Message:
NANCY: NANCY13: Implement new functionality in OneBuildPuzzle

Fixes Camille's dolls puzzle

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 d6888327e23..a925791376f 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -36,6 +36,12 @@
 namespace Nancy {
 namespace Action {
 
+// Does `inner` sit inside `outer`, allowing `tolerance` of overhang per side?
+static bool rectFitsIn(const Common::Rect &inner, const Common::Rect &outer, int16 tolerance) {
+	return inner.left >= outer.left - tolerance && inner.top >= outer.top - tolerance &&
+			inner.right <= outer.right + tolerance && inner.bottom <= outer.bottom + tolerance;
+}
+
 void OneBuildPuzzle::init() {
 	g_nancy->_resource->loadImage(_imageName, _image);
 	_image.setTransparentColor(_drawSurface.getTransparentColor());
@@ -124,10 +130,18 @@ void OneBuildPuzzle::init() {
 			// (top == bottom), which means "start scattered": the original
 			// init picks a random spot inside the home-scatter zone. Without
 			// this, such pieces get a zero-height rect and are invisible.
-			if (g_nancy->getGameType() >= kGameTypeNancy12 && p.homeRect.top == p.homeRect.bottom)
+			if (g_nancy->getGameType() >= kGameTypeNancy12 && p.homeRect.top == p.homeRect.bottom) {
 				scatterPiece(p);
-			else
+			} else {
 				p.gameRect = p.homeRect;
+
+				// A piece that already starts inside its own slot, at the
+				// rotation the slot calls for, counts as placed from the outset.
+				if (g_nancy->getGameType() >= kGameTypeNancy13 &&
+						p.defaultRotation == p.requiredRotation &&
+						rectFitsIn(p.homeRect, p.slotRect, _slotTolerance))
+					p.placed = true;
+			}
 		}
 
 		p.setVisible(true);
@@ -139,6 +153,8 @@ void OneBuildPuzzle::init() {
 	if (_countMode != kCountAllPieces)
 		updateCounter();
 
+	_closeupDisplay.setVisible(false);
+
 	_isInitialized = true;
 }
 
@@ -154,20 +170,36 @@ void OneBuildPuzzle::registerGraphics() {
 
 	if (_countMode != kCountAllPieces)
 		_counterDisplay.registerGraphics();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy13)
+		_closeupDisplay.registerGraphics();
 }
 
 // Nancy12 (AR 166) reworked OneBuildPuzzle onto the shared PuzzleBase loader:
 // a fixed 513-byte header blob, followed by six variable-length "random sound"
 // blocks, then a variable-count array of the same 66-byte piece records used by
 // the older games. The piece array is no longer a fixed 20-slot block.
+// Nancy13 shrinks the blob to 470 bytes (the exit hotspot and cancel scene move
+// into the shared hotspot block that follows), appends a seventh sound block for
+// the close-up, and grows the piece records to 99 bytes.
 void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
-	// --- PuzzleBase header blob (513 bytes) ---
+	const bool isNancy13 = g_nancy->getGameType() >= kGameTypeNancy13;
+
+	// --- PuzzleBase header blob (513 bytes; 470 in Nancy13) ---
 	readFilename(stream, _imageName);       // 0x00
 	_freePlacement = stream.readByte();     // 0x21
 	_canRotateAll = stream.readByte();      // 0x22
-	stream.skip(6);                         // 0x23: rotation/zone config
+	stream.skip(5);                         // 0x23: rotation/grab-zone config
+	_dropMode = (DropMode)stream.readByte(); // 0x28
 	_slotTolerance = stream.readSint16LE(); // 0x29
 
+	// The plain drop mode ignores the stored tolerance and demands an exact fit.
+	if (_dropMode == kDropNormal)
+		_slotTolerance = 0;
+
+	// Only Nancy13 allows extra slop on top of the tolerance.
+	_dropSlop = isNancy13 ? kDropSlop : 0;
+
 	_placementMode = (PlacementMode)stream.readByte(); // 0x2b
 	_countMode = (CountMode)stream.readByte();         // 0x2c
 	stream.skip(1);                                    // 0x2d: percentage flag
@@ -187,17 +219,15 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	readRect(stream, _scatterZone);         // 0xea..0xf9
 
 	readRect(stream, _placementZone);       // 0xfa: a piece may only be released in here
-	readRect(stream, _exitHotspot);         // 0x10a
 
-	// Nancy 13 reuses this record type with a rearranged header, so the bytes
-	// read above aren't rects there. Drop one that can't be a hotspot instead of
-	// handing it to the viewport, which clips (and asserts on) what it is given.
-	if (!_exitHotspot.isValidRect())
-		_exitHotspot = Common::Rect();
+	if (!isNancy13)
+		readRect(stream, _exitHotspot);     // 0x10a
 
-	_pieceCursorType = stream.readSint16LE();     // 0x11a
-	_heldPieceCursorType = stream.readSint16LE(); // 0x11c
-	stream.skip(2);                               // 0x11e: exit cursor, always _puzzleExitCursor
+	_pieceCursorType = stream.readSint16LE();     // 0x11a (0x10a in Nancy13)
+	_heldPieceCursorType = stream.readSint16LE(); // 0x11c (0x10c in Nancy13)
+
+	if (!isNancy13)
+		stream.skip(2);                     // 0x11e: exit cursor, always _puzzleExitCursor
 
 	readFilename(stream, _extraSoundName);  // 0x120: final-animation atlas image
 	readRect(stream, _animRectA);           // 0x141
@@ -209,17 +239,45 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	_hasFinalAnim = !_animRectA.isEmpty();
 	_hasCrank = !_animRectB.isEmpty();
 
-	_solveScene.readData(stream);           // 0x1cf
-	_cancelScene.readData(stream);          // 0x1e8 (ends the 513-byte blob)
+	_solveScene.readData(stream);           // 0x1cf (0x1bd in Nancy13, where it ends the blob)
+
+	if (isNancy13) {
+		// Shared hotspot records; the first is the give-up hotspot, which replaces
+		// the header's cancel scene.
+		int16 numZones = stream.readSint16LE();
+		for (int16 i = 0; i < numZones; ++i) {
+			Common::Rect zone;
+			readRect(stream, zone);
+			uint16 cursorType = stream.readUint16LE();
+			uint16 sceneID = stream.readUint16LE();
+			int16 flagLabel = stream.readSint16LE();
+			byte flagValue = stream.readByte();
+
+			if (i == 0) {
+				_exitHotspot = zone;
+				_exitCursorType = cursorType;
+				_cancelScene._sceneChange.sceneID = sceneID;
+				// The field after the scene id is an event-flag label, not a frame.
+				_cancelScene._sceneChange.frameID = 0;
+				_cancelScene._sceneChange.continueSceneSound = kContinueSceneSound;
+				_cancelScene._flag.label = flagLabel;
+				_cancelScene._flag.flag = flagValue;
+			}
+		}
+	} else {
+		_cancelScene.readData(stream);      // 0x1e8 (ends the 513-byte blob)
+	}
 
-	// --- Random-sound blocks: pickup, rotate, drop, good, bad, completion ---
-	RandomSoundBlock blocks[kNumSounds];
-	for (uint i = 0; i < kNumSounds; ++i)
+	// --- Random-sound blocks: pickup, rotate, drop, good, bad, completion, close-up ---
+	const uint numSoundBlocks = isNancy13 ? kNumSoundsNancy13 : kNumSounds;
+	RandomSoundBlock blocks[kNumSoundsNancy13];
+	for (uint i = 0; i < numSoundBlocks; ++i)
 		blocks[i].readData(stream);
 
-	SoundDescription *sounds[kNumSounds] = { &_pickupSound, &_rotateSound, &_dropSound,
-											 &_goodPlacementSound, &_badPlacementSound, &_completionSound };
-	for (uint i = 0; i < kNumSounds; ++i) {
+	SoundDescription *sounds[kNumSoundsNancy13] = { &_pickupSound, &_rotateSound, &_dropSound,
+													&_goodPlacementSound, &_badPlacementSound,
+													&_completionSound, &_closeupSound };
+	for (uint i = 0; i < numSoundBlocks; ++i) {
 		SoundDescription &s = *sounds[i];
 		s.name = blocks[i].names.empty() ? "NO SOUND" : blocks[i].names[0];
 		s.channelID = blocks[i].channel;
@@ -264,6 +322,14 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 
 		readRect(stream, p.slotRect);
 		readRect(stream, p.homeRect);
+
+		if (isNancy13) {
+			// Close-up view: the source region, and where it is drawn.
+			readRect(stream, p.closeupSrcRect);
+			readRect(stream, p.closeupDestRect);
+			p.hasCloseupSound = stream.readByte() != 0;
+		}
+
 		p.defaultRotation = stream.readByte();
 		p.requiredRotation = stream.readByte();
 		p.isPreRotated = p.requiredRotation == kPrePlacedRotation;
@@ -410,6 +476,8 @@ void OneBuildPuzzle::execute() {
 		g_nancy->_sound->loadSound(_goodPlacementSound);
 		g_nancy->_sound->loadSound(_badPlacementSound);
 		g_nancy->_sound->loadSound(_completionSound);
+		if (g_nancy->getGameType() >= kGameTypeNancy13)
+			g_nancy->_sound->loadSound(_closeupSound);
 		_state = kRun;
 		// fall through
 	case kRun:
@@ -523,13 +591,26 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 
 			Piece &piece = _pieces[_pickedUpPiece];
 
+			// Swap mode needs a target: released over empty space the click is
+			// ignored and the piece stays on the cursor. The target is chosen
+			// before the drop is judged, so a correct placement displaces the
+			// occupant too.
+			int16 target = -1;
+			int16 displaced = -1;
+			bool targetIsPiece = false;
+			if (_dropMode == kDropSwap) {
+				target = findDropTarget(piece.gameRect, targetIsPiece);
+				if (target == -1)
+					return;
+
+				if (targetIsPiece)
+					displaced = target;
+			}
+
 			Common::Rect slot = piece.slotRect;
 
 			// Bounding-box must fit within slot +- tolerance.
-			bool nearSlot = (piece.gameRect.left >= slot.left - _slotTolerance &&
-							 piece.gameRect.top  >= slot.top  - _slotTolerance &&
-							 piece.gameRect.right  <= slot.right  + _slotTolerance &&
-							 piece.gameRect.bottom <= slot.bottom + _slotTolerance);
+			bool nearSlot = rectFitsIn(piece.gameRect, slot, _slotTolerance + _dropSlop);
 
 			// A piece only fits at the orientation its slot calls for; a
 			// 180-degree flip keeps the same bounding box, so proximity alone
@@ -553,22 +634,59 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 			} else {
 				_correctlyPlaced = false;
 
-				// In counter mode a slot swallows whatever is dropped into it, so
-				// landing in one that isn't the piece's own costs a mistake. Once
-				// there are more mistakes than the puzzle allows it is lost.
-				if (_placementMode == kPlacementCounter && findSlotAt(piece.gameRect) != -1) {
-					piece.placed = true;
-					++_mistakes;
+				bool restorePosition = true;
+
+				// The piece takes over the target's spot, aligned by its left and
+				// bottom edges; the occupant is handed to the cursor in exchange.
+				if (_dropMode == kDropSwap && !_freePlacement) {
+					const Common::Rect &anchor = targetIsPiece ? _pieces[target].gameRect
+															   : _pieces[target].slotRect;
+					piece.gameRect.left = anchor.left;
+					piece.gameRect.bottom = anchor.bottom;
+					piece.gameRect.right = piece.gameRect.left + _pickedUpWidth;
+					piece.gameRect.top = piece.gameRect.bottom - _pickedUpHeight;
+					restorePosition = false;
+				} else if (_placementMode == kPlacementCounter || _dropMode == kDropAnySlot) {
+					int16 slotIdx = findSlotAt(piece.gameRect);
+
+					if (slotIdx != -1) {
+						restorePosition = false;
+
+						if (_placementMode == kPlacementCounter) {
+							// A slot swallows whatever is dropped into it, so a wrong
+							// one costs a mistake; too many and the puzzle is lost.
+							piece.placed = true;
+							++_mistakes;
+
+							if (_mistakes > _totalPieces - _requiredPieces) {
+								_isCancelled = true;
+								_state = kActionTrigger;
+							}
+						} else {
+							// Otherwise it snaps into the slot it landed in, without
+							// counting as placed.
+							piece.gameRect = _pieces[slotIdx].slotRect;
+						}
+					}
+				}
 
-					if (_mistakes > _totalPieces - _requiredPieces) {
-						_isCancelled = true;
-						_state = kActionTrigger;
+				if (restorePosition) {
+					if (!_freePlacement) {
+						piece.gameRect = _prevDragGameRect;
+					} else {
+						piece.curRotation = piece.defaultRotation;
+						piece.gameRect = piece.homeRect;
 					}
-				} else if (!_freePlacement) {
-					piece.gameRect = _prevDragGameRect;
-				} else {
-					piece.curRotation = piece.defaultRotation;
-					piece.gameRect = piece.homeRect;
+				}
+			}
+
+			if (displaced != -1) {
+				// The occupant has been pushed out of its spot.
+				Piece &other = _pieces[displaced];
+				if (other.placed && _placementMode == kPlacementNormal) {
+					other.placed = false;
+					if (_piecesPlaced > 0)
+						--_piecesPlaced;
 				}
 			}
 
@@ -581,9 +699,15 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 			}
 
 			updatePieceRender(_pickedUpPiece);
-			_isDragging = false;
-			_pickedUpPiece = -1;
 			playDropSound();
+
+			if (displaced != -1) {
+				// Handed to the cursor; the drop sound above covers the swap.
+				pickUpPiece(displaced, false);
+			} else {
+				_isDragging = false;
+				_pickedUpPiece = -1;
+			}
 		}
 		return;
 	}
@@ -599,6 +723,24 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// A close-up swallows the whole viewport: a click anywhere dismisses it and
+	// picks up the piece that opened it, not whatever lies under the cursor.
+	if (_closeupPiece != -1) {
+		setPieceCursor();
+
+		if (_solveState != kIdle)
+			return;
+
+		if (input.input & (NancyInput::kLeftMouseButtonUp | NancyInput::kRightMouseButtonUp)) {
+			int16 pieceIdx = _closeupPiece;
+			bool rightClick = (input.input & NancyInput::kRightMouseButtonUp);
+			playRotateSoundAndStartTimer();
+			closeCloseup();
+			pickUpPiece(pieceIdx, rightClick);
+		}
+		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.
@@ -637,20 +779,13 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 		bool leftClick = (input.input & NancyInput::kLeftMouseButtonUp);
 		bool rightClick = (input.input & NancyInput::kRightMouseButtonUp);
 		if ((leftClick || rightClick) && topmostUnplaced != -1) {
-			_pickedUpPiece = topmostUnplaced;
-
-			Piece &pp = _pieces[_pickedUpPiece];
-			pp.useAltSurface = false;
-
-			if (rightClick)
-				rotatePiece(_pickedUpPiece);
-
-			_isDragging = true;
-			_pickedUpWidth  = pp.rotateSurfaces[pp.curRotation].w;
-			_pickedUpHeight = pp.rotateSurfaces[pp.curRotation].h;
-			pp.setZ((uint16)(_z + (int)_pieces.size() * 2));
-			pp.registerGraphics();
 			playRotateSoundAndStartTimer();
+
+			// A piece with a close-up opens it instead of being picked up.
+			if (!_pieces[topmostUnplaced].closeupDestRect.isEmpty())
+				openCloseup(topmostUnplaced);
+			else
+				pickUpPiece(topmostUnplaced, rightClick);
 		}
 		return;
 	}
@@ -662,7 +797,10 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 	// Check exit hotspot
 	Common::Rect exitScreen = NancySceneState.getViewport().convertViewportToScreen(_exitHotspot);
 	if (exitScreen.contains(input.mousePos)) {
-		g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
+		if (_exitCursorType != 0)
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, true);
+		else
+			g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			_isCancelled = true;
 			_state = kActionTrigger;
@@ -700,6 +838,86 @@ void OneBuildPuzzle::setPieceCursor(bool isHeld) {
 	}
 }
 
+void OneBuildPuzzle::pickUpPiece(int16 pieceIdx, bool rotate) {
+	_pickedUpPiece = pieceIdx;
+
+	Piece &pp = _pieces[_pickedUpPiece];
+	pp.useAltSurface = false;
+
+	if (rotate)
+		rotatePiece(_pickedUpPiece);
+
+	_isDragging = true;
+	_pickedUpWidth  = pp.rotateSurfaces[pp.curRotation].w;
+	_pickedUpHeight = pp.rotateSurfaces[pp.curRotation].h;
+	pp.setZ((uint16)(_z + (int)_pieces.size() * 2));
+	pp.registerGraphics();
+}
+
+// Pieces at their current positions first, at the drop test's slop, then the
+// slots at the plain tolerance. The carried piece never matches itself.
+int16 OneBuildPuzzle::findDropTarget(const Common::Rect &dropRect, bool &isPiece) const {
+	for (uint i = 0; i < _pieces.size(); ++i) {
+		if ((int16)i == _pickedUpPiece)
+			continue;
+
+		if (rectFitsIn(dropRect, _pieces[i].gameRect, _slotTolerance + _dropSlop)) {
+			isPiece = true;
+			return (int16)i;
+		}
+	}
+
+	for (uint i = 0; i < _pieces.size(); ++i) {
+		if (_pieces[i].slotRect.isEmpty())
+			continue;
+
+		if (rectFitsIn(dropRect, _pieces[i].slotRect, _slotTolerance)) {
+			isPiece = false;
+			return (int16)i;
+		}
+	}
+
+	isPiece = false;
+	return -1;
+}
+
+void OneBuildPuzzle::openCloseup(int16 pieceIdx) {
+	const Piece &p = _pieces[pieceIdx];
+
+	_closeupDisplay._drawSurface.create(_image, p.closeupSrcRect);
+	_closeupDisplay.setTransparent(false);
+
+	Common::Rect dest = p.closeupDestRect;
+	const VIEW *viewData = GetEngineData(VIEW);
+	if (viewData)
+		dest.translate(viewData->screenPosition.left, viewData->screenPosition.top);
+
+	_closeupDisplay.moveTo(dest);
+	_closeupDisplay.setVisible(true);
+	_closeupPiece = pieceIdx;
+
+	// Flagged pieces also get a spoken remark, keyed by the sound name.
+	if (p.hasCloseupSound) {
+		g_nancy->_sound->playSound(_closeupSound);
+
+		Common::String text = resolveSubtitleText(_closeupSound.name, Common::String(), "AUTOTEXT");
+		if (text.empty())
+			text = resolveSubtitleText(_closeupSound.name, Common::String(), "CONVO");
+		showSubtitle(text);
+	}
+}
+
+void OneBuildPuzzle::closeCloseup() {
+	if (_closeupPiece == -1)
+		return;
+
+	if (_pieces[_closeupPiece].hasCloseupSound)
+		NancySceneState.getTextbox().clear();
+
+	_closeupDisplay.setVisible(false);
+	_closeupPiece = -1;
+}
+
 void OneBuildPuzzle::updatePieceRender(int pieceIdx) {
 	Piece &p = _pieces[pieceIdx];
 
@@ -835,8 +1053,7 @@ int16 OneBuildPuzzle::findSlotAt(const Common::Rect &rect) const {
 		if (slot.isEmpty())
 			continue;
 
-		if (rect.left >= slot.left - _slotTolerance && rect.top >= slot.top - _slotTolerance &&
-				rect.right <= slot.right + _slotTolerance && rect.bottom <= slot.bottom + _slotTolerance)
+		if (rectFitsIn(rect, slot, _slotTolerance))
 			return (int16)i;
 	}
 
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.h b/engines/nancy/action/puzzle/onebuildpuzzle.h
index 592065d6485..170b6c125d5 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.h
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.h
@@ -35,7 +35,7 @@ namespace Action {
 // Otherwise it returns to its previous position (or home in free placement mode).
 class OneBuildPuzzle : public RenderActionRecord {
 public:
-	OneBuildPuzzle() : RenderActionRecord(7), _finalAnimOverlay(99), _counterDisplay(99) {}
+	OneBuildPuzzle() : RenderActionRecord(7), _finalAnimOverlay(99), _counterDisplay(99), _closeupDisplay(99) {}
 	virtual ~OneBuildPuzzle() {}
 
 	void init() override;
@@ -61,6 +61,16 @@ protected:
 		kPlacementCounter = 2	// A placed piece drops out of sight into its slot
 	};
 
+	// What a drop that isn't a correct placement does.
+	enum DropMode {
+		kDropNormal = 0,	// The piece goes back where it came from
+		kDropAnySlot = 2,	// The piece snaps to whichever slot it landed in
+		kDropSwap = 3		// The piece takes the target's spot and displaces it
+	};
+
+	// Extra slop allowed on top of the slot tolerance when accepting a drop.
+	static const int16 kDropSlop = 7;
+
 	// What the puzzle counts, both to decide when it is finished and to fill in
 	// the on-screen counter.
 	enum CountMode {
@@ -85,6 +95,12 @@ protected:
 		uint8 requiredRotation = 0;
 		bool isPreRotated = false;
 
+		// Nancy13 close-up: clicking the piece shows this region blown up over
+		// the viewport instead of picking it up.
+		Common::Rect closeupSrcRect;
+		Common::Rect closeupDestRect;
+		bool hasCloseupSound = false;
+
 		// Runtime
 		Common::Rect gameRect;      // Current viewport-space rect
 		int curRotation = 0;
@@ -112,6 +128,8 @@ protected:
 	int16 _slotTolerance = 0;      // Proximity for snapping to slot
 	bool _orderedPlacement = false; // Pieces must be placed in a specific order
 	PlacementMode _placementMode = kPlacementNormal; // Nancy 12 only, earlier games are always normal
+	DropMode _dropMode = kDropNormal;
+	int16 _dropSlop = 0;           // kDropSlop from Nancy13 on
 	Common::Array<int16> _placementOrder; // 1-indexed piece IDs in required placement order
 
 	// Counter puzzles (Nancy 12): the puzzle is solved once _requiredPieces have
@@ -161,14 +179,16 @@ protected:
 	Common::Array<Piece> _pieces;
 
 	// Nancy12 stores the puzzle's sounds as this many random-sound blocks, in
-	// this fixed on-disk order.
+	// this fixed on-disk order. Nancy13 appends a seventh for the close-up view.
 	static const uint kNumSounds = 6;
+	static const uint kNumSoundsNancy13 = 7;
 	static const uint kPickupSound = 0;
 	static const uint kRotateSound = 1;
 	static const uint kDropSound = 2;
 	static const uint kGoodSound = 3;
 	static const uint kBadSound = 4;
 	static const uint kCompletionSound = 5;
+	static const uint kCloseupSound = 6;
 
 	SoundDescription _pickupSound;
 	SoundDescription _rotateSound;
@@ -190,8 +210,13 @@ protected:
 	SoundDescription _completionSound;
 	Common::String _completionText;
 
+	SoundDescription _closeupSound;
+
 	SceneChangeWithFlag _cancelScene;
 	Common::Rect _exitHotspot;
+	// Nancy13 stores this in the exit hotspot record; earlier games use
+	// _puzzleExitCursor.
+	uint16 _exitCursorType = 0;
 
 	// --- Runtime state ---
 
@@ -226,6 +251,9 @@ protected:
 
 	RenderObject _counterDisplay;        // Digit sprites showing the running count.
 
+	RenderObject _closeupDisplay;        // Nancy13 blown-up view of a single piece.
+	int16 _closeupPiece = -1;            // Piece whose close-up is showing, -1 if none.
+
 	// Previous drag position (for freePlacement restore on wrong drop)
 	Common::Rect _prevDragGameRect;
 
@@ -246,6 +274,14 @@ protected:
 	// texts; each caption uses its key if known, else the inline text.
 	void readPlacementTexts(Common::SeekableReadStream &stream, Common::Array<Common::String> &out);
 	void setPieceCursor(bool isHeld = false);
+	// Attach a piece to the cursor; the caller plays the pickup sound.
+	void pickUpPiece(int16 pieceIdx, bool rotate);
+	// Swap-mode target: the piece under the drop point, else the slot it landed
+	// in. isPiece says which matched; -1 means neither.
+	int16 findDropTarget(const Common::Rect &dropRect, bool &isPiece) const;
+	// Show/hide the Nancy13 blown-up view of a piece
+	void openCloseup(int16 pieceIdx);
+	void closeCloseup();
 
 	void playPickupSound();
 	void playRotateSoundAndStartTimer();


Commit: ff197ccdfe55e7aa51c0f6be5f7506e2873929a7
    https://github.com/scummvm/scummvm/commit/ff197ccdfe55e7aa51c0f6be5f7506e2873929a7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-31T05:11:21+03:00

Commit Message:
NANCY: NANCY13: Fix solving and exiting the second ScalePuzzle

In the second ScalePuzzle, unlike the first one, the player stays in
the puzzle scene after solving it, and exits by clicking on the exit
hotspot

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


diff --git a/engines/nancy/action/puzzle/scalepuzzle.cpp b/engines/nancy/action/puzzle/scalepuzzle.cpp
index 26ee614f320..29bcd95b7b6 100644
--- a/engines/nancy/action/puzzle/scalepuzzle.cpp
+++ b/engines/nancy/action/puzzle/scalepuzzle.cpp
@@ -59,7 +59,7 @@ void ScalePuzzle::readData(Common::SeekableReadStream &stream) {
 	_solveFlag.label = stream.readSint16LE();		// 0x27
 	_solveFlag.flag = stream.readByte();			// 0x29
 
-	_latchSound.readData(stream);				// played on give-up
+	_solveSound.readData(stream);				// played once the puzzle comes out solved
 
 	// The figures to match this scene: a required coin count, the figure's number, its
 	// open-latch sprite and destination rects, and the sound played when it lights.
@@ -241,7 +241,7 @@ void ScalePuzzle::recomputeBalance() {
 			if ((int)ABS(_tilt) == t.number) {
 				if (!t.lit) {
 					t.lit = true;
-					_endSound = playSoundBlock(t.sound);
+					playSoundBlock(t.sound);
 				}
 			} else {
 				for (uint j = i; j < _targets.size(); ++j) {
@@ -335,18 +335,18 @@ void ScalePuzzle::setDataCursor(uint16 cursorType) const {
 	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true);
 }
 
-SoundDescription ScalePuzzle::playSoundBlock(const RandomSoundBlock &block) {
-	SoundDescription desc;
+void ScalePuzzle::playSoundBlock(const RandomSoundBlock &block) {
 	if (block.names.empty()) {
-		return desc;
+		return;
 	}
 
 	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
 	const Common::String &name = block.names[idx];
 	if (name.empty() || name == "NO SOUND") {
-		return desc;
+		return;
 	}
 
+	SoundDescription desc;
 	desc.name = name;
 	desc.channelID = block.channel;
 	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
@@ -354,7 +354,6 @@ SoundDescription ScalePuzzle::playSoundBlock(const RandomSoundBlock &block) {
 
 	g_nancy->_sound->loadSound(desc);
 	g_nancy->_sound->playSound(desc);
-	return desc;
 }
 
 void ScalePuzzle::execute() {
@@ -369,30 +368,30 @@ void ScalePuzzle::execute() {
 			break;
 		}
 
-		if (_solved) {
-			bool soundDone = _endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound);
-			if (soundDone) {
-				_state = kActionTrigger;
-			}
+		if (_solved && !_solveTriggered) {
+			_state = kActionTrigger;
 		}
 		break;
 	case kActionTrigger:
 		if (_exitRequested) {
 			NancySceneState.setEventFlag(_exitFlag);
 			NancySceneState.changeScene(_exitScene);
+			finishExecution();
 		} else {
-			// Solved: play the latch sound, set the solve flag, change scene (9999 = stay).
-			playSoundBlock(_latchSound);
+			// Solved: play the sound, set the solve flag, change scene (9999 = stay). The puzzle
+			// keeps running afterwards, so the player can still leave through the exit hotspot.
+			playSoundBlock(_solveSound);
 			NancySceneState.setEventFlag(_solveFlag);
 			NancySceneState.changeScene(_solveScene);
+			_solveTriggered = true;
+			_state = kRun;
 		}
-		finishExecution();
 		break;
 	}
 }
 
 void ScalePuzzle::handleInput(NancyInput &input) {
-	if (_state != kRun || _solved) {
+	if (_state != kRun) {
 		return;
 	}
 
diff --git a/engines/nancy/action/puzzle/scalepuzzle.h b/engines/nancy/action/puzzle/scalepuzzle.h
index da853757fc4..1d8313be358 100644
--- a/engines/nancy/action/puzzle/scalepuzzle.h
+++ b/engines/nancy/action/puzzle/scalepuzzle.h
@@ -99,7 +99,7 @@ protected:
 	void blitCentered(const Common::Rect &src, const Common::Rect &slot);
 	void redraw();
 	void setDataCursor(uint16 cursorType) const;
-	SoundDescription playSoundBlock(const RandomSoundBlock &block);
+	void playSoundBlock(const RandomSoundBlock &block);
 
 	// -- File data --
 	Common::Path _imageName;				// 0x00
@@ -107,7 +107,7 @@ protected:
 	uint16 _dragCursorType = 0;				// 0x23 - raw Nancy13 cursor type while carrying
 	SceneChangeDescription _solveScene;		// 0x25 - applied when solved (9999 => none)
 	FlagDescription _solveFlag;				// 0x27 - set when solved
-	RandomSoundBlock _latchSound;			// the first sound block; played on give-up
+	RandomSoundBlock _solveSound;			// the first sound block; played once solved
 
 	Common::Array<Target> _targets;			// the figures to match in this scene
 	Common::Array<Coin> _coins;				// the coin definitions
@@ -143,8 +143,8 @@ protected:
 	Common::Point _dragPos;					// cursor position (viewport space) while carrying
 
 	bool _solved = false;
+	bool _solveTriggered = false;			// the solve flag/scene are applied only once
 	bool _exitRequested = false;
-	SoundDescription _endSound;				// the cue we wait on before changing scene
 
 	Graphics::ManagedSurface _image;
 };


Commit: 07fd83104ed0d73ac4c259051a9346359062f9da
    https://github.com/scummvm/scummvm/commit/07fd83104ed0d73ac4c259051a9346359062f9da
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-31T05:11:24+03:00

Commit Message:
NANCY: NANCY14: Move the list of loaded movies inside NancyEngine

Avoids the usage of a global constructor

Changed paths:
    engines/nancy/movieplayer.cpp
    engines/nancy/movieplayer.h
    engines/nancy/nancy.h


diff --git a/engines/nancy/movieplayer.cpp b/engines/nancy/movieplayer.cpp
index 5fbb850ea0e..e8032087ca2 100644
--- a/engines/nancy/movieplayer.cpp
+++ b/engines/nancy/movieplayer.cpp
@@ -47,8 +47,6 @@ private:
 	MoviePlayer &_owner;
 };
 
-Common::Array<MoviePlayer *> MoviePlayer::_loadedMovies;
-
 MoviePlayer::MoviePlayer() {}
 
 MoviePlayer::~MoviePlayer() {
@@ -56,9 +54,9 @@ MoviePlayer::~MoviePlayer() {
 }
 
 void MoviePlayer::unregisterMovie() {
-	for (uint i = 0; i < _loadedMovies.size(); ++i) {
-		if (_loadedMovies[i] == this) {
-			_loadedMovies.remove_at(i);
+	for (uint i = 0; i < g_nancy->_loadedMovies.size(); ++i) {
+		if (g_nancy->_loadedMovies[i] == this) {
+			g_nancy->_loadedMovies.remove_at(i);
 			break;
 		}
 	}
@@ -67,7 +65,7 @@ void MoviePlayer::unregisterMovie() {
 }
 
 MoviePlayer *MoviePlayer::findLoadedMovie(const Common::Path &name) {
-	for (MoviePlayer *movie : _loadedMovies) {
+	for (MoviePlayer *movie : g_nancy->_loadedMovies) {
 		if (movie->_loadedName.equalsIgnoreCase(name)) {
 			return movie;
 		}
@@ -143,7 +141,7 @@ bool MoviePlayer::loadFile(const Common::Path &name, byte videoPlaytype, bool bi
 	}
 
 	_loadedName = name;
-	_loadedMovies.push_back(this);
+	g_nancy->_loadedMovies.push_back(this);
 
 	return true;
 }
diff --git a/engines/nancy/movieplayer.h b/engines/nancy/movieplayer.h
index 36e4a734d38..7da2e04d8e0 100644
--- a/engines/nancy/movieplayer.h
+++ b/engines/nancy/movieplayer.h
@@ -125,10 +125,8 @@ private:
 	Common::ScopedPtr<Video::VideoDecoder> _decoder;
 	byte _videoType = kVideoPlaytypeAVF;
 
-	// Name this movie was loaded with, and the list of all currently loaded
-	// movies; both only serve findLoadedMovie().
+	// Name this movie was loaded with; used by findLoadedMovie().
 	Common::Path _loadedName;
-	static Common::Array<MoviePlayer *> _loadedMovies;
 
 	// Decoded-frame cache for the Bink path (AVF caches internally). Bink seeking
 	// re-decodes from the previous keyframe, so caching keeps panorama scrubbing
diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index 2ef81dde717..ef230572c17 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -66,6 +66,7 @@ class GraphicsManager;
 class CursorManager;
 class NancyConsole;
 class DeferredLoader;
+class MoviePlayer;
 
 namespace State {
 class State;
@@ -122,6 +123,9 @@ public:
 
 	Common::RandomSource *_randomSource;
 
+	// All loaded movies, for MoviePlayer::findLoadedMovie().
+	Common::Array<MoviePlayer *> _loadedMovies;
+
 	// Used to check whether we need to show the SaveDialog
 	bool _hasJustSaved;
 


Commit: be9669a0370b9e5ad4b6d773322661c53a5de99f
    https://github.com/scummvm/scummvm/commit/be9669a0370b9e5ad4b6d773322661c53a5de99f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-31T05:11:28+03:00

Commit Message:
NANCY: NANCY13: Use the correct cursor variant in puzzles

Changed paths:
    engines/nancy/action/puzzle/blockspuzzle.cpp
    engines/nancy/action/puzzle/blockspuzzle.h
    engines/nancy/action/puzzle/dropsortpuzzle.cpp
    engines/nancy/action/puzzle/onebuildpuzzle.cpp
    engines/nancy/action/puzzle/pachinkopuzzle.cpp
    engines/nancy/action/puzzle/pachinkopuzzle.h
    engines/nancy/action/puzzle/pegspuzzle.cpp
    engines/nancy/action/puzzle/pegspuzzle.h
    engines/nancy/action/puzzle/scalepuzzle.cpp
    engines/nancy/action/puzzle/scalepuzzle.h
    engines/nancy/action/puzzle/stepobjectspuzzle.cpp
    engines/nancy/action/puzzle/stepobjectspuzzle.h
    engines/nancy/action/puzzle/wordfindpuzzle.cpp


diff --git a/engines/nancy/action/puzzle/blockspuzzle.cpp b/engines/nancy/action/puzzle/blockspuzzle.cpp
index 1137ccd0a02..6f0f32a71dd 100644
--- a/engines/nancy/action/puzzle/blockspuzzle.cpp
+++ b/engines/nancy/action/puzzle/blockspuzzle.cpp
@@ -261,10 +261,10 @@ void BlocksPuzzle::redraw() {
 	_needsRedraw = true;
 }
 
-void BlocksPuzzle::setDataCursor(uint16 cursorType) const {
+void BlocksPuzzle::setDataCursor(uint16 cursorType, bool hotspotVariant) const {
 	// The ids in the AR data are raw Nancy13 cursor types, which is exactly what the
 	// "set from script" path expects.
-	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true);
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, hotspotVariant);
 }
 
 SoundDescription BlocksPuzzle::playSoundBlock(const RandomSoundBlock &block) {
@@ -464,7 +464,7 @@ void BlocksPuzzle::handleInput(NancyInput &input) {
 
 	if (!_exitHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
-		setDataCursor(_exitCursorType);
+		setDataCursor(_exitCursorType, false);
 		if (click) {
 			_exitRequested = true;
 		}
diff --git a/engines/nancy/action/puzzle/blockspuzzle.h b/engines/nancy/action/puzzle/blockspuzzle.h
index 4d0c5f38e78..432f60c067a 100644
--- a/engines/nancy/action/puzzle/blockspuzzle.h
+++ b/engines/nancy/action/puzzle/blockspuzzle.h
@@ -98,7 +98,8 @@ protected:
 	void drop(int16 cell);
 	void startTurn();
 	void redraw();
-	void setDataCursor(uint16 cursorType) const;
+	// Zone cursors take the idle sprite of their type, hover/drag cursors the hotspot one.
+	void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
 	SoundDescription playSoundBlock(const RandomSoundBlock &block);
 
 	// -- File data (111-byte header) --
diff --git a/engines/nancy/action/puzzle/dropsortpuzzle.cpp b/engines/nancy/action/puzzle/dropsortpuzzle.cpp
index 11f17f5a817..f79e6e78d5a 100644
--- a/engines/nancy/action/puzzle/dropsortpuzzle.cpp
+++ b/engines/nancy/action/puzzle/dropsortpuzzle.cpp
@@ -476,7 +476,7 @@ void DropSortPuzzle::handleInput(NancyInput &input) {
 
 	if (!_exitHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
-		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, false);
 		if (click) {
 			_exitRequested = true;
 		}
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.cpp b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
index a925791376f..86f5408a334 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -798,7 +798,7 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 	Common::Rect exitScreen = NancySceneState.getViewport().convertViewportToScreen(_exitHotspot);
 	if (exitScreen.contains(input.mousePos)) {
 		if (_exitCursorType != 0)
-			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, true);
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, false);
 		else
 			g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
index 422b4333ba3..98ebbe51700 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.cpp
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -304,8 +304,8 @@ SoundDescription PachinkoPuzzle::playSoundBlock(const RandomSoundBlock &block) {
 	return desc;
 }
 
-void PachinkoPuzzle::setDataCursor(uint16 cursorType) const {
-	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true);
+void PachinkoPuzzle::setDataCursor(uint16 cursorType, bool hotspotVariant) const {
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, hotspotVariant);
 }
 
 void PachinkoPuzzle::spawnBall() {
@@ -614,7 +614,7 @@ void PachinkoPuzzle::handleInput(NancyInput &input) {
 
 	if (!_launcherHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_launcherHotspot).contains(input.mousePos)) {
-		setDataCursor(_exitCursorType);
+		setDataCursor(_exitCursorType, false);
 		if (click) {
 			// One launcher click queues one ball.
 			_spawnPending = true;
@@ -626,7 +626,7 @@ void PachinkoPuzzle::handleInput(NancyInput &input) {
 
 	if (!_exitHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
-		setDataCursor(_exitCursorType);
+		setDataCursor(_exitCursorType, false);
 		if (click) {
 			_exitRequested = true;
 		}
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.h b/engines/nancy/action/puzzle/pachinkopuzzle.h
index 6b19c89e6e9..1040628d73e 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.h
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.h
@@ -120,7 +120,8 @@ protected:
 	int catchInHole(const Ball &ball) const;	// hole index the ball fell into, or -1
 	void advanceMachine(Machine &m, uint32 now);
 	SoundDescription playSoundBlock(const RandomSoundBlock &block);
-	void setDataCursor(uint16 cursorType) const;
+	// Zone cursors take the idle sprite of their type, hover/drag cursors the hotspot one.
+	void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
 
 	// -- File data (167-byte header, in stream order) --
 	Common::Path _imageName;				// 0x00 - board overlay (MUS_PachinkoPUZ02_OVL)
diff --git a/engines/nancy/action/puzzle/pegspuzzle.cpp b/engines/nancy/action/puzzle/pegspuzzle.cpp
index 98e86c67e7e..089044d6b95 100644
--- a/engines/nancy/action/puzzle/pegspuzzle.cpp
+++ b/engines/nancy/action/puzzle/pegspuzzle.cpp
@@ -255,10 +255,10 @@ Common::Point PegsPuzzle::cursorToViewport(const Common::Point &mousePos) const
 	return Common::Point(vpPt.left, vpPt.top);
 }
 
-void PegsPuzzle::setDataCursor(uint16 cursorType) const {
+void PegsPuzzle::setDataCursor(uint16 cursorType, bool hotspotVariant) const {
 	// The ids in the AR data are raw Nancy13 cursor types, which is exactly what the
 	// "set from script" path expects.
-	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true);
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, hotspotVariant);
 }
 
 void PegsPuzzle::redraw() {
@@ -414,7 +414,7 @@ void PegsPuzzle::handleInput(NancyInput &input) {
 
 	if (!_exitHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
-		setDataCursor(_exitCursorType);
+		setDataCursor(_exitCursorType, false);
 		if (click) {
 			_exitRequested = true;
 		}
diff --git a/engines/nancy/action/puzzle/pegspuzzle.h b/engines/nancy/action/puzzle/pegspuzzle.h
index a045ac7e544..243e465c0e1 100644
--- a/engines/nancy/action/puzzle/pegspuzzle.h
+++ b/engines/nancy/action/puzzle/pegspuzzle.h
@@ -72,7 +72,8 @@ protected:
 
 	Common::Point cursorToViewport(const Common::Point &mousePos) const;
 	// The puzzle's cursors are raw Nancy13 cursor type ids stored in the AR data.
-	void setDataCursor(uint16 cursorType) const;
+	// Zone cursors take the idle sprite of their type, hover/drag cursors the hotspot one.
+	void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
 
 	void redraw();
 	SoundDescription playSoundBlock(const RandomSoundBlock &block);
diff --git a/engines/nancy/action/puzzle/scalepuzzle.cpp b/engines/nancy/action/puzzle/scalepuzzle.cpp
index 29bcd95b7b6..03bd6c58521 100644
--- a/engines/nancy/action/puzzle/scalepuzzle.cpp
+++ b/engines/nancy/action/puzzle/scalepuzzle.cpp
@@ -329,10 +329,10 @@ void ScalePuzzle::redraw() {
 	_needsRedraw = true;
 }
 
-void ScalePuzzle::setDataCursor(uint16 cursorType) const {
+void ScalePuzzle::setDataCursor(uint16 cursorType, bool hotspotVariant) const {
 	// The ids in the AR data are raw Nancy13 cursor types, which is exactly what the
 	// "set from script" path expects.
-	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true);
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, hotspotVariant);
 }
 
 void ScalePuzzle::playSoundBlock(const RandomSoundBlock &block) {
@@ -446,7 +446,7 @@ void ScalePuzzle::handleInput(NancyInput &input) {
 
 	if (!_exitHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
-		setDataCursor(_exitCursorType);
+		setDataCursor(_exitCursorType, false);
 		if (click) {
 			_exitRequested = true;
 		}
diff --git a/engines/nancy/action/puzzle/scalepuzzle.h b/engines/nancy/action/puzzle/scalepuzzle.h
index 1d8313be358..2adafb5adf1 100644
--- a/engines/nancy/action/puzzle/scalepuzzle.h
+++ b/engines/nancy/action/puzzle/scalepuzzle.h
@@ -98,7 +98,8 @@ protected:
 	// slots, FUN_004b6660 case 0).
 	void blitCentered(const Common::Rect &src, const Common::Rect &slot);
 	void redraw();
-	void setDataCursor(uint16 cursorType) const;
+	// Zone cursors take the idle sprite of their type, hover/drag cursors the hotspot one.
+	void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
 	void playSoundBlock(const RandomSoundBlock &block);
 
 	// -- File data --
diff --git a/engines/nancy/action/puzzle/stepobjectspuzzle.cpp b/engines/nancy/action/puzzle/stepobjectspuzzle.cpp
index 31bd29f7572..c3d72f76959 100644
--- a/engines/nancy/action/puzzle/stepobjectspuzzle.cpp
+++ b/engines/nancy/action/puzzle/stepobjectspuzzle.cpp
@@ -324,10 +324,10 @@ void StepObjectsPuzzle::redraw() {
 	_needsRedraw = true;
 }
 
-void StepObjectsPuzzle::setDataCursor(uint16 cursorType) const {
+void StepObjectsPuzzle::setDataCursor(uint16 cursorType, bool hotspotVariant) const {
 	// The ids in the AR data are raw Nancy13 cursor types, which is exactly what the
 	// "set from script" path expects.
-	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true);
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, hotspotVariant);
 }
 
 SoundDescription StepObjectsPuzzle::playSoundBlock(const RandomSoundBlock &block) {
@@ -470,7 +470,7 @@ void StepObjectsPuzzle::handleInput(NancyInput &input) {
 	}
 
 	if (isHovered(_exitHotspot, input.mousePos)) {
-		setDataCursor(_exitCursorType);
+		setDataCursor(_exitCursorType, false);
 		if (click) {
 			_exitRequested = true;
 		}
diff --git a/engines/nancy/action/puzzle/stepobjectspuzzle.h b/engines/nancy/action/puzzle/stepobjectspuzzle.h
index b9933a0e32f..c1b817f951c 100644
--- a/engines/nancy/action/puzzle/stepobjectspuzzle.h
+++ b/engines/nancy/action/puzzle/stepobjectspuzzle.h
@@ -95,7 +95,8 @@ protected:
 	void drop(int row, int col);
 	void resetBoard();
 	void beginStepSound(SoundID sound, bool isDrop);
-	void setDataCursor(uint16 cursorType) const;
+	// Zone cursors take the idle sprite of their type, hover/drag cursors the hotspot one.
+	void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
 
 	void redraw();
 	void drawSprite(const Common::Rect &srcRect, const Common::Point &destPos, byte alpha);
diff --git a/engines/nancy/action/puzzle/wordfindpuzzle.cpp b/engines/nancy/action/puzzle/wordfindpuzzle.cpp
index ed4e9246f62..d701bbad3b0 100644
--- a/engines/nancy/action/puzzle/wordfindpuzzle.cpp
+++ b/engines/nancy/action/puzzle/wordfindpuzzle.cpp
@@ -396,7 +396,7 @@ void WordFindPuzzle::handleInput(NancyInput &input) {
 
 	if (!_exitHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
-		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, false);
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			_state = kActionTrigger;
 		}


Commit: c32bcb8f148fdb7db0fb75b90ce31d109e34946f
    https://github.com/scummvm/scummvm/commit/c32bcb8f148fdb7db0fb75b90ce31d109e34946f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-31T05:11:29+03:00

Commit Message:
NANCY: NANCY13: Use the correct cursor variant for ScalePuzzle

Changed paths:
    engines/nancy/action/puzzle/scalepuzzle.cpp


diff --git a/engines/nancy/action/puzzle/scalepuzzle.cpp b/engines/nancy/action/puzzle/scalepuzzle.cpp
index 03bd6c58521..0827237ff05 100644
--- a/engines/nancy/action/puzzle/scalepuzzle.cpp
+++ b/engines/nancy/action/puzzle/scalepuzzle.cpp
@@ -399,23 +399,24 @@ void ScalePuzzle::handleInput(NancyInput &input) {
 
 	// -- Carrying a coin: it follows the cursor; drop it on an empty slot. --
 	if (_carriedCoin != kNoCoin) {
-		setDataCursor(_dragCursorType);
+		SlotRegion region;
+		uint idx;
+		const bool overSlot = slotAtCursor(input.mousePos, true, region, idx);
+
+		// The drag cursor only takes its hotspot sprite while over a slot that can take the coin.
+		setDataCursor(_dragCursorType, overSlot);
 
 		Common::Rect screenPt(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1);
 		Common::Rect vpPt = NancySceneState.getViewport().convertScreenToViewport(screenPt);
 		_dragPos = Common::Point(vpPt.left, vpPt.top);
 		redraw();
 
-		if (click) {
-			SlotRegion region;
-			uint idx;
-			if (slotAtCursor(input.mousePos, true, region, idx)) {
-				group(region).coins[idx] = _carriedCoin;
-				_carriedCoin = kNoCoin;
-				playSoundBlock(region == kSourceTray ? _dropTraySound : _dropPanSound);
-				recomputeBalance();
-				redraw();
-			}
+		if (click && overSlot) {
+			group(region).coins[idx] = _carriedCoin;
+			_carriedCoin = kNoCoin;
+			playSoundBlock(region == kSourceTray ? _dropTraySound : _dropPanSound);
+			recomputeBalance();
+			redraw();
 		}
 
 		input.eatMouseInput();


Commit: 250ca11aa9306448530e89eebc724bb853f93857
    https://github.com/scummvm/scummvm/commit/250ca11aa9306448530e89eebc724bb853f93857
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-31T05:11:30+03:00

Commit Message:
NANCY: NANCY13: Fix solving TurningPuzzle

Now the grille screw puzzle can be solved correctly.
Also, use the correct cursor variant for the exit cursor

Changed paths:
    engines/nancy/action/puzzle/turningpuzzle.cpp


diff --git a/engines/nancy/action/puzzle/turningpuzzle.cpp b/engines/nancy/action/puzzle/turningpuzzle.cpp
index d8a59a6b96f..178f7b075bd 100644
--- a/engines/nancy/action/puzzle/turningpuzzle.cpp
+++ b/engines/nancy/action/puzzle/turningpuzzle.cpp
@@ -170,10 +170,12 @@ void TurningPuzzle::readDataNancy13(Common::SeekableReadStream &stream) {
 	_hitInset = stream.readUint16LE();			// 0x25
 	_turnFlagLabel = stream.readSint16LE();		// 0x27
 	_turnFlagValue = stream.readByte();			// 0x29
-	stream.skip(5);								// 0x2a - not yet identified
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();	// 0x2a
+	_solveScene._flag.label = stream.readSint16LE();			// 0x2c
+	_solveScene._flag.flag = stream.readByte();					// 0x2e
 
-	// A count-prefixed array of 23-byte hotspot records (as in PegsPuzzle); the first is
-	// the "give up" hotspot, and its scene doubles as the one shown once solved.
+	// A count-prefixed array of 23-byte hotspot records (as in PegsPuzzle);
+	// the first one is the "give up" hotspot.
 	int16 numZones = stream.readSint16LE();
 	for (int16 i = 0; i < numZones; ++i) {
 		Common::Rect r;
@@ -191,7 +193,6 @@ void TurningPuzzle::readDataNancy13(Common::SeekableReadStream &stream) {
 			_exitScene._sceneChange.frameID = 0;
 			_exitScene._flag.label = exitFlagLabel;
 			_exitScene._flag.flag = exitFlagValue;
-			_solveScene._sceneChange = _exitScene._sceneChange;
 		}
 	}
 
@@ -445,11 +446,21 @@ void TurningPuzzle::execute() {
 
 			return;
 		case kWaitForSound :
-			if (g_nancy->_sound->isSoundPlaying(_solveSound) || g_nancy->_sound->isSoundPlaying(_turnSound)) {
+			if (g_nancy->_sound->isSoundPlaying(_solveSound)) {
 				return;
 			}
 
-			NancySceneState.changeScene(_solveScene._sceneChange);
+			if (g_nancy->getGameType() < kGameTypeNancy13 && g_nancy->_sound->isSoundPlaying(_turnSound)) {
+				return;
+			}
+
+			if (g_nancy->getGameType() >= kGameTypeNancy13) {
+				// The solve scene and its event flag both come from the header.
+				_solveScene.execute();
+			} else {
+				NancySceneState.changeScene(_solveScene._sceneChange);
+			}
+
 			break;
 		case kNotSolved :
 			_exitScene.execute();
@@ -466,7 +477,8 @@ void TurningPuzzle::handleInput(NancyInput &input) {
 
 	if (NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
 		if (isNancy13)
-			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+			// Zone cursors use the idle sprite of their type, unlike the hover cursor below.
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, false);
 		else
 			g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
 


Commit: d52a834235f1455b6fc8614ae4d99ddf68a7d681
    https://github.com/scummvm/scummvm/commit/d52a834235f1455b6fc8614ae4d99ddf68a7d681
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-31T05:11:32+03:00

Commit Message:
NANCY: NANCY14: Implement the CameraAction AR

This is a scene action, used for the camera functionality. Also, share
the reusable camera code between the Nancy13 cellphone camera and the
Nancy14 camera

Changed paths:
  A engines/nancy/action/cameraaction.cpp
  A engines/nancy/action/cameraaction.h
  A engines/nancy/ui/camera.cpp
  A engines/nancy/ui/camera.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/enginedata.h
    engines/nancy/module.mk
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h
    engines/nancy/ui/cellphonepopup.cpp
    engines/nancy/util.cpp
    engines/nancy/util.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 5e43bffb63d..f744bec24c2 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -26,6 +26,7 @@
 #include "engines/nancy/action/miscrecords.h"
 
 #include "engines/nancy/action/autotext.h"
+#include "engines/nancy/action/cameraaction.h"
 #include "engines/nancy/action/conversation.h"
 #include "engines/nancy/action/interactivevideo.h"
 #include "engines/nancy/action/overlay.h"
@@ -389,10 +390,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new AddSearchLink();
 	case 132:	// Nancy12
 		return new ResourceUse();
-	case 133:	// Nancy14 - CameraAction
-		// Cell-phone camera action (introduced alongside the UICM camera UI).
-		// TODO: not yet implemented
-		return nullptr;
+	case 133:	// Nancy14
+		return new CameraAction();
 	case 134:	// Nancy15 - PlayCharAR
 		// Switches the active player character (Nancy / Frank / Joe), the
 		// dual-protagonist mechanic new to The Creature of Kapu Cave.
diff --git a/engines/nancy/action/cameraaction.cpp b/engines/nancy/action/cameraaction.cpp
new file mode 100644
index 00000000000..fea41e963cd
--- /dev/null
+++ b/engines/nancy/action/cameraaction.cpp
@@ -0,0 +1,357 @@
+/* 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 "engines/nancy/nancy.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/action/cameraaction.h"
+
+#include "engines/nancy/ui/camera.h"
+
+#include "engines/nancy/state/scene.h"
+
+namespace Nancy {
+namespace Action {
+
+// How long a control stays visibly pressed before it acts.
+static const uint32 kButtonPressDelay = 200;
+static const uint32 kMessageDuration = 1500;
+static const uint32 kConfirmDuration = 15000;
+
+void CameraAction::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);			// 0x00
+	_buttonCursorType = stream.readUint16LE();	// 0x21
+
+	for (uint i = 0; i < kNumControls; ++i) {	// 0x23
+		readRect(stream, _buttons[i].destRect);
+		readRect(stream, _buttons[i].srcRect);
+	}
+
+	readRect(stream, _pictureRect);				// 0xa3
+	readRect(stream, _cameraFullRect);			// 0xb3
+	readRect(stream, _confirmDeleteRect);		// 0xc3
+	readRect(stream, _deletedRect);				// 0xd3
+
+	_buttonSound.readData(stream);				// 0xe3
+
+	// Trailing count-prefixed array of 23-byte give-up hotspots. The exit always
+	// jumps to the scene's first frame.
+	int16 numExitZones = stream.readSint16LE();
+	for (int16 i = 0; i < numExitZones; ++i) {
+		Common::Rect r;
+		readRect(stream, r);
+		uint16 cursorType = stream.readUint16LE();
+		uint16 sceneID = stream.readUint16LE();
+		int16 flagLabel = stream.readSint16LE();
+		byte flagValue = stream.readByte();
+
+		if (i == 0) {
+			_exitHotspot = r;
+			_exitCursorType = cursorType;
+			_exitScene.sceneID = sceneID;
+			_exitScene.frameID = 0;
+			_exitFlag.label = flagLabel;
+			_exitFlag.flag = flagValue;
+		}
+	}
+}
+
+void CameraAction::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(vpBounds.width(), vpBounds.height(),
+		g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+	_image.setTransparentColor(_drawSurface.getTransparentColor());
+
+	_pendingButton = -1;
+	_takePictureRequested = false;
+	_exitRequested = false;
+
+	resetToBrowsing();
+	redraw();
+}
+
+void CameraAction::resetToBrowsing() {
+	_messageRect = Common::Rect();
+
+	uint count = UI::Camera::numPictures();
+	if (count) {
+		_pictureIndex = (int)count - 1;
+		_screen = kViewing;
+	} else {
+		_pictureIndex = -1;
+		_screen = kEmpty;
+	}
+}
+
+void CameraAction::showMessage(Screen screen) {
+	_screen = screen;
+
+	switch (screen) {
+	case kCameraFull:
+		_messageRect = _cameraFullRect;
+		_messageEndTime = g_nancy->getTotalPlayTime() + kMessageDuration;
+		break;
+	case kConfirmDelete:
+		_messageRect = _confirmDeleteRect;
+		_messageEndTime = g_nancy->getTotalPlayTime() + kConfirmDuration;
+		break;
+	default:
+		_messageRect = _deletedRect;
+		_messageEndTime = g_nancy->getTotalPlayTime() + kMessageDuration;
+		break;
+	}
+}
+
+bool CameraAction::isButtonEnabled(uint button) const {
+	if (_screen == kDeleted) {
+		return false;
+	}
+
+	// Taking a picture is all an empty camera can do.
+	if (button != kTakePicture && _screen == kEmpty) {
+		return false;
+	}
+
+	switch (button) {
+	case kDelete:
+		return _screen != kConfirmDelete;
+	case kPrevious:
+		return _screen != kViewing || _pictureIndex > 0;
+	case kNext:
+		return _screen != kViewing || _pictureIndex + 1 < (int)UI::Camera::numPictures();
+	default:
+		return true;
+	}
+}
+
+int CameraAction::buttonAtCursor(const Common::Point &mousePos) const {
+	for (uint i = 0; i < kNumControls; ++i) {
+		if (!_buttons[i].destRect.isEmpty() &&
+				NancySceneState.getViewport().convertViewportToScreen(_buttons[i].destRect).contains(mousePos)) {
+			return isButtonEnabled(i) ? (int)i : -1;
+		}
+	}
+
+	return -1;
+}
+
+void CameraAction::pressButton(uint button) {
+	switch (button) {
+	case kTakePicture: {
+		auto *uicm = GetEngineData(UICM);
+		if (uicm && UI::Camera::numPictures() >= uicm->maxPictures) {
+			showMessage(kCameraFull);
+		} else {
+			_takePictureRequested = true;
+		}
+
+		break;
+	}
+	case kDelete:
+		showMessage(kConfirmDelete);
+		break;
+	case kPrevious:
+		// Yes, while confirming a deletion.
+		if (_screen == kConfirmDelete) {
+			deleteCurrentPicture();
+			showMessage(kDeleted);
+		} else if (_screen == kViewing) {
+			--_pictureIndex;
+		}
+
+		break;
+	case kNext:
+		// No, while confirming a deletion.
+		if (_screen == kConfirmDelete) {
+			resetToBrowsing();
+		} else if (_screen == kViewing) {
+			++_pictureIndex;
+		}
+
+		break;
+	default:
+		break;
+	}
+}
+
+void CameraAction::deleteCurrentPicture() {
+	CellPhonePictureData *pd =
+		(CellPhonePictureData *)NancySceneState.getPuzzleData(CellPhonePictureData::getTag());
+	if (!pd || _pictureIndex < 0 || _pictureIndex >= (int)pd->pictures.size()) {
+		return;
+	}
+
+	// A subject stays "photographed" only while some picture still holds it.
+	const Common::Array<int16> subjects = pd->pictures[_pictureIndex].subjects;
+	pd->pictures.remove_at(_pictureIndex);
+
+	for (int16 subjectID : subjects) {
+		bool stillHeld = false;
+		for (const CapturedPicture &pic : pd->pictures) {
+			for (int16 held : pic.subjects) {
+				if (held == subjectID) {
+					stillHeld = true;
+					break;
+				}
+			}
+
+			if (stillHeld) {
+				break;
+			}
+		}
+
+		if (!stillHeld) {
+			NancySceneState.setEventFlag(subjectID, g_nancy->_false);
+		}
+	}
+
+	UI::Camera::setPictureCount(pd->pictures.size());
+}
+
+void CameraAction::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	if (_pendingButton >= 0) {
+		const Button &button = _buttons[_pendingButton];
+		_drawSurface.blitFrom(_image, button.srcRect,
+			Common::Point(button.destRect.left, button.destRect.top));
+	}
+
+	if (!_messageRect.isEmpty()) {
+		// A message plate takes over the display area.
+		_drawSurface.blitFrom(_image, _messageRect,
+			Common::Point(_pictureRect.left, _pictureRect.top));
+	} else if (_screen == kViewing) {
+		const CellPhonePictureData *pd =
+			(const CellPhonePictureData *)NancySceneState.getPuzzleData(CellPhonePictureData::getTag());
+		if (pd && _pictureIndex >= 0 && _pictureIndex < (int)pd->pictures.size()) {
+			const CapturedPicture &pic = pd->pictures[_pictureIndex];
+			if (pic.width && pic.height &&
+					pic.pixels.size() >= (uint)pic.width * (uint)pic.height * 4) {
+				// Pictures are captured at the display area's size.
+				Graphics::Surface src;
+				src.init(pic.width, pic.height, pic.width * 4,
+					const_cast<byte *>(pic.pixels.data()),
+					g_nancy->_graphics->getScreenPixelFormat());
+
+				Common::Rect srcRect(MIN<int16>(pic.width, _pictureRect.width()),
+					MIN<int16>(pic.height, _pictureRect.height()));
+				_drawSurface.blitFrom(src, srcRect,
+					Common::Point(_pictureRect.left, _pictureRect.top));
+			}
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void CameraAction::handleInput(NancyInput &input) {
+	if (_state != kRun || _takePictureRequested || _exitRequested) {
+		return;
+	}
+
+	if (!_exitHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_exitRequested = true;
+		}
+
+		input.eatMouseInput();
+		return;
+	}
+
+	int button = buttonAtCursor(input.mousePos);
+	if (button < 0) {
+		return;
+	}
+
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)_buttonCursorType, true);
+
+	if ((input.input & NancyInput::kLeftMouseButtonUp) && _pendingButton < 0) {
+		UI::Camera::playSoundBlock(_buttonSound);
+		_pendingButton = button;
+		_pendingTime = g_nancy->getTotalPlayTime() + kButtonPressDelay;
+		redraw();
+	}
+
+	input.eatMouseInput();
+}
+
+void CameraAction::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		break;
+	case kRun:
+		if (_exitRequested) {
+			NancySceneState.setEventFlag(_exitFlag);
+			NancySceneState.changeScene(_exitScene);
+			break;
+		}
+
+		if (_takePictureRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (!_messageRect.isEmpty() && g_nancy->getTotalPlayTime() > _messageEndTime) {
+			// Every plate, the delete prompt included, times out.
+			resetToBrowsing();
+			redraw();
+			break;
+		}
+
+		if (_pendingButton >= 0 && g_nancy->getTotalPlayTime() > _pendingTime) {
+			uint button = (uint)_pendingButton;
+			_pendingButton = -1;
+			pressButton(button);
+			redraw();
+		}
+
+		break;
+	case kActionTrigger:
+		// Back to the scene the camera was opened from, viewfinder up.
+		NancySceneState.popScene(true);
+		if (UI::Camera *camera = NancySceneState.getCamera()) {
+			camera->activate();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/cameraaction.h b/engines/nancy/action/cameraaction.h
new file mode 100644
index 00000000000..8496b63dde5
--- /dev/null
+++ b/engines/nancy/action/cameraaction.h
@@ -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/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_CAMERAACTION_H
+#define NANCY_ACTION_CAMERAACTION_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// The photo album of the Nancy14 camera (AR 133): shows one picture at a time,
+// with controls to take another, delete this one, and page back and forth. The
+// interface art is part of the scene background, so this only draws the display
+// area and the sprite of a control being pressed.
+class CameraAction : public RenderActionRecord {
+public:
+	CameraAction() : RenderActionRecord(7) {}
+	virtual ~CameraAction() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "CameraAction"; }
+
+	// kPrevious and kNext double as Yes and No while a deletion is confirmed.
+	enum Control { kTakePicture = 0, kDelete = 1, kPrevious = 2, kNext = 3, kNumControls = 4 };
+
+	enum Screen { kEmpty = 0, kCameraFull = 1, kViewing = 2, kConfirmDelete = 3, kDeleted = 4 };
+
+	struct Button {
+		Common::Rect destRect;	// on-screen hotspot, and where the sprite is drawn
+		Common::Rect srcRect;	// pressed sprite inside the interface image
+	};
+
+	// The control under the cursor, or -1 if there is none or it is disabled.
+	int buttonAtCursor(const Common::Point &mousePos) const;
+	bool isButtonEnabled(uint button) const;
+	void pressButton(uint button);
+
+	// Drops any message and selects the newest picture.
+	void resetToBrowsing();
+	void showMessage(Screen screen);
+	void deleteCurrentPicture();
+	void redraw();
+
+	// -- File data --
+	Common::Path _imageName;			// 0x00 - sprite & message sheet
+	uint16 _buttonCursorType = 0;		// 0x21 - cursor over an enabled control
+	Button _buttons[kNumControls];		// 0x23
+	Common::Rect _pictureRect;			// 0xa3 - where a picture is displayed
+	Common::Rect _cameraFullRect;		// 0xb3
+	Common::Rect _confirmDeleteRect;	// 0xc3
+	Common::Rect _deletedRect;			// 0xd3
+	RandomSoundBlock _buttonSound;		// 0xe3
+
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+	FlagDescription _exitFlag;
+
+	// -- Runtime state --
+	Graphics::ManagedSurface _image;
+	Screen _screen = kEmpty;
+	int _pictureIndex = -1;
+	Common::Rect _messageRect;			// plate currently shown, if any
+	uint32 _messageEndTime = 0;
+	int _pendingButton = -1;			// pressed, not yet acted on
+	uint32 _pendingTime = 0;
+	bool _takePictureRequested = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_CAMERAACTION_H
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 1eec70b1adf..6d1b1c5f2a6 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -754,21 +754,17 @@ struct UICL : public EngineData {
 // Shared by the UICL chunk and ChangeCellPhoneInfo (AR 130).
 void readContact(Common::SeekableReadStream &stream, UICL::Contact &c);
 
-// Camera UI, added in Nancy 14. This is a standalone camera. While it is active,
-// the cursor becomes a large viewfinder rectangle that the player aims at the
-// scene; clicking photographs every subject whose region falls inside the framed
-// area.
+// Standalone camera UI, added in Nancy14. While it is active a viewfinder sits at
+// the centre of the viewport and the scene's own hotspots are suppressed.
 struct UICM : public EngineData {
 	UICM(Common::SeekableReadStream *chunkStream);
 
-	// One photographable region. When a picture is taken, every subject whose
-	// frameID matches the current scene view and whose coords lie within the
-	// viewfinder rectangle is captured: its subjectID is recorded in the picture
-	// and its flag (if any) is set.
+	// A photographable region. Taking a picture captures every subject in the
+	// current scene lying wholly inside the viewfinder.
 	struct CameraSubject {
-		HotspotDescription hotspot;   // frameID + region that can be photographed
-		int16 subjectID = -1;         // identifies what was photographed
-		FlagDescription flag;         // event flag set on capture (often unset)
+		HotspotDescription hotspot;   // sceneID + region that can be photographed
+		int16 subjectID = -1;         // event flag raised while photographed
+		FlagDescription flag;         // second event flag (unset in every record)
 	};
 
 	Common::Path overlayImageName;            // "PHO_CameraView"
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index ba4c0173652..b61f8897924 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -6,6 +6,7 @@ MODULE_OBJS = \
   action/actionzone.o \
   action/arfactory.o \
   action/autotext.o \
+  action/cameraaction.o \
   action/datarecords.o \
   action/inventoryrecords.o \
   action/navigationrecords.o \
@@ -81,6 +82,7 @@ MODULE_OBJS = \
   ui/animatedbutton.o \
   ui/button.o \
   ui/clock.o \
+  ui/camera.o \
   ui/cellphonepopup.o \
   ui/conversationpopup.o \
   ui/inventorybox.o \
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 396bc28787b..c882ce422dd 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -41,6 +41,7 @@
 
 #include "engines/nancy/ui/button.h"
 #include "engines/nancy/ui/ornaments.h"
+#include "engines/nancy/ui/camera.h"
 #include "engines/nancy/ui/clock.h"
 #include "engines/nancy/ui/taskbar.h"
 
@@ -131,6 +132,7 @@ Scene::Scene() :
 		_textboxOrnaments(nullptr),
 		_inventoryBoxOrnaments(nullptr),
 		_clock(nullptr),
+		_camera(nullptr),
 		_actionManager(),
 		_difficulty(0),
 		_activeMovie(nullptr),
@@ -148,6 +150,7 @@ Scene::~Scene() {
 	delete _textboxOrnaments;
 	delete _inventoryBoxOrnaments;
 	delete _clock;
+	delete _camera;
 	delete _lightning;
 
 	clearPuzzleData();
@@ -858,6 +861,10 @@ void Scene::registerGraphics() {
 	if (_clock) {
 		_clock->registerGraphics();
 	}
+
+	if (_camera) {
+		_camera->registerGraphics();
+	}
 }
 
 void Scene::synchronize(Common::Serializer &ser) {
@@ -1615,9 +1622,20 @@ void Scene::handleInput() {
 		_inventoryBox.handleInput(input);
 	}
 
+	// While the viewfinder is up the scene's own hotspots and its panning are both
+	// suppressed; a click in the viewport only takes the shot.
+	const bool cameraActive = _camera && _camera->isActive();
+	if (cameraActive) {
+		_camera->handleInput(input);
+	}
+
 	// Handle invisible map button
 	// We do this before the viewport since TVD's map button overlaps the viewport's right hotspot
 	for (uint16 id : g_nancy->getStaticData().mapAccessSceneIDs) {
+		if (cameraActive) {
+			break;
+		}
+
 		if ((int)_sceneState.currentScene.sceneID == id) {
 			if (_mapHotspot.contains(input.mousePos)) {
 				g_nancy->_cursor->setCursorType(g_nancy->getGameType() == kGameTypeVampire ? CursorManager::kHotspot : CursorManager::kHotspotArrow);
@@ -1638,11 +1656,13 @@ void Scene::handleInput() {
 	}
 
 	// Handle clock before viewport since it overlaps the left hotspot in TVD
-	if (getClock()) {
+	if (getClock() && !cameraActive) {
 		getClock()->handleInput(input);
 	}
 
-	_viewport.handleInput(input);
+	if (!cameraActive) {
+		_viewport.handleInput(input);
+	}
 
 	_sceneState.currentScene.verticalOffset = _viewport.getCurVerticalScroll();
 
@@ -1651,7 +1671,9 @@ void Scene::handleInput() {
 		g_nancy->_sound->recalculateSoundEffects();
 	}
 
-	_actionManager.handleInput(input);
+	if (!cameraActive) {
+		_actionManager.handleInput(input);
+	}
 
 	// The whole Nancy 10+ taskbar (inventory / notebook / cell phone / MENU /
 	// HELP) stays usable even while a SecondaryMovie is playing; only the
@@ -1844,6 +1866,13 @@ void Scene::initStaticData() {
 		}
 	}
 
+	// The Nancy14 standalone camera; its photo album switches the viewfinder on.
+	auto *uicm = GetEngineData(UICM);
+	if (uicm) {
+		_camera = new UI::Camera();
+		_camera->init();
+	}
+
 	_state = kLoad;
 }
 
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 1c742ff67f7..31607b3399f 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -69,6 +69,7 @@ class ViewportOrnaments;
 class TextboxOrnaments;
 class InventoryBoxOrnaments;
 class Clock;
+class Camera;
 }
 
 namespace State {
@@ -211,6 +212,7 @@ public:
 	UI::CellPhonePopup &getCellPhonePopup() { return _cellPhonePopup; }
 	UI::ConversationPopup &getConversationPopup() { return _conversationPopup; }
 	UI::Clock *getClock();
+	UI::Camera *getCamera() { return _camera; }
 	UI::Taskbar *getTaskbar() { return _taskbar; }
 
 	Action::ActionManager &getActionManager() { return _actionManager; }
@@ -347,6 +349,8 @@ private:
 	UI::InventoryBoxOrnaments *_inventoryBoxOrnaments;
 	RenderObject *_clock;
 
+	UI::Camera *_camera;	// Nancy14 only
+
 	Common::Rect _mapHotspot;
 
 	// General data
diff --git a/engines/nancy/ui/camera.cpp b/engines/nancy/ui/camera.cpp
new file mode 100644
index 00000000000..841a4cc3877
--- /dev/null
+++ b/engines/nancy/ui/camera.cpp
@@ -0,0 +1,194 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/ui/camera.h"
+
+#include "engines/nancy/state/scene.h"
+
+namespace Nancy {
+namespace UI {
+
+static const byte kNoPictureCountVariable = 0xff;
+
+void Camera::init() {
+	_cameraData = GetEngineData(UICM);
+	assert(_cameraData);
+
+	setTransparent(true);
+	setVisible(false);
+}
+
+Common::Rect Camera::viewfinderScreenRect() const {
+	const Common::Rect vp = NancySceneState.getViewport().getScreenPosition();
+	const int16 w = MIN<int16>(_cameraData->viewRect.width(), vp.width());
+	const int16 h = MIN<int16>(_cameraData->viewRect.height(), vp.height());
+
+	Common::Rect box(w, h);
+	box.moveTo(vp.left + (vp.width() - w) / 2, vp.top + (vp.height() - h) / 2);
+	return box;
+}
+
+void Camera::activate() {
+	if (_isActive) {
+		return;
+	}
+
+	_isActive = true;
+
+	// The camera outlives every scene, so its surface is built on first use.
+	const Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	if (_drawSurface.w != vpBounds.width() || _drawSurface.h != vpBounds.height()) {
+		_drawSurface.create(vpBounds.width(), vpBounds.height(),
+			g_nancy->_graphics->getInputPixelFormat());
+
+		if (!_image.w) {
+			g_nancy->_resource->loadImage(_cameraData->overlayImageName, _image);
+		}
+
+		_image.setTransparentColor(_drawSurface.getTransparentColor());
+	}
+
+	moveTo(vpBounds);
+
+	// The viewfinder is fixed, so it only needs drawing once.
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	Common::Rect box = viewfinderScreenRect();
+	box.translate(-_screenPosition.left, -_screenPosition.top);
+	_drawSurface.blitFrom(_image, _cameraData->viewRect, box);
+
+	setVisible(true);
+	_needsRedraw = true;
+}
+
+void Camera::deactivate() {
+	if (!_isActive) {
+		return;
+	}
+
+	_isActive = false;
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setVisible(false);
+	_needsRedraw = true;
+}
+
+uint Camera::numPictures() {
+	// The roll is shared with the Nancy13 cell phone camera ('CPIC' save chunk).
+	const CellPhonePictureData *pd =
+		(const CellPhonePictureData *)NancySceneState.getPuzzleData(CellPhonePictureData::getTag());
+	return pd ? pd->pictures.size() : 0;
+}
+
+void Camera::setPictureCount(uint count) {
+	auto *cameraData = GetEngineData(UICM);
+	if (!cameraData || cameraData->pictureCount == kNoPictureCountVariable) {
+		return;
+	}
+
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	if (table) {
+		table->setSingleValue(cameraData->pictureCount, (int16)count);
+	}
+}
+
+void Camera::playSoundBlock(const RandomSoundBlock &block) {
+	if (block.names.empty()) {
+		return;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return;
+	}
+
+	SoundDescription desc;
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+}
+
+void Camera::takePicture() {
+	CellPhonePictureData *pd =
+		(CellPhonePictureData *)NancySceneState.getPuzzleData(CellPhonePictureData::getTag());
+	if (!pd) {
+		return;
+	}
+
+	const Common::Rect grab = viewportScreenToBackground(viewfinderScreenRect());
+
+	CapturedPicture picture;
+	if (!captureViewportPicture(grab, picture)) {
+		return;
+	}
+
+	// A subject's ID is the event flag saying it has been photographed; the album
+	// clears it again once no picture holds that subject.
+	const uint16 sceneID = NancySceneState.getSceneInfo().sceneID;
+	for (const UICM::CameraSubject &subject : _cameraData->subjects) {
+		if (subject.hotspot.frameID != sceneID || !grab.contains(subject.hotspot.coords)) {
+			continue;
+		}
+
+		picture.subjects.push_back(subject.subjectID);
+		NancySceneState.setEventFlag(subject.subjectID, g_nancy->_true);
+		NancySceneState.setEventFlag(subject.flag);
+	}
+
+	pd->pictures.push_back(picture);
+	setPictureCount(pd->pictures.size());
+	playSoundBlock(_cameraData->shutterSound);
+
+	deactivate();
+}
+
+void Camera::handleInput(NancyInput &input) {
+	// Input outside the viewport is left alone, so the taskbar stays usable.
+	if (!_isActive || !NancySceneState.getViewport().getScreenPosition().contains(input.mousePos)) {
+		return;
+	}
+
+	// The pointer is blanked so only the viewfinder shows.
+	g_nancy->_cursor->setCursorType(CursorManager::kNancy13Blank, true, false);
+
+	if (input.input & NancyInput::kLeftMouseButtonUp) {
+		takePicture();
+	}
+
+	input.eatMouseInput();
+}
+
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/camera.h b/engines/nancy/ui/camera.h
new file mode 100644
index 00000000000..9bcbb599808
--- /dev/null
+++ b/engines/nancy/ui/camera.h
@@ -0,0 +1,71 @@
+/* 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 NANCY_UI_CAMERA_H
+#define NANCY_UI_CAMERA_H
+
+#include "engines/nancy/enginedata.h"
+#include "engines/nancy/renderobject.h"
+
+namespace Nancy {
+
+struct NancyInput;
+
+namespace UI {
+
+// The standalone camera introduced in Nancy14 (UICM), switched on by its photo
+// album (CameraAction). While it is on, a viewfinder box sits at the centre of
+// the viewport and the scene's hotspots and panning are suppressed; a click
+// photographs every subject the box frames.
+class Camera : public RenderObject {
+public:
+	Camera() : RenderObject(9) {}
+	virtual ~Camera() {}
+
+	void init() override;
+
+	void activate();
+	void deactivate();
+	bool isActive() const { return _isActive; }
+
+	void handleInput(NancyInput &input);
+
+	// -- The camera roll, shared with the photo album --
+
+	static uint numPictures();
+	// Publishes the roll's size to the game variable UICM names, if it names one.
+	static void setPictureCount(uint count);
+	static void playSoundBlock(const RandomSoundBlock &block);
+
+protected:
+	// UICM's picture size, centred in the viewport.
+	Common::Rect viewfinderScreenRect() const;
+	void takePicture();
+
+	const UICM *_cameraData = nullptr;
+	Graphics::ManagedSurface _image;
+	bool _isActive = false;
+};
+
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_CAMERA_H
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index eed69c43cf1..3bf1a964ff1 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -1379,45 +1379,13 @@ void CellPhonePopup::captureViewport(const Common::Rect &screenRegion) {
 		return;
 	}
 
-	const Viewport &viewport = NancySceneState.getViewport();
-	const Graphics::ManagedSurface &vp = viewport.getBackground();
-	if (vp.w == 0 || vp.h == 0) {
-		return;
-	}
-
-	// Translate the framed screen region into the (scrolled) viewport background.
-	const Common::Rect vpScreen = viewport.getScreenPosition();
-	const int scrollY = (int)viewport.getCurVerticalScroll();
-	Common::Rect grab;
-	if (screenRegion.isEmpty()) {
-		grab = Common::Rect(0, scrollY, vpScreen.width(), scrollY + vpScreen.height());
-	} else {
-		grab = screenRegion;
-		grab.translate(-vpScreen.left, -vpScreen.top + scrollY);
-	}
-	grab.clip(Common::Rect(vp.w, vp.h));
-	if (grab.isEmpty()) {
-		return;
-	}
+	const Common::Rect grab = viewportScreenToBackground(screenRegion);
 
-	// Copy the sub-area, converted to BGRA32 so it can be re-displayed and saved.
-	Graphics::Surface sub = vp.rawSurface().getSubArea(grab);
-	Graphics::Surface *conv = sub.convertTo(g_nancy->_graphics->getScreenPixelFormat());
-	if (!conv) {
+	CapturedPicture pic;
+	if (!captureViewportPicture(grab, pic)) {
 		return;
 	}
 
-	CapturedPicture pic;
-	pic.width = (uint16)conv->w;
-	pic.height = (uint16)conv->h;
-	pic.pixels.resize((uint)pic.width * (uint)pic.height * 4);
-	for (int y = 0; y < conv->h; ++y) {
-		memcpy(pic.pixels.data() + (uint)y * (uint)pic.width * 4,
-				conv->getBasePtr(0, y), (uint)pic.width * 4);
-	}
-	conv->free();
-	delete conv;
-
 	// Every subject wholly inside the framed area is captured.
 	const uint16 sceneID = NancySceneState.getSceneInfo().sceneID;
 	for (uint i = 0; i < _uiclData->cameraSubjects.size(); ++i) {
diff --git a/engines/nancy/util.cpp b/engines/nancy/util.cpp
index d5af5a7341c..40ee54a9e0a 100644
--- a/engines/nancy/util.cpp
+++ b/engines/nancy/util.cpp
@@ -19,7 +19,9 @@
  */
 
 #include "engines/nancy/enginedata.h"
+#include "engines/nancy/graphics.h"
 #include "engines/nancy/nancy.h"
+#include "engines/nancy/puzzledata.h"
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/state/scene.h"
@@ -253,6 +255,50 @@ void readFilenameArray(Common::Serializer &stream, Common::Array<Common::Path> &
 	}
 }
 
+Common::Rect viewportScreenToBackground(const Common::Rect &screenRegion) {
+	const UI::Viewport &viewport = NancySceneState.getViewport();
+	const Graphics::ManagedSurface &background = viewport.getBackground();
+	const Common::Rect vpScreen = viewport.getScreenPosition();
+	const int scrollY = (int)viewport.getCurVerticalScroll();
+
+	Common::Rect grab;
+	if (screenRegion.isEmpty()) {
+		grab = Common::Rect(vpScreen.width(), vpScreen.height());
+		grab.translate(0, scrollY);
+	} else {
+		grab = screenRegion;
+		grab.translate(-vpScreen.left, -vpScreen.top + scrollY);
+	}
+
+	grab.clip(Common::Rect(background.w, background.h));
+	return grab;
+}
+
+bool captureViewportPicture(const Common::Rect &backgroundRegion, CapturedPicture &picture) {
+	const Graphics::ManagedSurface &background = NancySceneState.getViewport().getBackground();
+	if (background.w == 0 || background.h == 0 || backgroundRegion.isEmpty()) {
+		return false;
+	}
+
+	Graphics::Surface sub = background.rawSurface().getSubArea(backgroundRegion);
+	Graphics::Surface *converted = sub.convertTo(g_nancy->_graphics->getScreenPixelFormat());
+	if (!converted) {
+		return false;
+	}
+
+	picture.width = (uint16)converted->w;
+	picture.height = (uint16)converted->h;
+	picture.pixels.resize((uint)picture.width * (uint)picture.height * 4);
+	for (int y = 0; y < converted->h; ++y) {
+		memcpy(picture.pixels.data() + (uint)y * (uint)picture.width * 4,
+				converted->getBasePtr(0, y), (uint)picture.width * 4);
+	}
+
+	converted->free();
+	delete converted;
+	return true;
+}
+
 void readUIButton(Common::SeekableReadStream &stream, UIButtonRecord &dst) {
 	// Read common fields for both buttons and sliders
 	readFilename(stream, dst.primaryImageName);
diff --git a/engines/nancy/util.h b/engines/nancy/util.h
index 0fe835a4c2b..5cc58864d5e 100644
--- a/engines/nancy/util.h
+++ b/engines/nancy/util.h
@@ -31,6 +31,8 @@
 
 namespace Nancy {
 
+struct CapturedPicture;
+
 void readRect(Common::SeekableReadStream &stream, Common::Rect &inRect);
 void readRect(Common::Serializer &stream, Common::Rect &inRect, Common::Serializer::Version minVersion = 0, Common::Serializer::Version maxVersion = Common::Serializer::kLastVersion);
 void readRectArray(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &inArray, uint num, uint totalNum = 0);
@@ -76,6 +78,14 @@ Common::String readSubtitleText(Common::SeekableReadStream &stream);
 // render pass.
 void showSubtitle(const Common::String &text, bool forceRedraw = false, int overrideFontID = -1);
 
+// Maps a screen-space rect onto the live viewport's (scrolled) background, clipped
+// to it. An empty rect means the whole visible viewport.
+Common::Rect viewportScreenToBackground(const Common::Rect &screenRegion);
+
+// Grabs a background-space region of the live viewport into `picture`, converted to
+// BGRA32 so it can be redisplayed and saved. Used by both cameras.
+bool captureViewportPicture(const Common::Rect &backgroundRegion, CapturedPicture &picture);
+
 void readUIButton(Common::SeekableReadStream &stream, UIButtonRecord &dst);
 void readUISlider(Common::SeekableReadStream &stream, UISliderRecord &dst);
 void readUIPopupHeader(Common::SeekableReadStream &stream, UIPopupHeader &dst);


Commit: 7b6621918722eaa67d25b0c6b6da4f3fe4814412
    https://github.com/scummvm/scummvm/commit/7b6621918722eaa67d25b0c6b6da4f3fe4814412
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-31T05:11:35+03:00

Commit Message:
NANCY: NANCY14: Skip playing known broken overlay animation

Changed paths:
    engines/nancy/action/secondarymovie.cpp


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index c93386d14e2..1406e5deb9f 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -672,6 +672,12 @@ void PlaySecondaryMovie::onPause(bool pause) {
 void PlaySecondaryMovie::execute() {
 	switch (_state) {
 	case kBegin:
+		// WORKAROUND: Skip playing a known broken overlay animation in Nancy14
+		if (g_nancy->getGameType() == kGameTypeNancy14 && _videoName.baseName().equalsIgnoreCase("OFF0007_Water_OVLANIM")) {
+			_isDone = true;
+			return;
+		}
+
 		init();
 		registerGraphics();
 		g_nancy->_sound->loadSound(_sound);




More information about the Scummvm-git-logs mailing list