[Scummvm-git-logs] scummvm master -> 176bcd1722557fd907331500fae3e4a75eed1490

bluegr noreply at scummvm.org
Fri Oct 23 06:00:59 UTC 2026


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

Summary:
57912da14d NANCY: Redraw objects where they were last drawn on the screen
d8506fe0b8 NANCY: Work out what gets drawn only once per frame
a5de325353 NANCY: Accept event flag names in set_eventflags
ab5274d785 NANCY: NANCY19: Ignore record fields set to the value they already have
caaf60b8db NANCY: NANCY19: Don't run a record's OnDone again while it runs
c3c2ec27db NANCY: NANCY19: Skip speech with Esc, and make skipping movies quicker
f9ad0de1de NANCY: NANCY19: Keep the fractions of script z values in the draw order
558b23feb4 NANCY: NANCY19: Don't let scene records change the cursor over the UI
fce3b5afef NANCY: NANCY16: Don't add journal and task entries twice, or too early
6c1d9c4804 NANCY: NANCY21: Add the smaller additions to the script API
9bce91bf23 NANCY: NANCY21: Look up texts through the Autotext object
9accef79fb NANCY: NANCY21: Take the font of Text records from their font table
3a2c4a5529 NANCY: NANCY21: Add the new ways of handling streams
f53604be4c NANCY: NANCY21: Let scripts read and set the game clock
4e7ad93988 NANCY: NANCY21: Add the cell phone functions of Player
fc2d796072 NANCY: NANCY21: Add graph nodes, which group records
4e408fb0fd NANCY: NANCY21: Zoom into the scene with the camera of the phone
ed203b8a35 NANCY: NANCY19: Handle the source, loop, volume and modColor of movies
8b8e8b9af4 NANCY: NANCY21: Take pictures with the camera of the phone
431c4a0f57 NANCY: NANCY21: Let scripts change the images of UI controls
005e929c5b NANCY: NANCY20: Add the expand and collapse messages
cbc82928bc NANCY: NANCY20: Send "enable" and "disable" events to panel scripts
899651af65 NANCY: NANCY19: Handle the captureClicks field of panels
d55813f65c NANCY: NANCY19: Draw tooltips the way the original does
9967b78236 NANCY: NANCY21: Ask before leaving when the game can't be saved
3826133415 NANCY: NANCY21: Open the scenes of items by name
f800917874 NANCY: NANCY21: Hand the mouse wheel to scripts
9b3c993dd1 NANCY: NANCY21: Move Text records with their transform
18b4f1f920 NANCY: NANCY19: Add triangle drawing function (Draw:Tri)
5611223506 NANCY: NANCY21: Implement the Particle record
ffc2cfc4e3 NANCY: NANCY19: Add console commands for the scripted scenes
36f6285807 NANCY: NANCY21: Change to unstable - the game now starts
5c7e671d2a NANCY: NANCY16: Note DirectX SDK parts followed by NDUI and script math
997822d8d9 NANCY: NANCY16: Fix shadowing warnings in SnowmobilePuzzle
795062d75c NANCY: NANCY10: Reset the inventory scrollbar when it is reopened
ae8a75f6d9 NANCY: Play the solve sound of TurningPuzzle without a solve animation
4745ae5409 NANCY: NANCY10: Keep the normal cursor over the active QuizPuzzle box
25fd4c6b0d NANCY: Don't click when a script takes away an item
4393122b45 NANCY: Keep inventory sound overrides across scene changes
176bcd1722 NANCY: NANCY10: Fix ingredient counts in the cake mixing puzzle


Commit: 57912da14d8cd61613aaee853f36a88fbd69283e
    https://github.com/scummvm/scummvm/commit/57912da14d8cd61613aaee853f36a88fbd69283e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:16+03:00

Commit Message:
NANCY: Redraw objects where they were last drawn on the screen

Every object now keeps the place on the screen where it was last drawn,
and that is what gets redrawn when it moves, hides or is removed. The
place used to be worked out again from the viewport, which has scrolled
since in some cases, e.g. when leaving the Nancy21 phone also changes
to a scrolled scene, which left a part of the phone on the screen.

Changed paths:
    engines/nancy/graphics.cpp
    engines/nancy/renderobject.h


diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index 3cd5d3fd366..d858cd6ae03 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -160,20 +160,23 @@ void GraphicsManager::draw(bool updateScreen) {
 		current.updateGraphics();
 
 		if (current.needsRedraw()) {
+			const Common::Rect &drawnPosition = current.getDrawnScreenPosition();
 			if (current.isVisible()) {
-				if (current.hasMoved() && !current.getPreviousScreenPosition().isEmpty()) {
+				if (!drawnPosition.isEmpty() && drawnPosition != current.getScreenPosition()) {
 					// Object moved to a new location on screen, update the previous one
-					addDirtyRect(current, current.getPreviousScreenPosition());
+					addDirtyRect(current, drawnPosition);
 				}
 
 				// Redraw the current location
 				addDirtyRect(current, current.getScreenPosition());
-			} else if (!current.getPreviousScreenPosition().isEmpty()) {
-				// Object just turned invisible, redraw the last location
-				addDirtyRect(current, current.getPreviousScreenPosition());
+			} else if (!drawnPosition.isEmpty()) {
+				// Object just turned invisible, redraw the last location. An object
+				// that stays invisible has nothing to clean up, however often it changes.
+				addDirtyRect(current, drawnPosition);
 			}
 		}
 
+		current.setDrawnScreenPosition(current.isVisible() ? current.getScreenPosition() : Common::Rect());
 		current.setNeedsRedraw(false);
 		current.setHasMoved(false);
 		current.updatePreviousScreenPosition();
@@ -519,8 +522,11 @@ void GraphicsManager::reorderObject(RenderObject *object) {
 void GraphicsManager::removeObject(RenderObject *object) {
 	for (auto &r : _objects) {
 		if (r == object) {
-			// Make sure the object gets properly cleared
-			addDirtyRect(*r, r->getPreviousScreenPosition());
+			// Make sure the object gets properly cleared, where it was drawn
+			if (!r->getDrawnScreenPosition().isEmpty()) {
+				addDirtyRect(*r, r->getDrawnScreenPosition());
+			}
+
 			_objects.erase(&r);
 			break;
 		}
diff --git a/engines/nancy/renderobject.h b/engines/nancy/renderobject.h
index 6ba59ecfb89..84e7cf96573 100644
--- a/engines/nancy/renderobject.h
+++ b/engines/nancy/renderobject.h
@@ -52,6 +52,12 @@ public:
 	void setTransparent(bool isTransparent);
 	bool isVisible() const { return _isVisible; }
 
+	// Where on the screen the object was the last time it got drawn; empty when it
+	// wasn't shown. The viewport can scroll since, which moves where the object
+	// would be now, but not what is on the screen.
+	const Common::Rect &getDrawnScreenPosition() const { return _drawnScreenPosition; }
+	void setDrawnScreenPosition(const Common::Rect &position) { _drawnScreenPosition = position; }
+
 	bool needsRedraw() const { return _needsRedraw; }
 	void setNeedsRedraw(bool needsRedraw) { _needsRedraw = needsRedraw; }
 
@@ -96,6 +102,7 @@ protected:
 	bool _hasMoved;
 	bool _isNativeResolution = false;
 	uint16 _z;
+	Common::Rect _drawnScreenPosition;
 	Common::Rect _previousScreenPosition;
 	Common::Rect _screenPosition;
 };


Commit: d8506fe0b8a70a6c2cce233b95880fea1cfc7e7c
    https://github.com/scummvm/scummvm/commit/d8506fe0b8a70a6c2cce233b95880fea1cfc7e7c
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:17+03:00

Commit Message:
NANCY: Work out what gets drawn only once per frame

Drawing used to go over every object for each area being redrawn, and
again over every later one to see whether it hides the first, working
out screen positions each time. The visible objects and their places
are now gathered once per frame, which makes drawing scenes with many
objects cheaper. What gets drawn doesn't change.

Changed paths:
    engines/nancy/graphics.cpp


diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index d858cd6ae03..1f6785c3d7e 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -254,45 +254,50 @@ void GraphicsManager::filterDirtyRects(Common::List<Common::Rect> &rects) {
 // Perform the actual drawing. This checks for cases where something would be fully obscured,
 // and skips them (e.g. redrawing the Viewport won't also redraw the background)
 void GraphicsManager::drawObjects(Graphics::ManagedSurface &target, const Common::List<Common::Rect> &rects, Layer layer) {
-	for (const Common::Rect &rect : rects) {
-		for (RenderObject **it = _objects.begin(); it < _objects.end(); ++it) {
-			RenderObject &current = **it;
+	if (rects.empty()) {
+		return;
+	}
+
+	// What gets drawn and where, worked out once for all of the rects
+	struct DrawnObject {
+		const RenderObject *object;
+		Common::Rect position;
+		bool isOpaque;
+	};
 
-			if (!isInLayer(current, layer) || !current.isVisible() || current.getScreenPosition().isEmpty()) {
+	Common::Array<DrawnObject> drawn;
+	for (const RenderObject *object : _objects) {
+		if (!isInLayer(*object, layer) || !object->isVisible()) {
+			continue;
+		}
+
+		DrawnObject entry;
+		entry.object = object;
+		entry.position = object->getScreenPosition();
+		entry.isOpaque = !object->_drawSurface.hasTransparentColor() && object->_drawSurface.format != _transparentPixelFormat;
+		if (!entry.position.isEmpty()) {
+			drawn.push_back(entry);
+		}
+	}
+
+	for (const Common::Rect &rect : rects) {
+		for (uint i = 0; i < drawn.size(); ++i) {
+			const Common::Rect intersection = rect.findIntersectingRect(drawn[i].position);
+			if (intersection.isEmpty()) {
 				continue;
 			}
 
-			bool shouldSkip = false;
-
-			Common::Rect intersection = rect.findIntersectingRect(current.getScreenPosition());
-			if (!intersection.isEmpty()) {
-				// Found an intersecting RenderObject. Loop through the following
-				// RenderObjects, and see if we have another that fully obscures the intersection
-				for (auto it2 = it + 1; it2 < _objects.end(); ++it2) {
-					RenderObject &other = **it2;
-
-					if (!isInLayer(other, layer) || !other.isVisible() || other.getScreenPosition().isEmpty()) {
-						continue;
-					}
-
-					Common::Rect intersection2 = intersection.findIntersectingRect(other.getScreenPosition());
-					if (intersection == intersection2) {
-						// The entire area that would be drawn is obscured by another RenderObject.
-						// If the obscuring RenderObject is not transparent, we skip drawing current
-
-						if (!other._drawSurface.hasTransparentColor() && other._drawSurface.format != _transparentPixelFormat) {
-							// No transparency, skip current
-							shouldSkip = true;
-							break;
-						}
-					}
-				}
-
-				if (shouldSkip) {
-					continue;
+			// An opaque object that comes later and covers all of the area hides it
+			bool isHidden = false;
+			for (uint j = i + 1; j < drawn.size(); ++j) {
+				if (drawn[j].isOpaque && intersection.findIntersectingRect(drawn[j].position) == intersection) {
+					isHidden = true;
+					break;
 				}
+			}
 
-				blitObject(target, current, rect.findIntersectingRect(current.getScreenPosition()));
+			if (!isHidden) {
+				blitObject(target, *drawn[i].object, intersection);
 			}
 		}
 	}


Commit: a5de3253536e9f068023af2d6568e597f4e051fa
    https://github.com/scummvm/scummvm/commit/a5de3253536e9f068023af2d6568e597f4e051fa
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:18+03:00

Commit Message:
NANCY: Accept event flag names in set_eventflags

Flags can now also be given by name, e.g. "set_eventflags Snow_FL true"
in Nancy21, whose scripts know the flags only by name. The names are
the ones the console shows; from Nancy12 on, those of the EVNT chunk.
Numbers work as before.

Changed paths:
    engines/nancy/console.cpp


diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index b5955675eb4..cbc9c695800 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -1108,16 +1108,35 @@ bool NancyConsole::Cmd_getEventFlags(int argc, const char **argv) {
 bool NancyConsole::Cmd_setEventFlags(int argc, const char **argv) {
 	if (argc < 2 || argc % 2 == 0) {
 		debugPrintf("Sets one or more event flags to the provided value.\n");
-		debugPrintf("Usage: %s <flagID> <true/false>...\n", argv[0]);
+		debugPrintf("Usage: %s <flagID or name> <true/false>...\n", argv[0]);
 		return true;
 	}
 
 	// Event flags are addressed by label (numbered from 1000 in Nancy3 and later)
 	int baseLabel = g_nancy->getGameType() >= kGameTypeNancy3 ? 1000 : 0;
+	auto *eventFlags = GetEngineData(EVNT);
 
 	for (int i = 1; i < argc; i += 2) {
-		int label = baseLabel + atoi(argv[i]);
-		if (label - baseLabel < 0 || label - baseLabel >= (int)g_nancy->getStaticData().numEventFlags) {
+		int label = -1;
+		if (Common::isDigit(argv[i][0])) {
+			label = baseLabel + atoi(argv[i]);
+			if (label - baseLabel >= (int)g_nancy->getStaticData().numEventFlags) {
+				label = -1;
+			}
+		} else {
+			// By name. From Nancy12 on, the flags of the game are the ones of the
+			// EVNT chunk, numbered from 2000.
+			const uint firstLabel = eventFlags ? (uint)EVNT::kFirstEventFlag : (uint)baseLabel;
+			const uint numFlags = eventFlags ? eventFlags->eventFlagNames.size() : g_nancy->getStaticData().numEventFlags;
+			for (uint j = 0; j < numFlags; ++j) {
+				if (g_nancy->getEventFlagName(firstLabel + j).equalsIgnoreCase(argv[i])) {
+					label = firstLabel + j;
+					break;
+				}
+			}
+		}
+
+		if (label < 0) {
 			debugPrintf("Invalid flag %s\n", argv[i]);
 			continue;
 		}


Commit: ab5274d785f8ea8f3e7561023c3b863ff54c49c9
    https://github.com/scummvm/scummvm/commit/ab5274d785f8ea8f3e7561023c3b863ff54c49c9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:19+03:00

Commit Message:
NANCY: NANCY19: Ignore record fields set to the value they already have

Some scripts set fields like onScreen, source or z of their records
every frame, mostly to what they already are, e.g. the random fidget
movies of Nancy21. Each set redrew the record's picture and the area
it covers, which kept the CPU busy in scenes where nothing moved.
Setting a field to its current value now does nothing.

Changed paths:
    engines/nancy/script/mathtypes.h
    engines/nancy/script/record.cpp
    engines/nancy/script/records.cpp
    engines/nancy/script/types.h


diff --git a/engines/nancy/script/mathtypes.h b/engines/nancy/script/mathtypes.h
index 7033c633ba5..796ad861f12 100644
--- a/engines/nancy/script/mathtypes.h
+++ b/engines/nancy/script/mathtypes.h
@@ -50,6 +50,7 @@ struct ScriptMatrix {
 	ScriptMatrix();	// the identity
 
 	bool operator==(const ScriptMatrix &other) const;
+	bool operator!=(const ScriptMatrix &other) const { return !(*this == other); }
 	ScriptMatrix operator*(const ScriptMatrix &other) const;
 
 	// A point moved by the matrix
diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index f663636bb7c..3395db62a94 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -295,11 +295,18 @@ int Record::setScriptField() {
 			_hasActiveFunction = false;
 		}
 	} else if (key == "z") {
-		_z = luaL_checknumber(L, 3);
-		onZChanged();
+		const double z = luaL_checknumber(L, 3);
+		if (z != _z) {
+			_z = z;
+			onZChanged();
+		}
 	} else if (key == "transform" && hasTransform()) {
-		_transform = checkMatrix(L, 3);
-		onTransformChanged();
+		// Scripts that move things around set it every frame, whether it changed or not
+		const ScriptMatrix &transform = checkMatrix(L, 3);
+		if (transform != _transform) {
+			_transform = transform;
+			onTransformChanged();
+		}
 	} else if (!setField(key)) {
 		lua_pushvalue(L, 3);
 		storeValue(key.c_str());
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index fad705542c4..0607c98caf2 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -912,12 +912,28 @@ bool OverlayRecord::getField(const Common::String &key) {
 bool OverlayRecord::setField(const Common::String &key) {
 	lua_State *L = getLua();
 
+	// Only a change gets the picture drawn anew
 	if (key == "onScreen") {
-		_dest = checkRect(L, 3);
+		const ScriptRect dest = checkRect(L, 3);
+		if (dest == _dest) {
+			return true;
+		}
+
+		_dest = dest;
 	} else if (key == "source") {
-		_source = checkRect(L, 3);
+		const ScriptRect source = checkRect(L, 3);
+		if (source == _source) {
+			return true;
+		}
+
+		_source = source;
 	} else if (key == "modColor") {
-		_modColor = checkColor(L, 3);
+		const uint32 modColor = checkColor(L, 3);
+		if (modColor == _modColor) {
+			return true;
+		}
+
+		_modColor = modColor;
 	} else {
 		return false;
 	}
@@ -1130,11 +1146,17 @@ bool AnimOverlayRecord::setField(const Common::String &key) {
 	lua_State *L = getLua();
 
 	if (key == "onScreen") {
-		_dest = checkRect(L, 3);
-		applyFrame();
+		const ScriptRect dest = checkRect(L, 3);
+		if (dest != _dest) {
+			_dest = dest;
+			applyFrame();
+		}
 	} else if (key == "modColor") {
-		_modColor = checkColor(L, 3);
-		applyFrame();
+		const uint32 modColor = checkColor(L, 3);
+		if (modColor != _modColor) {
+			_modColor = modColor;
+			applyFrame();
+		}
 	} else if (key == "pauseOnLastFrame") {
 		_pausesOnLastFrame = lua_toboolean(L, 3) != 0;
 	} else if (key == "visible") {
@@ -1642,9 +1664,13 @@ bool MovieRecord::getField(const Common::String &key) {
 bool MovieRecord::setField(const Common::String &key) {
 	lua_State *L = getLua();
 
+	// Scripts set these every frame, mostly to what they already are
 	if (key == "onScreen") {
-		_dest = checkRect(L, 3);
-		applyPosition();
+		const ScriptRect dest = checkRect(L, 3);
+		if (dest != _dest) {
+			_dest = dest;
+			applyPosition();
+		}
 	} else if (key == "pauseOnLastFrame") {
 		_pausesOnLastFrame = lua_toboolean(L, 3) != 0;
 	} else if (key == "visible") {
diff --git a/engines/nancy/script/types.h b/engines/nancy/script/types.h
index e64c91b605f..88fe80db9be 100644
--- a/engines/nancy/script/types.h
+++ b/engines/nancy/script/types.h
@@ -55,6 +55,8 @@ struct ScriptRect {
 		return left == other.left && top == other.top && right == other.right && bottom == other.bottom;
 	}
 
+	bool operator!=(const ScriptRect &other) const { return !(*this == other); }
+
 	// An empty or inside out rectangle becomes an empty one
 	Common::Rect toRect() const;
 };


Commit: caaf60b8db36e2cbb2dced5e3c878fb52bf041b5
    https://github.com/scummvm/scummvm/commit/caaf60b8db36e2cbb2dced5e3c878fb52bf041b5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:20+03:00

Commit Message:
NANCY: NANCY19: Don't run a record's OnDone again while it runs

Some OnDone functions end with this:Done(), which called them again
and again until the C stack ran out, e.g. in the Backfast_SC timers
of Nancy21. A record now runs its OnDone once; the original stops
the same loop with a Lua error, which leaves the game the same way.

Changed paths:
    engines/nancy/script/record.cpp
    engines/nancy/script/record.h


diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index 3395db62a94..2dedf1f66f3 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -209,7 +209,17 @@ void Record::limitZ(double z) {
 
 void Record::finish() {
 	_isDone = true;
+
+	// Some OnDone functions end with this:Done(), which would call them again and
+	// again until the C stack ran out. The original stops there with a Lua error;
+	// running them once leaves things the same way.
+	if (_isFinishing) {
+		return;
+	}
+
+	_isFinishing = true;
 	callFunction("OnDone");
+	_isFinishing = false;
 }
 
 bool Record::reset() {
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index dbdbc6ccffd..cf10b9b182f 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -205,6 +205,7 @@ private:
 	bool _isActive = true;
 	bool _wasActive = false;
 	bool _isDone = false;
+	bool _isFinishing = false;	// while OnDone runs
 	bool _hasRunOnce = false;
 
 	bool _isScriptOwned = false;


Commit: c3c2ec27dbb44f4d949df0b33feee56be2ee8c77
    https://github.com/scummvm/scummvm/commit/c3c2ec27dbb44f4d949df0b33feee56be2ee8c77
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:22+03:00

Commit Message:
NANCY: NANCY19: Skip speech with Esc, and make skipping movies quicker

- Esc now skips the movies of every stream, like a phone call playing
  over the scene. When no movie is playing, it ends what the characters
  are saying: a voice line, or a line of a conversation. In a
  conversation, the lip-synced head stops on its last frame, so it
  stays in step with the body.
- A skipped movie counts its frames up to the end without decoding
  them, so the scripts that wait for one of its frames still see it.
  Decoding them made the skip take seconds, e.g. in Nancy21 scene 1700,
  whose cutscene has no keyframe other than its first.
- A movie that ended during a skip no longer keeps the skip going when
  the scripts start it over, as the random fidget movies do.

Changed paths:
    engines/nancy/script/convo.cpp
    engines/nancy/script/convo.h
    engines/nancy/script/record.cpp
    engines/nancy/script/record.h
    engines/nancy/script/records.cpp
    engines/nancy/script/records.h
    engines/nancy/script/scenecontext.cpp
    engines/nancy/script/scenecontext.h
    engines/nancy/state/scene.cpp
    engines/nancy/state/scenestream.cpp
    engines/nancy/state/scenestream.h


diff --git a/engines/nancy/script/convo.cpp b/engines/nancy/script/convo.cpp
index a6339430101..dc68f577fa5 100644
--- a/engines/nancy/script/convo.cpp
+++ b/engines/nancy/script/convo.cpp
@@ -282,6 +282,10 @@ void ConvoRecord::showFrame(uint frame) {
 // The line is over once the XSheet has reached its last frame, and the parts
 // that play by themselves have played through
 bool ConvoRecord::isVideoDone() const {
+	if (_isLineSkipped) {
+		return true;
+	}
+
 	for (const Tree *tree : _trees) {
 		if (isTreeFreeRunning(*tree) && !tree->player.endOfVideo()) {
 			return false;
@@ -291,6 +295,50 @@ bool ConvoRecord::isVideoDone() const {
 	return _numFrames == 0 || _frame == _numFrames - 1;
 }
 
+// The character stops talking, and the video goes to the end of the line, where
+// the responses wait to be picked. A response that is being said stops as well.
+bool ConvoRecord::skipSpeech() {
+	if (!isActive() || isDone()) {
+		return false;
+	}
+
+	switch (_state) {
+	case kWaitForLine:
+	case kPlayLine:
+		if (_isLineSkipped) {
+			return false;
+		}
+
+		g_nancy->_sound->stopSound(kCharacterChannel);
+		_hasSoundStarted = false;
+		_isLineSkipped = true;
+
+		// The parts that play by themselves, like the head that moves its lips,
+		// stop on their last frame, so they stay together with the rest
+		for (Tree *tree : _trees) {
+			if (isTreeFreeRunning(*tree) && tree->player.getFrameCount() > 0) {
+				const Graphics::Surface *movieFrame = tree->player.decodeNextFrame(tree->player.getFrameCount() - 1);
+				if (movieFrame) {
+					drawTree(*tree, *movieFrame);
+				}
+			}
+		}
+
+		if (_isVideo && _numFrames > 0) {
+			showFrame(_numFrames - 1);
+		}
+
+		return true;
+	case kWaitForResponse:
+	case kPlayResponse:
+		g_nancy->_sound->stopSound(kPlayerChannel);
+		_state = kPlayResponse;
+		return true;
+	default:
+		return false;
+	}
+}
+
 void ConvoRecord::playSound(const Common::String &name, uint16 channel) {
 	_sound = SoundDescription();
 	_sound.name = name;
@@ -391,6 +439,7 @@ void ConvoRecord::run(double sceneTime, double elapsedTime) {
 
 	switch (_state) {
 	case kStartLine:
+		_isLineSkipped = false;
 		playSound(_soundNames[_curSound], kCharacterChannel);
 
 		if (_curSound > 0 && ui) {
@@ -419,7 +468,7 @@ void ConvoRecord::run(double sceneTime, double elapsedTime) {
 		break;
 	case kPlayLine: {
 		for (Tree *tree : _trees) {
-			if (isTreeFreeRunning(*tree) && tree->player.needsUpdate()) {
+			if (!_isLineSkipped && isTreeFreeRunning(*tree) && tree->player.needsUpdate()) {
 				const Graphics::Surface *movieFrame = tree->player.decodeNextFrame();
 				if (movieFrame) {
 					drawTree(*tree, *movieFrame);
@@ -427,7 +476,7 @@ void ConvoRecord::run(double sceneTime, double elapsedTime) {
 			}
 		}
 
-		if (_isVideo && _numFrames > 0) {
+		if (_isVideo && _numFrames > 0 && !_isLineSkipped) {
 			// The frames keep up with the sound, and go on one at a time after it
 			uint frame = _frame + 1;
 			if (isSoundPlaying) {
diff --git a/engines/nancy/script/convo.h b/engines/nancy/script/convo.h
index 674514826b9..5cdad22c8ef 100644
--- a/engines/nancy/script/convo.h
+++ b/engines/nancy/script/convo.h
@@ -66,6 +66,9 @@ public:
 	// Responses can be added while the scripts named by the record are run
 	void addResponse(ConvoResponse *response) { _responses.push_back(response); }
 
+	// Enhancement: ends the line that is being said
+	bool skipSpeech() override;
+
 	// The UI reports the response the player picked
 	void onEngineAction(const UI::NDUI::Action &action) override;
 
@@ -124,6 +127,7 @@ private:
 	SoundDescription _sound;
 	bool _hasSoundStarted = false;
 	double _lineStartTime = 0.0;
+	bool _isLineSkipped = false;
 
 	Common::Array<ConvoResponse *> _offeredResponses;
 	int _pickedResponse = -1;
diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index 2dedf1f66f3..76a6500edc6 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -209,6 +209,9 @@ void Record::limitZ(double z) {
 
 void Record::finish() {
 	_isDone = true;
+	if (getContext().isSkipping()) {
+		_hasEndedInSkip = true;
+	}
 
 	// Some OnDone functions end with this:Done(), which would call them again and
 	// again until the C stack ran out. The original stops there with a Lua error;
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index cf10b9b182f..6395d538701 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -154,6 +154,15 @@ public:
 	virtual bool isSkippable() const { return false; }
 	virtual void onSkip() {}
 
+	// A record that ended during a skip doesn't keep it going when the scripts
+	// start it over, as the random fidget movies do
+	bool canBeSkipped() const { return !_hasEndedInSkip && isSkippable(); }
+	void onSkipEnded() { _hasEndedInSkip = false; }
+
+	// Enhancement: what a character says can be skipped as well. Returns
+	// whether the record was saying something.
+	virtual bool skipSpeech() { return false; }
+
 protected:
 	lua_State *getLua() const;
 
@@ -206,6 +215,7 @@ private:
 	bool _wasActive = false;
 	bool _isDone = false;
 	bool _isFinishing = false;	// while OnDone runs
+	bool _hasEndedInSkip = false;
 	bool _hasRunOnce = false;
 
 	bool _isScriptOwned = false;
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index 0607c98caf2..b54581bd758 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -1204,6 +1204,24 @@ void SoundRecord::onSkip() {
 	}
 }
 
+// The channels speech plays on: NPCVoice, PlayerVoice, Voice1, Voice2, UIVoice
+// and EngineVoice
+static const uint16 kFirstVoiceChannel = 24;
+static const uint16 kLastVoiceChannel = 29;
+
+bool SoundRecord::skipSpeech() {
+	if (!isActive() || !hasStarted() || _isLooping || !_isPlaying ||
+			_channel < kFirstVoiceChannel || _channel > kLastVoiceChannel ||
+			!g_nancy->_sound->isSoundPlaying(_sound)) {
+		return false;
+	}
+
+	// The record ends with its next run, and takes the caption with it
+	g_nancy->_sound->stopSound(_sound);
+	_endTime = 0.0;
+	return true;
+}
+
 void SoundRecord::runOnce() {
 	// A sound that would start in a movie that is being skipped is over at once,
 	// unless it is one that keeps playing afterwards
@@ -1552,20 +1570,24 @@ bool MovieRecord::isSkippable() const {
 }
 
 void MovieRecord::run(double sceneTime, double elapsedTime) {
-	// A movie that is being skipped moves on by one frame every time it runs
-	if (getContext().isSkipping() && isSkippable()) {
-		const int frame = _player.getCurrentFrame();
-		bool hasEnded = frame == _lastFrame;
-
-		if (!hasEnded && _lastFrame > frame) {
-			hasEnded = _player.decodeNextFrame() == nullptr;
-		} else if (!hasEnded) {
-			_player.goToFrame(frame - 1);
+	// A movie that is being skipped moves on by one frame every time it runs, so
+	// the scripts that wait for one of its frames still see it. The frames on the
+	// way aren't decoded, and neither is the last one: a skipped movie ends, which
+	// hides it, and getting to its last frame can take decoding all of it, since
+	// some have no other keyframe than their first one.
+	if (getContext().isSkipping() && canBeSkipped()) {
+		if (_skipFrame < 0) {
+			_skipFrame = _player.getCurrentFrame();
 		}
 
-		if (hasEnded) {
-			_player.goToFrame(_lastFrame);
-			drawFrame();
+		if (_skipFrame < _lastFrame) {
+			++_skipFrame;
+		} else if (_skipFrame > _lastFrame) {
+			--_skipFrame;
+		}
+
+		// The movie stays at its last frame for the scripts until it starts over
+		if (_skipFrame == _lastFrame) {
 			_endTime = sceneTime;
 			finish();
 		}
@@ -1573,6 +1595,8 @@ void MovieRecord::run(double sceneTime, double elapsedTime) {
 		return;
 	}
 
+	_skipFrame = -1;
+
 	if (!_isPaused && _player.update()) {
 		drawFrame();
 	}
@@ -1593,6 +1617,7 @@ void MovieRecord::run(double sceneTime, double elapsedTime) {
 
 bool MovieRecord::prepareReset() {
 	_endTime = 0.0;
+	_skipFrame = -1;
 	_isPaused = false;
 	_hasCalledOnPause = false;
 	_player.goToFrame(_firstFrame);
@@ -1645,7 +1670,7 @@ bool MovieRecord::getField(const Common::String &key) {
 	} else if (key == "visible") {
 		lua_pushboolean(L, _isVisible);
 	} else if (key == "frame") {
-		lua_pushinteger(L, _player.getCurrentFrame());
+		lua_pushinteger(L, _skipFrame >= 0 ? _skipFrame : _player.getCurrentFrame());
 	} else if (key == "endTime") {
 		lua_pushnumber(L, _endTime);
 	} else if (key == "firstFrame") {
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 4af17ebae24..81fa62b94ac 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -305,6 +305,7 @@ public:
 	SoundRecord(SceneContext &context) : Record(context, "ND.SoundAR") {}
 
 	void onSkip() override;
+	bool skipSpeech() override;
 
 protected:
 	void readTable(int tableIndex) override;
@@ -403,6 +404,7 @@ private:
 	MoviePlayer _player;
 	RecordGraphic _graphic;
 	ScriptRect _dest;
+	int _skipFrame = -1;	// the frame a skipped movie is at; -1 when not skipped
 
 	int _firstFrame = 0;
 	int _lastFrame = 0;
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index b390e43bd7a..f17fb9ca56f 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -195,7 +195,7 @@ static const double kSkipFrameSeconds = 1.0 / 15.0;
 
 bool SceneContext::hasSkippableRecord() const {
 	for (const Record *record : _records) {
-		if (record->isSkippable()) {
+		if (record->canBeSkipped()) {
 			return true;
 		}
 	}
@@ -216,6 +216,15 @@ bool SceneContext::skipMovies() {
 	return true;
 }
 
+bool SceneContext::skipSpeech() {
+	bool hasSkipped = false;
+	for (Record *record : _records) {
+		hasSkipped |= record->skipSpeech();
+	}
+
+	return hasSkipped;
+}
+
 void SceneContext::end() {
 	for (Record *record : _records) {
 		record->onDie();
@@ -308,6 +317,9 @@ void SceneContext::run(double elapsedTime) {
 	for (uint i = 0; _isSkipping && i < kSkipFramesPerRun; ++i) {
 		if (_hasChangedScene || !hasSkippableRecord()) {
 			_isSkipping = false;
+			for (Record *record : _records) {
+				record->onSkipEnded();
+			}
 		} else {
 			runFrame(kSkipFrameSeconds);
 		}
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index a4db8e8642e..2c27a080103 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -109,6 +109,10 @@ public:
 	bool skipMovies();
 	bool isSkipping() const { return _isSkipping; }
 
+	// Enhancement: ends what the characters of the scene are saying. Returns false
+	// when nobody is talking.
+	bool skipSpeech();
+
 	// The conversation of the scene, once one has begun
 	ConvoRecord *getConvo() const { return _convo; }
 	void setConvo(ConvoRecord *convo) { _convo = convo; }
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 80e2d4c2ce5..a7fda051115 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2756,7 +2756,14 @@ void Scene::handleInput() {
 			_hasSkippedMovie = _activeMovie->skip();
 #ifdef ENABLE_NANCY_V4
 		} else if (_scriptScene) {
-			_scriptScene->skipMovies();
+			// The movies of any stream go first, along with what is said in them.
+			// Without one, Esc ends what the characters are saying.
+			bool hasSkipped = _scriptScene->skipMovies();
+			hasSkipped |= _streams.skipMovies();
+			if (!hasSkipped) {
+				_scriptScene->skipSpeech();
+				_streams.skipSpeech();
+			}
 #endif
 		} else if (g_nancy->getGameType() <= kGameTypeNancy15) {
 			// Nancy16 has no main menu state
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index 036fd5886ca..121801fabde 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -321,6 +321,38 @@ void SceneStream::clearScene() {
 
 static const char *const kMainStreamName = "MainStream";
 
+#ifdef ENABLE_NANCY_V4
+bool SceneStream::skipSpeech() {
+	return _scriptScene && !_isEnded && _scriptScene->skipSpeech();
+}
+
+bool SceneStream::skipMovies() {
+	return _scriptScene && !_isEnded && _scriptScene->skipMovies();
+}
+
+bool SceneStreamManager::skipSpeech() {
+	bool hasSkipped = false;
+	for (SceneStream *stream : _streams) {
+		if (stream) {
+			hasSkipped |= stream->skipSpeech();
+		}
+	}
+
+	return hasSkipped;
+}
+
+bool SceneStreamManager::skipMovies() {
+	bool hasSkipped = false;
+	for (SceneStream *stream : _streams) {
+		if (stream) {
+			hasSkipped |= stream->skipMovies();
+		}
+	}
+
+	return hasSkipped;
+}
+#endif
+
 SceneStream *SceneStreamManager::find(const Common::String &name) const {
 	for (SceneStream *stream : _streams) {
 		if (stream && !stream->isEnded() && stream->getName().equalsIgnoreCase(name)) {
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
index b9b11883e0b..2a5be2a247e 100644
--- a/engines/nancy/state/scenestream.h
+++ b/engines/nancy/state/scenestream.h
@@ -90,6 +90,12 @@ public:
 	// Nancy19: hands a message to the scene. Returns false when it isn't for it.
 	bool receiveMessage(const Script::Message &message, bool &wasTaken);
 
+#ifdef ENABLE_NANCY_V4
+	// Enhancement: what Esc skips in the scene of the stream
+	bool skipSpeech();
+	bool skipMovies();
+#endif
+
 	void changeScene(const Common::String &sceneName, uint16 frameID);
 	void end() { _isEnded = true; }
 	bool isEnded() const { return _isEnded; }
@@ -179,6 +185,13 @@ public:
 	// false when there is none.
 	bool receiveMessage(const Script::Message &message, bool &wasTaken);
 
+#ifdef ENABLE_NANCY_V4
+	// Enhancement: Esc skips what is being said in any of the streams, or else
+	// the movies they play. Returns whether there was something to skip.
+	bool skipSpeech();
+	bool skipMovies();
+#endif
+
 	// Keeps the streams in the saved game, and starts them again after loading
 	void store();
 	void restore();


Commit: f9ad0de1dec6b7f87ebfb9f9971cbca07f7e79e6
    https://github.com/scummvm/scummvm/commit/f9ad0de1dec6b7f87ebfb9f9971cbca07f7e79e6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:24+03:00

Commit Message:
NANCY: NANCY19: Keep the fractions of script z values in the draw order

Script z values like 0.1 were cut down to whole numbers, so e.g. a
dragged jigsaw piece of Nancy21 could end up under the placed ones.
Each script z step is now 1000 draw orders. The UI panels and the
tooltip move up to 60000 and 64000 to stay above that, in Nancy16 and
later too, where nothing else is drawn that high.

Changed paths:
    engines/nancy/script/records.cpp
    engines/nancy/script/records.h
    engines/nancy/state/scenestream.cpp
    engines/nancy/ui/ndui/manager.cpp
    engines/nancy/ui/ndui/manager.h


diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index b54581bd758..526a5fa731a 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -107,9 +107,22 @@ Record *makeRecord(RecordKind kind, SceneContext &context) {
 static const double kBackgroundScriptZ = -5.0;
 static const int kBackgroundZ = 6;
 
+// Scripts tell apart z values as close as 0.001, and every stream is one step
+// closer than the one that began it
+static const double kStepsPerScriptZ = 1000.0;
+static const int kMaxScriptZOrder = 59000;
+
+uint16 RecordGraphic::getScriptZOrder(double z, int32 zOffset) {
+	const double steps = (z - kBackgroundScriptZ + zOffset) * kStepsPerScriptZ;
+	return (uint16)CLIP<int>(kBackgroundZ + (int)floor(steps + 0.5), 0, kMaxScriptZOrder);
+}
+
+uint16 RecordGraphic::getBackgroundZOrder(int32 zOffset) {
+	return getScriptZOrder(kBackgroundScriptZ, zOffset);
+}
+
 void RecordGraphic::setScriptZ(double z, int32 streamZOffset) {
-	const int engineZ = kBackgroundZ + (int)(z - kBackgroundScriptZ) + streamZOffset;
-	setZOrder((uint16)CLIP<int>(engineZ, 0, 0xFFFF));
+	setZOrder(getScriptZOrder(z, streamZOffset));
 }
 
 // Where something that was placed in the viewport ends up once the transform
@@ -862,7 +875,7 @@ void OverlayRecord::updateGraphics() {
 }
 
 // What a script draws by itself goes over everything else the scene shows
-static const uint16 kAttachmentZ = 999;
+static const uint16 kAttachmentZ = 59999;
 
 // TODO: The picture is cut off at the edges of the viewport
 void OverlayRecord::renderNow() {
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 81fa62b94ac..68491de44bc 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -80,6 +80,10 @@ public:
 	// Scripts order what they draw with numbers of their own
 	void setScriptZ(double z, int32 streamZOffset);
 
+	// The z order of a script z in a stream that is the given steps in front
+	static uint16 getScriptZOrder(double z, int32 zOffset);
+	static uint16 getBackgroundZOrder(int32 zOffset);
+
 protected:
 	bool isViewportRelative() const override { return true; }
 };
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index 121801fabde..61f6e6d7bea 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -228,6 +228,11 @@ void SceneStream::loadBackground(const Common::Path &videoFile) {
 	_background = new UI::Viewport();
 	_background->init();
 	_background->setZOrder(_background->getZOrder() + _zOffset);
+#ifdef ENABLE_NANCY_V4
+	if (_scriptScene) {
+		_background->setZOrder(Script::RecordGraphic::getBackgroundZOrder(_zOffset));
+	}
+#endif
 	_background->loadVideo(videoFile, _sceneInfo.frameID, 0, kPanNone, kLargeVideoFormat);
 	_background->registerGraphics();
 }
diff --git a/engines/nancy/ui/ndui/manager.cpp b/engines/nancy/ui/ndui/manager.cpp
index cd3256245a5..026f60d63b5 100644
--- a/engines/nancy/ui/ndui/manager.cpp
+++ b/engines/nancy/ui/ndui/manager.cpp
@@ -41,7 +41,7 @@ namespace UI {
 namespace NDUI {
 
 // Panels are drawn over everything else, in the order they were created
-static const uint16 kFirstPanelZ = 1000;
+static const uint16 kFirstPanelZ = 60000;
 
 // The cursor shown while the mouse is over the UI; Nancy19 has it by name
 static const uint16 kUICursor = 14;
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 37af648f8ea..c4c753d38c2 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -69,7 +69,7 @@ public:
 // get created while the game runs, like journal entries and conversation responses.
 class Manager {
 public:
-	static const uint16 kTooltipZ = 2000;
+	static const uint16 kTooltipZ = 64000;
 
 	// What the UI asked of the engine, which has to wait until the UI is done
 	// handling the player's input, since it can replace the UI


Commit: 558b23feb4088c362d7feddc1e381b0378b88c6f
    https://github.com/scummvm/scummvm/commit/558b23feb4088c362d7feddc1e381b0378b88c6f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:25+03:00

Commit Message:
NANCY: NANCY19: Don't let scene records change the cursor over the UI

When the mouse is over the UI, the UI's cursor is now kept. Before,
the records of the scene replaced it with their own, e.g. the air
hockey of Nancy21 hid the cursor over the load dialog.

Changed paths:
    engines/nancy/script/scenecontext.cpp


diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index f17fb9ca56f..95eb4893164 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -478,12 +478,15 @@ void SceneContext::handleInput(NancyInput &input) {
 		record->sendMouseMove(point, inViewport);
 	}
 
-	for (Record *record : _inputOrder) {
-		uint cursorType = 0;
-		bool isHot = false;
-		if (record->sendChangeCursor(point, inViewport, cursorType, isHot)) {
-			g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, isHot);
-			break;
+	// The UI that took the mouse already picked the cursor
+	if (input.mousePos.x >= 0) {
+		for (Record *record : _inputOrder) {
+			uint cursorType = 0;
+			bool isHot = false;
+			if (record->sendChangeCursor(point, inViewport, cursorType, isHot)) {
+				g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, isHot);
+				break;
+			}
 		}
 	}
 


Commit: fce3b5afef2825180d46bffa6e3094de08b7add9
    https://github.com/scummvm/scummvm/commit/fce3b5afef2825180d46bffa6e3094de08b7add9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:31+03:00

Commit Message:
NANCY: NANCY16: Don't add journal and task entries twice, or too early

A journal or task entry handed out again no longer shows up twice.
One handed out before the UI is there, e.g. right after loading in
Nancy21, is kept and comes back with the UI, instead of giving
"Couldn't create UI object" warnings.

Changed paths:
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index 7229296f17f..ae4ce9f2a91 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -406,12 +406,23 @@ void Manager::addJournalEntry(const Common::String &pageName, const Common::Stri
 	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
 	assert(autotext);
 
+	// The scripts that hand out the entries can run before the UI is there, e.g.
+	// right after loading. The entries are kept, and come back with the UI.
+	const Panel *page = findPanel(pageName);
+	if (!page) {
+		return;
+	}
+
 	Common::String title = autotext->texts.getValOrDefault(pageName);
 	if (!_journalPages.contains(title)) {
 		takeAction(kActionAddText, "JournalItems", title);
 		_journalPages.setVal(title, pageName);
 	}
 
+	if (page->hasControl(textKey)) {
+		return;
+	}
+
 	createObject(textKey, "JournalEntry", pageName);
 	takeAction(kActionAddText, textKey, autotext->texts.getValOrDefault(textKey));
 }
@@ -443,7 +454,17 @@ void Manager::addTasklistEntry(const Common::String &textKey, int16 flag) {
 	assert(autotext);
 
 	_tasklistFlags.setVal(textKey, flag);
-	createObject(textKey, "TasklistEntry", "TasklistDialog");
+
+	// Like the journal entries, the tasks come back with the UI
+	const Panel *list = findPanel("TasklistDialog");
+	if (!list) {
+		return;
+	}
+
+	if (!list->hasControl(textKey)) {
+		createObject(textKey, "TasklistEntry", "TasklistDialog");
+	}
+
 	takeAction(kActionSetText, textKey, autotext->texts.getValOrDefault(textKey));
 
 	TasklistData *tasklistData = (TasklistData *)NancySceneState.getPuzzleData(TasklistData::getTag());
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index b832e6c4d64..77f00721bb6 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -233,6 +233,16 @@ void Panel::addBuiltInTooltip() {
 	_tooltip->read(stream);
 }
 
+bool Panel::hasControl(const Common::String &name) const {
+	for (const Control *control : _controls) {
+		if (control->getName().equalsIgnoreCase(name)) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
 bool Panel::createObject(const Common::String &name, const Common::String &templateName) {
 	return _objectFactory && _objectFactory->createObject(name, templateName);
 }
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index 5660ebaa1da..1d3dda5b029 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -84,6 +84,8 @@ public:
 	// Whether saved games keep what was done to the panel and its controls
 	void setSavesState(bool savesState) { _savesState = savesState; }
 
+	bool hasControl(const Common::String &name) const;
+
 	// Nancy17 keeps the items apart from the panel that shows them, which comes
 	// with nothing but a template for their buttons
 	void addItemButtons(const Common::Array<byte> &buttonTemplate);


Commit: 6c1d9c480455e570b05ca6604959e427c148d076
    https://github.com/scummvm/scummvm/commit/6c1d9c480455e570b05ca6604959e427c148d076
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:33+03:00

Commit Message:
NANCY: NANCY21: Add the smaller additions to the script API

- string.compare and string.comparenocase
- Cursor:MoveTo, which puts the mouse at a place in the viewport
- Z.min and Z.max, the range z values are kept in
- Matrix:Perspective, for the falling snow
- The squared length of vectors, lenSq or lengthSq, for the air hockey

Changed paths:
    engines/nancy/script/gameapi.cpp
    engines/nancy/script/luastate.cpp
    engines/nancy/script/mathtypes.cpp


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 4766ada03cc..fa82a5cf2e8 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -192,6 +192,10 @@ static const uint kNoCursorType = 0xFFFFFFFF;
 static int cursorIndex(lua_State *L) {
 	const char *name = luaL_checkstring(L, 2);
 
+	if (strcmp(name, "MoveTo") == 0) {
+		return indexFunctions(L);
+	}
+
 	const int type = g_nancy->_cursor->getCursorTypeByName(name);
 	if (type >= 0) {
 		pushCursor(L, type, false);
@@ -282,9 +286,22 @@ static int cursorNewIndex(lua_State *L) {
 	return 0;
 }
 
+// Cursor:MoveTo(point), Nancy21: puts the mouse at a place in the viewport
+static int cursorMoveTo(lua_State *L) {
+	ScriptPoint point;
+	if (!readPoint(L, 2, point)) {
+		return luaL_argerror(L, 2, "expected a point");
+	}
+
+	const Common::Rect screen = NancySceneState.getViewport().convertViewportToScreen(Common::Rect(point.x, point.y, point.x + 1, point.y + 1));
+	g_nancy->_cursor->warpCursor(Common::Point(screen.left, screen.top));
+	return 0;
+}
+
 static const luaL_Reg kCursorFunctions[] = {
 	{ "__index", cursorIndex },
 	{ "__newindex", cursorNewIndex },
+	{ "MoveTo", cursorMoveTo },
 	{ nullptr, nullptr }
 };
 
@@ -1474,6 +1491,13 @@ void registerGameAPI(lua_State *L) {
 	luaL_getmetatable(L, "ND.ZHelper");
 	lua_pushnumber(L, 0.0);
 	lua_setfield(L, -2, "middle");
+	if (g_nancy->getGameType() >= kGameTypeNancy21) {
+		// The range z gets kept in
+		lua_pushnumber(L, -1.0);
+		lua_setfield(L, -2, "min");
+		lua_pushnumber(L, 8.9);
+		lua_setfield(L, -2, "max");
+	}
 	lua_pop(L, 1);
 
 	registerType(L, kCursorIndexType, kCursorIndexFunctions);
diff --git a/engines/nancy/script/luastate.cpp b/engines/nancy/script/luastate.cpp
index 7716aa162d3..7cc132a248a 100644
--- a/engines/nancy/script/luastate.cpp
+++ b/engines/nancy/script/luastate.cpp
@@ -143,6 +143,30 @@ static int mathIsNaN(lua_State *L) {
 	return 1;
 }
 
+// Nancy21's string.compare(a, b[, ordered]) and string.comparenocase(): whether
+// the strings are the same, or with "ordered" set, a number below, at or above 0
+static int compareStrings(lua_State *L, bool ignoreCase) {
+	const char *a = luaL_checkstring(L, 1);
+	const char *b = luaL_checkstring(L, 2);
+	const int result = ignoreCase ? scumm_stricmp(a, b) : strcmp(a, b);
+
+	if (lua_gettop(L) > 2 && lua_toboolean(L, 3)) {
+		lua_pushinteger(L, result);
+	} else {
+		lua_pushboolean(L, result == 0);
+	}
+
+	return 1;
+}
+
+static int stringCompare(lua_State *L) {
+	return compareStrings(L, false);
+}
+
+static int stringCompareNoCase(lua_State *L) {
+	return compareStrings(L, true);
+}
+
 LuaState::LuaState(SceneContext *context) {
 	_state = luaL_newstate();
 	if (!_state) {
@@ -161,6 +185,11 @@ LuaState::LuaState(SceneContext *context) {
 		setLibraryFunction(_state, LUA_MATHLIBNAME, "isnan", mathIsNaN);
 	}
 
+	if (g_nancy->getGameType() >= kGameTypeNancy21) {
+		setLibraryFunction(_state, LUA_STRLIBNAME, "compare", stringCompare);
+		setLibraryFunction(_state, LUA_STRLIBNAME, "comparenocase", stringCompareNoCase);
+	}
+
 	removeGlobal(_state, "collectgarbage");
 	removeGlobal(_state, "dofile");
 	removeGlobal(_state, "load");
diff --git a/engines/nancy/script/mathtypes.cpp b/engines/nancy/script/mathtypes.cpp
index 06e13fdc123..03b3ce5e231 100644
--- a/engines/nancy/script/mathtypes.cpp
+++ b/engines/nancy/script/mathtypes.cpp
@@ -216,6 +216,12 @@ static int vectorIndex(lua_State *L) {
 			lua_pushnumber(L, getVectorLength(vector));
 			return 1;
 		}
+
+		if (strcmp(key, "lenSq") == 0 || strcmp(key, "lengthSq") == 0) {
+			const double length = getVectorLength(vector);
+			lua_pushnumber(L, length * length);
+			return 1;
+		}
 	}
 
 	const int component = getVectorComponent(L, 2);
@@ -518,6 +524,27 @@ static int matrixTranslate(lua_State *L) {
 	return 1;
 }
 
+// Matrix:Perspective(fovy, aspectRatio, zNear, zFar), Nancy21: the projection of a
+// camera looking down the z axis, for the particles
+static int matrixPerspective(lua_State *L) {
+	const double fovy = luaL_checknumber(L, 2);
+	const double aspectRatio = luaL_checknumber(L, 3);
+	const double zNear = luaL_checknumber(L, 4);
+	const double zFar = luaL_checknumber(L, 5);
+
+	const double yScale = 1.0 / tan(fovy / 2.0);
+
+	ScriptMatrix matrix;
+	matrix.m[0][0] = yScale / aspectRatio;
+	matrix.m[1][1] = yScale;
+	matrix.m[2][2] = zFar / (zFar - zNear);
+	matrix.m[2][3] = 1.0;
+	matrix.m[3][2] = -zNear * zFar / (zFar - zNear);
+	matrix.m[3][3] = 0.0;
+	pushMatrix(L, matrix);
+	return 1;
+}
+
 // Matrix:Scale(x, y, z)
 static int matrixScale(lua_State *L) {
 	ScriptMatrix matrix;
@@ -762,6 +789,7 @@ static const luaL_Reg kMatrixHelperFunctions[] = {
 	{ "Translate", matrixTranslate },
 	{ "Scaling", matrixScale },
 	{ "Scale", matrixScale },
+	{ "Perspective", matrixPerspective },
 	{ "RotationAngle", matrixRotateAngle },
 	{ "RotateAngle", matrixRotateAngle },
 	{ "RotationAxis", matrixRotateAxis },


Commit: 9bce91bf23161fa6ac14393c320ed4a694487278
    https://github.com/scummvm/scummvm/commit/9bce91bf23161fa6ac14393c320ed4a694487278
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:34+03:00

Commit Message:
NANCY: NANCY21: Look up texts through the Autotext object

Autotext[key] gives the text of that key, or nil when there is none,
like the original. The catalog puzzle of Nancy21 checks its book
titles this way, and wouldn't start without it.

Changed paths:
    engines/nancy/script/gameapi.cpp


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index fa82a5cf2e8..20b80240d00 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -1476,8 +1476,27 @@ static int autotextRemoveCC(lua_State *L) {
 	return 1;
 }
 
+// Autotext.<key> is the text of that key, or nil when there is none
+static int autotextIndex(lua_State *L) {
+	indexFunctions(L);
+	if (!lua_isnil(L, -1) || lua_type(L, 2) != LUA_TSTRING) {
+		return 1;
+	}
+
+	lua_pop(L, 1);
+
+	Common::String text;
+	if (findText(lua_tostring(L, 2), text)) {
+		lua_pushstring(L, text.c_str());
+	} else {
+		lua_pushnil(L);
+	}
+
+	return 1;
+}
+
 static const luaL_Reg kAutotextFunctions[] = {
-	{ "__index", indexFunctions },
+	{ "__index", autotextIndex },
 	{ "RemoveCC", autotextRemoveCC },
 	{ nullptr, nullptr }
 };


Commit: 9accef79fbbe41b6e012da0d9ee3afca93054a40
    https://github.com/scummvm/scummvm/commit/9accef79fbbe41b6e012da0d9ee3afca93054a40
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:36+03:00

Commit Message:
NANCY: NANCY21: Take the font of Text records from their font table

Text records can name their font and whether it's bold in their font
table, instead of giving its number. Those texts got no font and were
left out, e.g. the orders of the snack shop puzzle.

Changed paths:
    engines/nancy/script/uiapi.cpp


diff --git a/engines/nancy/script/uiapi.cpp b/engines/nancy/script/uiapi.cpp
index 67e2ee832ce..972ca756f31 100644
--- a/engines/nancy/script/uiapi.cpp
+++ b/engines/nancy/script/uiapi.cpp
@@ -341,7 +341,13 @@ void writeTextElement(lua_State *L, int tableIndex, const Common::String &fontNa
 	int32 horizontalAlignment = kAlignCenter;
 	int32 verticalAlignment = kAlignTop;
 
+	// A font named in the font table takes the place of the one by number
+	Common::String name = fontName;
+	bool bold = isBold;
+
 	pushTableField(L, tableIndex, "font");
+	readField(L, -1, "name", name);
+	readField(L, -1, "bold", bold);
 	readNamedField(L, -1, "horzAlign", kHorizontalAlignments, ARRAYSIZE(kHorizontalAlignments), horizontalAlignment);
 	readNamedField(L, -1, "vertAlign", kVerticalAlignments, ARRAYSIZE(kVerticalAlignments), verticalAlignment);
 	readField(L, -1, "wrap", wraps);
@@ -354,8 +360,8 @@ void writeTextElement(lua_State *L, int tableIndex, const Common::String &fontNa
 		out.writeUint32BE(kDefaultColor);
 	}
 
-	writeName(out, fontName);
-	out.writeByte(isBold);
+	writeName(out, name);
+	out.writeByte(bold);
 	out.writeSint32LE(horizontalAlignment);
 	out.writeSint32LE(verticalAlignment);
 	out.writeByte(wraps);


Commit: 3a2c4a5529a48831e7d38370fcc9878b7dff1e91
    https://github.com/scummvm/scummvm/commit/3a2c4a5529a48831e7d38370fcc9878b7dff1e91
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:37+03:00

Commit Message:
NANCY: NANCY21: Add the new ways of handling streams

- Scene:BeginStream also takes a table, e.g. {stream, scene, ends,
  captureInput}. A stream begun this way is a step in front of the
  one that began it.
- A stream in front of the main one gets the mouse first, so that a
  click on the phone doesn't go through to the scene behind it.
- Scripts can ask for the running streams, how far in front they are,
  and the environment of the main scene, e.g. for the day and night
  changes and the camera.

Changed paths:
    engines/nancy/script/gameapi.cpp
    engines/nancy/state/scene.cpp
    engines/nancy/state/scenestream.cpp
    engines/nancy/state/scenestream.h


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 20b80240d00..239619301c4 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -978,15 +978,8 @@ static int sceneChangeScene(lua_State *L) {
 	return 1;
 }
 
-// Scene:BeginStream(name, scene, frame, lifetime, capturesInput, runsFirst)
-static int sceneBeginStream(lua_State *L) {
-	checkContext(L);
-	const char *name = luaL_checkstring(L, 2);
-	const char *sceneName = luaL_checkstring(L, 3);
-	const int frame = luaL_checkint(L, 4);
-	const Common::String lifetime = luaL_checkstring(L, 5);
-
-	State::StreamFlags flags;
+// How long a stream lives: "manual" (until it ends itself), "withScene" or "withStream"
+static bool readStreamLifetime(const Common::String &lifetime, State::StreamFlags &flags) {
 	if (lifetime == "manual") {
 		flags.lifetime = State::StreamFlags::kIndependent;
 	} else if (lifetime == "withScene") {
@@ -994,13 +987,58 @@ static int sceneBeginStream(lua_State *L) {
 	} else if (lifetime == "withStream") {
 		flags.lifetime = State::StreamFlags::kEndsWithParent;
 	} else {
-		return luaL_argerror(L, 5, "parameter must be \"manual\", \"withScene\" or \"withStream\"");
+		return false;
 	}
 
-	luaL_checktype(L, 6, LUA_TBOOLEAN);
-	luaL_checktype(L, 7, LUA_TBOOLEAN);
-	flags.capturesInput = lua_toboolean(L, 6) != 0;
-	flags.runsFirst = lua_toboolean(L, 7) != 0;
+	return true;
+}
+
+// Scene:BeginStream(name, scene, frame, lifetime, capturesInput, runsFirst), or
+// from Nancy21 on, Scene:BeginStream({stream, scene, frame, ends, captureInput})
+static int sceneBeginStream(lua_State *L) {
+	checkContext(L);
+
+	Common::String name;
+	Common::String sceneName;
+	int frame = 0;
+	State::StreamFlags flags;
+
+	if (lua_istable(L, 2)) {
+		if (!readField(L, 2, "stream", name)) {
+			return luaL_argerror(L, 2, "Table must have 'stream' field");
+		}
+
+		if (!readField(L, 2, "scene", sceneName)) {
+			return luaL_argerror(L, 2, "Table must have 'scene' field");
+		}
+
+		readField(L, 2, "frame", frame);
+
+		Common::String lifetime = "manual";
+		readField(L, 2, "ends", lifetime);
+		if (!readStreamLifetime(lifetime, flags)) {
+			return luaL_argerror(L, 2, "ends field must be \"manual\", \"withScene\" or \"withStream\"");
+		}
+
+		// TODO: "restartParent" and "isUI", which no script sets
+		readField(L, 2, "captureInput", flags.capturesInput);
+
+		// A stream is a step closer than the one that began it
+		flags.zOffset = 1;
+	} else {
+		name = luaL_checkstring(L, 2);
+		sceneName = luaL_checkstring(L, 3);
+		frame = luaL_checkint(L, 4);
+
+		if (!readStreamLifetime(luaL_checkstring(L, 5), flags)) {
+			return luaL_argerror(L, 5, "parameter must be \"manual\", \"withScene\" or \"withStream\"");
+		}
+
+		luaL_checktype(L, 6, LUA_TBOOLEAN);
+		luaL_checktype(L, 7, LUA_TBOOLEAN);
+		flags.capturesInput = lua_toboolean(L, 6) != 0;
+		flags.runsFirst = lua_toboolean(L, 7) != 0;
+	}
 
 	State::SceneStreamManager &streams = NancySceneState.getStreams();
 	const bool isNew = !streams.isRunning(name);
@@ -1071,7 +1109,18 @@ static const luaL_Reg kSceneFunctions[] = {
 };
 
 // Streams
-// TODO: Streams.environment and Streams.mainName
+
+// Streams.environment is the environment of the main stream's scene
+static int streamsIndex(lua_State *L) {
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "environment") {
+		lua_pushstring(L, NancySceneState.getStreams().getEnvironment().c_str());
+		return 1;
+	}
+
+	return indexFunctions(L);
+}
 
 // Streams:GetScene(stream), which gives no name for a stream that isn't running
 static int streamsGetScene(lua_State *L) {
@@ -1080,9 +1129,34 @@ static int streamsGetScene(lua_State *L) {
 	return 1;
 }
 
+// Streams:GetStreams(withUI), Nancy21: the names of the running streams, the
+// main stream among them. The one of the UI is only in there when asked for.
+static int streamsGetStreams(lua_State *L) {
+	const bool withUI = lua_toboolean(L, 2) != 0;
+
+	Common::Array<Common::String> names;
+	NancySceneState.getStreams().getStreamNames(names, withUI);
+
+	lua_createtable(L, names.size(), 0);
+	for (uint i = 0; i < names.size(); ++i) {
+		lua_pushstring(L, names[i].c_str());
+		lua_rawseti(L, -2, i + 1);
+	}
+
+	return 1;
+}
+
+// Streams:GetZ(stream), Nancy21
+static int streamsGetZ(lua_State *L) {
+	lua_pushnumber(L, NancySceneState.getStreams().getZ(luaL_checkstring(L, 2)));
+	return 1;
+}
+
 static const luaL_Reg kStreamsFunctions[] = {
-	{ "__index", indexFunctions },
+	{ "__index", streamsIndex },
 	{ "GetScene", streamsGetScene },
+	{ "GetStreams", streamsGetStreams },
+	{ "GetZ", streamsGetZ },
 	{ nullptr, nullptr }
 };
 
@@ -1529,6 +1603,10 @@ void registerGameAPI(lua_State *L) {
 	registerGlobal(L, "Scene", "ND.Scene", kSceneFunctions);
 	registerGlobal(L, "Autotext", "ND.Autotext", kAutotextFunctions);
 	registerGlobal(L, "Streams", "ND.Streams", kStreamsFunctions);
+	luaL_getmetatable(L, "ND.Streams");
+	lua_pushstring(L, State::SceneStreamManager::getMainStreamName());
+	lua_setfield(L, -2, "mainName");
+	lua_pop(L, 1);
 	registerGlobal(L, "Player", "ND.CurPlayer", kPlayerFunctions);
 	registerGlobal(L, "Message", "ND.Message", kMessageFunctions);
 	registerGlobal(L, "Viewport", "ND.Viewport", kViewportFunctions);
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index a7fda051115..f4a5b28a5e6 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -3111,8 +3111,8 @@ void Scene::initUI() {
 #endif
 		// The panels are there before the first frame of the scene, which can
 		// hide some of them right away
-		_streams.begin("UIStream", pcui->uiName.toString(), 0, StreamFlags());
-		_streams.loadNow("UIStream");
+		_streams.begin(SceneStreamManager::getUIStreamName(), pcui->uiName.toString(), 0, StreamFlags());
+		_streams.loadNow(SceneStreamManager::getUIStreamName());
 		return;
 	}
 
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index 61f6e6d7bea..c18b1c7910d 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -325,6 +325,15 @@ void SceneStream::clearScene() {
 }
 
 static const char *const kMainStreamName = "MainStream";
+static const char *const kUIStreamName = "UIStream";
+
+const char *SceneStreamManager::getMainStreamName() {
+	return kMainStreamName;
+}
+
+const char *SceneStreamManager::getUIStreamName() {
+	return kUIStreamName;
+}
 
 #ifdef ENABLE_NANCY_V4
 bool SceneStream::skipSpeech() {
@@ -358,6 +367,25 @@ bool SceneStreamManager::skipMovies() {
 }
 #endif
 
+double SceneStreamManager::getZ(const Common::String &name) const {
+	const SceneStream *stream = find(name);
+	return stream ? stream->getZOffset() : 0.0;
+}
+
+void SceneStreamManager::getStreamNames(Common::Array<Common::String> &names, bool withUI) const {
+	if (withUI && find(kUIStreamName)) {
+		names.push_back(kUIStreamName);
+	}
+
+	for (const SceneStream *stream : _streams) {
+		if (!stream) {
+			names.push_back(kMainStreamName);
+		} else if (!stream->isEnded() && !stream->getName().equalsIgnoreCase(kUIStreamName)) {
+			names.push_back(stream->getName());
+		}
+	}
+}
+
 SceneStream *SceneStreamManager::find(const Common::String &name) const {
 	for (SceneStream *stream : _streams) {
 		if (stream && !stream->isEnded() && stream->getName().equalsIgnoreCase(name)) {
@@ -513,7 +541,14 @@ bool SceneStreamManager::handleInput(NancyInput &input, bool beforeMainStream) {
 			continue;
 		}
 
-		if (isBeforeMainStream != beforeMainStream) {
+		// From Nancy21 on, a stream drawn in front of the main one gets the
+		// mouse before it too, like the phone
+		bool isInFront = isBeforeMainStream;
+		if (g_nancy->getGameType() >= kGameTypeNancy21 && stream->getZOffset() > 0) {
+			isInFront = true;
+		}
+
+		if (isInFront != beforeMainStream) {
 			continue;
 		}
 
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
index 2a5be2a247e..fa59491c7cb 100644
--- a/engines/nancy/state/scenestream.h
+++ b/engines/nancy/state/scenestream.h
@@ -180,6 +180,14 @@ public:
 
 	void onMainSceneChange();
 	void changeEnvironment(const Common::String &newEnvironment);
+	const Common::String &getEnvironment() const { return _environment; }
+
+	// The names of the running streams, in the order they run in. The UI's
+	// stream is left out unless asked for.
+	void getStreamNames(Common::Array<Common::String> &names, bool withUI) const;
+
+	static const char *getMainStreamName();
+	static const char *getUIStreamName();
 
 	// Nancy19: hands a message to the scenes of the streams it is for. Returns
 	// false when there is none.
@@ -192,6 +200,10 @@ public:
 	bool skipMovies();
 #endif
 
+	// Nancy21: where a stream is among the others. Every stream is a step closer
+	// than the one that began it, and the main stream is at 0.
+	double getZ(const Common::String &name) const;
+
 	// Keeps the streams in the saved game, and starts them again after loading
 	void store();
 	void restore();


Commit: f53604be4cf351295aef75898be34a2bcfc33110
    https://github.com/scummvm/scummvm/commit/f53604be4cf351295aef75898be34a2bcfc33110
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:39+03:00

Commit Message:
NANCY: NANCY21: Let scripts read and set the game clock

Player.time reads and sets the game clock as days, hours and minutes,
which the scripts use for the day and night changes. Player.name is
the character being played.

Changed paths:
    engines/nancy/script/gameapi.cpp
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 239619301c4..0bca4ef5e6e 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -1342,8 +1342,56 @@ void showPlayerEntries() {
 	}
 }
 
+// Player.time is the clock of the game, as {days, hours, minutes}. Player.name
+// is the character being played.
+static int playerIndex(lua_State *L) {
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "time") {
+		const Time time = NancySceneState.getPlayerTime();
+		lua_createtable(L, 0, 3);
+		lua_pushinteger(L, time.getDays());
+		lua_setfield(L, -2, "days");
+		lua_pushinteger(L, time.getHours());
+		lua_setfield(L, -2, "hours");
+		lua_pushinteger(L, time.getMinutes());
+		lua_setfield(L, -2, "minutes");
+		return 1;
+	}
+
+	if (key == "name") {
+		const PCUI *players = GetEngineData(PCUI);
+		const uint character = g_nancy->getPlayerCharacter();
+		lua_pushstring(L, players && character < players->characters.size() ? players->characters[character].name.c_str() : "");
+		return 1;
+	}
+
+	return indexFunctions(L);
+}
+
+static int playerNewIndex(lua_State *L) {
+	const Common::String key = luaL_checkstring(L, 2);
+	if (key != "time") {
+		return luaL_error(L, "Player does not have field %s", key.c_str());
+	}
+
+	luaL_checktype(L, 3, LUA_TTABLE);
+
+	static const char *const kFieldNames[] = { "days", "hours", "minutes" };
+	int values[3] = { 0, 0, 0 };
+	for (uint i = 0; i < 3; ++i) {
+		if (!readField(L, 3, kFieldNames[i], values[i])) {
+			return luaL_error(L, "Player.time must have a field '%s'", kFieldNames[i]);
+		}
+	}
+
+	NancySceneState.setPlayerClock((uint32)values[0] * 86400000 + (uint32)values[1] * 3600000 + (uint32)values[2] * 60000);
+	return 0;
+}
+
 static const luaL_Reg kPlayerFunctions[] = {
-	{ "__index", indexFunctions },
+	{ "__index", playerIndex },
+	{ "__newindex", playerNewIndex },
 	{ "Switch", playerSwitch },
 	{ "HasInventoryItem", playerHasInventoryItem },
 	{ "AddInventoryItem", playerAddInventoryItem },
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index f4a5b28a5e6..a8af80d08e1 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -470,6 +470,14 @@ void Scene::setPlayerTime(Time time, byte relative) {
 	}
 }
 
+void Scene::setPlayerClock(Time time) {
+	_timers.playerTime = time;
+
+	if (_ndui) {
+		_ndui->updatePlayerTime();
+	}
+}
+
 uint Scene::getPlayerTimeMinutes() const {
 	auto *bootSummary = GetEngineData(BSUM);
 	assert(bootSummary);
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 56bafeb51ef..c59e88a4917 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -169,6 +169,7 @@ public:
 	}
 
 	void setPlayerTime(Time time, byte relative);
+	void setPlayerClock(Time time);	// Nancy21: the days, hours and minutes at once
 	Time getPlayerTime() const { return _timers.playerTime; }
 	Time getTimerTime() const { return _timers.timerIsActive ? _timers.timerTime : 0; }
 	uint getPlayerTimeMinutes() const;


Commit: 4e7ad9398844c6ab63e9acb90a4d7164295133cc
    https://github.com/scummvm/scummvm/commit/4e7ad9398844c6ab63e9acb90a4d7164295133cc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:41+03:00

Commit Message:
NANCY: NANCY21: Add the cell phone functions of Player

Player:AddCellPhoneNumber and Player:SendCellPhoneText hand a new
contact or text message to the script that runs the phone, the same
way Message:Send hands on its messages, through a function they now
share.

Changed paths:
    engines/nancy/script/gameapi.cpp


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 0bca4ef5e6e..7793b51bfb3 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -1160,8 +1160,39 @@ static const luaL_Reg kStreamsFunctions[] = {
 	{ nullptr, nullptr }
 };
 
+// Hands a message to the scene or the record it is for, or else to the UI.
+// Returns whether a scene or a record took it.
+static bool deliverMessage(lua_State *L, const char *actionName, UI::NDUI::Action &action) {
+	// The targets that answer do so to the control whose event is being handled
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	if (ui) {
+		action.sender = ui->getEventSender();
+	}
+
+	// The scripts of a scene send in its name, and the ones of the UI in the
+	// name of that control
+	Message message;
+	message.action = actionName;
+	message.target = action.target;
+	message.text = action.text;
+	message.value = action.value;
+
+	const SceneContext *context = LuaState::getContext(L);
+	if (context && !context->getStreamName().equalsIgnoreCase(State::SceneStreamManager::getUIStreamName())) {
+		message.sender = context->getSceneName();
+	} else {
+		message.sender = action.sender;
+	}
+
+	bool wasTaken = false;
+	if (!NancySceneState.receiveMessage(message, wasTaken) && ui) {
+		ui->takeAction(action);
+	}
+
+	return wasTaken;
+}
+
 // Player, who is the character being played unless a function is told otherwise
-// TODO: AddCellPhoneNumber
 
 // The number of a character, or -1 when there is none of that name
 static int findCharacter(const char *name) {
@@ -1389,9 +1420,36 @@ static int playerNewIndex(lua_State *L) {
 	return 0;
 }
 
+// The phone book and the text messages of the cell phone are kept by a record of
+// the scripts, which goes by the name "PhoneManager"
+static const char *const kPhoneManagerName = "PhoneManager";
+
+// Player:AddCellPhoneNumber(contact[, character])
+static int playerAddCellPhoneNumber(lua_State *L) {
+	UI::NDUI::Action action;
+	action.type = UI::NDUI::kActionSetText;
+	action.target = kPhoneManagerName;
+	action.text = luaL_checkstring(L, 2);
+	deliverMessage(L, "setText", action);
+	return 0;
+}
+
+// Player:SendCellPhoneText(message[, character]), where the message is a number
+static int playerSendCellPhoneText(lua_State *L) {
+	UI::NDUI::Action action;
+	action.type = UI::NDUI::kActionSetValue;
+	action.target = kPhoneManagerName;
+	action.hasValue = true;
+	action.value = luaL_checkinteger(L, 2);
+	deliverMessage(L, "setValue", action);
+	return 0;
+}
+
 static const luaL_Reg kPlayerFunctions[] = {
 	{ "__index", playerIndex },
 	{ "__newindex", playerNewIndex },
+	{ "AddCellPhoneNumber", playerAddCellPhoneNumber },
+	{ "SendCellPhoneText", playerSendCellPhoneText },
 	{ "Switch", playerSwitch },
 	{ "HasInventoryItem", playerHasInventoryItem },
 	{ "AddInventoryItem", playerAddInventoryItem },
@@ -1440,9 +1498,6 @@ static const MessageActionName kMessageActionNames[] = {
 	{ "hideItem", UI::NDUI::kActionHideItem }
 };
 
-// The stream the scripts of the UI run in
-static const char *const kUIStreamName = "UIStream";
-
 // Message:Send(action, target[, value[, text[, isTextKey]]])
 // The target is a scene, a record that was given a name for it, or a part of
 // the UI. Returns whether a scene or a record took the message.
@@ -1484,33 +1539,7 @@ static int messageSend(lua_State *L) {
 		}
 	}
 
-	// The targets that answer do so to the control whose event is being handled
-	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
-	if (ui) {
-		action.sender = ui->getEventSender();
-	}
-
-	// The scripts of a scene send in its name, and the ones of the UI in the
-	// name of that control
-	Message message;
-	message.action = named->name;
-	message.target = action.target;
-	message.text = action.text;
-	message.value = action.value;
-
-	const SceneContext *context = LuaState::getContext(L);
-	if (context && !context->getStreamName().equalsIgnoreCase(kUIStreamName)) {
-		message.sender = context->getSceneName();
-	} else {
-		message.sender = action.sender;
-	}
-
-	bool wasTaken = false;
-	if (!NancySceneState.receiveMessage(message, wasTaken) && ui) {
-		ui->takeAction(action);
-	}
-
-	lua_pushboolean(L, wasTaken);
+	lua_pushboolean(L, deliverMessage(L, named->name, action));
 	return 1;
 }
 


Commit: fc2d7960726408ca4e22da297123fecf375dff26
    https://github.com/scummvm/scummvm/commit/fc2d7960726408ca4e22da297123fecf375dff26
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:43+03:00

Commit Message:
NANCY: NANCY21: Add graph nodes, which group records

Scripts can put records under a node made with Scene:GraphNode, and
take them out again with Scene:TransferChild. Moving the node or
changing its z moves the records under it along, e.g. the scrolling
lists of the puzzle trays. Nodes can be put under other nodes, too.

Changed paths:
  A engines/nancy/script/graphnode.cpp
  A engines/nancy/script/graphnode.h
    engines/nancy/module.mk
    engines/nancy/script/api.cpp
    engines/nancy/script/gameapi.cpp
    engines/nancy/script/record.cpp
    engines/nancy/script/record.h
    engines/nancy/script/scenecontext.cpp
    engines/nancy/script/scenecontext.h


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index d28bafd29ba..31ceea2828c 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -189,6 +189,7 @@ MODULE_OBJS += \
   script/bootapi.o \
   script/convo.o \
   script/gameapi.o \
+  script/graphnode.o \
   script/luabind.o \
   script/luastate.o \
   script/mathtypes.o \
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
index 3972debe3f5..b0755499c81 100644
--- a/engines/nancy/script/api.cpp
+++ b/engines/nancy/script/api.cpp
@@ -24,6 +24,7 @@
 
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/convo.h"
+#include "engines/nancy/script/graphnode.h"
 #include "engines/nancy/script/luabind.h"
 #include "engines/nancy/script/mathtypes.h"
 #include "engines/nancy/script/record.h"
@@ -81,6 +82,7 @@ void registerAPI(lua_State *L) {
 	registerBootAPI(L);
 	registerInventoryAPI(L);
 	registerGameAPI(L);
+	registerGraphNodeAPI(L);
 	registerUIAPI(L);
 	registerConvoAPI(L);
 	registerRecordAPI(L);
diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 7793b51bfb3..2fa2844dd1e 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -29,6 +29,7 @@
 #include "engines/nancy/sound.h"
 
 #include "engines/nancy/script/api.h"
+#include "engines/nancy/script/graphnode.h"
 #include "engines/nancy/script/luabind.h"
 #include "engines/nancy/script/luastate.h"
 #include "engines/nancy/script/record.h"
@@ -1048,6 +1049,27 @@ static int sceneBeginStream(lua_State *L) {
 	return 1;
 }
 
+// Scene:GraphNode(), Nancy21: a new node to group records of the scene under
+static int sceneGraphNode(lua_State *L) {
+	checkContext(L)->createGraphNode()->push(L);
+	return 1;
+}
+
+// Scene:TransferChild(child), Nancy21: takes a record or a node out of the node
+// it was put under
+static int sceneTransferChild(lua_State *L) {
+	checkContext(L);
+
+	GraphNode *node = testGraphNode(L, 2);
+	if (node) {
+		node->setParent(nullptr);
+	} else {
+		checkRecord(L, 2)->setParentNode(nullptr);
+	}
+
+	return 0;
+}
+
 // Scene:EndStream(name)
 static int sceneEndStream(lua_State *L) {
 	checkContext(L);
@@ -1105,6 +1127,8 @@ static const luaL_Reg kSceneFunctions[] = {
 	{ "BeginStream", sceneBeginStream },
 	{ "EndStream", sceneEndStream },
 	{ "ChangeScene", sceneChangeScene },
+	{ "GraphNode", sceneGraphNode },
+	{ "TransferChild", sceneTransferChild },
 	{ nullptr, nullptr }
 };
 
diff --git a/engines/nancy/script/graphnode.cpp b/engines/nancy/script/graphnode.cpp
new file mode 100644
index 00000000000..5e3d2b8f711
--- /dev/null
+++ b/engines/nancy/script/graphnode.cpp
@@ -0,0 +1,136 @@
+/* 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/script/graphnode.h"
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/record.h"
+#include "engines/nancy/script/scenecontext.h"
+
+namespace Nancy {
+namespace Script {
+
+static const char *const kGraphNodeType = "ND.GraphNode";
+
+double GraphNode::getZ() const {
+	return _parent ? _z + _parent->getZ() : _z;
+}
+
+ScriptMatrix GraphNode::getTransform() const {
+	return _parent ? _transform * _parent->getTransform() : _transform;
+}
+
+void GraphNode::setParent(GraphNode *parent) {
+	// A node can't go under itself
+	if (parent && parent->isUnder(this)) {
+		return;
+	}
+
+	_parent = parent;
+	_context.onGraphNodeChanged(this);
+}
+
+bool GraphNode::isUnder(const GraphNode *node) const {
+	for (const GraphNode *n = this; n; n = n->_parent) {
+		if (n == node) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+void GraphNode::push(lua_State *L) {
+	pushValue<GraphNode *>(L, kGraphNodeType, this);
+}
+
+int GraphNode::getScriptField(lua_State *L) {
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "z") {
+		lua_pushnumber(L, _z);
+		return 1;
+	} else if (key == "transform") {
+		pushMatrix(L, _transform);
+		return 1;
+	}
+
+	return indexFunctions(L);
+}
+
+int GraphNode::setScriptField(lua_State *L) {
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "z") {
+		_z = luaL_checknumber(L, 3);
+	} else if (key == "transform") {
+		_transform = checkMatrix(L, 3);
+	} else {
+		return luaL_error(L, "GraphNode does not have field %s", key.c_str());
+	}
+
+	_context.onGraphNodeChanged(this);
+	return 0;
+}
+
+GraphNode *testGraphNode(lua_State *L, int index) {
+	GraphNode **node = (GraphNode **)testUserData(L, index, kGraphNodeType);
+	return node ? *node : nullptr;
+}
+
+static GraphNode *checkGraphNode(lua_State *L, int index) {
+	return *(GraphNode **)checkUserData(L, index, kGraphNodeType);
+}
+
+static int graphNodeIndex(lua_State *L) {
+	return checkGraphNode(L, 1)->getScriptField(L);
+}
+
+static int graphNodeNewIndex(lua_State *L) {
+	return checkGraphNode(L, 1)->setScriptField(L);
+}
+
+// node:TransferChild(child), where the child is a record or another node
+static int graphNodeTransferChild(lua_State *L) {
+	GraphNode *node = checkGraphNode(L, 1);
+
+	GraphNode *childNode = testGraphNode(L, 2);
+	if (childNode) {
+		childNode->setParent(node);
+	} else {
+		checkRecord(L, 2)->setParentNode(node);
+	}
+
+	return 0;
+}
+
+static const luaL_Reg kGraphNodeFunctions[] = {
+	{ "__index", graphNodeIndex },
+	{ "__newindex", graphNodeNewIndex },
+	{ "TransferChild", graphNodeTransferChild },
+	{ nullptr, nullptr }
+};
+
+void registerGraphNodeAPI(lua_State *L) {
+	registerType(L, kGraphNodeType, kGraphNodeFunctions);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/graphnode.h b/engines/nancy/script/graphnode.h
new file mode 100644
index 00000000000..796acd587b3
--- /dev/null
+++ b/engines/nancy/script/graphnode.h
@@ -0,0 +1,77 @@
+/* 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_SCRIPT_GRAPHNODE_H
+#define NANCY_SCRIPT_GRAPHNODE_H
+
+#include "engines/nancy/script/mathtypes.h"
+
+struct lua_State;
+
+namespace Nancy {
+namespace Script {
+
+class SceneContext;
+
+// Nancy21 lets a scene group its records under nodes of their own, made with
+// Scene:GraphNode(). A record that was handed to a node with node:TransferChild()
+// is moved by the transform of the node on top of its own, and its z counts from
+// the one of the node. The tray of puzzle pieces scrolls its pieces that way.
+// Nodes can be put under other nodes as well.
+class GraphNode {
+public:
+	GraphNode(SceneContext &context) : _context(context) {}
+
+	SceneContext &getContext() const { return _context; }
+
+	// Including those of the nodes above
+	double getZ() const;
+	ScriptMatrix getTransform() const;
+
+	GraphNode *getParent() const { return _parent; }
+	void setParent(GraphNode *parent);
+
+	// Whether this is the node, or one under it
+	bool isUnder(const GraphNode *node) const;
+
+	void push(lua_State *L);
+
+	// Reads or writes "z" or "transform"; the key is at stack index 2, and a
+	// new value at 3
+	int getScriptField(lua_State *L);
+	int setScriptField(lua_State *L);
+
+private:
+	SceneContext &_context;
+	double _z = 0.0;
+	ScriptMatrix _transform;
+	GraphNode *_parent = nullptr;
+};
+
+void registerGraphNodeAPI(lua_State *L);
+
+// The node at a stack index, or null when something else is there
+GraphNode *testGraphNode(lua_State *L, int index);
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_GRAPHNODE_H
diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index 76a6500edc6..4d082e68125 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "engines/nancy/script/api.h"
+#include "engines/nancy/script/graphnode.h"
 #include "engines/nancy/script/luabind.h"
 #include "engines/nancy/script/luastate.h"
 #include "engines/nancy/script/record.h"
@@ -200,6 +201,26 @@ void Record::render() {
 	}
 }
 
+double Record::getZ() const {
+	return _parentNode ? _z + _parentNode->getZ() : _z;
+}
+
+ScriptMatrix Record::getTransform() const {
+	return _parentNode ? _transform * _parentNode->getTransform() : _transform;
+}
+
+void Record::setParentNode(GraphNode *node) {
+	_parentNode = node;
+	onParentNodeChanged();
+}
+
+void Record::onParentNodeChanged() {
+	onZChanged();
+	if (hasTransform()) {
+		onTransformChanged();
+	}
+}
+
 void Record::limitZ(double z) {
 	if (_z > z) {
 		_z = z;
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index 6395d538701..6f462cd9078 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -35,6 +35,7 @@ struct NancyInput;
 
 namespace Script {
 
+class GraphNode;
 class SceneContext;
 
 // What the player does that records get told about
@@ -119,7 +120,8 @@ public:
 	bool isActive() const { return _isActive && !_isDone; }
 	bool isDone() const { return _isDone; }
 	bool hasStarted() const { return _hasStarted; }
-	double getZ() const { return _z; }
+	// Counted from the node the record is under, when it is under one
+	double getZ() const;
 
 	// Puts the record no closer to the player than the given z
 	void limitZ(double z);
@@ -143,8 +145,16 @@ public:
 	bool sendMouseEvent(RecordEvent event, const ScriptPoint &point, bool inViewport);
 	bool sendKeyEvent(RecordEvent event, Common::KeyCode key);
 
-	// Where the record puts what it shows, for the kinds that show something
-	const ScriptMatrix &getTransform() const { return _transform; }
+	// Where the record puts what it shows, for the kinds that show something,
+	// after the nodes it is under moved it
+	ScriptMatrix getTransform() const;
+
+	// Nancy21: puts the record under a node; null for none
+	GraphNode *getParentNode() const { return _parentNode; }
+	void setParentNode(GraphNode *node);
+
+	// A node above the record changed its z or its transform
+	void onParentNodeChanged();
 
 	// Shows or hides what the record draws, once the records of a frame have run
 	virtual void updateGraphics() {}
@@ -209,6 +219,7 @@ private:
 
 	double _z = 0.0;
 	ScriptMatrix _transform;
+	GraphNode *_parentNode = nullptr;
 	bool _hasActiveFunction = false;
 	bool _hasStarted = false;
 	bool _isActive = true;
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index 95eb4893164..089c788322c 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -30,6 +30,7 @@
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/script/convo.h"
+#include "engines/nancy/script/graphnode.h"
 #include "engines/nancy/script/luabind.h"
 #include "engines/nancy/script/luastate.h"
 #include "engines/nancy/script/record.h"
@@ -74,6 +75,10 @@ SceneContext::~SceneContext() {
 		delete response;
 	}
 
+	for (GraphNode *node : _graphNodes) {
+		delete node;
+	}
+
 	for (auto &ref : _refs) {
 		delete ref._value;
 	}
@@ -423,6 +428,26 @@ void SceneContext::deleteDroppedRecords() {
 	}
 }
 
+GraphNode *SceneContext::createGraphNode() {
+	GraphNode *node = new GraphNode(*this);
+	_graphNodes.push_back(node);
+	return node;
+}
+
+void SceneContext::onGraphNodeChanged(GraphNode *node) {
+	for (Record *record : _records) {
+		if (record->getParentNode() && record->getParentNode()->isUnder(node)) {
+			record->onParentNodeChanged();
+		}
+	}
+
+	for (Record *record : _newRecords) {
+		if (record->getParentNode() && record->getParentNode()->isUnder(node)) {
+			record->onParentNodeChanged();
+		}
+	}
+}
+
 bool SceneContext::getViewportMouse(const NancyInput &input, ScriptPoint &point) {
 	const UI::Viewport &viewport = NancySceneState.getViewport();
 	const Common::Rect screenPosition = viewport.getScreenPosition();
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index 2c27a080103..f1489099921 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -41,6 +41,7 @@ class LuaState;
 class Record;
 struct RefImage;
 class ConvoRecord;
+class GraphNode;
 struct ConvoResponse;
 class SummaryRecord;
 
@@ -123,6 +124,12 @@ public:
 	// The panels a scene adds to the UI call into its scripts, so they go when it does
 	void setMakesUI() { _makesUI = true; }
 
+	// Nancy21: a new node to group records under, which lasts as long as the scene
+	GraphNode *createGraphNode();
+
+	// Lets the records under a node know that it changed its z or transform
+	void onGraphNodeChanged(GraphNode *node);
+
 	// Runs another script as a part of this scene
 	bool include(const Common::String &name);
 
@@ -160,6 +167,7 @@ private:
 	SummaryRecord *_summary = nullptr;
 	ConvoRecord *_convo = nullptr;
 	Common::Array<ConvoResponse *> _convoResponses;
+	Common::Array<GraphNode *> _graphNodes;
 	Common::HashMap<Common::String, RefImage *, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _refs;
 
 	double _time = 0.0;


Commit: 4e408fb0fdea3cd724d3ca0db9e8e78a26fe423b
    https://github.com/scummvm/scummvm/commit/4e408fb0fdea3cd724d3ca0db9e8e78a26fe423b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:44+03:00

Commit Message:
NANCY: NANCY21: Zoom into the scene with the camera of the phone

The camera of the Nancy21 phone zooms by giving a stream a transform,
with Streams:SetTransform. Everything that stream draws, its
background included, is now scaled and moved by it, and kept inside
the viewport. While anything is zoomed into, the whole viewport gets
redrawn.

Changed paths:
    engines/nancy/graphics.cpp
    engines/nancy/graphics.h
    engines/nancy/renderobject.h
    engines/nancy/script/convo.cpp
    engines/nancy/script/gameapi.cpp
    engines/nancy/script/records.cpp
    engines/nancy/script/records.h
    engines/nancy/script/scenecontext.cpp
    engines/nancy/script/scenecontext.h
    engines/nancy/state/scene.cpp
    engines/nancy/state/scenestream.cpp
    engines/nancy/state/scenestream.h


diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index 1f6785c3d7e..be0e6e4af6d 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -154,10 +154,12 @@ void GraphicsManager::draw(bool updateScreen) {
 
 	// Update graphics for all RenderObjects and determine
 	// the areas of the screen that need to be redrawn
+	bool isZoomed = false;
 	for (auto it : _objects) {
 		RenderObject &current = *it;
 
 		current.updateGraphics();
+		isZoomed |= current.isVisible() && current.isZoomed();
 
 		if (current.needsRedraw()) {
 			const Common::Rect &drawnPosition = current.getDrawnScreenPosition();
@@ -182,6 +184,14 @@ void GraphicsManager::draw(bool updateScreen) {
 		current.updatePreviousScreenPosition();
 	}
 
+	// While anything is zoomed into, and once it stops being, all of the
+	// viewport gets drawn anew
+	if (isZoomed || _wasZoomed) {
+		_dirtyRects.push_back(NancySceneState.getViewport().getScreenPosition());
+	}
+
+	_wasZoomed = isZoomed;
+
 	filterDirtyRects(_dirtyRects);
 
 	if (!isGameScaled()) {
@@ -273,7 +283,7 @@ void GraphicsManager::drawObjects(Graphics::ManagedSurface &target, const Common
 
 		DrawnObject entry;
 		entry.object = object;
-		entry.position = object->getScreenPosition();
+		entry.position = getDrawnPosition(*object);
 		entry.isOpaque = !object->_drawSurface.hasTransparentColor() && object->_drawSurface.format != _transparentPixelFormat;
 		if (!entry.position.isEmpty()) {
 			drawn.push_back(entry);
@@ -774,7 +784,50 @@ void GraphicsManager::blitObject(Graphics::ManagedSurface &target, const RenderO
 		return;
 	}
 
-	target.blitFrom(src._drawSurface, src._drawSurface.getBounds().findIntersectingRect(src.convertToLocal(screenRect)), screenRect);
+	if (!src.isZoomed()) {
+		target.blitFrom(src._drawSurface, src._drawSurface.getBounds().findIntersectingRect(src.convertToLocal(screenRect)), screenRect);
+		return;
+	}
+
+	// A zoomed object gets stretched from where it would be without the zoom
+	const ViewTransform &view = *src.getViewTransform();
+	const Common::Rect viewport = NancySceneState.getViewport().getScreenPosition();
+
+	Common::Rect unzoomed;
+	unzoomed.left = viewport.left + (int)floor((screenRect.left - viewport.left - view.offsetX) / view.scaleX);
+	unzoomed.top = viewport.top + (int)floor((screenRect.top - viewport.top - view.offsetY) / view.scaleY);
+	unzoomed.right = viewport.left + (int)ceil((screenRect.right - viewport.left - view.offsetX) / view.scaleX);
+	unzoomed.bottom = viewport.top + (int)ceil((screenRect.bottom - viewport.top - view.offsetY) / view.scaleY);
+	if (unzoomed.isEmpty()) {
+		return;
+	}
+
+	const Common::Rect source = src._drawSurface.getBounds().findIntersectingRect(src.convertToLocal(unzoomed));
+	if (!source.isEmpty()) {
+		target.blitFrom(src._drawSurface, source, screenRect);
+	}
+}
+
+Common::Rect GraphicsManager::getDrawnPosition(const RenderObject &object) const {
+	const Common::Rect position = object.getScreenPosition();
+	if (!object.isZoomed() || position.isEmpty()) {
+		return position;
+	}
+
+	const ViewTransform &view = *object.getViewTransform();
+	const Common::Rect viewport = NancySceneState.getViewport().getScreenPosition();
+	if (view.scaleX <= 0.0 || view.scaleY <= 0.0) {
+		return Common::Rect();
+	}
+
+	// Scaled around the top left of the viewport, then moved, and kept inside it
+	Common::Rect zoomed;
+	zoomed.left = viewport.left + (int)floor((position.left - viewport.left) * view.scaleX + view.offsetX);
+	zoomed.top = viewport.top + (int)floor((position.top - viewport.top) * view.scaleY + view.offsetY);
+	zoomed.right = viewport.left + (int)ceil((position.right - viewport.left) * view.scaleX + view.offsetX);
+	zoomed.bottom = viewport.top + (int)ceil((position.bottom - viewport.top) * view.scaleY + view.offsetY);
+	zoomed.clip(viewport);
+	return zoomed;
 }
 
 int GraphicsManager::objectComparator(const void *a, const void *b) {
diff --git a/engines/nancy/graphics.h b/engines/nancy/graphics.h
index ee5699c940a..8cc239a6090 100644
--- a/engines/nancy/graphics.h
+++ b/engines/nancy/graphics.h
@@ -142,6 +142,9 @@ private:
 	static void filterDirtyRects(Common::List<Common::Rect> &rects);
 	void drawObjects(Graphics::ManagedSurface &target, const Common::List<Common::Rect> &rects, Layer layer);
 	void blitObject(Graphics::ManagedSurface &target, const RenderObject &src, Common::Rect dest);
+
+	// Where an object gets drawn, which is somewhere else for one that is zoomed into
+	Common::Rect getDrawnPosition(const RenderObject &object) const;
 	void blitGameToScreen(Common::Rect screenRect);
 
 	static int objectComparator(const void *a, const void *b);
@@ -172,6 +175,7 @@ private:
 	uint32 _transColor = 0;
 
 	bool _isSuppressed;
+	bool _wasZoomed = false;	// whether something was zoomed into the last time
 };
 
 } // End of namespace Nancy
diff --git a/engines/nancy/renderobject.h b/engines/nancy/renderobject.h
index 84e7cf96573..23bfdc70278 100644
--- a/engines/nancy/renderobject.h
+++ b/engines/nancy/renderobject.h
@@ -30,6 +30,18 @@ namespace Nancy {
 class NancyEngine;
 class GraphicsManager;
 
+// Nancy21 can zoom into the viewport, with everything in some of the streams
+// of scenes: what is drawn gets scaled around the top left of the viewport, then
+// moved by the offset, in viewport pixels
+struct ViewTransform {
+	double scaleX = 1.0;
+	double scaleY = 1.0;
+	double offsetX = 0.0;
+	double offsetY = 0.0;
+
+	bool isIdentity() const { return scaleX == 1.0 && scaleY == 1.0 && offsetX == 0.0 && offsetY == 0.0; }
+};
+
 // Loosely equivalent to the original engine's ZRenderStructs.
 // A subclass of this will be automatically updated and drawn from the graphics manager,
 // but initialization needs to be done manually.
@@ -96,6 +108,11 @@ public:
 	// Needed for proper handling of objects inside the viewport
 	virtual bool isViewportRelative() const { return false; }
 
+	// The zoom the object gets drawn with, if any. It belongs to whoever set it.
+	const ViewTransform *getViewTransform() const { return _viewTransform; }
+	void setViewTransform(const ViewTransform *viewTransform) { _viewTransform = viewTransform; }
+	bool isZoomed() const { return _viewTransform && !_viewTransform->isIdentity(); }
+
 protected:
 	bool _needsRedraw;
 	bool _isVisible;
@@ -105,6 +122,7 @@ protected:
 	Common::Rect _drawnScreenPosition;
 	Common::Rect _previousScreenPosition;
 	Common::Rect _screenPosition;
+	const ViewTransform *_viewTransform = nullptr;
 };
 
 } // End of namespace Nancy
diff --git a/engines/nancy/script/convo.cpp b/engines/nancy/script/convo.cpp
index dc68f577fa5..f5c52daa631 100644
--- a/engines/nancy/script/convo.cpp
+++ b/engines/nancy/script/convo.cpp
@@ -231,7 +231,7 @@ bool ConvoRecord::loadXSheet(const Common::String &name) {
 // The first part is drawn over the ones after it
 void ConvoRecord::onZChanged() {
 	for (uint i = 0; i < _trees.size(); ++i) {
-		_trees[i]->graphic.setScriptZ(getZ() + (i == 0 ? 1 : 0), getContext().getZOffset());
+		_trees[i]->graphic.setScriptZ(getZ() + (i == 0 ? 1 : 0), getContext());
 	}
 }
 
diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 2fa2844dd1e..0a0621b225b 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -1176,11 +1176,26 @@ static int streamsGetZ(lua_State *L) {
 	return 1;
 }
 
+// Streams:GetTransform(stream) and Streams:SetTransform(stream, matrix), Nancy21
+static int streamsGetTransform(lua_State *L) {
+	const ScriptMatrix *transform = NancySceneState.getStreams().getTransform(luaL_checkstring(L, 2));
+	pushMatrix(L, transform ? *transform : ScriptMatrix());
+	return 1;
+}
+
+static int streamsSetTransform(lua_State *L) {
+	const char *streamName = luaL_checkstring(L, 2);
+	NancySceneState.getStreams().setTransform(streamName, checkMatrix(L, 3));
+	return 0;
+}
+
 static const luaL_Reg kStreamsFunctions[] = {
 	{ "__index", streamsIndex },
 	{ "GetScene", streamsGetScene },
 	{ "GetStreams", streamsGetStreams },
 	{ "GetZ", streamsGetZ },
+	{ "GetTransform", streamsGetTransform },
+	{ "SetTransform", streamsSetTransform },
 	{ nullptr, nullptr }
 };
 
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index 526a5fa731a..3065866551a 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -121,8 +121,9 @@ uint16 RecordGraphic::getBackgroundZOrder(int32 zOffset) {
 	return getScriptZOrder(kBackgroundScriptZ, zOffset);
 }
 
-void RecordGraphic::setScriptZ(double z, int32 streamZOffset) {
-	setZOrder(getScriptZOrder(z, streamZOffset));
+void RecordGraphic::setScriptZ(double z, const SceneContext &context) {
+	setZOrder(getScriptZOrder(z, context.getZOffset()));
+	setViewTransform(context.getViewTransform());
 }
 
 // Where something that was placed in the viewport ends up once the transform
@@ -737,7 +738,7 @@ bool OverrideRecord::hasMessagingName(const Common::String &name) {
 
 void OverrideRecord::onZChanged() {
 	if (_canvas) {
-		_canvas->setScriptZ(getZ(), getContext().getZOffset());
+		_canvas->setScriptZ(getZ(), getContext());
 	}
 }
 
@@ -769,7 +770,7 @@ void OverrideRecord::updateGraphics() {
 		_canvas->init();
 		_canvas->_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
 		_canvas->moveTo(bounds);
-		_canvas->setScriptZ(getZ(), getContext().getZOffset());
+		_canvas->setScriptZ(getZ(), getContext());
 		_canvas->registerGraphics();
 	}
 
@@ -862,7 +863,7 @@ void OverlayRecord::applyRects() {
 }
 
 void OverlayRecord::onZChanged() {
-	_graphic.setScriptZ(getZ(), getContext().getZOffset());
+	_graphic.setScriptZ(getZ(), getContext());
 }
 
 void OverlayRecord::updateGraphics() {
@@ -1095,7 +1096,7 @@ bool AnimOverlayRecord::prepareReset() {
 }
 
 void AnimOverlayRecord::onZChanged() {
-	_graphic.setScriptZ(getZ(), getContext().getZOffset());
+	_graphic.setScriptZ(getZ(), getContext());
 }
 
 void AnimOverlayRecord::updateGraphics() {
@@ -1448,7 +1449,7 @@ void TextRecord::run(double sceneTime, double elapsedTime) {
 }
 
 void TextRecord::onZChanged() {
-	_graphic.setScriptZ(getZ(), getContext().getZOffset());
+	_graphic.setScriptZ(getZ(), getContext());
 }
 
 void TextRecord::updateGraphics() {
@@ -1639,7 +1640,7 @@ bool MovieRecord::prepareReset() {
 }
 
 void MovieRecord::onZChanged() {
-	_graphic.setScriptZ(getZ(), getContext().getZOffset());
+	_graphic.setScriptZ(getZ(), getContext());
 }
 
 void MovieRecord::updateGraphics() {
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 68491de44bc..7b6bda7cbaa 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -77,8 +77,9 @@ public:
 	RecordGraphic() : RenderObject(0) {}
 	virtual ~RecordGraphic() {}
 
-	// Scripts order what they draw with numbers of their own
-	void setScriptZ(double z, int32 streamZOffset);
+	// Scripts order what they draw with numbers of their own. The scene also
+	// tells which stream it is in, which can zoom into what it draws.
+	void setScriptZ(double z, const SceneContext &context);
 
 	// The z order of a script z in a stream that is the given steps in front
 	static uint16 getScriptZOrder(double z, int32 zOffset);
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index 089c788322c..08f9304671b 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -428,6 +428,10 @@ void SceneContext::deleteDroppedRecords() {
 	}
 }
 
+const ViewTransform *SceneContext::getViewTransform() const {
+	return NancySceneState.getStreams().getViewTransform(_streamName);
+}
+
 GraphNode *SceneContext::createGraphNode() {
 	GraphNode *node = new GraphNode(*this);
 	_graphNodes.push_back(node);
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index f1489099921..0f2500fcdeb 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -34,6 +34,7 @@ struct lua_State;
 namespace Nancy {
 
 struct NancyInput;
+struct ViewTransform;
 
 namespace Script {
 
@@ -100,6 +101,9 @@ public:
 	void setFrame(uint frame) { _frame = frame; }
 	int32 getZOffset() const { return _zOffset; }
 
+	// Nancy21: the zoom of the stream the scene is in
+	const ViewTransform *getViewTransform() const;
+
 	// The seconds the scene has been running for
 	double getTime() const { return _time; }
 	double getElapsedTime() const { return _elapsedTime; }	// of the last frame
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index a8af80d08e1..0deec356802 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2307,6 +2307,9 @@ void Scene::loadScriptedScene(bool fromSaveFile) {
 	const bool hasBackground = !summary->getBackgroundName().empty();
 	_viewport.setVisible(hasBackground);
 
+	// Nancy21's camera can zoom into the main stream
+	_viewport.setViewTransform(_streams.getViewTransform(SceneStreamManager::getMainStreamName()));
+
 	if (hasBackground) {
 		_viewport.loadVideo(_sceneState.summary.videoFile, _sceneState.currentScene.frameID, 0, kPanNone, kLargeVideoFormat);
 	}
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index c18b1c7910d..f93e04acda4 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -233,6 +233,7 @@ void SceneStream::loadBackground(const Common::Path &videoFile) {
 		_background->setZOrder(Script::RecordGraphic::getBackgroundZOrder(_zOffset));
 	}
 #endif
+	_background->setViewTransform(&_viewTransform);
 	_background->loadVideo(videoFile, _sceneInfo.frameID, 0, kPanNone, kLargeVideoFormat);
 	_background->registerGraphics();
 }
@@ -335,6 +336,41 @@ const char *SceneStreamManager::getUIStreamName() {
 	return kUIStreamName;
 }
 
+#ifdef ENABLE_NANCY_V4
+const Script::ScriptMatrix *SceneStreamManager::getTransform(const Common::String &name) {
+	if (name.equalsIgnoreCase(kMainStreamName)) {
+		return &_mainStreamTransform;
+	}
+
+	SceneStream *stream = find(name);
+	return stream ? &stream->getTransform() : nullptr;
+}
+
+void SceneStreamManager::setTransform(const Common::String &name, const Script::ScriptMatrix &transform) {
+	Script::ScriptMatrix *matrix = nullptr;
+	ViewTransform *view = nullptr;
+
+	if (name.equalsIgnoreCase(kMainStreamName)) {
+		matrix = &_mainStreamTransform;
+		view = &_mainStreamViewTransform;
+	} else {
+		SceneStream *stream = find(name);
+		if (!stream) {
+			return;
+		}
+
+		matrix = &stream->getTransform();
+		view = &stream->getViewTransform();
+	}
+
+	*matrix = transform;
+	view->scaleX = transform.m[0][0];
+	view->scaleY = transform.m[1][1];
+	view->offsetX = transform.m[3][0];
+	view->offsetY = transform.m[3][1];
+}
+#endif
+
 #ifdef ENABLE_NANCY_V4
 bool SceneStream::skipSpeech() {
 	return _scriptScene && !_isEnded && _scriptScene->skipSpeech();
@@ -367,6 +403,15 @@ bool SceneStreamManager::skipMovies() {
 }
 #endif
 
+const ViewTransform *SceneStreamManager::getViewTransform(const Common::String &name) {
+	if (name.equalsIgnoreCase(kMainStreamName)) {
+		return &_mainStreamViewTransform;
+	}
+
+	SceneStream *stream = find(name);
+	return stream ? &stream->getViewTransform() : nullptr;
+}
+
 double SceneStreamManager::getZ(const Common::String &name) const {
 	const SceneStream *stream = find(name);
 	return stream ? stream->getZOffset() : 0.0;
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
index fa59491c7cb..d8327858bda 100644
--- a/engines/nancy/state/scenestream.h
+++ b/engines/nancy/state/scenestream.h
@@ -27,9 +27,14 @@
 
 #include "engines/nancy/commontypes.h"
 #include "engines/nancy/puzzledata.h"
+#include "engines/nancy/renderobject.h"
 
 #include "engines/nancy/action/actionmanager.h"
 
+#ifdef ENABLE_NANCY_V4
+#include "engines/nancy/script/mathtypes.h"
+#endif
+
 namespace Common {
 class SeekableReadStream;
 }
@@ -116,6 +121,10 @@ public:
 	const Common::String &getSceneName() const { return _sceneName; }
 	SceneChangeDescription &getSceneInfo() { return _sceneInfo; }
 	Action::ActionManager &getActionManager() { return _actionManager; }
+#ifdef ENABLE_NANCY_V4
+	Script::ScriptMatrix &getTransform() { return _transform; }
+#endif
+	ViewTransform &getViewTransform() { return _viewTransform; }
 
 	Common::Array<Child> _children;
 
@@ -141,8 +150,13 @@ private:
 #ifdef ENABLE_NANCY_V4
 	// Nancy19: the script of the scene, which takes the place of the action records
 	Script::SceneContext *_scriptScene = nullptr;
+
+	Script::ScriptMatrix _transform;	// Nancy21
 #endif
 
+	// The zoom of everything the stream draws
+	ViewTransform _viewTransform;
+
 	// Every stream has generic event flags of its own
 	Common::Array<byte> _genericFlags;
 
@@ -200,6 +214,16 @@ public:
 	bool skipMovies();
 #endif
 
+#ifdef ENABLE_NANCY_V4
+	// Nancy21: what the scripts move and scale everything in a stream with; null
+	// when there is no such stream. Only the scaling and the offset get drawn.
+	const Script::ScriptMatrix *getTransform(const Common::String &name);
+	void setTransform(const Common::String &name, const Script::ScriptMatrix &transform);
+#endif
+
+	// What a stream gets drawn with; null when there is no such stream
+	const ViewTransform *getViewTransform(const Common::String &name);
+
 	// Nancy21: where a stream is among the others. Every stream is a step closer
 	// than the one that began it, and the main stream is at 0.
 	double getZ(const Common::String &name) const;
@@ -218,6 +242,11 @@ private:
 	Common::Array<SceneStream::Child> _mainStreamChildren;
 	SceneStream *_running = nullptr;
 	Common::String _environment;
+
+#ifdef ENABLE_NANCY_V4
+	Script::ScriptMatrix _mainStreamTransform;
+#endif
+	ViewTransform _mainStreamViewTransform;
 };
 
 } // End of namespace State


Commit: ed203b8a3539bb2ffea23972ee2e2fc0a975c6d2
    https://github.com/scummvm/scummvm/commit/ed203b8a3539bb2ffea23972ee2e2fc0a975c6d2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:46+03:00

Commit Message:
NANCY: NANCY19: Handle the source, loop, volume and modColor of movies

Movie records now take the fields that were left out until now:
- source: the part of each frame to show; from Nancy21 on, scripts
  can also change it later
- loop: true plays forever, false once, a number that many times
- volume: 0.85 unless set
- modColor: what the frames are tinted with; Nancy21 hides some
  movies that keep playing with a color without any alpha

Movies can also be turned and resized by their transform, like
overlays.

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


diff --git a/engines/nancy/movieplayer.cpp b/engines/nancy/movieplayer.cpp
index 0603c57c64e..18595f82f87 100644
--- a/engines/nancy/movieplayer.cpp
+++ b/engines/nancy/movieplayer.cpp
@@ -344,4 +344,16 @@ void MoviePlayer::drawFrame(Graphics::ManagedSurface &dst, const Common::Point &
 	}
 }
 
+void MoviePlayer::drawFrame(Graphics::ManagedSurface &dst, const Common::Rect &source, const Common::Point &pos) const {
+	if (!_currentSurface) {
+		return;
+	}
+
+	Common::Rect area = source;
+	area.clip(Common::Rect(_currentSurface->w, _currentSurface->h));
+	if (!area.isEmpty()) {
+		dst.blitFrom(*_currentSurface, area, Common::Point(pos.x + area.left - source.left, pos.y + area.top - source.top));
+	}
+}
+
 } // End of namespace Nancy
diff --git a/engines/nancy/movieplayer.h b/engines/nancy/movieplayer.h
index 0a213a00883..29e854551fc 100644
--- a/engines/nancy/movieplayer.h
+++ b/engines/nancy/movieplayer.h
@@ -113,6 +113,7 @@ public:
 	bool isRangePlaying() const { return _rangePlaying; }
 	int getCurrentFrame() const { return getCurFrame(); }
 	void drawFrame(Graphics::ManagedSurface &dst, const Common::Point &pos) const;
+	void drawFrame(Graphics::ManagedSurface &dst, const Common::Rect &source, const Common::Point &pos) const;	// a part of it
 
 private:
 	friend class BinkCacheLoader;
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index 3065866551a..ad021e4aed0 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -127,9 +127,7 @@ void RecordGraphic::setScriptZ(double z, const SceneContext &context) {
 }
 
 // Where something that was placed in the viewport ends up once the transform
-// of its record has moved it.
-// TODO: Movies only take their place from the transform. Draw what it rotates
-// and scales as well.
+// of its record has moved it
 static Common::Rect getTransformedDest(const ScriptRect &dest, const ScriptMatrix &transform, int width, int height) {
 	// Far enough for anything a script moves around, and safe to add a size to
 	const int kLimit = 16000;
@@ -1551,10 +1549,36 @@ void MovieRecord::readTable(int tableIndex) {
 	readField(L, tableIndex, "pauseOnLastFrame", _pausesOnLastFrame);
 	readField(L, tableIndex, "visible", _isVisible);
 
+	// The whole of every frame, unless a part of it is asked for
+	_source = ScriptRect(0, 0, _player.getWidth(), _player.getHeight());
+	if (pushField(L, tableIndex, "source")) {
+		_source = checkRect(L, -1);
+		lua_pop(L, 1);
+	}
+
+	double volume = 0.85;
+	readField(L, tableIndex, "volume", volume);
+	_player.setVolume((byte)CLIP<int>((int)(volume * 100.0 + 0.5), 0, 100));
+
+	// true is forever, false once, and a number that many times
+	lua_getfield(L, tableIndex, "loop");
+	if (lua_isboolean(L, -1)) {
+		_numLoops = lua_toboolean(L, -1) ? 0 : 1;
+	} else if (lua_isnumber(L, -1)) {
+		_numLoops = lua_tointeger(L, -1);
+	} else if (!lua_isnil(L, -1)) {
+		luaL_error(L, "loop must be either a boolean or a number");
+	}
+
+	lua_pop(L, 1);
+
+	if (pushField(L, tableIndex, "modColor")) {
+		_modColor = checkColor(L, -1);
+		lua_pop(L, 1);
+	}
+
 	_graphic.init();
-	_graphic._drawSurface.create(_player.getWidth(), _player.getHeight(), g_nancy->_graphics->getScreenPixelFormat());
 	_graphic.setVisible(false);
-	applyPosition();
 	onZChanged();
 	_graphic.registerGraphics();
 
@@ -1562,15 +1586,45 @@ void MovieRecord::readTable(int tableIndex) {
 	drawFrame();
 }
 
-void MovieRecord::applyPosition() {
-	_graphic.moveTo(getTransformedDest(_dest, getTransform(), _player.getWidth(), _player.getHeight()));
+// Scripts hide a movie that keeps playing with a color without any alpha
+bool MovieRecord::isSeeThrough() const {
+	return (_modColor >> 24) == 0;
 }
 
 void MovieRecord::drawFrame() {
-	// A frame with see-through parts would let the one before it show
-	_graphic._drawSurface.clear(0);
-	_player.drawFrame(_graphic._drawSurface, Common::Point(0, 0));
-	_graphic.setNeedsRedraw(true);
+	if (isSeeThrough()) {
+		return;
+	}
+
+	const ScriptMatrix transform = getTransform();
+	const Common::Rect source = _source.toRect();
+
+	if (transform.isTranslation() && _modColor == kNoModColor) {
+		Graphics::ManagedSurface &surface = _graphic._drawSurface;
+		if (surface.w != source.width() || surface.h != source.height() || !surface.getPixels()) {
+			surface.create(MAX<int>(source.width(), 1), MAX<int>(source.height(), 1), g_nancy->_graphics->getScreenPixelFormat());
+		}
+
+		// A frame with see-through parts would let the one before it show
+		surface.clear(0);
+		_player.drawFrame(surface, source, Common::Point(0, 0));
+		_graphic.setTransparent(false);
+		_graphic.moveTo(getTransformedDest(_dest, transform, source.width(), source.height()));
+		_graphic.setNeedsRedraw(true);
+		return;
+	}
+
+	// Turned, resized or colored frames get drawn anew, the way overlays are
+	if (_frame.w != _player.getWidth() || _frame.h != _player.getHeight()) {
+		_frame.create(_player.getWidth(), _player.getHeight(), g_nancy->_graphics->getScreenPixelFormat());
+	}
+
+	_frame.clear(0);
+	_player.drawFrame(_frame, Common::Point(0, 0));
+
+	Common::Rect clipped = source;
+	clipped.clip(Common::Rect(_frame.w, _frame.h));
+	placePicture(_graphic, _frame, clipped, _dest, transform, _modColor);
 }
 
 void MovieRecord::runOnce() {
@@ -1578,9 +1632,9 @@ void MovieRecord::runOnce() {
 	drawFrame();
 }
 
-// A movie that plays through and ends
+// A movie that plays through and ends; one that loops forever never does
 bool MovieRecord::isSkippable() const {
-	return isActive() && hasStarted() && !_isPaused && !_pausesOnLastFrame;
+	return isActive() && hasStarted() && !_isPaused && !_pausesOnLastFrame && _numLoops != 0;
 }
 
 void MovieRecord::run(double sceneTime, double elapsedTime) {
@@ -1619,6 +1673,14 @@ void MovieRecord::run(double sceneTime, double elapsedTime) {
 		return;
 	}
 
+	// Another time through
+	++_numPlayed;
+	if (_numLoops == 0 || _numPlayed < _numLoops) {
+		_player.playRange(_firstFrame, _lastFrame);
+		drawFrame();
+		return;
+	}
+
 	if (!_pausesOnLastFrame) {
 		_endTime = sceneTime;
 		finish();
@@ -1631,6 +1693,7 @@ void MovieRecord::run(double sceneTime, double elapsedTime) {
 
 bool MovieRecord::prepareReset() {
 	_endTime = 0.0;
+	_numPlayed = 0;
 	_skipFrame = -1;
 	_isPaused = false;
 	_hasCalledOnPause = false;
@@ -1644,7 +1707,7 @@ void MovieRecord::onZChanged() {
 }
 
 void MovieRecord::updateGraphics() {
-	_graphic.setVisible(isActive() && _isVisible);
+	_graphic.setVisible(isActive() && _isVisible && !isSeeThrough());
 }
 
 // movie:Play() and movie:Pause()
@@ -1673,6 +1736,10 @@ bool MovieRecord::getField(const Common::String &key) {
 		lua_pushstring(L, _name.c_str());
 	} else if (key == "onScreen") {
 		pushRect(L, _dest);
+	} else if (key == "source") {
+		pushRect(L, _source);
+	} else if (key == "modColor") {
+		pushColor(L, _modColor);
 	} else if (key == "Play") {
 		lua_pushcfunction(L, moviePlay);
 	} else if (key == "Pause") {
@@ -1710,6 +1777,21 @@ bool MovieRecord::setField(const Common::String &key) {
 			_dest = dest;
 			applyPosition();
 		}
+	} else if (key == "source" && g_nancy->getGameType() >= kGameTypeNancy21) {
+		// Earlier games only take the part of the frames when the movie is made
+		const ScriptRect source = checkRect(L, 3);
+		if (source != _source) {
+			_source = source;
+			drawFrame();
+		}
+	} else if (key == "modColor") {
+		const uint32 modColor = checkColor(L, 3);
+		if (modColor != _modColor) {
+			_modColor = modColor;
+			drawFrame();
+		}
+	} else if (key == "volume") {
+		_player.setVolume((byte)CLIP<int>((int)(luaL_checknumber(L, 3) * 100.0 + 0.5), 0, 100));
 	} else if (key == "pauseOnLastFrame") {
 		_pausesOnLastFrame = lua_toboolean(L, 3) != 0;
 	} else if (key == "visible") {
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 7b6bda7cbaa..b07a8776c95 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -379,8 +379,7 @@ private:
 	uint _numRevealed = 0;
 };
 
-// Plays a movie in the viewport
-// TODO: Handle the source, volume, loop and modColor fields.
+// Plays a part of a movie in the viewport, once, a number of times or forever
 class MovieRecord : public Record {
 public:
 	MovieRecord(SceneContext &context) : Record(context, "ND.Movie") {}
@@ -402,13 +401,22 @@ protected:
 	bool setField(const Common::String &key) override;
 
 private:
+	bool isSeeThrough() const;
 	void drawFrame();
-	void applyPosition();
+	void applyPosition() { drawFrame(); }
 
 	Common::String _name;
 	MoviePlayer _player;
 	RecordGraphic _graphic;
+	ScriptRect _source;
 	ScriptRect _dest;
+	uint32 _modColor = 0xFFFFFFFF;	// what the colors of the frames get multiplied with
+
+	// What a turned, resized or colored frame gets drawn from
+	Graphics::ManagedSurface _frame;
+
+	int _numLoops = 1;		// 0 for forever
+	int _numPlayed = 0;
 	int _skipFrame = -1;	// the frame a skipped movie is at; -1 when not skipped
 
 	int _firstFrame = 0;


Commit: 8b8e8b9af4aafde31c3999b3e68898583780267b
    https://github.com/scummvm/scummvm/commit/8b8e8b9af4aafde31c3999b3e68898583780267b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:46+03:00

Commit Message:
NANCY: NANCY21: Take pictures with the camera of the phone

Save:Screenshot keeps a picture of a part of the viewport, as the game
drew it before scaling it to the screen. The pictures are kept as PNG
files with the saved game, and overlays show them when their tree is
"LOADSAVE".

Changed paths:
    engines/nancy/graphics.h
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h
    engines/nancy/script/gameapi.cpp
    engines/nancy/script/records.cpp


diff --git a/engines/nancy/graphics.h b/engines/nancy/graphics.h
index 8cc239a6090..80326019d24 100644
--- a/engines/nancy/graphics.h
+++ b/engines/nancy/graphics.h
@@ -99,6 +99,9 @@ public:
 	// Objects and the mouse are in game coordinates, unless they are at the screen's
 	// resolution. For the other games the two are the same.
 	bool isGameScaled() const { return !_gameSurface.empty(); }
+
+	// What the game drew last, at its own resolution
+	const Graphics::ManagedSurface &getGameImage() const { return isGameScaled() ? _gameSurface : _screen; }
 	Common::Rect getGameBounds() const;
 	Common::Point screenToGame(const Common::Point &pos) const;
 	Common::Point gameToScreen(const Common::Point &pos) const;
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 1bda9db86df..b18a60f77ea 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -1023,6 +1023,31 @@ void ScriptSavedData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void ScriptPictureData::synchronize(Common::Serializer &ser) {
+	uint16 numPictures = pictures.size();
+	ser.syncAsUint16LE(numPictures);
+
+	Common::Array<Common::String> names;
+	if (ser.isSaving()) {
+		for (auto &entry : pictures) {
+			names.push_back(entry._key);
+		}
+	} else {
+		pictures.clear();
+		names.resize(numPictures);
+	}
+
+	for (Common::String &name : names) {
+		ser.syncString(name);
+
+		Common::Array<byte> &picture = pictures[name];
+		uint32 size = picture.size();
+		ser.syncAsUint32LE(size);
+		picture.resize(size);
+		ser.syncBytes(picture.data(), size);
+	}
+}
+
 void ScriptJournalData::synchronize(Common::Serializer &ser) {
 	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
 		uint16 numEntries = journalEntries[i].size();
@@ -1072,6 +1097,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new ScriptSavedData();
 	case ScriptJournalData::getTag():
 		return new ScriptJournalData();
+	case ScriptPictureData::getTag():
+		return new ScriptPictureData();
 	case StreamData::getTag():
 		return new StreamData();
 	case BuildBoardData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 7c3df873307..9742bf35488 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -700,6 +700,15 @@ struct ScriptSavedData : public PuzzleData {
 	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> values;
 };
 
+// Nancy21. The pictures the camera of the cell phone took, as PNG files, which
+// the scripts show from the "LOADSAVE" tree
+struct ScriptPictureData : public PuzzleData {
+	static constexpr uint32 getTag() { return MKTAG('S', 'C', 'P', 'C'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> pictures;
+};
+
 // Nancy19. The journal entries and the tasks the scripts gave every player
 // character. A script only hands them out while its conditions hold, so they
 // are kept with the game.
diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 0a0621b225b..05e14b5757f 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -20,9 +20,14 @@
  */
 
 #include "common/debug.h"
+#include "common/memstream.h"
+#include "common/system.h"
+
+#include "image/png.h"
 
 #include "engines/nancy/cursor.h"
 #include "engines/nancy/enginedata.h"
+#include "engines/nancy/graphics.h"
 #include "engines/nancy/input.h"
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/puzzledata.h"
@@ -730,10 +735,46 @@ static int saveGame(lua_State *L) {
 	return 0;
 }
 
+// Save:Screenshot(rect[, name]), Nancy21: keeps a picture of a part of the
+// viewport with the game, for the camera of the cell phone. Overlays show it
+// from the "LOADSAVE" tree. Gives the name of the picture, or an empty one when
+// it couldn't be taken.
+static int saveScreenshot(lua_State *L) {
+	const ScriptRect &rect = checkRect(L, 2);
+	const Common::String name = lua_gettop(L) > 2 ? Common::String(luaL_checkstring(L, 3)) : Common::String::format("%u_OVL", g_system->getMillis());
+
+	// The viewport as it was drawn last, before it got scaled onto the screen
+	const Graphics::ManagedSurface &image = g_nancy->_graphics->getGameImage();
+	Common::Rect area = NancySceneState.getViewport().convertViewportToScreen(rect.toRect());
+	area.clip(Common::Rect(image.w, image.h));
+
+	bool isTaken = false;
+	if (!area.isEmpty()) {
+		const Graphics::Surface part = image.rawSurface().getSubArea(area);
+		Graphics::Surface *picture = part.convertTo(Graphics::PixelFormat::createFormatRGB24());
+
+		Common::MemoryWriteStreamDynamic png(DisposeAfterUse::YES);
+		if (Image::writePNG(png, *picture)) {
+			ScriptPictureData *pictures = (ScriptPictureData *)NancySceneState.getPuzzleData(ScriptPictureData::getTag());
+			Common::Array<byte> &data = pictures->pictures[name];
+			data.resize((uint)png.size());
+			memcpy(data.data(), png.getData(), png.size());
+			isTaken = true;
+		}
+
+		picture->free();
+		delete picture;
+	}
+
+	lua_pushstring(L, isTaken ? name.c_str() : "");
+	return 1;
+}
+
 static const luaL_Reg kSaveFunctions[] = {
 	{ "__index", indexFunctions },
 	{ "Data", saveData },
 	{ "Game", saveGame },
+	{ "Screenshot", saveScreenshot },
 	{ nullptr, nullptr }
 };
 
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index ad021e4aed0..dbbb0614b3e 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -23,10 +23,13 @@
 #include "common/random.h"
 #include "common/textconsole.h"
 
+#include "image/png.h"
+
 #include "engines/nancy/cursor.h"
 #include "engines/nancy/enginedata.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/nancy.h"
+#include "engines/nancy/puzzledata.h"
 #include "engines/nancy/resource.h"
 #include "engines/nancy/sound.h"
 
@@ -810,6 +813,39 @@ bool OverrideRecord::setField(const Common::String &key) {
 
 // Overlay
 
+// The "LOADSAVE" tree holds the pictures the camera took, which are kept with
+// the saved game
+static const char *const kPictureTreeName = "LOADSAVE";
+
+static bool loadOverlayImage(const Common::String &name, const Common::String &treeName, Graphics::ManagedSurface &image) {
+	if (!treeName.equalsIgnoreCase(kPictureTreeName)) {
+		return g_nancy->_resource->loadImage(Common::Path(name), image, treeName);
+	}
+
+#ifdef USE_PNG
+	const ScriptPictureData *pictures = (const ScriptPictureData *)NancySceneState.getPuzzleData(ScriptPictureData::getTag());
+	if (!pictures->pictures.contains(name)) {
+		warning("Could not find picture %s amongst the saved ones", name.c_str());
+		return false;
+	}
+
+	const Common::Array<byte> &data = pictures->pictures[name];
+	Common::MemoryReadStream stream(data.data(), data.size());
+	Image::PNGDecoder decoder;
+	if (!decoder.loadStream(stream)) {
+		return false;
+	}
+
+	Graphics::Surface *converted = decoder.getSurface()->convertTo(g_nancy->_graphics->getInputPixelFormat(32));
+	GraphicsManager::copyToManaged(*converted, image);
+	converted->free();
+	delete converted;
+	return true;
+#else
+	return false;
+#endif
+}
+
 void OverlayRecord::readTable(int tableIndex) {
 	lua_State *L = getLua();
 
@@ -818,7 +854,11 @@ void OverlayRecord::readTable(int tableIndex) {
 		luaL_error(L, "OverlayAR must be created with a ovl parameter");
 	}
 
-	if (!g_nancy->_resource->loadImage(Common::Path(imageName), _image)) {
+	// Nancy21 can name the tree the image is in
+	Common::String treeName;
+	readField(L, tableIndex, "tree", treeName);
+
+	if (!loadOverlayImage(imageName, treeName, _image)) {
 		luaL_error(L, "Could not load overlay %s", imageName.c_str());
 	}
 
@@ -1017,7 +1057,11 @@ void AnimOverlayRecord::readTable(int tableIndex) {
 		luaL_error(L, "AnimOverlays must have a ovl field");
 	}
 
-	if (!g_nancy->_resource->loadImage(Common::Path(imageName), _image)) {
+	// Nancy21 can name the tree the image is in
+	Common::String treeName;
+	readField(L, tableIndex, "tree", treeName);
+
+	if (!loadOverlayImage(imageName, treeName, _image)) {
 		luaL_error(L, "Could not load overlay %s", imageName.c_str());
 	}
 


Commit: 431c4a0f57b8d66866485b33d4beee689d16f8d1
    https://github.com/scummvm/scummvm/commit/431c4a0f57b8d66866485b33d4beee689d16f8d1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:48+03:00

Commit Message:
NANCY: NANCY21: Let scripts change the images of UI controls

The "setImage" message gives a control another texture, another part
of it, or both, e.g. the phone's buttons in Nancy21. Scripts pack the
part into the message's number with Message:EncodeRect.

Changed paths:
    engines/nancy/script/gameapi.cpp
    engines/nancy/ui/ndui/button.cpp
    engines/nancy/ui/ndui/common.cpp
    engines/nancy/ui/ndui/common.h
    engines/nancy/ui/ndui/control.cpp
    engines/nancy/ui/ndui/control.h


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 05e14b5757f..46721be157b 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -1555,7 +1555,6 @@ bool findText(const Common::String &key, Common::String &text) {
 }
 
 // Message, which has the UI do something to one of its panels or controls
-// TODO: Message:EncodeRect
 
 struct MessageActionName {
 	const char *name;
@@ -1573,6 +1572,7 @@ static const MessageActionName kMessageActionNames[] = {
 	{ "addText", UI::NDUI::kActionAddText },
 	{ "setText", UI::NDUI::kActionSetText },
 	{ "setValue", UI::NDUI::kActionSetValue },
+	{ "setImage", UI::NDUI::kActionSetImage },
 	{ "getValue", UI::NDUI::kActionQuery },
 	{ "showItem", UI::NDUI::kActionShowItem },
 	{ "hideItem", UI::NDUI::kActionHideItem }
@@ -1623,9 +1623,17 @@ static int messageSend(lua_State *L) {
 	return 1;
 }
 
+// Message:EncodeRect(rect), the value "setImage" takes a source rect as
+static int messageEncodeRect(lua_State *L) {
+	const ScriptRect &rect = checkRect(L, 2);
+	lua_pushnumber(L, UI::NDUI::encodeRect(rect.left, rect.top, rect.right, rect.bottom));
+	return 1;
+}
+
 static const luaL_Reg kMessageFunctions[] = {
 	{ "__index", indexFunctions },
 	{ "Send", messageSend },
+	{ "EncodeRect", messageEncodeRect },
 	{ nullptr, nullptr }
 };
 
diff --git a/engines/nancy/ui/ndui/button.cpp b/engines/nancy/ui/ndui/button.cpp
index 0a426550eb1..72fb3b14169 100644
--- a/engines/nancy/ui/ndui/button.cpp
+++ b/engines/nancy/ui/ndui/button.cpp
@@ -103,6 +103,9 @@ bool Button::doAction(const Action &action) {
 	case kActionSetText:
 		setText(action.text);
 		return true;
+	case kActionSetImage:
+		setImage(1, action);
+		return true;
 	default:
 		return Control::doAction(action);
 	}
diff --git a/engines/nancy/ui/ndui/common.cpp b/engines/nancy/ui/ndui/common.cpp
index 05e6dd9b963..3c5863012da 100644
--- a/engines/nancy/ui/ndui/common.cpp
+++ b/engines/nancy/ui/ndui/common.cpp
@@ -45,6 +45,31 @@ void Action::read(Common::SeekableReadStream &stream) {
 	readFilename(stream, text);
 }
 
+double encodeRect(int32 left, int32 top, int32 right, int32 bottom) {
+	if (left >= 0xFFFF || top >= 0xFFFF || right >= 0xFFFF || bottom >= 0xFFFF) {
+		return -1.0;
+	}
+
+	const uint64 bits = (uint64)(uint16)left | ((uint64)(uint16)top << 16) |
+		((uint64)(uint16)right << 32) | ((uint64)(uint16)bottom << 48);
+
+	double value;
+	memcpy(&value, &bits, sizeof(value));
+	return value;
+}
+
+Common::Rect decodeRect(double value) {
+	uint64 bits;
+	memcpy(&bits, &value, sizeof(bits));
+
+	Common::Rect rect;
+	rect.left = (int16)(bits & 0xFFFF);
+	rect.top = (int16)((bits >> 16) & 0xFFFF);
+	rect.right = (int16)((bits >> 32) & 0xFFFF);
+	rect.bottom = (int16)((bits >> 48) & 0xFFFF);
+	return rect;
+}
+
 static void addStateChange(Common::Array<UIStateData::Change> &changes, const Common::String &name, int32 action) {
 	UIStateData::Change change;
 	change.name = name;
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index c105d9c5a2d..2f22a2a498f 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -22,6 +22,7 @@
 #ifndef NANCY_UI_NDUI_COMMON_H
 #define NANCY_UI_NDUI_COMMON_H
 
+#include "common/rect.h"
 #include "common/str.h"
 #include "common/stream.h"
 
@@ -109,6 +110,7 @@ enum ActionType {
 	kActionSetText			= 12,	// also empties a list box
 	kActionSetValue			= 13,
 	kActionSetTexture		= 14,
+	kActionSetImage			= 15,	// Nancy21: a texture and a source rect, in one
 	kActionQuery			= 30,	// asks the engine to send its value back with kActionSetValue
 	kActionShowItem			= 40,	// inventory panels
 	kActionHideItem			= 41
@@ -143,6 +145,11 @@ public:
 	virtual bool createObject(const Common::String &name, const Common::String &templateName) = 0;
 };
 
+// Nancy21 passes rectangles around as the value of an action, with the four
+// sides packed into the bytes of the number. A side that doesn't fit gives -1.
+double encodeRect(int32 left, int32 top, int32 right, int32 bottom);
+Common::Rect decodeRect(double value);
+
 // Adds the actions that bring a panel or control from the state it starts in to the given one
 void addStateChanges(Common::Array<UIStateData::Change> &changes, const Common::String &name, int32 initialState, bool visible, bool enabled);
 
diff --git a/engines/nancy/ui/ndui/control.cpp b/engines/nancy/ui/ndui/control.cpp
index 03b4ee903e6..f934711b672 100644
--- a/engines/nancy/ui/ndui/control.cpp
+++ b/engines/nancy/ui/ndui/control.cpp
@@ -181,6 +181,23 @@ void Control::sendEvent(int32 eventType) {
 	}
 }
 
+void Control::setImage(uint element, const Action &action) {
+	if (element >= _elements.size()) {
+		return;
+	}
+
+	Element &elem = _elements[element];
+	if (!action.text.empty()) {
+		elem.setTextureName(action.text);
+	}
+
+	if (action.value > 0) {
+		elem._src = decodeRect(action.value);
+	}
+
+	_panel->setDirty();
+}
+
 ControlState Control::getState() const {
 	if (!_enabled || !_panel->isEnabled()) {
 		return kStateDisabled;
@@ -239,6 +256,9 @@ bool Static::doAction(const Action &action) {
 		_elements[0].setTextureName(action.text);
 		_panel->setDirty();
 		return true;
+	case kActionSetImage:
+		setImage(0, action);
+		return true;
 	default:
 		return Control::doAction(action);
 	}
diff --git a/engines/nancy/ui/ndui/control.h b/engines/nancy/ui/ndui/control.h
index 734ae14a0dc..d1bd3269fa9 100644
--- a/engines/nancy/ui/ndui/control.h
+++ b/engines/nancy/ui/ndui/control.h
@@ -94,6 +94,11 @@ protected:
 	virtual bool isOwnEvent(int32 eventType) const { return false; }
 
 	ControlState getState() const;
+
+	// Nancy21: what a "setImage" action asks for. A text picks another texture,
+	// and a value above 0 a source rect.
+	void setImage(uint element, const Action &action);
+
 	void drawSprite(Graphics::ManagedSurface &surface, uint element, const Common::Rect &dest) const;
 	void drawSprite(Graphics::ManagedSurface &surface, uint element, const Common::Rect &src, const Common::Rect &dest) const;
 


Commit: 005e929c5b856291afae959b395297f68f10d8c8
    https://github.com/scummvm/scummvm/commit/005e929c5b856291afae959b395297f68f10d8c8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:51+03:00

Commit Message:
NANCY: NANCY20: Add the expand and collapse messages

Message:Send knows "expand", "collapse" and "toggleCollapsed". Nancy20
sends "expand" to the scripts watching the cuckoo clock, which failed
with a script error before. A collapsed panel is hidden until it is
expanded again, and a collapsed list box is emptied, e.g. the list of
saved games when the exit question shows in Nancy20 and Nancy21.

Changed paths:
    engines/nancy/script/gameapi.cpp
    engines/nancy/ui/ndui/common.h
    engines/nancy/ui/ndui/listbox.cpp
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 46721be157b..9eb20a4664e 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -1573,6 +1573,9 @@ static const MessageActionName kMessageActionNames[] = {
 	{ "setText", UI::NDUI::kActionSetText },
 	{ "setValue", UI::NDUI::kActionSetValue },
 	{ "setImage", UI::NDUI::kActionSetImage },
+	{ "expand", UI::NDUI::kActionExpand },
+	{ "collapse", UI::NDUI::kActionCollapse },
+	{ "toggleCollapsed", UI::NDUI::kActionToggleCollapsed },
 	{ "getValue", UI::NDUI::kActionQuery },
 	{ "showItem", UI::NDUI::kActionShowItem },
 	{ "hideItem", UI::NDUI::kActionHideItem }
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index 2f22a2a498f..41b428ddb2a 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -103,6 +103,9 @@ enum ActionType {
 	kActionHide				= 2,
 	kActionToggle			= 3,
 	kActionSetRange			= 5,	// scroll bars
+	kActionExpand			= 5,	// Nancy20 panels, which can be collapsed...
+	kActionCollapse			= 6,
+	kActionToggleCollapsed	= 7,	// ...to their caption, which script panels don't have
 	kActionEnable			= 8,
 	kActionDisable			= 9,
 	kActionToggleEnabled	= 10,
diff --git a/engines/nancy/ui/ndui/listbox.cpp b/engines/nancy/ui/ndui/listbox.cpp
index b1048fb8c73..44ea533cc03 100644
--- a/engines/nancy/ui/ndui/listbox.cpp
+++ b/engines/nancy/ui/ndui/listbox.cpp
@@ -254,11 +254,14 @@ bool ListBox::doAction(const Action &action) {
 		addItem(action.text, action.value < 0);
 		_panel->setDirty();
 		return true;
+	// From Nancy20 on, "collapse" empties a list box, e.g. the list of saved games
+	case kActionCollapse:
 	case kActionSetText:
 		_items.clear();
 		_selected = -1;
 		_hovered = -1;
 		_scrollBar->setRange(0);
+		_panel->setDirty();
 		return true;
 	case kActionSetValue:
 		_selected = CLIP<int>((int)action.value, -1, (int)_items.size() - 1);
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 77f00721bb6..2acf77d8527 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -476,6 +476,15 @@ bool Panel::takeAction(const Action &action) {
 	case kActionDisable:
 		_enabled = false;
 		break;
+	case kActionExpand:
+		setCollapsed(false);
+		break;
+	case kActionCollapse:
+		setCollapsed(true);
+		break;
+	case kActionToggleCollapsed:
+		setCollapsed(!_isCollapsed);
+		break;
 	case kActionSetValue:
 		// A scroll panel follows its scroll bar, a whole step at a time
 		if (_scrollBarName.empty()) {
@@ -553,15 +562,39 @@ void Panel::finishBlending() {
 // Every show needs a matching hide before the panel goes away
 void Panel::show() {
 	++_showCount;
-	setVisible(true);
+	if (_isCollapsed) {
+		_isShownWhenExpanded = true;
+	} else {
+		setVisible(true);
+	}
+
 	arrangeControls();
 	fireEvent(kEventShow);
 }
 
+void Panel::setCollapsed(bool isCollapsed) {
+	if (isCollapsed == _isCollapsed) {
+		return;
+	}
+
+	_isCollapsed = isCollapsed;
+	if (isCollapsed) {
+		_isShownWhenExpanded = isVisible();
+		setVisible(false);
+		if (_capture) {
+			_capture->_isPressed = false;
+			_capture = nullptr;
+		}
+	} else {
+		setVisible(_isShownWhenExpanded);
+	}
+}
+
 void Panel::hide() {
 	--_showCount;
 	if (_showCount == 0) {
 		setVisible(false);
+		_isShownWhenExpanded = false;
 		if (_capture) {
 			_capture->_isPressed = false;
 			_capture = nullptr;
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index 1d3dda5b029..0ab9d9dc56a 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -138,6 +138,7 @@ private:
 	void readControl(Common::SeekableReadStream &stream);
 	void show();
 	void hide();
+	void setCollapsed(bool isCollapsed);
 	void fireEvent(int32 eventType);
 	void redraw();
 	Control *getControlAt(const Common::Point &pos) const;
@@ -169,6 +170,11 @@ private:
 	int32 _scrollOffset = 0;
 
 	int32 _initialState = kStartVisible;
+
+	// A collapsed panel stays out of sight, and shows again when it gets
+	// expanded, if it would have been showing by then
+	bool _isCollapsed = false;
+	bool _isShownWhenExpanded = false;
 	int _showCount = 0;
 	bool _enabled = true;
 	uint32 _lastUpdateTime = 0;


Commit: cbc82928bcfbf1ee4c7341fa5ac62be40360d8cb
    https://github.com/scummvm/scummvm/commit/cbc82928bcfbf1ee4c7341fa5ac62be40360d8cb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:53+03:00

Commit Message:
NANCY: NANCY20: Send "enable" and "disable" events to panel scripts

Enabling or disabling a panel now runs its script's event function
with "enable" or "disable", like the original. The phone button of
Nancy20 and Nancy21 uses this to close the phone when it is disabled.

Changed paths:
    engines/nancy/script/uiapi.cpp
    engines/nancy/ui/ndui/common.h
    engines/nancy/ui/ndui/panel.cpp


diff --git a/engines/nancy/script/uiapi.cpp b/engines/nancy/script/uiapi.cpp
index 972ca756f31..f125cc47082 100644
--- a/engines/nancy/script/uiapi.cpp
+++ b/engines/nancy/script/uiapi.cpp
@@ -47,7 +47,8 @@ namespace Script {
 //
 // TODO: The textShadow and captureClicks fields, the ways of arranging controls
 // other than left to right and right to left, and the events the controls don't
-// send yet: enable, disable, mouseEnter and mouseLeave.
+// send yet: mouseEnter and mouseLeave. Controls don't tell about being enabled
+// or disabled either.
 
 static const char *const kPanelType = "ND.Panel";
 
@@ -75,7 +76,9 @@ static const NamedValue kEventNames[] = {
 	{ "scrollBarValueChanged", UI::NDUI::kEventScrolled },
 	{ "show", UI::NDUI::kEventShow },
 	{ "hide", UI::NDUI::kEventHide },
-	{ "load", UI::NDUI::kEventUpdate }
+	{ "load", UI::NDUI::kEventUpdate },
+	{ "enable", UI::NDUI::kEventEnable },
+	{ "disable", UI::NDUI::kEventDisable }
 };
 
 static const NamedValue kInitialStates[] = {
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index 41b428ddb2a..cc4f8f20e87 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -94,6 +94,8 @@ enum EventType {
 	kEventShow			= 16,	// the panel holding the control was shown
 	kEventHide			= 17,	// the panel holding the control was hidden
 	kEventUpdate		= 18,	// sent every frame while the panel is visible; "load" to Nancy19 scripts
+	kEventEnable		= 19,	// Nancy20: the panel holding the control was enabled...
+	kEventDisable		= 20,	// ...or disabled
 	kEventActionRecord	= 100	// sent by a scene's action record
 };
 
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 2acf77d8527..c96abee9d22 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -472,9 +472,11 @@ bool Panel::takeAction(const Action &action) {
 		break;
 	case kActionEnable:
 		_enabled = true;
+		fireEvent(kEventEnable);
 		break;
 	case kActionDisable:
 		_enabled = false;
+		fireEvent(kEventDisable);
 		break;
 	case kActionExpand:
 		setCollapsed(false);


Commit: 899651af65ee9f9ad297ce12f3b95c56ffa91108
    https://github.com/scummvm/scummvm/commit/899651af65ee9f9ad297ce12f3b95c56ffa91108
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:54+03:00

Commit Message:
NANCY: NANCY19: Handle the captureClicks field of panels

Panels made with captureClicks = false only take the mouse over their
controls, so clicks elsewhere on them reach what's behind. By default,
panels still take every click inside them.

Changed paths:
    engines/nancy/script/uiapi.cpp
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/script/uiapi.cpp b/engines/nancy/script/uiapi.cpp
index f125cc47082..3578ddd7272 100644
--- a/engines/nancy/script/uiapi.cpp
+++ b/engines/nancy/script/uiapi.cpp
@@ -45,10 +45,10 @@ namespace Script {
 // A description gets written out the way a chunk has it, and read by the panel
 // or control it is for.
 //
-// TODO: The textShadow and captureClicks fields, the ways of arranging controls
-// other than left to right and right to left, and the events the controls don't
-// send yet: mouseEnter and mouseLeave. Controls don't tell about being enabled
-// or disabled either.
+// TODO: The textShadow field, the ways of arranging controls other than left to
+// right and right to left, and the events the controls don't send yet: mouseEnter,
+// mouseLeave, and enable and disable for a control of its own; only whole panels
+// send those.
 
 static const char *const kPanelType = "ND.Panel";
 
@@ -710,6 +710,11 @@ static int newPanel(lua_State *L, byte kind) {
 		panel->setSavesState(savesState);
 	}
 
+	bool capturesClicks = true;
+	if (readField(L, 2, "captureClicks", capturesClicks)) {
+		panel->setCapturesClicks(capturesClicks);
+	}
+
 	if (pushField(L, 2, "createObject")) {
 		if (!lua_isfunction(L, -1)) {
 			return luaL_error(L, "field 'createObject' must be a function");
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index c96abee9d22..8272f387f8b 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -792,7 +792,7 @@ bool Panel::handleInput(NancyInput &input, bool canHover, bool &isOverControl) {
 	Control *hovered = (isInside && _enabled) ? getControlAt(pos) : nullptr;
 	if (isInside && !hovered) {
 		Common::Point unscrolledPos(pos.x, pos.y - _scrollOffset);
-		isInside = _background->_bounds.contains(unscrolledPos);
+		isInside = _capturesClicks && _background->_bounds.contains(unscrolledPos);
 	}
 
 	for (Control *control : _controls) {
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index 0ab9d9dc56a..d4e31f4aade 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -86,6 +86,9 @@ public:
 
 	bool hasControl(const Common::String &name) const;
 
+	// Nancy19: a panel that doesn't capture clicks only takes the mouse over its controls
+	void setCapturesClicks(bool capturesClicks) { _capturesClicks = capturesClicks; }
+
 	// Nancy17 keeps the items apart from the panel that shows them, which comes
 	// with nothing but a template for their buttons
 	void addItemButtons(const Common::Array<byte> &buttonTemplate);
@@ -148,6 +151,7 @@ private:
 	byte _kind;
 	bool _isPlaced = false;
 	bool _savesState = true;
+	bool _capturesClicks = true;
 	ObjectFactory *_objectFactory = nullptr;
 	const INVD::Item *_newItem = nullptr;	// the item whose button is being made
 


Commit: d55813f65cccc777fad4a46dd77ec7b6439b4b97
    https://github.com/scummvm/scummvm/commit/d55813f65cccc777fad4a46dd77ec7b6439b4b97
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:55+03:00

Commit Message:
NANCY: NANCY19: Draw tooltips the way the original does

>From Nancy19 on, a tooltip is white text with nothing behind it, just
as large as the text. Before, it was black text on yellow, which only
stood in for colors that weren't known.

Changed paths:
    engines/nancy/ui/ndui/control.cpp
    engines/nancy/ui/ndui/panel.cpp


diff --git a/engines/nancy/ui/ndui/control.cpp b/engines/nancy/ui/ndui/control.cpp
index f934711b672..64d032372e2 100644
--- a/engines/nancy/ui/ndui/control.cpp
+++ b/engines/nancy/ui/ndui/control.cpp
@@ -275,7 +275,8 @@ bool Tooltip::isPrevented() const {
 }
 
 void Tooltip::render(Graphics::ManagedSurface &surface, const Common::String &text) {
-	static const int kPadding = 3;
+	// From Nancy19 on, the tooltip is just as large as its text
+	const int kPadding = g_nancy->getGameType() >= kGameTypeNancy19 ? 0 : 3;
 
 	const Graphics::Font *font = _elements[0].getSystemFont();
 	int width = (font ? font->getStringWidth(text) : 0) + 2 * kPadding;
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 8272f387f8b..d90600664bd 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -169,10 +169,9 @@ void Panel::addBuiltInTooltip() {
 	static const double kHideAfter = 10.0;
 	static const char *const kFontName = "UIFont";
 
-	// TODO: The colors of the original aren't known; these are the usual ones
-	// of a tooltip
-	static const uint32 kBackgroundColor = 0xFFFFFFE1;
-	static const uint32 kTextColor = 0xFF000000;
+	// White text with nothing behind it
+	static const uint32 kBackgroundColor = 0x00000000;
+	static const uint32 kTextColor = 0xFFFFFFFF;
 
 	// Centered, and on a single line
 	static const int32 kAlignCenter = 2;


Commit: 9967b78236d8200766c342f7e5303179142e70d4
    https://github.com/scummvm/scummvm/commit/9967b78236d8200766c342f7e5303179142e70d4
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:57+03:00

Commit Message:
NANCY: NANCY21: Ask before leaving when the game can't be saved

Scripts tell Engine_Saver when the game can't be saved, e.g. during a
cutscene. Leaving or loading a game then asks whether to go on without
saving, with Nancy21's ExitQueryDialog, instead of offering to save.

Changed paths:
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/manager.h
    engines/nancy/ui/ndui/saveload.cpp


diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index ae4ce9f2a91..44e9cf4d6c1 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -127,6 +127,8 @@ void Manager::takeEngineAction(const Action &action) {
 		onSaveQueryAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_SaveOverwrite")) {
 		onSaveOverwriteAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_ExitQuery")) {
+		onExitQueryAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_GameWindow")) {
 		onGameWindowAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_Matte")) {
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index c4c753d38c2..e8059ca0a3c 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -187,6 +187,7 @@ private:
 	void onSaverAction(const Action &action);
 	void onSaveQueryAction(const Action &action);
 	void onSaveOverwriteAction(const Action &action);
+	void onExitQueryAction(const Action &action);
 	void onGameWindowAction(const Action &action);
 	void onCellPhoneAction(const Action &action);
 	void onItemStreamClicked(const InvButton &button);
@@ -240,6 +241,10 @@ private:
 	bool _hasPendingAction = false;
 	Action _pendingAction;
 
+	// Nancy21: the scripts say when the game can't be saved, e.g. during a
+	// cutscene. Leaving the game then asks whether to leave without saving.
+	bool _isSavingAllowed = true;
+
 	Request _request;
 };
 
diff --git a/engines/nancy/ui/ndui/saveload.cpp b/engines/nancy/ui/ndui/saveload.cpp
index b373318743f..dfce3541fb3 100644
--- a/engines/nancy/ui/ndui/saveload.cpp
+++ b/engines/nancy/ui/ndui/saveload.cpp
@@ -59,7 +59,12 @@ void Manager::askToSaveFirst(const Action &action) {
 	_pendingAction.hasValue = true;
 	_pendingAction.value = -1;
 	_hasPendingAction = true;
-	takeAction(kActionShow, "SaveQueryDialog", 0.0);
+
+	if (!_isSavingAllowed && findPanel("ExitQueryDialog")) {
+		takeAction(kActionShow, "ExitQueryDialog", 0.0);
+	} else {
+		takeAction(kActionShow, "SaveQueryDialog", 0.0);
+	}
 }
 
 void Manager::runPendingAction() {
@@ -88,6 +93,22 @@ void Manager::onSaveQueryAction(const Action &action) {
 	}
 }
 
+// The answer to whether to go on without saving
+void Manager::onExitQueryAction(const Action &action) {
+	switch (action.type) {
+	case kActionEnable:
+		takeAction(kActionHide, "ExitQueryDialog", 0.0);
+		runPendingAction();
+		break;
+	case kActionDisable:
+		takeAction(kActionHide, "ExitQueryDialog", 0.0);
+		_hasPendingAction = false;
+		break;
+	default:
+		break;
+	}
+}
+
 // The saves get listed by their names; the second chance save isn't one of them
 uint Manager::fillSaveList(const Common::String &listName) {
 	takeAction(kActionSetText, listName, Common::String());
@@ -279,6 +300,12 @@ void Manager::onSaverAction(const Action &action) {
 		_typedSaveName.clear();
 		runPendingAction();
 		break;
+	case kActionEnable:
+		_isSavingAllowed = true;
+		break;
+	case kActionDisable:
+		_isSavingAllowed = false;
+		break;
 	case kActionSetText:
 		// Save names must work as file names; a bad one gets undone
 		if (!action.text.contains('<') && !action.text.contains('>') && !action.text.contains(':') && !action.text.contains('*') &&


Commit: 38261334150fdd2c8a707e067e148df624567c51
    https://github.com/scummvm/scummvm/commit/38261334150fdd2c8a707e067e148df624567c51
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:59+03:00

Commit Message:
NANCY: NANCY21: Open the scenes of items by name

Up to Nancy20, the scene that shows an item up close is named after
its number. Nancy21 names its script instead, e.g. "INV_Article_V_SC",
which was read as scene 0 and restarted the game. The item's scene is
now opened by its name.

Changed paths:
    engines/nancy/ui/ndui/button.cpp
    engines/nancy/ui/ndui/button.h
    engines/nancy/ui/ndui/enginehandlers.cpp


diff --git a/engines/nancy/ui/ndui/button.cpp b/engines/nancy/ui/ndui/button.cpp
index 72fb3b14169..28ecb5831f9 100644
--- a/engines/nancy/ui/ndui/button.cpp
+++ b/engines/nancy/ui/ndui/button.cpp
@@ -141,9 +141,11 @@ void InvButton::setItem(const INVD::Item &item) {
 	_kind = item.kind;
 	_valueIndex = item.valueIndex;
 
-	// The scene is named after its number, e.g. "s6200"
-	const Common::String sceneName = item.sceneName.toString();
-	_scene = sceneName.size() > 1 ? atoi(sceneName.c_str() + 1) : 0;
+	// The scene is named after its number, e.g. "s6200", up to Nancy20. Nancy21
+	// names the script of the scene instead, e.g. "INV_Article_V_SC".
+	_sceneName = item.sceneName.toString();
+	uint16 sceneID = 0;
+	_scene = parseSceneName(_sceneName, sceneID) ? sceneID : 0;
 
 	_elements[1].setTexture(_elements[1]._textureName, item.sourceRect);
 }
diff --git a/engines/nancy/ui/ndui/button.h b/engines/nancy/ui/ndui/button.h
index a101984144b..9cc07a94e89 100644
--- a/engines/nancy/ui/ndui/button.h
+++ b/engines/nancy/ui/ndui/button.h
@@ -70,6 +70,7 @@ public:
 	int16 getItemID() const { return _itemID; }
 	int32 getKind() const { return _kind; }
 	uint16 getScene() const { return (uint16)_scene; }
+	const Common::String &getSceneName() const { return _sceneName; }	// Nancy17
 	int16 getValueIndex() const { return _valueIndex; }
 
 protected:
@@ -80,6 +81,7 @@ protected:
 	int16 _itemID = -1;
 	int32 _kind = kItemPickUp;
 	int32 _scene = 0;
+	Common::String _sceneName;	// e.g. "s4823", or in Nancy21 the name of a script
 	int16 _valueIndex = 0;
 };
 
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index 44e9cf4d6c1..d2619d17bca 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -217,7 +217,7 @@ void Manager::viewInventoryItem(int16 itemID) {
 void Manager::onItemStreamClicked(const InvButton &button) {
 	const int16 itemID = button.getItemID();
 	const Common::String streamName = "INVSTR_" + button.getName();
-	const Common::String sceneName = Common::String::format("s%u", button.getScene());
+	const Common::String &sceneName = button.getSceneName();
 
 	// On top of everything the scenes below draw
 	static const int32 kItemStreamZOffset = 15;


Commit: f800917874b017296c858432f861a29e16ab1722
    https://github.com/scummvm/scummvm/commit/f800917874b017296c858432f861a29e16ab1722
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T08:59:59+03:00

Commit Message:
NANCY: NANCY21: Hand the mouse wheel to scripts

Records made of script functions get OnMouseScroll with the place of
the mouse and how far the wheel turned, a notch at a time. Nancy21's
puzzle trays, e.g. the orders of the snack shop, scroll with it.

Changed paths:
    engines/nancy/script/record.cpp
    engines/nancy/script/record.h
    engines/nancy/script/scenecontext.cpp


diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index 4d082e68125..5c89bc49ee6 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -350,7 +350,6 @@ int Record::setScriptField() {
 }
 
 // The functions scripts can give a record for what the player does
-// TODO: OnMouseScroll
 static const char *const kEventFunctionNames[kNumRecordEvents] = {
 	"OnLeftClick", "OnRightClick", "OnLeftDown", "OnLeftUp", "OnRightDown", "OnRightUp",
 	"OnKeyDown", "OnKey", "OnKeyUp"
@@ -420,6 +419,21 @@ bool Record::sendMouseEvent(RecordEvent event, const ScriptPoint &point, bool in
 	return isHandled;
 }
 
+// Only the records made of script functions handle mouse wheel events
+void Record::sendMouseScroll(const ScriptPoint &point, bool inViewport, int notches) {
+	if (!isActive() || !isScripted()) {
+		return;
+	}
+
+	_hasStarted = true;
+
+	lua_State *L = getLua();
+	pushPoint(L, point);
+	lua_pushboolean(L, inViewport);
+	lua_pushinteger(L, notches);
+	callFunction("OnMouseScroll", 3);
+}
+
 bool Record::sendKeyEvent(RecordEvent event, Common::KeyCode key) {
 	// Only the records made of script functions care about the keyboard
 	if (!isActive() || !isScripted()) {
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index 6f462cd9078..2f6e0ca9ef2 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -144,6 +144,7 @@ public:
 	bool sendChangeCursor(const ScriptPoint &point, bool inViewport, uint &cursorType, bool &isHot);
 	bool sendMouseEvent(RecordEvent event, const ScriptPoint &point, bool inViewport);
 	bool sendKeyEvent(RecordEvent event, Common::KeyCode key);
+	void sendMouseScroll(const ScriptPoint &point, bool inViewport, int notches);
 
 	// Where the record puts what it shows, for the kinds that show something,
 	// after the nodes it is under moved it
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index 08f9304671b..40c92da1c63 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -537,6 +537,14 @@ void SceneContext::handleInput(NancyInput &input) {
 		sendMouseEvent(kEventRightClick, point, inViewport);
 	}
 
+	// Every record tracks mouse wheel events, one notch at a time; up is 1
+	if (input.input & NancyInput::kMouseWheel) {
+		const int notches = (input.input & NancyInput::kMouseWheelUp) ? 1 : -1;
+		for (Record *record : _inputOrder) {
+			record->sendMouseScroll(point, inViewport, notches);
+		}
+	}
+
 	// The keys that went down last frame have no release of their own
 	for (Common::KeyCode key : _pressedKeys) {
 		sendKeyEvent(kEventKeyUp, key);


Commit: 9b3c993dd12dc4c46c35f190af4746fd0e15a902
    https://github.com/scummvm/scummvm/commit/9b3c993dd12dc4c46c35f190af4746fd0e15a902
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:01+03:00

Commit Message:
NANCY: NANCY21: Move Text records with their transform

Text records now follow the transform of the graph node they are
under, like overlays do, e.g. the orders on the snack shop tray.
The Text records of Nancy19 and Nancy20 have no transform.

Changed paths:
    engines/nancy/script/records.cpp
    engines/nancy/script/records.h


diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index dbbb0614b3e..3b3bae53a3c 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -1425,11 +1425,18 @@ void TextRecord::readTable(int tableIndex) {
 	_graphic.registerGraphics();
 }
 
+// The transform moves the text, like the scrolling lists of the UI do with theirs
 void TextRecord::applyDest() {
 	const Common::Rect dest = _dest.toRect();
-	_graphic._drawSurface.create(MAX<int>(dest.width(), 1), MAX<int>(dest.height(), 1), g_nancy->_graphics->getTransparentPixelFormat());
-	_graphic.moveTo(dest);
-	_isDirty = true;
+	const int width = MAX<int>(dest.width(), 1);
+	const int height = MAX<int>(dest.height(), 1);
+	if (_graphic._drawSurface.w != width || _graphic._drawSurface.h != height) {
+		_graphic._drawSurface.create(width, height, g_nancy->_graphics->getTransparentPixelFormat());
+		_isDirty = true;
+	}
+
+	_graphic.moveTo(getTransformedDest(_dest, getTransform(), width, height));
+	_graphic.setNeedsRedraw(true);
 }
 
 void TextRecord::setText(const Common::String &text) {
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index b07a8776c95..33495172dce 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -351,6 +351,8 @@ protected:
 	void readTable(int tableIndex) override;
 	void run(double sceneTime, double elapsedTime) override;
 	void onZChanged() override;
+	bool hasTransform() const override { return true; }
+	void onTransformChanged() override { applyDest(); }
 	bool prepareReset() override;
 	bool getField(const Common::String &key) override;
 	bool setField(const Common::String &key) override;


Commit: 18b4f1f9204ab4a7d91234e2426e2696a159c1ce
    https://github.com/scummvm/scummvm/commit/18b4f1f9204ab4a7d91234e2426e2696a159c1ce
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:02+03:00

Commit Message:
NANCY: NANCY19: Add triangle drawing function (Draw:Tri)

Scripts use it to fill triangular shapes in the drawing functions of
their records, e.g. PUZZLEUTILITIES_SC in Nancy20 and Nancy21. Nancy19
draws a triangle in a single color. From Nancy20 on, each corner can
have its own color, which fades into the others.

Changed paths:
    engines/nancy/script/gameapi.cpp


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 9eb20a4664e..f08fc8608c2 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -446,7 +446,6 @@ static const luaL_Reg kGameFunctions[] = {
 };
 
 // Draw, for the "Render" functions of the records
-// TODO: Draw:Tri
 
 static Graphics::ManagedSurface *checkDrawTarget(lua_State *L) {
 	Graphics::ManagedSurface *target = getDrawTarget();
@@ -506,6 +505,56 @@ static int drawLine(lua_State *L) {
 	return 0;
 }
 
+// Draw:Tri(a, b, c, color[, colorB[, colorC]]), Nancy19: a filled triangle.
+// From Nancy20 on, each corner can have a color of its own, and the color goes
+// from one corner to the others. The corners given no color take the first one.
+static int drawTri(lua_State *L) {
+	double x[3], y[3];
+	for (int i = 0; i < 3; ++i) {
+		checkDrawPlace(L, i + 2, x[i], y[i]);
+	}
+
+	uint32 colors[3];
+	colors[0] = checkColor(L, 5);
+	colors[1] = lua_isnoneornil(L, 6) ? colors[0] : checkColor(L, 6);
+	colors[2] = lua_isnoneornil(L, 7) ? colors[0] : checkColor(L, 7);
+	Graphics::ManagedSurface *target = checkDrawTarget(L);
+
+	const double area = (x[1] - x[0]) * (y[2] - y[0]) - (x[2] - x[0]) * (y[1] - y[0]);
+	if (area == 0.0) {
+		return 0;
+	}
+
+	const int minX = MAX<int>(0, (int)floor(MIN(x[0], MIN(x[1], x[2]))));
+	const int maxX = MIN<int>(target->w - 1, (int)ceil(MAX(x[0], MAX(x[1], x[2]))));
+	const int minY = MAX<int>(0, (int)floor(MIN(y[0], MIN(y[1], y[2]))));
+	const int maxY = MIN<int>(target->h - 1, (int)ceil(MAX(y[0], MAX(y[1], y[2]))));
+
+	for (int py = minY; py <= maxY; ++py) {
+		for (int px = minX; px <= maxX; ++px) {
+			// How much of each corner the middle of the pixel has
+			const double cx = px + 0.5;
+			const double cy = py + 0.5;
+			const double w0 = ((x[1] - cx) * (y[2] - cy) - (x[2] - cx) * (y[1] - cy)) / area;
+			const double w1 = ((x[2] - cx) * (y[0] - cy) - (x[0] - cx) * (y[2] - cy)) / area;
+			const double w2 = 1.0 - w0 - w1;
+			if (w0 < 0.0 || w1 < 0.0 || w2 < 0.0) {
+				continue;
+			}
+
+			uint32 argb = 0;
+			for (int shift = 0; shift < 32; shift += 8) {
+				const double channel = w0 * ((colors[0] >> shift) & 0xFF) + w1 * ((colors[1] >> shift) & 0xFF) + w2 * ((colors[2] >> shift) & 0xFF);
+				argb |= (uint32)CLIP<int>((int)(channel + 0.5), 0, 255) << shift;
+			}
+
+			target->setPixel(px, py, getDrawColor(*target, argb));
+		}
+	}
+
+	return 0;
+}
+
 // Draw:Circle(circle, color), Nancy20, where the circle is a table with a
 // center and a radius. Only the outline is drawn, as a polygon of 19 sides.
 static int drawCircle(lua_State *L) {
@@ -548,6 +597,7 @@ static const luaL_Reg kDrawFunctions[] = {
 	{ "__index", indexFunctions },
 	{ "Rect", drawRect },
 	{ "Line", drawLine },
+	{ "Tri", drawTri },
 	{ "Circle", drawCircle },
 	{ nullptr, nullptr }
 };


Commit: 5611223506d28e988f8c0d07130a2d88ffbaa790
    https://github.com/scummvm/scummvm/commit/5611223506d28e988f8c0d07130a2d88ffbaa790
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:04+03:00

Commit Message:
NANCY: NANCY21: Implement the Particle record

It is used for the falling snow of Nancy21. The record keeps making
particles out of parts of an image, and its stages decide where they
start, how they move, and how they fade. Particles are drawn through
a perspective projection, so they get smaller further away.

Changed paths:
  A engines/nancy/script/particles.cpp
  A engines/nancy/script/particles.h
    engines/nancy/module.mk
    engines/nancy/script/records.cpp


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 31ceea2828c..303d868bbfa 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -193,6 +193,7 @@ MODULE_OBJS += \
   script/luabind.o \
   script/luastate.o \
   script/mathtypes.o \
+  script/particles.o \
   script/record.o \
   script/records.o \
   script/ref.o \
diff --git a/engines/nancy/script/particles.cpp b/engines/nancy/script/particles.cpp
new file mode 100644
index 00000000000..e3a3f0cce6e
--- /dev/null
+++ b/engines/nancy/script/particles.cpp
@@ -0,0 +1,830 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+#include "common/util.h"
+
+#include "engines/nancy/enginedata.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/resource.h"
+
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/mathtypes.h"
+#include "engines/nancy/script/particles.h"
+#include "engines/nancy/script/scenecontext.h"
+#include "engines/nancy/script/types.h"
+
+namespace Nancy {
+namespace Script {
+
+// The simulation goes in steps of a fixed length
+static const double kStepsPerSecond = 30.0;
+
+// Particles are kept in a ring of this many
+static const uint kInitialParticles = 1024;
+
+static double randomFraction() {
+	return g_nancy->_randomSource->getRandomNumber(0xFFFFFF) / 16777216.0;
+}
+
+static double lerp(double from, double to, double fraction) {
+	return from + (to - from) * fraction;
+}
+
+static bool readTruth(lua_State *L, int tableIndex, const char *name) {
+	lua_getfield(L, tableIndex, name);
+	const bool value = lua_toboolean(L, -1) != 0;
+	lua_pop(L, 1);
+	return value;
+}
+
+// What happens to the particles, in the order the record runs the stages. A
+// stage can give a new particle its values, and do something to the particles
+// every step.
+class ParticleStage {
+public:
+	ParticleStage(ParticleRecord &record) : _record(record) {}
+	virtual ~ParticleStage() {}
+
+	// A stage that picks its values ahead of time does it for every particle
+	// as soon as it is added, and for the new ones when there is room for more
+	void onAdded() {
+		onGrown(0, _record.getMaxParticles());
+	}
+
+	void onGrown(uint first, uint count) {
+		if (_isPicked) {
+			initParticles(first, count);
+		}
+	}
+
+	void onSpawn(uint first, uint count) {
+		if (!_isPicked) {
+			initParticles(first, count);
+		}
+	}
+
+	virtual void update(uint first, uint count) {}
+
+protected:
+	virtual void initParticles(uint first, uint count) {}
+
+	// Scripts set it to "true", so anything but false and nil counts
+	void readPicked(lua_State *L, int tableIndex) {
+		_isPicked = readTruth(L, tableIndex, "init");
+	}
+
+	ParticleRecord &_record;
+	bool _isPicked = false;
+};
+
+// Every record has these two: the particles get older...
+class ParticleLifeStage : public ParticleStage {
+public:
+	ParticleLifeStage(ParticleRecord &record) : ParticleStage(record) {}
+
+	void update(uint first, uint count) override {
+		const double step = 1.0 / kStepsPerSecond / _record.getLife();
+		for (uint i = first; i < first + count; ++i) {
+			_record.getParticle(i).life += step;
+		}
+	}
+
+protected:
+	void initParticles(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			_record.getParticle(i).life = 0.0;
+		}
+	}
+};
+
+// ...and the forces on them get worked out anew every step
+class ParticleForceClearStage : public ParticleStage {
+public:
+	ParticleForceClearStage(ParticleRecord &record) : ParticleStage(record) {}
+
+	void update(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			Particle &particle = _record.getParticle(i);
+			particle.acceleration[0] = particle.acceleration[1] = particle.acceleration[2] = 0.0;
+		}
+	}
+};
+
+// Reads a vector field of the stage's table into the given one
+static bool readVectorField(lua_State *L, int tableIndex, const char *name, double *out, int size) {
+	if (!pushField(L, tableIndex, name)) {
+		return false;
+	}
+
+	const ScriptVector *vector = testVector(L, -1);
+	if (!vector) {
+		luaL_error(L, "field %s must be a vector", name);
+	}
+
+	for (int i = 0; i < size; ++i) {
+		out[i] = vector->v[i];
+	}
+
+	lua_pop(L, 1);
+	return true;
+}
+
+// "velocity": a speed between minSpeed and maxSpeed, in a direction that strays
+// from "direction" by up to the two variances, both in radians
+class ParticleVelocityStage : public ParticleStage {
+public:
+	ParticleVelocityStage(ParticleRecord &record, lua_State *L, int tableIndex) : ParticleStage(record) {
+		readPicked(L, tableIndex);
+		readField(L, tableIndex, "minSpeed", _minSpeed);
+		readField(L, tableIndex, "maxSpeed", _maxSpeed);
+
+		double direction[3] = { 0.0, 1.0, 0.0 };
+		readVectorField(L, tableIndex, "direction", direction, 3);
+
+		const double length = sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]);
+		if (length > 0.0) {
+			direction[0] /= length;
+			direction[1] /= length;
+			direction[2] /= length;
+		}
+
+		const double azimuth = atan2(direction[1], direction[0]);
+		const double inclination = acos(CLIP(direction[2], -1.0, 1.0));
+
+		double inclinationVariance = 0.0;
+		double azimuthVariance = 0.0;
+		readField(L, tableIndex, "inclinationVariance", inclinationVariance);
+		readField(L, tableIndex, "azimuthVariance", azimuthVariance);
+
+		_minInclination = inclination - inclinationVariance;
+		_maxInclination = inclination + inclinationVariance;
+		_minAzimuth = azimuth - azimuthVariance;
+		_maxAzimuth = azimuth + azimuthVariance;
+	}
+
+protected:
+	void initParticles(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			const double speed = lerp(_minSpeed, _maxSpeed, randomFraction()) / kStepsPerSecond;
+			const double inclination = lerp(_minInclination, _maxInclination, randomFraction());
+			const double azimuth = lerp(_minAzimuth, _maxAzimuth, randomFraction());
+
+			Particle &particle = _record.getParticle(i);
+			particle.velocity[0] = cos(azimuth) * speed * sin(inclination);
+			particle.velocity[1] = sin(azimuth) * speed * sin(inclination);
+			particle.velocity[2] = cos(inclination) * speed;
+		}
+	}
+
+private:
+	double _minSpeed = 0.0;
+	double _maxSpeed = 0.0;
+	double _minInclination = 0.0;
+	double _maxInclination = 0.0;
+	double _minAzimuth = 0.0;
+	double _maxAzimuth = 0.0;
+};
+
+// "angle" and "scale": a value between min and max
+class ParticleRangeStage : public ParticleStage {
+public:
+	ParticleRangeStage(ParticleRecord &record, lua_State *L, int tableIndex, double defaultValue, bool isAngle) :
+			ParticleStage(record), _min(defaultValue), _max(defaultValue), _isAngle(isAngle) {
+		readPicked(L, tableIndex);
+		readField(L, tableIndex, "min", _min);
+		readField(L, tableIndex, "max", _max);
+	}
+
+protected:
+	void initParticles(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			const double value = lerp(_min, _max, randomFraction());
+			if (_isAngle) {
+				_record.getParticle(i).angle = value;
+			} else {
+				_record.getParticle(i).scale = value;
+			}
+		}
+	}
+
+private:
+	double _min;
+	double _max;
+	bool _isAngle;
+};
+
+// "color": the "base" color the image gets multiplied with
+class ParticleColorStage : public ParticleStage {
+public:
+	ParticleColorStage(ParticleRecord &record, lua_State *L, int tableIndex) : ParticleStage(record) {
+		readPicked(L, tableIndex);
+		if (pushField(L, tableIndex, "base")) {
+			_color = checkColor(L, -1);
+			lua_pop(L, 1);
+		}
+	}
+
+protected:
+	void initParticles(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			_record.getParticle(i).color = _color;
+		}
+	}
+
+private:
+	uint32 _color = 0xFFFFFFFF;
+};
+
+// "sources": the parts of the image to pick from, as a list of rects
+class ParticleSourcesStage : public ParticleStage {
+public:
+	ParticleSourcesStage(ParticleRecord &record, lua_State *L, int tableIndex) : ParticleStage(record) {
+		readPicked(L, tableIndex);
+
+		for (int i = 1; ; ++i) {
+			lua_rawgeti(L, tableIndex, i);
+			if (lua_isnil(L, -1)) {
+				lua_pop(L, 1);
+				break;
+			}
+
+			_sources.push_back(checkRect(L, -1));
+			lua_pop(L, 1);
+		}
+	}
+
+protected:
+	void initParticles(uint first, uint count) override {
+		if (_sources.empty()) {
+			return;
+		}
+
+		const Graphics::ManagedSurface &image = _record.getImage();
+		const double width = MAX<int>(image.w, 1);
+		const double height = MAX<int>(image.h, 1);
+
+		for (uint i = first; i < first + count; ++i) {
+			const ScriptRect &source = _sources[g_nancy->_randomSource->getRandomNumber(_sources.size() - 1)];
+
+			Particle &particle = _record.getParticle(i);
+			particle.left = source.left / width;
+			particle.right = source.right / width;
+			particle.top = source.top / height;
+			particle.bottom = source.bottom / height;
+			particle.width = source.width();
+			particle.height = source.height();
+		}
+	}
+
+private:
+	Common::Array<ScriptRect> _sources;
+};
+
+// "positionPerspective": a place inside the view of the camera of the
+// "perspective" projection. x and y go from -1 to 1 across the view, and z from 0
+// at the near plane to 1 at the far one. More of the particles end up near.
+class ParticlePerspectivePositionStage : public ParticleStage {
+public:
+	ParticlePerspectivePositionStage(ParticleRecord &record, lua_State *L, int tableIndex) : ParticleStage(record) {
+		readPicked(L, tableIndex);
+
+		double min[3] = { 0.0, 0.0, 0.0 };
+		double max[3] = { 0.0, 0.0, 0.0 };
+		readVectorField(L, tableIndex, "min", min, 3);
+		readVectorField(L, tableIndex, "max", max, 3);
+
+		double fovy = 0.0;
+		double aspectRatio = 0.0;
+		if (!readField(L, tableIndex, "fovy", fovy) || !readField(L, tableIndex, "aspectRatio", aspectRatio) ||
+				!readField(L, tableIndex, "zN", _zNear) || !readField(L, tableIndex, "zF", _zFar)) {
+			luaL_error(L, "Position perspective stage expects numeric fields fovy, aspectRatio, zN, zF");
+		}
+
+		const double tanY = tan(fovy * 0.5);
+		const double tanX = tan(atan(aspectRatio * tanY));
+
+		_minX = tanX * min[0];
+		_maxX = tanX * max[0];
+		_minY = tanY * min[1];
+		_maxY = tanY * max[1];
+		_minZ = min[2];
+		_maxZ = max[2];
+	}
+
+protected:
+	void initParticles(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			const double z = lerp(_zNear, _zFar, pow(lerp(_minZ, _maxZ, randomFraction()), 0.3));
+
+			Particle &particle = _record.getParticle(i);
+			particle.position[2] = z;
+			particle.position[0] = lerp(z * _minX, z * _maxX, randomFraction());
+			particle.position[1] = lerp(z * _minY, z * _maxY, randomFraction());
+		}
+	}
+
+private:
+	double _minX, _maxX, _minY, _maxY, _minZ, _maxZ;
+	double _zNear = 0.0;
+	double _zFar = 0.0;
+};
+
+// "fade": the particles fade in or out once they are "start" into their life,
+// over "duration" of it
+class ParticleFadeStage : public ParticleStage {
+public:
+	ParticleFadeStage(ParticleRecord &record, lua_State *L, int tableIndex) : ParticleStage(record) {
+		readField(L, tableIndex, "start", _start);
+		readField(L, tableIndex, "duration", _duration);
+		_isFadingIn = readTruth(L, tableIndex, "in");
+	}
+
+	void update(uint first, uint count) override {
+		const double from = _isFadingIn ? 0.0 : 255.0;
+		const double change = _isFadingIn ? 255.0 : -255.0;
+
+		// The oldest particles come first, so the ones after one that hasn't
+		// started fading haven't either
+		for (uint i = first; i < first + count; ++i) {
+			Particle &particle = _record.getParticle(i);
+			const double fraction = (particle.life - _start) / _duration;
+			if (fraction < 0.0) {
+				break;
+			}
+
+			if (fraction <= 1.0) {
+				const uint32 alpha = (uint32)CLIP<int>((int)floor(fraction * change + from + 0.5), 0, 255);
+				particle.color = (particle.color & 0xFFFFFF) | (alpha << 24);
+			}
+		}
+	}
+
+private:
+	double _start = 0.0;
+	double _duration = 1.0;
+	bool _isFadingIn = false;
+};
+
+// "drag": slows the particles down by a factor every step
+class ParticleDragStage : public ParticleStage {
+public:
+	ParticleDragStage(ParticleRecord &record, lua_State *L, int tableIndex) : ParticleStage(record) {
+		readField(L, tableIndex, "factor", _factor);
+	}
+
+	void update(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			Particle &particle = _record.getParticle(i);
+			for (int j = 0; j < 3; ++j) {
+				particle.velocity[j] *= _factor;
+			}
+		}
+	}
+
+private:
+	double _factor = 0.95;
+};
+
+// "force": pulls at the particles with a "vector", in units a second squared
+class ParticleForceStage : public ParticleStage {
+public:
+	ParticleForceStage(ParticleRecord &record, lua_State *L, int tableIndex) : ParticleStage(record) {
+		if (readVectorField(L, tableIndex, "vector", _force, 3)) {
+			for (int i = 0; i < 3; ++i) {
+				_force[i] /= kStepsPerSecond * kStepsPerSecond;
+			}
+		}
+	}
+
+	void update(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			Particle &particle = _record.getParticle(i);
+			for (int j = 0; j < 3; ++j) {
+				particle.acceleration[j] += _force[j];
+			}
+		}
+	}
+
+private:
+	double _force[3] = { 0.0, -1.0 / (kStepsPerSecond * kStepsPerSecond), 0.0 };
+};
+
+// "turbulence": every step, every particle gets pushed by "factor" along one of
+// the axes, either way, or not at all
+class ParticleTurbulenceStage : public ParticleStage {
+public:
+	ParticleTurbulenceStage(ParticleRecord &record, lua_State *L, int tableIndex) : ParticleStage(record) {
+		readField(L, tableIndex, "factor", _factor);
+	}
+
+	void update(uint first, uint count) override {
+		for (uint i = first; i < first + count; ++i) {
+			const uint push = g_nancy->_randomSource->getRandomNumber(7);
+			if (push < 6) {
+				_record.getParticle(i).acceleration[push / 2] += (push & 1) ? -_factor : _factor;
+			}
+		}
+	}
+
+private:
+	double _factor = 0.95;
+};
+
+// TODO: The "position" and "script" stages, which no script uses
+static ParticleStage *makeStage(ParticleRecord &record, lua_State *L, int tableIndex) {
+	Common::String name;
+	if (!readField(L, tableIndex, "name", name)) {
+		luaL_error(L, "name must be set to a string at each stage");
+	}
+
+	if (name == "positionPerspective") {
+		return new ParticlePerspectivePositionStage(record, L, tableIndex);
+	} else if (name == "velocity") {
+		return new ParticleVelocityStage(record, L, tableIndex);
+	} else if (name == "angle") {
+		return new ParticleRangeStage(record, L, tableIndex, 0.0, true);
+	} else if (name == "scale") {
+		return new ParticleRangeStage(record, L, tableIndex, 1.0, false);
+	} else if (name == "color") {
+		return new ParticleColorStage(record, L, tableIndex);
+	} else if (name == "sources") {
+		return new ParticleSourcesStage(record, L, tableIndex);
+	} else if (name == "fade") {
+		return new ParticleFadeStage(record, L, tableIndex);
+	} else if (name == "drag") {
+		return new ParticleDragStage(record, L, tableIndex);
+	} else if (name == "force") {
+		return new ParticleForceStage(record, L, tableIndex);
+	} else if (name == "turbulence") {
+		return new ParticleTurbulenceStage(record, L, tableIndex);
+	}
+
+	luaL_error(L, "Stage name '%s' does not match a known stage", name.c_str());
+	return nullptr;
+}
+
+ParticleRecord::~ParticleRecord() {
+	for (ParticleStage *stage : _stages) {
+		delete stage;
+	}
+
+	delete _canvas;
+}
+
+void ParticleRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	Common::String imageName;
+	if (!readField(L, tableIndex, "ovl", imageName)) {
+		luaL_error(L, "Particle AR requires field 'ovl' to be a string");
+	}
+
+	if (!g_nancy->_resource->loadImage(Common::Path(imageName), _image)) {
+		luaL_error(L, "Could not load particle image %s", imageName.c_str());
+	}
+
+	if (!readField(L, tableIndex, "frequency", _frequency)) {
+		luaL_error(L, "Particle AR requires field 'frequency' to be a number");
+	}
+
+	if (!readField(L, tableIndex, "life", _life)) {
+		luaL_error(L, "Particle AR requires field 'life' to be a number");
+	}
+
+	if (pushField(L, tableIndex, "perspective")) {
+		_perspective = checkMatrix(L, -1);
+		lua_pop(L, 1);
+	}
+
+	_particles.resize(kInitialParticles);
+	addStage(new ParticleLifeStage(*this));
+	addStage(new ParticleForceClearStage(*this));
+
+	if (pushField(L, tableIndex, "stages")) {
+		const int stagesIndex = absoluteIndex(L, -1);
+		for (int i = 1; ; ++i) {
+			lua_rawgeti(L, stagesIndex, i);
+			if (lua_isnil(L, -1)) {
+				lua_pop(L, 1);
+				break;
+			}
+
+			luaL_checktype(L, -1, LUA_TTABLE);
+			addStage(makeStage(*this, L, absoluteIndex(L, -1)));
+			lua_pop(L, 1);
+		}
+
+		lua_pop(L, 1);
+	}
+
+	// The particles can be there before the record is
+	double fastForward = 0.0;
+	readField(L, tableIndex, "fastForward", fastForward);
+	if (fastForward > 0.0) {
+		simulate(fastForward);
+	}
+}
+
+void ParticleRecord::addStage(ParticleStage *stage) {
+	_stages.push_back(stage);
+	stage->onAdded();
+}
+
+void ParticleRecord::run(double sceneTime, double elapsedTime) {
+	simulate(elapsedTime);
+}
+
+// Steps through the given time, 30 steps a second
+void ParticleRecord::simulate(double seconds) {
+	_stepTime += seconds;
+	const int numSteps = (int)(_stepTime * kStepsPerSecond);
+	const int newSteps = numSteps - _numSteps;
+	_numSteps = numSteps;
+
+	for (int i = 0; i < newSteps; ++i) {
+		step();
+	}
+}
+
+void ParticleRecord::step() {
+	const int numSpawned = _numSpawned;
+	_spawnTime += 1.0 / kStepsPerSecond;
+	_numSpawned = (int)(_spawnTime * _frequency);
+
+	// The particles that lived long enough go
+	while (!_batches.empty() && _batches.front().time + _life <= _spawnTime) {
+		dropOldestBatch();
+	}
+
+	if (_numSpawned > numSpawned) {
+		spawn(_numSpawned - numSpawned);
+	}
+
+	runStages(_first, _count, false);
+
+	// Forces change the speeds, and speeds the places
+	for (uint i = 0; i < _count; ++i) {
+		Particle &particle = _particles[(_first + i) % _particles.size()];
+		for (int j = 0; j < 3; ++j) {
+			particle.velocity[j] += particle.acceleration[j];
+			particle.position[j] += particle.velocity[j];
+		}
+	}
+}
+
+// Runs every stage on particles of the ring, which can wrap around: either on
+// new particles, or for a step
+void ParticleRecord::runStages(uint first, uint count, bool isSpawn) {
+	const uint firstPart = MIN<uint>(count, _particles.size() - first);
+	for (ParticleStage *stage : _stages) {
+		if (isSpawn) {
+			stage->onSpawn(first, firstPart);
+			if (count > firstPart) {
+				stage->onSpawn(0, count - firstPart);
+			}
+		} else {
+			stage->update(first, firstPart);
+			if (count > firstPart) {
+				stage->update(0, count - firstPart);
+			}
+		}
+	}
+}
+
+void ParticleRecord::spawn(uint count) {
+	// There are as many particles as the frequency and the life call for
+	while (_count + count > _particles.size()) {
+		grow();
+	}
+
+	const uint first = (_first + _count) % _particles.size();
+	runStages(first, count, true);
+
+	Batch batch;
+	batch.time = _spawnTime;
+	batch.count = count;
+	_batches.push_back(batch);
+	_count += count;
+}
+
+// Doubles the room for particles. The ones that wrapped around to the start of
+// the ring move on past its old end, so that they still follow the others.
+void ParticleRecord::grow() {
+	const uint oldSize = _particles.size();
+	_particles.resize(oldSize * 2);
+	for (ParticleStage *stage : _stages) {
+		stage->onGrown(oldSize, oldSize);
+	}
+
+	const uint numWrapped = _first + _count > oldSize ? _first + _count - oldSize : 0;
+	for (uint i = 0; i < numWrapped; ++i) {
+		_particles[oldSize + i] = _particles[i];
+	}
+}
+
+void ParticleRecord::dropOldestBatch() {
+	const uint count = MIN(_batches.front().count, _count);
+	_count -= count;
+	_first = (_first + count) % _particles.size();
+	_batches.pop_front();
+}
+
+void ParticleRecord::onZChanged() {
+	if (_canvas) {
+		_canvas->setScriptZ(getZ(), getContext());
+	}
+}
+
+bool ParticleRecord::getField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "frequency") {
+		lua_pushnumber(L, _frequency);
+		return true;
+	} else if (key == "count") {
+		lua_pushinteger(L, _count);
+		return true;
+	}
+
+	return false;
+}
+
+bool ParticleRecord::setField(const Common::String &key) {
+	if (key == "frequency") {
+		_frequency = luaL_checknumber(getLua(), 3);
+		return true;
+	}
+
+	return false;
+}
+
+// The particles are drawn on a picture as large as the viewport, which is
+// started over every frame
+void ParticleRecord::updateGraphics() {
+	if (!isActive() || _count == 0) {
+		if (_canvas) {
+			_canvas->setVisible(false);
+		}
+
+		return;
+	}
+
+	if (!_canvas) {
+		const VIEW *viewport = GetEngineData(VIEW);
+		assert(viewport);
+
+		const Common::Rect bounds(viewport->screenPosition.width(), viewport->screenPosition.height());
+
+		_canvas = new RecordGraphic();
+		_canvas->init();
+		_canvas->_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
+		_canvas->moveTo(bounds);
+		_canvas->setScriptZ(getZ(), getContext());
+		_canvas->registerGraphics();
+
+		// Reading the image is quicker in the format of the picture
+		_image.convertToInPlace(_canvas->_drawSurface.format);
+	}
+
+	_canvas->_drawSurface.clear(0);
+
+	const ScriptMatrix transform = getTransform();
+	for (uint i = 0; i < _count; ++i) {
+		drawParticle(_particles[(_first + i) % _particles.size()], transform);
+	}
+
+	_canvas->setVisible(true);
+	_canvas->setNeedsRedraw(true);
+}
+
+// Where a corner of a particle ends up in the viewport. Returns false when it is
+// behind the camera.
+static bool projectCorner(double x, double y, const ScriptMatrix &matrix, int width, int height, double &outX, double &outY) {
+	double point[4];
+	for (int i = 0; i < 4; ++i) {
+		point[i] = x * matrix.m[0][i] + y * matrix.m[1][i] + matrix.m[3][i];
+	}
+
+	if (point[3] <= 0.0) {
+		return false;
+	}
+
+	outX = (point[0] / point[3] + 1.0) * 0.5 * width;
+	outY = (1.0 - point[1] / point[3]) * 0.5 * height;
+	return point[2] >= 0.0 && point[2] <= point[3];
+}
+
+// A particle is a square of a part of the image, sized, turned and moved into
+// place, then seen through the projection
+void ParticleRecord::drawParticle(const Particle &particle, const ScriptMatrix &transform) {
+	if (particle.width <= 0.0 || particle.height <= 0.0 || (particle.color >> 24) == 0) {
+		return;
+	}
+
+	ScriptMatrix place;
+	const double c = cos(particle.angle);
+	const double s = sin(particle.angle);
+	const double width = particle.width * particle.scale;
+	const double height = particle.height * particle.scale;
+	place.m[0][0] = width * c;
+	place.m[0][1] = width * s;
+	place.m[1][0] = -height * s;
+	place.m[1][1] = height * c;
+	place.m[3][0] = particle.position[0];
+	place.m[3][1] = particle.position[1];
+	place.m[3][2] = particle.position[2];
+
+	const ScriptMatrix matrix = place * transform * _perspective;
+
+	Graphics::ManagedSurface &target = _canvas->_drawSurface;
+
+	// The top left, top right and bottom left corners
+	double x0, y0, x1, y1, x2, y2;
+	if (!projectCorner(-0.5, 0.5, matrix, target.w, target.h, x0, y0) ||
+			!projectCorner(0.5, 0.5, matrix, target.w, target.h, x1, y1) ||
+			!projectCorner(-0.5, -0.5, matrix, target.w, target.h, x2, y2)) {
+		return;
+	}
+
+	// The quad stays a parallelogram, as all of it is at the same depth
+	const double ax = x1 - x0;
+	const double ay = y1 - y0;
+	const double bx = x2 - x0;
+	const double by = y2 - y0;
+	const double determinant = ax * by - ay * bx;
+	if (fabs(determinant) < 1e-9) {
+		return;
+	}
+
+	const double x3 = x1 + bx;
+	const double y3 = y1 + by;
+	const int minX = MAX<int>(0, (int)floor(MIN(MIN(x0, x1), MIN(x2, x3))));
+	const int maxX = MIN<int>(target.w - 1, (int)ceil(MAX(MAX(x0, x1), MAX(x2, x3))));
+	const int minY = MAX<int>(0, (int)floor(MIN(MIN(y0, y1), MIN(y2, y3))));
+	const int maxY = MIN<int>(target.h - 1, (int)ceil(MAX(MAX(y0, y1), MAX(y2, y3))));
+
+	const Graphics::PixelFormat &format = target.format;
+	const uint32 colorA = particle.color >> 24;
+	const uint32 colorR = (particle.color >> 16) & 0xFF;
+	const uint32 colorG = (particle.color >> 8) & 0xFF;
+	const uint32 colorB = particle.color & 0xFF;
+
+	for (int y = minY; y <= maxY; ++y) {
+		for (int x = minX; x <= maxX; ++x) {
+			// Where the middle of the pixel is on the square, from 0 to 1 across
+			const double px = x + 0.5 - x0;
+			const double py = y + 0.5 - y0;
+			const double across = (px * by - py * bx) / determinant;
+			const double down = (ax * py - ay * px) / determinant;
+			if (across < 0.0 || across >= 1.0 || down < 0.0 || down >= 1.0) {
+				continue;
+			}
+
+			const int u = CLIP<int>((int)(lerp(particle.left, particle.right, across) * _image.w), 0, _image.w - 1);
+			const int v = CLIP<int>((int)(lerp(particle.top, particle.bottom, down) * _image.h), 0, _image.h - 1);
+
+			uint8 a, r, g, b;
+			format.colorToARGB(_image.getPixel(u, v), a, r, g, b);
+
+			const uint32 alpha = a * colorA / 255;
+			if (alpha == 0) {
+				continue;
+			}
+
+			uint8 da, dr, dg, db;
+			format.colorToARGB(target.getPixel(x, y), da, dr, dg, db);
+
+			const uint32 sr = r * colorR / 255;
+			const uint32 sg = g * colorG / 255;
+			const uint32 sb = b * colorB / 255;
+			const uint32 outA = alpha + da * (255 - alpha) / 255;
+			const uint32 outR = (sr * alpha + dr * (255 - alpha)) / 255;
+			const uint32 outG = (sg * alpha + dg * (255 - alpha)) / 255;
+			const uint32 outB = (sb * alpha + db * (255 - alpha)) / 255;
+			target.setPixel(x, y, format.ARGBToColor(outA, outR, outG, outB));
+		}
+	}
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/particles.h b/engines/nancy/script/particles.h
new file mode 100644
index 00000000000..dfd7d5b935b
--- /dev/null
+++ b/engines/nancy/script/particles.h
@@ -0,0 +1,126 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_SCRIPT_PARTICLES_H
+#define NANCY_SCRIPT_PARTICLES_H
+
+#include "common/array.h"
+#include "common/list.h"
+
+#include "graphics/managed_surface.h"
+
+#include "engines/nancy/script/record.h"
+#include "engines/nancy/script/records.h"
+
+namespace Nancy {
+namespace Script {
+
+class ParticleStage;
+
+// One of the particles of a ParticleRecord. Speeds and forces are per step of
+// the simulation, which takes 30 steps a second.
+struct Particle {
+	double position[3] = { 0.0, 0.0, 0.0 };
+	double velocity[3] = { 0.0, 0.0, 0.0 };
+	double acceleration[3] = { 0.0, 0.0, 0.0 };
+	double life = 0.0;			// from 0 when made to 1 when gone
+	double angle = 0.0;
+	double scale = 1.0;
+	double width = 0.0;			// of the part of the image it shows
+	double height = 0.0;
+	double left = 0.0;			// the part of the image, from 0 to 1
+	double right = 0.0;
+	double top = 0.0;
+	double bottom = 0.0;
+	uint32 color = 0;			// 0xAARRGGBB
+};
+
+// Nancy21's falling snow. A Particle record keeps making particles out of parts
+// of an image, which it moves along and draws until their life is over:
+//
+//   AR:Particle({ ovl = "Flakes_OVL", frequency = 50, life = 6, perspective = ...,
+//                 stages = { { name = "velocity", minSpeed = 300, ... }, ... } })
+//
+// The stages decide what happens to the particles: where they are made, how
+// fast they go, what pulls at them, how they fade. A stage with "init" set picks
+// what it gives every particle once, when the record is made, instead of every
+// time a particle is made. The particles go through the "perspective"
+// projection, so they get smaller further off.
+class ParticleRecord : public Record {
+public:
+	ParticleRecord(SceneContext &context) : Record(context, "ND.ParticleAR") {}
+	~ParticleRecord() override;
+
+	void updateGraphics() override;
+
+	// For the stages
+	Particle &getParticle(uint index) { return _particles[index]; }
+	uint getMaxParticles() const { return _particles.size(); }
+	double getLife() const { return _life; }
+	const Graphics::ManagedSurface &getImage() const { return _image; }
+
+protected:
+	void readTable(int tableIndex) override;
+	void run(double sceneTime, double elapsedTime) override;
+	void onZChanged() override;
+	bool hasTransform() const override { return true; }
+	bool getField(const Common::String &key) override;
+	bool setField(const Common::String &key) override;
+
+private:
+	// The particles that were made at one time, which go at the same time
+	struct Batch {
+		double time;
+		uint count;
+	};
+
+	void addStage(ParticleStage *stage);
+	void simulate(double seconds);
+	void step();
+	void spawn(uint count);
+	void grow();
+	void dropOldestBatch();
+	void runStages(uint first, uint count, bool isSpawn);
+	void drawParticle(const Particle &particle, const ScriptMatrix &transform);
+
+	Graphics::ManagedSurface _image;
+	Common::Array<Particle> _particles;	// used as a ring, which grows when full
+	uint _first = 0;
+	uint _count = 0;
+	Common::List<Batch> _batches;
+	Common::Array<ParticleStage *> _stages;
+
+	double _frequency = 0.0;	// particles a second
+	double _life = 0.0;			// seconds
+	ScriptMatrix _perspective;
+
+	double _stepTime = 0.0;
+	int _numSteps = 0;
+	double _spawnTime = 0.0;
+	int _numSpawned = 0;
+
+	RecordGraphic *_canvas = nullptr;
+};
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_PARTICLES_H
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index 3b3bae53a3c..524e5003c79 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -36,6 +36,7 @@
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/convo.h"
 #include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/particles.h"
 #include "engines/nancy/script/records.h"
 #include "engines/nancy/script/ref.h"
 #include "engines/nancy/script/scenecontext.h"
@@ -98,8 +99,9 @@ Record *makeRecord(RecordKind kind, SceneContext &context) {
 		return new ConvoRecord(context, false);
 	case kRecordText:
 		return new TextRecord(context);
+	case kRecordParticle:
+		return new ParticleRecord(context);
 	default:
-		// TODO: The Particle record
 		return nullptr;
 	}
 }


Commit: ffc2cfc4e3eb34fd72cadb8e009b1a8761de1ae9
    https://github.com/scummvm/scummvm/commit/ffc2cfc4e3eb34fd72cadb8e009b1a8761de1ae9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:07+03:00

Commit Message:
NANCY: NANCY19: Add console commands for the scripted scenes

- list_records: lists the records of the running streams, with their
  names, states and z. With a stream given, it also shows the fields
  that the scripts gave them, to see what a scene is doing.
- script_export: writes a compiled Lua script to a file, so it can be
  decompiled, e.g. with unluac.
- load_scene: works in the scripted games, whose scenes are scripts
  rather than IFF files.

Changed paths:
    engines/nancy/console.cpp
    engines/nancy/console.h
    engines/nancy/script/record.cpp
    engines/nancy/script/record.h
    engines/nancy/script/scenecontext.cpp
    engines/nancy/script/scenecontext.h
    engines/nancy/state/scene.h
    engines/nancy/state/scenestream.h


diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index cbc9c695800..ebe120857f6 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -43,6 +43,11 @@
 
 #include "engines/nancy/action/actionrecord.h"
 
+#ifdef ENABLE_NANCY_V4
+#include "engines/nancy/script/record.h"
+#include "engines/nancy/script/scenecontext.h"
+#endif
+
 namespace Nancy {
 
 NancyConsole::NancyConsole() : GUI::Debugger() {
@@ -61,6 +66,10 @@ NancyConsole::NancyConsole() : GUI::Debugger() {
 	registerCmd("load_scene", WRAP_METHOD(NancyConsole, Cmd_loadScene));
 	registerCmd("scene_id", WRAP_METHOD(NancyConsole, Cmd_sceneID));
 	registerCmd("list_actionrecords", WRAP_METHOD(NancyConsole, Cmd_listActionRecords));
+#ifdef ENABLE_NANCY_V4
+	registerCmd("list_records", WRAP_METHOD(NancyConsole, Cmd_listRecords));
+	registerCmd("script_export", WRAP_METHOD(NancyConsole, Cmd_scriptExport));
+#endif
 	registerCmd("actionrecord_export", WRAP_METHOD(NancyConsole, Cmd_actionRecordExport));
 	registerCmd("scan_ar_type", WRAP_METHOD(NancyConsole, Cmd_scanForActionRecordType));
 	registerCmd("list_includes", WRAP_METHOD(NancyConsole, Cmd_listIncludes));
@@ -457,18 +466,23 @@ bool NancyConsole::Cmd_loadScene(int argc, const char **argv) {
 		return true;
 	}
 
-	Common::Path sceneName(Common::String::format("S%s", argv[1]));
-	IFF *iff = g_nancy->_resource->loadIFF(sceneName);
-	if (!iff) {
-		debugPrintf("Invalid scene S%s\n", argv[1]);
-		return true;
+	// Nancy19 and later scenes are scripts, and one that can't be loaded leaves
+	// the player where they are
+	if (g_nancy->getGameType() < kGameTypeNancy19) {
+		Common::Path sceneName(Common::String::format("S%s", argv[1]));
+		IFF *iff = g_nancy->_resource->loadIFF(sceneName);
+		if (!iff) {
+			debugPrintf("Invalid scene S%s\n", argv[1]);
+			return true;
+		}
+
+		delete iff;
 	}
 
 	SceneChangeDescription scene;
 	scene.sceneID = (uint16)atoi(argv[1]);
 	NancySceneState.changeScene(scene);
 	NancySceneState.setState(State::Scene::kLoad);
-	delete iff;
 	return cmdExit(0, nullptr);
 }
 
@@ -692,6 +706,137 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
 	}
 }
 
+#ifdef ENABLE_NANCY_V4
+// The records of the scripted scenes: those of every stream, or with the fields
+// the scripts gave them, those of one stream or one record of it
+bool NancyConsole::Cmd_listRecords(int argc, const char **argv) {
+	if (argc > 3) {
+		debugPrintf("Lists the records of the scene scripts, in Nancy19 and later.\n");
+		debugPrintf("Usage: %s [stream] [record name or kind]\n", argv[0]);
+		debugPrintf("Without a stream, the records of every stream are listed; with one, also their fields.\n");
+		debugPrintf("The main scene is the stream %s.\n", State::SceneStreamManager::getMainStreamName());
+		return true;
+	}
+
+	if (g_nancy->getGameType() < kGameTypeNancy19) {
+		debugPrintf("This game has action records; use list_actionrecords\n");
+		return true;
+	}
+
+	if (g_nancy->getState() != NancyState::kScene) {
+		debugPrintf("Not in the kScene state\n");
+		return true;
+	}
+
+	State::SceneStreamManager &streams = NancySceneState.getStreams();
+	Common::Array<Common::String> streamNames;
+	if (argc >= 2) {
+		streamNames.push_back(argv[1]);
+	} else {
+		streams.getStreamNames(streamNames, true);
+	}
+
+	const bool showsFields = argc >= 2;
+	const Common::String wantedRecord = argc == 3 ? argv[2] : "";
+
+	for (const Common::String &streamName : streamNames) {
+		Script::SceneContext *context = nullptr;
+		if (streamName.equalsIgnoreCase(State::SceneStreamManager::getMainStreamName())) {
+			context = NancySceneState.getScriptScene();
+		} else {
+			State::SceneStream *stream = streams.find(streamName);
+			context = stream ? stream->getScriptScene() : nullptr;
+		}
+
+		if (!context) {
+			debugPrintf("No running stream %s\n", streamName.c_str());
+			continue;
+		}
+
+		const Common::Array<Script::Record *> &records = context->getRecords();
+		debugPrintf("%s, scene %s: %u records\n", streamName.c_str(), context->getSceneName().c_str(), records.size());
+
+		for (uint i = 0; i < records.size(); ++i) {
+			Script::Record *record = records[i];
+			const Common::String globalName = context->findGlobalName(record);
+
+			// "ND.MovieAR" is a Movie
+			Common::String typeName = record->getTypeName();
+			if (typeName.hasPrefix("ND.")) {
+				typeName.erase(0, 3);
+			}
+
+			if (typeName.hasSuffix("AR")) {
+				typeName.erase(typeName.size() - 2);
+			}
+
+			// A record is asked for by its name, or by its kind
+			if (!wantedRecord.empty() && !globalName.equalsIgnoreCase(wantedRecord) && !typeName.equalsIgnoreCase(wantedRecord)) {
+				continue;
+			}
+
+			const char *state = "inactive";
+			if (record->isDone()) {
+				state = "done";
+			} else if (record->isActive()) {
+				state = record->hasStarted() ? "running" : "active";
+			}
+
+			debugPrintf("  %u: %s %s (%s, z %g)\n", i, typeName.c_str(),
+				globalName.empty() ? "-" : globalName.c_str(), state, record->getZ());
+
+			if (showsFields) {
+				Common::Array<Common::String> fields;
+				record->describeFields(fields);
+				for (const Common::String &field : fields) {
+					debugPrintf("      %s\n", field.c_str());
+				}
+			}
+		}
+
+		debugPrintf("\n");
+	}
+
+	return true;
+}
+
+// Writes a script as it is, compiled, to a file
+bool NancyConsole::Cmd_scriptExport(int argc, const char **argv) {
+	if (argc != 2) {
+		debugPrintf("Exports a script of Nancy19 and later\n");
+		debugPrintf("Usage: %s <script name or scene number>\n", argv[0]);
+		debugPrintf("The scripts are compiled Lua 5.1, which a decompiler like unluac turns into Lua code.\n");
+		return true;
+	}
+
+	// Scenes are named after their number
+	Common::String name = argv[1];
+	if (Common::isDigit(name.firstChar())) {
+		name = "s" + name;
+	}
+
+	Common::SeekableReadStream *script = g_nancy->_resource->loadScript(Common::Path(name));
+	if (!script) {
+		debugPrintf("Failed to find script '%s'\n", name.c_str());
+		return true;
+	}
+
+	Common::String filename = Common::String::format("%s_%s.luac", g_nancy->getGameId(), name.c_str());
+	Common::DumpFile dumpfile;
+	if (!dumpfile.open(Common::Path(filename))) {
+		debugPrintf("Failed to write '%s'\n", filename.c_str());
+		delete script;
+		return true;
+	}
+
+	dumpfile.writeStream(script);
+	dumpfile.close();
+	delete script;
+	debugPrintf("Wrote %s\n", filename.c_str());
+	return true;
+}
+#endif
+
 bool NancyConsole::Cmd_listActionRecords(int argc, const char **argv) {
 	using namespace Action;
 
diff --git a/engines/nancy/console.h b/engines/nancy/console.h
index 3f5b3d027b0..ccea1b1229e 100644
--- a/engines/nancy/console.h
+++ b/engines/nancy/console.h
@@ -56,6 +56,10 @@ private:
 	bool Cmd_loadScene(int argc, const char **argv);
 	bool Cmd_sceneID(int argc, const char **argv);
 	bool Cmd_listActionRecords(int argc, const char **argv);
+#ifdef ENABLE_NANCY_V4
+	bool Cmd_listRecords(int argc, const char **argv);
+	bool Cmd_scriptExport(int argc, const char **argv);
+#endif
 	bool Cmd_actionRecordExport(int argc, const char **argv);
 	bool Cmd_scanForActionRecordType(int argc, const char **argv);
 	bool Cmd_listIncludes(int argc, const char **argv);
diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index 5c89bc49ee6..328b9523112 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -19,6 +19,8 @@
  *
  */
 
+#include "common/algorithm.h"
+
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/graphnode.h"
 #include "engines/nancy/script/luabind.h"
@@ -26,6 +28,7 @@
 #include "engines/nancy/script/record.h"
 #include "engines/nancy/script/records.h"
 #include "engines/nancy/script/scenecontext.h"
+#include "engines/nancy/script/types.h"
 
 namespace Nancy {
 namespace Script {
@@ -453,7 +456,7 @@ bool Record::sendKeyEvent(RecordEvent event, Common::KeyCode key) {
 	return isHandled;
 }
 
-Record *checkRecord(lua_State *L, int index) {
+Record *toRecord(lua_State *L, int index) {
 	Record **object = (Record **)lua_touserdata(L, index);
 	if (object && lua_getmetatable(L, index)) {
 		lua_getfield(L, -1, kRecordMarker);
@@ -465,10 +468,111 @@ Record *checkRecord(lua_State *L, int index) {
 		}
 	}
 
-	luaL_typerror(L, index, "AR");
 	return nullptr;
 }
 
+Record *checkRecord(lua_State *L, int index) {
+	Record *record = toRecord(L, index);
+	if (!record) {
+		luaL_typerror(L, index, "AR");
+	}
+
+	return record;
+}
+
+// Only the scale and the move, which is what scripts mostly use
+static Common::String describeMatrix(const ScriptMatrix &matrix) {
+	return Common::String::format("Matrix(scale %g %g, move %g %g %g)",
+		matrix.m[0][0], matrix.m[1][1], matrix.m[3][0], matrix.m[3][1], matrix.m[3][2]);
+}
+
+Common::String describeValue(lua_State *L, int index) {
+	switch (lua_type(L, index)) {
+	case LUA_TNIL:
+		return "nil";
+	case LUA_TBOOLEAN:
+		return lua_toboolean(L, index) ? "true" : "false";
+	case LUA_TNUMBER:
+		return Common::String::format("%g", lua_tonumber(L, index));
+	case LUA_TSTRING:
+		return Common::String::format("\"%s\"", lua_tostring(L, index));
+	case LUA_TFUNCTION:
+		return "function";
+	case LUA_TTABLE:
+		return Common::String::format("table (%d)", (int)lua_objlen(L, index));
+	default:
+		break;
+	}
+
+	Record *record = toRecord(L, index);
+	if (record) {
+		return Common::String::format("AR %s", record->getTypeName());
+	}
+
+	const ScriptRect *rect = testRect(L, index);
+	if (rect) {
+		return Common::String::format("Rect(%d, %d, %d, %d)", rect->left, rect->top, rect->right, rect->bottom);
+	}
+
+	const ScriptVector *vector = testVector(L, index);
+	if (vector) {
+		Common::String text = "Vector(";
+		for (int i = 0; i < vector->size; ++i) {
+			text += Common::String::format(i ? ", %g" : "%g", vector->v[i]);
+		}
+
+		return text + ")";
+	}
+
+	const ScriptMatrix *matrix = testMatrix(L, index);
+	if (matrix) {
+		return describeMatrix(*matrix);
+	}
+
+	const uint32 *color = testColor(L, index);
+	if (color) {
+		return Common::String::format("Color(%u, %u, %u, %u)", *color >> 24, (*color >> 16) & 0xFF, (*color >> 8) & 0xFF, *color & 0xFF);
+	}
+
+	// The types of the scripts that print themselves, like Rect
+	if (luaL_callmeta(L, index, "__tostring")) {
+		const Common::String text = lua_isstring(L, -1) ? lua_tostring(L, -1) : "?";
+		lua_pop(L, 1);
+		return text;
+	}
+
+	return lua_typename(L, lua_type(L, index));
+}
+
+void Record::describeFields(Common::Array<Common::String> &lines) {
+	lua_State *L = getLua();
+	const int top = lua_gettop(L);
+
+	// The kind of record can have a newer value than the one it was made with
+	pushStorage();
+	lua_pushnil(L);
+	while (lua_next(L, -2)) {
+		if (lua_type(L, -2) == LUA_TSTRING) {
+			const Common::String key = lua_tostring(L, -2);
+			const bool hasNewerValue = getField(key);
+			lines.push_back(Common::String::format("%s = %s", key.c_str(), describeValue(L, -1).c_str()));
+			if (hasNewerValue) {
+				lua_pop(L, 1);
+			}
+		}
+
+		lua_pop(L, 1);
+	}
+
+	lua_settop(L, top);
+
+	if (hasTransform()) {
+		lines.push_back("world transform = " + describeMatrix(getTransform()));
+	}
+
+	Common::sort(lines.begin(), lines.end());
+}
+
 static int recordIndex(lua_State *L) {
 	return checkRecord(L, 1)->getScriptField();
 }
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index 2f6e0ca9ef2..eecc2f9bb96 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -174,6 +174,9 @@ public:
 	// whether the record was saying something.
 	virtual bool skipSpeech() { return false; }
 
+	// For the debugger: what the scripts gave the record, as "name = value"
+	void describeFields(Common::Array<Common::String> &lines);
+
 protected:
 	lua_State *getLua() const;
 
@@ -241,6 +244,12 @@ void registerRecordAPI(lua_State *L);
 // The record a script handed in, or a script error
 Record *checkRecord(lua_State *L, int index);
 
+// The record a value stands for, or null when it is something else
+Record *toRecord(lua_State *L, int index);
+
+// For the debugger: a value of the scripts as text
+Common::String describeValue(lua_State *L, int index);
+
 } // End of namespace Script
 } // End of namespace Nancy
 
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index 40c92da1c63..880e0ed79b9 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -142,6 +142,38 @@ bool SceneContext::load() {
 	return true;
 }
 
+Common::String SceneContext::findGlobalName(const Record *record) const {
+	lua_State *L = getLua();
+	const int top = lua_gettop(L);
+	Common::String name;
+
+	lua_pushnil(L);
+	while (name.empty() && lua_next(L, LUA_GLOBALSINDEX)) {
+		if (lua_type(L, -2) == LUA_TSTRING) {
+			if (toRecord(L, -1) == record) {
+				name = lua_tostring(L, -2);
+			} else if (lua_istable(L, -1)) {
+				lua_pushnil(L);
+				while (name.empty() && lua_next(L, -2)) {
+					if (toRecord(L, -1) == record) {
+						const Common::String key = lua_type(L, -2) == LUA_TSTRING ?
+							Common::String::format("\"%s\"", lua_tostring(L, -2)) :
+							Common::String::format("%g", lua_tonumber(L, -2));
+						name = Common::String::format("%s[%s]", lua_tostring(L, -4), key.c_str());
+					}
+
+					lua_pop(L, 1);
+				}
+			}
+		}
+
+		lua_pop(L, 1);
+	}
+
+	lua_settop(L, top);
+	return name;
+}
+
 bool SceneContext::include(const Common::String &name) {
 	return _lua->runScript(name);
 }
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index 0f2500fcdeb..6ed8543227c 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -92,6 +92,10 @@ public:
 	bool hasRecords() const { return !_records.empty() || !_newRecords.empty(); }
 	const Common::Array<Record *> &getRecords() const { return _records; }
 
+	// For the debugger: the global a script keeps the record in, like "doorNAV",
+	// or an entry of a table in one, like "laptopSFX[2]"; empty for none
+	Common::String findGlobalName(const Record *record) const;
+
 	// The record that describes the scene, which is always the first one
 	SummaryRecord *getSummary() const { return _summary; }
 
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index c59e88a4917..8f0bffa0e94 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -144,6 +144,11 @@ public:
 	// Nancy17 streams: scenes that run next to the one the player walks around in,
 	// which is the main stream
 	SceneStreamManager &getStreams() { return _streams; }
+
+#ifdef ENABLE_NANCY_V4
+	// Nancy19: the script of the main scene; null while there is none
+	Script::SceneContext *getScriptScene() const { return _scriptScene; }
+#endif
 	void addSceneVisit(uint16 sceneID) { _flags.sceneCounts.getOrCreateVal(sceneID)++; }
 	void pushScene(int16 itemID = -1);
 
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
index d8327858bda..4fabf53ea19 100644
--- a/engines/nancy/state/scenestream.h
+++ b/engines/nancy/state/scenestream.h
@@ -118,6 +118,9 @@ public:
 	const Common::String &getName() const { return _name; }
 	const StreamFlags &getFlags() const { return _flags; }
 	int32 getZOffset() const { return _zOffset; }
+#ifdef ENABLE_NANCY_V4
+	Script::SceneContext *getScriptScene() const { return _scriptScene; }
+#endif
 	const Common::String &getSceneName() const { return _sceneName; }
 	SceneChangeDescription &getSceneInfo() { return _sceneInfo; }
 	Action::ActionManager &getActionManager() { return _actionManager; }
@@ -200,6 +203,9 @@ public:
 	// stream is left out unless asked for.
 	void getStreamNames(Common::Array<Common::String> &names, bool withUI) const;
 
+	// The running stream of that name; null when there is none
+	SceneStream *find(const Common::String &name) const;
+
 	static const char *getMainStreamName();
 	static const char *getUIStreamName();
 
@@ -233,7 +239,6 @@ public:
 	void restore();
 
 private:
-	SceneStream *find(const Common::String &name) const;
 	void endChildren(const Common::Array<SceneStream::Child> &children);
 
 	// Every stream in the order they run and get input in. The main stream is


Commit: 36f628580765468dbde1fcab340b7b2ac5c57ce0
    https://github.com/scummvm/scummvm/commit/36f628580765468dbde1fcab340b7b2ac5c57ce0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:08+03:00

Commit Message:
NANCY: NANCY21: Change to unstable - the game now starts

Changed paths:
    engines/nancy/detection_tables.h


diff --git a/engines/nancy/detection_tables.h b/engines/nancy/detection_tables.h
index fc46b98c596..cb5f899bbd7 100644
--- a/engines/nancy/detection_tables.h
+++ b/engines/nancy/detection_tables.h
@@ -1064,7 +1064,7 @@ static const NancyGameDescription gameDescriptions[] = {
 			AD_ENTRY1s("ciftree.dat", "25249fabe11f2b865203284e798b8c09", 58348182),
 			Common::EN_ANY,
 			Common::kPlatformWindows,
-			ADGF_UNSUPPORTED | ADGF_DROPPLATFORM,
+			ADGF_UNSTABLE | ADGF_DROPPLATFORM,
 			NANCY8_GUIOPTIONS
 		},
 		kGameTypeNancy21


Commit: 5c7e671d2a2cbf0eac12a16c1f890453ccf942bb
    https://github.com/scummvm/scummvm/commit/5c7e671d2a2cbf0eac12a16c1f890453ccf942bb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:10+03:00

Commit Message:
NANCY: NANCY16: Note DirectX SDK parts followed by NDUI and script math

The NDUI UI of Nancy16 and later follows the DXUT widgets that the
original's Ndui.dll is built on, and the math of the scripts of
Nancy19 and later follows D3DX.

Changed paths:
    engines/nancy/script/mathtypes.h
    engines/nancy/ui/ndui/common.h


diff --git a/engines/nancy/script/mathtypes.h b/engines/nancy/script/mathtypes.h
index 796ad861f12..37a1a059ba1 100644
--- a/engines/nancy/script/mathtypes.h
+++ b/engines/nancy/script/mathtypes.h
@@ -32,6 +32,9 @@ namespace Script {
 // The vectors and matrices of the scripts. Scripts move what they draw around
 // with matrices: every record that shows something has a "transform", and the
 // puzzles and the map of Nancy19 are built on changing those.
+// The original does its math with D3DX, the math library of the DirectX SDK, and
+// the functions here follow its conventions and formulas, e.g. Matrix:Perspective
+// is D3DXMatrixPerspectiveFovLH.
 
 // Two to four numbers
 struct ScriptVector {
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index cc4f8f20e87..920ae990257 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -36,6 +36,8 @@ namespace NDUI {
 // set of panels, each holding a list of controls, described by NDUI chunks spread
 // over one IFF per widget. Controls react to the player by firing events, each of
 // which performs an action on a named panel or control, or hands it to the engine.
+// The original's toolkit, Ndui.dll, is built on the DXUT widgets of the DirectX SDK
+// (CDXUTDialog and its controls), which the panels and controls here follow.
 
 enum ControlType {
 	kControlButton		= 1,


Commit: 997822d8d93e09b3c852b48d9df13927571820d4
    https://github.com/scummvm/scummvm/commit/997822d8d93e09b3c852b48d9df13927571820d4
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:14+03:00

Commit Message:
NANCY: NANCY16: Fix shadowing warnings in SnowmobilePuzzle

The zone types of the snowmobile rides are now prefixed with kSnowZone,
as kZoneOverlay and kZoneBoundary clashed with the ActionZone types

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


diff --git a/engines/nancy/action/puzzle/snowmobilepuzzle.cpp b/engines/nancy/action/puzzle/snowmobilepuzzle.cpp
index f5807dfc10f..617dd8bd419 100644
--- a/engines/nancy/action/puzzle/snowmobilepuzzle.cpp
+++ b/engines/nancy/action/puzzle/snowmobilepuzzle.cpp
@@ -67,9 +67,9 @@ void SnowmobilePuzzle::Zone::centerOn(const Math::Vector2d &pos) {
 	int32 dy = (int32)(pos.getY() - rect.center().y);
 	rect.translate(dx, dy);
 
-	if (type == kZoneOverlay || type == kZoneTiledOverlay) {
+	if (type == kSnowZoneOverlay || type == kSnowZoneTiledOverlay) {
 		dest.translate(dx, dy);
-	} else if (type == kZonePointer) {
+	} else if (type == kSnowZonePointer) {
 		target = Math::Vector2d(rect.center().x, rect.center().y);
 	}
 }
@@ -112,21 +112,21 @@ void SnowmobilePuzzle::readZone(Common::SeekableReadStream &stream, Zone &zone)
 	zone.sound.readData(stream);
 
 	switch (zone.type) {
-	case kZoneChangeScene:
+	case kSnowZoneChangeScene:
 		zone.sceneID = stream.readUint16LE();
 		stream.skip(1);
 		break;
-	case kZoneFlag:
+	case kSnowZoneFlag:
 		zone.flagLabel = stream.readSint16LE();
 		zone.flagValue = stream.readByte();
 		break;
-	case kZoneDamage:
+	case kSnowZoneDamage:
 		zone.minDamage = stream.readSint32LE();
 		zone.maxDamage = stream.readSint32LE();
 		break;
-	case kZoneOverlay:
-	case kZoneTiledOverlay:
-	case kZonePointer: {
+	case kSnowZoneOverlay:
+	case kSnowZoneTiledOverlay:
+	case kSnowZonePointer: {
 		Common::String imageName;
 		readFilename(stream, imageName);
 		zone.imageID = loadSheet(imageName);
@@ -144,9 +144,9 @@ void SnowmobilePuzzle::readZone(Common::SeekableReadStream &stream, Zone &zone)
 		zone.layer = stream.readSint32LE();
 		break;
 	}
-	case kZoneSound:
-	case kZoneBoundary:
-	case kZoneNoRandomZones:
+	case kSnowZoneSound:
+	case kSnowZoneBoundary:
+	case kSnowZoneNoRandomZones:
 		break;
 	default:
 		error("SnowmobilePuzzle: unhandled zone type %d", zone.type);
@@ -247,7 +247,7 @@ void SnowmobilePuzzle::readData(Common::SeekableReadStream &stream) {
 }
 
 void SnowmobilePuzzle::finalizeZone(Zone &zone) const {
-	if (zone.type != kZoneOverlay && zone.type != kZoneTiledOverlay && zone.type != kZonePointer) {
+	if (zone.type != kSnowZoneOverlay && zone.type != kSnowZoneTiledOverlay && zone.type != kSnowZonePointer) {
 		return;
 	}
 
@@ -304,7 +304,7 @@ void SnowmobilePuzzle::init() {
 
 		// The first pointer follows the chased snowmobile
 		for (uint i = 0; i < _numFixedZones; ++i) {
-			if (_zones[i].type == kZonePointer) {
+			if (_zones[i].type == kSnowZonePointer) {
 				_pointerZone = i;
 				_zones[i].centerOn(_chaserPos);
 				break;
@@ -395,7 +395,7 @@ void SnowmobilePuzzle::updateField() {
 bool SnowmobilePuzzle::isCellExcluded(int32 cellX, int32 cellY) const {
 	Common::Rect32 cell(cellX * _cellSize, cellY * _cellSize, (cellX + 1) * _cellSize, (cellY + 1) * _cellSize);
 	for (uint i = 0; i < _numFixedZones; ++i) {
-		if (_zones[i].type == kZoneNoRandomZones && _zones[i].rect.intersects(cell)) {
+		if (_zones[i].type == kSnowZoneNoRandomZones && _zones[i].rect.intersects(cell)) {
 			return true;
 		}
 	}
@@ -513,7 +513,7 @@ void SnowmobilePuzzle::move(double ticks) {
 bool SnowmobilePuzzle::isFree() const {
 	Common::Rect32 bounds = _car.getBounds();
 	for (const Zone &zone : _zones) {
-		if (zone.type == kZoneBoundary && zone.isActive && zone.rect.intersects(bounds)) {
+		if (zone.type == kSnowZoneBoundary && zone.isActive && zone.rect.intersects(bounds)) {
 			return false;
 		}
 	}
@@ -583,12 +583,12 @@ bool SnowmobilePuzzle::isInZone(const Zone &zone) const {
 	int32 y = (int32)_car.pos.getY();
 
 	switch (zone.type) {
-	case kZoneBoundary:
+	case kSnowZoneBoundary:
 		return true;
-	case kZonePointer:
+	case kSnowZonePointer:
 		// Shown while the snowmobile is away from what the arrow points at
 		return !zone.rect.contains(x, y);
-	case kZoneTiledOverlay:
+	case kSnowZoneTiledOverlay:
 		if (zone.hasImplicitRect) {
 			return y > zone.rect.top && y < zone.rect.bottom;
 		}
@@ -603,11 +603,11 @@ bool SnowmobilePuzzle::isInZone(const Zone &zone) const {
 void SnowmobilePuzzle::seedZones() {
 	for (Zone &zone : _zones) {
 		switch (zone.type) {
-		case kZoneChangeScene:
-		case kZoneSound:
-		case kZoneFlag:
-		case kZoneDamage:
-		case kZoneNoRandomZones:
+		case kSnowZoneChangeScene:
+		case kSnowZoneSound:
+		case kSnowZoneFlag:
+		case kSnowZoneDamage:
+		case kSnowZoneNoRandomZones:
 			zone.isActive = isInZone(zone);
 			break;
 		default:
@@ -631,13 +631,13 @@ void SnowmobilePuzzle::updateZones() {
 
 void SnowmobilePuzzle::enterZone(Zone &zone) {
 	switch (zone.type) {
-	case kZoneChangeScene:
+	case kSnowZoneChangeScene:
 		changeScene(zone.sceneID);
 		break;
-	case kZoneFlag:
+	case kSnowZoneFlag:
 		NancySceneState.setEventFlag(zone.flagLabel, zone.flagValue ? g_nancy->_true : g_nancy->_false);
 		break;
-	case kZoneDamage:
+	case kSnowZoneDamage:
 		_car.damage += zone.minDamage;
 		if (zone.maxDamage > zone.minDamage) {
 			_car.damage += g_nancy->_randomSource->getRandomNumber(zone.maxDamage - zone.minDamage - 1);
@@ -880,13 +880,13 @@ void SnowmobilePuzzle::drawZones(int32 layer) {
 		const Graphics::ManagedSurface &image = *_sheets[zone.imageID];
 
 		switch (zone.type) {
-		case kZoneOverlay:
+		case kSnowZoneOverlay:
 			if (zone.dest.intersects(view)) {
 				drawSprite(image, zone.src, zone.dest.left - view.left, zone.dest.top - view.top);
 			}
 
 			break;
-		case kZoneTiledOverlay: {
+		case kSnowZoneTiledOverlay: {
 			int32 step = zone.dest.width();
 			if (step <= 0) {
 				break;
@@ -903,7 +903,7 @@ void SnowmobilePuzzle::drawZones(int32 layer) {
 
 			break;
 		}
-		case kZonePointer: {
+		case kSnowZonePointer: {
 			Common::Rect dest(zone.dest.left, zone.dest.top, zone.dest.right, zone.dest.bottom);
 			drawRotated(image, zone.src, dest, angleOf(zone.target - _car.pos));
 			break;
diff --git a/engines/nancy/action/puzzle/snowmobilepuzzle.h b/engines/nancy/action/puzzle/snowmobilepuzzle.h
index 5f7f77ff0aa..c1873acc109 100644
--- a/engines/nancy/action/puzzle/snowmobilepuzzle.h
+++ b/engines/nancy/action/puzzle/snowmobilepuzzle.h
@@ -64,15 +64,15 @@ protected:
 	}
 
 	enum ZoneType {
-		kZoneChangeScene	= 0x00,
-		kZoneSound			= 0x05,	// only plays its sound
-		kZoneFlag			= 0x0b,
-		kZoneOverlay		= 0x0d,
-		kZoneBoundary		= 0x14,
-		kZoneDamage			= 0x15,
-		kZoneTiledOverlay	= 0x17,	// repeated across the whole width of the view
-		kZonePointer		= 0x18,	// arrow at a fixed place in the view, pointing at the zone
-		kZoneNoRandomZones	= 0x19
+		kSnowZoneChangeScene	= 0x00,
+		kSnowZoneSound			= 0x05,	// only plays its sound
+		kSnowZoneFlag			= 0x0b,
+		kSnowZoneOverlay		= 0x0d,
+		kSnowZoneBoundary		= 0x14,
+		kSnowZoneDamage			= 0x15,
+		kSnowZoneTiledOverlay	= 0x17,	// repeated across the whole width of the view
+		kSnowZonePointer		= 0x18,	// arrow at a fixed place in the view, pointing at the zone
+		kSnowZoneNoRandomZones	= 0x19
 	};
 
 	struct Zone {
@@ -84,23 +84,23 @@ protected:
 		byte conditionValue = 0;
 		RandomSoundBlock sound;		// played on entering
 
-		uint16 sceneID = kNoScene;	// kZoneChangeScene
+		uint16 sceneID = kNoScene;	// kSnowZoneChangeScene
 
-		int16 flagLabel = -1;		// kZoneFlag
+		int16 flagLabel = -1;		// kSnowZoneFlag
 		byte flagValue = 0;
 
-		int32 minDamage = 0;		// kZoneDamage
+		int32 minDamage = 0;		// kSnowZoneDamage
 		int32 maxDamage = 0;
 
 		// Overlays
 		uint imageID = 0;
 		Common::Rect32 src;
-		Common::Rect32 dest;				// on the field; in the view for kZonePointer
+		Common::Rect32 dest;				// on the field; in the view for kSnowZonePointer
 		int32 layer = 0;			// 0 is below the snowmobiles, 1 above
 		bool hasImplicitRect = false;
 
 		bool isActive = false;
-		Math::Vector2d target;				// kZonePointer
+		Math::Vector2d target;				// kSnowZonePointer
 		int32 cellX = 0;			// for zones from a random set
 		int32 cellY = 0;
 


Commit: 795062d75c99fca6836ebff5c469f8f6015801e8
    https://github.com/scummvm/scummvm/commit/795062d75c99fca6836ebff5c469f8f6015801e8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:17+03:00

Commit Message:
NANCY: NANCY10: Reset the inventory scrollbar when it is reopened

Reopening the inventory popup now puts the scroll thumb back at the
top, matching the first page of items that is shown

Fix #17417

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


diff --git a/engines/nancy/ui/taskbar/inventorypopup.cpp b/engines/nancy/ui/taskbar/inventorypopup.cpp
index 5e0e296c5cb..93b2beb1cd3 100644
--- a/engines/nancy/ui/taskbar/inventorypopup.cpp
+++ b/engines/nancy/ui/taskbar/inventorypopup.cpp
@@ -82,6 +82,9 @@ void InventoryPopup::open() {
 		return;
 
 	_currentPage = 0;
+	_scrollPos = 0.0f;
+	_scrollbarDragging = false;
+	_scrollbarHovered = false;
 
 	rebuildVisibleList();
 	refreshGrid();


Commit: ae8a75f6d9163acf8044d709d749ca6f10b296ca
    https://github.com/scummvm/scummvm/commit/ae8a75f6d9163acf8044d709d749ca6f10b296ca
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:20+03:00

Commit Message:
NANCY: Play the solve sound of TurningPuzzle without a solve animation

Puzzles without a solve animation (e.g. the flower box in Nancy10
scene 3250) now play their solve sound after the solve delay

Fix #17418

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


diff --git a/engines/nancy/action/puzzle/turningpuzzle.cpp b/engines/nancy/action/puzzle/turningpuzzle.cpp
index 97afbbd9c57..693966d9696 100644
--- a/engines/nancy/action/puzzle/turningpuzzle.cpp
+++ b/engines/nancy/action/puzzle/turningpuzzle.cpp
@@ -469,8 +469,7 @@ void TurningPuzzle::execute() {
 			} else if (_solveAnimate) {
 				_solveState = kWaitForAnimation;
 			} else {
-				_solveState = kWaitForSound;
-				_shouldSetSolveFlag = true;
+				_solveState = kWaitBeforeSound;
 			}
 			_objectCurrentlyTurning = -1;
 			_turnFrameID = 0;


Commit: 4745ae540901e2ab592e6bf3b40af88dc9c51063
    https://github.com/scummvm/scummvm/commit/4745ae540901e2ab592e6bf3b40af88dc9c51063
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:21+03:00

Commit Message:
NANCY: NANCY10: Keep the normal cursor over the active QuizPuzzle box

The text box being typed in can't be clicked, so it now shows the
normal cursor instead of the puzzle arrow

Fix #17393

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


diff --git a/engines/nancy/action/puzzle/quizpuzzle.cpp b/engines/nancy/action/puzzle/quizpuzzle.cpp
index 41702434d33..d067c88bcb7 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.cpp
+++ b/engines/nancy/action/puzzle/quizpuzzle.cpp
@@ -918,6 +918,9 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 	for (uint i = 0; i < _boxes.size() && !mouseOverHotspot; ++i) {
 		if (_boxes[i].correct && !isNancy15)
 			continue;
+		// From Nancy 10 the box being typed in is not clickable and keeps the normal cursor
+		if (i == _currentBox && g_nancy->getGameType() >= kGameTypeNancy10)
+			continue;
 		Common::Rect screenRect = NancySceneState.getViewport().convertViewportToScreen(_boxes[i].rect);
 		if (!screenRect.contains(input.mousePos))
 			continue;


Commit: 25fd4c6b0d157cb1b94ffc1c711ebea7974f90f6
    https://github.com/scummvm/scummvm/commit/25fd4c6b0d157cb1b94ffc1c711ebea7974f90f6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:24+03:00

Commit Message:
NANCY: Don't click when a script takes away an item

Taking an item out of the inventory is now silent, like in the
original. Only clicking an item in the inventory makes the sound.
Scripts that take away the held item or put an item into the hand,
e.g. the cranks of the chest in Nancy10 scene 2061, no longer click.
A conversation that takes away an item no longer puts it into the
player's hand either.

Fix #16999, #16793

Changed paths:
    engines/nancy/action/conversation.cpp
    engines/nancy/state/scene.cpp
    engines/nancy/ui/inventorybox.cpp
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/taskbar/inventorypopup.cpp


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 1b9d92105e2..1ceb487502b 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -926,7 +926,7 @@ void ConversationSound::ConversationFlag::set() const {
 		if (flag.flag == g_nancy->_true) {
 			NancySceneState.addItemToInventory(flag.label);
 		} else {
-			NancySceneState.removeItemFromInventory(flag.label);
+			NancySceneState.removeItemFromInventory(flag.label, false);
 		}
 
 		break;
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 0deec356802..5c3a42ae019 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -583,12 +583,12 @@ void Scene::removeItemFromInventory(int16 id, bool pickUp) {
 	if (_flags.items[id] == g_nancy->_true || getHeldItem() == id) {
 		_flags.items[id] = g_nancy->_false;
 
+		// Unlike adding an item, this is silent: the inventory UI plays the
+		// click itself when the player takes an item out
 		if (pickUp) {
 			setHeldItem(id);
-			g_nancy->_sound->playSound("BUOK");
 		} else if (getHeldItem() == id) {
 			setHeldItem(-1);
-			g_nancy->_sound->playSound("BUOK");
 		}
 
 		if (_ndui) {
diff --git a/engines/nancy/ui/inventorybox.cpp b/engines/nancy/ui/inventorybox.cpp
index 7bf367fddf5..fcc7dbe3899 100644
--- a/engines/nancy/ui/inventorybox.cpp
+++ b/engines/nancy/ui/inventorybox.cpp
@@ -134,7 +134,11 @@ void InventoryBox::handleInput(NancyInput &input) {
 
 					if (!disabled) {
 						// Item is not disabled
-						NancySceneState.removeItemFromInventory(itemID, item.keepItem != kInvItemNewSceneView);
+						bool pickUp = item.keepItem != kInvItemNewSceneView;
+						NancySceneState.removeItemFromInventory(itemID, pickUp);
+						if (pickUp) {
+							g_nancy->_sound->playSound("BUOK");
+						}
 						hoveredHotspot = -1;
 
 						if (item.keepItem == kInvItemNewSceneView) {
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index d2619d17bca..24d591810fc 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -187,6 +187,7 @@ void Manager::onInventoryClicked(int16 itemID) {
 		break;
 	default:
 		NancySceneState.removeItemFromInventory(itemID, true);
+		g_nancy->_sound->playSound("BUOK");
 		break;
 	}
 }
@@ -253,6 +254,7 @@ void Manager::onItemStreamClicked(const InvButton &button) {
 		break;
 	default:
 		NancySceneState.removeItemFromInventory(itemID, true);
+		g_nancy->_sound->playSound("BUOK");
 		break;
 	}
 }
diff --git a/engines/nancy/ui/taskbar/inventorypopup.cpp b/engines/nancy/ui/taskbar/inventorypopup.cpp
index 93b2beb1cd3..4761ba836c5 100644
--- a/engines/nancy/ui/taskbar/inventorypopup.cpp
+++ b/engines/nancy/ui/taskbar/inventorypopup.cpp
@@ -550,11 +550,11 @@ void InventoryPopup::handleInput(NancyInput &input) {
 
 			const bool pickUp = item.keepItem != kInvItemNewSceneView;
 			NancySceneState.removeItemFromInventory(itemID, pickUp);
+			g_nancy->_sound->playSound("BUOK");
 
 			if (item.keepItem == kInvItemNewSceneView) {
 				// Close-up view: stash the item and warp to its scene, which
 				// dismisses the popup.
-				g_nancy->_sound->playSound("BUOK");
 				NancySceneState.pushScene(itemID);
 				SceneChangeDescription sceneChange;
 				sceneChange.sceneID = item.sceneID;


Commit: 4393122b450cc66c064c83ce60a0f31ad2af5abc
    https://github.com/scummvm/scummvm/commit/4393122b450cc66c064c83ce60a0f31ad2af5abc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:26+03:00

Commit Message:
NANCY: Keep inventory sound overrides across scene changes

The overrides installed by InventorySoundOverride were cleared on every
scene load, which the original doesn't do. They are now kept in a
PuzzleData chunk, so they persist across scene changes and saves.

Fix #16972

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


diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index b18a60f77ea..d232d524b4d 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -761,6 +761,42 @@ void PlayerCharacterData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void InventorySoundOverrideData::synchronize(Common::Serializer &ser) {
+	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
+		uint16 count = overrides[i].size();
+		ser.syncAsUint16LE(count);
+
+		if (ser.isSaving()) {
+			for (auto &entry : overrides[i]) {
+				uint16 itemID = entry._key;
+				ser.syncAsUint16LE(itemID);
+				syncOverride(ser, entry._value);
+			}
+		} else {
+			overrides[i].clear();
+
+			for (uint j = 0; j < count; ++j) {
+				uint16 itemID = 0;
+				ser.syncAsUint16LE(itemID);
+				syncOverride(ser, overrides[i].getOrCreateVal(itemID));
+			}
+		}
+	}
+}
+
+void InventorySoundOverrideData::syncOverride(Common::Serializer &ser, Override &override) {
+	ser.syncAsByte(override.isDefault);
+	ser.syncString(override.caption);
+	ser.syncString(override.sound.name);
+	ser.syncAsUint16LE(override.sound.channelID);
+	ser.syncAsUint16LE(override.sound.playCommands);
+	ser.syncAsUint16LE(override.sound.numLoops);
+	ser.syncAsUint16LE(override.sound.volume);
+	ser.syncAsUint16LE(override.sound.panAnchorFrame);
+	ser.syncAsUint32LE(override.sound.samplesPerSec);
+	ser.syncAsByte(override.sound.isPanning);
+}
+
 void WordFindPuzzleData::synchronize(Common::Serializer &ser) {
 	ser.syncAsSint16LE(currentWord);
 }
@@ -1161,6 +1197,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new TaskbarData();
 	case PlayerCharacterData::getTag():
 		return new PlayerCharacterData();
+	case InventorySoundOverrideData::getTag():
+		return new InventorySoundOverrideData();
 	default:
 		return nullptr;
 	}
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 9742bf35488..f44479cc0df 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -497,6 +497,29 @@ struct PlayerCharacterData : public PuzzleData {
 	Common::String designs[kMaxPlayerCharacters];
 };
 
+// Inventory "can't" sound overrides, installed by AR 123 (InventorySoundOverride).
+// An override stays in place until another record replaces or removes it, so it
+// has to survive both scene changes and a save/load. One set per player character;
+// everything before Nancy15 only ever touches the first.
+struct InventorySoundOverrideData : public PuzzleData {
+	struct Override {
+		bool isDefault = false; // When true, other fields are ignored
+		SoundDescription sound;
+		Common::String caption;
+	};
+
+	InventorySoundOverrideData() {}
+	virtual ~InventorySoundOverrideData() {}
+
+	static constexpr uint32 getTag() { return MKTAG('I', 'S', 'N', 'D'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::HashMap<uint16, Override> overrides[kMaxPlayerCharacters];
+
+private:
+	static void syncOverride(Common::Serializer &ser, Override &override);
+};
+
 // Nancy13+ WordFindPuzzle (AR 170). The puzzle is solved one word at a time across
 // several scene visits; this remembers which word is currently active so progress
 // survives leaving and re-entering the scene (and saving/loading).
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 5c3a42ae019..cc4dde4befe 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -859,13 +859,14 @@ int32 Scene::getCharacterUIResource(uint characterIndex, uint index) {
 
 void Scene::installInventorySoundOverride(byte command, const SoundDescription &sound,
 		const Common::String &caption, uint16 itemID, byte characterIndex) {
-	InventorySoundOverride newOverride;
+	InventorySoundOverrideData::Override newOverride;
 
 	// An override can be installed on a character who isn't being played, so it
 	// waits in their own set until they are
 	uint targetCharacter = characterIndex < kMaxPlayerCharacters ?
 		characterIndex : MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1);
-	Common::HashMap<uint16, InventorySoundOverride> &overrides = _inventorySoundOverrides[targetCharacter];
+	auto *overrideData = (InventorySoundOverrideData *)getPuzzleData(InventorySoundOverrideData::getTag());
+	Common::HashMap<uint16, InventorySoundOverrideData::Override> &overrides = overrideData->overrides[targetCharacter];
 
 	switch (command) {
 	case kInvSoundOverrideCommandNoSound :
@@ -921,8 +922,9 @@ static Common::String getSoundSubtitle(const Common::String &soundName, const Co
 	return fallback;
 }
 
-Common::HashMap<uint16, Scene::InventorySoundOverride> &Scene::activeSoundOverrides() {
-	return _inventorySoundOverrides[MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1)];
+Common::HashMap<uint16, InventorySoundOverrideData::Override> &Scene::activeSoundOverrides() {
+	auto *overrideData = (InventorySoundOverrideData *)getPuzzleData(InventorySoundOverrideData::getTag());
+	return overrideData->overrides[MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1)];
 }
 
 // Nancy15 moved the "can't" responses out of the inventory data and into the
@@ -970,7 +972,7 @@ void Scene::playPlayerCantSound(int16 itemID) {
 	Common::String caption;
 
 	if (itemID >= 0 && activeSoundOverrides().contains(itemID)) {
-		InventorySoundOverride &override = activeSoundOverrides()[itemID];
+		InventorySoundOverrideData::Override &override = activeSoundOverrides()[itemID];
 
 		if (override.isDefault) {
 			// Back to the character's generic response
@@ -1050,7 +1052,7 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 	} else if ((uint)itemID < _flags.items.size()) {
 		if (activeSoundOverrides().contains(itemID)) {
 			// We have an override installed
-			InventorySoundOverride &override = activeSoundOverrides()[itemID];
+			InventorySoundOverrideData::Override &override = activeSoundOverrides()[itemID];
 			if (!override.isDefault) {
 				// Not set to the default sound, play the override
 				g_nancy->_sound->loadSound(override.sound);
@@ -2221,13 +2223,6 @@ void Scene::load(bool fromSaveFile) {
 		}
 	}
 
-	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
-		for (auto &override : _inventorySoundOverrides[i]) {
-			g_nancy->_sound->stopSound(override._value.sound);
-		}
-		_inventorySoundOverrides[i].clear();
-	}
-
 	_timers.sceneTime = 0;
 	g_nancy->_sound->clearListenerPositionOverride();
 	g_nancy->_sound->recalculateSoundEffects();
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 8f0bffa0e94..4a0444268ce 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -471,14 +471,8 @@ private:
 		int16 primaryVideoResponsePicked = -1;
 	};
 
-	struct InventorySoundOverride {
-		bool isDefault = false; // When true, other fields are ignored
-		SoundDescription sound;
-		Common::String caption;
-	};
-
-	// The overrides of the character being played
-	Common::HashMap<uint16, InventorySoundOverride> &activeSoundOverrides();
+	// The inventory sound overrides of the character being played
+	Common::HashMap<uint16, InventorySoundOverrideData::Override> &activeSoundOverrides();
 
 	// UI
 	UI::FullScreenImage _frame;
@@ -516,9 +510,6 @@ private:
 	int16 _lastHintCharacter;
 	int16 _lastHintID;
 	NancyState::NancyState _gameStateRequested;
-	// One set of overrides per player character; everything before Nancy15 only
-	// ever touches the first
-	Common::HashMap<uint16, InventorySoundOverride> _inventorySoundOverrides[kMaxPlayerCharacters];
 
 	Misc::Lightning *_lightning;
 	Misc::ViewportFader *_viewportFader;


Commit: 176bcd1722557fd907331500fae3e4a75eed1490
    https://github.com/scummvm/scummvm/commit/176bcd1722557fd907331500fae3e4a75eed1490
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-23T09:00:26+03:00

Commit Message:
NANCY: NANCY10: Fix ingredient counts in the cake mixing puzzle

The ingredients already added are now kept when the player backs away
and picks "ADD MORE INGREDIENTS", so they can be added over several
visits. Throwing the mix away starts over, and no longer allows a
cake to be baked from the ingredients thrown away.

Fixes part of #17024

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


diff --git a/engines/nancy/action/puzzle/multibuildpuzzle.cpp b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
index e78a9fd3abb..47aa375660f 100644
--- a/engines/nancy/action/puzzle/multibuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
@@ -25,6 +25,7 @@
 #include "engines/nancy/sound.h"
 #include "engines/nancy/input.h"
 #include "engines/nancy/util.h"
+#include "engines/nancy/puzzledata.h"
 
 #include "engines/nancy/enginedata.h"
 #include "engines/nancy/state/scene.h"
@@ -109,6 +110,16 @@ void MultiBuildPuzzle::init() {
 		p.setZOrder((uint16)(_z + i + 1));
 	}
 
+	if (_retainState) {
+		restoreState();
+	} else if (g_nancy->getGameType() == kGameTypeNancy10) {
+		// Adding ingredients never lowers the flags in Nancy 10, so a new
+		// session lowers them here. Otherwise a mix the player threw away
+		// could still be baked.
+		writeSolveFlag(_minCountFlag, false, _minCountFlagLastValue);
+		writeSolveFlag(_solveScene._flag, false, _solveFlagLastValue);
+	}
+
 	_isInitialized = true;
 }
 
@@ -135,7 +146,7 @@ void MultiBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	_hasCloseupImage = (secName != "NO_FILE" && !secName.empty());
 
 	if (isNancy10) {
-		_retainState = stream.readByte() != 0;	// TODO: state isn't saved yet
+		_retainState = stream.readByte() != 0;
 	}
 
 	_numPieces = stream.readUint16LE();
@@ -396,6 +407,8 @@ void MultiBuildPuzzle::execute() {
 		g_nancy->_sound->stopSound(_solveSound);
 		g_nancy->_sound->stopSound(_missedSound);
 		g_nancy->_sound->stopSound(_submitSound);
+		if (g_nancy->getGameType() >= kGameTypeNancy10)
+			saveState();
 		if (_isSubmitted) {
 			NancySceneState.setEventFlag(_submitScene._flag);
 			NancySceneState.changeScene(_submitScene._sceneChange);
@@ -909,6 +922,41 @@ void MultiBuildPuzzle::resetPuzzle() {
 	_solveFlagLastValue = -1;
 }
 
+void MultiBuildPuzzle::saveState() {
+	MultiBuildPuzzleData *data = (MultiBuildPuzzleData *)NancySceneState.getPuzzleData(MultiBuildPuzzleData::getTag());
+	assert(data);
+
+	data->sceneID = NancySceneState.getSceneInfo().sceneID;
+	data->placeCounts.resize(_numPieces);
+	for (uint i = 0; i < _numPieces; ++i)
+		data->placeCounts[i] = _pieces[i]->placeCount;
+}
+
+void MultiBuildPuzzle::restoreState() {
+	MultiBuildPuzzleData *data = (MultiBuildPuzzleData *)NancySceneState.getPuzzleData(MultiBuildPuzzleData::getTag());
+	assert(data);
+
+	if (data->sceneID != NancySceneState.getSceneInfo().sceneID)
+		return;
+
+	// Retaining records put the ingredients back on the shelf after each drop,
+	// so the counts are all there is to restore.
+	for (uint i = 0; i < _numPieces && i < data->placeCounts.size(); ++i)
+		_pieces[i]->placeCount = (uint8)data->placeCounts[i];
+}
+
+void MultiBuildPuzzle::writeSolveFlag(const FlagDescription &flag, bool isSet, int &lastValue) {
+	if (flag.label == kFlagNoLabel)
+		return;
+
+	byte value = isSet ? flag.flag
+		: (flag.flag == g_nancy->_false ? g_nancy->_true : g_nancy->_false);
+	if ((int)value != lastValue) {
+		NancySceneState.setEventFlag(flag.label, value);
+		lastValue = value;
+	}
+}
+
 bool MultiBuildPuzzle::updateSolveFlags() {
 	uint16 total = 0;
 	for (uint i = 0; i < _numPieces; ++i) {
@@ -919,20 +967,11 @@ bool MultiBuildPuzzle::updateSolveFlags() {
 
 	// The cancel scene's flag is the "enough ingredients" flag that enables the
 	// BAKE option in the neighbouring scene. It is raised at/above the threshold.
-	// Below the threshold, Nancy 10 leaves it latched (so backing away from the
-	// counter and stepping back up keeps BAKE available), while Nancy 11+ clears
-	// it. Written only on a value change (see member comment).
-	if (_minCountFlag.label != kFlagNoLabel) {
-		const bool enough = total >= _requiredPieces;
-		if (enough || g_nancy->getGameType() >= kGameTypeNancy11) {
-			byte want = enough ? _minCountFlag.flag
-				: (_minCountFlag.flag == g_nancy->_false ? g_nancy->_true : g_nancy->_false);
-			if ((int)want != _minCountFlagLastValue) {
-				NancySceneState.setEventFlag(_minCountFlag.label, want);
-				_minCountFlagLastValue = want;
-			}
-		}
-	}
+	// Below the threshold, Nancy 10 leaves it as it is (a new session lowers it
+	// in init()), while Nancy 11+ clears it.
+	const bool enough = total >= _requiredPieces;
+	if (enough || g_nancy->getGameType() >= kGameTypeNancy11)
+		writeSolveFlag(_minCountFlag, enough, _minCountFlagLastValue);
 
 	if (total < _requiredPieces)
 		return false;
@@ -959,14 +998,7 @@ bool MultiBuildPuzzle::updateSolveFlags() {
 		}
 	}
 
-	if (_solveScene._flag.label != kFlagNoLabel) {
-		byte want = exact ? _solveScene._flag.flag
-			: (_solveScene._flag.flag == g_nancy->_false ? g_nancy->_true : g_nancy->_false);
-		if ((int)want != _solveFlagLastValue) {
-			NancySceneState.setEventFlag(_solveScene._flag.label, want);
-			_solveFlagLastValue = want;
-		}
-	}
+	writeSolveFlag(_solveScene._flag, exact, _solveFlagLastValue);
 
 	return exact;
 }
diff --git a/engines/nancy/action/puzzle/multibuildpuzzle.h b/engines/nancy/action/puzzle/multibuildpuzzle.h
index 5c23b260992..206bccb0739 100644
--- a/engines/nancy/action/puzzle/multibuildpuzzle.h
+++ b/engines/nancy/action/puzzle/multibuildpuzzle.h
@@ -104,9 +104,9 @@ protected:
 	bool _hasCloseupImage = false;
 
 	// Nancy 10 additions
-	// Parsed for correct chunk alignment but not acted upon: no shipping N10/N11
-	// MultiBuild puzzle sets it (cake mixing and cake cooking both have it clear),
-	// so the save/restore of placement counts is deliberately not implemented.
+	// Picks up the placement counts the puzzle was last left with in this scene.
+	// Cake mixing has two records: a fresh one, and a retaining one that runs
+	// after the player backs away and chooses to add more ingredients.
 	bool _retainState = false;
 	Common::Path _animImageName;    // Completion animation sprite sheet
 	bool _hasAnimImage = false;
@@ -211,6 +211,9 @@ protected:
 
 	void checkIfSolved();
 	void checkIfSolvedOnExit();
+	// Nancy 10: keep the placement counts for a retaining record to pick up.
+	void saveState();
+	void restoreState();
 	// Nancy 13: ring the order up through the first exit hotspot.
 	void submitPuzzle();
 	// Nancy 13: return every piece to its slot and drop the counter clones.
@@ -218,6 +221,9 @@ protected:
 	// Updates the min-count/solve event flags. Returns true when every piece
 	// satisfies its own placement rule.
 	bool updateSolveFlags();
+	// Writes the flag's value when isSet, its opposite otherwise, unless it
+	// already holds what was last written.
+	void writeSolveFlag(const FlagDescription &flag, bool isSet, int &lastValue);
 	void updatePieceRender(int pieceIdx);
 	static void rotateSurface90CW(const Graphics::ManagedSurface &src, Graphics::ManagedSurface &dst);
 	// Clone an existing piece at the end of _pieces (counter-piece respawn).
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index d232d524b4d..bccb4775c1f 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -838,6 +838,11 @@ void MirrorLightData::synchronize(Common::Serializer &ser) {
 		ser.syncAsDoubleLE(angles[i]);
 }
 
+void MultiBuildPuzzleData::synchronize(Common::Serializer &ser) {
+	ser.syncAsUint16LE(sceneID);
+	syncInt16Array(ser, placeCounts);
+}
+
 void BuildPuzzleData::synchronize(Common::Serializer &ser) {
 	ser.syncAsUint16LE(sceneID);
 	ser.syncAsSint16LE(placedCount);
@@ -1145,6 +1150,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new DragSurfaceData();
 	case BuildPuzzleData::getTag():
 		return new BuildPuzzleData();
+	case MultiBuildPuzzleData::getTag():
+		return new MultiBuildPuzzleData();
 	case MirrorLightData::getTag():
 		return new MirrorLightData();
 	case DrivingData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index f44479cc0df..d65e735e36b 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -596,6 +596,18 @@ struct MirrorLightData : public PuzzleData {
 	Common::Array<double> angles;	// radians, indexed by mirror
 };
 
+// Nancy10 MultiBuildPuzzle (AR 235). How many times each ingredient went in when
+// the puzzle was last left. A record with its retain byte set picks the counts
+// back up when it runs in the same scene (cake mixing, after "add more
+// ingredients"); any other record starts over.
+struct MultiBuildPuzzleData : public PuzzleData {
+	static constexpr uint32 getTag() { return MKTAG('M', 'B', 'L', 'D'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	uint16 sceneID = kNoScene;
+	Common::Array<int16> placeCounts;
+};
+
 // Nancy14 BuildPuzzle (AR 166). The board as it was after the last drop. A puzzle
 // scene that re-runs picks it back up, as long as it is still the last build
 // puzzle entered and its resume flag is set; otherwise the puzzle starts over.




More information about the Scummvm-git-logs mailing list