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

bluegr noreply at scummvm.org
Sun Jun 21 22:02:22 UTC 2026


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

Summary:
3c84d71946 NANCY: Data chunk handling updates for Nancy 10 and 11
3f35533111 NANCY: Implement the random movie playing feature for Nancy 11+
33273628ce NANCY: Remove leftover debug code in GridMapPuzzle
940a57fd1b NANCY: Implement TypingQuizPuzzle for Nancy11
10e9821f6b NANCY: Implement new MemoryPuzzle functionality for Nancy11+
979db279fc NANCY: Implement new OrderingPuzzle functionality for Nancy11
6267ae7f62 NANCY: Don't capture events in the text box when it's not visible
cf19cffcfa NANCY: Remove an unneeded assignment in MemoryPuzzle
3f823089d8 NANCY: Implement new functionality for BulPuzzle in Nancy11
b41dc6fb3b NANCY: Add new functionality for CollisionPuzzle in Nancy11
f6315c643f NANCY: Implement the FadeSoundToSilence AR for Nancy11


Commit: 3c84d71946ad5da6f3ea4c270eced4add1ef81d7
    https://github.com/scummvm/scummvm/commit/3c84d71946ad5da6f3ea4c270eced4add1ef81d7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:53+03:00

Commit Message:
NANCY: Data chunk handling updates for Nancy 10 and 11

- Remove the adjustments in the TBOX offsets
- Implement handling for the SCTB (Scrollable text box) chunk for
  Nancy 11 and newer
- Skip two new fields added in the UICL chunk. This fixes the
  cellphone for Nancy 11

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 482e0c69890..3cd219eb518 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -253,9 +253,10 @@ TBOX::TBOX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	scrollbarDefaultPos.y = chunkStream->readUint16LE();
 	scrollbarMaxScroll = chunkStream->readUint16LE();
 
-	upOffset = chunkStream->readUint16LE() + 1;
+	uint16 legacyOffsetAdjust = g_nancy->getGameType() < kGameTypeNancy10 ? 1 : 0;
+	upOffset = chunkStream->readUint16LE() + legacyOffsetAdjust;
 	downOffset = chunkStream->readUint16LE();
-	leftOffset = chunkStream->readUint16LE() - 1;
+	leftOffset = chunkStream->readUint16LE() - legacyOffsetAdjust;
 	rightOffset = chunkStream->readUint16LE();
 
 	if (g_nancy->getGameType() <= kGameTypeNancy9) {
@@ -872,8 +873,8 @@ MARK::MARK(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 }
 
 SCTB::SCTB(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
-	readFilename(*chunkStream, imageName);
-	// TODO
+	readUIPopupHeader(*chunkStream, header);
+	readRect(*chunkStream, restoreSrcRect);
 }
 
 SHUI::SHUI(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
@@ -994,6 +995,13 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	readRect(*chunkStream, connectingSpriteSrc);
 	readRect(*chunkStream, connectingSpriteSrcAlt);
 	readRect(*chunkStream, connectingSpriteDest);
+
+	if (g_nancy->getGameType() >= kGameTypeNancy11) {
+		// TODO: Looks to be a new coordinate - values (548, 50)
+		chunkStream->skip(4);
+		chunkStream->skip(4);
+	}
+
 	readRect(*chunkStream, onlineHeading.srcRect);
 	readRect(*chunkStream, onlineHeading.destRect);
 	readRect(*chunkStream, fullEmptyScreenSrc);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 11d29695d9d..bf430b4a2e4 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -506,13 +506,13 @@ struct SHUI : public EngineData {
 	Common::Array<Common::Rect> _sliderRects;	// Slider rects
 };
 
-// ScrollTextBox - introduced in Nancy 11. Configuration for the scrollable
-// text-box UI used for long textbox content (e.g. journal / scheduled-talk
-// panels) that needs to scroll within a fixed frame.
+// ScrollTextBox - introduced in Nancy 11. Standard UI popup header,
+// followed by the rect to restore when the text box is closed.
 struct SCTB : public EngineData {
 	SCTB(Common::SeekableReadStream *chunkStream);
 
-	Common::Path imageName;
+	UIPopupHeader header;
+	Common::Rect restoreSrcRect;
 };
 
 enum TaskButton {
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 243c95591d9..ff4da9ff9bb 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -487,12 +487,12 @@ void NancyEngine::bootGameEngine() {
 	LOAD_BOOT(TASK)	// Task bar (main UI)
 	LOAD_BOOT(UIIV)	// Inventory UI
 	LOAD_BOOT(UICO)	// Conversation UI
-	LOAD_BOOT(UICL) // Cell phone UI
-	LOAD_BOOT(UIBW) // Web browser UI
-	LOAD_BOOT(UINB) // Notebook UI
+	LOAD_BOOT(UICL)	// Cell phone UI
+	LOAD_BOOT(UIBW)	// Web browser UI
+	LOAD_BOOT(UINB)	// Notebook UI
 
 	// Nancy 11+
-	LOAD_BOOT(SCTB)	// Scheduled talk (?) UI
+	LOAD_BOOT(SCTB)	// Scrollable text box UI
 
 	// Nancy 12+
 	// HINT chunk has been removed
@@ -503,7 +503,7 @@ void NancyEngine::bootGameEngine() {
 	// RCPR and RCLB chunks have been removed
 	// LOAD_BOOT(MMIX)
 
-	// Nancy 14+
+	// Nancy 14
 	// LOAD_BOOT(UICM)
 
 	// Nancy 15+


Commit: 3f355331111a51c4ef7bde975791e8e2bcd5c854
    https://github.com/scummvm/scummvm/commit/3f355331111a51c4ef7bde975791e8e2bcd5c854
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:55+03:00

Commit Message:
NANCY: Implement the random movie playing feature for Nancy 11+

Nancy 11 and newer games used movies that played randomly when talking
to certain characters. These movies are loaded when a conversation
starts, and are unloaded when it ends by the newly introduced AR
PlayRandomMovieControl

Changed paths:
    engines/nancy/action/actionmanager.cpp
    engines/nancy/action/actionrecord.h
    engines/nancy/action/conversation.cpp
    engines/nancy/action/secondarymovie.cpp
    engines/nancy/action/secondarymovie.h
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 7cd07675549..8802874824f 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -568,10 +568,14 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
 }
 
 void ActionManager::clearActionRecords() {
-	for (auto &r : _records) {
-		delete r;
+	for (auto it = _records.begin(); it != _records.end(); ) {
+		if ((*it)->isPersistentAcrossScenes()) {
+			++it;
+			continue;
+		}
+		delete *it;
+		it = _records.erase(it);
 	}
-	_records.clear();
 	_activatedRecordsThisFrame.clear();
 	_previousRecordWasExecuted = false;
 }
diff --git a/engines/nancy/action/actionrecord.h b/engines/nancy/action/actionrecord.h
index 887fbc02afa..5b18d164f0e 100644
--- a/engines/nancy/action/actionrecord.h
+++ b/engines/nancy/action/actionrecord.h
@@ -123,6 +123,9 @@ public:
 	// Used for handling kCursorType dependency
 	virtual bool canHaveHotspot() const { return false; }
 
+	// Records returning true survive Scene::clearSceneData / ActionManager::clearActionRecords.
+	virtual bool isPersistentAcrossScenes() const { return false; }
+
 protected:
 	void finishExecution();
 
diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 4c4a4a609e8..1fc655a56e8 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -32,6 +32,7 @@
 #include "engines/nancy/resource.h"
 
 #include "engines/nancy/action/conversation.h"
+#include "engines/nancy/action/secondarymovie.h"
 
 #include "engines/nancy/state/scene.h"
 
@@ -383,6 +384,13 @@ void ConversationSound::execute() {
 						g_nancy->_sound->playSound(_responseGenericSound);
 					}
 
+					// Nancy 11+: play a fresh random sequence as the character's response anim.
+					if (PlaySecondaryMovie *active = NancySceneState.getActiveMovie()) {
+						if (active->_isRandom) {
+							active->playRandomSequence();
+						}
+					}
+
 					_state = kActionTrigger;
 				}
 			}
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 9e450a4e1e8..15b9e5dfe0d 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -30,12 +30,28 @@
 
 #include "common/random.h"
 #include "common/serializer.h"
+#include "common/system.h"
 
 #include "video/bink_decoder.h"
 
 namespace Nancy {
 namespace Action {
 
+PlaySecondaryMovie::PlaySecondaryMovie(bool isRandom)
+		: RenderActionRecord(8), _isRandom(isRandom) {
+	if (_isRandom) {
+		NancySceneState.notifyRandomMovieARLoaded();
+	}
+}
+
+void PlaySecondaryMovie::resetDecoder() {
+	if (_videoType == kVideoPlaytypeAVF) {
+		_decoder.reset(new AVFDecoder());
+	} else {
+		_decoder.reset(new Video::BinkDecoder());
+	}
+}
+
 PlaySecondaryMovie::~PlaySecondaryMovie() {
 	if (NancySceneState.getActiveMovie() == this) {
 		NancySceneState.setActiveMovie(nullptr);
@@ -49,10 +65,10 @@ PlaySecondaryMovie::~PlaySecondaryMovie() {
 void PlaySecondaryMovie::readRandomSequence(Common::Serializer &ser, RandomSequence &seq) {
 	readFilename(ser, seq.name);
 	ser.syncAsUint16LE(seq.startFrame);
-	ser.syncAsUint16LE(seq.unknown_0x23);
+	ser.syncAsUint16LE(seq.lastFrame);
 	ser.syncAsSint32LE(seq.minPauseMs);
 	ser.syncAsSint32LE(seq.maxPauseMs);
-	ser.syncAsUint16LE(seq.unknown_0x2D);
+	ser.syncAsUint16LE(seq.stayWeight);
 
 	uint16 nextCount = 0;
 	ser.syncAsUint16LE(nextCount);
@@ -60,7 +76,7 @@ void PlaySecondaryMovie::readRandomSequence(Common::Serializer &ser, RandomSeque
 	seq.nextSequences.resize(nextCount);
 	for (uint i = 0; i < nextCount; ++i) {
 		readFilename(ser, seq.nextSequences[i].name);
-		ser.syncAsUint16LE(seq.nextSequences[i].unknown);
+		ser.syncAsUint16LE(seq.nextSequences[i].weight);
 	}
 }
 
@@ -103,8 +119,10 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
 
 		if (startIdx >= 0) {
 			const RandomSequence &src = _sequences[startIdx];
+			_activeSequenceIndex = startIdx;
 			_videoName = src.name;
 			_firstFrame = src.startFrame;
+			_lastFrame = src.lastFrame;
 			_videoType = kVideoPlaytypeBink;
 			_videoFormat = kLargeVideoFormat;
 			_videoSceneChange = kMovieNoSceneChange;
@@ -116,6 +134,102 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
 	_sound.name = "NO SOUND";
 }
 
+bool PlaySecondaryMovie::activateRandomSequence(int index) {
+	if (index < 0 || index >= (int)_sequences.size()) {
+		return false;
+	}
+
+	const RandomSequence &src = _sequences[index];
+	_activeSequenceIndex = index;
+	_videoName = src.name;
+	_firstFrame = src.startFrame;
+	_lastFrame = src.lastFrame;
+
+	// Reload the decoder with the new movie. The original engine
+	// auto-detects AVF vs Bink from disk; we honour the existing
+	// _videoType but fall back to the other if needed.
+	resetDecoder();
+
+	Common::Path withExt = _videoName.append(_videoType == kVideoPlaytypeAVF ? ".avf" : ".bik");
+	if (!_decoder->loadFile(withExt)) {
+		_videoType = _videoType == kVideoPlaytypeAVF ? kVideoPlaytypeBink : kVideoPlaytypeAVF;
+		resetDecoder();
+		withExt = _videoName.append(_videoType == kVideoPlaytypeAVF ? ".avf" : ".bik");
+		if (!_decoder->loadFile(withExt)) {
+			warning("PlayRandomMovie: couldn't load %s", _videoName.toString().c_str());
+			return false;
+		}
+	}
+
+	_isFinished = false;
+	_curViewportFrame = -1;	// force visibility re-evaluation next tick
+	return true;
+}
+
+void PlaySecondaryMovie::playRandomSequence() {
+	if (!_isRandom || _sequences.empty()) {
+		return;
+	}
+	int picked = g_nancy->_randomSource->getRandomNumber(_sequences.size() - 1);
+	_randomChainState = kRandomPlaying;
+	_randomStopRequested = false;
+	activateRandomSequence(picked);
+}
+
+int PlaySecondaryMovie::rollNextSequence() {
+	if (_activeSequenceIndex < 0 || _activeSequenceIndex >= (int)_sequences.size()) {
+		return -1;
+	}
+
+	const RandomSequence &seq = _sequences[_activeSequenceIndex];
+
+	uint32 totalWeight = seq.stayWeight;
+	for (const NextSequenceRef &ns : seq.nextSequences) {
+		totalWeight += ns.weight;
+	}
+
+	if (totalWeight == 0) {
+		// No weights at all: stay indefinitely without a pause.
+		_randomChainState = kRandomPaused;
+		_randomPauseEndTime = g_system->getMillis() + 1000;	// re-check in 1s
+		return -1;
+	}
+
+	uint32 roll = g_nancy->_randomSource->getRandomNumber(totalWeight - 1);
+
+	if (roll < seq.stayWeight) {
+		int32 pauseMs = seq.minPauseMs;
+		if (seq.maxPauseMs > seq.minPauseMs) {
+			pauseMs += g_nancy->_randomSource->getRandomNumber(seq.maxPauseMs - seq.minPauseMs - 1);
+		}
+		_randomPauseEndTime = g_system->getMillis() + (uint32)MAX<int32>(0, pauseMs);
+		_randomChainState = kRandomPaused;
+		setVisible(false);
+		if (_decoder) {
+			_decoder->pauseVideo(true);
+		}
+		return -1;
+	}
+
+	uint32 cumulative = seq.stayWeight;
+	for (uint i = 0; i < seq.nextSequences.size(); ++i) {
+		cumulative += seq.nextSequences[i].weight;
+		if (roll < cumulative) {
+			// Look up the named sequence in _sequences[].
+			for (uint j = 0; j < _sequences.size(); ++j) {
+				if (_sequences[j].name == seq.nextSequences[i].name) {
+					return (int)j;
+				}
+			}
+			warning("PlayRandomMovie: next-sequence \"%s\" not part of this AR",
+				seq.nextSequences[i].name.toString().c_str());
+			return -1;
+		}
+	}
+
+	return -1;
+}
+
 void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
 	Common::Serializer ser(&stream, nullptr);
 	ser.setVersion(g_nancy->getGameType());
@@ -187,10 +301,7 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
 
 void PlaySecondaryMovie::init() {
 	if (!_decoder) {
-		if (_videoType == kVideoPlaytypeAVF)
-			_decoder.reset(new AVFDecoder());
-		else
-			_decoder.reset(new Video::BinkDecoder());
+		resetDecoder();
 	}
 
 	if (!_decoder->isVideoLoaded()) {
@@ -220,10 +331,7 @@ void PlaySecondaryMovie::init() {
 
 void PlaySecondaryMovie::onPause(bool pause) {
 	if (!_decoder) {
-		if (_videoType == kVideoPlaytypeAVF)
-			_decoder.reset(new AVFDecoder());
-		else
-			_decoder.reset(new Video::BinkDecoder());
+		resetDecoder();
 	}
 
 	_decoder->pauseVideo(pause);
@@ -274,6 +382,26 @@ void PlaySecondaryMovie::execute() {
 
 		// fall through
 	case kRun: {
+		// 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.
+		if (_isRandom && _randomChainState == kRandomPaused) {
+			if (_randomStopRequested) {
+				_state = kActionTrigger;
+				break;
+			}
+			if (g_system->getMillis() < _randomPauseEndTime) {
+				break;
+			}
+			_randomChainState = kRandomPlaying;
+			int picked = rollNextSequence();
+			if (picked >= 0) {
+				activateRandomSequence(picked);
+			}
+			// If picked < 0, rollNextSequence() set up another pause.
+			break;
+		}
+
 		int newFrame = NancySceneState.getSceneInfo().frameID;
 
 		if (newFrame != _curViewportFrame) {
@@ -289,8 +417,9 @@ void PlaySecondaryMovie::execute() {
 			if (activeFrame != -1) {
 				_screenPosition = _videoDescs[activeFrame].destRect;
 				setVisible(true);
-			} else if (_isRandom && _videoDescs.empty()) {
-				// No hotspots: play full-viewport instead of gating on a frame match.
+			} else if (_isRandom) {
+				// Random movies aren't gated on hotspot/viewport-frame
+				// matches the way regular PSMs are: play full viewport.
 				_screenPosition = NancySceneState.getViewport().getBounds();
 				setVisible(true);
 			} else {
@@ -340,12 +469,25 @@ void PlaySecondaryMovie::execute() {
 			(_decoder->getCurFrame() == _firstFrame && _playDirection == kPlayMovieReverse) ||
 			_decoder->endOfVideo()) {
 
-			// Stop the video and block it from starting again, but also wait for
-			// sound to end before changing state
 			_decoder->pauseVideo(true);
 			_isFinished = true;
 
-			if (!g_nancy->_sound->isSoundPlaying(_sound)) {
+			if (_isRandom) {
+				// Sequence finished: roll for next. If stop was requested
+				// by a PlayRandomMovieControl, wind the AR down normally.
+				if (_randomStopRequested) {
+					_state = kActionTrigger;
+				} else {
+					int picked = rollNextSequence();
+					if (picked >= 0) {
+						activateRandomSequence(picked);
+					}
+					// Otherwise the chain entered the paused state; no
+					// state-trigger transition.
+				}
+			} 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
 				g_nancy->_sound->stopSound(_sound);
 				_state = kActionTrigger;
 			}
@@ -381,10 +523,10 @@ void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
 }
 
 void PlayRandomMovieControl::execute() {
-	//PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
-	//if (target && target->_isRandom) {
-	//	target->stopRandom();
-	//}
+	PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
+	if (target && target->_isRandom) {
+		target->stopRandom();
+	}
 
 	_sceneChange.execute();
 	finishExecution();
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 0c8f35ad3e7..a251bde1060 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -62,24 +62,28 @@ public:
 		FlagDescription flagDesc;
 	};
 
-	// Name of the next sequence to chain to once the current one finishes.
+	// Name of the next sequence to chain to once the current one finishes,
+	// plus its selection weight in the weighted random pick.
 	struct NextSequenceRef {
 		Common::Path name;
-		uint16 unknown = 0;
+		uint16 weight = 0;
 	};
 
 	// `name` is both the sequence id and the movie filename.
 	struct RandomSequence {
 		Common::Path name;
 		uint16 startFrame = 0;
-		uint16 unknown_0x23 = 0;
+		uint16 lastFrame = 0;
 		int32 minPauseMs = 0;
 		int32 maxPauseMs = 0;
-		uint16 unknown_0x2D = 0;
+		// Weight assigned to "stay on this sequence" in the weighted random
+		// pick. A roll inside [0, stayWeight) means "don't transition";
+		// instead pause for [minPauseMs, maxPauseMs] and re-roll.
+		uint16 stayWeight = 0;
 		Common::Array<NextSequenceRef> nextSequences;
 	};
 
-	PlaySecondaryMovie(bool isRandom = false) : RenderActionRecord(8), _isRandom(isRandom) {}
+	PlaySecondaryMovie(bool isRandom = false);
 	virtual ~PlaySecondaryMovie();
 
 	void init() override;
@@ -119,8 +123,27 @@ public:
 	uint16 _randomPlayerCursorAllowed = kPlayerCursorAllowed;
 	Common::Array<RandomSequence> _sequences;
 
+	// Chain state. After a sequence's movie finishes the engine rolls a
+	// weighted pick: "stay" -> enter pause for a random duration and
+	// re-roll; valid next-sequence -> swap to that sequence's movie.
+	enum RandomChainState { kRandomPlaying, kRandomPaused };
+	int _activeSequenceIndex = -1;
+	RandomChainState _randomChainState = kRandomPlaying;
+	uint32 _randomPauseEndTime = 0;
+	bool _randomStopRequested = false;
+
+	// Called by PlayRandomMovieControl::execute() to wind down the AR.
+	void stopRandom() { _randomStopRequested = true; }
+
+	// Pick & start a fresh random sequence. No-op when not a random AR.
+	void playRandomSequence();
+
 	bool isViewportRelative() const override { return true; }
 
+	bool isPersistentAcrossScenes() const override {
+		return _isRandom && !_isDone && !_randomStopRequested;
+	}
+
 protected:
 	Common::String getRecordTypeName() const override {
 		return _isRandom ? "PlayRandomMovie" : "PlaySecondaryMovie";
@@ -131,6 +154,18 @@ protected:
 	void readRandomMovieData(Common::Serializer &ser, Common::SeekableReadStream &stream);
 	void readRandomSequence(Common::Serializer &ser, RandomSequence &seq);
 
+	// (Re)create _decoder as an AVFDecoder or BinkDecoder matching _videoType.
+	void resetDecoder();
+
+	// Apply a RandomSequence's playback config to the PSM flat fields
+	// and reload the decoder. Returns true on success.
+	bool activateRandomSequence(int index);
+
+	// Pick the next sequence (or "stay") per the weighted random rules.
+	// Returns -1 if "stay" was picked (and sets up the pause state),
+	// or the chosen sequence index otherwise.
+	int rollNextSequence();
+
 	Graphics::ManagedSurface _fullFrame;
 	int _curViewportFrame = -1;
 	bool _isFinished = false;
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 2c268d40fdd..503c202f03d 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -36,6 +36,8 @@
 #include "engines/nancy/state/scene.h"
 #include "engines/nancy/state/map.h"
 
+#include "engines/nancy/action/secondarymovie.h"
+
 #include "engines/nancy/ui/button.h"
 #include "engines/nancy/ui/ornaments.h"
 #include "engines/nancy/ui/clock.h"
@@ -1398,6 +1400,16 @@ void Scene::clearSceneData() {
 	}
 
 	clearLogicConditions();
+
+	// Stop a leftover random movie if the outgoing scene didn't include
+	// its own PSM(isRandom) AR (so it doesn't bleed into the next scene).
+	if (_activeMovie && _activeMovie->isPersistentAcrossScenes() && !_hadRandomMovieARThisScene) {
+		_activeMovie->stopRandom();
+	}
+	_hadRandomMovieARThisScene = false;
+
+	bool clearActiveMovie = _activeMovie && !_activeMovie->isPersistentAcrossScenes();
+
 	_actionManager.clearActionRecords();
 
 	if (_lightning) {
@@ -1413,7 +1425,10 @@ void Scene::clearSceneData() {
 	}
 
 	_activeConversation = nullptr;
-	_activeMovie = nullptr;
+
+	if (clearActiveMovie) {
+		_activeMovie = nullptr;
+	}
 }
 
 void Scene::clearPuzzleData() {
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 8a685f55be8..049557d6f61 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -193,6 +193,9 @@ public:
 
 	void setActiveMovie(Action::PlaySecondaryMovie *activeMovie);
 	Action::PlaySecondaryMovie *getActiveMovie();
+
+	// Called when a PSM(isRandom) AR is loaded — drives stale-chain cleanup.
+	void notifyRandomMovieARLoaded() { _hadRandomMovieARThisScene = true; }
 	void setActiveConversation(Action::ConversationSound *activeConversation);
 	Action::ConversationSound *getActiveConversation();
 
@@ -320,6 +323,10 @@ private:
 	Action::PlaySecondaryMovie *_activeMovie;
 	Action::ConversationSound *_activeConversation;
 
+	// Set by notifyRandomMovieARLoaded; checked in clearSceneData to wind
+	// down a persistent random-movie whose scene chain is over.
+	bool _hadRandomMovieARThisScene = false;
+
 	// Contains a screenshot of the Scene state from the last time it was exited
 	Graphics::ManagedSurface _lastScreenshot;
 


Commit: 33273628ce509e43c8b2dd4d8a566e8f2bea4084
    https://github.com/scummvm/scummvm/commit/33273628ce509e43c8b2dd4d8a566e8f2bea4084
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:56+03:00

Commit Message:
NANCY: Remove leftover debug code in GridMapPuzzle

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


diff --git a/engines/nancy/action/puzzle/gridmappuzzle.cpp b/engines/nancy/action/puzzle/gridmappuzzle.cpp
index 21d1ccbc0f4..bb1659be529 100644
--- a/engines/nancy/action/puzzle/gridmappuzzle.cpp
+++ b/engines/nancy/action/puzzle/gridmappuzzle.cpp
@@ -34,13 +34,6 @@ namespace Nancy {
 namespace Action {
 
 void GridMapPuzzle::readData(Common::SeekableReadStream &stream) {
-	uint pos = stream.pos();
-	Common::DumpFile d;
-	d.open("nancy10_gridmappuzzle.dat");
-	d.writeStream(&stream, stream.size());
-	d.close();
-	stream.seek(pos);
-
 	readFilename(stream, _boardImageName);
 	readFilename(stream, _cursorImageName);
 


Commit: 940a57fd1be1a9c335afedf097d3377d86428a5d
    https://github.com/scummvm/scummvm/commit/940a57fd1be1a9c335afedf097d3377d86428a5d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:57+03:00

Commit Message:
NANCY: Implement TypingQuizPuzzle for Nancy11

In this puzzle, lettered balloons appear at fixed positions; the
player pops a balloon by typing its character before it floats away.
The puzzle runs for a fixed time limit; the score is a typing rate
(characters per minute).If the final rate meets the target, the win
scene fires; otherwise a default scene fires (with a different event
flag depending on whether a partial threshold was reached).

Unrelated to the text-entry QuizPuzzle (231).

Changed paths:
  A engines/nancy/action/puzzle/typingquizpuzzle.cpp
  A engines/nancy/action/puzzle/typingquizpuzzle.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 c0efd860108..506541bbbe7 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -74,6 +74,7 @@
 #include "engines/nancy/action/puzzle/towerpuzzle.h"
 #include "engines/nancy/action/puzzle/turningpuzzle.h"
 #include "engines/nancy/action/puzzle/twodialpuzzle.h"
+#include "engines/nancy/action/puzzle/typingquizpuzzle.h"
 #include "engines/nancy/action/puzzle/whalesurvivorpuzzle.h"
 
 #include "engines/nancy/state/scene.h"
@@ -502,11 +503,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 244:
 		return new GridMapPuzzle();
 	// -- Nancy 11 and up --
-	case 245:	// Nancy11
-		warning("TypingQuizPuzzle");	// TODO
-		return nullptr;
-	case 246:	// Nancy11
-		warning("MatchPuzzle246");	// TODO
+	case 245:
+		return new TypingQuizPuzzle();
+	case 246:
+		warning("CardGamePuzzle");	// TODO
 		return nullptr;
 	default:
 		warning("Unknown action record type %d", type);
diff --git a/engines/nancy/action/puzzle/typingquizpuzzle.cpp b/engines/nancy/action/puzzle/typingquizpuzzle.cpp
new file mode 100644
index 00000000000..24a6c5f2d4c
--- /dev/null
+++ b/engines/nancy/action/puzzle/typingquizpuzzle.cpp
@@ -0,0 +1,464 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/font.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/typingquizpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// Persists the previous typing rate so the adaptive target can scale across plays.
+static int s_lastScore = 0;
+
+static char toAsciiUpper(char c) {
+	return (c >= 'a' && c <= 'z') ? (char)(c - ('a' - 'A')) : c;
+}
+
+static bool caseFoldEquals(char a, char b) {
+	return toAsciiUpper(a) == toAsciiUpper(b);
+}
+
+void TypingQuizPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);          // 0x000
+
+	_numImageRects = stream.readUint16LE();    // 0x021
+	for (uint i = 0; i < kMaxImageRects; ++i)
+		readRect(stream, _balloonSrcRects[i]); // 0x023
+
+	readRect(stream, _poppedSrcRect);          // 0x163
+
+	_numPositions = stream.readUint16LE();     // 0x173
+	for (uint i = 0; i < kMaxBalloons; ++i)
+		readRect(stream, _positionRects[i]);   // 0x175
+
+	for (uint i = 0; i < kNumDigits; ++i)
+		readRect(stream, _scoreDigitRects[i]); // 0x2b5
+	for (uint i = 0; i < kNumDigits; ++i)
+		readRect(stream, _timerDigitRects[i]); // 0x355
+
+	stream.skip(16);                           // 0x3f5 timer bounding rect (unused)
+
+	_scoreDest.x = stream.readSint32LE();      // 0x405
+	_scoreDest.y = stream.readSint32LE();      // 0x409
+	_timerDest.x = stream.readSint32LE();      // 0x40d
+	_timerDest.y = stream.readSint32LE();      // 0x411
+	stream.skip(8);                            // 0x415 score/timer clip widths (unused)
+
+	readRect(stream, _passedMsgSrcRect);       // 0x41d
+	_passedMsgDuration = stream.readUint32LE();// 0x42d
+
+	_caseSensitive = stream.readByte() != 0;   // 0x431
+	_allowDigits   = stream.readByte() != 0;   // 0x432
+	_keyboardMode  = stream.readByte();        // 0x433
+	stream.read(_allowedChars, kAllowedCharsLen); // 0x434
+
+	_balloonLifeMin = stream.readUint16LE();   // 0x452
+	_balloonLifeMax = stream.readUint16LE();   // 0x454
+	_minBalloons    = stream.readUint16LE();   // 0x456
+	_maxBalloons    = stream.readUint16LE();   // 0x458
+	_timeLimit      = stream.readUint16LE();   // 0x45a
+	stream.skip(2);                            // 0x45c (unused)
+	_scoreThreshold     = stream.readUint16LE(); // 0x45e
+	_targetScore        = stream.readUint16LE(); // 0x460
+	_targetScorePercent = stream.readUint16LE(); // 0x462
+	_minTargetScore     = stream.readUint16LE(); // 0x464
+
+	_popSound.readNormal(stream);              // 0x466
+	_wrongSound.readNormal(stream);            // 0x497
+	_escapeSound.readNormal(stream);           // 0x4c8
+
+	_winScene.readData(stream);                // 0x4f9 (20 bytes)
+	_winScene.continueSceneSound = stream.readUint16LE(); // 0x50d
+	_winFlag = stream.readSint16LE();          // 0x50f
+
+	_winSound.readNormal(stream);              // 0x511
+
+	_defaultScene.readData(stream);            // 0x542 (20 bytes)
+	_defaultScene.continueSceneSound = stream.readUint16LE(); // 0x556
+	_flagThreshold = stream.readSint16LE();    // 0x558
+	_flagFail      = stream.readSint16LE();    // 0x55a
+
+	stream.skip(18);                           // 0x55c (trailing, unused)
+
+	if (_numImageRects > kMaxImageRects)
+		_numImageRects = kMaxImageRects;
+	if (_numPositions > (uint16)kMaxBalloons)
+		_numPositions = kMaxBalloons;
+}
+
+void TypingQuizPuzzle::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(vpBounds.width(), vpBounds.height(),
+	                    g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+	_image.setTransparentColor(g_nancy->_graphics->getTransColor());
+
+	if (_popSound.name != "NO SOUND")
+		g_nancy->_sound->loadSound(_popSound);
+	if (_wrongSound.name != "NO SOUND")
+		g_nancy->_sound->loadSound(_wrongSound);
+	if (_escapeSound.name != "NO SOUND")
+		g_nancy->_sound->loadSound(_escapeSound);
+
+	// Adaptive target: scale the threshold to the previous run's typing rate
+	if (_targetScorePercent < 1 || s_lastScore < 1) {
+		_effectiveTarget = _targetScore;
+	} else {
+		_effectiveTarget = (uint16)(((int)s_lastScore * (int)_targetScorePercent) / 100);
+		if (_effectiveTarget <= _minTargetScore)
+			_effectiveTarget = _minTargetScore;
+	}
+
+	_startTime  = g_nancy->getTotalPlayTime();
+	_gameState  = kPlaying;
+	_pops       = 0;
+	_score      = 0;
+	_reachedTarget    = false;
+	_reachedThreshold = false;
+
+	spawnInitialBalloons();
+	redraw();
+}
+
+bool TypingQuizPuzzle::isValidChar(byte c) const {
+	if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
+		return true;
+	if (_allowDigits && c >= '0' && c <= '9')
+		return true;
+	if (_allowedChars[0] != 0) {
+		for (uint i = 0; i < kAllowedCharsLen; ++i) {
+			if (c == _allowedChars[i])
+				return true;
+		}
+	}
+	return false;
+}
+
+char TypingQuizPuzzle::pickRandomChar() {
+	Common::RandomSource &rnd = *g_nancy->_randomSource;
+	int maxChar = (_keyboardMode != 0) ? 0x100 : (_allowedChars[0] != 0 ? 0x7f : 0x7b);
+
+	for (;;) {
+		int c;
+		if (_allowDigits)
+			c = (int)rnd.getRandomNumber(maxChar - 0x30 - 1) + 0x30;
+		else
+			c = (int)rnd.getRandomNumber(maxChar - 0x41 - 1) + 0x41;
+
+		if (isValidChar((byte)c))
+			return (char)c;
+	}
+}
+
+int TypingQuizPuzzle::pickFreePosition() {
+	Common::RandomSource &rnd = *g_nancy->_randomSource;
+
+	for (;;) {
+		int idx = (int)rnd.getRandomNumber(_numPositions - 1);
+		bool occupied = false;
+		for (uint i = 0; i < _numPositions; ++i) {
+			if (_balloons[i].state != kBalloonInactive && _balloons[i].posIndex == idx) {
+				occupied = true;
+				break;
+			}
+		}
+		if (!occupied)
+			return idx;
+	}
+}
+
+void TypingQuizPuzzle::spawnBalloon(uint slot) {
+	Common::RandomSource &rnd = *g_nancy->_randomSource;
+	Balloon &b = _balloons[slot];
+
+	b.posIndex = pickFreePosition();
+	b.posRect  = _positionRects[b.posIndex];
+	b.imgRect  = _balloonSrcRects[_numImageRects ? (int)rnd.getRandomNumber(_numImageRects - 1) : 0];
+	b.ch       = pickRandomChar();
+	b.spawnTime  = g_nancy->getTotalPlayTime();
+	b.popEndTime = 0;
+	b.lifetime = (uint16)((int)rnd.getRandomNumber(_balloonLifeMax - _balloonLifeMin) + _balloonLifeMin);
+	b.state    = kBalloonActive;
+}
+
+void TypingQuizPuzzle::spawnInitialBalloons() {
+	for (uint i = 0; i < kMaxBalloons; ++i)
+		_balloons[i].state = kBalloonInactive;
+
+	Common::RandomSource &rnd = *g_nancy->_randomSource;
+	_activeCount = (int)rnd.getRandomNumber(_maxBalloons - _minBalloons) + _minBalloons;
+	if (_activeCount > (int)_numPositions)
+		_activeCount = _numPositions;
+
+	for (int c = 0; c < _activeCount; ++c)
+		spawnBalloon(c);
+}
+
+void TypingQuizPuzzle::respawnBalloons(int maxToAdd) {
+	Common::RandomSource &rnd = *g_nancy->_randomSource;
+
+	int target;
+	if (_activeCount + maxToAdd < _minBalloons) {
+		target = _minBalloons;
+	} else {
+		int add = (int)rnd.getRandomNumber(maxToAdd);
+		while (_activeCount + add < _minBalloons || _activeCount + add > _maxBalloons)
+			add = (int)rnd.getRandomNumber(maxToAdd);
+		target = _activeCount + add;
+	}
+
+	int toSpawn = target - _activeCount;
+	_activeCount = target;
+
+	for (uint slot = 0; slot < _numPositions && toSpawn > 0; ++slot) {
+		if (_balloons[slot].state == kBalloonInactive) {
+			spawnBalloon(slot);
+			--toSpawn;
+		}
+	}
+}
+
+void TypingQuizPuzzle::expireBalloons(uint32 now) {
+	int expired = 0;
+
+	for (uint i = 0; i < _numPositions; ++i) {
+		Balloon &b = _balloons[i];
+
+		// Active balloons float away once their lifetime elapses
+		if (b.state == kBalloonActive && (now - b.spawnTime) / 1000 > b.lifetime) {
+			b.state = kBalloonInactive;
+			--_activeCount;
+			++expired;
+		}
+
+		// Popped balloons clear once their burst animation finishes
+		if (b.state == kBalloonPopped && now >= b.popEndTime) {
+			b.state = kBalloonInactive;
+			--_activeCount;
+			respawnBalloons(2);
+		}
+	}
+
+	if (expired > 0) {
+		if (_escapeSound.name != "NO SOUND")
+			g_nancy->_sound->playSound(_escapeSound);
+		respawnBalloons(2);
+	}
+}
+
+void TypingQuizPuzzle::processTyping(uint32 now) {
+	for (uint k = 0; k < _pendingChars.size(); ++k) {
+		char typed = _pendingChars[k];
+		bool matched = false;
+
+		for (uint i = 0; i < _numPositions; ++i) {
+			Balloon &b = _balloons[i];
+			if (b.state != kBalloonActive)
+				continue;
+
+			bool isMatch = _caseSensitive ? (b.ch == typed) : caseFoldEquals(b.ch, typed);
+			if (isMatch) {
+				b.state = kBalloonPopped;
+				b.popEndTime = now + 1000;
+				++_pops;
+				if (_popSound.name != "NO SOUND")
+					g_nancy->_sound->playSound(_popSound);
+				matched = true;
+				break;
+			}
+		}
+
+		if (!matched && _wrongSound.name != "NO SOUND")
+			g_nancy->_sound->playSound(_wrongSound);
+	}
+
+	_pendingChars.clear();
+}
+
+void TypingQuizPuzzle::updateScore(uint32 now) {
+	uint32 elapsedSec = (now - _startTime) / 1000;
+
+	// Score is a typing rate (characters per minute)
+	if (elapsedSec > 0 && _pops > 0)
+		_score = (int)((float)_pops * 60.0f / (float)elapsedSec);
+
+	if (elapsedSec >= _timeLimit)
+		_gameState = kEvaluate;
+}
+
+void TypingQuizPuzzle::drawNumber(int value, const Common::Rect *digitRects, int x, int y) {
+	if (value < 0)
+		value = 0;
+
+	// Render digits most-significant first using each digit sprite's own width
+	Common::String digits = Common::String::format("%d", value);
+	int drawX = x;
+	for (uint i = 0; i < digits.size(); ++i) {
+		int d = digits[i] - '0';
+		const Common::Rect &src = digitRects[d];
+		_drawSurface.blitFrom(_image, src, Common::Point(drawX, y));
+		drawX += src.width();
+	}
+}
+
+void TypingQuizPuzzle::redraw() {
+	_needsRedraw = true;
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	if (_gameState != kPlaying) {
+		// Result message, centred in the viewport
+		int w = _passedMsgSrcRect.width();
+		int h = _passedMsgSrcRect.height();
+		int x = (_drawSurface.w - w) / 2;
+		int y = (_drawSurface.h - h) / 2;
+		_drawSurface.blitFrom(_image, _passedMsgSrcRect, Common::Point(x, y));
+		return;
+	}
+
+	const Font *font = g_nancy->_graphics->getFont(_fontID);
+
+	// Balloons and their characters
+	for (uint i = 0; i < _numPositions; ++i) {
+		const Balloon &b = _balloons[i];
+
+		if (b.state == kBalloonActive) {
+			_drawSurface.blitFrom(_image, b.imgRect, Common::Point(b.posRect.left, b.posRect.top));
+
+			if (font) {
+				Common::String s(b.ch);
+				int cw = font->getStringWidth(s);
+				int ch = font->getFontHeight();
+				int cx = b.posRect.left + (b.posRect.width() - cw) / 2;
+				int cy = b.posRect.top + (b.posRect.height() - ch) / 2;
+				font->drawString(&_drawSurface, s, cx, cy, b.posRect.width(), 0);
+			}
+		} else if (b.state == kBalloonPopped) {
+			_drawSurface.blitFrom(_image, _poppedSrcRect, Common::Point(b.posRect.left, b.posRect.top));
+		}
+	}
+
+	// Score and remaining-time displays
+	drawNumber(_score, _scoreDigitRects, _scoreDest.x, _scoreDest.y);
+
+	uint32 elapsedSec = (g_nancy->getTotalPlayTime() - _startTime) / 1000;
+	int remaining = (int)_timeLimit - (int)elapsedSec;
+	drawNumber(remaining, _timerDigitRects, _timerDest.x, _timerDest.y);
+}
+
+void TypingQuizPuzzle::triggerSceneChange() {
+	if (_reachedTarget) {
+		if (_winScene.sceneID != 9999)
+			NancySceneState.changeScene(_winScene);
+		if (_winFlag != -1)
+			NancySceneState.setEventFlag(_winFlag, g_nancy->_true);
+	} else {
+		NancySceneState.changeScene(_defaultScene);
+		if (_reachedThreshold && _flagThreshold != -1)
+			NancySceneState.setEventFlag(_flagThreshold, g_nancy->_true);
+		else if (_flagFail != -1)
+			NancySceneState.setEventFlag(_flagFail, g_nancy->_true);
+	}
+}
+
+void TypingQuizPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+
+		switch (_gameState) {
+		case kPlaying:
+			processTyping(now);
+			expireBalloons(now);
+			updateScore(now);
+			redraw();
+			break;
+
+		case kEvaluate:
+			if (_score >= (int)_effectiveTarget) {
+				_reachedTarget = true;
+				if (_winSound.name != "NO SOUND") {
+					g_nancy->_sound->loadSound(_winSound);
+					g_nancy->_sound->playSound(_winSound);
+				}
+			} else {
+				_reachedTarget    = false;
+				_reachedThreshold = _score >= (int)_scoreThreshold;
+			}
+
+			_resultEndTime = now + _passedMsgDuration;
+			_gameState = kResult;
+			redraw();
+			break;
+
+		case kResult:
+			redraw();
+			if (now >= _resultEndTime) {
+				s_lastScore = _score;
+				_state = kActionTrigger;
+			}
+			break;
+		}
+		break;
+	}
+	case kActionTrigger:
+		g_nancy->_sound->stopSound(_popSound);
+		g_nancy->_sound->stopSound(_wrongSound);
+		g_nancy->_sound->stopSound(_escapeSound);
+		g_nancy->_sound->stopSound(_winSound);
+
+		triggerSceneChange();
+		finishExecution();
+		break;
+	}
+}
+
+void TypingQuizPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _gameState != kPlaying)
+		return;
+
+	for (auto &key : input.otherKbdInput) {
+		if (key.ascii != 0 && isValidChar((byte)key.ascii))
+			_pendingChars += (char)key.ascii;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/typingquizpuzzle.h b/engines/nancy/action/puzzle/typingquizpuzzle.h
new file mode 100644
index 00000000000..e76147fccc8
--- /dev/null
+++ b/engines/nancy/action/puzzle/typingquizpuzzle.h
@@ -0,0 +1,163 @@
+/* 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_TYPINGQUIZPUZZLE_H
+#define NANCY_ACTION_TYPINGQUIZPUZZLE_H
+
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/commontypes.h"
+
+#include "graphics/managed_surface.h"
+
+namespace Nancy {
+namespace Action {
+
+// Real-time "typing quiz" arcade puzzle, new in Nancy 11 (Curse of Blackmoor Manor).
+// Lettered balloons appear at fixed positions; the player pops a balloon by typing its
+// character before it floats away. The puzzle runs for a fixed time limit; the score is
+// a typing rate (characters per minute). If the final rate meets the target, the win
+// scene fires; otherwise a default scene fires (with a different event flag depending on
+// whether a partial threshold was reached). Unrelated to the text-entry QuizPuzzle (231).
+class TypingQuizPuzzle : public RenderActionRecord {
+public:
+	TypingQuizPuzzle() : RenderActionRecord(7) {}
+	virtual ~TypingQuizPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "TypingQuizPuzzle"; }
+
+private:
+	static const uint kMaxBalloons = 20;   // position/balloon slots
+	static const uint kMaxImageRects = 20; // balloon image variations
+	static const uint kNumDigits = 10;     // digit sprites for the score/timer displays
+	static const uint kAllowedCharsLen = 30;
+
+	enum BalloonState {
+		kBalloonInactive = 0,
+		kBalloonActive   = 1, // floating, can be popped
+		kBalloonPopped   = 2  // popped, showing the burst sprite until _popEndTime
+	};
+
+	struct Balloon {
+		BalloonState state = kBalloonInactive;
+		int posIndex = -1;     // index into _positionRects
+		Common::Rect imgRect;  // source rect in the puzzle image
+		Common::Rect posRect;  // viewport-relative destination
+		char ch = 0;
+		uint32 spawnTime = 0;
+		uint32 popEndTime = 0;
+		uint16 lifetime = 0;   // seconds before the balloon floats away
+	};
+
+	// ---- Helpers ----
+	void spawnInitialBalloons();
+	void respawnBalloons(int maxToAdd);
+	void spawnBalloon(uint slot);
+	int pickFreePosition();      // index into _positionRects not currently occupied
+	char pickRandomChar();
+	bool isValidChar(byte c) const;
+	void expireBalloons(uint32 now);
+	void processTyping(uint32 now);
+	void updateScore(uint32 now);
+	void drawNumber(int value, const Common::Rect *digitRects, int x, int y);
+	void redraw();
+	void triggerSceneChange();
+
+	// ---- File data ----
+	Common::Path _imageName;                       // 0x000 puzzle sprite sheet
+	uint16 _numImageRects = 0;                     // 0x021
+	Common::Rect _balloonSrcRects[kMaxImageRects]; // 0x023
+	Common::Rect _poppedSrcRect;                   // 0x163 burst sprite
+	uint16 _numPositions = 0;                      // 0x173
+	Common::Rect _positionRects[kMaxBalloons];     // 0x175 viewport-relative
+
+	Common::Rect _scoreDigitRects[kNumDigits];     // 0x2b5
+	Common::Rect _timerDigitRects[kNumDigits];     // 0x355
+	Common::Point _scoreDest;                      // 0x405 viewport-relative
+	Common::Point _timerDest;                      // 0x40d viewport-relative
+	Common::Rect _passedMsgSrcRect;                // 0x41d result message sprite
+	uint32 _passedMsgDuration = 0;                 // 0x42d ms
+
+	bool _caseSensitive = false;                   // 0x431 (0 = case-insensitive)
+	bool _allowDigits = false;                     // 0x432
+	byte _keyboardMode = 0;                         // 0x433
+	byte _allowedChars[kAllowedCharsLen] = {};     // 0x434
+
+	uint16 _balloonLifeMin = 0;                    // 0x452 seconds
+	uint16 _balloonLifeMax = 0;                    // 0x454
+	uint16 _minBalloons = 0;                       // 0x456
+	uint16 _maxBalloons = 0;                        // 0x458
+	uint16 _timeLimit = 0;                         // 0x45a seconds
+	uint16 _scoreThreshold = 0;                    // 0x45e partial-credit score
+	uint16 _targetScore = 0;                       // 0x460 unscaled target rate
+	uint16 _targetScorePercent = 0;                // 0x462 adaptive scaling percent
+	uint16 _minTargetScore = 0;                    // 0x464 floor for the scaled target
+
+	SoundDescription _popSound;                    // 0x466 balloon popped
+	SoundDescription _wrongSound;                  // 0x497 wrong key
+	SoundDescription _escapeSound;                 // 0x4c8 balloon floated away
+
+	SceneChangeDescription _winScene;              // 0x4f9 (9999 = none)
+	int16 _winFlag = -1;                           // 0x50f
+
+	SoundDescription _winSound;                    // 0x511 played when the target is met
+
+	SceneChangeDescription _defaultScene;          // 0x542 (did not reach the target)
+	int16 _flagThreshold = -1;                     // 0x558 set if the partial threshold was reached
+	int16 _flagFail = -1;                          // 0x55a set otherwise
+
+	// ---- Runtime state ----
+	Graphics::ManagedSurface _image;
+	uint16 _fontID = 0;	// game default font for the balloon characters
+
+	Balloon _balloons[kMaxBalloons];
+	int _activeCount = 0;
+
+	enum GameState {
+		kPlaying  = 0, // typing against the clock
+		kEvaluate = 1, // time is up: decide win/loss, play win sound
+		kResult   = 2  // show the result message, then change scene
+	};
+	GameState _gameState = kPlaying;
+
+	uint32 _startTime = 0;
+	int _pops = 0;          // number of balloons popped
+	int _score = 0;         // displayed typing rate (characters per minute)
+	uint16 _effectiveTarget = 0;
+	bool _reachedTarget = false;
+	bool _reachedThreshold = false;
+	uint32 _resultEndTime = 0;
+
+	Common::String _pendingChars; // typed characters awaiting a match, filled by handleInput
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_TYPINGQUIZPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index af38b27b11e..d148fc80388 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -57,6 +57,7 @@ MODULE_OBJS = \
   action/puzzle/towerpuzzle.o \
   action/puzzle/turningpuzzle.o \
   action/puzzle/twodialpuzzle.o \
+  action/puzzle/typingquizpuzzle.o \
   action/puzzle/whalesurvivorpuzzle.o \
   ui/fullscreenimage.o \
   ui/animatedbutton.o \


Commit: 10e9821f6b665faec5ae543d1dbcf4c6c333142b
    https://github.com/scummvm/scummvm/commit/10e9821f6b665faec5ae543d1dbcf4c6c333142b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:58+03:00

Commit Message:
NANCY: Implement new MemoryPuzzle functionality for Nancy11+

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


diff --git a/engines/nancy/action/puzzle/memorypuzzle.cpp b/engines/nancy/action/puzzle/memorypuzzle.cpp
index 65ba67ed2bf..41f478231b8 100644
--- a/engines/nancy/action/puzzle/memorypuzzle.cpp
+++ b/engines/nancy/action/puzzle/memorypuzzle.cpp
@@ -34,26 +34,12 @@
 namespace Nancy {
 namespace Action {
 
-// Binary layout (offsets from stream start, all LE):
-//   0x000  33  image filename
-//   0x021 576  36 x face src rect (int32 l/t/r/b x 4) [types 0..35]
-//   0x261  48  3 x tab indicator src rect (one per tab)
-//   0x291 384  24 x card screen rect (viewport-relative, int32 x 4)
-//   0x411  16  tab rect (screen rect where the active-tab graphic is drawn)
-//   0x421 144  3 x 3 x 16 tab hotspot rects [currentTab][targetTab]
-//   0x4b1   4  flipDelay (uint32, milliseconds)
-//   0x4b5   4  numPairs (uint32; clamped [4..36] in init)
-//   0x4b9   4  requiredPairs (uint32; clamped [2..36] in init)
-//   0x4bd   1  cursor flag (byte; skip in ScummVM)
-//   0x4be   1  shuffle flag (0 = pairs stay within tab, nonzero = global)
-//   0x4bf  49  match sound (played when a matching pair is found)
-//   0x4f0  49  card flip sound (played when flipping a card face-up)
-//   0x521  25  win SceneChangeWithFlag
-//   0x53a   1  unknown (skip)
-//   0x53b  49  win sound
-//   0x56c  16  exit hotspot rect
-//   [total: 0x57c = 1404 bytes]
 void MemoryPuzzle::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy11) {
+		readDataNancy11(stream);
+		return;
+	}
+
 	// 0x000: image filename (33 bytes)
 	readFilename(stream, _imageName);
 
@@ -104,13 +90,71 @@ void MemoryPuzzle::readData(Common::SeekableReadStream &stream) {
 	readRect(stream, _exitHotspot);
 }
 
+// Nancy 11 reworked the layout: fewer (12) face rects, a configurable grid/page count,
+// per-card-type voice clips in 27 fixed-size (0xb6) blocks, and two outcome scenes.
+void MemoryPuzzle::readDataNancy11(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName); // 0x000
+
+	for (int i = 0; i < 12; ++i)            // 0x021 face src rects
+		readRect(stream, _faceSrcRects[i]);
+	for (int i = 0; i < kNumTabs; ++i)      // 0x0e1 page-tab indicator src rects
+		readRect(stream, _tabSrcRects[i]);
+	for (int i = 0; i < kCardsPerTab; ++i)  // 0x111 card position / back src rects
+		readRect(stream, _cardRects[i]);
+
+	readRect(stream, _tabRect);             // 0x291 tab indicator dest
+
+	for (int tab = 0; tab < kNumTabs; ++tab) // 0x2a1 tab hotspots
+		for (int slot = 0; slot < 3; ++slot)
+			readRect(stream, _tabHotspots[tab][slot]);
+
+	_flipDelay = stream.readUint32LE();     // 0x331
+	stream.skip(4);                         // 0x335 (second timing value, unused)
+	int32 requirePercent = stream.readSint32LE(); // 0x339 (-1 = use the fixed count below)
+	int32 requireCount   = stream.readSint32LE(); // 0x33d
+	stream.skip(4);                         // 0x341 (unused)
+
+	_shuffleGlobal = (stream.readByte() != 0); // 0x345
+	stream.skip(1);                         // 0x346 (flag, unused)
+
+	int32 pages      = stream.readSint32LE(); // 0x347
+	int32 gridsWide  = stream.readSint32LE(); // 0x34b
+	int32 gridsTall  = stream.readSint32LE(); // 0x34f
+	int32 srcWide    = stream.readSint32LE(); // 0x353
+	int32 srcTall    = stream.readSint32LE(); // 0x357
+	stream.skip(1);                         // 0x35b (tabs flag)
+
+	_numTabs     = CLIP<int>(pages, 1, kNumTabs);
+	_cardsPerTab = CLIP<int>(gridsWide * gridsTall, 1, kCardsPerTab);
+	_numTypes    = CLIP<int>(srcWide * srcTall, 1, kMaxTypes);
+	_numPairs    = _numTypes;
+	_requiredPairs = (requirePercent < 0) ? (uint32)requireCount
+	                                       : (uint32)(_numPairs * requirePercent / 100);
+
+	// 27 fixed 0xb6-byte voice-clip blocks: [0] is the card-flip sound, [17] starts the
+	// per-card match sounds (used here as a single match sound; per-type audio is a TODO).
+	_cardFlipSound.readNormal(stream);             // block 0 @ 0x35c
+	stream.skip(17 * 0xb6 - 0x31);                 // advance to block 17 @ 0xf72
+	_matchSound.readNormal(stream);                // block 17
+	stream.skip((27 - 17) * 0xb6 - 0x31);          // advance to the scenes @ 0x168e
+	_winSound.name = "NO SOUND";
+
+	// Solve scene (0x168e), then an alternate-outcome scene (0x16a8, unused). The event flags
+	// store a 16-bit value rather than a simple on/off.
+	_winScene._sceneChange.readData(stream);
+	_winScene._sceneChange.continueSceneSound = stream.readUint16LE();
+	_winScene._flag.label = stream.readSint16LE();
+	_winScene._flag.flag = stream.readSint16LE() ? g_nancy->_true : g_nancy->_false;
+	stream.skip(26); // alternate scene
+}
+
 // Shuffles type IDs (0..numPairs-1) into the 72-card array so that every type
 // appears exactly twice. numPairs is clamped to [4, 36]; cards beyond numPairs
 // remain typeId -1 (unassigned, unselectable). requiredPairs is clamped to [2, totalCards/2].
 void MemoryPuzzle::initCards() {
-	_numPairs = CLIP<uint32>(_numPairs, 4, (uint32)kMaxTypes);
+	_numPairs = CLIP<uint32>(_numPairs, 4, (uint32)_numTypes);
 
-	const int totalCards = kNumTabs * kCardsPerTab;
+	const int totalCards = _numTabs * _cardsPerTab;
 	const uint32 maxRequire = (uint32)(totalCards / 2);
 	_requiredPairs = CLIP<uint32>(_requiredPairs, 2, maxRequire);
 
@@ -129,9 +173,9 @@ void MemoryPuzzle::initCards() {
 
 	if (!_shuffleGlobal) {
 		// By-tab: pairs are always within the same tab.
-		for (int tab = 0; tab < kNumTabs; ++tab) {
-			int base = tab * kCardsPerTab;
-			for (int i = 0; i < kCardsPerTab; ++i) {
+		for (int tab = 0; tab < _numTabs; ++tab) {
+			int base = tab * _cardsPerTab;
+			for (int i = 0; i < _cardsPerTab; ++i) {
 				if (_cards[base + i].typeId != -1)
 					continue;
 				if (static_cast<uint32>(nextType) >= _numPairs)
@@ -142,7 +186,7 @@ void MemoryPuzzle::initCards() {
 				// Find a random unassigned slot in the same tab for the pair
 				int partner;
 				do {
-					partner = g_nancy->_randomSource->getRandomNumber(kCardsPerTab - 1);
+					partner = g_nancy->_randomSource->getRandomNumber(_cardsPerTab - 1);
 				} while (_cards[base + partner].typeId != -1);
 				_cards[base + partner].typeId = nextType;
 
@@ -256,7 +300,7 @@ void MemoryPuzzle::handleInput(NancyInput &input) {
 	}
 
 	// Tab switching: _tabHotspots[currentTab][slot] where slot is the target tab
-	for (int slot = 0; slot < kNumTabs; ++slot) {
+	for (int slot = 0; slot < _numTabs; ++slot) {
 		if (_tabHotspots[_currentTab][slot].contains(mouseVP)) {
 			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
 			if ((input.input & NancyInput::kLeftMouseButtonUp) && slot != _currentTab) {
@@ -281,8 +325,8 @@ void MemoryPuzzle::handleInput(NancyInput &input) {
 	if (_flipTimerActive)
 		return;
 
-	int base = _currentTab * kCardsPerTab;
-	for (int i = 0; i < kCardsPerTab; ++i) {
+	int base = _currentTab * _cardsPerTab;
+	for (int i = 0; i < _cardsPerTab; ++i) {
 		if (!_cardRects[i].contains(mouseVP))
 			continue;
 
@@ -356,21 +400,21 @@ void MemoryPuzzle::redrawCards() {
 
 	// Draw the active tab indicator over the corresponding tab button.
 	// The scene background shows inactive tab visuals; the overlay only marks the active one.
-	if (_currentTab < kNumTabs && !_tabSrcRects[_currentTab].isEmpty())
+	if (_currentTab < _numTabs && !_tabSrcRects[_currentTab].isEmpty())
 		_drawSurface.blitFrom(_image, _tabSrcRects[_currentTab],
 			Common::Point(_tabRect.left, _tabRect.top));
 
-	// Draw face-up and matched cards. Face-down cards are left transparent so
-	// the scene background (which carries the card-back visual) shows through.
-	int base = _currentTab * kCardsPerTab;
-	for (int i = 0; i < kCardsPerTab; ++i) {
+	// Draw face-up and matched cards. Face-down cards are left transparent so the scene
+	// background (which carries the card-back visual) shows through.
+	int base = _currentTab * _cardsPerTab;
+	for (int i = 0; i < _cardsPerTab; ++i) {
 		int idx = base + i;
 		const CardState &card = _cards[idx];
 		const Common::Rect &dest = _cardRects[i];
 
 		if (card.matchState != 0 || card.flipState != 0) {
 			int t = card.typeId;
-			if (t >= 0 && t < kMaxTypes && !_faceSrcRects[t].isEmpty())
+			if (t >= 0 && t < _numTypes && !_faceSrcRects[t].isEmpty())
 				_drawSurface.blitFrom(_image, _faceSrcRects[t],
 					Common::Point(dest.left, dest.top));
 		}
diff --git a/engines/nancy/action/puzzle/memorypuzzle.h b/engines/nancy/action/puzzle/memorypuzzle.h
index 5de2b030556..cd26e170b32 100644
--- a/engines/nancy/action/puzzle/memorypuzzle.h
+++ b/engines/nancy/action/puzzle/memorypuzzle.h
@@ -40,6 +40,7 @@ public:
 	void init() override;
 
 	void readData(Common::SeekableReadStream &stream) override;
+	void readDataNancy11(Common::SeekableReadStream &stream);
 	void execute() override;
 	void handleInput(NancyInput &input) override;
 
@@ -68,6 +69,11 @@ protected:
 	uint32 _requiredPairs = 12;    // pairs needed to win (clamped [2..36])
 	uint32 _flipDelay     = 1500;  // ms before non-matching cards flip back
 
+	// Counts are fixed in the Nancy 9 layout but configurable from Nancy 11 onwards
+	int _numTabs      = kNumTabs;
+	int _cardsPerTab  = kCardsPerTab;
+	int _numTypes     = kMaxTypes;
+
 	bool _shuffleGlobal = false;  // false = pairs stay within same tab; true = can cross tabs
 
 	SoundDescription _cardFlipSound; // played when a card is flipped face-up


Commit: 979db279fc019ebe7d38be626f0efd67de8a38b0
    https://github.com/scummvm/scummvm/commit/979db279fc019ebe7d38be626f0efd67de8a38b0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:03+03:00

Commit Message:
NANCY: Implement new OrderingPuzzle functionality for Nancy11

This puzzle is used as follows:
- Piano variant: scenes 3394, 3395
- Keypad variant: scenes 3196
- Keypad terse variant: scenes 2702, 2705

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 8270180851b..eedd6ab2ab1 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/orderingpuzzle.cpp
@@ -174,8 +174,20 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
 		readRectArray(ser, _overlaySrcs, numOverlays);
 		readRectArray(ser, _overlayDests, numOverlays);
 	} else if (isPiano && g_nancy->getGameType() >= kGameTypeNancy8) {
-		readFilenameArray(stream, _pianoSoundNames, numElements);
-		stream.skip((maxNumElements - numElements) * 33);
+		if (g_nancy->getGameType() >= kGameTypeNancy11) {
+			// Nancy 11 stores two interleaved sound names per key (e.g. a press and
+			// a release sound), padded to maxNumElements pairs
+			_pianoSoundNames.resize(numElements);
+			_pianoReleaseSoundNames.resize(numElements);
+			for (uint i = 0; i < numElements; ++i) {
+				readFilename(stream, _pianoSoundNames[i]);
+				readFilename(stream, _pianoReleaseSoundNames[i]);
+			}
+			stream.skip((maxNumElements - numElements) * 2 * 33);
+		} else {
+			readFilenameArray(stream, _pianoSoundNames, numElements);
+			stream.skip((maxNumElements - numElements) * 33);
+		}
 	}
 
 	if (ser.getVersion() > kGameTypeVampire) {
@@ -212,19 +224,61 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
 
 	if (isKeypad && g_nancy->getGameType() >= kGameTypeNancy7) {
 		if (_puzzleType == kKeypad) {
+			if (g_nancy->getGameType() >= kGameTypeNancy11) {
+				// Nancy 11 multi-stage keypad: a stage count, per-stage display rects, the codes
+				// for stages 1+, a final code matrix and an alternate scene, then the button rects.
+				_numStages = stream.readUint16LE();
+				stream.skip(20 * 16 + 1); // per-stage display rects + blink flag
+
+				_stageSequences.resize(4);
+				_stageCheckOrder.resize(4);
+				for (uint s = 0; s < 4; ++s) {
+					uint16 len = stream.readUint16LE();
+					_stageCheckOrder[s] = (stream.readByte() != 0);
+					len = MIN<uint16>(len, 30);
+					_stageSequences[s].resize(len);
+					for (uint16 k = 0; k < len; ++k)
+						_stageSequences[s][k] = stream.readByte();
+					stream.skip(30 - len);
+				}
+
+				stream.skip(25 + 25); // final code matrix + alternate scene (unused by the sequential model)
+			}
 			readRectArray(ser, _down1Rects, numElements, maxNumElements);
 			readRectArray(ser, _destRects, numElements, maxNumElements);
 		} else if (_puzzleType == kKeypadTerse) {
-			_down1Rects.resize(numElements);
-			_destRects.resize(numElements);
-
 			// Terse elements are the same size & placed on a grid (in the source image AND on screen)
 			uint16 columns = stream.readUint16LE();
-			stream.skip(2); // rows
+
+			// The Nancy 11 multi-stage terse keypad chains scenes: each scene holds one stage and
+			// the grid is preceded by the scene to advance to once this stage's code is entered.
+			// Standalone/final keypads omit it. A grid column count can never exceed maxNumElements,
+			// so a larger leading value is that scene id - it overrides the solve scene's target
+			// (the rest of the solve scene change is reused), then the real column count follows.
+			if (g_nancy->getGameType() >= kGameTypeNancy11 && columns > maxNumElements) {
+				_solveExitScene._sceneChange.sceneID = columns;
+				columns = stream.readUint16LE();
+			}
+
+			uint16 rows = stream.readUint16LE();
 
 			uint16 width = stream.readUint16LE();
 			uint16 height = stream.readUint16LE();
 
+			// Nancy 11 stores the element count as 0 and derives it from the grid dimensions
+			if (g_nancy->getGameType() >= kGameTypeNancy11) {
+				numElements = columns * rows;
+			}
+
+			// Guard against malformed dimensions (columns is a divisor below)
+			if (columns == 0) {
+				columns = 1;
+			}
+			numElements = MIN<uint16>(numElements, maxNumElements);
+
+			_down1Rects.resize(numElements);
+			_destRects.resize(numElements);
+
 			Common::Point srcStartPos, srcDist, destStartPos, destDist;
 
 			srcStartPos.x = stream.readUint16LE();
@@ -387,7 +441,36 @@ void OrderingPuzzle::execute() {
 				}
 			}
 
-			if (_puzzleType == kKeypad && _needButtonToCheckSuccess) {
+			if (_puzzleType == kKeypad && _numStages > 1) {
+				// Nancy 11 multi-stage keypad: every stage's code must be entered in turn.
+				// The check button confirms the current code; a wrong code resets the stage.
+				if (!_checkButtonPressed) {
+					return;
+				}
+
+				if (g_nancy->_sound->isSoundPlaying(_pushDownSound)) {
+					return;
+				}
+
+				_checkButtonPressed = false;
+
+				if (!solved) {
+					clearAllElements();
+					return;
+				}
+
+				if (_currentStage + 1 < (int)_numStages) {
+					++_currentStage;
+					_correctSequence = _stageSequences[_currentStage - 1];
+					_checkOrder = _stageCheckOrder[_currentStage - 1];
+					clearAllElements();
+					return;
+				}
+
+				// Final stage solved
+				NancySceneState.setEventFlag(_solveExitScene._flag);
+				_currentStage = 0;
+			} else if (_puzzleType == kKeypad && _needButtonToCheckSuccess) {
 				// KeypadPuzzle moves to the "success" scene regardless whether the puzzle was solved or not,
 				// provided the check button is pressed.
 				if (_checkButtonPressed) {
diff --git a/engines/nancy/action/puzzle/orderingpuzzle.h b/engines/nancy/action/puzzle/orderingpuzzle.h
index 3f9dbfa7f99..9d15e954b0d 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.h
+++ b/engines/nancy/action/puzzle/orderingpuzzle.h
@@ -73,12 +73,21 @@ protected:
 	Common::Array<Common::Rect> _hotspots;
 	Common::Array<uint16> _correctSequence;
 
+	// Nancy 11 multi-stage keypad: a series of codes that must be entered in turn. Stage 0 is
+	// _correctSequence above; the rest are stored here. The puzzle stays in one scene across all
+	// stages, so the same record instance tracks the current stage.
+	uint16 _numStages = 0;
+	Common::Array<Common::Array<uint16> > _stageSequences;
+	Common::Array<bool> _stageCheckOrder;
+	int _currentStage = 0;
+
 	uint16 _specialCursor1Id = CursorManager::kHotspot;
 	Common::Rect _specialCursor1Dest;
 	uint16 _specialCursor2Id = CursorManager::kHotspot;
 	Common::Rect _specialCursor2Dest;
 
 	Common::Array<Common::String> _pianoSoundNames; // nancy8 and up
+	Common::Array<Common::String> _pianoReleaseSoundNames; // nancy11 and up (second, interleaved, sound name per key)
 
 	uint16 _state2InvItem = 0;
 	Common::Array<Common::Rect> _overlaySrcs;


Commit: 6267ae7f621bee1411f85c5f6f9790e0f5ac3131
    https://github.com/scummvm/scummvm/commit/6267ae7f621bee1411f85c5f6f9790e0f5ac3131
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:04+03:00

Commit Message:
NANCY: Don't capture events in the text box when it's not visible

Avoids having mouse events eaten when they shouldn't be

Changed paths:
    engines/nancy/ui/textbox.cpp


diff --git a/engines/nancy/ui/textbox.cpp b/engines/nancy/ui/textbox.cpp
index a8248564fe3..270f3c0d3f9 100644
--- a/engines/nancy/ui/textbox.cpp
+++ b/engines/nancy/ui/textbox.cpp
@@ -196,6 +196,9 @@ void Textbox::setFullMode(bool open, uint32 timeoutMs) {
 }
 
 void Textbox::handleInput(NancyInput &input) {
+	if (!isVisible())
+		return;
+
 	if (_scrollbar)
 		_scrollbar->handleInput(input);
 


Commit: cf19cffcfabb4adea942118636fa811435684bd2
    https://github.com/scummvm/scummvm/commit/cf19cffcfabb4adea942118636fa811435684bd2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:05+03:00

Commit Message:
NANCY: Remove an unneeded assignment in MemoryPuzzle

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


diff --git a/engines/nancy/action/puzzle/memorypuzzle.cpp b/engines/nancy/action/puzzle/memorypuzzle.cpp
index 41f478231b8..d70e39ac382 100644
--- a/engines/nancy/action/puzzle/memorypuzzle.cpp
+++ b/engines/nancy/action/puzzle/memorypuzzle.cpp
@@ -137,7 +137,7 @@ void MemoryPuzzle::readDataNancy11(Common::SeekableReadStream &stream) {
 	stream.skip(17 * 0xb6 - 0x31);                 // advance to block 17 @ 0xf72
 	_matchSound.readNormal(stream);                // block 17
 	stream.skip((27 - 17) * 0xb6 - 0x31);          // advance to the scenes @ 0x168e
-	_winSound.name = "NO SOUND";
+	// Nancy 11 has no win sound; _winSound keeps its default "NO SOUND".
 
 	// Solve scene (0x168e), then an alternate-outcome scene (0x16a8, unused). The event flags
 	// store a 16-bit value rather than a simple on/off.


Commit: 3f823089d8eccfb461dec5c73df92abe507c3ff5
    https://github.com/scummvm/scummvm/commit/3f823089d8eccfb461dec5c73df92abe507c3ff5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:06+03:00

Commit Message:
NANCY: Implement new functionality for BulPuzzle in Nancy11

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


diff --git a/engines/nancy/action/puzzle/bulpuzzle.cpp b/engines/nancy/action/puzzle/bulpuzzle.cpp
index 0ef5047be48..49f0ab17fa2 100644
--- a/engines/nancy/action/puzzle/bulpuzzle.cpp
+++ b/engines/nancy/action/puzzle/bulpuzzle.cpp
@@ -177,6 +177,10 @@ void BulPuzzle::updateGraphics() {
 					// This was the last move, go to next turn
 					g_nancy->_sound->playSound(_moveSound);
 					_currentAction = kNone;
+					// The voiced "move" line plays at the end of a player's turn (its second roll)
+					if (g_nancy->getGameType() >= kGameTypeNancy11 && (_turn == 1 || _turn == 3)) {
+						playVoiceLine(_turn / _numRolls, 3);
+					}
 					_turn = _turn + 1 > 3 ? 0 : _turn + 1;
 					_changeLight = true;
 				}
@@ -195,6 +199,10 @@ void BulPuzzle::updateGraphics() {
 			return;
 		case kPass:
 			_currentAction = kNone;
+			// The voiced "transition" line plays when a player passes their turn
+			if (g_nancy->getGameType() >= kGameTypeNancy11 && (_turn == 1 || _turn == 3)) {
+				playVoiceLine(_turn / _numRolls, 1);
+			}
 			_turn = (_turn + 1 > _numRolls * 2) ? 0 : _turn + 1;
 
 			return;
@@ -213,6 +221,11 @@ void BulPuzzle::readData(Common::SeekableReadStream &stream) {
 	_numCells = stream.readUint16LE();
 	_numPieces = stream.readUint16LE();
 	_numRolls = stream.readUint16LE();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy11) {
+		stream.skip(2); // game mode + a flag byte
+	}
+
 	_playerStart = stream.readUint16LE();
 	_enemyStart = stream.readUint16LE();
 
@@ -256,23 +269,92 @@ void BulPuzzle::readData(Common::SeekableReadStream &stream) {
 	readRect(stream, _enemyLightSrc);
 	readRect(stream, _passButtonDisabledSrc);
 
-	_moveSound.readNormal(stream);
-	_enemyCapturedSound.readNormal(stream);
-	_playerCapturedSound.readNormal(stream);
-	_rollSound.readNormal(stream);
-	_passSound.readNormal(stream);
-	_resetSound.readNormal(stream);
+	if (g_nancy->getGameType() >= kGameTypeNancy11) {
+		// Nancy 11 keeps four fixed sounds, then per-event random voice-clip tables, then the scenes.
+		_moveSound.readNormal(stream);
+		_rollSound.readNormal(stream);
+		_passSound.readNormal(stream);
+		_resetSound.readNormal(stream);
+
+		// Captures are unused here and win/loss is voiced from the tables below, so the
+		// capture/solve/lose SoundDescriptions keep their default "NO SOUND".
+
+		// Two per-player blocks: a 6-byte header (channel + two unused shorts) then seven tables
+		static const int tableSizes[7] = { 1, 1, 4, 4, 4, 4, 4 };
+		for (int pl = 0; pl < 2; ++pl) {
+			_voiceChannel[pl] = stream.readUint16LE();
+			stream.skip(4);
+			for (int t = 0; t < 7; ++t) {
+				for (int e = 0; e < tableSizes[t]; ++e) {
+					Common::String name;
+					readFilename(stream, name);
+					if (!name.empty() && name != "NO SOUND")
+						_voiceLines[pl][t].push_back(name);
+				}
+			}
+		}
 
-	_solveScene.readData(stream);
-	_solveSoundDelay = stream.readUint16LE();
-	_solveSound.readNormal(stream);
+		// Loss scene (two possible flags) then the solve scene (one flag). The event flags
+		// store a value rather than a simple on/off.
+		_exitScene._sceneChange.readData(stream);
+		_exitScene._sceneChange.continueSceneSound = stream.readUint16LE();
+		_exitScene._flag.label = stream.readSint16LE();
+		_exitScene._flag.flag = stream.readByte();
+		_loseFlag2.label = stream.readSint16LE();
+		_loseFlag2.flag = stream.readByte();
+
+		_solveScene._sceneChange.readData(stream);
+		_solveScene._sceneChange.continueSceneSound = stream.readUint16LE();
+		_solveScene._flag.label = stream.readSint16LE();
+		_solveScene._flag.flag = stream.readByte();
+	} else {
+		_moveSound.readNormal(stream);
+		_enemyCapturedSound.readNormal(stream);
+		_playerCapturedSound.readNormal(stream);
+		_rollSound.readNormal(stream);
+		_passSound.readNormal(stream);
+		_resetSound.readNormal(stream);
+
+		_solveScene.readData(stream);
+		_solveSoundDelay = stream.readUint16LE();
+		_solveSound.readNormal(stream);
+
+		_exitScene.readData(stream);
+		_loseSoundDelay = stream.readUint16LE();
+		_loseSound.readNormal(stream);
+	}
 
-	_exitScene.readData(stream);
-	_loseSoundDelay = stream.readUint16LE();
-	_loseSound.readNormal(stream);
 	readRect(stream, _exitHotspot);
 }
 
+Common::String BulPuzzle::pickVoiceLine(int player, int table) {
+	if (player < 0 || player > 1 || table < 0 || table > 6) {
+		return Common::String();
+	}
+
+	const Common::Array<Common::String> &lines = _voiceLines[player][table];
+	if (lines.empty()) {
+		return Common::String();
+	}
+
+	return lines[g_nancy->_randomSource->getRandomNumber(lines.size() - 1)];
+}
+
+void BulPuzzle::playVoiceLine(int player, int table) {
+	Common::String line = pickVoiceLine(player, table);
+	if (line.empty()) {
+		return;
+	}
+
+	g_nancy->_sound->stopSound(_voiceSound);
+	_voiceSound.name = line;
+	_voiceSound.channelID = _voiceChannel[player];
+	_voiceSound.numLoops = 1;
+	_voiceSound.volume = _moveSound.volume;
+	g_nancy->_sound->loadSound(_voiceSound);
+	g_nancy->_sound->playSound(_voiceSound);
+}
+
 void BulPuzzle::execute() {
 	switch (_state) {
 	case kBegin:
@@ -284,26 +366,60 @@ void BulPuzzle::execute() {
 		g_nancy->_sound->loadSound(_passSound);
 		g_nancy->_sound->loadSound(_moveSound);
 
+		if (g_nancy->getGameType() >= kGameTypeNancy11) {
+			_prevSide = (_numRolls > 0 && _turn >= _numRolls) ? 1 : 0;
+			playVoiceLine(1, 0); // opponent's opening line
+		}
+
 		_state = kRun;
 		// fall through
-	case kRun:
+	case kRun: {
+		const bool isNancy11 = g_nancy->getGameType() >= kGameTypeNancy11;
+
+		// Voice the start of a turn when control changes hands
+		if (isNancy11 && _numRolls > 0) {
+			int side = (_turn >= _numRolls) ? 1 : 0;
+			if (side != _prevSide) {
+				_prevSide = side;
+				playVoiceLine(side, 2);
+			}
+		}
+
 		if (_playerPieces == 0) {
 			_state = kActionTrigger;
+			if (isNancy11) {
+				Common::String line = pickVoiceLine(1, 5); // opponent's victory line
+				if (!line.empty()) {
+					_loseSound.name = line;
+					_loseSound.channelID = _voiceChannel[1];
+					_loseSound.numLoops = 1;
+					_loseSound.volume = _moveSound.volume;
+				}
+			}
 			_nextMoveTime = g_nancy->getTotalPlayTime() + _loseSoundDelay * 1000;
 		}
 
 		if (_enemyPieces == 0) {
 			_playerWon = true;
 			_state = kActionTrigger;
+			if (isNancy11) {
+				Common::String line = pickVoiceLine(0, 5); // player's victory line
+				if (!line.empty()) {
+					_solveSound.name = line;
+					_solveSound.channelID = _voiceChannel[0];
+					_solveSound.numLoops = 1;
+					_solveSound.volume = _moveSound.volume;
+				}
+			}
 			_nextMoveTime = g_nancy->getTotalPlayTime() + _solveSoundDelay * 1000;
 		}
 
 		if (_state == kRun) {
 			break;
 		}
-
+	}
 		// fall through
-	case kActionTrigger:
+	case kActionTrigger: {
 		SoundDescription &sound = _playerWon ? _solveSound : _loseSound;
 
 		if (g_nancy->getTotalPlayTime() >= _nextMoveTime) {
@@ -315,6 +431,10 @@ void BulPuzzle::execute() {
 		if (_nextMoveTime == 0 && !g_nancy->_sound->isSoundPlaying(sound)) {
 			if (_playerWon) {
 				_solveScene.execute();
+			} else if (g_nancy->getGameType() >= kGameTypeNancy11) {
+				// The second flag marks giving up via the exit hotspot rather than being beaten
+				NancySceneState.changeScene(_exitScene._sceneChange);
+				NancySceneState.setEventFlag(_gaveUp ? _loseFlag2 : _exitScene._flag);
 			} else {
 				_exitScene.execute();
 			}
@@ -322,6 +442,7 @@ void BulPuzzle::execute() {
 
 		break;
 	}
+	}
 }
 
 void BulPuzzle::handleInput(NancyInput &input) {
@@ -331,6 +452,7 @@ void BulPuzzle::handleInput(NancyInput &input) {
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			_state = kActionTrigger;
 			_nextMoveTime = 0;
+			_gaveUp = true;
 		}
 
 		return;
diff --git a/engines/nancy/action/puzzle/bulpuzzle.h b/engines/nancy/action/puzzle/bulpuzzle.h
index c6df9d9f143..22c08e1324d 100644
--- a/engines/nancy/action/puzzle/bulpuzzle.h
+++ b/engines/nancy/action/puzzle/bulpuzzle.h
@@ -48,6 +48,10 @@ protected:
 	void movePiece(bool player);
 	void reset(bool capture);
 
+	// Nancy 11: pick (and optionally play) a random clip from one of the per-player voice tables
+	Common::String pickVoiceLine(int player, int table);
+	void playVoiceLine(int player, int table);
+
 	Common::String getRecordTypeName() const override { return "BulPuzzle"; }
 
 	Common::Path _imageName;
@@ -110,8 +114,17 @@ protected:
 	SceneChangeWithFlag _exitScene; // also when losing
 	uint16 _loseSoundDelay = 0;
 	SoundDescription _loseSound;
+	FlagDescription _loseFlag2; // nancy11: event flag set when the player gives up rather than loses
 	Common::Rect _exitHotspot;
 
+	// Nancy 11 voice clips: two players, seven tables each (entry counts 1,1,4,4,4,4,4).
+	// Table 0 = opening line, 2 = turn line, 5 = end-of-game line.
+	Common::Array<Common::String> _voiceLines[2][7];
+	uint16 _voiceChannel[2] = {};
+	SoundDescription _voiceSound;
+	int _prevSide = -1;
+	bool _gaveUp = false;
+
 	Graphics::ManagedSurface _image;
 
 	int16 _playerPos = 0;


Commit: b41dc6fb3b2daeb1ba9e8fbf90d1e00150e94d04
    https://github.com/scummvm/scummvm/commit/b41dc6fb3b2daeb1ba9e8fbf90d1e00150e94d04
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:07+03:00

Commit Message:
NANCY: Add new functionality for CollisionPuzzle in Nancy11

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


diff --git a/engines/nancy/action/puzzle/collisionpuzzle.cpp b/engines/nancy/action/puzzle/collisionpuzzle.cpp
index 60e73452dc6..27f78648859 100644
--- a/engines/nancy/action/puzzle/collisionpuzzle.cpp
+++ b/engines/nancy/action/puzzle/collisionpuzzle.cpp
@@ -187,9 +187,15 @@ void CollisionPuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);
 	uint16 numPieces = 0;
 
-	// Nancy 10+ TileMove stores rows then cols (was width then height for square grids)
+	// Nancy 11 raised the Collision piece limit from 5 to 10 and added a leading flag
+	const bool collisionNancy11 = (_puzzleType == kCollision && g_nancy->getGameType() >= kGameTypeNancy11);
+	if (collisionNancy11) {
+		stream.skip(1); // pieces are randomized when this is 0
+	}
+
+	// Nancy 10+ TileMove (and Nancy 11+ Collision) store rows then cols (was width then height for square grids)
 	uint16 width, height;
-	if (_puzzleType == kTileMove && g_nancy->getGameType() >= kGameTypeNancy10) {
+	if ((_puzzleType == kTileMove && g_nancy->getGameType() >= kGameTypeNancy10) || collisionNancy11) {
 		height = stream.readUint16LE();
 		width = stream.readUint16LE();
 	} else {
@@ -217,16 +223,29 @@ void CollisionPuzzle::readData(Common::SeekableReadStream &stream) {
 	}
 	stream.skip((maxGridSize - height) * maxGridSize * 2);
 
+	// Earlier Collision puzzles store wall/block markers as 6-10, which Nancy 11 reuses for
+	// home IDs. Shift them up to the internal 16-20 range.
+	if (_puzzleType == kCollision && g_nancy->getGameType() < kGameTypeNancy11) {
+		for (uint y = 0; y < height; ++y) {
+			for (uint x = 0; x < width; ++x) {
+				if (_grid[y][x] >= 6 && _grid[y][x] <= 10)
+					_grid[y][x] += 10;
+			}
+		}
+	}
+
 	if (_puzzleType == kCollision) {
+		const uint maxPieces = collisionNancy11 ? 10 : 5;
+
 		_startLocations.resize(numPieces);
 		for (uint i = 0; i < numPieces; ++i) {
 			_startLocations[i].x = stream.readUint16LE();
 			_startLocations[i].y = stream.readUint16LE();
 		}
-		stream.skip((5 - numPieces) * 4);
+		stream.skip((maxPieces - numPieces) * 4);
 
-		readRectArray(stream, _pieceSrcs, numPieces, 5);
-		readRectArray(stream, _homeSrcs, numPieces, 5);
+		readRectArray(stream, _pieceSrcs, numPieces, maxPieces);
+		readRectArray(stream, _homeSrcs, numPieces, maxPieces);
 
 		readRect(stream, _verticalWallSrc);
 		readRect(stream, _horizontalWallSrc);
diff --git a/engines/nancy/action/puzzle/collisionpuzzle.h b/engines/nancy/action/puzzle/collisionpuzzle.h
index ab8c29638fe..de69f379c50 100644
--- a/engines/nancy/action/puzzle/collisionpuzzle.h
+++ b/engines/nancy/action/puzzle/collisionpuzzle.h
@@ -52,8 +52,9 @@ public:
 	bool isViewportRelative() const override { return true; }
 
 protected:
-	// numbers 1-5 are home IDs, 0 is empty cell
-	enum WallType { kWallLeft = 6, kWallUp = 7, kWallDown = 8, kWallRight = 9, kBlock = 10 };
+	// 0 is an empty cell, positive numbers are home IDs. Wall/block markers use Nancy 11's
+	// values (16-20); earlier games store 6-10 and are remapped to these on load.
+	enum WallType { kWallLeft = 16, kWallUp = 17, kWallDown = 18, kWallRight = 19, kBlock = 20 };
 
 	class Piece : public RenderObject {
 	public:


Commit: f6315c643fec56c4635a8d72a23ff816f1a9ae48
    https://github.com/scummvm/scummvm/commit/f6315c643fec56c4635a8d72a23ff816f1a9ae48
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:08+03:00

Commit Message:
NANCY: Implement the FadeSoundToSilence AR for Nancy11

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 506541bbbe7..d8fd412d6ae 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -373,8 +373,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 140:
 		return new SetVolume();
 	case 147:	// Nancy11
-		warning("FadeSoundToSilence");	// TODO
-		return nullptr;
+		return new FadeSoundToSilence();
 	case 148:
 		// MakeScreenFile - seems to save a cropped image of the screen in a bitmap file?
 		// TODO: Used in Nancy 9, sand castle puzzle
diff --git a/engines/nancy/action/soundrecords.cpp b/engines/nancy/action/soundrecords.cpp
index 43559183a8c..9a84bbbfe5d 100644
--- a/engines/nancy/action/soundrecords.cpp
+++ b/engines/nancy/action/soundrecords.cpp
@@ -21,6 +21,7 @@
 
 #include "common/random.h"
 #include "common/config-manager.h"
+#include "common/system.h"
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/sound.h"
@@ -43,6 +44,36 @@ void SetVolume::execute() {
 	_isDone = true;
 }
 
+void FadeSoundToSilence::readData(Common::SeekableReadStream &stream) {
+	channel = stream.readUint16LE();
+	stream.skip(2); // pad / flag
+	fadeTimeMs = stream.readUint32LE();
+}
+
+void FadeSoundToSilence::execute() {
+	switch (_state) {
+	case kBegin:
+		_startVolume = g_nancy->_sound->getVolume(channel);
+		_startTime = g_system->getMillis();
+		_state = kRun;
+		break;
+	case kRun: {
+		const uint32 elapsed = g_system->getMillis() - _startTime;
+		if (fadeTimeMs == 0 || elapsed >= fadeTimeMs) {
+			g_nancy->_sound->setVolume(channel, 0);
+			_state = kActionTrigger;
+			break;
+		}
+		const uint16 v = (uint16)((uint32)_startVolume * (fadeTimeMs - elapsed) / fadeTimeMs);
+		g_nancy->_sound->setVolume(channel, v);
+		break;
+	}
+	case kActionTrigger:
+		finishExecution();
+		break;
+	}
+}
+
 void PlaySound::readData(Common::SeekableReadStream &stream) {
 	_sound.readDIGI(stream);
 
diff --git a/engines/nancy/action/soundrecords.h b/engines/nancy/action/soundrecords.h
index f1bf6703b23..02f5d34cc5a 100644
--- a/engines/nancy/action/soundrecords.h
+++ b/engines/nancy/action/soundrecords.h
@@ -40,6 +40,24 @@ protected:
 	Common::String getRecordTypeName() const override { return "SetVolume"; }
 };
 
+// Nancy 11+ AR 147. Linearly ramps a channel's volume down to 0 over
+// the given time, then stops execution.
+class FadeSoundToSilence : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	uint16 channel = 0;
+	uint32 fadeTimeMs = 0;
+
+protected:
+	Common::String getRecordTypeName() const override { return "FadeSoundToSilence"; }
+
+private:
+	uint32 _startTime = 0;
+	uint16 _startVolume = 0;
+};
+
 // Used for sound effects. From nancy3 up it includes 3D sound data, which lets
 // the sound move in 3D space as the player rotates/changes scenes. Also supports
 // changing the scene and/or setting a flag




More information about the Scummvm-git-logs mailing list