[Scummvm-git-logs] scummvm master -> 1807329275bf6f6d5dfaa9fdc34c9830e17e959d

bluegr noreply at scummvm.org
Tue Sep 1 05:38:40 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:
37caa7b292 NANCY: NANCY14: Use the correct cursor variant in puzzles
c473d30af4 NANCY: NANCY14: Play secondary movies correctly in panorama scenes
69d9dea238 NANCY: NANCY14: Draw the brush correctly in the PaintPuzzle
8255622eee NANCY: Add video names to debug info for secondary movies and videos
8a7ef2dd5a NANCY: NANCY14: Implement RolloverOverlay (AR 53)
aa8a45bc4d NANCY: NANCY13: Fix winning PachinkoPuzzle
7cdb76f1ec NANCY: NANCY13: Implement differences in PlaySecondaryMovie ARs 41, 46
e5ebcdc2f2 NANCY: NANCY14: Implement DecoderPuzzle (AR 182)
8eeca4f053 NANCY: NANCY14: Implement MeterPuzzle
1807329275 NANCY: NANCY14: Add PuzzleData records for DecoderPuzzle


Commit: 37caa7b292ced7e05646094ea2004bdf231656a7
    https://github.com/scummvm/scummvm/commit/37caa7b292ced7e05646094ea2004bdf231656a7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:12+03:00

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

Changed paths:
    engines/nancy/action/puzzle/adjustpuzzle.cpp
    engines/nancy/action/puzzle/adjustpuzzle.h
    engines/nancy/action/puzzle/hangmanpuzzle.cpp
    engines/nancy/action/puzzle/hangmanpuzzle.h


diff --git a/engines/nancy/action/puzzle/adjustpuzzle.cpp b/engines/nancy/action/puzzle/adjustpuzzle.cpp
index 7280b91cb50..23752fcc137 100644
--- a/engines/nancy/action/puzzle/adjustpuzzle.cpp
+++ b/engines/nancy/action/puzzle/adjustpuzzle.cpp
@@ -37,7 +37,7 @@ namespace Action {
 
 void AdjustPuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);		// 0x1c0
-	_field3d = stream.readSint16LE();		// 0x3d
+	_testCursorType = stream.readUint16LE();	// 0x3d
 
 	int16 numPieces = stream.readSint16LE();
 	_pieces.resize(numPieces);				// 0x3f
@@ -66,7 +66,7 @@ void AdjustPuzzle::readData(Common::SeekableReadStream &stream) {
 		_adjustSound.readData(stream);		// 0x12f
 	}
 
-	_field18a = stream.readSint16LE();		// 0x18a
+	_animCursorType = stream.readUint16LE();	// 0x18a
 
 	int16 numOverlays = stream.readSint16LE();
 	_overlayNames.resize(numOverlays);		// 0x18c
@@ -273,7 +273,7 @@ void AdjustPuzzle::handleInput(NancyInput &input) {
 	// The "test" control: evaluate the current settings.
 	if (!_rectB5.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_rectB5).contains(input.mousePos)) {
-		g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_testCursorType, true);
 		if (click) {
 			runTest();
 		}
diff --git a/engines/nancy/action/puzzle/adjustpuzzle.h b/engines/nancy/action/puzzle/adjustpuzzle.h
index 7a486f270b9..4c1f59edc62 100644
--- a/engines/nancy/action/puzzle/adjustpuzzle.h
+++ b/engines/nancy/action/puzzle/adjustpuzzle.h
@@ -50,7 +50,7 @@ public:
 protected:
 	Common::String getRecordTypeName() const override { return "AdjustPuzzle"; }
 
-	// One adjustable element. rects[0]/rects[2] are the decrement/increment
+	// One adjustable element. rects[1]/rects[3] are the decrement/increment
 	// hotspots; subRects are the per-state display frames (blitted at boundRect).
 	struct Piece {
 		Common::Rect rects[4];
@@ -82,7 +82,7 @@ protected:
 
 	// -- File data --
 	Common::Path _imageName;			// 0x1c0
-	int16 _field3d = 0;					// 0x3d
+	uint16 _testCursorType = 0;			// 0x3d - raw Nancy14 cursor type, shown over the test control
 	Common::Array<Piece> _pieces;		// 0x3f
 
 	Common::Rect _rectA5;				// 0xa5
@@ -96,7 +96,7 @@ protected:
 	Common::Rect _adjustRect;			// 0x11f
 	RandomSoundBlock _adjustSound;		// 0x12f (present only when _adjustName is set)
 
-	int16 _field18a = 0;						// 0x18a
+	uint16 _animCursorType = 0;					// 0x18a - cursor for the plotter animation, unused here
 	Common::Array<Common::Path> _overlayNames;	// 0x18c, result overlays
 	Common::Array<MatrixRow> _matrix;			// 0x1b0
 
diff --git a/engines/nancy/action/puzzle/hangmanpuzzle.cpp b/engines/nancy/action/puzzle/hangmanpuzzle.cpp
index dada33264a1..580f53e7a0c 100644
--- a/engines/nancy/action/puzzle/hangmanpuzzle.cpp
+++ b/engines/nancy/action/puzzle/hangmanpuzzle.cpp
@@ -39,7 +39,7 @@ namespace Action {
 void HangmanPuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _puzzleImageName);		// 0x3d
 	readFilename(stream, _lettersImageName);	// 0x41
-	_field45 = stream.readSint16LE();			// 0x45
+	_hoverCursorType = stream.readUint16LE();	// 0x45
 
 	int16 numWords = stream.readSint16LE();
 	_words.resize(numWords);
@@ -305,8 +305,7 @@ void HangmanPuzzle::handleInput(NancyInput &input) {
 
 	int tile = tileAtCursor(input.mousePos);
 	if (tile >= 0 && !_letters[tile].used) {
-		// Clickable-hotspot cursor for puzzles (the blue pointing hand).
-		g_nancy->_cursor->setCursorType(CursorManager::kPuzzleArrow);
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			commitGuess((uint)tile);
 		}
diff --git a/engines/nancy/action/puzzle/hangmanpuzzle.h b/engines/nancy/action/puzzle/hangmanpuzzle.h
index 34632972fb8..a2d6bdd787f 100644
--- a/engines/nancy/action/puzzle/hangmanpuzzle.h
+++ b/engines/nancy/action/puzzle/hangmanpuzzle.h
@@ -86,7 +86,7 @@ protected:
 	// -- File data --
 	Common::Path _puzzleImageName;		// 0x3d
 	Common::Path _lettersImageName;		// 0x41
-	int16 _field45 = 0;					// 0x45
+	uint16 _hoverCursorType = 0;		// 0x45 - raw Nancy14 cursor type, shown over a letter tile
 
 	Common::Array<Common::String> _words;			// candidate word bank
 	Common::Array<Common::Rect> _hangPieceRects;	// 0x57, hang-stage pieces


Commit: c473d30af4ebf25230373e7563cccd15ef2bd4fd
    https://github.com/scummvm/scummvm/commit/c473d30af4ebf25230373e7563cccd15ef2bd4fd
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:16+03:00

Commit Message:
NANCY: NANCY14: Play secondary movies correctly in panorama scenes

Fixes Heather's animation staying stuck at the beginning

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


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 1406e5deb9f..7dc72fb281d 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -771,9 +771,9 @@ void PlaySecondaryMovie::execute() {
 					_hotspot = _screenPosition;
 					_hasHotspot = true;
 				}
-			} else if (_isRandom) {
-				// Random movies aren't gated on hotspot/viewport-frame
-				// matches the way regular PSMs are: play full viewport.
+			} else if (_isRandom && _videoDescs.empty()) {
+				// A random movie with no descriptors isn't tied to a specific
+				// background frame: play it across the full viewport.
 				_screenPosition = NancySceneState.getViewport().getBounds();
 				setVisible(true);
 				_hasHotspot = false;
@@ -804,7 +804,7 @@ void PlaySecondaryMovie::execute() {
 			(_decoder.needsUpdate() ? _decoder.decodeNextFrame() : nullptr);
 
 		if (decodedFrame) {
-			uint descID = 0;
+			int descID = -1;
 
 			for (uint i = 0; i < _videoDescs.size(); ++i) {
 				if (_videoDescs[i].frameID == _curViewportFrame) {
@@ -815,12 +815,12 @@ void PlaySecondaryMovie::execute() {
 			GraphicsManager::copyToManaged(*decodedFrame, _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
 
 			// Nancy14 stores an all -1 srcRect to mean "use the whole frame".
-			Common::Rect srcRect = _videoDescs[descID].srcRect;
+			Common::Rect srcRect = descID != -1 ? _videoDescs[descID].srcRect : Common::Rect();
 			if (srcRect.isEmpty()) {
 				srcRect = Common::Rect(_fullFrame.w, _fullFrame.h);
 			}
 
-			Common::Rect destRect = _videoDescs[descID].destRect;
+			Common::Rect destRect = descID != -1 ? _videoDescs[descID].destRect : _screenPosition;
 
 			// The videoDesc's size might be larger than the decoded video (for example, nancy10's
 			// COR_AceFidgetEars_ANIM, and nancy12's PAR_ArcadeAnimationB); clamp here to avoid


Commit: 69d9dea2382b03821e6688b641bbd579804dccc0
    https://github.com/scummvm/scummvm/commit/69d9dea2382b03821e6688b641bbd579804dccc0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:18+03:00

Commit Message:
NANCY: NANCY14: Draw the brush correctly in the PaintPuzzle

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


diff --git a/engines/nancy/action/puzzle/paintpuzzle.cpp b/engines/nancy/action/puzzle/paintpuzzle.cpp
index 583f7b3b0a4..f961f4e65f4 100644
--- a/engines/nancy/action/puzzle/paintpuzzle.cpp
+++ b/engines/nancy/action/puzzle/paintpuzzle.cpp
@@ -35,7 +35,7 @@ namespace Action {
 
 void PaintPuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);		// 0x3d
-	_field5e = stream.readSint16LE();		// 0x5e
+	_hoverCursorType = stream.readUint16LE();	// 0x5e
 	_offset.x = stream.readSint32LE();		// 0x60
 	_offset.y = stream.readSint32LE();		// 0x64
 	readRect(stream, _canvasRect);			// 0x68
@@ -197,6 +197,62 @@ void PaintPuzzle::drawRegion(uint regionIndex) {
 	}
 }
 
+// Draws the held color's brush sprite at the cursor. The cursor is blanked while a
+// color is held, so the brush is what the player sees moving.
+void PaintPuzzle::drawBrush() {
+	if (_heldColor < 0 || _heldColor >= (int)_colors.size() || _image.w == 0) {
+		return;
+	}
+
+	const Common::Rect &src = _colors[_heldColor].fillRect;
+	if (src.isEmpty()) {
+		return;
+	}
+
+	byte tr, tg, tb;
+	g_nancy->_graphics->getInputPixelFormat().colorToRGB(g_nancy->_graphics->getTransColor(), tr, tg, tb);
+
+	// The sprite is centered on the cursor, then shifted by the offset.
+	const Common::Point dest(_brushPos.x - src.width() / 2 - _offset.x,
+		_brushPos.y - src.height() / 2 - _offset.y);
+
+	for (int y = 0; y < src.height() && src.top + y < _image.h; ++y) {
+		const int dy = dest.y + y;
+		if (dy < 0 || dy >= _drawSurface.h) {
+			continue;
+		}
+
+		for (int x = 0; x < src.width() && src.left + x < _image.w; ++x) {
+			const int dx = dest.x + x;
+			if (dx < 0 || dx >= _drawSurface.w) {
+				continue;
+			}
+
+			byte a, r, g, b;
+			_image.format.colorToARGB(_image.getPixel(src.left + x, src.top + y), a, r, g, b);
+			if (a == 0 || (r == tr && g == tg && b == tb)) {
+				continue;
+			}
+
+			if (a != 255) {
+				// Composite the anti-aliased edges over the painted regions below,
+				// instead of replacing their paint with semi-transparent pixels.
+				byte da, dr, dg, db;
+				_drawSurface.format.colorToARGB(_drawSurface.getPixel(dx, dy), da, dr, dg, db);
+
+				uint destAlpha = da * (255 - a) / 255;
+				uint outAlpha = a + destAlpha;
+				r = (r * a + dr * destAlpha) / outAlpha;
+				g = (g * a + dg * destAlpha) / outAlpha;
+				b = (b * a + db * destAlpha) / outAlpha;
+				a = outAlpha;
+			}
+
+			_drawSurface.setPixel(dx, dy, _drawSurface.format.ARGBToColor(a, r, g, b));
+		}
+	}
+}
+
 void PaintPuzzle::redraw() {
 	_drawSurface.clear(0);
 
@@ -208,6 +264,8 @@ void PaintPuzzle::redraw() {
 		}
 	}
 
+	drawBrush();
+
 	_needsRedraw = true;
 }
 
@@ -243,6 +301,20 @@ void PaintPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// A held color turns the cursor into its brush sprite, which the puzzle draws itself.
+	if (_heldColor >= 0) {
+		Common::Rect screenPt(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1);
+		Common::Rect vpPt = NancySceneState.getViewport().convertScreenToViewport(screenPt);
+		Common::Point brushPos(vpPt.left, vpPt.top);
+
+		if (brushPos != _brushPos) {
+			_brushPos = brushPos;
+			redraw();
+		}
+
+		g_nancy->_cursor->setCursorType(CursorManager::kNancy13Blank, true, false);
+	}
+
 	// Give-up hotspot: leave the puzzle.
 	if (!_exitHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
@@ -256,18 +328,16 @@ void PaintPuzzle::handleInput(NancyInput &input) {
 
 	int color = colorSwatchAtCursor(input.mousePos);
 	if (color >= 0) {
-		// Over a color swatch: show the blue puzzle-hotspot hand and pick the
-		// color on click.
-		g_nancy->_cursor->setCursorType(CursorManager::kPuzzleArrow);
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			_heldColor = color;
+			redraw();
 		}
 		input.eatMouseInput();
 		return;
 	}
 
-	// Over a paintable region with a color picked: paint it on click. The
-	// cursor is left as the held paintbrush item.
+	// Over a paintable region with a color picked: paint it on click.
 	int region = regionAtCursor(input.mousePos);
 	if (region >= 0 && _heldColor >= 0) {
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
diff --git a/engines/nancy/action/puzzle/paintpuzzle.h b/engines/nancy/action/puzzle/paintpuzzle.h
index 6a272db9464..0cce3fd9f17 100644
--- a/engines/nancy/action/puzzle/paintpuzzle.h
+++ b/engines/nancy/action/puzzle/paintpuzzle.h
@@ -79,14 +79,15 @@ protected:
 	byte shapeAlpha(const Graphics::ManagedSurface &img, int x, int y) const;
 	void paintRegion(uint regionIndex, int colorIndex);
 	void drawRegion(uint regionIndex);
+	void drawBrush();
 	void redraw();
 	bool isSolved() const;
 	void applyOutcome(const SceneOutcome &outcome);
 
 	// -- File data --
 	Common::Path _imageName;		// 0x3d
-	int16 _field5e = 0;				// 0x5e
-	Common::Point _offset;			// 0x60 (two int32)
+	uint16 _hoverCursorType = 0;	// 0x5e - raw Nancy14 cursor type, shown over a color swatch
+	Common::Point _offset;			// 0x60 (two int32) - brush sprite offset from the cursor
 	Common::Rect _canvasRect;		// 0x68
 
 	Common::Array<PaintColor> _colors;		// 0x78
@@ -107,6 +108,7 @@ protected:
 	Graphics::ManagedSurface _image;
 	Common::Array<Graphics::ManagedSurface> _regionImages;
 	int _heldColor = -1;
+	Common::Point _brushPos;
 	int _hoverRegion = -1;
 	int _hoverColor = -1;
 	bool _solved = false;


Commit: 8255622eee37a6d39d5f23676767c7697d01b961
    https://github.com/scummvm/scummvm/commit/8255622eee37a6d39d5f23676767c7697d01b961
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:19+03:00

Commit Message:
NANCY: Add video names to debug info for secondary movies and videos

Changed paths:
    engines/nancy/action/secondarymovie.h
    engines/nancy/action/secondaryvideo.h


diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index a711b717f27..3bab9d33ff7 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -200,7 +200,9 @@ public:
 	CursorManager::CursorType getHoverCursor() const override;
 	bool cursorSetFromScript() const override { return _isRandom && _talkSceneID != kNoScene; }
 
-	Common::String getRecordExtraInfo() const override { return Common::String::format("Scene %d", _sceneChange.sceneID); }
+	Common::String getRecordExtraInfo() const override {
+		return Common::String::format("Scene %d, file %s", _sceneChange.sceneID, _videoName.baseName().c_str());
+	}
 
 protected:
 	Common::String getRecordTypeName() const override {
diff --git a/engines/nancy/action/secondaryvideo.h b/engines/nancy/action/secondaryvideo.h
index 77a9c187c85..0457c1aaaa0 100644
--- a/engines/nancy/action/secondaryvideo.h
+++ b/engines/nancy/action/secondaryvideo.h
@@ -90,7 +90,9 @@ public:
 		return g_nancy->getGameType() >= kGameTypeNancy10 ? CursorManager::kHotspotTalk : CursorManager::kHotspot;
 	}
 
-	Common::String getRecordExtraInfo() const override { return Common::String::format("Scene %d", _sceneChange.sceneID); }
+	Common::String getRecordExtraInfo() const override {
+		return Common::String::format("Scene %d, file %s", _sceneChange.sceneID, _filename.baseName().c_str());
+	}
 
 protected:
 	Common::String getRecordTypeName() const override { return "PlaySecondaryVideo"; }


Commit: 8a7ef2dd5a4fe976d17c6ae749cb5b913ff5955a
    https://github.com/scummvm/scummvm/commit/8a7ef2dd5a4fe976d17c6ae749cb5b913ff5955a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:20+03:00

Commit Message:
NANCY: NANCY14: Implement RolloverOverlay (AR 53)

This is a rollover label: an image that is only drawn while the mouse
is inside its hotspot. Entering the hotspot plays a sound and sets an
event flag, and clicking it plays a second sound before changing the
scene.

Fixes reading the manual on Nancy's desk when starting a new game

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index f744bec24c2..60fbf64a745 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -230,7 +230,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 52:
 		return new PlaySecondaryVideo();
 	case 53:
-		return new PlaySecondaryMovie();
+		if (g_nancy->getGameType() >= kGameTypeNancy14)
+			return new RolloverOverlay();
+		else
+			return new PlaySecondaryMovie();
 	case 54:
 		if (g_nancy->getGameType() <= kGameTypeNancy1)
 			return new Overlay(false); // PlayStaticBitmapAnimation
diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index 11fae30bb30..c2d1d809ec5 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -31,6 +31,7 @@
 
 #include "engines/nancy/state/scene.h"
 
+#include "common/random.h"
 #include "common/serializer.h"
 
 #include "graphics/font.h"
@@ -558,5 +559,125 @@ void TextLineOverlay::execute() {
 	_isDone = true;
 }
 
+void RolloverOverlay::init() {
+	g_nancy->_resource->loadImage(_imageName, _fullSurface);
+
+	RenderObject::init();
+}
+
+void RolloverOverlay::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+	_transparency = stream.readUint16LE();
+	_z = stream.readUint16LE();
+	_hoverCursor = stream.readUint16LE();
+
+	readRect(stream, _hotspotRect);
+	readRect(stream, _srcRect);
+	readRect(stream, _destRect);
+
+	_flagOnHover.label = stream.readSint16LE();
+	_flagOnHover.flag = stream.readByte();
+	stream.skip(1);
+
+	_hoverSound.readData(stream);
+	_hoverSoundOnce = stream.readUint16LE();
+
+	_sceneChange.sceneID = stream.readUint16LE();
+	_sceneChange.frameID = stream.readUint16LE();
+	int16 verticalOffset = stream.readSint16LE();
+	_sceneChange.verticalOffset = verticalOffset >= 0 ? verticalOffset : 0;
+
+	_sceneChange.continueSceneSound = stream.readByte();
+
+	_clickSound.readData(stream);
+}
+
+void RolloverOverlay::playSoundBlock(const RandomSoundBlock &block) {
+	if (block.names.empty()) {
+		return;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return;
+	}
+
+	SoundDescription desc;
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+}
+
+void RolloverOverlay::handleInput(NancyInput &input) {
+	if (_state != kRun) {
+		return;
+	}
+
+	bool hovered = NancySceneState.getViewport().convertViewportToScreen(_hotspot).contains(input.mousePos);
+	if (hovered == _isHovered) {
+		return;
+	}
+
+	_isHovered = hovered;
+	setVisible(hovered);
+
+	if (!hovered) {
+		return;
+	}
+
+	if (_hoverSoundOnce == 0 || !_hoverSoundPlayed) {
+		playSoundBlock(_hoverSound);
+		_hoverSoundPlayed = true;
+	}
+
+	NancySceneState.setEventFlag(_flagOnHover);
+}
+
+void RolloverOverlay::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+
+		_drawSurface.create(_fullSurface, _srcRect);
+		setTransparent(_transparency >= kPlayOverlayTransparent);
+		moveTo(_destRect);
+		setVisible(false);
+		registerGraphics();
+
+		_hotspot = _hotspotRect;
+		_hasHotspot = true;
+
+		_state = kRun;
+		break;
+	case kRun:
+		// Visibility follows the mouse, see handleInput()
+		break;
+	case kActionTrigger:
+		if (!_clickSoundStarted) {
+			playSoundBlock(_clickSound);
+			_clickSoundStarted = true;
+		}
+
+		if (!_clickSound.names.empty() && g_nancy->_sound->isSoundPlaying((uint16)_clickSound.channel)) {
+			return;
+		}
+
+		setVisible(false);
+		_hasHotspot = false;
+
+		if (_sceneChange.sceneID != kNoScene) {
+			NancySceneState.changeScene(_sceneChange);
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
 } // End of namespace Action
 } // End of namespace Nancy
diff --git a/engines/nancy/action/overlay.h b/engines/nancy/action/overlay.h
index 7ae0724c569..88f9f145ab9 100644
--- a/engines/nancy/action/overlay.h
+++ b/engines/nancy/action/overlay.h
@@ -156,6 +156,52 @@ protected:
 	int16 _tableIndex = 0;
 };
 
+// Nancy14 AR 53. A rollover label: an image that is only drawn while the mouse
+// is inside its hotspot. Entering the hotspot plays a sound and sets an event
+// flag, and clicking it plays a second sound before changing the scene.
+class RolloverOverlay : public RenderActionRecord {
+public:
+	RolloverOverlay() : RenderActionRecord(7) {}
+	virtual ~RolloverOverlay() { _fullSurface.free(); }
+
+	void init() override;
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+	bool canHaveHotspot() const override { return true; }
+	CursorManager::CursorType getHoverCursor() const override { return (CursorManager::CursorType)_hoverCursor; }
+	bool cursorSetFromScript() const override { return true; }
+	Common::String getRecordExtraInfo() const override { return Common::String::format("Scene %d", _sceneChange.sceneID); }
+
+protected:
+	Common::String getRecordTypeName() const override { return "RolloverOverlay"; }
+
+	void playSoundBlock(const RandomSoundBlock &block);
+
+	Common::Path _imageName;
+	uint16 _transparency = kPlayOverlayPlain;
+	uint16 _hoverCursor = 0;
+	Common::Rect _hotspotRect;
+	Common::Rect _srcRect;
+	Common::Rect _destRect;
+	// Set every time the mouse enters the hotspot
+	FlagDescription _flagOnHover;
+	// When nonzero the hover sound is only played the first time; otherwise it
+	// plays on every hover
+	uint16 _hoverSoundOnce = 0;
+	RandomSoundBlock _hoverSound;
+	SceneChangeDescription _sceneChange;
+	RandomSoundBlock _clickSound;
+
+	bool _isHovered = false;
+	bool _hoverSoundPlayed = false;
+	bool _clickSoundStarted = false;
+
+	Graphics::ManagedSurface _fullSurface;
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 


Commit: aa8a45bc4d6512ca00cb21e9e3c77150fb0131e7
    https://github.com/scummvm/scummvm/commit/aa8a45bc4d6512ca00cb21e9e3c77150fb0131e7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:21+03:00

Commit Message:
NANCY: NANCY13: Fix winning PachinkoPuzzle

- Handle win scene and flags
- Add a cheat - Ctrl+Shift+P pushes the prospector one catch further up
  the mountain, gaining ground on the Yeti
- Cleanup comments
- Remove unused flag

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


diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
index 98ebbe51700..974bcde66a4 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.cpp
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -40,17 +40,13 @@ static const double kGravity = 50.0;			// downward accel (px/s^2)
 static const double kRestitution = 0.85;		// bounce energy retained
 static const double kTwoPi = 6.283185307179586;
 static const double kDeg2Rad = 0.017453292519943295;
-// Launch-param jitter: progress*0.95 + (rand%100)*0.002; launch-Y jitter: (rand%100)*0.01.
 static const double kSpawnParamMax = 0.95;
 static const double kSpawnParamJitter = 0.002;
 static const double kSpawnYJitter = 0.01;
-// Per-ball deceleration is not in the chunk (it lives in a runtime-built physics body), so
-// a small constant drag is used here as an approximation.
 static const double kDrag = 12.0;				// px/s per second
 
-// Collision geometry. Pins are stored as 1x1 points on a staggered ~30px grid; on screen
-// they are small round bumpers, so the hit distance matches the drawn pin + ball radii (a
-// ball may still thread the wider gaps, which is fine - the stagger catches it a row later).
+// Pins are stored as 1x1 points on a staggered ~30px grid but drawn as small round bumpers,
+// so they collide as circles at the drawn pin + ball radii.
 static const double kPinRadius = 4.0;
 static const double kBallRadius = 5.0;
 static const int kPhysicsSubsteps = 4;			// per frame, to avoid tunnelling the pins
@@ -58,8 +54,7 @@ static const int kPhysicsSubsteps = 4;			// per frame, to avoid tunnelling the p
 static const uint32 kHoleLitMs = 400;			// how long a hole stays lit after a catch
 
 // A climber reaches the pot once its accumulated steps hit this goal. moverSpeed per catch
-// is 2 (Miner) / 3 (Yeti), so the Yeti climbs faster - matching the game's "the Yeti
-// usually wins". The exact goal is not recovered from the chunk; this is a tuned value.
+// is 2 (Miner) / 3 (Yeti), so the Yeti climbs faster.
 static const int kClimbGoal = 30;
 
 static double wrapAngle(double a) {
@@ -73,9 +68,8 @@ static double wrapAngle(double a) {
 }
 
 void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
-	readFilename(stream, _imageName);			// 0x00 - board overlay
+	readFilename(stream, _imageName);			// board overlay
 
-	// 167-byte header blob.
 	readRect(stream, _ballSrc);					// ball sprite source
 	readRect(stream, _ballEntry);				// top-right entry chute
 	_velMin = stream.readSint32LE();			// launch-speed floor
@@ -110,12 +104,10 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
 		}
 	}
 
-	// The polymorphic Nancy13 ActionZone array (bumpers / walls / overlays). The shared
-	// ActionZone reader handles the Nancy13 layout when told to.
+	// The bumpers / walls / overlays, in the Nancy13 zone layout.
 	readActionZoneArray(stream, _zones, true);
 
-	// The base trailer: a count-prefixed array of 23-byte hotspot records. The sample
-	// carries the single "give up / exit" hotspot.
+	// The base trailer's hotspot records; the first is the give-up exit.
 	int16 numExit = stream.readSint16LE();
 	for (int16 i = 0; i < numExit; ++i) {
 		Common::Rect r;
@@ -129,7 +121,6 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
 			_exitHotspot = r;
 			_exitCursorType = cursorType;
 			_exitScene.sceneID = sceneID;
-			// The field after the scene id is a flag label (set on give-up), not a frame.
 			_exitScene.frameID = 0;
 			_exitFlag.label = exitFlagLabel;
 			_exitFlag.flag = exitFlagValue;
@@ -138,10 +129,9 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
 }
 
 void PachinkoPuzzle::readMachine(Common::SeekableReadStream &stream, Machine &m) {
-	readFilename(stream, m.imageName);			// the ANIM_OVL sprite strip
+	readFilename(stream, m.imageName);			// the sprite strip
 	m.animRate = stream.readSint32LE();			// frames per second
 
-	// Sprite-strip source rects (an embedded "sprite container": int16 count + rects).
 	int16 numFrames = stream.readSint16LE();
 	if (numFrames > 0) {
 		m.frames.resize(numFrames);
@@ -150,27 +140,30 @@ void PachinkoPuzzle::readMachine(Common::SeekableReadStream &stream, Machine &m)
 		}
 	}
 
-	// The slide "mover": a start rect, an end/catch rect, and a speed.
 	readRect(stream, m.moverStart);
 	readRect(stream, m.moverEnd);
 	m.moverSpeed = stream.readSint32LE();
 
-	m.winchSound.readData(stream);				// snd1 - the winch-up cue
+	m.winchSound.readData(stream);				// the winch-up cue
 
-	stream.skip(1);								// per-machine flag byte
+	stream.skip(1);
 
-	// The 55-byte blob: a filename[33] (the result movie - MUS_PachinkoWinANIM /
-	// MUS_PachinkoLoseANIM), then its 16-byte destination rect, then int32 + int16.
+	// The result movie, then the scene and event flag the puzzle exits through when this
+	// climber is the one that wins.
 	readFilename(stream, m.movieName);
 	readRect(stream, m.movieDest);
-	stream.skip(6);
+	stream.skip(1);
+	m.resultScene.sceneID = stream.readUint16LE();
+	m.resultScene.frameID = 0;
+	m.resultFlag.label = stream.readSint16LE();
+	m.resultFlag.flag = stream.readByte();
 
-	m.resultSound.readData(stream);				// snd2 - the win/lose voice cue
+	m.resultSound.readData(stream);				// the win/lose voice cue
 
-	stream.skip(1);								// byte (unused here)
-	stream.skip(4);								// int32 (unused here)
+	stream.skip(1);
+	stream.skip(4);
 
-	m.fastSound.readData(stream);				// snd3 - the fast-winch cue
+	m.fastSound.readData(stream);				// the fast-winch cue
 }
 
 void PachinkoPuzzle::loadMachineImage(Machine &m) {
@@ -340,8 +333,7 @@ void PachinkoPuzzle::spawnBall() {
 	playSoundBlock(_plinkSounds);
 }
 
-// Reflect the ball off any pin it now overlaps (pins collide as circles). Returns true if a
-// bounce happened.
+// Reflect the ball off any pin it now overlaps. Returns true if a bounce happened.
 bool PachinkoPuzzle::collidePins(Ball &ball) const {
 	const double hitDist = kPinRadius + kBallRadius;
 	for (const Common::Rect &pin : _pins) {
@@ -354,8 +346,7 @@ bool PachinkoPuzzle::collidePins(Ball &ball) const {
 			continue;
 		}
 
-		// Reflect the velocity about the surface normal (pin centre -> ball) and push the
-		// ball back out to the contact distance.
+		// Reflect about the surface normal and push the ball back out to contact distance.
 		double d = sqrt(d2);
 		double nxn = dx / d;
 		double nyn = dy / d;
@@ -398,8 +389,7 @@ void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
 		ball.speed = 0.0;
 	}
 
-	// Advance in small sub-steps so the ball cannot tunnel between the pins, bouncing off a
-	// pin or a side wall on the way.
+	// Advance in sub-steps so the ball cannot tunnel between the pins.
 	double subDt = dt / kPhysicsSubsteps;
 	for (int s = 0; s < kPhysicsSubsteps; ++s) {
 		ball.x += cos(ball.angle) * ball.speed * subDt;
@@ -409,8 +399,8 @@ void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
 			continue;
 		}
 
-		// Side walls of the panel keep the ball in play; the ball enters from the right of
-		// the right wall, so that wall only reflects once the ball is inside.
+		// The side walls keep the ball in play; the ball enters from the right of the right
+		// wall, so that wall only reflects once the ball is inside.
 		double vx2 = cos(ball.angle) * ball.speed;
 		double vy2 = -sin(ball.angle) * ball.speed;
 		bool reflected = false;
@@ -543,11 +533,9 @@ void PachinkoPuzzle::execute() {
 
 			// The first climber to reach the pot ends the game (Gold Digger = win, Yeti = lose).
 			if (_winMachine.climbSteps >= kClimbGoal) {
-				_solved = true;
 				_activeMachine = &_winMachine;
 				_pzState = kPlayResult;
 			} else if (_loseMachine.climbSteps >= kClimbGoal) {
-				_solved = false;
 				_activeMachine = &_loseMachine;
 				_pzState = kPlayResult;
 			}
@@ -596,10 +584,15 @@ void PachinkoPuzzle::execute() {
 		break;
 	}
 	case kActionTrigger:
-		// The give-up hotspot and the completion path both route to the exit scene; the
-		// win/lose branch is driven downstream by the solved flag and the puzzle event flag.
-		NancySceneState.setEventFlag(_exitFlag);
-		NancySceneState.changeScene(_exitScene);
+		// Each climber carries its own exit scene and event flag, so the winner decides where
+		// the puzzle leaves off; only giving up uses the trailer's exit.
+		if (_activeMachine) {
+			NancySceneState.setEventFlag(_activeMachine->resultFlag);
+			NancySceneState.changeScene(_activeMachine->resultScene);
+		} else {
+			NancySceneState.setEventFlag(_exitFlag);
+			NancySceneState.changeScene(_exitScene);
+		}
 		finishExecution();
 		break;
 	}
@@ -610,6 +603,18 @@ void PachinkoPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// Cheat: Ctrl+Shift+P pushes the prospector one catch further up the mountain, gaining
+	// ground on the Yeti.
+	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_p) {
+			_winMachine.climbSteps += _winMachine.moverSpeed > 0 ? _winMachine.moverSpeed : 1;
+			playSoundBlock(_winMachine.winchSound);
+			debug("Pachinko cheat: prospector pushed up to %d/%d", _winMachine.climbSteps, kClimbGoal);
+		}
+	}
+
 	const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
 
 	if (!_launcherHotspot.isEmpty() &&
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.h b/engines/nancy/action/puzzle/pachinkopuzzle.h
index 1040628d73e..fa55b8ad901 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.h
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.h
@@ -30,24 +30,13 @@
 namespace Nancy {
 namespace Action {
 
-// Pachinko / pinball ball-drop puzzle, new in Nancy13 (AR 175.
+// Pachinko / pinball ball-drop puzzle, new in Nancy13 (AR 175).
 //
-// The player clicks a spring launcher (a fixed hotspot on the right of the board) to
-// fire a ball leftward across a pin field. The ball falls under gravity and bounces off
-// pins and bumper zones (restitution ~0.85) until it settles into one of two catch
-// "machines": the Miner (a win) or the Yeti (a loss). Each machine then plays its own
-// result animation (MUS_PachinkoWinANIM for the Miner) before the puzzle finishes.
-//
-// The chunk is a 167-byte header, a random "plink" sound block, two machine sub-objects
-// (each: an ANIM sprite strip + a slide "mover" + three sound blocks + a 55-byte blob
-// whose leading filename is the result movie), a pin-rect array, a polymorphic Nancy13
-// ActionZone array (the bumpers/walls/overlays), and the give-up exit hotspot. The parse
-// is byte-exact (verified to consume the whole chunk).
-//
-// The physics uses a polar-coordinate integrator: velocity as speed + heading, gravity
-// added in cartesian, per-frame heading recomputed with atan2, sub-stepped rectangle
-// collision with restitution. Per-ball deceleration is not in the chunk, so it is
-// approximated (see kDrag).
+// The player clicks a spring launcher on the right of the board to fire a ball leftward
+// across a pin field. The ball falls under gravity, bouncing off the pins and the bumper
+// zones, until it drops into one of four holes. Each hole feeds one of two climbers racing
+// up the mountain to the pot: the Miner (a win) or the Yeti (a loss). The first to reach
+// the pot plays its result animation, then exits through its own scene and event flag.
 class PachinkoPuzzle : public RenderActionRecord {
 public:
 	PachinkoPuzzle() : RenderActionRecord(7) {}
@@ -64,21 +53,22 @@ public:
 protected:
 	Common::String getRecordTypeName() const override { return "PachinkoPuzzle"; }
 
-	// One of the two mountain climbers (Miner/Gold Digger = win, Yeti = lose). Each is an
-	// animated sprite that climbs from its start anchor (bottom of the mountain) up to its
-	// end anchor (the pot at the top) as balls fall into its holes.
+	// One of the two mountain climbers (Miner = win, Yeti = lose). An animated sprite that
+	// climbs from the bottom of the mountain up to the pot as balls fall into its holes.
 	struct Machine {
-		Common::Path imageName;					// the ANIM_OVL sprite strip
+		Common::Path imageName;					// the sprite strip
 		int32 animRate = 0;						// frames per second
 		Common::Array<Common::Rect> frames;		// sprite-strip source rects
-		Common::Rect moverStart;				// climb-path bottom anchor (2x2 point rect)
+		Common::Rect moverStart;				// climb-path bottom anchor
 		Common::Rect moverEnd;					// climb-path top anchor (the pot)
 		int32 moverSpeed = 0;					// climb steps gained per ball caught
-		RandomSoundBlock winchSound;			// [snd1] the winch-up cue
-		RandomSoundBlock resultSound;			// [snd2] the win/lose voice cue (MinerWin*/PachinkoLose*)
-		RandomSoundBlock fastSound;				// [snd3] the fast-winch cue
-		Common::Path movieName;					// result animation (blob[0], "" == none)
+		RandomSoundBlock winchSound;			// the winch-up cue
+		RandomSoundBlock resultSound;			// the win/lose voice cue
+		RandomSoundBlock fastSound;				// the fast-winch cue
+		Common::Path movieName;					// result animation ("" == none)
 		Common::Rect movieDest;					// where the result animation is drawn
+		SceneChangeDescription resultScene;		// where the puzzle exits when this climber wins
+		FlagDescription resultFlag;				// the event flag it sets on the way out
 
 		Graphics::ManagedSurface image;
 		uint frame = 0;							// current animation frame
@@ -86,8 +76,7 @@ protected:
 		int climbSteps = 0;						// accumulated climb (moverSpeed per catch)
 	};
 
-	// A single launched ball. Physics run in viewport space; the heading is stored as a
-	// scalar speed plus a heading angle in radians.
+	// A single launched ball, moving in viewport space.
 	struct Ball {
 		double x = 0.0;
 		double y = 0.0;
@@ -98,11 +87,11 @@ protected:
 	};
 
 	// One of the four holes on the panel. A ball that drops in advances its climber and
-	// briefly lights the hole (a sprite from the "lit" overlay).
+	// briefly lights the hole.
 	struct Hole {
 		Common::Rect rect;
 		Machine *climber = nullptr;
-		RandomSoundBlock sound;			// the bell cue (PinballBell_*)
+		RandomSoundBlock sound;			// the bell cue
 		Common::Rect litSrc;			// lit-hole sprite source (in _litImage)
 		Common::Rect litDest;			// where it is drawn
 		uint32 litUntil = 0;			// keep it lit until this time
@@ -123,10 +112,10 @@ protected:
 	// Zone cursors take the idle sprite of their type, hover/drag cursors the hotspot one.
 	void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
 
-	// -- File data (167-byte header, in stream order) --
-	Common::Path _imageName;				// 0x00 - board overlay (MUS_PachinkoPUZ02_OVL)
+	// -- File data --
+	Common::Path _imageName;				// board overlay
 
-	Common::Rect _ballSrc;					// ball sprite source in the overlay
+	Common::Rect _ballSrc;					// ball sprite source
 	Common::Rect _ballEntry;				// top-right entry chute (where balls appear)
 	int32 _velMin = 0;						// launch-speed floor
 	int32 _velMax = 0;						// launch-speed ceiling
@@ -142,16 +131,16 @@ protected:
 	int32 _spawnWindowMin = 0;
 	int32 _spawnWindowMax = 0;				// spawn window (ms)
 
-	RandomSoundBlock _plinkSounds;			// random ball-launch cues (LeverPull*)
+	RandomSoundBlock _plinkSounds;			// random ball-launch cues
 
 	Machine _winMachine;					// the Miner
 	Machine _loseMachine;					// the Yeti
 
 	Common::Array<Common::Rect> _pins;		// static pin collision rects
-	Common::Array<ActionZone> _zones;		// bumpers / walls / overlays (Nancy13 layout)
+	Common::Array<ActionZone> _zones;		// bumpers / walls / overlays
 	Common::Array<Hole> _holes;				// the four catch holes (built from _zones)
 
-	// The give-up / exit hotspot (the base trailer's 23-byte record).
+	// The give-up / exit hotspot.
 	Common::Rect _exitHotspot;
 	uint16 _exitCursorType = 0;
 	SceneChangeDescription _exitScene;
@@ -169,8 +158,7 @@ protected:
 	Common::Array<Ball> _balls;
 	bool _spawnPending = false;				// a launcher click awaiting a spawn
 	uint32 _spawnClickTime = 0;
-	Machine *_activeMachine = nullptr;		// the machine that caught the ball
-	bool _solved = false;
+	Machine *_activeMachine = nullptr;		// the climber that reached the pot
 	bool _exitRequested = false;
 	uint32 _lastUpdate = 0;
 	uint32 _resultTime = 0;


Commit: 7cdb76f1ecc139a99099d2d596e6d7714ce8b547
    https://github.com/scummvm/scummvm/commit/7cdb76f1ecc139a99099d2d596e6d7714ce8b547
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:22+03:00

Commit Message:
NANCY: NANCY13: Implement differences in PlaySecondaryMovie ARs 41, 46

- PlaySecondaryMovie (AR 41): Compacted the fields of the non-random
  variant
- PlayRandomMovieControl (AR 46): Handle variant which doesn't include
  a scene change

Fixes talking to Buddy Taffy at the Copper Gorge Museum

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


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 7dc72fb281d..146d0bed8a8 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -348,6 +348,33 @@ void PlaySecondaryMovie::resolveSentinelFrames() {
 	}
 }
 
+void PlaySecondaryMovie::stopRandomNow() {
+	if (!_isRandom) {
+		return;
+	}
+
+	_randomStopRequested = true;
+
+	if (_state == kRun) {
+		if (!_isFinished) {
+			_decoder.pauseVideo(true);
+			_isFinished = true;
+		}
+
+		_state = kActionTrigger;
+	}
+}
+
+void PlaySecondaryMovie::pauseRandom(bool pause) {
+	// The decoder counts pause levels, so only follow an actual change.
+	if (!_isRandom || _randomPaused == pause) {
+		return;
+	}
+
+	_randomPaused = pause;
+	_decoder.pauseVideo(pause);
+}
+
 void PlaySecondaryMovie::playRandomSequence() {
 	if (!_isRandom || _sequences.empty()) {
 		return;
@@ -448,6 +475,60 @@ int PlaySecondaryMovie::rollNextSequence() {
 	return -1;
 }
 
+// Orders a frame field that may still hold a sentinel: -1 is the movie's own
+// first frame, -2 its last.
+static int32 orderSentinelFrame(uint16 frame) {
+	switch ((int16)frame) {
+	case -1:
+		return -1;
+	case -2:
+		return 0x7FFFFFFF;
+	default:
+		return frame;
+	}
+}
+
+void PlaySecondaryMovie::readDataNancy13(Common::Serializer &ser, Common::SeekableReadStream &stream) {
+	readFilename(ser, _videoName);
+
+	ser.skip(2);	// Z order
+
+	// 2 selects an alpha plane, 1 no alpha; transparency comes from the
+	// decoded pixel format instead.
+	ser.syncAsUint16LE(_videoFormat);
+	_videoFormat = kLargeVideoFormat;
+
+	ser.syncAsUint16LE(_playerCursorAllowed);
+	ser.syncAsUint16LE(_numLoops);
+	ser.syncAsUint16LE(_firstFrame);
+	ser.syncAsUint16LE(_lastFrame);
+	ser.syncAsSint16LE(_sceneChange.sceneID);
+	ser.syncAsUint16LE(_sceneChange.frameID);
+
+	_playDirection = orderSentinelFrame(_lastFrame) < orderSentinelFrame(_firstFrame) ?
+		kPlayMovieReverse : kPlayMovieForward;
+
+	_videoSceneChange = _sceneChange.sceneID != kNoScene ? kMovieSceneChange : kMovieNoSceneChange;
+
+	uint16 numFrameFlags = 0;
+	ser.syncAsUint16LE(numFrameFlags);
+	_frameFlags.resize(numFrameFlags);
+	for (uint i = 0; i < numFrameFlags; ++i) {
+		ser.syncAsSint16LE(_frameFlags[i].frameID);
+		ser.syncAsSint16LE(_frameFlags[i].flagDesc.label);
+		ser.syncAsUint16LE(_frameFlags[i].flagDesc.flag);
+	}
+
+	uint16 numVideoDescs = 0;
+	ser.syncAsUint16LE(numVideoDescs);
+	_videoDescs.resize(numVideoDescs);
+	for (uint i = 0; i < numVideoDescs; ++i) {
+		_videoDescs[i].readData(stream);
+	}
+
+	_sound.name = "NO SOUND";
+}
+
 // Nancy14 compacted the non-random layout: the videoSceneChange 5/6 flag is
 // gone (a scene change is now requested via the sceneID sentinel), playDirection
 // moved after lastFrame, and a "hide on finish" flag was added. AR 44 matches
@@ -559,6 +640,12 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
 		return;
 	}
 
+	// Nancy13's AR 53 shares this class but carries a different chunk.
+	if (g_nancy->getGameType() == kGameTypeNancy13 && _type == 41) {
+		readDataNancy13(ser, stream);
+		return;
+	}
+
 	readFilename(ser, _videoName);
 	readFilename(ser, _paletteName, kGameTypeVampire, kGameTypeVampire);
 	readFilename(ser, _bitmapOverlayName, kGameTypeVampire, kGameTypeNancy9);
@@ -708,6 +795,11 @@ void PlaySecondaryMovie::execute() {
 
 		// fall through
 	case kRun: {
+		// Frozen by a PlayRandomMovieControl until it resumes the movie.
+		if (_randomPaused) {
+			break;
+		}
+
 		// Random-movie chain: while paused, wait for the pause to expire
 		// then re-roll. The roll itself may set up another pause, swap to
 		// the next sequence, or finish the AR if stop was requested.
@@ -891,6 +983,12 @@ void PlaySecondaryMovie::execute() {
 					// Otherwise the chain entered the paused state; no
 					// state-trigger transition.
 				}
+			} else if (_numLoops == 0 || _playCount + 1 < _numLoops) {
+				// More plays to go; restart in place.
+				++_playCount;
+				_isFinished = false;
+				_decoder.seekToFrame(_playDirection == kPlayMovieReverse ? _lastFrame : _firstFrame);
+				_decoder.pauseVideo(false);
 			} else if (!g_nancy->_sound->isSoundPlaying(_sound)) {
 				// Stop the video and block it from starting again, but also wait for
 				// sound to end before changing state
@@ -913,6 +1011,7 @@ void PlaySecondaryMovie::execute() {
 		// Allow looping
 		if (!_isDone) {
 			_isFinished = false;
+			_playCount = 0;
 			_decoder.seek(0);
 			_decoder.pauseVideo(false);
 		} else if (_playerCursorAllowed == kNoPlayerCursorAllowed) {
@@ -950,16 +1049,39 @@ void PlaySecondaryMovie::skip() {
 
 void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
 	_mode = stream.readByte();
-	_sceneChange.readData(stream, true, true);
+
+	_hasSceneChange = g_nancy->getGameType() < kGameTypeNancy13;
+	if (_hasSceneChange) {
+		_sceneChange.readData(stream, true, true);
+	}
 }
 
 void PlayRandomMovieControl::execute() {
 	PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
 	if (target && target->_isRandom) {
-		target->stopRandom();
+		if (_hasSceneChange) {
+			target->stopRandom();
+		} else {
+			switch (_mode) {
+			case kStopNow:
+				target->stopRandomNow();
+				break;
+			case kPauseMovie:
+				target->pauseRandom(true);
+				break;
+			case kResumeMovie:
+				target->pauseRandom(false);
+				break;
+			default:
+				break;
+			}
+		}
+	}
+
+	if (_hasSceneChange) {
+		_sceneChange.execute();
 	}
 
-	_sceneChange.execute();
 	finishExecution();
 }
 
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 3bab9d33ff7..0ef9a4dadc7 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -115,6 +115,11 @@ public:
 	// unused by playback.
 	uint16 _playStyle = 1;
 
+	// How many times the movie plays before the record finishes; 0 loops
+	// for as long as the scene lasts.
+	uint16 _numLoops = 1;
+	uint16 _playCount = 0;
+
 	// Volume of the movie's audio track, as a percentage. Carried by Nancy14+
 	// AR 44/47 only; every other record plays at full volume. SetMovieVolume
 	// (AR 150) can change it later.
@@ -167,6 +172,7 @@ public:
 	RandomChainState _randomChainState = kRandomPlaying;
 	uint32 _randomPauseEndTime = 0;
 	bool _randomStopRequested = false;
+	bool _randomPaused = false;
 
 	// Talkable-character hover state: whether the mouse is over the character,
 	// and whether the recognition (secondary) movie is currently playing.
@@ -183,8 +189,13 @@ public:
 	uint32 _rewindLastFrameTime = 0;
 	uint32 _rewindFrameDelay = 66;
 
-	// Called by PlayRandomMovieControl::execute() to wind down the AR.
+	// Called by PlayRandomMovieControl::execute(). stopRandom() winds the AR
+	// down once the sequence that's playing finishes; stopRandomNow() ends it
+	// on the spot; pauseRandom() freezes the movie with the record still
+	// running.
 	void stopRandom() { _randomStopRequested = true; }
+	void stopRandomNow();
+	void pauseRandom(bool pause);
 
 	// Pick & start a fresh random sequence. No-op when not a random AR.
 	void playRandomSequence();
@@ -224,6 +235,12 @@ protected:
 	// (random or by name) and seed the flat playback fields from it.
 	void applyStartingRandomSequence();
 
+	// Nancy13 compacted the non-random layout: a z-order, an alpha selector,
+	// the cursor flag, a loop count, the frame range and the scene change.
+	// Direction follows from lastFrame preceding firstFrame, and a scene
+	// change is requested through the sceneID sentinel.
+	void readDataNancy13(Common::Serializer &ser, Common::SeekableReadStream &stream);
+
 	void readDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream);
 
 	// Apply a RandomSequence's playback config to the PSM flat fields
@@ -276,8 +293,8 @@ public:
 
 	enum RandomMovieControlMode : byte {
 		kStopNow = 0,
-		kStopAfterSequence = 1,
-		kResume = 2
+		kPauseMovie = 1,
+		kResumeMovie = 2
 	};
 
 protected:
@@ -285,6 +302,9 @@ protected:
 
 	byte _mode = kStopNow;
 	SceneChangeWithFlag _sceneChange;
+	// Nancy13's record is the mode byte alone; earlier games append a scene
+	// change to it.
+	bool _hasSceneChange = true;
 };
 
 } // End of namespace Action


Commit: e5ebcdc2f29d0a3cf1b9ee68797acc8c1aba8a1d
    https://github.com/scummvm/scummvm/commit/e5ebcdc2f29d0a3cf1b9ee68797acc8c1aba8a1d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:24+03:00

Commit Message:
NANCY: NANCY14: Implement DecoderPuzzle (AR 182)

A puzzle with a typewriter, where all keys entered are decoded into
words (like Enigma)

Changed paths:
  A engines/nancy/action/puzzle/decoderpuzzle.cpp
  A engines/nancy/action/puzzle/decoderpuzzle.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 60fbf64a745..5b856f5656c 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -47,6 +47,7 @@
 #include "engines/nancy/action/puzzle/collisionpuzzle.h"
 #include "engines/nancy/action/puzzle/cubepuzzle.h"
 #include "engines/nancy/action/puzzle/cuttingpuzzle.h"
+#include "engines/nancy/action/puzzle/decoderpuzzle.h"
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
@@ -517,9 +518,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return nullptr;
 	case 181:
 		return new PaintPuzzle();
-	case 182:	// DecoderPuzzle
-		// TODO: not yet implemented
-		return nullptr;
+	case 182:
+		return new DecoderPuzzle();
 	// -- Nancy15 new puzzles (types 183-185) --
 	case 183:	// MagicBoxPuzzle
 		// TODO: not yet implemented
diff --git a/engines/nancy/action/puzzle/decoderpuzzle.cpp b/engines/nancy/action/puzzle/decoderpuzzle.cpp
new file mode 100644
index 00000000000..7825416dd1a
--- /dev/null
+++ b/engines/nancy/action/puzzle/decoderpuzzle.cpp
@@ -0,0 +1,411 @@
+/* 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/cursor.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/decoderpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint kWordSize				= 100;	// stride of a target word
+static const uint kSubstitutionSize		= 4;	// stride of either half of a substitution
+
+static const byte kEnterKey				= '\r';
+static const uint kNumRandomLetters		= 25;	// never picks 'Z'
+
+// Backspace, return and the punctuation the original accepts - a narrower set
+// than Common::isPunct()
+static const byte kAcceptedSymbols[] = {
+	'\b', '\r', ' ', '.', ',', '?', '!', '/', '$', '(', ')', '&', ':', ';', '+', '%', '=', '-'
+};
+
+// Accented characters accepted alongside them
+static const byte kAcceptedHighKeys[] = {
+	0x80, 0x9c, 0xa1, 0xbf, 0xc0, 0xc4, 0xc7, 0xc9, 0xd1, 0xd6, 0xdc, 0xdf,
+	0xe0, 0xe1, 0xe2, 0xe4, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xed, 0xee, 0xef,
+	0xf1, 0xf3, 0xf4, 0xf6, 0xf9, 0xfa, 0xfb, 0xfc
+};
+
+// Reads a NUL-padded string of exactly size bytes
+static Common::String readFixedString(Common::SeekableReadStream &stream, uint size) {
+	char *buf = new char[size + 1];
+	stream.read(buf, size);
+	buf[size] = '\0';
+	Common::String ret(buf);
+	delete[] buf;
+	return ret;
+}
+
+static bool isInKeySet(byte key, const byte *set, uint size) {
+	for (uint i = 0; i < size; ++i) {
+		if (key == set[i]) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+bool DecoderPuzzle::isAcceptedKey(byte key) {
+	// Common::isAlnum() and friends are ASCII-only, so the rest is listed out
+	return Common::isAlnum(key) ||
+		isInKeySet(key, kAcceptedSymbols, ARRAYSIZE(kAcceptedSymbols)) ||
+		isInKeySet(key, kAcceptedHighKeys, ARRAYSIZE(kAcceptedHighKeys));
+}
+
+DecoderData *DecoderPuzzle::getPuzzleData() const {
+	DecoderData *data = (DecoderData *)NancySceneState.getPuzzleData(DecoderData::getTag());
+	assert(data);
+	return data;
+}
+
+void DecoderPuzzle::readData(Common::SeekableReadStream &stream) {
+	_saveOutput = stream.readByte();		// 0x3d
+	_restoreOutput = stream.readByte();		// 0x3e
+	_fontID = stream.readUint16LE();		// 0x3f
+	_textX = stream.readSint32LE();			// 0x41
+	_textY = stream.readSint32LE();			// 0x45
+	_maxLength = stream.readSint16LE();		// 0x5d
+
+	// Target words and the typed half of every substitution are uppercased on
+	// load, making both comparisons case-insensitive
+	int16 numWords = stream.readSint16LE();
+	for (int16 i = 0; i < numWords; ++i) {
+		Common::String word = readFixedString(stream, kWordSize);
+		word.toUppercase();
+		_words.push_back(word);
+	}
+
+	// A count of halves, not of pairs
+	int16 numHalves = stream.readSint16LE();
+	_substitutions.resize(numHalves / 2);
+	for (uint i = 0; i < _substitutions.size(); ++i) {
+		Substitution &sub = _substitutions[i];
+		sub.keys = readFixedString(stream, kSubstitutionSize);
+		sub.keys.toUppercase();
+		sub.output = readFixedString(stream, kSubstitutionSize);
+	}
+
+	_typeSound.readData(stream);		// 0x6f
+	_decodeSound.readData(stream);		// 0xc5
+
+	readFilename(stream, _resetMovieName);	// 0x11b
+	readRect(stream, _resetMovieRect);		// 0x11f
+
+	_resetSound.readData(stream);		// 0x12f
+
+	_solveScene.sceneID = stream.readUint16LE();	// 0x1db
+	_solveScene.frameID = stream.readUint16LE();
+	_solveScene.continueSceneSound = kContinueSceneSound;
+	_solveFlag.label = stream.readSint16LE();
+	_solveFlag.flag = stream.readByte();
+
+	_solveSound.readData(stream);		// 0x185
+
+	// Count-prefixed 23-byte hotspot records; the first is the "give up" hotspot
+	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();
+		int16 flagLabel = stream.readSint16LE();
+		byte flagValue = stream.readByte();
+
+		if (i == 0) {
+			_exitHotspot = r;
+			_exitCursorType = cursorType;
+			_exitScene.sceneID = sceneID;
+			_exitScene.frameID = 0;
+			_exitScene.continueSceneSound = kContinueSceneSound;
+			_exitFlag.label = flagLabel;
+			_exitFlag.flag = flagValue;
+		}
+	}
+}
+
+void DecoderPuzzle::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);
+
+	// The decoded line is shared between a scene's records
+	DecoderData *data = getPuzzleData();
+	uint16 sceneID = NancySceneState.getSceneInfo().sceneID;
+	if (_restoreOutput && data->sceneID == sceneID) {
+		_output = data->text;
+	} else {
+		_output.clear();
+	}
+
+	if (_saveOutput) {
+		data->sceneID = sceneID;
+	}
+
+	_pending.clear();
+	_hasPendingKey = false;
+	_decodeSoundPending = false;
+	_resetting = false;
+	_solved = false;
+
+	if (!_resetMovieName.empty()) {
+		_resetMovie.loadFile(_resetMovieName);
+	}
+
+	redraw();
+}
+
+void DecoderPuzzle::onPause(bool paused) {
+	g_nancy->_input->setVKEnabled(!paused);
+	RenderActionRecord::onPause(paused);
+}
+
+void DecoderPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	if (block.names.empty()) {
+		return;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return;
+	}
+
+	SoundDescription desc;
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+}
+
+bool DecoderPuzzle::isSoundBlockPlaying(const RandomSoundBlock &block) const {
+	return !block.names.empty() && g_nancy->_sound->isSoundPlaying((uint16)block.channel);
+}
+
+bool DecoderPuzzle::decodePending(bool &noMatch) {
+	noMatch = false;
+
+	// With no substitution table every keystroke produces a random letter
+	if (_substitutions.empty()) {
+		_output += (char)('A' + g_nancy->_randomSource->getRandomNumber(kNumRandomLetters - 1));
+		_pending.clear();
+		return true;
+	}
+
+	for (uint i = 0; i < _substitutions.size(); ++i) {
+		if (_substitutions[i].keys == _pending) {
+			_output += _substitutions[i].output;
+			_pending.clear();
+			return true;
+		}
+	}
+
+	// Still the start of some substitution: keep collecting keys
+	for (uint i = 0; i < _substitutions.size(); ++i) {
+		if (_substitutions[i].keys.hasPrefix(_pending)) {
+			return false;
+		}
+	}
+
+	noMatch = true;
+	_pending.clear();
+	return false;
+}
+
+void DecoderPuzzle::checkSolved() {
+	for (uint i = 0; i < _words.size(); ++i) {
+		if (_words[i] == _output) {
+			_solved = true;
+			return;
+		}
+	}
+}
+
+void DecoderPuzzle::beginReset() {
+	_output.clear();
+	_pending.clear();
+
+	if (_resetMovie.isVideoLoaded() && _resetMovie.getFrameCount() > 0) {
+		_resetMovie.playRange(0, _resetMovie.getFrameCount() - 1);
+		_resetting = true;
+	}
+
+	redraw();
+}
+
+void DecoderPuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	if (_resetting) {
+		// The animation covers the line while it plays
+		_resetMovie.drawFrame(_drawSurface, Common::Point(_resetMovieRect.left, _resetMovieRect.top));
+	} else if (!_output.empty()) {
+		const Graphics::Font *font = g_nancy->_graphics->getFont(_fontID);
+		if (font) {
+			// The stored y is the text baseline
+			font->drawString(&_drawSurface, _output, _textX, _textY - (int)font->getFontHeight(),
+				_drawSurface.w - _textX, 0);
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void DecoderPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		// Both sounds share a channel, so hold the decode one back
+		if (_decodeSoundPending && !isSoundBlockPlaying(_typeSound)) {
+			playSoundBlock(_decodeSound);
+			_decodeSoundPending = false;
+		}
+
+		if (_solved) {
+			_resetMovie.close();
+			_resetting = false;
+			playSoundBlock(_solveSound);
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_resetting) {
+			if (!isSoundBlockPlaying(_resetSound)) {
+				playSoundBlock(_resetSound);
+			}
+
+			if (_resetMovie.update()) {
+				redraw();
+			}
+
+			if (!_resetMovie.isRangePlaying()) {
+				_resetting = false;
+				redraw();
+			}
+		} else if (_hasPendingKey) {
+			_hasPendingKey = false;
+
+			bool reset = (int)_output.size() > _maxLength || _pendingKey == kEnterKey;
+			if (!reset) {
+				bool noMatch = false;
+				if (decodePending(noMatch)) {
+					if (isSoundBlockPlaying(_typeSound)) {
+						_decodeSoundPending = true;
+					} else {
+						playSoundBlock(_decodeSound);
+					}
+
+					checkSolved();
+					redraw();
+				}
+
+				reset = noMatch;
+			}
+
+			if (reset) {
+				beginReset();
+			}
+		}
+
+		if (_saveOutput) {
+			getPuzzleData()->text = _output;
+		}
+
+		break;
+	case kActionTrigger:
+		// The solve voiceover gets to finish first
+		if (!_exitRequested && isSoundBlockPlaying(_solveSound)) {
+			break;
+		}
+
+		if (_exitRequested) {
+			NancySceneState.setEventFlag(_exitFlag);
+			NancySceneState.changeScene(_exitScene);
+		} else {
+			NancySceneState.setEventFlag(_solveFlag);
+			NancySceneState.changeScene(_solveScene);
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void DecoderPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _solved || _resetting || _hasPendingKey) {
+		return;
+	}
+
+	if (!_exitHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_exitRequested = true;
+			_state = kActionTrigger;
+			return;
+		}
+	}
+
+	for (uint i = 0; i < input.otherKbdInput.size(); ++i) {
+		byte key = (byte)input.otherKbdInput[i].ascii;
+
+		if (!isAcceptedKey(key)) {
+			continue;
+		}
+
+		// The original gets a key code, already uppercase for letters. Accented
+		// characters are left alone, as they are in the loaded substitutions.
+		if (Common::isLower(key)) {
+			key = toupper(key);
+		}
+
+		playSoundBlock(_typeSound);
+		_pending += (char)key;
+		_pendingKey = key;
+		_hasPendingKey = true;
+		break;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/decoderpuzzle.h b/engines/nancy/action/puzzle/decoderpuzzle.h
new file mode 100644
index 00000000000..87cf962e7b5
--- /dev/null
+++ b/engines/nancy/action/puzzle/decoderpuzzle.h
@@ -0,0 +1,125 @@
+/* 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_DECODERPUZZLE_H
+#define NANCY_ACTION_DECODERPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+
+struct DecoderData;
+
+namespace Action {
+
+// Keyboard decoding puzzle, new in Nancy14 (AR 182). Keystrokes collect into a
+// pending sequence; when it matches the typed half of a substitution, the other
+// half is appended to the decoded line. A sequence that is still a prefix of some
+// substitution keeps collecting; one that matches nothing - as does Enter, or
+// overflowing the line - wipes both buffers and plays the carriage-return
+// animation. Solved once the line equals one of the target words.
+//
+// Each scene using it holds two records, one with the substitution table and one
+// with none (every keystroke emits a random letter); they share the decoded line
+// through the puzzle data.
+class DecoderPuzzle : public RenderActionRecord {
+public:
+	DecoderPuzzle() : RenderActionRecord(7) {}
+	virtual ~DecoderPuzzle() {}
+
+	void init() override;
+	void onPause(bool paused) 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 "DecoderPuzzle"; }
+
+	// One entry of the substitution table: typing `keys` emits `output`.
+	struct Substitution {
+		Common::String keys;
+		Common::String output;
+	};
+
+	static bool isAcceptedKey(byte key);
+
+	DecoderData *getPuzzleData() const;
+	void playSoundBlock(const RandomSoundBlock &block);
+	bool isSoundBlockPlaying(const RandomSoundBlock &block) const;
+
+	// Consumes the pending sequence. True if it produced output; sets noMatch
+	// when the sequence cannot lead to any substitution.
+	bool decodePending(bool &noMatch);
+	void checkSolved();
+	void beginReset();
+	void redraw();
+
+	// -- File data --
+	byte _saveOutput = 0;		// 0x3d
+	byte _restoreOutput = 0;	// 0x3e
+	uint16 _fontID = 0;			// 0x3f
+	int32 _textX = 0;			// 0x41
+	int32 _textY = 0;			// 0x45, text baseline
+
+	Common::Array<Common::String> _words;			// target words, uppercased
+	Common::Array<Substitution> _substitutions;		// empty = emit random letters
+	int16 _maxLength = 0;							// 0x5d
+
+	RandomSoundBlock _typeSound;		// 0x6f, keystroke
+	RandomSoundBlock _decodeSound;		// 0xc5, substitution resolved
+
+	Common::Path _resetMovieName;		// 0x11b, carriage-return animation
+	Common::Rect _resetMovieRect;		// 0x11f
+	RandomSoundBlock _resetSound;		// 0x12f, plays while it runs
+
+	SceneChangeDescription _solveScene;	// 0x1db
+	FlagDescription _solveFlag;
+	RandomSoundBlock _solveSound;		// 0x185, plays before the scene change
+
+	// Give-up hotspot, from the count-prefixed 23-byte trailer
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+	FlagDescription _exitFlag;
+
+	// -- Runtime state --
+	MoviePlayer _resetMovie;
+
+	Common::String _output;		// the decoded line
+	Common::String _pending;	// keys typed since the last substitution
+	byte _pendingKey = 0;
+	bool _hasPendingKey = false;
+	bool _decodeSoundPending = false;
+	bool _resetting = false;
+	bool _solved = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_DECODERPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index b61f8897924..0bdaa9babdf 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -31,6 +31,7 @@ MODULE_OBJS = \
   action/puzzle/collisionpuzzle.o \
   action/puzzle/cubepuzzle.o \
   action/puzzle/cuttingpuzzle.o \
+  action/puzzle/decoderpuzzle.o \
   action/puzzle/dotconnectpuzzle.o \
   action/puzzle/drivingpuzzle.o \
   action/puzzle/dropsortpuzzle.o \


Commit: 8eeca4f05393d9a0bfe309fb9e1890cb70e14409
    https://github.com/scummvm/scummvm/commit/8eeca4f05393d9a0bfe309fb9e1890cb70e14409
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:25+03:00

Commit Message:
NANCY: NANCY14: Implement MeterPuzzle

Handles the health meters for the final fight

Changed paths:
  A engines/nancy/action/puzzle/meterpuzzle.cpp
  A engines/nancy/action/puzzle/meterpuzzle.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 5b856f5656c..92949b01e26 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -59,6 +59,7 @@
 #include "engines/nancy/action/puzzle/magnetmazepuzzle.h"
 #include "engines/nancy/action/puzzle/mazechasepuzzle.h"
 #include "engines/nancy/action/puzzle/memorypuzzle.h"
+#include "engines/nancy/action/puzzle/meterpuzzle.h"
 #include "engines/nancy/action/puzzle/mindpuzzle.h"
 #include "engines/nancy/action/puzzle/minigolfpuzzle.h"
 #include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
@@ -510,9 +511,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new HangmanPuzzle();
 	case 178:
 		return new AdjustPuzzle();
-	case 179:	// MeterPuzzle
-		// TODO: not yet implemented
-		return nullptr;
+	case 179:
+		return new MeterPuzzle();
 	case 180:	// BlockingPuzzle
 		// TODO: not yet implemented
 		return nullptr;
diff --git a/engines/nancy/action/puzzle/meterpuzzle.cpp b/engines/nancy/action/puzzle/meterpuzzle.cpp
new file mode 100644
index 00000000000..7107af1c52d
--- /dev/null
+++ b/engines/nancy/action/puzzle/meterpuzzle.cpp
@@ -0,0 +1,125 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/action/puzzle/meterpuzzle.h"
+
+#include "engines/nancy/state/scene.h"
+
+namespace Nancy {
+namespace Action {
+
+void MeterPuzzle::readData(Common::SeekableReadStream &stream) {
+	_mode = stream.readSint16LE();		// 0x00
+	_modeParam = stream.readSint16LE();	// 0x02
+	_modeValue = stream.readSint32LE();	// 0x04
+
+	readFilename(stream, _animName);	// 0x08
+
+	_videoFormat = stream.readUint16LE();	// 0x29
+	_value = stream.readSint16LE();			// 0x2b
+	_firstFrame = stream.readSint16LE();	// 0x2d
+	_lastFrame = stream.readSint16LE();		// 0x2f
+
+	readRect(stream, _srcRect);			// 0x31
+	readRect(stream, _destRect);		// 0x41
+}
+
+void MeterPuzzle::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);
+
+	_animation.loadFile(_animName);
+
+	_displayedValue = -1;
+	redraw();
+}
+
+int32 MeterPuzzle::sampleValue() const {
+	// Modes 1/2 track value-table entry _modeParam; mode 0's source is unused here.
+	if ((_mode == 1 || _mode == 2) && _modeParam != 0xff) {
+		TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+		if (table) {
+			int16 value = table->getValue(_modeParam);
+			return value == kNoTableValue ? 0 : value;	// unset reads as empty
+		}
+	}
+
+	return _value;
+}
+
+int MeterPuzzle::computeFrame() const {
+	int frameCount = _animation.getFrameCount();
+	if (frameCount <= 0 || _modeValue == 0) {
+		return 0;
+	}
+
+	// frame = round(frameCount * value / modeValue).
+	int frame = (int)((double)frameCount * (double)sampleValue() / (double)_modeValue + 0.5);
+	return CLIP(frame, 0, frameCount - 1);
+}
+
+void MeterPuzzle::redraw() {
+	if (!_animation.isVideoLoaded()) {
+		return;
+	}
+
+	int frame = computeFrame();
+	if (frame == _displayedValue) {
+		return;
+	}
+
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	_animation.goToFrame(frame);
+	// _srcRect is unused
+	_animation.drawFrame(_drawSurface, Common::Point(_destRect.left, _destRect.top));
+
+	_displayedValue = frame;
+	_needsRedraw = true;
+}
+
+void MeterPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		// Repaint when the tracked value's frame changes.
+		redraw();
+		break;
+	default:
+		break;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/meterpuzzle.h b/engines/nancy/action/puzzle/meterpuzzle.h
new file mode 100644
index 00000000000..cf6ecb5a42b
--- /dev/null
+++ b/engines/nancy/action/puzzle/meterpuzzle.h
@@ -0,0 +1,76 @@
+/* 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_METERPUZZLE_H
+#define NANCY_ACTION_METERPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// A meter/gauge display, new in Nancy14 (AR 179): shows one frame of a bar
+// animation. In modes 1/2 the value is a shared value table entry, driven by
+// BlockingPuzzle (AR 180). In Nancy14, it handles the health meters for the
+// final fight.
+class MeterPuzzle : public RenderActionRecord {
+public:
+	MeterPuzzle() : RenderActionRecord(7) {}
+	virtual ~MeterPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "MeterPuzzle"; }
+
+	void redraw();
+	int32 sampleValue() const;	// the tracked value (a value-table entry in modes 1/2)
+	int computeFrame() const;	// the frame for the current value
+
+	// -- File data --
+	int16 _mode = 0;			// 0x00 - mode (0/1/2)
+	int16 _modeParam = 0;		// 0x02
+	int32 _modeValue = 0;		// 0x04
+
+	Common::Path _animName;		// 0x08 - the bar animation
+	uint16 _videoFormat = 0;	// 0x29
+	int16 _value = 0;			// 0x2b - fallback value
+	int16 _firstFrame = 0;		// 0x2d
+	int16 _lastFrame = 0;		// 0x2f
+	Common::Rect _srcRect;		// 0x31
+	Common::Rect _destRect;		// 0x41 - draw position
+
+	// -- Runtime state --
+	MoviePlayer _animation;
+	int16 _displayedValue = -1;	// last drawn frame
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_METERPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 0bdaa9babdf..8e3fc2335c0 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -43,6 +43,7 @@ MODULE_OBJS = \
   action/puzzle/mazechasepuzzle.o \
   action/puzzle/matchpuzzle.o \
   action/puzzle/memorypuzzle.o \
+  action/puzzle/meterpuzzle.o \
   action/puzzle/mindpuzzle.o \
   action/puzzle/minigolfpuzzle.o \
   action/puzzle/mirrorlightpuzzle.o \


Commit: 1807329275bf6f6d5dfaa9fdc34c9830e17e959d
    https://github.com/scummvm/scummvm/commit/1807329275bf6f6d5dfaa9fdc34c9830e17e959d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:26+03:00

Commit Message:
NANCY: NANCY14: Add PuzzleData records for DecoderPuzzle

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


diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 43f9006b479..306ed573341 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -514,6 +514,11 @@ void HangmanData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void DecoderData::synchronize(Common::Serializer &ser) {
+	ser.syncAsUint16LE(sceneID);
+	ser.syncString(text);
+}
+
 void DrivingData::synchronize(Common::Serializer &ser) {
 	ser.syncAsByte(valid);
 	ser.syncAsSint32LE(carX);
@@ -534,6 +539,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new WordFindPuzzleData();
 	case HangmanData::getTag():
 		return new HangmanData();
+	case DecoderData::getTag():
+		return new DecoderData();
 	case SliderPuzzleData::getTag():
 		return new SliderPuzzleData();
 	case RippedLetterPuzzleData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index b3cb0a899e7..e6261e4435d 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -399,6 +399,20 @@ struct HangmanData : public PuzzleData {
 	Common::Array<Common::String> usedWords;
 };
 
+// Nancy14+ DecoderPuzzle (AR 182). The decoded line typed so far, plus the scene
+// it belongs to. A scene's two records (with and without the substitution table)
+// hand the line to each other through here; other scenes start empty.
+struct DecoderData : public PuzzleData {
+	DecoderData() {}
+	virtual ~DecoderData() {}
+
+	static constexpr uint32 getTag() { return MKTAG('D', 'C', 'D', 'R'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	uint16 sceneID = kNoScene;
+	Common::String text;
+};
+
 // Nancy12 DrivingPuzzle (AR 160). The car's position, heading and tire state persist
 // across visits to the driving map (driving into a location, then coming back), matching
 // the original's retainState mechanism, which saves the car to globals every frame and




More information about the Scummvm-git-logs mailing list