[Scummvm-git-logs] scummvm master -> 6aa8fa9b6f9e5a7ae670cc355af1b727ff75c995

bluegr noreply at scummvm.org
Fri Aug 7 03:06:49 UTC 2026


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

Summary:
f36dc38bea NANCY: NANCY12: More work on DrivingPuzzle
a72a80d983 NANCY: NANCY7: Prefer Bink videos when both Bink and AVF are present
4133e1fdc5 NANCY: NANCY12: Implement new SortPuzzle functionality
1c8420f637 NANCY: NANCY12: Implement new functionality for OneBuildPuzzle
5504142fbd NANCY: NANCY12: Show correct subtitles for overriden "I can't do that"
07d7b5fb66 NANCY: NANCY12: Fix jitter of last placed piece in SortPuzzle
6c3705875a NANCY: NANCY12: Fix flag triggering in SewingMachinePuzzle
c0477955d0 NANCY: NANCY12: Set cursor from puzzle data in OrderingPuzzle
d9e5cd4e2d NANCY: NANCY10: Add correct draw check for the checkbox
6aa8fa9b6f NANCY: NANCY12: Implement new solve state handling in TwoDialPuzzle


Commit: f36dc38bead14f74d671f3505ec16469627b7eec
    https://github.com/scummvm/scummvm/commit/f36dc38bead14f74d671f3505ec16469627b7eec
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:24+03:00

Commit Message:
NANCY: NANCY12: More work on DrivingPuzzle

- Add handling for obstacles (e.g. cows)
- Fix layers drawn above the car (trees, power poles, roofs etc)
- Fix depletion of gas (needs to be preserved across scenes)
- Add cheat for gas tank refill and infinite fuel with Control-Shift-G
- Add cheat to fix and replace the car tyre with Control-Shift-T

This bumps up the savegame version

Changed paths:
    engines/nancy/action/actionzone.cpp
    engines/nancy/action/actionzone.h
    engines/nancy/action/puzzle/drivingpuzzle.cpp
    engines/nancy/action/puzzle/drivingpuzzle.h
    engines/nancy/nancy.h
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h


diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
index bf16c6bf8bd..41447b17280 100644
--- a/engines/nancy/action/actionzone.cpp
+++ b/engines/nancy/action/actionzone.cpp
@@ -172,7 +172,7 @@ void ActionZone::readOverlayZone(Common::SeekableReadStream &stream, bool isNanc
 	}
 	stream.skip(4);		// int32
 	stream.skip(1);		// byte (loop/play mode)
-	stream.skip(4);		// int32
+	overlayLayer = stream.readSint32LE();
 }
 
 void readActionZoneArray(Common::SeekableReadStream &stream, Common::Array<ActionZone> &out, bool isNancy13) {
diff --git a/engines/nancy/action/actionzone.h b/engines/nancy/action/actionzone.h
index 96d255e6a85..4d3045a09aa 100644
--- a/engines/nancy/action/actionzone.h
+++ b/engines/nancy/action/actionzone.h
@@ -121,6 +121,7 @@ struct ActionZone {
 	Common::String overlayName;
 	Common::Array<Common::Rect> overlaySrcRects;
 	Common::Rect overlayDestRect;
+	int32 overlayLayer = 0;	// draw pass: 0 renders under the car, 1 over it
 
 	// The Nancy13 pinball layout (AR 175) differs from the Nancy12 one: the base carries an
 	// extra int32 before the sound block, and the overlay/unknown-0x15/bumper subtypes have
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.cpp b/engines/nancy/action/puzzle/drivingpuzzle.cpp
index cd66be16c15..44b20a0b4a2 100644
--- a/engines/nancy/action/puzzle/drivingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/drivingpuzzle.cpp
@@ -23,6 +23,7 @@
 #include "common/random.h"
 
 #include "engines/nancy/nancy.h"
+#include "engines/nancy/enginedata.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/resource.h"
 #include "engines/nancy/sound.h"
@@ -181,11 +182,20 @@ void DrivingPuzzle::classifyZones(const Common::Array<ActionZone> &zones) {
 			ov.destRect = z.overlayDestRect;
 			ov.condFlag = z.val49;
 			ov.condValue = z.val4b;
+			ov.aboveCar = z.overlayLayer != 0;
 			if (ov.imageIndex >= 0) {
 				_overlays.push_back(ov);
 			}
 			break;
 		}
+		case kZoneBoundary: {	// flag-gated road obstacle (a cow blocking the road, etc.)
+			Obstacle obs;
+			obs.rect = z.rect;
+			obs.condFlag = z.val49;
+			obs.condValue = z.val4b;
+			_obstacles.push_back(obs);
+			break;
+		}
 		default:
 			// The remaining subtypes (terrain markers) are not simulated yet.
 			break;
@@ -275,6 +285,8 @@ void DrivingPuzzle::init() {
 			_carY = data->carY;
 			_carHeading = data->heading;
 			_tireDamage = data->tireDamage;
+			_fuelBurnAccum = data->fuelBurnAccum;
+			_infiniteFuel = data->infiniteFuel;
 		}
 	}
 
@@ -329,7 +341,7 @@ int DrivingPuzzle::overlayImageIndex(const Common::String &name) {
 	return (int)_overlayImages.size() - 1;
 }
 
-void DrivingPuzzle::drawOverlays(const Common::Point &cam) {
+void DrivingPuzzle::drawOverlays(const Common::Point &cam, bool aboveCar) {
 	if (_overlays.empty()) {
 		return;
 	}
@@ -340,6 +352,9 @@ void DrivingPuzzle::drawOverlays(const Common::Point &cam) {
 
 	for (uint i = 0; i < _overlays.size(); ++i) {
 		const Overlay &ov = _overlays[i];
+		if (ov.aboveCar != aboveCar) {
+			continue;
+		}
 		if (ov.imageIndex < 0 || !view.intersects(ov.destRect)) {
 			continue;
 		}
@@ -368,6 +383,24 @@ void DrivingPuzzle::saveState() const {
 		data->carY = (int32)(_carY + 0.5);
 		data->heading = _carHeading;
 		data->tireDamage = _tireDamage;
+		data->fuelBurnAccum = _fuelBurnAccum;
+		data->infiniteFuel = _infiniteFuel;
+	}
+}
+
+void DrivingPuzzle::refillFuel() {
+	const UIRC *uirc = GetEngineData(UIRC)
+	if (uirc && _frictionIndex >= 0 && (uint)_frictionIndex < uirc->items.size()) {
+		NancySceneState.setUIResource(_frictionIndex, uirc->items[_frictionIndex].id);
+		_fuelBurnAccum = 0.0;
+	}
+}
+
+void DrivingPuzzle::repairTire() {
+	_tireDamage = 0;
+	const UIRC *uirc = GetEngineData(UIRC)
+	if (uirc && kTireResourceIndex < uirc->items.size()) {
+		NancySceneState.setUIResource(kTireResourceIndex, uirc->items[kTireResourceIndex].id);
 	}
 }
 
@@ -383,7 +416,20 @@ bool DrivingPuzzle::isWall(int px, int py) const {
 }
 
 bool DrivingPuzzle::isBlocked(const Common::Point &p) const {
-	return isWall(p.x, p.y);
+	if (isWall(p.x, p.y)) {
+		return true;
+	}
+
+	// A cow (or similar) is blocking the road while its story flag is set.
+	for (uint i = 0; i < _obstacles.size(); ++i) {
+		const Obstacle &obs = _obstacles[i];
+		if (obs.rect.contains(p) &&
+			(obs.condFlag == -1 || NancySceneState.getEventFlag(obs.condFlag, obs.condValue))) {
+			return true;
+		}
+	}
+
+	return false;
 }
 
 void DrivingPuzzle::drawScene() {
@@ -393,9 +439,9 @@ void DrivingPuzzle::drawScene() {
 
 	_drawSurface.blitFrom(_image, Common::Rect(camX, camY, camX + _drawSurface.w, camY + _drawSurface.h), Common::Point(0, 0));
 
-	// Map decorations (buildings, cars, potholes, animated cows/flags) sit on the map,
+	// Ground-level decorations (potholes, parked cars, cows, fountain) lie on the road,
 	// under the cars.
-	drawOverlays(cam);
+	drawOverlays(cam, false);
 
 	// The chaser car (kChase), drawn under the player car.
 	if (_variant == kChase && !_frameRects2.empty() && _chaseCarImage.w > 0) {
@@ -413,6 +459,10 @@ void DrivingPuzzle::drawScene() {
 		_drawSurface.blitFrom(_carImage, src, Common::Point(sx, sy));
 	}
 
+	// Tall decorations (buildings, trees, power-line poles, flags) draw over the cars,
+	// so the car passes behind them.
+	drawOverlays(cam, true);
+
 	_needsRedraw = true;
 }
 
@@ -580,7 +630,7 @@ void DrivingPuzzle::updatePhysics(int throttle, double cursorDist) {
 	// The gas tank empties by the distance the car actually travels this frame divided by
 	// the header's distance divisor. The DT_RESOURCE scene dependency reads the same
 	// resource to warn Nancy when it runs low.
-	if (_distanceDivisor > 0) {
+	if (_distanceDivisor > 0 && !_infiniteFuel) {
 		double moved = sqrt((_carX - preX) * (_carX - preX) + (_carY - preY) * (_carY - preY));
 		_fuelBurnAccum += moved / (double)_distanceDivisor;
 		if (_fuelBurnAccum >= 1.0) {
@@ -679,6 +729,27 @@ void DrivingPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// Cheats: Ctrl+Shift+G toggles infinite fuel (tops the tank off and stops the drain);
+	// Ctrl+Shift+T repairs the spare tire (clears pothole wear and restores it to good).
+	for (uint i = 0; i < input.otherKbdInput.size(); ++i) {
+		const Common::KeyState &key = input.otherKbdInput[i];
+		if ((key.flags & Common::KBD_CTRL) == 0 || (key.flags & Common::KBD_SHIFT) == 0) {
+			continue;
+		}
+		if (key.keycode == Common::KEYCODE_g) {
+			_infiniteFuel = !_infiniteFuel;
+			if (_infiniteFuel) {
+				refillFuel();
+			}
+			saveState();
+			debug("Gas cheat: infinite fuel %s", _infiniteFuel ? "ON" : "OFF");
+		} else if (key.keycode == Common::KEYCODE_t) {
+			repairTire();
+			saveState();
+			debug("Tire cheat: spare tire repaired");
+		}
+	}
+
 	// A tire has blown: hold the car still until the blowout sound finishes, then leave
 	// for the flat-tire scene (where Nancy fits the spare).
 	if (_flatTirePending) {
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.h b/engines/nancy/action/puzzle/drivingpuzzle.h
index 5e9e8fb9bd1..f944cbc9dac 100644
--- a/engines/nancy/action/puzzle/drivingpuzzle.h
+++ b/engines/nancy/action/puzzle/drivingpuzzle.h
@@ -137,6 +137,15 @@ protected:
 		bool carInside = false;		// the car was inside this zone last frame
 	};
 
+	// A removable road obstacle (type 0x14): a cow (or similar) that blocks the car while
+	// its event-flag condition holds. Each has a matching 0x0d cow overlay gated on the same
+	// flag, so the sprite and the collision appear and vanish together with the story state.
+	struct Obstacle {
+		Common::Rect rect;
+		int16 condFlag = -1;			// base zone val49: event flag gating the block
+		byte condValue = 0;				// base zone val4b: the flag value that activates it
+	};
+
 	// A cosmetic map decoration (type 0x0d): a sprite drawn onto the map at destRect.
 	// A single source rect is static; several are animation frames cycled over time.
 	// It is only visible while its event-flag condition holds (condFlag == -1 = always).
@@ -146,6 +155,7 @@ protected:
 		Common::Rect destRect;			// map space
 		int16 condFlag = -1;			// base zone val49: event flag gating visibility
 		byte condValue = 0;				// base zone val4b: the flag value that shows it
+		bool aboveCar = false;			// layer 1 draws over the car (tall props), 0 under
 	};
 
 	// A recorded chaser-path waypoint (kChase): the pursuer plays these back in real
@@ -191,6 +201,12 @@ protected:
 	// (and saving). Only does anything when the header's retainState flag is set.
 	void saveState() const;
 
+	// Refills the gas tank to the full amount from the UIRC boot chunk (the infinite-fuel cheat).
+	void refillFuel();
+
+	// Clears the accumulated pothole wear and restores the spare tire to good (the fix-tire cheat).
+	void repairTire();
+
 	// Whether a map-space point is off the road: off the map, or a non-white (dark)
 	// pixel in the collision mask (its white marks the drivable streets).
 	bool isWall(int px, int py) const;
@@ -211,7 +227,7 @@ protected:
 
 	// Draws the map's cosmetic decorations (animated frames cycled over time), offset by
 	// the camera and clipped to the visible window.
-	void drawOverlays(const Common::Point &cam);
+	void drawOverlays(const Common::Point &cam, bool aboveCar);
 
 	// Redraws the scrolling map (car-centered camera) and the car sprite(s) on top.
 	void drawScene();
@@ -229,7 +245,8 @@ protected:
 	int32 _startAngle = 0;		// blob+0x6b: start heading, degrees
 	int32 _forwardSpeed = 0;	// blob+0x6f: forward speed cap
 	int32 _reverseSpeed = 0;	// blob+0x73
-	int16 _frictionIndex = 0;	// blob+0x77: index into the shared friction table
+	int16 _frictionIndex = 0;	// blob+0x77: UIRC resource index for the fuel gauge
+	static const uint kTireResourceIndex = 2;	// UIRC resource index for the tire gauge (1 = good)
 	int32 _distanceDivisor = 0;	// blob+0x7b
 	bool _retainState = false;	// blob+0x7f: resume from the saved position
 	uint16 _finishScene = kNoScene;	// blob+0x80: the scene entered when a tire goes flat
@@ -256,6 +273,7 @@ protected:
 	Common::Array<MudZone> _mudZones;				// type 0x03
 	Common::Array<Pothole> _potholes;				// type 0x17
 	Common::Array<Overlay> _overlays;				// type 0x0d (map decorations)
+	Common::Array<Obstacle> _obstacles;				// type 0x14 (flag-gated road obstacles)
 	Common::Array<Graphics::ManagedSurface> _overlayImages;
 	Common::Array<Common::String> _overlayImageNames;
 
@@ -291,6 +309,7 @@ protected:
 	double _fuelBurnAccum = 0.0;
 	int _tireDamage = 0;
 	bool _flatTirePending = false;
+	bool _infiniteFuel = false;	// cheat: Ctrl+Shift+G tops the tank and stops it draining
 
 	// Chaser (kChase) runtime state.
 	bool _chaseStarted = false;
diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index 583aa165b23..14c9c5a0bcd 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -54,7 +54,7 @@ class Serializer;
  */
 namespace Nancy {
 
-static const int kSavegameVersion = 7;
+static const int kSavegameVersion = 8;
 
 struct NancyGameDescription;
 
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 9c1457140a9..290f1505180 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -496,6 +496,9 @@ void DrivingData::synchronize(Common::Serializer &ser) {
 	ser.syncAsDoubleLE(heading);
 	ser.syncAsSint32LE(tireDamage);
 	ser.syncAsByte(flatTire);
+	// Added in savegame version 8; older saves default these to 0/false.
+	ser.syncAsDoubleLE(fuelBurnAccum, 8);
+	ser.syncAsByte(infiniteFuel, 8);
 }
 
 PuzzleData *makePuzzleData(const uint32 tag) {
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 3a42c7820f6..90badcc3894 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -411,6 +411,8 @@ struct DrivingData : public PuzzleData {
 	double heading = 0.0;
 	int32 tireDamage = 0;
 	bool flatTire = false;
+	double fuelBurnAccum = 0.0;	// fractional fuel drained but not yet a whole unit
+	bool infiniteFuel = false;	// cheat toggle, kept across building visits
 };
 
 PuzzleData *makePuzzleData(const uint32 tag);


Commit: a72a80d983b6efbc66a63511112b87a979e9e05b
    https://github.com/scummvm/scummvm/commit/a72a80d983b6efbc66a63511112b87a979e9e05b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:25+03:00

Commit Message:
NANCY: NANCY7: Prefer Bink videos when both Bink and AVF are present

Fixes graphics in SortPuzzle (Cake Puzzle) in Nancy12, where the
puzzle's background (EXT_PieSorting_PUZ) exists as both a Bink and an
AVF video, but the AVF video contains an early unused version

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


diff --git a/engines/nancy/movieplayer.cpp b/engines/nancy/movieplayer.cpp
index ce685c3650f..6c6ad8aa384 100644
--- a/engines/nancy/movieplayer.cpp
+++ b/engines/nancy/movieplayer.cpp
@@ -54,13 +54,13 @@ bool MoviePlayer::loadFile(const Common::Path &name, bool bidirectionalCache) {
 	const Common::Path avfPath = name.append(".avf");
 	const Common::Path bikPath = name.append(".bik");
 
-	// Detect the format from which file exists (AVF wins if both do).
-	if (Common::File::exists(avfPath)) {
-		_videoType = kVideoPlaytypeAVF;
-		_decoder.reset(new AVFDecoder(bidirectionalCache ? AVFDecoder::kLoadBidirectional : AVFDecoder::kLoadForward));
-	} else if (Common::File::exists(bikPath)) {
+	// Detect the format from which file exists. Bink wins if both do.
+	if (Common::File::exists(bikPath)) {
 		_videoType = kVideoPlaytypeBink;
 		_decoder.reset(new Video::BinkDecoder());
+	} else if (Common::File::exists(avfPath)) {
+		_videoType = kVideoPlaytypeAVF;
+		_decoder.reset(new AVFDecoder(bidirectionalCache ? AVFDecoder::kLoadBidirectional : AVFDecoder::kLoadForward));
 	} else {
 		_decoder.reset();
 		return false;
diff --git a/engines/nancy/movieplayer.h b/engines/nancy/movieplayer.h
index d8f5aa8df05..07d9d1259ab 100644
--- a/engines/nancy/movieplayer.h
+++ b/engines/nancy/movieplayer.h
@@ -59,7 +59,7 @@ public:
 	~MoviePlayer();
 
 	// Load <name> + ".avf"/".bik", auto-detecting the format from which file
-	// exists (AVF preferred if both do) and creating the matching decoder.
+	// exists (Bink preferred if both do) and creating the matching decoder.
 	// bidirectionalCache enables fast bidirectional scrubbing; pass it only for
 	// scrubbed panorama scenes.
 	bool loadFile(const Common::Path &name, bool bidirectionalCache = false);


Commit: 4133e1fdc5744ba9767e61a14b64331a8114327b
    https://github.com/scummvm/scummvm/commit/4133e1fdc5744ba9767e61a14b64331a8114327b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:26+03:00

Commit Message:
NANCY: NANCY12: Implement new SortPuzzle functionality

Fixes issues in the Cake Sorting puzzle

Changed paths:
    engines/nancy/action/puzzle/sortpuzzle.cpp
    engines/nancy/action/puzzle/sortpuzzle.h
    engines/nancy/cursor.cpp
    engines/nancy/cursor.h


diff --git a/engines/nancy/action/puzzle/sortpuzzle.cpp b/engines/nancy/action/puzzle/sortpuzzle.cpp
index b5304b93daf..e5cfcff41fb 100644
--- a/engines/nancy/action/puzzle/sortpuzzle.cpp
+++ b/engines/nancy/action/puzzle/sortpuzzle.cpp
@@ -127,23 +127,31 @@ void SortPuzzle::readData(Common::SeekableReadStream &stream) {
 }
 
 // Nancy 12 reworked the record: the board layout is computed from a single cell
-// size plus origin/spacing (rather than a per-position rect grid), gems are drawn
-// from one source sprite per value, and there is an (unused-here) preset board and
-// shuffle flag. The engine reshuffles itself, so those are skipped.
+// size plus origin/spacing (rather than a per-position rect grid), the solved
+// layout is stored as a preset grid of values, and the win check is configurable
+// through a set of flags.
 void SortPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	readFilename(stream, _boardImageName);  // 0x000
 	readFilename(stream, _cursorImageName); // 0x021
 	_retainState = (stream.readByte() != 0); // 0x042
 	_rows = stream.readUint16LE();           // 0x043
 	_cols = stream.readUint16LE();           // 0x045
-	stream.skip(4);                          // 0x047 unknown
-	stream.skip(1);                          // 0x04b preset/shuffle flag (engine reshuffles)
-	stream.skip(200);                        // 0x04c preset board values (100 x int16)
+
+	_matchRow          = (stream.readByte() != 0); // 0x047
+	_matchGroup        = (stream.readByte() != 0); // 0x048
+	_matchValue        = (stream.readByte() != 0); // 0x049
+	_allowSwappedPairs = (stream.readByte() != 0); // 0x04a
+	_usePresetBoard    = (stream.readByte() != 0); // 0x04b
+
+	for (uint i = 0; i < (uint)(kPresetStride * kPresetStride); ++i) { // 0x04c
+		_presetBoard[i] = stream.readSint16LE();
+	}
+
 	stream.skip(2);                          // 0x114 unknown
 	_groupDivisor = stream.readUint16LE();   // 0x116
 	_valueRange   = stream.readUint16LE();   // 0x118
 
-	_valueSrcRects.resize(kNumValueRects);   // 0x11a: one source sprite per gem value
+	_valueSrcRects.resize(kNumValueRects);   // 0x11a
 	for (uint i = 0; i < (uint)kNumValueRects; ++i) {
 		readRect(stream, _valueSrcRects[i]);
 	}
@@ -177,17 +185,22 @@ void SortPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 
 	_cellWidth  = _valueSrcRects[0].width();
 	_cellHeight = _valueSrcRects[0].height();
+
+	// Cells only react to clicks well inside their own bounds, so that the
+	// near-contiguous grid doesn't drop a piece into the neighboring slot
+	_hotspotInsetX = (_cellWidth  - 1) / 4;
+	_hotspotInsetY = (_cellHeight - 1) / 4;
 }
 
 Common::Rect SortPuzzle::cellSprite(const Cell &cell) const {
 	if (g_nancy->getGameType() >= kGameTypeNancy12) {
-		// Each pie has a kind (its group/column, cell.srcCol) and a size
-		// (cell.value, smallest to biggest). The source sprites are laid out as a
-		// grid - kind * kSizesPerKind + size - so the index must combine both,
-		// otherwise every kind would draw the same pie at a given size.
-		if (cell.srcCol >= 0 && cell.value >= 0) {
-			uint idx = (uint)cell.srcCol * kSizesPerKind + (uint)cell.value;
-			if (idx < _valueSrcRects.size()) {
+		// A pie is identified by the row it started in (cell.srcRow), its kind
+		// (the group of columns it started in, cell.srcCol) and its size
+		// (cell.value). The source sprites are laid out as a grid over all three,
+		// so the index has to combine them.
+		if (cell.srcRow >= 0 && cell.srcCol >= 0 && cell.value >= 0) {
+			uint idx = ((uint)cell.srcRow * kGroupsPerRow + (uint)cell.srcCol) * kSizesPerKind + (uint)cell.value;
+			if (idx < (uint)kNumBoardRects) {
 				return _valueSrcRects[idx];
 			}
 		}
@@ -201,6 +214,19 @@ Common::Rect SortPuzzle::cellSprite(const Cell &cell) const {
 	return Common::Rect();
 }
 
+// Nancy 12 keeps a second, smaller set of sprites for the piece being carried
+// around. When a size has no such sprite the board one is drawn instead.
+Common::Rect SortPuzzle::heldSprite(const Cell &cell) const {
+	if (cell.value >= 0 && cell.value < kSizesPerKind) {
+		const Common::Rect &src = _valueSrcRects[kNumBoardRects + cell.value];
+		if (!src.isEmpty()) {
+			return src;
+		}
+	}
+
+	return cellSprite(cell);
+}
+
 void SortPuzzle::initState() {
 	SortPuzzleData *spd = (SortPuzzleData *)NancySceneState.getPuzzleData(SortPuzzleData::getTag());
 	if (_retainState && spd && !spd->currentState.empty() && !spd->solvedState.empty()) {
@@ -216,9 +242,19 @@ void SortPuzzle::initState() {
 			Cell &cell = _solved[r][c];
 			cell.srcRow  = (int16)r;
 			cell.srcCol  = (int16)(c / groupSize);
-			cell.value   = (int16)(g_nancy->_randomSource->getRandomNumber(_valueRange - 1));
 			cell.isEmpty = false;
 
+			if (_usePresetBoard) {
+				cell.value = _presetBoard[r * kPresetStride + c];
+				if (cell.value == kEmptyCell) {
+					cell.srcRow  = kEmptyCell;
+					cell.isEmpty = true;
+				}
+				continue;
+			}
+
+			cell.value = (int16)(g_nancy->_randomSource->getRandomNumber(_valueRange - 1));
+
 			int groupStart = (c / groupSize) * groupSize;
 			int k = c;
 			while (k > groupStart && _solved[r][k].value < _solved[r][k - 1].value) {
@@ -265,8 +301,12 @@ void SortPuzzle::init() {
 
 	g_nancy->_resource->loadImage(_boardImageName, _boardImage);
 	_boardImage.setTransparentColor(_drawSurface.getTransparentColor());
-	g_nancy->_resource->loadImage(_cursorImageName, _cursorImage);
-	_cursorImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+	// Nancy 12 has no separate cursor image and draws the held piece from the board one
+	if (!_cursorImageName.empty() && _cursorImageName.baseName() != "NO_FILE") {
+		g_nancy->_resource->loadImage(_cursorImageName, _cursorImage);
+		_cursorImage.setTransparentColor(_drawSurface.getTransparentColor());
+	}
 
 	initState();
 
@@ -344,10 +384,19 @@ Common::Rect SortPuzzle::cellRect(int row, int col) const {
 	return Common::Rect(x, y, x + _cellWidth, y + _cellHeight);
 }
 
+Common::Rect SortPuzzle::cellHotspot(int row, int col) const {
+	Common::Rect rect = cellRect(row, col);
+	rect.left   += _hotspotInsetX;
+	rect.right  -= _hotspotInsetX;
+	rect.top    += _hotspotInsetY;
+	rect.bottom -= _hotspotInsetY;
+	return rect;
+}
+
 bool SortPuzzle::hitTestCell(const Common::Point &p, int &outRow, int &outCol) const {
 	for (int r = 0; r < (int)_rows; ++r) {
 		for (int c = 0; c < (int)_cols; ++c) {
-			if (cellRect(r, c).contains(p)) {
+			if (cellHotspot(r, c).contains(p)) {
 				outRow = r;
 				outCol = c;
 				return true;
@@ -383,26 +432,42 @@ void SortPuzzle::handleInput(NancyInput &input) {
 		redraw();
 	}
 
+	// Nancy 12 uses the dedicated puzzle hands: a closed one over a piece that can
+	// be picked up, an open one over a slot the carried piece can go into. Off the
+	// grid the cursor goes blank, since the carried piece is drawn at the mouse.
+	const bool useNewCursors = g_nancy->getGameType() >= kGameTypeNancy12;
+
 	int row, col;
 	bool hitCell = hitTestCell(mouseVP, row, col);
 
+	// An empty slot is only a target while carrying a piece
+	if (hitCell && !_hasHeld && _current[row][col].isEmpty) {
+		hitCell = false;
+	}
+
 	if (!hitCell) {
 		if (!_exitHotspot.isEmpty() && _exitHotspot.contains(mouseVP)) {
 			g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
 			if (input.input & NancyInput::kLeftMouseButtonUp)
 				_subState = kExitToCancel;
+		} else if (_hasHeld && useNewCursors) {
+			g_nancy->_cursor->setCursorType(CursorManager::kNewBlank);
 		}
 		return;
 	}
 
-	g_nancy->_cursor->setCursorType(_hasHeld ? CursorManager::kDropHand
-	                                         : CursorManager::kHotspot);
+	if (useNewCursors) {
+		g_nancy->_cursor->setCursorType(_hasHeld ? CursorManager::kNewUseHandHotspot
+		                                         : CursorManager::kNewDragHandHotspot);
+	} else {
+		g_nancy->_cursor->setCursorType(_hasHeld ? CursorManager::kDropHand
+		                                         : CursorManager::kHotspot);
+	}
+
 	if (!(input.input & NancyInput::kLeftMouseButtonUp))
 		return;
 
 	if (!_hasHeld) {
-		if (_current[row][col].isEmpty)
-			return;
 		_held = _current[row][col];
 		_hasHeld = true;
 		_current[row][col].isEmpty = true;
@@ -431,16 +496,63 @@ void SortPuzzle::handleInput(NancyInput &input) {
 	redraw();
 }
 
+bool SortPuzzle::cellsMatch(const Cell &cur, const Cell &sol) const {
+	if (cur.isEmpty || sol.isEmpty) {
+		return cur.isEmpty && sol.isEmpty;
+	}
+
+	if (_matchRow && cur.srcRow != sol.srcRow) {
+		return false;
+	}
+
+	if (_matchGroup && cur.srcCol != sol.srcCol) {
+		return false;
+	}
+
+	if (_matchValue && cur.value != sol.value) {
+		return false;
+	}
+
+	return true;
+}
+
 void SortPuzzle::checkSolved() {
+	const int groupSize = _groupDivisor ? (int)_cols / (int)_groupDivisor : 0;
+
 	for (int r = 0; r < (int)_rows; ++r) {
-		for (int c = 0; c < (int)_cols; ++c) {
-			const Cell &cur = _current[r][c];
-			const Cell &sol = _solved[r][c];
-			if (cur.isEmpty || cur.srcRow != sol.srcRow ||
-			    cur.srcCol != sol.srcCol || cur.value != sol.value)
+		int c = 0;
+		while (c < (int)_cols) {
+			if (cellsMatch(_current[r][c], _solved[r][c])) {
+				++c;
+				continue;
+			}
+
+			// Puzzles that pair their cells up two at a time accept a pair sorted
+			// the other way around
+			if (!_allowSwappedPairs || groupSize != 2 || c >= (int)_cols - 1) {
+				return;
+			}
+
+			if (!cellsMatch(_current[r][c + 1], _solved[r][c]) ||
+					!cellsMatch(_current[r][c], _solved[r][c + 1])) {
+				return;
+			}
+
+			const Cell &first  = _current[r][c];
+			const Cell &second = _current[r][c + 1];
+			bool firstKeepsGroup  = first.isEmpty  || first.srcCol  == _solved[r][c].srcCol;
+			bool secondKeepsGroup = second.isEmpty || second.srcCol == _solved[r][c + 1].srcCol;
+
+			if (firstKeepsGroup && secondKeepsGroup) {
+				c += 2;
+			} else if (!_matchGroup || firstKeepsGroup) {
+				c += 1;
+			} else {
 				return;
+			}
 		}
 	}
+
 	_isSolved = true;
 	_subState = kPlayWinSound;
 }
@@ -475,10 +587,10 @@ void SortPuzzle::redraw() {
 			}
 		}
 		if (!drawn) {
-			Common::Rect src = cellSprite(_held);
+			Common::Rect src = g_nancy->getGameType() >= kGameTypeNancy12 ? heldSprite(_held) : cellSprite(_held);
 			if (!src.isEmpty()) {
-				int x = _heldDrawPos.x - _cellWidth  / 2;
-				int y = _heldDrawPos.y - _cellHeight / 2;
+				int x = _heldDrawPos.x - src.width()  / 2;
+				int y = _heldDrawPos.y - src.height() / 2;
 				_drawSurface.blitFrom(_boardImage, src, Common::Point(x, y));
 			}
 		}
diff --git a/engines/nancy/action/puzzle/sortpuzzle.h b/engines/nancy/action/puzzle/sortpuzzle.h
index 42ff9ebe781..db26f5536fa 100644
--- a/engines/nancy/action/puzzle/sortpuzzle.h
+++ b/engines/nancy/action/puzzle/sortpuzzle.h
@@ -63,8 +63,18 @@ protected:
 	static const int kMaxSourceRows = 8;
 	static const int kMaxSourceCols = 5;
 	static const int kNumCursors    = 10;
-	static const int kNumValueRects = 114; // Nancy 12: per-kind/size source sprites (19 kinds x 6 sizes)
-	static const int kSizesPerKind  = 6;   // Nancy 12: stride between kinds in _valueSrcRects
+
+	// Nancy 12 source sprite table: one sprite per (row, group, value) triple,
+	// followed by kSizesPerKind sprites used for the piece held at the cursor.
+	static const int kSizesPerKind   = 6;
+	static const int kGroupsPerRow   = 3;
+	static const int kSpriteRows     = 6;
+	static const int kNumBoardRects  = kSpriteRows * kGroupsPerRow * kSizesPerKind;
+	static const int kNumValueRects  = kNumBoardRects + kSizesPerKind;
+
+	// Nancy 12 preset board: a fixed-stride grid of values, -1 marking an empty cell
+	static const int kPresetStride = 10;
+	static const int16 kEmptyCell  = -1;
 
 	// File data
 
@@ -77,6 +87,16 @@ protected:
 	uint16 _groupDivisor = 1;
 	uint16 _valueRange   = 1;
 
+	// Which of a cell's three identifying fields the win check compares, and
+	// whether two neighboring cells may be sorted the other way around
+	bool _matchRow          = true;
+	bool _matchGroup        = true;
+	bool _matchValue        = true;
+	bool _allowSwappedPairs = false;
+
+	bool  _usePresetBoard = false;
+	int16 _presetBoard[kPresetStride * kPresetStride] = {};
+
 	Common::Rect _cellSrcRects[kMaxSourceRows][kMaxSourceCols];
 	Common::Rect _cursorSrcRects[kNumCursors];
 	Common::Array<Common::Rect> _valueSrcRects; // Nancy 12: indexed by gem value
@@ -87,6 +107,8 @@ protected:
 	uint16 _spacingY = 0;
 	int16  _cellWidth  = 0;
 	int16  _cellHeight = 0;
+	int16  _hotspotInsetX = 0;
+	int16  _hotspotInsetY = 0;
 
 	SoundDescription _pickupSound;
 	SoundDescription _dropSound;
@@ -127,8 +149,11 @@ protected:
 	void persistState();
 	void redraw();
 	void checkSolved();
+	bool cellsMatch(const Cell &cur, const Cell &sol) const;
 	Common::Rect cellRect(int row, int col) const;
+	Common::Rect cellHotspot(int row, int col) const;
 	Common::Rect cellSprite(const Cell &cell) const;
+	Common::Rect heldSprite(const Cell &cell) const;
 	bool hitTestCell(const Common::Point &p, int &outRow, int &outCol) const;
 };
 
diff --git a/engines/nancy/cursor.cpp b/engines/nancy/cursor.cpp
index 13e8d4fdaa4..d15bf7cfd31 100644
--- a/engines/nancy/cursor.cpp
+++ b/engines/nancy/cursor.cpp
@@ -184,8 +184,10 @@ uint CursorManager::resolveNancy10CursorID(CursorType type, int16 itemID, bool s
 	case kInvertedRotateRight:  return kNewInvertedRotateRight;
 	case kInvertedRotateLeft:   return kNewInvertedRotateLeft;
 	case kDragHand:             return kNewDragHand;
+	case kNewDragHandHotspot:   return kNewDragHandHotspot;
 	case kNewUseHand:           return kNewUseHand;
 	case kNewUseHandHotspot:    return kNewUseHandHotspot;
+	case kNewBlank:             return kNewBlank;
 	case kNewRotatePiece:       return kNewRotatePiece;
 	case kPuzzleArrow:          return kNewPuzzleArrow;
 	case kNewPuzzleSlideUp:     return kNewPuzzleSlideUp;
diff --git a/engines/nancy/cursor.h b/engines/nancy/cursor.h
index 429ffedd92b..1cb44f99d30 100644
--- a/engines/nancy/cursor.h
+++ b/engines/nancy/cursor.h
@@ -90,9 +90,10 @@ public:
 		kNewRotateLeft			= 30,	// Type 15 — 360 scenes
 		kNewInvertedRotateRight = 32,	// Type 16 — Inverted 360 rotation
 		kNewInvertedRotateLeft	= 34,	// Type 17 — Inverted 360 rotation
-		kNewUseHand				= 36,	// Type 18 — Hand used while using items, and while carrying a puzzle piece
-		kNewUseHandHotspot		= 37,	// Type 18 hotspot — Hand shown when hovering a piece that can be picked up
-		kNewDragHand			= 38,	// Type 19 — Hand used while dragging puzzle pieces (e.g. SortPuzzle pickup action sets this)
+		kNewUseHand				= 36,	// Type 18 — Open hand, used while using items and while carrying a puzzle piece
+		kNewUseHandHotspot		= 37,	// Type 18 hotspot — Open hand shown over a slot a carried piece can be dropped into
+		kNewDragHand			= 38,	// Type 19 — Closed hand, used while dragging puzzle pieces
+		kNewDragHandHotspot		= 39,	// Type 19 hotspot — Closed hand shown over a piece that can be picked up
 		kNewRotatePiece			= 40,	// Type 20 — Rotate arrows shown over a rotatable puzzle piece
 		kNewDialCW				= 41,	// Type 20 hotspot — Dial turn cursors, used by SafeDialPuzzle
 		kNewDialCCW				= 43,	// Type 21 hotspot
@@ -102,6 +103,7 @@ public:
 		kNewPuzzleSlideLeft		= 51,	// Type 25 hotspot
 		kNewPuzzleSlideRight	= 53,	// Type 26 hotspot
 		kNewDropHand			= 64,	// Type 32 — Hand shown when a held piece is dropped (briefly set on the drop action)
+		kNewBlank				= 72,	// Type 36 — Empty sprite, used while a puzzle draws a carried piece at the mouse itself
 
 		// Cursor types in Nancy13 and newer games. Nancy13 rebuilt the CURS
 		// sheet with 45 system cursor types, each stored as an [idle, hotspot]


Commit: 1c8420f6377b8f639d0e46124e40e73ae4ce89b7
    https://github.com/scummvm/scummvm/commit/1c8420f6377b8f639d0e46124e40e73ae4ce89b7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:28+03:00

Commit Message:
NANCY: NANCY12: Implement new functionality for OneBuildPuzzle

Fixes issues in Zippy's nuts'n'bolts puzzle

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


diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.cpp b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
index 13a56f960d9..d6888327e23 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -62,6 +62,22 @@ void OneBuildPuzzle::init() {
 		_finalAnimOverlay.setVisible(false);
 	}
 
+	// A Nancy 12 puzzle can put more pieces on screen than it describes: every
+	// piece past the described ones is a copy of a randomly picked description,
+	// and always starts scattered (see scatterPiece()).
+	if (!_pieces.empty() && _pieces.size() < _totalPieces) {
+		uint numDescribed = _pieces.size();
+		_pieces.resize(_totalPieces);
+
+		for (uint i = numDescribed; i < _totalPieces; ++i) {
+			Piece &p = _pieces[i];
+			const Piece &copied = _pieces[g_nancy->_randomSource->getRandomNumber(numDescribed - 1)];
+			p.srcRect = copied.srcRect;
+			p.altSrcRect = copied.altSrcRect;
+			p.slotRect = copied.slotRect;
+		}
+	}
+
 	for (uint i = 0; i < _pieces.size(); ++i) {
 		Piece &p = _pieces[i];
 		int w = p.srcRect.width();
@@ -114,12 +130,15 @@ void OneBuildPuzzle::init() {
 				p.gameRect = p.homeRect;
 		}
 
-		updatePieceRender(i);
 		p.setVisible(true);
 		p.setTransparent(true);
 		p.setZ(_z + (uint16)i + 1);
+		updatePieceRender(i);
 	}
 
+	if (_countMode != kCountAllPieces)
+		updateCounter();
+
 	_isInitialized = true;
 }
 
@@ -132,6 +151,9 @@ void OneBuildPuzzle::registerGraphics() {
 
 	if (_hasFinalAnim)
 		_finalAnimOverlay.registerGraphics();
+
+	if (_countMode != kCountAllPieces)
+		_counterDisplay.registerGraphics();
 }
 
 // Nancy12 (AR 166) reworked OneBuildPuzzle onto the shared PuzzleBase loader:
@@ -143,19 +165,39 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);       // 0x00
 	_freePlacement = stream.readByte();     // 0x21
 	_canRotateAll = stream.readByte();      // 0x22
-	stream.skip(6);                         // 0x23: rotation/zone config + placement-mode byte
+	stream.skip(6);                         // 0x23: rotation/zone config
 	_slotTolerance = stream.readSint16LE(); // 0x29
 
-	// 0x2b..0xe9: placement-mode byte, final-animation centering rect and filler
-	// count. None are needed by this port.
-	stream.skip(0xea - 0x2b);
+	_placementMode = (PlacementMode)stream.readByte(); // 0x2b
+	_countMode = (CountMode)stream.readByte();         // 0x2c
+	stream.skip(1);                                    // 0x2d: percentage flag
+
+	for (uint i = 0; i < kNumDigits; ++i)   // 0x2e: counter digit sprites
+		readRect(stream, _digitSrcRects[i]);
+
+	_counterPos.x = (int16)stream.readSint32LE(); // 0xce
+	_counterPos.y = (int16)stream.readSint32LE(); // 0xd2
+	_counterSpacing = stream.readSint16LE();      // 0xd6
+
+	stream.skip(0xe8 - 0xd8);                   // 0xd8: final-animation centering rect
+	_requiredPieces = stream.readSint16LE();    // 0xe8
 
 	// 0xea: home-scatter zone. Pieces whose stored home rect is empty are
 	// scattered to a random spot inside this rect at init (see scatterPiece()).
 	readRect(stream, _scatterZone);         // 0xea..0xf9
 
-	// 0xfa..0x11f: misc config, unused by this port.
-	stream.skip(0x120 - 0xfa);
+	readRect(stream, _placementZone);       // 0xfa: a piece may only be released in here
+	readRect(stream, _exitHotspot);         // 0x10a
+
+	// Nancy 13 reuses this record type with a rearranged header, so the bytes
+	// read above aren't rects there. Drop one that can't be a hotspot instead of
+	// handing it to the viewport, which clips (and asserts on) what it is given.
+	if (!_exitHotspot.isValidRect())
+		_exitHotspot = Common::Rect();
+
+	_pieceCursorType = stream.readSint16LE();     // 0x11a
+	_heldPieceCursorType = stream.readSint16LE(); // 0x11c
+	stream.skip(2);                               // 0x11e: exit cursor, always _puzzleExitCursor
 
 	readFilename(stream, _extraSoundName);  // 0x120: final-animation atlas image
 	readRect(stream, _animRectA);           // 0x141
@@ -204,7 +246,7 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	_badTexts.resize(3);
 
 	// --- Piece array (variable count) ---
-	stream.readSint16LE(); // Secondary piece count (matches numPieces in practice)
+	_totalPieces = stream.readUint16LE();
 	_numPieces = stream.readUint16LE();
 
 	_pieces.resize(_numPieces);
@@ -253,6 +295,7 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);
 
 	_numPieces = stream.readUint16LE();
+	_totalPieces = _numPieces;
 	_freePlacement = stream.readByte();
 	_canRotateAll = stream.readByte();
 	stream.skip(6); // rotationMode, zoneHeight, zoneWidth, mouse-clamping flag
@@ -454,7 +497,7 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 		// The held fork shows the hotspot hand cursor while over the placement
 		// region, and the plain magnifying glass everywhere else.
 		if (_placementZone.isEmpty() || _placementZone.contains(mouseVP))
-			setPieceCursor();
+			setPieceCursor(true);
 		else
 			g_nancy->_cursor->setCursorType(CursorManager::kNormal);
 
@@ -509,7 +552,19 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 						++_piecesPlaced;
 			} else {
 				_correctlyPlaced = false;
-				if (!_freePlacement) {
+
+				// In counter mode a slot swallows whatever is dropped into it, so
+				// landing in one that isn't the piece's own costs a mistake. Once
+				// there are more mistakes than the puzzle allows it is lost.
+				if (_placementMode == kPlacementCounter && findSlotAt(piece.gameRect) != -1) {
+					piece.placed = true;
+					++_mistakes;
+
+					if (_mistakes > _totalPieces - _requiredPieces) {
+						_isCancelled = true;
+						_state = kActionTrigger;
+					}
+				} else if (!_freePlacement) {
 					piece.gameRect = _prevDragGameRect;
 				} else {
 					piece.curRotation = piece.defaultRotation;
@@ -517,6 +572,8 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 				}
 			}
 
+			updateCounter();
+
 			// Re-arm at-home art when the piece lands back on homeRect
 			if (!piece.altSurface.empty() && !piece.placed &&
 					piece.gameRect == piece.homeRect) {
@@ -552,6 +609,13 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 		Piece &p = _pieces[i];
 		if (!p.gameRect.contains(mouseVP))
 			continue;
+
+		// A piece that has dropped into its slot in counter mode is gone for
+		// good, so it doesn't react to the cursor any more. Everywhere else a
+		// placed piece can still be picked back up.
+		if (p.placed && _placementMode == kPlacementCounter)
+			continue;
+
 		if (topmostAny == -1 || p.getZOrder() > _pieces[topmostAny].getZOrder())
 			topmostAny = (int16)i;
 		if (!p.placed) {
@@ -623,16 +687,30 @@ void OneBuildPuzzle::readPlacementTexts(Common::SeekableReadStream &stream, Comm
 	}
 }
 
-void OneBuildPuzzle::setPieceCursor() {
-	if (g_nancy->getGameType() >= kGameTypeNancy10)
+void OneBuildPuzzle::setPieceCursor(bool isHeld) {
+	if (g_nancy->getGameType() >= kGameTypeNancy10) {
+		// Nancy 12 carries a second cursor for a piece that's on the cursor;
+		// the older games use the same one for hovering and carrying.
+		int16 cursorType = (isHeld && _heldPieceCursorType != 0) ? _heldPieceCursorType : _pieceCursorType;
+
 		// The piece hand uses the hotspot variant (blue hand with an outline).
-		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_pieceCursorType, true, true);
-	else
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, true);
+	} else {
 		g_nancy->_cursor->setCursorType(CursorManager::kCustom1);
+	}
 }
 
 void OneBuildPuzzle::updatePieceRender(int pieceIdx) {
 	Piece &p = _pieces[pieceIdx];
+
+	// In counter mode the slot rect is a container the piece is dropped into
+	// (a drawer, in the Nancy 12 nuts and bolts puzzle) and is much larger than
+	// the piece itself, so a placed piece is hidden instead of drawn in it.
+	if (p.placed && _placementMode == kPlacementCounter) {
+		p.setVisible(false);
+		return;
+	}
+
 	if (p.useAltSurface && !p.altSurface.empty()) {
 		p._drawSurface.create(p.altSurface, p.altSurface.getBounds());
 	} else {
@@ -745,20 +823,94 @@ void OneBuildPuzzle::scatterPiece(Piece &p) {
 	int top  = zone.top  + (int)g_nancy->_randomSource->getRandomNumber(MAX(0, maxTop - zone.top));
 
 	p.gameRect = Common::Rect((int16)left, (int16)top, (int16)(left + w), (int16)(top + h));
+
+	// The scattered spot becomes the piece's home, so a piece dropped away from
+	// its slot returns there instead of to the empty rect it was loaded with.
+	p.homeRect = p.gameRect;
 }
 
-void OneBuildPuzzle::checkAllPlaced() {
+int16 OneBuildPuzzle::findSlotAt(const Common::Rect &rect) const {
 	for (uint i = 0; i < _pieces.size(); ++i) {
-		if (_pieces[i].placed)
-			continue;
-
-		// Nancy 10: pieces with an empty slotRect (top == 0 && bottom == 0)
-		// are filler — they don't need to be placed for the puzzle to solve.
 		const Common::Rect &slot = _pieces[i].slotRect;
-		if (slot.top == 0 && slot.bottom == 0)
+		if (slot.isEmpty())
 			continue;
 
+		if (rect.left >= slot.left - _slotTolerance && rect.top >= slot.top - _slotTolerance &&
+				rect.right <= slot.right + _slotTolerance && rect.bottom <= slot.bottom + _slotTolerance)
+			return (int16)i;
+	}
+
+	return -1;
+}
+
+void OneBuildPuzzle::updateCounter() {
+	if (_countMode == kCountAllPieces)
 		return;
+
+	uint16 value;
+	if (_countMode == kCountPlacements)
+		value = _piecesPlaced;
+	else if (_placementMode == kPlacementCounter)
+		value = _mistakes;
+	else
+		value = _totalPieces - _piecesPlaced;
+
+	Common::String digits = Common::String::format("%u", (uint)value);
+
+	int width = 0;
+	int height = 0;
+	for (uint i = 0; i < digits.size(); ++i) {
+		const Common::Rect &digit = _digitSrcRects[digits[i] - '0'];
+		width += digit.width() + (i ? _counterSpacing : 0);
+		height = MAX<int>(height, digit.height());
+	}
+
+	if (width == 0 || height == 0)
+		return;
+
+	_counterDisplay._drawSurface.create(width, height, _image.format);
+	_counterDisplay.setTransparent(true);
+
+	// Clear to the transparent color first so the gaps between the digits stay
+	// see-through.
+	_counterDisplay._drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	int destX = 0;
+	for (uint i = 0; i < digits.size(); ++i) {
+		const Common::Rect &digit = _digitSrcRects[digits[i] - '0'];
+		_counterDisplay._drawSurface.blitFrom(_image, digit, Common::Point(destX, 0));
+		destX += digit.width() + _counterSpacing;
+	}
+
+	Common::Rect dest(_counterPos.x, _counterPos.y, _counterPos.x + width, _counterPos.y + height);
+	const VIEW *viewData = GetEngineData(VIEW);
+	if (viewData)
+		dest.translate(viewData->screenPosition.left, viewData->screenPosition.top);
+
+	_counterDisplay.moveTo(dest);
+	_counterDisplay.setVisible(true);
+	_counterDisplay.setNeedsRedraw(true);
+}
+
+void OneBuildPuzzle::checkAllPlaced() {
+	if (_countMode != kCountAllPieces) {
+		// Counter puzzles end as soon as enough pieces have gone into the right
+		// slot, even when a few are still lying around.
+		if (_piecesPlaced < _requiredPieces)
+			return;
+	} else {
+		for (uint i = 0; i < _pieces.size(); ++i) {
+			if (_pieces[i].placed)
+				continue;
+
+			// Nancy 10: pieces with an empty slotRect (top == 0 && bottom == 0)
+			// are filler — they don't need to be placed for the puzzle to solve.
+			const Common::Rect &slot = _pieces[i].slotRect;
+			if (slot.top == 0 && slot.bottom == 0)
+				continue;
+
+			return;
+		}
 	}
 
 	_isSolved = true;
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.h b/engines/nancy/action/puzzle/onebuildpuzzle.h
index 8e8e6b9e31a..592065d6485 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.h
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.h
@@ -35,7 +35,7 @@ namespace Action {
 // Otherwise it returns to its previous position (or home in free placement mode).
 class OneBuildPuzzle : public RenderActionRecord {
 public:
-	OneBuildPuzzle() : RenderActionRecord(7), _finalAnimOverlay(99) {}
+	OneBuildPuzzle() : RenderActionRecord(7), _finalAnimOverlay(99), _counterDisplay(99) {}
 	virtual ~OneBuildPuzzle() {}
 
 	void init() override;
@@ -56,6 +56,21 @@ protected:
 	// pre-placed flag it used to be.
 	static const uint8 kPrePlacedRotation = 10;
 
+	enum PlacementMode {
+		kPlacementNormal = 1,	// A placed piece stays on screen, drawn in its slot
+		kPlacementCounter = 2	// A placed piece drops out of sight into its slot
+	};
+
+	// What the puzzle counts, both to decide when it is finished and to fill in
+	// the on-screen counter.
+	enum CountMode {
+		kCountAllPieces = 0,	// No counter; the puzzle ends once every piece is in its slot
+		kCountPlacements = 1,	// Correct placements
+		kCountMistakes = 2		// Mistakes, or pieces left to place outside counter placement
+	};
+
+	static const uint kNumDigits = 10;
+
 	struct Piece : RenderObject {
 		Piece() : RenderObject(0) {}
 
@@ -90,13 +105,25 @@ protected:
 	// --- File data ---
 
 	Common::Path _imageName;
-	uint16 _numPieces = 0;
+	uint16 _numPieces = 0;         // Number of piece descriptions in the puzzle data
+	uint16 _totalPieces = 0;       // Number of pieces on screen; see init() for the extra ones
 	bool _freePlacement = false;   // Wrong drop restores to previous position, not home
 	bool _canRotateAll = false;    // All pieces can be rotated
 	int16 _slotTolerance = 0;      // Proximity for snapping to slot
 	bool _orderedPlacement = false; // Pieces must be placed in a specific order
+	PlacementMode _placementMode = kPlacementNormal; // Nancy 12 only, earlier games are always normal
 	Common::Array<int16> _placementOrder; // 1-indexed piece IDs in required placement order
 
+	// Counter puzzles (Nancy 12): the puzzle is solved once _requiredPieces have
+	// been placed correctly, and lost once the remaining pieces have all been
+	// dropped in the wrong slot. The running count is drawn from digit sprites
+	// found in the puzzle image.
+	CountMode _countMode = kCountAllPieces;
+	int16 _requiredPieces = 0;
+	Common::Rect _digitSrcRects[kNumDigits];
+	Common::Point _counterPos;
+	int16 _counterSpacing = 0;
+
 	// Stacking order of the pieces that start out already placed, 1-indexed.
 	// TODO: not applied yet; pre-placed pieces keep their array order.
 	Common::Array<int16> _preplacedZOrder;
@@ -106,8 +133,10 @@ protected:
 	// Filename only (no SoundDescription metadata).
 	Common::String _extraSoundName;
 
-	// Cursor type shown while hovering or carrying a piece (Nancy 10+).
+	// Cursor type shown while hovering a piece (Nancy 10+), and the one shown
+	// while carrying it (Nancy 12+; the older games reuse the hover cursor).
 	int16 _pieceCursorType = 0;
+	int16 _heldPieceCursorType = 0;
 
 	// Post-placement sprite-sheet animation. _animRectA is the on-screen
 	// rect where the animation plays AND the click hotspot the user must
@@ -184,6 +213,7 @@ protected:
 	bool _isDropSound = false;       // True if last sound played was a drop sound
 	bool _correctlyPlaced = false;   // True if the last drop was correctly placed
 	uint16 _piecesPlaced = 0;    // Number of pieces correctly placed so far
+	uint16 _mistakes = 0;        // Number of pieces dropped in the wrong slot
 	uint32 _timerEnd = 0;        // Millisecond timestamp when the current timer expires
 
 	bool _finalAnimDone = false;
@@ -194,6 +224,8 @@ protected:
 	int16 _animFrameCounter = 0;         // 0..framesPerStep-1, the X index within the current row.
 	int16 _animRowCounter = 0;           // 0..totalRows-1, how many cycles have completed.
 
+	RenderObject _counterDisplay;        // Digit sprites showing the running count.
+
 	// Previous drag position (for freePlacement restore on wrong drop)
 	Common::Rect _prevDragGameRect;
 
@@ -213,7 +245,7 @@ protected:
 	// Read a good/bad caption block: three AUTOTEXT keys then three inline
 	// texts; each caption uses its key if known, else the inline text.
 	void readPlacementTexts(Common::SeekableReadStream &stream, Common::Array<Common::String> &out);
-	void setPieceCursor();
+	void setPieceCursor(bool isHeld = false);
 
 	void playPickupSound();
 	void playRotateSoundAndStartTimer();
@@ -223,6 +255,10 @@ protected:
 	void checkAllPlaced();
 	// Place a piece at a random spot inside _scatterZone (Nancy12 empty-home pieces)
 	void scatterPiece(Piece &p);
+	// Index of the first slot the given rect fits inside, or -1 if it fits none
+	int16 findSlotAt(const Common::Rect &rect) const;
+	// Redraw the counter with the value the puzzle's count mode calls for
+	void updateCounter();
 	void rotatePiece(int pieceIdx);
 	void updateDragPosition(Common::Point mouseVP);
 	// Update the render object for a piece (set _drawSurface and moveTo gameRect)


Commit: 5504142fbd41d8c9d2496270261d1d36d6b2d382
    https://github.com/scummvm/scummvm/commit/5504142fbd41d8c9d2496270261d1d36d6b2d382
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:29+03:00

Commit Message:
NANCY: NANCY12: Show correct subtitles for overriden "I can't do that"

Fixes subtitles in the tire changing scene

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 408df240032..83b231d2d15 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -529,26 +529,28 @@ void Scene::installInventorySoundOverride(byte command, const SoundDescription &
 // Nancy9 and newer no longer store the "can't" caption alongside the sound.
 // Instead, the caption is looked up in the CVTX text chunks by the played
 // sound's name: the narration/observations (AUTOTEXT) chunk is searched first,
-// then the conversation (CONVO) chunk.
-static Common::String getSoundSubtitle(const Common::String &soundName) {
-	if (soundName.empty() || soundName.equalsIgnoreCase("NO SOUND")) {
-		return Common::String();
-	}
-
-	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
-	if (autotext) {
-		Common::String text = autotext->texts.getValOrDefault(soundName, "");
-		if (!text.empty()) {
-			return text;
+// then the conversation (CONVO) chunk. Games that predate the change have no
+// text chunks, so they always end up with the caption stored in the data.
+static Common::String getSoundSubtitle(const Common::String &soundName, const Common::String &fallback) {
+	if (!soundName.empty() && !soundName.equalsIgnoreCase("NO SOUND")) {
+		const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+		if (autotext) {
+			Common::String text = autotext->texts.getValOrDefault(soundName, "");
+			if (!text.empty()) {
+				return text;
+			}
 		}
-	}
 
-	const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
-	if (convo) {
-		return convo->texts.getValOrDefault(soundName, "");
+		const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
+		if (convo) {
+			Common::String text = convo->texts.getValOrDefault(soundName, "");
+			if (!text.empty()) {
+				return text;
+			}
+		}
 	}
 
-	return Common::String();
+	return fallback;
 }
 
 void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
@@ -575,7 +577,8 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 			g_nancy->_sound->playSound(inventoryData->cantSound);
 
 			if (ConfMan.getBool("subtitles")) {
-				_textbox.addTextLine(inventoryData->cantText, inventoryData->captionAutoClearTime);
+				_textbox.addTextLine(getSoundSubtitle(inventoryData->cantSound.name, inventoryData->cantText),
+					inventoryData->captionAutoClearTime);
 			}
 		} else {
 			// TVD and nancy1 contain no sound data in INV, and have no captions
@@ -591,7 +594,10 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 				g_nancy->_sound->playSound(override.sound);
 
 				if (ConfMan.getBool("subtitles")) {
-					_textbox.addTextLine(override.caption, inventoryData->captionAutoClearTime);
+					// The caption is looked up in the CVTX text chunks by the
+					// override's sound name; the stored caption is the fallback
+					_textbox.addTextLine(getSoundSubtitle(override.sound.name, override.caption),
+						inventoryData->captionAutoClearTime);
 				}
 				return;
 			} else {
@@ -611,12 +617,15 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 					g_nancy->_sound->playSound(inventoryData->cantSound);
 
 					if (ConfMan.getBool("subtitles")) {
-						_textbox.addTextLine(inventoryData->cantText, inventoryData->captionAutoClearTime);
+						_textbox.addTextLine(getSoundSubtitle(inventoryData->cantSound.name, inventoryData->cantText),
+							inventoryData->captionAutoClearTime);
 					}
 				} else {
 					// Should be unreachable
 					g_nancy->_sound->playSound("CANT");
 				}
+
+				return;
 			}
 		}
 
@@ -650,10 +659,7 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 
 				// The caption is looked up in the CVTX text chunks by the sound's
 				// name; the item's own caption field is only a fallback
-				cantText = getSoundSubtitle(cantSound.name);
-				if (cantText.empty()) {
-					cantText = item.cantTexts[soundIndex];
-				}
+				cantText = getSoundSubtitle(cantSound.name, item.cantTexts[soundIndex]);
 			}
 
 			g_nancy->_sound->loadSound(cantSound);
@@ -668,7 +674,8 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 			g_nancy->_sound->playSound(inventoryData->cantSound);
 
 			if (ConfMan.getBool("subtitles")) {
-				_textbox.addTextLine(inventoryData->cantText, inventoryData->captionAutoClearTime);
+				_textbox.addTextLine(getSoundSubtitle(inventoryData->cantSound.name, inventoryData->cantText),
+					inventoryData->captionAutoClearTime);
 			}
 		} else {
 			// TVD and nancy1 contain no sound data in INV, and have no captions


Commit: 07d7b5fb668b7cb877ed48e6a9881e258971a3fc
    https://github.com/scummvm/scummvm/commit/07d7b5fb668b7cb877ed48e6a9881e258971a3fc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:31+03:00

Commit Message:
NANCY: NANCY12: Fix jitter of last placed piece in SortPuzzle

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


diff --git a/engines/nancy/action/puzzle/sortpuzzle.cpp b/engines/nancy/action/puzzle/sortpuzzle.cpp
index e5cfcff41fb..ee8e98724bb 100644
--- a/engines/nancy/action/puzzle/sortpuzzle.cpp
+++ b/engines/nancy/action/puzzle/sortpuzzle.cpp
@@ -470,6 +470,10 @@ void SortPuzzle::handleInput(NancyInput &input) {
 	if (!_hasHeld) {
 		_held = _current[row][col];
 		_hasHeld = true;
+		// Anchor the piece to the mouse right away; the move check above only runs
+		// once something is already held, so it would otherwise be drawn for one
+		// frame at the spot where the previous piece was dropped
+		_heldDrawPos = mouseVP;
 		_current[row][col].isEmpty = true;
 		if (_pickupSound.name != "NO SOUND") {
 			g_nancy->_sound->loadSound(_pickupSound);


Commit: 6c3705875aa3fa9879fc210c7f6349cfbb293ba0
    https://github.com/scummvm/scummvm/commit/6c3705875aa3fa9879fc210c7f6349cfbb293ba0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:32+03:00

Commit Message:
NANCY: NANCY12: Fix flag triggering in SewingMachinePuzzle

Also, add a cheat, Ctrl-Shift-C, to forgive any mistakes made while
sewing.

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


diff --git a/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp b/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
index 82b0e26f389..b348c02b413 100644
--- a/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
+++ b/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
@@ -170,11 +170,11 @@ void SewingMachinePuzzle::feedCloth(const Common::Point &delta) {
 	drawCloth();
 	checkSeam();
 
-	// Reaching any bottom trigger finishes the seam.
+	// Reaching a bottom trigger whose mistake-flag gate is satisfied finishes the seam.
 	if (!_solved) {
 		Common::Point end = needleInStrip();
 		for (uint i = 0; i < _triggerZones.size(); ++i) {
-			if (_zones[_triggerZones[i]].rect.contains(end)) {
+			if (triggerFires(_zones[_triggerZones[i]], end)) {
 				_solved = true;
 				_state = kActionTrigger;
 				break;
@@ -183,6 +183,17 @@ void SewingMachinePuzzle::feedCloth(const Common::Point &delta) {
 	}
 }
 
+bool SewingMachinePuzzle::triggerFires(const ActionZone &z, const Common::Point &needle) const {
+	if (!z.rect.contains(needle)) {
+		return false;
+	}
+
+	// Both triggers gate on the mistake flag (val49): the narrow zone fires only on a
+	// clean run (flag clear, val4b 0 -> EV_Solved_Dress), the wide zone only when a
+	// mistake was made (flag set, val4b 1 -> EV_Tried_Dress), so they are exclusive.
+	return z.val49 == kFlagNoLabel || NancySceneState.getEventFlag(z.val49, z.val4b);
+}
+
 void SewingMachinePuzzle::checkSeam() {
 	if (!_hasSeamMask || _collisionZone < 0) {
 		return;
@@ -288,14 +299,14 @@ void SewingMachinePuzzle::execute() {
 	case kRun:
 		break;
 	case kActionTrigger: {
-		// Raise a flag for every bottom trigger the needle finished inside: the wide
-		// zone marks the seam attempted, the narrow (centered) zone marks it solved.
+		// Raise the flag for whichever bottom trigger fires: a clean run finishes in the
+		// narrow zone (EV_Solved_Dress), a mistaken one in the wide zone (EV_Tried_Dress).
 		// They share the win scene, so cross-dissolve to it once.
 		Common::Point end = needleInStrip();
 		const ActionZone *sceneZone = nullptr;
 		for (uint i = 0; i < _triggerZones.size(); ++i) {
 			const ActionZone &tz = _zones[_triggerZones[i]];
-			if (!tz.rect.contains(end)) {
+			if (!triggerFires(tz, end)) {
 				continue;
 			}
 			if (tz.tailId != -1) {
@@ -325,6 +336,19 @@ void SewingMachinePuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// Cheat: Ctrl+Shift+C forgives any mistakes, clearing the collision flag so a
+	// finished seam still counts as a clean solve. The off-seam latch is left as-is so
+	// clearing while still off the line doesn't instantly re-flag; the next fresh stray
+	// does.
+	for (uint i = 0; i < input.otherKbdInput.size(); ++i) {
+		const Common::KeyState &key = input.otherKbdInput[i];
+		if ((key.flags & Common::KBD_CTRL) && (key.flags & Common::KBD_SHIFT) &&
+				key.keycode == Common::KEYCODE_c && _collisionZone >= 0) {
+			NancySceneState.setEventFlag(_zones[_collisionZone].tailId, g_nancy->_false);
+			debug("Sewing cheat: mistakes cleared");
+		}
+	}
+
 	g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
 
 	if (input.input & NancyInput::kLeftMouseButtonDown) {
diff --git a/engines/nancy/action/puzzle/sewingmachinepuzzle.h b/engines/nancy/action/puzzle/sewingmachinepuzzle.h
index 7fb43e7807a..6885674267c 100644
--- a/engines/nancy/action/puzzle/sewingmachinepuzzle.h
+++ b/engines/nancy/action/puzzle/sewingmachinepuzzle.h
@@ -64,6 +64,9 @@ protected:
 	// Advances the sewing by a drag delta, marking the puzzle solved once the whole
 	// seam has been fed through.
 	void feedCloth(const Common::Point &delta);
+	// Whether a bottom trigger fires: the needle is inside it and its mistake-flag gate
+	// matches (narrow = clean run, wide = a mistake was made).
+	bool triggerFires(const ActionZone &z, const Common::Point &needle) const;
 
 	// Tests the needle against the seam mask; leaving the marked corridor plays a
 	// mistake line and sets the zone's flag (edge-triggered, once per excursion).


Commit: c0477955d0f355951fb6765d13091b6b92326806
    https://github.com/scummvm/scummvm/commit/c0477955d0f355951fb6765d13091b6b92326806
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:34+03:00

Commit Message:
NANCY: NANCY12: Set cursor from puzzle data in OrderingPuzzle

Fixes cursor in the diary puzzle

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


diff --git a/engines/nancy/action/puzzle/orderingpuzzle.cpp b/engines/nancy/action/puzzle/orderingpuzzle.cpp
index d130dd186d5..96e094c7425 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/orderingpuzzle.cpp
@@ -247,7 +247,9 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
 				// codes (the win still uses the sequence read above), then three button
 				// rect arrays - sprite source rects, on-screen dest positions, and tighter
 				// per-button hotspots. The dest rects double as the hotspots here.
-				stream.skip(0x309 - 0x10c); // grid + per-stage codes (not modeled)
+				_buttonCursorID = stream.readUint16LE();
+				_exitCursorID = stream.readUint16LE();
+				stream.skip(0x309 - 0x110); // grid + per-stage codes (not modeled)
 				readRectArray(stream, _down1Rects, numElements, 30); // button sprite source rects
 				readRectArray(stream, _destRects, numElements, 30);  // on-screen button positions
 				stream.skip(30 * 16);       // tighter per-button hotspots (we use _destRects)
@@ -713,7 +715,7 @@ void OrderingPuzzle::handleInput(NancyInput &input) {
 	}
 
 	if (NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
-		g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
+		setHoverCursor(_exitCursorID, g_nancy->_cursor->_puzzleExitCursor);
 
 		if (canClick && input.input & NancyInput::kLeftMouseButtonUp) {
 			_state = kActionTrigger;
@@ -722,7 +724,7 @@ void OrderingPuzzle::handleInput(NancyInput &input) {
 	}
 
 	if (_needButtonToCheckSuccess && NancySceneState.getViewport().convertViewportToScreen(_checkButtonDest).contains(input.mousePos)) {
-		g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+		setHoverCursor(_buttonCursorID, CursorManager::kHotspot);
 
 		if (canClick && input.input & NancyInput::kLeftMouseButtonUp) {
 			_checkButtonPressed = true;
@@ -744,7 +746,7 @@ void OrderingPuzzle::handleInput(NancyInput &input) {
 			} else if (NancySceneState.getViewport().convertViewportToScreen(_specialCursor2Dest).contains(input.mousePos)) {
 				g_nancy->_cursor->setCursorType((CursorManager::CursorType)_specialCursor2Id, true);
 			} else {
-				g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+				setHoverCursor(_buttonCursorID, CursorManager::kHotspot);
 			}
 
 			if (canClick && input.input & NancyInput::kLeftMouseButtonUp) {
@@ -818,6 +820,14 @@ Common::String OrderingPuzzle::getRecordTypeName() const {
 	}
 }
 
+void OrderingPuzzle::setHoverCursor(int16 cursorID, CursorManager::CursorType defaultCursor) {
+	if (cursorID >= 0) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorID, true);
+	} else {
+		g_nancy->_cursor->setCursorType(defaultCursor);
+	}
+}
+
 void OrderingPuzzle::pushDown(uint id) {
 	if (g_nancy->getGameType() == kGameTypeVampire) {
 		g_nancy->_sound->playSound("BUOK");
diff --git a/engines/nancy/action/puzzle/orderingpuzzle.h b/engines/nancy/action/puzzle/orderingpuzzle.h
index c4308327a07..1c1e957f303 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.h
+++ b/engines/nancy/action/puzzle/orderingpuzzle.h
@@ -54,6 +54,7 @@ public:
 protected:
 	Common::String getRecordTypeName() const override;
 
+	void setHoverCursor(int16 cursorID, CursorManager::CursorType defaultCursor);
 	void pushDown(uint id);
 	void setToSecondState(uint id);
 	void popUp(uint id);
@@ -102,6 +103,12 @@ protected:
 	SceneChangeWithFlag _deathScene;
 	bool _stageDeath = false;
 
+	// Nancy 12 keypads pick their hover cursors from the action record data.
+	// Both are raw Nancy 10+ system cursor types; -1 means the puzzle uses the
+	// engine defaults instead.
+	int16 _buttonCursorID = -1;
+	int16 _exitCursorID = -1;
+
 	uint16 _specialCursor1Id = CursorManager::kHotspot;
 	Common::Rect _specialCursor1Dest;
 	uint16 _specialCursor2Id = CursorManager::kHotspot;


Commit: d9e5cd4e2da059f7f8fede6a6fdd8367a233327e
    https://github.com/scummvm/scummvm/commit/d9e5cd4e2da059f7f8fede6a6fdd8367a233327e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:35+03:00

Commit Message:
NANCY: NANCY10: Add correct draw check for the checkbox

Fixes subtitles not being displayed in the Nancy12 ham radio puzzle

Changed paths:
    engines/nancy/state/scene.cpp
    engines/nancy/ui/textbox.cpp
    engines/nancy/ui/textbox.h


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 83b231d2d15..e36efabcc9a 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1875,7 +1875,7 @@ void Scene::clearSceneData(bool nextIsNoArt) {
 		_lightning->endLightning();
 	}
 
-	if (_textbox.hasBeenDrawn() || g_nancy->getGameType() >= kGameTypeNancy10) {
+	if (_textbox.hasBeenDrawn()) {
 		// Improvement: the dog portrait scenes in nancy7 queue a piece of text,
 		// then immediately change the scene. This makes the text disappear instantly;
 		// instead, we check if the textbox has been drawn, and don't clear it if it hasn't.
diff --git a/engines/nancy/ui/textbox.cpp b/engines/nancy/ui/textbox.cpp
index d86f359fa13..ca2eda74480 100644
--- a/engines/nancy/ui/textbox.cpp
+++ b/engines/nancy/ui/textbox.cpp
@@ -217,6 +217,13 @@ void Textbox::clear() {
 	}
 }
 
+bool Textbox::hasBeenDrawn() const {
+	if (_scrollTextBox)
+		return !_scrollTextBox->needsRedraw();
+
+	return Misc::HypertextParser::hasBeenDrawn();
+}
+
 void Textbox::addTextLine(const Common::String &text, uint32 autoClearTime) {
 	// WORKAROUND: Don't draw debug strings in the textbox. Refer to bug
 	// #16745 for a case in Nancy9, scene 2579 (after making a sandwich).
diff --git a/engines/nancy/ui/textbox.h b/engines/nancy/ui/textbox.h
index 83894ba5b02..f1284a097d6 100644
--- a/engines/nancy/ui/textbox.h
+++ b/engines/nancy/ui/textbox.h
@@ -49,6 +49,8 @@ public:
 	void drawTextbox();
 	void clear() override;
 
+	bool hasBeenDrawn() const;
+
 	void addTextLine(const Common::String &text, uint32 autoClearTime = 0);
 	void setOverrideFont(const uint fontID);
 


Commit: 6aa8fa9b6f9e5a7ae670cc355af1b727ff75c995
    https://github.com/scummvm/scummvm/commit/6aa8fa9b6f9e5a7ae670cc355af1b727ff75c995
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:37+03:00

Commit Message:
NANCY: NANCY12: Implement new solve state handling in TwoDialPuzzle

Fixes handling of the ham radio puzzle

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


diff --git a/engines/nancy/action/puzzle/twodialpuzzle.cpp b/engines/nancy/action/puzzle/twodialpuzzle.cpp
index 79b8668ee0b..b1746447623 100644
--- a/engines/nancy/action/puzzle/twodialpuzzle.cpp
+++ b/engines/nancy/action/puzzle/twodialpuzzle.cpp
@@ -133,28 +133,16 @@ void TwoDialPuzzle::execute() {
 		_state = kRun;
 		// fall through
 	case kRun:
+		if (g_nancy->getGameType() >= kGameTypeNancy12) {
+			runNancy12();
+			break;
+		}
+
 		if (g_nancy->_sound->isSoundPlaying(_rotateSounds[0]) || g_nancy->_sound->isSoundPlaying(_rotateSounds[1])) {
 			return;
 		}
 
-		if (g_nancy->getGameType() >= kGameTypeNancy12) {
-			// A combo solves only while one of its solutions is active: the dial
-			// positions match and that solution's condition flag is currently set.
-			// The matched solution supplies the scene to change to.
-			for (uint i = 0; i < _solutions.size(); ++i) {
-				const DialSolution &sol = _solutions[i];
-				if (sol.sceneID != kNoScene &&
-						_currentPositions[0] == sol.positions[0] &&
-						_currentPositions[1] == sol.positions[1] &&
-						NancySceneState.getEventFlag(sol.condition.label, sol.condition.flag)) {
-					_solveScene._sceneChange.sceneID = sol.sceneID;
-					_state = kActionTrigger;
-					_isSolved = true;
-					_solveSoundDelayTime = g_nancy->getTotalPlayTime() + (_solveSoundDelay * 1000);
-					break;
-				}
-			}
-		} else if ((uint)_currentPositions[0] == _correctPositions[0] && (uint)_currentPositions[1] == _correctPositions[1]) {
+		if ((uint)_currentPositions[0] == _correctPositions[0] && (uint)_currentPositions[1] == _correctPositions[1]) {
 			_state = kActionTrigger;
 			_isSolved = true;
 			_solveSoundDelayTime = g_nancy->getTotalPlayTime() + (_solveSoundDelay * 1000);
@@ -163,7 +151,11 @@ void TwoDialPuzzle::execute() {
 		break;
 	case kActionTrigger:
 		if (_isSolved) {
-			if (_solveSoundDelayTime != 0) {
+			if (g_nancy->getGameType() >= kGameTypeNancy12) {
+				// The solve sound has already played out during the run phase, and
+				// the matched solution has supplied the scene to change to
+				_solveScene.execute();
+			} else if (_solveSoundDelayTime != 0) {
 				if (g_nancy->getTotalPlayTime() < _solveSoundDelayTime) {
 					return;
 				}
@@ -191,8 +183,67 @@ void TwoDialPuzzle::execute() {
 	}
 }
 
+void TwoDialPuzzle::runNancy12() {
+	switch (_solveState) {
+	case kCheckSolutions: {
+		// A combo solves only while one of its solutions is active: the dial
+		// positions match and that solution's condition flag is currently set
+		int16 matched = -1;
+		for (uint i = 0; i < _solutions.size(); ++i) {
+			const DialSolution &sol = _solutions[i];
+			if (sol.sceneID != kNoScene &&
+					_currentPositions[0] == sol.positions[0] &&
+					_currentPositions[1] == sol.positions[1] &&
+					NancySceneState.getEventFlag(sol.condition.label, sol.condition.flag)) {
+				matched = i;
+				break;
+			}
+		}
+
+		if (matched == -1) {
+			_lastMatchedSolution = -1;
+			break;
+		}
+
+		if (matched != _lastMatchedSolution) {
+			// The dials only just landed on this solution; it counts once they
+			// have rested on it for solveSoundDelay milliseconds
+			_lastMatchedSolution = matched;
+			_solveSoundDelayTime = g_nancy->getTotalPlayTime() + _solveSoundDelay;
+			break;
+		}
+
+		if (g_nancy->getTotalPlayTime() > _solveSoundDelayTime) {
+			_solveScene._sceneChange.sceneID = _solutions[matched].sceneID;
+			_isSolved = true;
+			_solveState = kPlaySolveSound;
+		}
+
+		break;
+	}
+	case kPlaySolveSound:
+		g_nancy->_sound->loadSound(_solveSound);
+		g_nancy->_sound->playSound(_solveSound);
+		_solveState = kWaitForSounds;
+		break;
+	case kWaitForSounds:
+		if (_isSolved) {
+			if (!g_nancy->_sound->isSoundPlaying(_solveSound)) {
+				g_nancy->_sound->stopSound(_solveSound);
+				_state = kActionTrigger;
+			}
+		} else if (!g_nancy->_sound->isSoundPlaying(_rotateSounds[0]) &&
+				!g_nancy->_sound->isSoundPlaying(_rotateSounds[1])) {
+			_solveState = kCheckSolutions;
+		}
+
+		break;
+	}
+}
+
 void TwoDialPuzzle::handleInput(NancyInput &input) {
-	bool canClick = (_state == kRun) && !g_nancy->_sound->isSoundPlaying(_rotateSounds[0]) && !g_nancy->_sound->isSoundPlaying(_rotateSounds[1]);
+	bool canClick = (_state == kRun) && !_isSolved &&
+		!g_nancy->_sound->isSoundPlaying(_rotateSounds[0]) && !g_nancy->_sound->isSoundPlaying(_rotateSounds[1]);
 
 	if (NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
 		g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
@@ -218,6 +269,12 @@ void TwoDialPuzzle::handleInput(NancyInput &input) {
 				}
 
 				g_nancy->_sound->playSound(_rotateSounds[i]);
+
+				if (g_nancy->getGameType() >= kGameTypeNancy12) {
+					// A dial in motion suspends the solution check until it stops
+					_solveState = kWaitForSounds;
+				}
+
 				_drawSurface.fillRect(_dests[0].findIntersectingRect(_dests[1]), _drawSurface.getTransparentColor());
 
 				// Blit both dials just in case
diff --git a/engines/nancy/action/puzzle/twodialpuzzle.h b/engines/nancy/action/puzzle/twodialpuzzle.h
index ce2948812df..083cc78f95a 100644
--- a/engines/nancy/action/puzzle/twodialpuzzle.h
+++ b/engines/nancy/action/puzzle/twodialpuzzle.h
@@ -45,6 +45,8 @@ public:
 protected:
 	Common::String getRecordTypeName() const override { return "TwoDialPuzzle"; }
 
+	void runNancy12();
+
 	Common::Path _imageName;
 
 	bool _isClockwise[2] = { false, false };
@@ -66,6 +68,14 @@ protected:
 	};
 	Common::Array<DialSolution> _solutions;
 
+	// Nancy 12+ splits the run phase into three steps: the dials must rest on a
+	// matching solution long enough for it to count, then the solve sound plays
+	// out before the record finishes. Turning a dial also drops back into
+	// kWaitForSounds, which suspends the check until the dial stops rattling.
+	enum SolveState { kCheckSolutions, kPlaySolveSound, kWaitForSounds };
+	SolveState _solveState = kCheckSolutions;
+	int16 _lastMatchedSolution = -1;
+
 	SoundDescription _rotateSounds[2];
 
 	SceneChangeWithFlag _solveScene;




More information about the Scummvm-git-logs mailing list