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

bluegr noreply at scummvm.org
Fri Jul 17 22:57:22 UTC 2026


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

Summary:
d9ef38a6a1 NANCY: NANCY13: Use 32bpp surfaces for the credits scene
14aabe50b0 NANCY: NANCY13: Convert 24bpp surfaces to 32bpp
ac5c94345a NANCY: NANCY13: Use 32bpp surfaces for the cellphone
9e77673c48 NANCY: NANCY13: Add a default value to getInputPixelFormat()
064799d460 NANCY: NANCY13: Implement PegsPuzzle
39f37be12d NANCY: NANCY13: Implement BlocksPuzzle
1aaab1a9d3 NANCY: Clean up action record dispatch table
3f3f08aebb NANCY: NANCY13: Implement StepObjectsPuzzle
c2832334da NANCY: NANCY13: Implement new functionality in TurningPuzzle


Commit: d9ef38a6a15b9b3118c39061e1a915a49af22901
    https://github.com/scummvm/scummvm/commit/d9ef38a6a15b9b3118c39061e1a915a49af22901
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:56:48+03:00

Commit Message:
NANCY: NANCY13: Use 32bpp surfaces for the credits scene

Changed paths:
    engines/nancy/state/credits.cpp


diff --git a/engines/nancy/state/credits.cpp b/engines/nancy/state/credits.cpp
index e4352372230..27c38f3ac87 100644
--- a/engines/nancy/state/credits.cpp
+++ b/engines/nancy/state/credits.cpp
@@ -136,9 +136,10 @@ void Credits::run() {
 
 void Credits::drawTextSurface(uint id) {
 	Graphics::ManagedSurface image;
+	const uint bpp = g_nancy->getGameType() >= kGameTypeNancy13 ? 32 : 16;
 	uint surfaceHeight = _textSurface.getBounds().height();
 	g_nancy->_resource->loadImage(_creditsData->textNames[id], image);
-	_fullTextSurface.create(image.w, image.h + (surfaceHeight * 2), g_nancy->_graphics->getInputPixelFormat());
+	_fullTextSurface.create(image.w, image.h + (surfaceHeight * 2), g_nancy->_graphics->getInputPixelFormat(bpp));
 	_fullTextSurface.setTransparentColor(g_nancy->_graphics->getTransColor());
 	_fullTextSurface.clear(_fullTextSurface.getTransparentColor());
 	_fullTextSurface.blitFrom(image, Common::Point(0, surfaceHeight));


Commit: 14aabe50b0309ade28fa7bcd89e03f8832aade2b
    https://github.com/scummvm/scummvm/commit/14aabe50b0309ade28fa7bcd89e03f8832aade2b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:56:50+03:00

Commit Message:
NANCY: NANCY13: Convert 24bpp surfaces to 32bpp

These 24bpp surfaces are rare. Fixes crash when opening the game
settings screen

Changed paths:
    engines/nancy/resource.cpp


diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index e981df1ff52..9ae3bb11fba 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -176,6 +176,9 @@ bool ResourceManager::loadImage(const Common::Path &name, Graphics::ManagedSurfa
 
 	GraphicsManager::copyToManaged(buf, surf, info.width, info.height, g_nancy->_graphics->getInputPixelFormat(info.depth));
 
+	if (info.depth == 24)
+		surf.convertToInPlace(g_nancy->_graphics->getInputPixelFormat(32));
+
 	delete[] buf;
 	delete stream;
 	return true;


Commit: ac5c94345a37542f58fc1dbf945aa2b757ec620e
    https://github.com/scummvm/scummvm/commit/ac5c94345a37542f58fc1dbf945aa2b757ec620e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:56:53+03:00

Commit Message:
NANCY: NANCY13: Use 32bpp surfaces for the cellphone

Changed paths:
    engines/nancy/ui/cellphonepopup.cpp
    engines/nancy/ui/inventorypopup.cpp


diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 192ca7c6cd8..7c270e66776 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -53,7 +53,8 @@ public:
 
 	void render(uint width, uint height, uint32 transColor,
 				const Common::String &text, uint fontID) {
-		initSurfaces(width, height, g_nancy->_graphics->getInputPixelFormat(),
+		const uint bpp = g_nancy->getGameType() >= kGameTypeNancy13 ? 32 : 16;
+		initSurfaces(width, height, g_nancy->_graphics->getInputPixelFormat(bpp),
 						transColor, transColor);
 		_fullSurface.setTransparentColor(transColor);
 		addTextLine(text);
@@ -107,7 +108,7 @@ void CellPhonePopup::init() {
 
 	Common::Rect bounds = _screenPosition;
 	bounds.moveTo(0, 0);
-	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getScreenPixelFormat());
 
 	// Persistent state lives in CellPhoneData (saved across the game).
 	// First-time init seeds the runtime contact list from the chunk;
diff --git a/engines/nancy/ui/inventorypopup.cpp b/engines/nancy/ui/inventorypopup.cpp
index b98c168eb11..c54c840c592 100644
--- a/engines/nancy/ui/inventorypopup.cpp
+++ b/engines/nancy/ui/inventorypopup.cpp
@@ -62,7 +62,7 @@ void InventoryPopup::init() {
 
 	Common::Rect bounds = _screenPosition;
 	bounds.moveTo(0, 0);
-	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getScreenPixelFormat());
 
 	setActiveFilterIndex(0);
 


Commit: 9e77673c4874e35f080b91ce04e9624b6051c107
    https://github.com/scummvm/scummvm/commit/9e77673c4874e35f080b91ce04e9624b6051c107
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:56:58+03:00

Commit Message:
NANCY: NANCY13: Add a default value to getInputPixelFormat()

We only need to override the BPP used for getInputPixelFormat() in the
resource handler, so use game version checking by default, to simplify
the caller code and avoid case-by-case checks

Changed paths:
    engines/nancy/graphics.cpp
    engines/nancy/graphics.h
    engines/nancy/state/credits.cpp
    engines/nancy/ui/cellphonepopup.cpp
    engines/nancy/ui/notebookpopup.cpp


diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index c4c6662318b..2dc26f8cdc6 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -401,6 +401,8 @@ const Graphics::PixelFormat &GraphicsManager::getInputPixelFormat(uint bpp) {
 		return _clut8Format;
 
 	switch (bpp) {
+	case 0:
+		return g_nancy->getGameType() >= kGameTypeNancy13 ? _inputPixelFormat32 : _inputPixelFormat16;
 	case 16:
 		return _inputPixelFormat16;	// RGB555
 	case 24:
diff --git a/engines/nancy/graphics.h b/engines/nancy/graphics.h
index c57540489e9..f2e9d7bb0f7 100644
--- a/engines/nancy/graphics.h
+++ b/engines/nancy/graphics.h
@@ -52,7 +52,7 @@ public:
 	const Font *getFont(uint id) const { return id < _fonts.size() ? &_fonts[id] : nullptr; }
 	const Graphics::Screen *getScreen() { return &_screen; }
 
-	const Graphics::PixelFormat &getInputPixelFormat(uint bpp = 16);
+	const Graphics::PixelFormat &getInputPixelFormat(uint bpp = 0);
 	const Graphics::PixelFormat &getScreenPixelFormat();
 	const Graphics::PixelFormat &getTransparentPixelFormat();
 	uint32 getTransColor() { return _transColor; }
diff --git a/engines/nancy/state/credits.cpp b/engines/nancy/state/credits.cpp
index 27c38f3ac87..e4352372230 100644
--- a/engines/nancy/state/credits.cpp
+++ b/engines/nancy/state/credits.cpp
@@ -136,10 +136,9 @@ void Credits::run() {
 
 void Credits::drawTextSurface(uint id) {
 	Graphics::ManagedSurface image;
-	const uint bpp = g_nancy->getGameType() >= kGameTypeNancy13 ? 32 : 16;
 	uint surfaceHeight = _textSurface.getBounds().height();
 	g_nancy->_resource->loadImage(_creditsData->textNames[id], image);
-	_fullTextSurface.create(image.w, image.h + (surfaceHeight * 2), g_nancy->_graphics->getInputPixelFormat(bpp));
+	_fullTextSurface.create(image.w, image.h + (surfaceHeight * 2), g_nancy->_graphics->getInputPixelFormat());
 	_fullTextSurface.setTransparentColor(g_nancy->_graphics->getTransColor());
 	_fullTextSurface.clear(_fullTextSurface.getTransparentColor());
 	_fullTextSurface.blitFrom(image, Common::Point(0, surfaceHeight));
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 7c270e66776..e99f40c8897 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -53,8 +53,7 @@ public:
 
 	void render(uint width, uint height, uint32 transColor,
 				const Common::String &text, uint fontID) {
-		const uint bpp = g_nancy->getGameType() >= kGameTypeNancy13 ? 32 : 16;
-		initSurfaces(width, height, g_nancy->_graphics->getInputPixelFormat(bpp),
+		initSurfaces(width, height, g_nancy->_graphics->getInputPixelFormat(),
 						transColor, transColor);
 		_fullSurface.setTransparentColor(transColor);
 		addTextLine(text);
diff --git a/engines/nancy/ui/notebookpopup.cpp b/engines/nancy/ui/notebookpopup.cpp
index da5f62923f0..32e2b78bcfe 100644
--- a/engines/nancy/ui/notebookpopup.cpp
+++ b/engines/nancy/ui/notebookpopup.cpp
@@ -80,9 +80,8 @@ void NotebookPopup::init() {
 	// painted by drawBackground() — paper stays stationary while text
 	// scrolls. Color 0 would clip real font pixels in Nancy fonts.
 	const uint32 trans = g_nancy->_graphics->getTransColor();
-	const uint bpp = g_nancy->getGameType() >= kGameTypeNancy13 ? 32 : 16;
 	initSurfaces(_uinbData->textRect.width(), kHypertextSurfaceHeight,
-		g_nancy->_graphics->getInputPixelFormat(bpp), trans, trans);
+		g_nancy->_graphics->getInputPixelFormat(), trans, trans);
 	_fullSurface.setTransparentColor(trans);
 	_textHighlightSurface.setTransparentColor(trans);
 


Commit: 064799d4600233f7eafb3869ffc07ce38261708d
    https://github.com/scummvm/scummvm/commit/064799d4600233f7eafb3869ffc07ce38261708d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:57:03+03:00

Commit Message:
NANCY: NANCY13: Implement PegsPuzzle

Peg-solitaire puzzle, new in Nancy13 (AR 173, "leaping" jump game). The
board is a grid of holes (in Nancy13, a 7x7 grid with the four 2x2
corners blocked off, i.e. the classic 33-hole English peg-solitaire
cross). A peg jumps over an adjacent peg into an empty hole two cells
away, removing the jumped peg; a peg that can chain another jump stays
selected. The puzzle is solved when no move remains and the number of
pegs left is at most the target count.

Changed paths:
  A engines/nancy/action/puzzle/pegspuzzle.cpp
  A engines/nancy/action/puzzle/pegspuzzle.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/module.mk


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index a289a62a75f..0a0dfeecdbf 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -63,6 +63,7 @@
 #include "engines/nancy/action/puzzle/overridelockpuzzle.h"
 #include "engines/nancy/action/puzzle/passwordpuzzle.h"
 #include "engines/nancy/action/puzzle/peepholepuzzle.h"
+#include "engines/nancy/action/puzzle/pegspuzzle.h"
 #include "engines/nancy/action/puzzle/quizpuzzle.h"
 #include "engines/nancy/action/puzzle/raycastpuzzle.h"
 #include "engines/nancy/action/puzzle/riddlepuzzle.h"
@@ -196,8 +197,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 46:	// Nancy11
 		return new PlayRandomMovieControl();
 	// Nancy14 dispatches the secondary-movie family from 41-47 instead of the
-	// legacy 50-53 slots. Only the Nancy14 layout is verified; earlier games
-	// stay unmapped here.
+	// legacy 50-53 slots.
 	case 41:	// PlaySecondaryMovie
 	case 44:	// PlaySecondaryMovie (adds a trailing volume byte)
 		if (g_nancy->getGameType() >= kGameTypeNancy14)
@@ -509,28 +509,30 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// SetPlayerClock lived here up to Nancy11; moved to 140 in Nancy12
 		return new SetPlayerClock();
 	case 171:
-		// TurningPuzzle, moved here from 209 in Nancy13.
-		if (g_nancy->getGameType() >= kGameTypeNancy13)
-			return new TurningPuzzle();
+		// TurningPuzzle, moved here from 209 in Nancy13. NOTE: the Nancy13 chunk is a
+		// *different* layout from the 209 version (variable-length per-face string labels
+		// instead of a single image strip), so TurningPuzzle::readData would desync the
+		// stream. Left unimplemented (returns nullptr) until a readDataNancy13 + reworked
+		// draw path exist.
+		// TODO: not yet implemented for Nancy13
 		return nullptr;
 	case 172:
 		// BlocksPuzzle, new in Nancy13
 		// TODO: not yet implemented
 		return nullptr;
 	case 173:
-		// PegsPuzzle, new in Nancy13
-		// TODO: not yet implemented
-		return nullptr;
+		// PegsPuzzle (peg solitaire), new in Nancy13
+		return new PegsPuzzle();
 	case 174:
-		// Unknown Puzzle, new in Nancy13
+		// ScalePuzzle (balance scale), new in Nancy13
 		// TODO: not yet implemented
 		return nullptr;
 	case 175:
-		// Unknown Puzzle, new in Nancy13
+		// PachinkoPuzzle (ball drop), new in Nancy13
 		// TODO: not yet implemented
 		return nullptr;
 	case 176:
-		// Unknown Puzzle, new in Nancy13
+		// DropSortPuzzle (drag-drop sort activity), new in Nancy13
 		// TODO: not yet implemented
 		return nullptr;
 	// -- Nancy14 new puzzles (types 177-182) --
diff --git a/engines/nancy/action/puzzle/pegspuzzle.cpp b/engines/nancy/action/puzzle/pegspuzzle.cpp
new file mode 100644
index 00000000000..5a1cbcf4367
--- /dev/null
+++ b/engines/nancy/action/puzzle/pegspuzzle.cpp
@@ -0,0 +1,426 @@
+/* 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/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/pegspuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void PegsPuzzle::readData(Common::SeekableReadStream &stream) {
+	// 96-byte header (bulk-copied by the original into a sub-object).
+	readFilename(stream, _imageName);			// 0x00
+	// The puzzle carries its own cursors as raw Nancy13 cursor type ids.
+	_hoverCursorType = stream.readUint16LE();	// 0x21
+	_dragCursorType = stream.readUint16LE();	// 0x23
+	_startEmptyFlag = stream.readByte();	// 0x25
+	_startEmptyPos = stream.readByte();		// 0x26
+	_targetPegCount = stream.readByte();	// 0x27
+
+	// Win / lose scene changes, each {uint16 sceneID, uint16 frameID, byte}. A frameID of
+	// 0xffff means "no specific frame" (the target scene may be a video, so seeking to
+	// 65535 must be avoided) - keep the default frame 0.
+	_winScene.sceneID = stream.readUint16LE();	// 0x28
+	uint16 winFrame = stream.readUint16LE();	// 0x2a
+	_winScene.frameID = (winFrame == 0xffff) ? 0 : winFrame;
+	stream.skip(1);								// 0x2c
+	_loseScene.sceneID = stream.readUint16LE();	// 0x2d
+	uint16 loseFrame = stream.readUint16LE();	// 0x2f
+	_loseScene.frameID = (loseFrame == 0xffff) ? 0 : loseFrame;
+	stream.skip(1);								// 0x31
+
+	readRect(stream, _pegSrc);				// 0x32 - int32 rect
+	readRect(stream, _selectedSrc);			// 0x42 - int32 rect
+	_originY = stream.readUint16LE();		// 0x52
+	_originX = stream.readUint16LE();		// 0x54
+	_numRows = stream.readUint16LE();		// 0x56
+	_numCols = stream.readUint16LE();		// 0x58
+	_pitchYBias = stream.readUint16LE();	// 0x5a
+	_pitchXBias = stream.readUint16LE();	// 0x5c
+	_numBlocked = stream.readUint16LE();	// 0x5e
+
+	// The blocked cells (the corners cut out of the grid).
+	_blockedPositions.resize(_numBlocked);
+	for (uint i = 0; i < _numBlocked; ++i) {
+		_blockedPositions[i] = stream.readByte();
+	}
+
+	// A count-prefixed array of fixed 23-byte hotspot records:
+	// {rect, u16 cursorType, u16 sceneID, u16 frameID, byte}. The sample carries one - the
+	// "give up / exit" hotspot (leave the puzzle unsolved), with the exit cursor type.
+	int16 numZones = stream.readSint16LE();
+	for (int16 i = 0; i < numZones; ++i) {
+		Common::Rect r;
+		readRect(stream, r);
+		uint16 cursorType = stream.readUint16LE();
+		uint16 sceneID = stream.readUint16LE();
+		uint16 frameID = stream.readUint16LE();
+		stream.skip(1);							// trailing flag
+
+		if (i == 0) {
+			_exitHotspot = r;
+			_exitCursorType = cursorType;
+			_exitScene.sceneID = sceneID;
+			_exitScene.frameID = (frameID == 0xffff) ? 0 : frameID;
+		}
+	}
+
+	// Five random-sound blocks: [0] peg select, [1] jump, [2] selection pulse, [3] win, [4] lose.
+	_sounds.resize(5);
+	for (uint i = 0; i < 5; ++i) {
+		_sounds[i].readData(stream);
+	}
+}
+
+void PegsPuzzle::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());
+
+	// Build the board: every hole starts with a peg, the cut-out corners are blocked,
+	// and one hole (usually the centre) starts empty.
+	_board.resize(64);
+	for (uint i = 0; i < 64; ++i) {
+		_board[i] = kPeg;
+	}
+	for (uint i = 0; i < _blockedPositions.size(); ++i) {
+		byte pos = _blockedPositions[i];
+		int col = _numRows ? (pos % _numRows) : 0;
+		int row = _numCols ? (pos / _numCols) : 0;
+		if (validCell(col, row)) {
+			_board[cellIndex(col, row)] = kBlocked;
+		}
+	}
+	if (_startEmptyFlag == 0) {
+		int col = _numRows ? (_startEmptyPos % _numRows) : 0;
+		int row = _numCols ? (_startEmptyPos / _numCols) : 0;
+		if (validCell(col, row)) {
+			_board[cellIndex(col, row)] = kEmpty;
+		}
+	}
+
+	// Per-cell destination rects (viewport space). The pitch is the sprite size plus a
+	// small per-axis bias, exactly as the original computes it.
+	int pitchX = (_pitchXBias - _pegSrc.left) + _pegSrc.right;
+	int pitchY = (_pitchYBias - _pegSrc.top) + _pegSrc.bottom;
+	int cellW = _pegSrc.right - _pegSrc.left;
+	int cellH = _pegSrc.bottom - _pegSrc.top;
+
+	_destRects.resize(64);
+	for (int row = 0; row < _numRows; ++row) {
+		for (int col = 0; col < _numCols; ++col) {
+			int left = _originX + pitchX * col;
+			int top = _originY + pitchY * row;
+			_destRects[cellIndex(col, row)] = Common::Rect((int16)left, (int16)top,
+				(int16)(left + cellW), (int16)(top + cellH));
+		}
+	}
+
+	NancySceneState.setNoHeldItem();
+
+	redraw();
+	registerGraphics();
+}
+
+bool PegsPuzzle::validCell(int col, int row) const {
+	return col >= 0 && row >= 0 && col < _numCols && row < _numRows;
+}
+
+bool PegsPuzzle::canJump(int col, int row, int dCol, int dRow, int &destCol, int &destRow) const {
+	destCol = col + 2 * dCol;
+	destRow = row + 2 * dRow;
+	if (!validCell(destCol, destRow)) {
+		return false;
+	}
+
+	int midCol = col + dCol;
+	int midRow = row + dRow;
+	return _board[cellIndex(col, row)] == kPeg &&
+		_board[cellIndex(midCol, midRow)] == kPeg &&
+		_board[cellIndex(destCol, destRow)] == kEmpty;
+}
+
+bool PegsPuzzle::isCarriedTarget(int col, int row) const {
+	if (_carriedCol < 0) {
+		return false;
+	}
+
+	static const int dirs[4][2] = { { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } };
+	for (int d = 0; d < 4; ++d) {
+		int dc, dr;
+		if (canJump(_carriedCol, _carriedRow, dirs[d][0], dirs[d][1], dc, dr) && dc == col && dr == row) {
+			return true;
+		}
+	}
+	return false;
+}
+
+bool PegsPuzzle::cellAtCursor(const Common::Point &mousePos, int &outCol, int &outRow) const {
+	for (int row = 0; row < _numRows; ++row) {
+		for (int col = 0; col < _numCols; ++col) {
+			int idx = cellIndex(col, row);
+			if (_board[idx] != kBlocked &&
+					NancySceneState.getViewport().convertViewportToScreen(_destRects[idx]).contains(mousePos)) {
+				outCol = col;
+				outRow = row;
+				return true;
+			}
+		}
+	}
+	return false;
+}
+
+bool PegsPuzzle::cellHasAnyMove(int col, int row) const {
+	static const int dirs[4][2] = { { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } };
+	for (int d = 0; d < 4; ++d) {
+		int dc, dr;
+		if (canJump(col, row, dirs[d][0], dirs[d][1], dc, dr)) {
+			return true;
+		}
+	}
+	return false;
+}
+
+bool PegsPuzzle::anyMovesLeft() const {
+	for (int row = 0; row < _numRows; ++row) {
+		for (int col = 0; col < _numCols; ++col) {
+			if (_board[cellIndex(col, row)] == kPeg && cellHasAnyMove(col, row)) {
+				return true;
+			}
+		}
+	}
+	return false;
+}
+
+int PegsPuzzle::pegCount() const {
+	int count = 0;
+	for (int row = 0; row < _numRows; ++row) {
+		for (int col = 0; col < _numCols; ++col) {
+			if (_board[cellIndex(col, row)] == kPeg) {
+				++count;
+			}
+		}
+	}
+	return count;
+}
+
+void PegsPuzzle::doJump(int fromCol, int fromRow, int destCol, int destRow) {
+	int midCol = (fromCol + destCol) / 2;
+	int midRow = (fromRow + destRow) / 2;
+	_board[cellIndex(fromCol, fromRow)] = kEmpty;
+	_board[cellIndex(midCol, midRow)] = kEmpty;
+	_board[cellIndex(destCol, destRow)] = kPeg;
+}
+
+Common::Point PegsPuzzle::cursorToViewport(const Common::Point &mousePos) const {
+	Common::Rect screenPt(mousePos.x, mousePos.y, mousePos.x + 1, mousePos.y + 1);
+	Common::Rect vpPt = NancySceneState.getViewport().convertScreenToViewport(screenPt);
+	return Common::Point(vpPt.left, vpPt.top);
+}
+
+void PegsPuzzle::setDataCursor(uint16 cursorType) 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);
+}
+
+void PegsPuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	const bool carrying = (_carriedCol >= 0);
+
+	for (int row = 0; row < _numRows; ++row) {
+		for (int col = 0; col < _numCols; ++col) {
+			int idx = cellIndex(col, row);
+			if (_board[idx] != kPeg) {
+				continue;
+			}
+
+			// The peg being carried is lifted off the board and drawn at the cursor instead.
+			if (carrying && col == _carriedCol && row == _carriedRow) {
+				continue;
+			}
+
+			const Common::Rect &dest = _destRects[idx];
+			_drawSurface.blitFrom(_image, _pegSrc, Common::Point(dest.left, dest.top));
+		}
+	}
+
+	// The dragged piece, following the cursor.
+	if (carrying) {
+		int w = _pegSrc.right - _pegSrc.left;
+		int h = _pegSrc.bottom - _pegSrc.top;
+		_drawSurface.blitFrom(_image, _pegSrc, Common::Point(_dragPos.x - w / 2, _dragPos.y - h / 2));
+	}
+
+	_needsRedraw = true;
+}
+
+SoundDescription PegsPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	SoundDescription desc;
+	if (block.names.empty()) {
+		return desc;
+	}
+
+	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;
+	}
+
+	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);
+	return desc;
+}
+
+void PegsPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		// The original evaluates win/lose only once no legal move remains.
+		if (!_ended && _carriedCol < 0 && !anyMovesLeft()) {
+			_ended = true;
+			_solved = (pegCount() <= _targetPegCount);
+			_endSound = playSoundBlock(_solved ? _sounds[3] : _sounds[4]);
+			_endTime = now;
+			redraw();
+		}
+
+		if (_ended) {
+			bool soundDone = _endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound);
+			if (soundDone && now - _endTime > 500) {
+				_state = kActionTrigger;
+			}
+		}
+
+		break;
+	}
+	case kActionTrigger: {
+		if (_exitRequested) {
+			if (_exitScene.sceneID != kNoScene) {
+				NancySceneState.changeScene(_exitScene);
+			}
+		} else {
+			const SceneChangeDescription &sc = _solved ? _winScene : _loseScene;
+			if (sc.sceneID != kNoScene) {
+				NancySceneState.changeScene(sc);
+			}
+		}
+
+		finishExecution();
+		break;
+	}
+	}
+}
+
+void PegsPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _ended) {
+		return;
+	}
+
+	const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+	// -- Carrying a peg: it follows the cursor; drop it on a reachable hole to jump. --
+	if (_carriedCol >= 0) {
+		setDataCursor(_dragCursorType);
+
+		// The dragged piece tracks the cursor (viewport space).
+		_dragPos = cursorToViewport(input.mousePos);
+		redraw();
+
+		if (click) {
+			int col, row;
+			if (cellAtCursor(input.mousePos, col, row) && isCarriedTarget(col, row)) {
+				doJump(_carriedCol, _carriedRow, col, row);
+				playSoundBlock(_sounds[1]);
+			}
+
+			// The peg is always set down, whether it jumped or not. Chaining another jump
+			// is simply picking the landed peg up again.
+			_carriedCol = _carriedRow = -1;
+			redraw();
+		}
+
+		input.eatMouseInput();
+		return;
+	}
+
+	// -- Not carrying: pick up a movable peg, or leave via the exit hotspot. --
+	int col, row;
+	if (cellAtCursor(input.mousePos, col, row)) {
+		if (_board[cellIndex(col, row)] == kPeg && cellHasAnyMove(col, row)) {
+			setDataCursor(_hoverCursorType);
+			if (click) {
+				_carriedCol = col;
+				_carriedRow = row;
+				// Seed the drag position before the first draw, otherwise the piece would
+				// briefly appear at the previous drag's position.
+				_dragPos = cursorToViewport(input.mousePos);
+				playSoundBlock(_sounds[0]);
+				redraw();
+			}
+			input.eatMouseInput();
+		}
+		return;
+	}
+
+	if (!_exitHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		setDataCursor(_exitCursorType);
+		if (click) {
+			_exitRequested = true;
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/pegspuzzle.h b/engines/nancy/action/puzzle/pegspuzzle.h
new file mode 100644
index 00000000000..fd9e33f7610
--- /dev/null
+++ b/engines/nancy/action/puzzle/pegspuzzle.h
@@ -0,0 +1,126 @@
+/* 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_PEGSPUZZLE_H
+#define NANCY_ACTION_PEGSPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Peg-solitaire puzzle, new in Nancy13 (AR 173, "leaping" jump game). The board is
+// a grid of holes (in Nancy13, a 7x7 grid with the four 2x2 corners blocked off,
+// i.e. the classic 33-hole English peg-solitaire cross). A peg jumps over an
+// adjacent peg into an empty hole two cells away, removing the jumped peg. The
+// puzzle is solved when no move remains and the number of pegs left is at most the
+// target count.
+//
+// Pegs are dragged and dropped: clicking a movable peg picks it up (the hand/drag
+// cursor carries the piece) and dropping it on a hole it can reach performs the jump.
+// Cell state matches the original: 0 = peg, 1 = empty hole, 2 = blocked (no hole).
+class PegsPuzzle : public RenderActionRecord {
+public:
+	PegsPuzzle() : RenderActionRecord(7) {}
+	virtual ~PegsPuzzle() {}
+
+	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 "PegsPuzzle"; }
+
+	enum CellState : byte { kPeg = 0, kEmpty = 1, kBlocked = 2 };
+
+	int cellIndex(int col, int row) const { return col + row * 8; }
+	bool validCell(int col, int row) const;
+	// Whether a peg at (col,row) can jump in the direction (dCol,dRow), and if so the
+	// landing cell.
+	bool canJump(int col, int row, int dCol, int dRow, int &destCol, int &destRow) const;
+	// Whether the carried peg can jump onto (col,row).
+	bool isCarriedTarget(int col, int row) const;
+	// The board cell whose destination rect contains the (screen-space) cursor, or false.
+	bool cellAtCursor(const Common::Point &mousePos, int &outCol, int &outRow) const;
+	bool cellHasAnyMove(int col, int row) const;
+	bool anyMovesLeft() const;
+	int pegCount() const;
+	void doJump(int fromCol, int fromRow, int destCol, int destRow);
+
+	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;
+
+	void redraw();
+	SoundDescription playSoundBlock(const RandomSoundBlock &block);
+
+	// -- File data (96-byte header) --
+	Common::Path _imageName;			// 0x00
+	uint16 _hoverCursorType = 0;		// 0x21 - cursor while hovering a movable peg (open hand)
+	uint16 _dragCursorType = 0;			// 0x23 - cursor while carrying a peg (pointing finger)
+	byte _startEmptyFlag = 0;			// 0x25 - 0 => mark _startEmptyPos empty at init
+	byte _startEmptyPos = 0;			// 0x26 - the initially-empty hole (usually the centre)
+	byte _targetPegCount = 0;			// 0x27 - win when pegs remaining <= this
+	SceneChangeDescription _winScene;	// 0x28 - shown when solved (9999 => none)
+	SceneChangeDescription _loseScene;	// 0x2d - shown when no move remains and unsolved
+	Common::Rect _pegSrc;				// 0x32 - normal peg sprite (viewport image)
+	Common::Rect _selectedSrc;			// 0x42 - highlight sprite (selected peg / target holes)
+	uint16 _originY = 0;				// 0x52 - board top-left (viewport space)
+	uint16 _originX = 0;				// 0x54
+	uint16 _numRows = 0;				// 0x56
+	uint16 _numCols = 0;				// 0x58
+	uint16 _pitchYBias = 0;				// 0x5a
+	uint16 _pitchXBias = 0;				// 0x5c
+	uint16 _numBlocked = 0;				// 0x5e - count of blocked-corner positions
+
+	Common::Array<byte> _blockedPositions;
+
+	// The clickable "give up / exit" hotspot (the base-class hotspot record).
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;			// the record's leading field is its cursor type
+	SceneChangeDescription _exitScene;
+
+	Common::Array<RandomSoundBlock> _sounds;	// 5 blocks: select / jump / pulse / win / lose
+
+	// -- Runtime state --
+	Common::Array<byte> _board;			// 64 cells (8-wide stride)
+	Common::Array<Common::Rect> _destRects;
+	int _carriedCol = -1;				// the peg currently picked up (dragged), or -1
+	int _carriedRow = -1;
+	Common::Point _dragPos;				// cursor position (viewport space) while dragging
+	bool _ended = false;
+	bool _solved = false;
+	bool _exitRequested = false;
+	uint32 _endTime = 0;
+	SoundDescription _endSound;			// the win/lose cue we wait on before changing scene
+
+	Graphics::ManagedSurface _image;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_PEGSPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 7791e4cbba2..49c49f0d0f6 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -47,6 +47,7 @@ MODULE_OBJS = \
   action/puzzle/overridelockpuzzle.o \
   action/puzzle/passwordpuzzle.o \
   action/puzzle/peepholepuzzle.o \
+  action/puzzle/pegspuzzle.o \
   action/puzzle/quizpuzzle.o \
   action/puzzle/raycastpuzzle.o \
   action/puzzle/riddlepuzzle.o \


Commit: 39f37be12d4235fd760e76f36dbac87b3e7f5bdb
    https://github.com/scummvm/scummvm/commit/39f37be12d4235fd760e76f36dbac87b3e7f5bdb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:57:05+03:00

Commit Message:
NANCY: NANCY13: Implement BlocksPuzzle

Shaped-blocks puzzle, new in Nancy13 (AR 172). Carved blocks sit in the
cells of an isometric board. A block is picked up by clicking it,
carried on the cursor, and put down on another cell; dropping it onto
an occupied cell swaps the two, leaving the displaced block in hand.
A block can also be turned, stepping through its rotations one at a
time with a short tweening animation. The board is solved once every
cell holds its target block at its target rotation.

Changed paths:
  A engines/nancy/action/puzzle/blockspuzzle.cpp
  A engines/nancy/action/puzzle/blockspuzzle.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/module.mk


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 0a0dfeecdbf..232190a97b4 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -37,6 +37,7 @@
 #include "engines/nancy/action/puzzle/assemblypuzzle.h"
 #include "engines/nancy/action/puzzle/bballpuzzle.h"
 #include "engines/nancy/action/puzzle/beadpuzzle.h"
+#include "engines/nancy/action/puzzle/blockspuzzle.h"
 #include "engines/nancy/action/puzzle/boardgamepuzzle.h"
 #include "engines/nancy/action/puzzle/bulpuzzle.h"
 #include "engines/nancy/action/puzzle/bombpuzzle.h"
@@ -517,11 +518,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// TODO: not yet implemented for Nancy13
 		return nullptr;
 	case 172:
-		// BlocksPuzzle, new in Nancy13
-		// TODO: not yet implemented
-		return nullptr;
+		return new BlocksPuzzle();
 	case 173:
-		// PegsPuzzle (peg solitaire), new in Nancy13
 		return new PegsPuzzle();
 	case 174:
 		// ScalePuzzle (balance scale), new in Nancy13
diff --git a/engines/nancy/action/puzzle/blockspuzzle.cpp b/engines/nancy/action/puzzle/blockspuzzle.cpp
new file mode 100644
index 00000000000..d3c1fe9a8dd
--- /dev/null
+++ b/engines/nancy/action/puzzle/blockspuzzle.cpp
@@ -0,0 +1,478 @@
+/* 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 "common/util.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/blockspuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void BlocksPuzzle::readData(Common::SeekableReadStream &stream) {
+	// 111-byte header (bulk-copied by the original into a sub-object).
+	readFilename(stream, _imageName);			// 0x00
+	_turnDuration = stream.readUint16LE();		// 0x21
+	_carryCursorType = stream.readUint16LE();	// 0x23
+	_turnCursorType = stream.readUint16LE();	// 0x25
+	_startFlag.label = stream.readSint16LE();	// 0x27
+	_startFlag.flag = stream.readByte();		// 0x29
+
+	readRect(stream, _overlaySrc);			// 0x2a
+	readRect(stream, _overlayDest);			// 0x3a
+	readRect(stream, _turntableDest);		// 0x4a
+	readRect(stream, _turntableHotspot);	// 0x5a
+
+	// The scene change and flag applied once the board comes out solved. The frame is
+	// always the scene's first, and its sound carries over.
+	_solveScene.sceneID = stream.readUint16LE();	// 0x6a
+	_solveScene.continueSceneSound = kContinueSceneSound;
+	_solveFlag.label = stream.readSint16LE();		// 0x6c
+	_solveFlag.flag = stream.readByte();			// 0x6e
+
+	// A count-prefixed array of fixed 23-byte hotspot records:
+	// {rect, u16 cursorType, u16 sceneID, u16 frameID, byte}. The sample carries one - the
+	// "give up / exit" hotspot (leave the puzzle unsolved), with the exit cursor type.
+	int16 numZones = stream.readSint16LE();
+	for (int16 i = 0; i < numZones; ++i) {
+		Common::Rect r;
+		readRect(stream, r);
+		uint16 cursorType = stream.readUint16LE();
+		uint16 sceneID = stream.readUint16LE();
+		uint16 frameID = stream.readUint16LE();
+		stream.skip(1);							// trailing flag
+
+		if (i == 0) {
+			_exitHotspot = r;
+			_exitCursorType = cursorType;
+			_exitScene.sceneID = sceneID;
+			// A frameID of 0xffff means "no specific frame" (the target scene may be a
+			// video, so seeking to 65535 must be avoided) - keep the default frame 0.
+			_exitScene.frameID = (frameID == 0xffff) ? 0 : frameID;
+		}
+	}
+
+	// The block shapes, each a 13-byte descriptor of its row in the atlas image.
+	int16 numBlocks = stream.readSint16LE();
+	_blocks.resize(numBlocks);
+	for (int16 i = 0; i < numBlocks; ++i) {
+		Block &block = _blocks[i];
+		block.numRotations = stream.readByte();
+		block.numTweenFrames = stream.readSint16LE();
+		block.gap = stream.readSint16LE();
+		block.width = stream.readSint16LE();
+		block.height = stream.readSint16LE();
+		block.atlasX = stream.readSint16LE();
+		block.atlasY = stream.readSint16LE();
+	}
+
+	// The board cells, each a 39-byte record holding both its starting and its target
+	// contents.
+	int16 numCells = stream.readSint16LE();
+	_cells.resize(numCells);
+	for (int16 i = 0; i < numCells; ++i) {
+		Cell &cell = _cells[i];
+		cell.block = stream.readSint16LE();
+		cell.rotation = stream.readByte();
+		cell.targetBlock = stream.readSint16LE();
+		cell.targetRotation = stream.readUint16LE();
+		readRect(stream, cell.dest);
+		readRect(stream, cell.hotspot);
+	}
+
+	// Three random-sound blocks: [0] turn, [1] pick up / put down, [2] success.
+	for (uint i = 0; i < kNumSounds; ++i) {
+		_sounds[i].readData(stream);
+	}
+}
+
+void BlocksPuzzle::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());
+
+	_hasTurntable = !_turntableDest.isEmpty();
+
+	NancySceneState.setEventFlag(_startFlag);
+	NancySceneState.setNoHeldItem();
+
+	redraw();
+	registerGraphics();
+}
+
+Common::Rect BlocksPuzzle::blockSrc(int16 block, byte rotation, int16 frame) const {
+	const Block &b = _blocks[block];
+	int16 left = b.atlasX + ((b.numTweenFrames + 1) * rotation + frame) * (b.gap + b.width);
+	return Common::Rect(left, b.atlasY, left + b.width, b.atlasY + b.height);
+}
+
+int16 BlocksPuzzle::cellAtCursor(const Common::Point &mousePos, bool occupiedOnly) const {
+	for (uint i = 0; i < _cells.size(); ++i) {
+		const Cell &cell = _cells[i];
+		if (occupiedOnly && cell.block == kNoBlock) {
+			continue;
+		}
+
+		if (NancySceneState.getViewport().convertViewportToScreen(cell.hotspot).contains(mousePos)) {
+			return (int16)i;
+		}
+	}
+
+	return kNoBlock;
+}
+
+bool BlocksPuzzle::isSolved() const {
+	for (uint i = 0; i < _cells.size(); ++i) {
+		const Cell &cell = _cells[i];
+		if (cell.block == kNoBlock || cell.block != cell.targetBlock || cell.rotation != cell.targetRotation) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+void BlocksPuzzle::pickUp(int16 cell) {
+	playSoundBlock(_sounds[kHandleSound]);
+
+	if (cell == kTurntableCell) {
+		_carriedBlock = _turnBlock;
+		_carriedRotation = _turnRotation;
+		_carriedSrc = _turnSrc;
+		_turnBlock = kNoBlock;
+	} else {
+		Cell &c = _cells[cell];
+		_carriedBlock = c.block;
+		_carriedRotation = c.rotation;
+		_carriedSrc = blockSrc(c.block, c.rotation, 0);
+		c.block = kNoBlock;
+	}
+
+	redraw();
+}
+
+void BlocksPuzzle::drop(int16 cell) {
+	playSoundBlock(_sounds[kHandleSound]);
+
+	// Putting a block down where one already sits swaps them, so the displaced block ends
+	// up in hand and the board never loses a piece.
+	if (cell == kTurntableCell) {
+		if (_turnBlock != kNoBlock) {
+			SWAP(_turnBlock, _carriedBlock);
+			SWAP(_turnRotation, _carriedRotation);
+			SWAP(_turnSrc, _carriedSrc);
+		} else {
+			_turnBlock = _carriedBlock;
+			_turnRotation = _carriedRotation;
+			_turnSrc = _carriedSrc;
+			_carriedBlock = kNoBlock;
+		}
+	} else {
+		Cell &c = _cells[cell];
+		if (c.block != kNoBlock) {
+			SWAP(c.block, _carriedBlock);
+			SWAP(c.rotation, _carriedRotation);
+			_carriedSrc = blockSrc(_carriedBlock, _carriedRotation, 0);
+		} else {
+			c.block = _carriedBlock;
+			c.rotation = _carriedRotation;
+			_carriedBlock = kNoBlock;
+		}
+	}
+
+	redraw();
+}
+
+void BlocksPuzzle::startTurn() {
+	// Without a turntable the carried block is turned where it is, on the cursor.
+	if (!_hasTurntable) {
+		_turnBlock = _carriedBlock;
+		_turnRotation = _carriedRotation;
+		_turnSrc = _carriedSrc;
+	}
+
+	playSoundBlock(_sounds[kTurnSound]);
+	_puzzleState = kTurnFrame;
+	_turnFrame = 1;
+}
+
+void BlocksPuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	for (uint i = 0; i < _cells.size(); ++i) {
+		const Cell &cell = _cells[i];
+		if (cell.block == kNoBlock) {
+			continue;
+		}
+
+		_drawSurface.blitFrom(_image, blockSrc(cell.block, cell.rotation, 0),
+			Common::Point(cell.dest.left, cell.dest.top));
+	}
+
+	if (!_overlayDest.isEmpty()) {
+		_drawSurface.blitFrom(_image, _overlaySrc, Common::Point(_overlayDest.left, _overlayDest.top));
+	}
+
+	if (_hasTurntable && _turnBlock != kNoBlock) {
+		_drawSurface.blitFrom(_image, _turnSrc, Common::Point(_turntableDest.left, _turntableDest.top));
+	}
+
+	// The carried block rides the cursor. While it is being turned in hand its sprite is
+	// the animation's current frame.
+	if (_carriedBlock != kNoBlock) {
+		_drawSurface.blitFrom(_image, _carriedSrc,
+			Common::Point(_dragPos.x - _carriedSrc.width() / 2, _dragPos.y - _carriedSrc.height() / 2));
+	}
+
+	_needsRedraw = true;
+}
+
+void BlocksPuzzle::setDataCursor(uint16 cursorType) 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);
+}
+
+SoundDescription BlocksPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	SoundDescription desc;
+	if (block.names.empty()) {
+		return desc;
+	}
+
+	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;
+	}
+
+	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);
+	return desc;
+}
+
+void BlocksPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		switch (_puzzleState) {
+		case kPlaying:
+			if (isSolved()) {
+				_solved = true;
+				_puzzleState = kStartSolved;
+			}
+
+			break;
+		case kStartSolved:
+			_solveSound = playSoundBlock(_sounds[kSuccessSound]);
+			_puzzleState = kWaitSolved;
+
+			if (_solveSound.name.empty()) {
+				_state = kActionTrigger;
+			}
+
+			break;
+		case kTurnFrame: {
+			const Block &block = _blocks[_turnBlock];
+			int16 lastFrame = block.numTweenFrames + 1;
+
+			if (_turnFrame == lastFrame) {
+				if (_turnRotation == block.numRotations - 1) {
+					_turnSrc = blockSrc(_turnBlock, 0, 0);
+					_turnRotation = 0;
+				} else {
+					// One past a rotation's last tween frame is the next rotation's
+					// resting frame, so the animation lands on it before stepping over.
+					_turnSrc = blockSrc(_turnBlock, _turnRotation, _turnFrame);
+					++_turnRotation;
+				}
+
+				_puzzleState = kPlaying;
+				_turnFrame = 0;
+
+				if (!_hasTurntable) {
+					// The block is back in hand, turned one step further.
+					_carriedBlock = _turnBlock;
+					_carriedRotation = _turnRotation;
+					_turnBlock = kNoBlock;
+				}
+			} else {
+				_turnSrc = blockSrc(_turnBlock, _turnRotation, _turnFrame);
+				++_turnFrame;
+				_puzzleState = kTurnDelay;
+				_nextFrameTime = g_nancy->getTotalPlayTime() + _turnDuration / (block.numTweenFrames + 1);
+			}
+
+			_carriedSrc = _turnSrc;
+			redraw();
+			break;
+		}
+		case kTurnDelay:
+			if (g_nancy->getTotalPlayTime() >= _nextFrameTime) {
+				_puzzleState = kTurnFrame;
+			}
+
+			break;
+		case kWaitSolved:
+			if (!g_nancy->_sound->isSoundPlaying(_solveSound)) {
+				_state = kActionTrigger;
+			}
+
+			break;
+		}
+
+		break;
+	case kActionTrigger:
+		if (_exitRequested) {
+			if (_exitScene.sceneID != kNoScene) {
+				NancySceneState.changeScene(_exitScene);
+			}
+		} else {
+			if (_solveScene.sceneID != kNoScene) {
+				NancySceneState.changeScene(_solveScene);
+			}
+
+			NancySceneState.setEventFlag(_solveFlag);
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void BlocksPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _solved) {
+		return;
+	}
+
+	// The carried block keeps tracking the cursor even while it is being turned.
+	if (_carriedBlock != kNoBlock) {
+		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 (_puzzleState != kPlaying) {
+		return;
+	}
+
+	const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+	// -- Carrying a block: it follows the cursor until it is put down. --
+	if (_carriedBlock != kNoBlock) {
+		// Boards with no turntable turn the block in hand instead.
+		if (!_hasTurntable && (input.input & NancyInput::kRightMouseButtonUp)) {
+			startTurn();
+			input.eatMouseInput();
+			return;
+		}
+
+		int16 cell = cellAtCursor(input.mousePos, false);
+		if (cell == kNoBlock && _hasTurntable &&
+				NancySceneState.getViewport().convertViewportToScreen(_turntableDest).contains(input.mousePos)) {
+			cell = kTurntableCell;
+		}
+
+		if (cell != kNoBlock) {
+			setDataCursor(_carryCursorType);
+			if (click) {
+				drop(cell);
+			}
+
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	// -- Empty-handed: pick a block up, turn the one on the turntable, or leave. --
+	int16 cell = cellAtCursor(input.mousePos, true);
+	if (cell != kNoBlock) {
+		setDataCursor(_carryCursorType);
+		if (click) {
+			pickUp(cell);
+		}
+
+		input.eatMouseInput();
+		return;
+	}
+
+	if (_hasTurntable && _turnBlock != kNoBlock) {
+		if (NancySceneState.getViewport().convertViewportToScreen(_turntableHotspot).contains(input.mousePos)) {
+			setDataCursor(_turnCursorType);
+			if (click) {
+				startTurn();
+			}
+
+			input.eatMouseInput();
+			return;
+		}
+
+		if (NancySceneState.getViewport().convertViewportToScreen(_turntableDest).contains(input.mousePos)) {
+			setDataCursor(_carryCursorType);
+			if (click) {
+				pickUp(kTurntableCell);
+			}
+
+			input.eatMouseInput();
+			return;
+		}
+	}
+
+	if (!_exitHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		setDataCursor(_exitCursorType);
+		if (click) {
+			_exitRequested = true;
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/blockspuzzle.h b/engines/nancy/action/puzzle/blockspuzzle.h
new file mode 100644
index 00000000000..c2df25de629
--- /dev/null
+++ b/engines/nancy/action/puzzle/blockspuzzle.h
@@ -0,0 +1,152 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_BLOCKSPUZZLE_H
+#define NANCY_ACTION_BLOCKSPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Shaped-blocks puzzle, new in Nancy13 (AR 172). Carved blocks sit in the cells of an
+// isometric board. A block is picked up by clicking it, carried on the cursor, and put
+// down on another cell; dropping it onto an occupied cell swaps the two, leaving the
+// displaced block in hand. A block can also be turned, stepping through its rotations one
+// at a time with a short tweening animation. The board is solved once every cell holds
+// its target block at its target rotation.
+//
+// Turning happens in one of two places. Boards that declare a turntable area have blocks
+// carried onto it and turned there; boards without one (the turntable rect is empty) turn
+// the carried block in hand instead, on a right click.
+class BlocksPuzzle : public RenderActionRecord {
+public:
+	BlocksPuzzle() : RenderActionRecord(7) {}
+	virtual ~BlocksPuzzle() {}
+
+	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 "BlocksPuzzle"; }
+
+	enum PuzzleState {
+		kPlaying		= 0,	// waiting for the board to come out solved
+		kStartSolved	= 1,	// solved: start the success sound
+		kTurnFrame		= 2,	// show the next frame of the turn animation
+		kTurnDelay		= 3,	// hold that frame for its share of the turn duration
+		kWaitSolved		= 4		// let the success sound finish before leaving
+	};
+
+	enum SoundID { kTurnSound = 0, kHandleSound = 1, kSuccessSound = 2, kNumSounds = 3 };
+
+	static const int16 kNoBlock = -1;
+	// Stands in for a cell index when the turntable area is the thing being clicked.
+	static const int16 kTurntableCell = 26;
+
+	// Every sprite of a block lives in a single row of the atlas image, laid out as
+	// numRotations groups of (numTweenFrames + 1) frames.
+	struct Block {
+		byte numRotations = 0;		// 0x00
+		int16 numTweenFrames = 0;	// 0x01
+		int16 gap = 0;				// 0x03 - padding between atlas frames
+		int16 width = 0;			// 0x05
+		int16 height = 0;			// 0x07
+		int16 atlasX = 0;			// 0x09
+		int16 atlasY = 0;			// 0x0b
+	};
+
+	struct Cell {
+		int16 block = kNoBlock;			// 0x00
+		byte rotation = 0;				// 0x02
+		int16 targetBlock = kNoBlock;	// 0x03
+		uint16 targetRotation = 0;		// 0x05
+		Common::Rect dest;				// 0x07
+		Common::Rect hotspot;			// 0x17 - the flat centre of the isometric tile
+	};
+
+	Common::Rect blockSrc(int16 block, byte rotation, int16 frame) const;
+	// The cell whose hotspot contains the cursor, or kNoBlock. Picking up only considers
+	// occupied cells; putting down considers every cell.
+	int16 cellAtCursor(const Common::Point &mousePos, bool occupiedOnly) const;
+	bool isSolved() const;
+	void pickUp(int16 cell);
+	void drop(int16 cell);
+	void startTurn();
+	void redraw();
+	void setDataCursor(uint16 cursorType) const;
+	SoundDescription playSoundBlock(const RandomSoundBlock &block);
+
+	// -- File data (111-byte header) --
+	Common::Path _imageName;			// 0x00
+	uint16 _turnDuration = 0;			// 0x21 - total ms of one rotation's tween
+	uint16 _carryCursorType = 0;		// 0x23 - raw Nancy13 cursor types
+	uint16 _turnCursorType = 0;			// 0x25
+	FlagDescription _startFlag;			// 0x27 - set as the puzzle opens
+	Common::Rect _overlaySrc;			// 0x2a - board dressing, drawn over the blocks
+	Common::Rect _overlayDest;			// 0x3a
+	Common::Rect _turntableDest;		// 0x4a - empty when the board has no turntable
+	Common::Rect _turntableHotspot;		// 0x5a - click to turn the block on the turntable
+	SceneChangeDescription _solveScene;	// 0x6a
+	FlagDescription _solveFlag;			// 0x6c
+
+	Common::Array<Block> _blocks;
+	Common::Array<Cell> _cells;
+
+	// The clickable "give up / exit" hotspot (the base-class hotspot record).
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+
+	RandomSoundBlock _sounds[kNumSounds];	// turn / handle / success
+
+	// -- Runtime state --
+	PuzzleState _puzzleState = kPlaying;
+	bool _hasTurntable = false;
+
+	int16 _carriedBlock = kNoBlock;		// the block on the cursor, or kNoBlock
+	byte _carriedRotation = 0;
+	Common::Rect _carriedSrc;
+	Common::Point _dragPos;				// cursor position (viewport space) while carrying
+
+	int16 _turnBlock = kNoBlock;		// the block being turned, or kNoBlock
+	byte _turnRotation = 0;
+	Common::Rect _turnSrc;
+	int16 _turnFrame = 0;
+	uint32 _nextFrameTime = 0;
+
+	bool _solved = false;
+	bool _exitRequested = false;
+	SoundDescription _solveSound;		// the cue we wait on before changing scene
+
+	Graphics::ManagedSurface _image;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_BLOCKSPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 49c49f0d0f6..634c2abf780 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -21,6 +21,7 @@ MODULE_OBJS = \
   action/puzzle/assemblypuzzle.o \
   action/puzzle/bballpuzzle.o \
   action/puzzle/beadpuzzle.o \
+  action/puzzle/blockspuzzle.o \
   action/puzzle/boardgamepuzzle.o \
   action/puzzle/bulpuzzle.o \
   action/puzzle/bombpuzzle.o \


Commit: 1aaab1a9d35b784307617fc5d83a968bdabd94b1
    https://github.com/scummvm/scummvm/commit/1aaab1a9d35b784307617fc5d83a968bdabd94b1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:57:07+03:00

Commit Message:
NANCY: Clean up action record dispatch table

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 232190a97b4..017ecd3f2a5 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -183,7 +183,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new HotMultiframeMultiSceneChange();	// Moved from 13 in Nancy 10
 	case 27:
 		return new HotMultiframeMultiSceneCursorTypeSceneChange(); // Moved from 24 to 27 in Nancy10
-	case 28:
+	case 28:	// Nancy10
 		return new InteractiveVideo();	// Moved from 26 to 28 in Nancy10
 	case 29:	// Nancy10
 		return new ControlUIItems();
@@ -193,23 +193,17 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new StartPlayerScrolling();
 	case 32:	// Nancy10
 		return new UIPopupPrepScene();
+	case 41:	// Nancy14
+	case 44:	// Nancy14 (adds a trailing volume byte)
+		return new PlaySecondaryMovie();
+	case 42:	// Nancy14
+	case 43:	// Nancy14
+		return new PlaySecondaryMovie(true);
 	case 45:	// Nancy11 - random-movie variant of PlaySecondaryMovie
 		return new PlaySecondaryMovie(true);
 	case 46:	// Nancy11
 		return new PlayRandomMovieControl();
-	// Nancy14 dispatches the secondary-movie family from 41-47 instead of the
-	// legacy 50-53 slots.
-	case 41:	// PlaySecondaryMovie
-	case 44:	// PlaySecondaryMovie (adds a trailing volume byte)
-		if (g_nancy->getGameType() >= kGameTypeNancy14)
-			return new PlaySecondaryMovie();
-		return nullptr;
-	case 42:	// PlaySecondaryMovie, random-sequence variant
-	case 43:
-		if (g_nancy->getGameType() >= kGameTypeNancy14)
-			return new PlaySecondaryMovie(true);
-		return nullptr;
-	case 47:	// PlaySecondaryMovie subclass with a per-frame flag list
+	case 47:	// Nancy14 - PlaySecondaryMovie subclass with a per-frame flag list
 		// TODO: not yet implemented
 		return nullptr;
 	case 40:
@@ -248,19 +242,15 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 59:
 		return new ConversationCelT();
 	case 60:
-		if (g_nancy->getGameType() <= kGameTypeNancy5) {
-			// Only used in tvd and nancy1
-			return new MapCall();
-		} else {
+		if (g_nancy->getGameType() <= kGameTypeNancy5)
+			return new MapCall();	// Only used in tvd and nancy1
+		else
 			return new ConversationSoundT();
-		}
 	case 61:
-		if (g_nancy->getGameType() <= kGameTypeNancy5) {
-			// Only used in tvd and nancy1
-			return new MapCallHot1Fr();
-		} else {
+		if (g_nancy->getGameType() <= kGameTypeNancy5)
+			return new MapCallHot1Fr();	// Only used in tvd and nancy1
+		else
 			return new Autotext();
-		}
 	case 62:
 		if (g_nancy->getGameType() <= kGameTypeNancy7)
 			return new MapCallHotMultiframe(); // TVD/nancy1 only
@@ -305,16 +295,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new ValueTest();
 	case 81:	// Nancy11
 		return new TextBoxWrite(true);
-	case 94:
-		// Nancy12: moved from 106
-		if (g_nancy->getGameType() >= kGameTypeNancy12)
-			return new EventFlagsMultiHS(false);
-		return nullptr;
-	case 95:
-		// Nancy12: moved from 107
-		if (g_nancy->getGameType() >= kGameTypeNancy12)
-			return new EventFlags();
-		return nullptr;
+	case 94:	// Nancy12
+		return new EventFlagsMultiHS(false);	// moved from 106
+	case 95:	// Nancy12
+		return new EventFlags();	// moved from 107
 	case 96:	// Nancy11
 		return new RandomizeEventFlags();
 	case 97:
@@ -364,12 +348,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new SliderPuzzle();
 	case 118:
 		return new PasswordPuzzle();
-	case 119:
-		if (g_nancy->getGameType() >= kGameTypeNancy7) {
-			// This got moved in nancy7
-			return new OrderingPuzzle(OrderingPuzzle::kOrdering);
-		}
-		return nullptr;
+	case 119:	// Nancy7
+		return new OrderingPuzzle(OrderingPuzzle::kOrdering);
 	case 120:
 		return new AddInventoryNoHS();
 	case 121:
@@ -394,11 +374,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new AddSearchLink();
 	case 132:	// Nancy12
 		return new ResourceUse();
-	case 133:	// Nancy14: new AR, unidentified
-		// TODO: not yet implemented
-		return nullptr;
-	case 143:	// Nancy14: new paired AR, unidentified
-	case 144:
+	case 133:	// Nancy14
 		// TODO: not yet implemented
 		return nullptr;
 	case 140:
@@ -411,14 +387,14 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// Saves a cropped image of the screen to a bitmap/TGA file.
 		// TODO: debug-only feature, not implemented
 		return nullptr;
-	case 145:	// Nancy13
-		if (g_nancy->getGameType() >= kGameTypeNancy13)
-			return new PlaySound(); // Moved from 150 in Nancy13
+	case 143:	// Nancy14
+	case 144:	// Nancy14
+		// TODO: new paired AR, not yet implemented
 		return nullptr;
+	case 145:	// Nancy13
+		return new PlaySound(); // Moved from 150 in Nancy13
 	case 146:	// Nancy13
-		if (g_nancy->getGameType() >= kGameTypeNancy13)
-			return new FadeSoundToSilence(); // Moved from 147 in Nancy13
-		return nullptr;
+		return new FadeSoundToSilence(); // Moved from 147 in Nancy13
 	case 147:	// Nancy11
 		if (g_nancy->getGameType() >= kGameTypeNancy13)
 			return new SetVolume();			// Moved from 148 in Nancy13
@@ -445,11 +421,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return nullptr;	// Nancy14: SetMovieVolume, TODO. PlaySound moved to 145 in Nancy13.
 		return new PlaySound();
 	case 151:
-		if (g_nancy->getGameType() <= kGameTypeNancy6)  {
+		if (g_nancy->getGameType() <= kGameTypeNancy6)
 			return new PlaySound(); // PlayStreamSound
-		} else {
+		else
 			return new PlayRandomSoundTerse();
-		}
 	case 152:
 		return new PlaySoundFrameAnchor();
 	case 153:
@@ -469,13 +444,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return nullptr;	// Nancy14: new AR here, not PlaySoundTerse. TODO.
 		return new PlaySoundTerse();
 	case 160:
-		// In Nancy12 the hint system was removed (the HINT boot chunk is gone) and this
-		// slot was reused for a new driving/racing puzzle.
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
 			return new DrivingPuzzle(DrivingPuzzle::kDriving);
 		return new HintSystem();
 	case 161:
-		// PlaySoundEventFlagTerse moved to 149 in Nancy12; this slot was reused for a new puzzle.
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
 			return new MinigolfPuzzle();
 		return new PlaySoundEventFlagTerse();
@@ -489,8 +461,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 165:
 		return new MindPuzzle();
 	case 166:
-		// OneBuildPuzzle, moved here from 234 in Nancy12
-		return new OneBuildPuzzle();
+		return new OneBuildPuzzle();	// moved from 234 in Nancy12
 	case 167:
 		return new DrivingPuzzle(DrivingPuzzle::kChase);
 	case 168:
@@ -507,16 +478,9 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			// TODO: not yet implemented
 			return nullptr;
 		}
-		// SetPlayerClock lived here up to Nancy11; moved to 140 in Nancy12
-		return new SetPlayerClock();
+		return new SetPlayerClock();	// moved to 140 in Nancy12
 	case 171:
-		// TurningPuzzle, moved here from 209 in Nancy13. NOTE: the Nancy13 chunk is a
-		// *different* layout from the 209 version (variable-length per-face string labels
-		// instead of a single image strip), so TurningPuzzle::readData would desync the
-		// stream. Left unimplemented (returns nullptr) until a readDataNancy13 + reworked
-		// draw path exist.
-		// TODO: not yet implemented for Nancy13
-		return nullptr;
+		return new TurningPuzzle();	// moved from 209 in Nancy13
 	case 172:
 		return new BlocksPuzzle();
 	case 173:
@@ -621,8 +585,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 233:
 		return new SoundMatchPuzzle();
 	case 234:
-		// Moved to 166 in Nancy12
-		return new OneBuildPuzzle();
+		return new OneBuildPuzzle();	// moved to 166 in Nancy12
 	case 235:
 		return new MultiBuildPuzzle();
 	case 237:


Commit: 3f3f08aebbbaef3b07e8a923e7032daa24b11959
    https://github.com/scummvm/scummvm/commit/3f3f08aebbbaef3b07e8a923e7032daa24b11959
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:57:09+03:00

Commit Message:
NANCY: NANCY13: Implement StepObjectsPuzzle

Dance-step puzzle. Each object (a shoe) is dragged across a grid of
cells (the dance floor). A footprint is left on each cell that a dance
shoe visits, so each cell can only be stepped on once, and the overall
dance has to be done in one go. The puzzle is solved once the sequence
of dance steps matches the scripted one.

Changed paths:
  A engines/nancy/action/puzzle/stepobjectspuzzle.cpp
  A engines/nancy/action/puzzle/stepobjectspuzzle.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/module.mk


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 017ecd3f2a5..86fa7b7dc3c 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -78,6 +78,7 @@
 #include "engines/nancy/action/puzzle/soundequalizerpuzzle.h"
 #include "engines/nancy/action/puzzle/soundmatchpuzzle.h"
 #include "engines/nancy/action/puzzle/spigotpuzzle.h"
+#include "engines/nancy/action/puzzle/stepobjectspuzzle.h"
 #include "engines/nancy/action/puzzle/tangrampuzzle.h"
 #include "engines/nancy/action/puzzle/telephone.h"
 #include "engines/nancy/action/puzzle/towerpuzzle.h"
@@ -468,9 +469,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new Set3DSoundListenerPosition();
 	// -- Nancy 13 new/relocated puzzles (types 169-176) --
 	case 169:
-		// StepObjectsPuzzle, new in Nancy13
-		// TODO: not yet implemented
-		return nullptr;
+		return new StepObjectsPuzzle();
 	case 170:
 		if (g_nancy->getGameType() >= kGameTypeNancy13) {
 			// WordFindPuzzle, new in Nancy13. This reuses the slot that used
diff --git a/engines/nancy/action/puzzle/stepobjectspuzzle.cpp b/engines/nancy/action/puzzle/stepobjectspuzzle.cpp
new file mode 100644
index 00000000000..383e3d70603
--- /dev/null
+++ b/engines/nancy/action/puzzle/stepobjectspuzzle.cpp
@@ -0,0 +1,480 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/stepobjectspuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// Footprints are ghosted so they read as a trail rather than as more objects
+static const byte kFootprintAlpha = 179;
+
+// A footstep sound holds the board for this long before the step is judged
+static const uint32 kStepSoundDelay = 300;
+
+void StepObjectsPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+	_cursorType = stream.readUint16LE();
+	_numRows = stream.readUint16LE();
+	_numCols = stream.readUint16LE();
+	_pitchY = stream.readUint16LE();
+	_pitchX = stream.readUint16LE();
+	_solveScene.sceneID = stream.readUint16LE();
+	_solveFlag.label = stream.readSint16LE();
+	_solveFlag.flag = stream.readByte();
+	_numSteps = stream.readUint16LE();
+
+	_solution.resize(_numSteps);
+	for (uint i = 0; i < _numSteps; ++i) {
+		_solution[i].objectID = stream.readByte();
+		_solution[i].row = stream.readSint16LE();
+		_solution[i].col = stream.readSint16LE();
+	}
+
+	uint16 numObjects = stream.readUint16LE();
+	_objects.resize(numObjects);
+	for (uint i = 0; i < numObjects; ++i) {
+		StepObject &object = _objects[i];
+		object.undoWrongStep = stream.readByte() != 0;
+		readRect(stream, object.srcRect);
+		readRect(stream, object.footprintSrcRect);
+
+		// Only the top left corner is used; the cells are sized from the sprite
+		Common::Rect originRect;
+		readRect(stream, originRect);
+		object.gridOrigin = Common::Point(originRect.left, originRect.top);
+
+		object.startRow = stream.readUint16LE();
+		object.startCol = stream.readUint16LE();
+	}
+
+	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();
+		uint16 frameID = stream.readUint16LE();
+		stream.skip(1);
+
+		if (i == 0) {
+			_exitHotspot = zone;
+			_exitCursorType = cursorType;
+			_exitScene.sceneID = sceneID;
+			_exitScene.frameID = (frameID == 0xffff) ? 0 : frameID;
+		}
+	}
+
+	_sounds.resize(kNumSounds);
+	for (uint i = 0; i < kNumSounds; ++i) {
+		_sounds[i].readData(stream);
+	}
+}
+
+void StepObjectsPuzzle::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+
+	// Footprints are drawn with partial alpha, so the surface carries an alpha
+	// channel instead of a transparent color key
+	_drawSurface.create(vpBounds.width(), vpBounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+
+	resetBoard();
+
+	NancySceneState.setNoHeldItem();
+
+	redraw();
+	registerGraphics();
+}
+
+Common::Rect StepObjectsPuzzle::getCellRect(const StepObject &object, int row, int col) const {
+	int16 left = object.gridOrigin.x + _pitchX * col;
+	int16 top = object.gridOrigin.y + _pitchY * row;
+	return Common::Rect(left, top, left + object.srcRect.width(), top + object.srcRect.height());
+}
+
+bool StepObjectsPuzzle::isHovered(const Common::Rect &viewportRect, const Common::Point &mousePos) const {
+	return !viewportRect.isEmpty() &&
+		NancySceneState.getViewport().convertViewportToScreen(viewportRect).contains(mousePos);
+}
+
+bool StepObjectsPuzzle::cellAtCursor(const StepObject &object, const Common::Point &mousePos, int &outRow, int &outCol) const {
+	for (int row = 0; row < _numRows; ++row) {
+		for (int col = 0; col < _numCols; ++col) {
+			if (isHovered(getCellRect(object, row, col), mousePos)) {
+				outRow = row;
+				outCol = col;
+				return true;
+			}
+		}
+	}
+
+	return false;
+}
+
+bool StepObjectsPuzzle::canStepOn(uint objectID, int row, int col) const {
+	const StepObject &object = _objects[objectID];
+
+	// An object may always return to the cell it started from; doing so clears the routine
+	if (row == object.startRow && col == object.startCol) {
+		return true;
+	}
+
+	for (uint i = 0; i < _trail.size(); ++i) {
+		if (_trail[i].objectID == objectID && _trail[i].row == row && _trail[i].col == col) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+bool StepObjectsPuzzle::isSolutionMatched() const {
+	if (_playerSteps.size() != _solution.size()) {
+		return false;
+	}
+
+	for (uint i = 0; i < _solution.size(); ++i) {
+		if (_playerSteps[i].objectID != _solution[i].objectID ||
+				_playerSteps[i].row != _solution[i].row ||
+				_playerSteps[i].col != _solution[i].col) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+void StepObjectsPuzzle::resetBoard() {
+	_trail.clear();
+	_playerSteps.clear();
+	_carriedID = -1;
+
+	for (uint i = 0; i < _objects.size(); ++i) {
+		_objects[i].row = _objects[i].startRow;
+		_objects[i].col = _objects[i].startCol;
+	}
+}
+
+void StepObjectsPuzzle::beginStepSound(SoundID sound, bool isDrop) {
+	playSoundBlock(_sounds[sound]);
+	_lastStepWasDrop = isDrop;
+	_stepSoundEnd = g_nancy->getTotalPlayTime() + kStepSoundDelay;
+	_puzzleState = kStepping;
+}
+
+void StepObjectsPuzzle::pickUp(uint objectID) {
+	StepObject &object = _objects[objectID];
+
+	// The cell being vacated keeps a footprint, so it cannot be used again
+	Step step;
+	step.objectID = objectID;
+	step.row = object.row;
+	step.col = object.col;
+	_trail.push_back(step);
+
+	_carriedID = objectID;
+	_carriedRect = getCellRect(object, object.row, object.col);
+
+	beginStepSound(kSoundPickUp, false);
+}
+
+void StepObjectsPuzzle::drop(int row, int col) {
+	StepObject &object = _objects[_carriedID];
+	int16 prevRow = object.row;
+	int16 prevCol = object.col;
+
+	object.row = row;
+	object.col = col;
+
+	Step step;
+	step.objectID = _carriedID;
+	step.row = row;
+	step.col = col;
+	_playerSteps.push_back(step);
+
+	uint index = _playerSteps.size() - 1;
+	bool correct = index < _solution.size() &&
+		_solution[index].objectID == step.objectID &&
+		_solution[index].row == step.row &&
+		_solution[index].col == step.col;
+
+	if (!correct) {
+		if (object.undoWrongStep) {
+			_playerSteps.pop_back();
+			_trail.pop_back();
+			object.row = prevRow;
+			object.col = prevCol;
+		} else if (row == object.startRow && col == object.startCol) {
+			playSoundBlock(_sounds[kSoundReset]);
+			resetBoard();
+			redraw();
+			return;
+		}
+	}
+
+	_carriedID = -1;
+	_lastStepCorrect = correct;
+	beginStepSound(kSoundStep, true);
+	redraw();
+}
+
+void StepObjectsPuzzle::drawSprite(const Common::Rect &srcRect, const Common::Point &destPos, byte alpha) {
+	if (srcRect.isEmpty() || !_image.getBounds().contains(srcRect)) {
+		return;
+	}
+
+	// The color key is matched on RGB alone, since an image may carry an alpha channel
+	byte tr, tg, tb;
+	g_nancy->_graphics->getInputPixelFormat().colorToRGB(g_nancy->_graphics->getTransColor(), tr, tg, tb);
+
+	for (int y = 0; y < srcRect.height(); ++y) {
+		int destY = destPos.y + y;
+		if (destY < 0 || destY >= _drawSurface.h) {
+			continue;
+		}
+
+		for (int x = 0; x < srcRect.width(); ++x) {
+			int destX = destPos.x + x;
+			if (destX < 0 || destX >= _drawSurface.w) {
+				continue;
+			}
+
+			// An image without an alpha channel reports every pixel opaque and keys on
+			// the color instead; one with an alpha channel masks the sprite out with it
+			byte a, r, g, b;
+			_image.format.colorToARGB(_image.getPixel(srcRect.left + x, srcRect.top + y), a, r, g, b);
+			if (a == 0 || (r == tr && g == tg && b == tb)) {
+				continue;
+			}
+
+			_drawSurface.setPixel(destX, destY, _drawSurface.format.ARGBToColor(a * alpha / 255, r, g, b));
+		}
+	}
+}
+
+void StepObjectsPuzzle::redraw() {
+	_drawSurface.clear(0);
+
+	for (uint i = 0; i < _trail.size(); ++i) {
+		const Step &step = _trail[i];
+		if (step.objectID >= _objects.size()) {
+			continue;
+		}
+
+		const StepObject &object = _objects[step.objectID];
+		Common::Rect cell = getCellRect(object, step.row, step.col);
+
+		if (object.footprintSrcRect.isEmpty()) {
+			drawSprite(object.srcRect, Common::Point(cell.left, cell.top), kFootprintAlpha);
+		} else {
+			drawSprite(object.footprintSrcRect, Common::Point(cell.left, cell.top), 255);
+		}
+	}
+
+	for (uint i = 0; i < _objects.size(); ++i) {
+		if ((int)i == _carriedID) {
+			continue;
+		}
+
+		const StepObject &object = _objects[i];
+		Common::Rect cell = getCellRect(object, object.row, object.col);
+		drawSprite(object.srcRect, Common::Point(cell.left, cell.top), 255);
+	}
+
+	if (_carriedID >= 0) {
+		drawSprite(_objects[_carriedID].srcRect, Common::Point(_carriedRect.left, _carriedRect.top), 255);
+	}
+
+	_needsRedraw = true;
+}
+
+void StepObjectsPuzzle::setDataCursor(uint16 cursorType) 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);
+}
+
+SoundDescription StepObjectsPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	SoundDescription desc;
+	if (block.names.empty()) {
+		return desc;
+	}
+
+	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;
+	}
+
+	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);
+	return desc;
+}
+
+void StepObjectsPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		switch (_puzzleState) {
+		case kIdle:
+			if (!_solved && _playerSteps.size() == _numSteps && isSolutionMatched()) {
+				_solved = true;
+				_solveSound = playSoundBlock(_sounds[kSoundSolved]);
+				_puzzleState = kSolved;
+			}
+
+			break;
+		case kStepping:
+			if (g_nancy->getTotalPlayTime() >= _stepSoundEnd) {
+				if (_lastStepWasDrop) {
+					playSoundBlock(_sounds[_lastStepCorrect ? kSoundCorrectStep : kSoundWrongStep]);
+				}
+
+				_puzzleState = kIdle;
+			}
+
+			break;
+		case kSolved:
+			if (_solveSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_solveSound)) {
+				_state = kActionTrigger;
+			}
+
+			break;
+		}
+
+		break;
+	case kActionTrigger:
+		if (_solved) {
+			NancySceneState.setEventFlag(_solveFlag);
+
+			if (_solveScene.sceneID != kNoScene) {
+				NancySceneState.changeScene(_solveScene);
+			}
+		} else if (_exitScene.sceneID != kNoScene) {
+			NancySceneState.changeScene(_exitScene);
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void StepObjectsPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _solved) {
+		return;
+	}
+
+	// A footstep sound holds the board: a carried object still follows the
+	// cursor, but no further step may be taken until the sound finishes
+	const bool click = _puzzleState == kIdle && (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+	if (_carriedID >= 0) {
+		const StepObject &object = _objects[_carriedID];
+		setDataCursor(_cursorType);
+
+		// The carried sprite is centered on the cursor and kept inside the viewport
+		Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+		Common::Rect screenPt(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1);
+		Common::Rect vpPt = NancySceneState.getViewport().convertScreenToViewport(screenPt);
+		int16 w = object.srcRect.width();
+		int16 h = object.srcRect.height();
+		int16 left = CLIP<int16>(vpPt.left - w / 2, vpBounds.left, vpBounds.right - w);
+		int16 top = CLIP<int16>(vpPt.top - h / 2, vpBounds.top, vpBounds.bottom - h);
+		_carriedRect = Common::Rect(left, top, left + w, top + h);
+		redraw();
+
+		if (click) {
+			int row, col;
+			if (cellAtCursor(object, input.mousePos, row, col) && canStepOn(_carriedID, row, col)) {
+				drop(row, col);
+			}
+		}
+
+		input.eatMouseInput();
+		return;
+	}
+
+	for (uint i = 0; i < _objects.size(); ++i) {
+		const StepObject &object = _objects[i];
+
+		if (isHovered(getCellRect(object, object.row, object.col), input.mousePos)) {
+			setDataCursor(_cursorType);
+			if (click) {
+				pickUp(i);
+				redraw();
+			}
+
+			input.eatMouseInput();
+			return;
+		}
+
+		// Clicking the cell an object started from, once it has moved away, clears the routine
+		if (isHovered(getCellRect(object, object.startRow, object.startCol), input.mousePos)) {
+			setDataCursor(_cursorType);
+			if (click) {
+				playSoundBlock(_sounds[kSoundReset]);
+				resetBoard();
+				redraw();
+			}
+
+			input.eatMouseInput();
+			return;
+		}
+	}
+
+	if (isHovered(_exitHotspot, input.mousePos)) {
+		setDataCursor(_exitCursorType);
+		if (click) {
+			_exitRequested = true;
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/stepobjectspuzzle.h b/engines/nancy/action/puzzle/stepobjectspuzzle.h
new file mode 100644
index 00000000000..7cf3c7ac79a
--- /dev/null
+++ b/engines/nancy/action/puzzle/stepobjectspuzzle.h
@@ -0,0 +1,146 @@
+/* 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_STEPOBJECTSPUZZLE_H
+#define NANCY_ACTION_STEPOBJECTSPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy13 AR 169
+// Dance-step puzzle. Each object (a shoe) is dragged across a grid of
+// cells (the dance floor). A footprint is left on each cell that a dance
+// shoe visits, so each cell can only be stepped on once, and the overall
+// dance has to be done in one go. The puzzle is solved once the sequence
+// of dance steps matches the scripted one.
+class StepObjectsPuzzle : public RenderActionRecord {
+public:
+	StepObjectsPuzzle() : RenderActionRecord(7) {}
+	virtual ~StepObjectsPuzzle() {}
+
+	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 "StepObjectsPuzzle"; }
+
+	enum SoundID {
+		kSoundPickUp = 0,
+		kSoundStep = 1,
+		kSoundCorrectStep = 2,
+		kSoundWrongStep = 3,
+		kSoundReset = 4,
+		kSoundSolved = 5,
+		kNumSounds = 6
+	};
+
+	enum PuzzleState {
+		kIdle,
+		kStepping,	// a footstep sound is playing; the board ignores input
+		kSolved
+	};
+
+	// A single placement: which object was put down, and on which cell.
+	struct Step {
+		byte objectID = 0;
+		int16 row = 0;
+		int16 col = 0;
+	};
+
+	struct StepObject {
+		bool undoWrongStep = false;
+		Common::Rect srcRect;
+		Common::Rect footprintSrcRect;	// when empty, the object's own sprite is used, faded
+		Common::Point gridOrigin;
+		uint16 startRow = 0;
+		uint16 startCol = 0;
+
+		int16 row = 0;
+		int16 col = 0;
+	};
+
+	Common::Rect getCellRect(const StepObject &object, int row, int col) const;
+	bool isHovered(const Common::Rect &viewportRect, const Common::Point &mousePos) const;
+	bool cellAtCursor(const StepObject &object, const Common::Point &mousePos, int &outRow, int &outCol) const;
+	bool canStepOn(uint objectID, int row, int col) const;
+	bool isSolutionMatched() const;
+
+	void pickUp(uint objectID);
+	void drop(int row, int col);
+	void resetBoard();
+	void beginStepSound(SoundID sound, bool isDrop);
+	void setDataCursor(uint16 cursorType) const;
+
+	void redraw();
+	void drawSprite(const Common::Rect &srcRect, const Common::Point &destPos, byte alpha);
+	SoundDescription playSoundBlock(const RandomSoundBlock &block);
+
+	// File data
+	Common::Path _imageName;
+	// The puzzle carries its own cursor as a raw Nancy13 cursor type id; the same
+	// one is used for hovering and for carrying an object.
+	uint16 _cursorType = 0;
+	uint16 _numRows = 0;
+	uint16 _numCols = 0;
+	uint16 _pitchY = 0;
+	uint16 _pitchX = 0;
+	SceneChangeDescription _solveScene;
+	FlagDescription _solveFlag;
+	uint16 _numSteps = 0;
+
+	Common::Array<Step> _solution;
+	Common::Array<StepObject> _objects;
+
+	// The clickable "give up / leave" hotspot.
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+
+	Common::Array<RandomSoundBlock> _sounds;
+
+	// Runtime state
+	Common::Array<Step> _trail;
+	Common::Array<Step> _playerSteps;
+	PuzzleState _puzzleState = kIdle;
+	int _carriedID = -1;
+	Common::Rect _carriedRect;
+	uint32 _stepSoundEnd = 0;
+	bool _lastStepWasDrop = false;
+	bool _lastStepCorrect = false;
+	bool _solved = false;
+	bool _exitRequested = false;
+	SoundDescription _solveSound;
+
+	Graphics::ManagedSurface _image;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_STEPOBJECTSPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 634c2abf780..75e044b8228 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -62,6 +62,7 @@ MODULE_OBJS = \
   action/puzzle/soundequalizerpuzzle.o \
   action/puzzle/soundmatchpuzzle.o \
   action/puzzle/spigotpuzzle.o \
+  action/puzzle/stepobjectspuzzle.o \
   action/puzzle/tangrampuzzle.o \
   action/puzzle/telephone.o \
   action/puzzle/towerpuzzle.o \


Commit: c2832334dac1bfc47f37822973a50c29585f4f66
    https://github.com/scummvm/scummvm/commit/c2832334dac1bfc47f37822973a50c29585f4f66
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-18T01:57:10+03:00

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

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


diff --git a/engines/nancy/action/puzzle/turningpuzzle.cpp b/engines/nancy/action/puzzle/turningpuzzle.cpp
index 878e8fe1636..fc5825da93b 100644
--- a/engines/nancy/action/puzzle/turningpuzzle.cpp
+++ b/engines/nancy/action/puzzle/turningpuzzle.cpp
@@ -19,12 +19,15 @@
  *
  */
 
+#include "common/random.h"
+
 #include "engines/nancy/util.h"
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/resource.h"
 #include "engines/nancy/sound.h"
 #include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
 #include "engines/nancy/puzzledata.h"
 #include "engines/nancy/state/scene.h"
 
@@ -50,6 +53,38 @@ void TurningPuzzle::updateGraphics() {
 		return;
 	}
 
+	if (g_nancy->getGameType() >= kGameTypeNancy13) {
+		if (_objectCurrentlyTurning == -1 || g_nancy->getTotalPlayTime() <= _nextTurnTime) {
+			return;
+		}
+
+		uint framesPerTurn = framesPerTurnOf(_objectCurrentlyTurning);
+		++_turnFrameID;
+
+		if (_turnFrameID > framesPerTurn) {
+			// The turn is over: commit it, then redraw everything on its new face.
+			turnLogic(_objectCurrentlyTurning);
+			_objectCurrentlyTurning = -1;
+			_turnFrameID = 0;
+			_nextTurnTime = 0;
+			drawAllObjects();
+			return;
+		}
+
+		// Step the turning object and everything linked to it through the in-between frames.
+		_nextTurnTime = g_nancy->getTotalPlayTime() + (_turnDelay / (framesPerTurn + 1));
+		drawObject(_objectCurrentlyTurning, _currentOrder[_objectCurrentlyTurning], _turnFrameID);
+
+		for (uint j = 0; j < _links[_objectCurrentlyTurning].size(); ++j) {
+			uint linkedID = _links[_objectCurrentlyTurning][j] - 1;
+			if (linkedID < _currentOrder.size()) {
+				drawObject(linkedID, _currentOrder[linkedID], _turnFrameID);
+			}
+		}
+
+		return;
+	}
+
 	if (_solveState == kWaitForAnimation) {
 		if (g_nancy->getTotalPlayTime() > _nextTurnTime) {
 			_nextTurnTime = g_nancy->getTotalPlayTime() + (_solveDelayBetweenTurns * 1000 / _numFramesPerTurn);
@@ -127,7 +162,160 @@ void TurningPuzzle::updateGraphics() {
 	}
 }
 
+void TurningPuzzle::readDataNancy13(Common::SeekableReadStream &stream) {
+	// 47-byte header
+	readFilename(stream, _imageName);			// 0x00
+	_turnDelay = stream.readUint16LE();			// 0x21
+	_hoverCursorType = stream.readUint16LE();	// 0x23
+	_hitInset = stream.readUint16LE();			// 0x25
+	_turnFlagLabel = stream.readSint16LE();		// 0x27
+	_turnFlagValue = stream.readByte();			// 0x29
+	stream.skip(5);								// 0x2a - not yet identified
+
+	// 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.
+	int16 numZones = stream.readSint16LE();
+	for (int16 i = 0; i < numZones; ++i) {
+		Common::Rect r;
+		readRect(stream, r);
+		uint16 cursorType = stream.readUint16LE();
+		uint16 sceneID = stream.readUint16LE();
+		uint16 frameID = stream.readUint16LE();
+		stream.skip(1);
+
+		if (i == 0) {
+			_exitHotspot = r;
+			_exitCursorType = cursorType;
+			_exitScene._sceneChange.sceneID = sceneID;
+			// 0xffff means "no frame" - the target scene may be a video.
+			_exitScene._sceneChange.frameID = (frameID == 0xffff) ? 0 : frameID;
+			_solveScene._sceneChange = _exitScene._sceneChange;
+		}
+	}
+
+	uint16 numTypes = stream.readUint16LE();
+	_pieceTypes.resize(numTypes);
+	for (uint i = 0; i < numTypes; ++i) {
+		PieceType &t = _pieceTypes[i];
+		t.numFaces = stream.readByte();
+		t.framesPerTurn = stream.readSint16LE();
+		t.gap = stream.readSint16LE();
+		t.cellW = stream.readSint16LE();
+		t.cellH = stream.readSint16LE();
+		t.srcStartX = stream.readSint16LE();
+		t.srcStartY = stream.readSint16LE();
+	}
+
+	uint16 numObjects = stream.readUint16LE();
+	_links.resize(numObjects);
+	_pieceTypeIDs.resize(numObjects);
+	_startPositions.resize(numObjects);
+	_destRects.resize(numObjects);
+	_correctOrders.resize(3);
+	for (uint n = 0; n < 3; ++n) {
+		_correctOrders[n].resize(numObjects);
+	}
+
+	for (uint i = 0; i < numObjects; ++i) {
+		uint16 numLinks = stream.readUint16LE();
+		for (uint16 j = 0; j < numLinks; ++j) {
+			byte link = stream.readByte();
+			if (link != 0) {
+				// 1-based, as in the older games
+				_links[i].push_back(link);
+			}
+		}
+
+		_pieceTypeIDs[i] = stream.readUint16LE();
+		_startPositions[i] = stream.readUint16LE();
+		for (uint n = 0; n < 3; ++n) {
+			_correctOrders[n][i] = stream.readUint16LE();
+		}
+		readRect(stream, _destRects[i]);
+	}
+
+	// Only the middle of an object is clickable.
+	_hotspots = _destRects;
+	for (uint i = 0; i < _hotspots.size(); ++i) {
+		_hotspots[i].grow(-(int16)_hitInset);
+	}
+
+	_turnSoundBlock.readData(stream);
+	_solveSoundBlock.readData(stream);
+}
+
+uint TurningPuzzle::numFacesOf(uint objectID) const {
+	if (g_nancy->getGameType() < kGameTypeNancy13) {
+		return _numFaces;
+	}
+	return _pieceTypes[_pieceTypeIDs[objectID]].numFaces;
+}
+
+uint TurningPuzzle::framesPerTurnOf(uint objectID) const {
+	if (g_nancy->getGameType() < kGameTypeNancy13) {
+		return _numFramesPerTurn;
+	}
+	return _pieceTypes[_pieceTypeIDs[objectID]].framesPerTurn;
+}
+
+bool TurningPuzzle::isSolved() const {
+	if (g_nancy->getGameType() < kGameTypeNancy13) {
+		return _currentOrder == _correctOrder;
+	}
+
+	// Any one of the (up to three) alternative orders solves it. Unused solutions are
+	// filled with 0xffff, which no face can ever match.
+	for (uint n = 0; n < _correctOrders.size(); ++n) {
+		bool match = true;
+		for (uint i = 0; i < _currentOrder.size(); ++i) {
+			if (_currentOrder[i] != _correctOrders[n][i]) {
+				match = false;
+				break;
+			}
+		}
+
+		if (match) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+void TurningPuzzle::drawAllObjects() {
+	for (uint i = 0; i < _currentOrder.size(); ++i) {
+		drawObject(i, _currentOrder[i], 0);
+	}
+}
+
+SoundDescription TurningPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	SoundDescription desc;
+	if (block.names.empty()) {
+		return desc;
+	}
+
+	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;
+	}
+
+	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);
+	return desc;
+}
+
 void TurningPuzzle::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy13) {
+		readDataNancy13(stream);
+		return;
+	}
+
 	readFilename(stream, _imageName);
 	uint numSpindles = stream.readUint16LE();
 	_numFaces = stream.readUint16LE();
@@ -204,11 +392,12 @@ void TurningPuzzle::execute() {
 	switch (_state) {
 	case kBegin :
 		init();
-		g_nancy->_sound->loadSound(_turnSound);
-		_currentOrder = _startPositions;
-		for (uint i = 0; i < _currentOrder.size(); ++i) {
-			drawObject(i, _currentOrder[i], 0);
+		if (g_nancy->getGameType() < kGameTypeNancy13) {
+			// Nancy13 picks its turn sound out of a random block on every turn instead.
+			g_nancy->_sound->loadSound(_turnSound);
 		}
+		_currentOrder = _startPositions;
+		drawAllObjects();
 
 		NancySceneState.setNoHeldItem();
 
@@ -219,9 +408,12 @@ void TurningPuzzle::execute() {
 			return;
 		}
 
-		if (_currentOrder == _correctOrder) {
+		if (isSolved()) {
 			_state = kActionTrigger;
-			if (_solveAnimate) {
+			if (g_nancy->getGameType() >= kGameTypeNancy13) {
+				_solveSound = playSoundBlock(_solveSoundBlock);
+				_solveState = kWaitForSound;
+			} else if (_solveAnimate) {
 				_solveState = kWaitForAnimation;
 			} else {
 				_solveState = kWaitForSound;
@@ -268,38 +460,85 @@ void TurningPuzzle::execute() {
 }
 
 void TurningPuzzle::handleInput(NancyInput &input) {
+	const bool isNancy13 = g_nancy->getGameType() >= kGameTypeNancy13;
+
 	if (NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
-		g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
+		if (isNancy13)
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+		else
+			g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
 
-		if (input.input & NancyInput::kLeftMouseButtonUp) {
+		if (input.input & NancyInput::kLeftMouseButtonUp)
 			_state = kActionTrigger;
-		}
 
 		return;
 	}
 
 	for (uint i = 0; i < _hotspots.size(); ++i) {
-		if (NancySceneState.getViewport().convertViewportToScreen(_hotspots[i]).contains(input.mousePos)) {
+		if (!NancySceneState.getViewport().convertViewportToScreen(_hotspots[i]).contains(input.mousePos))
+			continue;
+
+		if (isNancy13)
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
+		else
 			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
 
-			if (_objectCurrentlyTurning != -1) {
-				break;
-			}
+		if (_objectCurrentlyTurning != -1)
+			break;
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			if (isNancy13) {
+				// The original sets this flag the first time the player turns anything.
+				if (_turnFlagLabel != -1 && !_turnFlagSet) {
+					NancySceneState.setEventFlag(_turnFlagLabel,
+						_turnFlagValue ? g_nancy->_true : g_nancy->_false);
+					_turnFlagSet = true;
+				}
+
+				// Nancy13 picks a fresh turn sound out of a random block each time.
+				_turnSound = playSoundBlock(_turnSoundBlock);
 
-			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				// Start the turn animation from a clean frame counter. (Older games
+				// leave these untouched here - they are already 0 when a spindle is
+				// clickable, and resetting them would disturb the solve animation).
+				_turnFrameID = 0;
+				_nextTurnTime = 0;
+			} else {
 				g_nancy->_sound->playSound(_turnSound);
-				_objectCurrentlyTurning = i;
 			}
 
-			// fixes nancy4 scene 4308
-			input.eatMouseInput();
-
-			return;
+			_objectCurrentlyTurning = i;
 		}
+
+		// fixes nancy4 scene 4308
+		input.eatMouseInput();
+		return;
 	}
 }
 
 void TurningPuzzle::drawObject(uint objectID, uint faceID, uint frameID) {
+	if (g_nancy->getGameType() >= kGameTypeNancy13) {
+		// The strip is a grid: one row per object type, with its faces laid out along X,
+		// each face taking (framesPerTurn + 1) frames.
+		const PieceType &t = _pieceTypes[_pieceTypeIDs[objectID]];
+		int stride = t.gap + t.cellW;
+		int step = (t.framesPerTurn + 1) * (int)faceID + (int)frameID;
+
+		Common::Rect srcRect;
+		srcRect.left = t.srcStartX + stride * step;
+		srcRect.top = t.srcStartY;
+		srcRect.right = srcRect.left + t.cellW;
+		srcRect.bottom = srcRect.top + t.cellH;
+
+		// Clear the object's cell first: unlike the older path (opaque sprites that fully
+		// cover their cell), the Nancy13 pipe sprites are transparent, so the previous
+		// frame would otherwise show through underneath.
+		_drawSurface.fillRect(_destRects[objectID], _drawSurface.getTransparentColor());
+		_drawSurface.blitFrom(_image, srcRect, _destRects[objectID]);
+		_needsRedraw = true;
+		return;
+	}
+
 	Common::Rect srcRect = _destRects[objectID];
 	srcRect.moveTo(_startPos);
 	Common::Point inc(_srcIncrement.x == 1 ? srcRect.width() : _srcIncrement.x, _srcIncrement.y == -2 ? srcRect.height() : _srcIncrement.y);
@@ -312,14 +551,19 @@ void TurningPuzzle::drawObject(uint objectID, uint faceID, uint frameID) {
 
 void TurningPuzzle::turnLogic(uint objectID) {
 	++_currentOrder[objectID];
-	if (_currentOrder[objectID] >= _numFaces) {
+	if (_currentOrder[objectID] >= numFacesOf(objectID)) {
 		_currentOrder[objectID] = 0;
 	}
 
 	for (uint j = 0; j < _links[objectID].size(); ++j) {
-		++_currentOrder[_links[objectID][j] - 1];
-		if (_currentOrder[_links[objectID][j] - 1] >= _numFaces) {
-			_currentOrder[_links[objectID][j] - 1] = 0;
+		uint linkedID = _links[objectID][j] - 1;
+		if (linkedID >= _currentOrder.size()) {
+			continue;
+		}
+
+		++_currentOrder[linkedID];
+		if (_currentOrder[linkedID] >= numFacesOf(linkedID)) {
+			_currentOrder[linkedID] = 0;
 		}
 	}
 }
diff --git a/engines/nancy/action/puzzle/turningpuzzle.h b/engines/nancy/action/puzzle/turningpuzzle.h
index 06fcfb138d0..455d0933279 100644
--- a/engines/nancy/action/puzzle/turningpuzzle.h
+++ b/engines/nancy/action/puzzle/turningpuzzle.h
@@ -29,7 +29,13 @@ namespace Action {
 
 // Handles a specific type of puzzle where clicking an object rotates it,
 // as well as several other objects linked to it. Examples are the sun/moon
-// and staircase spindle puzzles in nancy3
+// and staircase spindle puzzles in nancy3.
+//
+// Nancy13 moved this record from type 209 to 171 and reworked its chunk, but kept the
+// mechanic identical. The differences: every object references a "type" that carries
+// its own face count, turn-frame count and sprite geometry (instead of one global pair);
+// the links are a byte array; each object stores its own destination rect; and the
+// solution is given as up to three alternative face orders.
 class TurningPuzzle : public RenderActionRecord {
 public:
 	enum SolveState { kNotSolved, kWaitForAnimation, kWaitBeforeSound, kWaitForSound };
@@ -48,6 +54,24 @@ public:
 protected:
 	Common::String getRecordTypeName() const override { return "TurningPuzzle"; }
 
+	// Nancy13: the per-object sprite/turn description its objects point at.
+	struct PieceType {
+		byte numFaces = 0;
+		int16 framesPerTurn = 0;
+		int16 gap = 0;			// horizontal padding between sprites in the strip
+		int16 cellW = 0;
+		int16 cellH = 0;
+		int16 srcStartX = 0;
+		int16 srcStartY = 0;	// this type's row in the strip
+	};
+
+	void readDataNancy13(Common::SeekableReadStream &stream);
+	bool isSolved() const;
+	uint numFacesOf(uint objectID) const;
+	uint framesPerTurnOf(uint objectID) const;
+	void drawAllObjects();
+	SoundDescription playSoundBlock(const RandomSoundBlock &block);
+
 	void drawObject(uint objectID, uint faceID, uint frameID);
 	void turnLogic(uint objectID);
 
@@ -82,6 +106,20 @@ protected:
 	SceneChangeWithFlag _exitScene;
 	Common::Rect _exitHotspot;
 
+	// -- Nancy13 only --
+	Common::Array<PieceType> _pieceTypes;
+	Common::Array<uint16> _pieceTypeIDs;					// per object
+	Common::Array<Common::Array<uint16>> _correctOrders;	// up to three alternative solutions
+	uint16 _turnDelay = 0;			// header 0x21 - length of a whole turn, in ms
+	uint16 _hoverCursorType = 0;	// header 0x23 - raw Nancy13 cursor id (a turn cursor)
+	uint16 _hitInset = 0;			// header 0x25 - hotspots are the dest rect shrunk by this
+	int16 _turnFlagLabel = -1;		// header 0x27 - set once the player turns anything
+	byte _turnFlagValue = 0;		// header 0x29
+	uint16 _exitCursorType = 0;		// from the exit hotspot record
+	RandomSoundBlock _turnSoundBlock;
+	RandomSoundBlock _solveSoundBlock;
+	bool _turnFlagSet = false;
+
 	Graphics::ManagedSurface _image;
 	Common::Array<uint16> _currentOrder;
 




More information about the Scummvm-git-logs mailing list