[Scummvm-git-logs] scummvm master -> 362cbfc838d4129745be6f60f8e0e079f8642dec

bluegr noreply at scummvm.org
Tue Oct 13 16:24:28 UTC 2026


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

Summary:
9687a28473 NANCY: NANCY16: Initial work and mapping of the AR table
7e47235b11 NANCY: NANCY16: Read action record dependencies
abfd72fa4c NANCY: NANCY16: Read scene change records
1ada16ff16 NANCY: NANCY16: Read clock, timer, and screenshot records
2aa9e9a4ad NANCY: NANCY16: Read sound and inventory records
634f28a01e NANCY: NANCY16: Pass the new type to the SceneChange AR
7870545580 NANCY: NANCY16: Handle new functionality in movie ARs
cf8b549f08 NANCY: NANCY16: Handle new format for the rollover overlay AR
8435e62de9 NANCY: NANCY16: Handle the new TGA image header in game image data
5c03c50f7d NANCY: NANCY16: Add a check in playSound() for menu sound existence
43d9b6d6b6 NANCY: NANCY16: Lookup resources in the new sound/video directories
afa5aaacb8 NANCY: NANCY16: Load convo texts correctly, and populate static data
5a15643c3b NANCY: NANCY16: Implement the new TrueType system fonts
0fab21e2c8 NANCY: NANCY16: Draw hypertext with the system fonts
42d743d8ad NANCY: NANCY16: Use a 800x600 resolution, and scale the viewport
02f3fca772 NANCY: NANCY16: Fix BSUM and VIEW chunk handling
86b7cf176d NANCY: NANCY16: Show the logo screens
4025b882b7 NANCY: NANCY16: Implement the viewport fader
c22d3d160a NANCY: NANCY16: Add the HideCursor record
90b115304b NANCY: NANCY16: Track the day value
e19a4e8506 NANCY: NANCY16: Read event flag records
d3e5831804 NANCY: NANCY16: Don't require inventory data when printing dependencies
1420790479 NANCY: NANCY16: Don't play scene area names as sounds
b52901e37d NANCY: NANCY16: Read the conversation response scenes
575940f836 NANCY: Add shared color conversion and blending helpers
a9ed5ffc2c NANCY: NANCY16: Add a reader for exclusive rects
8bcdc8fc05 NANCY: NANCY16: NDUI: Start implementing the new UI (common, element)
c70f3e61ec NANCY: NANCY16: NDUI: Add controls, panels and manager for the new UI
a04600bcb5 NANCY: NANCY16: NDUI: Implement the button types
f835b45235 NANCY: NANCY16: NDUI: Add the slider, scroll bar and list box widgets
8c7abcd668 NANCY: NANCY16: NDUI: Implement the edit box
84740abb0e NANCY: NANCY16: NDUI: Implement the engine handlers
65dc22d026 NANCY: NANCY16: NDUI: Add the save, load and options handlers
c388395436 NANCY: NANCY16: Use the NDUI in the scene
5c86caf88c NANCY: NANCY16: Show conversations in the UI
e79fb3368f NANCY: NANCY16: Add the UI records
ad55eaabfc NANCY: Stop the speech of a skipped cinematic
3dad6370d9 NANCY: NANCY16: Implement the AutotextScroll AR
926fd1a241 NANCY: NANCY16: Fix regression with the pixel format used
6e4ea17fd4 NANCY: NANCY16: Implement FoxGeesePuzzle
0443e04cd0 NANCY: NANCY16: Read the z order of movies
db34fbb313 NANCY: NANCY16: Implement new functionality in TurningPuzzle
276607c66a NANCY: NANCY16: Handle new data layout in RotatingLockPuzzle
1be97e00c0 NANCY: NANCY16: Implement new functionality in QuizPuzzle
0c87a77ed0 NANCY: NANCY16: Go back to the second chance save on losing
db41cd094c NANCY: NANCY16: Implement RowMatchPuzzle
e400d1a1e2 NANCY: NANCY16: Allow the game to start
8fcf78a5a2 NANCY: NANCY16: Implement FishingPuzzle
79a81b2bd8 NANCY: NANCY16: Implement AnalogClock
16a61b5446 NANCY: NANCY16: Implement KeypadPhone
5690eb6eeb NANCY: NANCY16: Implement MinesweeperPuzzle
33b5bebdb2 NANCY: NANCY16: Hang held pieces off the cursor like the original
7a4d321fea NANCY: NANCY16: Implement CookingPuzzle
907840cbeb NANCY: NANCY16: Implement the EscapeGridPuzzle changes
c0b9bdee6e NANCY: NANCY16: NDUI: Add the sound handler
2f945c44fb NANCY: NANCY16: Implement the wolf puzzles
433abfeb70 NANCY: NANCY16: Fix the scene count dependency
28d3a5724b NANCY: NANCY16: Fix the timer state dependency and paused timers
8def92a242 NANCY: NANCY16: Fix the value table records and dependency
01f53c0e9a NANCY: NANCY16: Play the music of the scene's area
ab15798a67 NANCY: NANCY16: Keep the state of the UI in saved games
77e5074fbc NANCY: NANCY16: Fix the scroll range of journal pages
3b0320e002 NANCY: NANCY16: Implement the snowmobile rides (SnowmobilePuzzle)
54efc9266d NANCY: NANCY16: Let the XSheet pick the frames of conversation movies
29881b3065 NANCY: Stop the character animation when a line is skipped
27599e3873 NANCY: NANCY17: Read the new boot data
5c09182a3e NANCY: NANCY17: Implement scene streams
e7f0027d16 NANCY: NANCY17: Read the new Nancy UI (NDUI) data
3439ccb69a NANCY: NANCY17: Handle the changed records
521641050a NANCY: NANCY17: Implement the new records
d6c60dd064 NANCY: NANCY17: Change to unstable - the game now starts
3550d93386 NANCY: NANCY17: Show pressed buttons in RotatingLockPuzzle
6508d4b4e0 NANCY: NANCY17: Implement the BuildPuzzle changes
860b885e23 NANCY: NANCY17: Set the flags movies have at their first and last frame
8ee30ea07b NANCY: NANCY17: Fix the carried piece in BuildPuzzle
f096d27d5e NANCY: NANCY17: Implement GoldbergPuzzle
2638e295bd NANCY: NANCY17: Implement DialSequencePuzzle
1744197037 NANCY: NANCY14: Draw the BuildPuzzle overlays in viewport coordinates
bad38ab6c6 NANCY: Drop the factory checks for unused record types
92b8af4835 NANCY: NANCY17: Implement TileCyclePuzzle
c7b356ffa7 NANCY: NANCY17: Implement BookSortPuzzle
8d8df8fb60 NANCY: NANCY17: Implement DragSurfacePuzzle
bae4678acb NANCY: NANCY17: Implement ShovelMeasurePuzzle
ef34b3f0d1 NANCY: NANCY17: Implement WhackItPuzzle
8accf93f9c NANCY: NANCY17: Implement SkeeBallPuzzle
8671d64780 NANCY: NANCY17: Implement EscapePuzzle
df69a4211f NANCY: NANCY17: Implement FlashMemoryPuzzle
9b909b90ff NANCY: NANCY17: Show the Rain and FadeOverlay record graphics
69e6530df2 NANCY: NANCY17: Cut conversation movies to the size of their picture
10eda80309 NANCY: Merge the dirty rects that overlap
255aae1e23 NANCY: NANCY17: Leave the far edges out of the conversation movie rects
a8750f1bfc NANCY: NANCY17: Fix the logo screens
b20c7cc5a7 NANCY: NANCY16: Hook the ScummVM save/load dialogs to the new NDUI
1e04d63b47 NANCY: NANCY10: Move the Nancy10-15 UI files into ui/taskbar
6596106204 NANCY: NANCY18: Read and write the new CifFile and CifTree format
9e4cad9e34 NANCY: NANCY18: Load images stored as PNG files
b27a11f691 NANCY: NANCY18: Read the shorter BSUM chunk
aa02ae97b0 NANCY: Fix a crash when the image of an object is missing
e5c270322f NANCY: NANCY16: Start the parts of a conversation line on a new line
c279653ea1 NANCY: NANCY16: Show the captions of muffled lines
e17164d39b NANCY: NANCY16: Hide overlays whose dependencies no longer hold
8f3932bfb6 NANCY: NANCY16: Show the time of day right away
8b32d76489 NANCY: NANCY16: Close the dialog of a conversation cut short
3345e1a9a8 NANCY: NANCY18: Set the item and cursor type counts
0ac15749a7 NANCY: NANCY18: Run the leave script of an environment
f11d08dc77 NANCY: NANCY18: Let collections hold names
cfaf485b01 NANCY: NANCY18: Add the saved data puzzles keep under a name
8a36107a96 NANCY: NANCY18: Add the records that save and load a game by name
1ebb107949 NANCY: NANCY18: Add the UTurn and GraphChase records
64cd5fb6f9 NANCY: NANCY18: Add the overlay that picks a random color
65fcd09cb6 NANCY: NANCY18: Handle changes of existing records
343c554cb6 NANCY: NANCY18: Read the new fields of BuildPuzzle
be51c95645 NANCY: NANCY18: Mark the game as unstable
bf5365fa6a NANCY: NANCY16: Use the item names of the game in the console
ef56198d6c NANCY: Make time and value dependencies readable in the console
a81013b721 NANCY: NANCY16: Start the next day when the clock is moved past midnight
729a7f26f5 NANCY: NANCY18: Add the mosaic variant of the PaintPuzzle
c61e107e35 NANCY: NANCY18: Add the TypewriterText record
e66f7a9565 NANCY: NANCY18: Add the ChessChatPuzzle
2a55a54ac6 NANCY: NANCY18: Add the SequenceLockPuzzle
41bd608fa5 NANCY: NANCY18: Add the sentry rooms - SentryPuzzle/SentryStartPosition
23ea8aa8a3 NANCY: NANCY18: Add the stakeout mini game (StakeOutPuzzle)
e98ceec887 NANCY: NANCY18: Add the Scopa card game
2b611f5963 NANCY: NANCY18: Fix overlays being one pixel too large
92537e0148 NANCY: NANCY18: Fix movies being one pixel too large
2c58ebacf8 NANCY: NANCY18: Read the pause flag of random movie sequences
fbe629fe21 NANCY: NANCY18: Hide random movies whose dependencies no longer hold
77594e63ca NANCY: NANCY18: Implement the dance minigame (DancePuzzle)
c5a5d24a54 NANCY: Clean up the AR factory switch
d04607f990 NANCY: NANCY16: Update the meters of the UI that show a timer
4453d4a08e NANCY: NANCY16: Don't cut off controls placed outside of their panel
0b9c6dd94a NANCY: NANCY16: Fix typing the answers of Tino's survey (QuizPuzzle)
bc6c47c9c4 NANCY: NANCY16: Fix a crash when an item is shown after pickup
020b8a76c1 NANCY: NANCY16: Change the scene as soon as a sound asks for it
3cf4cfe32f COMMON: LUA: Restore lundump (loader of precompiled script chunks)
1299041a32 COMMON: LUA: Load precompiled chunks made on other platforms
550358268f NANCY: NANCY18: Move to a subengine that depends on libpng
8683020776 NANCY: NANCY19: Add the nancy_v4 subengine
2447dea099 NANCY: Document the major engine versions and update supported games
e1a217438a NANCY: NANCY19: Add the value types of the scripts
e796dc625c NANCY: NANCY19: Add vectors and matrices
9413d198e9 NANCY: NANCY19: Read the boot data
b4c91d7841 NANCY: NANCY19: Run scenes from scripts
283c7f59ee NANCY: NANCY19: Implement the Overlay and AnimOverlay records
03c1b26755 NANCY: NANCY19: Mark what the scripts can't use yet with TODOs
2321ae5a1e NANCY: NANCY19: Implement the Sound record
ce80cfe382 NANCY: NANCY19: Implement the Movie record
de16b8476e NANCY: NANCY19: Implement Ref images, and saving the data of scripts
dd67fae5a6 NANCY: NANCY16: Read NDUI panels a part at a time
637c024be8 NANCY: NANCY19: Build the UI from scripts
7d4908a3a3 NANCY: NANCY19: Show the captions of sounds
012bf23722 NANCY: NANCY19: Implement conversations
946e40c2ab NANCY: NANCY19: Let the player skip movies
1b40847440 NANCY: NANCY19: Change to unstable - the game now starts
aeba00c68a NANCY: NANCY11: Fix the plant puzzle (MultiBuildPuzzle) not completing
746e98bc19 NANCY: NANCY10: Fix stale task list checkbox hotspots after scrolling
085c6b843a COMMON: LUA: Update Lua from 5.1.3 to 5.1.5
0345e3bce2 NANCY: NANCY19: Sort records by z when handling input
d9c774af78 NANCY: NANCY19: Implement the journal and the task list
82b382fb61 NANCY: NANCY19: Play a test sound when a volume slider is released
0919044c2e NANCY: NANCY19: Implement the Text record
611f398ac1 NANCY: NANCY19: Draw rotated, scaled and tinted overlays
0ca782db0d NANCY: NANCY19: Show tooltips
291880913d NANCY: NANCY19: Don't draw the background picture of scrolling panels
5eef0a0609 NANCY: NANCY19: Draw the puzzle piece held on the cursor
62ad355136 NANCY: NANCY16: Send an event when a list box item is double-clicked
fb4623a372 NANCY: NANCY19: Add Streams:GetScene()
64c9a83a5a NANCY: NANCY19: Call the OnSave functions of scripts when saving
c084fccf05 NANCY: NANCY19: Let scripts send messages to scenes and records
be7f7ec7c5 NANCY: NANCY19: Play the theme music of each area
6cded30d2d NANCY: NANCY10: Fix the cellphone search and e-mail lists
c27d1a43b0 NANCY: NANCY10: Fix the magnet maze puzzle to behave like the original
41b1020f2a NANCY: Let physical objects run into obstacles
faae5ace2f NANCY: NANCY13: Fix the frame and the lever of the Gold Rush game
4714ce654a COMMON: LUA: Whitespace fixes
362cbfc838 NANCY: Fix signed/unsigned warnings


Commit: 9687a2847356a01b62173e07d4942caa72f7fef8
    https://github.com/scummvm/scummvm/commit/9687a2847356a01b62173e07d4942caa72f7fef8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:11+03:00

Commit Message:
NANCY: NANCY16: Initial work and mapping of the AR table

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index d1854f6397d..15142e9cb7c 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -34,9 +34,11 @@
 #include "engines/nancy/action/secondarymovie.h"
 
 #include "engines/nancy/action/puzzle/adjustpuzzle.h"
+//#include "engines/nancy/action/puzzle/analogclock.h"
 #include "engines/nancy/action/puzzle/angletosspuzzle.h"
 #include "engines/nancy/action/puzzle/arcadepuzzle.h"
 #include "engines/nancy/action/puzzle/assemblypuzzle.h"
+//#include "engines/nancy/action/puzzle/autotextscroll.h"
 #include "engines/nancy/action/puzzle/bballpuzzle.h"
 #include "engines/nancy/action/puzzle/beadpuzzle.h"
 #include "engines/nancy/action/puzzle/blockingpuzzle.h"
@@ -47,6 +49,7 @@
 #include "engines/nancy/action/puzzle/bombpuzzle.h"
 #include "engines/nancy/action/puzzle/cardgamepuzzle.h"
 #include "engines/nancy/action/puzzle/collisionpuzzle.h"
+//#include "engines/nancy/action/puzzle/cookingpuzzle.h"
 #include "engines/nancy/action/puzzle/cubepuzzle.h"
 #include "engines/nancy/action/puzzle/cuttingpuzzle.h"
 #include "engines/nancy/action/puzzle/decoderpuzzle.h"
@@ -54,17 +57,21 @@
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
 #include "engines/nancy/action/puzzle/escapegridpuzzle.h"
+//#include "engines/nancy/action/puzzle/fishingpuzzle.h"
+//#include "engines/nancy/action/puzzle/foxgeesepuzzle.h"
 #include "engines/nancy/action/puzzle/gridmappuzzle.h"
 #include "engines/nancy/action/puzzle/magicboxpuzzle.h"
 #include "engines/nancy/action/puzzle/matchpuzzle.h"
 #include "engines/nancy/action/puzzle/hamradiopuzzle.h"
 #include "engines/nancy/action/puzzle/hangmanpuzzle.h"
+//#include "engines/nancy/action/puzzle/keypadphone.h"
 #include "engines/nancy/action/puzzle/lettergridpuzzle.h"
 #include "engines/nancy/action/puzzle/leverpuzzle.h"
 #include "engines/nancy/action/puzzle/magnetmazepuzzle.h"
 #include "engines/nancy/action/puzzle/mazechasepuzzle.h"
 #include "engines/nancy/action/puzzle/memorypuzzle.h"
 #include "engines/nancy/action/puzzle/meterpuzzle.h"
+//#include "engines/nancy/action/puzzle/minesweeperpuzzle.h"
 #include "engines/nancy/action/puzzle/mindpuzzle.h"
 #include "engines/nancy/action/puzzle/minigolfpuzzle.h"
 #include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
@@ -84,6 +91,7 @@
 #include "engines/nancy/action/puzzle/riddlepuzzle.h"
 #include "engines/nancy/action/puzzle/rippedletterpuzzle.h"
 #include "engines/nancy/action/puzzle/rotatinglockpuzzle.h"
+//#include "engines/nancy/action/puzzle/rowmatchpuzzle.h"
 #include "engines/nancy/action/puzzle/safedialpuzzle.h"
 #include "engines/nancy/action/puzzle/scalepuzzle.h"
 #include "engines/nancy/action/puzzle/setplayerclock.h"
@@ -112,6 +120,9 @@ namespace Nancy {
 namespace Action {
 
 ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableReadStream *recordStream) {
+	// The types returning nullptr in Nancy16 were changed there, and their new data
+	// hasn't been analyzed yet. This keeps the readers of the older games from
+	// misparsing it.
 	switch (type) {
 	case 10:
 		if (g_nancy->getGameType() <= kGameTypeNancy9)
@@ -136,25 +147,41 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 14:
 		return new Hot1FrSceneChange(CursorManager::kExit);
 	case 15:
-		return new Hot1FrSceneChange(CursorManager::kMoveForward);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new Hot1FrSceneChange(CursorManager::kMoveForward);
+		else
+			return new SceneChange();
 	case 16:
-		return new Hot1FrSceneChange(CursorManager::kMoveBackward);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new Hot1FrSceneChange(CursorManager::kMoveBackward);
+		else
+			return new SceneChange();	// With frame
 	case 17:
-		return new Hot1FrSceneChange(CursorManager::kMoveUp);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new Hot1FrSceneChange(CursorManager::kMoveUp);
+		else
+			return nullptr;	// TODO: Scene change with frame and event flag
 	case 18:
-		return new Hot1FrSceneChange(CursorManager::kMoveDown);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new Hot1FrSceneChange(CursorManager::kMoveDown);
+		else
+			return nullptr;	// TODO: Scene change with frame and event flags
 	case 19:
 		if (g_nancy->getGameType() <= kGameTypeNancy9)
 			return new HotMultiframeSceneChange(CursorManager::kMoveForward);
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Hot1FrSceneChange(CursorManager::kMoveLeft);		// Moved from 22
+		else
+			return new Hot1FrSceneChange(CursorManager::kNormal, true);
 	case 20:
 		if (g_nancy->getGameType() == kGameTypeVampire)
 			return new PaletteThisScene();
 		else if (g_nancy->getGameType() <= kGameTypeNancy9)
 			return new HotMultiframeSceneChange(CursorManager::kMoveUp);
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Hot1FrSceneChange(CursorManager::kMoveRight);	// Moved from 23
+		else
+			return nullptr;	// TODO: Scene change with cursor and multiple hotspots
 	case 21:
 		if (g_nancy->getGameType() == kGameTypeVampire)
 			return new PaletteNextScene();
@@ -189,24 +216,33 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			assert(newRec);
 			newRec->_isTerse = true;
 			return newRec;
-		} else {
+		} else if (g_nancy->getGameType() <= kGameTypeNancy15) {
 			return new HotMultiframeSceneChange(CursorManager::kMoveDown);		// Moved from 21
+		} else {
+			return nullptr;	// TODO: Scene change with event flags, cursor and multiple hotspots
 		}
 	}
 	case 26:
 		if (g_nancy->getGameType() <= kGameTypeNancy9)
 			return new InteractiveVideo();
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new HotMultiframeMultiSceneChange();	// Moved from 13
+		else
+			return nullptr;	// TODO: InvCursorAR
 	case 27:	// Nancy10
 		if (g_nancy->getGameType() <= kGameTypeNancy12)
 			return new HotMultiframeMultiSceneCursorTypeSceneChange();	// Moved from 24
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new HotMultiframeInvTypeSceneChange();
+		else
+			return nullptr;	//return new HideCursor();	// TODO
 	case 28:	// Nancy10
 		return new InteractiveVideo();	// Moved from 26
 	case 29:	// Nancy10
-		return new ControlUIItems();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new ControlUIItems();
+		else
+			return nullptr;	//return new UIControl();	// TODO
 	case 30:	// Nancy11
 		return new StopPlayerScrolling();
 	case 31:	// Nancy11
@@ -220,8 +256,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 40:
 		if (g_nancy->getGameType() <= kGameTypeNancy1)
 			return new LightningOn(); // Only used in TVD
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new SpecialEffect();
+		else
+			return nullptr;	//return new Fader();	// TODO
 	case 41:	// Nancy13
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kSecondaryMovieTerse);
 	case 42:	// Nancy14
@@ -229,11 +267,18 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 45:	// Nancy11
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kRandomMovie);
 	case 44:	// Nancy14
+	case 48:	// Nancy16, with a random position
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kMovieWithVolume);
 	case 46:	// Nancy11
-		return new PlayRandomMovieControl();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new PlayRandomMovieControl();
+		else
+			return nullptr;	// TODO: Movie control
 	case 47:	// Nancy14
-		return new PlaySecondaryMovie(PlaySecondaryMovie::kInteractiveMovie);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new PlaySecondaryMovie(PlaySecondaryMovie::kInteractiveMovie);
+		else
+			return nullptr;	// TODO
 	case 50:
 		return new ConversationVideo();
 	case 51:
@@ -305,9 +350,15 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 70:
 		return new TextScroll(TextScroll::kAutotextEntryList);
 	case 71:
-		return new ModifyListEntry(ModifyListEntry::kAdd);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new ModifyListEntry(ModifyListEntry::kAdd);
+		else
+			return nullptr;	//return new AddJournalEntry();	// TODO
 	case 72:
-		return new ModifyListEntry(ModifyListEntry::kDelete);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new ModifyListEntry(ModifyListEntry::kDelete);
+		else
+			return nullptr;	//return new AddTasklistEntry();	// TODO
 	case 73:
 		return new ModifyListEntry(ModifyListEntry::kMark);
 	case 74:	// Nancy10 only
@@ -327,6 +378,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new ValueTest();
 	case 81:	// Nancy11
 		return new TextBoxWrite(TextBoxWrite::kAutotextWrite);
+	case 90:	// Nancy16
+		return new EventFlags(EventFlags::kEventFlags);
+	case 91:	// Nancy16
+		return new EventFlagsMultiHS(EventFlagsMultiHS::kCursorHS);
 	case 94:	// Nancy12
 		return new EventFlagsMultiHS(EventFlagsMultiHS::kMultiHS);	// Moved from 106
 	case 95:	// Nancy12
@@ -342,15 +397,24 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 100:
 		return new BumpPlayerClock();
 	case 101:
-		return new SaveContinueGame();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new SaveContinueGame();
+		else
+			return new BumpPlayerClock();		// Moved from 100
 	case 102:
-		return new TurnOffMainRendering();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new TurnOffMainRendering();
+		else
+			return new SaveContinueGame();		// Moved from 101
 	case 103:
 		return new TurnOnMainRendering();
 	case 104:
 		return new ResetAndStartTimer();
 	case 105:
-		return new StopTimer();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new StopTimer();
+		else
+			return new MakeScreenFile();		// Moved from 141
 	case 106:
 		return new EventFlagsMultiHS(EventFlagsMultiHS::kMultiHS);
 	case 107:
@@ -375,13 +439,19 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 115:
 		return new LeverPuzzle();
 	case 116:
-		return new Telephone(Telephone::kTelephone);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new Telephone(Telephone::kTelephone);
+		else
+			return nullptr;	//return new KeypadPhone();	// TODO
 	case 117:
 		return new SliderPuzzle();
 	case 118:
 		return new PasswordPuzzle();
 	case 119:	// Nancy7
-		return new OrderingPuzzle(OrderingPuzzle::kOrdering);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new OrderingPuzzle(OrderingPuzzle::kOrdering);
+		else
+			return nullptr;
 	case 120:
 		return new AddInventoryNoHS();
 	case 121:
@@ -407,11 +477,17 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 132:	// Nancy12
 		return new ResourceUse();
 	case 133:	// Nancy14
-		return new CameraAction();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new CameraAction();
+		else
+			return nullptr;
 	case 134:	// Nancy15
 		// Switches the active player character (Nancy / Frank / Joe), the
 		// dual-protagonist mechanic new to The Creature of Kapu Cave
-		return new PlayChar();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new PlayChar();
+		else
+			return nullptr;
 	case 140:
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new SetVolume();			// Moved to 149 in Nancy9, empty slot in Nancy9-11
@@ -472,76 +548,169 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 159:
 		if (g_nancy->getGameType() <= kGameTypeNancy13)
 			return new PlaySoundTerse();
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new LetterGridPuzzle();
+		else
+			return nullptr;
 	case 160:
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new HintSystem();
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new DrivingPuzzle(DrivingPuzzle::kDriving);
+		else
+			return nullptr;	// Snowmobile
 	case 161:
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new PlaySoundEventFlagTerse();
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new MinigolfPuzzle();
+		else
+			return nullptr;
 	// -- Nancy 12 new puzzles/action records --
 	case 162:
-		return new SewingMachinePuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new SewingMachinePuzzle();
+		else
+			return nullptr;
 	case 163:
-		return new MirrorLightPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new MirrorLightPuzzle();
+		else
+			return nullptr;
 	case 164:
-		return new BoardGamePuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new BoardGamePuzzle();
+		else
+			return nullptr;
 	case 165:
-		return new MindPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new MindPuzzle();
+		else
+			return nullptr;
 	case 166:
 		if (g_nancy->getGameType() <= kGameTypeNancy13)
 			return new OneBuildPuzzle();	// Moved from 234
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new BuildPuzzle();
+		else
+			return nullptr;
 	case 167:
-		return new DrivingPuzzle(DrivingPuzzle::kChase);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new DrivingPuzzle(DrivingPuzzle::kChase);
+		else
+			return nullptr;	// Snowmobile chase
 	case 168:
-		return new Set3DSoundListenerPosition();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new Set3DSoundListenerPosition();
+		else
+			return nullptr;
 	// -- Nancy 13 new/relocated puzzles (types 169-176) --
 	case 169:
-		return new StepObjectsPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new StepObjectsPuzzle();
+		else
+			return nullptr;
 	case 170:
 		if (g_nancy->getGameType() <= kGameTypeNancy12)
 			return new SetPlayerClock();	// Moved to 140 in Nancy12, unused slot in Nancy12
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new WordFindPuzzle();
+		else
+			return nullptr;
 	case 171:
 		return new TurningPuzzle();	// Moved from 209
 	case 172:
-		return new BlocksPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new BlocksPuzzle();
+		else
+			return nullptr;
 	case 173:
-		return new PegsPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new PegsPuzzle();
+		else
+			return nullptr;
 	case 174:
-		return new ScalePuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new ScalePuzzle();
+		else
+			return nullptr;
 	case 175:
-		return new PachinkoPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new PachinkoPuzzle();
+		else
+			return nullptr;
 	case 176:
-		return new DropSortPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new DropSortPuzzle();
+		else
+			return nullptr;
 	// -- Nancy14 new puzzles (types 177-182) --
 	case 177:
-		return new HangmanPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new HangmanPuzzle();
+		else
+			return nullptr;
 	case 178:
-		return new AdjustPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new AdjustPuzzle();
+		else
+			return nullptr;
 	case 179:
-		return new MeterPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new MeterPuzzle();
+		else
+			return nullptr;
 	case 180:
-		return new BlockingPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new BlockingPuzzle();
+		else
+			return nullptr;
 	case 181:
-		return new PaintPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new PaintPuzzle();
+		else
+			return nullptr;
 	case 182:
-		return new DecoderPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new DecoderPuzzle();
+		else
+			return nullptr;
 	// -- Nancy15 new puzzles (types 183-185) --
 	case 183:
-		return new MagicBoxPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new MagicBoxPuzzle();
+		else
+			return nullptr;
 	case 184:
 		return new EscapeGridPuzzle();
 	case 185:
-		return new WeightSortPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new WeightSortPuzzle();
+		else
+			return nullptr;
+	// -- Nancy16 new puzzles (types 186-199) --
+	case 186:
+		return nullptr;	//return new FoxGeesePuzzle();	// TODO
+	case 189:
+		return new QuizPuzzle();		// Moved from 231
+	case 192:
+		return nullptr; // return new RowMatchPuzzle();	// TODO
+	case 193:
+		return nullptr; // return new FishingPuzzle();
+	case 194:
+		return nullptr; // return new CookingPuzzle();
+	case 195:
+		return nullptr; // return new MinesweeperPuzzle();	// TODO
+	case 196:
+		return nullptr; // return new AnalogClock();	// TODO
+	case 197:
+		return nullptr; // return new AutotextScroll();	// TODO
+	case 188:	// Empty in the original
+	case 190:	// MemoryPuzzle, moved from 238
+	case 191:	// BeadPuzzle, moved from 243
+	case 198:	// WolfCommands ("Movie Buttons"), builds its buttons in the new UI
+	case 199:	// WolfMaze, driven by the commands queued through 198
+		return nullptr;
 	case 200:
 		return new SoundEqualizerPuzzle();
 	case 201:


Commit: 7e47235b11000e64f8d5ac5b659ef1f90ee3eb2e
    https://github.com/scummvm/scummvm/commit/7e47235b11000e64f8d5ac5b659ef1f90ee3eb2e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:13+03:00

Commit Message:
NANCY: NANCY16: Read action record dependencies

Dependencies come before the record data, with a count and an
end marker

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


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 1cb8ee2f809..2ebb37cb2fd 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -152,6 +152,36 @@ ActionRecord *ActionManager::createAndLoadNewRecord(Common::SeekableReadStream &
 	descBuf[0x2F] = '\0';
 	byte ARType = inputData.readByte();
 	byte execType = inputData.readByte();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// Nancy16 moved the dependencies in front of the record data, where they
+		// are preceded by their count and followed by an end marker
+		ActionRecord *newRecord = createActionRecord(ARType, &inputData);
+		if (!newRecord) {
+			newRecord = new Unimplemented();
+		}
+
+		newRecord->_description = descBuf;
+		newRecord->_type = ARType;
+		newRecord->_execType = (ActionRecord::ExecutionType)execType;
+
+		uint32 numDependencies = inputData.readUint32LE();
+		if (numDependencies) {
+			readDependencies(inputData, *newRecord, numDependencies, 16);
+		} else {
+			newRecord->_isActive = true;
+		}
+
+		char endMarker[0x21] = {};
+		inputData.read(endMarker, 0x20);
+		if (Common::String(endMarker) != "EndOfDeps") {
+			warning("Action record type %u has malformed dependency data, description:\n%s", ARType, descBuf);
+		}
+
+		newRecord->readData(inputData);
+		return newRecord;
+	}
+
 	ActionRecord *newRecord = createActionRecord(ARType, &inputData);
 
 	if (!newRecord) {
@@ -188,68 +218,72 @@ ActionRecord *ActionManager::createAndLoadNewRecord(Common::SeekableReadStream &
 			newRecord->_dependencies.satisfied = true;
 		}
 
-		Common::Stack<DependencyRecord *> depStack;
-		depStack.push(&newRecord->_dependencies);
-
-		// Initialize the dependencies data
-		for (uint16 i = 0; i < numDependencies; ++i) {
-			depStack.top()->children.push_back(DependencyRecord());
-			DependencyRecord &dep = depStack.top()->children.back();
-
-			if (singleDepSize == 12) {
-				dep.type = (DependencyType)inputData.readByte();
-				dep.label = inputData.readByte();
-				dep.condition = inputData.readByte();
-				dep.orFlag = inputData.readByte();
-			} else if (singleDepSize == 16) {
-				dep.type = (DependencyType)inputData.readUint16LE();
-				dep.label = inputData.readUint16LE();
-				dep.condition = inputData.readUint16LE();
-				dep.orFlag = inputData.readUint16LE();
-			}
+		readDependencies(inputData, *newRecord, numDependencies, singleDepSize);
+	} else {
+		// Set new record to active if it doesn't depend on anything
+		newRecord->_isActive = true;
+	}
 
-			dep.hours = inputData.readSint16LE();
-			dep.minutes = inputData.readSint16LE();
-			dep.seconds = inputData.readSint16LE();
-			dep.milliseconds = inputData.readSint16LE();
+	return newRecord;
+}
 
-			switch (dep.type) {
-			case DependencyType::kElapsedPlayerTime:
-			case DependencyType::kSceneCount:
-				// These keep their own data in the time fields, which isn't a duration
-				break;
-			case DependencyType::kOpenParenthesis:
-				depStack.push(&dep);
-				break;
-			case DependencyType::kCloseParenthesis:
-				depStack.top()->children.pop_back();
-				depStack.pop();
-				break;
-			default:
-				if (dep.hours != -1 || dep.minutes != -1 || dep.seconds != -1) {
-					int16 seconds = dep.seconds;
-					if (seconds >= kTimerDurationIndexBase && isTimerDurationDependency(dep.type)) {
-						auto *bootSummary = GetEngineData(BSUM);
-						assert(bootSummary);
-
-						uint index = seconds - kTimerDurationIndexBase;
-						if (index < bootSummary->timerDurations.size()) {
-							seconds = bootSummary->timerDurations[index];
-						}
-					}
+void ActionManager::readDependencies(Common::SeekableReadStream &inputData, ActionRecord &record, uint numDependencies, uint singleDepSize) {
+	Common::Stack<DependencyRecord *> depStack;
+	depStack.push(&record._dependencies);
+
+	// Initialize the dependencies data
+	for (uint16 i = 0; i < numDependencies; ++i) {
+		depStack.top()->children.push_back(DependencyRecord());
+		DependencyRecord &dep = depStack.top()->children.back();
+
+		if (singleDepSize == 12) {
+			dep.type = (DependencyType)inputData.readByte();
+			dep.label = inputData.readByte();
+			dep.condition = inputData.readByte();
+			dep.orFlag = inputData.readByte();
+		} else if (singleDepSize == 16) {
+			dep.type = (DependencyType)inputData.readUint16LE();
+			dep.label = inputData.readUint16LE();
+			dep.condition = inputData.readUint16LE();
+			dep.orFlag = inputData.readUint16LE();
+		}
 
-					dep.timeData = ((dep.hours * 60 + dep.minutes) * 60 + seconds) * 1000 + dep.milliseconds;
+		dep.hours = inputData.readSint16LE();
+		dep.minutes = inputData.readSint16LE();
+		dep.seconds = inputData.readSint16LE();
+		dep.milliseconds = inputData.readSint16LE();
+
+		switch (dep.type) {
+		case DependencyType::kElapsedPlayerTime:
+		case DependencyType::kSceneCount:
+			// These keep their own data in the time fields, which isn't a duration
+			break;
+		case DependencyType::kOpenParenthesis:
+			depStack.push(&dep);
+			break;
+		case DependencyType::kCloseParenthesis:
+			depStack.top()->children.pop_back();
+			depStack.pop();
+			break;
+		default:
+			if (dep.hours != -1 || dep.minutes != -1 || dep.seconds != -1) {
+				int16 seconds = dep.seconds;
+				if (seconds >= kTimerDurationIndexBase && isTimerDurationDependency(dep.type)) {
+					auto *bootSummary = GetEngineData(BSUM);
+					assert(bootSummary);
+
+					uint index = seconds - kTimerDurationIndexBase;
+					if (index < bootSummary->timerDurations.size()) {
+						seconds = bootSummary->timerDurations[index];
+					}
 				}
 
-				break;
+				dep.timeData = ((dep.hours * 60 + dep.minutes) * 60 + seconds) * 1000 + dep.milliseconds;
 			}
+
+			break;
 		}
-	} else {
-		// Set new record to active if it doesn't depend on anything
-		newRecord->_isActive = true;
 	}
-
-	return newRecord;
 }
 
 void ActionManager::processActionRecords() {
@@ -261,8 +295,10 @@ void ActionManager::processActionRecords() {
 		}
 
 		// Process dependencies every call. We make sure to ignore cursor dependencies,
-		// as they are only handled when calling from handleInput()
-		processDependency(record->_dependencies, *record, record->canHaveHotspot());
+		// as they are only handled when calling from handleInput(). Nancy16 checks
+		// them every frame, like any other dependency.
+		processDependency(record->_dependencies, *record,
+			record->canHaveHotspot() && g_nancy->getGameType() <= kGameTypeNancy15);
 		record->_isActive = _previousRecordWasExecuted = record->_dependencies.satisfied;
 
 		if (record->_isActive) {
@@ -603,6 +639,13 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
 			dep.satisfied = true;
 			break;
 		case DependencyType::kCursorType: {
+			if (g_nancy->getGameType() >= kGameTypeNancy16) {
+				// Holding (condition true) or not holding the item. Nothing plays
+				// a "can't" sound, and using the item never takes it out of the hand.
+				dep.satisfied = (NancySceneState.getHeldItem() == dep.label) == (dep.condition == g_nancy->_true);
+				break;
+			}
+
 			if (doNotCheckCursor) {
 				dep.satisfied = true;
 			} else {
diff --git a/engines/nancy/action/actionmanager.h b/engines/nancy/action/actionmanager.h
index eea85bf937f..ecc999fc89f 100644
--- a/engines/nancy/action/actionmanager.h
+++ b/engines/nancy/action/actionmanager.h
@@ -74,6 +74,7 @@ public:
 
 protected:
 	static ActionRecord *createActionRecord(uint16 type, Common::SeekableReadStream *recordStream = nullptr);
+	static void readDependencies(Common::SeekableReadStream &inputData, ActionRecord &record, uint numDependencies, uint singleDepSize);
 
 	void synchronizeMovieWithSound();
 


Commit: abfd72fa4c41a6b5bd4e0d7e06e90f37c9b77a7f
    https://github.com/scummvm/scummvm/commit/abfd72fa4c41a6b5bd4e0d7e06e90f37c9b77a7f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:16+03:00

Commit Message:
NANCY: NANCY16: Read scene change records

Types 15, 16 and 19 are plain scene changes with a 32-bit frame, and no
sound or listener data

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


diff --git a/engines/nancy/action/navigationrecords.cpp b/engines/nancy/action/navigationrecords.cpp
index ebaa72abe12..39b9e1fbe60 100644
--- a/engines/nancy/action/navigationrecords.cpp
+++ b/engines/nancy/action/navigationrecords.cpp
@@ -32,9 +32,27 @@ namespace Nancy {
 namespace Action {
 
 void SceneChange::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		readDataNancy16(stream);
+		return;
+	}
+
 	_sceneChange.readData(stream);
 }
 
+// Nancy16 dropped the vertical offset, sound continuation flag and listener
+// vector from its scene changes, and widened the frame ID to 32 bits
+void SceneChange::readDataNancy16(Common::SeekableReadStream &stream) {
+	_sceneChange.sceneID = stream.readUint16LE();
+	if (_type != 15) {
+		_sceneChange.frameID = stream.readUint32LE();
+	}
+
+	_sceneChange.continueSceneSound = kContinueSceneSound;
+	_sceneChange.listenerFrontVector.set(0, 0, 1);
+	_sceneChange.frontVectorFrameID = _sceneChange.frameID;
+}
+
 void SceneChange::execute() {
 	NancySceneState.changeScene(_sceneChange);
 	_isDone = true;
@@ -134,6 +152,13 @@ void HotSingleFrameSceneChange::execute() {
 }
 
 void Hot1FrSceneChange::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy16 && !_isTerse) {
+		readDataNancy16(stream);
+		_hoverCursor = (CursorManager::CursorType)stream.readUint32LE();
+		readRect(stream, _hotspotDesc.coords);
+		return;
+	}
+
 	if (_dynamicCursor)
 		_hoverCursor = (CursorManager::CursorType)stream.readUint16LE();
 
diff --git a/engines/nancy/action/navigationrecords.h b/engines/nancy/action/navigationrecords.h
index c188bad1b7e..77fc5d67b8f 100644
--- a/engines/nancy/action/navigationrecords.h
+++ b/engines/nancy/action/navigationrecords.h
@@ -39,6 +39,8 @@ public:
 
 protected:
 	Common::String getRecordTypeName() const override { return "SceneChange"; }
+
+	void readDataNancy16(Common::SeekableReadStream &stream);
 };
 
 // Changes the scene when clicked


Commit: 1ada16ff1664a2a2fcd38e40e42bc94890438b2c
    https://github.com/scummvm/scummvm/commit/1ada16ff1664a2a2fcd38e40e42bc94890438b2c
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:18+03:00

Commit Message:
NANCY: NANCY16: Read clock, timer, and screenshot records

- Handle the new fields in BumpPlayerClock and MakeScreenFile
- Timer sounds are a counted list

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


diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 4d5062441bd..104afc000d6 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -416,6 +416,12 @@ void BumpPlayerClock::readData(Common::SeekableReadStream &stream) {
 	_relative = stream.readByte();
 	_hours = stream.readSint16LE();
 	_minutes = stream.readSint16LE();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// Value table indices to take the hours and minutes from instead,
+		// never used in the game data
+		stream.skip(4);
+	}
 }
 
 void BumpPlayerClock::execute() {
@@ -437,8 +443,10 @@ void MakeScreenFile::readData(Common::SeekableReadStream &stream) {
 
 	readRect(stream, _cropRect);
 
-	// Image format selector, unused
-	stream.skip(1);
+	if (g_nancy->getGameType() <= kGameTypeNancy15) {
+		// Image format selector, unused
+		stream.skip(1);
+	}
 }
 
 void MakeScreenFile::execute() {
@@ -605,17 +613,34 @@ void ResetAndStartTimer::readData(Common::SeekableReadStream &stream) {
 		_minutes = stream.readSint16LE();        // 0x06
 		_seconds = stream.readSint16LE();        // 0x08
 		_milliseconds = stream.readSint16LE();   // 0x0a
-		_sound.volume = stream.readUint16LE();   // 0x0c
-		_sound.channelID = stream.readUint16LE(); // 0x0e
-		_sound.numLoops = 1;
 
-		// Three candidate expiry-sound names (0x10, 33 bytes each); one is
-		// picked at random, "NO SOUND" marks an empty slot
 		Common::Array<Common::String> names;
-		for (uint i = 0; i < 3; ++i) {
-			Common::String name = readFixedSizeString(stream, 0x21);
-			if (!name.empty() && !name.equalsIgnoreCase("NO SOUND")) {
-				names.push_back(name);
+		if (g_nancy->getGameType() >= kGameTypeNancy16) {
+			// Up to three expiry-sound names, preceded by their count and
+			// followed by the sound settings
+			uint16 numNames = stream.readUint16LE();
+			for (uint i = 0; i < numNames; ++i) {
+				Common::String name = readFixedSizeString(stream, 0x21);
+				if (!name.empty() && !name.equalsIgnoreCase("NO SOUND")) {
+					names.push_back(name);
+				}
+			}
+
+			_sound.channelID = stream.readUint16LE();
+			_sound.numLoops = stream.readUint32LE();
+			_sound.volume = stream.readUint16LE();
+		} else {
+			_sound.volume = stream.readUint16LE();   // 0x0c
+			_sound.channelID = stream.readUint16LE(); // 0x0e
+			_sound.numLoops = 1;
+
+			// Three candidate expiry-sound names (0x10, 33 bytes each); one is
+			// picked at random, "NO SOUND" marks an empty slot
+			for (uint i = 0; i < 3; ++i) {
+				Common::String name = readFixedSizeString(stream, 0x21);
+				if (!name.empty() && !name.equalsIgnoreCase("NO SOUND")) {
+					names.push_back(name);
+				}
 			}
 		}
 


Commit: 2aa9e9a4ad9b091debbf1cec09183613db6347a1
    https://github.com/scummvm/scummvm/commit/2aa9e9a4ad9b091debbf1cec09183613db6347a1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:20+03:00

Commit Message:
NANCY: NANCY16: Read sound and inventory records

Changed paths:
    engines/nancy/action/inventoryrecords.cpp
    engines/nancy/action/soundrecords.cpp


diff --git a/engines/nancy/action/inventoryrecords.cpp b/engines/nancy/action/inventoryrecords.cpp
index edecaf00911..2d0723cfea5 100644
--- a/engines/nancy/action/inventoryrecords.cpp
+++ b/engines/nancy/action/inventoryrecords.cpp
@@ -38,7 +38,11 @@ void AddInventoryNoHS::readData(Common::SeekableReadStream &stream) {
 		// The character the item goes to took the first half of the cursor flag
 		_characterIndex = stream.readByte();
 		_setCursor = stream.readByte();
-		_forceCursor = stream.readUint16LE();
+
+		// Nancy16 dropped the force cursor field
+		if (g_nancy->getGameType() == kGameTypeNancy15) {
+			_forceCursor = stream.readUint16LE();
+		}
 	} else if (g_nancy->getGameType() >= kGameTypeNancy6) {
 		_setCursor = stream.readUint16LE();
 		_forceCursor = stream.readUint16LE();
diff --git a/engines/nancy/action/soundrecords.cpp b/engines/nancy/action/soundrecords.cpp
index 4b6b7886ce6..7e61964b90f 100644
--- a/engines/nancy/action/soundrecords.cpp
+++ b/engines/nancy/action/soundrecords.cpp
@@ -98,7 +98,12 @@ static Common::String resolveSoundSubtitle(const Common::String &soundName) {
 
 void SetVolume::readData(Common::SeekableReadStream &stream) {
 	channel = stream.readUint16LE();
-	volume = stream.readByte();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		volume = CLIP<int32>(stream.readSint32LE(), 0, 100);
+	} else {
+		volume = stream.readByte();
+	}
 }
 
 void SetVolume::execute() {
@@ -108,7 +113,12 @@ void SetVolume::execute() {
 
 void SetMovieVolume::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, movieName);
-	volume = MIN<byte>(stream.readByte(), 100);
+
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		volume = CLIP<int32>(stream.readSint32LE(), 0, 100);
+	} else {
+		volume = MIN<byte>(stream.readByte(), 100);
+	}
 }
 
 void SetMovieVolume::execute() {
@@ -122,7 +132,11 @@ void SetMovieVolume::execute() {
 
 void FadeSoundToSilence::readData(Common::SeekableReadStream &stream) {
 	channel = stream.readUint16LE();
-	stream.skip(2); // pad / flag
+
+	if (g_nancy->getGameType() <= kGameTypeNancy15) {
+		stream.skip(2); // pad / flag
+	}
+
 	fadeTimeMs = stream.readUint32LE();
 }
 
@@ -492,7 +506,18 @@ void PlaySoundMultiHS::execute() {
 
 void StopSound::readData(Common::SeekableReadStream &stream) {
 	_channelID = stream.readUint16LE();
-	_sceneChange.readData(stream);
+
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// Nancy16 cut the scene change down to its scene and frame IDs
+		_sceneChange._sceneChange.sceneID = stream.readUint16LE();
+		_sceneChange._sceneChange.frameID = stream.readUint16LE();
+		_sceneChange._sceneChange.continueSceneSound = kContinueSceneSound;
+		_sceneChange._sceneChange.listenerFrontVector.set(0, 0, 1);
+		_sceneChange._flag.label = stream.readSint16LE();
+		_sceneChange._flag.flag = stream.readUint16LE();
+	} else {
+		_sceneChange.readData(stream);
+	}
 }
 
 void StopSound::execute() {


Commit: 634f28a01edf0bb85ab50e20a4426741ceaf9a76
    https://github.com/scummvm/scummvm/commit/634f28a01edf0bb85ab50e20a4426741ceaf9a76
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:22+03:00

Commit Message:
NANCY: NANCY16: Pass the new type to the SceneChange AR

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 15142e9cb7c..ce8a4859a83 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -150,12 +150,12 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Hot1FrSceneChange(CursorManager::kMoveForward);
 		else
-			return new SceneChange();
+			return new SceneChange(SceneChange::kWithoutFrame);
 	case 16:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Hot1FrSceneChange(CursorManager::kMoveBackward);
 		else
-			return new SceneChange();	// With frame
+			return new SceneChange(SceneChange::kWithFrame);
 	case 17:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Hot1FrSceneChange(CursorManager::kMoveUp);
diff --git a/engines/nancy/action/navigationrecords.cpp b/engines/nancy/action/navigationrecords.cpp
index 39b9e1fbe60..1ccbe89d081 100644
--- a/engines/nancy/action/navigationrecords.cpp
+++ b/engines/nancy/action/navigationrecords.cpp
@@ -44,7 +44,7 @@ void SceneChange::readData(Common::SeekableReadStream &stream) {
 // vector from its scene changes, and widened the frame ID to 32 bits
 void SceneChange::readDataNancy16(Common::SeekableReadStream &stream) {
 	_sceneChange.sceneID = stream.readUint16LE();
-	if (_type != 15) {
+	if (_frameType == kWithFrame) {
 		_sceneChange.frameID = stream.readUint32LE();
 	}
 
diff --git a/engines/nancy/action/navigationrecords.h b/engines/nancy/action/navigationrecords.h
index 77fc5d67b8f..45ef16ba657 100644
--- a/engines/nancy/action/navigationrecords.h
+++ b/engines/nancy/action/navigationrecords.h
@@ -30,6 +30,14 @@ namespace Action {
 // Simply changes the scene
 class SceneChange : public ActionRecord {
 public:
+	// Nancy16: whether the record also names the frame to open the scene at
+	enum FrameType {
+		kWithFrame,
+		kWithoutFrame
+	};
+
+	SceneChange(FrameType frameType = kWithFrame) : _frameType(frameType) {}
+
 	void readData(Common::SeekableReadStream &stream) override;
 	void execute() override;
 
@@ -41,6 +49,8 @@ protected:
 	Common::String getRecordTypeName() const override { return "SceneChange"; }
 
 	void readDataNancy16(Common::SeekableReadStream &stream);
+
+	FrameType _frameType;
 };
 
 // Changes the scene when clicked


Commit: 7870545580b1116a3a4e2c1226d549e935c0be4a
    https://github.com/scummvm/scummvm/commit/7870545580b1116a3a4e2c1226d549e935c0be4a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:24+03:00

Commit Message:
NANCY: NANCY16: Handle new functionality in movie ARs

- Random movies gained an identifier and lost their masks
- Implement new movie type, which draws its movie at a random spot
  inside its destination

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index ce8a4859a83..4f49e1db3ee 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -267,7 +267,6 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 45:	// Nancy11
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kRandomMovie);
 	case 44:	// Nancy14
-	case 48:	// Nancy16, with a random position
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kMovieWithVolume);
 	case 46:	// Nancy11
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
@@ -279,6 +278,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new PlaySecondaryMovie(PlaySecondaryMovie::kInteractiveMovie);
 		else
 			return nullptr;	// TODO
+	case 48:	// Nancy16
+		return new PlaySecondaryMovie(PlaySecondaryMovie::kMovieWithRandomPosition);
 	case 50:
 		return new ConversationVideo();
 	case 51:
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index a3545870503..00c8df4435b 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -280,6 +280,10 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
 // blt-descriptor list, in place of Nancy13's secondaryMovie record + hotspot
 // list.
 void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		ser.skip(33);	// Movie identifier, not referenced by any supported record
+	}
+
 	readFilename(ser, _startingSequenceName);
 
 	ser.syncAsUint16LE(_videoFormat);
@@ -313,13 +317,15 @@ void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Com
 	}
 
 	// Foreground mask: the name of an image, followed by the blt descriptors
-	// that place it over the movie for each background frame.
-	readFilename(ser, _maskName);
-	uint16 numMaskDescs = 0;
-	ser.syncAsUint16LE(numMaskDescs);
-	_maskDescs.resize(numMaskDescs);
-	for (uint i = 0; i < numMaskDescs; ++i) {
-		_maskDescs[i].readData(stream);
+	// that place it over the movie for each background frame. Dropped in Nancy16.
+	if (g_nancy->getGameType() <= kGameTypeNancy15) {
+		readFilename(ser, _maskName);
+		uint16 numMaskDescs = 0;
+		ser.syncAsUint16LE(numMaskDescs);
+		_maskDescs.resize(numMaskDescs);
+		for (uint i = 0; i < numMaskDescs; ++i) {
+			_maskDescs[i].readData(stream);
+		}
 	}
 
 	applyStartingRandomSequence();
@@ -721,7 +727,7 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 
 	// AR 44 and its subclass AR 47 read the play-style field (retail "mode 0");
 	// AR 41 ("mode 1") does not.
-	if (isNancy15 && (_movieType == kMovieWithVolume || _movieType == kInteractiveMovie)) {
+	if (isNancy15 && (_movieType == kMovieWithVolume || _movieType == kMovieWithRandomPosition || _movieType == kInteractiveMovie)) {
 		ser.syncAsUint16LE(_playStyle);
 	}
 
@@ -752,7 +758,7 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 	// Per-movie volume. AR 44 always carries it. AR 47 does too, but only from
 	// Nancy15 - the retail flipped the "mode" convention, and Nancy14's AR 47
 	// omits the byte.
-	if (_movieType == kMovieWithVolume || (_movieType == kInteractiveMovie && isNancy15)) {
+	if (_movieType == kMovieWithVolume || _movieType == kMovieWithRandomPosition || (_movieType == kInteractiveMovie && isNancy15)) {
 		ser.syncAsByte(_movieVolume);
 		_movieVolume = MIN<byte>(_movieVolume, 100);
 	}
@@ -766,6 +772,10 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 		ser.syncAsUint16LE(_frameFlags[i].flagDesc.flag);
 	}
 
+	if (_movieType == kMovieWithRandomPosition) {
+		ser.syncAsByte(_randomPosition);
+	}
+
 	uint16 numVideoDescs = 0;
 	ser.syncAsUint16LE(numVideoDescs);
 	_videoDescs.resize(numVideoDescs);
@@ -1027,7 +1037,7 @@ void PlaySecondaryMovie::updateViewportFrame() {
 
 	if (activeFrame != -1) {
 		if (_fullFrame.empty()) {
-			moveTo(_videoDescs[activeFrame].destRect);
+			moveTo(getVideoDescDest(activeFrame));
 		} else {
 			// Crop the last decoded frame for the new background frame, since a
 			// finished or paused movie won't decode another one.
@@ -1063,7 +1073,7 @@ void PlaySecondaryMovie::applyVideoDesc(int descID) {
 		srcRect = Common::Rect(_fullFrame.w, _fullFrame.h);
 	}
 
-	Common::Rect destRect = descID != -1 ? _videoDescs[descID].destRect : _screenPosition;
+	Common::Rect destRect = descID != -1 ? getVideoDescDest(descID) : _screenPosition;
 
 	// The videoDesc's size might be larger than the decoded video (for example, nancy10's
 	// COR_AceFidgetEars_ANIM, and nancy12's PAR_ArcadeAnimationB); clamp here to avoid
@@ -1083,6 +1093,47 @@ void PlaySecondaryMovie::applyVideoDesc(int descID) {
 	moveTo(destRect);
 }
 
+Common::Rect PlaySecondaryMovie::getVideoDescDest(int descID) const {
+	const SecondaryVideoDescription &desc = _videoDescs[descID];
+	if (_randomPosition == kFixedPosition) {
+		return desc.destRect;
+	}
+
+	Common::Rect dest(desc.srcRect.isEmpty() ? _decoder.getWidth() : desc.srcRect.width(),
+					desc.srcRect.isEmpty() ? _decoder.getHeight() : desc.srcRect.height());
+	dest.moveTo(desc.destRect.left + _randomOffset.x, desc.destRect.top + _randomOffset.y);
+	return dest;
+}
+
+void PlaySecondaryMovie::pickRandomPosition() {
+	_randomOffset = Common::Point();
+
+	int frameID = NancySceneState.getSceneInfo().frameID;
+	for (const SecondaryVideoDescription &desc : _videoDescs) {
+		if (desc.frameID != frameID) {
+			continue;
+		}
+
+		int16 width = desc.srcRect.isEmpty() ? _decoder.getWidth() : desc.srcRect.width();
+		int16 height = desc.srcRect.isEmpty() ? _decoder.getHeight() : desc.srcRect.height();
+		int16 rangeX = desc.destRect.width() - width;
+		int16 rangeY = desc.destRect.height() - height;
+
+		if (rangeX > 0) {
+			_randomOffset.x = g_nancy->_randomSource->getRandomNumber(rangeX - 1);
+		}
+
+		if (rangeY > 0) {
+			_randomOffset.y = g_nancy->_randomSource->getRandomNumber(rangeY - 1);
+		}
+
+		break;
+	}
+
+	// Reapply the position on the next graphics update
+	_curViewportFrame = -1;
+}
+
 void PlaySecondaryMovie::onPause(bool pause) {
 	_decoder.pauseVideo(pause);
 	RenderActionRecord::onPause(pause);
@@ -1099,6 +1150,11 @@ void PlaySecondaryMovie::execute() {
 
 		init();
 		registerGraphics();
+
+		if (_randomPosition != kFixedPosition) {
+			pickRandomPosition();
+		}
+
 		g_nancy->_sound->loadSound(_sound);
 		g_nancy->_sound->playSound(_sound);
 
@@ -1262,6 +1318,10 @@ void PlaySecondaryMovie::execute() {
 			} else if (_numLoops == 0 || _playCount + 1 < _numLoops) {
 				// More plays to go; restart in place.
 				++_playCount;
+
+				if (_randomPosition == kRandomPositionPerLoop) {
+					pickRandomPosition();
+				}
 				_isFinished = false;
 				_decoder.seekToFrame(_playDirection == kPlayMovieReverse ? _lastFrame : _firstFrame);
 				_decoder.pauseVideo(false);
@@ -1326,9 +1386,25 @@ bool PlaySecondaryMovie::isSkippable() {
 		Common::Rect(_decoder.getWidth(), _decoder.getHeight()) == NancySceneState.getViewport().getBounds();
 }
 
-void PlaySecondaryMovie::skip() {
+bool PlaySecondaryMovie::skip() {
 	if (!isSkippable()) {
-		return;
+		return false;
+	}
+
+	// The flags of the frames that won't be played get set, as if the movie had
+	// reached its end. Other records may be waiting for them.
+	int curFrame = _decoder.isVideoLoaded() ? _decoder.getCurFrame() : -1;
+	for (const FlagAtFrame &frameFlag : _frameFlags) {
+		bool isPending;
+		if (_playDirection == kPlayMovieReverse) {
+			isPending = (curFrame == -1 || frameFlag.frameID < curFrame) && frameFlag.frameID >= _firstFrame;
+		} else {
+			isPending = frameFlag.frameID > curFrame && frameFlag.frameID <= _lastFrame;
+		}
+
+		if (isPending) {
+			NancySceneState.setEventFlag(frameFlag.flagDesc);
+		}
 	}
 
 	g_nancy->_sound->stopSound(_sound);
@@ -1339,6 +1415,7 @@ void PlaySecondaryMovie::skip() {
 	}
 
 	_state = kActionTrigger;
+	return true;
 }
 
 // --- PlayRandomMovieControl --------------------------------------------
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 71803408bda..4d54c4d27fe 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -103,7 +103,8 @@ public:
 		kSecondaryMovieTerse,	// AR 41, Nancy13 and up
 		kRandomMovie,			// ARs 42, 43, 45
 		kMovieWithVolume,		// AR 44
-		kInteractiveMovie		// AR 47
+		kInteractiveMovie,		// AR 47
+		kMovieWithRandomPosition	// AR 48, Nancy16: AR 44 at a random spot inside its dest rect
 	};
 
 	PlaySecondaryMovie(MovieType movieType);
@@ -121,8 +122,8 @@ public:
 
 	// Enhancement: jump a cinematic straight to its end, as if it had finished
 	// playing on its own. Only cinematics are skipped; background animations
-	// the player isn't waiting on keep playing.
-	void skip();
+	// the player isn't waiting on keep playing. Returns true when skipped.
+	bool skip();
 
 	Common::Path _videoName;
 	Common::Path _paletteName;
@@ -153,6 +154,16 @@ public:
 	// (AR 150) can change it later.
 	byte _movieVolume = 100;
 
+	// How a kMovieWithRandomPosition movie picks its spot
+	enum RandomPosition : byte {
+		kFixedPosition			= 0,
+		kRandomPositionOnce		= 1,
+		kRandomPositionPerLoop	= 2
+	};
+
+	byte _randomPosition = kFixedPosition;
+	Common::Point _randomOffset;
+
 	// AR 47 "InteractiveVideo" (a PlaySecondaryMovie subclass): the movie's
 	// clickable areas live in an external .iv file, which lists them per movie
 	// frame and tags each one with a set ID. The record itself carries the name
@@ -366,6 +377,8 @@ protected:
 	// Crop the last decoded frame with the given video description and move
 	// the movie to its destination. -1 uses the whole frame at its current spot.
 	void applyVideoDesc(int descID);
+	Common::Rect getVideoDescDest(int descID) const;
+	void pickRandomPosition();
 
 	Graphics::ManagedSurface _fullFrame;
 	Graphics::ManagedSurface _maskImage;


Commit: cf8b549f08e78f3b1d195b37e765b300c86bb24d
    https://github.com/scummvm/scummvm/commit/cf8b549f08e78f3b1d195b37e765b300c86bb24d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:27+03:00

Commit Message:
NANCY: NANCY16: Handle new format for the rollover overlay AR

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


diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index 15fbbcb31e6..efaa58b54de 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -675,6 +675,11 @@ void RolloverOverlay::init() {
 }
 
 void RolloverOverlay::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		readDataNancy16(stream);
+		return;
+	}
+
 	readFilename(stream, _imageName);
 	_transparency = stream.readUint16LE();
 	_z = stream.readUint16LE();
@@ -701,6 +706,32 @@ void RolloverOverlay::readData(Common::SeekableReadStream &stream) {
 	_clickSound.readData(stream);
 }
 
+// Nancy16 dropped the transparency field and widened the z value
+void RolloverOverlay::readDataNancy16(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+	_z = stream.readUint32LE();
+	_hoverCursor = stream.readUint16LE();
+
+	readRect(stream, _hotspotRect);
+	readRect(stream, _srcRect);
+	readRect(stream, _destRect);
+
+	_flagOnHover.label = stream.readSint16LE();
+	_flagOnHover.flag = stream.readByte();
+
+	_hoverSound.readData(stream);
+	_hoverSoundOnce = stream.readByte();
+
+	_sceneChange.sceneID = stream.readUint16LE();
+	_sceneChange.frameID = stream.readUint16LE();
+	_sceneChange.continueSceneSound = kContinueSceneSound;
+
+	_flagOnClick.label = stream.readSint16LE();
+	_flagOnClick.flag = stream.readByte();
+
+	_clickSound.readData(stream);
+}
+
 void RolloverOverlay::playSoundBlock(const RandomSoundBlock &block) {
 	if (block.names.empty()) {
 		return;
diff --git a/engines/nancy/action/overlay.h b/engines/nancy/action/overlay.h
index 90499d96598..fc11e969b30 100644
--- a/engines/nancy/action/overlay.h
+++ b/engines/nancy/action/overlay.h
@@ -217,6 +217,7 @@ public:
 protected:
 	Common::String getRecordTypeName() const override { return "RolloverOverlay"; }
 
+	void readDataNancy16(Common::SeekableReadStream &stream);
 	void playSoundBlock(const RandomSoundBlock &block);
 
 	Common::Path _imageName;


Commit: 8435e62de914af736e2cedb15b8ac3948fee17a8
    https://github.com/scummvm/scummvm/commit/8435e62de914af736e2cedb15b8ac3948fee17a8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:29+03:00

Commit Message:
NANCY: NANCY16: Handle the new TGA image header in game image data

Changed paths:
    engines/nancy/resource.cpp


diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index 4a985c28c14..2794a657f44 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -162,6 +162,32 @@ bool ResourceManager::loadImage(const Common::Path &name, Graphics::ManagedSurfa
 		*outDest = info.dest;
 	}
 
+	// Nancy16 stores its images as uncompressed, top-down TGA files. Only the header
+	// needs skipping; the image's size and depth come from the CIF info.
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		byte idLength = stream->readByte();
+		byte colorMapType = stream->readByte();
+		byte imageType = stream->readByte();
+		stream->skip(9); // color map spec, origin
+		uint16 width = stream->readUint16LE();
+		uint16 height = stream->readUint16LE();
+		byte bpp = stream->readByte();
+		byte descriptor = stream->readByte();
+
+		const byte kTGATrueColor = 2;
+		const byte kTGATopDown = 0x20;
+
+		if (colorMapType != 0 || imageType != kTGATrueColor || !(descriptor & kTGATopDown) ||
+				bpp != info.depth || width != info.width || height != info.height) {
+			warning("Image '%s' is an unsupported TGA (type %u, color map %u, %u bpp, descriptor 0x%02x, %ux%u)",
+				name.toString().c_str(), imageType, colorMapType, bpp, descriptor, width, height);
+			delete stream;
+			return false;
+		}
+
+		stream->skip(idLength);
+	}
+
 	// Finally, copy the data into the surface
 	uint32 bufSize = info.pitch * info.height;
 	byte *buf = new byte[bufSize];


Commit: 5c03c50f7d8d1346a509cd6131d0c6fc45999d4e
    https://github.com/scummvm/scummvm/commit/5c03c50f7d8d1346a509cd6131d0c6fc45999d4e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:31+03:00

Commit Message:
NANCY: NANCY16: Add a check in playSound() for menu sound existence

Nancy16 has no menu sound

Changed paths:
    engines/nancy/sound.cpp


diff --git a/engines/nancy/sound.cpp b/engines/nancy/sound.cpp
index db4e4f1eea0..6a25ec1e5ab 100644
--- a/engines/nancy/sound.cpp
+++ b/engines/nancy/sound.cpp
@@ -434,6 +434,11 @@ void SoundManager::playSound(const SoundDescription &description) {
 }
 
 void SoundManager::playSound(const Common::String &chunkName) {
+	// Nancy16 has no menu sound
+	if (!_commonSounds.contains(chunkName)) {
+		return;
+	}
+
 	const SoundDescription &desc = _commonSounds[chunkName];
 
 	if (!isSoundPlaying(desc)) {


Commit: 43d9b6d6b6ab87c9a6975b993f3bf6e729ecd696
    https://github.com/scummvm/scummvm/commit/43d9b6d6b6ab87c9a6975b993f3bf6e729ecd696
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:34+03:00

Commit Message:
NANCY: NANCY16: Lookup resources in the new sound/video directories

Changed paths:
    engines/nancy/nancy.cpp


diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 9118a71be52..0e8d00b0488 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -587,6 +587,8 @@ void NancyEngine::bootGameEngine() {
 	SearchMan.addSubDirectoryMatching(gameDataDir, "cdsound");
 	SearchMan.addSubDirectoryMatching(gameDataDir, "hdvideo");
 	SearchMan.addSubDirectoryMatching(gameDataDir, "cdvideo");
+	SearchMan.addSubDirectoryMatching(gameDataDir, "sound");	// Nancy16+
+	SearchMan.addSubDirectoryMatching(gameDataDir, "video");	// Nancy16+
 	SearchMan.addSubDirectoryMatching(gameDataDir, "iff");
 	SearchMan.addSubDirectoryMatching(gameDataDir, "art");
 	SearchMan.addSubDirectoryMatching(gameDataDir, "font");


Commit: afa5aaacb8d3a3410eeedf8b45e235b429732e7b
    https://github.com/scummvm/scummvm/commit/afa5aaacb8d3a3410eeedf8b45e235b429732e7b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:37+03:00

Commit Message:
NANCY: NANCY16: Load convo texts correctly, and populate static data

Changed paths:
    engines/nancy/nancy.cpp


diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 0e8d00b0488..2f8829d8dcc 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -761,6 +761,14 @@ void NancyEngine::bootGameEngine() {
 
 	delete iff;
 
+	// Load convo texts and autotext. This has to happen before a player character's
+	// tree gets added: Nancy16's has a UI file with the same name as the convo texts.
+	auto *bsum = GetEngineData(BSUM);
+	if (bsum && !bsum->conversationTextsFilename.empty() && !bsum->autotextFilename.empty()) {
+		LOAD_CHUNK(bsum->conversationTextsFilename.toString().c_str(), CVTX, "CVTX", "CONVO")
+		LOAD_CHUNK(bsum->autotextFilename.toString().c_str(), CVTX, "CVTX", "AUTOTEXT")
+	}
+
 	if (getGameType() >= kGameTypeNancy15) {
 		// The default player character is Nancy, who always occupies the first PCUI slot
 		setPlayerCharacter(0);
@@ -778,13 +786,6 @@ void NancyEngine::bootGameEngine() {
 		}
 	}
 
-	// Load convo texts and autotext
-	auto *bsum = GetEngineData(BSUM);
-	if (bsum && !bsum->conversationTextsFilename.empty() && !bsum->autotextFilename.empty()) {
-		LOAD_CHUNK(bsum->conversationTextsFilename.toString().c_str(), CVTX, "CVTX", "CONVO")
-		LOAD_CHUNK(bsum->autotextFilename.toString().c_str(), CVTX, "CVTX", "AUTOTEXT")
-	}
-
 	#undef LOAD_BOOT_L2
 	#undef LOAD_BOOT_L
 	#undef LOAD_BOOT
@@ -950,6 +951,8 @@ void NancyEngine::populateStaticData() {
 		break;
 	case kGameTypeNancy14:
 	case kGameTypeNancy15:
+	case kGameTypeNancy16:
+		// Nancy16 has 40 items, but its cursor table still has room for 49
 		_staticData.numItems = 49;
 		_staticData.numCursorTypes = 45;
 		break;


Commit: 5a15643c3bea9b83bf7085029a2283a1c345e57d
    https://github.com/scummvm/scummvm/commit/5a15643c3bea9b83bf7085029a2283a1c345e57d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:39+03:00

Commit Message:
NANCY: NANCY16: Implement the new TrueType system fonts

- TrueType system fonts are now used, instead of the previous custom
  font glyphs
- Windows fonts get drawn with a TrueType substitute, each with a bold
  variant
- COLR chunks hold a font color for each UI control state

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


diff --git a/engines/nancy/font.cpp b/engines/nancy/font.cpp
index bded4095619..2934405bb3e 100644
--- a/engines/nancy/font.cpp
+++ b/engines/nancy/font.cpp
@@ -19,6 +19,9 @@
  *
  */
 
+#include "graphics/fontman.h"
+#include "graphics/fonts/ttf.h"
+
 #include "engines/nancy/font.h"
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/resource.h"
@@ -175,15 +178,56 @@ void Font::read(Common::SeekableReadStream &stream) {
 	assert(_textboxData);
 }
 
+void Font::readSystemFont(Common::SeekableReadStream &stream, bool bold) {
+	const int32 kBoldExtraWeight = 300;
+
+	Common::String faceName;
+	readFilename(stream, _description);
+	readFilename(stream, faceName);
+	int32 height = stream.readSint32LE();
+	int32 weight = stream.readSint32LE();
+
+	if (bold) {
+		_description += "_Bold";
+		weight += kBoldExtraWeight;
+	}
+
+	// None of the Windows fonts the game asks for (Tahoma, Arial, Lucida Handwriting)
+	// ship with ScummVM, so they all get replaced with the same sans-serif font
+#ifdef USE_FREETYPE2
+	const int32 kBoldWeight = 700;
+	const char *substitute = weight >= kBoldWeight ? "LiberationSans-Bold.ttf" : "LiberationSans-Regular.ttf";
+	_systemFont.reset(Graphics::loadTTFFontFromArchive(substitute, height, Graphics::kTTFSizeModeCell), DisposeAfterUse::YES);
+#endif
+
+	if (!_systemFont) {
+		warning("Could not load a substitute for font %s (%s, height %d, weight %d)", _description.c_str(), faceName.c_str(), height, weight);
+		_systemFont.reset(FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont), DisposeAfterUse::NO);
+	}
+}
+
 int Font::getCharWidth(uint32 chr) const {
+	if (_systemFont) {
+		return _systemFont->getCharWidth(chr);
+	}
+
 	return getCharacterSourceRect(chr).width() + _charSpace;
 }
 
 int Font::getLineHeight() const {
+	if (_systemFont) {
+		return _systemFont->getFontHeight();
+	}
+
 	return _characterRects.empty() ? getFontHeight() : _characterRects[0].height();
 }
 
 void Font::drawChar(Graphics::Surface *dst, uint32 chr, int x, int y, uint32 color) const {
+	if (_systemFont) {
+		_systemFont->drawChar(dst, chr, x, y, g_nancy->_graphics->getFontColor(color, dst->format));
+		return;
+	}
+
 	Common::Rect srcRect = getCharacterSourceRect(chr);
 	if (color == 0) {
 		srcRect.translate(_color0CoordsOffset.x, _color0CoordsOffset.y);
@@ -209,6 +253,10 @@ void Font::drawChar(Graphics::Surface *dst, uint32 chr, int x, int y, uint32 col
 }
 
 uint32 Font::getColorPixel(uint color) const {
+	if (_systemFont) {
+		return g_nancy->_graphics->getFontColor(color, g_nancy->_graphics->getInputPixelFormat());
+	}
+
 	// The glyphs are pre-colored in the atlas (two color variants selected by
 	// the color-coordinate offsets, mirroring drawChar). Scan a solid glyph for
 	// the first non-transparent pixel to recover the color an underline should
diff --git a/engines/nancy/font.h b/engines/nancy/font.h
index 6a76ac4f188..97e37a8f64e 100644
--- a/engines/nancy/font.h
+++ b/engines/nancy/font.h
@@ -22,6 +22,7 @@
 #define NANCY_FONT_H
 
 #include "common/array.h"
+#include "common/ptr.h"
 
 #include "graphics/font.h"
 #include "graphics/managed_surface.h"
@@ -43,14 +44,22 @@ public:
 
 	void read(Common::SeekableReadStream &stream);
 
-	int getFontHeight() const override { return _fontHeight - 1; }
+	// Nancy16+ uses system fonts, which get drawn with a TrueType substitute.
+	// The color passed when drawing is then a font color ID. Every font also
+	// comes in bold, which gets "_Bold" added to its name.
+	void readSystemFont(Common::SeekableReadStream &stream, bool bold);
+	const Common::String &getName() const { return _description; }
+	// Nancy16+: the substitute font, for drawing with an explicit color instead of a color ID
+	const Graphics::Font *getSystemFont() const { return _systemFont.get(); }
+
+	int getFontHeight() const override { return _systemFont ? _systemFont->getFontHeight() : _fontHeight - 1; }
 
 	// The vertical advance between text lines (height of the first glyph).
 	int getLineHeight() const;
 
-	int getMaxCharWidth() const override { return _maxCharWidth; }
+	int getMaxCharWidth() const override { return _systemFont ? _systemFont->getMaxCharWidth() : _maxCharWidth; }
 	int getCharWidth(uint32 chr) const override;
-	int getKerningOffset(uint32 left, uint32 right) const override { return 0; }
+	int getKerningOffset(uint32 left, uint32 right) const override { return _systemFont ? _systemFont->getKerningOffset(left, right) : 0; }
 
 	void drawChar(Graphics::Surface *dst, uint32 chr, int x, int y, uint32 color) const override;
 
@@ -156,6 +165,8 @@ private:
 
 	const struct TBOX *_textboxData = nullptr;
 
+	Common::DisposablePtr<const Graphics::Font> _systemFont{nullptr, DisposeAfterUse::NO};
+
 	int _fontHeight = 0;
 	int _maxCharWidth = 0;
 	uint _transColor = 0;
diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index b5aa9fa1cd0..6fca1848657 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -25,6 +25,7 @@
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/graphics.h"
+#include "engines/nancy/iff.h"
 #include "engines/nancy/renderobject.h"
 #include "engines/nancy/resource.h"
 #include "engines/nancy/cursor.h"
@@ -180,6 +181,65 @@ void GraphicsManager::loadFonts(Common::SeekableReadStream *chunkStream) {
 	delete chunkStream;
 }
 
+// Fonts get placed at their IDs, which font tags in text refer to. The bold versions
+// follow them, and can only be found by name.
+void GraphicsManager::loadSystemFonts(const IFF &fontIFF) {
+	Common::SeekableReadStream *chunkStream = nullptr;
+	for (uint i = 0; (chunkStream = fontIFF.getChunkStream("FONT", i)); ++i) {
+		uint32 id = chunkStream->readUint32LE();
+		if (id >= _fonts.size()) {
+			_fonts.resize(id + 1);
+		}
+
+		_fonts[id].readSystemFont(*chunkStream, false);
+		delete chunkStream;
+	}
+
+	for (uint i = 0; (chunkStream = fontIFF.getChunkStream("FONT", i)); ++i) {
+		chunkStream->skip(4);
+		_fonts.resize(_fonts.size() + 1);
+		_fonts.back().readSystemFont(*chunkStream, true);
+		delete chunkStream;
+	}
+
+	for (uint i = 0; (chunkStream = fontIFF.getChunkStream("COLR", i)); ++i) {
+		uint32 id = chunkStream->readUint32LE();
+		if (id >= _fontColors.size()) {
+			_fontColors.resize(id + 1);
+		}
+
+		// ARGB colors for the normal, disabled, mouse over and pressed states. The
+		// focused state looks normal, and the hidden one is transparent.
+		FontColor &color = _fontColors[id];
+		color.colors[kFontColorNormal] = chunkStream->readUint32BE();
+		color.colors[kFontColorFocus] = color.colors[kFontColorNormal];
+		color.colors[kFontColorDisabled] = chunkStream->readUint32BE();
+		color.colors[kFontColorMouseOver] = chunkStream->readUint32BE();
+		color.colors[kFontColorPressed] = chunkStream->readUint32BE();
+		delete chunkStream;
+	}
+}
+
+const Font *GraphicsManager::getFont(const Common::String &name) const {
+	int id = getFontID(name);
+	return id >= 0 ? &_fonts[id] : nullptr;
+}
+
+int GraphicsManager::getFontID(const Common::String &name) const {
+	for (uint i = 0; i < _fonts.size(); ++i) {
+		if (_fonts[i].getName().equalsIgnoreCase(name)) {
+			return i;
+		}
+	}
+
+	return -1;
+}
+
+uint32 GraphicsManager::getFontColor(uint id, const Graphics::PixelFormat &format, FontColorState state) const {
+	uint32 argb = id < _fontColors.size() ? _fontColors[id].colors[state] : 0xFFFFFFFF;
+	return format.ARGBToColor(argb >> 24, (argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF);
+}
+
 void GraphicsManager::addObject(RenderObject *object) {
 	for (auto &r : _objects) {
 		if (r == object) {
diff --git a/engines/nancy/graphics.h b/engines/nancy/graphics.h
index c23fe308d40..a89a54c3e20 100644
--- a/engines/nancy/graphics.h
+++ b/engines/nancy/graphics.h
@@ -30,8 +30,20 @@
 
 namespace Nancy {
 
+class IFF;
 class RenderObject;
 
+// Nancy16+ font colors have one color for each state of a UI control
+enum FontColorState {
+	kFontColorNormal		= 0,
+	kFontColorDisabled		= 1,
+	kFontColorHidden		= 2,
+	kFontColorFocus			= 3,
+	kFontColorMouseOver		= 4,
+	kFontColorPressed		= 5,
+	kNumFontColorStates		= 6
+};
+
 // Graphics class that handles multilayered surface rendering with minimal redraw
 class GraphicsManager {
 public:
@@ -41,6 +53,7 @@ public:
 	void draw(bool updateScreen = true);
 
 	void loadFonts(Common::SeekableReadStream *chunkStream);
+	void loadSystemFonts(const IFF &fontIFF);
 
 	void addObject(RenderObject *object);
 	void removeObject(RenderObject *object);
@@ -52,6 +65,9 @@ public:
 	void suppressNextDraw();
 
 	const Font *getFont(uint id) const { return id < _fonts.size() ? &_fonts[id] : nullptr; }
+	const Font *getFont(const Common::String &name) const;
+	int getFontID(const Common::String &name) const;
+	uint32 getFontColor(uint id, const Graphics::PixelFormat &format, FontColorState state = kFontColorNormal) const;
 	const Graphics::Screen *getScreen() { return &_screen; }
 
 	const Graphics::PixelFormat &getInputPixelFormat(uint bpp = 0);
@@ -94,6 +110,11 @@ private:
 
 	Graphics::Screen _screen;
 	Common::Array<Font> _fonts;
+	struct FontColor {
+		uint32 colors[kNumFontColorStates] = {};
+	};
+
+	Common::Array<FontColor> _fontColors;
 
 	Common::List<Common::Rect> _dirtyRects;
 
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 2f8829d8dcc..90be928e77a 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -750,7 +750,13 @@ void NancyEngine::bootGameEngine() {
 		IFF *fontIFF = _resource->loadIFF("font");
 		if (!fontIFF)
 			error("Failed to load font IFF");
-		_graphics->loadFonts(fontIFF->getChunkStream("FONT"));
+
+		if (getGameType() >= kGameTypeNancy16) {
+			_graphics->loadSystemFonts(*fontIFF);
+		} else {
+			_graphics->loadFonts(fontIFF->getChunkStream("FONT"));
+		}
+
 		delete fontIFF;
 	}
 


Commit: 0fab21e2c8aed22c4bcf367ebc7eaddfef32b8c9
    https://github.com/scummvm/scummvm/commit/0fab21e2c8aed22c4bcf367ebc7eaddfef32b8c9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:41+03:00

Commit Message:
NANCY: NANCY16: Draw hypertext with the system fonts

- Text can start in its own color and justification
- Color tags pick the font color of the control state

Changed paths:
    engines/nancy/misc/hypertext.cpp
    engines/nancy/misc/hypertext.h


diff --git a/engines/nancy/misc/hypertext.cpp b/engines/nancy/misc/hypertext.cpp
index 075cd4902b4..f9a20534377 100644
--- a/engines/nancy/misc/hypertext.cpp
+++ b/engines/nancy/misc/hypertext.cpp
@@ -23,6 +23,7 @@
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/resource.h"
+#include "engines/nancy/font.h"
 
 #include "engines/nancy/misc/hypertext.h"
 
@@ -60,7 +61,39 @@ void HypertextParser::setImageName(const Common::Path &name) {
 	_imageName = name;
 }
 
+void HypertextParser::setDefaultTextRGB(byte r, byte g, byte b) {
+	_defaultTextRGB[0] = r;
+	_defaultTextRGB[1] = g;
+	_defaultTextRGB[2] = b;
+	_hasDefaultTextRGB = true;
+}
+
+void HypertextParser::drawTextRun(Graphics::ManagedSurface &surface, const Font *font, const Common::String &text, int x, int y, int width, int colorID) const {
+	if (font->getSystemFont()) {
+		font->getSystemFont()->drawString(&surface, text, x, y, width, getColorPixel(font, colorID));
+	} else {
+		font->drawString(&surface, text, x, y, width, colorID);
+	}
+}
+
+uint32 HypertextParser::getColorPixel(const Font *font, int colorID) const {
+	if (colorID == kDefaultRGB) {
+		return _fullSurface.format.ARGBToColor(255, _defaultTextRGB[0], _defaultTextRGB[1], _defaultTextRGB[2]);
+	}
+
+	if (font->getSystemFont()) {
+		return g_nancy->_graphics->getFontColor(colorID, _fullSurface.format, _fontColorState);
+	}
+
+	return font->getColorPixel(colorID);
+}
+
 static uint lineStep(const Font *font) {
+	// Nancy16+ system fonts put their lines right under each other
+	if (font->getSystemFont()) {
+		return font->getFontHeight();
+	}
+
 	if (g_nancy->getGameType() >= kGameTypeNancy10) {
 		const uint h = font->getLineHeight();
 		return h + h / 9;
@@ -311,8 +344,8 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 		// Go through the wrapped lines and draw them, making sure to
 		// respect color tokens
 		uint totalCharsDrawn = 0;
-		byte colorID = _defaultTextColor;
-		uint justification = kJustifyLeft;
+		int colorID = _hasDefaultTextRGB ? kDefaultRGB : (int)_defaultTextColor;
+		uint justification = _defaultJustification;
 		bool underline = false;
 		uint numNewlineTokens = 0;
 		uint horizontalOffset = 0;
@@ -322,11 +355,7 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 			horizontalOffset = 0;
 			newLineStart = false;
 
-			// Draw images
-			if (newlineTokens.empty()) {
-				warning("HypertextParser::drawAllText():: newlineTokens list was empty at line %u out of %u wrapped lines", lineNumber+1, wrappedLines.size());
-			}
-
+			// Draw images. Lines wrapped past the last newline token don't start a new line.
 			if (!newlineTokens.empty() && newlineTokens.front() <= totalCharsDrawn) {
 				newlineTokens.pop();
 				newLineStart = true;
@@ -495,29 +524,22 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 				// Draw the normal text
 				const int drawX = textBounds.left + horizontalOffset + (newLineStart ? 0 : leftOffsetNonNewline);
 				const int drawY = textBounds.top + _numDrawnLines * lineStep(font) + _imageVerticalOffset;
-				font->drawString(				&_fullSurface,
-												stringToDraw,
-												drawX,
-												drawY,
-												textBounds.width(),
-												colorID);
+				drawTextRun(_fullSurface, font, stringToDraw, drawX, drawY, textBounds.width(), colorID);
 
 				// Underline the segment (the <u> markup toggle) in the text color.
 				if (underline && !stringToDraw.empty()) {
 					const int underlineWidth = font->getStringWidth(stringToDraw);
 					const int underlineY = drawY + font->getFontHeight() - 1;
 					_fullSurface.fillRect(Common::Rect(drawX, underlineY, drawX + underlineWidth, underlineY + 1),
-											font->getColorPixel(colorID));
+											getColorPixel(font, colorID));
 				}
 
 				// Then, draw the highlight
 				if (hasHotspot && !_textHighlightSurface.empty()) {
-					highlightFont->drawString(	&_textHighlightSurface,
-												stringToDraw,
-												textBounds.left + horizontalOffset + (newLineStart ? leftOffsetNonNewline : 0),
-												textBounds.top + _numDrawnLines * lineStep(highlightFont) + _imageVerticalOffset,
-												textBounds.width(),
-												colorID);
+					drawTextRun(_textHighlightSurface, highlightFont, stringToDraw,
+								textBounds.left + horizontalOffset + (newLineStart ? leftOffsetNonNewline : 0),
+								textBounds.top + _numDrawnLines * lineStep(highlightFont) + _imageVerticalOffset,
+								textBounds.width(), colorID);
 				}
 
 				// Count number of non-space characters drawn. Used for color.
diff --git a/engines/nancy/misc/hypertext.h b/engines/nancy/misc/hypertext.h
index 3aaa9d32b34..72d69341871 100644
--- a/engines/nancy/misc/hypertext.h
+++ b/engines/nancy/misc/hypertext.h
@@ -23,6 +23,7 @@
 #define NANCY_MISC_HYPERTEXT_H
 
 #include "engines/nancy/renderobject.h"
+#include "engines/nancy/graphics.h"
 
 namespace Nancy {
 
@@ -66,12 +67,18 @@ protected:
 
 	void drawInlineImage(const InlineImage &inlineImage, const Common::Rect &textBounds, uint horizontalOffset, uint justification, const Font *font);
 
+	// Nancy16 UI text starts out in a color of its own, until a color tag picks one of
+	// the font colors; that color then gets used in place of _defaultTextColor
+	void setDefaultTextRGB(byte r, byte g, byte b);
+
 	Graphics::ManagedSurface _fullSurface; 			// Contains all rendered text (may be cropped)
 	Graphics::ManagedSurface _textHighlightSurface; // Same as above, but drawn with the highlight font
 
 	uint32 _backgroundColor;
 	uint32 _highlightBackgroundColor;
 	uint _defaultTextColor;
+	uint _defaultJustification = kJustifyLeft;
+	FontColorState _fontColorState = kFontColorNormal;	// Nancy16+: picks the color of a color tag
 	int _imageVerticalOffset;
 
 	Common::Array<Common::String> _textLines;
@@ -91,6 +98,16 @@ protected:
 	uint16 _numDrawnLines;
 	uint16 _drawnTextHeight;
 	bool _needsTextRedraw;
+
+private:
+	// Color IDs are font color IDs, or this for the color given to setDefaultTextRGB()
+	static const int kDefaultRGB = -1;
+
+	void drawTextRun(Graphics::ManagedSurface &surface, const Font *font, const Common::String &text, int x, int y, int width, int colorID) const;
+	uint32 getColorPixel(const Font *font, int colorID) const;
+
+	bool _hasDefaultTextRGB = false;
+	byte _defaultTextRGB[3] = {};
 };
 
 } // End of namespace Misc


Commit: 42d743d8adf88ae412cd170a4336e0cc7c5c087c
    https://github.com/scummvm/scummvm/commit/42d743d8adf88ae412cd170a4336e0cc7c5c087c
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:44+03:00

Commit Message:
NANCY: NANCY16: Use a 800x600 resolution, and scale the viewport

- Nancy16+ games run at a 800x600 resolution, instead of 640x480
- The game assets and area is still at 640x480 internally
- The 640x480 game area is drawn at its own size and scaled up
- Native resolution objects are drawn over it at the screen's size
- The mouse is converted between the two
- VIEW rects are no longer inclusive

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


diff --git a/engines/nancy/cursor.cpp b/engines/nancy/cursor.cpp
index 246009c6731..e21ca26bf80 100644
--- a/engines/nancy/cursor.cpp
+++ b/engines/nancy/cursor.cpp
@@ -437,7 +437,8 @@ void CursorManager::applyCursor() {
 	}
 
 	if (_warpedMousePos.x != -500 && _warpedMousePos.y != -500) {
-		g_system->warpMouse(_warpedMousePos.x, _warpedMousePos.y);
+		Common::Point screenPos = g_nancy->_graphics->gameToScreen(_warpedMousePos);
+		g_system->warpMouse(screenPos.x, screenPos.y);
 		_warpedMousePos.x = -500;
 		_warpedMousePos.y = -500;
 	}
diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index 6fca1848657..10354bcc540 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -42,7 +42,7 @@ GraphicsManager::GraphicsManager() :
 	_screenPixelFormat32(Graphics::PixelFormat::createFormatBGRA32()),
 	_clut8Format(Graphics::PixelFormat::createFormatCLUT8()),
 	_transparentPixelFormat(Graphics::PixelFormat::createFormatBGRA32()),
-	_screen(640, 480, getScreenPixelFormat()),
+	_screen(getScreenBounds().width(), getScreenBounds().height(), getScreenPixelFormat()),
 	_isSuppressed(false){}
 void GraphicsManager::init() {
 	auto *bsum = GetEngineData(BSUM);
@@ -58,10 +58,21 @@ void GraphicsManager::init() {
 					  (bsum->bTrans << format.bShift);
 	}
 
-	initGraphics(640, 480, &getScreenPixelFormat());
+	initGraphics(_screen.w, _screen.h, &getScreenPixelFormat());
 	_screen.setTransparentColor(getTransColor());
 	_screen.clear();
 
+	// Nancy16 runs at a higher resolution than the game was made for. The
+	// viewport's chunk holds the resolution the game gets drawn at.
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		auto *viewportData = GetEngineData(VIEW);
+		assert(viewportData);
+
+		_gameSurface.create(viewportData->bounds.width(), viewportData->bounds.height(), getScreenPixelFormat());
+		_gameSurface.setTransparentColor(getTransColor());
+		_gameSurface.clear();
+	}
+
 	// OB0 has been dropped in Nancy16+
 	const ImageChunk *ob0 = (const ImageChunk *)g_nancy->getEngineData("OB0");
 	if (ob0)
@@ -87,14 +98,14 @@ void GraphicsManager::draw(bool updateScreen) {
 			if (current.isVisible()) {
 				if (current.hasMoved() && !current.getPreviousScreenPosition().isEmpty()) {
 					// Object moved to a new location on screen, update the previous one
-					_dirtyRects.push_back(current.getPreviousScreenPosition());
+					addDirtyRect(current, current.getPreviousScreenPosition());
 				}
 
 				// Redraw the current location
-				_dirtyRects.push_back(current.getScreenPosition());
+				addDirtyRect(current, current.getScreenPosition());
 			} else if (!current.getPreviousScreenPosition().isEmpty()) {
 				// Object just turned invisible, redraw the last location
-				_dirtyRects.push_back(current.getPreviousScreenPosition());
+				addDirtyRect(current, current.getPreviousScreenPosition());
 			}
 		}
 
@@ -103,23 +114,74 @@ void GraphicsManager::draw(bool updateScreen) {
 		current.updatePreviousScreenPosition();
 	}
 
-	// Filter out dirty rects that are completely inside others to reduce overdraw
-	for (auto outer = _dirtyRects.begin(); outer != _dirtyRects.end(); ++outer) {
-		for (auto inner = _dirtyRects.begin(); inner != _dirtyRects.end(); ++inner) {
+	filterDirtyRects(_dirtyRects);
+
+	if (!isGameScaled()) {
+		drawObjects(_screen, _dirtyRects, kLayerAll);
+	} else {
+		// The game gets drawn at its own resolution, and the changed parts get scaled
+		// onto the screen, where the objects at the screen's resolution go over them
+		drawObjects(_gameSurface, _dirtyRects, kLayerGame);
+
+		for (const Common::Rect &rect : _dirtyRects) {
+			// Filtering blends each screen pixel with its game pixel's neighbors
+			Common::Rect screenRect = gameToScreen(rect);
+			screenRect.grow(2);
+			screenRect.clip(Common::Rect(_screen.w, _screen.h));
+			_nativeDirtyRects.push_back(screenRect);
+		}
+
+		filterDirtyRects(_nativeDirtyRects);
+
+		for (const Common::Rect &rect : _nativeDirtyRects) {
+			blitGameToScreen(rect);
+		}
+
+		drawObjects(_screen, _nativeDirtyRects, kLayerNative);
+	}
+
+	// Draw the screen
+	if (updateScreen) {
+		_screen.update();
+	}
+
+	// Remove all dirty rects for the next frame
+	_dirtyRects.clear();
+	_nativeDirtyRects.clear();
+}
+
+void GraphicsManager::addDirtyRect(const RenderObject &object, const Common::Rect &rect) {
+	if (isGameScaled() && object.isNativeResolution()) {
+		_nativeDirtyRects.push_back(rect);
+	} else {
+		_dirtyRects.push_back(rect);
+	}
+}
+
+bool GraphicsManager::isInLayer(const RenderObject &object, Layer layer) const {
+	return layer == kLayerAll || object.isNativeResolution() == (layer == kLayerNative);
+}
+
+// Filter out dirty rects that are completely inside others to reduce overdraw
+void GraphicsManager::filterDirtyRects(Common::List<Common::Rect> &rects) {
+	for (auto outer = rects.begin(); outer != rects.end(); ++outer) {
+		for (auto inner = rects.begin(); inner != rects.end(); ++inner) {
 			if (inner != outer && outer->contains(*inner)) {
-				_dirtyRects.erase(inner);
+				rects.erase(inner);
 				break;
 			}
 		}
 	}
+}
 
-	// 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)
-	for (Common::Rect rect : _dirtyRects) {
+// 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 (!current.isVisible() || current.getScreenPosition().isEmpty()) {
+			if (!isInLayer(current, layer) || !current.isVisible() || current.getScreenPosition().isEmpty()) {
 				continue;
 			}
 
@@ -132,7 +194,7 @@ void GraphicsManager::draw(bool updateScreen) {
 				for (auto it2 = it + 1; it2 < _objects.end(); ++it2) {
 					RenderObject &other = **it2;
 
-					if (!other.isVisible() || other.getScreenPosition().isEmpty()) {
+					if (!isInLayer(other, layer) || !other.isVisible() || other.getScreenPosition().isEmpty()) {
 						continue;
 					}
 
@@ -153,18 +215,93 @@ void GraphicsManager::draw(bool updateScreen) {
 					continue;
 				}
 
-				blitToScreen(current, rect.findIntersectingRect(current.getScreenPosition()));
+				blitObject(target, current, rect.findIntersectingRect(current.getScreenPosition()));
 			}
 		}
 	}
+}
 
-	// Draw the screen
-	if (updateScreen) {
-		_screen.update();
+// The game pixel a screen pixel's center falls on, in 1/256ths of a pixel, as texture
+// sampling puts it; the fraction is how much of the next game pixel gets blended in
+static int getBilinearPosition(int screenPos, int screenSize, int gameSize) {
+	int pos = (2 * screenPos + 1) * gameSize * 128 / screenSize - 128;
+	return CLIP<int>(pos, 0, (gameSize - 1) * 256);
+}
+
+// The game gets scaled onto the screen with bilinear filtering, like the original's
+// Direct3D texture sampling does
+void GraphicsManager::blitGameToScreen(Common::Rect screenRect) {
+	screenRect.clip(Common::Rect(_screen.w, _screen.h));
+	if (screenRect.isEmpty()) {
+		return;
 	}
 
-	// Remove all dirty rects for the next frame
-	_dirtyRects.clear();
+	assert(_screen.format.bytesPerPixel == 4 && _gameSurface.format == _screen.format);
+
+	// Where each column samples the game, and how much of the next game pixel it takes
+	Common::Array<int> srcColumns(screenRect.width());
+	Common::Array<int> columnWeights(screenRect.width());
+	for (int x = screenRect.left; x < screenRect.right; ++x) {
+		int pos = getBilinearPosition(x, _screen.w, _gameSurface.w);
+		srcColumns[x - screenRect.left] = pos >> 8;
+		columnWeights[x - screenRect.left] = pos & 0xFF;
+	}
+
+	for (int y = screenRect.top; y < screenRect.bottom; ++y) {
+		int pos = getBilinearPosition(y, _screen.h, _gameSurface.h);
+		int srcY = pos >> 8;
+		int rowWeight = pos & 0xFF;
+
+		const byte *row0 = (const byte *)_gameSurface.getBasePtr(0, srcY);
+		const byte *row1 = (const byte *)_gameSurface.getBasePtr(0, MIN<int>(srcY + 1, _gameSurface.h - 1));
+		byte *dest = (byte *)_screen.getBasePtr(screenRect.left, y);
+
+		for (int i = 0; i < screenRect.width(); ++i) {
+			int x0 = srcColumns[i] * 4;
+			int x1 = MIN<int>(srcColumns[i] + 1, _gameSurface.w - 1) * 4;
+			int colWeight = columnWeights[i];
+
+			for (int channel = 0; channel < 4; ++channel) {
+				int top = row0[x0 + channel] * (256 - colWeight) + row0[x1 + channel] * colWeight;
+				int bottom = row1[x0 + channel] * (256 - colWeight) + row1[x1 + channel] * colWeight;
+				*dest++ = (top * (256 - rowWeight) + bottom * rowWeight + (1 << 15)) >> 16;
+			}
+		}
+	}
+
+	_screen.addDirtyRect(screenRect);
+}
+
+Common::Rect GraphicsManager::getGameBounds() const {
+	return isGameScaled() ? Common::Rect(_gameSurface.w, _gameSurface.h) : Common::Rect(_screen.w, _screen.h);
+}
+
+Common::Point GraphicsManager::screenToGame(const Common::Point &pos) const {
+	if (!isGameScaled()) {
+		return pos;
+	}
+
+	return Common::Point(pos.x * _gameSurface.w / _screen.w, pos.y * _gameSurface.h / _screen.h);
+}
+
+Common::Point GraphicsManager::gameToScreen(const Common::Point &pos) const {
+	if (!isGameScaled()) {
+		return pos;
+	}
+
+	return Common::Point(pos.x * _screen.w / _gameSurface.w, pos.y * _screen.h / _gameSurface.h);
+}
+
+// The screen area covering every pixel the game rect gets scaled onto
+Common::Rect GraphicsManager::gameToScreen(const Common::Rect &rect) const {
+	if (!isGameScaled()) {
+		return rect;
+	}
+
+	return Common::Rect(rect.left * _screen.w / _gameSurface.w,
+		rect.top * _screen.h / _gameSurface.h,
+		(rect.right * _screen.w + _gameSurface.w - 1) / _gameSurface.w,
+		(rect.bottom * _screen.h + _gameSurface.h - 1) / _gameSurface.h);
 }
 
 void GraphicsManager::loadFonts(Common::SeekableReadStream *chunkStream) {
@@ -270,7 +407,7 @@ void GraphicsManager::removeObject(RenderObject *object) {
 	for (auto &r : _objects) {
 		if (r == object) {
 			// Make sure the object gets properly cleared
-			_dirtyRects.push_back(r->getPreviousScreenPosition());
+			addDirtyRect(*r, r->getPreviousScreenPosition());
 			_objects.erase(&r);
 			break;
 		}
@@ -476,6 +613,10 @@ const Graphics::PixelFormat &GraphicsManager::getInputPixelFormat(uint bpp) {
 	}
 }
 
+Common::Rect GraphicsManager::getScreenBounds() {
+	return (g_nancy->getGameType() >= kGameTypeNancy16) ? Common::Rect(800, 600) : Common::Rect(640, 480);
+}
+
 const Graphics::PixelFormat &GraphicsManager::getScreenPixelFormat() {
 	return (g_nancy->getGameType() >= kGameTypeNancy13) ? _screenPixelFormat32 : _screenPixelFormat16;
 }
@@ -502,9 +643,9 @@ void GraphicsManager::screenshotScreen(Graphics::ManagedSurface &inSurf) {
 	inSurf.copyFrom(_screen);
 }
 
-// Draw a given screen-space rectangle to the screen
-void GraphicsManager::blitToScreen(const RenderObject &src, Common::Rect screenRect) {
-	_screen.blitFrom(src._drawSurface, src._drawSurface.getBounds().findIntersectingRect(src.convertToLocal(screenRect)), screenRect);
+// Draw a given screen-space rectangle of an object
+void GraphicsManager::blitObject(Graphics::ManagedSurface &target, const RenderObject &src, Common::Rect screenRect) {
+	target.blitFrom(src._drawSurface, src._drawSurface.getBounds().findIntersectingRect(src.convertToLocal(screenRect)), screenRect);
 }
 
 int GraphicsManager::objectComparator(const void *a, const void *b) {
diff --git a/engines/nancy/graphics.h b/engines/nancy/graphics.h
index a89a54c3e20..3e24c279224 100644
--- a/engines/nancy/graphics.h
+++ b/engines/nancy/graphics.h
@@ -69,6 +69,16 @@ public:
 	int getFontID(const Common::String &name) const;
 	uint32 getFontColor(uint id, const Graphics::PixelFormat &format, FontColorState state = kFontColorNormal) const;
 	const Graphics::Screen *getScreen() { return &_screen; }
+	static Common::Rect getScreenBounds();
+
+	// Nancy16 draws the game at a lower resolution than the screen's, and scales it up.
+	// 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(); }
+	Common::Rect getGameBounds() const;
+	Common::Point screenToGame(const Common::Point &pos) const;
+	Common::Point gameToScreen(const Common::Point &pos) const;
+	Common::Rect gameToScreen(const Common::Rect &rect) const;
 
 	const Graphics::PixelFormat &getInputPixelFormat(uint bpp = 0);
 	const Graphics::PixelFormat &getScreenPixelFormat();
@@ -97,7 +107,18 @@ public:
 
 
 private:
-	void blitToScreen(const RenderObject &src, Common::Rect dest);
+	enum Layer {
+		kLayerAll,		// the game isn't scaled
+		kLayerGame,
+		kLayerNative
+	};
+
+	void addDirtyRect(const RenderObject &object, const Common::Rect &rect);
+	bool isInLayer(const RenderObject &object, Layer layer) const;
+	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);
+	void blitGameToScreen(Common::Rect screenRect);
 
 	static int objectComparator(const void *a, const void *b);
 
@@ -116,7 +137,10 @@ private:
 
 	Common::Array<FontColor> _fontColors;
 
-	Common::List<Common::Rect> _dirtyRects;
+	Graphics::ManagedSurface _gameSurface;		// empty unless the game is scaled
+
+	Common::List<Common::Rect> _dirtyRects;			// in game coordinates
+	Common::List<Common::Rect> _nativeDirtyRects;	// in screen coordinates
 
 	Common::HashMap<uint16, Graphics::ManagedSurface> _autotextSurfaces;
 	Common::HashMap<uint16, Common::Rect> _autotextSurfaceBounds;
diff --git a/engines/nancy/input.cpp b/engines/nancy/input.cpp
index c63dcbe14ac..8ba99d7b82d 100644
--- a/engines/nancy/input.cpp
+++ b/engines/nancy/input.cpp
@@ -29,6 +29,7 @@
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/input.h"
+#include "engines/nancy/graphics.h"
 
 namespace Nancy {
 
@@ -154,7 +155,8 @@ NancyInput InputManager::getInput() const {
 	}
 
 	if (_mouseEnabled || g_nancy->getState() == NancyState::kCredits) {
-		ret.mousePos = g_nancy->getEventManager()->getMousePos();
+		ret.screenMousePos = g_nancy->getEventManager()->getMousePos();
+		ret.mousePos = g_nancy->_graphics->screenToGame(ret.screenMousePos);
 	} else {
 		ret.eatMouseInput();
 	}
diff --git a/engines/nancy/input.h b/engines/nancy/input.h
index 3f2c16777c4..310b9396279 100644
--- a/engines/nancy/input.h
+++ b/engines/nancy/input.h
@@ -61,11 +61,12 @@ struct NancyInput {
 		kMouseWheel				= kMouseWheelUp | kMouseWheelDown
 	};
 
-	Common::Point mousePos;
+	Common::Point mousePos;			// in game coordinates
+	Common::Point screenMousePos;	// Nancy16: for what gets drawn at the screen's own resolution
 	uint16 input;
 	Common::Array<Common::KeyState> otherKbdInput;
 
-	void eatMouseInput() { mousePos.x = -1; input &= ~(kLeftMouseButton | kRightMouseButton | kMouseWheel); }
+	void eatMouseInput() { mousePos.x = -1; screenMousePos.x = -1; input &= ~(kLeftMouseButton | kRightMouseButton | kMouseWheel); }
 
 	// Consumes just the wheel input, leaving the mouse position and buttons alone,
 	// so a widget that scrolls can still keep updating its hover state
diff --git a/engines/nancy/renderobject.h b/engines/nancy/renderobject.h
index 5befdb74601..6ba59ecfb89 100644
--- a/engines/nancy/renderobject.h
+++ b/engines/nancy/renderobject.h
@@ -57,6 +57,11 @@ public:
 
 	void setHasMoved(bool hasMoved) { _hasMoved = hasMoved; }
 
+	// Nancy16 scales the game up to fit the screen; these objects get drawn at the
+	// screen's own resolution instead, and are positioned in screen coordinates
+	bool isNativeResolution() const { return _isNativeResolution; }
+	void setNativeResolution(bool isNative) { _isNativeResolution = isNative; }
+
 	// Only used by The Vampire Diaries
 	void grabPalette(byte *colors, uint paletteStart = 0, uint paletteSize = 256);
 	void setPalette(const Common::Path &paletteName, uint paletteStart = 0, uint paletteSize = 256);
@@ -89,6 +94,7 @@ protected:
 	bool _needsRedraw;
 	bool _isVisible;
 	bool _hasMoved;
+	bool _isNativeResolution = false;
 	uint16 _z;
 	Common::Rect _previousScreenPosition;
 	Common::Rect _screenPosition;


Commit: 02f3fca772f7307acfd6322edee183372dbd2099
    https://github.com/scummvm/scummvm/commit/02f3fca772f7307acfd6322edee183372dbd2099
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:46+03:00

Commit Message:
NANCY: NANCY16: Fix BSUM and VIEW chunk handling

- BSUM chunk: Set the day field from the BSUM record
- VIEW chunk: rects aren't inclusive

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 5bc0b5d6f72..3496d6647fa 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -57,7 +57,8 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	s.syncAsUint16LE(firstScene.frameID, kGameTypeNancy16);
 	s.syncAsUint16LE(startTimeHours, kGameTypeNancy16);
 	s.syncAsUint16LE(startTimeMinutes, kGameTypeNancy16);
-	s.skip(7, kGameTypeNancy16);	// End of day fields, disabled in the game data
+	s.syncAsByte(dayValueIndex, kGameTypeNancy16);
+	s.skip(6, kGameTypeNancy16);	// End of day fields, disabled in the game data
 
 	s.syncAsUint16LE(firstScene.sceneID, kGameTypeVampire, kGameTypeNancy15);
 	s.skip(0xC, kGameTypeVampire, kGameTypeVampire); // Palette name + unknown 2 bytes
@@ -153,6 +154,14 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 VIEW::VIEW(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	readRect(*chunkStream, screenPosition);
 	readRect(*chunkStream, bounds);
+
+	// Nancy16's rects aren't inclusive; the viewport is exactly as large as the video
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		--screenPosition.right;
+		--screenPosition.bottom;
+		--bounds.right;
+		--bounds.bottom;
+	}
 }
 
 PCAL::PCAL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 749e2c4c4b5..1357637f9b1 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -64,6 +64,7 @@ struct BSUM : public EngineData {
 	// is raised so the scripts can send the player to bed. Writing to the value
 	// at dayValueIndex puts the player to sleep: the next day starts at
 	// wakeUpHour, and the new day number gets copied into that value.
+	// Nancy16 only keeps the day value, which goes up by one at midnight.
 	byte dayValueIndex = 0;
 	int16 endOfDayFlag = kEvNoEvent;
 	uint16 endOfDayHour = 0;


Commit: 86b7cf176dda15930afe0523acac1175fc77ecc2
    https://github.com/scummvm/scummvm/commit/86b7cf176dda15930afe0523acac1175fc77ecc2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:48+03:00

Commit Message:
NANCY: NANCY16: Show the logo screens

- LGx chunks list screens with an image and an optional movie
- They lead into the first scene (Nancy's room / interactive menu)

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 3496d6647fa..e7dc199ce79 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -922,6 +922,16 @@ ImageChunk::ImageChunk(Common::SeekableReadStream *chunkStream) : EngineData(chu
 	height = chunkStream->readUint16LE();
 }
 
+LogoScreen::LogoScreen(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	readFilename(*chunkStream, imageName);
+	readFilename(*chunkStream, movieName);
+	chunkStream->skip(8);
+	position.x = chunkStream->readSint16LE();
+	position.y = chunkStream->readSint16LE();
+
+	// Followed by a sound, which is always "NO SOUND"
+}
+
 CVTX::CVTX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	uint16 numEntries = chunkStream->readUint16LE();
 
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 1357637f9b1..559eebb54d9 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -499,6 +499,16 @@ struct ImageChunk : public EngineData {
 	uint16 height;
 };
 
+// Nancy16 logo screen, one per LGx chunk: an image, with an optional movie on top.
+// The screens are shown one after another before the game starts.
+struct LogoScreen : public EngineData {
+	LogoScreen(Common::SeekableReadStream *chunkStream);
+
+	Common::Path imageName;
+	Common::Path movieName;
+	Common::Point position;
+};
+
 // Contains text data. Every string is tagged with a key via which
 // it can be accessed. Used to store dialogue and journal (autotext) strings.
 // NOT found inside BOOT; these are stored in their own cifs, the names of which
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 90be928e77a..1d11dc4f13c 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -647,11 +647,18 @@ void NancyEngine::bootGameEngine() {
 	if (getGameType() <= kGameTypeNancy15) {
 		LOAD_BOOT_L(ImageChunk, "LG0")
 	} else {
-		// Nancy16 shifted the logo chunks by one: LG0 now holds the HeR logo that used to
-		// be in PLGO, while LG1 holds the intro splash that used to be in LG0. Load them
-		// under the keys the logo state expects.
-		LOAD_BOOT_L2(ImageChunk, "LG1", "LG0")
-		LOAD_BOOT_L2(ImageChunk, "LG0", "PLGO")
+		// Nancy16 has a list of logo screens, starting with the HeR logo, which used
+		// to be in PLGO
+		for (uint i = 0; ; ++i) {
+			Common::String name = Common::String::format("LG%u", i);
+			chunkStream = iff->getChunkStream(name);
+			if (!chunkStream) {
+				break;
+			}
+
+			_engineData.setVal(name, new LogoScreen(chunkStream));
+			delete chunkStream;
+		}
 	}
 
 	// One weird version of nancy3 has a partner logo implemented the same way as the other image chunks
diff --git a/engines/nancy/state/logo.cpp b/engines/nancy/state/logo.cpp
index f57103cf1b6..2386dbb46d1 100644
--- a/engines/nancy/state/logo.cpp
+++ b/engines/nancy/state/logo.cpp
@@ -49,6 +49,9 @@ void Logo::process() {
 	case kRun:
 		run();
 		break;
+	case kShowScreens:
+		runScreens();
+		break;
 	case kStop:
 		stop();
 	}
@@ -59,6 +62,8 @@ void Logo::onStateEnter(const NancyState::NancyState prevState) {
 	if (prevState == NancyState::kPause) {
 		if (_state == kPlayIntroVideo) {
 			_tvdVideoDecoder.pauseVideo(false);
+		} else if (_state == kShowScreens) {
+			_screenMovie.pauseVideo(false);
 		} else if (_state == kRun) {
 			g_nancy->_sound->pauseSound("MSND", false);
 		}
@@ -70,6 +75,8 @@ bool Logo::onStateExit(const NancyState::NancyState nextState) {
 	if (nextState == NancyState::kPause) {
 		if (_state == kPlayIntroVideo) {
 			_tvdVideoDecoder.pauseVideo(true);
+		} else if (_state == kShowScreens) {
+			_screenMovie.pauseVideo(true);
 		} else if (_state == kRun) {
 			g_nancy->_sound->pauseSound("MSND", true);
 		}
@@ -81,6 +88,16 @@ bool Logo::onStateExit(const NancyState::NancyState nextState) {
 }
 
 void Logo::init() {
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// The logo screens are made for the screen's resolution, not the game's
+		_logoImage.setNativeResolution(true);
+		_videoObj.setNativeResolution(true);
+		_screenID = 0;
+		_state = kShowScreens;
+		startScreen();
+		return;
+	}
+
 	const ImageChunk *lg0 = (const ImageChunk *)g_nancy->getEngineData("LG0");
 	const ImageChunk *plg0 = (const ImageChunk *)g_nancy->getEngineData("PLG0");
 	if (!plg0) {
@@ -151,12 +168,67 @@ void Logo::run() {
 	}
 }
 
+void Logo::startScreen() {
+	const LogoScreen *screen = (const LogoScreen *)g_nancy->getEngineData(Common::String::format("LG%u", _screenID));
+	if (!screen) {
+		_state = kStop;
+		return;
+	}
+
+	_logoImage.init(screen->imageName);
+	_logoImage.moveTo(screen->position);
+	_logoImage.registerGraphics();
+
+	_screenMovie.close();
+	_videoObj.setVisible(false);
+	if (!screen->movieName.empty() && _screenMovie.loadFile(screen->movieName)) {
+		_videoObj.moveTo(Common::Rect(screen->position.x, screen->position.y,
+			screen->position.x + _screenMovie.getWidth(), screen->position.y + _screenMovie.getHeight()));
+		_videoObj.registerGraphics();
+		_screenMovie.start();
+	}
+
+	_startTicks = g_nancy->getTotalPlayTime();
+}
+
+// A screen lasts until its movie ends, or for a while when it has none; a click skips it
+void Logo::runScreens() {
+	bool isDone = false;
+	if (_screenMovie.isVideoLoaded()) {
+		if (_screenMovie.needsUpdate()) {
+			const Graphics::Surface *frame = _screenMovie.decodeNextFrame();
+			if (frame) {
+				GraphicsManager::copyToManaged(*frame, _videoObj._drawSurface);
+				_videoObj.setVisible(true);
+			}
+		}
+
+		isDone = _screenMovie.endOfVideo();
+	} else {
+		isDone = g_nancy->getTotalPlayTime() - _startTicks >= g_nancy->getStaticData().logoEndAfter;
+	}
+
+	if (g_nancy->_input->getInput().input & NancyInput::kLeftMouseButtonDown) {
+		isDone = true;
+	}
+
+	if (isDone) {
+		++_screenID;
+		startScreen();
+	}
+}
+
 void Logo::stop() {
+	_screenMovie.close();
+
 	// The original engine checks for N+D and N+C key combos here.
 	// For the N+C key combo it looks for some kind of cheat file
 	// to initialize the game state with.
 
-	if (!ConfMan.hasKey("original_menus") || ConfMan.getBool("original_menus")) {
+	// Nancy16 has no main menu state; its logos lead straight into the first scene
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		g_nancy->setState(NancyState::kScene);
+	} else if (!ConfMan.hasKey("original_menus") || ConfMan.getBool("original_menus")) {
 		g_nancy->setState(NancyState::kMainMenu);
 	} else {
 		g_nancy->setState(NancyState::kScene);
diff --git a/engines/nancy/state/logo.h b/engines/nancy/state/logo.h
index 255a358714a..6be532c265b 100644
--- a/engines/nancy/state/logo.h
+++ b/engines/nancy/state/logo.h
@@ -26,6 +26,7 @@
 #include "video/avi_decoder.h"
 
 #include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
 #include "engines/nancy/state/state.h"
 #include "engines/nancy/ui/fullscreenimage.h"
 
@@ -49,11 +50,16 @@ private:
 	void run();
 	void stop();
 
+	// Nancy16 shows a list of logo screens instead
+	void startScreen();
+	void runScreens();
+
 	enum State {
 		kInit,
 		kPlayIntroVideo,
 		kStartSound,
 		kRun,
+		kShowScreens,
 		kStop
 	};
 
@@ -64,6 +70,9 @@ private:
 	SoundDescription _msnd;
 	Video::AVIDecoder _tvdVideoDecoder;
 	RenderObject _videoObj;
+
+	uint _screenID = 0;
+	MoviePlayer _screenMovie;
 };
 
 #define NancyLogoState State::Logo::instance()


Commit: 4025b882b79c637c557dc592f6d0b2fc2e87e135
    https://github.com/scummvm/scummvm/commit/4025b882b79c637c557dc592f6d0b2fc2e87e135
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:52+03:00

Commit Message:
NANCY: NANCY16: Implement the viewport fader

- The Fader record (type 40) arms a fade that covers the next scene change
- The new scene takes over once the fade has held, then fades in

Changed paths:
  A engines/nancy/misc/viewportfader.cpp
  A engines/nancy/misc/viewportfader.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/action/miscrecords.h
    engines/nancy/module.mk
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 4f49e1db3ee..8a4b00b0e0f 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -259,7 +259,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new SpecialEffect();
 		else
-			return nullptr;	//return new Fader();	// TODO
+			return new Fader();
 	case 41:	// Nancy13
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kSecondaryMovieTerse);
 	case 42:	// Nancy14
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 104afc000d6..28e31907d07 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -29,6 +29,7 @@
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/action/miscrecords.h"
+#include "engines/nancy/misc/viewportfader.h"
 
 #include "engines/nancy/state/scene.h"
 
@@ -429,6 +430,22 @@ void BumpPlayerClock::execute() {
 	finishExecution();
 }
 
+void Fader::readData(Common::SeekableReadStream &stream) {
+	stream.skip(4);	// fade time, unused: the fade speed is fixed
+	_holdTime = stream.readUint32LE();
+	_color = stream.readUint32BE() & 0xFFFFFF;
+}
+
+void Fader::execute() {
+	Misc::ViewportFader *fader = NancySceneState.getViewportFader();
+	assert(fader);
+
+	if (fader->isIdle()) {
+		fader->arm(_color, _holdTime);
+		finishExecution();
+	}
+}
+
 void SaveContinueGame::readData(Common::SeekableReadStream &stream) {
 	stream.skip(1);
 }
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index e0b461291f4..40665eab3e8 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -432,6 +432,21 @@ protected:
 	Common::String getRecordTypeName() const override { return "LoseGame"; }
 };
 
+// Nancy16 fade of the viewport to a solid color and back, covering the next scene
+// change. The fade itself is owned by the scene; the record only arms it, waiting
+// for any previous fade to end first.
+class Fader : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "Fader"; }
+
+	uint32 _holdTime = 0;	// ms
+	uint32 _color = 0;		// RGB
+};
+
 // Adds a scene to the "stack" (which is just a single value). Used in combination with PopScene.
 class PushScene : public ActionRecord {
 public:
diff --git a/engines/nancy/misc/viewportfader.cpp b/engines/nancy/misc/viewportfader.cpp
new file mode 100644
index 00000000000..4d1e3383645
--- /dev/null
+++ b/engines/nancy/misc/viewportfader.cpp
@@ -0,0 +1,128 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/cursor.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/misc/viewportfader.h"
+
+namespace Nancy {
+namespace Misc {
+
+// Each 1/30 of a second, the color keeps this much of its distance to the target
+static const float kBlendRate = 0.9f;
+
+static byte toByte(float channel) {
+	return (byte)(CLIP(channel, 0.0f, 1.0f) * 255 + 0.5f);
+}
+
+void ViewportFader::init() {
+	Common::Rect bounds = NancySceneState.getViewport().getScreenPosition();
+	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
+	_drawSurface.clear(0);
+	moveTo(bounds);
+	setVisible(false);
+}
+
+void ViewportFader::arm(uint32 color, uint32 holdTime) {
+	_target.a = 1.0f;
+	_target.r = ((color >> 16) & 0xFF) / 255.0f;
+	_target.g = ((color >> 8) & 0xFF) / 255.0f;
+	_target.b = (color & 0xFF) / 255.0f;
+	_holdTime = holdTime;
+
+	_state = kArmed;
+}
+
+void ViewportFader::onSceneChange() {
+	if (_state != kArmed) {
+		return;
+	}
+
+	_state = kFadingIn;
+	_lastUpdateTime = g_nancy->getTotalPlayTime();
+	g_nancy->_cursor->showCursor(false);
+}
+
+// Returns true once the color has reached the target
+bool ViewportFader::blendTowards(const Color &target, float seconds) {
+	float amount = 1.0f - powf(kBlendRate, 30.0f * seconds);
+	_current.a += (target.a - _current.a) * amount;
+	_current.r += (target.r - _current.r) * amount;
+	_current.g += (target.g - _current.g) * amount;
+	_current.b += (target.b - _current.b) * amount;
+
+	return toByte(_current.a) == toByte(target.a) && toByte(_current.r) == toByte(target.r) &&
+		toByte(_current.g) == toByte(target.g) && toByte(_current.b) == toByte(target.b);
+}
+
+void ViewportFader::updateGraphics() {
+	if (_state == kIdle || _state == kArmed) {
+		return;
+	}
+
+	uint32 now = g_nancy->getTotalPlayTime();
+	float seconds = (now - _lastUpdateTime) / 1000.0f;
+	_lastUpdateTime = now;
+
+	switch (_state) {
+	case kFadingIn:
+		if (blendTowards(_target, seconds)) {
+			_state = kHolding;
+			_holdEndTime = now + _holdTime;
+		}
+
+		break;
+	case kHolding:
+		if (now > _holdEndTime) {
+			_state = kFadingOut;
+			g_nancy->_cursor->showCursor(true);
+		}
+
+		break;
+	case kFadingOut:
+		// The fade goes back to transparent black
+		if (blendTowards(Color(), seconds)) {
+			_state = kIdle;
+		}
+
+		break;
+	default:
+		break;
+	}
+
+	redraw();
+}
+
+void ViewportFader::redraw() {
+	byte alpha = toByte(_current.a);
+	setVisible(alpha != 0);
+	if (alpha != 0) {
+		_drawSurface.clear(_drawSurface.format.ARGBToColor(alpha, toByte(_current.r), toByte(_current.g), toByte(_current.b)));
+	}
+
+	_needsRedraw = true;
+}
+
+} // End of namespace Misc
+} // End of namespace Nancy
diff --git a/engines/nancy/misc/viewportfader.h b/engines/nancy/misc/viewportfader.h
new file mode 100644
index 00000000000..03799bdb045
--- /dev/null
+++ b/engines/nancy/misc/viewportfader.h
@@ -0,0 +1,82 @@
+/* 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_MISC_VIEWPORTFADER_H
+#define NANCY_MISC_VIEWPORTFADER_H
+
+#include "engines/nancy/renderobject.h"
+
+namespace Nancy {
+namespace Misc {
+
+// Nancy16 covers scene changes by fading the viewport to a solid color, holding it,
+// and fading back once the next scene is in. An action record arms the fade, and the
+// next scene change starts it. The new scene only takes over after the hold.
+class ViewportFader : public RenderObject {
+public:
+	ViewportFader() : RenderObject(16) {}
+	virtual ~ViewportFader() {}
+
+	void init() override;
+	void updateGraphics() override;
+
+	bool isIdle() const { return _state == kIdle; }
+
+	// The color is RGB; the fade always ends fully opaque
+	void arm(uint32 color, uint32 holdTime);
+
+	// Starts an armed fade, when a scene change gets requested
+	void onSceneChange();
+
+	// The requested scene waits until the viewport is fully covered
+	bool holdsSceneChange() const { return _state == kFadingIn || _state == kHolding; }
+
+private:
+	enum State {
+		kIdle,
+		kArmed,
+		kFadingIn,
+		kHolding,
+		kFadingOut
+	};
+
+	struct Color {
+		float a = 0;
+		float r = 0;
+		float g = 0;
+		float b = 0;
+	};
+
+	bool blendTowards(const Color &target, float seconds);
+	void redraw();
+
+	State _state = kIdle;
+	Color _current;
+	Color _target;
+	uint32 _holdTime = 0;
+	uint32 _holdEndTime = 0;
+	uint32 _lastUpdateTime = 0;
+};
+
+} // End of namespace Misc
+} // End of namespace Nancy
+
+#endif // NANCY_MISC_VIEWPORTFADER_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index a6774c6904b..2e291effebc 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -118,6 +118,7 @@ MODULE_OBJS = \
   misc/hypertext.o \
   misc/lightning.o \
   misc/specialeffect.o \
+  misc/viewportfader.o \
   cif.o \
   commontypes.o \
   console.o \
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index aadbf6ba5d9..71e6a7b464b 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -47,6 +47,7 @@
 
 #include "engines/nancy/misc/lightning.h"
 #include "engines/nancy/misc/specialeffect.h"
+#include "engines/nancy/misc/viewportfader.h"
 
 namespace Common {
 DECLARE_SINGLETON(Nancy::State::Scene);
@@ -138,6 +139,7 @@ Scene::Scene() :
 		_activeMovie(nullptr),
 		_activeConversation(nullptr),
 		_lightning(nullptr),
+		_viewportFader(nullptr),
 		_destroyOnExit(false),
 		_isRunningAd(false),
 		_hotspotDebug(50) {}
@@ -152,6 +154,7 @@ Scene::~Scene() {
 	delete _clock;
 	delete _camera;
 	delete _lightning;
+	delete _viewportFader;
 
 	clearPuzzleData();
 }
@@ -167,6 +170,12 @@ void Scene::process() {
 
 		// fall through
 	case kLoad:
+		// The current scene keeps running under the fade that covers the change
+		if (_viewportFader && _viewportFader->holdsSceneChange()) {
+			run();
+			break;
+		}
+
 		load();
 		// fall through
 	case kStartSound:
@@ -249,6 +258,10 @@ void Scene::changeScene(const SceneChangeDescription &sceneDescription) {
 
 	_sceneState.nextScene = sceneDescription;
 	_state = kLoad;
+
+	if (_viewportFader) {
+		_viewportFader->onSceneChange();
+	}
 }
 
 void Scene::pushScene(int16 itemID) {
@@ -1255,6 +1268,10 @@ void Scene::registerGraphics() {
 		_inventoryBoxOrnaments->setVisible(true);
 	}
 
+	if (_viewportFader) {
+		_viewportFader->registerGraphics();
+	}
+
 	if (_clock) {
 		_clock->registerGraphics();
 	}
@@ -1780,6 +1797,11 @@ void Scene::init() {
 		_lightning = new Misc::Lightning();
 	}
 
+	if (g_nancy->getGameType() >= kGameTypeNancy16 && !_viewportFader) {
+		_viewportFader = new Misc::ViewportFader();
+		_viewportFader->init();
+	}
+
 	Common::Rect vpPos = _viewport.getScreenPosition();
 	_hotspotDebug._drawSurface.create(vpPos.width(), vpPos.height(), g_nancy->_graphics->getScreenPixelFormat());
 	_hotspotDebug.moveTo(vpPos);
@@ -2031,7 +2053,7 @@ void Scene::run() {
 
 	g_nancy->_sound->soundEffectMaintenance();
 
-	if (_state == kLoad) {
+	if (_state == kLoad && !(_viewportFader && _viewportFader->holdsSceneChange())) {
 		g_nancy->_graphics->suppressNextDraw();
 	}
 }
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 44426060bf6..d5c3a8fdb2c 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -60,6 +60,7 @@ class PlaySecondaryMovie;
 namespace Misc {
 class Lightning;
 class SpecialEffect;
+class ViewportFader;
 }
 
 namespace UI {
@@ -273,6 +274,7 @@ public:
 
 	// The Vampire Diaries only;
 	void beginLightning(int16 distance, uint16 pulseTime, int16 rgbPercent);
+	Misc::ViewportFader *getViewportFader() { return _viewportFader; }
 
 	// Used from nancy2 onwards
 	void specialEffect(byte type, uint16 fadeToBlackTime, uint16 frameTime);
@@ -441,6 +443,7 @@ private:
 	Common::HashMap<uint16, InventorySoundOverride> _inventorySoundOverrides[kMaxPlayerCharacters];
 
 	Misc::Lightning *_lightning;
+	Misc::ViewportFader *_viewportFader;
 	Common::Queue<Misc::SpecialEffect> _specialEffects;
 
 	Common::HashMap<uint32, PuzzleData *> _puzzleData;


Commit: c22d3d160a540471cb394168ccbcd5c44827f61f
    https://github.com/scummvm/scummvm/commit/c22d3d160a540471cb394168ccbcd5c44827f61f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:54+03:00

Commit Message:
NANCY: NANCY16: Add the HideCursor record

HideCursor (AR 27) hides the cursor and blocks the mouse while it's
active

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 8a4b00b0e0f..7d655346a18 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -235,7 +235,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new HotMultiframeInvTypeSceneChange();
 		else
-			return nullptr;	//return new HideCursor();	// TODO
+			return new HideCursor();
 	case 28:	// Nancy10
 		return new InteractiveVideo();	// Moved from 26
 	case 29:	// Nancy10
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 28e31907d07..8a8afadecd8 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -899,6 +899,20 @@ void GotoMenu::execute() {
 	_isDone = true;
 }
 
+HideCursor::~HideCursor() {
+	if (_isHiding) {
+		g_nancy->setMouseEnabled(true);
+	}
+}
+
+void HideCursor::execute() {
+	if (_state == kBegin) {
+		g_nancy->setMouseEnabled(false);
+		_isHiding = true;
+		_state = kRun;
+	}
+}
+
 void LoseGame::readData(Common::SeekableReadStream &stream) {
 	stream.skip(1);
 }
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 40665eab3e8..8a79b72be69 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -432,6 +432,20 @@ protected:
 	Common::String getRecordTypeName() const override { return "LoseGame"; }
 };
 
+// Nancy16+. Hides the cursor and blocks mouse input for as long as the record exists.
+class HideCursor : public ActionRecord {
+public:
+	~HideCursor() override;
+
+	void readData(Common::SeekableReadStream &stream) override {}
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "HideCursor"; }
+
+	bool _isHiding = false;
+};
+
 // Nancy16 fade of the viewport to a solid color and back, covering the next scene
 // change. The fade itself is owned by the scene; the record only arms it, waiting
 // for any previous fade to end first.


Commit: 90b115304b5e7ac24e4703967a04d6b21e5255bc
    https://github.com/scummvm/scummvm/commit/90b115304b5e7ac24e4703967a04d6b21e5255bc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:55+03:00

Commit Message:
NANCY: NANCY16: Track the day value

- Table index 255 (BSUM day value index) holds the day, which goes up at midnight
- All byte indices of the value table are valid

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


diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index cbfad7203be..765aae4add9 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -388,6 +388,11 @@ uint TableData::getNumSingleValues() const {
 }
 
 uint TableData::getNumComboValues() const {
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// Every byte index is a valid value
+		return 256 - getNumSingleValues();
+	}
+
 	return g_nancy->getGameType() == kGameTypeNancy8 ? 20 : 10;
 }
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 71e6a7b464b..f3a40aaad61 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2020,8 +2020,13 @@ void Scene::run() {
 		auto *bootSummary = GetEngineData(BSUM);
 		assert(bootSummary);
 
+		uint16 day = _timers.playerTime.getDays();
 		_timers.playerTime += 60000; // Add a minute
 		_timers.playerTimeNextMinute = currentPlayTime + bootSummary->playerTimeMinuteLength;
+
+		if (g_nancy->getGameType() >= kGameTypeNancy16 && _timers.playerTime.getDays() != day) {
+			advanceDayValue();
+		}
 	}
 
 	updateEndOfDay();
@@ -2082,6 +2087,19 @@ void Scene::updateEndOfDay() {
 	}
 }
 
+void Scene::advanceDayValue() {
+	auto *bootSummary = GetEngineData(BSUM);
+	assert(bootSummary);
+
+	TableData *playerTable = (TableData *)getPuzzleData(TableData::getTag());
+	if (!playerTable) {
+		return;
+	}
+
+	int16 day = playerTable->getValue(bootSummary->dayValueIndex);
+	playerTable->setValue(bootSummary->dayValueIndex, day == kNoTableValue ? 1 : day + 1);
+}
+
 void Scene::setPlayerDay(int16 day) {
 	auto *bootSummary = GetEngineData(BSUM);
 	assert(bootSummary);
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index d5c3a8fdb2c..4d873b6acac 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -327,6 +327,7 @@ private:
 	// once it gets late. In Nancy14-15, also starts the next day at the wake-up
 	// hour after the player has been sent to sleep.
 	void updateEndOfDay();
+	void advanceDayValue();
 	void fireSoftwareTimer(TimerData::Timer &timer);
 	void fireTimerTrigger(TimerData::Trigger &trigger);
 


Commit: e19a4e8506ba0ab1ec9852ac573a650835621407
    https://github.com/scummvm/scummvm/commit/e19a4e8506ba0ab1ec9852ac573a650835621407
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:16:58+03:00

Commit Message:
NANCY: NANCY16: Read event flag records

Changed paths:
    engines/nancy/action/datarecords.cpp
    engines/nancy/action/datarecords.h
    engines/nancy/commontypes.cpp
    engines/nancy/commontypes.h


diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index 89350e50c82..76e149aca76 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -119,8 +119,13 @@ void TableIndexSetValueHS::execute() {
 
 void SetValue::readData(Common::SeekableReadStream &stream) {
 	_index = stream.readByte();
-	_shouldSet = stream.readByte();
+	_mode = (SetMode)stream.readByte();
 	_value = stream.readSint16LE();
+
+	// Nancy16 can set a random value in [_value, _randomMax)
+	if (g_nancy->getGameType() >= kGameTypeNancy16 && _mode == kSetRandom) {
+		_randomMax = stream.readSint16LE();
+	}
 }
 
 void SetValue::execute() {
@@ -135,21 +140,26 @@ void SetValue::execute() {
 		NancySceneState.requestSleep();
 	}
 
+	int16 value = _value;
+	if (_mode == kSetRandom && _randomMax > _value) {
+		value += g_nancy->_randomSource->getRandomNumber(_randomMax - _value - 1);
+	}
+
 	if (_index < numSingleValues) {
 		// Single values
 		int16 curValue = playerTable->getSingleValue(_index);
-		if (_shouldSet || curValue == kNoTableValue) {
-			playerTable->setSingleValue(_index, _value);
+		if (_mode != kAdd || curValue == kNoTableValue) {
+			playerTable->setSingleValue(_index, value);
 		} else {
-			playerTable->setSingleValue(_index, curValue + _value);
+			playerTable->setSingleValue(_index, curValue + value);
 		}
 	} else {
 		// Combo values
 		float curValue = playerTable->getComboValue(_index - numSingleValues);
-		if (_shouldSet || curValue == (float)kNoTableValue) {
-			playerTable->setComboValue(_index - numSingleValues, _value);
+		if (_mode != kAdd || curValue == (float)kNoTableValue) {
+			playerTable->setComboValue(_index - numSingleValues, value);
 		} else {
-			playerTable->setComboValue(_index - numSingleValues, curValue + _value);
+			playerTable->setComboValue(_index - numSingleValues, curValue + value);
 		}
 	}
 
@@ -341,8 +351,7 @@ void ValueTest::execute() {
 
 Common::String EventFlags::getRecordExtraInfo() const {
 	Common::String info;
-	for (uint i = 0; i < ARRAYSIZE(_flags.descs); ++i) {
-		const FlagDescription &desc = _flags.descs[i];
+	for (const FlagDescription &desc : _flags.descs) {
 		if (desc.label == kFlagNoLabel) {
 			continue;
 		}
@@ -360,15 +369,11 @@ void EventFlags::readData(Common::SeekableReadStream &stream) {
 			// Nancy15 writes only the flags it actually sets, preceded by their
 			// number, instead of a fixed block of 10 descriptions
 			uint16 numFlags = stream.readUint16LE();
+			_flags.descs.resize(numFlags);
 
-			for (uint i = 0; i < numFlags; ++i) {
-				int16 label = stream.readSint16LE();
-				uint16 flag = stream.readUint16LE();
-
-				if (i < ARRAYSIZE(_flags.descs)) {
-					_flags.descs[i].label = label;
-					_flags.descs[i].flag = flag;
-				}
+			for (FlagDescription &desc : _flags.descs) {
+				desc.label = stream.readSint16LE();
+				desc.flag = stream.readUint16LE();
 			}
 		} else {
 			_flags.readData(stream);
@@ -391,7 +396,11 @@ void EventFlagsMultiHS::readData(Common::SeekableReadStream &stream) {
 	EventFlags::readData(stream);
 
 	if (_hotspotType != kMultiHS) {
-		_hoverCursor = (CursorManager::CursorType)stream.readUint16LE();
+		if (g_nancy->getGameType() >= kGameTypeNancy16) {
+			_hoverCursor = (CursorManager::CursorType)stream.readUint32LE();
+		} else {
+			_hoverCursor = (CursorManager::CursorType)stream.readUint16LE();
+		}
 	}
 
 	uint16 numHotspots = stream.readUint16LE();
diff --git a/engines/nancy/action/datarecords.h b/engines/nancy/action/datarecords.h
index 944ef8777fd..0331e781194 100644
--- a/engines/nancy/action/datarecords.h
+++ b/engines/nancy/action/datarecords.h
@@ -60,15 +60,22 @@ public:
 	void execute() override;
 
 	Common::String getRecordExtraInfo() const override {
-		return Common::String::format("Value %d -> %d (should set is %d)", _index, _value, _shouldSet);
+		return Common::String::format("Value %d -> %d (mode %d)", _index, _value, _mode);
 	}
 
 protected:
 	Common::String getRecordTypeName() const override { return "SetValue"; }
 
+	enum SetMode : byte {
+		kAdd		= 0,
+		kSet		= 1,
+		kSetRandom	= 2		// Nancy16+
+	};
+
 	byte _index = 0;
-	bool _shouldSet = false;
+	SetMode _mode = kAdd;
 	int16 _value = kNoTableValue;
+	int16 _randomMax = 0;
 };
 
 class SetValueCombo : public ActionRecord {
diff --git a/engines/nancy/commontypes.cpp b/engines/nancy/commontypes.cpp
index 703daebc2e0..b3470375ebc 100644
--- a/engines/nancy/commontypes.cpp
+++ b/engines/nancy/commontypes.cpp
@@ -123,8 +123,8 @@ void MultiEventFlagDescription::readData(Common::SeekableReadStream &stream) {
 }
 
 void MultiEventFlagDescription::execute() {
-	for (uint i = 0; i < 10; ++i) {
-		NancySceneState.setEventFlag(descs[i]);
+	for (const FlagDescription &desc : descs) {
+		NancySceneState.setEventFlag(desc);
 	}
 }
 
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index ddf62428c09..9e687da59a7 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -229,9 +229,12 @@ struct FrameBlitDescription {
 	void readData(Common::SeekableReadStream &stream, bool longFormat = false);
 };
 
-// Describes 10 event flag changes to be executed when an action is triggered
+// Describes event flag changes to be executed when an action is triggered.
+// There are always 10, except in Nancy15+ event flag records, which list any number.
 struct MultiEventFlagDescription {
-	FlagDescription descs[10];
+	MultiEventFlagDescription() : descs(10) {}
+
+	Common::Array<FlagDescription> descs;
 
 	void readData(Common::SeekableReadStream &stream);
 	void execute();


Commit: d3e5831804723526aa6fe990e4d3d041c533fce7
    https://github.com/scummvm/scummvm/commit/d3e5831804723526aa6fe990e4d3d041c533fce7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:00+03:00

Commit Message:
NANCY: NANCY16: Don't require inventory data when printing dependencies

Nancy16 has no INV chunk

Changed paths:
    engines/nancy/console.cpp


diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index 2f90f883db4..8a82b4998f4 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -532,8 +532,8 @@ static uint numPlayerCharacters() {
 void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &record) {
 	using namespace Action;
 
+	// Nancy16 has no item descriptions
 	auto *inventoryData = GetEngineData(INV);
-	assert(inventoryData);
 
 	for (const DependencyRecord &dep : record.children) {
 		debugPrintf("\n\t\t");
@@ -544,7 +544,7 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
 		case DependencyType::kInventory:
 			debugPrintf("kInventory, item %u, %s, %s",
 				dep.label,
-				(uint16)dep.label < inventoryData->itemDescriptions.size() ?
+				inventoryData && (uint16)dep.label < inventoryData->itemDescriptions.size() ?
 					inventoryData->itemDescriptions[dep.label].name.c_str() :
 					"Invalid",
 				dep.condition == g_nancy->_true ? "true" : "false");
@@ -601,7 +601,9 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
 		case DependencyType::kCursorType:
 			debugPrintf("kCursorType, item %u, %s",
 				dep.label,
-				inventoryData->itemDescriptions[dep.label].name.c_str());
+				inventoryData && (uint16)dep.label < inventoryData->itemDescriptions.size() ?
+					inventoryData->itemDescriptions[dep.label].name.c_str() :
+					"Invalid");
 			break;
 		case DependencyType::kPlayerTOD:
 			debugPrintf("kPlayerTOD, %s",


Commit: 14207904792efd2c344857da2760b9d5b910ddbc
    https://github.com/scummvm/scummvm/commit/14207904792efd2c344857da2760b9d5b910ddbc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:01+03:00

Commit Message:
NANCY: NANCY16: Don't play scene area names as sounds

The scene summary's sound is a 3-letter area code in Nancy16

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index f3a40aaad61..1e97e5e3ebb 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -182,8 +182,12 @@ void Scene::process() {
 		_state = kRun;
 		if (_sceneState.currentScene.continueSceneSound == kLoadSceneSound) {
 			g_nancy->_sound->stopAndUnloadSceneSpecificSounds();
-			g_nancy->_sound->loadSound(_sceneState.summary.sound);
-			g_nancy->_sound->playSound(_sceneState.summary.sound);
+
+			// Nancy16 keeps the name of the scene's area there instead of a sound
+			if (g_nancy->getGameType() <= kGameTypeNancy15) {
+				g_nancy->_sound->loadSound(_sceneState.summary.sound);
+				g_nancy->_sound->playSound(_sceneState.summary.sound);
+			}
 		}
 		// fall through
 	case kRun:


Commit: b52901e37d1e460afafaebb9c44047cc14e39689
    https://github.com/scummvm/scummvm/commit/b52901e37d1e460afafaebb9c44047cc14e39689
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:03+03:00

Commit Message:
NANCY: NANCY16: Read the conversation response scenes

The conditional dialogue and goodbye scenes are named instead of given by character ID

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 4947ab70bef..3af00d42c64 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -200,8 +200,21 @@ void ConversationSound::readDataNancy13(Common::SeekableReadStream &stream) {
 
 	readCelDataNancy13(stream);
 
-	_conditionalResponseCharacterID = stream.readByte();
-	_goodbyeResponseCharacterID = stream.readByte();
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		readFilename(stream, _conditionalResponseScene);
+		readFilename(stream, _goodbyeResponseScene);
+
+		if (_conditionalResponseScene.equalsIgnoreCase("NO_INFOCHECK")) {
+			_conditionalResponseScene.clear();
+		}
+
+		if (_goodbyeResponseScene.equalsIgnoreCase("NO_BYE")) {
+			_goodbyeResponseScene.clear();
+		}
+	} else {
+		_conditionalResponseCharacterID = stream.readByte();
+		_goodbyeResponseCharacterID = stream.readByte();
+	}
 
 	_sceneChange.sceneID = stream.readUint16LE();
 	_sceneChange.frameID = stream.readUint16LE();
@@ -398,11 +411,11 @@ void ConversationSound::execute() {
 			uint numRegularResponses = _responses.size();
 
 			// Add responses when conditions have been satisfied
-			if (_conditionalResponseCharacterID != _noResponse) {
+			if (_conditionalResponseCharacterID != _noResponse || !_conditionalResponseScene.empty()) {
 				addConditionalDialogue();
 			}
 
-			if (_goodbyeResponseCharacterID != _noResponse) {
+			if (_goodbyeResponseCharacterID != _noResponse || !_goodbyeResponseScene.empty()) {
 				addGoodbye();
 			}
 
@@ -721,7 +734,8 @@ void ConversationSound::addGoodbye() {
 
 void ConversationSound::addConditionalDialogueNancy12() {
 	// Every action record in scene S<800 + charID> is one conditional response
-	Common::Path sceneName(Common::String::format("S%u", 800 + _conditionalResponseCharacterID));
+	Common::Path sceneName(_conditionalResponseScene.empty() ?
+		Common::String::format("S%u", 800 + _conditionalResponseCharacterID) : _conditionalResponseScene);
 	IFF *iff = g_nancy->_resource->loadIFF(sceneName);
 	if (!iff) {
 		warning("Could not load conditional dialogue scene %s", sceneName.toString().c_str());
@@ -770,7 +784,8 @@ void ConversationSound::addConditionalDialogueNancy12() {
 
 void ConversationSound::addGoodbyeNancy12() {
 	// Use the first satisfied goodbye record in scene S<900 + charID>
-	Common::Path sceneName(Common::String::format("S%u", 900 + _goodbyeResponseCharacterID));
+	Common::Path sceneName(_goodbyeResponseScene.empty() ?
+		Common::String::format("S%u", 900 + _goodbyeResponseCharacterID) : _goodbyeResponseScene);
 	IFF *iff = g_nancy->_resource->loadIFF(sceneName);
 	if (!iff) {
 		warning("Could not load goodbye scene %s", sceneName.toString().c_str());
diff --git a/engines/nancy/action/conversation.h b/engines/nancy/action/conversation.h
index 278e2cf284e..50383237c06 100644
--- a/engines/nancy/action/conversation.h
+++ b/engines/nancy/action/conversation.h
@@ -136,6 +136,9 @@ protected:
 
 	byte _conditionalResponseCharacterID;
 	byte _goodbyeResponseCharacterID;
+	// Nancy16 names the conditional dialogue and goodbye scenes directly
+	Common::String _conditionalResponseScene;
+	Common::String _goodbyeResponseScene;
 	byte _defaultNextScene = kDefaultNextSceneEnabled;
 	byte _popNextScene = kNoPopNextScene;
 	SceneChangeDescription _sceneChange;


Commit: 575940f8364aae6b11bb40ffc23f90ce195ce8e2
    https://github.com/scummvm/scummvm/commit/575940f8364aae6b11bb40ffc23f90ce195ce8e2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:05+03:00

Commit Message:
NANCY: Add shared color conversion and blending helpers

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


diff --git a/engines/nancy/action/puzzle/paintpuzzle.cpp b/engines/nancy/action/puzzle/paintpuzzle.cpp
index fd3fecd47dd..7547a564da8 100644
--- a/engines/nancy/action/puzzle/paintpuzzle.cpp
+++ b/engines/nancy/action/puzzle/paintpuzzle.cpp
@@ -199,33 +199,12 @@ void PaintPuzzle::drawRegion(uint regionIndex) {
 		for (int x = 0; x < img.w && dst.left + x < _drawSurface.w; ++x) {
 			byte a = shapeAlpha(img, x, y);
 			if (a != 0) {
-				blendPixel(dst.left + x, dst.top + y, a, color.r, color.g, color.b);
+				blendPixel(_drawSurface, dst.left + x, dst.top + y, a, color.r, color.g, color.b);
 			}
 		}
 	}
 }
 
-// Composites a pixel over what is already drawn at (x,y).
-void PaintPuzzle::blendPixel(int x, int y, byte a, byte r, byte g, byte b) {
-	if (x < 0 || y < 0 || x >= _drawSurface.w || y >= _drawSurface.h) {
-		return;
-	}
-
-	if (a != 255) {
-		byte da, dr, dg, db;
-		_drawSurface.format.colorToARGB(_drawSurface.getPixel(x, y), da, dr, dg, db);
-
-		uint destAlpha = da * (255 - a) / 255;
-		uint outAlpha = a + destAlpha;
-		r = (r * a + dr * destAlpha) / outAlpha;
-		g = (g * a + dg * destAlpha) / outAlpha;
-		b = (b * a + db * destAlpha) / outAlpha;
-		a = outAlpha;
-	}
-
-	_drawSurface.setPixel(x, y, _drawSurface.format.ARGBToColor(a, r, g, b));
-}
-
 // Draws a sprite from img, skipping transparent pixels and compositing
 // anti-aliased edges over what is already drawn.
 void PaintPuzzle::drawSprite(const Graphics::ManagedSurface &img, const Common::Rect &src, const Common::Point &dest) {
@@ -254,7 +233,7 @@ void PaintPuzzle::drawSprite(const Graphics::ManagedSurface &img, const Common::
 				continue;
 			}
 
-			blendPixel(dx, dy, a, r, g, b);
+			blendPixel(_drawSurface, dx, dy, a, r, g, b);
 		}
 	}
 }
diff --git a/engines/nancy/action/puzzle/paintpuzzle.h b/engines/nancy/action/puzzle/paintpuzzle.h
index 5469261b5e7..e795a59f1f5 100644
--- a/engines/nancy/action/puzzle/paintpuzzle.h
+++ b/engines/nancy/action/puzzle/paintpuzzle.h
@@ -86,7 +86,6 @@ protected:
 	void paintRegion(uint regionIndex, int colorIndex);
 	void dropColor();
 	void drawRegion(uint regionIndex);
-	void blendPixel(int x, int y, byte a, byte r, byte g, byte b);
 	void drawSprite(const Graphics::ManagedSurface &img, const Common::Rect &src, const Common::Point &dest);
 	void drawObjects();
 	void drawBrush();
diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index 10354bcc540..6d543e4c9c5 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -33,6 +33,71 @@
 
 namespace Nancy {
 
+// The ARGB colors in the game data are 32-bit ARGB pixels
+static const Graphics::PixelFormat kARGBFormat = Graphics::PixelFormat::createFormatARGB32();
+
+uint32 argbToColor(const Graphics::PixelFormat &format, uint32 argb) {
+	byte a, r, g, b;
+	kARGBFormat.colorToARGB(argb, a, r, g, b);
+	return format.ARGBToColor(a, r, g, b);
+}
+
+void blendPixel(Graphics::ManagedSurface &surface, int x, int y, byte a, byte r, byte g, byte b) {
+	if (x < 0 || y < 0 || x >= surface.w || y >= surface.h) {
+		return;
+	}
+
+	byte destA, destR, destG, destB;
+	surface.format.colorToARGB(surface.getPixel(x, y), destA, destR, destG, destB);
+
+	uint destWeight = destA * (255 - a) / 255;
+	uint outA = a + destWeight;
+	if (outA == 0) {
+		return;
+	}
+
+	surface.setPixel(x, y, surface.format.ARGBToColor(outA,
+		(r * a + destR * destWeight) / outA,
+		(g * a + destG * destWeight) / outA,
+		(b * a + destB * destWeight) / outA));
+}
+
+byte colorChannelToByte(float channel) {
+	return (byte)(CLIP(channel, 0.0f, 1.0f) * 255 + 0.5f);
+}
+
+void argbToChannels(uint32 argb, float *channels) {
+	byte a, r, g, b;
+	kARGBFormat.colorToARGB(argb, a, r, g, b);
+	channels[0] = a / 255.0f;
+	channels[1] = r / 255.0f;
+	channels[2] = g / 255.0f;
+	channels[3] = b / 255.0f;
+}
+
+uint32 channelsToArgb(const float *channels) {
+	return kARGBFormat.ARGBToColor(colorChannelToByte(channels[0]), colorChannelToByte(channels[1]),
+		colorChannelToByte(channels[2]), colorChannelToByte(channels[3]));
+}
+
+float getBlendAmount(float seconds, float rate) {
+	return 1.0f - powf(rate, 30.0f * seconds);
+}
+
+bool blendColor(float *current, uint32 target, float amount) {
+	float targetChannels[4];
+	argbToChannels(target, targetChannels);
+
+	bool changed = false;
+	for (uint i = 0; i < 4; ++i) {
+		byte before = colorChannelToByte(current[i]);
+		current[i] += (targetChannels[i] - current[i]) * amount;
+		changed |= colorChannelToByte(current[i]) != before;
+	}
+
+	return changed;
+}
+
 GraphicsManager::GraphicsManager() :
 	_objects(objectComparator),
 	_inputPixelFormat16(2, 5, 5, 5, 0, 10, 5, 0, 0),
@@ -373,8 +438,7 @@ int GraphicsManager::getFontID(const Common::String &name) const {
 }
 
 uint32 GraphicsManager::getFontColor(uint id, const Graphics::PixelFormat &format, FontColorState state) const {
-	uint32 argb = id < _fontColors.size() ? _fontColors[id].colors[state] : 0xFFFFFFFF;
-	return format.ARGBToColor(argb >> 24, (argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF);
+	return argbToColor(format, id < _fontColors.size() ? _fontColors[id].colors[state] : 0xFFFFFFFF);
 }
 
 void GraphicsManager::addObject(RenderObject *object) {
diff --git a/engines/nancy/graphics.h b/engines/nancy/graphics.h
index 3e24c279224..37dffc43f37 100644
--- a/engines/nancy/graphics.h
+++ b/engines/nancy/graphics.h
@@ -33,6 +33,25 @@ namespace Nancy {
 class IFF;
 class RenderObject;
 
+// An ARGB color, as a pixel of the given format
+uint32 argbToColor(const Graphics::PixelFormat &format, uint32 argb);
+
+// Draws a pixel with straight alpha over what the surface already holds
+void blendPixel(Graphics::ManagedSurface &surface, int x, int y, byte a, byte r, byte g, byte b);
+
+// Nancy16 blends its colors as ARGB channels from 0 to 1, in that order. These convert
+// between a channel and the byte it gets drawn with, and between channels and colors.
+byte colorChannelToByte(float channel);
+void argbToChannels(uint32 argb, float *channels);
+uint32 channelsToArgb(const float *channels);
+
+// How much of the way toward a new color gets covered in the given time. Each 1/30
+// of a second, the color keeps the given rate of its distance to the target.
+float getBlendAmount(float seconds, float rate);
+
+// Moves an ARGB color toward the target; returns true when its bytes changed
+bool blendColor(float *current, uint32 target, float amount);
+
 // Nancy16+ font colors have one color for each state of a UI control
 enum FontColorState {
 	kFontColorNormal		= 0,
diff --git a/engines/nancy/misc/viewportfader.cpp b/engines/nancy/misc/viewportfader.cpp
index 4d1e3383645..8b62943fca3 100644
--- a/engines/nancy/misc/viewportfader.cpp
+++ b/engines/nancy/misc/viewportfader.cpp
@@ -29,13 +29,9 @@
 namespace Nancy {
 namespace Misc {
 
-// Each 1/30 of a second, the color keeps this much of its distance to the target
+// How quickly the color moves toward the target; a lower rate is faster
 static const float kBlendRate = 0.9f;
 
-static byte toByte(float channel) {
-	return (byte)(CLIP(channel, 0.0f, 1.0f) * 255 + 0.5f);
-}
-
 void ViewportFader::init() {
 	Common::Rect bounds = NancySceneState.getViewport().getScreenPosition();
 	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
@@ -45,10 +41,7 @@ void ViewportFader::init() {
 }
 
 void ViewportFader::arm(uint32 color, uint32 holdTime) {
-	_target.a = 1.0f;
-	_target.r = ((color >> 16) & 0xFF) / 255.0f;
-	_target.g = ((color >> 8) & 0xFF) / 255.0f;
-	_target.b = (color & 0xFF) / 255.0f;
+	_target = color | 0xFF000000;
 	_holdTime = holdTime;
 
 	_state = kArmed;
@@ -65,15 +58,9 @@ void ViewportFader::onSceneChange() {
 }
 
 // Returns true once the color has reached the target
-bool ViewportFader::blendTowards(const Color &target, float seconds) {
-	float amount = 1.0f - powf(kBlendRate, 30.0f * seconds);
-	_current.a += (target.a - _current.a) * amount;
-	_current.r += (target.r - _current.r) * amount;
-	_current.g += (target.g - _current.g) * amount;
-	_current.b += (target.b - _current.b) * amount;
-
-	return toByte(_current.a) == toByte(target.a) && toByte(_current.r) == toByte(target.r) &&
-		toByte(_current.g) == toByte(target.g) && toByte(_current.b) == toByte(target.b);
+bool ViewportFader::blendTowards(uint32 target, float seconds) {
+	blendColor(_current, target, getBlendAmount(seconds, kBlendRate));
+	return channelsToArgb(_current) == target;
 }
 
 void ViewportFader::updateGraphics() {
@@ -102,7 +89,7 @@ void ViewportFader::updateGraphics() {
 		break;
 	case kFadingOut:
 		// The fade goes back to transparent black
-		if (blendTowards(Color(), seconds)) {
+		if (blendTowards(0, seconds)) {
 			_state = kIdle;
 		}
 
@@ -115,10 +102,11 @@ void ViewportFader::updateGraphics() {
 }
 
 void ViewportFader::redraw() {
-	byte alpha = toByte(_current.a);
-	setVisible(alpha != 0);
-	if (alpha != 0) {
-		_drawSurface.clear(_drawSurface.format.ARGBToColor(alpha, toByte(_current.r), toByte(_current.g), toByte(_current.b)));
+	uint32 color = channelsToArgb(_current);
+	bool isVisible = (color >> 24) != 0;
+	setVisible(isVisible);
+	if (isVisible) {
+		_drawSurface.clear(argbToColor(_drawSurface.format, color));
 	}
 
 	_needsRedraw = true;
diff --git a/engines/nancy/misc/viewportfader.h b/engines/nancy/misc/viewportfader.h
index 03799bdb045..ceedaff6b23 100644
--- a/engines/nancy/misc/viewportfader.h
+++ b/engines/nancy/misc/viewportfader.h
@@ -58,19 +58,12 @@ private:
 		kFadingOut
 	};
 
-	struct Color {
-		float a = 0;
-		float r = 0;
-		float g = 0;
-		float b = 0;
-	};
-
-	bool blendTowards(const Color &target, float seconds);
+	bool blendTowards(uint32 target, float seconds);
 	void redraw();
 
 	State _state = kIdle;
-	Color _current;
-	Color _target;
+	float _current[4] = {};		// ARGB channels, starting at transparent black
+	uint32 _target = 0;			// ARGB
 	uint32 _holdTime = 0;
 	uint32 _holdEndTime = 0;
 	uint32 _lastUpdateTime = 0;


Commit: a9ed5ffc2c300f23cec32ecbc587ddb92f4392a8
    https://github.com/scummvm/scummvm/commit/a9ed5ffc2c300f23cec32ecbc587ddb92f4392a8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:06+03:00

Commit Message:
NANCY: NANCY16: Add a reader for exclusive rects

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index e7dc199ce79..ead070f6f9b 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -152,15 +152,13 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 }
 
 VIEW::VIEW(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
-	readRect(*chunkStream, screenPosition);
-	readRect(*chunkStream, bounds);
-
-	// Nancy16's rects aren't inclusive; the viewport is exactly as large as the video
+	// Nancy16's viewport is exactly as large as the video
 	if (g_nancy->getGameType() >= kGameTypeNancy16) {
-		--screenPosition.right;
-		--screenPosition.bottom;
-		--bounds.right;
-		--bounds.bottom;
+		readExclusiveRect(*chunkStream, screenPosition);
+		readExclusiveRect(*chunkStream, bounds);
+	} else {
+		readRect(*chunkStream, screenPosition);
+		readRect(*chunkStream, bounds);
 	}
 }
 
diff --git a/engines/nancy/util.cpp b/engines/nancy/util.cpp
index cd2bc0eb71d..4248a9823f1 100644
--- a/engines/nancy/util.cpp
+++ b/engines/nancy/util.cpp
@@ -45,6 +45,13 @@ void readRect(Common::SeekableReadStream &stream, Common::Rect &inRect) {
 	}
 }
 
+void readExclusiveRect(Common::SeekableReadStream &stream, Common::Rect &inRect) {
+	inRect.left = stream.readSint32LE();
+	inRect.top = stream.readSint32LE();
+	inRect.right = stream.readSint32LE();
+	inRect.bottom = stream.readSint32LE();
+}
+
 void readRect(Common::Serializer &stream, Common::Rect &inRect, Common::Serializer::Version minVersion, Common::Serializer::Version maxVersion) {
 	Common::Serializer::Version version = stream.getVersion();
 	if (version >= minVersion && version <= maxVersion) {
diff --git a/engines/nancy/util.h b/engines/nancy/util.h
index 0e046498e9e..a399a68358f 100644
--- a/engines/nancy/util.h
+++ b/engines/nancy/util.h
@@ -34,6 +34,8 @@ namespace Nancy {
 struct CapturedPicture;
 
 void readRect(Common::SeekableReadStream &stream, Common::Rect &inRect);
+// Nancy16 stores rects whose right and bottom edges are already exclusive
+void readExclusiveRect(Common::SeekableReadStream &stream, Common::Rect &inRect);
 void readRect(Common::Serializer &stream, Common::Rect &inRect, Common::Serializer::Version minVersion = 0, Common::Serializer::Version maxVersion = Common::Serializer::kLastVersion);
 void readRectArray(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &inArray, uint num, uint totalNum = 0);
 void readRectArray(Common::Serializer &stream, Common::Array<Common::Rect> &inArray, uint num, uint totalNum = 0, Common::Serializer::Version minVersion = 0, Common::Serializer::Version maxVersion = Common::Serializer::kLastVersion);


Commit: 8bcdc8fc05def700cc305043b9f6d722144b9461
    https://github.com/scummvm/scummvm/commit/8bcdc8fc05def700cc305043b9f6d722144b9461
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:09+03:00

Commit Message:
NANCY: NANCY16: NDUI: Start implementing the new UI (common, element)

Nancy16 replaced the per-widget UI chunks with a small widget toolkit,
called NDUI, and contained in an external DLL, ndui.dll.
The UI is a 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.

Changed paths:
  A engines/nancy/ui/ndui/common.cpp
  A engines/nancy/ui/ndui/common.h
  A engines/nancy/ui/ndui/element.cpp
  A engines/nancy/ui/ndui/element.h
    engines/nancy/module.mk


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 2e291effebc..f0e61cdefed 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -98,6 +98,8 @@ MODULE_OBJS = \
   ui/conversationpopup.o \
   ui/inventorybox.o \
   ui/inventorypopup.o \
+  ui/ndui/common.o \
+  ui/ndui/element.o \
   ui/notebookpopup.o \
   ui/ornaments.o \
   ui/scrollbar.o \
diff --git a/engines/nancy/ui/ndui/common.cpp b/engines/nancy/ui/ndui/common.cpp
new file mode 100644
index 00000000000..cb8827d3134
--- /dev/null
+++ b/engines/nancy/ui/ndui/common.cpp
@@ -0,0 +1,50 @@
+/* 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/util.h"
+
+#include "engines/nancy/ui/ndui/common.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// An event's value comes from this field instead, unless it holds this
+static const int16 kNoIntValue = -4;
+
+void Action::read(Common::SeekableReadStream &stream) {
+	eventType = stream.readSint32LE();
+	type = stream.readSint32LE();
+	readFilename(stream, target);
+	hasValue = stream.readByte();
+	value = stream.readDoubleLE();
+
+	int16 intValue = stream.readSint16LE();
+	if (intValue != kNoIntValue) {
+		value = intValue;
+	}
+
+	readFilename(stream, text);
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
new file mode 100644
index 00000000000..b2966e7175a
--- /dev/null
+++ b/engines/nancy/ui/ndui/common.h
@@ -0,0 +1,127 @@
+/* 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_UI_NDUI_COMMON_H
+#define NANCY_UI_NDUI_COMMON_H
+
+#include "common/str.h"
+#include "common/stream.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// Nancy16 replaced the per-widget UI chunks with a small widget toolkit. The UI is a
+// 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.
+
+enum ControlType {
+	kControlButton		= 1,
+	kControlStatic		= 2,
+	kControlSlider		= 3,
+	kControlRadioGroup	= 5,
+	kControlRadioButton	= 6,
+	kControlEditBox		= 7,
+	kControlListBox		= 8,
+	kControlScrollBar	= 9,
+	kControlCheckBox	= 10,
+	kControlInvButton	= 12,
+	kControlVarStatic	= 14
+};
+
+// The kinds of objects an NDUI chunk describes
+enum ObjectKind {
+	kObjectPanel		= 0,
+	kObjectScrollPanel	= 11,
+	kObjectInvPanel		= 13,
+	kObjectTemplate		= 40	// a control the engine creates copies of at runtime
+};
+
+// The states a control can be drawn in; every element carries a color for each
+enum ControlState {
+	kStateNormal		= 0,
+	kStateDisabled		= 1,
+	kStateHidden		= 2,
+	kStateFocus			= 3,
+	kStateMouseOver		= 4,
+	kStatePressed		= 5,
+	kNumStates			= 6
+};
+
+enum EventType {
+	kEventOwn			= -1,	// whatever the control itself reports: a click, a new value...
+	kEventClicked		= 1,
+	kEventRadioChanged	= 3,
+	kEventChecked		= 4,
+	kEventUnchecked		= 5,
+	kEventSliderChanged	= 6,
+	kEventTextEntered	= 8,	// the player pressed Enter in an edit box
+	kEventTextChanged	= 9,
+	kEventListSelected	= 11,
+	kEventScrolled		= 15,
+	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
+	kEventActionRecord	= 100	// sent by a scene's action record
+};
+
+enum ActionType {
+	kActionShow				= 1,
+	kActionHide				= 2,
+	kActionToggle			= 3,
+	kActionSetRange			= 5,	// scroll bars
+	kActionEnable			= 8,
+	kActionDisable			= 9,
+	kActionToggleEnabled	= 10,
+	kActionAddText			= 11,	// also adds a list box item
+	kActionSetText			= 12,	// also empties a list box
+	kActionSetValue			= 13,
+	kActionSetTexture		= 14,
+	kActionQuery			= 30,	// asks the engine to send its value back with kActionSetValue
+	kActionShowItem			= 40,	// inventory panels
+	kActionHideItem			= 41
+};
+
+struct Action {
+	void read(Common::SeekableReadStream &stream);
+
+	int32 eventType = 0;		// only for actions bound to a control's event
+	int32 type = 0;
+	Common::String target;		// a panel, a control, or an "Engine_" handler
+	bool hasValue = false;
+	double value = 0;
+	Common::String text;
+	Common::String sender;		// the control that fired the event
+};
+
+// How quickly colors move toward the ones of a new state; a lower rate is faster
+static const float kStaticBlendRate = 0.7f;
+static const float kControlBlendRate = 0.8f;
+
+// The lines a notch of the mouse wheel scrolls
+static const int kWheelLines = 3;
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_COMMON_H
diff --git a/engines/nancy/ui/ndui/element.cpp b/engines/nancy/ui/ndui/element.cpp
new file mode 100644
index 00000000000..96972a818aa
--- /dev/null
+++ b/engines/nancy/ui/ndui/element.cpp
@@ -0,0 +1,258 @@
+/* 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 "graphics/font.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/font.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/misc/hypertext.h"
+
+#include "engines/nancy/ui/ndui/element.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+static const uint32 kDefaultColor = 0xFFFFFFFF;
+static const uint32 kDefaultDisabledColor = 0xC8808080;
+
+// Lays out text, with its markup, in the font and color of an element
+class ElementText : public Misc::HypertextParser {
+public:
+	enum Alignment {
+		kAlignLeft		= 0,
+		kAlignRight		= 1,
+		kAlignCenter	= 2
+	};
+
+	const Graphics::ManagedSurface &render(const Common::String &text, int width, int height, uint fontID, byte r, byte g, byte b, int32 alignment, ControlState state) {
+		_fullSurface.create(width, height, g_nancy->_graphics->getTransparentPixelFormat());
+		_fullSurface.clear(0);
+
+		// The control states and the font color states come in the same order
+		_fontColorState = (FontColorState)state;
+
+		if (alignment == kAlignCenter) {
+			_defaultJustification = kJustifyCenter;
+		} else if (alignment == kAlignRight) {
+			_defaultJustification = kJustifyRight;
+		}
+
+		setDefaultTextRGB(r, g, b);
+		addTextLine(text);
+		drawAllText(Common::Rect(width, height), 0, fontID, fontID);
+		return _fullSurface;
+	}
+
+	// The number of lines the text gets wrapped into
+	uint countLines(const Common::String &text, int width, uint fontID) {
+		_fullSurface.create(width, 1, g_nancy->_graphics->getTransparentPixelFormat());
+		addTextLine(text);
+		drawAllText(Common::Rect(width, 1), 0, fontID, fontID);
+
+		// Every text gets an empty line after it
+		return _numDrawnLines - 1;
+	}
+};
+
+void Element::read(Common::SeekableReadStream &stream) {
+	Common::String textureName;
+	Common::Rect src;
+	readFilename(stream, textureName);
+	readExclusiveRect(stream, src);
+	setTexture(textureName, src);
+
+	_colors[kStateNormal] = stream.readUint32BE();
+	_colors[kStateDisabled] = stream.readUint32BE();
+	_colors[kStateMouseOver] = stream.readUint32BE();
+	_colors[kStatePressed] = stream.readUint32BE();
+
+	readFilename(stream, _fontName);
+	if (stream.readByte()) {
+		_fontName += "_Bold";
+	}
+
+	_horizontalAlignment = stream.readSint32LE();
+	_verticalAlignment = stream.readSint32LE();
+	_wordWrap = stream.readByte();
+
+	setFontColors(stream.readUint32BE());
+	_fontColors[kStateDisabled] = stream.readUint32BE();
+	_fontColors[kStateMouseOver] = stream.readUint32BE();
+	_fontColors[kStatePressed] = stream.readUint32BE();
+}
+
+void Element::setTexture(const Common::String &textureName, const Common::Rect &src) {
+	_textureName = textureName;
+	_src = src;
+
+	for (uint i = 0; i < kNumStates; ++i) {
+		_colors[i] = kDefaultColor;
+	}
+
+	_colors[kStateDisabled] = kDefaultDisabledColor;
+	_colors[kStateHidden] = 0;
+}
+
+void Element::setFontColors(uint32 normalColor) {
+	for (uint i = 0; i < kNumStates; ++i) {
+		_fontColors[i] = normalColor;
+	}
+
+	_fontColors[kStateDisabled] = kDefaultDisabledColor;
+	_fontColors[kStateHidden] = 0;
+}
+
+void Element::moveFontTo(Element &other) {
+	other._fontName = _fontName;
+	other._horizontalAlignment = _horizontalAlignment;
+	other._verticalAlignment = _verticalAlignment;
+	other._wordWrap = _wordWrap;
+	for (uint i = 0; i < kNumStates; ++i) {
+		other._fontColors[i] = _fontColors[i];
+	}
+
+	setFontColors(0);
+}
+
+bool Element::blend(ControlState state, float seconds, float rate) {
+	_state = state;
+	float amount = getBlendAmount(seconds, rate);
+	bool changed = blendColor(_current, _colors[state], amount);
+	changed |= blendColor(_currentFont, _fontColors[state], amount);
+	return changed;
+}
+
+// The texture is stretched to fill the destination, and tinted by the current color
+void Element::drawSprite(Graphics::ManagedSurface &surface, const Common::Rect &src, const Common::Rect &dest, const Graphics::ManagedSurface &texture) const {
+	byte tintA = colorChannelToByte(_current[0]);
+	if (tintA == 0 || src.isEmpty() || dest.isEmpty()) {
+		return;
+	}
+
+	byte tintR = colorChannelToByte(_current[1]);
+	byte tintG = colorChannelToByte(_current[2]);
+	byte tintB = colorChannelToByte(_current[3]);
+
+	Common::Rect clipped = dest.findIntersectingRect(Common::Rect(surface.w, surface.h));
+	for (int y = clipped.top; y < clipped.bottom; ++y) {
+		int srcY = src.top + (y - dest.top) * src.height() / dest.height();
+		for (int x = clipped.left; x < clipped.right; ++x) {
+			int srcX = src.left + (x - dest.left) * src.width() / dest.width();
+			if (srcX < 0 || srcY < 0 || srcX >= texture.w || srcY >= texture.h) {
+				continue;
+			}
+
+			byte a, r, g, b;
+			texture.format.colorToARGB(texture.getPixel(srcX, srcY), a, r, g, b);
+			a = a * tintA / 255;
+			if (a != 0) {
+				blendPixel(surface, x, y, a, r * tintR / 255, g * tintG / 255, b * tintB / 255);
+			}
+		}
+	}
+}
+
+int Element::measureTextHeight(const Common::String &text, int width) const {
+	int fontID = g_nancy->_graphics->getFontID(_fontName);
+	if (text.empty() || width <= 0 || fontID < 0 || !g_nancy->_graphics->getFont((uint)fontID)->getSystemFont()) {
+		return 0;
+	}
+
+	ElementText layout;
+	return layout.countLines(text, width, (uint)fontID) * getFontHeight();
+}
+
+const Graphics::Font *Element::getSystemFont() const {
+	const Font *font = g_nancy->_graphics->getFont(_fontName);
+	return font ? font->getSystemFont() : nullptr;
+}
+
+int Element::getFontHeight() const {
+	const Font *font = g_nancy->_graphics->getFont(_fontName);
+	return font && font->getSystemFont() ? font->getSystemFont()->getFontHeight() : 0;
+}
+
+void Element::drawText(Graphics::ManagedSurface &surface, const Common::String &text, const Common::Rect &dest, bool shadow) const {
+	drawText(surface, text, dest, _currentFont, _state, shadow);
+}
+
+void Element::drawText(Graphics::ManagedSurface &surface, const Common::String &text, const Common::Rect &dest, const float *color, ControlState state, bool shadow) const {
+	byte alpha = colorChannelToByte(color[0]);
+	int fontID = g_nancy->_graphics->getFontID(_fontName);
+	if (text.empty() || alpha == 0 || dest.isEmpty() || fontID < 0 || !g_nancy->_graphics->getFont((uint)fontID)->getSystemFont()) {
+		return;
+	}
+
+	ElementText layout;
+	const Graphics::ManagedSurface &rendered = layout.render(text, dest.width(), dest.height(), (uint)fontID,
+		colorChannelToByte(color[1]), colorChannelToByte(color[2]), colorChannelToByte(color[3]), _horizontalAlignment, state);
+
+	// Only text kept on a single line gets aligned vertically
+	int top = dest.top;
+	if (!_wordWrap) {
+		int lineHeight = getFontHeight();
+		if (_verticalAlignment == kAlignVCenter) {
+			top += (dest.height() - lineHeight) / 2;
+		} else if (_verticalAlignment == kAlignBottom) {
+			top += dest.height() - lineHeight;
+		}
+	}
+
+	// The text gets blended in with the color's alpha. The shadow is the same shape
+	// in black, one pixel down and to the right.
+	for (uint pass = shadow ? 0 : 1; pass < 2; ++pass) {
+		int offset = pass == 0 ? 1 : 0;
+		for (int y = 0; y < rendered.h; ++y) {
+			int destY = top + y + offset;
+			if (destY < 0 || destY >= surface.h) {
+				continue;
+			}
+
+			for (int x = 0; x < rendered.w; ++x) {
+				int destX = dest.left + x + offset;
+				if (destX < 0 || destX >= surface.w) {
+					continue;
+				}
+
+				byte a, r, g, b;
+				rendered.format.colorToARGB(rendered.getPixel(x, y), a, r, g, b);
+				a = a * alpha / 255;
+				if (a == 0) {
+					continue;
+				}
+
+				if (pass == 0) {
+					blendPixel(surface, destX, destY, a, 0, 0, 0);
+				} else {
+					blendPixel(surface, destX, destY, a, r, g, b);
+				}
+			}
+		}
+	}
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/element.h b/engines/nancy/ui/ndui/element.h
new file mode 100644
index 00000000000..88d728be28d
--- /dev/null
+++ b/engines/nancy/ui/ndui/element.h
@@ -0,0 +1,98 @@
+/* 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_UI_NDUI_ELEMENT_H
+#define NANCY_UI_NDUI_ELEMENT_H
+
+#include "common/rect.h"
+#include "engines/nancy/ui/ndui/common.h"
+
+namespace Graphics {
+class Font;
+class ManagedSurface;
+}
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// One visual part of a control: a region of a texture and a font, each with a color
+// for every control state. A control holds one element or more (a button has its face,
+// a scroll bar its track, arrows and thumb), and tells them which state it is in.
+// Each element then fades its colors toward the ones of that state, and draws its
+// texture tinted with them, and text in its font. Its control gets the texture by name
+// from the Manager, which keeps them loaded.
+class Element {
+public:
+	void read(Common::SeekableReadStream &stream);
+	void setTexture(const Common::String &textureName, const Common::Rect &src);
+
+	// Hands the text settings over to another element, keeping only a disabled color
+	void moveFontTo(Element &other);
+
+	// Moves the current colors toward the ones for the given state; returns true
+	// when the colors that get drawn have changed
+	bool blend(ControlState state, float seconds, float rate);
+
+	void drawSprite(Graphics::ManagedSurface &surface, const Common::Rect &src, const Common::Rect &dest, const Graphics::ManagedSurface &texture) const;
+	void drawText(Graphics::ManagedSurface &surface, const Common::String &text, const Common::Rect &dest, bool shadow) const;
+	void drawText(Graphics::ManagedSurface &surface, const Common::String &text, const Common::Rect &dest, const float *color, ControlState state, bool shadow) const;
+
+	uint32 getFontColor(ControlState state) const { return _fontColors[state]; }
+	int getFontHeight() const;
+
+	// The height the text takes up when it gets wrapped to the given width
+	int measureTextHeight(const Common::String &text, int width) const;
+	const Graphics::Font *getSystemFont() const;
+	void setTextureName(const Common::String &textureName) { _textureName = textureName; }
+
+	Common::String _textureName;
+	Common::Rect _src;
+	uint32 _colors[kNumStates] = {};
+
+private:
+	enum VerticalAlignment {
+		kAlignVCenter	= 2,
+		kAlignTop		= 3,
+		kAlignBottom	= 4
+	};
+
+	void setFontColors(uint32 normalColor);
+
+	Common::String _fontName;
+	int32 _horizontalAlignment = 0;		// left, right or centered
+	int32 _verticalAlignment = kAlignTop;
+	bool _wordWrap = false;
+	uint32 _fontColors[kNumStates] = {};
+
+	// ARGB, 0 to 1; both start at the hidden color
+	float _current[4] = {};
+	float _currentFont[4] = {};
+
+	// The state the colors are moving toward; color tags in the text follow it
+	ControlState _state = kStateHidden;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_ELEMENT_H


Commit: c70f3e61ecbd9002254ab837e1f78d037a4f9cc5
    https://github.com/scummvm/scummvm/commit/c70f3e61ecbd9002254ab837e1f78d037a4f9cc5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:11+03:00

Commit Message:
NANCY: NANCY16: NDUI: Add controls, panels and manager for the new UI

- Controls fire the actions bound to their events; statics show text and pictures
- Panels hold, lay out and draw controls, and route input and actions to them
- The manager creates the panels from the UI IFFs, and routes actions and input

Changed paths:
  A engines/nancy/ui/ndui/control.cpp
  A engines/nancy/ui/ndui/control.h
  A engines/nancy/ui/ndui/manager.cpp
  A engines/nancy/ui/ndui/manager.h
  A engines/nancy/ui/ndui/panel.cpp
  A engines/nancy/ui/ndui/panel.h
    engines/nancy/module.mk


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index f0e61cdefed..93da74ac7f6 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -99,7 +99,10 @@ MODULE_OBJS = \
   ui/inventorybox.o \
   ui/inventorypopup.o \
   ui/ndui/common.o \
+  ui/ndui/control.o \
   ui/ndui/element.o \
+  ui/ndui/manager.o \
+  ui/ndui/panel.o \
   ui/notebookpopup.o \
   ui/ornaments.o \
   ui/scrollbar.o \
diff --git a/engines/nancy/ui/ndui/control.cpp b/engines/nancy/ui/ndui/control.cpp
new file mode 100644
index 00000000000..2a044654604
--- /dev/null
+++ b/engines/nancy/ui/ndui/control.cpp
@@ -0,0 +1,232 @@
+/* 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/util.h"
+
+#include "engines/nancy/ui/ndui/control.h"
+#include "engines/nancy/ui/ndui/panel.h"
+#include "engines/nancy/ui/ndui/manager.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+void Control::read(Common::SeekableReadStream &stream) {
+	stream.skip(33); // version
+
+	int32 initialState = stream.readSint32LE();
+	if (initialState == kStateDisabled) {
+		_enabled = false;
+	} else if (initialState == kStateHidden) {
+		_visible = false;
+	}
+
+	readFilename(stream, _name);
+	readExclusiveRect(stream, _bounds);
+	readFilename(stream, _text);
+	_autoHeight = _bounds.height() == 0;
+
+	_events.resize(stream.readSint16LE());
+	for (Action &event : _events) {
+		event.read(stream);
+	}
+
+	_elements.resize(1);
+	_elements[0].read(stream);
+
+	readExtra(stream);
+
+	if (_autoHeight) {
+		fitHeightToText();
+	}
+}
+
+bool Control::takeAction(const Action &action) {
+	return _name.equalsIgnoreCase(action.target) && doAction(action);
+}
+
+bool Control::doAction(const Action &action) {
+	switch (action.type) {
+	case kActionShow:
+		setVisible(true);
+		break;
+	case kActionHide:
+		setVisible(false);
+		break;
+	case kActionToggle:
+		setVisible(!_visible);
+		break;
+	case kActionEnable:
+		setEnabled(true);
+		break;
+	case kActionDisable:
+		setEnabled(false);
+		break;
+	case kActionToggleEnabled:
+		setEnabled(!_enabled);
+		break;
+	default:
+		return false;
+	}
+
+	return true;
+}
+
+void Control::setVisible(bool visible) {
+	if (_visible != visible) {
+		_visible = visible;
+		_isMouseOver = false;
+		_isPressed = false;
+		_panel->arrangeControls();
+	}
+}
+
+void Control::setEnabled(bool enabled) {
+	if (_enabled != enabled) {
+		_enabled = enabled;
+		_panel->setDirty();
+	}
+}
+
+void Control::setText(const Common::String &text) {
+	if (_text == text) {
+		return;
+	}
+
+	_text = text;
+	_panel->setDirty();
+
+	if (_autoHeight) {
+		fitHeightToText();
+		_panel->arrangeControls();
+	}
+}
+
+void Control::fitHeightToText() {
+	_bounds.bottom = _bounds.top + MAX(_elements[0].measureTextHeight(_text, getTextWidth()), getMinHeight());
+}
+
+void Control::fireEvents(int32 eventType, bool isOwnEvent, double value, const Common::String &text) {
+	for (const Action &event : _events) {
+		if (event.eventType != eventType && !(isOwnEvent && event.eventType == kEventOwn)) {
+			continue;
+		}
+
+		Action action = event;
+		action.sender = _name;
+		if (!action.hasValue) {
+			action.hasValue = true;
+			action.value = value;
+			action.text = text;
+		}
+
+		_panel->getManager()->takeAction(action);
+	}
+}
+
+void Control::sendEvent(int32 eventType) {
+	double value = 0;
+	Common::String text;
+	if (getValue(eventType, value, text)) {
+		fireEvents(eventType, isOwnEvent(eventType), value, text);
+	}
+}
+
+ControlState Control::getState() const {
+	if (!_enabled || !_panel->isEnabled()) {
+		return kStateDisabled;
+	}
+
+	return kStateNormal;
+}
+
+void Control::drawSprite(Graphics::ManagedSurface &surface, uint element, const Common::Rect &dest) const {
+	drawSprite(surface, element, _elements[element]._src, dest);
+}
+
+void Control::drawSprite(Graphics::ManagedSurface &surface, uint element, const Common::Rect &src, const Common::Rect &dest) const {
+	const Element &elem = _elements[element];
+	if (elem._textureName.empty()) {
+		return;
+	}
+
+	const Graphics::ManagedSurface *texture = _panel->getManager()->getTexture(elem._textureName);
+	if (texture) {
+		elem.drawSprite(surface, src, dest, *texture);
+	}
+}
+
+bool Static::update(float seconds) {
+	if (!_visible) {
+		return false;
+	}
+
+	return _elements[0].blend(getState(), seconds, kStaticBlendRate);
+}
+
+void Static::draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const {
+	if (!_visible) {
+		return;
+	}
+
+	Common::Rect dest = _bounds;
+	dest.translate(offset.x, offset.y);
+	drawSprite(surface, 0, dest);
+	_elements[0].drawText(surface, _text, dest, true);
+}
+
+bool Static::doAction(const Action &action) {
+	switch (action.type) {
+	case kActionAddText:
+		setText(_text + action.text);
+		return true;
+	case kActionSetText:
+		setText(action.text);
+		return true;
+	case kActionSetValue:
+		setVisible(action.value != 0);
+		return true;
+	case kActionSetTexture:
+		_elements[0].setTextureName(action.text);
+		_panel->setDirty();
+		return true;
+	default:
+		return Control::doAction(action);
+	}
+}
+
+void VarStatic::readExtra(Common::SeekableReadStream &stream) {
+	_width = stream.readSint32LE();
+	_precision = stream.readSint32LE();
+}
+
+bool VarStatic::doAction(const Action &action) {
+	if (action.type == kActionSetValue) {
+		setText(Common::String::format("%0*.*f", (int)_width, (int)_precision, action.value));
+		return true;
+	}
+
+	return Static::doAction(action);
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/control.h b/engines/nancy/ui/ndui/control.h
new file mode 100644
index 00000000000..2b42619c354
--- /dev/null
+++ b/engines/nancy/ui/ndui/control.h
@@ -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/>.
+ *
+ */
+
+#ifndef NANCY_UI_NDUI_CONTROL_H
+#define NANCY_UI_NDUI_CONTROL_H
+
+#include "common/array.h"
+
+#include "engines/nancy/ui/ndui/element.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+class Panel;
+
+// A widget inside a panel, drawn with its elements. The player's clicks, drags and key
+// presses turn into events, and each event fires the actions bound to it in the NDUI
+// data; the panel hands the control the actions that name it.
+class Control {
+public:
+	Control(Panel *panel) : _panel(panel) {}
+	virtual ~Control() {}
+
+	void read(Common::SeekableReadStream &stream);
+
+	// Returns true when the action was meant for this control
+	bool takeAction(const Action &action);
+
+	// Advances the color blending; returns true when the control looks different
+	virtual bool update(float seconds) = 0;
+	virtual void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const = 0;
+
+	// The mouse, in the coordinates of the panel's controls. A control that returns
+	// true from onMouseDown() keeps getting the mouse until the button is released.
+	virtual bool containsPoint(const Common::Point &pos) const { return false; }
+	virtual void onMouseHover(const Common::Point &pos) {}
+	virtual bool onMouseDown(const Common::Point &pos) { return false; }
+	virtual void onMouseMove(const Common::Point &pos) {}
+	virtual void onMouseUp(const Common::Point &pos) {}
+	virtual bool onWheel(int notches) { return false; }
+
+	void setVisible(bool visible);
+	void setEnabled(bool enabled);
+	void setText(const Common::String &text);
+
+	const Common::String &getName() const { return _name; }
+	void setName(const Common::String &name) { _name = name; }
+	bool isVisible() const { return _visible; }
+	bool isEnabled() const { return _enabled; }
+
+	// Fires the actions bound to an event; the ones without a value of their own
+	// get the given one
+	void fireEvents(int32 eventType, bool isOwnEvent, double value, const Common::String &text);
+
+	int _id = 0;
+	Common::Rect _bounds;		// relative to the panel
+	bool _isMouseOver = false;
+	bool _isPressed = false;
+
+protected:
+	virtual void readExtra(Common::SeekableReadStream &stream) {}
+	virtual bool doAction(const Action &action);
+
+	// Fires the events for something the player did to the control
+	void sendEvent(int32 eventType);
+	virtual bool getValue(int32 eventType, double &value, Common::String &text) const { return false; }
+	virtual bool isOwnEvent(int32 eventType) const { return false; }
+
+	ControlState getState() const;
+	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;
+
+	// The width the text gets wrapped to
+	virtual int getTextWidth() const { return _bounds.width(); }
+	virtual int getMinHeight() const { return 0; }
+	void fitHeightToText();
+
+	Panel *_panel;
+	Common::String _name;
+	Common::String _text;
+	bool _visible = true;
+	bool _enabled = true;
+	bool _autoHeight = false;	// controls without a height are as tall as their text
+	Common::Array<Action> _events;
+	Common::Array<Element> _elements;
+};
+
+// Draws its element and text while visible; used for panel backgrounds, labels and pictures
+class Static : public Control {
+public:
+	Static(Panel *panel) : Control(panel) {}
+
+	bool update(float seconds) override;
+	void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const override;
+
+protected:
+	bool doAction(const Action &action) override;
+};
+
+// A static that shows a number
+class VarStatic : public Static {
+public:
+	VarStatic(Panel *panel) : Static(panel) {}
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+
+	int32 _width = 0;
+	int32 _precision = 0;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_CONTROL_H
diff --git a/engines/nancy/ui/ndui/manager.cpp b/engines/nancy/ui/ndui/manager.cpp
new file mode 100644
index 00000000000..c32c9637499
--- /dev/null
+++ b/engines/nancy/ui/ndui/manager.cpp
@@ -0,0 +1,201 @@
+/* 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/debug.h"
+#include "common/memstream.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/iff.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/ui/ndui/manager.h"
+#include "engines/nancy/ui/ndui/panel.h"
+#include "engines/nancy/ui/ndui/control.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// Panels are drawn over everything else, in the order they were created
+static const uint16 kFirstPanelZ = 1000;
+
+// The cursor shown while the mouse is over the UI
+static const uint16 kUICursor = 14;
+
+Manager::~Manager() {
+	for (Panel *panel : _panels) {
+		delete panel;
+	}
+
+	for (auto &texture : _textures) {
+		delete texture._value;
+	}
+}
+
+void Manager::load(const Common::String &name) {
+	IFF *iff = g_nancy->_resource->loadIFF(Common::Path(name));
+	if (!iff) {
+		error("Failed to load UI file %s", name.c_str());
+	}
+
+	for (const Common::String &include : iff->getIncludes()) {
+		load(include);
+	}
+
+	Common::SeekableReadStream *chunk = nullptr;
+	for (uint i = 0; chunk = iff->getChunkStream("NDUI", i), chunk != nullptr; ++i) {
+		byte kind = chunk->readByte();
+		if (kind == kObjectTemplate) {
+			// Kept as data, and read again for every copy. The name comes after
+			// the version, the control type and the control's own version and state.
+			Common::Array<byte> data(chunk->size() - chunk->pos());
+			chunk->read(data.data(), data.size());
+
+			Common::String templateName;
+			Common::MemoryReadStream nameStream(data.data(), data.size());
+			nameStream.skip(33 + 4 + 33 + 4);
+			readFilename(nameStream, templateName);
+			_templates.setVal(templateName, data);
+		} else {
+			Panel *panel = new Panel(this, (uint16)(kFirstPanelZ + _panels.size()), kind);
+			panel->read(*chunk);
+			_panels.push_back(panel);
+		}
+
+		delete chunk;
+	}
+
+	delete iff;
+}
+
+void Manager::registerGraphics() {
+	for (Panel *panel : _panels) {
+		panel->registerGraphics();
+	}
+}
+
+void Manager::takeAction(const Action &action) {
+	for (Panel *panel : _panels) {
+		if (panel->takeAction(action)) {
+			return;
+		}
+	}
+
+	debugC(1, kDebugEngine, "Unhandled NDUI action %d on \"%s\" from \"%s\"", action.type, action.target.c_str(), action.sender.c_str());
+}
+
+void Manager::takeAction(int32 type, const Common::String &target, double value) {
+	Action action;
+	action.type = type;
+	action.target = target;
+	action.hasValue = true;
+	action.value = value;
+	takeAction(action);
+}
+
+void Manager::takeAction(int32 type, const Common::String &target, const Common::String &text) {
+	Action action;
+	action.type = type;
+	action.target = target;
+	action.hasValue = true;
+	action.text = text;
+	takeAction(action);
+}
+
+Manager::Request Manager::takeRequest() {
+	Request request = _request;
+	_request = Request();
+	return request;
+}
+
+void Manager::createObject(const Common::String &name, const Common::String &templateName, const Common::String &parentName) {
+	Panel *parent = findPanel(parentName);
+	if (!parent || !_templates.contains(templateName)) {
+		warning("Couldn't create UI object %s from %s in %s", name.c_str(), templateName.c_str(), parentName.c_str());
+		return;
+	}
+
+	const Common::Array<byte> &data = _templates[templateName];
+	Common::MemoryReadStream stream(data.data(), data.size());
+	stream.skip(33); // version
+	int32 type = stream.readSint32LE();
+
+	Control *control = parent->addNewControl(type, stream);
+	control->setName(name);
+	parent->arrangeControls();
+}
+
+void Manager::destroyObject(const Common::String &name) {
+	for (Panel *panel : _panels) {
+		if (panel->removeControl(name)) {
+			return;
+		}
+	}
+}
+
+Panel *Manager::findPanel(const Common::String &name) const {
+	for (Panel *panel : _panels) {
+		if (panel->getName().equalsIgnoreCase(name)) {
+			return panel;
+		}
+	}
+
+	return nullptr;
+}
+
+void Manager::handleInput(NancyInput &input) {
+	// The panels drawn on top get the mouse first
+	bool isOverUI = false;
+	bool isOverControl = false;
+	for (int i = _panels.size() - 1; i >= 0; --i) {
+		bool isOverPanelControl = false;
+		if (_panels[i]->handleInput(input, !isOverUI, isOverPanelControl)) {
+			isOverUI = true;
+			isOverControl |= isOverPanelControl;
+		}
+	}
+
+	if (isOverUI) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)kUICursor, true, isOverControl);
+		input.eatMouseInput();
+	}
+}
+
+const Graphics::ManagedSurface *Manager::getTexture(const Common::String &name) {
+	Graphics::ManagedSurface *texture = _textures.getValOrDefault(name, nullptr);
+	if (!texture) {
+		texture = new Graphics::ManagedSurface();
+		if (!g_nancy->_resource->loadImage(Common::Path(name), *texture)) {
+			warning("Couldn't load UI texture %s", name.c_str());
+		}
+
+		_textures.setVal(name, texture);
+	}
+
+	return texture->empty() ? nullptr : texture;
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
new file mode 100644
index 00000000000..380fc09485e
--- /dev/null
+++ b/engines/nancy/ui/ndui/manager.h
@@ -0,0 +1,107 @@
+/* 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_UI_NDUI_MANAGER_H
+#define NANCY_UI_NDUI_MANAGER_H
+
+#include "common/array.h"
+#include "common/hash-str.h"
+
+#include "engines/nancy/ui/ndui/common.h"
+
+namespace Graphics {
+class ManagedSurface;
+}
+
+namespace Nancy {
+
+struct NancyInput;
+
+namespace UI {
+namespace NDUI {
+
+class Panel;
+
+// The Nancy16 UI, owned by the scene. It creates the panels from the NDUI chunks of the
+// player character's UI IFF and the IFFs it includes, and stands between them and the
+// rest of the engine:
+// - Actions, whether fired by a control's event or sent by a scene's UIControl record,
+//   go to the panel or control they name. The ones naming an "Engine_" target go to
+//   the engine handlers instead: inventory, journal, saving and loading, options...
+// - The scene tells it about the inventory, captions, conversations, the clock and scene
+//   changes, which it passes on to the controls that show them.
+// - Input goes to the topmost panel under the mouse, and keys to the focused edit box.
+// - What the UI asks of the engine (new game, load, save, quit) waits for the scene to
+//   pick it up, since it can replace the UI itself.
+// It also keeps the textures the elements draw, and the templates of the controls that
+// get created while the game runs, like journal entries and conversation responses.
+class Manager {
+public:
+	// 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
+	enum RequestType {
+		kRequestNone,
+		kRequestNewGame,
+		kRequestLoad,
+		kRequestSave,
+		kRequestQuit
+	};
+
+	struct Request {
+		RequestType type = kRequestNone;
+		int slot = -1;
+		Common::String name;
+	};
+
+	~Manager();
+
+	Request takeRequest();
+
+	// Creates every panel in the IFF with the given name, and in the ones it includes
+	void load(const Common::String &name);
+
+	void takeAction(const Action &action);
+	void takeAction(int32 type, const Common::String &target, double value);
+	void takeAction(int32 type, const Common::String &target, const Common::String &text);
+	void handleInput(NancyInput &input);
+	void registerGraphics();
+
+	const Graphics::ManagedSurface *getTexture(const Common::String &name);
+
+	// Creates a control from a template, as the last one of a panel
+	void createObject(const Common::String &name, const Common::String &templateName, const Common::String &parentName);
+	void destroyObject(const Common::String &name);
+
+private:
+	Panel *findPanel(const Common::String &name) const;
+
+	Common::Array<Panel *> _panels;
+	Common::HashMap<Common::String, Graphics::ManagedSurface *, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _textures;
+	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _templates;
+
+	Request _request;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_MANAGER_H
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
new file mode 100644
index 00000000000..4f2c40aa83a
--- /dev/null
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -0,0 +1,411 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/ui/ndui/panel.h"
+#include "engines/nancy/ui/ndui/control.h"
+#include "engines/nancy/ui/ndui/manager.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+Panel::Panel(Manager *manager, uint16 z, byte kind) : RenderObject(z), _manager(manager), _kind(kind) {
+	setNativeResolution(true);
+}
+
+Panel::~Panel() {
+	delete _background;
+
+	for (Control *control : _controls) {
+		delete control;
+	}
+}
+
+void Panel::read(Common::SeekableReadStream &stream) {
+	stream.skip(33); // version
+
+	int32 mode = stream.readSint32LE();
+	_arrangement = (Arrangement)stream.readSint32LE();
+	stream.skip(4);
+	_spacing.x = stream.readSint32LE();
+	_spacing.y = stream.readSint32LE();
+	readExclusiveRect(stream, _margins);
+	_backgroundColor = stream.readUint32BE();
+	stream.skip(1);
+
+	// The background is a static that also gives the panel its name, position and events
+	_background = new Static(this);
+	if (stream.readSint32LE() == kControlStatic) {
+		_background->read(stream);
+	}
+
+	int16 numControls = stream.readSint16LE();
+	for (int16 i = 0; i < numControls; ++i) {
+		readControl(stream);
+	}
+
+	if (_kind == kObjectScrollPanel || _kind == kObjectInvPanel) {
+		readFilename(stream, _scrollBarName);
+		_scrollStep = MAX<int32>(stream.readSint32LE(), 1);
+	}
+
+	Common::Rect screenPosition = _background->_bounds;
+	_background->_bounds.moveTo(0, 0);
+	moveTo(screenPosition);
+	_drawSurface.create(screenPosition.width(), screenPosition.height(), g_nancy->_graphics->getTransparentPixelFormat());
+
+	switch (mode) {
+	case kModeVisible:
+		_showCount = 1;
+		break;
+	case kModeVisibleDisabled:
+		_showCount = 1;
+		_enabled = false;
+		break;
+	default:
+		break;
+	}
+
+	setVisible(_showCount > 0);
+	arrangeControls();
+}
+
+void Panel::readControl(Common::SeekableReadStream &stream) {
+	int32 type = stream.readSint32LE();
+	addNewControl(type, stream);
+}
+
+Control *Panel::addNewControl(int32 type, Common::SeekableReadStream &stream) {
+	Control *control = nullptr;
+	switch (type) {
+	case kControlStatic:
+		control = new Static(this);
+		break;
+	case kControlVarStatic:
+		control = new VarStatic(this);
+		break;
+	default:
+		error("Unknown NDUI control type %d", type);
+	}
+
+	control->read(stream);
+	addControl(control);
+	return control;
+}
+
+// Controls are numbered in the order they get added
+void Panel::addControl(Control *control) {
+	control->_id = _nextID++;
+	_controls.push_back(control);
+	_arrangeOrder.push_back(control);
+}
+
+const Common::String &Panel::getName() const {
+	return _background->getName();
+}
+
+void Panel::arrangeControls() {
+	_isDirty = true;
+
+	if (_arrangement == kArrangeNone) {
+		return;
+	}
+
+	int width = _screenPosition.width();
+	int rowStart = _arrangement == kArrangeLeftToRight ? _margins.left : width - _margins.right;
+	int x = rowStart;
+	int y = _margins.top;
+	int rowBottom = y;
+
+	// Controls go one after another along a row, and start a new row when they don't fit
+	for (Control *control : _arrangeOrder) {
+		if (!control->isVisible()) {
+			continue;
+		}
+
+		int controlWidth = control->_bounds.width();
+		int controlHeight = control->_bounds.height();
+
+		if (_arrangement == kArrangeLeftToRight) {
+			if (x + controlWidth > width - _margins.right) {
+				x = rowStart;
+				y = rowBottom + _spacing.y;
+			}
+
+			control->_bounds.moveTo(x, y);
+			x += controlWidth + _spacing.x;
+		} else {
+			if (x - controlWidth < _margins.left) {
+				x = rowStart;
+				y = rowBottom + _spacing.y;
+			}
+
+			control->_bounds.moveTo(x - controlWidth, y);
+			x -= controlWidth + _spacing.x;
+		}
+
+		rowBottom = MAX(rowBottom, y + controlHeight);
+	}
+
+	// A scroll panel's scroll bar covers everything the controls take up
+	if (!_scrollBarName.empty()) {
+		int contentHeight = rowBottom + _margins.bottom;
+		if (contentHeight <= _screenPosition.height()) {
+			_scrollOffset = 0;
+		}
+
+		_manager->takeAction(kActionSetRange, _scrollBarName, (double)contentHeight);
+	}
+}
+
+bool Panel::takeAction(const Action &action) {
+	if (!getName().equalsIgnoreCase(action.target)) {
+		for (Control *control : _controls) {
+			if (control->takeAction(action)) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	switch (action.type) {
+	case kActionShow:
+		show();
+		break;
+	case kActionHide:
+		hide();
+		break;
+	case kActionToggle:
+		if (isVisible()) {
+			hide();
+		} else {
+			show();
+		}
+
+		break;
+	case kActionEnable:
+		_enabled = true;
+		break;
+	case kActionDisable:
+		_enabled = false;
+		break;
+	case kActionSetValue:
+		// A scroll panel follows its scroll bar, a whole step at a time
+		if (_scrollBarName.empty()) {
+			return false;
+		}
+
+		_scrollOffset = ((int)action.value + _scrollStep / 2) / _scrollStep * _scrollStep;
+		break;
+	default:
+		return false;
+	}
+
+	_isDirty = true;
+	return true;
+}
+
+// Every show needs a matching hide before the panel goes away
+void Panel::show() {
+	++_showCount;
+	setVisible(true);
+	fireEvent(kEventShow);
+}
+
+void Panel::hide() {
+	--_showCount;
+	if (_showCount == 0) {
+		setVisible(false);
+		if (_capture) {
+			_capture->_isPressed = false;
+			_capture = nullptr;
+		}
+
+		fireEvent(kEventHide);
+	} else if (_showCount < 0) {
+		_showCount = 0;
+	}
+}
+
+void Panel::fireEvent(int32 eventType) {
+	_background->fireEvents(eventType, false, 0, getName());
+
+	for (Control *control : _controls) {
+		control->fireEvents(eventType, false, 0, control->getName());
+	}
+}
+
+bool Panel::removeControl(const Common::String &name) {
+	for (uint i = 0; i < _controls.size(); ++i) {
+		Control *control = _controls[i];
+		if (!control->getName().equalsIgnoreCase(name)) {
+			continue;
+		}
+
+		_controls.remove_at(i);
+
+		for (uint j = 0; j < _arrangeOrder.size(); ++j) {
+			if (_arrangeOrder[j] == control) {
+				_arrangeOrder.remove_at(j);
+				break;
+			}
+		}
+
+		if (_capture == control) {
+			_capture = nullptr;
+		}
+
+		delete control;
+		arrangeControls();
+		return true;
+	}
+
+	return false;
+}
+
+void Panel::updateGraphics() {
+	uint32 now = g_nancy->getTotalPlayTime();
+	if (_lastUpdateTime == 0) {
+		_lastUpdateTime = now;
+	}
+
+	float seconds = (now - _lastUpdateTime) / 1000.0f;
+	_lastUpdateTime = now;
+
+	if (!isVisible()) {
+		return;
+	}
+
+	for (Control *control : _controls) {
+		control->fireEvents(kEventUpdate, false, 0, control->getName());
+	}
+
+	bool changed = _background->update(seconds);
+	for (Control *control : _controls) {
+		changed |= control->update(seconds);
+	}
+
+	if (changed || _isDirty) {
+		redraw();
+	}
+}
+
+void Panel::redraw() {
+	_drawSurface.clear(0);
+
+	if (_backgroundColor >> 24) {
+		_drawSurface.clear(argbToColor(_drawSurface.format, _backgroundColor));
+	}
+
+	_background->draw(_drawSurface, Common::Point());
+	for (Control *control : _controls) {
+		control->draw(_drawSurface, Common::Point(0, -_scrollOffset));
+	}
+
+	_isDirty = false;
+	_needsRedraw = true;
+}
+
+Control *Panel::getControlAt(const Common::Point &pos) const {
+	for (Control *control : _controls) {
+		if (control->isVisible() && control->isEnabled() && control->containsPoint(pos)) {
+			return control;
+		}
+	}
+
+	return nullptr;
+}
+
+bool Panel::handleInput(NancyInput &input, bool canHover, bool &isOverControl) {
+	isOverControl = false;
+	Common::Point pos(input.screenMousePos.x - _screenPosition.left, input.screenMousePos.y - _screenPosition.top + _scrollOffset);
+
+	// A control that got pressed keeps the mouse until it is released
+	if (_capture) {
+		Control *capture = _capture;
+		if (input.input & NancyInput::kLeftMouseButtonHeld) {
+			capture->onMouseMove(pos);
+		} else {
+			_capture = nullptr;
+			capture->onMouseUp(pos);
+		}
+
+		_isDirty = true;
+		isOverControl = true;
+		return true;
+	}
+
+	bool isInside = canHover && isVisible() && _screenPosition.contains(input.screenMousePos);
+	Control *hovered = (isInside && _enabled) ? getControlAt(pos) : nullptr;
+
+	for (Control *control : _controls) {
+		if (control->_isMouseOver != (control == hovered)) {
+			control->_isMouseOver = control == hovered;
+			_isDirty = true;
+		}
+	}
+
+	if (!isInside) {
+		return false;
+	}
+
+	int notches = 0;
+	if (input.input & NancyInput::kMouseWheelUp) {
+		notches = 1;
+	} else if (input.input & NancyInput::kMouseWheelDown) {
+		notches = -1;
+	}
+
+	if (hovered) {
+		isOverControl = true;
+		hovered->onMouseHover(pos);
+
+		if ((input.input & NancyInput::kLeftMouseButtonDown) && hovered->onMouseDown(pos)) {
+			_capture = hovered;
+		}
+
+		if (notches && hovered->onWheel(notches)) {
+			notches = 0;
+		}
+
+		_isDirty = true;
+	} else if (_enabled && (input.input & NancyInput::kLeftMouseButtonUp)) {
+		// A click on the panel itself reports no value
+		_background->fireEvents(kEventClicked, false, -1, getName());
+	}
+
+	// The wheel scrolls a scroll panel by one step
+	if (notches && !_scrollBarName.empty()) {
+		_manager->takeAction(kActionSetValue, _scrollBarName, (double)(notches * _scrollStep));
+	}
+
+	return true;
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
new file mode 100644
index 00000000000..7b900ae03fe
--- /dev/null
+++ b/engines/nancy/ui/ndui/panel.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_UI_NDUI_PANEL_H
+#define NANCY_UI_NDUI_PANEL_H
+
+#include "engines/nancy/renderobject.h"
+
+#include "engines/nancy/ui/ndui/common.h"
+
+namespace Nancy {
+
+struct NancyInput;
+
+namespace UI {
+namespace NDUI {
+
+class Manager;
+class Control;
+class Static;
+
+// A window of the UI, like the inventory, the journal or the bars around the viewport.
+// Each NDUI chunk describes one panel: its background, which is a Static that also gives
+// the panel its name and position, its controls, and how they get laid out. The panel
+// draws its controls into its own surface, at the screen's resolution, whenever one of
+// them changes. It passes the mouse to the control under it, and actions to the control
+// they name. Scroll panels move their controls along with a scroll bar, and inventory
+// panels show a button for every item the player has.
+class Panel : public RenderObject {
+public:
+	Panel(Manager *manager, uint16 z, byte kind);
+	virtual ~Panel();
+
+	void read(Common::SeekableReadStream &stream);
+
+	void updateGraphics() override;
+
+	// Returns true when the action was meant for this panel or one of its controls
+	bool takeAction(const Action &action);
+
+	// Returns true when the mouse is over the panel, or held by one of its controls.
+	// Panels further down can't be hovered once one above took the mouse.
+	bool handleInput(NancyInput &input, bool canHover, bool &isOverControl);
+
+	const Common::String &getName() const;
+	bool isEnabled() const { return _enabled; }
+	Manager *getManager() const { return _manager; }
+
+	void addControl(Control *control);
+	void setDirty() { _isDirty = true; }
+
+	// Reads a control of the given type and adds it
+	Control *addNewControl(int32 type, Common::SeekableReadStream &stream);
+	bool removeControl(const Common::String &name);
+
+	// Places the visible controls one after another, when the panel asks for it
+	void arrangeControls();
+
+private:
+	enum Arrangement {
+		kArrangeNone		= 0,
+		kArrangeLeftToRight	= 1,
+		kArrangeRightToLeft	= 2
+	};
+
+	enum Mode {
+		kModeVisible			= 0,
+		kModeVisibleDisabled	= 1,
+		kModeHidden				= 2
+	};
+
+	void readControl(Common::SeekableReadStream &stream);
+	void show();
+	void hide();
+	void fireEvent(int32 eventType);
+	void redraw();
+	Control *getControlAt(const Common::Point &pos) const;
+
+	Manager *_manager;
+	byte _kind;
+
+	uint32 _backgroundColor = 0;
+	Static *_background = nullptr;
+	Common::Array<Control *> _controls;
+	Common::Array<Control *> _arrangeOrder;
+	int _nextID = 1;
+
+	Arrangement _arrangement = kArrangeNone;
+	Common::Point _spacing;
+	Common::Rect _margins;
+
+	// Scroll panels move their controls up and down, following a scroll bar
+	Common::String _scrollBarName;
+	int32 _scrollStep = 1;
+	int32 _scrollOffset = 0;
+
+	int _showCount = 0;
+	bool _enabled = true;
+	uint32 _lastUpdateTime = 0;
+	bool _isDirty = true;
+	Control *_capture = nullptr;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_PANEL_H


Commit: a04600bcb5b0f0196d63a51ab95a53d93ccb42b9
    https://github.com/scummvm/scummvm/commit/a04600bcb5b0f0196d63a51ab95a53d93ccb42b9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:13+03:00

Commit Message:
NANCY: NANCY16: NDUI: Implement the button types

- Buttons, check boxes, radio buttons and radio groups
- Inventory buttons, one per item, shown and hidden as the player's items change

Changed paths:
  A engines/nancy/ui/ndui/button.cpp
  A engines/nancy/ui/ndui/button.h
    engines/nancy/module.mk
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 93da74ac7f6..1864862cc1d 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -98,6 +98,7 @@ MODULE_OBJS = \
   ui/conversationpopup.o \
   ui/inventorybox.o \
   ui/inventorypopup.o \
+  ui/ndui/button.o \
   ui/ndui/common.o \
   ui/ndui/control.o \
   ui/ndui/element.o \
diff --git a/engines/nancy/ui/ndui/button.cpp b/engines/nancy/ui/ndui/button.cpp
new file mode 100644
index 00000000000..47f8e5b13ba
--- /dev/null
+++ b/engines/nancy/ui/ndui/button.cpp
@@ -0,0 +1,317 @@
+/* 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/util.h"
+
+#include "engines/nancy/ui/ndui/button.h"
+#include "engines/nancy/ui/ndui/panel.h"
+#include "engines/nancy/ui/ndui/manager.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+void Button::readExtra(Common::SeekableReadStream &stream) {
+	Common::String textureName;
+	Common::Rect src;
+	readFilename(stream, textureName);
+	readExclusiveRect(stream, src);
+
+	_elements.resize(2);
+	_elements[1].setTexture(textureName, src);
+
+	_pressedOffset.x = stream.readSint32LE();
+	_pressedOffset.y = stream.readSint32LE();
+}
+
+ControlState Button::getButtonState() const {
+	if (!_visible) {
+		return kStateHidden;
+	}
+
+	ControlState state = getState();
+	if (state != kStateNormal) {
+		return state;
+	}
+
+	if (_isPressed) {
+		return kStatePressed;
+	}
+
+	return _isMouseOver ? kStateMouseOver : kStateNormal;
+}
+
+// Buttons keep being drawn after they get hidden, until they have faded out
+bool Button::update(float seconds) {
+	ControlState state = getButtonState();
+	float rate = state == kStatePressed ? 0.0f : kControlBlendRate;
+
+	bool changed = _elements[0].blend(state, seconds, rate);
+	changed |= _elements[1].blend(state, seconds, rate);
+	return changed;
+}
+
+void Button::draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const {
+	Common::Rect dest = _bounds;
+	dest.translate(offset.x, offset.y);
+	if (_isPressed) {
+		dest.translate(_pressedOffset.x, _pressedOffset.y);
+	}
+
+	drawSprite(surface, 0, dest);
+	_elements[0].drawText(surface, _text, dest, false);
+	drawSprite(surface, 1, dest);
+	_elements[1].drawText(surface, _text, dest, false);
+}
+
+bool Button::onMouseDown(const Common::Point &pos) {
+	_isPressed = true;
+	return true;
+}
+
+void Button::onMouseUp(const Common::Point &pos) {
+	_isPressed = false;
+	if (_visible && _enabled && _bounds.contains(pos)) {
+		sendEvent(kEventClicked);
+	}
+}
+
+bool Button::doAction(const Action &action) {
+	switch (action.type) {
+	case kActionAddText:
+		setText(_text + action.text);
+		return true;
+	case kActionSetText:
+		setText(action.text);
+		return true;
+	default:
+		return Control::doAction(action);
+	}
+}
+
+bool Button::getValue(int32 eventType, double &value, Common::String &text) const {
+	if (eventType != kEventClicked) {
+		return false;
+	}
+
+	value = _id;
+	text = _name;
+	return true;
+}
+
+void InvButton::readExtra(Common::SeekableReadStream &stream) {
+	Button::readExtra(stream);
+
+	_itemID = stream.readSint16LE();
+	_kind = stream.readSint32LE();
+	_scene = stream.readSint32LE();
+	_valueIndex = stream.readSint16LE();
+
+	// The count gets drawn over the item picture
+	_elements[0].moveFontTo(_elements[1]);
+}
+
+bool InvButton::doAction(const Action &action) {
+	if (action.type == kActionSetValue) {
+		setText(Common::String::format(" %d ", (int)action.value));
+		return true;
+	}
+
+	return Button::doAction(action);
+}
+
+bool InvButton::getValue(int32 eventType, double &value, Common::String &text) const {
+	if (eventType != kEventClicked) {
+		return false;
+	}
+
+	value = _itemID;
+	text = _name;
+	return true;
+}
+
+void CheckBox::readCheckTexture(Common::SeekableReadStream &stream) {
+	Common::String textureName;
+	Common::Rect src;
+	readFilename(stream, textureName);
+	readExclusiveRect(stream, src);
+
+	_elements.resize(2);
+	_elements[1].setTexture(textureName, src);
+}
+
+void CheckBox::readExtra(Common::SeekableReadStream &stream) {
+	readCheckTexture(stream);
+	_checked = stream.readByte() == 1;
+}
+
+// The box is as large as its picture, at the left of the control, and the text
+// follows it
+int CheckBox::getTextWidth() const {
+	return _bounds.width() - (int)(1.25f * _elements[0]._src.width());
+}
+
+Common::Rect CheckBox::getBoxBounds() const {
+	const Common::Rect &src = _elements[0]._src;
+	Common::Rect box(src.width(), src.height());
+	box.moveTo(_bounds.left, _bounds.top + (_bounds.height() - src.height()) / 2);
+	return box;
+}
+
+bool CheckBox::update(float seconds) {
+	ControlState state = kStateHidden;
+	if (_visible) {
+		state = getState();
+		if (state == kStateNormal) {
+			if (_isPressed || _checked) {
+				state = kStatePressed;
+			} else if (_isMouseOver) {
+				state = kStateMouseOver;
+			}
+		}
+	}
+
+	float rate = state == kStatePressed ? 0.0f : kControlBlendRate;
+	bool changed = _elements[0].blend(state, seconds, rate);
+	changed |= _elements[1].blend(_checked ? state : kStateHidden, seconds, rate);
+	return changed;
+}
+
+void CheckBox::draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const {
+	Common::Rect box = getBoxBounds();
+	box.translate(offset.x, offset.y);
+
+	Common::Rect textBounds = _bounds;
+	textBounds.translate(offset.x, offset.y);
+	textBounds.left += (int)(1.25f * box.width());
+
+	drawSprite(surface, 0, box);
+	_elements[0].drawText(surface, _text, textBounds, true);
+	drawSprite(surface, 1, box);
+}
+
+bool CheckBox::onMouseDown(const Common::Point &pos) {
+	_isPressed = true;
+	return true;
+}
+
+void CheckBox::onMouseUp(const Common::Point &pos) {
+	_isPressed = false;
+	if (_visible && _enabled && _bounds.contains(pos)) {
+		onClicked();
+	}
+}
+
+void CheckBox::onClicked() {
+	_checked = !_checked;
+	sendEvent(_checked ? kEventChecked : kEventUnchecked);
+}
+
+bool CheckBox::doAction(const Action &action) {
+	switch (action.type) {
+	case kActionSetText:
+		setText(action.text);
+		return true;
+	case kActionSetValue:
+		_checked = action.value != 0;
+		_panel->setDirty();
+		return true;
+	default:
+		return Control::doAction(action);
+	}
+}
+
+bool CheckBox::getValue(int32 eventType, double &value, Common::String &text) const {
+	if (eventType != kEventChecked && eventType != kEventUnchecked) {
+		return false;
+	}
+
+	value = _checked ? 1 : 0;
+	text = _name;
+	return true;
+}
+
+bool CheckBox::isOwnEvent(int32 eventType) const {
+	return eventType == (_checked ? kEventChecked : kEventUnchecked);
+}
+
+void RadioButton::readExtra(Common::SeekableReadStream &stream) {
+	readCheckTexture(stream);
+
+	// Buttons read as part of a group already belong to it
+	Common::String group;
+	readFilename(stream, group);
+	if (!group.empty()) {
+		_group = group;
+	}
+}
+
+void RadioButton::onClicked() {
+	if (!_checked) {
+		_panel->uncheckRadioGroup(_group);
+		_checked = true;
+		sendEvent(kEventRadioChanged);
+	}
+}
+
+bool RadioButton::doAction(const Action &action) {
+	if (action.type == kActionSetValue) {
+		if (action.value != 0) {
+			_panel->uncheckRadioGroup(_group);
+		}
+
+		_checked = action.value != 0;
+		_panel->setDirty();
+		return true;
+	}
+
+	return CheckBox::doAction(action);
+}
+
+// The value is the position of the checked button in its group
+bool RadioButton::getValue(int32 eventType, double &value, Common::String &text) const {
+	if (eventType != kEventRadioChanged) {
+		return false;
+	}
+
+	value = (uint32)_panel->getCheckedRadioIndex(_group);
+	text = _name;
+	return true;
+}
+
+void RadioGroup::readExtra(Common::SeekableReadStream &stream) {
+	Common::String selected;
+	readFilename(stream, selected);
+
+	int16 numButtons = stream.readSint16LE();
+	for (int16 i = 0; i < numButtons; ++i) {
+		stream.skip(4); // control type
+		RadioButton *button = new RadioButton(_panel);
+		button->setGroup(_name);
+		button->read(stream);
+		button->setChecked(button->getName().equalsIgnoreCase(selected));
+		_panel->addRadioButton(button);
+	}
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/button.h b/engines/nancy/ui/ndui/button.h
new file mode 100644
index 00000000000..bc4137c733a
--- /dev/null
+++ b/engines/nancy/ui/ndui/button.h
@@ -0,0 +1,142 @@
+/* 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_UI_NDUI_BUTTON_H
+#define NANCY_UI_NDUI_BUTTON_H
+
+#include "engines/nancy/ui/ndui/control.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+class Button : public Control {
+public:
+	Button(Panel *panel) : Control(panel) {}
+
+	bool update(float seconds) override;
+	void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const override;
+
+	bool containsPoint(const Common::Point &pos) const override { return _bounds.contains(pos); }
+	bool onMouseDown(const Common::Point &pos) override;
+	void onMouseUp(const Common::Point &pos) override;
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+	bool getValue(int32 eventType, double &value, Common::String &text) const override;
+	bool isOwnEvent(int32 eventType) const override { return eventType == kEventClicked; }
+	ControlState getButtonState() const;
+
+	Common::Point _pressedOffset;
+};
+
+// One inventory item; its text shows how many of the item the player has
+class InvButton : public Button {
+public:
+	// What clicking an item does
+	enum ItemKind {
+		kItemPickUp				= 1,	// the item gets held
+		kItemView				= 3,	// the item leaves the inventory while its scene is up
+		kItemOpenCountedScene	= 4,	// opens a scene; the button shows a table value
+		kItemOpenScene			= 6		// opens a scene
+	};
+
+	InvButton(Panel *panel) : Button(panel) {}
+
+	int16 getItemID() const { return _itemID; }
+	int32 getKind() const { return _kind; }
+	uint16 getScene() const { return (uint16)_scene; }
+	int16 getValueIndex() const { return _valueIndex; }
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+	bool getValue(int32 eventType, double &value, Common::String &text) const override;
+
+	int16 _itemID = -1;
+	int32 _kind = kItemPickUp;
+	int32 _scene = 0;
+	int16 _valueIndex = 0;
+};
+
+class CheckBox : public Control {
+public:
+	CheckBox(Panel *panel) : Control(panel) {}
+
+	bool update(float seconds) override;
+	void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const override;
+
+	bool containsPoint(const Common::Point &pos) const override { return _bounds.contains(pos); }
+	bool onMouseDown(const Common::Point &pos) override;
+	void onMouseUp(const Common::Point &pos) override;
+
+	bool isChecked() const { return _checked; }
+	void setChecked(bool checked) { _checked = checked; }
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+	bool getValue(int32 eventType, double &value, Common::String &text) const override;
+	bool isOwnEvent(int32 eventType) const override;
+
+	virtual void onClicked();
+	Common::Rect getBoxBounds() const;
+	int getTextWidth() const override;
+	int getMinHeight() const override { return _elements[0]._src.height(); }
+	void readCheckTexture(Common::SeekableReadStream &stream);
+
+	bool _checked = false;
+};
+
+// A check box that unchecks the other buttons of its group when it gets checked
+class RadioButton : public CheckBox {
+public:
+	RadioButton(Panel *panel) : CheckBox(panel) {}
+
+	const Common::String &getGroup() const { return _group; }
+	void setGroup(const Common::String &group) { _group = group; }
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+	bool getValue(int32 eventType, double &value, Common::String &text) const override;
+	bool isOwnEvent(int32 eventType) const override { return eventType == kEventRadioChanged; }
+
+	void onClicked() override;
+
+	Common::String _group;
+};
+
+// Holds a set of radio buttons, which get added to the panel as controls of their own
+class RadioGroup : public Static {
+public:
+	RadioGroup(Panel *panel) : Static(panel) {}
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_BUTTON_H
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 4f2c40aa83a..6aa7a2c223b 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -26,12 +26,16 @@
 
 #include "engines/nancy/ui/ndui/panel.h"
 #include "engines/nancy/ui/ndui/control.h"
+#include "engines/nancy/ui/ndui/button.h"
 #include "engines/nancy/ui/ndui/manager.h"
 
 namespace Nancy {
 namespace UI {
 namespace NDUI {
 
+// Inventory buttons are numbered after their items
+static const int kInvButtonFirstID = 10;
+
 Panel::Panel(Manager *manager, uint16 z, byte kind) : RenderObject(z), _manager(manager), _kind(kind) {
 	setNativeResolution(true);
 }
@@ -101,9 +105,27 @@ void Panel::readControl(Common::SeekableReadStream &stream) {
 Control *Panel::addNewControl(int32 type, Common::SeekableReadStream &stream) {
 	Control *control = nullptr;
 	switch (type) {
+	case kControlButton:
+		control = new Button(this);
+		break;
 	case kControlStatic:
 		control = new Static(this);
 		break;
+	case kControlRadioGroup:
+		control = new RadioGroup(this);
+		break;
+	case kControlRadioButton: {
+		RadioButton *button = new RadioButton(this);
+		button->read(stream);
+		addRadioButton(button);
+		return button;
+	}
+	case kControlCheckBox:
+		control = new CheckBox(this);
+		break;
+	case kControlInvButton:
+		control = new InvButton(this);
+		break;
 	case kControlVarStatic:
 		control = new VarStatic(this);
 		break;
@@ -116,13 +138,24 @@ Control *Panel::addNewControl(int32 type, Common::SeekableReadStream &stream) {
 	return control;
 }
 
-// Controls are numbered in the order they get added
+// Controls are numbered in the order they get added; inventory buttons after their item
 void Panel::addControl(Control *control) {
-	control->_id = _nextID++;
+	if (_kind == kObjectInvPanel) {
+		InvButton *button = (InvButton *)control;
+		control->_id = button->getItemID() + kInvButtonFirstID;
+	} else {
+		control->_id = _nextID++;
+	}
+
 	_controls.push_back(control);
 	_arrangeOrder.push_back(control);
 }
 
+void Panel::addRadioButton(RadioButton *button) {
+	addControl(button);
+	_radioButtons.push_back(button);
+}
+
 const Common::String &Panel::getName() const {
 	return _background->getName();
 }
@@ -221,6 +254,14 @@ bool Panel::takeAction(const Action &action) {
 
 		_scrollOffset = ((int)action.value + _scrollStep / 2) / _scrollStep * _scrollStep;
 		break;
+	case kActionShowItem:
+	case kActionHideItem:
+		if (_kind != kObjectInvPanel) {
+			return false;
+		}
+
+		showItem((int16)action.value, action.type == kActionShowItem);
+		break;
 	default:
 		return false;
 	}
@@ -229,6 +270,25 @@ bool Panel::takeAction(const Action &action) {
 	return true;
 }
 
+// The newest item goes first
+void Panel::showItem(int16 itemID, bool show) {
+	for (uint i = 0; i < _arrangeOrder.size(); ++i) {
+		Control *control = _arrangeOrder[i];
+		if (((InvButton *)control)->getItemID() != itemID) {
+			continue;
+		}
+
+		if (show) {
+			_arrangeOrder.remove_at(i);
+			_arrangeOrder.insert_at(0, control);
+		}
+
+		control->setVisible(show);
+		arrangeControls();
+		return;
+	}
+}
+
 // Every show needs a matching hide before the panel goes away
 void Panel::show() {
 	++_showCount;
@@ -275,6 +335,13 @@ bool Panel::removeControl(const Common::String &name) {
 			}
 		}
 
+		for (uint j = 0; j < _radioButtons.size(); ++j) {
+			if (_radioButtons[j] == control) {
+				_radioButtons.remove_at(j);
+				break;
+			}
+		}
+
 		if (_capture == control) {
 			_capture = nullptr;
 		}
@@ -287,6 +354,33 @@ bool Panel::removeControl(const Common::String &name) {
 	return false;
 }
 
+void Panel::uncheckRadioGroup(const Common::String &group) {
+	for (RadioButton *button : _radioButtons) {
+		if (button->getGroup().equalsIgnoreCase(group)) {
+			button->setChecked(false);
+		}
+	}
+
+	_isDirty = true;
+}
+
+int Panel::getCheckedRadioIndex(const Common::String &group) const {
+	int index = 0;
+	for (RadioButton *button : _radioButtons) {
+		if (!button->getGroup().equalsIgnoreCase(group)) {
+			continue;
+		}
+
+		if (button->isChecked()) {
+			return index;
+		}
+
+		++index;
+	}
+
+	return -1;
+}
+
 void Panel::updateGraphics() {
 	uint32 now = g_nancy->getTotalPlayTime();
 	if (_lastUpdateTime == 0) {
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index 7b900ae03fe..0b06d40799c 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -36,6 +36,7 @@ namespace NDUI {
 class Manager;
 class Control;
 class Static;
+class RadioButton;
 
 // A window of the UI, like the inventory, the journal or the bars around the viewport.
 // Each NDUI chunk describes one panel: its background, which is a Static that also gives
@@ -65,6 +66,7 @@ public:
 	Manager *getManager() const { return _manager; }
 
 	void addControl(Control *control);
+	void addRadioButton(RadioButton *button);
 	void setDirty() { _isDirty = true; }
 
 	// Reads a control of the given type and adds it
@@ -74,6 +76,9 @@ public:
 	// Places the visible controls one after another, when the panel asks for it
 	void arrangeControls();
 
+	void uncheckRadioGroup(const Common::String &group);
+	int getCheckedRadioIndex(const Common::String &group) const;
+
 private:
 	enum Arrangement {
 		kArrangeNone		= 0,
@@ -93,6 +98,7 @@ private:
 	void fireEvent(int32 eventType);
 	void redraw();
 	Control *getControlAt(const Common::Point &pos) const;
+	void showItem(int16 itemID, bool show);
 
 	Manager *_manager;
 	byte _kind;
@@ -101,6 +107,7 @@ private:
 	Static *_background = nullptr;
 	Common::Array<Control *> _controls;
 	Common::Array<Control *> _arrangeOrder;
+	Common::Array<RadioButton *> _radioButtons;
 	int _nextID = 1;
 
 	Arrangement _arrangement = kArrangeNone;


Commit: f835b452356a5bdd7cfc42bf0cb8d129285edad1
    https://github.com/scummvm/scummvm/commit/f835b452356a5bdd7cfc42bf0cb8d129285edad1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:15+03:00

Commit Message:
NANCY: NANCY16: NDUI: Add the slider, scroll bar and list box widgets

- Sliders pick a value in a range; scroll bars scroll by items, with
  page sizes and arrows
- List boxes show a scrollable, selectable list of text items

Changed paths:
  A engines/nancy/ui/ndui/listbox.cpp
  A engines/nancy/ui/ndui/listbox.h
  A engines/nancy/ui/ndui/scrollbar.cpp
  A engines/nancy/ui/ndui/scrollbar.h
  A engines/nancy/ui/ndui/slider.cpp
  A engines/nancy/ui/ndui/slider.h
    engines/nancy/module.mk


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 1864862cc1d..62cb2b8875c 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -102,8 +102,11 @@ MODULE_OBJS = \
   ui/ndui/common.o \
   ui/ndui/control.o \
   ui/ndui/element.o \
+  ui/ndui/listbox.o \
   ui/ndui/manager.o \
   ui/ndui/panel.o \
+  ui/ndui/scrollbar.o \
+  ui/ndui/slider.o \
   ui/notebookpopup.o \
   ui/ornaments.o \
   ui/scrollbar.o \
diff --git a/engines/nancy/ui/ndui/listbox.cpp b/engines/nancy/ui/ndui/listbox.cpp
new file mode 100644
index 00000000000..4aa15b191ae
--- /dev/null
+++ b/engines/nancy/ui/ndui/listbox.cpp
@@ -0,0 +1,279 @@
+/* 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/graphics.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/ui/ndui/listbox.h"
+#include "engines/nancy/ui/ndui/scrollbar.h"
+#include "engines/nancy/ui/ndui/panel.h"
+#include "engines/nancy/ui/ndui/manager.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+ListBox::~ListBox() {
+	delete _scrollBar;
+}
+
+void ListBox::readExtra(Common::SeekableReadStream &stream) {
+	// The selection highlight
+	_elements.resize(2);
+	_elements[1].read(stream);
+
+	stream.skip(4); // control type of the scroll bar
+	_scrollBar = new ScrollBar(_panel);
+	_scrollBar->read(stream);
+
+	_border = stream.readSint32LE();
+	_margin = stream.readSint32LE();
+
+	int16 numItems = stream.readSint16LE();
+	for (int16 i = 0; i < numItems; ++i) {
+		Common::String item;
+		readFilename(stream, item);
+		addItem(item, false);
+	}
+
+	Common::String selected;
+	readFilename(stream, selected);
+	for (uint i = 0; i < _items.size(); ++i) {
+		if (_items[i].text.equalsIgnoreCase(selected)) {
+			_selected = i;
+			break;
+		}
+	}
+
+	updateLayout();
+}
+
+void ListBox::addItem(const Common::String &text, bool atStart) {
+	Item item;
+	item.text = text;
+	argbToChannels(_elements[0].getFontColor(kStateNormal), item.color);
+
+	if (atStart) {
+		_items.insert_at(0, item);
+		if (_selected >= 0) {
+			++_selected;
+		}
+	} else {
+		_items.push_back(item);
+	}
+
+	_scrollBar->setRange(_items.size());
+}
+
+// Rows are as tall as the selection picture, or as the text when there is none
+void ListBox::updateLayout() {
+	_rowHeight = _elements[1]._src.height() > 0 ? _elements[1]._src.height() - 1 : _elements[0].getFontHeight();
+	if (_rowHeight > 0) {
+		_scrollBar->setPageSize(getTextBounds().height() / _rowHeight);
+	}
+
+	_scrollBar->setRange(_items.size());
+}
+
+Common::Rect ListBox::getSelectionBounds() const {
+	Common::Rect bounds = _bounds;
+	bounds.grow(-_border);
+	return bounds;
+}
+
+Common::Rect ListBox::getTextBounds() const {
+	Common::Rect bounds = getSelectionBounds();
+	bounds.left += _margin;
+	bounds.right -= _margin;
+	return bounds;
+}
+
+int ListBox::getItemAt(const Common::Point &pos) const {
+	if (_rowHeight <= 0) {
+		return -1;
+	}
+
+	Common::Rect text = getTextBounds();
+	int first = _scrollBar->getPosition();
+	int numRows = text.height() / _rowHeight;
+	int item = (pos.y - text.top) / _rowHeight + first;
+	if (pos.y < text.top || item >= (int)_items.size() || item >= first + numRows) {
+		return -1;
+	}
+
+	return item;
+}
+
+bool ListBox::update(float seconds) {
+	bool changed = false;
+	if (_visible) {
+		changed |= _elements[0].blend(kStateNormal, seconds, kStaticBlendRate);
+		changed |= _elements[1].blend(kStatePressed, seconds, kStaticBlendRate);
+	}
+
+	float amount = getBlendAmount(seconds, kStaticBlendRate);
+	for (uint i = 0; i < _items.size(); ++i) {
+		bool isHovered = _isMouseOver && (int)i == _hovered;
+		changed |= blendColor(_items[i].color, _elements[0].getFontColor(isHovered ? kStateMouseOver : kStateNormal), amount);
+	}
+
+	changed |= _scrollBar->update(seconds);
+	return changed;
+}
+
+void ListBox::draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const {
+	if (!_visible) {
+		return;
+	}
+
+	Common::Rect bounds = _bounds;
+	bounds.translate(offset.x, offset.y);
+	drawSprite(surface, 0, bounds);
+
+	if (_rowHeight > 0) {
+		Common::Rect selection = getSelectionBounds();
+		selection.translate(offset.x, offset.y);
+		Common::Rect text = getTextBounds();
+		text.translate(offset.x, offset.y);
+
+		Common::Rect row(text.left, text.top, text.right, text.top + _rowHeight);
+		for (uint i = _scrollBar->getPosition(); i < _items.size() && row.bottom <= text.bottom; ++i) {
+			if ((int)i == _selected) {
+				drawSprite(surface, 1, Common::Rect(selection.left, row.top, selection.right, row.bottom));
+				_elements[1].drawText(surface, _items[i].text, row, false);
+			} else {
+				bool isHovered = _isMouseOver && (int)i == _hovered;
+				_elements[0].drawText(surface, _items[i].text, row, _items[i].color, isHovered ? kStateMouseOver : kStateNormal, false);
+			}
+
+			row.translate(0, _rowHeight);
+		}
+	}
+
+	_scrollBar->draw(surface, offset);
+}
+
+void ListBox::onMouseHover(const Common::Point &pos) {
+	Common::Rect text = getTextBounds();
+	_hovered = (pos.x >= text.left && pos.x < text.right) ? getItemAt(pos) : -1;
+}
+
+bool ListBox::onMouseDown(const Common::Point &pos) {
+	if (_scrollBar->containsPoint(pos)) {
+		_isScrollBarDragging = _scrollBar->onMouseDown(pos);
+		return true;
+	}
+
+	int item = getItemAt(pos);
+	if (!getSelectionBounds().contains(pos) || item < 0) {
+		return false;
+	}
+
+	_selected = item;
+	_isDragging = true;
+	_panel->setDirty();
+	sendEvent(kEventListSelected);
+	return true;
+}
+
+// Dragging moves the selection, and scrolls when it goes past the visible rows
+void ListBox::onMouseMove(const Common::Point &pos) {
+	if (_isScrollBarDragging) {
+		_scrollBar->onMouseMove(pos);
+		return;
+	}
+
+	if (!_isDragging || _rowHeight <= 0 || _items.empty()) {
+		return;
+	}
+
+	Common::Rect text = getTextBounds();
+	int first = _scrollBar->getPosition();
+	int numRows = text.height() / _rowHeight;
+	int item = (pos.y - text.top) / _rowHeight + first;
+	if (pos.y < text.top) {
+		_scrollBar->scroll(-1);
+		item = _scrollBar->getPosition();
+	} else if (item >= first + numRows) {
+		_scrollBar->scroll(1);
+		item = _scrollBar->getPosition() + numRows - 1;
+	}
+
+	item = CLIP<int>(item, 0, (int)_items.size() - 1);
+	if (item != _selected) {
+		_selected = item;
+		_panel->setDirty();
+		sendEvent(kEventListSelected);
+	}
+}
+
+void ListBox::onMouseUp(const Common::Point &pos) {
+	if (_isScrollBarDragging) {
+		_scrollBar->onMouseUp(pos);
+	}
+
+	_isScrollBarDragging = false;
+	_isDragging = false;
+}
+
+bool ListBox::onWheel(int notches) {
+	_scrollBar->scroll(-notches * kWheelLines);
+	return true;
+}
+
+bool ListBox::doAction(const Action &action) {
+	switch (action.type) {
+	case kActionAddText:
+		addItem(action.text, action.value < 0);
+		_panel->setDirty();
+		return true;
+	case kActionSetText:
+		_items.clear();
+		_selected = -1;
+		_hovered = -1;
+		_scrollBar->setRange(0);
+		return true;
+	case kActionSetValue:
+		_selected = CLIP<int>((int)action.value, -1, (int)_items.size() - 1);
+		_panel->setDirty();
+		if (_selected != -1) {
+			sendEvent(kEventListSelected);
+		}
+
+		return true;
+	default:
+		return Control::doAction(action);
+	}
+}
+
+bool ListBox::getValue(int32 eventType, double &value, Common::String &text) const {
+	if (eventType != kEventListSelected) {
+		return false;
+	}
+
+	value = _selected;
+	text = _selected >= 0 ? _items[_selected].text : Common::String();
+	return true;
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/listbox.h b/engines/nancy/ui/ndui/listbox.h
new file mode 100644
index 00000000000..bcf6acc5708
--- /dev/null
+++ b/engines/nancy/ui/ndui/listbox.h
@@ -0,0 +1,80 @@
+/* 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_UI_NDUI_LISTBOX_H
+#define NANCY_UI_NDUI_LISTBOX_H
+
+#include "engines/nancy/ui/ndui/control.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+class ScrollBar;
+
+class ListBox : public Control {
+public:
+	ListBox(Panel *panel) : Control(panel) {}
+	~ListBox() override;
+
+	bool update(float seconds) override;
+	void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const override;
+
+	bool containsPoint(const Common::Point &pos) const override { return _bounds.contains(pos); }
+	void onMouseHover(const Common::Point &pos) override;
+	bool onMouseDown(const Common::Point &pos) override;
+	void onMouseMove(const Common::Point &pos) override;
+	void onMouseUp(const Common::Point &pos) override;
+	bool onWheel(int notches) override;
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+	bool getValue(int32 eventType, double &value, Common::String &text) const override;
+	bool isOwnEvent(int32 eventType) const override { return eventType == kEventListSelected; }
+
+	struct Item {
+		Common::String text;
+		float color[4] = {};
+	};
+
+	void addItem(const Common::String &text, bool atStart);
+	void updateLayout();
+	Common::Rect getSelectionBounds() const;
+	Common::Rect getTextBounds() const;
+	int getItemAt(const Common::Point &pos) const;
+
+	ScrollBar *_scrollBar = nullptr;
+	int32 _border = 0;
+	int32 _margin = 0;
+	Common::Array<Item> _items;
+	int _selected = -1;
+	int _hovered = -1;
+	int _rowHeight = 0;
+	bool _isDragging = false;
+	bool _isScrollBarDragging = false;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_LISTBOX_H
diff --git a/engines/nancy/ui/ndui/scrollbar.cpp b/engines/nancy/ui/ndui/scrollbar.cpp
new file mode 100644
index 00000000000..5f8d370e509
--- /dev/null
+++ b/engines/nancy/ui/ndui/scrollbar.cpp
@@ -0,0 +1,229 @@
+/* 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/ui/ndui/scrollbar.h"
+#include "engines/nancy/ui/ndui/panel.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// The thumb of a scroll bar never gets smaller than this
+static const int kMinThumbHeight = 8;
+
+// Holding an arrow of a scroll bar keeps scrolling, after a delay
+static const float kArrowRepeatDelay = 0.33f;
+static const float kArrowRepeatInterval = 0.05f;
+
+void ScrollBar::readExtra(Common::SeekableReadStream &stream) {
+	// Up arrow, down arrow and thumb, after the track
+	_elements.resize(4);
+	for (uint i = 1; i < 4; ++i) {
+		_elements[i].read(stream);
+	}
+
+	_pageSize = stream.readSint32LE();
+	capPosition();
+	updateThumb();
+}
+
+// The arrows are square
+Common::Rect ScrollBar::getUpButton() const {
+	return Common::Rect(_bounds.left, _bounds.top, _bounds.right, _bounds.top + _bounds.width());
+}
+
+Common::Rect ScrollBar::getDownButton() const {
+	return Common::Rect(_bounds.left, _bounds.bottom - _bounds.width(), _bounds.right, _bounds.bottom);
+}
+
+Common::Rect ScrollBar::getTrack() const {
+	return Common::Rect(_bounds.left, getUpButton().bottom, _bounds.right, getDownButton().top);
+}
+
+void ScrollBar::capPosition() {
+	if (_position < 0 || _end <= _pageSize) {
+		_position = 0;
+	} else if (_position + _pageSize > _end) {
+		_position = _end - _pageSize;
+	}
+}
+
+// Every time the thumb gets placed, the scroll bar tells others about its position
+void ScrollBar::updateThumb() {
+	Common::Rect track = getTrack();
+	_panel->setDirty();
+
+	if (_pageSize < _end) {
+		int height = MAX(track.height() * _pageSize / _end, kMinThumbHeight);
+		int top = track.top + (track.height() - height) * _position / (_end - _pageSize);
+		_thumb = Common::Rect(track.left, top, track.right, top + height);
+		_showThumb = true;
+		sendEvent(kEventScrolled);
+	} else {
+		_thumb = Common::Rect(track.left, track.top, track.right, track.top);
+		_showThumb = false;
+	}
+}
+
+void ScrollBar::setRange(int32 end) {
+	_end = end;
+	capPosition();
+	updateThumb();
+}
+
+void ScrollBar::setPageSize(int32 pageSize) {
+	_pageSize = pageSize;
+	capPosition();
+	updateThumb();
+}
+
+void ScrollBar::scroll(int32 delta) {
+	_position += delta;
+	capPosition();
+	updateThumb();
+}
+
+bool ScrollBar::update(float seconds) {
+	if (_arrow != kArrowNone) {
+		_arrowHeldTime += seconds;
+		if (_arrowHeldTime > kArrowRepeatDelay) {
+			_arrowRepeatTime += seconds;
+			while (_arrowRepeatTime >= kArrowRepeatInterval) {
+				_arrowRepeatTime -= kArrowRepeatInterval;
+				if (_arrow == kArrowUp && getUpButton().contains(_lastMouse)) {
+					scroll(-1);
+				} else if (_arrow == kArrowDown && getDownButton().contains(_lastMouse)) {
+					scroll(1);
+				}
+			}
+		}
+	}
+
+	ControlState state = kStateHidden;
+	if (_visible) {
+		state = getState();
+		if (state == kStateNormal) {
+			if (!_showThumb) {
+				state = kStateDisabled;
+			} else if (_isMouseOver) {
+				state = kStateMouseOver;
+			}
+		}
+	}
+
+	bool changed = false;
+	for (Element &element : _elements) {
+		changed |= element.blend(state, seconds, kControlBlendRate);
+	}
+
+	return changed;
+}
+
+void ScrollBar::draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const {
+	Common::Rect rects[4] = { getTrack(), getUpButton(), getDownButton(), _thumb };
+	for (uint i = 0; i < 4; ++i) {
+		rects[i].translate(offset.x, offset.y);
+		drawSprite(surface, i, rects[i]);
+	}
+}
+
+bool ScrollBar::onMouseDown(const Common::Point &pos) {
+	_lastMouse = pos;
+	_arrowHeldTime = 0;
+	_arrowRepeatTime = 0;
+
+	if (getUpButton().contains(pos)) {
+		scroll(-1);
+		_arrow = kArrowUp;
+	} else if (getDownButton().contains(pos)) {
+		scroll(1);
+		_arrow = kArrowDown;
+	} else if (_showThumb && _thumb.contains(pos)) {
+		_isDragging = true;
+		_dragOffset = pos.y - _thumb.top;
+	} else if (_showThumb && pos.y < _thumb.top) {
+		scroll(-(_pageSize - 1));
+	} else if (_showThumb && pos.y >= _thumb.bottom) {
+		scroll(_pageSize - 1);
+	}
+
+	return true;
+}
+
+void ScrollBar::onMouseMove(const Common::Point &pos) {
+	_lastMouse = pos;
+	if (!_isDragging) {
+		return;
+	}
+
+	Common::Rect track = getTrack();
+	int maxThumbTop = track.height() - _thumb.height();
+	int maxFirst = _end - _pageSize + 1;
+	if (maxThumbTop <= 0 || maxFirst <= 0) {
+		return;
+	}
+
+	int thumbTop = CLIP<int>(pos.y - _dragOffset - track.top, 0, maxThumbTop);
+	int32 position = (thumbTop + maxThumbTop / (maxFirst * 2)) * maxFirst / maxThumbTop;
+	if (position != _position) {
+		_position = position;
+		capPosition();
+		updateThumb();
+	}
+}
+
+void ScrollBar::onMouseUp(const Common::Point &pos) {
+	_isDragging = false;
+	_arrow = kArrowNone;
+	updateThumb();
+}
+
+bool ScrollBar::onWheel(int notches) {
+	scroll(-notches * kWheelLines);
+	return true;
+}
+
+bool ScrollBar::doAction(const Action &action) {
+	switch (action.type) {
+	case kActionSetRange:
+		setRange((int32)action.value);
+		return true;
+	case kActionSetValue:
+		scroll(-(int32)action.value);
+		return true;
+	default:
+		return Control::doAction(action);
+	}
+}
+
+bool ScrollBar::getValue(int32 eventType, double &value, Common::String &text) const {
+	if (eventType != kEventScrolled) {
+		return false;
+	}
+
+	value = _position;
+	text = _name;
+	return true;
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/scrollbar.h b/engines/nancy/ui/ndui/scrollbar.h
new file mode 100644
index 00000000000..d04fa8a15c7
--- /dev/null
+++ b/engines/nancy/ui/ndui/scrollbar.h
@@ -0,0 +1,85 @@
+/* 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_UI_NDUI_SCROLLBAR_H
+#define NANCY_UI_NDUI_SCROLLBAR_H
+
+#include "engines/nancy/ui/ndui/control.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+class ScrollBar : public Control {
+public:
+	ScrollBar(Panel *panel) : Control(panel) {}
+
+	bool update(float seconds) override;
+	void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const override;
+
+	bool containsPoint(const Common::Point &pos) const override { return _bounds.contains(pos); }
+	bool onMouseDown(const Common::Point &pos) override;
+	void onMouseMove(const Common::Point &pos) override;
+	void onMouseUp(const Common::Point &pos) override;
+	bool onWheel(int notches) override;
+
+	int32 getPosition() const { return _position; }
+	void setRange(int32 end);
+	void setPageSize(int32 pageSize);
+	void scroll(int32 delta);
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+	bool getValue(int32 eventType, double &value, Common::String &text) const override;
+	bool isOwnEvent(int32 eventType) const override { return eventType == kEventScrolled; }
+
+	enum ArrowState {
+		kArrowNone,
+		kArrowUp,
+		kArrowDown
+	};
+
+	Common::Rect getUpButton() const;
+	Common::Rect getDownButton() const;
+	Common::Rect getTrack() const;
+	void capPosition();
+	void updateThumb();
+
+	int32 _pageSize = 0;
+	int32 _end = 0;
+	int32 _position = 0;
+	bool _showThumb = false;
+	Common::Rect _thumb;
+
+	bool _isDragging = false;
+	int _dragOffset = 0;
+	ArrowState _arrow = kArrowNone;
+	float _arrowHeldTime = 0;
+	float _arrowRepeatTime = 0;
+	Common::Point _lastMouse;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_SCROLLBAR_H
diff --git a/engines/nancy/ui/ndui/slider.cpp b/engines/nancy/ui/ndui/slider.cpp
new file mode 100644
index 00000000000..440f3502d63
--- /dev/null
+++ b/engines/nancy/ui/ndui/slider.cpp
@@ -0,0 +1,175 @@
+/* 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/util.h"
+
+#include "engines/nancy/ui/ndui/slider.h"
+#include "engines/nancy/ui/ndui/panel.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+void Slider::readExtra(Common::SeekableReadStream &stream) {
+	Common::String textureName;
+	Common::Rect src;
+	_elements.resize(3);
+
+	readFilename(stream, textureName);
+	readExclusiveRect(stream, src);
+	_elements[1].setTexture(textureName, src);
+
+	readFilename(stream, textureName);
+	readExclusiveRect(stream, src);
+	_hasFill = !textureName.empty();
+	_elements[2].setTexture(textureName, src);
+
+	_min = stream.readSint32LE();
+	_max = MAX(stream.readSint32LE(), _min);
+	_value = CLIP(stream.readSint32LE(), _min, _max);
+}
+
+int Slider::valueAt(int x) const {
+	if (_bounds.width() == 0) {
+		return _min;
+	}
+
+	return (int)(0.5f + _min + (float)(_max - _min) / _bounds.width() * (x - _bounds.left));
+}
+
+// The thumb is as wide as its picture, and centered on the position of the value
+Common::Rect Slider::getThumbBounds() const {
+	int width = _elements[1]._src.width();
+	int x = _bounds.left - width / 2;
+	if (_max > _min) {
+		x += (_value - _min) * _bounds.width() / (_max - _min);
+	}
+
+	return Common::Rect(x, _bounds.top, x + width, _bounds.bottom);
+}
+
+void Slider::setValue(int value, bool byPlayer) {
+	value = CLIP<int>(value, _min, _max);
+	if (value == _value) {
+		return;
+	}
+
+	_value = value;
+	_panel->setDirty();
+	if (byPlayer) {
+		sendEvent(kEventSliderChanged);
+	}
+}
+
+bool Slider::update(float seconds) {
+	ControlState state = kStateHidden;
+	if (_visible) {
+		state = getState();
+		if (state == kStateNormal) {
+			if (_isPressed) {
+				state = kStatePressed;
+			} else if (_isMouseOver) {
+				state = kStateMouseOver;
+			}
+		}
+	}
+
+	float rate = state == kStatePressed ? 0.0f : kControlBlendRate;
+	bool changed = _elements[0].blend(state, seconds, rate);
+	if (_hasFill) {
+		changed |= _elements[2].blend(state, seconds, rate);
+	}
+
+	changed |= _elements[1].blend(state, seconds, rate);
+	return changed;
+}
+
+void Slider::draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const {
+	Common::Rect track = _bounds;
+	track.translate(offset.x, offset.y);
+	Common::Rect thumb = getThumbBounds();
+	thumb.translate(offset.x, offset.y);
+
+	drawSprite(surface, 0, track);
+
+	// The fill is cut off at the thumb instead of getting squeezed
+	if (_hasFill && thumb.left > track.left) {
+		Common::Rect src = _elements[2]._src;
+		src.right = src.left + (thumb.left - track.left);
+		drawSprite(surface, 2, src, Common::Rect(track.left, track.top, thumb.left, track.bottom));
+	}
+
+	drawSprite(surface, 1, thumb);
+}
+
+bool Slider::onMouseDown(const Common::Point &pos) {
+	_isPressed = true;
+
+	// Dragging the thumb keeps it where it was grabbed. A click elsewhere on the
+	// track moves the value by one, and dragging from there follows the mouse.
+	Common::Rect thumb = getThumbBounds();
+	int valueX = thumb.left + thumb.width() / 2;
+	if (thumb.contains(pos)) {
+		_dragOffset = valueX - pos.x;
+		return true;
+	}
+
+	_dragOffset = 0;
+	if (pos.x > valueX) {
+		setValue(_value + 1, true);
+	} else if (pos.x < valueX) {
+		setValue(_value - 1, true);
+	}
+
+	return true;
+}
+
+void Slider::onMouseMove(const Common::Point &pos) {
+	setValue(valueAt(pos.x + _dragOffset), true);
+}
+
+void Slider::onMouseUp(const Common::Point &pos) {
+	_isPressed = false;
+	sendEvent(kEventSliderChanged);
+}
+
+bool Slider::doAction(const Action &action) {
+	if (action.type == kActionSetValue) {
+		setValue((int)action.value, false);
+		return true;
+	}
+
+	return Control::doAction(action);
+}
+
+bool Slider::getValue(int32 eventType, double &value, Common::String &text) const {
+	if (eventType != kEventSliderChanged) {
+		return false;
+	}
+
+	value = _value;
+	text = _name;
+	return true;
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/slider.h b/engines/nancy/ui/ndui/slider.h
new file mode 100644
index 00000000000..4e3318332a5
--- /dev/null
+++ b/engines/nancy/ui/ndui/slider.h
@@ -0,0 +1,64 @@
+/* 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_UI_NDUI_SLIDER_H
+#define NANCY_UI_NDUI_SLIDER_H
+
+#include "engines/nancy/ui/ndui/control.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+class Slider : public Control {
+public:
+	Slider(Panel *panel) : Control(panel) {}
+
+	bool update(float seconds) override;
+	void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const override;
+
+	bool containsPoint(const Common::Point &pos) const override { return _bounds.contains(pos); }
+	bool onMouseDown(const Common::Point &pos) override;
+	void onMouseMove(const Common::Point &pos) override;
+	void onMouseUp(const Common::Point &pos) override;
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+	bool getValue(int32 eventType, double &value, Common::String &text) const override;
+	bool isOwnEvent(int32 eventType) const override { return eventType == kEventSliderChanged; }
+
+	Common::Rect getThumbBounds() const;
+	int valueAt(int x) const;
+	void setValue(int value, bool byPlayer);
+
+	bool _hasFill = false;		// a third element, drawn over the track up to the thumb
+	int32 _min = 0;
+	int32 _max = 0;
+	int32 _value = 0;
+	int _dragOffset = 0;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_SLIDER_H


Commit: 8c7abcd6689726ce2d5b88e1aedd14a259a6bf83
    https://github.com/scummvm/scummvm/commit/8c7abcd6689726ce2d5b88e1aedd14a259a6bf83
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:19+03:00

Commit Message:
NANCY: NANCY16: NDUI: Implement the edit box

- A single line of text with a caret, selection and scrolling
- Keys go to the edit box of the topmost panel

Changed paths:
  A engines/nancy/ui/ndui/editbox.cpp
  A engines/nancy/ui/ndui/editbox.h
    engines/nancy/module.mk
    engines/nancy/ui/ndui/manager.cpp
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 62cb2b8875c..ae5e132eb35 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -101,6 +101,7 @@ MODULE_OBJS = \
   ui/ndui/button.o \
   ui/ndui/common.o \
   ui/ndui/control.o \
+  ui/ndui/editbox.o \
   ui/ndui/element.o \
   ui/ndui/listbox.o \
   ui/ndui/manager.o \
diff --git a/engines/nancy/ui/ndui/editbox.cpp b/engines/nancy/ui/ndui/editbox.cpp
new file mode 100644
index 00000000000..4424b3e44ef
--- /dev/null
+++ b/engines/nancy/ui/ndui/editbox.cpp
@@ -0,0 +1,256 @@
+/* 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 "graphics/font.h"
+
+#include "engines/nancy/font.h"
+#include "engines/nancy/graphics.h"
+
+#include "engines/nancy/ui/ndui/editbox.h"
+#include "engines/nancy/ui/ndui/panel.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// The caret blinks this often, in seconds
+static const float kCaretBlinkTime = 0.53f;
+
+void EditBox::readExtra(Common::SeekableReadStream &stream) {
+	_selectedTextColor = stream.readUint32BE();
+	_selectionColor = stream.readUint32BE();
+	_caretColor = stream.readUint32BE();
+	_border = stream.readSint32LE();
+}
+
+Common::Rect EditBox::getTextBounds() const {
+	Common::Rect bounds = _bounds;
+	bounds.grow(-_border);
+	return bounds;
+}
+
+// Relative to the start of the text
+int EditBox::getCharX(int index) const {
+	const Graphics::Font *font = _elements[0].getSystemFont();
+	return font ? font->getStringWidth(_text.substr(0, index)) : 0;
+}
+
+// The caret goes to the nearest gap between two letters
+int EditBox::getCharAt(int x) const {
+	int textX = x - getTextBounds().left + _scroll;
+	for (uint i = 0; i < _text.size(); ++i) {
+		if (textX < (getCharX(i) + getCharX(i + 1)) / 2) {
+			return i;
+		}
+	}
+
+	return _text.size();
+}
+
+void EditBox::scrollToCaret() {
+	int width = getTextBounds().width();
+	int caretX = getCharX(_caret);
+	if (caretX < _scroll) {
+		_scroll = caretX;
+	} else if (caretX > _scroll + width) {
+		_scroll = caretX - width;
+	}
+}
+
+void EditBox::moveCaret(int caret, bool select) {
+	_caret = CLIP<int>(caret, 0, _text.size());
+	if (!select) {
+		_selectionStart = _caret;
+	}
+
+	scrollToCaret();
+	_isCaretShown = true;
+	_blinkTime = 0;
+	_panel->setDirty();
+}
+
+void EditBox::deleteSelection() {
+	int start = MIN(_caret, _selectionStart);
+	int end = MAX(_caret, _selectionStart);
+	_text.erase(start, end - start);
+	_caret = _selectionStart = start;
+}
+
+void EditBox::handleKey(const Common::KeyState &key) {
+	bool select = key.hasFlags(Common::KBD_SHIFT);
+
+	switch (key.keycode) {
+	case Common::KEYCODE_LEFT:
+		moveCaret(_caret - 1, select);
+		return;
+	case Common::KEYCODE_RIGHT:
+		moveCaret(_caret + 1, select);
+		return;
+	case Common::KEYCODE_HOME:
+		moveCaret(0, select);
+		return;
+	case Common::KEYCODE_END:
+		moveCaret(_text.size(), select);
+		return;
+	case Common::KEYCODE_RETURN:
+	case Common::KEYCODE_KP_ENTER:
+		sendEvent(kEventTextEntered);
+		return;
+	case Common::KEYCODE_BACKSPACE:
+		if (_caret != _selectionStart) {
+			deleteSelection();
+		} else if (_caret > 0) {
+			_text.deleteChar(--_caret);
+			_selectionStart = _caret;
+		} else {
+			return;
+		}
+
+		break;
+	case Common::KEYCODE_DELETE:
+		if (_caret != _selectionStart) {
+			deleteSelection();
+		} else if (_caret < (int)_text.size()) {
+			_text.deleteChar(_caret);
+		} else {
+			return;
+		}
+
+		break;
+	default:
+		if (key.ascii < 32 || key.ascii > 126) {
+			return;
+		}
+
+		// Typing replaces the selection
+		if (_caret != _selectionStart) {
+			deleteSelection();
+		}
+
+		_text.insertChar((char)key.ascii, _caret);
+		++_caret;
+		_selectionStart = _caret;
+		break;
+	}
+
+	moveCaret(_caret, false);
+	sendEvent(kEventTextChanged);
+}
+
+bool EditBox::doAction(const Action &action) {
+	switch (action.type) {
+	case kActionAddText:
+		_text += action.text;
+		moveCaret(_text.size(), false);
+		return true;
+	case kActionSetText:
+		// A value selects all of the text, so that typing replaces it
+		_text = action.text;
+		_scroll = 0;
+		moveCaret(_text.size(), false);
+		if (action.value != 0) {
+			_selectionStart = 0;
+		}
+
+		return true;
+	default:
+		return Control::doAction(action);
+	}
+}
+
+bool EditBox::getValue(int32 eventType, double &value, Common::String &text) const {
+	if (eventType != kEventTextEntered && eventType != kEventTextChanged) {
+		return false;
+	}
+
+	value = 0;
+	text = _text;
+	return true;
+}
+
+bool EditBox::update(float seconds) {
+	bool changed = false;
+	_blinkTime += seconds;
+	if (_blinkTime >= kCaretBlinkTime) {
+		_blinkTime = 0;
+		_isCaretShown = !_isCaretShown;
+		changed = true;
+	}
+
+	changed |= _elements[0].blend(_visible ? getState() : kStateHidden, seconds, kControlBlendRate);
+	return changed;
+}
+
+void EditBox::draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const {
+	const Graphics::Font *font = _elements[0].getSystemFont();
+	if (!_visible || !font) {
+		return;
+	}
+
+	Common::Rect bounds = _bounds;
+	bounds.translate(offset.x, offset.y);
+	drawSprite(surface, 0, bounds);
+
+	Common::Rect textBounds = getTextBounds();
+	textBounds.translate(offset.x, offset.y);
+	textBounds.clip(Common::Rect(surface.w, surface.h));
+	if (textBounds.isEmpty()) {
+		return;
+	}
+
+	// Everything gets drawn inside the text area, which the text scrolls through
+	Graphics::ManagedSurface area(surface, textBounds);
+	int x = -_scroll;
+	int y = (area.h - font->getFontHeight()) / 2;
+
+	uint32 textColor = _elements[0].getFontColor(getState());
+	font->drawString(&area, _text, x, y, getCharX(_text.size()) + 1, argbToColor(area.format, textColor | 0xFF000000));
+
+	if (_caret != _selectionStart) {
+		int start = MIN(_caret, _selectionStart);
+		int end = MAX(_caret, _selectionStart);
+		Common::Rect selection(x + getCharX(start), 0, x + getCharX(end), area.h);
+		selection.clip(Common::Rect(area.w, area.h));
+		area.fillRect(selection, argbToColor(area.format, _selectionColor));
+		font->drawString(&area, _text.substr(start, end - start), x + getCharX(start), y, getCharX(end) - getCharX(start) + 1,
+			argbToColor(area.format, _selectedTextColor | 0xFF000000));
+	}
+
+	if (_isCaretShown) {
+		int caretX = x + getCharX(_caret);
+		Common::Rect caret(caretX - 1, 0, caretX + 1, area.h);
+		caret.clip(Common::Rect(area.w, area.h));
+		area.fillRect(caret, argbToColor(area.format, _caretColor));
+	}
+}
+
+bool EditBox::onMouseDown(const Common::Point &pos) {
+	moveCaret(getCharAt(pos.x), false);
+	return true;
+}
+
+void EditBox::onMouseMove(const Common::Point &pos) {
+	moveCaret(getCharAt(pos.x), true);
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/editbox.h b/engines/nancy/ui/ndui/editbox.h
new file mode 100644
index 00000000000..3d6713e3d88
--- /dev/null
+++ b/engines/nancy/ui/ndui/editbox.h
@@ -0,0 +1,76 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_UI_NDUI_EDITBOX_H
+#define NANCY_UI_NDUI_EDITBOX_H
+
+#include "common/keyboard.h"
+
+#include "engines/nancy/ui/ndui/control.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// A single line of text the player can type in
+class EditBox : public Control {
+public:
+	EditBox(Panel *panel) : Control(panel) {}
+
+	bool update(float seconds) override;
+	void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const override;
+
+	bool containsPoint(const Common::Point &pos) const override { return _bounds.contains(pos); }
+	bool onMouseDown(const Common::Point &pos) override;
+	void onMouseMove(const Common::Point &pos) override;
+
+	void handleKey(const Common::KeyState &key);
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+	bool doAction(const Action &action) override;
+	bool getValue(int32 eventType, double &value, Common::String &text) const override;
+	bool isOwnEvent(int32 eventType) const override { return eventType == kEventTextChanged; }
+
+	Common::Rect getTextBounds() const;
+	int getCharX(int index) const;
+	int getCharAt(int x) const;
+	void deleteSelection();
+	void moveCaret(int caret, bool select);
+	void scrollToCaret();
+
+	uint32 _selectedTextColor = 0;
+	uint32 _selectionColor = 0;
+	uint32 _caretColor = 0;
+	int32 _border = 0;
+
+	int _caret = 0;
+	int _selectionStart = 0;	// equals the caret when nothing is selected
+	int _scroll = 0;
+	float _blinkTime = 0;
+	bool _isCaretShown = true;
+};
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_NDUI_EDITBOX_H
diff --git a/engines/nancy/ui/ndui/manager.cpp b/engines/nancy/ui/ndui/manager.cpp
index c32c9637499..96b8284d867 100644
--- a/engines/nancy/ui/ndui/manager.cpp
+++ b/engines/nancy/ui/ndui/manager.cpp
@@ -32,6 +32,7 @@
 #include "engines/nancy/ui/ndui/manager.h"
 #include "engines/nancy/ui/ndui/panel.h"
 #include "engines/nancy/ui/ndui/control.h"
+#include "engines/nancy/ui/ndui/editbox.h"
 
 namespace Nancy {
 namespace UI {
@@ -165,6 +166,19 @@ Panel *Manager::findPanel(const Common::String &name) const {
 }
 
 void Manager::handleInput(NancyInput &input) {
+	// The dialog on top gets what gets typed
+	for (int i = _panels.size() - 1; i >= 0; --i) {
+		EditBox *editBox = _panels[i]->getKeyboardFocus();
+		if (editBox) {
+			for (const Common::KeyState &key : input.otherKbdInput) {
+				editBox->handleKey(key);
+			}
+
+			input.otherKbdInput.clear();
+			break;
+		}
+	}
+
 	// The panels drawn on top get the mouse first
 	bool isOverUI = false;
 	bool isOverControl = false;
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 6aa7a2c223b..2b5b819a0d4 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -27,6 +27,10 @@
 #include "engines/nancy/ui/ndui/panel.h"
 #include "engines/nancy/ui/ndui/control.h"
 #include "engines/nancy/ui/ndui/button.h"
+#include "engines/nancy/ui/ndui/slider.h"
+#include "engines/nancy/ui/ndui/scrollbar.h"
+#include "engines/nancy/ui/ndui/listbox.h"
+#include "engines/nancy/ui/ndui/editbox.h"
 #include "engines/nancy/ui/ndui/manager.h"
 
 namespace Nancy {
@@ -111,6 +115,9 @@ Control *Panel::addNewControl(int32 type, Common::SeekableReadStream &stream) {
 	case kControlStatic:
 		control = new Static(this);
 		break;
+	case kControlSlider:
+		control = new Slider(this);
+		break;
 	case kControlRadioGroup:
 		control = new RadioGroup(this);
 		break;
@@ -120,6 +127,12 @@ Control *Panel::addNewControl(int32 type, Common::SeekableReadStream &stream) {
 		addRadioButton(button);
 		return button;
 	}
+	case kControlListBox:
+		control = new ListBox(this);
+		break;
+	case kControlScrollBar:
+		control = new ScrollBar(this);
+		break;
 	case kControlCheckBox:
 		control = new CheckBox(this);
 		break;
@@ -129,6 +142,12 @@ Control *Panel::addNewControl(int32 type, Common::SeekableReadStream &stream) {
 	case kControlVarStatic:
 		control = new VarStatic(this);
 		break;
+	case kControlEditBox: {
+		EditBox *editBox = new EditBox(this);
+		_editBoxes.push_back(editBox);
+		control = editBox;
+		break;
+	}
 	default:
 		error("Unknown NDUI control type %d", type);
 	}
@@ -342,6 +361,13 @@ bool Panel::removeControl(const Common::String &name) {
 			}
 		}
 
+		for (uint j = 0; j < _editBoxes.size(); ++j) {
+			if (_editBoxes[j] == control) {
+				_editBoxes.remove_at(j);
+				break;
+			}
+		}
+
 		if (_capture == control) {
 			_capture = nullptr;
 		}
@@ -354,6 +380,20 @@ bool Panel::removeControl(const Common::String &name) {
 	return false;
 }
 
+EditBox *Panel::getKeyboardFocus() const {
+	if (!isVisible() || !_enabled) {
+		return nullptr;
+	}
+
+	for (EditBox *editBox : _editBoxes) {
+		if (editBox->isVisible() && editBox->isEnabled()) {
+			return editBox;
+		}
+	}
+
+	return nullptr;
+}
+
 void Panel::uncheckRadioGroup(const Common::String &group) {
 	for (RadioButton *button : _radioButtons) {
 		if (button->getGroup().equalsIgnoreCase(group)) {
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index 0b06d40799c..b3e1c3680fa 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -37,6 +37,7 @@ class Manager;
 class Control;
 class Static;
 class RadioButton;
+class EditBox;
 
 // A window of the UI, like the inventory, the journal or the bars around the viewport.
 // Each NDUI chunk describes one panel: its background, which is a Static that also gives
@@ -73,6 +74,9 @@ public:
 	Control *addNewControl(int32 type, Common::SeekableReadStream &stream);
 	bool removeControl(const Common::String &name);
 
+	// The edit box that gets typed into, when the panel is up
+	EditBox *getKeyboardFocus() const;
+
 	// Places the visible controls one after another, when the panel asks for it
 	void arrangeControls();
 
@@ -108,6 +112,7 @@ private:
 	Common::Array<Control *> _controls;
 	Common::Array<Control *> _arrangeOrder;
 	Common::Array<RadioButton *> _radioButtons;
+	Common::Array<EditBox *> _editBoxes;
 	int _nextID = 1;
 
 	Arrangement _arrangement = kArrangeNone;


Commit: 84740abb0e32489f7c0314c62b0b930efde6c49e
    https://github.com/scummvm/scummvm/commit/84740abb0e32489f7c0314c62b0b930efde6c49e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:21+03:00

Commit Message:
NANCY: NANCY16: NDUI: Implement the engine handlers

- Inventory, journal, task list, conversations, value displays, clock and captions
- Items looked at in their own scene leave the inventory until the scene is left

Changed paths:
  A engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/module.mk
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h
    engines/nancy/ui/ndui/manager.cpp
    engines/nancy/ui/ndui/manager.h
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index ae5e132eb35..b85e7c537aa 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -103,6 +103,7 @@ MODULE_OBJS = \
   ui/ndui/control.o \
   ui/ndui/editbox.o \
   ui/ndui/element.o \
+  ui/ndui/enginehandlers.o \
   ui/ndui/listbox.o \
   ui/ndui/manager.o \
   ui/ndui/panel.o \
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 765aae4add9..30d4fd6f82d 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -173,6 +173,16 @@ void JournalData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void TasklistData::synchronize(Common::Serializer &ser) {
+	uint32 numEntries = checkedEntries.size();
+	ser.syncAsUint32LE(numEntries);
+	checkedEntries.resize(numEntries);
+
+	for (Common::String &entry : checkedEntries) {
+		ser.syncString(entry);
+	}
+}
+
 void JournalData::syncOneJournal(Common::Serializer &ser, Common::HashMap<uint16, Common::Array<Entry>> &journal) {
 	uint16 numEntries = journal.size();
 	ser.syncAsUint16LE(numEntries);
@@ -804,6 +814,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new GridMapPuzzleData();
 	case JournalData::getTag():
 		return new JournalData();
+	case TasklistData::getTag():
+		return new TasklistData();
 	case TableData::getTag():
 		return new TableData();
 	case CellPhoneData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 28f1973ccd7..68686375427 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -215,6 +215,17 @@ private:
 	static void syncOneJournal(Common::Serializer &ser, Common::HashMap<uint16, Common::Array<Entry>> &journal);
 };
 
+// Nancy16: the entries of the task list the player has checked off
+struct TasklistData : public PuzzleData {
+	TasklistData() {}
+	virtual ~TasklistData() {}
+
+	static constexpr uint32 getTag() { return MKTAG('T', 'L', 'S', 'T'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::Array<Common::String> checkedEntries;
+};
+
 // Contains variables that can be read and modified through action records.
 // Mixes two separate things:
 // - the exhibit data table in nancy6
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 1e97e5e3ebb..82f471c3511 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2618,6 +2618,24 @@ void Scene::initStaticData() {
 	_state = kLoad;
 }
 
+void Scene::viewInventoryItem(int16 itemID) {
+	removeItemFromInventory(itemID, false);
+
+	if (_sceneState.pushedInvItemID == -1) {
+		pushScene();
+	} else {
+		addItemToInventory(_sceneState.pushedInvItemID);
+	}
+
+	_sceneState.pushedInvItemID = itemID;
+}
+
+void Scene::returnViewedInventoryItem() {
+	int16 itemID = _sceneState.pushedInvItemID;
+	_sceneState.pushedInvItemID = -1;
+	addItemToInventory(itemID);
+}
+
 void Scene::clearSceneData(bool nextIsNoArt) {
 	// Clear generic flags only
 	for (uint16 id : g_nancy->getStaticData().genericEventFlags) {
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 4d873b6acac..4b3054f40e5 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -121,6 +121,11 @@ public:
 
 	void changeScene(const SceneChangeDescription &sceneDescription);
 	void pushScene(int16 itemID = -1);
+
+	// Nancy16: an item looked at in its own scene leaves the inventory until the
+	// scene is left; it is kept as the pushed inventory item until then
+	void viewInventoryItem(int16 itemID);
+	void returnViewedInventoryItem();
 	void popScene(bool inventory = false);
 	int16 getPushedInvItemID() const { return _sceneState.pushedInvItemID; }
 
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
new file mode 100644
index 00000000000..66f4a4b9bc3
--- /dev/null
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -0,0 +1,328 @@
+/* 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/debug.h"
+#include "common/config-manager.h"
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/state/scene.h"
+
+#include "engines/nancy/ui/ndui/manager.h"
+#include "engines/nancy/ui/ndui/panel.h"
+#include "engines/nancy/ui/ndui/button.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// The timer whose displays show the time of day
+static const int kPlayerClockTimer = -1;
+
+void Manager::takeEngineAction(const Action &action) {
+	if (action.target.equalsIgnoreCase("Engine_Timer")) {
+		// A control asks to show the value of a timer
+		if (action.type == kActionSetText && (int)action.value == kPlayerClockTimer) {
+			for (const Common::String &display : _clockDisplays) {
+				if (display.equalsIgnoreCase(action.sender)) {
+					return;
+				}
+			}
+
+			_clockDisplays.push_back(action.sender);
+		}
+	} else if (action.target.equalsIgnoreCase("Engine_ClosedCaption")) {
+		if (action.type == kActionQuery) {
+			takeAction(kActionSetValue, action.sender, ConfMan.getBool("subtitles") ? 1.0 : 0.0);
+		} else if (action.type == kActionSetValue) {
+			ConfMan.setBool("subtitles", action.value != 0);
+			takeAction(action.value != 0 ? kActionShow : kActionHide, "CCText", action.value);
+		}
+	} else if (action.target.equalsIgnoreCase("Engine_Index")) {
+		if (action.type == kActionSetText) {
+			registerValueDisplay((int16)action.value, action.sender);
+		} else if (action.type == kActionDisable) {
+			for (uint i = 0; i < _valueDisplays.size(); ++i) {
+				if (_valueDisplays[i].name.equalsIgnoreCase(action.sender)) {
+					_valueDisplays.remove_at(i);
+					--i;
+				}
+			}
+		}
+	} else if (action.target.equalsIgnoreCase("Engine_Inv")) {
+		if (action.type == kActionSetValue) {
+			onInventoryClicked((int16)action.value);
+		}
+	} else if (action.target.equalsIgnoreCase("Engine_Convo")) {
+		// The player picked a response, by its place in the list
+		if (action.type == kActionSetValue && _isInConversation) {
+			NancySceneState.setLogicCondition((int16)action.value, g_nancy->_true);
+		}
+	} else if (action.target.equalsIgnoreCase("Engine_Journal")) {
+		onJournalAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_Tasklist")) {
+		onTasklistAction(action);
+	} else {
+		debugC(3, kDebugEngine, "Unhandled NDUI engine action %d on \"%s\" from \"%s\"", action.type, action.target.c_str(), action.sender.c_str());
+	}
+}
+
+// A click puts a held item back. Otherwise, the clicked item gets picked up, or
+// opens the scene that shows it.
+void Manager::onInventoryClicked(int16 itemID) {
+	int16 heldItem = NancySceneState.getHeldItem();
+	if (heldItem != -1) {
+		NancySceneState.addItemToInventory(heldItem);
+		return;
+	}
+
+	InvButton *button = findItemButton(itemID);
+	if (!button) {
+		return;
+	}
+
+	SceneChangeDescription scene;
+	scene.sceneID = button->getScene();
+	scene.continueSceneSound = kContinueSceneSound;
+
+	switch (button->getKind()) {
+	case InvButton::kItemView:
+		NancySceneState.viewInventoryItem(itemID);
+		NancySceneState.changeScene(scene);
+		break;
+	case InvButton::kItemOpenCountedScene:
+	case InvButton::kItemOpenScene:
+		NancySceneState.pushScene();
+		NancySceneState.changeScene(scene);
+		break;
+	default:
+		NancySceneState.removeItemFromInventory(itemID, true);
+		break;
+	}
+}
+
+// An item that was taken out to be looked at goes back once its scene is left
+void Manager::onSceneChange(uint16 sceneID) {
+	int16 viewedItem = NancySceneState.getPushedInvItemID();
+	if (viewedItem == -1) {
+		return;
+	}
+
+	InvButton *button = findItemButton(viewedItem);
+	if (!button || button->getScene() != sceneID) {
+		NancySceneState.returnViewedInventoryItem();
+	}
+}
+
+void Manager::showInventoryItem(int16 itemID, bool show) {
+	takeAction(show ? kActionShowItem : kActionHideItem, "InvDialog", (double)itemID);
+}
+
+void Manager::registerValueDisplay(int16 index, const Common::String &display) {
+	ValueDisplay valueDisplay;
+	valueDisplay.index = index;
+	valueDisplay.name = display;
+	_valueDisplays.push_back(valueDisplay);
+}
+
+// Value displays follow the value table
+void Manager::update() {
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	if (!table) {
+		return;
+	}
+
+	for (ValueDisplay &display : _valueDisplays) {
+		int16 value = table->getValue(display.index);
+		if (!display.hasShownValue || display.shownValue != value) {
+			display.hasShownValue = true;
+			display.shownValue = value;
+			takeAction(kActionSetValue, display.name, (double)value);
+		}
+	}
+}
+
+// The journal has one page per topic. The title of a topic is the text of its page's
+// name, and picking a title in the list shows the page.
+void Manager::addJournalEntry(const Common::String &pageName, const Common::String &textKey) {
+	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+	assert(autotext);
+
+	Common::String title = autotext->texts.getValOrDefault(pageName);
+	if (!_journalPages.contains(title)) {
+		takeAction(kActionAddText, "JournalItems", title);
+		_journalPages.setVal(title, pageName);
+	}
+
+	createObject(textKey, "JournalEntry", pageName);
+	takeAction(kActionAddText, textKey, autotext->texts.getValOrDefault(textKey));
+}
+
+void Manager::onJournalAction(const Action &action) {
+	if (action.type == kActionShow) {
+		if (!_shownJournalPage.empty()) {
+			takeAction(kActionShow, _shownJournalPage, 0.0);
+		}
+	} else if (action.type == kActionSetText) {
+		if (!_journalPages.contains(action.text)) {
+			return;
+		}
+
+		for (auto &page : _journalPages) {
+			takeAction(kActionHide, page._value, 0.0);
+		}
+
+		_shownJournalPage = _journalPages[action.text];
+		takeAction(kActionSetValue, _shownJournalPage, 0.0);
+		takeAction(kActionShow, _shownJournalPage, 0.0);
+	}
+}
+
+// Task list entries get checked off by the player, but only once the task is done,
+// and they can't be unchecked
+void Manager::addTasklistEntry(const Common::String &textKey, int16 flag) {
+	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+	assert(autotext);
+
+	_tasklistFlags.setVal(textKey, flag);
+	createObject(textKey, "TasklistEntry", "TasklistDialog");
+	takeAction(kActionSetText, textKey, autotext->texts.getValOrDefault(textKey));
+
+	TasklistData *tasklistData = (TasklistData *)NancySceneState.getPuzzleData(TasklistData::getTag());
+	for (const Common::String &entry : tasklistData->checkedEntries) {
+		if (entry.equalsIgnoreCase(textKey)) {
+			takeAction(kActionSetValue, textKey, 1.0);
+			break;
+		}
+	}
+}
+
+void Manager::onTasklistAction(const Action &action) {
+	if (action.type == kActionHide) {
+		playTasklistSound(0, false);
+		playTasklistSound(1, false);
+		return;
+	}
+
+	if (action.type != kActionSetValue) {
+		return;
+	}
+
+	if (action.value == 0) {
+		takeAction(kActionSetValue, action.sender, 1.0);
+		return;
+	}
+
+	int16 flag = _tasklistFlags.getValOrDefault(action.sender, kEvNoEvent);
+	if (flag != kEvNoEvent && NancySceneState.getEventFlag(flag, g_nancy->_false)) {
+		takeAction(kActionSetValue, action.sender, 0.0);
+	} else {
+		TasklistData *tasklistData = (TasklistData *)NancySceneState.getPuzzleData(TasklistData::getTag());
+		tasklistData->checkedEntries.push_back(action.sender);
+	}
+
+	playTasklistSound(0, true);
+}
+
+void Manager::playTasklistSound(uint bank, bool play) {
+	const TSKL *tasklistSounds = GetEngineData(TSKL);
+	if (!tasklistSounds) {
+		return;
+	}
+
+	const TSKL::SoundBank &soundBank = tasklistSounds->soundBanks[bank];
+	SoundDescription sound;
+	sound.channelID = soundBank.channelID;
+
+	if (!play) {
+		g_nancy->_sound->stopSound(sound);
+		return;
+	}
+
+	if (soundBank.soundNames.empty()) {
+		return;
+	}
+
+	sound.name = soundBank.soundNames[g_nancy->_randomSource->getRandomNumber(soundBank.soundNames.size() - 1)].toString();
+	sound.numLoops = soundBank.numLoops;
+	sound.volume = soundBank.volume;
+	g_nancy->_sound->loadSound(sound);
+	g_nancy->_sound->playSound(sound);
+}
+
+// The responses are made from a template, and picked by the order they are in.
+// The inventory gets closed, and can't be opened while talking.
+void Manager::startConversation(const Common::String &caption, const Common::Array<Common::String> &responses) {
+	takeAction(kActionSetText, "ConvoCCText", caption);
+
+	for (uint i = 0; i < responses.size(); ++i) {
+		Common::String name = Common::String::format("ConvoResponse%u", i);
+		createObject(name, "ConvoResponse", "ConvoDialog");
+		takeAction(kActionSetText, name, responses[i]);
+		_responseNames.push_back(name);
+	}
+
+	takeAction(kActionHide, "InvDialog", 0.0);
+	takeAction(kActionDisable, "ShowInv", 0.0);
+	_isInConversation = true;
+	takeAction(kActionSetValue, "ConvoDialog", 0.0);
+	takeAction(kActionShow, "ConvoDialog", 0.0);
+}
+
+void Manager::endConversation() {
+	if (!_isInConversation) {
+		return;
+	}
+
+	_isInConversation = false;
+	takeAction(kActionHide, "ConvoDialog", 0.0);
+	takeAction(kActionEnable, "ShowInv", 0.0);
+
+	for (const Common::String &name : _responseNames) {
+		destroyObject(name);
+	}
+
+	_responseNames.clear();
+	takeAction(kActionSetText, "ConvoCCText", Common::String());
+}
+
+void Manager::setCaption(const Common::String &text) {
+	takeAction(kActionSetText, "CCText", text);
+}
+
+// The time of day is shown as "1:05 pm", grayed out at night
+void Manager::setPlayerTime(uint32 minutesOfDay, bool isDaytime) {
+	uint32 hours = minutesOfDay / 60;
+	uint32 displayHours = hours % 12 == 0 ? 12 : hours % 12;
+	Common::String text = Common::String::format("%u:%02u %s", displayHours, minutesOfDay % 60, hours < 12 ? "am" : "pm");
+
+	for (const Common::String &display : _clockDisplays) {
+		takeAction(kActionSetText, display, text);
+		takeAction(isDaytime ? kActionEnable : kActionDisable, display, 0.0);
+	}
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/manager.cpp b/engines/nancy/ui/ndui/manager.cpp
index 96b8284d867..3f756750023 100644
--- a/engines/nancy/ui/ndui/manager.cpp
+++ b/engines/nancy/ui/ndui/manager.cpp
@@ -32,6 +32,7 @@
 #include "engines/nancy/ui/ndui/manager.h"
 #include "engines/nancy/ui/ndui/panel.h"
 #include "engines/nancy/ui/ndui/control.h"
+#include "engines/nancy/ui/ndui/button.h"
 #include "engines/nancy/ui/ndui/editbox.h"
 
 namespace Nancy {
@@ -97,6 +98,11 @@ void Manager::registerGraphics() {
 }
 
 void Manager::takeAction(const Action &action) {
+	if (action.target.hasPrefixIgnoreCase("Engine_")) {
+		takeEngineAction(action);
+		return;
+	}
+
 	for (Panel *panel : _panels) {
 		if (panel->takeAction(action)) {
 			return;
@@ -165,6 +171,17 @@ Panel *Manager::findPanel(const Common::String &name) const {
 	return nullptr;
 }
 
+InvButton *Manager::findItemButton(int16 itemID) const {
+	for (Panel *panel : _panels) {
+		InvButton *button = panel->findItemButton(itemID);
+		if (button) {
+			return button;
+		}
+	}
+
+	return nullptr;
+}
+
 void Manager::handleInput(NancyInput &input) {
 	// The dialog on top gets what gets typed
 	for (int i = _panels.size() - 1; i >= 0; --i) {
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 380fc09485e..8d5154adb2e 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -39,6 +39,7 @@ namespace UI {
 namespace NDUI {
 
 class Panel;
+class InvButton;
 
 // The Nancy16 UI, owned by the scene. It creates the panels from the NDUI chunks of the
 // player character's UI IFF and the IFFs it includes, and stands between them and the
@@ -90,13 +91,54 @@ public:
 	void createObject(const Common::String &name, const Common::String &templateName, const Common::String &parentName);
 	void destroyObject(const Common::String &name);
 
+	// Engine state shown by the UI
+	void update();
+	void setCaption(const Common::String &text);
+	void setPlayerTime(uint32 minutesOfDay, bool isDaytime);
+	void showInventoryItem(int16 itemID, bool show);
+	void onSceneChange(uint16 sceneID);
+	void addJournalEntry(const Common::String &pageName, const Common::String &textKey);
+	void addTasklistEntry(const Common::String &textKey, int16 flag);
+	void startConversation(const Common::String &caption, const Common::Array<Common::String> &responses);
+	void endConversation();
+
+	// Displays a value of the value table, until it gets unregistered
+	void registerValueDisplay(int16 index, const Common::String &display);
+
 private:
+	struct ValueDisplay {
+		int16 index = 0;
+		Common::String name;
+		bool hasShownValue = false;
+		int16 shownValue = 0;
+	};
+
+	typedef Common::HashMap<Common::String, Common::String, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> StringMap;
+	typedef Common::HashMap<Common::String, int16, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> FlagMap;
+
+	void takeEngineAction(const Action &action);
+	void onInventoryClicked(int16 itemID);
+	void onJournalAction(const Action &action);
+	void onTasklistAction(const Action &action);
+	void playTasklistSound(uint bank, bool play);
+
 	Panel *findPanel(const Common::String &name) const;
+	InvButton *findItemButton(int16 itemID) const;
 
 	Common::Array<Panel *> _panels;
 	Common::HashMap<Common::String, Graphics::ManagedSurface *, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _textures;
 	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _templates;
 
+	Common::Array<Common::String> _clockDisplays;
+	Common::Array<ValueDisplay> _valueDisplays;
+
+	StringMap _journalPages;		// page title -> page panel
+	Common::String _shownJournalPage;
+	FlagMap _tasklistFlags;			// entry -> event flag that has to be set to check it off
+
+	bool _isInConversation = false;
+	Common::Array<Common::String> _responseNames;
+
 	Request _request;
 };
 
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 2b5b819a0d4..b34f3ff0522 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -162,6 +162,10 @@ void Panel::addControl(Control *control) {
 	if (_kind == kObjectInvPanel) {
 		InvButton *button = (InvButton *)control;
 		control->_id = button->getItemID() + kInvButtonFirstID;
+
+		if (button->getKind() == InvButton::kItemOpenCountedScene) {
+			_manager->registerValueDisplay(button->getValueIndex(), button->getName());
+		}
 	} else {
 		control->_id = _nextID++;
 	}
@@ -394,6 +398,21 @@ EditBox *Panel::getKeyboardFocus() const {
 	return nullptr;
 }
 
+InvButton *Panel::findItemButton(int16 itemID) const {
+	if (_kind != kObjectInvPanel) {
+		return nullptr;
+	}
+
+	for (Control *control : _controls) {
+		InvButton *button = (InvButton *)control;
+		if (button->getItemID() == itemID) {
+			return button;
+		}
+	}
+
+	return nullptr;
+}
+
 void Panel::uncheckRadioGroup(const Common::String &group) {
 	for (RadioButton *button : _radioButtons) {
 		if (button->getGroup().equalsIgnoreCase(group)) {
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index b3e1c3680fa..75a25e63225 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -38,6 +38,7 @@ class Control;
 class Static;
 class RadioButton;
 class EditBox;
+class InvButton;
 
 // A window of the UI, like the inventory, the journal or the bars around the viewport.
 // Each NDUI chunk describes one panel: its background, which is a Static that also gives
@@ -73,6 +74,7 @@ public:
 	// Reads a control of the given type and adds it
 	Control *addNewControl(int32 type, Common::SeekableReadStream &stream);
 	bool removeControl(const Common::String &name);
+	InvButton *findItemButton(int16 itemID) const;
 
 	// The edit box that gets typed into, when the panel is up
 	EditBox *getKeyboardFocus() const;


Commit: 65dc22d0269425a15ce69fee86529ce5a4a21460
    https://github.com/scummvm/scummvm/commit/65dc22d0269425a15ce69fee86529ce5a4a21460
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:24+03:00

Commit Message:
NANCY: NANCY16: NDUI: Add the save, load and options handlers

- Load and save dialogs with screenshots, and asking to save before leaving the game
- Quitting, the matte color around the viewport, the volumes and the flags options

Changed paths:
  A engines/nancy/ui/ndui/saveload.cpp
    engines/nancy/module.mk
    engines/nancy/ui/fullscreenimage.cpp
    engines/nancy/ui/fullscreenimage.h
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/manager.h


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index b85e7c537aa..eb551174d9a 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -107,6 +107,7 @@ MODULE_OBJS = \
   ui/ndui/listbox.o \
   ui/ndui/manager.o \
   ui/ndui/panel.o \
+  ui/ndui/saveload.o \
   ui/ndui/scrollbar.o \
   ui/ndui/slider.o \
   ui/notebookpopup.o \
diff --git a/engines/nancy/ui/fullscreenimage.cpp b/engines/nancy/ui/fullscreenimage.cpp
index 9f11e8323c6..31e0ed9c39b 100644
--- a/engines/nancy/ui/fullscreenimage.cpp
+++ b/engines/nancy/ui/fullscreenimage.cpp
@@ -21,6 +21,7 @@
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/resource.h"
+#include "engines/nancy/graphics.h"
 
 #include "engines/nancy/ui/fullscreenimage.h"
 
@@ -38,5 +39,19 @@ void FullScreenImage::init(const Common::Path &imageName) {
 	RenderObject::init();
 }
 
+void FullScreenImage::initBlank() {
+	Common::Rect bounds = g_nancy->_graphics->getGameBounds();
+	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getScreenPixelFormat());
+	_drawSurface.clear(_drawSurface.format.ARGBToColor(255, 0, 0, 0));
+	_screenPosition = bounds;
+
+	RenderObject::init();
+}
+
+void FullScreenImage::setBlankColor(uint32 argb) {
+	_drawSurface.clear(argbToColor(_drawSurface.format, argb | 0xFF000000));
+	_needsRedraw = true;
+}
+
 } // End of namespace UI
 } // End of namespace Nancy
diff --git a/engines/nancy/ui/fullscreenimage.h b/engines/nancy/ui/fullscreenimage.h
index f6d55d4ae56..d47642d75a3 100644
--- a/engines/nancy/ui/fullscreenimage.h
+++ b/engines/nancy/ui/fullscreenimage.h
@@ -34,6 +34,10 @@ public:
 
 	void init(const Common::Path &imageName);
 
+	// Covers the game area in black, for games that have no frame image
+	void initBlank();
+	void setBlankColor(uint32 argb);
+
 protected:
 	void init() override {}
 };
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index 66f4a4b9bc3..d1a612a4bda 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -83,6 +83,22 @@ void Manager::takeEngineAction(const Action &action) {
 		onJournalAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_Tasklist")) {
 		onTasklistAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_Loader")) {
+		onLoaderAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_Saver")) {
+		onSaverAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_SaveQuery")) {
+		onSaveQueryAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_GameWindow")) {
+		onGameWindowAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_VoiceVolume")) {
+		onVolumeAction(action, "speech_volume");
+	} else if (action.target.equalsIgnoreCase("Engine_FXVolume")) {
+		onVolumeAction(action, "sfx_volume");
+	} else if (action.target.equalsIgnoreCase("Engine_MusicVolume")) {
+		onVolumeAction(action, "music_volume");
+	} else if (action.target.equalsIgnoreCase("Engine_Flags")) {
+		onFlagsAction(action);
 	} else {
 		debugC(3, kDebugEngine, "Unhandled NDUI engine action %d on \"%s\" from \"%s\"", action.type, action.target.c_str(), action.sender.c_str());
 	}
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 8d5154adb2e..6b2f2a2f7f5 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -105,6 +105,9 @@ public:
 	// Displays a value of the value table, until it gets unregistered
 	void registerValueDisplay(int16 index, const Common::String &display);
 
+	// The background color outside of the viewport, picked in the options
+	void applyMatteColor();
+
 private:
 	struct ValueDisplay {
 		int16 index = 0;
@@ -122,6 +125,18 @@ private:
 	void onTasklistAction(const Action &action);
 	void playTasklistSound(uint bank, bool play);
 
+	void onLoaderAction(const Action &action);
+	void onSaverAction(const Action &action);
+	void onSaveQueryAction(const Action &action);
+	void onGameWindowAction(const Action &action);
+	void onVolumeAction(const Action &action, const char *configKey);
+	void onFlagsAction(const Action &action);
+	void askToSaveFirst(const Action &action);
+	void runPendingAction();
+	uint fillSaveList(const Common::String &listName);
+	int findSaveSlot(const Common::String &name) const;
+	void setSaveScreenshot(const Graphics::ManagedSurface &image, const Common::Rect &src);
+
 	Panel *findPanel(const Common::String &name) const;
 	InvButton *findItemButton(int16 itemID) const;
 
@@ -139,6 +154,14 @@ private:
 	bool _isInConversation = false;
 	Common::Array<Common::String> _responseNames;
 
+	Common::String _selectedSave;	// in the load dialog
+	Common::String _typedSaveName;	// in the save dialog
+	Common::String _lastSaveName;
+
+	// What the player was about to do when asked whether to save first
+	bool _hasPendingAction = false;
+	Action _pendingAction;
+
 	Request _request;
 };
 
diff --git a/engines/nancy/ui/ndui/saveload.cpp b/engines/nancy/ui/ndui/saveload.cpp
new file mode 100644
index 00000000000..c5ca203e777
--- /dev/null
+++ b/engines/nancy/ui/ndui/saveload.cpp
@@ -0,0 +1,326 @@
+/* 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/config-manager.h"
+
+#include "audio/mixer.h"
+
+#include "engines/metaengine.h"
+#include "engines/savestate.h"
+
+#include "graphics/screen.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+
+#include "engines/nancy/state/scene.h"
+
+#include "engines/nancy/ui/ndui/manager.h"
+#include "engines/nancy/ui/ndui/panel.h"
+
+namespace Nancy {
+namespace UI {
+namespace NDUI {
+
+// The load dialog lists this as a save, for starting over
+static const char *const kNewGameName = "_StartNewGame_";
+
+// The picture of a save, as the save and load dialogs ask for it
+static const char *const kScreenshotTexture = "Engine_LoadSave";
+static const int kScreenshotWidth = 256;
+static const int kScreenshotHeight = 154;
+
+// Leaving the game or loading another one first asks whether to save, then goes
+// on with what the player was doing
+void Manager::askToSaveFirst(const Action &action) {
+	_pendingAction = action;
+	_pendingAction.hasValue = true;
+	_pendingAction.value = -1;
+	_hasPendingAction = true;
+	takeAction(kActionShow, "SaveQueryDialog", 0.0);
+}
+
+void Manager::runPendingAction() {
+	if (_hasPendingAction) {
+		_hasPendingAction = false;
+		takeAction(_pendingAction);
+	}
+}
+
+void Manager::onSaveQueryAction(const Action &action) {
+	switch (action.type) {
+	case kActionShow:
+		takeAction(kActionHide, "SaveQueryDialog", 0.0);
+		takeAction(kActionShow, "Engine_Saver", 0.0);
+		break;
+	case kActionHide:
+		takeAction(kActionHide, "SaveQueryDialog", 0.0);
+		runPendingAction();
+		break;
+	case kActionDisable:
+		takeAction(kActionHide, "SaveQueryDialog", 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());
+
+	uint numSaves = 0;
+	SaveStateList saves = g_nancy->getMetaEngine()->listSaves(ConfMan.getActiveDomainName().c_str());
+	for (const SaveStateDescriptor &save : saves) {
+		if (save.getSaveSlot() == g_nancy->getMetaEngine()->getMaximumSaveSlot() || save.getSaveSlot() == g_nancy->getAutosaveSlot()) {
+			continue;
+		}
+
+		takeAction(kActionAddText, listName, save.getDescription());
+		++numSaves;
+	}
+
+	return numSaves;
+}
+
+int Manager::findSaveSlot(const Common::String &name) const {
+	SaveStateList saves = g_nancy->getMetaEngine()->listSaves(ConfMan.getActiveDomainName().c_str());
+	for (const SaveStateDescriptor &save : saves) {
+		if (save.getSaveSlot() != g_nancy->getMetaEngine()->getMaximumSaveSlot() && save.getSaveSlot() != g_nancy->getAutosaveSlot() &&
+				save.getDescription().equalsIgnoreCase(name)) {
+			return save.getSaveSlot();
+		}
+	}
+
+	return -1;
+}
+
+// The dialogs show a scaled down picture of the game
+void Manager::setSaveScreenshot(const Graphics::ManagedSurface &image, const Common::Rect &src) {
+	Graphics::ManagedSurface *texture = _textures.getValOrDefault(kScreenshotTexture, nullptr);
+	if (!texture) {
+		texture = new Graphics::ManagedSurface();
+		_textures.setVal(kScreenshotTexture, texture);
+	}
+
+	texture->create(kScreenshotWidth, kScreenshotHeight, g_nancy->_graphics->getTransparentPixelFormat());
+	texture->blitFrom(image, src, Common::Rect(kScreenshotWidth, kScreenshotHeight));
+}
+
+void Manager::onLoaderAction(const Action &action) {
+	switch (action.type) {
+	case kActionShow:
+		if (action.value >= 0) {
+			askToSaveFirst(action);
+		} else {
+			takeAction(fillSaveList("LoadList") > 0 ? kActionEnable : kActionDisable, "LoadButton", 0.0);
+			takeAction(kActionSetValue, "LoadList", 0.0);
+			takeAction(kActionShow, "LoadDialog", 0.0);
+		}
+
+		return;
+	case kActionSetText: {
+		_selectedSave = action.text;
+		int slot = findSaveSlot(_selectedSave);
+		if (_selectedSave == kNewGameName || slot == -1) {
+			return;
+		}
+
+		SaveStateDescriptor save = g_nancy->getMetaEngine()->querySaveMetaInfos(ConfMan.getActiveDomainName().c_str(), slot);
+		takeAction(kActionSetText, "LoadGameDetails", save.getSaveDate() + " " + save.getSaveTime());
+
+		if (save.getThumbnail()) {
+			Graphics::ManagedSurface thumbnail;
+			thumbnail.copyFrom(*save.getThumbnail());
+			setSaveScreenshot(thumbnail, Common::Rect(thumbnail.w, thumbnail.h));
+			takeAction(kActionSetTexture, "LoadScreenshot", kScreenshotTexture);
+		}
+
+		return;
+	}
+	case kActionSetValue:
+		if (_selectedSave == kNewGameName) {
+			_request.type = kRequestNewGame;
+		} else {
+			_request.type = kRequestLoad;
+			_request.slot = findSaveSlot(_selectedSave);
+		}
+
+		_lastSaveName = _selectedSave;
+		break;
+	case kActionHide:
+		break;
+	default:
+		return;
+	}
+
+	_selectedSave.clear();
+	takeAction(kActionHide, "LoadDialog", 0.0);
+	takeAction(kActionSetText, "LoadList", Common::String());
+}
+
+void Manager::onSaverAction(const Action &action) {
+	switch (action.type) {
+	case kActionShow: {
+		fillSaveList("SaveList");
+		_typedSaveName = _lastSaveName;
+
+		Action setName;
+		setName.type = kActionSetText;
+		setName.target = "SaveName";
+		setName.hasValue = true;
+		setName.value = 1;
+		setName.text = _lastSaveName;
+		takeAction(setName);
+
+		takeAction(kActionShow, "SaveDialog", 0.0);
+
+		// The picture is of the viewport, as it was before the dialog came up
+		const Graphics::Screen *screen = g_nancy->_graphics->getScreen();
+		Graphics::ManagedSurface screenCopy;
+		screenCopy.copyFrom(*screen);
+		setSaveScreenshot(screenCopy, g_nancy->_graphics->gameToScreen(NancySceneState.getViewport().getScreenPosition()));
+		takeAction(kActionSetTexture, "SaveScreenshot", kScreenshotTexture);
+		break;
+	}
+	case kActionHide:
+		takeAction(kActionHide, "SaveDialog", 0.0);
+		takeAction(kActionSetText, "SaveList", Common::String());
+		_typedSaveName.clear();
+		runPendingAction();
+		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('*') &&
+				!action.text.contains('?') && !action.text.contains('"') && !action.text.contains('/') && !action.text.contains('\\') &&
+				!action.text.contains('|') && !action.text.equalsIgnoreCase(kNewGameName)) {
+			_typedSaveName = action.text;
+		} else {
+			takeAction(kActionSetText, "SaveName", _typedSaveName);
+		}
+
+		break;
+	case kActionSetValue: {
+		if (_typedSaveName.empty()) {
+			break;
+		}
+
+		takeAction(kActionHide, "SaveDialog", 0.0);
+		takeAction(kActionSetText, "SaveList", Common::String());
+
+		// A name that is in use gets saved over; a new one gets the first free slot
+		int slot = findSaveSlot(_typedSaveName);
+		for (int i = 1; slot == -1 && i < g_nancy->getMetaEngine()->getMaximumSaveSlot(); ++i) {
+			if (i != g_nancy->getAutosaveSlot() && !g_nancy->getMetaEngine()->querySaveMetaInfos(ConfMan.getActiveDomainName().c_str(), i).isValid()) {
+				slot = i;
+			}
+		}
+
+		if (slot != -1) {
+			_request.type = kRequestSave;
+			_request.slot = slot;
+			_request.name = _typedSaveName;
+		} else {
+			warning("No free save slot for %s", _typedSaveName.c_str());
+		}
+
+		_lastSaveName = _typedSaveName;
+		_typedSaveName.clear();
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+void Manager::onGameWindowAction(const Action &action) {
+	switch (action.type) {
+	case kActionHide:
+		// Quitting
+		if (action.value >= 0) {
+			askToSaveFirst(action);
+		} else {
+			_request.type = kRequestQuit;
+		}
+
+		break;
+	case kActionSetValue:
+		ConfMan.setInt("matte_color", (int)action.value);
+		applyMatteColor();
+		break;
+	case kActionQuery: {
+		const Graphics::PixelFormat &format = g_nancy->_graphics->getTransparentPixelFormat();
+		bool isCurrent = ConfMan.hasKey("matte_color") &&
+			g_nancy->_graphics->getFontColor(ConfMan.getInt("matte_color"), format) == g_nancy->_graphics->getFontColor((uint)action.value, format);
+		takeAction(kActionSetValue, action.sender, isCurrent ? 1.0 : 0.0);
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+// The matte is black until a font color gets picked for it
+void Manager::applyMatteColor() {
+	uint32 color = 0xFF000000;
+	if (ConfMan.hasKey("matte_color")) {
+		color = g_nancy->_graphics->getFontColor(ConfMan.getInt("matte_color"), Graphics::PixelFormat::createFormatARGB32());
+	}
+
+	NancySceneState.getFrame().setBlankColor(color);
+}
+
+// The sliders go from 0 to 100, and have to read back what they set
+void Manager::onVolumeAction(const Action &action, const char *configKey) {
+	if (action.type == kActionQuery) {
+		takeAction(kActionSetValue, action.sender, (double)((ConfMan.getInt(configKey) * 100 + Audio::Mixer::kMaxMixerVolume / 2) / Audio::Mixer::kMaxMixerVolume));
+	} else if (action.type == kActionSetValue) {
+		ConfMan.setInt(configKey, (int)action.value * Audio::Mixer::kMaxMixerVolume / 100);
+		g_nancy->syncSoundSettings();
+	}
+}
+
+// The value is the event flag
+void Manager::onFlagsAction(const Action &action) {
+	int16 label = (int16)action.value;
+
+	switch (action.type) {
+	case kActionEnable:
+		NancySceneState.setEventFlag(label, g_nancy->_true);
+		break;
+	case kActionDisable:
+		NancySceneState.setEventFlag(label, g_nancy->_false);
+		break;
+	case kActionToggleEnabled:
+		NancySceneState.setEventFlag(label, NancySceneState.getEventFlag(label, g_nancy->_true) ? g_nancy->_false : g_nancy->_true);
+		break;
+	case kActionQuery:
+		takeAction(kActionSetValue, action.sender, NancySceneState.getEventFlag(label, g_nancy->_true) ? 1.0 : 0.0);
+		break;
+	default:
+		break;
+	}
+}
+
+} // End of namespace NDUI
+} // End of namespace UI
+} // End of namespace Nancy


Commit: c38839543657eedefadf0b1d00f20ea33aa6c684
    https://github.com/scummvm/scummvm/commit/c38839543657eedefadf0b1d00f20ea33aa6c684
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:28+03:00

Commit Message:
NANCY: NANCY16: Use the NDUI in the scene

- The NDUI panels replace the frame, textbox, popups, taskbar and clock
- Captions, inventory changes, input, the clock and UI requests go through it
- The journal and task list prep records run for the whole game

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 82f471c3511..f412f33008b 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -48,6 +48,7 @@
 #include "engines/nancy/misc/lightning.h"
 #include "engines/nancy/misc/specialeffect.h"
 #include "engines/nancy/misc/viewportfader.h"
+#include "engines/nancy/ui/ndui/manager.h"
 
 namespace Common {
 DECLARE_SINGLETON(Nancy::State::Scene);
@@ -140,6 +141,7 @@ Scene::Scene() :
 		_activeConversation(nullptr),
 		_lightning(nullptr),
 		_viewportFader(nullptr),
+		_ndui(nullptr),
 		_destroyOnExit(false),
 		_isRunningAd(false),
 		_hotspotDebug(50) {}
@@ -155,6 +157,7 @@ Scene::~Scene() {
 	delete _camera;
 	delete _lightning;
 	delete _viewportFader;
+	delete _ndui;
 
 	clearPuzzleData();
 }
@@ -434,6 +437,10 @@ void Scene::addItemToInventory(int16 id) {
 
 		g_nancy->_sound->playSound("BUOK");
 
+		if (_ndui) {
+			_ndui->showInventoryItem(id, true);
+		}
+
 		if (g_nancy->getGameType() <= kGameTypeNancy9) {
 			_inventoryBox.addItem(id);
 		} else {
@@ -486,6 +493,10 @@ void Scene::removeItemFromInventory(int16 id, bool pickUp) {
 			g_nancy->_sound->playSound("BUOK");
 		}
 
+		if (_ndui) {
+			_ndui->showInventoryItem(id, false);
+		}
+
 		if (g_nancy->getGameType() <= kGameTypeNancy9) {
 			_inventoryBox.removeItem(id);
 		} else {
@@ -1228,13 +1239,16 @@ void Scene::registerGraphics() {
 	_frame.registerGraphics();
 	_viewport.registerGraphics();
 
-	_textbox.registerGraphics();
-
 	// Pre-Nancy 10: inventory box is always-on-screen.
 	// Nancy 10+: a separate popup widget driven by UIIV (initially hidden).
-	if (g_nancy->getGameType() <= kGameTypeNancy9) {
+	// Nancy 16+: all of the UI is made of NDUI panels.
+	if (_ndui) {
+		_ndui->registerGraphics();
+	} else if (g_nancy->getGameType() <= kGameTypeNancy9) {
+		_textbox.registerGraphics();
 		_inventoryBox.registerGraphics();
 	} else {
+		_textbox.registerGraphics();
 		_inventoryPopup.registerGraphics();
 		_notebookPopup.registerGraphics();
 		_cellPhonePopup.registerGraphics();
@@ -1704,6 +1718,10 @@ void Scene::synchronize(Common::Serializer &ser) {
 				applyPlayerCharacter(playerChar->characterIndex);
 			}
 		}
+
+		if (_ndui) {
+			initUI();
+		}
 	}
 
 	_isRunningAd = false;
@@ -1989,8 +2007,12 @@ void Scene::load(bool fromSaveFile) {
 		_flags.sceneCounts.getOrCreateVal(_sceneState.currentScene.sceneID)++;
 	}
 
+	if (_ndui) {
+		_ndui->onSceneChange(_sceneState.currentScene.sceneID);
+	}
+
 	// Re-evaluate taskbar notification states against the new scene.
-	if (g_nancy->getGameType() >= kGameTypeNancy10) {
+	if (_taskbar) {
 		_taskbar->updateNotificationStates(_sceneState.currentScene.sceneID);
 	}
 
@@ -2031,6 +2053,11 @@ void Scene::run() {
 		if (g_nancy->getGameType() >= kGameTypeNancy16 && _timers.playerTime.getDays() != day) {
 			advanceDayValue();
 		}
+
+		if (_ndui) {
+			uint minutes = _timers.playerTime.getHours() * 60 + _timers.playerTime.getMinutes();
+			_ndui->setPlayerTime(minutes, minutes >= bootSummary->dayStartMinutes && minutes <= bootSummary->dayEndMinutes);
+		}
 	}
 
 	updateEndOfDay();
@@ -2044,6 +2071,34 @@ void Scene::run() {
 
 	_actionManager.processActionRecords();
 
+	if (_ndui) {
+		_uiPrepActions.processActionRecords();
+		_ndui->update();
+
+		UI::NDUI::Manager::Request request = _ndui->takeRequest();
+		switch (request.type) {
+		case UI::NDUI::Manager::kRequestNewGame:
+			setDestroyOnExit();
+			g_nancy->setState(NancyState::kScene);
+			return;
+		case UI::NDUI::Manager::kRequestLoad:
+			if (request.slot != -1) {
+				g_nancy->loadGameState(request.slot);
+				return;
+			}
+
+			break;
+		case UI::NDUI::Manager::kRequestSave:
+			g_nancy->saveGameState(request.slot, request.name, false);
+			break;
+		case UI::NDUI::Manager::kRequestQuit:
+			g_nancy->quitGame();
+			return;
+		default:
+			break;
+		}
+	}
+
 	if (_lightning) {
 		_lightning->run();
 	}
@@ -2330,7 +2385,8 @@ void Scene::handleInput() {
 			_activeConversation->skipLine();
 		} else if (_activeMovie) {
 			_activeMovie->skip();
-		} else {
+		} else if (!_ndui) {
+			// Nancy16 has no main menu state
 			g_nancy->setState(NancyState::kMainMenu);
 			return;
 		}
@@ -2342,7 +2398,9 @@ void Scene::handleInput() {
 	// strip, so they get first crack at input — otherwise a click inside
 	// the popup that overlapped the textbox area could accidentally pick
 	// a conversation response.
-	if (g_nancy->getGameType() >= kGameTypeNancy10) {
+	if (_ndui) {
+		_ndui->handleInput(input);
+	} else if (g_nancy->getGameType() >= kGameTypeNancy10) {
 		// Confine the cursor to whichever popup is open so the player
 		// can't drag it into the underlying scene UI.
 		const Common::Rect confine = activePopupConfinement();
@@ -2359,7 +2417,10 @@ void Scene::handleInput() {
 		_cellPhonePopup.handleInput(input);
 	}
 
-	_textbox.handleInput(input);
+	if (!_ndui) {
+		_textbox.handleInput(input);
+	}
+
 	if (g_nancy->getGameType() <= kGameTypeNancy9) {
 		_inventoryBox.handleInput(input);
 	}
@@ -2526,6 +2587,25 @@ void Scene::handleInput() {
 }
 
 void Scene::initStaticData() {
+	// Nancy16 describes its whole UI with NDUI panels, which replace the frame,
+	// textbox, popups, taskbar and clock of the earlier games
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		_frame.initBlank();
+		_viewport.init();
+
+		initUI();
+
+		// The popups of the earlier games go unused, and must never count as open
+		_inventoryPopup.setVisible(false);
+		_notebookPopup.setVisible(false);
+		_cellPhonePopup.setVisible(false);
+		_conversationPopup.setVisible(false);
+
+		g_nancy->setMouseEnabled(true);
+		_state = kLoad;
+		return;
+	}
+
 	auto *bootSummary = GetEngineData(BSUM);
 	assert(bootSummary);
 
@@ -2618,6 +2698,48 @@ void Scene::initStaticData() {
 	_state = kLoad;
 }
 
+// The UI starts out empty, and gets filled with the player's items, journal and
+// task list; the records that fill the last two run again as their conditions allow
+void Scene::initUI() {
+	auto *pcui = GetEngineData(PCUI);
+	auto *puih = GetEngineData(PUIH);
+	assert(pcui && puih);
+
+	delete _ndui;
+	_ndui = new UI::NDUI::Manager();
+	_ndui->load(pcui->uiName.toString());
+
+	// The most recent items come first
+	const Common::Array<int16> &order = _inventoryBox.getOrder();
+	for (int i = (int)order.size() - 1; i >= 0; --i) {
+		if (order[i] != -1 && _flags.items[order[i]] == g_nancy->_true) {
+			_ndui->showInventoryItem(order[i], true);
+		}
+	}
+
+	_uiPrepActions.clearActionRecords();
+	loadUIPrepRecords(puih->journalPrepName);
+	loadUIPrepRecords(puih->tasklistPrepName);
+
+	_ndui->applyMatteColor();
+	_ndui->registerGraphics();
+}
+
+void Scene::loadUIPrepRecords(const Common::Path &name) {
+	IFF *iff = g_nancy->_resource->loadIFF(name);
+	if (!iff) {
+		error("Failed to load IFF %s", name.toString().c_str());
+	}
+
+	Common::SeekableReadStream *chunk = nullptr;
+	for (uint i = 0; chunk = iff->getChunkStream("ACT", i), chunk != nullptr; ++i) {
+		_uiPrepActions.addNewActionRecord(*chunk);
+		delete chunk;
+	}
+
+	delete iff;
+}
+
 void Scene::viewInventoryItem(int16 itemID) {
 	removeItemFromInventory(itemID, false);
 
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 4b3054f40e5..41099fdcf3b 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -71,6 +71,10 @@ class TextboxOrnaments;
 class InventoryBoxOrnaments;
 class Clock;
 class Camera;
+
+namespace NDUI {
+class Manager;
+}
 }
 
 namespace State {
@@ -280,6 +284,7 @@ public:
 	// The Vampire Diaries only;
 	void beginLightning(int16 distance, uint16 pulseTime, int16 rgbPercent);
 	Misc::ViewportFader *getViewportFader() { return _viewportFader; }
+	UI::NDUI::Manager *getNDUI() { return _ndui; }
 
 	// Used from nancy2 onwards
 	void specialEffect(byte type, uint16 fadeToBlackTime, uint16 frameTime);
@@ -349,6 +354,8 @@ private:
 	void playPlayerCantSound(int16 itemID);
 
 	void initStaticData();
+	void initUI();
+	void loadUIPrepRecords(const Common::Path &name);
 
 	void clearSceneData(bool nextIsNoArt = false);
 	void clearPuzzleData();
@@ -450,11 +457,16 @@ private:
 
 	Misc::Lightning *_lightning;
 	Misc::ViewportFader *_viewportFader;
+	UI::NDUI::Manager *_ndui;
 	Common::Queue<Misc::SpecialEffect> _specialEffects;
 
 	Common::HashMap<uint32, PuzzleData *> _puzzleData;
 
 	Action::ActionManager _actionManager;
+
+	// Nancy16: the records that fill the journal and the task list, which wait for
+	// their conditions for the whole game
+	Action::ActionManager _uiPrepActions;
 	Action::PlaySecondaryMovie *_activeMovie;
 	Action::ConversationSound *_activeConversation;
 
diff --git a/engines/nancy/ui/textbox.cpp b/engines/nancy/ui/textbox.cpp
index 0ef3a200371..1ed6c861a4b 100644
--- a/engines/nancy/ui/textbox.cpp
+++ b/engines/nancy/ui/textbox.cpp
@@ -32,6 +32,7 @@
 #include "engines/nancy/ui/textbox.h"
 #include "engines/nancy/ui/scrollbar.h"
 #include "engines/nancy/ui/scrolltextbox.h"
+#include "engines/nancy/ui/ndui/manager.h"
 
 namespace Nancy {
 namespace UI {
@@ -213,6 +214,12 @@ void Textbox::drawTextbox() {
 }
 
 void Textbox::clear() {
+	// Nancy16 shows its captions in the UI instead
+	if (NDUI::Manager *ndui = NancySceneState.getNDUI()) {
+		ndui->setCaption(Common::String());
+		return;
+	}
+
 	if (_scrollTextBox) {
 		_scrollTextBox->clear();
 		return;
@@ -246,6 +253,11 @@ void Textbox::addTextLine(const Common::String &text, uint32 autoClearTime) {
 	if (text.contains(debugString))
 		return;
 
+	if (NDUI::Manager *ndui = NancySceneState.getNDUI()) {
+		ndui->setCaption(text);
+		return;
+	}
+
 	if (_scrollTextBox) {
 		_scrollTextBox->addTextLine(text, autoClearTime);
 		return;


Commit: 5c86caf88cf4c05d257efa8acc76dc8d3af67138
    https://github.com/scummvm/scummvm/commit/5c86caf88cf4c05d257efa8acc76dc8d3af67138
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:29+03:00

Commit Message:
NANCY: NANCY16: Show conversations in the UI

- The line and its responses are passed to the NDUI together
- The NDUI conversation is ended in place of the conversation popup

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 3af00d42c64..278dabd7642 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -37,6 +37,7 @@
 #include "engines/nancy/action/secondarymovie.h"
 
 #include "engines/nancy/state/scene.h"
+#include "engines/nancy/ui/ndui/manager.h"
 
 namespace Nancy {
 namespace Action {
@@ -355,7 +356,10 @@ void ConversationSound::execute() {
 	case kRun:
 		if (!_hasDrawnTextbox) {
 			_hasDrawnTextbox = true;
-			if (g_nancy->getGameType() >= kGameTypeNancy10) {
+			UI::NDUI::Manager *ndui = NancySceneState.getNDUI();
+			if (ndui) {
+				// The Nancy16 UI shows the line and the responses together, below
+			} else if (g_nancy->getGameType() >= kGameTypeNancy10) {
 				NancySceneState.getConversationPopup().open();
 
 				if (ConfMan.getBool("subtitles")) {
@@ -424,18 +428,25 @@ void ConversationSound::execute() {
 				responsesToAdd.push_back(i);
 			}
 
-			if (g_nancy->getGameType() >= kGameTypeNancy10) {
+			if (!ndui && g_nancy->getGameType() >= kGameTypeNancy10) {
 				NancySceneState.getConversationPopup().setResponseStart();
 			}
 
+			Common::Array<Common::String> responseTexts;
 			for (uint i : responsesToAdd) {
-				if (g_nancy->getGameType() >= kGameTypeNancy10) {
+				if (ndui) {
+					responseTexts.push_back(_responses[i].text);
+				} else if (g_nancy->getGameType() >= kGameTypeNancy10) {
 					NancySceneState.getConversationPopup().addTextLine(_responses[i].text);
 				} else {
 					NancySceneState.getTextbox().addTextLine(_responses[i].text);
 				}
 				_responses[i].isOnScreen = true;
 			}
+
+			if (ndui) {
+				ndui->startConversation(ConfMan.getBool("subtitles") ? _text : Common::String(), responseTexts);
+			}
 		}
 
 		if (!g_nancy->_sound->isSoundPlaying(_sound) && (_isSkipped || isVideoDonePlaying())) {
@@ -545,7 +556,9 @@ void ConversationSound::execute() {
 				}
 			}
 
-			if (g_nancy->getGameType() >= kGameTypeNancy10) {
+			if (NancySceneState.getNDUI()) {
+				NancySceneState.getNDUI()->endConversation();
+			} else if (g_nancy->getGameType() >= kGameTypeNancy10) {
 				NancySceneState.getConversationPopup().close();
 			}
 			


Commit: e79fb3368fba29acd24b75be468bef190b9c94c6
    https://github.com/scummvm/scummvm/commit/e79fb3368fba29acd24b75be468bef190b9c94c6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:31+03:00

Commit Message:
NANCY: NANCY16: Add the UI records

- UIControl (29) sends an action to a panel or control of the NDUI
- AddJournalEntry (71) and AddTasklistEntry (72) fill the journal and
  the task list

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 7d655346a18..07d54059e1b 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -242,7 +242,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new ControlUIItems();
 		else
-			return nullptr;	//return new UIControl();	// TODO
+			return new UIControl();
 	case 30:	// Nancy11
 		return new StopPlayerScrolling();
 	case 31:	// Nancy11
@@ -354,12 +354,12 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new ModifyListEntry(ModifyListEntry::kAdd);
 		else
-			return nullptr;	//return new AddJournalEntry();	// TODO
+			return new AddJournalEntry();
 	case 72:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new ModifyListEntry(ModifyListEntry::kDelete);
 		else
-			return nullptr;	//return new AddTasklistEntry();	// TODO
+			return new AddTasklistEntry();
 	case 73:
 		return new ModifyListEntry(ModifyListEntry::kMark);
 	case 74:	// Nancy10 only
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 8a8afadecd8..08a5a3dfd05 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -30,6 +30,7 @@
 
 #include "engines/nancy/action/miscrecords.h"
 #include "engines/nancy/misc/viewportfader.h"
+#include "engines/nancy/ui/ndui/manager.h"
 
 #include "engines/nancy/state/scene.h"
 
@@ -913,6 +914,52 @@ void HideCursor::execute() {
 	}
 }
 
+void UIControl::readData(Common::SeekableReadStream &stream) {
+	_actionType = stream.readSint32LE();
+	readFilename(stream, _target);
+	readFilename(stream, _textKey);
+	_value = stream.readDoubleLE();
+}
+
+void UIControl::execute() {
+	UI::NDUI::Action action;
+	action.eventType = UI::NDUI::kEventActionRecord;
+	action.type = _actionType;
+	action.target = _target;
+	action.hasValue = true;
+	action.value = _value;
+	action.sender = "ActionRecord";
+
+	if (!_textKey.empty()) {
+		const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+		assert(autotext);
+		action.text = autotext->texts.getValOrDefault(_textKey);
+	}
+
+	NancySceneState.getNDUI()->takeAction(action);
+	finishExecution();
+}
+
+void AddJournalEntry::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _pageName);
+	readFilename(stream, _textKey);
+}
+
+void AddJournalEntry::execute() {
+	NancySceneState.getNDUI()->addJournalEntry(_pageName, _textKey);
+	finishExecution();
+}
+
+void AddTasklistEntry::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _textKey);
+	_flag = stream.readSint16LE();
+}
+
+void AddTasklistEntry::execute() {
+	NancySceneState.getNDUI()->addTasklistEntry(_textKey, _flag);
+	finishExecution();
+}
+
 void LoseGame::readData(Common::SeekableReadStream &stream) {
 	stream.skip(1);
 }
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 8a79b72be69..45d60da80a2 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -446,6 +446,49 @@ protected:
 	bool _isHiding = false;
 };
 
+// Nancy16 record that sends an action to a panel or control of the UI, the same way
+// the UI's own controls do when they are clicked
+class UIControl : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "UIControl"; }
+
+	int32 _actionType = 0;
+	Common::String _target;
+	Common::String _textKey;	// AUTOTEXT key
+	double _value = 0;
+};
+
+// Nancy16 journal entry. The page names a panel of the journal, and its text is the
+// title of the page.
+class AddJournalEntry : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "AddJournalEntry"; }
+
+	Common::String _pageName;
+	Common::String _textKey;	// AUTOTEXT key
+};
+
+// Nancy16 task list entry, which can be checked off once its flag is set
+class AddTasklistEntry : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "AddTasklistEntry"; }
+
+	Common::String _textKey;	// AUTOTEXT key
+	int16 _flag = kEvNoEvent;
+};
+
 // Nancy16 fade of the viewport to a solid color and back, covering the next scene
 // change. The fade itself is owned by the scene; the record only arms it, waiting
 // for any previous fade to end first.


Commit: ad55eaabfcf955af5ad8e9c18d146f97c1504345
    https://github.com/scummvm/scummvm/commit/ad55eaabfcf955af5ad8e9c18d146f97c1504345
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:34+03:00

Commit Message:
NANCY: Stop the speech of a skipped cinematic

- Skipping a cinematic with Esc also stops the lines spoken by sound
  records that don't change the scene
- Captions of the stopped lines are cleared

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


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 2ebb37cb2fd..4419a8dc276 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -840,6 +840,33 @@ void ActionManager::onPause(bool pause) {
 	}
 }
 
+// A stopped sound lets its record finish on its own, the same way it would at the
+// end of the line
+void ActionManager::stopSpeechSounds() {
+	const Common::Array<byte> &speechChannels = g_nancy->getStaticData().soundChannelInfo.speechChannels;
+	bool stoppedCaptionedSound = false;
+
+	for (ActionRecord *record : _records) {
+		PlaySound *sound = dynamic_cast<PlaySound *>(record);
+		if (!sound || !sound->_isActive || sound->_isDone || sound->_state != ActionRecord::kRun ||
+				sound->_sceneChange.sceneID != kNoScene) {
+			continue;
+		}
+
+		for (byte channel : speechChannels) {
+			if (sound->_sound.channelID == channel) {
+				g_nancy->_sound->stopSound(sound->_sound);
+				stoppedCaptionedSound |= !sound->_ccText.empty();
+				break;
+			}
+		}
+	}
+
+	if (stoppedCaptionedSound) {
+		NancySceneState.getTextbox().clear();
+	}
+}
+
 void ActionManager::synchronize(Common::Serializer &ser) {
 	// When loading, the records should already have been initialized by scene
 	for (auto &rec : _records) {
diff --git a/engines/nancy/action/actionmanager.h b/engines/nancy/action/actionmanager.h
index ecc999fc89f..47b9318dcc3 100644
--- a/engines/nancy/action/actionmanager.h
+++ b/engines/nancy/action/actionmanager.h
@@ -66,6 +66,10 @@ public:
 
 	void onPause(bool pause);
 
+	// Enhancement: stops the lines being spoken by sound records that don't lead to
+	// another scene, after a cinematic got skipped
+	void stopSpeechSounds();
+
 	void synchronize(Common::Serializer &serializer);
 
 	static ActionRecord *createAndLoadNewRecord(Common::SeekableReadStream &inputData);
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index f412f33008b..945993d510c 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2071,6 +2071,13 @@ void Scene::run() {
 
 	_actionManager.processActionRecords();
 
+	// The records a skipped cinematic set off have started by now; its dialogue
+	// shouldn't go on without it
+	if (_hasSkippedMovie) {
+		_hasSkippedMovie = false;
+		_actionManager.stopSpeechSounds();
+	}
+
 	if (_ndui) {
 		_uiPrepActions.processActionRecords();
 		_ndui->update();
@@ -2384,7 +2391,7 @@ void Scene::handleInput() {
 		if (_activeConversation) {
 			_activeConversation->skipLine();
 		} else if (_activeMovie) {
-			_activeMovie->skip();
+			_hasSkippedMovie = _activeMovie->skip();
 		} else if (!_ndui) {
 			// Nancy16 has no main menu state
 			g_nancy->setState(NancyState::kMainMenu);
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 41099fdcf3b..ec261b0e518 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -477,6 +477,9 @@ private:
 	// Whether esc was already down last frame, so a held key only skips once
 	bool _escHeld = false;
 
+	// Set when esc skipped a cinematic this frame
+	bool _hasSkippedMovie = false;
+
 	// Contains a screenshot of the Scene state from the last time it was exited
 	Graphics::ManagedSurface _lastScreenshot;
 


Commit: 3dad6370d9dd2eda56b4cf4199ee77ff644da598
    https://github.com/scummvm/scummvm/commit/3dad6370d9dd2eda56b4cf4199ee77ff644da598
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:36+03:00

Commit Message:
NANCY: NANCY16: Implement the AutotextScroll AR

Action record 197 scrolls AUTOTEXT hypertext up the viewport, for the
end credits

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 07d54059e1b..2aeb85ed325 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -26,6 +26,7 @@
 #include "engines/nancy/action/miscrecords.h"
 
 #include "engines/nancy/action/autotext.h"
+#include "engines/nancy/action/autotextscroll.h"
 #include "engines/nancy/action/cameraaction.h"
 #include "engines/nancy/action/conversation.h"
 #include "engines/nancy/action/interactivevideo.h"
@@ -38,7 +39,6 @@
 #include "engines/nancy/action/puzzle/angletosspuzzle.h"
 #include "engines/nancy/action/puzzle/arcadepuzzle.h"
 #include "engines/nancy/action/puzzle/assemblypuzzle.h"
-//#include "engines/nancy/action/puzzle/autotextscroll.h"
 #include "engines/nancy/action/puzzle/bballpuzzle.h"
 #include "engines/nancy/action/puzzle/beadpuzzle.h"
 #include "engines/nancy/action/puzzle/blockingpuzzle.h"
@@ -705,7 +705,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 196:
 		return nullptr; // return new AnalogClock();	// TODO
 	case 197:
-		return nullptr; // return new AutotextScroll();	// TODO
+		return new AutotextScroll();	// Used for the end credits
 	case 188:	// Empty in the original
 	case 190:	// MemoryPuzzle, moved from 238
 	case 191:	// BeadPuzzle, moved from 243
diff --git a/engines/nancy/action/autotextscroll.cpp b/engines/nancy/action/autotextscroll.cpp
new file mode 100644
index 00000000000..08c20c4baf4
--- /dev/null
+++ b/engines/nancy/action/autotextscroll.cpp
@@ -0,0 +1,196 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/enginedata.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/misc/hypertext.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/autotextscroll.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint32 kMsPerTick = 20;
+static const float kTicksPerSecond = 50.0f;
+
+// Upper limit for the height of a single rendered entry
+static const uint kMaxTextHeight = 8000;
+
+class AutotextScrollRenderer : public Misc::HypertextParser {
+public:
+	void render(const Common::String &text, uint width, uint fontID, uint colorID, Graphics::ManagedSurface &image);
+};
+
+void AutotextScrollRenderer::render(const Common::String &text, uint width, uint fontID, uint colorID, Graphics::ManagedSurface &image) {
+	_fullSurface.create(width, kMaxTextHeight, g_nancy->_graphics->getInputPixelFormat());
+	_fullSurface.clear(g_nancy->_graphics->getTransColor());
+	_defaultTextColor = colorID;
+
+	addTextLine(text);
+	drawAllText(_fullSurface.getBounds(), 0, fontID, fontID);
+
+	uint height = CLIP<uint>(_drawnTextHeight, 1, kMaxTextHeight);
+	image.create(width, height, _fullSurface.format);
+	image.blitFrom(_fullSurface, Common::Rect(width, height), Common::Point());
+	image.setTransparentColor(g_nancy->_graphics->getTransColor());
+}
+
+AutotextScroll::~AutotextScroll() {
+	for (ScrollingText *text : _texts) {
+		delete text;
+	}
+}
+
+void AutotextScroll::readData(Common::SeekableReadStream &stream) {
+	_startDepth = stream.readSint32LE();
+	readRect(stream, _bounds);
+
+	float interval = stream.readFloatLE();
+	_intervalTicks = (int32)interval;
+	_speed = stream.readFloatLE() / (interval * kTicksPerSecond);
+
+	_fontID = stream.readUint32LE();
+	_colorID = stream.readUint32LE();
+
+	_keys.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (Common::String &key : _keys) {
+		readFilename(stream, key);
+	}
+
+	_doneFlag.label = stream.readSint16LE();
+	_doneFlag.flag = stream.readByte();
+}
+
+// The text gets drawn over the viewport
+void AutotextScroll::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(vpBounds.width(), vpBounds.height(), g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	// The first entry starts right away
+	_intervalCounter = _intervalTicks;
+	_nextKey = 0;
+	_lastTickTime = g_nancy->getTotalPlayTime();
+}
+
+void AutotextScroll::startText(const Common::String &key) {
+	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+	assert(autotext);
+
+	ScrollingText *text = new ScrollingText();
+	AutotextScrollRenderer renderer;
+	renderer.render(autotext->texts[key], _bounds.width(), _fontID, _colorID, text->image);
+	text->offset = _bounds.top - _startDepth;
+	_texts.push_back(text);
+}
+
+// Returns true once the text is out of view
+bool AutotextScroll::scrollText(ScrollingText &text, int32 ticks) {
+	text.ticks += ticks;
+	text.offset = (int32)((_bounds.top - _startDepth) - text.ticks * _speed);
+
+	// The data rects include their bottom edge
+	if (text.offset < _bounds.top - (_bounds.bottom - 1) && _speed >= 0) {
+		return true;
+	}
+
+	return text.offset > text.image.h && _speed <= 0;
+}
+
+void AutotextScroll::update(int32 ticks) {
+	_intervalCounter += ticks;
+	if (_intervalCounter >= _intervalTicks && _nextKey < _keys.size()) {
+		// Empty entries leave a gap
+		if (!_keys[_nextKey].empty()) {
+			startText(_keys[_nextKey]);
+		}
+
+		++_nextKey;
+		_intervalCounter = 0;
+	}
+
+	for (uint i = 0; i < _texts.size(); ) {
+		if (scrollText(*_texts[i], ticks)) {
+			delete _texts[i];
+			_texts.remove_at(i);
+		} else {
+			++i;
+		}
+	}
+}
+
+void AutotextScroll::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (const ScrollingText *text : _texts) {
+		Common::Rect dest(_bounds.left, _bounds.top - text->offset, _bounds.left + text->image.w, _bounds.top - text->offset + text->image.h);
+		Common::Rect visible = dest.findIntersectingRect(_bounds);
+		if (visible.isEmpty()) {
+			continue;
+		}
+
+		Common::Rect src = visible;
+		src.translate(-dest.left, -dest.top);
+		_drawSurface.blitFrom(text->image, src, visible.origin());
+	}
+
+	_needsRedraw = true;
+}
+
+void AutotextScroll::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+		int32 ticks = (now - _lastTickTime) / kMsPerTick;
+		_lastTickTime += ticks * kMsPerTick;
+
+		if (ticks > 0) {
+			update(ticks);
+		}
+
+		if (_nextKey >= _keys.size() && _texts.empty()) {
+			_state = kActionTrigger;
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		NancySceneState.setEventFlag(_doneFlag);
+		finishExecution();
+		break;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/autotextscroll.h b/engines/nancy/action/autotextscroll.h
new file mode 100644
index 00000000000..aaaf7b93208
--- /dev/null
+++ b/engines/nancy/action/autotextscroll.h
@@ -0,0 +1,81 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_AUTOTEXTSCROLL_H
+#define NANCY_ACTION_AUTOTEXTSCROLL_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 scrolling text (AR 197), used for the end credits. Each of the listed AUTOTEXT
+// entries is rendered as hypertext and scrolled through a rectangle, one after another.
+// Once they have all scrolled out of view, an event flag gets set.
+class AutotextScroll : public RenderActionRecord {
+public:
+	AutotextScroll() : RenderActionRecord(7) {}
+	virtual ~AutotextScroll();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "AutotextScroll"; }
+
+	struct ScrollingText {
+		Graphics::ManagedSurface image;
+		int32 ticks = 0;
+		int32 offset = 0;		// scroll position; the text starts this far above the top of the rect
+	};
+
+	void startText(const Common::String &key);
+	bool scrollText(ScrollingText &text, int32 ticks);
+	void update(int32 ticks);
+
+	void redraw();
+
+	// -- File data --
+	int32 _startDepth = 0;			// how far below the top of the rect the text starts
+	Common::Rect _bounds;
+	int32 _intervalTicks = 0;		// between two entries starting
+	float _speed = 0;				// pixels per tick; negative scrolls up
+	uint32 _fontID = 0;
+	uint32 _colorID = 0;
+	Common::Array<Common::String> _keys;
+	FlagDescription _doneFlag;
+
+	// -- Runtime state --
+	uint _nextKey = 0;
+	int32 _intervalCounter = 0;
+	Common::Array<ScrollingText *> _texts;
+	uint32 _lastTickTime = 0;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_AUTOTEXTSCROLL_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index eb551174d9a..57011133df3 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -6,6 +6,7 @@ MODULE_OBJS = \
   action/actionzone.o \
   action/arfactory.o \
   action/autotext.o \
+  action/autotextscroll.o \
   action/cameraaction.o \
   action/datarecords.o \
   action/inventoryrecords.o \


Commit: 926fd1a2411f32ac59297e3520974d63bf43ffe0
    https://github.com/scummvm/scummvm/commit/926fd1a2411f32ac59297e3520974d63bf43ffe0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:38+03:00

Commit Message:
NANCY: NANCY16: Fix regression with the pixel format used

This caused the subtitles to not show and the UI buttons to be drawn
with the wrong color

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


diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index 6d543e4c9c5..10f4cec5651 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -34,7 +34,7 @@
 namespace Nancy {
 
 // The ARGB colors in the game data are 32-bit ARGB pixels
-static const Graphics::PixelFormat kARGBFormat = Graphics::PixelFormat::createFormatARGB32();
+const Graphics::PixelFormat kARGBFormat(4, 8, 8, 8, 8, 16, 8, 0, 24);
 
 uint32 argbToColor(const Graphics::PixelFormat &format, uint32 argb) {
 	byte a, r, g, b;
diff --git a/engines/nancy/graphics.h b/engines/nancy/graphics.h
index 37dffc43f37..e0c83dc8b45 100644
--- a/engines/nancy/graphics.h
+++ b/engines/nancy/graphics.h
@@ -33,6 +33,9 @@ namespace Nancy {
 class IFF;
 class RenderObject;
 
+// Packed 0xAARRGGBB colors, independent of the byte order in memory
+extern const Graphics::PixelFormat kARGBFormat;
+
 // An ARGB color, as a pixel of the given format
 uint32 argbToColor(const Graphics::PixelFormat &format, uint32 argb);
 
diff --git a/engines/nancy/ui/ndui/saveload.cpp b/engines/nancy/ui/ndui/saveload.cpp
index c5ca203e777..7b66dfdb5b2 100644
--- a/engines/nancy/ui/ndui/saveload.cpp
+++ b/engines/nancy/ui/ndui/saveload.cpp
@@ -283,7 +283,7 @@ void Manager::onGameWindowAction(const Action &action) {
 void Manager::applyMatteColor() {
 	uint32 color = 0xFF000000;
 	if (ConfMan.hasKey("matte_color")) {
-		color = g_nancy->_graphics->getFontColor(ConfMan.getInt("matte_color"), Graphics::PixelFormat::createFormatARGB32());
+		color = g_nancy->_graphics->getFontColor(ConfMan.getInt("matte_color"), kARGBFormat);
 	}
 
 	NancySceneState.getFrame().setBlankColor(color);


Commit: 6e4ea17fd4ed1060090743566402df5b1d6b15d8
    https://github.com/scummvm/scummvm/commit/6e4ea17fd4ed1060090743566402df5b1d6b15d8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:41+03:00

Commit Message:
NANCY: NANCY16: Implement FoxGeesePuzzle

In this mini game, he player moves the geese to trap a computer
controlled fox, which captures geese by jumping over them

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 2aeb85ed325..a4edd3088e1 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -58,7 +58,7 @@
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
 #include "engines/nancy/action/puzzle/escapegridpuzzle.h"
 //#include "engines/nancy/action/puzzle/fishingpuzzle.h"
-//#include "engines/nancy/action/puzzle/foxgeesepuzzle.h"
+#include "engines/nancy/action/puzzle/foxgeesepuzzle.h"
 #include "engines/nancy/action/puzzle/gridmappuzzle.h"
 #include "engines/nancy/action/puzzle/magicboxpuzzle.h"
 #include "engines/nancy/action/puzzle/matchpuzzle.h"
@@ -691,7 +691,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return nullptr;
 	// -- Nancy16 new puzzles (types 186-199) --
 	case 186:
-		return nullptr;	//return new FoxGeesePuzzle();	// TODO
+		return new FoxGeesePuzzle();
 	case 189:
 		return new QuizPuzzle();		// Moved from 231
 	case 192:
diff --git a/engines/nancy/action/puzzle/foxgeesepuzzle.cpp b/engines/nancy/action/puzzle/foxgeesepuzzle.cpp
new file mode 100644
index 00000000000..425924a8728
--- /dev/null
+++ b/engines/nancy/action/puzzle/foxgeesepuzzle.cpp
@@ -0,0 +1,581 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/foxgeesepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const Common::Point kDirections[] = {
+	Common::Point(-1, -1), Common::Point(0, -1), Common::Point(1, -1), Common::Point(1, 0),
+	Common::Point(1, 1), Common::Point(0, 1), Common::Point(-1, 1), Common::Point(-1, 0)
+};
+
+static const Common::Point kNoPosition(-1, -1);
+static const Common::Point kBoardCenter(3, 3);
+
+// The geese win once they're down to this many
+static const uint kMinGeese = 4;
+
+FoxGeesePuzzle::~FoxGeesePuzzle() {
+	if (_isGooseHeld) {
+		g_nancy->_cursor->showCursor(true);
+	}
+}
+
+// The board is a cross made of three rows and three columns of seven holes each
+bool FoxGeesePuzzle::isOnBoard(const Common::Point &pos) {
+	if (pos.x < 0 || pos.y < 0 || pos.x >= kBoardSize || pos.y >= kBoardSize) {
+		return false;
+	}
+
+	return (pos.x >= 2 && pos.x <= 4) || (pos.y >= 2 && pos.y <= 4);
+}
+
+void FoxGeesePuzzle::readGrid(Common::SeekableReadStream &stream, char grid[kBoardSize][kBoardSize]) {
+	for (int y = 0; y < kBoardSize; ++y) {
+		stream.read(grid[y], kBoardSize);
+	}
+}
+
+void FoxGeesePuzzle::readEnding(Common::SeekableReadStream &stream, Ending &ending) {
+	ending.scene._sceneChange.sceneID = stream.readUint16LE();
+	ending.scene._sceneChange.frameID = stream.readUint16LE();
+	ending.scene._sceneChange.continueSceneSound = kContinueSceneSound;
+	ending.scene._flag.label = stream.readSint16LE();
+	ending.scene._flag.flag = stream.readByte();
+	ending.sound.readData(stream);
+}
+
+void FoxGeesePuzzle::readData(Common::SeekableReadStream &stream) {
+	if (stream.readUint16LE() != kNumCells) {
+		error("FoxGeesePuzzle: the board needs %d cells", kNumCells);
+	}
+
+	for (int y = 0; y < kBoardSize; ++y) {
+		for (int x = 0; x < kBoardSize; ++x) {
+			if (isOnBoard(Common::Point(x, y))) {
+				readRect(stream, _cellDests[y][x]);
+			}
+		}
+	}
+
+	readFilename(stream, _imageName);
+	readRect(stream, _foxSrc);
+	readRect(stream, _gooseSrc);
+	_heldGooseHotspot.x = stream.readSint32LE();
+	_heldGooseHotspot.y = stream.readSint32LE();
+
+	// The starting position, with an 'F' for the fox and a 'G' for each goose
+	char grid[kBoardSize][kBoardSize];
+	readGrid(stream, grid);
+	uint numFoxes = 0;
+	for (int y = 0; y < kBoardSize; ++y) {
+		for (int x = 0; x < kBoardSize; ++x) {
+			if (!isOnBoard(Common::Point(x, y))) {
+				continue;
+			}
+
+			if (grid[y][x] == 'F') {
+				_startPieces[y][x] = kFox;
+				++numFoxes;
+			} else if (grid[y][x] == 'G') {
+				_startPieces[y][x] = kGoose;
+			}
+		}
+	}
+
+	if (numFoxes != 1) {
+		error("FoxGeesePuzzle: the board needs exactly one fox");
+	}
+
+	_foxTurnCursor = stream.readUint16LE();
+	_heldGooseCursor = stream.readUint16LE();
+	_gooseCursor = stream.readUint16LE();
+	_foxMoveDuration = (uint32)(stream.readFloatLE() * 1000);
+	_foxMovesFirst = stream.readByte() == 1;
+	_isSimpleFox = stream.readByte() == 0;
+
+	_gooseMoveSound.readData(stream);
+	_foxMoveSound.readData(stream);
+	_foxJumpSound.readData(stream);
+
+	// Each win ending marks the fox positions it applies to with a '*'
+	_winEndings.resize(stream.readSint16LE());
+	for (WinEnding &win : _winEndings) {
+		readGrid(stream, grid);
+		for (int y = 0; y < kBoardSize; ++y) {
+			for (int x = 0; x < kBoardSize; ++x) {
+				if (grid[y][x] == '*' && isOnBoard(Common::Point(x, y))) {
+					win.foxPositions.push_back(Common::Point(x, y));
+				}
+			}
+		}
+
+		readEnding(stream, win.ending);
+	}
+
+	readEnding(stream, _defaultWinEnding);
+	readEnding(stream, _loseEnding);
+
+	readExitHotspot(stream);
+}
+
+void FoxGeesePuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	memcpy(_pieces, _startPieces, sizeof(_pieces));
+
+	memset(_links, 0, sizeof(_links));
+	for (int i = 0; i < kBoardSize; ++i) {
+		// Columns and rows
+		bool isArm = i < 2 || i > 4;
+		addLine(Common::Point(i, isArm ? 2 : 0), Common::Point(i, isArm ? 4 : 6));
+		addLine(Common::Point(isArm ? 2 : 0, i), Common::Point(isArm ? 4 : 6, i));
+	}
+
+	// Diagonals
+	addLine(Common::Point(0, 4), Common::Point(4, 0));
+	addLine(Common::Point(2, 4), Common::Point(4, 2));
+	addLine(Common::Point(2, 6), Common::Point(6, 2));
+	addLine(Common::Point(0, 2), Common::Point(4, 6));
+	addLine(Common::Point(2, 2), Common::Point(4, 4));
+	addLine(Common::Point(2, 0), Common::Point(6, 4));
+
+	_isFoxTurn = _foxMovesFirst;
+
+	NancySceneState.setNoHeldItem();
+	redraw();
+}
+
+// Connects every pair of neighboring holes along a straight or diagonal line
+void FoxGeesePuzzle::addLine(const Common::Point &from, const Common::Point &to) {
+	Common::Point step(CLIP<int16>(to.x - from.x, -1, 1), CLIP<int16>(to.y - from.y, -1, 1));
+	int dir = 0;
+	while (kDirections[dir] != step) {
+		++dir;
+	}
+
+	int reverseDir = (dir + kNumDirections / 2) % kNumDirections;
+	for (Common::Point pos = from; pos != to; pos += step) {
+		Common::Point next = pos + step;
+		_links[pos.y][pos.x] |= 1 << dir;
+		_links[next.y][next.x] |= 1 << reverseDir;
+	}
+}
+
+bool FoxGeesePuzzle::cellAt(const Common::Point &mousePos, Common::Point &pos) const {
+	for (int y = 0; y < kBoardSize; ++y) {
+		for (int x = 0; x < kBoardSize; ++x) {
+			if (isOnBoard(Common::Point(x, y)) && _cellDests[y][x].contains(mousePos)) {
+				pos = Common::Point(x, y);
+				return true;
+			}
+		}
+	}
+
+	return false;
+}
+
+// Moves go to a connected empty hole. Jumps go over a connected piece, onto the
+// empty hole after it on the same line.
+void FoxGeesePuzzle::getMoves(const Common::Point &pos, Common::Array<Common::Point> &moves,
+		Common::Array<Jump> *jumps, Common::Array<Common::Point> *neighbors) const {
+	for (int dir = 0; dir < kNumDirections; ++dir) {
+		if (!(_links[pos.y][pos.x] & (1 << dir))) {
+			continue;
+		}
+
+		Common::Point next = pos + kDirections[dir];
+		if (neighbors) {
+			neighbors->push_back(next);
+		}
+
+		if (pieceAt(next) == kEmpty) {
+			moves.push_back(next);
+		} else if (jumps && (_links[next.y][next.x] & (1 << dir))) {
+			Common::Point landing = next + kDirections[dir];
+			if (pieceAt(landing) == kEmpty) {
+				Jump jump;
+				jump.over = next;
+				jump.to = landing;
+				jumps->push_back(jump);
+			}
+		}
+	}
+}
+
+uint FoxGeesePuzzle::countGeese() const {
+	uint count = 0;
+	for (int y = 0; y < kBoardSize; ++y) {
+		for (int x = 0; x < kBoardSize; ++x) {
+			if (_pieces[y][x] == kGoose) {
+				++count;
+			}
+		}
+	}
+
+	return count;
+}
+
+// Returns false when the fox is trapped. Otherwise, fills in the move's destination, and
+// the goose it jumps over, if any; a destination off the board means the fox ends its turn.
+bool FoxGeesePuzzle::findFoxMove(Common::Point &to, Common::Point &over) {
+	Common::Array<Common::Point> moves;
+	Common::Array<Jump> jumps;
+	getMoves(_foxFrom, moves, &jumps);
+
+	if (moves.empty() && jumps.empty()) {
+		return false;
+	}
+
+	to = kNoPosition;
+	over = kNoPosition;
+
+	Common::RandomSource *rnd = g_nancy->_randomSource;
+	if (_isFoxChainingJumps) {
+		if (!jumps.empty()) {
+			const Jump &jump = jumps[rnd->getRandomNumber(jumps.size() - 1)];
+			to = jump.to;
+			over = jump.over;
+		}
+	} else if (_isSimpleFox || rnd->getRandomNumber(9) >= 2) {
+		if (!jumps.empty()) {
+			const Jump &jump = jumps[rnd->getRandomNumber(jumps.size() - 1)];
+			to = jump.to;
+			over = jump.over;
+		} else if (_isSimpleFox || (!findMostMobileMove(moves, to) && !findMoveNearCenter(moves, to))) {
+			to = moves[rnd->getRandomNumber(moves.size() - 1)];
+		}
+	} else {
+		// Occasionally, any move goes
+		uint choice = rnd->getRandomNumber(jumps.size() + moves.size() - 1);
+		if (choice < jumps.size()) {
+			to = jumps[choice].to;
+			over = jumps[choice].over;
+		} else {
+			to = moves[choice - jumps.size()];
+		}
+	}
+
+	return true;
+}
+
+// Picks one of the moves that leave the fox with the most moves afterwards,
+// unless they all do equally well
+bool FoxGeesePuzzle::findMostMobileMove(const Common::Array<Common::Point> &moves, Common::Point &to) const {
+	Common::Array<Common::Point> best;
+	uint bestCount = 0;
+	for (const Common::Point &move : moves) {
+		Common::Array<Common::Point> nextMoves;
+		getMoves(move, nextMoves);
+		if (nextMoves.size() > bestCount) {
+			best.clear();
+		}
+
+		if (nextMoves.size() >= bestCount) {
+			best.push_back(move);
+			bestCount = nextMoves.size();
+		}
+	}
+
+	if (best.size() == moves.size()) {
+		return false;
+	}
+
+	to = best[g_nancy->_randomSource->getRandomNumber(best.size() - 1)];
+	return true;
+}
+
+// Picks one of the moves next to the center of the board
+bool FoxGeesePuzzle::findMoveNearCenter(const Common::Array<Common::Point> &moves, Common::Point &to) const {
+	Common::Array<Common::Point> candidates;
+	for (const Common::Point &move : moves) {
+		Common::Array<Common::Point> nextMoves;
+		Common::Array<Common::Point> neighbors;
+		getMoves(move, nextMoves, nullptr, &neighbors);
+		for (const Common::Point &neighbor : neighbors) {
+			if (neighbor == kBoardCenter) {
+				candidates.push_back(move);
+				break;
+			}
+		}
+	}
+
+	if (candidates.empty()) {
+		return false;
+	}
+
+	to = candidates[g_nancy->_randomSource->getRandomNumber(candidates.size() - 1)];
+	return true;
+}
+
+void FoxGeesePuzzle::startFoxTurn() {
+	for (int y = 0; y < kBoardSize; ++y) {
+		for (int x = 0; x < kBoardSize; ++x) {
+			if (_pieces[y][x] == kFox) {
+				_foxFrom = Common::Point(x, y);
+			}
+		}
+	}
+
+	Common::Point to, over;
+	if (!findFoxMove(to, over)) {
+		// The fox is trapped; the ending may depend on where
+		for (const WinEnding &win : _winEndings) {
+			for (const Common::Point &pos : win.foxPositions) {
+				if (pos == _foxFrom) {
+					startEnding(win.ending);
+					return;
+				}
+			}
+		}
+
+		startEnding(_defaultWinEnding);
+		return;
+	}
+
+	if (!isOnBoard(to)) {
+		_isFoxChainingJumps = false;
+		_isFoxTurn = false;
+		return;
+	}
+
+	_foxTo = to;
+	_captured = over;
+	_isFoxMoving = true;
+	_foxMoveStart = g_nancy->getTotalPlayTime();
+
+	// After a jump, the fox gets to keep jumping
+	if (isOnBoard(over)) {
+		playSoundBlock(_foxJumpSound);
+		_isFoxChainingJumps = true;
+	} else {
+		playSoundBlock(_foxMoveSound);
+		_isFoxTurn = false;
+	}
+}
+
+void FoxGeesePuzzle::finishFoxMove() {
+	_isFoxMoving = false;
+	pieceAt(_foxFrom) = kEmpty;
+	pieceAt(_foxTo) = kFox;
+	if (isOnBoard(_captured)) {
+		pieceAt(_captured) = kEmpty;
+		_captured = kNoPosition;
+	}
+
+	if (countGeese() <= kMinGeese) {
+		startEnding(_loseEnding);
+	}
+}
+
+void FoxGeesePuzzle::placeGoose(const Common::Point &mousePos) {
+	_isGooseHeld = false;
+	g_nancy->_cursor->showCursor(true);
+
+	Common::Point pos;
+	if (cellAt(mousePos, pos)) {
+		Common::Array<Common::Point> moves;
+		getMoves(_heldGooseFrom, moves);
+		for (const Common::Point &move : moves) {
+			if (move == pos) {
+				pieceAt(pos) = kGoose;
+				playSoundBlock(_gooseMoveSound);
+				_isFoxTurn = true;
+				return;
+			}
+		}
+	}
+
+	pieceAt(_heldGooseFrom) = kGoose;
+}
+
+void FoxGeesePuzzle::startEnding(const Ending &ending) {
+	_isEnding = true;
+	_endScene = ending.scene;
+	_endSound = playSoundBlock(ending.sound);
+}
+
+void FoxGeesePuzzle::setCursor(const Common::Point &mousePos) {
+	if (_isFoxTurn || _isFoxMoving) {
+		setDataCursor(_foxTurnCursor, false);
+		return;
+	}
+
+	Common::Point pos;
+	bool isOverCell = cellAt(mousePos, pos);
+	if (_isGooseHeld) {
+		bool canMove = false;
+		if (isOverCell) {
+			Common::Array<Common::Point> moves;
+			getMoves(_heldGooseFrom, moves);
+			for (const Common::Point &move : moves) {
+				if (move == pos) {
+					canMove = true;
+					break;
+				}
+			}
+		}
+
+		setDataCursor(_heldGooseCursor, canMove);
+	} else if (isOverCell && pieceAt(pos) == kGoose) {
+		setDataCursor(_gooseCursor);
+	}
+}
+
+void FoxGeesePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (int y = 0; y < kBoardSize; ++y) {
+		for (int x = 0; x < kBoardSize; ++x) {
+			Common::Point pos(x, y);
+			if (_isFoxMoving && (pos == _foxFrom || pos == _captured)) {
+				continue;
+			}
+
+			if (_pieces[y][x] != kEmpty) {
+				_drawSurface.blitFrom(_image, _pieces[y][x] == kFox ? _foxSrc : _gooseSrc, _cellDests[y][x].origin());
+			}
+		}
+	}
+
+	// The moving fox slides between holes, while the goose it jumps over fades out
+	if (_isFoxMoving) {
+		float progress = _foxMoveDuration ? (float)(g_nancy->getTotalPlayTime() - _foxMoveStart) / _foxMoveDuration : 1.0f;
+		progress = CLIP(progress, 0.0f, 1.0f);
+
+		if (isOnBoard(_captured)) {
+			_drawSurface.transBlitFrom(_image, _gooseSrc, cellDest(_captured).origin(), _image.getTransparentColor(),
+				false, (uint32)((1.0f - progress) * 255));
+		}
+
+		const Common::Rect &from = cellDest(_foxFrom);
+		const Common::Rect &to = cellDest(_foxTo);
+		Common::Point foxPos(from.left + (int16)((to.left - from.left) * progress),
+			from.top + (int16)((to.top - from.top) * progress));
+		_drawSurface.blitFrom(_image, _foxSrc, foxPos);
+	}
+
+	// A held goose takes the place of the cursor
+	if (_isGooseHeld) {
+		_drawSurface.blitFrom(_image, _gooseSrc, _mousePos - _heldGooseHotspot);
+	}
+
+	_needsRedraw = true;
+}
+
+void FoxGeesePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_isEnding) {
+			if (_endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound)) {
+				_state = kActionTrigger;
+			}
+		} else if (_isFoxMoving) {
+			if (g_nancy->getTotalPlayTime() - _foxMoveStart >= _foxMoveDuration) {
+				finishFoxMove();
+			}
+		} else if (_isFoxTurn) {
+			startFoxTurn();
+		}
+
+		redraw();
+		break;
+	case kActionTrigger:
+		if (_exitRequested) {
+			_exitScene.execute();
+		} else {
+			_endScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void FoxGeesePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _isEnding || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	_mousePos = mouseRect.origin();
+
+	setCursor(_mousePos);
+
+	if (!_isGooseHeld && hoverExitHotspot(input)) {
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	if (_isFoxTurn || _isFoxMoving || !(input.input & NancyInput::kLeftMouseButtonUp)) {
+		return;
+	}
+
+	if (_isGooseHeld) {
+		placeGoose(_mousePos);
+	} else {
+		Common::Point pos;
+		if (cellAt(_mousePos, pos) && pieceAt(pos) == kGoose) {
+			pieceAt(pos) = kEmpty;
+			_heldGooseFrom = pos;
+			_isGooseHeld = true;
+			g_nancy->_cursor->showCursor(false);
+		}
+	}
+
+	input.eatMouseInput();
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/foxgeesepuzzle.h b/engines/nancy/action/puzzle/foxgeesepuzzle.h
new file mode 100644
index 00000000000..21ff1a036c1
--- /dev/null
+++ b/engines/nancy/action/puzzle/foxgeesepuzzle.h
@@ -0,0 +1,147 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_FOXGEESEPUZZLE_H
+#define NANCY_ACTION_FOXGEESEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 Fox and Geese board game (AR 186), played on the 33-hole cross-shaped board.
+// The player moves the geese, and wins by trapping the computer-controlled fox, which
+// moves along the board lines and captures geese by jumping over them.
+class FoxGeesePuzzle : public PuzzleRecord {
+public:
+	FoxGeesePuzzle() : PuzzleRecord(7) {}
+	virtual ~FoxGeesePuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "FoxGeesePuzzle"; }
+
+	static const int kBoardSize = 7;
+	static const int kNumCells = 33;
+	static const int kNumDirections = 8;
+
+	enum Piece : byte {
+		kEmpty,
+		kGoose,
+		kFox
+	};
+
+	struct Jump {
+		Common::Point over;
+		Common::Point to;
+	};
+
+	struct Ending {
+		SceneChangeWithFlag scene;
+		RandomSoundBlock sound;
+	};
+
+	struct WinEnding {
+		Common::Array<Common::Point> foxPositions;
+		Ending ending;
+	};
+
+	static bool isOnBoard(const Common::Point &pos);
+	static void readGrid(Common::SeekableReadStream &stream, char grid[kBoardSize][kBoardSize]);
+	static void readEnding(Common::SeekableReadStream &stream, Ending &ending);
+
+	void addLine(const Common::Point &from, const Common::Point &to);
+	Piece &pieceAt(const Common::Point &pos) { return _pieces[pos.y][pos.x]; }
+	Piece pieceAt(const Common::Point &pos) const { return _pieces[pos.y][pos.x]; }
+	bool cellAt(const Common::Point &mousePos, Common::Point &pos) const;
+	const Common::Rect &cellDest(const Common::Point &pos) const { return _cellDests[pos.y][pos.x]; }
+
+	void getMoves(const Common::Point &pos, Common::Array<Common::Point> &moves,
+		Common::Array<Jump> *jumps = nullptr, Common::Array<Common::Point> *neighbors = nullptr) const;
+	uint countGeese() const;
+	bool findFoxMove(Common::Point &to, Common::Point &over);
+	bool findMostMobileMove(const Common::Array<Common::Point> &moves, Common::Point &to) const;
+	bool findMoveNearCenter(const Common::Array<Common::Point> &moves, Common::Point &to) const;
+
+	void startFoxTurn();
+	void finishFoxMove();
+	void placeGoose(const Common::Point &mousePos);
+	void startEnding(const Ending &ending);
+	void setCursor(const Common::Point &mousePos);
+
+	void redraw();
+
+	// -- File data --
+	Common::Rect _cellDests[kBoardSize][kBoardSize];
+	Piece _startPieces[kBoardSize][kBoardSize] = {};
+	Common::Rect _foxSrc;
+	Common::Rect _gooseSrc;
+	Common::Point _heldGooseHotspot;
+
+	uint16 _foxTurnCursor = 0;
+	uint16 _heldGooseCursor = 0;
+	uint16 _gooseCursor = 0;
+	uint32 _foxMoveDuration = 0;		// ms
+	bool _foxMovesFirst = false;
+	bool _isSimpleFox = false;			// only takes jumps and moves at random
+
+	RandomSoundBlock _gooseMoveSound;
+	RandomSoundBlock _foxMoveSound;
+	RandomSoundBlock _foxJumpSound;
+
+	Common::Array<WinEnding> _winEndings;
+	Ending _defaultWinEnding;
+	Ending _loseEnding;
+
+	// -- Runtime state --
+	byte _links[kBoardSize][kBoardSize] = {};	// bitmask of the directions connected by a board line
+	Piece _pieces[kBoardSize][kBoardSize] = {};
+
+	bool _isFoxTurn = false;
+	bool _isFoxMoving = false;
+	bool _isFoxChainingJumps = false;
+	Common::Point _foxFrom;
+	Common::Point _foxTo;
+	Common::Point _captured;
+	uint32 _foxMoveStart = 0;
+
+	bool _isGooseHeld = false;
+	Common::Point _heldGooseFrom;
+	Common::Point _mousePos;
+
+	bool _isEnding = false;
+	bool _exitRequested = false;
+	SceneChangeWithFlag _endScene;
+	SoundDescription _endSound;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_FOXGEESEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 57011133df3..81ab711761b 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -41,6 +41,7 @@ MODULE_OBJS = \
   action/puzzle/drivingpuzzle.o \
   action/puzzle/dropsortpuzzle.o \
   action/puzzle/escapegridpuzzle.o \
+  action/puzzle/foxgeesepuzzle.o \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \
   action/puzzle/hangmanpuzzle.o \


Commit: 0443e04cd02f1b636d415a50556b6bfd0ec680b5
    https://github.com/scummvm/scummvm/commit/0443e04cd02f1b636d415a50556b6bfd0ec680b5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:43+03:00

Commit Message:
NANCY: NANCY16: Read the z order of movies

Animated characters are no longer covered by overlays drawn under them,
e.g. in scene 3710

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


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 00c8df4435b..f86ea44c7d3 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -288,7 +288,11 @@ void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Com
 
 	ser.syncAsUint16LE(_videoFormat);
 	_videoFormat = kLargeVideoFormat;
-	ser.skip(2);	// Visibility frame ID; ScummVM drives visibility from the videoDescs
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		ser.syncAsUint16LE(_z);
+	} else {
+		ser.skip(2);	// Visibility frame ID; ScummVM drives visibility from the videoDescs
+	}
 	ser.syncAsUint16LE(_randomPlayerCursorAllowed);
 	ser.skip(2);	// Event flag gating the roll for a next sequence; -1 = always roll
 	// Talkable character: the cursor shown while the mouse is over it, and the
@@ -721,7 +725,12 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 	ser.syncAsUint16LE(_videoFormat);
 	_videoFormat = kLargeVideoFormat;
 
-	ser.skip(2);	// Visibility frame ID; ScummVM drives visibility from the videoDescs instead
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// Sorted together with the overlays, e.g. shadows under the characters
+		ser.syncAsUint16LE(_z);
+	} else {
+		ser.skip(2);	// Visibility frame ID; ScummVM drives visibility from the videoDescs instead
+	}
 	ser.syncAsUint16LE(_playerCursorAllowed);
 	ser.syncAsUint16LE(_hideOnFinish);
 


Commit: db34fbb3137c4575c09a8007db0bff5c619190c4
    https://github.com/scummvm/scummvm/commit/db34fbb3137c4575c09a8007db0bff5c619190c4
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:45+03:00

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

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


diff --git a/engines/nancy/action/puzzle/turningpuzzle.cpp b/engines/nancy/action/puzzle/turningpuzzle.cpp
index 6804ba7807b..97afbbd9c57 100644
--- a/engines/nancy/action/puzzle/turningpuzzle.cpp
+++ b/engines/nancy/action/puzzle/turningpuzzle.cpp
@@ -205,9 +205,21 @@ void TurningPuzzle::readDataNancy13(Common::SeekableReadStream &stream) {
 	_hitInset = stream.readUint16LE();			// 0x25
 	_turnFlagLabel = stream.readSint16LE();		// 0x27
 	_turnFlagValue = stream.readByte();			// 0x29
-	_solveScene._sceneChange.sceneID = stream.readUint16LE();	// 0x2a
-	_solveScene._flag.label = stream.readSint16LE();			// 0x2c
-	_solveScene._flag.flag = stream.readByte();					// 0x2e
+
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// Each alternative solution leads to a scene of its own
+		uint16 numSolveScenes = stream.readUint16LE();
+		_solveScenes.resize(numSolveScenes);
+		for (SceneChangeWithFlag &solveScene : _solveScenes) {
+			solveScene._sceneChange.sceneID = stream.readUint16LE();
+			solveScene._flag.label = stream.readSint16LE();
+			solveScene._flag.flag = stream.readByte();
+		}
+	} else {
+		_solveScene._sceneChange.sceneID = stream.readUint16LE();	// 0x2a
+		_solveScene._flag.label = stream.readSint16LE();			// 0x2c
+		_solveScene._flag.flag = stream.readByte();					// 0x2e
+	}
 
 	// A count-prefixed array of 23-byte hotspot records (as in PegsPuzzle);
 	// the first one is the "give up" hotspot.
@@ -231,10 +243,6 @@ void TurningPuzzle::readDataNancy13(Common::SeekableReadStream &stream) {
 	_pieceTypeIDs.resize(numObjects);
 	_startPositions.resize(numObjects);
 	_destRects.resize(numObjects);
-	_correctOrders.resize(3);
-	for (uint n = 0; n < 3; ++n) {
-		_correctOrders[n].resize(numObjects);
-	}
 
 	for (uint i = 0; i < numObjects; ++i) {
 		uint16 numLinks = stream.readUint16LE();
@@ -248,7 +256,14 @@ void TurningPuzzle::readDataNancy13(Common::SeekableReadStream &stream) {
 
 		_pieceTypeIDs[i] = stream.readUint16LE();
 		_startPositions[i] = stream.readUint16LE();
-		for (uint n = 0; n < 3; ++n) {
+
+		// Nancy16 stores as many solutions as there are solve scenes
+		uint16 numOrders = g_nancy->getGameType() >= kGameTypeNancy16 ? stream.readUint16LE() : 3;
+		if (numOrders > _correctOrders.size()) {
+			_correctOrders.resize(numOrders, Common::Array<uint16>(numObjects, 0xFFFF));
+		}
+
+		for (uint n = 0; n < numOrders; ++n) {
 			_correctOrders[n][i] = stream.readUint16LE();
 		}
 		readRect(stream, _destRects[i]);
@@ -307,8 +322,12 @@ bool TurningPuzzle::isSolved() const {
 		return _currentOrder == _correctOrder;
 	}
 
-	// Any one of the (up to three) alternative orders solves it. Unused solutions are
-	// filled with 0xffff, which no face can ever match.
+	return getSolvedOrder() != -1;
+}
+
+int TurningPuzzle::getSolvedOrder() const {
+	// Any one of the alternative orders solves it. Unused solutions are filled
+	// with 0xffff, which no face can ever match.
 	for (uint n = 0; n < _correctOrders.size(); ++n) {
 		bool match = true;
 		for (uint i = 0; i < _currentOrder.size(); ++i) {
@@ -319,11 +338,11 @@ bool TurningPuzzle::isSolved() const {
 		}
 
 		if (match) {
-			return true;
+			return n;
 		}
 	}
 
-	return false;
+	return -1;
 }
 
 void TurningPuzzle::drawAllObjects() {
@@ -436,6 +455,14 @@ void TurningPuzzle::execute() {
 
 		if (isSolved()) {
 			_state = kActionTrigger;
+
+			if (!_solveScenes.empty()) {
+				uint order = getSolvedOrder();
+				if (order < _solveScenes.size()) {
+					_solveScene = _solveScenes[order];
+				}
+			}
+
 			if (g_nancy->getGameType() >= kGameTypeNancy13) {
 				_solveSound = playSoundBlock(_solveSoundBlock);
 				_solveState = kWaitForSound;
diff --git a/engines/nancy/action/puzzle/turningpuzzle.h b/engines/nancy/action/puzzle/turningpuzzle.h
index 1cc679f2970..ee759ee439f 100644
--- a/engines/nancy/action/puzzle/turningpuzzle.h
+++ b/engines/nancy/action/puzzle/turningpuzzle.h
@@ -72,6 +72,7 @@ protected:
 
 	void readDataNancy13(Common::SeekableReadStream &stream);
 	bool isSolved() const;
+	int getSolvedOrder() const;
 	void drawOverlay(bool advanceFrames);
 	uint numFacesOf(uint objectID) const;
 	uint framesPerTurnOf(uint objectID) const;
@@ -106,6 +107,7 @@ protected:
 	Common::Array<PieceType> _pieceTypes;
 	Common::Array<uint16> _pieceTypeIDs;					// per object
 	Common::Array<Common::Array<uint16>> _correctOrders;	// up to three alternative solutions
+	Common::Array<SceneChangeWithFlag> _solveScenes;		// Nancy16: one per alternative solution
 	uint16 _turnDelay = 0;			// header 0x21 - length of a whole turn, in ms
 	uint16 _hoverCursorType = 0;	// header 0x23 - raw Nancy13 cursor id (a turn cursor)
 	uint16 _hitInset = 0;			// header 0x25 - hotspots are the dest rect shrunk by this


Commit: 276607c66a0d5efa969956847c8f8c21402170b1
    https://github.com/scummvm/scummvm/commit/276607c66a0d5efa969956847c8f8c21402170b1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:48+03:00

Commit Message:
NANCY: NANCY16: Handle new data layout in RotatingLockPuzzle

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


diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
index 1e582351e84..8ac6aa98c34 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
@@ -28,6 +28,7 @@
 #include "engines/nancy/input.h"
 #include "engines/nancy/util.h"
 
+#include "engines/nancy/action/soundrecords.h"
 #include "engines/nancy/action/puzzle/rotatinglockpuzzle.h"
 
 #include "engines/nancy/state/scene.h"
@@ -45,6 +46,11 @@ void RotatingLockPuzzle::init() {
 }
 
 void RotatingLockPuzzle::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		readDataNancy16(stream);
+		return;
+	}
+
 	const bool isNancy10 = g_nancy->getGameType() >= kGameTypeNancy10;
 
 	readFilename(stream, _imageName);
@@ -157,6 +163,56 @@ void RotatingLockPuzzle::readData(Common::SeekableReadStream &stream) {
 	}
 }
 
+// Nancy16 dropped the fixed-size slots in favor of arrays sized by the icon and dial counts
+void RotatingLockPuzzle::readDataNancy16(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+
+	_iconsPerDial = stream.readUint16LE();
+	readRectArray(stream, _srcRects, _iconsPerDial);
+
+	uint16 numDials = stream.readUint16LE();
+	readRectArray(stream, _destRects, numDials);
+	readRectArray(stream, _upHotspots, numDials);
+	readRectArray(stream, _downHotspots, numDials);
+
+	for (uint i = 0; i < numDials; ++i) {
+		if (i == 0) {
+			_screenPosition = _destRects[i];
+		} else {
+			_screenPosition.extend(_destRects[i]);
+		}
+	}
+
+	_correctSequence.resize(numDials);
+	stream.read(_correctSequence.data(), numDials);
+	_startSequence.resize(numDials);
+	stream.read(_startSequence.data(), numDials);
+
+	int16 upType = stream.readSint16LE();
+	int16 downType = stream.readSint16LE();
+	if (upType != 0) {
+		_upCursorType = (CursorManager::CursorType)upType;
+	}
+
+	if (downType != 0) {
+		_downCursorType = (CursorManager::CursorType)downType;
+	}
+
+	Common::String caption;
+	readMultiNameSound(stream, _clickSound, caption);
+
+	_solveCondition.label = stream.readSint16LE();
+	_solveCondition.flag = stream.readByte();
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	readMultiNameSound(stream, _solveSound, caption);
+
+	readExitHotspot(stream);
+}
+
 void RotatingLockPuzzle::execute() {
 	const bool isNancy10 = g_nancy->getGameType() >= kGameTypeNancy10;
 
@@ -196,6 +252,10 @@ void RotatingLockPuzzle::execute() {
 				}
 			}
 
+			if (g_nancy->getGameType() >= kGameTypeNancy16 && !NancySceneState.getEventFlag(_solveCondition)) {
+				return;
+			}
+
 			_solveSoundPlayTime = g_nancy->getTotalPlayTime() + _solveSoundDelay * 1000;
 			_solveState = kPlaySound;
 			// fall through
diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.h b/engines/nancy/action/puzzle/rotatinglockpuzzle.h
index 2c85332da90..aed0a5868f3 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.h
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.h
@@ -56,6 +56,8 @@ public:
 	CursorManager::CursorType _upCursorType = CursorManager::kMoveUp;
 	CursorManager::CursorType _downCursorType = CursorManager::kMoveDown;
 	SoundDescription _clickSound;
+	// Nancy16+: an event flag that also has to be set for the puzzle to count as solved
+	FlagDescription _solveCondition;
 
 	SolveState _solveState = kNotSolved;
 	Common::Array<byte> _currentSequence;
@@ -66,6 +68,7 @@ public:
 protected:
 	Common::String getRecordTypeName() const override { return "RotatingLockPuzzle"; }
 
+	void readDataNancy16(Common::SeekableReadStream &stream);
 	void drawDial(uint id);
 };
 


Commit: 1be97e00c0a0fbbd8374d1a792e01afc5ac15407
    https://github.com/scummvm/scummvm/commit/1be97e00c0a0fbbd8374d1a792e01afc5ac15407
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:50+03:00

Commit Message:
NANCY: NANCY16: Implement new functionality in QuizPuzzle

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


diff --git a/engines/nancy/action/puzzle/quizpuzzle.cpp b/engines/nancy/action/puzzle/quizpuzzle.cpp
index 7744a84a6a6..bed9f9f7a59 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.cpp
+++ b/engines/nancy/action/puzzle/quizpuzzle.cpp
@@ -23,6 +23,7 @@
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/cursor.h"
+#include "engines/nancy/font.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/sound.h"
 #include "engines/nancy/input.h"
@@ -305,9 +306,77 @@ void QuizPuzzle::readDataNancy15(Common::SeekableReadStream &stream) {
 	}
 }
 
+// ---- Nancy 16 data format ----
+// The header grew to 109 bytes: the font is named, the typed text has its own
+// length limit, and the allowed characters moved out after it. The two captions
+// are gone, and the flag-setting hotspots share one cursor.
+void QuizPuzzle::readDataNancy16(Common::SeekableReadStream &stream) {
+	stream.skip(33);	// the record's name
+	_textColor = stream.readUint32BE();
+
+	readFilename(stream, _fontName);
+	_maxTypedLength = stream.readUint16LE();
+	_cursorBlinkInterval = stream.readUint16LE();
+	_cursorChar = stream.readByte();
+	_isDisplayOnly = (_cursorChar == 0);
+	_keepBoxOnWrongAnswer = (stream.readByte() != 0);
+	_autoCheck = (stream.readByte() == 0); // 0 = auto-check ON
+	_cycleSolvedBoxes = (stream.readByte() != 0);
+	stream.skip(66);
+	_hotspotCursor = stream.readUint16LE();
+
+	readFilename(stream, _allowedChars);
+	_invalidKeySound.readData(stream);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_doneSoundBlock.readData(stream);
+
+	readRect(stream, _exitHotspot);
+	_exitCursorType = stream.readUint16LE();
+	_exitCursorFromData = true;
+	_exitScene._sceneChange.sceneID = stream.readUint16LE();
+	_exitScene._flag.label = stream.readSint16LE();
+	_exitScene._flag.flag = g_nancy->_true;
+
+	readExitHotspots(stream, _hotspots);
+
+	// The box count is stored doubled
+	uint16 numBoxes = stream.readUint16LE() / 2;
+
+	if (numBoxes > kMaxBoxes) {
+		warning("QuizPuzzle: numBoxes %d exceeds maximum %d, clamping", numBoxes, (int)kMaxBoxes);
+		numBoxes = kMaxBoxes;
+	}
+
+	_boxes.resize(numBoxes);
+
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		Box &box = _boxes[i];
+		readRect(stream, box.rect);
+
+		uint16 numAnswers = stream.readUint16LE();
+		box.answers.resize(numAnswers);
+		for (uint j = 0; j < numAnswers; ++j) {
+			readFilename(stream, box.answers[j]);
+			box.maxAnswerLength = MAX<uint16>(box.maxAnswerLength, box.answers[j].size());
+		}
+
+		box.correctFlag = stream.readSint16LE();
+		box.nonEmptyFlag = stream.readSint16LE();
+
+		box.correctSound.readData(stream);
+		box.wrongSound.readData(stream);
+	}
+}
+
 void QuizPuzzle::readData(Common::SeekableReadStream &stream) {
 	if (g_nancy->getGameType() == kGameTypeNancy8) {
 		readDataNancy8(stream);
+	} else if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		readDataNancy16(stream);
 	} else if (g_nancy->getGameType() >= kGameTypeNancy15) {
 		readDataNancy15(stream);
 	} else {
@@ -320,6 +389,10 @@ char QuizPuzzle::getCursorChar() const {
 }
 
 uint QuizPuzzle::getMaxTypedLength() const {
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		return _maxTypedLength;
+	}
+
 	return g_nancy->getGameType() >= kGameTypeNancy15 ? kMaxTypedLengthNancy15 : kMaxTypedLength;
 }
 
@@ -810,7 +883,9 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 				continue;
 			}
 
-			if (hotspot.cursorType != 0) {
+			if (g_nancy->getGameType() >= kGameTypeNancy16) {
+				setDataCursor(_hotspotCursor);
+			} else if (hotspot.cursorType != 0) {
 				g_nancy->_cursor->setCursorType((CursorManager::CursorType)hotspot.cursorType, true);
 			} else {
 				g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
@@ -900,6 +975,11 @@ void QuizPuzzle::onPause(bool paused) {
 void QuizPuzzle::drawText() {
 	_drawSurface.clear(g_nancy->_graphics->getTransColor());
 
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		drawTextNancy16();
+		return;
+	}
+
 	const Graphics::Font *font = g_nancy->_graphics->getFont(_fontID);
 	if (!font) {
 		warning("QuizPuzzle: font %u is missing, no answer text will be drawn", _fontID);
@@ -925,6 +1005,32 @@ void QuizPuzzle::drawText() {
 	_needsRedraw = true;
 }
 
+void QuizPuzzle::drawTextNancy16() {
+	const Font *font = g_nancy->_graphics->getFont(_fontName);
+	const Graphics::Font *systemFont = font ? font->getSystemFont() : nullptr;
+	if (!systemFont) {
+		warning("QuizPuzzle: font %s is missing, no answer text will be drawn", _fontName.c_str());
+		return;
+	}
+
+	uint32 color = argbToColor(_drawSurface.format, _textColor | 0xFF000000);
+
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		const Common::String &text = _boxes[i].typedText;
+		if (text.empty())
+			continue;
+
+		Common::Rect bounds = _boxes[i].rect;
+		bounds = NancySceneState.getViewport().convertViewportToScreen(bounds);
+		bounds = convertToLocal(bounds);
+
+		int y = bounds.bottom - systemFont->getFontHeight();
+		systemFont->drawString(_drawSurface.surfacePtr(), text, bounds.left, y, bounds.width(), color);
+	}
+
+	_needsRedraw = true;
+}
+
 void QuizPuzzle::advanceToNextBox() {
 	for (uint n = 0; n < _boxes.size(); ++n) {
 		_currentBox = (_currentBox + 1) % _boxes.size();
diff --git a/engines/nancy/action/puzzle/quizpuzzle.h b/engines/nancy/action/puzzle/quizpuzzle.h
index 8005a82b11b..99a0109eb81 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.h
+++ b/engines/nancy/action/puzzle/quizpuzzle.h
@@ -29,9 +29,10 @@
 namespace Nancy {
 namespace Action {
 
-// Text-entry quiz with multiple text boxes. Three data formats: Nancy 8,
-// Nancy 9 - Nancy 14, and Nancy 15, which rewrote the record around
-// count-prefixed answer lists and RandomSoundBlocks.
+// Text-entry quiz with multiple text boxes. Four data formats: Nancy 8,
+// Nancy 9 - Nancy 14, Nancy 15, which rewrote the record around count-prefixed
+// answer lists and RandomSoundBlocks, and Nancy 16, which draws with a named
+// system font and an explicit text color.
 class QuizPuzzle : public PuzzleRecord {
 public:
 	QuizPuzzle() : PuzzleRecord(7) {}
@@ -83,6 +84,7 @@ private:
 	void readDataNancy8(Common::SeekableReadStream &stream);
 	void readDataNancy9(Common::SeekableReadStream &stream);
 	void readDataNancy15(Common::SeekableReadStream &stream);
+	void readDataNancy16(Common::SeekableReadStream &stream);
 
 	// Format-specific state machines
 	void executeNancy8();
@@ -90,6 +92,7 @@ private:
 
 	// Helpers
 	void drawText();
+	void drawTextNancy16();
 	void advanceToNextBox();
 	bool checkAllSolved() const;
 	// Scores the current box. A silent check only marks the box solved, as the
@@ -142,6 +145,12 @@ private:
 	RandomSoundBlock _invalidKeySound;	// played when a character is not allowed
 	Common::Array<ExitHotspot> _hotspots;	// flag-setting regions around the boxes
 
+	// ---- Data (Nancy 16) ----
+	Common::String _fontName;			// replaces _fontID
+	uint32 _textColor = 0;				// ARGB
+	uint16 _maxTypedLength = 0;
+	uint16 _hotspotCursor = 0;			// over any of _hotspots
+
 	// ---- Runtime state ----
 	uint _currentBox = 0;		// which box currently receives keyboard input
 	bool _solved = false;		// all boxes answered correctly


Commit: 0c87a77ed069e37166b51b9cf6cab57f923d7d1a
    https://github.com/scummvm/scummvm/commit/0c87a77ed069e37166b51b9cf6cab57f923d7d1a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:52+03:00

Commit Message:
NANCY: NANCY16: Go back to the second chance save on losing

Nancy16 has no main menu; LoseGame reloads the save made by
SaveContinueGame instead

Changed paths:
    engines/nancy/action/miscrecords.cpp
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h


diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 08a5a3dfd05..f6b83bc66c0 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -965,6 +965,12 @@ void LoseGame::readData(Common::SeekableReadStream &stream) {
 }
 
 void LoseGame::execute() {
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		NancySceneState.loadSecondChance();
+		_isDone = true;
+		return;
+	}
+
 	g_nancy->_sound->stopAndUnloadSceneSpecificSounds();
 	NancySceneState.setDestroyOnExit();
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 945993d510c..81c0616d43f 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2078,6 +2078,15 @@ void Scene::run() {
 		_actionManager.stopSpeechSounds();
 	}
 
+	if (_isLoadingSecondChance) {
+		_isLoadingSecondChance = false;
+		if (g_nancy->loadGameState(g_nancy->getMetaEngine()->getMaximumSaveSlot()).getCode() == Common::kNoError) {
+			return;
+		}
+
+		warning("Scene: no second chance save to go back to");
+	}
+
 	if (_ndui) {
 		_uiPrepActions.processActionRecords();
 		_ndui->update();
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index ec261b0e518..b1b7f8a14d6 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -121,6 +121,10 @@ public:
 	// Used when winning/losing game
 	void setDestroyOnExit() { _destroyOnExit = true; }
 
+	// Nancy16 has no main menu to go to on losing; the game goes back to the second
+	// chance save instead, once the current records have run
+	void loadSecondChance() { _isLoadingSecondChance = true; }
+
 	bool isRunningAd() const { return _isRunningAd; }
 
 	void changeScene(const SceneChangeDescription &sceneDescription);
@@ -480,6 +484,8 @@ private:
 	// Set when esc skipped a cinematic this frame
 	bool _hasSkippedMovie = false;
 
+	bool _isLoadingSecondChance = false;
+
 	// Contains a screenshot of the Scene state from the last time it was exited
 	Graphics::ManagedSurface _lastScreenshot;
 


Commit: db41cd094cacea6263030c40ee4f5c6697313476
    https://github.com/scummvm/scummvm/commit/db41cd094cacea6263030c40ee4f5c6697313476
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:54+03:00

Commit Message:
NANCY: NANCY16: Implement RowMatchPuzzle

- Record 192, the secret passage: pushing the spare tile into a column
  shifts its tiles down, and the bottom one drops out
- Solved when every column is in order and the spare tile is back in
  place

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index a4edd3088e1..2b5cc33f338 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -91,7 +91,7 @@
 #include "engines/nancy/action/puzzle/riddlepuzzle.h"
 #include "engines/nancy/action/puzzle/rippedletterpuzzle.h"
 #include "engines/nancy/action/puzzle/rotatinglockpuzzle.h"
-//#include "engines/nancy/action/puzzle/rowmatchpuzzle.h"
+#include "engines/nancy/action/puzzle/rowmatchpuzzle.h"
 #include "engines/nancy/action/puzzle/safedialpuzzle.h"
 #include "engines/nancy/action/puzzle/scalepuzzle.h"
 #include "engines/nancy/action/puzzle/setplayerclock.h"
@@ -695,7 +695,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 189:
 		return new QuizPuzzle();		// Moved from 231
 	case 192:
-		return nullptr; // return new RowMatchPuzzle();	// TODO
+		return new RowMatchPuzzle();
 	case 193:
 		return nullptr; // return new FishingPuzzle();
 	case 194:
diff --git a/engines/nancy/action/puzzle/rowmatchpuzzle.cpp b/engines/nancy/action/puzzle/rowmatchpuzzle.cpp
new file mode 100644
index 00000000000..a6194d4fe12
--- /dev/null
+++ b/engines/nancy/action/puzzle/rowmatchpuzzle.cpp
@@ -0,0 +1,311 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/rowmatchpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+RowMatchPuzzle::~RowMatchPuzzle() {
+	if (_heldTile != kNoTile) {
+		g_nancy->_cursor->showCursor(true);
+	}
+}
+
+void RowMatchPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+
+	_tileSrcs.resize(stream.readSint16LE());
+	for (Common::Rect &src : _tileSrcs) {
+		readRect(stream, src);
+	}
+
+	readRect(stream, _spareDest);
+	_spareSolutionTile = getTile(stream.readSint16LE());
+
+	_columns.resize(stream.readSint16LE());
+	for (Column &column : _columns) {
+		stream.skip(1);
+
+		// The count includes both of the ids in each slot
+		column.slots.resize(MAX<int16>(stream.readSint16LE() / 2, 0));
+		if (column.slots.empty()) {
+			continue;
+		}
+
+		for (Slot &slot : column.slots) {
+			slot.startTile = getTile(stream.readSint16LE());
+			slot.solutionTile = stream.readSint16LE();
+			readRect(stream, slot.dest);
+		}
+
+		readRect(stream, column.insertHotspot);
+		readRect(stream, column.droppedTileDest);
+	}
+
+	_insertCursor = stream.readUint16LE();
+	_takeCursor = stream.readUint16LE();
+	_heldTileCursor = stream.readUint16LE();
+
+	_insertSound.readData(stream);
+	_takeSound.readData(stream);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_solveSoundBlock.readData(stream);
+
+	// Unlike most puzzles, this one has more than one way out
+	readExitHotspots(stream, _exits);
+}
+
+void RowMatchPuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	for (Column &column : _columns) {
+		for (Slot &slot : column.slots) {
+			slot.tile = slot.startTile;
+		}
+
+		column.droppedTile = kNoTile;
+	}
+
+	_spareTile = _spareSolutionTile;
+
+	NancySceneState.setNoHeldItem();
+	redraw();
+}
+
+bool RowMatchPuzzle::isSolved() const {
+	for (const Column &column : _columns) {
+		for (const Slot &slot : column.slots) {
+			if (slot.tile == kNoTile || slot.tile != slot.solutionTile) {
+				return false;
+			}
+		}
+	}
+
+	return _spareTile != kNoTile && _spareTile == _spareSolutionTile;
+}
+
+void RowMatchPuzzle::holdTile(int tile) {
+	_heldTile = tile;
+	g_nancy->_cursor->showCursor(false);
+}
+
+void RowMatchPuzzle::releaseTile() {
+	_heldTile = kNoTile;
+	g_nancy->_cursor->showCursor(true);
+}
+
+void RowMatchPuzzle::setCursor(const Common::Point &pos) {
+	if (_heldTile != kNoTile) {
+		for (const Column &column : _columns) {
+			if (column.insertHotspot.contains(pos)) {
+				setDataCursor(_insertCursor);
+				return;
+			}
+		}
+
+		if (_spareTile == kNoTile && _spareDest.contains(pos)) {
+			setDataCursor(_insertCursor);
+		} else {
+			setDataCursor(_heldTileCursor, false);
+		}
+
+		return;
+	}
+
+	for (const Column &column : _columns) {
+		if (column.droppedTile != kNoTile && column.droppedTileDest.contains(pos)) {
+			setDataCursor(_takeCursor);
+			return;
+		}
+	}
+
+	if (_spareTile != kNoTile && _spareDest.contains(pos)) {
+		setDataCursor(_takeCursor);
+	}
+}
+
+void RowMatchPuzzle::drawTile(int tile, const Common::Point &pos) {
+	if (tile != kNoTile) {
+		_drawSurface.blitFrom(_image, _tileSrcs[tile], pos);
+	}
+}
+
+void RowMatchPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (const Column &column : _columns) {
+		for (const Slot &slot : column.slots) {
+			drawTile(slot.tile, slot.dest.origin());
+		}
+
+		drawTile(column.droppedTile, column.droppedTileDest.origin());
+	}
+
+	drawTile(_spareTile, _spareDest.origin());
+
+	// A held tile takes the place of the cursor
+	drawTile(_heldTile, _mousePos);
+
+	_needsRedraw = true;
+}
+
+void RowMatchPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_isSolved) {
+			if (!isSolveSoundPlaying()) {
+				_state = kActionTrigger;
+			}
+
+			break;
+		}
+
+		if (isSolved()) {
+			_isSolved = true;
+			if (_heldTile != kNoTile) {
+				releaseTile();
+			}
+
+			_solveSound = playSoundBlock(_solveSoundBlock);
+		}
+
+		redraw();
+		break;
+	case kActionTrigger:
+		if (_exitRequested) {
+			_exitScene.execute();
+		} else {
+			_solveScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void RowMatchPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _isSolved || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	_mousePos = mouseRect.origin();
+
+	setCursor(_mousePos);
+
+	if (_heldTile == kNoTile) {
+		for (const ExitHotspot &exit : _exits) {
+			if (!exit.hotspot.contains(_mousePos)) {
+				continue;
+			}
+
+			setDataCursor(exit.cursorType);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				_exitScene._sceneChange = exit.scene;
+				_exitScene._flag = exit.flag;
+				_exitRequested = true;
+				input.eatMouseInput();
+			}
+
+			return;
+		}
+	}
+
+	if (!(input.input & NancyInput::kLeftMouseButtonUp)) {
+		return;
+	}
+
+	if (_heldTile != kNoTile) {
+		// Pushing the tile in at the top shifts the column down, and drops out its bottom tile
+		for (Column &column : _columns) {
+			if (!column.insertHotspot.contains(_mousePos)) {
+				continue;
+			}
+
+			int carried = _heldTile;
+			for (Slot &slot : column.slots) {
+				SWAP(carried, slot.tile);
+			}
+
+			column.droppedTile = carried;
+			playSoundBlock(_insertSound);
+			releaseTile();
+			input.eatMouseInput();
+			return;
+		}
+
+		if (_spareTile == kNoTile && _spareDest.contains(_mousePos)) {
+			_spareTile = _heldTile;
+			releaseTile();
+		}
+	} else {
+		for (Column &column : _columns) {
+			if (column.droppedTile != kNoTile && column.droppedTileDest.contains(_mousePos)) {
+				holdTile(column.droppedTile);
+				column.droppedTile = kNoTile;
+				playSoundBlock(_takeSound);
+				input.eatMouseInput();
+				return;
+			}
+		}
+
+		if (_spareTile != kNoTile && _spareDest.contains(_mousePos)) {
+			holdTile(_spareTile);
+			_spareTile = kNoTile;
+			playSoundBlock(_takeSound);
+		}
+	}
+
+	input.eatMouseInput();
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/rowmatchpuzzle.h b/engines/nancy/action/puzzle/rowmatchpuzzle.h
new file mode 100644
index 00000000000..8beba74ecab
--- /dev/null
+++ b/engines/nancy/action/puzzle/rowmatchpuzzle.h
@@ -0,0 +1,103 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_ROWMATCHPUZZLE_H
+#define NANCY_ACTION_ROWMATCHPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 secret passage puzzle (AR 192). Tiles are stacked in columns; pushing the
+// spare tile into the top of a column shifts its tiles down, and the one at the bottom
+// drops out, ready to be picked up and pushed into another column. The puzzle is solved
+// when every column is in order and the original spare tile is back in its place.
+class RowMatchPuzzle : public PuzzleRecord {
+public:
+	RowMatchPuzzle() : PuzzleRecord(7) {}
+	virtual ~RowMatchPuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "RowMatchPuzzle"; }
+
+	static const int kNoTile = -1;
+
+	struct Slot {
+		int startTile = kNoTile;
+		int solutionTile = kNoTile;
+		int tile = kNoTile;
+		Common::Rect dest;
+	};
+
+	struct Column {
+		Common::Array<Slot> slots;
+		Common::Rect insertHotspot;
+		Common::Rect droppedTileDest;	// also where the dropped tile is picked up
+		int droppedTile = kNoTile;
+	};
+
+	int getTile(int16 id) const { return id >= 0 && id < (int)_tileSrcs.size() ? id : kNoTile; }
+	bool isSolved() const;
+	void setCursor(const Common::Point &pos);
+	void holdTile(int tile);
+	void releaseTile();
+	void drawTile(int tile, const Common::Point &pos);
+
+	void redraw();
+
+	// -- File data --
+	Common::Array<Common::Rect> _tileSrcs;
+	Common::Rect _spareDest;
+	int _spareSolutionTile = kNoTile;
+	Common::Array<Column> _columns;
+
+	uint16 _insertCursor = 0;
+	uint16 _takeCursor = 0;
+	uint16 _heldTileCursor = 0;
+	RandomSoundBlock _insertSound;
+	RandomSoundBlock _takeSound;
+	RandomSoundBlock _solveSoundBlock;
+	Common::Array<ExitHotspot> _exits;
+
+	// -- Runtime state --
+	int _spareTile = kNoTile;
+	int _heldTile = kNoTile;
+	Common::Point _mousePos;
+
+	bool _isSolved = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_ROWMATCHPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 81ab711761b..bab74de5996 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -72,6 +72,7 @@ MODULE_OBJS = \
   action/puzzle/riddlepuzzle.o \
   action/puzzle/rippedletterpuzzle.o \
   action/puzzle/rotatinglockpuzzle.o \
+  action/puzzle/rowmatchpuzzle.o \
   action/puzzle/safedialpuzzle.o \
   action/puzzle/scalepuzzle.o \
   action/puzzle/setplayerclock.o \


Commit: e400d1a1e24e3b5cad1028b8f7a648da848cfdf6
    https://github.com/scummvm/scummvm/commit/e400d1a1e24e3b5cad1028b8f7a648da848cfdf6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:56+03:00

Commit Message:
NANCY: NANCY16: Allow the game to start

The game now starts and is playable, so remove the explicit startup
error guard

Changed paths:
    engines/nancy/nancy.cpp


diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 1d11dc4f13c..ed63ac37526 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -802,10 +802,6 @@ void NancyEngine::bootGameEngine() {
 	#undef LOAD_BOOT_L2
 	#undef LOAD_BOOT_L
 	#undef LOAD_BOOT
-
-	if (getGameType() >= kGameTypeNancy16) {
-		error("Game not supported; Use console to inspect game data");
-	}
 }
 
 State::State *NancyEngine::getStateObject(NancyState::NancyState state) const {


Commit: 8fcf78a5a214a796c86af6ed24b99be237e45572
    https://github.com/scummvm/scummvm/commit/8fcf78a5a214a796c86af6ed24b99be237e45572
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:17:58+03:00

Commit Message:
NANCY: NANCY16: Implement FishingPuzzle

- Record 193, ice fishing: fish and junk swim across the lanes, and
  clicking moves the hook to that depth
- The first catch to touch the hook leads to its own scene, and pulling
  the hook out ends the puzzle

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 2b5cc33f338..db9915571af 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -57,7 +57,7 @@
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
 #include "engines/nancy/action/puzzle/escapegridpuzzle.h"
-//#include "engines/nancy/action/puzzle/fishingpuzzle.h"
+#include "engines/nancy/action/puzzle/fishingpuzzle.h"
 #include "engines/nancy/action/puzzle/foxgeesepuzzle.h"
 #include "engines/nancy/action/puzzle/gridmappuzzle.h"
 #include "engines/nancy/action/puzzle/magicboxpuzzle.h"
@@ -697,7 +697,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 192:
 		return new RowMatchPuzzle();
 	case 193:
-		return nullptr; // return new FishingPuzzle();
+		return new FishingPuzzle();
 	case 194:
 		return nullptr; // return new CookingPuzzle();
 	case 195:
diff --git a/engines/nancy/action/puzzle/fishingpuzzle.cpp b/engines/nancy/action/puzzle/fishingpuzzle.cpp
new file mode 100644
index 00000000000..2121dafc17d
--- /dev/null
+++ b/engines/nancy/action/puzzle/fishingpuzzle.cpp
@@ -0,0 +1,538 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/fishingpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint32 kMsPerTick = 20;
+static const float kTicksPerSecond = 50.0f;
+
+// Swim animations play at this rate when moving at the slowest speed
+static const float kBaseAnimFPS = 15.0f;
+
+// Path tangents are the swim speed scaled by this
+static const float kTangentScale = 10.0f / 3.0f;
+
+static const float kLineStopThreshold = 0.001f;
+
+float FishingPuzzle::Vec2::length() const {
+	return sqrtf(x * x + y * y);
+}
+
+FishingPuzzle::~FishingPuzzle() {
+	for (Graphics::ManagedSurface *image : _catchableImages) {
+		delete image;
+	}
+}
+
+void FishingPuzzle::readEnding(Common::SeekableReadStream &stream, Ending &ending) {
+	ending.scene._sceneChange.sceneID = stream.readUint16LE();
+	ending.scene._sceneChange.frameID = stream.readUint16LE();
+	ending.scene._sceneChange.continueSceneSound = kContinueSceneSound;
+	ending.scene._flag.label = stream.readSint16LE();
+	ending.scene._flag.flag = stream.readByte();
+	ending.sound.readData(stream);
+}
+
+void FishingPuzzle::readData(Common::SeekableReadStream &stream) {
+	_catchables.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (Catchable &catchable : _catchables) {
+		readFilename(stream, catchable.name);
+		catchable.snagsLine = stream.readByte() == 1;
+		catchable.minSpeed = stream.readFloatLE();
+		catchable.maxSpeed = stream.readFloatLE();
+		readFilename(stream, catchable.imageName);
+
+		catchable.frames.resize(MAX<int16>(stream.readSint16LE(), 0));
+		for (Common::Rect &frame : catchable.frames) {
+			readRect(stream, frame);
+		}
+
+		if (catchable.frames.empty()) {
+			error("FishingPuzzle: catchable %s has no frames", catchable.name.c_str());
+		}
+
+		readRect(stream, catchable.hitbox);
+		catchable.hitbox.translate(-catchable.frames[0].left, -catchable.frames[0].top);
+		readEnding(stream, catchable.ending);
+	}
+
+	_lanes.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (Lane &lane : _lanes) {
+		readRect(stream, lane.bounds);
+		lane.minInterval = stream.readFloatLE();
+		lane.maxInterval = stream.readFloatLE();
+
+		lane.entries.resize(MAX<int16>(stream.readSint16LE(), 0));
+		for (LaneEntry &entry : lane.entries) {
+			Common::String name;
+			readFilename(stream, name);
+			entry.percent = stream.readSint32LE();
+
+			uint i = 0;
+			while (i < _catchables.size() && _catchables[i].name != name) {
+				++i;
+			}
+
+			if (i == _catchables.size()) {
+				error("FishingPuzzle: lane refers to missing catchable %s", name.c_str());
+			}
+
+			entry.catchable = i;
+		}
+	}
+
+	_cursor = stream.readUint16LE();
+
+	readFilename(stream, _imageName);
+	_lineAcceleration = stream.readFloatLE() / kTicksPerSecond;
+	readRect(stream, _lineSrc);
+	_hookPoint.x = stream.readSint32LE() - _lineSrc.left;
+	_hookPoint.y = stream.readSint32LE() - _lineSrc.top;
+	_lineTop.x = stream.readSint32LE();
+	_lineTop.y = stream.readSint32LE();
+	_lineBottom.x = stream.readSint32LE();
+	_lineBottom.y = stream.readSint32LE();
+	readRect(stream, _lineBounds);
+
+	Common::Rect start;
+	readRect(stream, start);
+	_lineStart.x = start.left;
+	_lineStart.y = start.bottom;
+
+	_clickOffset.x = stream.readSint32LE();
+	_clickOffset.y = stream.readSint32LE();
+	_reelSound.readData(stream);
+	_exitHookY = stream.readSint32LE();
+	readEnding(stream, _exitEnding);
+
+	readExitHotspot(stream);
+}
+
+void FishingPuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	// The catchables all tend to share a single sprite sheet
+	Common::Array<Common::Path> loadedNames;
+	for (Catchable &catchable : _catchables) {
+		uint i = 0;
+		while (i < loadedNames.size() && loadedNames[i] != catchable.imageName) {
+			++i;
+		}
+
+		if (i == loadedNames.size()) {
+			Graphics::ManagedSurface *image = new Graphics::ManagedSurface();
+			g_nancy->_resource->loadImage(catchable.imageName, *image);
+			image->setTransparentColor(_drawSurface.getTransparentColor());
+			_catchableImages.push_back(image);
+			loadedNames.push_back(catchable.imageName);
+		}
+
+		catchable.imageID = i;
+	}
+
+	// Smaller catchables are drawn behind the larger ones
+	for (uint i = 0; i < _catchables.size(); ++i) {
+		const Common::Rect &frame = _catchables[i].frames[0];
+		int32 area = frame.width() * frame.height();
+
+		uint pos = 0;
+		while (pos < _drawOrder.size()) {
+			const Common::Rect &other = _catchables[_drawOrder[pos]].frames[0];
+			if (other.width() * other.height() > area) {
+				break;
+			}
+
+			++pos;
+		}
+
+		_drawOrder.insert_at(pos, i);
+	}
+
+	// Lanes start at a random point of their first interval
+	for (Lane &lane : _lanes) {
+		pickLaneInterval(lane);
+		lane.ticks = (float)g_nancy->_randomSource->getRandomNumber(32767) / 32767.0f * lane.interval * kTicksPerSecond;
+	}
+
+	_linePos = Vec2(_lineStart.x, _lineSrc.top - _lineSrc.bottom + _lineStart.y);
+	_lineTarget = Common::Point((int16)_linePos.x, (int16)_linePos.y);
+	_lineVelocity = Vec2();
+	_lineAccel = Vec2();
+	_swimmers.clear();
+
+	NancySceneState.setNoHeldItem();
+	_lastTickTime = _lastFrameTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+void FishingPuzzle::pickLaneInterval(Lane &lane) {
+	lane.ticks = 0;
+	lane.interval = lane.minInterval +
+		(float)g_nancy->_randomSource->getRandomNumber(32767) / 32767.0f * (lane.maxInterval - lane.minInterval);
+}
+
+// Builds a path across the lane, from just outside one side to just outside the other,
+// through up to three evenly spaced waypoints at random depths and speeds
+void FishingPuzzle::spawn(const Lane &lane, uint catchable) {
+	Common::RandomSource *rnd = g_nancy->_randomSource;
+	const Catchable &data = _catchables[catchable];
+	const Common::Rect &frame = data.frames[0];
+
+	Swimmer swimmer;
+	swimmer.catchable = catchable;
+	swimmer.isMirrored = rnd->getRandomNumber(1) == 1;
+
+	// The data rects include their right and bottom edges
+	int frameWidth = frame.width() - 1;
+	int laneRight = lane.bounds.right - 1;
+	int laneHeight = MAX<int>(lane.bounds.height() - 1, 1);
+
+	int numMidpoints = rnd->getRandomNumber(3);
+	float step = (lane.bounds.width() - 1) / (numMidpoints + 0.00001f);
+
+	for (int i = 0; i < numMidpoints + 2; ++i) {
+		Waypoint point;
+		point.pos.y = lane.bounds.top - (frame.height() - 1) / 2 + (int)rnd->getRandomNumber(laneHeight - 1);
+		float speed = data.minSpeed + (float)rnd->getRandomNumber(32767) / 32767.0f * (data.maxSpeed - data.minSpeed);
+		point.tangent.x = speed * kTangentScale * (swimmer.isMirrored ? -1 : 1);
+
+		if (i == 0) {
+			point.pos.x = swimmer.isMirrored ? laneRight + frameWidth + 1 : lane.bounds.left - frameWidth - 1;
+		} else if (i == numMidpoints + 1) {
+			point.pos.x = swimmer.isMirrored ? lane.bounds.left - 1 : laneRight + 1;
+		} else {
+			int index = swimmer.isMirrored ? numMidpoints - i + 1 : i - 1;
+			point.pos.x = swimmer.isMirrored ? index * step - step * 0.5f : index * step + step * 0.5f;
+		}
+
+		swimmer.path.push_back(point);
+	}
+
+	for (uint i = 1; i < swimmer.path.size(); ++i) {
+		Waypoint &prev = swimmer.path[i - 1];
+		Waypoint &cur = swimmer.path[i];
+		float speed = fabsf((prev.tangent.x + cur.tangent.x) * 0.5f);
+		cur.ticks = (cur.pos - prev.pos).length() / speed * kTicksPerSecond * kTangentScale;
+	}
+
+	moveSwimmer(swimmer, 1);
+	_swimmers.push_back(swimmer);
+}
+
+FishingPuzzle::Vec2 FishingPuzzle::hermite(const Vec2 &p1, const Vec2 &t1, const Vec2 &p2, const Vec2 &t2, float s) {
+	float s2 = s * s;
+	float s3 = s2 * s;
+	return p1 * (2 * s3 - 3 * s2 + 1) + t1 * (s3 - 2 * s2 + s) + p2 * (3 * s2 - 2 * s3) + t2 * (s3 - s2);
+}
+
+// Follows the path along a spline, and returns true once its end is reached
+bool FishingPuzzle::moveSwimmer(Swimmer &swimmer, float ticks) {
+	Waypoint from = swimmer.path[0];
+	Waypoint to = swimmer.path[1];
+	Vec2 prevPos = hermite(from.pos, from.tangent, to.pos, to.tangent, swimmer.segmentTicks / MAX(to.ticks, 1.0f));
+
+	swimmer.segmentTicks += ticks;
+	while (swimmer.segmentTicks >= to.ticks) {
+		swimmer.segmentTicks -= to.ticks;
+		from = to;
+		swimmer.path.remove_at(0);
+		if (swimmer.path.size() < 2) {
+			return true;
+		}
+
+		to = swimmer.path[1];
+	}
+
+	swimmer.pos = hermite(from.pos, from.tangent, to.pos, to.tangent, swimmer.segmentTicks / MAX(to.ticks, 1.0f));
+
+	// The swim animation speeds up along with the swimmer
+	const Catchable &data = _catchables[swimmer.catchable];
+	float fps = (swimmer.pos - prevPos).length() / data.minSpeed * (kTicksPerSecond / ticks) * kBaseAnimFPS;
+	if (fps > 0) {
+		swimmer.animFPS = fps;
+	}
+
+	swimmer.animPhase += ticks / kTicksPerSecond * swimmer.animFPS / data.frames.size();
+	swimmer.animPhase -= floorf(swimmer.animPhase);
+
+	return false;
+}
+
+const Common::Rect &FishingPuzzle::getFrame(const Swimmer &swimmer) const {
+	const Common::Array<Common::Rect> &frames = _catchables[swimmer.catchable].frames;
+	return frames[MIN<uint>((uint)(swimmer.animPhase * frames.size()), frames.size() - 1)];
+}
+
+bool FishingPuzzle::segmentsIntersect(const Vec2 &a1, const Vec2 &a2, const Vec2 &b1, const Vec2 &b2) {
+	Vec2 da = a2 - a1;
+	Vec2 db = b2 - b1;
+	float denom = da.x * db.y - da.y * db.x;
+	if (fabsf(denom) < 0.0001f) {
+		return false;
+	}
+
+	Vec2 diff = b1 - a1;
+	float t = (diff.x * db.y - diff.y * db.x) / denom;
+	float u = (diff.x * da.y - diff.y * da.x) / denom;
+	return t >= 0 && t <= 1 && u >= 0 && u <= 1;
+}
+
+// Caught when the hook is inside the hitbox, or for some catchables when the line crosses it
+bool FishingPuzzle::isCaught(const Swimmer &swimmer, const Vec2 &hook, const Vec2 &lineTop, const Vec2 &lineBottom) const {
+	const Catchable &data = _catchables[swimmer.catchable];
+	// The data rects include their right and bottom edges
+	float left = swimmer.isMirrored ? swimmer.pos.x - (data.hitbox.right - 1) : swimmer.pos.x + data.hitbox.left;
+	float right = swimmer.isMirrored ? swimmer.pos.x - data.hitbox.left : swimmer.pos.x + (data.hitbox.right - 1);
+	float top = swimmer.pos.y + data.hitbox.top;
+	float bottom = swimmer.pos.y + (data.hitbox.bottom - 1);
+
+	if (hook.x >= left && hook.x <= right && hook.y >= top && hook.y <= bottom) {
+		return true;
+	}
+
+	if (!data.snagsLine) {
+		return false;
+	}
+
+	Vec2 corners[4] = { Vec2(left, top), Vec2(right, top), Vec2(right, bottom), Vec2(left, bottom) };
+	for (uint i = 0; i < 4; ++i) {
+		if (segmentsIntersect(lineTop, lineBottom, corners[i], corners[(i + 1) % 4])) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+// The line accelerates towards the clicked depth, keeping within its bounds
+void FishingPuzzle::moveHookTo(const Common::Point &pos) {
+	playSoundBlock(_reelSound);
+	_lineVelocity = Vec2();
+
+	int32 x = pos.x + _clickOffset.x - _hookPoint.x;
+	int32 y = pos.y + _clickOffset.y - _hookPoint.y;
+	x = MAX<int32>(x, _lineBounds.left);
+	x -= MAX<int32>(_lineSrc.width() + x - _lineBounds.right, 0);
+	y -= MAX<int32>(_lineSrc.height() + y - _lineBounds.bottom, 0);
+
+	_lineTarget = Common::Point((int16)x, (int16)y);
+	Vec2 direction = Vec2(x, y) - _linePos;
+	float length = direction.length();
+	_lineAccel = length > 0 ? direction * (_lineAcceleration / length) : Vec2();
+}
+
+// Returns true when the hook gets pulled out of the water
+bool FishingPuzzle::moveLine(int32 ticks) {
+	if (_lineVelocity.length() <= kLineStopThreshold && _lineAccel.length() <= kLineStopThreshold) {
+		return false;
+	}
+
+	_lineVelocity = _lineVelocity + _lineAccel * ticks;
+	Vec2 newPos = _linePos + _lineVelocity;
+	Vec2 target(_lineTarget.x, _lineTarget.y);
+
+	// Stop once the target has been passed
+	if ((target - _linePos).length() < (target - newPos).length()) {
+		_linePos = target;
+		_lineVelocity = Vec2();
+		_lineAccel = Vec2();
+	} else {
+		_linePos = newPos;
+	}
+
+	if (_hookPoint.y + _linePos.y < _exitHookY) {
+		startEnding(_exitEnding);
+		return true;
+	}
+
+	return false;
+}
+
+void FishingPuzzle::update(int32 lineTicks, float ticks) {
+	if (lineTicks > 0 && moveLine(lineTicks)) {
+		return;
+	}
+
+	Vec2 hook((int)(_hookPoint.x + _linePos.x), (int)(_hookPoint.y + _linePos.y));
+	Vec2 lineTop((int)(_lineTop.x + _linePos.x), (int)(_lineTop.y + _linePos.y));
+	Vec2 lineBottom((int)(_lineBottom.x + _linePos.x), (int)(_lineBottom.y + _linePos.y));
+
+	for (uint i = 0; i < _swimmers.size(); ) {
+		if (moveSwimmer(_swimmers[i], ticks)) {
+			_swimmers.remove_at(i);
+			continue;
+		}
+
+		if (isCaught(_swimmers[i], hook, lineTop, lineBottom)) {
+			startEnding(_catchables[_swimmers[i].catchable].ending);
+			return;
+		}
+
+		++i;
+	}
+
+	// Each lane sends out a random catchable at random intervals
+	for (Lane &lane : _lanes) {
+		lane.ticks += ticks;
+		if (lane.interval >= lane.ticks / kTicksPerSecond) {
+			continue;
+		}
+
+		pickLaneInterval(lane);
+
+		int32 roll = g_nancy->_randomSource->getRandomNumber(99);
+		int32 total = 0;
+		for (const LaneEntry &entry : lane.entries) {
+			total += entry.percent;
+			if (roll < total) {
+				spawn(lane, entry.catchable);
+				break;
+			}
+		}
+	}
+}
+
+void FishingPuzzle::startEnding(const Ending &ending) {
+	_isEnding = true;
+	_endScene = ending.scene;
+	_endSound = playSoundBlock(ending.sound);
+}
+
+void FishingPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	_drawSurface.blitFrom(_image, _lineSrc, Common::Point((int16)floorf(_linePos.x + 0.5f), (int16)floorf(_linePos.y + 0.5f)));
+
+	for (uint catchable : _drawOrder) {
+		for (const Swimmer &swimmer : _swimmers) {
+			if (swimmer.catchable != catchable) {
+				continue;
+			}
+
+			const Graphics::ManagedSurface &image = *_catchableImages[_catchables[catchable].imageID];
+			const Common::Rect &frame = getFrame(swimmer);
+			Common::Point pos((int16)floorf(swimmer.pos.x + 0.5f), (int16)floorf(swimmer.pos.y + 0.5f));
+			if (swimmer.isMirrored) {
+				// A flipped blit mirrors across its whole source surface, so it gets the frame on its own
+				const Graphics::Surface frameSurface = image.getSubArea(frame);
+				pos.x -= frame.width();
+				_drawSurface.transBlitFrom(frameSurface, pos, image.getTransparentColor(), true);
+			} else {
+				_drawSurface.blitFrom(image, frame, pos);
+			}
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void FishingPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_isEnding) {
+			if (_endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound)) {
+				_state = kActionTrigger;
+			}
+
+			break;
+		}
+
+		// The line's speed depends on how many updates it gets, so it keeps to whole ticks
+		uint32 now = g_nancy->getTotalPlayTime();
+		int32 lineTicks = (now - _lastTickTime) / kMsPerTick;
+		_lastTickTime += lineTicks * kMsPerTick;
+
+		float ticks = (now - _lastFrameTime) / (float)kMsPerTick;
+		_lastFrameTime = now;
+
+		if (ticks > 0) {
+			update(lineTicks, ticks);
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		if (_exitRequested) {
+			_exitScene.execute();
+		} else {
+			_endScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void FishingPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _isEnding || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	if (hoverExitHotspot(input)) {
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	setDataCursor(_cursor);
+
+	if (input.input & NancyInput::kLeftMouseButtonUp) {
+		Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+			Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+		moveHookTo(mouseRect.origin());
+		input.eatMouseInput();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/fishingpuzzle.h b/engines/nancy/action/puzzle/fishingpuzzle.h
new file mode 100644
index 00000000000..60b0d2616a9
--- /dev/null
+++ b/engines/nancy/action/puzzle/fishingpuzzle.h
@@ -0,0 +1,170 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_FISHINGPUZZLE_H
+#define NANCY_ACTION_FISHINGPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 ice fishing puzzle (AR 193). Fish and junk swim across the lanes of the lake
+// below the ice hole, and clicking moves the hook to the clicked depth. Whatever touches
+// the hook first is caught, and each catch leads to its own scene. Large catches also
+// get snagged on the line itself. Pulling the hook out of the water ends the puzzle.
+class FishingPuzzle : public PuzzleRecord {
+public:
+	// Drawn over the scene's movies, which include a water animation covering the viewport
+	FishingPuzzle() : PuzzleRecord(15) {}
+	virtual ~FishingPuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "FishingPuzzle"; }
+
+	struct Vec2 {
+		float x = 0;
+		float y = 0;
+
+		Vec2() {}
+		Vec2(float x_, float y_) : x(x_), y(y_) {}
+
+		Vec2 operator+(const Vec2 &v) const { return Vec2(x + v.x, y + v.y); }
+		Vec2 operator-(const Vec2 &v) const { return Vec2(x - v.x, y - v.y); }
+		Vec2 operator*(float f) const { return Vec2(x * f, y * f); }
+		float length() const;
+	};
+
+	struct Ending {
+		SceneChangeWithFlag scene;
+		RandomSoundBlock sound;
+	};
+
+	struct Catchable {
+		Common::String name;
+		bool snagsLine = false;			// caught by touching any part of the line, not just the hook
+		float minSpeed = 0;				// pixels per second
+		float maxSpeed = 0;
+		Common::Path imageName;
+		uint imageID = 0;
+		Common::Array<Common::Rect> frames;
+		Common::Rect hitbox;			// relative to the frame origin
+		Ending ending;
+	};
+
+	struct LaneEntry {
+		uint catchable = 0;
+		int32 percent = 0;
+	};
+
+	struct Lane {
+		Common::Rect bounds;
+		float minInterval = 0;			// seconds between catchables
+		float maxInterval = 0;
+		Common::Array<LaneEntry> entries;
+
+		float ticks = 0;
+		float interval = 0;
+	};
+
+	struct Waypoint {
+		Vec2 pos;
+		Vec2 tangent;
+		float ticks = 0;				// time taken to get here from the previous waypoint
+	};
+
+	// A catchable swimming across a lane
+	struct Swimmer {
+		uint catchable = 0;
+		bool isMirrored = false;		// swims right to left
+		Common::Array<Waypoint> path;
+		float segmentTicks = 0;
+		Vec2 pos;
+		float animPhase = 0;			// 0 to 1, over all frames
+		float animFPS = 0;
+	};
+
+	static void readEnding(Common::SeekableReadStream &stream, Ending &ending);
+	static Vec2 hermite(const Vec2 &p1, const Vec2 &t1, const Vec2 &p2, const Vec2 &t2, float s);
+	static bool segmentsIntersect(const Vec2 &a1, const Vec2 &a2, const Vec2 &b1, const Vec2 &b2);
+
+	void pickLaneInterval(Lane &lane);
+	void spawn(const Lane &lane, uint catchable);
+	bool moveSwimmer(Swimmer &swimmer, float ticks);
+	const Common::Rect &getFrame(const Swimmer &swimmer) const;
+	bool isCaught(const Swimmer &swimmer, const Vec2 &hook, const Vec2 &lineTop, const Vec2 &lineBottom) const;
+
+	void moveHookTo(const Common::Point &pos);
+	bool moveLine(int32 ticks);
+	void update(int32 lineTicks, float ticks);
+	void startEnding(const Ending &ending);
+
+	void redraw();
+
+	// -- File data --
+	Common::Array<Catchable> _catchables;
+	Common::Array<Lane> _lanes;
+	uint16 _cursor = 0;
+
+	// The line image is _image
+	float _lineAcceleration = 0;		// pixels per tick squared
+	Common::Rect _lineSrc;
+	Common::Point _hookPoint;			// all relative to the line's top left
+	Common::Point _lineTop;
+	Common::Point _lineBottom;
+	Common::Rect _lineBounds;
+	Common::Point _lineStart;			// left and bottom
+	Common::Point _clickOffset;
+	RandomSoundBlock _reelSound;
+	int32 _exitHookY = 0;				// pulling the hook above this ends the puzzle
+	Ending _exitEnding;
+
+	// -- Runtime state --
+	Common::Array<Graphics::ManagedSurface *> _catchableImages;
+	Common::Array<uint> _drawOrder;
+	Common::Array<Swimmer> _swimmers;
+
+	Vec2 _linePos;
+	Vec2 _lineVelocity;
+	Vec2 _lineAccel;
+	Common::Point _lineTarget;
+
+	uint32 _lastTickTime = 0;			// the line moves in whole ticks
+	uint32 _lastFrameTime = 0;			// everything else moves smoothly
+	bool _isEnding = false;
+	bool _exitRequested = false;
+	SceneChangeWithFlag _endScene;
+	SoundDescription _endSound;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_FISHINGPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index bab74de5996..cbb94ba7e45 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -41,6 +41,7 @@ MODULE_OBJS = \
   action/puzzle/drivingpuzzle.o \
   action/puzzle/dropsortpuzzle.o \
   action/puzzle/escapegridpuzzle.o \
+  action/puzzle/fishingpuzzle.o \
   action/puzzle/foxgeesepuzzle.o \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \


Commit: 79a81b2bd89f201a675190be605c71b226c58664
    https://github.com/scummvm/scummvm/commit/79a81b2bd89f201a675190be605c71b226c58664
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:00+03:00

Commit Message:
NANCY: NANCY16: Implement AnalogClock

- Record 196, the alarm clock in the first room: the hands follow the
  player clock
- Clicking the alarm hand moves it on by an hour, and the alarm button
  advances the clock to that hour

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index db9915571af..da02953c9b9 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -35,7 +35,7 @@
 #include "engines/nancy/action/secondarymovie.h"
 
 #include "engines/nancy/action/puzzle/adjustpuzzle.h"
-//#include "engines/nancy/action/puzzle/analogclock.h"
+#include "engines/nancy/action/puzzle/analogclock.h"
 #include "engines/nancy/action/puzzle/angletosspuzzle.h"
 #include "engines/nancy/action/puzzle/arcadepuzzle.h"
 #include "engines/nancy/action/puzzle/assemblypuzzle.h"
@@ -703,7 +703,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 195:
 		return nullptr; // return new MinesweeperPuzzle();	// TODO
 	case 196:
-		return nullptr; // return new AnalogClock();	// TODO
+		return new AnalogClock();
 	case 197:
 		return new AutotextScroll();	// Used for the end credits
 	case 188:	// Empty in the original
diff --git a/engines/nancy/action/puzzle/analogclock.cpp b/engines/nancy/action/puzzle/analogclock.cpp
new file mode 100644
index 00000000000..65107c5a15e
--- /dev/null
+++ b/engines/nancy/action/puzzle/analogclock.cpp
@@ -0,0 +1,244 @@
+/* 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/archive.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/analogclock.h"
+
+namespace Nancy {
+namespace Action {
+
+void AnalogClock::readData(Common::SeekableReadStream &stream) {
+	_alarmIndex = stream.readSint16LE();
+
+	for (Hand &hand : _hands) {
+		readRect(stream, hand.dest);
+		readFilename(stream, hand.movieName);
+	}
+
+	// The alarm needs both its movie and its value table entry
+	if (_hands[kAlarmHand].movieName.empty() || !hasAlarm()) {
+		_alarmIndex = kNoAlarm;
+	}
+
+	_alarmHandCursor = stream.readUint16LE();
+	_alarmHandSound.readData(stream);
+	_tickSound.readData(stream);
+
+	readFilename(stream, _imageName);
+	readRect(stream, _buttonDownSrc);
+	readRect(stream, _buttonDest);
+	_buttonCursor = stream.readUint16LE();
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_buttonSoundBlock.readData(stream);
+
+	readExitHotspot(stream);
+}
+
+void AnalogClock::init() {
+	initViewportSurface();
+
+	// The game data lacks the pressed button's image
+	if (SearchMan.hasFile(_imageName) || SearchMan.hasFile(_imageName.append(".cif"))) {
+		loadImage();
+	}
+
+	for (uint i = 0; i < kNumHands; ++i) {
+		if (i != kAlarmHand || hasAlarm()) {
+			_hands[i].movie.loadFile(_hands[i].movieName);
+		}
+	}
+
+	redraw();
+}
+
+int16 AnalogClock::getAlarmHour() const {
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	assert(table);
+	return table->getValue(_alarmIndex);
+}
+
+void AnalogClock::drawHand(Hand &hand, int frame) {
+	if (!hand.movie.isVideoLoaded()) {
+		return;
+	}
+
+	if (frame != hand.frame) {
+		const Graphics::Surface *decoded = hand.movie.decodeNextFrame(frame);
+		if (!decoded) {
+			return;
+		}
+
+		GraphicsManager::copyToManaged(*decoded, hand.frameSurface);
+		hand.frameSurface.setTransparentColor(_drawSurface.getTransparentColor());
+		hand.frame = frame;
+	}
+
+	_drawSurface.blitFrom(hand.frameSurface, hand.dest.origin());
+}
+
+// Advances the player clock to the next time the alarm hour comes around
+void AnalogClock::pressAlarmButton() {
+	uint minutes = NancySceneState.getPlayerTimeMinutes();
+	int hour = minutes / 60;
+	int alarmHour = getAlarmHour();
+	if (alarmHour <= hour) {
+		alarmHour += 12 + ((hour - alarmHour) / 12) * 12;
+	}
+
+	int32 advance = (alarmHour - hour) * 60 - (int32)(minutes % 60);
+	NancySceneState.setPlayerTime((uint32)advance * 60000, kRelativeClockBump);
+
+	_isButtonPressed = true;
+	_solveSound = playSoundBlock(_buttonSoundBlock);
+}
+
+void AnalogClock::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	uint minutes = NancySceneState.getPlayerTimeMinutes();
+	Hand &hourHand = _hands[kHourHand];
+	Hand &minuteHand = _hands[kMinuteHand];
+	if (hourHand.movie.isVideoLoaded()) {
+		float hours = (minutes / 60) % 12 + (minutes % 60) / 60.0f;
+		drawHand(hourHand, (int)(hourHand.movie.getFrameCount() * hours / 12.0f));
+	}
+
+	drawHand(minuteHand, _minuteFrame);
+
+	if (hasAlarm()) {
+		drawHand(_hands[kAlarmHand], getAlarmHour());
+	}
+
+	if (_isButtonPressed && !_image.empty()) {
+		_drawSurface.blitFrom(_image, _buttonDownSrc, _buttonDest.origin());
+	}
+
+	_needsRedraw = true;
+}
+
+void AnalogClock::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_isButtonPressed) {
+			if (!isSolveSoundPlaying()) {
+				_state = kActionTrigger;
+			}
+
+			break;
+		}
+
+		// The minute hand ticks audibly
+		Hand &minuteHand = _hands[kMinuteHand];
+		if (minuteHand.movie.isVideoLoaded()) {
+			int frame = (int)(minuteHand.movie.getFrameCount() * (NancySceneState.getPlayerTimeMinutes() % 60) / 60.0f);
+			if (frame != _minuteFrame) {
+				playSoundBlock(_tickSound);
+				_minuteFrame = frame;
+			}
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		if (_exitRequested) {
+			_exitScene.execute();
+		} else {
+			_solveScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void AnalogClock::handleInput(NancyInput &input) {
+	if (_state != kRun || _isButtonPressed || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	if (hoverExitHotspot(input)) {
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	if (!hasAlarm()) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+	bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+	if (_hands[kAlarmHand].dest.contains(pos)) {
+		setDataCursor(_alarmHandCursor);
+		if (clicked) {
+			// Each click moves the alarm on by an hour
+			TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+			assert(table);
+			table->setValue(_alarmIndex, (getAlarmHour() + 1) % 12);
+			playSoundBlock(_alarmHandSound);
+			input.eatMouseInput();
+		}
+	} else if (_buttonDest.contains(pos)) {
+		setDataCursor(_buttonCursor);
+		if (clicked) {
+			pressAlarmButton();
+			input.eatMouseInput();
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/analogclock.h b/engines/nancy/action/puzzle/analogclock.h
new file mode 100644
index 00000000000..fc569f81440
--- /dev/null
+++ b/engines/nancy/action/puzzle/analogclock.h
@@ -0,0 +1,97 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_ANALOGCLOCK_H
+#define NANCY_ACTION_ANALOGCLOCK_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 alarm clock (AR 196). The hands follow the player clock, and are frames of
+// their own movies. Clicking the alarm hand moves it on by an hour; pressing the alarm
+// button then advances the player clock to the next time the alarm hour comes around.
+class AnalogClock : public PuzzleRecord {
+public:
+	AnalogClock() : PuzzleRecord(7) {}
+	virtual ~AnalogClock() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "AnalogClock"; }
+
+	// The alarm is disabled without a value table entry for it
+	static const int16 kNoAlarm = -4;
+
+	enum HandType {
+		kHourHand,
+		kMinuteHand,
+		kAlarmHand,
+		kNumHands
+	};
+
+	struct Hand {
+		Common::Rect dest;
+		Common::Path movieName;
+
+		MoviePlayer movie;
+		Graphics::ManagedSurface frameSurface;
+		int frame = -1;
+	};
+
+	bool hasAlarm() const { return _alarmIndex != kNoAlarm; }
+	int16 getAlarmHour() const;
+	void drawHand(Hand &hand, int frame);
+	void pressAlarmButton();
+
+	void redraw();
+
+	// -- File data --
+	int16 _alarmIndex = kNoAlarm;		// value table entry holding the alarm hour
+	Hand _hands[kNumHands];
+	uint16 _alarmHandCursor = 0;
+	RandomSoundBlock _alarmHandSound;
+	RandomSoundBlock _tickSound;
+	Common::Rect _buttonDownSrc;
+	Common::Rect _buttonDest;
+	uint16 _buttonCursor = 0;
+	RandomSoundBlock _buttonSoundBlock;
+
+	// -- Runtime state --
+	int _minuteFrame = 0;
+	bool _isButtonPressed = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_ANALOGCLOCK_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index cbb94ba7e45..5b903d48c03 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -21,6 +21,7 @@ MODULE_OBJS = \
   action/secondarymovie.o \
   action/secondaryvideo.o \
   action/puzzle/adjustpuzzle.o \
+  action/puzzle/analogclock.o \
   action/puzzle/angletosspuzzle.o \
   action/puzzle/arcadepuzzle.o \
   action/puzzle/assemblypuzzle.o \


Commit: 16a61b5446a0bdbcb281eedf1d49d2d7337d1fc3
    https://github.com/scummvm/scummvm/commit/16a61b5446a0bdbcb281eedf1d49d2d7337d1fc3
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:03+03:00

Commit Message:
NANCY: NANCY16: Implement KeypadPhone

- Record 116 replaces the older telephones: the player dials on a
  keypad, and the number is looked up shortly after the last key
- Known numbers, numbers matching a class (area code and length)
  and anything else each lead to their own scene
- Enhancement: the dial tone stops at the first key press

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index da02953c9b9..42b01eb8399 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -64,7 +64,7 @@
 #include "engines/nancy/action/puzzle/matchpuzzle.h"
 #include "engines/nancy/action/puzzle/hamradiopuzzle.h"
 #include "engines/nancy/action/puzzle/hangmanpuzzle.h"
-//#include "engines/nancy/action/puzzle/keypadphone.h"
+#include "engines/nancy/action/puzzle/keypadphone.h"
 #include "engines/nancy/action/puzzle/lettergridpuzzle.h"
 #include "engines/nancy/action/puzzle/leverpuzzle.h"
 #include "engines/nancy/action/puzzle/magnetmazepuzzle.h"
@@ -443,7 +443,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Telephone(Telephone::kTelephone);
 		else
-			return nullptr;	//return new KeypadPhone();	// TODO
+			return new KeypadPhone();
 	case 117:
 		return new SliderPuzzle();
 	case 118:
diff --git a/engines/nancy/action/puzzle/keypadphone.cpp b/engines/nancy/action/puzzle/keypadphone.cpp
new file mode 100644
index 00000000000..75eb003882d
--- /dev/null
+++ b/engines/nancy/action/puzzle/keypadphone.cpp
@@ -0,0 +1,259 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/keypadphone.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint32 kMsPerTick = 20;
+static const float kTicksPerSecond = 50.0f;
+
+void KeypadPhone::readScene(Common::SeekableReadStream &stream, SceneChangeWithFlag &scene) {
+	scene._sceneChange.sceneID = stream.readUint16LE();
+	scene._sceneChange.frameID = stream.readUint16LE();
+	scene._sceneChange.continueSceneSound = kContinueSceneSound;
+	scene._flag.label = stream.readSint16LE();
+	scene._flag.flag = stream.readByte();
+}
+
+// Numbers are written with dashes and spaces; only the keys on the keypad count
+Common::String KeypadPhone::keepDialable(const Common::String &text) {
+	Common::String result;
+	for (char c : text) {
+		if (Common::isDigit(c) || c == '*' || c == '#') {
+			result += c;
+		}
+	}
+
+	return result;
+}
+
+void KeypadPhone::readData(Common::SeekableReadStream &stream) {
+	_maxDigits = stream.readUint32LE();
+	_noClassWaitTicks = (int32)(stream.readFloatLE() * kTicksPerSecond);
+	_classWaitTicks = (int32)(stream.readFloatLE() * kTicksPerSecond);
+
+	_numbers.resize(stream.readUint16LE());
+	for (Number &number : _numbers) {
+		Common::String digits;
+		readFilename(stream, digits);
+		number.digits = keepDialable(digits);
+		number.condition.label = stream.readSint16LE();
+		number.condition.flag = stream.readByte();
+		readScene(stream, number.scene);
+	}
+
+	_buttonCursor = stream.readUint16LE();
+	readFilename(stream, _imageName);
+	_pressedTicks = (int32)(stream.readFloatLE() * kTicksPerSecond);
+
+	_buttons.resize(stream.readUint16LE());
+	for (Button &button : _buttons) {
+		button.key = stream.readByte();
+		readRect(stream, button.src);
+		readRect(stream, button.dest);
+		button.sound.readData(stream);
+	}
+
+	_classes.resize(stream.readUint16LE());
+	for (NumberClass &numberClass : _classes) {
+		readFilename(stream, numberClass.prefix);
+		numberClass.minExtraDigits = stream.readSint32LE();
+		numberClass.maxExtraDigits = stream.readSint32LE();
+		readScene(stream, numberClass.scene);
+	}
+
+	_dialToneBlock.readData(stream);
+	readExitHotspots(stream, _exits);
+}
+
+void KeypadPhone::init() {
+	initViewportSurface();
+	loadImage();
+
+	_lastTickTime = g_nancy->getTotalPlayTime();
+	_dialTone = playSoundBlock(_dialToneBlock);
+}
+
+// A class matches digits that start with its prefix and have the right amount after it
+const KeypadPhone::NumberClass *KeypadPhone::findClass() const {
+	for (const NumberClass &numberClass : _classes) {
+		int32 extra = (int32)_dialed.size() - (int32)numberClass.prefix.size();
+		if (_dialed.hasPrefix(numberClass.prefix) && extra >= numberClass.minExtraDigits && extra <= numberClass.maxExtraDigits) {
+			return &numberClass;
+		}
+	}
+
+	return nullptr;
+}
+
+void KeypadPhone::pressButton(const Button &button) {
+	_dialed += button.key;
+
+	// Enhancement: the original keeps the dial tone going until the phone changes scene
+	g_nancy->_sound->stopSound(_dialTone);
+	playSoundBlock(button.sound);
+
+	// The number gets dialed once the player stops pressing keys, sooner when it
+	// already looks complete
+	if (_dialed.size() >= _maxDigits) {
+		_dialWaitTicks = 1;
+	} else {
+		_dialWaitTicks = findClass() ? _classWaitTicks : _noClassWaitTicks;
+	}
+
+	_pressedButton = &button;
+	_pressedTicksLeft = _pressedTicks;
+}
+
+void KeypadPhone::dial() {
+	const Number *emptyNumber = nullptr;
+	const Number *dialedNumber = nullptr;
+	for (const Number &number : _numbers) {
+		if (number.digits.empty()) {
+			emptyNumber = &number;
+		} else if (number.digits == _dialed) {
+			dialedNumber = &number;
+		}
+	}
+
+	const NumberClass *numberClass = findClass();
+	if (dialedNumber && (dialedNumber->condition.label == kEvNoEvent ||
+			NancySceneState.getEventFlag(dialedNumber->condition.label, dialedNumber->condition.flag))) {
+		_endScene = dialedNumber->scene;
+	} else if (numberClass) {
+		_endScene = numberClass->scene;
+	} else if (emptyNumber) {
+		_endScene = emptyNumber->scene;
+	}
+
+	g_nancy->_sound->stopSound(_dialTone);
+	_hasDialed = true;
+}
+
+void KeypadPhone::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	if (_pressedButton && _pressedTicksLeft > 0) {
+		_drawSurface.blitFrom(_image, _pressedButton->src, _pressedButton->dest.origin());
+	}
+
+	_needsRedraw = true;
+}
+
+void KeypadPhone::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+		int32 ticks = (now - _lastTickTime) / kMsPerTick;
+		_lastTickTime += ticks * kMsPerTick;
+
+		if (ticks > 0) {
+			_pressedTicksLeft = MAX<int32>(_pressedTicksLeft - ticks, 0);
+
+			if (_dialWaitTicks > 0) {
+				_dialWaitTicks = MAX<int32>(_dialWaitTicks - ticks, 0);
+				if (_dialWaitTicks == 0) {
+					dial();
+				}
+			}
+		}
+
+		redraw();
+
+		if (_hasDialed) {
+			_state = kActionTrigger;
+		}
+
+		break;
+	}
+	case kActionTrigger:
+		if (_hasDialed) {
+			_endScene.execute();
+		} else {
+			g_nancy->_sound->stopSound(_dialTone);
+			_exitScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void KeypadPhone::handleInput(NancyInput &input) {
+	if (_state != kRun || _hasDialed) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+	bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+	for (const ExitHotspot &exit : _exits) {
+		if (!exit.hotspot.contains(pos)) {
+			continue;
+		}
+
+		setDataCursor(exit.cursorType);
+		if (clicked) {
+			_exitScene._sceneChange = exit.scene;
+			_exitScene._flag = exit.flag;
+			_state = kActionTrigger;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	for (const Button &button : _buttons) {
+		if (!button.dest.contains(pos)) {
+			continue;
+		}
+
+		setDataCursor(_buttonCursor);
+		if (clicked) {
+			pressButton(button);
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/keypadphone.h b/engines/nancy/action/puzzle/keypadphone.h
new file mode 100644
index 00000000000..0b39e70328d
--- /dev/null
+++ b/engines/nancy/action/puzzle/keypadphone.h
@@ -0,0 +1,109 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_KEYPADPHONE_H
+#define NANCY_ACTION_KEYPADPHONE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 telephone (AR 116), which replaced the older Telephone records. The player
+// dials on a keypad, and a short while after the last key the dialed number is looked
+// up: a known number leads to its own scene, a number that fits one of the number
+// classes (area code and length) leads to that class's scene, and anything else to
+// the scene of the record's empty number.
+class KeypadPhone : public PuzzleRecord {
+public:
+	KeypadPhone() : PuzzleRecord(7) {}
+	virtual ~KeypadPhone() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "KeypadPhone"; }
+
+	struct Number {
+		Common::String digits;
+		FlagDescription condition;		// the number only answers while this is set
+		SceneChangeWithFlag scene;
+	};
+
+	struct Button {
+		char key = 0;
+		Common::Rect src;				// pressed look
+		Common::Rect dest;
+		RandomSoundBlock sound;
+	};
+
+	struct NumberClass {
+		Common::String prefix;
+		int32 minExtraDigits = 0;
+		int32 maxExtraDigits = 0;
+		SceneChangeWithFlag scene;
+	};
+
+	static void readScene(Common::SeekableReadStream &stream, SceneChangeWithFlag &scene);
+	static Common::String keepDialable(const Common::String &text);
+
+	const NumberClass *findClass() const;
+	void pressButton(const Button &button);
+	void dial();
+
+	void redraw();
+
+	// -- File data --
+	uint32 _maxDigits = 0;
+	int32 _noClassWaitTicks = 0;		// after the last key, when the digits fit no class yet
+	int32 _classWaitTicks = 0;			// after the last key, when they do
+	Common::Array<Number> _numbers;		// includes the one with no digits
+	uint16 _buttonCursor = 0;
+	int32 _pressedTicks = 0;
+	Common::Array<Button> _buttons;
+	Common::Array<NumberClass> _classes;
+	RandomSoundBlock _dialToneBlock;
+	Common::Array<ExitHotspot> _exits;
+
+	// -- Runtime state --
+	Common::String _dialed;
+	int32 _dialWaitTicks = 0;
+	int32 _pressedTicksLeft = 0;
+	const Button *_pressedButton = nullptr;
+	uint32 _lastTickTime = 0;
+
+	SoundDescription _dialTone;
+	SceneChangeWithFlag _endScene;
+	bool _hasDialed = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_KEYPADPHONE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 5b903d48c03..826b5541c94 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -47,6 +47,7 @@ MODULE_OBJS = \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \
   action/puzzle/hangmanpuzzle.o \
+  action/puzzle/keypadphone.o \
   action/puzzle/lettergridpuzzle.o \
   action/puzzle/leverpuzzle.o \
   action/puzzle/magnetmazepuzzle.o \


Commit: 5690eb6eebc6e93f492872997988186962a42d1f
    https://github.com/scummvm/scummvm/commit/5690eb6eebc6e93f492872997988186962a42d1f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:05+03:00

Commit Message:
NANCY: NANCY16: Implement MinesweeperPuzzle

- Record 195, the snow shovel: the frozen pond is cleared from its
  edges inwards, and thin ice has to be marked with cones
- Cracking the thin ice too often sends the player into the water

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 42b01eb8399..2c320275d41 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -71,7 +71,7 @@
 #include "engines/nancy/action/puzzle/mazechasepuzzle.h"
 #include "engines/nancy/action/puzzle/memorypuzzle.h"
 #include "engines/nancy/action/puzzle/meterpuzzle.h"
-//#include "engines/nancy/action/puzzle/minesweeperpuzzle.h"
+#include "engines/nancy/action/puzzle/minesweeperpuzzle.h"
 #include "engines/nancy/action/puzzle/mindpuzzle.h"
 #include "engines/nancy/action/puzzle/minigolfpuzzle.h"
 #include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
@@ -701,7 +701,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 194:
 		return nullptr; // return new CookingPuzzle();
 	case 195:
-		return nullptr; // return new MinesweeperPuzzle();	// TODO
+		return new MinesweeperPuzzle();
 	case 196:
 		return new AnalogClock();
 	case 197:
diff --git a/engines/nancy/action/puzzle/minesweeperpuzzle.cpp b/engines/nancy/action/puzzle/minesweeperpuzzle.cpp
new file mode 100644
index 00000000000..951a97b4347
--- /dev/null
+++ b/engines/nancy/action/puzzle/minesweeperpuzzle.cpp
@@ -0,0 +1,556 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/font.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/minesweeperpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+MinesweeperPuzzle::~MinesweeperPuzzle() {
+	if (_shovel.isHeld || _cone.isHeld) {
+		g_nancy->_cursor->showCursor(true);
+	}
+}
+
+void MinesweeperPuzzle::readTool(Common::SeekableReadStream &stream, Tool &tool) {
+	readRect(stream, tool.idleSrc);
+	readRect(stream, tool.activeSrc);
+	tool.cursor = stream.readUint16LE();
+	tool.hotspot.x = stream.readSint32LE();
+	tool.hotspot.y = stream.readSint32LE();
+	tool.pickUpSound.readData(stream);
+	tool.putDownSound.readData(stream);
+}
+
+void MinesweeperPuzzle::readEnding(Common::SeekableReadStream &stream, Ending &ending) {
+	ending.scene._sceneChange.sceneID = stream.readUint16LE();
+	ending.scene._sceneChange.frameID = stream.readUint16LE();
+	ending.scene._sceneChange.continueSceneSound = kContinueSceneSound;
+	ending.scene._flag.label = stream.readSint16LE();
+	ending.scene._flag.flag = stream.readByte();
+	ending.sound.readData(stream);
+}
+
+void MinesweeperPuzzle::readData(Common::SeekableReadStream &stream) {
+	stream.skip(33);
+	_maxCracks = stream.readUint32LE();
+
+	readFilename(stream, _toolImageName);
+	readTool(stream, _shovel);
+	readTool(stream, _cone);
+	_hoverCursor = stream.readUint16LE();
+
+	readRect(stream, _shovelHotspot);
+	readRect(stream, _coneHotspot);
+	readRect(stream, _coneCountDest);
+	_coneCountColor = stream.readUint32BE();
+	readFilename(stream, _coneCountFontName);
+
+	_digSound.readData(stream);
+	_crackSound.readData(stream);
+	_digNotAllowedSound.readData(stream);
+	_coneNotAllowedSound.readData(stream);
+
+	readEnding(stream, _solveEnding);
+	readEnding(stream, _failEnding);
+
+	for (uint i = 0; i < ARRAYSIZE(_tileSrcs); ++i) {
+		readRect(stream, _tileSrcs[i]);
+	}
+
+	readRect(stream, _coneSrc);
+	readFilename(stream, _boardImageName);
+
+	// Clicks are tested without the cells' right and bottom edges, so neighbors don't overlap
+	Common::Rect firstCell;
+	readExclusiveRect(stream, firstCell);
+	int32 stepX = firstCell.width() + 1 + stream.readSint32LE();
+	int32 stepY = firstCell.height() + 1 + stream.readSint32LE();
+
+	// The board is drawn as rows of '-' for solid ice and '*' for thin ice
+	uint16 numRows = stream.readUint16LE();
+	Common::Array<Common::String> rows(numRows);
+	for (Common::String &row : rows) {
+		readFilename(stream, row);
+	}
+
+	_height = numRows;
+	_width = numRows ? rows[0].size() : 0;
+	if (_width == 0) {
+		error("MinesweeperPuzzle: empty board");
+	}
+
+	_cells.resize(_width * _height);
+	for (uint y = 0; y < _height; ++y) {
+		if (rows[y].size() != _width) {
+			error("MinesweeperPuzzle: board rows have different lengths");
+		}
+
+		for (uint x = 0; x < _width; ++x) {
+			Cell &cell = _cells[y * _width + x];
+			cell.dest = firstCell;
+			cell.dest.translate(x * stepX, y * stepY);
+			cell.type = rows[y][x] == '*' ? kThinIce : kClear;
+		}
+	}
+
+	for (uint i = 0; i < _cells.size(); ++i) {
+		if (_cells[i].type == kThinIce) {
+			continue;
+		}
+
+		Common::Array<int> neighbors;
+		getNeighbors(i, neighbors);
+		for (int neighbor : neighbors) {
+			if (_cells[neighbor].type == kThinIce) {
+				++_cells[i].type;
+			}
+		}
+	}
+
+	readExitHotspot(stream);
+}
+
+void MinesweeperPuzzle::init() {
+	initViewportSurface();
+
+	g_nancy->_resource->loadImage(_toolImageName, _toolImage);
+	_toolImage.setTransparentColor(_drawSurface.getTransparentColor());
+	g_nancy->_resource->loadImage(_boardImageName, _boardImage);
+	_boardImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+	// There are exactly as many cones as there are patches of thin ice
+	_conesLeft = 0;
+	for (const Cell &cell : _cells) {
+		if (cell.type == kThinIce) {
+			++_conesLeft;
+		}
+	}
+
+	NancySceneState.setNoHeldItem();
+	redraw();
+}
+
+int MinesweeperPuzzle::cellAt(const Common::Point &pos) const {
+	for (uint i = 0; i < _cells.size(); ++i) {
+		if (_cells[i].dest.contains(pos)) {
+			return i;
+		}
+	}
+
+	return -1;
+}
+
+void MinesweeperPuzzle::getNeighbors(int cell, Common::Array<int> &neighbors) const {
+	int x = cell % _width;
+	int y = cell / _width;
+
+	for (int dy = -1; dy <= 1; ++dy) {
+		for (int dx = -1; dx <= 1; ++dx) {
+			if (dx == 0 && dy == 0) {
+				continue;
+			}
+
+			int nx = x + dx;
+			int ny = y + dy;
+			if (nx >= 0 && ny >= 0 && nx < (int)_width && ny < (int)_height) {
+				neighbors.push_back(ny * _width + nx);
+			}
+		}
+	}
+}
+
+// Cells on the edge of the pond can always be reached; the others only
+// once one of their neighbors has been cleared
+bool MinesweeperPuzzle::isReachable(int cell) const {
+	Common::Array<int> neighbors;
+	getNeighbors(cell, neighbors);
+	if (neighbors.size() != 8) {
+		return true;
+	}
+
+	for (int neighbor : neighbors) {
+		if (isRevealed(neighbor)) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+MinesweeperPuzzle::DigResult MinesweeperPuzzle::dig(const Common::Point &pos) {
+	int id = cellAt(pos);
+	if (id == -1) {
+		return kOffBoard;
+	}
+
+	if (!isReachable(id)) {
+		return kNotAllowed;
+	}
+
+	Cell &cell = _cells[id];
+	if (cell.shownType == kThinIce) {
+		return kFellThrough;
+	}
+
+	if (cell.hasCone || isRevealed(id)) {
+		return kNotAllowed;
+	}
+
+	cell.shownType = cell.type;
+	return cell.type == kThinIce ? kCracked : kDug;
+}
+
+MinesweeperPuzzle::ConeResult MinesweeperPuzzle::toggleCone(const Common::Point &pos) {
+	int id = cellAt(pos);
+	if (id == -1) {
+		return kConeOffBoard;
+	}
+
+	if (!isReachable(id)) {
+		return kConeNotAllowed;
+	}
+
+	Cell &cell = _cells[id];
+	if (cell.shownType == kCovered || cell.shownType == kThinIce) {
+		cell.hasCone = !cell.hasCone;
+		return cell.hasCone ? kConePlaced : kConeRemoved;
+	}
+
+	if (!cell.hasCone) {
+		return kConeNotAllowed;
+	}
+
+	cell.hasCone = false;
+	return kConeRemoved;
+}
+
+bool MinesweeperPuzzle::removeCone(const Common::Point &pos) {
+	int id = cellAt(pos);
+	if (id == -1 || !_cells[id].hasCone) {
+		return false;
+	}
+
+	_cells[id].hasCone = false;
+	return true;
+}
+
+// Solved once every patch of thin ice has a cone on it, at which point the rest of the ice is cleared
+bool MinesweeperPuzzle::isSolved() {
+	for (const Cell &cell : _cells) {
+		if (cell.type == kThinIce && !cell.hasCone) {
+			return false;
+		}
+	}
+
+	for (Cell &cell : _cells) {
+		if (cell.shownType != cell.type && !cell.hasCone) {
+			cell.shownType = cell.type;
+			cell.hasCone = false;
+		}
+	}
+
+	return true;
+}
+
+void MinesweeperPuzzle::handleDigResult(DigResult result) {
+	switch (result) {
+	case kNotAllowed:
+		playSoundBlock(_digNotAllowedSound);
+		break;
+	case kFellThrough:
+		startEnding(_failEnding);
+		break;
+	case kCracked:
+		if (++_cracks > _maxCracks) {
+			startEnding(_failEnding);
+		} else {
+			playSoundBlock(_crackSound);
+		}
+
+		break;
+	case kDug:
+		if (isSolved()) {
+			startEnding(_solveEnding);
+		} else {
+			playSoundBlock(_digSound);
+		}
+
+		break;
+	default:
+		break;
+	}
+}
+
+void MinesweeperPuzzle::handleConeResult(ConeResult result) {
+	switch (result) {
+	case kConePlaced:
+		playSoundBlock(_cone.putDownSound);
+		if (--_conesLeft == 0) {
+			if (_cone.isHeld) {
+				putDownTool(_cone);
+			}
+
+			if (isSolved()) {
+				startEnding(_solveEnding);
+			}
+		}
+
+		break;
+	case kConeRemoved:
+		playSoundBlock(_cone.pickUpSound);
+		++_conesLeft;
+		break;
+	case kConeNotAllowed:
+		playSoundBlock(_coneNotAllowedSound);
+		break;
+	default:
+		break;
+	}
+}
+
+void MinesweeperPuzzle::pickUpTool(Tool &tool) {
+	tool.isHeld = true;
+	g_nancy->_cursor->showCursor(false);
+	playSoundBlock(tool.pickUpSound);
+}
+
+void MinesweeperPuzzle::putDownTool(Tool &tool) {
+	tool.isHeld = false;
+	g_nancy->_cursor->showCursor(true);
+	playSoundBlock(tool.putDownSound);
+}
+
+void MinesweeperPuzzle::startEnding(const Ending &ending) {
+	_isEnding = true;
+	_endScene = ending.scene;
+	_endSound = playSoundBlock(ending.sound);
+
+	if (_shovel.isHeld) {
+		putDownTool(_shovel);
+	}
+
+	if (_cone.isHeld) {
+		putDownTool(_cone);
+	}
+}
+
+void MinesweeperPuzzle::setCursor(const Common::Point &pos) {
+	if (_shovel.isHeld) {
+		setDataCursor(_shovel.cursor);
+		return;
+	}
+
+	if (_cone.isHeld) {
+		setDataCursor(_cone.cursor);
+		return;
+	}
+
+	int id = cellAt(pos);
+	if ((id != -1 && _cells[id].hasCone) || _shovelHotspot.contains(pos) ||
+			(_coneHotspot.contains(pos) && _conesLeft > 0)) {
+		setDataCursor(_hoverCursor);
+	}
+}
+
+// Some tiles lie partly or wholly outside the board image, e.g. the covered one; only
+// the part inside it gets drawn
+void MinesweeperPuzzle::drawTile(const Common::Rect &src, const Common::Point &dest) {
+	Common::Rect clipped = src.findIntersectingRect(Common::Rect(_boardImage.w, _boardImage.h));
+	if (clipped.isEmpty()) {
+		return;
+	}
+
+	_drawSurface.blitFrom(_boardImage, clipped, dest + (clipped.origin() - src.origin()));
+}
+
+void MinesweeperPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	if (!_shovel.isHeld) {
+		_drawSurface.blitFrom(_toolImage, _shovel.idleSrc, _shovelHotspot.origin());
+	}
+
+	if (!_cone.isHeld && _conesLeft > 0) {
+		_drawSurface.blitFrom(_toolImage, _cone.idleSrc, _coneHotspot.origin());
+	}
+
+	const Font *countFont = g_nancy->_graphics->getFont(_coneCountFontName);
+	if (countFont && countFont->getSystemFont()) {
+		uint32 color = argbToColor(_drawSurface.format, _coneCountColor);
+		countFont->getSystemFont()->drawString(_drawSurface.surfacePtr(), Common::String::format("%d", _conesLeft),
+			_coneCountDest.left, _coneCountDest.top, _coneCountDest.width(), color);
+	}
+
+	for (const Cell &cell : _cells) {
+		drawTile(_tileSrcs[cell.shownType], cell.dest.origin());
+		if (cell.hasCone) {
+			drawTile(_coneSrc, cell.dest.origin());
+		}
+	}
+
+	// A held tool takes the place of the cursor, and changes when it can be used where it points
+	const Tool *heldTool = _shovel.isHeld ? &_shovel : (_cone.isHeld ? &_cone : nullptr);
+	if (heldTool) {
+		bool usable = false;
+		int id = cellAt(_mousePos);
+		if (id != -1 && isReachable(id)) {
+			usable = !isRevealed(id) || (heldTool == &_cone && _cells[id].type == kThinIce);
+		}
+
+		// The tool hangs off the (blank) cursor's top left corner
+		_drawSurface.blitFrom(_toolImage, usable ? heldTool->activeSrc : heldTool->idleSrc,
+			_mousePos - g_nancy->_cursor->getCurrentCursorHotspot() + heldTool->hotspot);
+	}
+
+	_needsRedraw = true;
+}
+
+void MinesweeperPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_isEnding && (_endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound))) {
+			_state = kActionTrigger;
+		}
+
+		redraw();
+		break;
+	case kActionTrigger:
+		if (_exitRequested) {
+			_exitScene.execute();
+		} else {
+			_endScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void MinesweeperPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _isEnding || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+	_mousePos = pos;
+
+	setCursor(pos);
+
+	// Right clicking uses the tool that isn't held
+	if (input.input & NancyInput::kRightMouseButtonUp) {
+		if (_cone.isHeld) {
+			DigResult result = dig(pos);
+			if (result != kOffBoard) {
+				handleDigResult(result);
+			}
+		} else if (_shovel.isHeld) {
+			if (_conesLeft > 0) {
+				ConeResult result = toggleCone(pos);
+				if (result != kConeOffBoard) {
+					handleConeResult(result);
+				}
+			} else if (removeCone(pos)) {
+				playSoundBlock(_cone.pickUpSound);
+				++_conesLeft;
+			}
+		}
+
+		input.eatMouseInput();
+		return;
+	}
+
+	if (input.input & NancyInput::kLeftMouseButtonUp) {
+		bool shovelWasHeld = _shovel.isHeld;
+		bool coneWasHeld = _cone.isHeld;
+
+		if (shovelWasHeld) {
+			DigResult result = dig(pos);
+			if (result != kOffBoard) {
+				handleDigResult(result);
+				input.eatMouseInput();
+				return;
+			}
+
+			putDownTool(_shovel);
+		}
+
+		if (coneWasHeld) {
+			ConeResult result = toggleCone(pos);
+			if (result != kConeOffBoard) {
+				handleConeResult(result);
+				input.eatMouseInput();
+				return;
+			}
+
+			putDownTool(_cone);
+		}
+
+		if (_shovelHotspot.contains(pos) && !shovelWasHeld) {
+			pickUpTool(_shovel);
+		} else if (_coneHotspot.contains(pos) && !coneWasHeld && _conesLeft > 0) {
+			pickUpTool(_cone);
+		} else if (removeCone(pos)) {
+			// Picking up a cone from the ice
+			++_conesLeft;
+			pickUpTool(_cone);
+		} else if (!shovelWasHeld && !coneWasHeld && hoverExitHotspot(input)) {
+			_exitRequested = true;
+		}
+
+		input.eatMouseInput();
+		return;
+	}
+
+	if (!_shovel.isHeld && !_cone.isHeld) {
+		hoverExitHotspot(input);
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/minesweeperpuzzle.h b/engines/nancy/action/puzzle/minesweeperpuzzle.h
new file mode 100644
index 00000000000..71dc073983c
--- /dev/null
+++ b/engines/nancy/action/puzzle/minesweeperpuzzle.h
@@ -0,0 +1,158 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_MINESWEEPERPUZZLE_H
+#define NANCY_ACTION_MINESWEEPERPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 snow shovel puzzle (AR 195), a take on Minesweeper. The frozen pond is cleared
+// with a shovel, working inwards from its edges, and the thin patches of ice have to be
+// marked with cones. Cracking the thin ice too often sends the player into the water.
+class MinesweeperPuzzle : public PuzzleRecord {
+public:
+	MinesweeperPuzzle() : PuzzleRecord(7) {}
+	virtual ~MinesweeperPuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "MinesweeperPuzzle"; }
+
+	// Tile types; the ones past kClear are for clear ice with 0-8 thin neighbors
+	enum TileType {
+		kCovered	= 0,
+		kThinIce	= 1,
+		kClear		= 2
+	};
+
+	enum DigResult {
+		kDug,
+		kCracked,
+		kNotAllowed,
+		kFellThrough,
+		kOffBoard
+	};
+
+	enum ConeResult {
+		kConePlaced,
+		kConeRemoved,
+		kConeNotAllowed,
+		kConeOffBoard
+	};
+
+	struct Tool {
+		Common::Rect idleSrc;
+		Common::Rect activeSrc;		// shown while the tool can be used where it points
+		uint16 cursor = 0;
+		Common::Point hotspot;			// where the held sprite goes, relative to the cursor's top left
+		RandomSoundBlock pickUpSound;
+		RandomSoundBlock putDownSound;
+		bool isHeld = false;
+	};
+
+	struct Cell {
+		byte type = kCovered;
+		byte shownType = kCovered;
+		bool hasCone = false;
+		Common::Rect dest;
+	};
+
+	struct Ending {
+		SceneChangeWithFlag scene;
+		RandomSoundBlock sound;
+	};
+
+	static void readTool(Common::SeekableReadStream &stream, Tool &tool);
+	static void readEnding(Common::SeekableReadStream &stream, Ending &ending);
+
+	int cellAt(const Common::Point &pos) const;
+	void getNeighbors(int cell, Common::Array<int> &neighbors) const;
+	bool isReachable(int cell) const;
+	bool isRevealed(int cell) const { return _cells[cell].shownType == _cells[cell].type; }
+
+	DigResult dig(const Common::Point &pos);
+	ConeResult toggleCone(const Common::Point &pos);
+	bool removeCone(const Common::Point &pos);
+	bool isSolved();
+
+	void handleDigResult(DigResult result);
+	void handleConeResult(ConeResult result);
+	void pickUpTool(Tool &tool);
+	void putDownTool(Tool &tool);
+	void setCursor(const Common::Point &pos);
+	void startEnding(const Ending &ending);
+
+	void drawTile(const Common::Rect &src, const Common::Point &dest);
+	void redraw();
+
+	// -- File data --
+	uint32 _maxCracks = 0;
+	Common::Path _toolImageName;
+	Tool _shovel;
+	Tool _cone;
+	uint16 _hoverCursor = 0;
+	Common::Rect _shovelHotspot;
+	Common::Rect _coneHotspot;
+	Common::Rect _coneCountDest;
+	uint32 _coneCountColor = 0;		// ARGB
+	Common::String _coneCountFontName;
+	RandomSoundBlock _digSound;
+	RandomSoundBlock _crackSound;
+	RandomSoundBlock _digNotAllowedSound;
+	RandomSoundBlock _coneNotAllowedSound;
+	Ending _solveEnding;
+	Ending _failEnding;
+
+	Common::Rect _tileSrcs[11];
+	Common::Rect _coneSrc;
+	Common::Path _boardImageName;
+	uint _width = 0;
+	uint _height = 0;
+	Common::Array<Cell> _cells;
+
+	// -- Runtime state --
+	int _conesLeft = 0;
+	uint _cracks = 0;
+	bool _isEnding = false;
+	bool _exitRequested = false;
+	SceneChangeWithFlag _endScene;
+	SoundDescription _endSound;
+	Common::Point _mousePos;
+
+	Graphics::ManagedSurface _toolImage;
+	Graphics::ManagedSurface _boardImage;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_MINESWEEPERPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 826b5541c94..c2532387959 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -56,6 +56,7 @@ MODULE_OBJS = \
   action/puzzle/matchpuzzle.o \
   action/puzzle/memorypuzzle.o \
   action/puzzle/meterpuzzle.o \
+  action/puzzle/minesweeperpuzzle.o \
   action/puzzle/mindpuzzle.o \
   action/puzzle/minigolfpuzzle.o \
   action/puzzle/mirrorlightpuzzle.o \


Commit: 33b5bebdb2ea97b07f811d9d5531c68b092e1559
    https://github.com/scummvm/scummvm/commit/33b5bebdb2ea97b07f811d9d5531c68b092e1559
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:07+03:00

Commit Message:
NANCY: NANCY16: Hang held pieces off the cursor like the original

- MouseFollowObject can center the piece on the cursor image and move
  it by an offset
- FoxGeese, RowMatch and Minesweeper carry their pieces with it; the
  cursor stays visible

Changed paths:
    engines/nancy/action/puzzle/foxgeesepuzzle.cpp
    engines/nancy/action/puzzle/foxgeesepuzzle.h
    engines/nancy/action/puzzle/minesweeperpuzzle.cpp
    engines/nancy/action/puzzle/minesweeperpuzzle.h
    engines/nancy/action/puzzle/rowmatchpuzzle.cpp
    engines/nancy/action/puzzle/rowmatchpuzzle.h
    engines/nancy/action/puzzlerecord.cpp
    engines/nancy/action/puzzlerecord.h
    engines/nancy/cursor.h


diff --git a/engines/nancy/action/puzzle/foxgeesepuzzle.cpp b/engines/nancy/action/puzzle/foxgeesepuzzle.cpp
index 425924a8728..90afd90034c 100644
--- a/engines/nancy/action/puzzle/foxgeesepuzzle.cpp
+++ b/engines/nancy/action/puzzle/foxgeesepuzzle.cpp
@@ -25,7 +25,6 @@
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/sound.h"
 #include "engines/nancy/input.h"
-#include "engines/nancy/cursor.h"
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/state/scene.h"
@@ -45,12 +44,6 @@ static const Common::Point kBoardCenter(3, 3);
 // The geese win once they're down to this many
 static const uint kMinGeese = 4;
 
-FoxGeesePuzzle::~FoxGeesePuzzle() {
-	if (_isGooseHeld) {
-		g_nancy->_cursor->showCursor(true);
-	}
-}
-
 // The board is a cross made of three rows and three columns of seven holes each
 bool FoxGeesePuzzle::isOnBoard(const Common::Point &pos) {
 	if (pos.x < 0 || pos.y < 0 || pos.x >= kBoardSize || pos.y >= kBoardSize) {
@@ -401,7 +394,6 @@ void FoxGeesePuzzle::finishFoxMove() {
 
 void FoxGeesePuzzle::placeGoose(const Common::Point &mousePos) {
 	_isGooseHeld = false;
-	g_nancy->_cursor->showCursor(true);
 
 	Common::Point pos;
 	if (cellAt(mousePos, pos)) {
@@ -486,9 +478,8 @@ void FoxGeesePuzzle::redraw() {
 		_drawSurface.blitFrom(_image, _foxSrc, foxPos);
 	}
 
-	// A held goose takes the place of the cursor
 	if (_isGooseHeld) {
-		_drawSurface.blitFrom(_image, _gooseSrc, _mousePos - _heldGooseHotspot);
+		_drawSurface.blitFrom(_image, _gooseSrc, getHeldPiecePos(_mousePos, _gooseSrc, _heldGooseHotspot));
 	}
 
 	_needsRedraw = true;
@@ -570,7 +561,6 @@ void FoxGeesePuzzle::handleInput(NancyInput &input) {
 			pieceAt(pos) = kEmpty;
 			_heldGooseFrom = pos;
 			_isGooseHeld = true;
-			g_nancy->_cursor->showCursor(false);
 		}
 	}
 
diff --git a/engines/nancy/action/puzzle/foxgeesepuzzle.h b/engines/nancy/action/puzzle/foxgeesepuzzle.h
index 21ff1a036c1..31be96590b5 100644
--- a/engines/nancy/action/puzzle/foxgeesepuzzle.h
+++ b/engines/nancy/action/puzzle/foxgeesepuzzle.h
@@ -34,7 +34,7 @@ namespace Action {
 class FoxGeesePuzzle : public PuzzleRecord {
 public:
 	FoxGeesePuzzle() : PuzzleRecord(7) {}
-	virtual ~FoxGeesePuzzle();
+	virtual ~FoxGeesePuzzle() {}
 
 	void init() override;
 
@@ -102,7 +102,7 @@ protected:
 	Piece _startPieces[kBoardSize][kBoardSize] = {};
 	Common::Rect _foxSrc;
 	Common::Rect _gooseSrc;
-	Common::Point _heldGooseHotspot;
+	Common::Point _heldGooseHotspot;		// moves the held goose away from the cursor's center
 
 	uint16 _foxTurnCursor = 0;
 	uint16 _heldGooseCursor = 0;
diff --git a/engines/nancy/action/puzzle/minesweeperpuzzle.cpp b/engines/nancy/action/puzzle/minesweeperpuzzle.cpp
index 951a97b4347..63efb5047df 100644
--- a/engines/nancy/action/puzzle/minesweeperpuzzle.cpp
+++ b/engines/nancy/action/puzzle/minesweeperpuzzle.cpp
@@ -24,7 +24,6 @@
 #include "engines/nancy/resource.h"
 #include "engines/nancy/sound.h"
 #include "engines/nancy/input.h"
-#include "engines/nancy/cursor.h"
 #include "engines/nancy/font.h"
 #include "engines/nancy/util.h"
 
@@ -34,12 +33,6 @@
 namespace Nancy {
 namespace Action {
 
-MinesweeperPuzzle::~MinesweeperPuzzle() {
-	if (_shovel.isHeld || _cone.isHeld) {
-		g_nancy->_cursor->showCursor(true);
-	}
-}
-
 void MinesweeperPuzzle::readTool(Common::SeekableReadStream &stream, Tool &tool) {
 	readRect(stream, tool.idleSrc);
 	readRect(stream, tool.activeSrc);
@@ -339,13 +332,11 @@ void MinesweeperPuzzle::handleConeResult(ConeResult result) {
 
 void MinesweeperPuzzle::pickUpTool(Tool &tool) {
 	tool.isHeld = true;
-	g_nancy->_cursor->showCursor(false);
 	playSoundBlock(tool.pickUpSound);
 }
 
 void MinesweeperPuzzle::putDownTool(Tool &tool) {
 	tool.isHeld = false;
-	g_nancy->_cursor->showCursor(true);
 	playSoundBlock(tool.putDownSound);
 }
 
@@ -426,9 +417,8 @@ void MinesweeperPuzzle::redraw() {
 			usable = !isRevealed(id) || (heldTool == &_cone && _cells[id].type == kThinIce);
 		}
 
-		// The tool hangs off the (blank) cursor's top left corner
-		_drawSurface.blitFrom(_toolImage, usable ? heldTool->activeSrc : heldTool->idleSrc,
-			_mousePos - g_nancy->_cursor->getCurrentCursorHotspot() + heldTool->hotspot);
+		const Common::Rect &src = usable ? heldTool->activeSrc : heldTool->idleSrc;
+		_drawSurface.blitFrom(_toolImage, src, getHeldPiecePos(_mousePos, src, heldTool->hotspot));
 	}
 
 	_needsRedraw = true;
diff --git a/engines/nancy/action/puzzle/minesweeperpuzzle.h b/engines/nancy/action/puzzle/minesweeperpuzzle.h
index 71dc073983c..095920c666b 100644
--- a/engines/nancy/action/puzzle/minesweeperpuzzle.h
+++ b/engines/nancy/action/puzzle/minesweeperpuzzle.h
@@ -34,7 +34,7 @@ namespace Action {
 class MinesweeperPuzzle : public PuzzleRecord {
 public:
 	MinesweeperPuzzle() : PuzzleRecord(7) {}
-	virtual ~MinesweeperPuzzle();
+	virtual ~MinesweeperPuzzle() {}
 
 	void init() override;
 
@@ -73,7 +73,7 @@ protected:
 		Common::Rect idleSrc;
 		Common::Rect activeSrc;		// shown while the tool can be used where it points
 		uint16 cursor = 0;
-		Common::Point hotspot;			// where the held sprite goes, relative to the cursor's top left
+		Common::Point hotspot;			// moves the held tool away from the cursor's center
 		RandomSoundBlock pickUpSound;
 		RandomSoundBlock putDownSound;
 		bool isHeld = false;
diff --git a/engines/nancy/action/puzzle/rowmatchpuzzle.cpp b/engines/nancy/action/puzzle/rowmatchpuzzle.cpp
index a6194d4fe12..ed04ede24bc 100644
--- a/engines/nancy/action/puzzle/rowmatchpuzzle.cpp
+++ b/engines/nancy/action/puzzle/rowmatchpuzzle.cpp
@@ -23,7 +23,6 @@
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/sound.h"
 #include "engines/nancy/input.h"
-#include "engines/nancy/cursor.h"
 
 #include "engines/nancy/state/scene.h"
 #include "engines/nancy/action/puzzle/rowmatchpuzzle.h"
@@ -31,12 +30,6 @@
 namespace Nancy {
 namespace Action {
 
-RowMatchPuzzle::~RowMatchPuzzle() {
-	if (_heldTile != kNoTile) {
-		g_nancy->_cursor->showCursor(true);
-	}
-}
-
 void RowMatchPuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);
 
@@ -118,12 +111,10 @@ bool RowMatchPuzzle::isSolved() const {
 
 void RowMatchPuzzle::holdTile(int tile) {
 	_heldTile = tile;
-	g_nancy->_cursor->showCursor(false);
 }
 
 void RowMatchPuzzle::releaseTile() {
 	_heldTile = kNoTile;
-	g_nancy->_cursor->showCursor(true);
 }
 
 void RowMatchPuzzle::setCursor(const Common::Point &pos) {
@@ -175,8 +166,9 @@ void RowMatchPuzzle::redraw() {
 
 	drawTile(_spareTile, _spareDest.origin());
 
-	// A held tile takes the place of the cursor
-	drawTile(_heldTile, _mousePos);
+	if (_heldTile != kNoTile) {
+		drawTile(_heldTile, getHeldPiecePos(_mousePos, _tileSrcs[_heldTile], Common::Point()));
+	}
 
 	_needsRedraw = true;
 }
diff --git a/engines/nancy/action/puzzle/rowmatchpuzzle.h b/engines/nancy/action/puzzle/rowmatchpuzzle.h
index 8beba74ecab..e4da624f4c6 100644
--- a/engines/nancy/action/puzzle/rowmatchpuzzle.h
+++ b/engines/nancy/action/puzzle/rowmatchpuzzle.h
@@ -36,7 +36,7 @@ namespace Action {
 class RowMatchPuzzle : public PuzzleRecord {
 public:
 	RowMatchPuzzle() : PuzzleRecord(7) {}
-	virtual ~RowMatchPuzzle();
+	virtual ~RowMatchPuzzle() {}
 
 	void init() override;
 
diff --git a/engines/nancy/action/puzzlerecord.cpp b/engines/nancy/action/puzzlerecord.cpp
index a69cd823ecd..d6ea6342739 100644
--- a/engines/nancy/action/puzzlerecord.cpp
+++ b/engines/nancy/action/puzzlerecord.cpp
@@ -83,6 +83,13 @@ void PuzzleRecord::setDataCursor(uint16 cursorType, bool hotspotVariant) const {
 	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, hotspotVariant);
 }
 
+Common::Point PuzzleRecord::getHeldPiecePos(const Common::Point &mousePos, const Common::Rect &src, const Common::Point &offset) const {
+	const Common::Rect &cursor = g_nancy->_cursor->getCurrentCursorBounds();
+	Common::Point cursorCenter = mousePos - g_nancy->_cursor->getCurrentCursorHotspot() +
+		Common::Point(cursor.width() / 2, cursor.height() / 2);
+	return cursorCenter - Common::Point(src.width() / 2, src.height() / 2) - offset;
+}
+
 SoundDescription PuzzleRecord::playSoundBlock(const RandomSoundBlock &block) {
 	SoundDescription desc;
 	if (block.names.empty()) {
diff --git a/engines/nancy/action/puzzlerecord.h b/engines/nancy/action/puzzlerecord.h
index c30ee8dbc91..22cf5508753 100644
--- a/engines/nancy/action/puzzlerecord.h
+++ b/engines/nancy/action/puzzlerecord.h
@@ -51,6 +51,10 @@ protected:
 	// The ids in the AR data are raw cursor types, which is what the "set from script" path expects
 	void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
 
+	// Nancy16 hangs a held piece off the current cursor: the piece is centered on the
+	// cursor's image, then moved back by the offset from the data
+	Common::Point getHeldPiecePos(const Common::Point &mousePos, const Common::Rect &src, const Common::Point &offset) const;
+
 	// Plays a random sound from the block and shows its caption, looked up by the sound's
 	// name, first in AUTOTEXT, then in CONVO. Returns the sound's description, which is
 	// left as "NO SOUND" when the block has nothing to play.
diff --git a/engines/nancy/cursor.h b/engines/nancy/cursor.h
index 1605a8e8e93..bdf11540a6a 100644
--- a/engines/nancy/cursor.h
+++ b/engines/nancy/cursor.h
@@ -156,6 +156,7 @@ public:
 	void applyCursor();
 
 	const Common::Point &getCurrentCursorHotspot() { return _cursors[_curCursorID].hotspot;}
+	const Common::Rect &getCurrentCursorBounds() { return _cursors[_curCursorID].bounds; }
 	const Common::Rect &getPrimaryVideoInactiveZone() { return _primaryVideoInactiveZone; }
 	const Common::Point &getPrimaryVideoInitialPos() { return _primaryVideoInitialPos; }
 


Commit: 7a4d321fead9d19aac4acb0bd0066a9f13d4aaf8
    https://github.com/scummvm/scummvm/commit/7a4d321fead9d19aac4acb0bd0066a9f13d4aaf8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:09+03:00

Commit Message:
NANCY: NANCY16: Implement CookingPuzzle

- Record 194, the diner kitchen: ingredients go from bins into the
  microwave, grill and buns, and cook over time
- Finished food is put on the plates of the order tickets and served;
  food left cooking too long sets off the fire alarm

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 2c320275d41..590d4131174 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -49,7 +49,7 @@
 #include "engines/nancy/action/puzzle/bombpuzzle.h"
 #include "engines/nancy/action/puzzle/cardgamepuzzle.h"
 #include "engines/nancy/action/puzzle/collisionpuzzle.h"
-//#include "engines/nancy/action/puzzle/cookingpuzzle.h"
+#include "engines/nancy/action/puzzle/cookingpuzzle.h"
 #include "engines/nancy/action/puzzle/cubepuzzle.h"
 #include "engines/nancy/action/puzzle/cuttingpuzzle.h"
 #include "engines/nancy/action/puzzle/decoderpuzzle.h"
@@ -699,7 +699,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 193:
 		return new FishingPuzzle();
 	case 194:
-		return nullptr; // return new CookingPuzzle();
+		return new CookingPuzzle();
 	case 195:
 		return new MinesweeperPuzzle();
 	case 196:
diff --git a/engines/nancy/action/puzzle/cookingpuzzle.cpp b/engines/nancy/action/puzzle/cookingpuzzle.cpp
new file mode 100644
index 00000000000..0d670b6d349
--- /dev/null
+++ b/engines/nancy/action/puzzle/cookingpuzzle.cpp
@@ -0,0 +1,1092 @@
+/* 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/hashmap.h"
+#include "common/hash-str.h"
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/font.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/ui/textbox.h"
+#include "engines/nancy/action/puzzle/cookingpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// Cooking progresses in ticks of 20ms
+static const uint32 kMsPerTick = 20;
+static const float kTicksPerSecond = 50.0f;
+
+// The stacked copies of an ingredient are drawn slightly scattered, using the random
+// sequence of the game's C runtime seeded with the slot's index
+static int16 nextJitter(uint32 &seed) {
+	seed = seed * 214013 + 2531011;
+	int value = (seed >> 16) & 0x7FFF;
+	return (int16)((float)value / 32767.0f * 8.0f - 4.0f);
+}
+
+CookingPuzzle::~CookingPuzzle() {
+	if (_heldFood && !_heldFood->isBin) {
+		deleteFood(_heldFood);
+	}
+
+	for (Processor &processor : _processors) {
+		for (Slot &slot : processor.slots) {
+			deleteFood(slot.food);
+		}
+	}
+
+	for (Plate &plate : _plates) {
+		for (Food *food : plate.foods) {
+			deleteFood(food);
+		}
+	}
+
+	for (Food *bin : _bins) {
+		deleteFood(bin);
+	}
+}
+
+// Unlike RandomSoundBlock::readData(), the channel, loops and volume are there even
+// when no sounds are named
+void CookingPuzzle::readSoundBank(Common::SeekableReadStream &stream, RandomSoundBlock &sound) {
+	int16 numNames = stream.readSint16LE();
+	sound.names.resize(MAX<int16>(numNames, 0));
+	for (Common::String &name : sound.names) {
+		readFilename(stream, name);
+	}
+
+	sound.channel = stream.readSint16LE();
+	sound.numLoops = stream.readSint32LE();
+	sound.volume = stream.readSint16LE();
+}
+
+void CookingPuzzle::readFoodType(Common::SeekableReadStream &stream, FoodType &food) {
+	readFilename(stream, food.name);
+
+	Common::String doneness;
+	readFilename(stream, doneness);
+	if (doneness == "WellDone") {
+		food.initialDoneness = kWellDone;
+	} else if (doneness == "OverDone") {
+		food.initialDoneness = kOverDone;
+	}
+
+	food.minCookTime = stream.readFloatLE();
+	food.maxCookTime = stream.readFloatLE();
+	food.hoverCursor = stream.readUint16LE();
+	food.cursorHotspot.x = stream.readSint32LE();
+	food.cursorHotspot.y = stream.readSint32LE();
+	readRect(stream, food.binHotspot);
+
+	for (uint i = 0; i < kNumDoneness; ++i) {
+		readRect(stream, food.cursorSrcs[i]);
+		readRect(stream, food.processorSrcs[i]);
+		readRect(stream, food.plateSrcs[i]);
+	}
+
+	readSoundBank(stream, food.cookSound);
+}
+
+void CookingPuzzle::readOrder(Common::SeekableReadStream &stream, Order &order) {
+	readFilename(stream, order.food);
+	order.minCount = stream.readSint32LE();
+	order.maxCount = stream.readSint32LE();
+	readRect(stream, order.countDest);
+
+	int16 numFlags = stream.readSint16LE();
+	order.flags.resize(MAX<int16>(numFlags, 0));
+	for (IngredientFlag &flag : order.flags) {
+		readFilename(stream, flag.ingredient);
+		flag.flag.label = stream.readSint16LE();
+		flag.flag.flag = stream.readByte();
+	}
+
+	int16 numRequired = stream.readSint16LE();
+	order.required.resize(MAX<int16>(numRequired, 0));
+	for (Order &required : order.required) {
+		readOrder(stream, required);
+	}
+
+	int16 numOptional = stream.readSint16LE();
+	order.optional.resize(MAX<int16>(numOptional, 0));
+	for (Order &optional : order.optional) {
+		readOrder(stream, optional);
+	}
+}
+
+void CookingPuzzle::readData(Common::SeekableReadStream &stream) {
+	stream.skip(33); // Data version
+
+	readFilename(stream, _imageName);
+
+	// Foods that come out of the bins, then foods that come out of the processors
+	for (uint list = 0; list < 2; ++list) {
+		int16 numFoods = stream.readSint16LE();
+		for (int16 i = 0; i < numFoods; ++i) {
+			_foodTypes.push_back(FoodType());
+			readFoodType(stream, _foodTypes.back());
+		}
+	}
+
+	int16 numProcessors = stream.readSint16LE();
+	_processors.resize(MAX<int16>(numProcessors, 0));
+	for (Processor &processor : _processors) {
+		int16 numRecipes = stream.readSint16LE();
+		processor.recipes.resize(MAX<int16>(numRecipes, 0));
+		for (Recipe &recipe : processor.recipes) {
+			readFilename(stream, recipe.input);
+			readFilename(stream, recipe.output);
+
+			int16 numIngredients = stream.readSint16LE();
+			recipe.ingredients.resize(MAX<int16>(numIngredients, 0));
+			for (Common::String &ingredient : recipe.ingredients) {
+				readFilename(stream, ingredient);
+			}
+		}
+
+		processor.cooks = stream.readByte() == 1;
+		processor.takeCursor = stream.readUint16LE();
+
+		int16 numSlots = stream.readSint16LE();
+		processor.slots.resize(MAX<int16>(numSlots, 0));
+		for (Slot &slot : processor.slots) {
+			readRect(stream, slot.dropHotspot);
+			readRect(stream, slot.addHotspot);
+			readRect(stream, slot.takeHotspot);
+		}
+	}
+
+	readFilename(stream, _plateImageName);
+	readRect(stream, _plateSrc);
+	readRect(stream, _plateDest);
+	readRect(stream, _plateBarArea);
+	readRect(stream, _serveHotspot);
+	readRect(stream, _ticketIconSrc);
+
+	// Every plate's ticket takes one of these at random
+	Common::Array<Common::Rect> ticketHotspots;
+	readRectArray(stream, ticketHotspots, stream.readUint16LE());
+
+	int16 numLayouts = stream.readSint16LE();
+	_plateLayouts.resize(MAX<int16>(numLayouts, 0));
+	for (PlateLayout &layout : _plateLayouts) {
+		readFilename(stream, layout.food);
+		readRectArray(stream, layout.dests, stream.readUint16LE());
+	}
+
+	_plateFoodCursor = stream.readUint16LE();
+	_plateCloseCursor = stream.readUint16LE();
+	_serveCursor = stream.readUint16LE();
+	_ticketCursor = stream.readUint16LE();
+	readRect(stream, _ticketDest);
+	readFilename(stream, _countFontName);
+	_countColor = stream.readUint32BE();
+	_captionTime = stream.readFloatLE();
+
+	readSoundBank(stream, _orderRightSound);
+	readSoundBank(stream, _orderWrongSound);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	readSoundBank(stream, _allServedSound);
+
+	int16 numPlates = stream.readSint16LE();
+	if (numPlates > (int16)ticketHotspots.size()) {
+		error("CookingPuzzle: more plates than ticket hotspots");
+	}
+
+	_plates.resize(MAX<int16>(numPlates, 0));
+	for (Plate &plate : _plates) {
+		uint ticket = g_nancy->_randomSource->getRandomNumber(ticketHotspots.size() - 1);
+		plate.ticketHotspot = ticketHotspots[ticket];
+		ticketHotspots.remove_at(ticket);
+
+		readRect(stream, plate.ticketSrc);
+		plate.acceptsBadlyCooked = stream.readByte() == 1;
+		plate.badlyCookedFlag.label = stream.readSint16LE();
+		plate.badlyCookedFlag.flag = stream.readByte();
+
+		int16 numOrders = stream.readSint16LE();
+		plate.orders.resize(MAX<int16>(numOrders, 0));
+		for (Order &order : plate.orders) {
+			readOrder(stream, order);
+			rollOrderCounts(order);
+		}
+	}
+
+	int16 numPages = stream.readSint16LE();
+	_manualPageNames.resize(MAX<int16>(numPages, 0));
+	for (Common::Path &page : _manualPageNames) {
+		readFilename(stream, page);
+	}
+
+	readRect(stream, _manualPrevHotspot);
+	readRect(stream, _manualNextHotspot);
+	readRect(stream, _manualOpenHotspot);
+	readRect(stream, _manualCloseHotspot);
+	readRect(stream, _manualSrc);
+	readRect(stream, _manualDest);
+	_manualPrevCursor = stream.readUint16LE();
+	_manualNextCursor = stream.readUint16LE();
+	_manualOpenCursor = stream.readUint16LE();
+	_manualCloseCursor = stream.readUint16LE();
+	readSoundBank(stream, _pageFlipSound);
+	readSoundBank(stream, _manualOpenSound);
+	readSoundBank(stream, _manualCloseSound);
+
+	readRect(stream, _garbageHotspot);
+	readSoundBank(stream, _garbageSound);
+	_heldCursor = stream.readUint16LE();
+
+	_fireScene._sceneChange.sceneID = stream.readUint16LE();
+	_fireScene._sceneChange.frameID = stream.readUint16LE();
+	_fireScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_fireScene._flag.label = stream.readSint16LE();
+	_fireScene._flag.flag = stream.readByte();
+	readSoundBank(stream, _fireSound);
+
+	readExitHotspot(stream);
+}
+
+void CookingPuzzle::rollOrderCounts(Order &order) {
+	int32 range = order.maxCount - order.minCount;
+	order.count = order.minCount + (range > 0 ? (int32)g_nancy->_randomSource->getRandomNumber(range) : 0);
+
+	for (Order &required : order.required) {
+		rollOrderCounts(required);
+	}
+
+	for (Order &optional : order.optional) {
+		rollOrderCounts(optional);
+	}
+}
+
+void CookingPuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	g_nancy->_resource->loadImage(_plateImageName, _plateImage);
+	_plateImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+	_manualPages.resize(_manualPageNames.size());
+	for (uint i = 0; i < _manualPageNames.size(); ++i) {
+		g_nancy->_resource->loadImage(_manualPageNames[i], _manualPages[i]);
+		_manualPages[i].setTransparentColor(_drawSurface.getTransparentColor());
+	}
+
+	// Every food type has a bin that never runs out; most of them just can't be clicked
+	for (const FoodType &type : _foodTypes) {
+		Food *bin = new Food();
+		bin->type = &type;
+		bin->isBin = true;
+		bin->doneness = type.initialDoneness;
+		_bins.push_back(bin);
+	}
+
+	NancySceneState.setNoHeldItem();
+	_lastTickTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+const CookingPuzzle::FoodType *CookingPuzzle::findFoodType(const Common::String &name) const {
+	for (const FoodType &type : _foodTypes) {
+		if (type.name == name) {
+			return &type;
+		}
+	}
+
+	return nullptr;
+}
+
+const CookingPuzzle::Recipe *CookingPuzzle::findRecipe(const Processor &processor, const Common::String &input) const {
+	for (const Recipe &recipe : processor.recipes) {
+		if (recipe.input == input) {
+			return &recipe;
+		}
+	}
+
+	return nullptr;
+}
+
+const CookingPuzzle::Order *CookingPuzzle::findOrder(const Common::Array<Order> &orders, const Common::String &food) {
+	for (const Order &order : orders) {
+		if (order.food == food) {
+			return ℴ
+		}
+	}
+
+	return nullptr;
+}
+
+// Makes a food of the given type out of another one, which passes on its doneness,
+// cooking progress and ingredients
+CookingPuzzle::Food *CookingPuzzle::createFood(const Common::String &typeName, const Food *source) {
+	const FoodType *type = findFoodType(typeName);
+	if (!type) {
+		error("CookingPuzzle: could not find food %s", typeName.c_str());
+	}
+
+	Food *food = new Food();
+	food->type = type;
+	food->doneness = source->doneness;
+
+	float step = type->minCookTime + (float)g_nancy->_randomSource->getRandomNumber(32767) / 32767.0f * (type->maxCookTime - type->minCookTime);
+	food->ticksPerStep = (int32)(step * kTicksPerSecond);
+
+	if (!source->isBin) {
+		if (source->ticksPerStep > 0) {
+			float progress = (float)source->cookTicks / (float)source->ticksPerStep;
+			food->cookTicks = (int32)(progress * (float)food->ticksPerStep);
+		}
+
+		for (const Food *ingredient : source->ingredients) {
+			Food *copy = createFood(ingredient->type->name, ingredient);
+			copy->visibleInProcessor = false;
+			insertSorted(food->ingredients, copy);
+		}
+	}
+
+	return food;
+}
+
+void CookingPuzzle::deleteFood(Food *food) {
+	if (!food) {
+		return;
+	}
+
+	for (Food *ingredient : food->ingredients) {
+		deleteFood(ingredient);
+	}
+
+	delete food;
+}
+
+// Foods with the same name keep the order in which they were added
+void CookingPuzzle::insertSorted(Common::Array<Food *> &list, Food *food) {
+	uint i = 0;
+	while (i < list.size() && list[i]->type->name.compareTo(food->type->name) <= 0) {
+		++i;
+	}
+
+	list.insert_at(i, food);
+}
+
+void CookingPuzzle::holdFood(Food *food) {
+	_heldFood = food;
+}
+
+// Discarding a food that came straight out of a bin has no effect, as bins never run out
+void CookingPuzzle::releaseHeldFood(bool discard) {
+	if (discard && _heldFood && !_heldFood->isBin) {
+		deleteFood(_heldFood);
+	}
+
+	_heldFood = nullptr;
+}
+
+bool CookingPuzzle::clickManual(const Common::Point &pos) {
+	if (_manualCloseHotspot.contains(pos)) {
+		playSoundBlock(_manualCloseSound);
+		_manualOpen = false;
+	}
+
+	if (_manualPages.empty()) {
+		return true;
+	}
+
+	if (_manualPrevHotspot.contains(pos)) {
+		playSoundBlock(_pageFlipSound);
+		_manualPage = (_manualPage + _manualPages.size() - 1) % _manualPages.size();
+	}
+
+	if (_manualNextHotspot.contains(pos)) {
+		playSoundBlock(_pageFlipSound);
+		_manualPage = (_manualPage + 1) % _manualPages.size();
+	}
+
+	return true;
+}
+
+// Puts the held food on a plate. Food that isn't part of the plate's order,
+// or that the plate already has enough of, gets thrown away.
+bool CookingPuzzle::putOnPlate(Plate &plate) {
+	for (const Order &order : plate.orders) {
+		if (order.food != _heldFood->type->name || order.count == 0) {
+			continue;
+		}
+
+		int32 numOnPlate = 0;
+		for (const Food *food : plate.foods) {
+			if (food->type->name == order.food) {
+				++numOnPlate;
+			}
+		}
+
+		if (numOnPlate == order.maxCount) {
+			break;
+		}
+
+		Food *food = _heldFood->isBin ? createFood(_heldFood->type->name, _heldFood) : _heldFood;
+		insertSorted(plate.foods, food);
+		releaseHeldFood(false);
+		return true;
+	}
+
+	releaseHeldFood(true);
+	return false;
+}
+
+bool CookingPuzzle::foodMatches(const Food *food, const Order &order, Common::Array<FlagDescription> &flags,
+		bool checkDoneness, bool &allWellDone) const {
+	allWellDone = allWellDone && food->doneness == kWellDone;
+	if (checkDoneness && food->doneness != kWellDone) {
+		return false;
+	}
+
+	Common::HashMap<Common::String, int32> requiredCounts;
+	Common::HashMap<Common::String, int32> optionalCounts;
+
+	for (const Food *ingredient : food->ingredients) {
+		const Common::String &name = ingredient->type->name;
+
+		for (const IngredientFlag &flag : order.flags) {
+			if (flag.ingredient == name) {
+				flags.push_back(flag.flag);
+			}
+		}
+
+		const Order *required = findOrder(order.required, name);
+		if (required) {
+			Common::Array<FlagDescription> ingredientFlags;
+			bool ingredientWellDone = allWellDone;
+			if (foodMatches(ingredient, *required, ingredientFlags, checkDoneness, ingredientWellDone)) {
+				flags.push_back(ingredientFlags);
+				allWellDone = ingredientWellDone;
+				++requiredCounts.getOrCreateVal(name);
+				continue;
+			}
+		}
+
+		const Order *optional = findOrder(order.optional, name);
+		if (optional) {
+			bool ingredientWellDone = allWellDone;
+			if (foodMatches(ingredient, *optional, flags, checkDoneness, ingredientWellDone)) {
+				allWellDone = ingredientWellDone;
+				++optionalCounts.getOrCreateVal(name);
+				continue;
+			}
+		}
+
+		return false;
+	}
+
+	for (const Order &required : order.required) {
+		if (requiredCounts.getValOrDefault(required.food, 0) != required.count) {
+			return false;
+		}
+	}
+
+	// Optional ingredients only have to stay within their range when they're there at all
+	for (const Order &optional : order.optional) {
+		if (optionalCounts.contains(optional.food)) {
+			int32 count = optionalCounts[optional.food];
+			if (count < optional.minCount || count > optional.maxCount) {
+				return false;
+			}
+		}
+	}
+
+	return true;
+}
+
+// Checks the plate against its order. Whatever the outcome, the plate is cleared.
+bool CookingPuzzle::servePlate(Plate &plate) {
+	bool success = true;
+	bool allWellDone = true;
+	Common::Array<FlagDescription> flags;
+
+	for (const Order &order : plate.orders) {
+		int32 numFound = 0;
+		for (const Food *food : plate.foods) {
+			if (food->type->name != order.food) {
+				continue;
+			}
+
+			++numFound;
+			if (!foodMatches(food, order, flags, !plate.acceptsBadlyCooked, allWellDone)) {
+				success = false;
+			}
+		}
+
+		if (numFound != order.count) {
+			success = false;
+		}
+
+		if (!success) {
+			break;
+		}
+	}
+
+	for (Food *food : plate.foods) {
+		deleteFood(food);
+	}
+
+	plate.foods.clear();
+
+	if (success) {
+		if (!allWellDone && plate.acceptsBadlyCooked) {
+			NancySceneState.setEventFlag(plate.badlyCookedFlag);
+		}
+
+		for (const FlagDescription &flag : flags) {
+			NancySceneState.setEventFlag(flag);
+		}
+	}
+
+	return success;
+}
+
+bool CookingPuzzle::clickPlates(const Common::Point &pos) {
+	if (_openPlate == -1) {
+		for (uint i = 0; i < _plates.size(); ++i) {
+			if (!_plates[i].ticketHotspot.contains(pos)) {
+				continue;
+			}
+
+			if (!_heldFood || !putOnPlate(_plates[i])) {
+				_openPlate = i;
+			}
+
+			return true;
+		}
+
+		return false;
+	}
+
+	Plate &plate = _plates[_openPlate];
+
+	if (!_plateBarArea.contains(pos) && _plateDest.contains(pos)) {
+		if (_serveHotspot.contains(pos)) {
+			if (servePlate(plate)) {
+				_plates.remove_at(_openPlate);
+
+				if (_plates.empty()) {
+					startEnding(_solveScene, _allServedSound);
+				} else {
+					_rightSoundPending = true;
+					playSoundBlock(_orderRightSound);
+				}
+			} else {
+				_wrongSoundPending = true;
+				playSoundBlock(_orderWrongSound);
+			}
+
+			_openPlate = -1;
+			return true;
+		}
+
+		for (const PlateLayout &layout : _plateLayouts) {
+			for (uint i = 0; i < plate.foods.size(); ++i) {
+				if (plate.foods[i]->type->name != layout.food) {
+					continue;
+				}
+
+				for (const Common::Rect &dest : layout.dests) {
+					if (dest.contains(pos)) {
+						holdFood(plate.foods[i]);
+						plate.foods.remove_at(i);
+						_openPlate = -1;
+						return true;
+					}
+				}
+
+				break;
+			}
+		}
+
+		return true;
+	}
+
+	_openPlate = -1;
+	return true;
+}
+
+bool CookingPuzzle::clickProcessors(const Common::Point &pos) {
+	for (Processor &processor : _processors) {
+		for (Slot &slot : processor.slots) {
+			if (slot.takeHotspot.contains(pos) && !_heldFood && slot.food) {
+				holdFood(slot.food);
+				slot.food = nullptr;
+				slot.recipeInput.clear();
+				return true;
+			}
+
+			if (slot.dropHotspot.contains(pos) && _heldFood && !slot.food) {
+				const Recipe *recipe = findRecipe(processor, _heldFood->type->name);
+				if (recipe) {
+					slot.food = createFood(recipe->output, _heldFood);
+					slot.recipeInput = _heldFood->type->name;
+					if (!_heldFood->isBin) {
+						releaseHeldFood(true);
+					}
+
+					return true;
+				}
+			}
+
+			if (slot.addHotspot.contains(pos) && _heldFood && slot.food) {
+				const Recipe *recipe = findRecipe(processor, slot.recipeInput);
+				if (recipe) {
+					for (const Common::String &ingredient : recipe->ingredients) {
+						if (ingredient != _heldFood->type->name) {
+							continue;
+						}
+
+						if (_heldFood->isBin) {
+							insertSorted(slot.food->ingredients, createFood(ingredient, _heldFood));
+						} else {
+							insertSorted(slot.food->ingredients, _heldFood);
+							releaseHeldFood(false);
+						}
+
+						return true;
+					}
+				}
+			}
+		}
+	}
+
+	return false;
+}
+
+// Clicking the bin of the held food puts it back
+bool CookingPuzzle::clickBins(const Common::Point &pos) {
+	for (Food *bin : _bins) {
+		if (!bin->type->binHotspot.contains(pos)) {
+			continue;
+		}
+
+		bool isSameBin = _heldFood == bin;
+		if (_heldFood) {
+			releaseHeldFood(true);
+		}
+
+		if (!isSameBin) {
+			holdFood(bin);
+		}
+
+		return true;
+	}
+
+	return false;
+}
+
+bool CookingPuzzle::clickGarbage(const Common::Point &pos) {
+	if (!_garbageHotspot.contains(pos) || !_heldFood) {
+		return false;
+	}
+
+	playSoundBlock(_garbageSound);
+	releaseHeldFood(true);
+	return true;
+}
+
+bool CookingPuzzle::updateCursor(const Common::Point &pos) {
+	if (_heldFood) {
+		setDataCursor(_heldCursor);
+		return true;
+	}
+
+	if (_manualOpen) {
+		if (_manualCloseHotspot.contains(pos)) {
+			setDataCursor(_manualCloseCursor);
+			return true;
+		}
+
+		if (!_manualPages.empty()) {
+			if (_manualPrevHotspot.contains(pos)) {
+				setDataCursor(_manualPrevCursor);
+				return true;
+			}
+
+			if (_manualNextHotspot.contains(pos)) {
+				setDataCursor(_manualNextCursor);
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	if (_openPlate != -1) {
+		if (_plateBarArea.contains(pos) || !_plateDest.contains(pos)) {
+			setDataCursor(_plateCloseCursor);
+			return true;
+		}
+
+		if (_serveHotspot.contains(pos)) {
+			setDataCursor(_serveCursor);
+			return true;
+		}
+
+		const Plate &plate = _plates[_openPlate];
+		for (const PlateLayout &layout : _plateLayouts) {
+			bool onPlate = false;
+			for (const Food *food : plate.foods) {
+				if (food->type->name == layout.food) {
+					onPlate = true;
+					break;
+				}
+			}
+
+			if (!onPlate) {
+				continue;
+			}
+
+			for (const Common::Rect &dest : layout.dests) {
+				if (dest.contains(pos)) {
+					setDataCursor(_plateFoodCursor);
+					return true;
+				}
+			}
+		}
+
+		return false;
+	}
+
+	for (const Plate &plate : _plates) {
+		if (plate.ticketHotspot.contains(pos)) {
+			setDataCursor(_ticketCursor);
+			return true;
+		}
+	}
+
+	if (_manualOpenHotspot.contains(pos)) {
+		setDataCursor(_manualOpenCursor);
+		return true;
+	}
+
+	for (const Processor &processor : _processors) {
+		for (const Slot &slot : processor.slots) {
+			if (slot.takeHotspot.contains(pos) && slot.food) {
+				setDataCursor(processor.takeCursor);
+				return true;
+			}
+		}
+	}
+
+	for (const Food *bin : _bins) {
+		if (bin->type->binHotspot.contains(pos)) {
+			setDataCursor(bin->type->hoverCursor);
+			return true;
+		}
+	}
+
+	return false;
+}
+
+// Advances the food by a doneness step every time a step's worth of cooking has
+// passed. A food that is already overdone sets off the fire alarm instead.
+bool CookingPuzzle::cook(Food *food, int32 ticks) {
+	int32 oldTicks = food->cookTicks;
+	int32 newTicks = oldTicks + ticks;
+	food->cookTicks = newTicks;
+
+	if (food->ticksPerStep <= 0) {
+		return false;
+	}
+
+	if (newTicks / food->ticksPerStep == oldTicks / food->ticksPerStep && oldTicks != 0) {
+		return false;
+	}
+
+	playSoundBlock(food->type->cookSound);
+	if (oldTicks <= 0) {
+		return false;
+	}
+
+	if (food->doneness == kOverDone) {
+		return true;
+	}
+
+	++food->doneness;
+	return false;
+}
+
+void CookingPuzzle::update(int32 ticks) {
+	for (Processor &processor : _processors) {
+		if (!processor.cooks) {
+			continue;
+		}
+
+		for (Slot &slot : processor.slots) {
+			if (slot.food && cook(slot.food, ticks)) {
+				startEnding(_fireScene, _fireSound);
+				return;
+			}
+		}
+	}
+
+	// The served order's caption goes away a little while after its sound ends
+	if (_captionTicks > 0) {
+		_captionTicks = MAX<int32>(_captionTicks - ticks, 0);
+		if (_captionTicks == 0) {
+			NancySceneState.getTextbox().clear();
+		}
+	} else {
+		if (_rightSoundPending && !isSoundBlockPlaying(_orderRightSound)) {
+			_rightSoundPending = false;
+			_captionTicks = (int32)(_captionTime * kTicksPerSecond);
+		}
+
+		if (_wrongSoundPending && !isSoundBlockPlaying(_orderWrongSound)) {
+			_wrongSoundPending = false;
+			_captionTicks = (int32)(_captionTime * kTicksPerSecond);
+		}
+	}
+}
+
+void CookingPuzzle::startEnding(const SceneChangeWithFlag &scene, const RandomSoundBlock &sound) {
+	_isEnding = true;
+	_endScene = scene;
+	_endSound = playSoundBlock(sound);
+}
+
+void CookingPuzzle::drawFood(const Food *food, const Common::Rect &dest, bool jitter, uint32 &randSeed) {
+	if (food->visibleInProcessor) {
+		Common::Point offset;
+		if (jitter) {
+			offset.y = nextJitter(randSeed);
+			offset.x = nextJitter(randSeed);
+		}
+
+		const Common::Rect &src = food->type->processorSrcs[food->doneness];
+		if (!src.isEmpty()) {
+			_drawSurface.blitFrom(_image, src, Common::Point(dest.left + offset.x, dest.top + offset.y));
+		}
+	}
+
+	const Food *previous = nullptr;
+	for (const Food *ingredient : food->ingredients) {
+		bool sameAsPrevious = previous && previous->type->name == ingredient->type->name;
+		drawFood(ingredient, dest, sameAsPrevious, randSeed);
+		previous = ingredient;
+	}
+}
+
+void CookingPuzzle::drawOrderCounts(const Order &order, const Graphics::Font *font, uint32 color) {
+	if (order.count <= 0) {
+		return;
+	}
+
+	font->drawString(_drawSurface.surfacePtr(), Common::String::format("%d", order.count),
+		order.countDest.left, order.countDest.top, order.countDest.width(), color);
+
+	for (const Order &required : order.required) {
+		drawOrderCounts(required, font, color);
+	}
+}
+
+void CookingPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (const Processor &processor : _processors) {
+		for (uint i = 0; i < processor.slots.size(); ++i) {
+			const Slot &slot = processor.slots[i];
+			if (slot.food) {
+				uint32 randSeed = i;
+				drawFood(slot.food, slot.addHotspot, false, randSeed);
+			}
+		}
+	}
+
+	if (_openPlate == -1) {
+		for (const Plate &plate : _plates) {
+			_drawSurface.blitFrom(_plateImage, _ticketIconSrc, plate.ticketHotspot.origin());
+		}
+	} else {
+		const Plate &plate = _plates[_openPlate];
+		_drawSurface.blitFrom(_plateImage, _plateSrc, _plateDest.origin());
+
+		for (const PlateLayout &layout : _plateLayouts) {
+			uint slot = 0;
+			for (const Food *food : plate.foods) {
+				if (food->type->name != layout.food) {
+					continue;
+				}
+
+				if (slot >= layout.dests.size()) {
+					error("CookingPuzzle: not enough plate positions for food %s", food->type->name.c_str());
+				}
+
+				const Common::Rect &src = food->type->plateSrcs[food->doneness];
+				if (!src.isEmpty()) {
+					_drawSurface.blitFrom(_image, src, layout.dests[slot].origin());
+				}
+
+				++slot;
+			}
+		}
+
+		const Font *countFont = g_nancy->_graphics->getFont(_countFontName);
+		if (countFont && countFont->getSystemFont()) {
+			uint32 color = argbToColor(_drawSurface.format, _countColor);
+			for (const Order &order : plate.orders) {
+				drawOrderCounts(order, countFont->getSystemFont(), color);
+			}
+		}
+
+		_drawSurface.blitFrom(_plateImage, plate.ticketSrc, _ticketDest.origin());
+	}
+
+	if (_manualOpen && _manualPage < _manualPages.size()) {
+		_drawSurface.blitFrom(_manualPages[_manualPage], _manualSrc, _manualDest.origin());
+	}
+
+	if (_heldFood) {
+		const Common::Rect &src = _heldFood->type->cursorSrcs[_heldFood->doneness];
+		if (!src.isEmpty()) {
+			_drawSurface.blitFrom(_image, src, getHeldPiecePos(_mousePos, src, _heldFood->type->cursorHotspot));
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void CookingPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_isEnding) {
+			if (_endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound)) {
+				_state = kActionTrigger;
+			}
+
+			break;
+		}
+
+		uint32 now = g_nancy->getTotalPlayTime();
+		int32 ticks = (now - _lastTickTime) / kMsPerTick;
+		_lastTickTime += ticks * kMsPerTick;
+
+		if (ticks > 0) {
+			update(ticks);
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		if (_heldFood) {
+			releaseHeldFood(true);
+		}
+
+		if (_exitRequested) {
+			_exitScene.execute();
+		} else {
+			_endScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void CookingPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _isEnding || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	_mousePos = pos;
+
+	// Right clicking puts back food taken out of a bin
+	if ((input.input & NancyInput::kRightMouseButtonUp) && _heldFood && _heldFood->isBin) {
+		releaseHeldFood(false);
+	}
+
+	bool cursorSet = updateCursor(pos);
+
+	if (input.input & NancyInput::kLeftMouseButtonUp) {
+		bool handled = false;
+		if (_manualOpen) {
+			handled = clickManual(pos);
+		}
+
+		if (!handled) {
+			handled = clickPlates(pos);
+		}
+
+		if (!handled && _manualOpenHotspot.contains(pos)) {
+			playSoundBlock(_manualOpenSound);
+			_manualOpen = true;
+			handled = true;
+		}
+
+		if (!handled) {
+			handled = clickProcessors(pos) || clickBins(pos) || clickGarbage(pos);
+		}
+
+		if (handled || _isEnding) {
+			input.eatMouseInput();
+			return;
+		}
+	}
+
+	if (!cursorSet && hoverExitHotspot(input)) {
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_exitRequested = true;
+		}
+	}
+
+	input.eatMouseInput();
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/cookingpuzzle.h b/engines/nancy/action/puzzle/cookingpuzzle.h
new file mode 100644
index 00000000000..c24f9f581f2
--- /dev/null
+++ b/engines/nancy/action/puzzle/cookingpuzzle.h
@@ -0,0 +1,242 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_COOKINGPUZZLE_H
+#define NANCY_ACTION_COOKINGPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 diner cooking puzzle (AR 194). Ingredients are taken from bins and put into
+// processors (microwave, grill, burger buns), which turn them into other foods and cook
+// them over time. Finished foods are put on the plates of the customers' order tickets
+// and served. Leaving food to cook for too long sets off the fire alarm.
+class CookingPuzzle : public PuzzleRecord {
+public:
+	CookingPuzzle() : PuzzleRecord(7) {}
+	virtual ~CookingPuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "CookingPuzzle"; }
+
+	enum Doneness : byte {
+		kUnderDone	= 0,
+		kWellDone	= 1,
+		kOverDone	= 2
+	};
+
+	static const uint kNumDoneness = 3;
+
+	struct FoodType {
+		Common::String name;
+		byte initialDoneness = kUnderDone;
+		// Seconds per doneness step, picked at random within the range
+		float minCookTime = 0;
+		float maxCookTime = 0;
+		uint16 hoverCursor = 0;
+		Common::Point cursorHotspot;	// moves the held food away from the cursor's center
+		Common::Rect binHotspot;	// empty for foods that only come out of a processor
+		Common::Rect cursorSrcs[kNumDoneness];
+		Common::Rect processorSrcs[kNumDoneness];
+		Common::Rect plateSrcs[kNumDoneness];
+		RandomSoundBlock cookSound;
+	};
+
+	// A food in play. The ones standing in for the bins are never used up.
+	struct Food {
+		const FoodType *type = nullptr;
+		bool isBin = false;
+		byte doneness = kUnderDone;
+		int32 cookTicks = 0;
+		int32 ticksPerStep = 0;
+		bool visibleInProcessor = true;
+		Common::Array<Food *> ingredients;	// sorted by name
+	};
+
+	struct Recipe {
+		Common::String input;
+		Common::String output;
+		Common::Array<Common::String> ingredients;
+	};
+
+	struct Slot {
+		Common::Rect dropHotspot;
+		Common::Rect addHotspot;	// where ingredients go, and where the food is drawn
+		Common::Rect takeHotspot;
+		Food *food = nullptr;
+		Common::String recipeInput;
+	};
+
+	struct Processor {
+		Common::Array<Recipe> recipes;
+		bool cooks = false;
+		uint16 takeCursor = 0;
+		Common::Array<Slot> slots;
+	};
+
+	struct IngredientFlag {
+		Common::String ingredient;
+		FlagDescription flag;
+	};
+
+	struct Order {
+		Common::String food;
+		int32 minCount = 0;
+		int32 maxCount = 0;
+		int32 count = 0;	// rolled between the two
+		Common::Rect countDest;
+		Common::Array<IngredientFlag> flags;
+		Common::Array<Order> required;
+		Common::Array<Order> optional;
+	};
+
+	struct Plate {
+		Common::Rect ticketHotspot;
+		Common::Rect ticketSrc;
+		bool acceptsBadlyCooked = false;
+		FlagDescription badlyCookedFlag;
+		Common::Array<Order> orders;
+		Common::Array<Food *> foods;	// sorted by name
+	};
+
+	struct PlateLayout {
+		Common::String food;
+		Common::Array<Common::Rect> dests;
+	};
+
+	static void readSoundBank(Common::SeekableReadStream &stream, RandomSoundBlock &sound);
+	static void readFoodType(Common::SeekableReadStream &stream, FoodType &food);
+	static void readOrder(Common::SeekableReadStream &stream, Order &order);
+	static void rollOrderCounts(Order &order);
+
+	const FoodType *findFoodType(const Common::String &name) const;
+	const Recipe *findRecipe(const Processor &processor, const Common::String &input) const;
+	static const Order *findOrder(const Common::Array<Order> &orders, const Common::String &food);
+
+	Food *createFood(const Common::String &typeName, const Food *source);
+	static void deleteFood(Food *food);
+	static void insertSorted(Common::Array<Food *> &list, Food *food);
+
+	void holdFood(Food *food);
+	void releaseHeldFood(bool discard);
+
+	bool clickManual(const Common::Point &pos);
+	bool clickPlates(const Common::Point &pos);
+	bool clickProcessors(const Common::Point &pos);
+	bool clickBins(const Common::Point &pos);
+	bool clickGarbage(const Common::Point &pos);
+	bool putOnPlate(Plate &plate);
+	bool servePlate(Plate &plate);
+	bool foodMatches(const Food *food, const Order &order, Common::Array<FlagDescription> &flags,
+		bool checkDoneness, bool &allWellDone) const;
+
+	bool updateCursor(const Common::Point &pos);
+	void update(int32 ticks);
+	bool cook(Food *food, int32 ticks);
+
+	void redraw();
+	void drawFood(const Food *food, const Common::Rect &dest, bool jitter, uint32 &randSeed);
+	void drawOrderCounts(const Order &order, const Graphics::Font *font, uint32 color);
+
+	void startEnding(const SceneChangeWithFlag &scene, const RandomSoundBlock &sound);
+
+	// -- File data --
+	Common::Array<FoodType> _foodTypes;
+	Common::Array<Processor> _processors;
+
+	Common::Path _plateImageName;
+	Common::Rect _plateSrc;
+	Common::Rect _plateDest;
+	Common::Rect _plateBarArea;			// clicking it, or outside the plate, puts the plate away
+	Common::Rect _serveHotspot;
+	Common::Rect _ticketIconSrc;
+	Common::Array<PlateLayout> _plateLayouts;
+	uint16 _plateFoodCursor = 0;
+	uint16 _plateCloseCursor = 0;
+	uint16 _serveCursor = 0;
+	uint16 _ticketCursor = 0;
+	Common::Rect _ticketDest;
+	Common::String _countFontName;
+	uint32 _countColor = 0;				// ARGB
+	float _captionTime = 0;				// seconds the served-order caption stays up after its sound
+	RandomSoundBlock _orderRightSound;
+	RandomSoundBlock _orderWrongSound;
+	RandomSoundBlock _allServedSound;
+	Common::Array<Plate> _plates;
+
+	Common::Array<Common::Path> _manualPageNames;
+	Common::Rect _manualPrevHotspot;
+	Common::Rect _manualNextHotspot;
+	Common::Rect _manualOpenHotspot;
+	Common::Rect _manualCloseHotspot;
+	Common::Rect _manualSrc;
+	Common::Rect _manualDest;
+	uint16 _manualPrevCursor = 0;
+	uint16 _manualNextCursor = 0;
+	uint16 _manualOpenCursor = 0;
+	uint16 _manualCloseCursor = 0;
+	RandomSoundBlock _pageFlipSound;
+	RandomSoundBlock _manualOpenSound;
+	RandomSoundBlock _manualCloseSound;
+
+	Common::Rect _garbageHotspot;
+	RandomSoundBlock _garbageSound;
+	uint16 _heldCursor = 0;
+	SceneChangeWithFlag _fireScene;
+	RandomSoundBlock _fireSound;
+
+	// -- Runtime state --
+	Common::Array<Food *> _bins;
+	Food *_heldFood = nullptr;
+	int _openPlate = -1;
+	bool _manualOpen = false;
+	uint _manualPage = 0;
+
+	bool _rightSoundPending = false;
+	bool _wrongSoundPending = false;
+	int32 _captionTicks = 0;
+
+	uint32 _lastTickTime = 0;
+	bool _isEnding = false;
+	bool _exitRequested = false;
+	SceneChangeWithFlag _endScene;
+	SoundDescription _endSound;
+	Common::Point _mousePos;
+
+	Graphics::ManagedSurface _plateImage;
+	Common::Array<Graphics::ManagedSurface> _manualPages;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_COOKINGPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index c2532387959..cb1a0f436c1 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -35,6 +35,7 @@ MODULE_OBJS = \
   action/puzzle/bombpuzzle.o \
   action/puzzle/cardgamepuzzle.o \
   action/puzzle/collisionpuzzle.o \
+  action/puzzle/cookingpuzzle.o \
   action/puzzle/cubepuzzle.o \
   action/puzzle/cuttingpuzzle.o \
   action/puzzle/decoderpuzzle.o \


Commit: 907840cbebdb1735aea69b1685bee0c37e8cadd0
    https://github.com/scummvm/scummvm/commit/907840cbebdb1735aea69b1685bee0c37e8cadd0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:12+03:00

Commit Message:
NANCY: NANCY16: Implement the EscapeGridPuzzle changes

- A share of the grid's cells can be replaced by other tiles at random
- Each cell becomes a random variant of its tile type
- Grids can lay their cells out in rows, with 8 directions instead of 6
- The ice floes rise and sink around each jump instead of sliding
- Characters play a sound as they start jumping

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


diff --git a/engines/nancy/action/puzzle/escapegridpuzzle.cpp b/engines/nancy/action/puzzle/escapegridpuzzle.cpp
index d5fba493a70..906f5e29085 100644
--- a/engines/nancy/action/puzzle/escapegridpuzzle.cpp
+++ b/engines/nancy/action/puzzle/escapegridpuzzle.cpp
@@ -42,7 +42,7 @@ static const int kTileRefTolerance = 8;
 static const Common::Point kNoCell(-1, -1);
 
 // The jump animation of each hex direction.
-static const uint kCompassDirection[EscapeGridPuzzle::kNumDirections] = { 0, 1, 3, 4, 5, 7 };
+static const uint kCompassDirection[EscapeGridPuzzle::kNumDirections] = { 0, 1, 3, 4, 5, 7, 2, 6 };
 
 EscapeGridPuzzle::~EscapeGridPuzzle() {
 	for (uint i = 0; i < _jumpMovies.size(); ++i) {
@@ -83,17 +83,41 @@ void EscapeGridPuzzle::readData(Common::SeekableReadStream &stream) {
 	readRect(stream, _rowStepCell);
 	readRect(stream, _colStepCell);
 
-	_unknownGridFlag = stream.readByte();
+	_isHexGrid = stream.readByte() == 1;
 	_numCols = stream.readSint32LE();
 	_numRows = stream.readSint32LE();
 	_hexDelay = (int32)(stream.readFloatLE() * (1000 / kTickLength) + 0.5f); // in seconds
 
+	// Nancy16 can scatter tiles randomly: a share of the cells marked with a tile id
+	// get replaced, and the rest lose their random marker bit
+	Common::Array<RandomTiles> randomTiles;
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		randomTiles.resize(stream.readUint16LE());
+		for (RandomTiles &tiles : randomTiles) {
+			tiles.id = stream.readSint32LE();
+			tiles.replacementID = stream.readSint32LE();
+			tiles.percent = stream.readFloatLE();
+		}
+	}
+
 	uint numCells = (uint)MAX<int32>(0, _numCols) * (uint)MAX<int32>(0, _numRows);
 	_grid.resize(numCells);
 	for (uint i = 0; i < numCells; ++i) {
 		_grid[i] = stream.readSint32LE();
 	}
 
+	for (const RandomTiles &tiles : randomTiles) {
+		placeRandomTiles(tiles);
+	}
+
+	// Nancy16 grids leave out the variant bits of the tile ids; each cell becomes one
+	// of the tile types that only differ from it in those bits, picked at random
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		for (int32 &id : _grid) {
+			id = pickTileVariant(id);
+		}
+	}
+
 	// Characters are counted in animations, eight each.
 	int16 numBinks = stream.readSint16LE();
 	if (numBinks % 8 != 0) {
@@ -123,6 +147,10 @@ void EscapeGridPuzzle::readData(Common::SeekableReadStream &stream) {
 			}
 		}
 
+		if (g_nancy->getGameType() >= kGameTypeNancy16) {
+			character.jumpSound.readData(stream);
+		}
+
 		for (uint j = 0; j < kNumCompassDirections; ++j) {
 			readFilename(stream, character.jumpNames[j]);
 			character.jumpOffsetX[j] = stream.readSint32LE();
@@ -166,6 +194,44 @@ void EscapeGridPuzzle::readData(Common::SeekableReadStream &stream) {
 	_exitScene._sceneChange.continueSceneSound = kContinueSceneSound;
 }
 
+// Replaces a share of the matching cells, picked at random, and clears the random
+// marker from the others
+void EscapeGridPuzzle::placeRandomTiles(const RandomTiles &tiles) {
+	uint remaining = 0;
+	for (int32 id : _grid) {
+		if (id == tiles.id) {
+			++remaining;
+		}
+	}
+
+	int32 numToReplace = (int32)(tiles.percent * 0.01f * remaining);
+	for (int32 &id : _grid) {
+		if (id != tiles.id) {
+			continue;
+		}
+
+		if ((int32)g_nancy->_randomSource->getRandomNumber(remaining - 1) < numToReplace) {
+			id = tiles.replacementID;
+			--numToReplace;
+		} else {
+			id &= ~kTileRandom;
+		}
+
+		--remaining;
+	}
+}
+
+int32 EscapeGridPuzzle::pickTileVariant(int32 id) const {
+	Common::Array<int32> variants;
+	for (const TileType &type : _tileTypes) {
+		if ((type.id & ~kTileVariantBits) == id) {
+			variants.push_back(type.id);
+		}
+	}
+
+	return variants.empty() ? 0 : variants[g_nancy->_randomSource->getRandomNumber(variants.size() - 1)];
+}
+
 static void loadPuzzleImage(const Common::Path &name, Graphics::ManagedSurface &surface, uint32 transColor) {
 	if (name.empty()) {
 		return;
@@ -257,8 +323,30 @@ void EscapeGridPuzzle::init() {
 	registerGraphics();
 }
 
-// Odd columns hang half a row lower than even ones.
-Common::Point EscapeGridPuzzle::step(const Common::Point &cell, Direction dir) {
+// On a hex grid, odd columns hang half a row lower than even ones; otherwise the
+// diagonal steps go straight to the next row
+Common::Point EscapeGridPuzzle::step(const Common::Point &cell, Direction dir) const {
+	if (!_isHexGrid) {
+		switch (dir) {
+		case kNorth:
+			return Common::Point(cell.x, cell.y - 1);
+		case kNorthEast:
+			return Common::Point(cell.x + 1, cell.y - 1);
+		case kSouthEast:
+			return Common::Point(cell.x + 1, cell.y + 1);
+		case kSouth:
+			return Common::Point(cell.x, cell.y + 1);
+		case kSouthWest:
+			return Common::Point(cell.x - 1, cell.y + 1);
+		case kNorthWest:
+			return Common::Point(cell.x - 1, cell.y - 1);
+		case kEast:
+			return Common::Point(cell.x + 1, cell.y);
+		default:
+			return Common::Point(cell.x - 1, cell.y);
+		}
+	}
+
 	bool odd = (cell.x & 1) != 0;
 
 	switch (dir) {
@@ -277,8 +365,20 @@ Common::Point EscapeGridPuzzle::step(const Common::Point &cell, Direction dir) {
 	}
 }
 
-bool EscapeGridPuzzle::directionTo(const Common::Point &from, const Common::Point &to, Direction &dir) {
-	for (int i = 0; i < kNumDirections; ++i) {
+EscapeGridPuzzle::Direction EscapeGridPuzzle::opposite(Direction dir) {
+	if (dir == kEast) {
+		return kWest;
+	}
+
+	if (dir == kWest) {
+		return kEast;
+	}
+
+	return (Direction)((dir + kNumHexDirections / 2) % kNumHexDirections);
+}
+
+bool EscapeGridPuzzle::directionTo(const Common::Point &from, const Common::Point &to, Direction &dir) const {
+	for (uint i = 0; i < numDirections(); ++i) {
 		if (step(from, (Direction)i) == to) {
 			dir = (Direction)i;
 			return true;
@@ -360,8 +460,10 @@ bool EscapeGridPuzzle::canStepOnto(const Common::Array<int32> &grid, const Commo
 		return false;
 	}
 
+	// In Nancy16, the missing bit is one of the variant bits, which only pick a floe's shape
 	int32 id = grid[cell.y * _numCols + cell.x];
-	if (!(id & kTileWalkable) || (id & kTileMissing)) {
+	bool isMissing = (id & kTileMissing) && g_nancy->getGameType() <= kGameTypeNancy15;
+	if (!(id & kTileWalkable) || isMissing) {
 		return false;
 	}
 
@@ -394,7 +496,7 @@ void EscapeGridPuzzle::collectLine(const Common::Array<int32> &grid, const Commo
 		return;
 	}
 
-	Direction back = (Direction)((dir + kNumDirections / 2) % kNumDirections);
+	Direction back = opposite(dir);
 	Common::Array<Common::Point> behind;
 	for (Common::Point p = to; validCell(p) && (grid[p.y * _numCols + p.x] & kTileMovable); p = step(p, back)) {
 		behind.push_back(p);
@@ -486,6 +588,70 @@ void EscapeGridPuzzle::slideBoard(const Common::Point &from, const Common::Point
 	}
 }
 
+// Nancy16's floes don't slide. Instead, the lake cells in line with the jump form a loop:
+// the landing floe, then back from the one jumped off, then from the far end ahead back to
+// the landing floe. The cell four along that loop rises as a floe, and the one right past
+// the landing floe sinks; with nothing ahead of the landing floe, the farthest one behind
+// sinks instead.
+void EscapeGridPuzzle::moveIceFloes(const Common::Point &from, const Common::Point &to) {
+	static const int32 kIceFloe = kTileWalkable | kTileLake;
+	static const int32 kOpenWater = kTileLake | kTileSunk;
+
+	Direction dir;
+	if (!validCell(to) || !(tileAt(to) & kTileLake) || !directionTo(from, to, dir)) {
+		return;
+	}
+
+	Direction back = opposite(dir);
+
+	Common::Array<Common::Point> ahead;
+	for (Common::Point p = to; validCell(p) && (tileAt(p) & kTileLake); p = step(p, dir)) {
+		ahead.push_back(p);
+	}
+
+	Common::Array<Common::Point> behind;
+	for (Common::Point p = from; validCell(p) && (tileAt(p) & kTileLake); p = step(p, back)) {
+		behind.push_back(p);
+	}
+
+	Common::Array<Common::Point> loop;
+	loop.push_back(to);
+	for (const Common::Point &p : behind) {
+		loop.push_back(p);
+	}
+
+	Common::Point sinking = kNoCell;
+	if (ahead.size() > 1) {
+		sinking = ahead[1];
+		for (uint i = ahead.size() - 1; i >= 1; --i) {
+			loop.push_back(ahead[i]);
+		}
+	} else if (!behind.empty()) {
+		sinking = behind.back();
+	}
+
+	if (sinking != kNoCell && (tileAt(sinking) & kOpenWater) == kOpenWater) {
+		sinking = kNoCell;
+	}
+
+	Common::Point rising = loop[4 % loop.size()];
+	if ((tileAt(rising) & kIceFloe) == kIceFloe) {
+		rising = kNoCell;
+	}
+
+	if (rising != kNoCell) {
+		int32 id = pickTileVariant(kIceFloe);
+		tileAt(rising) = id;
+		pushTileAnim(rising, kTileRise, id, tileRiseTime(id));
+	}
+
+	if (sinking != kNoCell && sinking != to && sinking != rising) {
+		int32 id = tileAt(sinking);
+		pushTileAnim(sinking, kTileSink, id, tileSinkTime(id));
+		tileAt(sinking) = pickTileVariant(kOpenWater);
+	}
+}
+
 int32 EscapeGridPuzzle::tileSinkTime(int32 tileID) const {
 	const TileType *type = tileType(tileID);
 	return (type && !type->sink.srcs.empty()) ? framesToTicks(type->sink.srcs.size()) : _defaultSinkTime;
@@ -618,7 +784,7 @@ bool EscapeGridPuzzle::updateTileAnims(int32 ticks) {
 }
 
 bool EscapeGridPuzzle::hasAnyMove(uint actor) const {
-	for (int i = 0; i < kNumDirections; ++i) {
+	for (uint i = 0; i < numDirections(); ++i) {
 		if (canStepOnto(_grid, _actorCells, step(_actorCells[actor], (Direction)i))) {
 			return true;
 		}
@@ -642,7 +808,7 @@ Common::Point EscapeGridPuzzle::chooseOpponentMove(uint actor) {
 		target = bestPath[0];
 	} else {
 		Common::Array<Common::Point> candidates;
-		for (int i = 0; i < kNumDirections; ++i) {
+		for (uint i = 0; i < numDirections(); ++i) {
 			candidates.push_back(step(_actorCells[actor], (Direction)i));
 		}
 
@@ -682,7 +848,7 @@ void EscapeGridPuzzle::searchPath(uint actor, int depth, Common::Array<int32> &g
 		return;
 	}
 
-	for (int i = 0; i < kNumDirections; ++i) {
+	for (uint i = 0; i < numDirections(); ++i) {
 		Common::Point next = step(pos, (Direction)i);
 		if (!canStepOnto(grid, cells, next)) {
 			continue;
@@ -715,6 +881,12 @@ void EscapeGridPuzzle::startMove(const Common::Point &target) {
 	uint compassDir = kCompassDirection[dir];
 	const MoviePlayer *movie = _jumpMovies[_actors[_turn].characterID * kNumCompassDirections + compassDir];
 
+	// Characters make a sound as they start jumping
+	int32 id = _actors[_turn].characterID;
+	if (_actorAnims[_turn].empty() && id >= 0 && (uint)id < _characters.size()) {
+		playSoundBlock(_characters[id].jumpSound);
+	}
+
 	ActorAnim anim;
 	anim.kind = kActorJump;
 	anim.from = from;
@@ -746,7 +918,11 @@ void EscapeGridPuzzle::updateTurn(int32 ticks) {
 		_moveIssued = false;
 		_slidingPhase = true;
 		for (uint i = 0; i < _landedJumps.size(); ++i) {
-			slideBoard(_landedJumps[i].from, _landedJumps[i].to);
+			if (g_nancy->getGameType() >= kGameTypeNancy16) {
+				moveIceFloes(_landedJumps[i].from, _landedJumps[i].to);
+			} else {
+				slideBoard(_landedJumps[i].from, _landedJumps[i].to);
+			}
 		}
 		_landedJumps.clear();
 		return;
diff --git a/engines/nancy/action/puzzle/escapegridpuzzle.h b/engines/nancy/action/puzzle/escapegridpuzzle.h
index 329863e35d5..fc9d1a81b8c 100644
--- a/engines/nancy/action/puzzle/escapegridpuzzle.h
+++ b/engines/nancy/action/puzzle/escapegridpuzzle.h
@@ -52,8 +52,12 @@ public:
 	// Bits of a tile type id.
 	enum TileFlags : int32 {
 		kTileWalkable	= 0x001,
-		kTileMovable	= 0x010,	// slides along when a line is pushed
-		kTileMissing	= 0x100		// overrides kTileWalkable
+		kTileLake		= 0x002,	// Nancy16: a floe or open water
+		kTileSunk		= 0x004,	// Nancy16: open water
+		kTileRandom		= 0x008,	// Nancy16, see RandomTiles
+		kTileMovable	= 0x010,	// Nancy15: slides along when a line is pushed
+		kTileMissing	= 0x100,	// Nancy15: overrides kTileWalkable
+		kTileVariantBits = 0x1f0	// Nancy16: pick the look, left out of the grid; see pickTileVariant()
 	};
 
 	// The six neighbors of a hex, in the order they are searched.
@@ -64,9 +68,13 @@ public:
 		kSouth		= 3,
 		kSouthWest	= 4,
 		kNorthWest	= 5,
+		kEast		= 6,	// only on boards laid out in rows
+		kWest		= 7,
 		kNumDirections
 	};
 
+	static const uint kNumHexDirections = kEast;
+
 	// Jump animations are indexed clockwise from north; a hex leaves east and west empty.
 	static const uint kNumCompassDirections = 8;
 
@@ -104,11 +112,18 @@ public:
 		int32 offsetX = 0;
 		int32 offsetY = 0;
 		Common::Array<CharacterTileAnims> tileAnims;
+		RandomSoundBlock jumpSound;		// Nancy16
 		Common::Path jumpNames[kNumCompassDirections];
 		int32 jumpOffsetX[kNumCompassDirections] = {};
 		int32 jumpOffsetY[kNumCompassDirections] = {};
 	};
 
+	struct RandomTiles {
+		int32 id = 0;
+		int32 replacementID = 0;
+		float percent = 0;
+	};
+
 	struct Actor {
 		int32 characterID = 0;
 		Common::Point start;
@@ -164,9 +179,13 @@ protected:
 	};
 
 	static void readAnimation(Common::SeekableReadStream &stream, Animation &dst);
+	void placeRandomTiles(const RandomTiles &tiles);
+	int32 pickTileVariant(int32 id) const;
 
-	static Common::Point step(const Common::Point &cell, Direction dir);
-	static bool directionTo(const Common::Point &from, const Common::Point &to, Direction &dir);
+	uint numDirections() const { return _isHexGrid ? kNumHexDirections : (uint)kNumDirections; }
+	static Direction opposite(Direction dir);
+	Common::Point step(const Common::Point &cell, Direction dir) const;
+	bool directionTo(const Common::Point &from, const Common::Point &to, Direction &dir) const;
 	static int32 framesToTicks(uint numFrames);
 	static uint frameAt(uint numFrames, int32 elapsed);
 
@@ -186,6 +205,7 @@ protected:
 	void rotateLine(Common::Array<int32> &grid, Common::Array<Common::Point> &cells,
 		const Common::Array<Common::Point> &line) const;
 	void slideBoard(const Common::Point &from, const Common::Point &to);
+	void moveIceFloes(const Common::Point &from, const Common::Point &to);
 
 	int32 tileSinkTime(int32 tileID) const;
 	int32 tileRiseTime(int32 tileID) const;
@@ -237,7 +257,7 @@ protected:
 	Common::Rect _rowStepCell;
 	Common::Rect _colStepCell;
 
-	byte _unknownGridFlag = 0;
+	bool _isHexGrid = true;			// otherwise the cells line up in rows (Nancy16)
 	int32 _numCols = 0;
 	int32 _numRows = 0;
 	int32 _hexDelay = 0;			// between one hex of a sliding line and the next


Commit: c0b9bdee6eb85b803ea781281aea427130e67304
    https://github.com/scummvm/scummvm/commit/c0b9bdee6eb85b803ea781281aea427130e67304
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:16+03:00

Commit Message:
NANCY: NANCY16: NDUI: Add the sound handler

Engine_Sound pauses and resumes a sound channel, as the Geiger
counter's mute button asks

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


diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index d1a612a4bda..b7c975316b9 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -99,6 +99,8 @@ void Manager::takeEngineAction(const Action &action) {
 		onVolumeAction(action, "music_volume");
 	} else if (action.target.equalsIgnoreCase("Engine_Flags")) {
 		onFlagsAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_Sound")) {
+		onSoundAction(action);
 	} else {
 		debugC(3, kDebugEngine, "Unhandled NDUI engine action %d on \"%s\" from \"%s\"", action.type, action.target.c_str(), action.sender.c_str());
 	}
@@ -261,6 +263,44 @@ void Manager::onTasklistAction(const Action &action) {
 	playTasklistSound(0, true);
 }
 
+// Pauses and resumes the sound on a channel, e.g. the Geiger counter's clicking. Showing
+// only resumes a channel the UI paused itself, and hiding only pauses one that is playing.
+void Manager::onSoundAction(const Action &action) {
+	uint16 channel = (uint16)action.value;
+	int index = -1;
+	for (uint i = 0; i < _pausedChannels.size(); ++i) {
+		if (_pausedChannels[i] == channel) {
+			index = i;
+			break;
+		}
+	}
+
+	bool pause;
+	switch (action.type) {
+	case kActionShow:
+		pause = false;
+		break;
+	case kActionHide:
+		pause = true;
+		break;
+	case kActionToggle:
+		pause = index == -1;
+		break;
+	default:
+		return;
+	}
+
+	if (pause) {
+		if (index == -1 && g_nancy->_sound->isSoundPlaying(channel)) {
+			g_nancy->_sound->pauseSound(channel, true);
+			_pausedChannels.push_back(channel);
+		}
+	} else if (index != -1) {
+		g_nancy->_sound->pauseSound(channel, false);
+		_pausedChannels.remove_at(index);
+	}
+}
+
 void Manager::playTasklistSound(uint bank, bool play) {
 	const TSKL *tasklistSounds = GetEngineData(TSKL);
 	if (!tasklistSounds) {
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 6b2f2a2f7f5..150a843d5c2 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -124,6 +124,7 @@ private:
 	void onJournalAction(const Action &action);
 	void onTasklistAction(const Action &action);
 	void playTasklistSound(uint bank, bool play);
+	void onSoundAction(const Action &action);
 
 	void onLoaderAction(const Action &action);
 	void onSaverAction(const Action &action);
@@ -154,6 +155,8 @@ private:
 	bool _isInConversation = false;
 	Common::Array<Common::String> _responseNames;
 
+	Common::Array<uint16> _pausedChannels;	// sound channels the UI paused, to resume later
+
 	Common::String _selectedSave;	// in the load dialog
 	Common::String _typedSaveName;	// in the save dialog
 	Common::String _lastSaveName;


Commit: 2f945c44fbaaca952e4a16a7224ea3187b4fa6cf
    https://github.com/scummvm/scummvm/commit/2f945c44fbaaca952e4a16a7224ea3187b4fa6cf
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:20+03:00

Commit Message:
NANCY: NANCY16: Implement the wolf puzzles

- WolfCommands (198) fills the command dialog with a button per
  command, and has Nancy call the queued commands out
- The commands are left as a playlist under the name of the wolf's
  random movie, which plays them through its "commanded" next sequence
- WolfMazePuzzle (199) runs the wolf through the maze with the same
  commands
- Records can register to answer an NDUI engine target of their own
- Move the captioned playback of a random sound block to
  RandomSoundBlock::play()
- The playlists are kept by the scene and aren't saved
- NDUI panels no longer push a too-wide control off an empty row, and
  grow to hold their arranged controls, instead of clipping them

Changed paths:
  A engines/nancy/action/puzzle/wolfmazepuzzle.cpp
  A engines/nancy/action/puzzle/wolfmazepuzzle.h
  A engines/nancy/action/wolfcommands.cpp
  A engines/nancy/action/wolfcommands.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/puzzlerecord.cpp
    engines/nancy/action/secondarymovie.cpp
    engines/nancy/action/secondarymovie.h
    engines/nancy/commontypes.cpp
    engines/nancy/commontypes.h
    engines/nancy/module.mk
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/manager.h
    engines/nancy/ui/ndui/panel.cpp


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 590d4131174..c87ef4d2601 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -33,6 +33,7 @@
 #include "engines/nancy/action/overlay.h"
 #include "engines/nancy/action/secondaryvideo.h"
 #include "engines/nancy/action/secondarymovie.h"
+#include "engines/nancy/action/wolfcommands.h"
 
 #include "engines/nancy/action/puzzle/adjustpuzzle.h"
 #include "engines/nancy/action/puzzle/analogclock.h"
@@ -109,6 +110,7 @@
 #include "engines/nancy/action/puzzle/twodialpuzzle.h"
 #include "engines/nancy/action/puzzle/typingquizpuzzle.h"
 #include "engines/nancy/action/puzzle/weightsortpuzzle.h"
+#include "engines/nancy/action/puzzle/wolfmazepuzzle.h"
 #include "engines/nancy/action/puzzle/whalesurvivorpuzzle.h"
 #include "engines/nancy/action/puzzle/wordfindpuzzle.h"
 
@@ -706,12 +708,14 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new AnalogClock();
 	case 197:
 		return new AutotextScroll();	// Used for the end credits
-	case 188:	// Empty in the original
-	case 190:	// MemoryPuzzle, moved from 238
-	case 191:	// BeadPuzzle, moved from 243
-	case 198:	// WolfCommands ("Movie Buttons"), builds its buttons in the new UI
-	case 199:	// WolfMaze, driven by the commands queued through 198
+	case 188:	// Empty in Nancy16, added for completeness
+	case 190:	// MemoryPuzzle, moved from 238, unused in Nancy16
+	case 191:	// BeadPuzzle, moved from 243, unused in Nancy16
 		return nullptr;
+	case 198:
+		return new WolfCommands();
+	case 199:
+		return new WolfMazePuzzle();	// Driven by the commands given through 198
 	case 200:
 		return new SoundEqualizerPuzzle();
 	case 201:
diff --git a/engines/nancy/action/puzzle/wolfmazepuzzle.cpp b/engines/nancy/action/puzzle/wolfmazepuzzle.cpp
new file mode 100644
index 00000000000..b87a2a2963d
--- /dev/null
+++ b/engines/nancy/action/puzzle/wolfmazepuzzle.cpp
@@ -0,0 +1,490 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/wolfmazepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint kRowLength = 50;
+
+Common::Point WolfMazePuzzle::step(const Common::Point &cell, int direction) {
+	static const int8 kSteps[kNumDirections][2] = {
+		{ 0, -1 }, { 1, -1 }, { 1, 0 }, { 1, 1 }, { 0, 1 }, { -1, 1 }, { -1, 0 }, { -1, -1 }
+	};
+
+	return Common::Point(cell.x + kSteps[direction][0], cell.y + kSteps[direction][1]);
+}
+
+WolfMazePuzzle::Command WolfMazePuzzle::commandFromName(const Common::String &name) {
+	static const char *const kNames[] = { "Right", "Left", "Forward", "Backward", "Around", "Bark", "Jump", "Paw" };
+
+	for (uint i = 0; i < ARRAYSIZE(kNames); ++i) {
+		if (name.equalsIgnoreCase(kNames[i])) {
+			return (Command)(kRight + i);
+		}
+	}
+
+	return kCommandNone;
+}
+
+void WolfMazePuzzle::readEnding(Common::SeekableReadStream &stream, Ending &ending) {
+	ending.scene._sceneChange.sceneID = stream.readUint16LE();
+	ending.scene._sceneChange.frameID = stream.readUint16LE();
+	ending.scene._sceneChange.continueSceneSound = kContinueSceneSound;
+	ending.scene._flag.label = stream.readSint16LE();
+	ending.scene._flag.flag = stream.readByte();
+	ending.sound.readData(stream);
+}
+
+bool WolfMazePuzzle::isValid(const Common::Point &cell) const {
+	return cell.x >= 0 && cell.y >= 0 && cell.x < (int)_width && cell.y < (int)_height;
+}
+
+bool WolfMazePuzzle::hasNeighbor(const Common::Point &cell, int direction) const {
+	return typeAt(step(cell, direction)) != kEmpty;
+}
+
+int WolfMazePuzzle::countNeighbors(const Common::Point &cell, bool orthogonalOnly) const {
+	int count = 0;
+	for (int direction = 0; direction < kNumDirections; direction += orthogonalOnly ? 2 : 1) {
+		if (hasNeighbor(cell, direction)) {
+			++count;
+		}
+	}
+
+	return count;
+}
+
+void WolfMazePuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _playlistName);
+	_cursor = stream.readUint16LE();
+
+	// The map is drawn with characters: ' ' is empty, '*' a path, 'L' a path that shows
+	// tracks, 'P' a pit, 'S' a switch, 'B' a barricade and 'E' the exit
+	Common::Array<Common::String> rows(MAX<int16>(stream.readSint16LE(), 0));
+	for (Common::String &row : rows) {
+		char buf[kRowLength + 1];
+		stream.read(buf, kRowLength);
+		buf[kRowLength] = '\0';
+		row = buf;
+	}
+
+	_height = rows.size();
+	_width = _height ? rows[0].size() : 0;
+
+	Common::Rect firstCell, nextColumn, nextRow;
+	readRect(stream, firstCell);
+	readRect(stream, nextColumn);
+	readRect(stream, nextRow);
+	readFilename(stream, _imageName);
+	readRect(stream, _trackSrc);
+
+	_cells.resize(_width * _height);
+	for (uint y = 0; y < _height; ++y) {
+		if (rows[y].size() != _width) {
+			error("WolfMazePuzzle: grid rows are of different length");
+		}
+
+		for (uint x = 0; x < _width; ++x) {
+			Cell &cell = _cells[y * _width + x];
+			cell.dest = firstCell;
+			cell.dest.translate(x * (nextColumn.left - firstCell.left), y * (nextRow.top - firstCell.top));
+
+			switch (rows[y][x]) {
+			case ' ':
+				cell.type = kEmpty;
+				break;
+			case '*':
+				cell.type = kPath;
+				break;
+			case 'L':
+			case 'l':
+				cell.type = kTrack;
+				break;
+			case 'P':
+			case 'p':
+				cell.type = kPit;
+				break;
+			case 'S':
+			case 's':
+				cell.type = kSwitch;
+				break;
+			case 'B':
+			case 'b':
+				cell.type = kBarricadeAcross;
+				break;
+			case 'E':
+			case 'e':
+				cell.type = kExit;
+				break;
+			default:
+				error("WolfMazePuzzle: unknown tile '%c'", rows[y][x]);
+			}
+		}
+	}
+
+	for (uint i = 0; i < 2; ++i) {
+		int16 count = stream.readSint16LE();
+		for (int16 j = 0; j < count; ++j) {
+			Common::Point pos;
+			pos.x = stream.readSint32LE();
+			pos.y = stream.readSint32LE();
+			FlagDescription flag;
+			flag.label = stream.readSint16LE();
+			flag.flag = stream.readByte();
+			if (isValid(pos)) {
+				cellAt(pos).flag = flag;
+			}
+		}
+	}
+
+	// Paths that branch off stop the wolf, and barricades face across their path
+	for (uint y = 0; y < _height; ++y) {
+		for (uint x = 0; x < _width; ++x) {
+			Common::Point pos(x, y);
+			Cell &cell = cellAt(pos);
+			if (cell.type == kPath && countNeighbors(pos, true) > 2) {
+				cell.type = kJunction;
+			} else if (cell.type == kBarricadeAcross && hasNeighbor(pos, kNorth) && hasNeighbor(pos, kSouth)) {
+				cell.type = kBarricadeUpDown;
+			}
+		}
+	}
+
+	_wolfStart.x = stream.readSint32LE();
+	_wolfStart.y = stream.readSint32LE();
+	Common::Point facing;
+	facing.x = stream.readSint32LE();
+	facing.y = stream.readSint32LE();
+	for (int direction = 0; direction < kNumDirections; ++direction) {
+		if (step(_wolfStart, direction) == facing) {
+			_wolfStartDirection = direction;
+		}
+	}
+
+	_moveTime = (uint32)(stream.readFloatLE() * 1000);
+	_returnTime = (uint32)(stream.readFloatLE() * 1000);
+
+	int16 numMovies = stream.readSint16LE();
+	for (int16 i = 0; i < numMovies; ++i) {
+		Common::String movie, command;
+		readFilename(stream, movie);
+		readFilename(stream, command);
+		_movieCommands.setVal(movie, commandFromName(command));
+	}
+
+	_commandSound.readData(stream);
+	_blockedSound.readData(stream);
+	_invalidSound.readData(stream);
+	_runSound.readData(stream);
+	readEnding(stream, _failEnding);
+	readEnding(stream, _solveEnding);
+
+	readExitHotspot(stream);
+}
+
+void WolfMazePuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	// The commands WolfCommands left for the wolf
+	Common::Array<Common::String> movies;
+	NancySceneState.takeMoviePlaylist(_playlistName, movies);
+	for (const Common::String &movie : movies) {
+		if (_movieCommands.contains(movie)) {
+			_commands.push_back(_movieCommands[movie]);
+		} else {
+			warning("WolfMazePuzzle: movie %s isn't mapped to a command", movie.c_str());
+		}
+	}
+
+	_direction = _wolfStartDirection;
+	enterCell(_wolfStart);
+	_lastTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+void WolfMazePuzzle::enterCell(const Common::Point &cell) {
+	_wolf = cell;
+	++cellAt(cell).visits;
+	_track.push_back(cell);
+}
+
+// Where the path ahead ends, the wolf turns to the first other way she didn't come from
+void WolfMazePuzzle::followBend() {
+	if (typeAt(step(_wolf, _direction)) != kEmpty) {
+		return;
+	}
+
+	int back = (_direction + kNumDirections / 2) % kNumDirections;
+	for (int direction = 0; direction < kNumDirections; ++direction) {
+		if (direction != back && hasNeighbor(_wolf, direction)) {
+			_direction = direction;
+			return;
+		}
+	}
+}
+
+WolfMazePuzzle::WolfStatus WolfMazePuzzle::updateWolf(uint32 elapsed) {
+	if (_isMoving) {
+		_timer += elapsed;
+		if (_timer < _moveTime) {
+			return kRunning;
+		}
+
+		_timer = 0;
+		followBend();
+
+		Common::Point ahead = step(_wolf, _direction);
+		switch (typeAt(ahead)) {
+		case kPit:
+			if (!_isJumping) {
+				break;
+			}
+
+			// fall through
+		case kPath:
+		case kTrack:
+			_isJumping = false;
+			enterCell(ahead);
+			return kRunning;
+		case kJunction:
+			enterCell(ahead);
+			_isMoving = false;
+			return kDone;
+		case kExit:
+			enterCell(ahead);
+			_isMoving = false;
+			return kExited;
+		case kBarricadeAcross:
+		case kBarricadeUpDown: {
+			bool isInTheWay = typeAt(ahead) == kBarricadeAcross ?
+				(_direction == kEast || _direction == kWest) : (_direction == kNorth || _direction == kSouth);
+			const FlagDescription &flag = cellAt(ahead).flag;
+			if (isInTheWay && !NancySceneState.getEventFlag(flag.label, flag.flag)) {
+				_isMoving = false;
+				return kBlocked;
+			}
+
+			enterCell(ahead);
+			return kRunning;
+		}
+		default:
+			break;
+		}
+
+		_isMoving = false;
+		return kDone;
+	}
+
+	if (_isReturning) {
+		_timer += elapsed;
+		if (_timer >= _returnTime) {
+			_timer = 0;
+			if (_track.empty()) {
+				_isReturning = false;
+				return kReturned;
+			}
+
+			_wolf = _track.back();
+			--cellAt(_wolf).visits;
+			_track.pop_back();
+		}
+
+		return kRunning;
+	}
+
+	return _track.empty() ? kReturned : kDone;
+}
+
+// Returns false when the wolf can't carry the command out
+bool WolfMazePuzzle::runCommand(Command command) {
+	Common::Point ahead = step(_wolf, _direction);
+
+	switch (command) {
+	case kAround:
+	case kBark:
+		return true;
+	case kJump:
+		if (typeAt(ahead) != kPit) {
+			return false;
+		}
+
+		_isMoving = true;
+		_isJumping = true;
+		return true;
+	case kPaw:
+		if (typeAt(ahead) != kSwitch) {
+			return false;
+		}
+
+		NancySceneState.setEventFlag(cellAt(ahead).flag);
+		return true;
+	case kReturn:
+		_isReturning = true;
+		return true;
+	case kRight:
+	case kLeft:
+	case kForward:
+	case kBackward: {
+		// Moves are relative to where the wolf is facing
+		static const int kTurns[] = { 2, 6, 0, 4 };
+		int direction = (_direction + kTurns[command - kRight]) % kNumDirections;
+		TileType type = typeAt(step(_wolf, direction));
+		if (type == kEmpty || type == kPit || type == kSwitch) {
+			return false;
+		}
+
+		_direction = direction;
+		_isMoving = true;
+		return true;
+	}
+	default:
+		return false;
+	}
+}
+
+void WolfMazePuzzle::startEnding(const Ending &ending) {
+	_isEnding = true;
+	_endScene = ending.scene;
+	_endSound = playSoundBlock(ending.sound);
+}
+
+void WolfMazePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (const Cell &cell : _cells) {
+		if (cell.type == kTrack && cell.visits > 0) {
+			_drawSurface.blitFrom(_image, _trackSrc, cell.dest.origin());
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void WolfMazePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_isEnding) {
+			if (!g_nancy->_sound->isSoundPlaying(_endSound)) {
+				_state = kActionTrigger;
+			}
+
+			break;
+		}
+
+		uint32 now = g_nancy->getTotalPlayTime();
+		uint32 elapsed = now - _lastTime;
+		_lastTime = now;
+
+		switch (updateWolf(elapsed)) {
+		case kDone:
+			if (_commands.empty()) {
+				runCommand(kReturn);
+			} else {
+				Command command = _commands.remove_at(0);
+				if (runCommand(command)) {
+					playSoundBlock(_commandSound);
+				} else {
+					playSoundBlock(_invalidSound);
+					_commands.clear();
+				}
+			}
+
+			break;
+		case kRunning:
+			if (!g_nancy->_sound->isSoundPlaying(_runSoundDesc)) {
+				_runSoundDesc = playSoundBlock(_runSound);
+			}
+
+			break;
+		case kExited:
+			startEnding(_solveEnding);
+			break;
+		case kBlocked:
+			playSoundBlock(_blockedSound);
+			_commands.clear();
+			runCommand(kReturn);
+			break;
+		case kReturned:
+			if (_commands.empty()) {
+				startEnding(_failEnding);
+			}
+
+			break;
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		if (_exitRequested) {
+			_exitScene.execute();
+		} else {
+			_endScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void WolfMazePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _isEnding || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	if (hoverExitHotspot(input)) {
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	setDataCursor(_cursor);
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/wolfmazepuzzle.h b/engines/nancy/action/puzzle/wolfmazepuzzle.h
new file mode 100644
index 00000000000..ca2c2152cea
--- /dev/null
+++ b/engines/nancy/action/puzzle/wolfmazepuzzle.h
@@ -0,0 +1,163 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_WOLFMAZEPUZZLE_H
+#define NANCY_ACTION_WOLFMAZEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 wolf maze (AR 199). Isis runs through a map of the sewers, following the
+// commands given to her through WolfCommands. Moves turn her and send her running until
+// she reaches a junction or a wall, following the bends of the tunnels on the way. She
+// jumps over pits and paws switches, which open barricades. Once the commands run out,
+// or a closed barricade blocks her, she comes back the way she went; reaching the exit
+// solves the puzzle. Only the tracks she leaves on the map get drawn.
+class WolfMazePuzzle : public PuzzleRecord {
+public:
+	WolfMazePuzzle() : PuzzleRecord(7) {}
+	virtual ~WolfMazePuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "WolfMazePuzzle"; }
+
+	enum TileType {
+		kEmpty,
+		kPath,
+		kTrack,			// a path that shows the wolf's tracks
+		kJunction,		// a path with more than two neighbors, where the wolf stops
+		kPit,
+		kSwitch,
+		kBarricadeAcross,	// in a path running east to west
+		kBarricadeUpDown,	// in a path running north to south
+		kExit
+	};
+
+	// Clockwise from north
+	enum Direction {
+		kNorth, kNorthEast, kEast, kSouthEast, kSouth, kSouthWest, kWest, kNorthWest,
+		kNumDirections
+	};
+
+	enum Command {
+		kCommandNone,
+		kRight,
+		kLeft,
+		kForward,
+		kBackward,
+		kAround,
+		kBark,
+		kJump,
+		kPaw,
+		kReturn			// given once the queued commands run out
+	};
+
+	enum WolfStatus {
+		kDone,			// standing, waiting for a command
+		kRunning,
+		kExited,
+		kReturned,
+		kBlocked
+	};
+
+	struct Cell {
+		TileType type = kEmpty;
+		Common::Rect dest;
+		uint visits = 0;
+		FlagDescription flag;	// a switch sets it; a barricade is open while it is set
+	};
+
+	struct Ending {
+		SceneChangeWithFlag scene;
+		RandomSoundBlock sound;
+	};
+
+	static Common::Point step(const Common::Point &cell, int direction);
+	static Command commandFromName(const Common::String &name);
+	static void readEnding(Common::SeekableReadStream &stream, Ending &ending);
+
+	bool isValid(const Common::Point &cell) const;
+	Cell &cellAt(const Common::Point &cell) { return _cells[cell.y * _width + cell.x]; }
+	const Cell &cellAt(const Common::Point &cell) const { return _cells[cell.y * _width + cell.x]; }
+	TileType typeAt(const Common::Point &cell) const { return isValid(cell) ? cellAt(cell).type : kEmpty; }
+	int countNeighbors(const Common::Point &cell, bool orthogonalOnly) const;
+	bool hasNeighbor(const Common::Point &cell, int direction) const;
+
+	void enterCell(const Common::Point &cell);
+	void followBend();
+	WolfStatus updateWolf(uint32 elapsed);
+	bool runCommand(Command command);
+	void startEnding(const Ending &ending);
+
+	void redraw();
+
+	// -- File data --
+	Common::String _playlistName;
+	uint16 _cursor = 0;
+	uint _width = 0;
+	uint _height = 0;
+	Common::Array<Cell> _cells;
+	Common::Rect _trackSrc;			// in _image
+	Common::Point _wolfStart;
+	int _wolfStartDirection = kNorth;
+	uint32 _moveTime = 0;			// ms per cell
+	uint32 _returnTime = 0;
+	Common::HashMap<Common::String, Command, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _movieCommands;
+	RandomSoundBlock _commandSound;
+	RandomSoundBlock _blockedSound;
+	RandomSoundBlock _invalidSound;
+	RandomSoundBlock _runSound;
+	Ending _failEnding;
+	Ending _solveEnding;
+
+	// -- Runtime state --
+	Common::Array<Command> _commands;
+	Common::Point _wolf;
+	int _direction = kNorth;
+	bool _isMoving = false;
+	bool _isJumping = false;
+	bool _isReturning = false;
+	uint32 _timer = 0;
+	Common::Array<Common::Point> _track;	// the cells the wolf went through, to come back by
+	SoundDescription _runSoundDesc;
+
+	uint32 _lastTime = 0;
+	bool _isEnding = false;
+	bool _exitRequested = false;
+	SceneChangeWithFlag _endScene;
+	SoundDescription _endSound;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_WOLFMAZEPUZZLE_H
diff --git a/engines/nancy/action/puzzlerecord.cpp b/engines/nancy/action/puzzlerecord.cpp
index d6ea6342739..eac098ce61a 100644
--- a/engines/nancy/action/puzzlerecord.cpp
+++ b/engines/nancy/action/puzzlerecord.cpp
@@ -91,32 +91,7 @@ Common::Point PuzzleRecord::getHeldPiecePos(const Common::Point &mousePos, const
 }
 
 SoundDescription PuzzleRecord::playSoundBlock(const RandomSoundBlock &block) {
-	SoundDescription desc;
-	if (block.names.empty()) {
-		return desc;
-	}
-
-	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
-	const Common::String &name = block.names[idx];
-	if (name.empty() || name == "NO SOUND") {
-		return desc;
-	}
-
-	desc.name = name;
-	desc.channelID = block.channel;
-	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
-	desc.volume = block.volume;
-
-	g_nancy->_sound->loadSound(desc);
-	g_nancy->_sound->playSound(desc);
-
-	Common::String caption = resolveSubtitleText(name);
-	if (caption.empty()) {
-		caption = resolveSubtitleText(name, Common::String(), "CONVO");
-	}
-	showSubtitle(caption);
-
-	return desc;
+	return block.play();
 }
 
 bool PuzzleRecord::isSoundBlockPlaying(const RandomSoundBlock &block) const {
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index f86ea44c7d3..5447f56214c 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -206,6 +206,9 @@ void PlaySecondaryMovie::readRandomSequence(Common::Serializer &ser, RandomSeque
 		case -5:
 			next.condition = kNextIfChannel12Playing;
 			break;
+		case -6:
+			next.condition = kNextIfCommanded;
+			break;
 		default:
 			next.weight = weight;
 			break;
@@ -281,7 +284,7 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
 // list.
 void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream) {
 	if (g_nancy->getGameType() >= kGameTypeNancy16) {
-		ser.skip(33);	// Movie identifier, not referenced by any supported record
+		readFilename(ser, _playlistName);
 	}
 
 	readFilename(ser, _startingSequenceName);
@@ -377,6 +380,12 @@ bool PlaySecondaryMovie::activateRandomSequence(int index) {
 
 	const RandomSequence &src = _sequences[index];
 	_activeSequenceIndex = index;
+
+	// A commanded movie is done with once it starts
+	if (!_commandedSequences.empty() && src.name == _commandedSequences[0]) {
+		_commandedSequences.remove_at(0);
+	}
+
 	_videoName = src.name;
 	_firstFrame = src.startFrame;
 	_lastFrame = src.lastFrame;
@@ -523,7 +532,7 @@ bool PlaySecondaryMovie::randomPauseElapsed() const {
 	case kPauseUntilNotHovered:
 		return !_isHovered;
 	default:
-		return g_system->getMillis() >= _randomPauseEndTime;
+		return !_commandedSequences.empty() || g_system->getMillis() >= _randomPauseEndTime;
 	}
 }
 
@@ -551,6 +560,23 @@ int PlaySecondaryMovie::pickNextSequence() {
 		return -1;
 	}
 
+	// A commanded movie takes over where the sequence allows it: "commanded_movie" plays
+	// it right away, another name is played on the way to it, and "random_movie" leaves
+	// the pick to chance
+	if (!_commandedSequences.empty()) {
+		for (const NextSequenceRef &next : seq.nextSequences) {
+			if (next.condition != kNextIfCommanded) {
+				continue;
+			}
+
+			if (next.name == "random_movie") {
+				break;
+			}
+
+			return lookupSequence(next.name == "commanded_movie" ? _commandedSequences[0] : next.name);
+		}
+	}
+
 	// The special-flag entries are tried first, in the order the original uses:
 	// the one matching the current hover state, then the sound-gated ones.
 	const NextCondition hoverCondition = _isHovered ? kNextIfHovered : kNextIfNotHovered;
@@ -617,7 +643,8 @@ int PlaySecondaryMovie::rollNextSequence() {
 	if (g_nancy->getGameType() >= kGameTypeNancy13) {
 		// First a percent chance to stay on this sequence and pause; the pick
 		// among the next sequences happens once the pause is over.
-		if (seq.stayWeight != 0 && (uint)g_nancy->_randomSource->getRandomNumber(99) < seq.stayWeight) {
+		if (_commandedSequences.empty() && seq.stayWeight != 0 &&
+				(uint)g_nancy->_randomSource->getRandomNumber(99) < seq.stayWeight) {
 			return beginRandomPause(seq);
 		}
 
@@ -1197,6 +1224,16 @@ void PlaySecondaryMovie::execute() {
 			break;
 		}
 
+		// Nancy16: movies commanded through the playlist left under this movie's name
+		if (isRandom() && !_playlistName.empty()) {
+			Common::Array<Common::String> movies;
+			if (NancySceneState.takeMoviePlaylist(_playlistName, movies)) {
+				for (const Common::String &movie : movies) {
+					_commandedSequences.push_back(Common::Path(movie));
+				}
+			}
+		}
+
 		// Random-movie chain: while paused, wait for the pause to expire
 		// then re-roll. The roll itself may set up another pause, swap to
 		// the next sequence, or finish the AR if stop was requested.
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 4d54c4d27fe..eafdb3cd8fc 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -70,7 +70,8 @@ public:
 		kNextIfHovered,				// -2: the mouse is over the movie
 		kNextIfNotHovered,			// -3: it isn't
 		kNextIfChannel13Playing,	// -4
-		kNextIfChannel12Playing		// -5
+		kNextIfChannel12Playing,	// -5
+		kNextIfCommanded			// -6: a movie was commanded; "commanded_movie" plays it, "random_movie" ignores it
 	};
 
 	// Name of the next sequence to chain to once the current one finishes,
@@ -202,6 +203,8 @@ public:
 	// Random-movie state (only populated for kRandomMovie).
 	// "RandomMovie" picks any sequence; otherwise it names the starting one.
 	Common::String _startingSequenceName;
+	// Nancy16: the name movie commands are left under, e.g. the wolf's commands
+	Common::String _playlistName;
 	uint16 _randomPlayerCursorAllowed = kPlayerCursorAllowed;
 	Common::Array<RandomSequence> _sequences;
 
@@ -237,6 +240,7 @@ public:
 	// holds its last frame on screen instead of hiding.
 	enum RandomPauseMode { kPauseTimed, kPauseUntilHovered, kPauseUntilNotHovered };
 	int _activeSequenceIndex = -1;
+	Common::Array<Common::Path> _commandedSequences;
 	RandomChainState _randomChainState = kRandomPlaying;
 	RandomPauseMode _randomPauseMode = kPauseTimed;
 	uint32 _randomPauseEndTime = 0;
diff --git a/engines/nancy/action/wolfcommands.cpp b/engines/nancy/action/wolfcommands.cpp
new file mode 100644
index 00000000000..91fbddaf458
--- /dev/null
+++ b/engines/nancy/action/wolfcommands.cpp
@@ -0,0 +1,182 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/wolfcommands.h"
+
+namespace Nancy {
+namespace Action {
+
+static const char *const kEngineTargetName = "Engine_WolfCommand";
+
+WolfCommands::~WolfCommands() {
+	UI::NDUI::Manager *ndui = NancySceneState.getNDUI();
+	if (!_isRegistered || !ndui) {
+		return;
+	}
+
+	ndui->removeEngineTarget(kEngineTargetName);
+	ndui->takeAction(UI::NDUI::kActionHide, _dialogName, 1.0);
+	ndui->takeAction(UI::NDUI::kActionSetText, _displayName, Common::String());
+	for (const Command &command : _commands) {
+		ndui->destroyObject(command.buttonName);
+	}
+}
+
+void WolfCommands::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _playlistName);
+	readFilename(stream, _dialogName);
+	readFilename(stream, _buttonTemplate);
+	readFilename(stream, _displayName);
+	_maxCommands = stream.readUint32LE();
+	readFilename(stream, _separator);
+	_doneFlag.label = stream.readSint16LE();
+	_doneFlag.flag = stream.readByte();
+
+	_commands.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (Command &command : _commands) {
+		readFilename(stream, command.buttonName);
+		readFilename(stream, command.label);
+		command.clickSound.readData(stream);
+		command.callSound.readData(stream);
+	}
+
+	_nameSound.readData(stream);
+	_goSound.readData(stream);
+	_fullSound.readData(stream);
+}
+
+const WolfCommands::Command *WolfCommands::findCommand(const Common::String &buttonName) const {
+	for (const Command &command : _commands) {
+		if (command.buttonName.equalsIgnoreCase(buttonName)) {
+			return &command;
+		}
+	}
+
+	return nullptr;
+}
+
+void WolfCommands::addCommand(const Common::String &buttonName) {
+	const Command *command = findCommand(buttonName);
+	if (!command) {
+		return;
+	}
+
+	_queue.push_back(command->buttonName);
+	command->clickSound.play();
+	NancySceneState.getNDUI()->takeAction(UI::NDUI::kActionAddText, _displayName, command->label + _separator);
+}
+
+void WolfCommands::clearCommands() {
+	NancySceneState.getNDUI()->takeAction(UI::NDUI::kActionSetText, _displayName, Common::String());
+	_queue.clear();
+}
+
+// Nancy calls the wolf by her name, then each of the queued commands, then says "go";
+// after that, the wolf gets the commands
+void WolfCommands::callNextCommand() {
+	++_callIndex;
+	if (_callIndex < (int)_queue.size()) {
+		_callSound = findCommand(_queue[_callIndex])->callSound.play();
+		return;
+	}
+
+	if (_callIndex == (int)_queue.size()) {
+		_callSound = _goSound.play();
+		return;
+	}
+
+	NancySceneState.setMoviePlaylist(_playlistName, _queue);
+	NancySceneState.setEventFlag(_doneFlag);
+	NancySceneState.getNDUI()->takeAction(UI::NDUI::kActionEnable, _dialogName, 1.0);
+
+	_isCalling = false;
+	_clearOnNextCommand = true;
+}
+
+void WolfCommands::execute() {
+	UI::NDUI::Manager *ndui = NancySceneState.getNDUI();
+	if (!ndui) {
+		return;
+	}
+
+	switch (_state) {
+	case kBegin:
+		for (const Command &command : _commands) {
+			ndui->createObject(command.buttonName, _buttonTemplate, _dialogName);
+			ndui->takeAction(UI::NDUI::kActionSetText, command.buttonName, command.label);
+		}
+
+		ndui->addEngineTarget(kEngineTargetName, this);
+		_isRegistered = true;
+		ndui->takeAction(UI::NDUI::kActionShow, _dialogName, 1.0);
+		ndui->takeAction(UI::NDUI::kActionEnable, _dialogName, 1.0);
+		_state = kRun;
+		break;
+	case kRun:
+		if (_isCalling && !g_nancy->_sound->isSoundPlaying(_callSound)) {
+			callNextCommand();
+		}
+
+		break;
+	default:
+		break;
+	}
+}
+
+// The dialog's buttons: a command button adds its command, "Clear" empties the list and
+// "Go" has Nancy call the commands out
+void WolfCommands::onEngineAction(const UI::NDUI::Action &action) {
+	switch (action.type) {
+	case UI::NDUI::kActionAddText:
+		if (_queue.size() < _maxCommands) {
+			addCommand(action.text);
+		} else if (_clearOnNextCommand) {
+			clearCommands();
+			addCommand(action.text);
+		} else {
+			_fullSound.play();
+			return;
+		}
+
+		break;
+	case UI::NDUI::kActionSetText:
+		clearCommands();
+		break;
+	case UI::NDUI::kActionSetValue:
+		NancySceneState.getNDUI()->takeAction(UI::NDUI::kActionDisable, _dialogName, 1.0);
+		_callSound = _nameSound.play();
+		_callIndex = -1;
+		_isCalling = true;
+		break;
+	default:
+		return;
+	}
+
+	_clearOnNextCommand = false;
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/wolfcommands.h b/engines/nancy/action/wolfcommands.h
new file mode 100644
index 00000000000..1dfda8a2940
--- /dev/null
+++ b/engines/nancy/action/wolfcommands.h
@@ -0,0 +1,86 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_WOLFCOMMANDS_H
+#define NANCY_ACTION_WOLFCOMMANDS_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/ui/ndui/manager.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 commands for Isis the wolf (AR 198). The record fills a UI dialog with a
+// button for each command; the player queues up a few, and "Go" has Nancy call them
+// out. The queued commands then get left as a playlist under the wolf's name, for the
+// wolf's movie or the wolf maze to act out, and an event flag gets set.
+class WolfCommands : public ActionRecord, public UI::NDUI::EngineTarget {
+public:
+	WolfCommands() {}
+	virtual ~WolfCommands();
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	void onEngineAction(const UI::NDUI::Action &action) override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "WolfCommands"; }
+
+	struct Command {
+		Common::String buttonName;	// also the movie the wolf acts the command out with
+		Common::String label;
+		RandomSoundBlock clickSound;
+		RandomSoundBlock callSound;	// Nancy calls the command out
+	};
+
+	const Command *findCommand(const Common::String &buttonName) const;
+	void addCommand(const Common::String &buttonName);
+	void clearCommands();
+	void callNextCommand();
+
+	// -- File data --
+	Common::String _playlistName;
+	Common::String _dialogName;
+	Common::String _buttonTemplate;
+	Common::String _displayName;		// shows the queued commands
+	uint32 _maxCommands = 0;
+	Common::String _separator;
+	FlagDescription _doneFlag;
+	Common::Array<Command> _commands;
+	RandomSoundBlock _nameSound;		// Nancy calls the wolf by her name
+	RandomSoundBlock _goSound;
+	RandomSoundBlock _fullSound;
+
+	// -- Runtime state --
+	Common::Array<Common::String> _queue;
+	bool _isRegistered = false;
+	bool _isCalling = false;			// Nancy is calling out the queued commands
+	int _callIndex = -1;				// -1 while calling the wolf by her name
+	SoundDescription _callSound;
+	bool _clearOnNextCommand = false;	// the commands of the last run are still shown
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_WOLFCOMMANDS_H
diff --git a/engines/nancy/commontypes.cpp b/engines/nancy/commontypes.cpp
index b3470375ebc..939a176b613 100644
--- a/engines/nancy/commontypes.cpp
+++ b/engines/nancy/commontypes.cpp
@@ -24,9 +24,11 @@
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/cif.h"
 #include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
 #include "engines/nancy/state/scene.h"
 
 #include "common/memstream.h"
+#include "common/random.h"
 
 namespace Nancy {
 
@@ -290,6 +292,35 @@ void RandomSoundBlock::readData(Common::SeekableReadStream &stream) {
 	}
 }
 
+SoundDescription RandomSoundBlock::play() const {
+	SoundDescription desc;
+	if (names.empty()) {
+		return desc;
+	}
+
+	uint idx = names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(names.size() - 1);
+	const Common::String &name = names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return desc;
+	}
+
+	desc.name = name;
+	desc.channelID = channel;
+	desc.numLoops = numLoops > 0 ? numLoops : 1;
+	desc.volume = volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+
+	Common::String caption = resolveSubtitleText(name);
+	if (caption.empty()) {
+		caption = resolveSubtitleText(name, Common::String(), "CONVO");
+	}
+	showSubtitle(caption);
+
+	return desc;
+}
+
 void ConditionalDialogue::readData(Common::SeekableReadStream &stream) {
 	textID = stream.readByte();
 	sceneID = stream.readUint16LE();
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index 9e687da59a7..2d502f896c6 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -327,6 +327,11 @@ struct RandomSoundBlock {
 	int16 volume = 0;
 
 	void readData(Common::SeekableReadStream &stream);
+
+	// Plays one of the sounds, picked at random, and shows its caption, looked up by the
+	// sound's name, first in AUTOTEXT, then in CONVO. Returns the sound's description,
+	// which is left as "NO SOUND" when there is nothing to play.
+	SoundDescription play() const;
 };
 
 // Structs inside nancy.dat, which contains all the data that was
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index cb1a0f436c1..7e9bdd495ef 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -20,6 +20,7 @@ MODULE_OBJS = \
   action/puzzlerecord.o \
   action/secondarymovie.o \
   action/secondaryvideo.o \
+  action/wolfcommands.o \
   action/puzzle/adjustpuzzle.o \
   action/puzzle/analogclock.o \
   action/puzzle/angletosspuzzle.o \
@@ -95,6 +96,7 @@ MODULE_OBJS = \
   action/puzzle/twodialpuzzle.o \
   action/puzzle/typingquizpuzzle.o \
   action/puzzle/weightsortpuzzle.o \
+  action/puzzle/wolfmazepuzzle.o \
   action/puzzle/whalesurvivorpuzzle.o \
   action/puzzle/wordfindpuzzle.o \
   ui/fullscreenimage.o \
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 81c0616d43f..159f048de1b 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2756,6 +2756,20 @@ void Scene::loadUIPrepRecords(const Common::Path &name) {
 	delete iff;
 }
 
+void Scene::setMoviePlaylist(const Common::String &name, const Common::Array<Common::String> &movies) {
+	_moviePlaylists.setVal(name, movies);
+}
+
+bool Scene::takeMoviePlaylist(const Common::String &name, Common::Array<Common::String> &movies) {
+	if (!_moviePlaylists.contains(name)) {
+		return false;
+	}
+
+	movies = _moviePlaylists[name];
+	_moviePlaylists.erase(name);
+	return true;
+}
+
 void Scene::viewInventoryItem(int16 itemID) {
 	removeItemFromInventory(itemID, false);
 
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index b1b7f8a14d6..3662725f0fe 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -125,6 +125,12 @@ public:
 	// chance save instead, once the current records have run
 	void loadSecondChance() { _isLoadingSecondChance = true; }
 
+	// Nancy16: lists of movies left under the name of the movie or puzzle that plays
+	// them, e.g. the commands given to the wolf. Taking a list forgets it; returns false
+	// when there is none.
+	void setMoviePlaylist(const Common::String &name, const Common::Array<Common::String> &movies);
+	bool takeMoviePlaylist(const Common::String &name, Common::Array<Common::String> &movies);
+
 	bool isRunningAd() const { return _isRunningAd; }
 
 	void changeScene(const SceneChangeDescription &sceneDescription);
@@ -486,6 +492,8 @@ private:
 
 	bool _isLoadingSecondChance = false;
 
+	Common::HashMap<Common::String, Common::Array<Common::String>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _moviePlaylists;
+
 	// Contains a screenshot of the Scene state from the last time it was exited
 	Graphics::ManagedSurface _lastScreenshot;
 
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index b7c975316b9..1cb5c16d89d 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -40,8 +40,18 @@ namespace NDUI {
 // The timer whose displays show the time of day
 static const int kPlayerClockTimer = -1;
 
+void Manager::addEngineTarget(const Common::String &name, EngineTarget *target) {
+	_engineTargets.setVal(name, target);
+}
+
+void Manager::removeEngineTarget(const Common::String &name) {
+	_engineTargets.erase(name);
+}
+
 void Manager::takeEngineAction(const Action &action) {
-	if (action.target.equalsIgnoreCase("Engine_Timer")) {
+	if (_engineTargets.contains(action.target)) {
+		_engineTargets[action.target]->onEngineAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_Timer")) {
 		// A control asks to show the value of a timer
 		if (action.type == kActionSetText && (int)action.value == kPlayerClockTimer) {
 			for (const Common::String &display : _clockDisplays) {
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 150a843d5c2..bdc5d103241 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -41,6 +41,14 @@ namespace NDUI {
 class Panel;
 class InvButton;
 
+// Something outside the UI that answers the actions sent to an "Engine_" target of its
+// own, for as long as it is registered with the Manager
+class EngineTarget {
+public:
+	virtual ~EngineTarget() {}
+	virtual void onEngineAction(const Action &action) = 0;
+};
+
 // The Nancy16 UI, owned by the scene. It creates the panels from the NDUI chunks of the
 // player character's UI IFF and the IFFs it includes, and stands between them and the
 // rest of the engine:
@@ -105,6 +113,9 @@ public:
 	// Displays a value of the value table, until it gets unregistered
 	void registerValueDisplay(int16 index, const Common::String &display);
 
+	void addEngineTarget(const Common::String &name, EngineTarget *target);
+	void removeEngineTarget(const Common::String &name);
+
 	// The background color outside of the viewport, picked in the options
 	void applyMatteColor();
 
@@ -157,6 +168,8 @@ private:
 
 	Common::Array<uint16> _pausedChannels;	// sound channels the UI paused, to resume later
 
+	Common::HashMap<Common::String, EngineTarget *, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _engineTargets;
+
 	Common::String _selectedSave;	// in the load dialog
 	Common::String _typedSaveName;	// in the save dialog
 	Common::String _lastSaveName;
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index b34f3ff0522..127968c2cba 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -196,7 +196,8 @@ void Panel::arrangeControls() {
 	int y = _margins.top;
 	int rowBottom = y;
 
-	// Controls go one after another along a row, and start a new row when they don't fit
+	// Controls go one after another along a row, and start a new row when they don't fit;
+	// one too wide for a row still goes on it when the row is empty
 	for (Control *control : _arrangeOrder) {
 		if (!control->isVisible()) {
 			continue;
@@ -206,7 +207,7 @@ void Panel::arrangeControls() {
 		int controlHeight = control->_bounds.height();
 
 		if (_arrangement == kArrangeLeftToRight) {
-			if (x + controlWidth > width - _margins.right) {
+			if (x != rowStart && x + controlWidth > width - _margins.right) {
 				x = rowStart;
 				y = rowBottom + _spacing.y;
 			}
@@ -214,7 +215,7 @@ void Panel::arrangeControls() {
 			control->_bounds.moveTo(x, y);
 			x += controlWidth + _spacing.x;
 		} else {
-			if (x - controlWidth < _margins.left) {
+			if (x != rowStart && x - controlWidth < _margins.left) {
 				x = rowStart;
 				y = rowBottom + _spacing.y;
 			}
@@ -234,6 +235,12 @@ void Panel::arrangeControls() {
 		}
 
 		_manager->takeAction(kActionSetRange, _scrollBarName, (double)contentHeight);
+	} else if (rowBottom > _drawSurface.h) {
+		// Controls aren't clipped to a panel that doesn't scroll, so it grows to hold them
+		Common::Rect screenPosition = _screenPosition;
+		screenPosition.bottom = screenPosition.top + rowBottom;
+		moveTo(screenPosition);
+		_drawSurface.create(screenPosition.width(), screenPosition.height(), g_nancy->_graphics->getTransparentPixelFormat());
 	}
 }
 
@@ -471,7 +478,7 @@ void Panel::redraw() {
 	_drawSurface.clear(0);
 
 	if (_backgroundColor >> 24) {
-		_drawSurface.clear(argbToColor(_drawSurface.format, _backgroundColor));
+		_drawSurface.fillRect(_background->_bounds, argbToColor(_drawSurface.format, _backgroundColor));
 	}
 
 	_background->draw(_drawSurface, Common::Point());


Commit: 433abfeb702cfd197a0c175fd5bf13305f537fd9
    https://github.com/scummvm/scummvm/commit/433abfeb702cfd197a0c175fd5bf13305f537fd9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:22+03:00

Commit Message:
NANCY: NANCY16: Fix the scene count dependency

- The count is compared by mode: ==, >, >=, <, <=, !=
- Fixes the hang after crashing the snowmobile (scene 4837)

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


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 4419a8dc276..5b1ad852447 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -571,6 +571,36 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
 			// This dependency type keeps its data in the time variables
 			// Note: nancy7 completely flipped the meaning of 1 and 2
 			int count = NancySceneState.getSceneCounts(dep.hours);
+
+			if (g_nancy->getGameType() >= kGameTypeNancy16) {
+				// Nancy16 compares the count the same way it compares resources
+				switch (dep.milliseconds) {
+				case 0:
+					dep.satisfied = count == dep.minutes;
+					break;
+				case 1:
+					dep.satisfied = count > dep.minutes;
+					break;
+				case 2:
+					dep.satisfied = count >= dep.minutes;
+					break;
+				case 3:
+					dep.satisfied = count < dep.minutes;
+					break;
+				case 4:
+					dep.satisfied = count <= dep.minutes;
+					break;
+				case 5:
+					dep.satisfied = count != dep.minutes;
+					break;
+				default:
+					dep.satisfied = false;
+					break;
+				}
+
+				break;
+			}
+
 			switch (dep.milliseconds) {
 			case 1:
 				if (	(dep.minutes < count && g_nancy->getGameType() <= kGameTypeNancy6) ||


Commit: 28d3a5724b4f46f7074f54487d00374b6a4bcfd5
    https://github.com/scummvm/scummvm/commit/28d3a5724b4f46f7074f54487d00374b6a4bcfd5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:25+03:00

Commit Message:
NANCY: NANCY16: Fix the timer state dependency and paused timers

The timer dependency now compares the timer's state (idle, running
or paused) against a requested one, instead of only checking whether
the timer is active. Timer commands are also accepted by a paused
timer, not just by a running one.

Fixes the hypothermia timer, which kept running after going back
indoors.

Changed paths:
    engines/nancy/action/actionmanager.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 5b1ad852447..3c8b3dd11ef 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -745,8 +745,13 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
 
 			break;
 		case DependencyType::kTimerIsActive:
-			// Nancy11+ only: satisfied while the software-timer slot is active
-			dep.satisfied = NancySceneState.isSoftwareTimerActive(dep.label);
+			if (g_nancy->getGameType() >= kGameTypeNancy16) {
+				// Nancy16 compares the slot's state against the one in the hours field
+				dep.satisfied = (NancySceneState.getSoftwareTimerState(dep.label) == dep.hours) == (dep.condition != 0);
+			} else {
+				// Nancy11+ only: satisfied while the software-timer slot is active
+				dep.satisfied = NancySceneState.isSoftwareTimerActive(dep.label);
+			}
 
 			break;
 		case DependencyType::kTimerEqualsDependencyTime:
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index f6b83bc66c0..5bca28b4585 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -699,12 +699,16 @@ void ResetAndStartTimer::execute() {
 	}
 
 	// Nancy 12+ issues a command to one of the software-timer slots. Every
-	// command other than kStart only takes effect while the slot is running.
+	// command other than kStart only takes effect while the slot is running
+	// (Nancy16: also while it's paused).
 	TimerData::Timer *timer = getSoftwareTimer(_timerIndex);
 	if (timer) {
+		const bool acceptsCommands = g_nancy->getGameType() >= kGameTypeNancy16 ?
+			timer->state != TimerData::Timer::kIdle : timer->state == TimerData::Timer::kRunning;
+
 		if (_command == kStart) {
 			timer->state = TimerData::Timer::kRunning;
-		} else if (timer->state == TimerData::Timer::kRunning) {
+		} else if (acceptsCommands) {
 			const bool isNancy14 = g_nancy->getGameType() >= kGameTypeNancy14;
 
 			// Seconds from kTimerDurationIndexBase (Nancy14+: above it) index
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 159f048de1b..b221934b6bf 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2266,6 +2266,14 @@ bool Scene::isSoftwareTimerActive(uint16 index) const {
 		timer.state == TimerData::Timer::kRepeating;
 }
 
+int32 Scene::getSoftwareTimerState(uint16 index) const {
+	if (index >= TimerData::kNumTimers || !_puzzleData.contains(TimerData::getTag())) {
+		return TimerData::Timer::kIdle;
+	}
+
+	return ((const TimerData *)_puzzleData.getVal(TimerData::getTag()))->timers[index].state;
+}
+
 uint32 Scene::getSoftwareTimerElapsed(uint16 index) const {
 	if (index >= TimerData::kNumTimers || !_puzzleData.contains(TimerData::getTag())) {
 		return 0;
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 3662725f0fe..62095016a94 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -206,6 +206,7 @@ public:
 
 	// Nancy 11+ software-timer queries used by timer dependencies.
 	bool isSoftwareTimerActive(uint16 index) const;
+	int32 getSoftwareTimerState(uint16 index) const;
 	uint32 getSoftwareTimerElapsed(uint16 index) const;
 
 	// Nancy 12+ UI resource values (the UIRC boot chunk). Resource 0 is the


Commit: 8def92a242e554156f44c5dba4ebbc385d9bd246
    https://github.com/scummvm/scummvm/commit/8def92a242e554156f44c5dba4ebbc385d9bd246
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:28+03:00

Commit Message:
NANCY: NANCY16: Fix the value table records and dependency

Nancy16 no longer splits the table into single and combo values:
every value is indexed by a signed byte (-1 is the current day),
and a value that was never set reads as 0.

- SetValueCombo can store its sum in any value, and uses new
  markers for unused and literal entries
- ValueTest uses the same comparisons as the value test dependency,
  and its literals are signed
- The value test dependency handles negative indices, and has a new
  "not equal" comparison

Fixes the "combo value index is out of range" warnings.

Changed paths:
    engines/nancy/action/actionmanager.cpp
    engines/nancy/action/datarecords.cpp
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 3c8b3dd11ef..4c656595ed8 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -331,16 +331,6 @@ enum PlayerTimeComparison {
 	kPlayerTimeBetween	= 3		// Nancy11+
 };
 
-// How a value-table test dependency (see below) compares the value against its
-// threshold. Matches the Nancy14 comparator's condition encoding.
-enum ValueTestComparison {
-	kValueEqual				= 0,
-	kValueGreater			= 1,
-	kValueGreaterOrEqual	= 2,
-	kValueLess				= 3,
-	kValueLessOrEqual		= 4
-};
-
 // A value-table test dependency with this in its hours field compares against
 // another value instead of a constant
 static const int16 kValueTestAgainstValue = 1;
@@ -377,6 +367,17 @@ static bool evaluateValueTestDependency(const DependencyRecord &dep) {
 	// test (kElapsedPlayerDay) that shares this layout. When the hours field is
 	// kValueTestAgainstValue, the milliseconds field is a value index instead.
 	int32 threshold = dep.milliseconds;
+
+	// Nancy16 indexes every value with a signed byte (-1 is the day), and
+	// reads unset values as 0
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		if (dep.hours == kValueTestAgainstValue) {
+			threshold = table->getValueOrZero((byte)dep.milliseconds);
+		}
+
+		return TableData::compare(table->getValueOrZero((byte)dep.label), threshold, dep.condition);
+	}
+
 	if (dep.hours == kValueTestAgainstValue) {
 		threshold = getValueTestValue(*table, dep.milliseconds, threshold);
 	}
@@ -388,20 +389,7 @@ static bool evaluateValueTestDependency(const DependencyRecord &dep) {
 		return false;
 	}
 
-	switch (dep.condition) {
-	case kValueEqual:
-		return value == threshold;
-	case kValueGreater:
-		return value > threshold;
-	case kValueGreaterOrEqual:
-		return value >= threshold;
-	case kValueLess:
-		return value < threshold;
-	case kValueLessOrEqual:
-		return value <= threshold;
-	default:
-		return false;
-	}
+	return TableData::compare(value, threshold, dep.condition);
 }
 
 // Nancy15+ dependencies that act on a player character name them in the
diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index 76e149aca76..ae3a6c5cd2f 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -166,6 +166,10 @@ void SetValue::execute() {
 	finishExecution();
 }
 
+// Nancy16's index markers inside SetValueCombo and ValueTest records
+static const byte kNoIndexNancy16		= 0xFC;
+static const byte kLiteralIndexNancy16	= 0xFD;
+
 void SetValueCombo::readData(Common::SeekableReadStream &stream) {
 	_valueIndex = stream.readByte();
 
@@ -181,6 +185,33 @@ void SetValueCombo::execute() {
 	TableData *playerTable = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
 	assert(playerTable);
 
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// Nancy16 can store the sum in any value, and leaves it alone when
+		// the record has nothing to add up
+		float sum = 0;
+		bool hasEntries = false;
+		for (uint i = 0; i < _indices.size(); ++i) {
+			if (_indices[i] == kNoIndexNancy16) {
+				continue;
+			}
+
+			if (_indices[i] == kLiteralIndexNancy16) {
+				sum += _percentages[i];
+			} else {
+				sum += playerTable->getValueOrZero(_indices[i]) * (_percentages[i] / 100.f);
+			}
+
+			hasEntries = true;
+		}
+
+		if (hasEntries) {
+			playerTable->setValue(_valueIndex, (int16)sum);
+		}
+
+		finishExecution();
+		return;
+	}
+
 	uint numSingleValues = playerTable->getNumSingleValues();
 
 	playerTable->setComboValue(_valueIndex - numSingleValues, 0);
@@ -245,6 +276,32 @@ void ValueTest::execute() {
 	TableData *playerTable = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
 	assert(playerTable);
 
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// Nancy16 compares like the value test dependency, treats unset values
+		// as 0, and its literals are signed
+		int16 value = playerTable->getValueOrZero(_valueIndex);
+		bool satisfied = false;
+
+		if (_testType == kTestSome || _testType == kTestActualValue) {
+			for (uint i = 0; i < _indicesToTest.size() && !satisfied; ++i) {
+				if (_indicesToTest[i] == kNoIndexNancy16) {
+					continue;
+				}
+
+				int16 otherValue = _testType == kTestActualValue ?
+					(int8)_indicesToTest[i] : playerTable->getValueOrZero(_indicesToTest[i]);
+				satisfied = TableData::compare(value, otherValue, _condition);
+			}
+		}
+
+		if (satisfied) {
+			NancySceneState.setEventFlag(_flagToSet, g_nancy->_true);
+		}
+
+		finishExecution();
+		return;
+	}
+
 	uint numSingleValues = playerTable->getNumSingleValues();
 
 	float testedValue;
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 30d4fd6f82d..c88fa3da480 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -430,6 +430,30 @@ int16 TableData::getValue(uint16 index) const {
 	return (int16)(value + (value < 0 ? -0.5f : 0.5f));
 }
 
+int16 TableData::getValueOrZero(uint16 index) const {
+	int16 value = getValue(index);
+	return value == kNoTableValue ? 0 : value;
+}
+
+bool TableData::compare(int32 value, int32 other, uint16 comparison) {
+	switch (comparison) {
+	case kEqual:
+		return value == other;
+	case kGreater:
+		return value > other;
+	case kGreaterOrEqual:
+		return value >= other;
+	case kLess:
+		return value < other;
+	case kLessOrEqual:
+		return value <= other;
+	case kNotEqual:
+		return value != other;
+	default:
+		return false;
+	}
+}
+
 void TableData::setValue(uint16 index, int16 value) {
 	uint numSingleValues = getNumSingleValues();
 	if (index < numSingleValues) {
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 68686375427..c42bb8bf3f4 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -259,6 +259,21 @@ struct TableData : public PuzzleData {
 	int16 getValue(uint16 index) const;
 	void setValue(uint16 index, int16 value);
 
+	// Nancy16 reads a value that was never set as 0
+	int16 getValueOrZero(uint16 index) const;
+
+	// How Nancy14+ value tests compare a value against another
+	enum Comparison {
+		kEqual			= 0,
+		kGreater		= 1,
+		kGreaterOrEqual	= 2,
+		kLess			= 3,
+		kLessOrEqual	= 4,
+		kNotEqual		= 5		// Nancy16+
+	};
+
+	static bool compare(int32 value, int32 other, uint16 comparison);
+
 	Common::Array<int16> singleValues;
 	Common::Array<float> comboValues;
 };


Commit: 01f53c0e9a2afbffad935ebb4bf447ff30261080
    https://github.com/scummvm/scummvm/commit/01f53c0e9a2afbffad935ebb4bf447ff30261080
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:31+03:00

Commit Message:
NANCY: NANCY16: Play the music of the scene's area

- The sound of a scene names the music mix of its area, like in Nancy13-15, so it
  was wrong to skip it
- A track of the mix, picked at random, starts whenever a scene finds the music
  channel silent, so the tracks follow one another regardless of scene changes

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index b221934b6bf..827c48d40dc 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -186,12 +186,19 @@ void Scene::process() {
 		if (_sceneState.currentScene.continueSceneSound == kLoadSceneSound) {
 			g_nancy->_sound->stopAndUnloadSceneSpecificSounds();
 
-			// Nancy16 keeps the name of the scene's area there instead of a sound
 			if (g_nancy->getGameType() <= kGameTypeNancy15) {
 				g_nancy->_sound->loadSound(_sceneState.summary.sound);
 				g_nancy->_sound->playSound(_sceneState.summary.sound);
 			}
 		}
+
+		// Nancy16 scenes name the music mix of their area. A track of it, picked at
+		// random, starts whenever a scene finds its music channel silent, so the
+		// tracks follow one another with no regard to scene changes.
+		if (g_nancy->getGameType() == kGameTypeNancy16 && !g_nancy->_sound->isSoundPlaying(_sceneState.summary.sound)) {
+			g_nancy->_sound->loadSound(_sceneState.summary.sound, nullptr, true);
+			g_nancy->_sound->playSound(_sceneState.summary.sound);
+		}
 		// fall through
 	case kRun:
 		run();


Commit: ab15798a670755fd00536a30a4454204ffa4f396
    https://github.com/scummvm/scummvm/commit/ab15798a670755fd00536a30a4454204ffa4f396
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:33+03:00

Commit Message:
NANCY: NANCY16: Keep the state of the UI in saved games

- Scenes show, hide, enable and disable panels and controls once, so a
  loaded game lost those changes, e.g. the clothes on the paper doll
  and the disabled meters
- Saving keeps how each panel and control differs from the state it
  starts in, and loading replays it on the new UI
- The restored UI shows up at once, instead of fading in and making the
  icons flicker

Changed paths:
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h
    engines/nancy/state/scene.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
    engines/nancy/ui/ndui/manager.cpp
    engines/nancy/ui/ndui/manager.h
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index c88fa3da480..fe4d5f8d797 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -183,6 +183,17 @@ void TasklistData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void UIStateData::synchronize(Common::Serializer &ser) {
+	uint32 numChanges = changes.size();
+	ser.syncAsUint32LE(numChanges);
+	changes.resize(numChanges);
+
+	for (Change &change : changes) {
+		ser.syncString(change.name);
+		ser.syncAsSint32LE(change.action);
+	}
+}
+
 void JournalData::syncOneJournal(Common::Serializer &ser, Common::HashMap<uint16, Common::Array<Entry>> &journal) {
 	uint16 numEntries = journal.size();
 	ser.syncAsUint16LE(numEntries);
@@ -840,6 +851,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new JournalData();
 	case TasklistData::getTag():
 		return new TasklistData();
+	case UIStateData::getTag():
+		return new UIStateData();
 	case TableData::getTag():
 		return new TableData();
 	case CellPhoneData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index c42bb8bf3f4..744fcae4ad1 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -226,6 +226,23 @@ struct TasklistData : public PuzzleData {
 	Common::Array<Common::String> checkedEntries;
 };
 
+// Nancy16: how the panels and controls of the UI differ from the state they start in,
+// as the actions that bring them there, e.g. a meter a scene has shown or disabled
+struct UIStateData : public PuzzleData {
+	UIStateData() {}
+	virtual ~UIStateData() {}
+
+	struct Change {
+		Common::String name;
+		int32 action = 0;
+	};
+
+	static constexpr uint32 getTag() { return MKTAG('U', 'I', 'S', 'T'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::Array<Change> changes;
+};
+
 // Contains variables that can be read and modified through action records.
 // Mixes two separate things:
 // - the exhibit data table in nancy6
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 827c48d40dc..f06c1308641 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1672,6 +1672,10 @@ void Scene::synchronize(Common::Serializer &ser) {
 		return;
 	}
 
+	if (_ndui && ser.isSaving()) {
+		_ndui->saveState(((UIStateData *)getPuzzleData(UIStateData::getTag()))->changes);
+	}
+
 	byte numPuzzleData = _puzzleData.size();
 	ser.syncAsByte(numPuzzleData);
 
@@ -1728,6 +1732,7 @@ void Scene::synchronize(Common::Serializer &ser) {
 
 		if (_ndui) {
 			initUI();
+			_ndui->restoreState(((UIStateData *)getPuzzleData(UIStateData::getTag()))->changes);
 		}
 	}
 
diff --git a/engines/nancy/ui/ndui/common.cpp b/engines/nancy/ui/ndui/common.cpp
index cb8827d3134..05e6dd9b963 100644
--- a/engines/nancy/ui/ndui/common.cpp
+++ b/engines/nancy/ui/ndui/common.cpp
@@ -45,6 +45,23 @@ void Action::read(Common::SeekableReadStream &stream) {
 	readFilename(stream, text);
 }
 
+static void addStateChange(Common::Array<UIStateData::Change> &changes, const Common::String &name, int32 action) {
+	UIStateData::Change change;
+	change.name = name;
+	change.action = action;
+	changes.push_back(change);
+}
+
+void addStateChanges(Common::Array<UIStateData::Change> &changes, const Common::String &name, int32 initialState, bool visible, bool enabled) {
+	if (visible != (initialState != kStartHidden)) {
+		addStateChange(changes, name, visible ? kActionShow : kActionHide);
+	}
+
+	if (enabled != (initialState != kStartDisabled)) {
+		addStateChange(changes, name, enabled ? kActionEnable : kActionDisable);
+	}
+}
+
 } // End of namespace NDUI
 } // End of namespace UI
 } // End of namespace Nancy
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index b2966e7175a..4c8fa520a78 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -25,6 +25,8 @@
 #include "common/str.h"
 #include "common/stream.h"
 
+#include "engines/nancy/puzzledata.h"
+
 namespace Nancy {
 namespace UI {
 namespace NDUI {
@@ -67,6 +69,13 @@ enum ControlState {
 	kNumStates			= 6
 };
 
+// What a panel or control starts as
+enum InitialState {
+	kStartVisible	= 0,
+	kStartDisabled	= 1,
+	kStartHidden	= 2
+};
+
 enum EventType {
 	kEventOwn			= -1,	// whatever the control itself reports: a click, a new value...
 	kEventClicked		= 1,
@@ -113,10 +122,16 @@ struct Action {
 	Common::String sender;		// the control that fired the event
 };
 
+// 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);
+
 // How quickly colors move toward the ones of a new state; a lower rate is faster
 static const float kStaticBlendRate = 0.7f;
 static const float kControlBlendRate = 0.8f;
 
+// Long enough for any color to reach the one it is moving toward
+static const float kFinishBlendingTime = 1000.0f;
+
 // The lines a notch of the mouse wheel scrolls
 static const int kWheelLines = 3;
 
diff --git a/engines/nancy/ui/ndui/control.cpp b/engines/nancy/ui/ndui/control.cpp
index 2a044654604..0b5a387459b 100644
--- a/engines/nancy/ui/ndui/control.cpp
+++ b/engines/nancy/ui/ndui/control.cpp
@@ -32,10 +32,10 @@ namespace NDUI {
 void Control::read(Common::SeekableReadStream &stream) {
 	stream.skip(33); // version
 
-	int32 initialState = stream.readSint32LE();
-	if (initialState == kStateDisabled) {
+	_initialState = stream.readSint32LE();
+	if (_initialState == kStartDisabled) {
 		_enabled = false;
-	} else if (initialState == kStateHidden) {
+	} else if (_initialState == kStartHidden) {
 		_visible = false;
 	}
 
diff --git a/engines/nancy/ui/ndui/control.h b/engines/nancy/ui/ndui/control.h
index 2b42619c354..5dadb78f6ad 100644
--- a/engines/nancy/ui/ndui/control.h
+++ b/engines/nancy/ui/ndui/control.h
@@ -66,6 +66,7 @@ public:
 	void setName(const Common::String &name) { _name = name; }
 	bool isVisible() const { return _visible; }
 	bool isEnabled() const { return _enabled; }
+	int32 getInitialState() const { return _initialState; }
 
 	// Fires the actions bound to an event; the ones without a value of their own
 	// get the given one
@@ -97,6 +98,7 @@ protected:
 	Panel *_panel;
 	Common::String _name;
 	Common::String _text;
+	int32 _initialState = kStartVisible;
 	bool _visible = true;
 	bool _enabled = true;
 	bool _autoHeight = false;	// controls without a height are as tall as their text
diff --git a/engines/nancy/ui/ndui/manager.cpp b/engines/nancy/ui/ndui/manager.cpp
index 3f756750023..d3586ffc185 100644
--- a/engines/nancy/ui/ndui/manager.cpp
+++ b/engines/nancy/ui/ndui/manager.cpp
@@ -130,6 +130,23 @@ void Manager::takeAction(int32 type, const Common::String &target, const Common:
 	takeAction(action);
 }
 
+void Manager::saveState(Common::Array<UIStateData::Change> &changes) const {
+	changes.clear();
+	for (const Panel *panel : _panels) {
+		panel->saveState(changes);
+	}
+}
+
+void Manager::restoreState(const Common::Array<UIStateData::Change> &changes) {
+	for (const UIStateData::Change &change : changes) {
+		takeAction(change.action, change.name, 0.0);
+	}
+
+	for (Panel *panel : _panels) {
+		panel->finishBlending();
+	}
+}
+
 Manager::Request Manager::takeRequest() {
 	Request request = _request;
 	_request = Request();
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index bdc5d103241..4c43cfc0482 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -113,6 +113,12 @@ public:
 	// Displays a value of the value table, until it gets unregistered
 	void registerValueDisplay(int16 index, const Common::String &display);
 
+	// What scenes and the player changed about the panels and controls, kept in saved games
+	void saveState(Common::Array<UIStateData::Change> &changes) const;
+	// The controls show up as they were saved right away, since a UI that was
+	// already on screen has nothing to fade in from
+	void restoreState(const Common::Array<UIStateData::Change> &changes);
+
 	void addEngineTarget(const Common::String &name, EngineTarget *target);
 	void removeEngineTarget(const Common::String &name);
 
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 127968c2cba..685854bc41c 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -55,7 +55,7 @@ Panel::~Panel() {
 void Panel::read(Common::SeekableReadStream &stream) {
 	stream.skip(33); // version
 
-	int32 mode = stream.readSint32LE();
+	_initialState = stream.readSint32LE();
 	_arrangement = (Arrangement)stream.readSint32LE();
 	stream.skip(4);
 	_spacing.x = stream.readSint32LE();
@@ -85,11 +85,11 @@ void Panel::read(Common::SeekableReadStream &stream) {
 	moveTo(screenPosition);
 	_drawSurface.create(screenPosition.width(), screenPosition.height(), g_nancy->_graphics->getTransparentPixelFormat());
 
-	switch (mode) {
-	case kModeVisible:
+	switch (_initialState) {
+	case kStartVisible:
 		_showCount = 1;
 		break;
-	case kModeVisibleDisabled:
+	case kStartDisabled:
 		_showCount = 1;
 		_enabled = false;
 		break;
@@ -319,6 +319,23 @@ void Panel::showItem(int16 itemID, bool show) {
 	}
 }
 
+void Panel::saveState(Common::Array<UIStateData::Change> &changes) const {
+	for (const Control *control : _controls) {
+		addStateChanges(changes, control->getName(), control->getInitialState(), control->isVisible(), control->isEnabled());
+	}
+
+	addStateChanges(changes, getName(), _initialState, isVisible(), _enabled);
+}
+
+void Panel::finishBlending() {
+	_background->update(kFinishBlendingTime);
+	for (Control *control : _controls) {
+		control->update(kFinishBlendingTime);
+	}
+
+	_isDirty = true;
+}
+
 // Every show needs a matching hide before the panel goes away
 void Panel::show() {
 	++_showCount;
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index 75a25e63225..a26aab6bd80 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -71,6 +71,12 @@ public:
 	void addRadioButton(RadioButton *button);
 	void setDirty() { _isDirty = true; }
 
+	// Adds what differs from the state the panel and its controls start in
+	void saveState(Common::Array<UIStateData::Change> &changes) const;
+
+	// Gives the controls the colors of the state they are in, without fading to them
+	void finishBlending();
+
 	// Reads a control of the given type and adds it
 	Control *addNewControl(int32 type, Common::SeekableReadStream &stream);
 	bool removeControl(const Common::String &name);
@@ -92,12 +98,6 @@ private:
 		kArrangeRightToLeft	= 2
 	};
 
-	enum Mode {
-		kModeVisible			= 0,
-		kModeVisibleDisabled	= 1,
-		kModeHidden				= 2
-	};
-
 	void readControl(Common::SeekableReadStream &stream);
 	void show();
 	void hide();
@@ -126,6 +126,7 @@ private:
 	int32 _scrollStep = 1;
 	int32 _scrollOffset = 0;
 
+	int32 _initialState = kStartVisible;
 	int _showCount = 0;
 	bool _enabled = true;
 	uint32 _lastUpdateTime = 0;


Commit: 77e5074fbc4aac45ba96141b082b5152c44d83de
    https://github.com/scummvm/scummvm/commit/77e5074fbc4aac45ba96141b082b5152c44d83de
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:35+03:00

Commit Message:
NANCY: NANCY16: Fix the scroll range of journal pages

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


diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 685854bc41c..d795cb1c7de 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -227,14 +227,20 @@ void Panel::arrangeControls() {
 		rowBottom = MAX(rowBottom, y + controlHeight);
 	}
 
-	// A scroll panel's scroll bar covers everything the controls take up
 	if (!_scrollBarName.empty()) {
-		int contentHeight = rowBottom + _margins.bottom;
-		if (contentHeight <= _screenPosition.height()) {
+		// Scroll panels can share a scroll bar, which covers what the controls
+		// of the visible one take up, to the end of the last scroll step
+		int contentHeight = rowBottom;
+		int overflow = contentHeight - _screenPosition.height();
+		if (overflow <= 0) {
 			_scrollOffset = 0;
+		} else if (overflow % _scrollStep) {
+			contentHeight += _scrollStep - overflow % _scrollStep;
 		}
 
-		_manager->takeAction(kActionSetRange, _scrollBarName, (double)contentHeight);
+		if (isVisible()) {
+			_manager->takeAction(kActionSetRange, _scrollBarName, (double)contentHeight);
+		}
 	} else if (rowBottom > _drawSurface.h) {
 		// Controls aren't clipped to a panel that doesn't scroll, so it grows to hold them
 		Common::Rect screenPosition = _screenPosition;
@@ -283,6 +289,12 @@ bool Panel::takeAction(const Action &action) {
 		}
 
 		_scrollOffset = ((int)action.value + _scrollStep / 2) / _scrollStep * _scrollStep;
+
+		// The scroll bar starts over when something else moves the panel
+		if (!action.sender.equalsIgnoreCase(_scrollBarName)) {
+			_manager->takeAction(kActionSetRange, _scrollBarName, action.value);
+		}
+
 		break;
 	case kActionShowItem:
 	case kActionHideItem:
@@ -340,6 +352,7 @@ void Panel::finishBlending() {
 void Panel::show() {
 	++_showCount;
 	setVisible(true);
+	arrangeControls();
 	fireEvent(kEventShow);
 }
 


Commit: 3b0320e002ddc5ec56ad84d6c005133eb7533bd1
    https://github.com/scummvm/scummvm/commit/3b0320e002ddc5ec56ad84d6c005133eb7533bd1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:37+03:00

Commit Message:
NANCY: NANCY16: Implement the snowmobile rides (SnowmobilePuzzle)

- Records 160 (ride) and 167 (chase) get a new SnowmobilePuzzle class
- The field is built from zones and random obstacle sets, not a map

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index c87ef4d2601..3a2b54513f2 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -98,6 +98,7 @@
 #include "engines/nancy/action/puzzle/setplayerclock.h"
 #include "engines/nancy/action/puzzle/sewingmachinepuzzle.h"
 #include "engines/nancy/action/puzzle/sliderpuzzle.h"
+#include "engines/nancy/action/puzzle/snowmobilepuzzle.h"
 #include "engines/nancy/action/puzzle/sortpuzzle.h"
 #include "engines/nancy/action/puzzle/soundequalizerpuzzle.h"
 #include "engines/nancy/action/puzzle/soundmatchpuzzle.h"
@@ -561,7 +562,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new DrivingPuzzle(DrivingPuzzle::kDriving);
 		else
-			return nullptr;	// Snowmobile
+			return new SnowmobilePuzzle(SnowmobilePuzzle::kDriving);
 	case 161:
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new PlaySoundEventFlagTerse();
@@ -601,7 +602,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new DrivingPuzzle(DrivingPuzzle::kChase);
 		else
-			return nullptr;	// Snowmobile chase
+			return new SnowmobilePuzzle(SnowmobilePuzzle::kChase);
 	case 168:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Set3DSoundListenerPosition();
diff --git a/engines/nancy/action/puzzle/snowmobilepuzzle.cpp b/engines/nancy/action/puzzle/snowmobilepuzzle.cpp
new file mode 100644
index 00000000000..1cc02a94f24
--- /dev/null
+++ b/engines/nancy/action/puzzle/snowmobilepuzzle.cpp
@@ -0,0 +1,1022 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/snowmobilepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint32 kMsPerTick = 20;
+static const double kTicksPerSecond = 50.0;
+
+static const double kTurnPerTick = M_PI / kTicksPerSecond;
+static const double kBrakeFactor = 0.4;			// braking is this much quicker than speeding up
+static const double kFrictionFactor = 3.0;		// seconds it takes to coast to a stop from full speed
+static const double kMinSpeed = 0.01;
+static const int32 kMaxDamage = 100;
+
+// How fast the chased snowmobile runs through its path, depending on where the player is
+static const double kChaseRateBehind = 5.0;		// it is behind the player
+static const double kChaseRateInReach = 1.2;	// the player is within the next stage's reach
+static const double kChaseRateFar = 0.6;		// more than half a view ahead
+static const double kChaseRateOutOfView = 0.2;
+static const double kChaseCloseDistance = 60.0;
+
+static const uint16 kMouseSteeringCursor = 0;
+static const uint16 kKeySteeringCursor = 23;
+
+// The field is far larger than what a Common::Rect can hold. Its rects are stored
+// with exclusive right and bottom edges
+static void readRect32(Common::SeekableReadStream &stream, Common::Rect32 &rect) {
+	rect.left = stream.readSint32LE();
+	rect.top = stream.readSint32LE();
+	rect.right = stream.readSint32LE();
+	rect.bottom = stream.readSint32LE();
+}
+
+void SnowmobilePuzzle::Zone::centerOn(const Math::Vector2d &pos) {
+	int32 dx = (int32)(pos.getX() - rect.center().x);
+	int32 dy = (int32)(pos.getY() - rect.center().y);
+	rect.translate(dx, dy);
+
+	if (type == kZoneOverlay || type == kZoneTiledOverlay) {
+		dest.translate(dx, dy);
+	} else if (type == kZonePointer) {
+		target = Math::Vector2d(rect.center().x, rect.center().y);
+	}
+}
+
+Common::Rect32 SnowmobilePuzzle::Snowmobile::getBounds() const {
+	int32 left = (int32)(pos.getX() - size / 2);
+	int32 top = (int32)(pos.getY() - size / 2);
+	return Common::Rect32(left, top, left + size, top + size);
+}
+
+SnowmobilePuzzle::~SnowmobilePuzzle() {
+	for (Graphics::ManagedSurface *sheet : _sheets) {
+		delete sheet;
+	}
+}
+
+uint SnowmobilePuzzle::loadSheet(const Common::String &name) {
+	for (uint i = 0; i < _sheetNames.size(); ++i) {
+		if (_sheetNames[i].equalsIgnoreCase(name)) {
+			return i;
+		}
+	}
+
+	Graphics::ManagedSurface *sheet = new Graphics::ManagedSurface();
+	g_nancy->_resource->loadImage(Common::Path(name), *sheet);
+	_sheets.push_back(sheet);
+	_sheetNames.push_back(name);
+	return _sheets.size() - 1;
+}
+
+void SnowmobilePuzzle::readZone(Common::SeekableReadStream &stream, Zone &zone) {
+	zone.type = stream.readSint32LE() & 0xff;
+	readRect32(stream, zone.rect);
+	stream.skip(33);	// collision mask, not used by the snowmobile rides
+	zone.minSpeed = stream.readDoubleLE();
+	zone.maxSpeed = stream.readDoubleLE();
+	zone.conditionFlag = stream.readSint16LE();
+	zone.conditionValue = stream.readByte();
+	stream.skip(4);		// delay before the zone takes effect, always 0
+	zone.sound.readData(stream);
+
+	switch (zone.type) {
+	case kZoneChangeScene:
+		zone.sceneID = stream.readUint16LE();
+		stream.skip(1);
+		break;
+	case kZoneFlag:
+		zone.flagLabel = stream.readSint16LE();
+		zone.flagValue = stream.readByte();
+		break;
+	case kZoneDamage:
+		zone.minDamage = stream.readSint32LE();
+		zone.maxDamage = stream.readSint32LE();
+		break;
+	case kZoneOverlay:
+	case kZoneTiledOverlay:
+	case kZonePointer: {
+		Common::String imageName;
+		readFilename(stream, imageName);
+		zone.imageID = loadSheet(imageName);
+
+		// The rides only have still overlays
+		int16 numFrames = stream.readSint16LE();
+		if (numFrames < 1) {
+			error("SnowmobilePuzzle: overlay zone without frames");
+		}
+
+		readRect32(stream, zone.src);
+		stream.skip((numFrames - 1) * 16);
+		readRect32(stream, zone.dest);
+		stream.skip(4);	// animation loops
+		zone.layer = stream.readSint32LE();
+		break;
+	}
+	case kZoneSound:
+	case kZoneBoundary:
+	case kZoneNoRandomZones:
+		break;
+	default:
+		error("SnowmobilePuzzle: unhandled zone type %d", zone.type);
+	}
+}
+
+void SnowmobilePuzzle::readZones(Common::SeekableReadStream &stream, Common::Array<Zone> &zones) {
+	zones.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (Zone &zone : zones) {
+		readZone(stream, zone);
+	}
+}
+
+void SnowmobilePuzzle::readData(Common::SeekableReadStream &stream) {
+	stream.skip(33);	// name the ride's state is kept under, empty
+	stream.skip(66);	// background image and collision mask, both empty
+	readFilename(stream, _imageName);
+	_startX = stream.readSint32LE();
+	_startY = stream.readSint32LE();
+	_startAngle = stream.readSint32LE();
+	_forwardSpeed = stream.readSint32LE();
+	_reverseSpeed = stream.readSint32LE();
+	_resourceID = stream.readSint16LE();
+	stream.skip(6);
+	_crashScene = stream.readUint16LE();
+
+	_crashSound.readData(stream);
+	RandomSoundBlock hornSound;	// empty
+	hornSound.readData(stream);
+	_engineSound.readData(stream);
+
+	// Only the first frame is used, turned to face the heading
+	int16 numFrames = stream.readSint16LE();
+	if (numFrames < 1) {
+		error("SnowmobilePuzzle: no snowmobile frames");
+	}
+
+	readRect32(stream, _carSrc);
+	stream.skip((numFrames - 1) * 16);
+
+	if (_variant == kChase) {
+		_stages.resize(MAX<int16>(stream.readSint16LE(), 0));
+		for (ChaseStage &stage : _stages) {
+			stage.distance = stream.readSint16LE();
+			stage.duration = stream.readSint32LE() * 1000;
+			stage.reachedFlag = stream.readSint16LE();
+			stage.inReachFlag = stream.readSint16LE();
+		}
+
+		_winScene = stream.readUint16LE();
+		_winFlag = stream.readSint16LE();
+		readFilename(stream, _chaserImageName);
+
+		numFrames = stream.readSint16LE();
+		if (numFrames < 1) {
+			error("SnowmobilePuzzle: no frames for the chased snowmobile");
+		}
+
+		readRect32(stream, _chaserSrc);
+		stream.skip((numFrames - 1) * 16);
+	}
+
+	readZones(stream, _zones);
+	_numFixedZones = _zones.size();
+
+	_cellSize = stream.readSint32LE();
+	_density = stream.readSint32LE();
+	if (_cellSize <= 0) {
+		error("SnowmobilePuzzle: invalid cell size %d", _cellSize);
+	}
+
+	_sets.resize(stream.readUint16LE());
+	int32 total = 0;
+	for (ZoneSet &set : _sets) {
+		total += (int32)stream.readFloatLE();
+		set.maxRoll = total;
+		readZones(stream, set.zones);
+	}
+
+	if (_variant == kChase) {
+		// Zones for the chased snowmobile, none in the data
+		Common::Array<Zone> chaserZones;
+		readZones(stream, chaserZones);
+
+		_path.resize(MAX<int16>(stream.readSint16LE(), 0));
+		for (Waypoint &point : _path) {
+			point.time = stream.readUint32LE();
+			int16 x = stream.readSint16LE();
+			int16 y = stream.readSint16LE();
+			point.pos = Math::Vector2d(x, y);
+			point.heading = stream.readDoubleLE();
+		}
+
+		if (_path.size() < 2 || _stages.empty()) {
+			error("SnowmobilePuzzle: chase without a path or stages");
+		}
+	}
+}
+
+void SnowmobilePuzzle::finalizeZone(Zone &zone) const {
+	if (zone.type != kZoneOverlay && zone.type != kZoneTiledOverlay && zone.type != kZonePointer) {
+		return;
+	}
+
+	// An overlay without a rect of its own shows whenever it may be in view
+	if (zone.rect.isEmpty()) {
+		zone.hasImplicitRect = true;
+		zone.rect = Common::Rect32(zone.dest.left - _drawSurface.w, zone.dest.top - _drawSurface.h,
+			zone.dest.right + _drawSurface.w, zone.dest.bottom + _drawSurface.h);
+	}
+
+	zone.target = Math::Vector2d(zone.rect.center().x, zone.rect.center().y);
+}
+
+void SnowmobilePuzzle::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+
+	// The overlays have soft edges, so the surface carries an alpha channel
+	_drawSurface.create(vpBounds.width(), vpBounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
+	_drawSurface.clear(0);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+
+	_car = Snowmobile();
+	_car.pos = Math::Vector2d(_startX, _startY);
+	_car.heading = _startAngle * M_PI / 180.0;
+	_car.maxSpeed = _forwardSpeed;
+	_car.minSpeed = -_reverseSpeed;
+	_car.friction = _forwardSpeed / kFrictionFactor / kTicksPerSecond;
+	_car.size = MAX<int32>((_carSrc.width() + _carSrc.height()) / 2, 2);
+
+	for (Zone &zone : _zones) {
+		finalizeZone(zone);
+	}
+
+	for (ZoneSet &set : _sets) {
+		for (Zone &zone : set.zones) {
+			finalizeZone(zone);
+		}
+	}
+
+	_cellRadiusX = (_cellSize - 1 + _drawSurface.w) / _cellSize;
+	_cellRadiusY = (_cellSize - 1 + _drawSurface.h) / _cellSize;
+
+	if (_variant == kChase) {
+		_chaserImageID = loadSheet(_chaserImageName);
+		_pathDuration = MAX<uint32>(_path.back().time - _path[0].time, 1);
+		_pathLap = _path.back().pos - _path[0].pos;
+		_pathTime = 0;
+		_chaserPos = getPathPos(0);
+		_chaserHeading = wrapAngle(_path[findWaypoint(0)].heading);
+		_curStage = 0;
+
+		// The first pointer follows the chased snowmobile
+		for (uint i = 0; i < _numFixedZones; ++i) {
+			if (_zones[i].type == kZonePointer) {
+				_pointerZone = i;
+				_zones[i].centerOn(_chaserPos);
+				break;
+			}
+		}
+	}
+
+	populateField();
+	seedZones();
+
+	if (!_engineSound.names.empty()) {
+		_engineSoundDesc.name = _engineSound.names[0];
+		_engineSoundDesc.channelID = _engineSound.channel;
+		_engineSoundDesc.numLoops = _engineSound.numLoops;
+		_engineSoundDesc.volume = _engineSound.volume;
+		g_nancy->_sound->loadSound(_engineSoundDesc);
+		g_nancy->_sound->playSound(_engineSoundDesc);
+	}
+
+	_lastMousePos = g_nancy->_input->getInput().mousePos;
+	_lastTickTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+void SnowmobilePuzzle::getCurrentCell(int32 &cellX, int32 &cellY) const {
+	Common::Rect32 bounds = _car.getBounds();
+	cellX = bounds.center().x / _cellSize;
+	cellY = bounds.center().y / _cellSize;
+}
+
+// Fills the surroundings, save for the cells right next to the snowmobile
+void SnowmobilePuzzle::populateField() {
+	getCurrentCell(_lastCellX, _lastCellY);
+	if (_sets.empty()) {
+		return;
+	}
+
+	for (int32 x = _lastCellX - _cellRadiusX; x <= _lastCellX + _cellRadiusX; ++x) {
+		for (int32 y = _lastCellY - _cellRadiusY; y <= _lastCellY + _cellRadiusY; ++y) {
+			if ((ABS(_lastCellX - x) > 1 || ABS(_lastCellY - y) > 1) && rollForCell(x, y)) {
+				spawnSet(x, y);
+			}
+		}
+	}
+}
+
+// Drops the sets left behind, and fills the cells that just came within range
+void SnowmobilePuzzle::updateField() {
+	int32 cellX, cellY;
+	getCurrentCell(cellX, cellY);
+	if (cellX == _lastCellX && cellY == _lastCellY) {
+		return;
+	}
+
+	Common::Rect32 kept(cellX - _cellRadiusX, cellY - _cellRadiusY, cellX + _cellRadiusX + 1, cellY + _cellRadiusY + 1);
+	for (uint i = _numFixedZones; i < _zones.size(); ) {
+		if (kept.contains(_zones[i].cellX, _zones[i].cellY)) {
+			++i;
+		} else {
+			_zones.remove_at(i);
+		}
+	}
+
+	if (!_sets.empty()) {
+		if (cellX != _lastCellX) {
+			int32 x = cellX + (cellX < _lastCellX ? -_cellRadiusX : _cellRadiusX);
+			for (int32 y = cellY - _cellRadiusY; y < cellY + _cellRadiusY; ++y) {
+				if (!isCellExcluded(x, y) && rollForCell(x, y)) {
+					spawnSet(x, y);
+				}
+			}
+		}
+
+		if (cellY != _lastCellY) {
+			int32 y = cellY + (cellY < _lastCellY ? -_cellRadiusY : _cellRadiusY);
+			for (int32 x = cellX - _cellRadiusX; x < cellX + _cellRadiusX; ++x) {
+				if (!isCellExcluded(x, y) && rollForCell(x, y)) {
+					spawnSet(x, y);
+				}
+			}
+		}
+	}
+
+	_lastCellX = cellX;
+	_lastCellY = cellY;
+}
+
+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)) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+bool SnowmobilePuzzle::rollForCell(int32 cellX, int32 cellY) const {
+	bool result = (int32)g_nancy->_randomSource->getRandomNumber(99) < _density;
+
+	// The chased snowmobile's path is kept clear
+	int32 top, bottom;
+	if (_variant == kChase && getPathRange(cellX * _cellSize, (cellX + 1) * _cellSize, top, bottom) &&
+			(cellY - 1) * _cellSize <= bottom && top < (cellY + 2) * _cellSize) {
+		return false;
+	}
+
+	return result;
+}
+
+void SnowmobilePuzzle::spawnSet(int32 cellX, int32 cellY) {
+	int32 roll = g_nancy->_randomSource->getRandomNumber(99);
+	const ZoneSet *set = nullptr;
+	for (const ZoneSet &candidate : _sets) {
+		if (roll < candidate.maxRoll) {
+			set = &candidate;
+			break;
+		}
+	}
+
+	int32 x = cellX * _cellSize + (int32)g_nancy->_randomSource->getRandomNumber(_cellSize - 1);
+	int32 y = cellY * _cellSize + (int32)g_nancy->_randomSource->getRandomNumber(_cellSize - 1);
+	Math::Vector2d pos(x, y);
+
+	if (!set) {
+		return;
+	}
+
+	for (const Zone &source : set->zones) {
+		Zone zone = source;
+		zone.centerOn(pos);
+		zone.cellX = cellX;
+		zone.cellY = cellY;
+		_zones.push_back(zone);
+	}
+}
+
+double SnowmobilePuzzle::wrapAngle(double angle) {
+	while (angle < 0) {
+		angle += 2 * M_PI;
+	}
+
+	while (angle >= 2 * M_PI) {
+		angle -= 2 * M_PI;
+	}
+
+	return angle;
+}
+
+// Angles go counterclockwise, with the y axis pointing down
+double SnowmobilePuzzle::angleOf(const Math::Vector2d &v) {
+	double angle = atan2(-v.getY(), v.getX());
+	return angle < 0 ? angle + 2 * M_PI : angle;
+}
+
+void SnowmobilePuzzle::setSpeed(double speed) {
+	speed = CLIP(speed, _car.minSpeed, _car.maxSpeed);
+	_car.speed = fabs(speed) < kMinSpeed ? 0 : speed;
+}
+
+void SnowmobilePuzzle::steer(int32 ticks) {
+	if (_inputFlags & NancyInput::kMoveLeft) {
+		_car.heading = wrapAngle(_car.heading + kTurnPerTick * ticks);
+	} else if (_inputFlags & NancyInput::kMoveRight) {
+		_car.heading = wrapAngle(_car.heading - kTurnPerTick * ticks);
+	}
+
+	double acceleration = _car.maxSpeed / kTicksPerSecond * ticks;
+	if (_inputFlags & (NancyInput::kMoveUp | NancyInput::kLeftMouseButtonDown | NancyInput::kLeftMouseButtonHeld)) {
+		setSpeed(_car.speed + acceleration);
+	} else if (_inputFlags & (NancyInput::kMoveDown | NancyInput::kRightMouseButtonDown | NancyInput::kRightMouseButtonHeld)) {
+		setSpeed(_car.speed - acceleration / kBrakeFactor);
+	}
+}
+
+// Friction never reverses the direction of travel
+void SnowmobilePuzzle::applyFriction(double ticks) {
+	if (fabs(_car.speed) < 0.00001) {
+		setSpeed(0);
+		return;
+	}
+
+	bool wasReversing = _car.speed < 0;
+	double change = _car.friction * ticks;
+	setSpeed(_car.speed + (wasReversing ? change : -change));
+	if ((_car.speed < 0) != wasReversing) {
+		setSpeed(0);
+	}
+}
+
+// Moves in steps of at most a pixel, so nothing gets jumped over
+void SnowmobilePuzzle::move(double ticks) {
+	Math::Vector2d delta((float)(cos(_car.heading) * _car.speed / kTicksPerSecond * ticks),
+		(float)(-sin(_car.heading) * _car.speed / kTicksPerSecond * ticks));
+
+	if (fabs(delta.getX()) > 1.0 || fabs(delta.getY()) > 1.0) {
+		move(ticks * 0.5);
+		move(ticks * 0.5);
+		return;
+	}
+
+	tryMove(_car.pos + delta);
+}
+
+bool SnowmobilePuzzle::isFree() const {
+	Common::Rect32 bounds = _car.getBounds();
+	for (const Zone &zone : _zones) {
+		if (zone.type == kZoneBoundary && zone.isActive && zone.rect.intersects(bounds)) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+// Each axis is tried on its own, which lets the snowmobile slide along boundaries
+void SnowmobilePuzzle::tryMove(const Math::Vector2d &pos) {
+	Math::Vector2d oldPos = _car.pos;
+	Math::Vector2d newPos = oldPos;
+
+	_car.pos = Math::Vector2d(pos.getX(), oldPos.getY());
+	bool movedX = false;
+	if (isFree()) {
+		newPos.setX(pos.getX());
+		movedX = pos.getX() != oldPos.getX();
+	}
+
+	_car.pos = Math::Vector2d(oldPos.getX(), pos.getY());
+	bool movedY = false;
+	if (isFree()) {
+		newPos.setY(pos.getY());
+		movedY = pos.getY() != oldPos.getY();
+	}
+
+	if (!movedX && !movedY) {
+		_car.speed = 0;
+	}
+
+	_car.pos = newPos;
+	if (!isFree()) {
+		_car.speed = 0;
+		_car.pos = oldPos;
+	}
+}
+
+void SnowmobilePuzzle::update(int32 ticks) {
+	steer(ticks);
+	applyFriction(ticks);
+	move(ticks);
+	updateField();
+
+	if (_car.damage >= kMaxDamage) {
+		_car.damage = 0;
+		_hasCrashed = true;
+		playSoundBlock(_crashSound);
+		changeScene(_crashScene);
+	}
+
+	if (_hasCrashed || NancySceneState.getUIResource(_resourceID) == 0) {
+		_zonesEnabled = false;
+	}
+}
+
+bool SnowmobilePuzzle::isInZone(const Zone &zone) const {
+	if (zone.conditionFlag != -1 && !NancySceneState.getEventFlag(zone.conditionFlag, zone.conditionValue)) {
+		return false;
+	}
+
+	double speed = fabs(_car.speed);
+	if (speed < zone.minSpeed || speed > zone.maxSpeed) {
+		return false;
+	}
+
+	int32 x = (int32)_car.pos.getX();
+	int32 y = (int32)_car.pos.getY();
+
+	switch (zone.type) {
+	case kZoneBoundary:
+		return true;
+	case kZonePointer:
+		// Shown while the snowmobile is away from what the arrow points at
+		return !zone.rect.contains(x, y);
+	case kZoneTiledOverlay:
+		if (zone.hasImplicitRect) {
+			return y > zone.rect.top && y < zone.rect.bottom;
+		}
+
+		// fall through
+	default:
+		return zone.rect.contains(x, y);
+	}
+}
+
+// The zones the snowmobile starts in only take effect once they are left and entered again
+void SnowmobilePuzzle::seedZones() {
+	for (Zone &zone : _zones) {
+		switch (zone.type) {
+		case kZoneChangeScene:
+		case kZoneSound:
+		case kZoneFlag:
+		case kZoneDamage:
+		case kZoneNoRandomZones:
+			zone.isActive = isInZone(zone);
+			break;
+		default:
+			break;
+		}
+	}
+}
+
+void SnowmobilePuzzle::updateZones() {
+	for (Zone &zone : _zones) {
+		bool isInside = isInZone(zone);
+		if (isInside && !zone.isActive) {
+			zone.isActive = true;
+			playSoundBlock(zone.sound);
+			enterZone(zone);
+		} else if (!isInside) {
+			zone.isActive = false;
+		}
+	}
+}
+
+void SnowmobilePuzzle::enterZone(Zone &zone) {
+	switch (zone.type) {
+	case kZoneChangeScene:
+		changeScene(zone.sceneID);
+		break;
+	case kZoneFlag:
+		NancySceneState.setEventFlag(zone.flagLabel, zone.flagValue ? g_nancy->_true : g_nancy->_false);
+		break;
+	case kZoneDamage:
+		_car.damage += zone.minDamage;
+		if (zone.maxDamage > zone.minDamage) {
+			_car.damage += g_nancy->_randomSource->getRandomNumber(zone.maxDamage - zone.minDamage - 1);
+		}
+
+		break;
+	default:
+		break;
+	}
+}
+
+// Returns the last waypoint that is not past the given time within a lap
+uint SnowmobilePuzzle::findWaypoint(uint32 time) const {
+	uint low = 0;
+	uint high = _path.size();
+	while (low < high) {
+		uint mid = (low + high) / 2;
+		if (_path[mid].time < time) {
+			low = mid + 1;
+		} else {
+			high = mid;
+		}
+	}
+
+	if (low == _path.size() || _path[low].time > time) {
+		low = low == 0 ? _path.size() - 1 : low - 1;
+	}
+
+	return low;
+}
+
+Math::Vector2d SnowmobilePuzzle::getPathPos(uint32 time) const {
+	uint32 lap = time / _pathDuration;
+	uint32 lapTime = time % _pathDuration;
+
+	uint from = findWaypoint(lapTime);
+	uint to = from + 1;
+	bool wraps = to == _path.size();
+	if (wraps) {
+		to = 0;
+	}
+
+	int32 span = (int32)(_path[to].time - _path[from].time) + (wraps ? (int32)_pathDuration : 0);
+	double fraction = span != 0 ? (double)(int32)(lapTime - _path[from].time) / span : 0;
+
+	Math::Vector2d fromPos = _path[from].pos + _pathLap * lap;
+	Math::Vector2d toPos = _path[to].pos + _pathLap * (lap + (wraps ? 1 : 0));
+	return fromPos + (toPos - fromPos) * (float)fraction;
+}
+
+// Finds the vertical extent of the path between two x coordinates
+bool SnowmobilePuzzle::getPathRange(int32 left, int32 right, int32 &top, int32 &bottom) const {
+	top = 0x7fffffff;
+	bottom = -0x7fffffff - 1;
+
+	Math::Vector2d offset = _pathLap * (int32)((left - _path[0].pos.getX()) / _pathLap.getX());
+	bool isInside = false;
+	for (const Waypoint &point : _path) {
+		double x = point.pos.getX() + offset.getX();
+		if (!isInside) {
+			if (x < left) {
+				continue;
+			}
+
+			isInside = true;
+		}
+
+		double y = point.pos.getY() + offset.getY();
+		if (y < top) {
+			top = (int32)y;
+		}
+
+		if (y > bottom) {
+			bottom = (int32)y;
+		}
+
+		if (right <= x) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+// The chased snowmobile waits for the player when ahead, and hurries when caught up with
+void SnowmobilePuzzle::updateChase(int32 ticks) {
+	ChaseStage &stage = _stages[_curStage];
+	int32 nextDistance = _curStage + 1 < _stages.size() ? _stages[_curStage + 1].distance : (int32)kChaseCloseDistance;
+
+	Math::Vector2d offset = _chaserPos - _car.pos;
+	double distance = offset.getMagnitude();
+
+	double rate = 1.0;
+	if (offset.getX() < 0) {
+		rate = kChaseRateBehind;
+	} else if (offset.getX() < kChaseCloseDistance) {
+		rate = 1.0 + (kChaseCloseDistance - offset.getX()) / kChaseCloseDistance;
+	} else if (nextDistance > distance) {
+		rate = kChaseRateInReach;
+	} else if (offset.getX() > _drawSurface.w) {
+		rate = kChaseRateOutOfView;
+	} else if (offset.getX() > _drawSurface.w / 2) {
+		rate = kChaseRateFar;
+	}
+
+	_pathTime = (uint32)(ticks * (double)kMsPerTick * rate + _pathTime);
+	_chaserPos = getPathPos(_pathTime);
+
+	uint32 lapTime = _pathTime % _pathDuration;
+	uint waypoint = findWaypoint(lapTime);
+	if (_path[waypoint].time < lapTime && waypoint + 1 < _path.size()) {
+		++waypoint;
+	}
+
+	_chaserHeading = wrapAngle(_path[waypoint].heading);
+
+	if (_pointerZone != -1) {
+		_zones[_pointerZone].centerOn(_chaserPos);
+	}
+
+	if (updateStage(stage, distance) && ++_curStage == _stages.size()) {
+		changeScene(_winScene);
+		NancySceneState.setEventFlag(_winFlag, g_nancy->_true);
+	}
+}
+
+// Returns true once the player has stayed within reach for long enough
+bool SnowmobilePuzzle::updateStage(ChaseStage &stage, double distance) {
+	if (stage.distance <= distance) {
+		if (stage.isInReach) {
+			stage.isInReach = false;
+			NancySceneState.setEventFlag(stage.inReachFlag, g_nancy->_false);
+		}
+
+		return false;
+	}
+
+	uint32 now = g_nancy->getTotalPlayTime();
+	if (!stage.isInReach) {
+		NancySceneState.setEventFlag(stage.reachedFlag, g_nancy->_true);
+		stage.reachedFlag = -1;
+		NancySceneState.setEventFlag(stage.inReachFlag, g_nancy->_true);
+		stage.isInReach = true;
+		stage.reachedTime = now;
+	}
+
+	return now - stage.reachedTime >= stage.duration;
+}
+
+void SnowmobilePuzzle::changeScene(uint16 sceneID) {
+	if (_nextScene == kNoScene) {
+		_nextScene = sceneID;
+	}
+}
+
+Common::Rect32 SnowmobilePuzzle::getView() const {
+	int32 left = (int32)(_car.pos.getX() - _drawSurface.w / 2);
+	int32 top = (int32)(_car.pos.getY() - _drawSurface.h / 2);
+	return Common::Rect32(left, top, left + _drawSurface.w, top + _drawSurface.h);
+}
+
+// The player's snowmobile always sits at the center of the view
+Common::Rect SnowmobilePuzzle::getCarDest(const Common::Rect32 &frame) const {
+	int16 left = _drawSurface.w / 2 - frame.width() / 2;
+	int16 top = _drawSurface.h / 2 - frame.height() / 2;
+	return Common::Rect(left, top, left + frame.width(), top + frame.height());
+}
+
+void SnowmobilePuzzle::blendPixel(int x, int y, const Graphics::ManagedSurface &image, uint32 color) {
+	byte srcA, srcR, srcG, srcB;
+	image.format.colorToARGB(color, srcA, srcR, srcG, srcB);
+	if (srcA == 0) {
+		return;
+	}
+
+	if (srcA != 255) {
+		byte dstA, dstR, dstG, dstB;
+		_drawSurface.format.colorToARGB(_drawSurface.getPixel(x, y), dstA, dstR, dstG, dstB);
+
+		uint dstWeight = dstA * (255 - srcA) / 255;
+		uint outA = srcA + dstWeight;
+		srcR = (srcR * srcA + dstR * dstWeight) / outA;
+		srcG = (srcG * srcA + dstG * dstWeight) / outA;
+		srcB = (srcB * srcA + dstB * dstWeight) / outA;
+		srcA = outA;
+	}
+
+	_drawSurface.setPixel(x, y, _drawSurface.format.ARGBToColor(srcA, srcR, srcG, srcB));
+}
+
+void SnowmobilePuzzle::drawSprite(const Graphics::ManagedSurface &image, const Common::Rect32 &src, int32 destX, int32 destY) {
+	int32 startX = MAX<int32>(0, -destX);
+	int32 startY = MAX<int32>(0, -destY);
+	int32 endX = MIN<int32>(src.width(), _drawSurface.w - destX);
+	int32 endY = MIN<int32>(src.height(), _drawSurface.h - destY);
+
+	for (int32 y = startY; y < endY; ++y) {
+		for (int32 x = startX; x < endX; ++x) {
+			blendPixel(destX + x, destY + y, image, image.getPixel(src.left + x, src.top + y));
+		}
+	}
+}
+
+// Draws the sprite turned counterclockwise around the center of dest
+void SnowmobilePuzzle::drawRotated(const Graphics::ManagedSurface &image, const Common::Rect32 &src, const Common::Rect &dest, double angle) {
+	double cosA = cos(angle);
+	double sinA = sin(angle);
+	double halfW = src.width() * 0.5;
+	double halfH = src.height() * 0.5;
+	double centerX = dest.left + halfW;
+	double centerY = dest.top + halfH;
+	int32 radius = (int32)ceil(sqrt(halfW * halfW + halfH * halfH));
+
+	int32 startX = MAX<int32>(0, (int32)centerX - radius);
+	int32 startY = MAX<int32>(0, (int32)centerY - radius);
+	int32 endX = MIN<int32>(_drawSurface.w, (int32)centerX + radius + 1);
+	int32 endY = MIN<int32>(_drawSurface.h, (int32)centerY + radius + 1);
+
+	for (int32 y = startY; y < endY; ++y) {
+		for (int32 x = startX; x < endX; ++x) {
+			double dx = x + 0.5 - centerX;
+			double dy = y + 0.5 - centerY;
+			int32 srcX = (int32)floor(dx * cosA - dy * sinA + halfW);
+			int32 srcY = (int32)floor(dx * sinA + dy * cosA + halfH);
+			if (srcX >= 0 && srcX < src.width() && srcY >= 0 && srcY < src.height()) {
+				blendPixel(x, y, image, image.getPixel(src.left + srcX, src.top + srcY));
+			}
+		}
+	}
+}
+
+void SnowmobilePuzzle::drawZones(int32 layer) {
+	Common::Rect32 view = getView();
+
+	for (const Zone &zone : _zones) {
+		if (!zone.isActive || zone.layer != layer) {
+			continue;
+		}
+
+		const Graphics::ManagedSurface &image = *_sheets[zone.imageID];
+
+		switch (zone.type) {
+		case kZoneOverlay:
+			if (zone.dest.intersects(view)) {
+				drawSprite(image, zone.src, zone.dest.left - view.left, zone.dest.top - view.top);
+			}
+
+			break;
+		case kZoneTiledOverlay: {
+			int32 step = zone.dest.width();
+			if (step <= 0) {
+				break;
+			}
+
+			int32 x = (zone.dest.left - view.left) % step;
+			if (x > 0) {
+				x -= step;
+			}
+
+			for (; x < _drawSurface.w; x += step) {
+				drawSprite(image, zone.src, x, zone.dest.top - view.top);
+			}
+
+			break;
+		}
+		case kZonePointer: {
+			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;
+		}
+		default:
+			break;
+		}
+	}
+}
+
+void SnowmobilePuzzle::redraw() {
+	_drawSurface.clear(0);
+
+	drawZones(0);
+
+	Common::Rect carDest = getCarDest(_carSrc);
+
+	if (_variant == kChase) {
+		Math::Vector2d offset = _chaserPos - _car.pos;
+		int32 left = carDest.left + (int32)offset.getX();
+		int32 top = carDest.top + (int32)offset.getY();
+		Common::Rect32 chaserDest(left, top, left + _chaserSrc.width(), top + _chaserSrc.height());
+		if (chaserDest.intersects(Common::Rect32(0, 0, _drawSurface.w, _drawSurface.h))) {
+			drawRotated(*_sheets[_chaserImageID], _chaserSrc,
+				Common::Rect(chaserDest.left, chaserDest.top, chaserDest.right, chaserDest.bottom), _chaserHeading);
+		}
+	}
+
+	drawRotated(_image, _carSrc, carDest, _car.heading);
+
+	drawZones(1);
+
+	_needsRedraw = true;
+}
+
+void SnowmobilePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+		int32 ticks = (now - _lastTickTime) / kMsPerTick;
+		_lastTickTime += ticks * kMsPerTick;
+
+		if (ticks > 0) {
+			update(ticks);
+			if (_variant == kChase) {
+				updateChase(ticks);
+			}
+		}
+
+		if (_zonesEnabled) {
+			updateZones();
+		}
+
+		redraw();
+
+		if (_nextScene != kNoScene) {
+			_state = kActionTrigger;
+		}
+
+		break;
+	}
+	case kActionTrigger: {
+		if (!_engineSoundDesc.name.empty()) {
+			g_nancy->_sound->stopSound(_engineSoundDesc);
+		}
+
+		SceneChangeDescription sceneChange;
+		sceneChange.sceneID = _nextScene;
+		sceneChange.continueSceneSound = kContinueSceneSound;
+		NancySceneState.changeScene(sceneChange);
+		finishExecution();
+		break;
+	}
+	}
+}
+
+void SnowmobilePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun) {
+		return;
+	}
+
+	_inputFlags = input.input;
+
+	// The arrow keys take over the steering until the mouse is moved again
+	if (_inputFlags & (NancyInput::kMoveUp | NancyInput::kMoveDown | NancyInput::kMoveLeft | NancyInput::kMoveRight)) {
+		_isMouseSteering = false;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	if (input.mousePos != _lastMousePos) {
+		_lastMousePos = input.mousePos;
+		_isMouseSteering = true;
+
+		// The snowmobile faces the cursor
+		Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+			Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+		Common::Rect carDest = getCarDest(_carSrc);
+		Math::Vector2d direction(mouseRect.left - (carDest.left + carDest.width() / 2),
+			mouseRect.top - (carDest.top + carDest.height() / 2));
+		_car.heading = angleOf(direction);
+	}
+
+	setDataCursor(_isMouseSteering ? kMouseSteeringCursor : kKeySteeringCursor, false);
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/snowmobilepuzzle.h b/engines/nancy/action/puzzle/snowmobilepuzzle.h
new file mode 100644
index 00000000000..5f7f77ff0aa
--- /dev/null
+++ b/engines/nancy/action/puzzle/snowmobilepuzzle.h
@@ -0,0 +1,256 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_SNOWMOBILEPUZZLE_H
+#define NANCY_ACTION_SNOWMOBILEPUZZLE_H
+
+#include "math/vector2d.h"
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy16 snowmobile rides. The snowmobile is seen from above, at the center of a view
+// that scrolls over an endless snow field. The field has no map: it is made of zones,
+// a few fixed ones (the start and finish lines, their scenery) and sets of obstacles
+// that get scattered at random around the snowmobile as it moves. Hitting obstacles
+// damages the snowmobile, and enough damage ends the ride.
+//   kDriving - ride to the zone that leads to the next scene
+//   kChase   - keep close to another snowmobile that follows a recorded path
+class SnowmobilePuzzle : public PuzzleRecord {
+public:
+	enum Variant {
+		kDriving,
+		kChase
+	};
+
+	SnowmobilePuzzle(Variant variant) : PuzzleRecord(7), _variant(variant) {}
+	virtual ~SnowmobilePuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+	// The crash scene has no art of its own, and plays its explosion over the field
+	bool survivesSceneChange(bool nextSceneIsNoArt) const override { return nextSceneIsNoArt; }
+
+protected:
+	Common::String getRecordTypeName() const override {
+		return _variant == kChase ? "SnowmobileChasePuzzle" : "SnowmobilePuzzle";
+	}
+
+	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
+	};
+
+	struct Zone {
+		byte type = 0;
+		Common::Rect32 rect;
+		double minSpeed = 0;		// only applies while the snowmobile's speed is within these
+		double maxSpeed = 0;
+		int16 conditionFlag = -1;
+		byte conditionValue = 0;
+		RandomSoundBlock sound;		// played on entering
+
+		uint16 sceneID = kNoScene;	// kZoneChangeScene
+
+		int16 flagLabel = -1;		// kZoneFlag
+		byte flagValue = 0;
+
+		int32 minDamage = 0;		// kZoneDamage
+		int32 maxDamage = 0;
+
+		// Overlays
+		uint imageID = 0;
+		Common::Rect32 src;
+		Common::Rect32 dest;				// on the field; in the view for kZonePointer
+		int32 layer = 0;			// 0 is below the snowmobiles, 1 above
+		bool hasImplicitRect = false;
+
+		bool isActive = false;
+		Math::Vector2d target;				// kZonePointer
+		int32 cellX = 0;			// for zones from a random set
+		int32 cellY = 0;
+
+		// Moves the zone so its rect is centered on the point
+		void centerOn(const Math::Vector2d &pos);
+	};
+
+	struct ZoneSet {
+		int32 maxRoll = 0;			// running total of the sets' percentages
+		Common::Array<Zone> zones;
+	};
+
+	struct Snowmobile {
+		Math::Vector2d pos;
+		double heading = 0;			// radians, counterclockwise from east
+		double speed = 0;			// pixels per second
+		double maxSpeed = 0;
+		double minSpeed = 0;
+		double friction = 0;		// speed lost per tick
+		int32 size = 0;				// side of the square that collides with boundaries
+		int32 damage = 0;
+
+		Common::Rect32 getBounds() const;
+	};
+
+	// The chase is won by getting within reach of the other snowmobile and
+	// staying there for a while, several times over
+	struct ChaseStage {
+		int16 distance = 0;
+		uint32 duration = 0;		// ms
+		int16 reachedFlag = -1;		// set the first time
+		int16 inReachFlag = -1;		// follows the state
+		bool isInReach = false;
+		uint32 reachedTime = 0;
+	};
+
+	struct Waypoint {
+		uint32 time = 0;
+		Math::Vector2d pos;
+		double heading = 0;
+	};
+
+	void readZone(Common::SeekableReadStream &stream, Zone &zone);
+	void readZones(Common::SeekableReadStream &stream, Common::Array<Zone> &zones);
+	uint loadSheet(const Common::String &name);
+	void finalizeZone(Zone &zone) const;
+
+	// Random zones
+	void getCurrentCell(int32 &cellX, int32 &cellY) const;
+	void populateField();
+	void updateField();
+	bool rollForCell(int32 cellX, int32 cellY) const;
+	void spawnSet(int32 cellX, int32 cellY);
+	bool isCellExcluded(int32 cellX, int32 cellY) const;
+
+	// Physics
+	static double wrapAngle(double angle);
+	static double angleOf(const Math::Vector2d &v);
+	void setSpeed(double speed);
+	void steer(int32 ticks);
+	void applyFriction(double ticks);
+	void move(double ticks);
+	void tryMove(const Math::Vector2d &pos);
+	bool isFree() const;
+	void update(int32 ticks);
+
+	// Zones
+	bool isInZone(const Zone &zone) const;
+	void seedZones();
+	void updateZones();
+	void enterZone(Zone &zone);
+
+	// The path repeats forever, each lap starting where the last one ended
+	uint findWaypoint(uint32 time) const;
+	Math::Vector2d getPathPos(uint32 time) const;
+	bool getPathRange(int32 left, int32 right, int32 &top, int32 &bottom) const;
+	void updateChase(int32 ticks);
+	bool updateStage(ChaseStage &stage, double distance);
+
+	void changeScene(uint16 sceneID);
+
+	Common::Rect32 getView() const;
+	Common::Rect getCarDest(const Common::Rect32 &frame) const;
+	void blendPixel(int x, int y, const Graphics::ManagedSurface &image, uint32 color);
+	void drawSprite(const Graphics::ManagedSurface &image, const Common::Rect32 &src, int32 destX, int32 destY);
+	void drawRotated(const Graphics::ManagedSurface &image, const Common::Rect32 &src, const Common::Rect &dest, double angle);
+	void drawZones(int32 layer);
+	void redraw();
+
+	Variant _variant;
+
+	// -- File data --
+	// The snowmobile's image is _image
+	int32 _startX = 0;
+	int32 _startY = 0;
+	int32 _startAngle = 0;			// degrees
+	int32 _forwardSpeed = 0;
+	int32 _reverseSpeed = 0;
+	int16 _resourceID = 0;			// the zones stop working once this UI resource runs out
+	uint16 _crashScene = kNoScene;
+	RandomSoundBlock _crashSound;
+	RandomSoundBlock _engineSound;
+	Common::Rect32 _carSrc;
+
+	Common::Array<ChaseStage> _stages;
+	uint16 _winScene = kNoScene;
+	int16 _winFlag = -1;
+	Common::String _chaserImageName;
+	Common::Rect32 _chaserSrc;
+	Common::Array<Waypoint> _path;
+
+	int32 _cellSize = 0;
+	int32 _density = 0;				// percent chance of a cell getting a set
+	Common::Array<ZoneSet> _sets;
+
+	// -- Runtime state --
+	Common::Array<Common::String> _sheetNames;
+	Common::Array<Graphics::ManagedSurface *> _sheets;
+	uint _chaserImageID = 0;
+
+	// The fixed zones, followed by the ones spawned from the sets
+	Common::Array<Zone> _zones;
+	uint _numFixedZones = 0;
+	int32 _cellRadiusX = 0;
+	int32 _cellRadiusY = 0;
+	int32 _lastCellX = 0;
+	int32 _lastCellY = 0;
+
+	SoundDescription _engineSoundDesc;
+
+	Snowmobile _car;
+	bool _hasCrashed = false;
+	bool _zonesEnabled = true;
+
+	Math::Vector2d _chaserPos;
+	double _chaserHeading = 0;
+	uint32 _pathTime = 0;
+	uint32 _pathDuration = 1;
+	Math::Vector2d _pathLap;
+	uint _curStage = 0;
+	int _pointerZone = -1;
+
+	uint32 _lastTickTime = 0;
+	uint16 _inputFlags = 0;
+	bool _isMouseSteering = true;
+	Common::Point _lastMousePos;
+	uint16 _nextScene = kNoScene;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_SNOWMOBILEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 7e9bdd495ef..5664edb8a9e 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -84,6 +84,7 @@ MODULE_OBJS = \
   action/puzzle/setplayerclock.o \
   action/puzzle/sewingmachinepuzzle.o \
   action/puzzle/sliderpuzzle.o \
+  action/puzzle/snowmobilepuzzle.o \
   action/puzzle/sortpuzzle.o \
   action/puzzle/soundequalizerpuzzle.o \
   action/puzzle/soundmatchpuzzle.o \


Commit: 54efc9266dcaae2274ff183d21766e231447c5ec
    https://github.com/scummvm/scummvm/commit/54efc9266dcaae2274ff183d21766e231447c5ec
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:38+03:00

Commit Message:
NANCY: NANCY16: Let the XSheet pick the frames of conversation movies

The frame records of an XSheet no longer name cels. Each one is now 24
bytes long, and holds the movie frame to show for each tree. The body of
a character is a single movie with all of its fidgets, and the XSheet
picks frames out of it, holding, jumping and playing backwards as
needed. A value of -1 leaves the tree playing on its own, which is used
for the lip synced head.

We played the body movie from start to end, so the fidgets ran into
each other, as seen with Yanni in scene 1402.

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 278dabd7642..0156f2fcfff 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -1076,6 +1076,7 @@ void ConversationCel::init() {
 	// talking head, which is played into the tree's rect while the body keeps
 	// animating from cels.
 	_treeMovies.resize(_celRObjects.size());
+	_treeShownFrames.resize(_celRObjects.size(), kMovieFrameFreeRunning);
 	for (uint i = 0; i < _celRObjects.size(); ++i) {
 		// _treeRects is only filled in from Nancy15 on, so earlier games always
 		// take the cel path here
@@ -1095,14 +1096,44 @@ void ConversationCel::init() {
 			continue;
 		}
 
-		movie->start();
 		_treeMovies[i] = movie;
 		_celRObjects[i].moveTo(_treeRects[i]);
+
+		if (isTreeFreeRunning(i)) {
+			movie->start();
+		} else {
+			showMovieFrame(i, _firstFrame);
+		}
 	}
 
 	registerGraphics();
 }
 
+bool ConversationCel::isTreeFreeRunning(uint tree) const {
+	if (tree >= _treeMovieFrames.size() || _firstFrame >= _treeMovieFrames[tree].size()) {
+		return true;
+	}
+
+	return _treeMovieFrames[tree][_firstFrame] == kMovieFrameFreeRunning;
+}
+
+void ConversationCel::showMovieFrame(uint tree, uint xsheetFrame) {
+	if (tree >= _treeMovieFrames.size() || xsheetFrame >= _treeMovieFrames[tree].size()) {
+		return;
+	}
+
+	const int32 movieFrame = _treeMovieFrames[tree][xsheetFrame];
+	if (movieFrame == kMovieFrameFreeRunning || movieFrame == _treeShownFrames[tree]) {
+		return;
+	}
+
+	const Graphics::Surface *frame = _treeMovies[tree]->decodeNextFrame(movieFrame);
+	if (frame) {
+		_celRObjects[tree].setMovieFrame(*frame);
+		_treeShownFrames[tree] = movieFrame;
+	}
+}
+
 void ConversationCel::registerGraphics() {
 	for (uint i = 0; i < _celRObjects.size(); ++i) {
 		_celRObjects[i].setZOrder(9 + _drawingOrder[i]);
@@ -1120,7 +1151,7 @@ void ConversationCel::updateGraphics() {
 	uint32 currentTime = g_nancy->getTotalPlayTime();
 
 	for (uint i = 0; i < _treeMovies.size(); ++i) {
-		if (_treeMovies[i] && _treeMovies[i]->needsUpdate()) {
+		if (_treeMovies[i] && isTreeFreeRunning(i) && _treeMovies[i]->needsUpdate()) {
 			const Graphics::Surface *frame = _treeMovies[i]->decodeNextFrame();
 			if (frame) {
 				_celRObjects[i].setMovieFrame(*frame);
@@ -1131,7 +1162,9 @@ void ConversationCel::updateGraphics() {
 	if (_state == kRun && currentTime > _nextFrameTime && _curFrame < MIN<uint>(_lastFrame + 1, _celNames[0].size())) {
 		for (uint i = 0; i < _celRObjects.size(); ++i) {
 			if (i < _treeMovies.size() && _treeMovies[i]) {
-				// Movie trees run on the movie's own clock
+				// Movie trees run on the movie's own clock, unless the XSheet
+				// picks their frames
+				showMovieFrame(i, _curFrame);
 				continue;
 			}
 
@@ -1271,6 +1304,26 @@ void ConversationCel::readXSheet(Common::SeekableReadStream &stream, const Commo
 	}
 
 	_celNames.resize(numTrees, Common::Array<Common::Path>(numFrames));
+
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// The frame records name a movie frame per tree slot instead of a cel
+		const uint kMaxTrees = 4;
+
+		_treeMovieFrames.resize(numTrees, Common::Array<int32>(numFrames));
+		for (uint i = 0; i < numFrames; ++i) {
+			for (uint j = 0; j < kMaxTrees; ++j) {
+				const int32 movieFrame = xsheet->readSint32LE();
+				if (j < numTrees) {
+					_treeMovieFrames[j][i] = movieFrame;
+				}
+			}
+			xsheet->skip(8);
+		}
+
+		delete xsheet;
+		return;
+	}
+
 	for (uint i = 0; i < numFrames; ++i) {
 		for (uint j = 0; j < numTrees; ++j) {
 			readFilename(*xsheet, _celNames[j][i]);
diff --git a/engines/nancy/action/conversation.h b/engines/nancy/action/conversation.h
index 50383237c06..5a552332c5f 100644
--- a/engines/nancy/action/conversation.h
+++ b/engines/nancy/action/conversation.h
@@ -242,6 +242,17 @@ protected:
 	Common::Array<Common::Rect> _treeRects;
 	Common::Array<Common::SharedPtr<MoviePlayer>> _treeMovies;
 
+	// Nancy16 has no cels left: every tree is a movie, and each XSheet frame holds
+	// the movie frame to show per tree. kMovieFrameFreeRunning leaves the tree
+	// playing on the movie's own clock (the lip-synced head); the body movie is a
+	// library of fidgets the XSheet picks frames from.
+	static const int32 kMovieFrameFreeRunning = -1;
+	Common::Array<Common::Array<int32>> _treeMovieFrames;
+	Common::Array<int32> _treeShownFrames;
+
+	bool isTreeFreeRunning(uint tree) const;
+	void showMovieFrame(uint tree, uint xsheetFrame);
+
 	uint _curFrame = 0;
 	uint32 _nextFrameTime = 0;
 


Commit: 29881b30658aa6a34f5857b30b49d248fd423668
    https://github.com/scummvm/scummvm/commit/29881b30658aa6a34f5857b30b49d248fd423668
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:40+03:00

Commit Message:
NANCY: Stop the character animation when a line is skipped

Skipping a line with Esc only stopped its sound, so the character kept
animating without it. Cel conversations now jump to the last frame of
the line, and movies that play on their own are moved to their last
frame.

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 0156f2fcfff..4aa75fbc912 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -576,6 +576,7 @@ void ConversationSound::skipLine() {
 		// skipped makes the next execute() take the "line has finished" path.
 		g_nancy->_sound->stopSound(_sound);
 		_isSkipped = true;
+		skipVideo();
 		break;
 	case kActionTrigger:
 		// The player's chosen response is playing; cut that short instead
@@ -1338,6 +1339,33 @@ bool ConversationCel::isVideoDonePlaying() {
 	return _curFrame >= MIN<uint>(_lastFrame, _celNames[0].size()) && _nextFrameTime <= g_nancy->getTotalPlayTime();
 }
 
+void ConversationCel::skipVideo() {
+	if (_celNames.empty() || _celNames[0].empty()) {
+		return;
+	}
+
+	// Jump to the last frame of the line; updateGraphics() draws it on its next
+	// pass and then has nothing left to play.
+	const uint lastFrame = MIN<uint>(_lastFrame, _celNames[0].size() - 1);
+	if (_curFrame <= lastFrame) {
+		_curFrame = lastFrame;
+		_nextFrameTime = 0;
+	}
+
+	// Movies on their own clock don't follow the frame counter, so park them
+	// on their last frame as well
+	for (uint i = 0; i < _treeMovies.size(); ++i) {
+		if (!_treeMovies[i] || !isTreeFreeRunning(i) || _treeMovies[i]->endOfVideo()) {
+			continue;
+		}
+
+		const Graphics::Surface *frame = _treeMovies[i]->decodeNextFrame(_treeMovies[i]->getFrameCount() - 1);
+		if (frame) {
+			_celRObjects[i].setMovieFrame(*frame);
+		}
+	}
+}
+
 ConversationCel::Cel &ConversationCel::loadCel(const Common::Path &name, const Common::String &treeName) {
 	// Assumes head and body cels will be named differently
 	if (_celCache.contains(name)) {
diff --git a/engines/nancy/action/conversation.h b/engines/nancy/action/conversation.h
index 5a552332c5f..31f3b056e87 100644
--- a/engines/nancy/action/conversation.h
+++ b/engines/nancy/action/conversation.h
@@ -48,6 +48,8 @@ public:
 	void execute() override;
 
 	virtual bool isVideoDonePlaying() { return true; }
+	// Called when the line gets skipped, so the character stops animating with it
+	virtual void skipVideo() {}
 	bool isViewportRelative() const override { return true; }
 
 	// Enhancement: cut the currently playing line short, as if its sound and
@@ -217,6 +219,7 @@ protected:
 	static const byte kCelOverrideTreeRectsOn	= 2;
 
 	bool isVideoDonePlaying() override;
+	void skipVideo() override;
 	Cel &loadCel(const Common::Path &name, const Common::String &treeName);
 
 	void readXSheet(Common::SeekableReadStream &stream, const Common::String &xsheetName);


Commit: 27599e3873bb3e3f36fd4f882c0ab80f7f825674
    https://github.com/scummvm/scummvm/commit/27599e3873bb3e3f36fd4f882c0ab80f7f825674
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:42+03:00

Commit Message:
NANCY: NANCY17: Read the new boot data

- BSUM moved its fields, and names the inventory file
- ENVS holds the areas of the game with their music and ambient script,
  and replaces MMIX for the music
- INVD lists the inventory items with their scene and picture
- PCUI names the starting character, PUIH the scripts run on becoming
  and leaving a character, and its texts
- The logo chunks lost two of the values in front of the position
- The UI texts are added to the autotext
- The active character's tree goes above the main one, so its
  conversation panels are found

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index ead070f6f9b..a239df2e510 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -44,12 +44,14 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	s.skip(0x49, kGameTypeNancy1, kGameTypeNancy1);
 	s.skip(0x43, kGameTypeNancy2, kGameTypeNancy9);
 	s.skip(0x41, kGameTypeNancy10, kGameTypeNancy15);
-	s.skip(0x55, kGameTypeNancy16);
+	s.skip(0x55, kGameTypeNancy16, kGameTypeNancy16);
+	s.skip(0x51, kGameTypeNancy17);
 
 	readFilename(s, conversationTextsFilename, kGameTypeNancy6);
 	readFilename(s, autotextFilename, kGameTypeNancy6);
 	readFilename(s, fontFilename, kGameTypeNancy12);
 	readFilename(s, flagsFilename, kGameTypeNancy12);
+	readFilename(s, inventoryFilename, kGameTypeNancy17);
 
 	// Nancy16 went back to the pre-Nancy14 field order, and dropped both vertical
 	// offsets and the ad scene
@@ -923,7 +925,8 @@ ImageChunk::ImageChunk(Common::SeekableReadStream *chunkStream) : EngineData(chu
 LogoScreen::LogoScreen(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	readFilename(*chunkStream, imageName);
 	readFilename(*chunkStream, movieName);
-	chunkStream->skip(8);
+	// Nancy17 dropped two of the four values in front of the position
+	chunkStream->skip(g_nancy->getGameType() >= kGameTypeNancy17 ? 4 : 8);
 	position.x = chunkStream->readSint16LE();
 	position.y = chunkStream->readSint16LE();
 
@@ -1495,6 +1498,37 @@ MMIX::MMIX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	}
 }
 
+ENVS::ENVS(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	const uint16 count = chunkStream->readUint16LE();
+	environments.resize(count);
+
+	for (uint16 i = 0; i < count; ++i) {
+		Environment &env = environments[i];
+		readFilename(*chunkStream, env.code);
+		readFilename(*chunkStream, env.name);
+		readFilename(*chunkStream, env.ambientScriptName);
+		chunkStream->skip(33);	// A third name, empty in the game data
+
+		const uint16 numTracks = chunkStream->readUint16LE();
+		readFilenameArray(*chunkStream, env.musicNames, numTracks);
+	}
+}
+
+INVD::INVD(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	const uint16 count = chunkStream->readUint16LE();
+	items.resize(count);
+
+	for (uint16 i = 0; i < count; ++i) {
+		Item &item = items[i];
+		item.id = chunkStream->readSint16LE();
+		readFilename(*chunkStream, item.name);
+		item.kind = chunkStream->readSint32LE();
+		readFilename(*chunkStream, item.sceneName);
+		item.valueIndex = chunkStream->readSint16LE();
+		readExclusiveRect(*chunkStream, item.sourceRect);
+	}
+}
+
 LVLN::LVLN(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	// The count is the total number of strings, which always comes in
 	// (code, name) pairs - hence it must be even.
@@ -1510,7 +1544,15 @@ LVLN::LVLN(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 }
 
 PCUI::PCUI(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
-	flag = chunkStream->readByte();
+	// Nancy17 replaced the leading byte and the per-entry slot bytes with
+	// character names ("Tutor", "Nancy", "Bess"), listed in slot order
+	const bool hasNames = g_nancy->getGameType() >= kGameTypeNancy17;
+
+	if (hasNames) {
+		readFilename(*chunkStream, startCharacterName);
+	} else {
+		flag = chunkStream->readByte();
+	}
 
 	if (g_nancy->getGameType() >= kGameTypeNancy16) {
 		readFilename(*chunkStream, uiName);
@@ -1521,8 +1563,13 @@ PCUI::PCUI(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 
 	for (uint16 i = 0; i < count; ++i) {
 		// Each entry begins with the slot index it populates.
-		const byte slot = chunkStream->readByte();
+		const byte slot = hasNames ? i : chunkStream->readByte();
 		Character &chr = (slot < characters.size()) ? characters[slot] : characters[i];
+
+		if (hasNames) {
+			readFilename(*chunkStream, chr.name);
+		}
+
 		readFilename(*chunkStream, chr.imageName);
 		readFilename(*chunkStream, chr.defaultImageName);
 		chr.id = chunkStream->readUint16LE();
@@ -1543,13 +1590,21 @@ LDSN::LDSN(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 }
 
 PUIH::PUIH(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
-	flag = chunkStream->readByte();
+	// Nancy17 dropped the leading byte
+	if (g_nancy->getGameType() <= kGameTypeNancy16) {
+		flag = chunkStream->readByte();
+	}
+
 	readFilename(*chunkStream, themeName);
 	readFilename(*chunkStream, swatchImageName);
 
-	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+	if (g_nancy->getGameType() == kGameTypeNancy16) {
 		readFilename(*chunkStream, journalPrepName);
 		readFilename(*chunkStream, tasklistPrepName);
+	} else if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		readFilename(*chunkStream, becomeScriptName);
+		readFilename(*chunkStream, leaveScriptName);
+		readFilename(*chunkStream, textsName);
 	}
 }
 
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 559eebb54d9..70a321ae5a4 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -50,6 +50,9 @@ struct BSUM : public EngineData {
 	Common::Path fontFilename;
 	Common::Path flagsFilename;
 
+	// Nancy17+
+	Common::Path inventoryFilename;
+
 	// Game start section
 	SceneChangeDescription firstScene;
 	uint16 startTimeHours;
@@ -932,6 +935,40 @@ struct MMIX : public EngineData {
 	Common::Array<Record> records;
 };
 
+// Environment table. Introduced in Nancy 17, where it replaces both the music
+// mix table and the level name table. Each record describes one location code
+// (e.g. "CUR"): its name ("Curio Shop"), the script with its ambient sounds
+// ("CUR_Amb_Sfx") and the music tracks that may play there.
+struct ENVS : public EngineData {
+	struct Environment {
+		Common::String code;
+		Common::String name;
+		Common::Path ambientScriptName;
+		Common::Array<Common::String> musicNames;
+	};
+
+	ENVS(Common::SeekableReadStream *chunkStream);
+
+	Common::Array<Environment> environments;
+};
+
+// Inventory item table. Introduced in Nancy 17, in the file named by the boot
+// summary. The inventory panel creates one button per item out of it.
+struct INVD : public EngineData {
+	struct Item {
+		int16 id = -1;
+		Common::String name;
+		int32 kind = 0;				// what clicking the item does
+		Common::Path sceneName;		// e.g. "s6200", for the kinds that open a scene
+		int16 valueIndex = 0;
+		Common::Rect sourceRect;
+	};
+
+	INVD(Common::SeekableReadStream *chunkStream);
+
+	Common::Array<Item> items;
+};
+
 // Level name table. Introduced in Nancy 15. Maps a short scene-prefix
 // code (e.g. "KAP") to its human-readable level name (e.g. "Kapu Cave").
 struct LVLN : public EngineData {
@@ -946,6 +983,7 @@ struct LVLN : public EngineData {
 // Each entry supplies the image names for one selectable character.
 struct PCUI : public EngineData {
 	struct Character {
+		Common::String name;				// Nancy 17+, e.g. "Nancy"
 		Common::String imageName;			// e.g. "PUI_CRE_Nancy"
 		Common::String defaultImageName;	// e.g. "PUI_CRE_Nancy_Default"
 		uint16 id = 0;
@@ -954,6 +992,7 @@ struct PCUI : public EngineData {
 	PCUI(Common::SeekableReadStream *chunkStream);
 
 	byte flag = 0;
+	Common::String startCharacterName;		// Nancy 17+, e.g. "Tutor"
 	Common::Path uiName;					// Nancy 16+, e.g. "UI_Main"
 	Common::Array<Character> characters;	// indexed by the on-disk slot byte
 };
@@ -991,9 +1030,15 @@ struct PUIH : public EngineData {
 	Common::String themeName;	// e.g. "Nancy Classic Look (Default)"
 	Common::String swatchImageName;	// e.g. "UI_Swatch_ND"
 
-	// Nancy 16+. Names of the IFFs describing the journal and task list popups
+	// Nancy 16. Names of the IFFs describing the journal and task list popups
 	Common::Path journalPrepName;	// e.g. "Journal_Prep"
 	Common::Path tasklistPrepName;	// e.g. "Tasklist_Prep"
+
+	// Nancy 17+. Scripts run when switching to and away from the character,
+	// and the character's UI texts
+	Common::Path becomeScriptName;	// e.g. "OnBecomeNancy"
+	Common::Path leaveScriptName;	// e.g. "OnLeaveNancy"
+	Common::Path textsName;			// e.g. "UI_Text"
 };
 
 // Task list sounds. Introduced in Nancy 16 (per-character boot). Holds two
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index ed63ac37526..306c19e3ebc 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -270,11 +270,16 @@ bool NancyEngine::setPlayerCharacter(uint characterIndex) {
 	// Every character ships their own copy of the popup UI resources, so the
 	// incoming character's tree has to outrank the ones already loaded
 	if (_resource->readCifTree(treeName, "dat", 1)) {
+		// Nancy17 has the UI itself in the tree of the character the game starts
+		// with, which the others fall back on. It still has to outrank the main
+		// tree: both have a file called "convo".
+		const int basePriority = getGameType() >= kGameTypeNancy17 ? 2 : 1;
+
 		if (!_playerCharacterTree.empty()) {
-			_resource->setCifTreePriority(_playerCharacterTree, 1);
+			_resource->setCifTreePriority(_playerCharacterTree, basePriority);
 		}
 
-		_resource->setCifTreePriority(treeName, 2);
+		_resource->setCifTreePriority(treeName, basePriority + 1);
 	}
 
 	// The UI description chunks live in the character's own boot IFF, e.g.
@@ -322,12 +327,39 @@ bool NancyEngine::setPlayerCharacter(uint characterIndex) {
 
 	delete iff;
 
+	// Nancy17 characters can bring their own UI texts, which join the autotext
+	const PUIH *puih = GetEngineData(PUIH);
+	if (puih && !puih->textsName.empty()) {
+		addAutotext(puih->textsName);
+	}
+
 	_playerCharacter = characterIndex;
 	_playerCharacterTree = treeName;
 
 	return true;
 }
 
+void NancyEngine::addAutotext(const Common::Path &name) {
+	CVTX *autotext = (CVTX *)_engineData.getValOrDefault("AUTOTEXT", nullptr);
+	IFF *iff = _resource->loadIFF(name);
+	if (!autotext || !iff) {
+		delete iff;
+		return;
+	}
+
+	Common::SeekableReadStream *chunkStream = iff->getChunkStream("CVTX");
+	if (chunkStream) {
+		CVTX texts(chunkStream);
+		for (auto &text : texts.texts) {
+			autotext->texts.setVal(text._key, text._value);
+		}
+
+		delete chunkStream;
+	}
+
+	delete iff;
+}
+
 // From Nancy12 the event flags are split into two ranges: 1000 generic engine
 // flags (labels 1000-1999) followed by the game-specific flags (labels from 2000),
 // whose names are listed in the EVNT chunk.
@@ -741,6 +773,11 @@ void NancyEngine::bootGameEngine() {
 	LOAD_BOOT(PCUI)	// Player-character selector (Nancy / Frank / Joe)
 	LOAD_BOOT(LDSN)	// Player-character "Design Select" screen layout
 
+	// Nancy 17
+	// LVLN and MMIX have been merged into the new ENVS chunk, and PCAL and CRED are gone.
+	// The inventory items are back in the game data, in a file of their own.
+	LOAD_BOOT(ENVS)	// Environment table (location code -> name, ambient sounds, music tracks)
+
 	// Nancy 16
 	// Only BSUM, PCUI, LVLN, PCAL, LGx, LOAD, CURS, VIEW and MMIX are left in BOOT.
 	// The LDSN chunk has been removed along with the player-character switcher, and
@@ -782,9 +819,22 @@ void NancyEngine::bootGameEngine() {
 		LOAD_CHUNK(bsum->autotextFilename.toString().c_str(), CVTX, "CVTX", "AUTOTEXT")
 	}
 
+	if (bsum && !bsum->inventoryFilename.empty()) {
+		LOAD_CHUNK(bsum->inventoryFilename.toString().c_str(), INVD, "INVD", "INVD")
+	}
+
 	if (getGameType() >= kGameTypeNancy15) {
-		// The default player character is Nancy, who always occupies the first PCUI slot
-		setPlayerCharacter(0);
+		// The default player character is Nancy, who always occupies the first PCUI slot.
+		// Nancy17 names the starting character instead
+		uint startCharacter = 0;
+		const PCUI *pcui = GetEngineData(PCUI);
+		for (uint i = 0; pcui && i < pcui->characters.size(); ++i) {
+			if (!pcui->startCharacterName.empty() && pcui->characters[i].name.equalsIgnoreCase(pcui->startCharacterName)) {
+				startCharacter = i;
+			}
+		}
+
+		setPlayerCharacter(startCharacter);
 	}
 
 	if (getGameType() >= kGameTypeNancy12) {
@@ -961,7 +1011,8 @@ void NancyEngine::populateStaticData() {
 	case kGameTypeNancy14:
 	case kGameTypeNancy15:
 	case kGameTypeNancy16:
-		// Nancy16 has 40 items, but its cursor table still has room for 49
+	case kGameTypeNancy17:
+		// Nancy16 and Nancy17 have 40 items, but their cursor table still has room for 49
 		_staticData.numItems = 49;
 		_staticData.numCursorTypes = 45;
 		break;
diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index 74cbeecae4b..78b32056a6a 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -124,6 +124,9 @@ public:
 	bool setPlayerCharacter(uint characterIndex);
 	uint getPlayerCharacter() const { return _playerCharacter; }
 
+	// Adds the texts of a text file to the autotext
+	void addAutotext(const Common::Path &name);
+
 	// A character's "design" is the look their UI wears; it names the CIF tree
 	// everything is loaded from. Nancy's can be changed on the Design Select
 	// screen, and defaults to the character's PCUI entry.
diff --git a/engines/nancy/sound.cpp b/engines/nancy/sound.cpp
index 6a25ec1e5ab..76b818e587c 100644
--- a/engines/nancy/sound.cpp
+++ b/engines/nancy/sound.cpp
@@ -263,6 +263,23 @@ SoundManager::~SoundManager() {
 }
 
 Common::String SoundManager::resolveMusicMix(const Common::String &name) const {
+	// Nancy17 keeps the music tracks in the environment table
+	auto *envs = GetEngineData(ENVS);
+	if (envs) {
+		for (const ENVS::Environment &env : envs->environments) {
+			if (env.code.equalsIgnoreCase(name)) {
+				if (env.musicNames.empty()) {
+					return "NO SOUND";
+				}
+
+				uint pick = g_nancy->_randomSource->getRandomNumber(env.musicNames.size() - 1);
+				return env.musicNames[pick];
+			}
+		}
+
+		return name;
+	}
+
 	auto *mmix = GetEngineData(MMIX);
 	if (!mmix) {
 		// Pre-Nancy13, or no mix table present


Commit: 5c09182a3ea653edd526b59136467ab704978568
    https://github.com/scummvm/scummvm/commit/5c09182a3ea653edd526b59136467ab704978568
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:44+03:00

Commit Message:
NANCY: NANCY17: Implement scene streams

Scenes can run side by side with the main one, each with its own
records, background and generic flags. A stream ends with the stream
that started it, or when that one changes scene, depending on how it
was started, and can take the input before the main scene.

- BeginStream (26), EndStream (27) and OnSceneChange (93)
- Scene changes can name the stream they change (21, 25), and the
  types with flags and hotspots are implemented (17, 18, 20)
- Entering an area starts its ambient script as a stream, which runs
  until the player leaves the area
- Switching to a character starts their "become" script as a stream,
  and switching away ends it and starts their "leave" script
- Streams are kept in saved games

Changed paths:
  A engines/nancy/state/scenestream.cpp
  A engines/nancy/state/scenestream.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/navigationrecords.cpp
    engines/nancy/action/navigationrecords.h
    engines/nancy/commontypes.h
    engines/nancy/module.mk
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 3a2b54513f2..c7de4655394 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -123,8 +123,8 @@ namespace Nancy {
 namespace Action {
 
 ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableReadStream *recordStream) {
-	// The types returning nullptr in Nancy16 were changed there, and their new data
-	// hasn't been analyzed yet. This keeps the readers of the older games from
+	// The types returning nullptr in Nancy16 and Nancy17 were changed there, and their
+	// new data hasn't been analyzed yet. This keeps the readers of the older games from
 	// misparsing it.
 	switch (type) {
 	case 10:
@@ -163,12 +163,12 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Hot1FrSceneChange(CursorManager::kMoveUp);
 		else
-			return nullptr;	// TODO: Scene change with frame and event flag
+			return new SceneChangeWithExtras(SceneChangeWithExtras::kSingleFlag);
 	case 18:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Hot1FrSceneChange(CursorManager::kMoveDown);
 		else
-			return nullptr;	// TODO: Scene change with frame and event flags
+			return new SceneChangeWithExtras(SceneChangeWithExtras::kFlagList);
 	case 19:
 		if (g_nancy->getGameType() <= kGameTypeNancy9)
 			return new HotMultiframeSceneChange(CursorManager::kMoveForward);
@@ -184,14 +184,16 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Hot1FrSceneChange(CursorManager::kMoveRight);	// Moved from 23
 		else
-			return nullptr;	// TODO: Scene change with cursor and multiple hotspots
+			return new SceneChangeWithExtras(SceneChangeWithExtras::kHotspots);
 	case 21:
 		if (g_nancy->getGameType() == kGameTypeVampire)
 			return new PaletteNextScene();
 		else if (g_nancy->getGameType() <= kGameTypeNancy9)
 			return new HotMultiframeSceneChange(CursorManager::kMoveDown);
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy16)
 			return new HotSingleFrameSceneChange();
+		else
+			return new SceneChangeWithExtras(SceneChangeWithExtras::kStreamName);
 	case 22:
 		if (g_nancy->getGameType() <= kGameTypeNancy9)
 			return new Hot1FrSceneChange(CursorManager::kMoveLeft);
@@ -221,8 +223,11 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return newRec;
 		} else if (g_nancy->getGameType() <= kGameTypeNancy15) {
 			return new HotMultiframeSceneChange(CursorManager::kMoveDown);		// Moved from 21
+		} else if (g_nancy->getGameType() <= kGameTypeNancy16) {
+			return new SceneChangeWithExtras(SceneChangeWithExtras::kFlagList | SceneChangeWithExtras::kHotspots);
 		} else {
-			return nullptr;	// TODO: Scene change with event flags, cursor and multiple hotspots
+			// Nancy17 added the name of the stream to change the scene of
+			return new SceneChangeWithExtras(SceneChangeWithExtras::kStreamName | SceneChangeWithExtras::kFlagList | SceneChangeWithExtras::kHotspots);
 		}
 	}
 	case 26:
@@ -230,15 +235,19 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new InteractiveVideo();
 		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new HotMultiframeMultiSceneChange();	// Moved from 13
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy16)
 			return nullptr;	// TODO: InvCursorAR
+		else
+			return new BeginStream();
 	case 27:	// Nancy10
 		if (g_nancy->getGameType() <= kGameTypeNancy12)
 			return new HotMultiframeMultiSceneCursorTypeSceneChange();	// Moved from 24
 		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new HotMultiframeInvTypeSceneChange();
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy16)
 			return new HideCursor();
+		else
+			return new EndStream();
 	case 28:	// Nancy10
 		return new InteractiveVideo();	// Moved from 26
 	case 29:	// Nancy10
@@ -386,6 +395,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new EventFlags(EventFlags::kEventFlags);
 	case 91:	// Nancy16
 		return new EventFlagsMultiHS(EventFlagsMultiHS::kCursorHS);
+	case 93:	// Nancy17
+		return new OnSceneChange();
 	case 94:	// Nancy12
 		return new EventFlagsMultiHS(EventFlagsMultiHS::kMultiHS);	// Moved from 106
 	case 95:	// Nancy12
diff --git a/engines/nancy/action/navigationrecords.cpp b/engines/nancy/action/navigationrecords.cpp
index 1ccbe89d081..1fefb3de2e6 100644
--- a/engines/nancy/action/navigationrecords.cpp
+++ b/engines/nancy/action/navigationrecords.cpp
@@ -472,5 +472,103 @@ void MapCallHotMultiframe::execute() {
 	}
 }
 
+void SceneChangeWithExtras::readData(Common::SeekableReadStream &stream) {
+	readDataNancy16(stream);
+
+	if (_extras & kStreamName) {
+		readFilename(stream, _streamName);
+	}
+
+	if (_extras & (kSingleFlag | kFlagList)) {
+		_flags.descs.resize((_extras & kFlagList) ? stream.readUint16LE() : 1);
+		for (FlagDescription &desc : _flags.descs) {
+			desc.label = stream.readSint16LE();
+			desc.flag = stream.readUint16LE();
+		}
+	}
+
+	if (_extras & kHotspots) {
+		_hoverCursor = (CursorManager::CursorType)stream.readUint32LE();
+
+		_hotspots.resize(stream.readUint16LE());
+		for (HotspotDescription &hotspot : _hotspots) {
+			hotspot.readData(stream);
+		}
+	}
+}
+
+void SceneChangeWithExtras::execute() {
+	if ((_extras & kHotspots) && _state != kActionTrigger) {
+		// Wait for a click
+		_state = kRun;
+		_hasHotspot = false;
+
+		for (const HotspotDescription &hotspot : _hotspots) {
+			if (hotspot.frameID == NancySceneState.getSceneInfo().frameID) {
+				_hasHotspot = true;
+				_hotspot = hotspot.coords;
+			}
+		}
+
+		return;
+	}
+
+	_hasHotspot = false;
+
+	if (_extras & kStreamName) {
+		NancySceneState.getStreams().changeScene(_streamName, _sceneChange);
+	} else {
+		NancySceneState.changeScene(_sceneChange);
+	}
+
+	// The flags come with the scene change, and get left alone without one
+	if (_sceneChange.sceneID != kNoScene) {
+		_flags.execute();
+	}
+
+	_isDone = true;
+}
+
+void BeginStream::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _sceneName);
+	_frameID = stream.readUint32LE();
+	readFilename(stream, _streamName);
+	_flags.read(stream);
+}
+
+void BeginStream::execute() {
+	NancySceneState.getStreams().begin(_streamName, _sceneName, _frameID, _flags);
+	_isDone = true;
+}
+
+void EndStream::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _streamName);
+}
+
+void EndStream::execute() {
+	NancySceneState.getStreams().end(_streamName);
+	_isDone = true;
+}
+
+void OnSceneChange::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _streamName);
+	_flag.label = stream.readSint16LE();
+	_flag.flag = stream.readByte();
+}
+
+void OnSceneChange::execute() {
+	const Common::String sceneName = NancySceneState.getStreams().getSceneName(_streamName);
+
+	if (_state == kBegin) {
+		_sceneName = sceneName;
+		_state = kRun;
+	}
+
+	if (!sceneName.equalsIgnoreCase(_sceneName)) {
+		NancySceneState.setEventFlag(_flag);
+		_isDone = true;
+	}
+}
+
 } // End of namespace Action
 } // End of namespace Nancy
diff --git a/engines/nancy/action/navigationrecords.h b/engines/nancy/action/navigationrecords.h
index 45ef16ba657..f73c768fb56 100644
--- a/engines/nancy/action/navigationrecords.h
+++ b/engines/nancy/action/navigationrecords.h
@@ -24,6 +24,8 @@
 
 #include "engines/nancy/action/actionrecord.h"
 
+#include "engines/nancy/state/scenestream.h"
+
 namespace Nancy {
 namespace Action {
 
@@ -285,6 +287,76 @@ protected:
 	Common::String getRecordTypeName() const override { return "MapCallHotMultiframe"; }
 };
 
+// Nancy16+ scene change which can also set event flags, wait for a click on a
+// hotspot, and (Nancy17) change the scene of another stream
+class SceneChangeWithExtras : public SceneChange {
+public:
+	enum Extras {
+		kStreamName	= 1 << 0,
+		kSingleFlag	= 1 << 1,
+		kFlagList	= 1 << 2,
+		kHotspots	= 1 << 3
+	};
+
+	SceneChangeWithExtras(uint extras) : _extras(extras) { _flags.descs.clear(); }
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	CursorManager::CursorType getHoverCursor() const override { return _hoverCursor; }
+	bool canHaveHotspot() const override { return (_extras & kHotspots) != 0; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "SceneChangeWithExtras"; }
+
+	uint _extras;
+	Common::String _streamName;
+	MultiEventFlagDescription _flags;
+	CursorManager::CursorType _hoverCursor = CursorManager::kHotspot;
+	Common::Array<HotspotDescription> _hotspots;
+};
+
+// Nancy17. Starts a scene in a stream of its own, next to the scene that asks for it
+class BeginStream : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "BeginStream"; }
+
+	Common::String _sceneName;	// e.g. "s4409"
+	uint32 _frameID = 0;
+	Common::String _streamName;	// e.g. "BRU_Random_sfx"
+	State::StreamFlags _flags;
+};
+
+// Nancy17. Ends a stream; without a name, the one the record runs in
+class EndStream : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "EndStream"; }
+
+	Common::String _streamName;
+};
+
+// Nancy17. Sets a flag once a stream has moved on to another scene
+class OnSceneChange : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "OnSceneChange"; }
+
+	Common::String _streamName;
+	FlagDescription _flag;
+	Common::String _sceneName;
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index 2d502f896c6..e352bbaa7e6 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -49,6 +49,7 @@ static const int8 kFlagNoLabel						= -1;
 static const int8 kEvNoEvent						= -1;
 static const int8 kFrNoFrame						= -1;
 static const uint16 kNoScene						= 9999;
+static const uint16 kNoSceneNancy17					= 0x7fff;	// what the Nancy17 data has in its place
 
 // Nancy15 alternates between three protagonists (Nancy and the Hardy boys
 // Frank and Joe), each of whom keeps their own UI and journal. The two
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 5664edb8a9e..409b5a818f2 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -137,6 +137,7 @@ MODULE_OBJS = \
   state/map.o \
   state/savedialog.o \
   state/scene.o \
+  state/scenestream.o \
   state/setupmenu.o \
   misc/hypertext.o \
   misc/lightning.o \
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index fe4d5f8d797..c3f75237197 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -811,8 +811,35 @@ void BuildPuzzleData::synchronize(Common::Serializer &ser) {
 	syncInt16Array(ser, zones);
 }
 
+void StreamData::synchronize(Common::Serializer &ser) {
+	uint16 numStreams = streams.size();
+	ser.syncAsUint16LE(numStreams);
+	streams.resize(numStreams);
+
+	for (Stream &stream : streams) {
+		ser.syncAsByte(stream.isMainStream);
+		ser.syncString(stream.name);
+		ser.syncAsByte(stream.lifetime);
+		ser.syncAsByte(stream.capturesInput);
+		ser.syncAsByte(stream.runsFirst);
+		ser.syncAsSint32LE(stream.zOffset);
+		ser.syncString(stream.sceneName);
+		ser.syncAsUint16LE(stream.frameID);
+
+		uint16 numChildren = stream.children.size();
+		ser.syncAsUint16LE(numChildren);
+		stream.children.resize(numChildren);
+		for (Child &child : stream.children) {
+			ser.syncString(child.name);
+			ser.syncAsByte(child.lifetime);
+		}
+	}
+}
+
 PuzzleData *makePuzzleData(const uint32 tag) {
 	switch(tag) {
+	case StreamData::getTag():
+		return new StreamData();
 	case BuildPuzzleData::getTag():
 		return new BuildPuzzleData();
 	case MirrorLightData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 744fcae4ad1..5c7da2a2e06 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -590,6 +590,32 @@ struct BuildPuzzleData : public PuzzleData {
 	Common::Array<int16> zones;		// per zone: numWrong, then one count per ingredient
 };
 
+// Nancy17. The scenes that run in streams next to the main one, so they can be
+// started again after loading
+struct StreamData : public PuzzleData {
+	struct Child {
+		Common::String name;
+		byte lifetime = 0;
+	};
+
+	struct Stream {
+		bool isMainStream = false;
+		Common::String name;
+		byte lifetime = 0;
+		bool capturesInput = false;
+		bool runsFirst = false;
+		int32 zOffset = 0;
+		Common::String sceneName;
+		uint16 frameID = 0;
+		Common::Array<Child> children;
+	};
+
+	static constexpr uint32 getTag() { return MKTAG('S', 'T', 'R', 'M'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::Array<Stream> streams;
+};
+
 PuzzleData *makePuzzleData(const uint32 tag);
 
 } // End of namespace Nancy
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index f06c1308641..96e31ffbf88 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -159,6 +159,7 @@ Scene::~Scene() {
 	delete _viewportFader;
 	delete _ndui;
 
+	_streams.clear();
 	clearPuzzleData();
 }
 
@@ -266,7 +267,19 @@ bool Scene::onStateExit(const NancyState::NancyState nextState) {
 }
 
 void Scene::changeScene(const SceneChangeDescription &sceneDescription) {
-	if (sceneDescription.sceneID == kNoScene || _state == kLoad) {
+	if (sceneDescription.sceneID == kNoScene ||
+			(sceneDescription.sceneID == kNoSceneNancy17 && g_nancy->getGameType() >= kGameTypeNancy17)) {
+		return;
+	}
+
+	// A stream's records change the scene of their own stream
+	SceneStream *runningStream = _streams.getRunning();
+	if (runningStream) {
+		runningStream->changeScene(Common::String::format("s%u", sceneDescription.sceneID), sceneDescription.frameID);
+		return;
+	}
+
+	if (_state == kLoad) {
 		return;
 	}
 
@@ -1056,6 +1069,17 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 	}
 }
 
+// The generic flags are the ones that get cleared with every scene
+static bool isGenericEventFlag(int16 index) {
+	for (uint16 id : g_nancy->getStaticData().genericEventFlags) {
+		if (id == index) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
 int16 Scene::eventFlagToIndex(int16 label) const {
 	// Nancy3+ number their event flags from 1000. Nancy12 then split the flags
 	// into two ranges: the generic engine flags kept the 1xxx numbering, while
@@ -1073,6 +1097,12 @@ int16 Scene::eventFlagToIndex(int16 label) const {
 void Scene::setEventFlag(int16 label, byte flag) {
 	label = eventFlagToIndex(label);
 
+	SceneStream *runningStream = _streams.getRunning();
+	if (runningStream && isGenericEventFlag(label)) {
+		runningStream->setEventFlag(label, flag);
+		return;
+	}
+
 	if (label > kEvNoEvent && (uint)label < g_nancy->getStaticData().numEventFlags) {
 		_flags.eventFlags[label] = flag;
 	}
@@ -1085,6 +1115,11 @@ void Scene::setEventFlag(FlagDescription eventFlag) {
 bool Scene::getEventFlag(int16 label, byte flag) const {
 	label = eventFlagToIndex(label);
 
+	SceneStream *runningStream = _streams.getRunning();
+	if (runningStream && isGenericEventFlag(label)) {
+		return runningStream->getEventFlag(label) == flag;
+	}
+
 	if (label > kEvNoEvent && (uint)label < g_nancy->getStaticData().numEventFlags) {
 		return _flags.eventFlags[label] == flag;
 	} else {
@@ -1445,6 +1480,33 @@ bool Scene::applyPlayerCharacter(uint characterIndex) {
 	// screen while the data they were built from is still around
 	closeActivePopups();
 
+	if (_ndui) {
+		// Nancy17 has a script for a character to take over with, and one to leave
+		// with. The first runs for as long as the character is played.
+		const PUIH *puih = GetEngineData(PUIH);
+		const Common::String becomeScriptName = puih->becomeScriptName.toString();
+		const Common::String leaveScriptName = puih->leaveScriptName.toString();
+
+		if (!g_nancy->setPlayerCharacter(characterIndex)) {
+			return false;
+		}
+
+		initUI();
+
+		if (!becomeScriptName.empty()) {
+			_streams.end(becomeScriptName + "_Stream");
+		}
+
+		if (!leaveScriptName.empty()) {
+			StreamFlags flags;
+			flags.runsFirst = true;
+			_streams.begin(leaveScriptName + "_Stream", leaveScriptName, 0, flags);
+		}
+
+		g_nancy->_graphics->redrawAll();
+		return true;
+	}
+
 	if (!g_nancy->setPlayerCharacter(characterIndex)) {
 		return false;
 	}
@@ -1507,6 +1569,10 @@ void Scene::synchronize(Common::Serializer &ser) {
 	if (_flags.eventFlags.empty())
 		init();
 
+	if (ser.isSaving() && !_streams.isEmpty()) {
+		_streams.store();
+	}
+
 	ser.syncAsUint16LE(_sceneState.currentScene.sceneID);
 	ser.syncAsUint16LE(_sceneState.currentScene.frameID);
 	ser.syncAsUint16LE(_sceneState.currentScene.verticalOffset);
@@ -1525,6 +1591,7 @@ void Scene::synchronize(Common::Serializer &ser) {
 
 		g_nancy->_sound->stopAllSounds();
 
+		_streams.clear();
 		load(true);
 	}
 
@@ -1734,6 +1801,10 @@ void Scene::synchronize(Common::Serializer &ser) {
 			initUI();
 			_ndui->restoreState(((UIStateData *)getPuzzleData(UIStateData::getTag()))->changes);
 		}
+
+		if (!_streams.isEmpty()) {
+			_streams.restore();
+		}
 	}
 
 	_isRunningAd = false;
@@ -1934,6 +2005,8 @@ void Scene::load(bool fromSaveFile) {
 	const bool nextIsNoArt = _sceneState.summary.videoFile == "NO_ART_SCENE";
 
 	clearSceneData(nextIsNoArt);
+
+	_streams.onMainSceneChange();
 	g_nancy->_graphics->suppressNextDraw();
 
 	debugC(0, kDebugScene, "Loading new scene %i: description \"%s\", frame %i, vertical scroll %i, %s",
@@ -1967,8 +2040,15 @@ void Scene::load(bool fromSaveFile) {
 
 	// "NO_ART_SCENE" and (Nancy 11+) "POPUP_PREP_SCENE" are videoless sentinel
 	// scenes that carry only logic ARs; they have no viewport art to load.
+	// Nancy17 scenes with no background leave the viewport empty
+	const bool hasNoBackground = _sceneState.summary.videoFile.equalsIgnoreCase("NO_BG");
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		_viewport.setVisible(!hasNoBackground);
+	}
+
 	if (_sceneState.summary.videoFile != "NO_ART_SCENE" &&
-			_sceneState.summary.videoFile != "POPUP_PREP_SCENE") {
+			_sceneState.summary.videoFile != "POPUP_PREP_SCENE" &&
+			!hasNoBackground) {
 		const Common::Path palettePath = !_sceneState.summary.palettes.empty() ?
 			_sceneState.summary.palettes[(byte)_sceneState.currentScene.paletteID] :
 			Common::Path();
@@ -2023,6 +2103,10 @@ void Scene::load(bool fromSaveFile) {
 		_ndui->onSceneChange(_sceneState.currentScene.sceneID);
 	}
 
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		_streams.changeEnvironment(_sceneState.summary.sound.name);
+	}
+
 	// Re-evaluate taskbar notification states against the new scene.
 	if (_taskbar) {
 		_taskbar->updateNotificationStates(_sceneState.currentScene.sceneID);
@@ -2081,7 +2165,7 @@ void Scene::run() {
 		return;
 	}
 
-	_actionManager.processActionRecords();
+	_streams.process();
 
 	// The records a skipped cinematic set off have started by now; its dialogue
 	// shouldn't go on without it
@@ -2499,7 +2583,10 @@ void Scene::handleInput() {
 		getClock()->handleInput(input);
 	}
 
-	if (!cameraActive) {
+	// The streams in front of the main one get the input first, and may keep it
+	const bool isInputCaptured = _streams.handleInput(input, true);
+
+	if (!cameraActive && !isInputCaptured) {
 		_viewport.handleInput(input);
 	}
 
@@ -2510,8 +2597,9 @@ void Scene::handleInput() {
 		g_nancy->_sound->recalculateSoundEffects();
 	}
 
-	if (!cameraActive) {
+	if (!cameraActive && !isInputCaptured) {
 		_actionManager.handleInput(input);
+		_streams.handleInput(input, false);
 	}
 
 	// The whole Nancy 10+ taskbar (inventory / notebook / cell phone / MENU /
@@ -2629,6 +2717,10 @@ void Scene::initStaticData() {
 		_frame.initBlank();
 		_viewport.init();
 
+		if (g_nancy->getGameType() >= kGameTypeNancy17) {
+			_streams.clear();
+		}
+
 		initUI();
 
 		// The popups of the earlier games go unused, and must never count as open
@@ -2753,9 +2845,20 @@ void Scene::initUI() {
 		}
 	}
 
+	// Nancy17 has no prep records; the script that runs when a character takes
+	// over starts the streams that fill the journal and the task list
 	_uiPrepActions.clearActionRecords();
-	loadUIPrepRecords(puih->journalPrepName);
-	loadUIPrepRecords(puih->tasklistPrepName);
+	if (!puih->journalPrepName.empty()) {
+		loadUIPrepRecords(puih->journalPrepName);
+		loadUIPrepRecords(puih->tasklistPrepName);
+	}
+
+	if (!puih->becomeScriptName.empty()) {
+		StreamFlags flags;
+		flags.runsFirst = true;
+		const Common::String scriptName = puih->becomeScriptName.toString();
+		_streams.begin(scriptName + "_Stream", scriptName, 0, flags);
+	}
 
 	_ndui->applyMatteColor();
 	_ndui->registerGraphics();
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 62095016a94..aceed3eabbb 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -31,6 +31,7 @@
 #include "engines/nancy/action/actionmanager.h"
 
 #include "engines/nancy/state/state.h"
+#include "engines/nancy/state/scenestream.h"
 
 #include "engines/nancy/ui/fullscreenimage.h"
 #include "engines/nancy/ui/viewport.h"
@@ -134,6 +135,11 @@ public:
 	bool isRunningAd() const { return _isRunningAd; }
 
 	void changeScene(const SceneChangeDescription &sceneDescription);
+
+	// Nancy17 streams: scenes that run next to the one the player walks around in,
+	// which is the main stream
+	SceneStreamManager &getStreams() { return _streams; }
+	void addSceneVisit(uint16 sceneID) { _flags.sceneCounts.getOrCreateVal(sceneID)++; }
 	void pushScene(int16 itemID = -1);
 
 	// Nancy16: an item looked at in its own scene leaves the inventory until the
@@ -276,9 +282,12 @@ public:
 	UI::Camera *getCamera() { return _camera; }
 	UI::Taskbar *getTaskbar() { return _taskbar; }
 
-	Action::ActionManager &getActionManager() { return _actionManager; }
+	// The records of a stream get the manager and the scene of their own stream
+	Action::ActionManager &getActionManager() { return _streams.getRunning() ? _streams.getRunning()->getActionManager() : _actionManager; }
+	Action::ActionManager &getMainActionManager() { return _actionManager; }
 
-	SceneChangeDescription &getSceneInfo() { return _sceneState.currentScene; }
+	SceneChangeDescription &getSceneInfo() { return _streams.getRunning() ? _streams.getRunning()->getSceneInfo() : _sceneState.currentScene; }
+	const SceneChangeDescription &getMainSceneInfo() const { return _sceneState.currentScene; }
 	SceneChangeDescription &getNextSceneInfo() { return _sceneState.nextScene; }
 	const SceneSummary &getSceneSummary() const { return _sceneState.summary; }
 
@@ -478,6 +487,9 @@ private:
 	// Nancy16: the records that fill the journal and the task list, which wait for
 	// their conditions for the whole game
 	Action::ActionManager _uiPrepActions;
+
+	// Nancy17: the scenes running next to this one
+	SceneStreamManager _streams;
 	Action::PlaySecondaryMovie *_activeMovie;
 	Action::ConversationSound *_activeConversation;
 
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
new file mode 100644
index 00000000000..1c34a818f02
--- /dev/null
+++ b/engines/nancy/state/scenestream.cpp
@@ -0,0 +1,462 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/iff.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/action/actionrecord.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/state/scenestream.h"
+
+#include "engines/nancy/ui/viewport.h"
+
+namespace Nancy {
+namespace State {
+
+static const uint16 kNoSceneID = 0x7ffe;
+
+void StreamFlags::read(Common::SeekableReadStream &stream) {
+	lifetime = stream.readByte();
+	capturesInput = stream.readByte();
+	runsFirst = stream.readByte();
+	stream.skip(1);	// Unknown
+	zOffset = stream.readSint32LE();
+}
+
+SceneStream::SceneStream(const Common::String &name, const StreamFlags &flags, int32 zOffset) :
+		_name(name),
+		_flags(flags),
+		_zOffset(zOffset) {
+	_sceneInfo.sceneID = kNoSceneID;
+	_genericFlags.resize(g_nancy->getStaticData().genericEventFlags.size());
+	clearScene();
+}
+
+SceneStream::~SceneStream() {
+	clearScene();
+}
+
+bool SceneStream::update() {
+	if (_isEnded) {
+		return false;
+	}
+
+	_actionManager.processActionRecords();
+
+	if (_hasNextScene && !_isEnded) {
+		_hasNextScene = false;
+
+		if (loadScene()) {
+			_actionManager.processActionRecords();
+		} else if (_sceneName.empty()) {
+			// There was nothing to run in the first place
+			_isEnded = true;
+		}
+	}
+
+	return !_isEnded;
+}
+
+void SceneStream::handleInput(NancyInput &input) {
+	if (!_isEnded) {
+		_actionManager.handleInput(input);
+	}
+}
+
+void SceneStream::changeScene(const Common::String &sceneName, uint16 frameID) {
+	// Only the first request made in a frame counts
+	if (_hasNextScene || sceneName.equalsIgnoreCase("s32767")) {
+		return;
+	}
+
+	_hasNextScene = true;
+	_nextSceneName = sceneName;
+	_nextFrameID = frameID;
+}
+
+void SceneStream::setEventFlag(int16 index, byte flag) {
+	const Common::Array<uint16> &genericFlags = g_nancy->getStaticData().genericEventFlags;
+	for (uint i = 0; i < genericFlags.size(); ++i) {
+		if (genericFlags[i] == index) {
+			_genericFlags[i] = flag;
+		}
+	}
+}
+
+byte SceneStream::getEventFlag(int16 index) const {
+	const Common::Array<uint16> &genericFlags = g_nancy->getStaticData().genericEventFlags;
+	for (uint i = 0; i < genericFlags.size(); ++i) {
+		if (genericFlags[i] == index) {
+			return _genericFlags[i];
+		}
+	}
+
+	return g_nancy->_false;
+}
+
+bool SceneStream::loadScene() {
+	IFF *sceneIFF = g_nancy->_resource->loadIFF(Common::Path(_nextSceneName));
+	if (!sceneIFF) {
+		warning("Stream %s: missing scene file %s", _name.c_str(), _nextSceneName.c_str());
+		return false;
+	}
+
+	Common::SeekableReadStream *summaryChunk = sceneIFF->getChunkStream("TSUM");
+	if (!summaryChunk) {
+		warning("Stream %s: scene file %s has no summary", _name.c_str(), _nextSceneName.c_str());
+		delete sceneIFF;
+		return false;
+	}
+
+	Common::Path videoFile;
+	summaryChunk->skip(0x32);	// Description
+	readFilename(*summaryChunk, videoFile);
+	delete summaryChunk;
+
+	// The streams that were started for the outgoing scene end with it
+	const bool hadScene = !_sceneName.empty();
+	clearScene();
+
+	if (hadScene) {
+		for (uint i = 0; i < _children.size(); ) {
+			if (_children[i].lifetime == StreamFlags::kEndsWithParentScene) {
+				NancySceneState.getStreams().end(_children[i].name);
+				_children.remove_at(i);
+			} else {
+				++i;
+			}
+		}
+	}
+
+	_sceneName = _nextSceneName;
+	_sceneInfo.frameID = _nextFrameID;
+
+	// Numbered scenes are named after their number
+	_sceneInfo.sceneID = kNoSceneID;
+	if (_sceneName.size() > 1 && tolower(_sceneName[0]) == 's' && Common::isDigit(_sceneName[1])) {
+		_sceneInfo.sceneID = atoi(_sceneName.c_str() + 1);
+		NancySceneState.addSceneVisit(_sceneInfo.sceneID);
+	}
+
+	debugC(0, kDebugScene, "Stream %s: loading scene %s, frame %u", _name.c_str(), _sceneName.c_str(), _nextFrameID);
+
+	if (!videoFile.empty() && !videoFile.equalsIgnoreCase("NO_BG")) {
+		_background = new UI::Viewport();
+		_background->init();
+		_background->setZOrder(_background->getZOrder() + _zOffset);
+		_background->loadVideo(videoFile, _sceneInfo.frameID, 0, kPanNone, kLargeVideoFormat);
+		_background->registerGraphics();
+	}
+
+	Common::SeekableReadStream *actionRecordChunk = nullptr;
+	for (uint i = 0; actionRecordChunk = sceneIFF->getChunkStream("ACT", i), actionRecordChunk != nullptr; ++i) {
+		_actionManager.addNewActionRecord(*actionRecordChunk, sceneIFF->getChunkSource("ACT", i));
+		delete actionRecordChunk;
+
+		// The records that draw something are stacked along with their stream
+		RenderObject *renderObject = dynamic_cast<RenderObject *>(_actionManager._records.back());
+		if (renderObject && _zOffset) {
+			renderObject->setZOrder(renderObject->getZOrder() + _zOffset);
+		}
+	}
+
+	delete sceneIFF;
+	return true;
+}
+
+void SceneStream::clearScene() {
+	// Nothing of a stream's scene carries over to the next one
+	for (Action::ActionRecord *record : _actionManager._records) {
+		delete record;
+	}
+
+	_actionManager._records.clear();
+	_actionManager.clearActionRecords();
+
+	delete _background;
+	_background = nullptr;
+
+	for (byte &flag : _genericFlags) {
+		flag = g_nancy->_false;
+	}
+}
+
+static const char *const kMainStreamName = "MainStream";
+
+SceneStream *SceneStreamManager::find(const Common::String &name) const {
+	for (SceneStream *stream : _streams) {
+		if (stream && !stream->isEnded() && stream->getName().equalsIgnoreCase(name)) {
+			return stream;
+		}
+	}
+
+	return nullptr;
+}
+
+void SceneStreamManager::begin(const Common::String &name, const Common::String &sceneName, uint16 frameID, const StreamFlags &flags) {
+	if (find(name)) {
+		// Already running
+		return;
+	}
+
+	int32 zOffset = flags.zOffset + (_running ? _running->getZOffset() : 0);
+	SceneStream *stream = new SceneStream(name, flags, zOffset);
+	stream->changeScene(sceneName, frameID);
+
+	if (flags.runsFirst) {
+		_streams.insert_at(0, stream);
+	} else {
+		_streams.push_back(stream);
+	}
+
+	// The stream that starts another one decides how long it lives
+	SceneStream::Child child;
+	child.name = name;
+	child.lifetime = flags.lifetime;
+	if (_running) {
+		_running->_children.push_back(child);
+	} else {
+		_mainStreamChildren.push_back(child);
+	}
+}
+
+void SceneStreamManager::end(const Common::String &name) {
+	SceneStream *stream = name.empty() ? _running : find(name);
+	if (!stream) {
+		return;
+	}
+
+	stream->end();
+	endChildren(stream->_children);
+}
+
+void SceneStreamManager::endChildren(const Common::Array<SceneStream::Child> &children) {
+	for (const SceneStream::Child &child : children) {
+		if (child.lifetime != StreamFlags::kIndependent) {
+			end(child.name);
+		}
+	}
+}
+
+void SceneStreamManager::changeScene(const Common::String &name, const SceneChangeDescription &sceneDescription) {
+	SceneStream *stream = name.empty() ? _running : find(name);
+	if (stream) {
+		stream->changeScene(Common::String::format("s%u", sceneDescription.sceneID), sceneDescription.frameID);
+		return;
+	}
+
+	if (name.empty() || name.equalsIgnoreCase(kMainStreamName)) {
+		SceneStream *running = _running;
+		_running = nullptr;
+		NancySceneState.changeScene(sceneDescription);
+		_running = running;
+	} else {
+		warning("Tried to change the scene of stream %s, which is not running", name.c_str());
+	}
+}
+
+Common::String SceneStreamManager::getSceneName(const Common::String &name) const {
+	if (name.equalsIgnoreCase(kMainStreamName)) {
+		return Common::String::format("s%u", NancySceneState.getMainSceneInfo().sceneID);
+	}
+
+	const SceneStream *stream = name.empty() ? _running : find(name);
+	return stream ? stream->getSceneName() : Common::String();
+}
+
+void SceneStreamManager::process() {
+	if (_streams.empty()) {
+		NancySceneState.getMainActionManager().processActionRecords();
+		return;
+	}
+
+	// Streams begin and end while the records run
+	const Common::Array<SceneStream *> streams = _streams;
+	for (SceneStream *stream : streams) {
+		if (!stream) {
+			NancySceneState.getMainActionManager().processActionRecords();
+			continue;
+		}
+
+		_running = stream;
+		if (!stream->update()) {
+			// Takes the streams that depend on it along
+			endChildren(stream->_children);
+		}
+
+		_running = nullptr;
+	}
+
+	for (uint i = 0; i < _streams.size(); ) {
+		if (_streams[i] && _streams[i]->isEnded()) {
+			delete _streams[i];
+			_streams.remove_at(i);
+		} else {
+			++i;
+		}
+	}
+}
+
+// Returns whether a stream kept the input to itself
+bool SceneStreamManager::handleInput(NancyInput &input, bool beforeMainStream) {
+	const Common::Array<SceneStream *> streams = _streams;
+	bool isBeforeMainStream = true;
+
+	for (SceneStream *stream : streams) {
+		if (!stream) {
+			isBeforeMainStream = false;
+			continue;
+		}
+
+		if (isBeforeMainStream != beforeMainStream) {
+			continue;
+		}
+
+		_running = stream;
+		stream->handleInput(input);
+		_running = nullptr;
+
+		if (stream->getFlags().capturesInput) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+void SceneStreamManager::clear() {
+	for (SceneStream *stream : _streams) {
+		delete stream;
+	}
+
+	_streams.clear();
+	_mainStreamChildren.clear();
+	_environment.clear();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		// The main stream
+		_streams.push_back(nullptr);
+	}
+}
+
+// The streams that were started for the outgoing scene of the main stream end with it
+void SceneStreamManager::onMainSceneChange() {
+	for (uint i = 0; i < _mainStreamChildren.size(); ) {
+		if (_mainStreamChildren[i].lifetime == StreamFlags::kEndsWithParentScene) {
+			end(_mainStreamChildren[i].name);
+			_mainStreamChildren.remove_at(i);
+		} else {
+			++i;
+		}
+	}
+}
+
+// Every environment has a script of its own, with its ambient sounds for example,
+// which runs for as long as the main stream stays in it
+void SceneStreamManager::changeEnvironment(const Common::String &newEnvironment) {
+	if (newEnvironment.equalsIgnoreCase(_environment)) {
+		return;
+	}
+
+	const ENVS *envs = GetEngineData(ENVS);
+	if (!envs) {
+		return;
+	}
+
+	for (const ENVS::Environment &env : envs->environments) {
+		if (env.code.equalsIgnoreCase(_environment)) {
+			end(env.code + "_Enter_Stream");
+		}
+	}
+
+	_environment = newEnvironment;
+
+	for (const ENVS::Environment &env : envs->environments) {
+		if (env.code.equalsIgnoreCase(newEnvironment) && !env.ambientScriptName.empty()) {
+			StreamFlags flags;
+			flags.runsFirst = true;
+			begin(env.code + "_Enter_Stream", env.ambientScriptName.toString(), 0, flags);
+		}
+	}
+}
+
+// Only the scenes of the streams get saved; their records start over
+void SceneStreamManager::store() {
+	StreamData *data = (StreamData *)NancySceneState.getPuzzleData(StreamData::getTag());
+	data->streams.clear();
+
+	for (SceneStream *stream : _streams) {
+		StreamData::Stream saved;
+
+		if (stream) {
+			if (stream->isEnded()) {
+				continue;
+			}
+
+			saved.name = stream->getName();
+			saved.lifetime = stream->getFlags().lifetime;
+			saved.capturesInput = stream->getFlags().capturesInput;
+			saved.runsFirst = stream->getFlags().runsFirst;
+			saved.zOffset = stream->getZOffset();
+			saved.sceneName = stream->getSceneName();
+			saved.frameID = stream->getSceneInfo().frameID;
+			saved.children = stream->_children;
+		} else {
+			saved.isMainStream = true;
+			saved.children = _mainStreamChildren;
+		}
+
+		data->streams.push_back(saved);
+	}
+}
+
+void SceneStreamManager::restore() {
+	const StreamData *data = (const StreamData *)NancySceneState.getPuzzleData(StreamData::getTag());
+
+	for (const StreamData::Stream &saved : data->streams) {
+		if (saved.isMainStream) {
+			_mainStreamChildren = saved.children;
+			continue;
+		}
+
+		// Loading the scene has already started some of them
+		if (find(saved.name) || saved.sceneName.empty()) {
+			continue;
+		}
+
+		StreamFlags flags;
+		flags.lifetime = saved.lifetime;
+		flags.capturesInput = saved.capturesInput;
+		flags.runsFirst = saved.runsFirst;
+
+		SceneStream *stream = new SceneStream(saved.name, flags, saved.zOffset);
+		stream->changeScene(saved.sceneName, saved.frameID);
+		stream->_children = saved.children;
+		_streams.push_back(stream);
+	}
+}
+
+} // End of namespace State
+} // End of namespace Nancy
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
new file mode 100644
index 00000000000..7eacfc4b5d1
--- /dev/null
+++ b/engines/nancy/state/scenestream.h
@@ -0,0 +1,162 @@
+/* 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_STATE_SCENESTREAM_H
+#define NANCY_STATE_SCENESTREAM_H
+
+#include "common/array.h"
+#include "common/str.h"
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/action/actionmanager.h"
+
+namespace Common {
+class SeekableReadStream;
+}
+
+namespace Nancy {
+
+struct NancyInput;
+
+namespace UI {
+class Viewport;
+}
+
+namespace State {
+
+// What a stream gets started with
+struct StreamFlags {
+	enum Lifetime {
+		kIndependent			= 0,
+		kEndsWithParentScene	= 1,	// ...and with the parent itself
+		kEndsWithParent			= 2
+	};
+
+	void read(Common::SeekableReadStream &stream);
+
+	byte lifetime = kIndependent;
+	bool capturesInput = false;	// the streams after this one get no input
+	bool runsFirst = false;		// put in front of the running streams
+	int32 zOffset = 0;			// added to the z order of everything the stream draws
+};
+
+// Nancy17 runs several scenes side by side, each in a stream of its own. The scene
+// the player walks around in is the main stream, and is still handled by the Scene
+// state itself; this is one of the others. Those play the ambient sounds of an
+// environment, fill the journal, show a close-up or a phone call on top of the main
+// scene, and so on. A scene change made by a stream's records only changes the scene
+// of that stream.
+class SceneStream {
+public:
+	typedef StreamData::Child Child;
+
+	// The z offset is the stream's own on top of the one of the stream that started it
+	SceneStream(const Common::String &name, const StreamFlags &flags, int32 zOffset);
+	~SceneStream();
+
+	// Runs the records of the stream's scene, and loads the next scene when one got
+	// requested. Returns false once the stream is over.
+	bool update();
+	void handleInput(NancyInput &input);
+
+	void changeScene(const Common::String &sceneName, uint16 frameID);
+	void end() { _isEnded = true; }
+	bool isEnded() const { return _isEnded; }
+
+	void setEventFlag(int16 index, byte flag);
+	byte getEventFlag(int16 index) const;
+
+	const Common::String &getName() const { return _name; }
+	const StreamFlags &getFlags() const { return _flags; }
+	int32 getZOffset() const { return _zOffset; }
+	const Common::String &getSceneName() const { return _sceneName; }
+	SceneChangeDescription &getSceneInfo() { return _sceneInfo; }
+	Action::ActionManager &getActionManager() { return _actionManager; }
+
+	Common::Array<Child> _children;
+
+private:
+	bool loadScene();
+	void clearScene();
+
+	Common::String _name;
+	StreamFlags _flags;
+	int32 _zOffset;
+
+	Common::String _sceneName;
+	SceneChangeDescription _sceneInfo;
+	Action::ActionManager _actionManager;
+	UI::Viewport *_background = nullptr;
+
+	// Every stream has generic event flags of its own
+	Common::Array<byte> _genericFlags;
+
+	bool _hasNextScene = false;
+	Common::String _nextSceneName;
+	uint16 _nextFrameID = 0;
+
+	bool _isEnded = false;
+};
+
+// The streams running next to the main one. Where a stream gets named, no name
+// stands for the stream whose records are running.
+class SceneStreamManager {
+public:
+	void begin(const Common::String &name, const Common::String &sceneName, uint16 frameID, const StreamFlags &flags);
+	void end(const Common::String &name);
+	void changeScene(const Common::String &name, const SceneChangeDescription &sceneDescription);
+	Common::String getSceneName(const Common::String &name) const;
+	bool isRunning(const Common::String &name) const { return find(name) != nullptr; }
+
+	// The stream whose records are running or getting input; null for the main one
+	SceneStream *getRunning() const { return _running; }
+
+	void process();
+	bool handleInput(NancyInput &input, bool beforeMainStream);
+
+	void clear();
+	bool isEmpty() const { return _streams.empty(); }
+
+	void onMainSceneChange();
+	void changeEnvironment(const Common::String &newEnvironment);
+
+	// Keeps the streams in the saved game, and starts them again after loading
+	void store();
+	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
+	// the scene itself, and takes part as a null entry.
+	Common::Array<SceneStream *> _streams;
+	Common::Array<SceneStream::Child> _mainStreamChildren;
+	SceneStream *_running = nullptr;
+	Common::String _environment;
+};
+
+} // End of namespace State
+} // End of namespace Nancy
+
+#endif // NANCY_STATE_SCENESTREAM_H


Commit: e7f0027d166eadfe6dedfd3da545571e2917aeb8
    https://github.com/scummvm/scummvm/commit/e7f0027d166eadfe6dedfd3da545571e2917aeb8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:47+03:00

Commit Message:
NANCY: NANCY17: Read the new Nancy UI (NDUI) data

- Panels and controls have tooltips, shown after hovering a while
- Control texts are keys into the autotext
- The inventory buttons are made from a template, one per item
- Looking at an item runs its scene as a stream, also when a record
  asks for it (GoInvViewScene)
- The cell phone lists the contacts added by AddPhoneEntry (127), and
  calls them through a stream
- Handle the actions the UI sends to the engine: item buttons send
  their name to Engine_Inv, and its panel takes back the held item;
  Engine_Matte sets the matte color; Engine_CellPhone calls a contact

Changed paths:
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/inventoryrecords.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/action/miscrecords.h
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h
    engines/nancy/state/scene.cpp
    engines/nancy/ui/ndui/button.cpp
    engines/nancy/ui/ndui/button.h
    engines/nancy/ui/ndui/common.h
    engines/nancy/ui/ndui/control.cpp
    engines/nancy/ui/ndui/control.h
    engines/nancy/ui/ndui/element.cpp
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/manager.cpp
    engines/nancy/ui/ndui/manager.h
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index c7de4655394..b06d6d088db 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -481,6 +481,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new PopInvViewPriorScene();
 	case 126:
 		return new GoInvViewScene();
+	case 127:	// Nancy17
+		return new AddPhoneEntry();
 	case 128:	// Nancy10
 		return new CellPhonePopCellSceneFromStack();
 	case 129:	// Nancy10
diff --git a/engines/nancy/action/inventoryrecords.cpp b/engines/nancy/action/inventoryrecords.cpp
index 2d0723cfea5..98e3cd2910d 100644
--- a/engines/nancy/action/inventoryrecords.cpp
+++ b/engines/nancy/action/inventoryrecords.cpp
@@ -28,6 +28,8 @@
 
 #include "engines/nancy/state/scene.h"
 
+#include "engines/nancy/ui/ndui/manager.h"
+
 namespace Nancy {
 namespace Action {
 
@@ -228,10 +230,21 @@ void PopInvViewPriorScene::execute() {
 
 void GoInvViewScene::readData(Common::SeekableReadStream &stream) {
 	_itemID = stream.readUint16LE();
-	_addToInventory = stream.readUint16LE();
+
+	// Nancy17 only names the item
+	if (g_nancy->getGameType() <= kGameTypeNancy16) {
+		_addToInventory = stream.readUint16LE();
+	}
 }
 
 void GoInvViewScene::execute() {
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		// The same as clicking the item in the inventory
+		NancySceneState.getNDUI()->clickInventoryItem(_itemID);
+		_isDone = true;
+		return;
+	}
+
 	auto *inv = GetEngineData(INV);
 	assert(inv);
 
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 5bca28b4585..baa58b594c6 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1334,5 +1334,31 @@ void PlayChar::execute() {
 	finishExecution();
 }
 
+void AddPhoneEntry::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _entry.name);
+	readFilename(stream, _entry.number);
+	readFilename(stream, _entry.sceneName);
+	_entry.nameFlag = stream.readSint16LE();
+}
+
+void AddPhoneEntry::execute() {
+	auto *data = (PhoneBookData *)NancySceneState.getPuzzleData(PhoneBookData::getTag());
+	Common::Array<PhoneBookData::Entry> &entries = data->entries[g_nancy->getPlayerCharacter()];
+
+	bool isNew = true;
+	for (const PhoneBookData::Entry &entry : entries) {
+		if (entry.name.equalsIgnoreCase(_entry.name)) {
+			isNew = false;
+		}
+	}
+
+	if (isNew) {
+		entries.push_back(_entry);
+	}
+
+	NancySceneState.getNDUI()->showPhoneBookEntry(_entry, isNew);
+	_isDone = true;
+}
+
 } // End of namespace Action
 } // End of namespace Nancy
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 45d60da80a2..788e6b27292 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -24,6 +24,7 @@
 
 #include "engines/nancy/action/actionrecord.h"
 #include "engines/nancy/enginedata.h"
+#include "engines/nancy/puzzledata.h"
 
 namespace Nancy {
 
@@ -638,6 +639,18 @@ protected:
 	Common::Path _videoFile;	// replacement scene background, empty to keep the current one
 };
 
+// Nancy17. Adds a contact to the cell phone of the character being played
+class AddPhoneEntry : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "AddPhoneEntry"; }
+
+	PhoneBookData::Entry _entry;
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index c3f75237197..97f448d62c6 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -836,8 +836,33 @@ void StreamData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+Common::String PhoneBookData::Entry::getDisplayText() const {
+	if (nameFlag == -1 || NancySceneState.getEventFlag(nameFlag, g_nancy->_true)) {
+		return name;
+	}
+
+	return number;
+}
+
+void PhoneBookData::synchronize(Common::Serializer &ser) {
+	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
+		uint16 numEntries = entries[i].size();
+		ser.syncAsUint16LE(numEntries);
+		entries[i].resize(numEntries);
+
+		for (Entry &entry : entries[i]) {
+			ser.syncString(entry.name);
+			ser.syncString(entry.number);
+			ser.syncString(entry.sceneName);
+			ser.syncAsSint16LE(entry.nameFlag);
+		}
+	}
+}
+
 PuzzleData *makePuzzleData(const uint32 tag) {
 	switch(tag) {
+	case PhoneBookData::getTag():
+		return new PhoneBookData();
 	case StreamData::getTag():
 		return new StreamData();
 	case BuildPuzzleData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 5c7da2a2e06..658e7b19128 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -616,6 +616,24 @@ struct StreamData : public PuzzleData {
 	Common::Array<Stream> streams;
 };
 
+// Nancy17. The contacts in the cell phone of every player character
+struct PhoneBookData : public PuzzleData {
+	struct Entry {
+		Common::String name;
+		Common::String number;
+		Common::String sceneName;	// the scene that plays the call
+		int16 nameFlag = -1;		// the number shows in place of the name until this is set
+
+		// What the phone lists the contact as
+		Common::String getDisplayText() const;
+	};
+
+	static constexpr uint32 getTag() { return MKTAG('P', 'H', 'B', 'K'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::Array<Entry> entries[kMaxPlayerCharacters];
+};
+
 PuzzleData *makePuzzleData(const uint32 tag);
 
 } // End of namespace Nancy
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 96e31ffbf88..c589a172e9b 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2837,6 +2837,11 @@ void Scene::initUI() {
 	_ndui = new UI::NDUI::Manager();
 	_ndui->load(pcui->uiName.toString());
 
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		_ndui->createItemButtons();
+		_ndui->createPhoneBook();
+	}
+
 	// The most recent items come first
 	const Common::Array<int16> &order = _inventoryBox.getOrder();
 	for (int i = (int)order.size() - 1; i >= 0; --i) {
diff --git a/engines/nancy/ui/ndui/button.cpp b/engines/nancy/ui/ndui/button.cpp
index 47f8e5b13ba..0a426550eb1 100644
--- a/engines/nancy/ui/ndui/button.cpp
+++ b/engines/nancy/ui/ndui/button.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "engines/nancy/nancy.h"
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/ui/ndui/button.h"
@@ -120,15 +121,30 @@ bool Button::getValue(int32 eventType, double &value, Common::String &text) cons
 void InvButton::readExtra(Common::SeekableReadStream &stream) {
 	Button::readExtra(stream);
 
-	_itemID = stream.readSint16LE();
-	_kind = stream.readSint32LE();
-	_scene = stream.readSint32LE();
-	_valueIndex = stream.readSint16LE();
+	if (g_nancy->getGameType() <= kGameTypeNancy16) {
+		_itemID = stream.readSint16LE();
+		_kind = stream.readSint32LE();
+		_scene = stream.readSint32LE();
+		_valueIndex = stream.readSint16LE();
+	}
 
 	// The count gets drawn over the item picture
 	_elements[0].moveFontTo(_elements[1]);
 }
 
+void InvButton::setItem(const INVD::Item &item) {
+	_name = item.name;
+	_itemID = item.id;
+	_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;
+
+	_elements[1].setTexture(_elements[1]._textureName, item.sourceRect);
+}
+
 bool InvButton::doAction(const Action &action) {
 	if (action.type == kActionSetValue) {
 		setText(Common::String::format(" %d ", (int)action.value));
diff --git a/engines/nancy/ui/ndui/button.h b/engines/nancy/ui/ndui/button.h
index bc4137c733a..a101984144b 100644
--- a/engines/nancy/ui/ndui/button.h
+++ b/engines/nancy/ui/ndui/button.h
@@ -22,6 +22,8 @@
 #ifndef NANCY_UI_NDUI_BUTTON_H
 #define NANCY_UI_NDUI_BUTTON_H
 
+#include "engines/nancy/enginedata.h"
+
 #include "engines/nancy/ui/ndui/control.h"
 
 namespace Nancy {
@@ -62,6 +64,9 @@ public:
 
 	InvButton(Panel *panel) : Button(panel) {}
 
+	// Nancy17 buttons are made from a template, and get their item afterwards
+	void setItem(const INVD::Item &item);
+
 	int16 getItemID() const { return _itemID; }
 	int32 getKind() const { return _kind; }
 	uint16 getScene() const { return (uint16)_scene; }
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index 4c8fa520a78..38ecd547ffc 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -47,7 +47,8 @@ enum ControlType {
 	kControlScrollBar	= 9,
 	kControlCheckBox	= 10,
 	kControlInvButton	= 12,
-	kControlVarStatic	= 14
+	kControlVarStatic	= 14,
+	kControlTooltip		= 15	// Nancy17
 };
 
 // The kinds of objects an NDUI chunk describes
diff --git a/engines/nancy/ui/ndui/control.cpp b/engines/nancy/ui/ndui/control.cpp
index 0b5a387459b..5d5053b6b46 100644
--- a/engines/nancy/ui/ndui/control.cpp
+++ b/engines/nancy/ui/ndui/control.cpp
@@ -19,8 +19,14 @@
  *
  */
 
+#include "graphics/font.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
 #include "engines/nancy/util.h"
 
+#include "engines/nancy/state/scene.h"
+
 #include "engines/nancy/ui/ndui/control.h"
 #include "engines/nancy/ui/ndui/panel.h"
 #include "engines/nancy/ui/ndui/manager.h"
@@ -44,6 +50,20 @@ void Control::read(Common::SeekableReadStream &stream) {
 	readFilename(stream, _text);
 	_autoHeight = _bounds.height() == 0;
 
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		readFilename(stream, _tooltip);
+
+		// The texts are the keys of autotext entries, when there are any by their name
+		const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+		if (autotext && autotext->texts.contains(_text)) {
+			_text = autotext->texts[_text];
+		}
+
+		if (autotext && autotext->texts.contains(_tooltip)) {
+			_tooltip = autotext->texts[_tooltip];
+		}
+	}
+
 	_events.resize(stream.readSint16LE());
 	for (Action &event : _events) {
 		event.read(stream);
@@ -213,6 +233,36 @@ bool Static::doAction(const Action &action) {
 	}
 }
 
+void Tooltip::readExtra(Common::SeekableReadStream &stream) {
+	_showAfter = stream.readDoubleLE();
+	_hideAfter = stream.readDoubleLE();
+	_preventFlag = stream.readSint16LE();
+}
+
+bool Tooltip::isPrevented() const {
+	return NancySceneState.getEventFlag(_preventFlag, g_nancy->_true);
+}
+
+void Tooltip::render(Graphics::ManagedSurface &surface, const Common::String &text) {
+	static const int kPadding = 3;
+
+	const Graphics::Font *font = _elements[0].getSystemFont();
+	int width = (font ? font->getStringWidth(text) : 0) + 2 * kPadding;
+	int height = _elements[0].getFontHeight() + 2 * kPadding;
+
+	surface.create(width, height, g_nancy->_graphics->getTransparentPixelFormat());
+	surface.clear(argbToColor(surface.format, _elements[0]._colors[kStateNormal]));
+
+	_text = text;
+	_bounds = Common::Rect(width, height);
+	_visible = true;
+
+	// The colors are there at once
+	update(10.0f);
+	draw(surface, Common::Point());
+	_visible = false;
+}
+
 void VarStatic::readExtra(Common::SeekableReadStream &stream) {
 	_width = stream.readSint32LE();
 	_precision = stream.readSint32LE();
diff --git a/engines/nancy/ui/ndui/control.h b/engines/nancy/ui/ndui/control.h
index 5dadb78f6ad..c9dd3ee4539 100644
--- a/engines/nancy/ui/ndui/control.h
+++ b/engines/nancy/ui/ndui/control.h
@@ -64,6 +64,7 @@ public:
 
 	const Common::String &getName() const { return _name; }
 	void setName(const Common::String &name) { _name = name; }
+	const Common::String &getTooltip() const { return _tooltip; }
 	bool isVisible() const { return _visible; }
 	bool isEnabled() const { return _enabled; }
 	int32 getInitialState() const { return _initialState; }
@@ -98,6 +99,7 @@ protected:
 	Panel *_panel;
 	Common::String _name;
 	Common::String _text;
+	Common::String _tooltip;	// Nancy17
 	int32 _initialState = kStartVisible;
 	bool _visible = true;
 	bool _enabled = true;
@@ -118,6 +120,27 @@ protected:
 	bool doAction(const Action &action) override;
 };
 
+// Nancy17. Every panel has one, to show the tooltip of the control under the mouse.
+// It comes up once the mouse has been over the control for a while, and gives up
+// after another while.
+class Tooltip : public Static {
+public:
+	Tooltip(Panel *panel) : Static(panel) {}
+
+	bool isTimeToShow(float hoverSeconds) const { return hoverSeconds >= _showAfter && hoverSeconds <= _hideAfter; }
+	bool isPrevented() const;
+
+	// Draws the tooltip into a surface that gets just large enough for it
+	void render(Graphics::ManagedSurface &surface, const Common::String &text);
+
+protected:
+	void readExtra(Common::SeekableReadStream &stream) override;
+
+	double _showAfter = 0;
+	double _hideAfter = 0;
+	int16 _preventFlag = -1;
+};
+
 // A static that shows a number
 class VarStatic : public Static {
 public:
diff --git a/engines/nancy/ui/ndui/element.cpp b/engines/nancy/ui/ndui/element.cpp
index 96972a818aa..94d5d9fcd33 100644
--- a/engines/nancy/ui/ndui/element.cpp
+++ b/engines/nancy/ui/ndui/element.cpp
@@ -101,6 +101,10 @@ void Element::read(Common::SeekableReadStream &stream) {
 	_fontColors[kStateDisabled] = stream.readUint32BE();
 	_fontColors[kStateMouseOver] = stream.readUint32BE();
 	_fontColors[kStatePressed] = stream.readUint32BE();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		stream.skip(1);	// A flag of the control, taken from its first element
+	}
 }
 
 void Element::setTexture(const Common::String &textureName, const Common::Rect &src) {
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index 1cb5c16d89d..d5929927ab9 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -81,7 +81,14 @@ void Manager::takeEngineAction(const Action &action) {
 			}
 		}
 	} else if (action.target.equalsIgnoreCase("Engine_Inv")) {
-		if (action.type == kActionSetValue) {
+		if (g_nancy->getGameType() >= kGameTypeNancy17) {
+			// The buttons send their name; the panel behind them only takes a held item back
+			if (action.type == kActionSetText) {
+				onInventoryClicked((int16)action.value);
+			} else if (action.type == kActionSetValue) {
+				NancySceneState.addItemToInventory(NancySceneState.getHeldItem());
+			}
+		} else if (action.type == kActionSetValue) {
 			onInventoryClicked((int16)action.value);
 		}
 	} else if (action.target.equalsIgnoreCase("Engine_Convo")) {
@@ -101,6 +108,13 @@ void Manager::takeEngineAction(const Action &action) {
 		onSaveQueryAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_GameWindow")) {
 		onGameWindowAction(action);
+	} else if (action.target.equalsIgnoreCase("Engine_Matte")) {
+		// Nancy17 gave the matte color a target of its own
+		if (action.type == kActionSetValue || action.type == kActionQuery) {
+			onGameWindowAction(action);
+		}
+	} else if (action.target.equalsIgnoreCase("Engine_CellPhone")) {
+		onCellPhoneAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_VoiceVolume")) {
 		onVolumeAction(action, "speech_volume");
 	} else if (action.target.equalsIgnoreCase("Engine_FXVolume")) {
@@ -130,6 +144,11 @@ void Manager::onInventoryClicked(int16 itemID) {
 		return;
 	}
 
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		onItemStreamClicked(*button);
+		return;
+	}
+
 	SceneChangeDescription scene;
 	scene.sceneID = button->getScene();
 	scene.continueSceneSound = kContinueSceneSound;
@@ -150,6 +169,95 @@ void Manager::onInventoryClicked(int16 itemID) {
 	}
 }
 
+// Nancy17 shows the scene of an item in a stream of its own, on top of the scene
+// the player is in. That scene ends its stream, and puts back an item that was
+// taken out to be looked at.
+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());
+
+	// On top of everything the scenes below draw
+	static const int32 kItemStreamZOffset = 15;
+
+	State::StreamFlags flags;
+	flags.capturesInput = true;
+	flags.runsFirst = true;
+	flags.zOffset = kItemStreamZOffset;
+
+	switch (button.getKind()) {
+	case InvButton::kItemView: {
+		// One item is looked at at a time
+		const INVD *items = GetEngineData(INVD);
+		for (const INVD::Item &item : items->items) {
+			if (item.kind == InvButton::kItemView && item.id != itemID) {
+				NancySceneState.getStreams().end("INVSTR_" + item.name);
+			}
+		}
+
+		if (!NancySceneState.getStreams().isRunning(streamName)) {
+			NancySceneState.getStreams().begin(streamName, sceneName, 0, flags);
+			NancySceneState.removeItemFromInventory(itemID, false);
+		}
+
+		break;
+	}
+	case InvButton::kItemOpenCountedScene:
+	case InvButton::kItemOpenScene:
+		if (!NancySceneState.getStreams().isRunning(streamName)) {
+			NancySceneState.getStreams().begin(streamName, sceneName, 0, flags);
+		}
+
+		break;
+	default:
+		NancySceneState.removeItemFromInventory(itemID, true);
+		break;
+	}
+}
+
+// The contacts are buttons made from a template, named after the contact
+static const char *const kPhoneBookEntryPrefix = "PB:";
+
+void Manager::showPhoneBookEntry(const PhoneBookData::Entry &entry, bool isNew) {
+	const Common::String buttonName = kPhoneBookEntryPrefix + entry.name;
+
+	if (isNew) {
+		createObject(buttonName, "PhoneBookEntry", "CellPhoneDialog");
+	}
+
+	takeAction(kActionSetText, buttonName, entry.getDisplayText());
+}
+
+void Manager::createPhoneBook() {
+	auto *data = (PhoneBookData *)NancySceneState.getPuzzleData(PhoneBookData::getTag());
+	for (const PhoneBookData::Entry &entry : data->entries[g_nancy->getPlayerCharacter()]) {
+		showPhoneBookEntry(entry, true);
+	}
+}
+
+// Picking a contact calls them: the call is a scene in a stream of its own, and
+// the phone can't be used until that scene enables it again
+void Manager::onCellPhoneAction(const Action &action) {
+	if (action.type != kActionSetValue) {
+		return;
+	}
+
+	auto *data = (PhoneBookData *)NancySceneState.getPuzzleData(PhoneBookData::getTag());
+	for (const PhoneBookData::Entry &entry : data->entries[g_nancy->getPlayerCharacter()]) {
+		if (!action.text.equalsIgnoreCase(kPhoneBookEntryPrefix + entry.name)) {
+			continue;
+		}
+
+		takeAction(kActionDisable, "CellPhoneDialog", 0.0);
+
+		State::StreamFlags flags;
+		flags.capturesInput = true;
+		flags.runsFirst = true;
+		NancySceneState.getStreams().begin("CellPhoneStream", entry.sceneName, 0, flags);
+		return;
+	}
+}
+
 // An item that was taken out to be looked at goes back once its scene is left
 void Manager::onSceneChange(uint16 sceneID) {
 	int16 viewedItem = NancySceneState.getPushedInvItemID();
@@ -163,6 +271,14 @@ void Manager::onSceneChange(uint16 sceneID) {
 	}
 }
 
+// Nancy17 makes the buttons of the inventory out of the item table
+void Manager::createItemButtons() {
+	Panel *panel = findPanel("InvDialog");
+	if (panel && _templates.contains("InvButton")) {
+		panel->addItemButtons(_templates["InvButton"]);
+	}
+}
+
 void Manager::showInventoryItem(int16 itemID, bool show) {
 	takeAction(show ? kActionShowItem : kActionHideItem, "InvDialog", (double)itemID);
 }
diff --git a/engines/nancy/ui/ndui/manager.cpp b/engines/nancy/ui/ndui/manager.cpp
index d3586ffc185..6f777e905e5 100644
--- a/engines/nancy/ui/ndui/manager.cpp
+++ b/engines/nancy/ui/ndui/manager.cpp
@@ -27,6 +27,7 @@
 #include "engines/nancy/iff.h"
 #include "engines/nancy/input.h"
 #include "engines/nancy/cursor.h"
+#include "engines/nancy/graphics.h"
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/ui/ndui/manager.h"
@@ -95,6 +96,51 @@ void Manager::registerGraphics() {
 	for (Panel *panel : _panels) {
 		panel->registerGraphics();
 	}
+
+	_tooltipWindow.setNativeResolution(true);
+	_tooltipWindow.setVisible(false);
+	_tooltipWindow.registerGraphics();
+}
+
+void Manager::setHoveredTooltip(const Control *control, Tooltip *tooltip) {
+	_hoveredControl = control;
+	_hoveredTooltip = tooltip;
+}
+
+void Manager::updateTooltip(const Common::Point &mousePos) {
+	const uint32 now = g_nancy->getTotalPlayTime();
+
+	if (_hoveredControl != _tooltipControl) {
+		// Another control starts over
+		_tooltipControl = _hoveredControl;
+		_tooltipHoverStart = now;
+		_isTooltipDismissed = false;
+	}
+
+	bool show = _tooltipControl && !_isTooltipDismissed && !_hoveredTooltip->isPrevented() &&
+		_hoveredTooltip->isTimeToShow((now - _tooltipHoverStart) / 1000.0f);
+
+	if (show && _tooltipWindow.isVisible()) {
+		// The mouse getting to the tooltip takes it down for good
+		if (_tooltipWindow.getScreenPositionRaw().contains(mousePos)) {
+			_isTooltipDismissed = true;
+			show = false;
+		}
+	} else if (show) {
+		_hoveredTooltip->render(_tooltipWindow._drawSurface, _tooltipControl->getTooltip());
+
+		// Above the mouse, and inside of the screen
+		const int width = _tooltipWindow._drawSurface.w;
+		const int height = _tooltipWindow._drawSurface.h;
+		const Common::Rect screen = g_nancy->_graphics->getScreen()->getBounds();
+		int x = MAX<int>(MIN<int>(mousePos.x + 1, screen.right - width - 7), 5);
+		int y = MAX<int>(MIN<int>(mousePos.y - height - 6, screen.bottom - height - 7), 5);
+
+		_tooltipWindow.moveTo(Common::Rect(x, y, x + width, y + height));
+		_tooltipWindow.setNeedsRedraw(true);
+	}
+
+	_tooltipWindow.setVisible(show);
 }
 
 void Manager::takeAction(const Action &action) {
@@ -213,6 +259,8 @@ void Manager::handleInput(NancyInput &input) {
 		}
 	}
 
+	_hoveredControl = nullptr;
+
 	// The panels drawn on top get the mouse first
 	bool isOverUI = false;
 	bool isOverControl = false;
@@ -224,6 +272,8 @@ void Manager::handleInput(NancyInput &input) {
 		}
 	}
 
+	updateTooltip(input.screenMousePos);
+
 	if (isOverUI) {
 		g_nancy->_cursor->setCursorType((CursorManager::CursorType)kUICursor, true, isOverControl);
 		input.eatMouseInput();
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 4c43cfc0482..36ed9b8fb4e 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -25,6 +25,9 @@
 #include "common/array.h"
 #include "common/hash-str.h"
 
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/renderobject.h"
+
 #include "engines/nancy/ui/ndui/common.h"
 
 namespace Graphics {
@@ -39,6 +42,8 @@ namespace UI {
 namespace NDUI {
 
 class Panel;
+class Control;
+class Tooltip;
 class InvButton;
 
 // Something outside the UI that answers the actions sent to an "Engine_" target of its
@@ -64,6 +69,8 @@ public:
 // get created while the game runs, like journal entries and conversation responses.
 class Manager {
 public:
+	static const uint16 kTooltipZ = 2000;
+
 	// 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
 	enum RequestType {
@@ -93,6 +100,9 @@ public:
 	void handleInput(NancyInput &input);
 	void registerGraphics();
 
+	// Nancy17: the control under the mouse that has a tooltip, and the panel's control that shows it
+	void setHoveredTooltip(const Control *control, Tooltip *tooltip);
+
 	const Graphics::ManagedSurface *getTexture(const Common::String &name);
 
 	// Creates a control from a template, as the last one of a panel
@@ -103,6 +113,12 @@ public:
 	void update();
 	void setCaption(const Common::String &text);
 	void setPlayerTime(uint32 minutesOfDay, bool isDaytime);
+	void createItemButtons();
+	void clickInventoryItem(int16 itemID) { onInventoryClicked(itemID); }
+
+	// Nancy17 cell phone
+	void createPhoneBook();
+	void showPhoneBookEntry(const PhoneBookData::Entry &entry, bool isNew);
 	void showInventoryItem(int16 itemID, bool show);
 	void onSceneChange(uint16 sceneID);
 	void addJournalEntry(const Common::String &pageName, const Common::String &textKey);
@@ -147,6 +163,8 @@ private:
 	void onSaverAction(const Action &action);
 	void onSaveQueryAction(const Action &action);
 	void onGameWindowAction(const Action &action);
+	void onCellPhoneAction(const Action &action);
+	void onItemStreamClicked(const InvButton &button);
 	void onVolumeAction(const Action &action, const char *configKey);
 	void onFlagsAction(const Action &action);
 	void askToSaveFirst(const Action &action);
@@ -162,6 +180,16 @@ private:
 	Common::HashMap<Common::String, Graphics::ManagedSurface *, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _textures;
 	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _templates;
 
+	void updateTooltip(const Common::Point &mousePos);
+
+	// Nancy17 tooltips are drawn on top of every panel
+	RenderObject _tooltipWindow{kTooltipZ};
+	const Control *_hoveredControl = nullptr;
+	Tooltip *_hoveredTooltip = nullptr;
+	const Control *_tooltipControl = nullptr;
+	uint32 _tooltipHoverStart = 0;
+	bool _isTooltipDismissed = false;
+
 	Common::Array<Common::String> _clockDisplays;
 	Common::Array<ValueDisplay> _valueDisplays;
 
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index d795cb1c7de..c59a25509ca 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -24,6 +24,8 @@
 #include "engines/nancy/input.h"
 #include "engines/nancy/util.h"
 
+#include "common/memstream.h"
+
 #include "engines/nancy/ui/ndui/panel.h"
 #include "engines/nancy/ui/ndui/control.h"
 #include "engines/nancy/ui/ndui/button.h"
@@ -46,6 +48,7 @@ Panel::Panel(Manager *manager, uint16 z, byte kind) : RenderObject(z), _manager(
 
 Panel::~Panel() {
 	delete _background;
+	delete _tooltip;
 
 	for (Control *control : _controls) {
 		delete control;
@@ -62,7 +65,7 @@ void Panel::read(Common::SeekableReadStream &stream) {
 	_spacing.y = stream.readSint32LE();
 	readExclusiveRect(stream, _margins);
 	_backgroundColor = stream.readUint32BE();
-	stream.skip(1);
+	stream.skip(g_nancy->getGameType() >= kGameTypeNancy17 ? 2 : 1);
 
 	// The background is a static that also gives the panel its name, position and events
 	_background = new Static(this);
@@ -70,6 +73,11 @@ void Panel::read(Common::SeekableReadStream &stream) {
 		_background->read(stream);
 	}
 
+	if (g_nancy->getGameType() >= kGameTypeNancy17 && stream.readSint32LE() == kControlTooltip) {
+		_tooltip = new Tooltip(this);
+		_tooltip->read(stream);
+	}
+
 	int16 numControls = stream.readSint16LE();
 	for (int16 i = 0; i < numControls; ++i) {
 		readControl(stream);
@@ -157,6 +165,28 @@ Control *Panel::addNewControl(int32 type, Common::SeekableReadStream &stream) {
 	return control;
 }
 
+void Panel::addItemButtons(const Common::Array<byte> &buttonTemplate) {
+	const INVD *items = GetEngineData(INVD);
+	if (!items) {
+		return;
+	}
+
+	_kind = kObjectInvPanel;
+
+	for (const INVD::Item &item : items->items) {
+		Common::MemoryReadStream stream(buttonTemplate.data(), buttonTemplate.size());
+		stream.skip(33 + 4); // version and control type
+
+		InvButton *button = new InvButton(this);
+		button->read(stream);
+		button->setItem(item);
+		button->setVisible(false);
+		addControl(button);
+	}
+
+	arrangeControls();
+}
+
 // Controls are numbered in the order they get added; inventory buttons after their item
 void Panel::addControl(Control *control) {
 	if (_kind == kObjectInvPanel) {
@@ -563,6 +593,10 @@ bool Panel::handleInput(NancyInput &input, bool canHover, bool &isOverControl) {
 		return false;
 	}
 
+	if (hovered && _tooltip && !hovered->getTooltip().empty()) {
+		_manager->setHoveredTooltip(hovered, _tooltip);
+	}
+
 	int notches = 0;
 	if (input.input & NancyInput::kMouseWheelUp) {
 		notches = 1;
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index a26aab6bd80..7eeb224000d 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -39,6 +39,7 @@ class Static;
 class RadioButton;
 class EditBox;
 class InvButton;
+class Tooltip;
 
 // A window of the UI, like the inventory, the journal or the bars around the viewport.
 // Each NDUI chunk describes one panel: its background, which is a Static that also gives
@@ -54,6 +55,11 @@ public:
 
 	void read(Common::SeekableReadStream &stream);
 
+	// 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);
+	Tooltip *getTooltip() const { return _tooltip; }
+
 	void updateGraphics() override;
 
 	// Returns true when the action was meant for this panel or one of its controls
@@ -111,6 +117,7 @@ private:
 
 	uint32 _backgroundColor = 0;
 	Static *_background = nullptr;
+	Tooltip *_tooltip = nullptr;
 	Common::Array<Control *> _controls;
 	Common::Array<Control *> _arrangeOrder;
 	Common::Array<RadioButton *> _radioButtons;


Commit: 3439ccb69a649671aed531b789efaaef43f10d7a
    https://github.com/scummvm/scummvm/commit/3439ccb69a649671aed531b789efaaef43f10d7a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:48+03:00

Commit Message:
NANCY: NANCY17: Handle the changed records

- Adding and removing inventory items name the character the item
  belongs to
- Movies and random movies lost their video format field, and mark
  "no scene change" with a different value
- Movie control (46) has a list of names after its mode
- Conversations always list the sound files of a line
- Textbox texts are also looked up in the conversation texts
- PlayChar (134) picks the character by name
- HeldItemFlags (28): a hotspot that sets a different flag for every
  item it gets clicked with
- HideCursor moved to 29, and UIControl to 34

Changed paths:
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/conversation.cpp
    engines/nancy/action/inventoryrecords.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/action/miscrecords.h
    engines/nancy/action/secondarymovie.cpp


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index b06d6d088db..2210592a4d6 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -249,18 +249,25 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new EndStream();
 	case 28:	// Nancy10
-		return new InteractiveVideo();	// Moved from 26
+		if (g_nancy->getGameType() <= kGameTypeNancy16)
+			return new InteractiveVideo();	// Moved from 26
+		else
+			return new HeldItemFlags();	// Moved from 26
 	case 29:	// Nancy10
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new ControlUIItems();
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy16)
 			return new UIControl();
+		else
+			return new HideCursor();	// Moved from 27
 	case 30:	// Nancy11
 		return new StopPlayerScrolling();
 	case 31:	// Nancy11
 		return new StartPlayerScrolling();
 	case 32:	// Nancy10
 		return new UIPopupPrepScene();
+	case 34:	// Nancy17
+		return new UIControl();	// Moved from 29
 	case 35:	// Nancy12
 		return new ConversationInfoCheck();
 	case 36:	// Nancy12
@@ -283,13 +290,17 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 46:	// Nancy11
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new PlayRandomMovieControl();
+		else if (g_nancy->getGameType() <= kGameTypeNancy16)
+			return nullptr;	// Not used by the game
 		else
-			return nullptr;	// TODO: Movie control
+			return new PlayRandomMovieControl();
 	case 47:	// Nancy14
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new PlaySecondaryMovie(PlaySecondaryMovie::kInteractiveMovie);
+		else if (g_nancy->getGameType() <= kGameTypeNancy16)
+			return nullptr;	// Not used by the game
 		else
-			return nullptr;	// TODO
+			return new PlaySecondaryMovie(PlaySecondaryMovie::kInteractiveMovie);
 	case 48:	// Nancy16
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kMovieWithRandomPosition);
 	case 50:
@@ -503,8 +514,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// dual-protagonist mechanic new to The Creature of Kapu Cave
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new PlayChar();
+		else if (g_nancy->getGameType() <= kGameTypeNancy16)
+			return nullptr;	// Nancy16 has a single player character
 		else
-			return nullptr;
+			return new PlayChar();	// Nancy17 switches between Nancy and Bess
 	case 140:
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new SetVolume();			// Moved to 149 in Nancy9, empty slot in Nancy9-11
diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 4aa75fbc912..6eaf86c214a 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -181,13 +181,18 @@ static Common::String resolveConversationText(const Common::String &key) {
 }
 
 void ConversationSound::readDataNancy13(Common::SeekableReadStream &stream) {
-	readFilename(stream, _sound.name);
+	// Nancy17 always lists the sound files of the line after their count
+	const bool hasSoundList = g_nancy->getGameType() >= kGameTypeNancy17;
+	if (!hasSoundList) {
+		readFilename(stream, _sound.name);
+	}
+
 	_sound.channelID = 12;	// hardcoded, as in the terse variants
 	_sound.numLoops = 1;
 
 	// A sound name of "CONCAT" (Nancy14+) means the line is split across several
 	// sound files, which follow after their count. The original allows 5 at most.
-	if (_sound.name.equalsIgnoreCase("CONCAT")) {
+	if (hasSoundList || _sound.name.equalsIgnoreCase("CONCAT")) {
 		const uint16 numSounds = stream.readUint16LE();
 		_concatSounds.resize(numSounds);
 		for (uint i = 0; i < numSounds; ++i) {
@@ -221,6 +226,10 @@ void ConversationSound::readDataNancy13(Common::SeekableReadStream &stream) {
 	_sceneChange.frameID = stream.readUint16LE();
 	_sceneChange.continueSceneSound = kContinueSceneSound;
 
+	if (_sceneChange.sceneID == kNoSceneNancy17 && g_nancy->getGameType() >= kGameTypeNancy17) {
+		_sceneChange.sceneID = kNoScene;
+	}
+
 	// Caption and response texts are external, keyed by sound name.
 	// Each part of a concatenated line has its own caption; they make up one
 	// exchange, so they are shown together.
diff --git a/engines/nancy/action/inventoryrecords.cpp b/engines/nancy/action/inventoryrecords.cpp
index 98e3cd2910d..6fb685c34b0 100644
--- a/engines/nancy/action/inventoryrecords.cpp
+++ b/engines/nancy/action/inventoryrecords.cpp
@@ -33,10 +33,29 @@
 namespace Nancy {
 namespace Action {
 
+// Nancy17 names the character an item belongs to; anything that isn't a
+// character's name stands for whoever is being played
+static byte readCharacterName(Common::SeekableReadStream &stream) {
+	Common::String name;
+	readFilename(stream, name);
+
+	const PCUI *pcui = GetEngineData(PCUI);
+	for (uint i = 0; pcui && i < pcui->characters.size(); ++i) {
+		if (pcui->characters[i].name.equalsIgnoreCase(name)) {
+			return i;
+		}
+	}
+
+	return kPlayerCharacterActive;
+}
+
 void AddInventoryNoHS::readData(Common::SeekableReadStream &stream) {
 	_itemID = stream.readUint16LE();
 
-	if (g_nancy->getGameType() >= kGameTypeNancy15) {
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		_characterIndex = readCharacterName(stream);
+		_setCursor = stream.readByte();
+	} else if (g_nancy->getGameType() >= kGameTypeNancy15) {
 		// The character the item goes to took the first half of the cursor flag
 		_characterIndex = stream.readByte();
 		_setCursor = stream.readByte();
@@ -63,7 +82,9 @@ void AddInventoryNoHS::execute() {
 void RemoveInventoryNoHS::readData(Common::SeekableReadStream &stream) {
 	_itemID = stream.readUint16LE();
 
-	if (g_nancy->getGameType() >= kGameTypeNancy15) {
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		_characterIndex = readCharacterName(stream);
+	} else if (g_nancy->getGameType() >= kGameTypeNancy15) {
 		_characterIndex = stream.readByte();
 	}
 }
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index baa58b594c6..8a034a6373f 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -131,6 +131,12 @@ static void readTextboxText(Common::SeekableReadStream &stream, Common::String &
 		assert(autotext);
 
 		out = autotext->texts.getValOrDefault(stringID, "");
+
+		// The spoken lines have their captions with the conversation texts
+		const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
+		if (out.empty() && convo) {
+			out = convo->texts.getValOrDefault(stringID, "");
+		}
 	} else if (size > 0) {
 		char *buf = new char[size];
 		stream.read(buf, size);
@@ -1297,6 +1303,22 @@ void ResourceUse::execute() {
 }
 
 void PlayChar::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		// Nancy17 names the character, and has no background to swap in
+		Common::String name;
+		readFilename(stream, name);
+
+		_characterIndex = kPlayerCharacterActive;
+		const PCUI *pcui = GetEngineData(PCUI);
+		for (uint i = 0; pcui && i < pcui->characters.size(); ++i) {
+			if (pcui->characters[i].name.equalsIgnoreCase(name)) {
+				_characterIndex = i;
+			}
+		}
+
+		return;
+	}
+
 	_characterIndex = stream.readByte();
 	readFilename(stream, _videoFile);
 }
@@ -1360,5 +1382,59 @@ void AddPhoneEntry::execute() {
 	_isDone = true;
 }
 
+static void readWideFlag(Common::SeekableReadStream &stream, FlagDescription &flag) {
+	flag.label = stream.readSint16LE();
+	flag.flag = stream.readUint16LE();
+}
+
+void HeldItemFlags::readData(Common::SeekableReadStream &stream) {
+	_hoverCursor = (CursorManager::CursorType)stream.readUint32LE();
+	readWideFlag(stream, _noItemFlag);
+
+	_itemFlags.resize(stream.readUint16LE());
+	for (ItemFlag &itemFlag : _itemFlags) {
+		itemFlag.itemID = stream.readSint32LE();
+		readWideFlag(stream, itemFlag.flag);
+	}
+
+	readWideFlag(stream, _otherItemFlag);
+
+	_hotspots.resize(stream.readUint16LE());
+	for (HotspotDescription &hotspot : _hotspots) {
+		hotspot.readData(stream);
+	}
+}
+
+void HeldItemFlags::execute() {
+	if (_state != kActionTrigger) {
+		_state = kRun;
+		_hasHotspot = false;
+
+		for (const HotspotDescription &hotspot : _hotspots) {
+			if (hotspot.frameID == NancySceneState.getSceneInfo().frameID) {
+				_hasHotspot = true;
+				_hotspot = hotspot.coords;
+			}
+		}
+
+		return;
+	}
+
+	FlagDescription flag = _noItemFlag;
+	int16 heldItem = NancySceneState.getHeldItem();
+	if (heldItem != -1) {
+		flag = _otherItemFlag;
+		for (const ItemFlag &itemFlag : _itemFlags) {
+			if (itemFlag.itemID == heldItem) {
+				flag = itemFlag.flag;
+			}
+		}
+	}
+
+	NancySceneState.setEventFlag(flag);
+	_hasHotspot = false;
+	finishExecution();
+}
+
 } // End of namespace Action
 } // End of namespace Nancy
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 788e6b27292..fbbc5e4fc99 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -651,6 +651,31 @@ protected:
 	PhoneBookData::Entry _entry;
 };
 
+// Nancy17. A hotspot that sets a different event flag for every item it can get
+// clicked with, one for any other item, and one for an empty hand
+class HeldItemFlags : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	CursorManager::CursorType getHoverCursor() const override { return _hoverCursor; }
+	bool canHaveHotspot() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "HeldItemFlags"; }
+
+	struct ItemFlag {
+		int16 itemID = -1;
+		FlagDescription flag;
+	};
+
+	CursorManager::CursorType _hoverCursor = CursorManager::kHotspot;
+	FlagDescription _noItemFlag;
+	Common::Array<ItemFlag> _itemFlags;
+	FlagDescription _otherItemFlag;
+	Common::Array<HotspotDescription> _hotspots;
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 5447f56214c..efeeffa3bfa 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -289,7 +289,8 @@ void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Com
 
 	readFilename(ser, _startingSequenceName);
 
-	ser.syncAsUint16LE(_videoFormat);
+	// Nancy17 dropped the video format
+	ser.syncAsUint16LE(_videoFormat, 0, kGameTypeNancy16);
 	_videoFormat = kLargeVideoFormat;
 	if (g_nancy->getGameType() >= kGameTypeNancy16) {
 		ser.syncAsUint16LE(_z);
@@ -305,6 +306,10 @@ void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Com
 	ser.syncAsUint16LE(_talkSceneID);
 	ser.skip(2);	// Conversation frame ID (0 in known data)
 
+	if (g_nancy->getGameType() >= kGameTypeNancy17 && _talkSceneID == kNoSceneNancy17) {
+		_talkSceneID = kNoScene;
+	}
+
 	ser.syncAsByte(_movieVolume);
 	_movieVolume = MIN<byte>(_movieVolume, 100);
 
@@ -339,10 +344,10 @@ void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Com
 }
 
 void PlaySecondaryMovie::applyStartingRandomSequence() {
-	// "RandomMovie" picks any sequence; otherwise look up by name.
+	// "RandomMovie" ("random_movie" from Nancy16 on) picks any sequence; otherwise look up by name.
 	if (!_sequences.empty()) {
 		int startIdx = -1;
-		if (_startingSequenceName == "RandomMovie") {
+		if (_startingSequenceName == "RandomMovie" || _startingSequenceName.equalsIgnoreCase("random_movie")) {
 			startIdx = g_nancy->_randomSource->getRandomNumber(_sequences.size() - 1);
 		} else {
 			for (uint i = 0; i < _sequences.size(); ++i) {
@@ -749,7 +754,8 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 
 	readFilename(ser, _videoName);
 
-	ser.syncAsUint16LE(_videoFormat);
+	// Nancy17 dropped the video format
+	ser.syncAsUint16LE(_videoFormat, 0, kGameTypeNancy16);
 	_videoFormat = kLargeVideoFormat;
 
 	if (g_nancy->getGameType() >= kGameTypeNancy16) {
@@ -780,6 +786,8 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 			(uint16)(minLoops + g_nancy->_randomSource->getRandomNumber(maxLoops - minLoops - 1)) : minLoops;
 	}
 
+	ser.skip(1, kGameTypeNancy17);	// Always 0 in the game data
+
 	ser.syncAsUint16LE(_firstFrame);
 	ser.syncAsUint16LE(_lastFrame);
 
@@ -789,6 +797,11 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 	ser.syncAsSint16LE(_sceneChange.sceneID);
 	ser.syncAsUint16LE(_sceneChange.frameID);
 
+	// Nancy17 marks "no scene change" with a different value
+	if (g_nancy->getGameType() >= kGameTypeNancy17 && _sceneChange.sceneID == kNoSceneNancy17) {
+		_sceneChange.sceneID = kNoScene;
+	}
+
 	_videoSceneChange = _sceneChange.sceneID != kNoScene ? kMovieSceneChange : kMovieNoSceneChange;
 
 	// Per-movie volume. AR 44 always carries it. AR 47 does too, but only from
@@ -1477,6 +1490,12 @@ void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
 
 	_mode = stream.readByte();
 
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		// A list of names follows, which is a single empty one in the game data
+		const uint16 numNames = stream.readUint16LE();
+		stream.skip(numNames * 33);
+	}
+
 	_hasSceneChange = g_nancy->getGameType() < kGameTypeNancy13;
 	if (_hasSceneChange) {
 		_sceneChange.readData(stream, true, true);


Commit: 521641050a3a25dfc5de6c839ebf821129e094f4
    https://github.com/scummvm/scummvm/commit/521641050a3a25dfc5de6c839ebf821129e094f4
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:51+03:00

Commit Message:
NANCY: NANCY17: Implement the new records

- CollectionControl, CollectionEdit and CollectionCheck (37-39) keep
  named lists of numbers, e.g. the buttons pressed so far, and check
  them against a combination
- FadeOverlay (40) fades a color over a part of the viewport
- SetRandomFlag (92) sets one of its flags at random
- Rain (200) draws falling drops over a part of the viewport

Changed paths:
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/action/miscrecords.h
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 2210592a4d6..3704d123a95 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -261,7 +261,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new HideCursor();	// Moved from 27
 	case 30:	// Nancy11
-		return new StopPlayerScrolling();
+		if (g_nancy->getGameType() <= kGameTypeNancy16)
+			return new StopPlayerScrolling();
+		else
+			return nullptr;	// SetCursor, which the game doesn't use
 	case 31:	// Nancy11
 		return new StartPlayerScrolling();
 	case 32:	// Nancy10
@@ -272,13 +275,21 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new ConversationInfoCheck();
 	case 36:	// Nancy12
 		return new ConversationGoodbye();
+	case 37:	// Nancy17
+		return new CollectionControl();
+	case 38:	// Nancy17
+		return new CollectionEdit();
+	case 39:	// Nancy17
+		return new CollectionCheck();
 	case 40:
 		if (g_nancy->getGameType() <= kGameTypeNancy1)
 			return new LightningOn(); // Only used in TVD
 		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new SpecialEffect();
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy16)
 			return new Fader();
+		else
+			return new FadeOverlay();
 	case 41:	// Nancy13
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kSecondaryMovieTerse);
 	case 42:	// Nancy14
@@ -406,6 +417,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new EventFlags(EventFlags::kEventFlags);
 	case 91:	// Nancy16
 		return new EventFlagsMultiHS(EventFlagsMultiHS::kCursorHS);
+	case 92:	// Nancy17
+		return new SetRandomFlag();
 	case 93:	// Nancy17
 		return new OnSceneChange();
 	case 94:	// Nancy12
@@ -744,7 +757,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 199:
 		return new WolfMazePuzzle();	// Driven by the commands given through 198
 	case 200:
-		return new SoundEqualizerPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new SoundEqualizerPuzzle();
+		else
+			return new Rain();
 	case 201:
 		return new TowerPuzzle();
 	case 202:
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 8a034a6373f..1279f922b69 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1356,6 +1356,75 @@ void PlayChar::execute() {
 	finishExecution();
 }
 
+void FadeOverlay::readData(Common::SeekableReadStream &stream) {
+	_seconds = stream.readFloatLE();
+	_z = stream.readUint32LE();
+	_fromColor = stream.readUint32BE();
+	_toColor = stream.readUint32BE();
+	readExclusiveRect(stream, _bounds);
+	_doneFlag.label = stream.readSint16LE();
+	_doneFlag.flag = stream.readByte();
+}
+
+void FadeOverlay::execute() {
+	if (_state == kBegin) {
+		_drawSurface.create(_bounds.width(), _bounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
+		moveTo(_bounds);
+		RenderObject::init();
+
+		_startTime = g_nancy->getTotalPlayTime();
+		_state = kRun;
+	}
+
+	if (_hasEnded) {
+		return;
+	}
+
+	float seconds = (g_nancy->getTotalPlayTime() - _startTime) / 1000.0f;
+	float progress = _seconds > 0 ? CLIP<float>(seconds / _seconds, 0, 1) : 1;
+
+	// The color eases in and out of the change
+	float amount = 0.5f + 0.5f * sin((progress - 0.5f) * M_PI);
+
+	uint32 color = 0;
+	for (uint shift = 0; shift < 32; shift += 8) {
+		float from = (_fromColor >> shift) & 0xFF;
+		float to = (_toColor >> shift) & 0xFF;
+		color |= (uint32)(from + (to - from) * amount) << shift;
+	}
+
+	_drawSurface.clear(argbToColor(_drawSurface.format, color));
+	_needsRedraw = true;
+
+	if (seconds > _seconds) {
+		_hasEnded = true;
+		NancySceneState.setEventFlag(_doneFlag);
+	}
+}
+
+void SetRandomFlag::readData(Common::SeekableReadStream &stream) {
+	_choices.resize(stream.readUint16LE());
+	for (Choice &choice : _choices) {
+		choice.flag.label = stream.readSint16LE();
+		choice.flag.flag = stream.readByte();
+		choice.percent = stream.readSint32LE();
+	}
+}
+
+void SetRandomFlag::execute() {
+	// The percentages add up to 100
+	int roll = g_nancy->_randomSource->getRandomNumber(99);
+	for (const Choice &choice : _choices) {
+		roll -= choice.percent;
+		if (roll < 0) {
+			NancySceneState.setEventFlag(choice.flag);
+			break;
+		}
+	}
+
+	_isDone = true;
+}
+
 void AddPhoneEntry::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _entry.name);
 	readFilename(stream, _entry.number);
@@ -1382,6 +1451,152 @@ void AddPhoneEntry::execute() {
 	_isDone = true;
 }
 
+// The numbers of a collection record take up the rest of its data. The original
+// also has collections of flags and of names, which no scene makes use of.
+static void readCollectionValues(Common::SeekableReadStream &stream, Common::Array<double> &values) {
+	const uint16 numValues = stream.readUint16LE();
+	if (!numValues) {
+		return;
+	}
+
+	if ((stream.size() - stream.pos()) / numValues != 8) {
+		warning("Unsupported collection item type");
+		return;
+	}
+
+	values.resize(numValues);
+	for (double &value : values) {
+		value = stream.readDoubleLE();
+	}
+}
+
+void CollectionControl::readData(Common::SeekableReadStream &stream) {
+	_operation = stream.readByte();
+	readFilename(stream, _name);
+
+	if (_operation == kCreate) {
+		_replaceExisting = stream.readByte();
+		stream.skip(1);
+		_maxSize = stream.readUint32LE();
+		stream.skip(1);		// Item type, always numbers
+		stream.skip(21);	// A flag, a rectangle and a color
+	}
+}
+
+void CollectionControl::execute() {
+	auto *data = (CollectionData *)NancySceneState.getPuzzleData(CollectionData::getTag());
+
+	if (_operation == kDelete) {
+		data->collections.erase(_name);
+	} else if (_replaceExisting || !data->collections.contains(_name)) {
+		CollectionData::Collection &collection = data->collections[_name];
+		collection.maxSize = _maxSize;
+		collection.values.clear();
+	}
+
+	_isDone = true;
+}
+
+void CollectionEdit::readData(Common::SeekableReadStream &stream) {
+	stream.skip(1);	// Edit type, always "add"
+	readFilename(stream, _name);
+	readCollectionValues(stream, _values);
+}
+
+void CollectionEdit::execute() {
+	auto *data = (CollectionData *)NancySceneState.getPuzzleData(CollectionData::getTag());
+
+	if (data->collections.contains(_name)) {
+		CollectionData::Collection &collection = data->collections[_name];
+		collection.values.push_back(_values);
+
+		// The oldest values make room for the new ones
+		while (collection.values.size() > collection.maxSize) {
+			collection.values.remove_at(0);
+		}
+	} else {
+		warning("%s: tried to add but collection does not exist", _name.c_str());
+	}
+
+	_isDone = true;
+}
+
+void CollectionCheck::readData(Common::SeekableReadStream &stream) {
+	_checkType = stream.readByte();
+	readFilename(stream, _name);
+
+	const uint numFlags = _checkType == kSize ? 3 : 2;
+	for (uint i = 0; i < numFlags; ++i) {
+		_flags[i].label = stream.readSint16LE();
+		_flags[i].flag = stream.readByte();
+	}
+
+	if (_checkType == kSize) {
+		_size = stream.readSint32LE();
+	} else {
+		readCollectionValues(stream, _values);
+	}
+}
+
+bool CollectionCheck::check(const Common::Array<double> &collection) const {
+	switch (_checkType) {
+	case kContainsAll: {
+		Common::Array<double> remaining = collection;
+		for (double value : _values) {
+			bool found = false;
+			for (uint i = 0; i < remaining.size() && !found; ++i) {
+				if (remaining[i] == value) {
+					remaining.remove_at(i);
+					found = true;
+				}
+			}
+
+			if (!found) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+	case kIsEqual:
+		return collection == _values;
+	case kFollowsPattern:
+		for (uint i = 0; i < collection.size(); ++i) {
+			if (_values.empty() || collection[i] != _values[i % _values.size()]) {
+				return false;
+			}
+		}
+
+		return true;
+	default:
+		return false;
+	}
+}
+
+void CollectionCheck::execute() {
+	auto *data = (CollectionData *)NancySceneState.getPuzzleData(CollectionData::getTag());
+
+	if (data->collections.contains(_name)) {
+		const Common::Array<double> &collection = data->collections[_name].values;
+
+		if (_checkType == kSize) {
+			if (_size < (int32)collection.size()) {
+				NancySceneState.setEventFlag(_flags[0]);
+			} else if (_size == (int32)collection.size()) {
+				NancySceneState.setEventFlag(_flags[1]);
+			} else {
+				NancySceneState.setEventFlag(_flags[2]);
+			}
+		} else {
+			NancySceneState.setEventFlag(_flags[check(collection) ? 0 : 1]);
+		}
+	} else {
+		warning("%s: tried to check collection does not exist", _name.c_str());
+	}
+
+	_isDone = true;
+}
+
 static void readWideFlag(Common::SeekableReadStream &stream, FlagDescription &flag) {
 	flag.label = stream.readSint16LE();
 	flag.flag = stream.readUint16LE();
@@ -1436,5 +1651,129 @@ void HeldItemFlags::execute() {
 	finishExecution();
 }
 
+void Rain::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+
+	_dropSrcs.resize(stream.readUint16LE());
+	for (Common::Rect &src : _dropSrcs) {
+		readRect(stream, src);
+	}
+
+	_areas.resize(stream.readUint16LE());
+	for (HotspotDescription &area : _areas) {
+		area.frameID = stream.readUint16LE();
+		readExclusiveRect(stream, area.coords);
+	}
+
+	_z = stream.readSint32LE();
+	_minDistance = stream.readFloatLE();
+	_maxDistance = stream.readFloatLE();
+	_numDrops = stream.readSint32LE();
+	_interval = (uint32)(stream.readFloatLE() * 1000);
+}
+
+void Rain::execute() {
+	if (_state == kBegin) {
+		g_nancy->_resource->loadImage(_imageName, _image);
+		RenderObject::init();
+		setVisible(false);
+		_state = kRun;
+	}
+
+	int areaIndex = -1;
+	for (uint i = 0; i < _areas.size(); ++i) {
+		if (_areas[i].frameID == NancySceneState.getSceneInfo().frameID) {
+			areaIndex = i;
+		}
+	}
+
+	if (areaIndex != _areaIndex) {
+		_areaIndex = areaIndex;
+		_nextDrawTime = 0;
+		setVisible(areaIndex != -1);
+
+		if (areaIndex != -1) {
+			const Common::Rect &area = _areas[areaIndex].coords;
+			_drawSurface.create(area.width(), area.height(), g_nancy->_graphics->getTransparentPixelFormat());
+			moveTo(area);
+		}
+	}
+
+	if (_areaIndex != -1 && !_dropSrcs.empty() && g_nancy->getTotalPlayTime() >= _nextDrawTime) {
+		_nextDrawTime = g_nancy->getTotalPlayTime() + _interval;
+		drawDrops();
+	}
+}
+
+void Rain::drawDrops() {
+	static const float kDistanceCurve = 0.85f;
+	static const float kDistanceFade = 0.75f;
+
+	_drawSurface.clear(0);
+
+	const float exponent = 1.0f - (_maxDistance - _minDistance) * kDistanceCurve;
+
+	for (int i = 0; i < _numDrops; ++i) {
+		const Common::Rect &src = _dropSrcs[g_nancy->_randomSource->getRandomNumber(_dropSrcs.size() - 1)];
+
+		float distance = _minDistance + (_maxDistance - _minDistance) * g_nancy->_randomSource->getRandomNumber(32767) / 32767.0f;
+		distance = pow(distance, exponent);
+
+		byte alpha = (byte)((1.0f - kDistanceFade * distance) * 255);
+		int height = MAX<int>((int)((1.0f - distance) * src.height()), 1);
+
+		// Anywhere that leaves a part of the drop inside
+		int rangeX = _drawSurface.w + src.width() - 2;
+		int rangeY = _drawSurface.h + height - 2;
+		if (rangeX <= 0 || rangeY <= 0) {
+			continue;
+		}
+
+		Common::Point pos;
+		pos.x = 1 - src.width() + g_nancy->_randomSource->getRandomNumber(rangeX - 1);
+		pos.y = 1 - height + g_nancy->_randomSource->getRandomNumber(rangeY - 1);
+
+		drawDrop(src, pos, height, alpha);
+	}
+
+	_needsRedraw = true;
+}
+
+// Draws a drop squeezed to the given height, on top of the ones already there
+void Rain::drawDrop(const Common::Rect &src, const Common::Point &pos, int height, byte alpha) {
+	for (int y = 0; y < height; ++y) {
+		int destY = pos.y + y;
+		int srcY = src.top + y * src.height() / height;
+		if (destY < 0 || destY >= _drawSurface.h || srcY >= _image.h) {
+			continue;
+		}
+
+		for (int x = 0; x < src.width(); ++x) {
+			int destX = pos.x + x;
+			int srcX = src.left + x;
+			if (destX < 0 || destX >= _drawSurface.w || srcX >= _image.w) {
+				continue;
+			}
+
+			byte a, r, g, b;
+			_image.format.colorToARGB(_image.getPixel(srcX, srcY), a, r, g, b);
+			a = a * alpha / 255;
+			if (a == 0) {
+				continue;
+			}
+
+			byte destA, destR, destG, destB;
+			_drawSurface.format.colorToARGB(_drawSurface.getPixel(destX, destY), destA, destR, destG, destB);
+
+			uint outA = a + destA * (255 - a) / 255;
+			r = (r * a + destR * destA * (255 - a) / 255) / outA;
+			g = (g * a + destG * destA * (255 - a) / 255) / outA;
+			b = (b * a + destB * destA * (255 - a) / 255) / outA;
+
+			_drawSurface.setPixel(destX, destY, _drawSurface.format.ARGBToColor(outA, r, g, b));
+		}
+	}
+}
+
 } // End of namespace Action
 } // End of namespace Nancy
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index fbbc5e4fc99..1ad57923c87 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -639,6 +639,109 @@ protected:
 	Common::Path _videoFile;	// replacement scene background, empty to keep the current one
 };
 
+// Nancy17. Covers a part of the viewport with a color that changes into another
+// one over time, e.g. from transparent to black. It stays at the last color for
+// as long as the scene does.
+class FadeOverlay : public RenderActionRecord {
+public:
+	FadeOverlay() : RenderActionRecord(7) {}
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "FadeOverlay"; }
+
+	float _seconds = 0;
+	uint32 _fromColor = 0;	// ARGB
+	uint32 _toColor = 0;	// ARGB
+	Common::Rect _bounds;
+	FlagDescription _doneFlag;
+
+	uint32 _startTime = 0;
+	bool _hasEnded = false;
+};
+
+// Nancy17. Sets one of several flags, each of which has a percent chance to be the one
+class SetRandomFlag : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "SetRandomFlag"; }
+
+	struct Choice {
+		FlagDescription flag;
+		int32 percent = 0;
+	};
+
+	Common::Array<Choice> _choices;
+};
+
+// Nancy17. A collection is a named list of numbers, which keeps the most recent
+// ones once it is full. Scenes use them for combinations, e.g. to add every
+// button the player presses and check the last few against the solution.
+class CollectionControl : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	enum Operation { kCreate = 0, kDelete = 1 };
+
+	Common::String getRecordTypeName() const override { return "CollectionControl"; }
+
+	byte _operation = kCreate;
+	Common::String _name;
+	bool _replaceExisting = false;
+	uint32 _maxSize = 0;
+};
+
+// Nancy17. Adds numbers to a collection
+class CollectionEdit : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "CollectionEdit"; }
+
+	Common::String _name;
+	Common::Array<double> _values;
+};
+
+// Nancy17. Compares a collection against a list of numbers or a size, and sets
+// one of its flags by the result
+class CollectionCheck : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	enum CheckType {
+		kContainsAll	= 0,	// every listed number is in the collection, in any order
+		kIsEqual		= 1,
+		kSize			= 2,
+		kFollowsPattern	= 3		// the collection goes through the list, over and over
+	};
+
+	Common::String getRecordTypeName() const override { return "CollectionCheck"; }
+
+	bool check(const Common::Array<double> &collection) const;
+
+	byte _checkType = kContainsAll;
+	Common::String _name;
+
+	// Match and mismatch; the size check has one for a bigger, an equal and a
+	// smaller collection
+	FlagDescription _flags[3];
+	int32 _size = 0;
+	Common::Array<double> _values;
+};
+
 // Nancy17. Adds a contact to the cell phone of the character being played
 class AddPhoneEntry : public ActionRecord {
 public:
@@ -676,6 +779,37 @@ protected:
 	Common::Array<HotspotDescription> _hotspots;
 };
 
+// Nancy17. Rain over a part of the viewport: a number of drops, which get placed
+// anew a few times a second. The further away a drop is, the shorter and the
+// fainter it gets drawn.
+class Rain : public RenderActionRecord {
+public:
+	Rain() : RenderActionRecord(7) {}
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "Rain"; }
+
+	void drawDrops();
+	void drawDrop(const Common::Rect &src, const Common::Point &pos, int height, byte alpha);
+
+	Common::Path _imageName;
+	Common::Array<Common::Rect> _dropSrcs;
+	Common::Array<HotspotDescription> _areas;	// where it rains, for every frame of the scene
+	float _minDistance = 0;
+	float _maxDistance = 1;
+	int32 _numDrops = 0;
+	uint32 _interval = 0;
+
+	Graphics::ManagedSurface _image;
+	int _areaIndex = -1;
+	uint32 _nextDrawTime = 0;
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 97f448d62c6..86fa885632c 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -836,6 +836,37 @@ void StreamData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void CollectionData::synchronize(Common::Serializer &ser) {
+	uint16 numCollections = collections.size();
+	ser.syncAsUint16LE(numCollections);
+
+	if (ser.isSaving()) {
+		for (auto &entry : collections) {
+			Common::String name = entry._key;
+			ser.syncString(name);
+			ser.syncAsUint32LE(entry._value.maxSize);
+
+			uint16 numValues = entry._value.values.size();
+			ser.syncAsUint16LE(numValues);
+			ser.syncArray(entry._value.values.data(), numValues, Common::Serializer::DoubleLE);
+		}
+	} else {
+		collections.clear();
+		for (uint i = 0; i < numCollections; ++i) {
+			Common::String name;
+			ser.syncString(name);
+
+			Collection &collection = collections[name];
+			ser.syncAsUint32LE(collection.maxSize);
+
+			uint16 numValues = 0;
+			ser.syncAsUint16LE(numValues);
+			collection.values.resize(numValues);
+			ser.syncArray(collection.values.data(), numValues, Common::Serializer::DoubleLE);
+		}
+	}
+}
+
 Common::String PhoneBookData::Entry::getDisplayText() const {
 	if (nameFlag == -1 || NancySceneState.getEventFlag(nameFlag, g_nancy->_true)) {
 		return name;
@@ -865,6 +896,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new PhoneBookData();
 	case StreamData::getTag():
 		return new StreamData();
+	case CollectionData::getTag():
+		return new CollectionData();
 	case BuildPuzzleData::getTag():
 		return new BuildPuzzleData();
 	case MirrorLightData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 658e7b19128..bbafe0c2163 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -616,6 +616,20 @@ struct StreamData : public PuzzleData {
 	Common::Array<Stream> streams;
 };
 
+// Nancy17. Named lists of numbers that the scenes fill and check, e.g. the
+// buttons the player pressed so far
+struct CollectionData : public PuzzleData {
+	struct Collection {
+		uint32 maxSize = 0;
+		Common::Array<double> values;
+	};
+
+	static constexpr uint32 getTag() { return MKTAG('C', 'O', 'L', 'L'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::HashMap<Common::String, Collection, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> collections;
+};
+
 // Nancy17. The contacts in the cell phone of every player character
 struct PhoneBookData : public PuzzleData {
 	struct Entry {


Commit: d6c60dd064e65dbfe8eac8856711c6a92c0f8022
    https://github.com/scummvm/scummvm/commit/d6c60dd064e65dbfe8eac8856711c6a92c0f8022
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:54+03:00

Commit Message:
NANCY: NANCY17: 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 6d38f5053f7..19b5d0f2fb4 100644
--- a/engines/nancy/detection_tables.h
+++ b/engines/nancy/detection_tables.h
@@ -992,7 +992,7 @@ static const NancyGameDescription gameDescriptions[] = {
 			AD_ENTRY1s("ciftree.dat", "039950bfb8d1d614727ace5359ba6e20", 55775218),
 			Common::EN_ANY,
 			Common::kPlatformWindows,
-			ADGF_UNSUPPORTED | ADGF_DROPPLATFORM,
+			ADGF_UNSTABLE | ADGF_DROPPLATFORM,
 			NANCY8_GUIOPTIONS
 		},
 		kGameTypeNancy17


Commit: 3550d9338609668b4eb271987225737dc333d887
    https://github.com/scummvm/scummvm/commit/3550d9338609668b4eb271987225737dc333d887
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:56+03:00

Commit Message:
NANCY: NANCY17: Show pressed buttons in RotatingLockPuzzle

The data has a picture for the pressed up and down buttons, and how
long it shows after a click

Changed paths:
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
    engines/nancy/action/puzzle/rotatinglockpuzzle.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 3704d123a95..0532a8bec56 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -583,7 +583,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 155: // StopAndUnloadSound, but we always unload
 		return new StopSound();
 	case 156:	// Nancy11
-		return new Update3DSound();
+		if (g_nancy->getGameType() <= kGameTypeNancy16)
+			return new Update3DSound();
+		else
+			return new RotatingLockPuzzle();	// Moved from 114
 	case 157:
 		return new PlaySoundCC();
 	case 158:
diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
index 8ac6aa98c34..21323173bd4 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
@@ -170,9 +170,23 @@ void RotatingLockPuzzle::readDataNancy16(Common::SeekableReadStream &stream) {
 	_iconsPerDial = stream.readUint16LE();
 	readRectArray(stream, _srcRects, _iconsPerDial);
 
+	// Nancy17 added the pictures of the pressed buttons, and how long they show
+	const bool isNancy17 = g_nancy->getGameType() >= kGameTypeNancy17;
+
 	uint16 numDials = stream.readUint16LE();
 	readRectArray(stream, _destRects, numDials);
+
+	if (isNancy17) {
+		_pressedTime = (uint32)(stream.readDoubleLE() * 1000);
+		readRect(stream, _upPressedSrc);
+	}
+
 	readRectArray(stream, _upHotspots, numDials);
+
+	if (isNancy17) {
+		readRect(stream, _downPressedSrc);
+	}
+
 	readRectArray(stream, _downHotspots, numDials);
 
 	for (uint i = 0; i < numDials; ++i) {
@@ -181,6 +195,15 @@ void RotatingLockPuzzle::readDataNancy16(Common::SeekableReadStream &stream) {
 		} else {
 			_screenPosition.extend(_destRects[i]);
 		}
+
+		// The pressed buttons get drawn too
+		if (!_upPressedSrc.isEmpty()) {
+			_screenPosition.extend(_upHotspots[i]);
+		}
+
+		if (!_downPressedSrc.isEmpty()) {
+			_screenPosition.extend(_downHotspots[i]);
+		}
 	}
 
 	_correctSequence.resize(numDials);
@@ -244,6 +267,15 @@ void RotatingLockPuzzle::execute() {
 		_state = kRun;
 		// fall through
 	case kRun:
+		if (!_pressedButton.isEmpty() && g_nancy->getTotalPlayTime() > _pressedEndTime) {
+			// The button comes back up
+			Common::Rect dest = _pressedButton;
+			dest.translate(-_screenPosition.left, -_screenPosition.top);
+			_drawSurface.fillRect(dest, g_nancy->_graphics->getTransColor());
+			_pressedButton = Common::Rect();
+			_needsRedraw = true;
+		}
+
 		switch (_solveState) {
 		case kNotSolved:
 			for (uint i = 0; i < _correctSequence.size(); ++i) {
@@ -320,6 +352,7 @@ void RotatingLockPuzzle::handleInput(NancyInput &input) {
 					n = 0;
 				_currentSequence[i] = (byte)n;
 				drawDial(i);
+				drawPressedButton(_upPressedSrc, _upHotspots[i]);
 			}
 
 			return;
@@ -339,6 +372,7 @@ void RotatingLockPuzzle::handleInput(NancyInput &input) {
 					n = (int)_iconsPerDial - 1;
 				_currentSequence[i] = (byte)n;
 				drawDial(i);
+				drawPressedButton(_downPressedSrc, _downHotspots[i]);
 			}
 
 			return;
@@ -352,5 +386,23 @@ void RotatingLockPuzzle::drawDial(uint id) {
 	_needsRedraw = true;
 }
 
+void RotatingLockPuzzle::drawPressedButton(const Common::Rect &src, const Common::Rect &button) {
+	if (src.isEmpty()) {
+		return;
+	}
+
+	// One button shows as pressed at a time
+	if (!_pressedButton.isEmpty()) {
+		Common::Rect previous = _pressedButton;
+		previous.translate(-_screenPosition.left, -_screenPosition.top);
+		_drawSurface.fillRect(previous, g_nancy->_graphics->getTransColor());
+	}
+
+	_drawSurface.blitFrom(_image, src, Common::Point(button.left - _screenPosition.left, button.top - _screenPosition.top));
+	_pressedButton = button;
+	_pressedEndTime = g_nancy->getTotalPlayTime() + _pressedTime;
+	_needsRedraw = true;
+}
+
 } // End of namespace Action
 } // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.h b/engines/nancy/action/puzzle/rotatinglockpuzzle.h
index aed0a5868f3..0aa7bc46aa8 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.h
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.h
@@ -59,6 +59,14 @@ public:
 	// Nancy16+: an event flag that also has to be set for the puzzle to count as solved
 	FlagDescription _solveCondition;
 
+	// Nancy17: a clicked up or down button shows as pressed for a moment. An
+	// empty picture means the button has none.
+	Common::Rect _upPressedSrc;
+	Common::Rect _downPressedSrc;
+	uint32 _pressedTime = 0;	// in milliseconds
+	Common::Rect _pressedButton;
+	uint32 _pressedEndTime = 0;
+
 	SolveState _solveState = kNotSolved;
 	Common::Array<byte> _currentSequence;
 	Time _solveSoundPlayTime;
@@ -70,6 +78,7 @@ protected:
 
 	void readDataNancy16(Common::SeekableReadStream &stream);
 	void drawDial(uint id);
+	void drawPressedButton(const Common::Rect &src, const Common::Rect &button);
 };
 
 } // End of namespace Action


Commit: 6508d4b4e006f95738fa2d58fe338cbc65b61446
    https://github.com/scummvm/scummvm/commit/6508d4b4e006f95738fa2d58fe338cbc65b61446
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:18:58+03:00

Commit Message:
NANCY: NANCY17: Implement the BuildPuzzle changes

- Pieces can start in a zone, a dropped piece swaps out the one in a
  full zone, and the zones a held piece fits are highlighted
- Holding an inventory item can stand for one of the pieces
- A board can be kept under a name, also in saved games

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 0532a8bec56..d548f405738 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -636,10 +636,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 166:
 		if (g_nancy->getGameType() <= kGameTypeNancy13)
 			return new OneBuildPuzzle();	// Moved from 234
-		else if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new BuildPuzzle();
 		else
-			return nullptr;
+			return new BuildPuzzle();
 	case 167:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new DrivingPuzzle(DrivingPuzzle::kChase);
diff --git a/engines/nancy/action/puzzle/buildpuzzle.cpp b/engines/nancy/action/puzzle/buildpuzzle.cpp
index 2e053c21eea..e7c8f6faef2 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/buildpuzzle.cpp
@@ -28,6 +28,7 @@
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/enginedata.h"
+#include "engines/nancy/puzzledata.h"
 #include "engines/nancy/state/scene.h"
 
 #include "engines/nancy/action/puzzle/buildpuzzle.h"
@@ -48,6 +49,11 @@ static void readSoundBlock(Common::SeekableReadStream &stream, SoundDescription
 }
 
 void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		readDataNancy17(stream);
+		return;
+	}
+
 	readFilename(stream, _imageName);
 	readFilename(stream, _altImageName);
 
@@ -183,6 +189,125 @@ void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	}
 }
 
+void BuildPuzzle::readDataNancy17(Common::SeekableReadStream &stream) {
+	_isNancy17 = true;
+
+	readFilename(stream, _imageName);
+	readFilename(stream, _altImageName);
+
+	_trayImageMode = stream.readByte();
+	readFilename(stream, _saveKey);
+
+	int16 requiredPlaced = stream.readSint16LE();
+	_isSolvable = requiredPlaced != -1;
+	_requiredPlaced = MAX<int16>(requiredPlaced, 0);
+	_usePlacedGate = stream.readByte();
+
+	int16 stateItemID = stream.readSint16LE();
+	_stateItemID = stateItemID < 0 ? 255 : stateItemID;
+
+	readFilename(stream, _anim1Name);
+	readRect(stream, _anim1Rect);
+	readFilename(stream, _anim2Name);
+	readRect(stream, _anim2Rect);
+	_pieceCursorType = stream.readSint16LE();
+	_heldPieceCursorType = stream.readSint16LE();
+	_carryCursorType = stream.readSint16LE();
+
+	uint16 numZones = stream.readUint16LE() / 2;
+	_zones.resize(numZones);
+	for (uint i = 0; i < numZones; ++i) {
+		Zone &zone = _zones[i];
+		readRect(stream, zone.hotspot);
+		readRect(stream, zone.highlightSrc);
+		zone.capacity = stream.readUint16LE();
+		stream.skip(1);
+		zone.fill = (ZoneFill)stream.readByte();
+		zone.marksPlaced = stream.readByte();
+
+		uint16 numIngredients = stream.readUint16LE();
+		zone.ingredients.resize(numIngredients);
+		for (uint j = 0; j < numIngredients; ++j) {
+			Ingredient &ingredient = zone.ingredients[j];
+			ingredient.pieceID = stream.readSint16LE();
+			ingredient.quantity = stream.readByte();
+			ingredient.mode = stream.readByte();
+		}
+	}
+
+	uint16 numHolds = stream.readUint16LE();
+	_holds.resize(numHolds);
+	for (uint i = 0; i < numHolds; ++i) {
+		HoldSlot &hold = _holds[i];
+		readRect(stream, hold.srcRect);
+		readRect(stream, hold.destRect);
+		readRect(stream, hold.fillSrcRect1);
+		readRect(stream, hold.fillSrcRect2);
+		hold.amount = stream.readByte();
+	}
+
+	if (numHolds > 0)
+		readSoundBlock(stream, _holdSound);
+
+	uint16 numPieces = stream.readUint16LE();
+	_pieces.resize(numPieces);
+	for (uint i = 0; i < numPieces; ++i) {
+		Piece &piece = _pieces[i];
+		readRect(stream, piece.srcRect);
+		piece.startZone = stream.readSint16LE();
+		readRect(stream, piece.destRect);
+		readRect(stream, piece.dragSrcRect);
+		readRect(stream, piece.placedSrcRect);
+		readRect(stream, piece.closeupSrcRect);
+		readRect(stream, piece.closeupDestRect);
+		readRect(stream, piece.placedDestRect);
+
+		piece.kind = stream.readByte();
+		piece.zoneID = stream.readSint16LE();
+		piece.itemID = stream.readSint16LE();
+		piece.placedFlag = stream.readSint16LE();
+		piece.inventoryItem = stream.readSint16LE();
+
+		int16 numValues = stream.readSint16LE();
+		if (numValues > 0) {
+			piece.holds.resize(numValues - 1);
+			for (int16 j = 0; j < numValues - 1; ++j) {
+				piece.holds[j] = stream.readSint16LE();
+			}
+
+			piece.fillVariant = stream.readSint16LE();
+		}
+	}
+
+	readSoundBlock(stream, _pickupSound);
+	readSoundBlock(stream, _dropSound);
+	readSoundBlock(stream, _notebookSound);
+	readSoundBlock(stream, _putDownSound);
+
+	_wrongIngredientFlag = stream.readSint16LE();
+	_solvedFlag = stream.readSint16LE();
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_solveSoundBlock.readData(stream);
+
+	_failScene.sceneID = stream.readUint16LE();
+	_failScene.frameID = stream.readUint16LE();
+	_failScene.continueSceneSound = kContinueSceneSound;
+	_failFlag.label = stream.readSint16LE();
+	_failFlag.flag = stream.readByte();
+
+	SoundDescription unused;
+	readSoundBlock(stream, unused);
+
+	readExitHotspots(stream, _exitHotspots);
+	for (uint i = 0; i < _exitHotspots.size(); ++i) {
+		_exitHotspots[i].scene.continueSceneSound = kContinueSceneSound;
+	}
+}
+
 void BuildPuzzle::setFlagOnChange(int16 label, bool value, int8 &last) {
 	if (label == -1 || last == (int8)value) {
 		return;
@@ -203,23 +328,29 @@ void BuildPuzzle::setPieceCursor(bool isHeld) {
 }
 
 void BuildPuzzle::init() {
-	BuildPuzzleData *data = (BuildPuzzleData *)NancySceneState.getPuzzleData(BuildPuzzleData::getTag());
-	assert(data);
-
-	uint16 sceneID = NancySceneState.getSceneInfo().sceneID;
-	// Several scenes share this puzzle and the one saved board, so the board is
-	// only picked up by the scene it was saved for. Any other scene starts over
-	// and drops the board, so a later resume can't load another puzzle's pieces.
-	bool resume = data->sceneID == sceneID && _resumeFlag != -1 &&
+	// Nancy17 keeps its boards by name instead
+	BuildPuzzleData *data = nullptr;
+	bool resume = false;
+
+	if (!_isNancy17) {
+		data = (BuildPuzzleData *)NancySceneState.getPuzzleData(BuildPuzzleData::getTag());
+		assert(data);
+
+		uint16 sceneID = NancySceneState.getSceneInfo().sceneID;
+		// Several scenes share this puzzle and the one saved board, so the board is
+		// only picked up by the scene it was saved for. Any other scene starts over
+		// and drops the board, so a later resume can't load another puzzle's pieces.
+		resume = data->sceneID == sceneID && _resumeFlag != -1 &&
 					NancySceneState.getEventFlag(_resumeFlag, g_nancy->_true);
 
-	if (!resume) {
-		data->sceneID = sceneID;
-		data->placedCount = 0;
-		data->solved = false;
-		data->wrongIngredient = false;
-		data->pieces.clear();
-		data->zones.clear();
+		if (!resume) {
+			data->sceneID = sceneID;
+			data->placedCount = 0;
+			data->solved = false;
+			data->wrongIngredient = false;
+			data->pieces.clear();
+			data->zones.clear();
+		}
 	}
 
 	const uint32 transColor = g_nancy->_graphics->getTransColor();
@@ -246,7 +377,17 @@ void BuildPuzzle::init() {
 	_pieces.resize(_numDefined + numSpare);
 
 	for (uint i = 0; i < _zones.size(); ++i) {
-		_zones[i].counts.resize(_zones[i].ingredients.size());
+		Zone &zone = _zones[i];
+		zone.counts.resize(zone.ingredients.size());
+
+		if (!zone.highlightSrc.isEmpty()) {
+			zone.highlight._drawSurface.create(_image, zone.highlightSrc);
+			zone.highlight.setTransparent(true);
+			zone.highlight.moveTo(zone.hotspot);
+			// Above the pieces in the zones, below a close-up
+			zone.highlight.setZOrder(_z + (uint16)_pieces.size() + 1);
+			zone.highlight.setVisible(false);
+		}
 	}
 
 	for (uint i = 0; i < _pieces.size(); ++i) {
@@ -299,7 +440,9 @@ void BuildPuzzle::init() {
 	_counter.setTransparent(true);
 	_counter.setVisible(false);
 
-	if (resume) {
+	if (_isNancy17) {
+		initBoard();
+	} else if (resume) {
 		restoreState(*data);
 	} else {
 		// Starting over clears everything the puzzle wrote.
@@ -330,6 +473,12 @@ void BuildPuzzle::registerGraphics() {
 		_holds[i].registerGraphics();
 	}
 
+	for (uint i = 0; i < _zones.size(); ++i) {
+		if (!_zones[i].highlightSrc.isEmpty()) {
+			_zones[i].highlight.registerGraphics();
+		}
+	}
+
 	_cursorItem.registerGraphics();
 	_buttonPress.registerGraphics();
 	_doneOverlay.registerGraphics();
@@ -370,7 +519,8 @@ void BuildPuzzle::updateCursorItem(const Common::Point &mouseVP) {
 	Common::Rect dest((int16)(mouseVP.x - width / 2), (int16)(mouseVP.y - height / 2),
 						(int16)(mouseVP.x - width / 2 + width), (int16)(mouseVP.y - height / 2 + height));
 
-	_cursorItem._drawSurface.create(_altImage, *src);
+	// Nancy17 keeps the art of a carried piece in the main image
+	_cursorItem._drawSurface.create(_isNancy17 ? _image : _altImage, *src);
 	_cursorItem.setTransparent(true);
 	_cursorItem.moveTo(dest);
 	_cursorItem.setVisible(true);
@@ -386,7 +536,7 @@ void BuildPuzzle::updatePieceRender(int16 pieceIdx) {
 
 	// Each of the three states has its own art, and only a piece resting at home
 	// is drawn from the image the puzzle selects; the other two always come from
-	// the alt one.
+	// the alt one, or from the main one in Nancy17.
 	// A carried piece lives on the cursor instead of on the board.
 	if (pieceIdx == _heldPiece) {
 		piece.setVisible(false);
@@ -400,7 +550,7 @@ void BuildPuzzle::updatePieceRender(int16 pieceIdx) {
 
 	if (isPlaced) {
 		src = &piece.placedSrcRect;
-		surf = &_altImage;
+		surf = _isNancy17 ? &_image : &_altImage;
 	}
 
 	if (src->isEmpty()) {
@@ -433,6 +583,7 @@ int16 BuildPuzzle::clonePiece(int16 pieceIdx) {
 		clone.kind = original.kind;
 		clone.zoneID = original.zoneID;
 		clone.itemID = original.itemID;
+		clone.placedFlag = original.placedFlag;
 		clone.holds = original.holds;
 		clone.fillVariant = original.fillVariant;
 		clone.liveRect = original.liveRect;
@@ -472,6 +623,10 @@ void BuildPuzzle::adjustZone(int16 zoneIdx, int16 pieceID, int8 delta) {
 }
 
 bool BuildPuzzle::checkSolved() const {
+	if (!_isSolvable) {
+		return false;
+	}
+
 	for (uint i = 0; i < _zones.size(); ++i) {
 		const Zone &zone = _zones[i];
 
@@ -557,15 +712,12 @@ void BuildPuzzle::pickUpPiece(int16 pieceIdx) {
 	// Taking a piece back out of a zone undoes its contribution. A copy only
 	// exists while it is in a zone, so it goes away rather than onto the cursor.
 	if (piece.assignedZone != -1) {
-		adjustZone(piece.assignedZone, piece.sourceID, -1);
-		addItemValue(piece.itemID, -1);
-		setPlacedCount(_placedCount - 1);
-		piece.assignedZone = -1;
-		updateDoneOverlay();
+		takeOutOfZone(pieceIdx);
 
 		if (pieceIdx >= (int16)_numDefined) {
 			piece.inUse = false;
 			piece.setVisible(false);
+			saveBoard();
 			return;
 		}
 	}
@@ -576,6 +728,23 @@ void BuildPuzzle::pickUpPiece(int16 pieceIdx) {
 	g_nancy->_sound->loadSound(_pickupSound);
 	g_nancy->_sound->playSound(_pickupSound);
 	updatePieceRender(pieceIdx);
+	saveBoard();
+}
+
+void BuildPuzzle::takeOutOfZone(int16 pieceIdx) {
+	Piece &piece = _pieces[pieceIdx];
+
+	adjustZone(piece.assignedZone, piece.sourceID, -1);
+	addItemValue(piece.itemID, -1);
+	setPlacedCount(_placedCount - 1);
+
+	if (piece.placedFlag != -1) {
+		NancySceneState.setEventFlag(piece.placedFlag, g_nancy->_false);
+	}
+
+	piece.assignedZone = -1;
+	piece.locked = false;
+	updateDoneOverlay();
 }
 
 void BuildPuzzle::returnPiece(int16 pieceIdx) {
@@ -585,6 +754,7 @@ void BuildPuzzle::returnPiece(int16 pieceIdx) {
 	_heldPiece = -1;
 	_pieces[pieceIdx].setZOrder((uint16)(_z + pieceIdx + 1));
 	updatePieceRender(pieceIdx);
+	saveBoard();
 }
 
 void BuildPuzzle::placePiece(int16 pieceIdx, int16 zoneIdx, const Common::Point &dropPos) {
@@ -639,6 +809,10 @@ void BuildPuzzle::placePiece(int16 pieceIdx, int16 zoneIdx, const Common::Point
 	adjustZone(zoneIdx, piece.sourceID, (int8)carriedAmount());
 	addItemValue(piece.itemID, carriedAmount());
 
+	if (piece.placedFlag != -1) {
+		NancySceneState.setEventFlag(piece.placedFlag, g_nancy->_true);
+	}
+
 	g_nancy->_sound->loadSound(_dropSound);
 	g_nancy->_sound->playSound(_dropSound);
 
@@ -656,6 +830,15 @@ void BuildPuzzle::placePiece(int16 pieceIdx, int16 zoneIdx, const Common::Point
 		saveState();
 	}
 
+	saveBoard();
+
+	// Nancy17 has nothing to hand the puzzle in with, it ends once it is solved
+	if (_isNancy17 && solved) {
+		_isSolved = true;
+		_state = kActionTrigger;
+		return;
+	}
+
 	// Without the gate the puzzle waits to be handed in, so a wrong mix can be
 	// thrown away first.
 	if (!_usePlacedGate) {
@@ -851,6 +1034,160 @@ void BuildPuzzle::updateDoneOverlay() {
 	_doneOverlay.setVisible(allFull);
 }
 
+bool BuildPuzzle::zoneAccepts(const Piece &piece, int16 zoneIdx) const {
+	if (piece.zoneID == zoneIdx || piece.zoneID == kAnyZone) {
+		return true;
+	}
+
+	const Common::Rect &hotspot = _zones[zoneIdx].hotspot;
+	return piece.zoneID == kAnyZoneOfItsSize &&
+			piece.srcRect.width() == hotspot.width() && piece.srcRect.height() == hotspot.height();
+}
+
+int16 BuildPuzzle::findInventoryPiece() const {
+	int16 heldItem = NancySceneState.getHeldItem();
+	if (heldItem == -1) {
+		return -1;
+	}
+
+	for (uint i = 0; i < _numDefined; ++i) {
+		if (_pieces[i].inventoryItem == heldItem) {
+			return (int16)i;
+		}
+	}
+
+	return -1;
+}
+
+void BuildPuzzle::updateZoneHighlights(int16 pieceIdx) {
+	for (uint i = 0; i < _zones.size(); ++i) {
+		Zone &zone = _zones[i];
+		if (!zone.highlightSrc.isEmpty()) {
+			zone.highlight.setVisible(pieceIdx != -1 && zoneAccepts(_pieces[pieceIdx], (int16)i));
+		}
+	}
+}
+
+void BuildPuzzle::placeAtStart(int16 pieceIdx) {
+	Piece &piece = _pieces[pieceIdx];
+	const Zone &zone = _zones[piece.startZone];
+
+	if (zone.fill == kFillTopLeft) {
+		// Left aligned to the zone, sitting on its bottom edge
+		piece.liveRect = Common::Rect(zone.hotspot.left, (int16)(zone.hotspot.bottom - piece.srcRect.height()),
+										(int16)(zone.hotspot.left + piece.srcRect.width()), zone.hotspot.bottom);
+	} else {
+		piece.liveRect = zone.hotspot;
+	}
+
+	piece.assignedZone = piece.startZone;
+	adjustZone(piece.startZone, piece.sourceID, 1);
+	++_placedCount;
+	updatePieceRender(pieceIdx);
+}
+
+void BuildPuzzle::initBoard() {
+	for (uint i = 0; i < _numDefined; ++i) {
+		if (_pieces[i].placedFlag != -1) {
+			NancySceneState.setEventFlag(_pieces[i].placedFlag, g_nancy->_false);
+		}
+	}
+
+	setFlagOnChange(_solvedFlag, false, _lastSolvedFlag);
+	setFlagOnChange(_wrongIngredientFlag, false, _lastWrongFlag);
+
+	const BuildBoardData *boards = (const BuildBoardData *)NancySceneState.getPuzzleData(BuildBoardData::getTag());
+	assert(boards);
+
+	if (!_saveKey.empty() && boards->boards.contains(_saveKey)) {
+		restoreBoard(*boards);
+
+		// A board that was left solved ends the puzzle right away
+		if (checkSolved()) {
+			setFlagOnChange(_solvedFlag, true, _lastSolvedFlag);
+			_isSolved = true;
+		}
+	} else {
+		for (uint i = 0; i < _numDefined; ++i) {
+			if (_pieces[i].startZone >= 0 && _pieces[i].startZone < (int16)_zones.size()) {
+				placeAtStart((int16)i);
+			}
+		}
+	}
+
+	setPlacedCount(_placedCount);
+	saveBoard();
+}
+
+void BuildPuzzle::saveBoard() {
+	if (_saveKey.empty()) {
+		return;
+	}
+
+	BuildBoardData *boards = (BuildBoardData *)NancySceneState.getPuzzleData(BuildBoardData::getTag());
+	assert(boards);
+
+	BuildBoardData::Board &board = boards->boards[_saveKey];
+	board.placedCount = _placedCount;
+	board.heldPiece = _heldPiece;
+	board.pieces.clear();
+
+	for (uint i = 0; i < _pieces.size(); ++i) {
+		const Piece &piece = _pieces[i];
+		if (!piece.inUse) {
+			continue;
+		}
+
+		BuildBoardData::Piece saved;
+		saved.sourceID = piece.sourceID;
+		saved.zoneID = piece.assignedZone;
+		saved.rect = piece.liveRect;
+		saved.isLocked = piece.locked;
+		board.pieces.push_back(saved);
+	}
+}
+
+void BuildPuzzle::restoreBoard(const BuildBoardData &boards) {
+	const BuildBoardData::Board &board = boards.boards[_saveKey];
+
+	for (uint i = 0; i < board.pieces.size(); ++i) {
+		const BuildBoardData::Piece &saved = board.pieces[i];
+		if (saved.sourceID < 0 || saved.sourceID >= (int16)_numDefined) {
+			continue;
+		}
+
+		// The pieces from the record come first, in order; anything after them
+		// is a copy of one of them.
+		int16 pieceIdx = (int16)i;
+		if (pieceIdx >= (int16)_numDefined) {
+			pieceIdx = clonePiece(saved.sourceID);
+			if (pieceIdx == -1) {
+				break;
+			}
+		}
+
+		Piece &piece = _pieces[pieceIdx];
+		piece.liveRect = saved.rect;
+		piece.locked = saved.isLocked;
+
+		if (saved.zoneID >= 0 && saved.zoneID < (int16)_zones.size()) {
+			piece.assignedZone = saved.zoneID;
+			adjustZone(saved.zoneID, piece.sourceID, 1);
+		}
+
+		updatePieceRender(pieceIdx);
+	}
+
+	_placedCount = board.placedCount;
+
+	// The piece that was being carried is back on the cursor
+	if (board.heldPiece >= 0 && board.heldPiece < (int16)_numDefined) {
+		_heldPiece = board.heldPiece;
+		_pieces[_heldPiece].setZOrder((uint16)(_z + _pieces.size() + 1));
+		updatePieceRender(_heldPiece);
+	}
+}
+
 void BuildPuzzle::saveState() {
 	BuildPuzzleData *data = (BuildPuzzleData *)NancySceneState.getPuzzleData(BuildPuzzleData::getTag());
 	assert(data);
@@ -932,7 +1269,7 @@ void BuildPuzzle::execute() {
 	case kBegin:
 		init();
 		registerGraphics();
-		_state = kRun;
+		_state = _isSolved ? kActionTrigger : kRun;
 		break;
 	case kRun:
 		updateCounter();
@@ -951,6 +1288,7 @@ void BuildPuzzle::execute() {
 		break;
 	case kActionTrigger:
 		if (_isSolved) {
+			playSoundBlock(_solveSoundBlock);
 			_solveScene.execute();
 		} else if (_isFailed) {
 			NancySceneState.setEventFlag(_failFlag);
@@ -985,6 +1323,14 @@ void BuildPuzzle::handleInput(NancyInput &input) {
 
 	updateCursorItem(mouseVP);
 
+	if (_isNancy17) {
+		if (!handleBoardInputNancy17(mouseVP, clicked)) {
+			handleExitInput(input, clicked);
+		}
+
+		return;
+	}
+
 	// The buttons sit above the board, and submitting needs enough pieces placed.
 	if (!_startOverHotspot.isEmpty() && _startOverHotspot.contains(mouseVP)) {
 		setPieceCursor(false);
@@ -1145,6 +1491,148 @@ void BuildPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	handleExitInput(input, clicked);
+}
+
+bool BuildPuzzle::handleBoardInputNancy17(const Common::Point &mouseVP, bool clicked) {
+	// A close-up covers the board; clicking it takes the piece, except for a
+	// piece that is only ever there to be looked at.
+	if (_closeupPiece != -1) {
+		updateZoneHighlights(-1);
+
+		if (!_closeupDest.contains(mouseVP)) {
+			return false;
+		}
+
+		setPieceCursor(false);
+
+		if (clicked) {
+			int16 pieceIdx = _closeupPiece;
+			closeCloseup();
+
+			if (_pieces[pieceIdx].kind != 3) {
+				pickUpPiece(pieceIdx);
+				updateCursorItem(mouseVP);
+			}
+		}
+
+		return true;
+	}
+
+	// What a click on a zone would put into it: the carried piece, or the one
+	// that stands for the inventory item in hand
+	int16 carried = _heldPiece != -1 ? _heldPiece : findInventoryPiece();
+	updateZoneHighlights(carried);
+
+	// The first zone under the mouse is the only one the piece can go into
+	int16 target = -1;
+	bool isOverZone = false;
+	for (uint i = 0; i < _zones.size() && carried != -1 && !isOverZone; ++i) {
+		const Zone &zone = _zones[i];
+		if (!zone.hotspot.contains(mouseVP)) {
+			continue;
+		}
+
+		isOverZone = true;
+		bool isClosed = zone.marksPlaced != 0 && zone.numHeld > (int16)zone.capacity;
+		if (!isClosed && zoneAccepts(_pieces[carried], (int16)i)) {
+			target = (int16)i;
+		}
+	}
+
+	// Over a full zone the piece in it is the one that is pointed at, and nothing
+	// is over a zone that turns the carried piece down
+	int16 hovered = -1;
+	for (uint i = 0; i < _pieces.size() && hovered == -1 && (target != -1 || !isOverZone); ++i) {
+		const Piece &piece = _pieces[i];
+		if (!piece.inUse || (int16)i == _heldPiece) {
+			continue;
+		}
+
+		if (target != -1) {
+			if (piece.assignedZone == target && _zones[target].numHeld >= (int16)_zones[target].capacity) {
+				hovered = (int16)i;
+			}
+
+			continue;
+		}
+
+		if (piece.locked || !piece.liveRect.contains(mouseVP)) {
+			continue;
+		}
+
+		// A piece in a zone can only be swapped for one that goes into the same zones
+		if (_heldPiece != -1 && piece.assignedZone != -1) {
+			int16 heldZoneID = _pieces[_heldPiece].zoneID;
+			if (heldZoneID != kAnyZone && heldZoneID != piece.zoneID) {
+				continue;
+			}
+		}
+
+		hovered = (int16)i;
+	}
+
+	if (hovered != -1) {
+		setPieceCursor(false);
+	} else if (target != -1) {
+		setPieceCursor(true);
+	} else if (_heldPiece != -1) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_carryCursorType, true, false);
+	} else {
+		return false;
+	}
+
+	if (!clicked) {
+		return true;
+	}
+
+	if (target != -1) {
+		// The piece that was in the zone makes room, and ends up being picked up below
+		if (hovered != -1) {
+			takeOutOfZone(hovered);
+			_pieces[hovered].liveRect = _pieces[hovered].destRect;
+			updatePieceRender(hovered);
+		}
+
+		if (_heldPiece == -1) {
+			// The inventory item is used up
+			NancySceneState.removeItemFromInventory(_pieces[carried].inventoryItem, false);
+		}
+
+		placePiece(carried, target, mouseVP);
+
+		if (_isSolved) {
+			updateZoneHighlights(-1);
+			updateCursorItem(mouseVP);
+			return true;
+		}
+	} else if (_heldPiece != -1) {
+		// A piece without a place of its own can only be put down in a zone
+		if (_pieces[_heldPiece].destRect.isEmpty()) {
+			return true;
+		}
+
+		g_nancy->_sound->loadSound(_putDownSound);
+		g_nancy->_sound->playSound(_putDownSound);
+		returnPiece(_heldPiece);
+	}
+
+	if (hovered != -1) {
+		if (!_pieces[hovered].closeupSrcRect.isEmpty()) {
+			g_nancy->_sound->loadSound(_pickupSound);
+			g_nancy->_sound->playSound(_pickupSound);
+			openCloseup(hovered);
+		} else {
+			pickUpPiece(hovered);
+		}
+	}
+
+	updateZoneHighlights(_closeupPiece == -1 ? _heldPiece : -1);
+	updateCursorItem(mouseVP);
+	return true;
+}
+
+void BuildPuzzle::handleExitInput(NancyInput &input, bool clicked) {
 	for (uint i = 0; i < _exitHotspots.size(); ++i) {
 		const ExitHotspot &exit = _exitHotspots[i];
 		if (exit.hotspot.isEmpty() ||
diff --git a/engines/nancy/action/puzzle/buildpuzzle.h b/engines/nancy/action/puzzle/buildpuzzle.h
index 9f7367141f8..a10889d0891 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.h
+++ b/engines/nancy/action/puzzle/buildpuzzle.h
@@ -28,6 +28,7 @@
 namespace Nancy {
 
 struct BuildPuzzleData;
+struct BuildBoardData;
 
 namespace Action {
 
@@ -35,6 +36,10 @@ namespace Action {
 // clothes design). Pieces are dragged into zones, and a zone is satisfied once
 // it holds the quantities its ingredient list asks for; a piece is placed by
 // being assigned a zone index rather than by matching a rect.
+//
+// Nancy 17 dropped the buttons and the counter. Its zones hold one piece that is
+// swapped for the one dropped on it, pieces can start out in a zone, and a board
+// can be kept under a name for GoldbergPuzzle to look at.
 class BuildPuzzle : public PuzzleRecord {
 public:
 	BuildPuzzle() : PuzzleRecord(7), _doneOverlay(0), _counter(97), _buttonPress(98), _cursorItem(99) {}
@@ -71,9 +76,22 @@ protected:
 
 	static const byte kConsumedKind = 1;	// this kind is taken by the zone, not copied
 
+	// What a piece's zoneID can be, other than the index of its only zone
+	enum PieceZone {
+		kAnyZone = -1,
+		kAnyZoneOfItsSize = -2	// Nancy17
+	};
+
+	struct ZoneHighlight : RenderObject {
+		ZoneHighlight() : RenderObject(0) {}
+
+		bool isViewportRelative() const override { return true; }
+	};
+
 	// A container pieces are dropped into.
 	struct Zone {
 		Common::Rect hotspot;
+		Common::Rect highlightSrc;	// Nancy17: shown on the zone while a piece that fits it is carried
 		uint16 capacity = 0;		// the zone counts as full at this many pieces
 		ZoneFill fill = kFillAbsorb;
 		byte marksPlaced = 0;
@@ -83,6 +101,7 @@ protected:
 		Common::Array<byte> counts;	// how many of each ingredient are in the zone
 		int16 numWrong = 0;			// pieces in here that no ingredient asked for
 		int16 numHeld = 0;
+		ZoneHighlight highlight;
 	};
 
 	// A measuring implement. Scooping with one adds `amount` of an ingredient at
@@ -105,6 +124,7 @@ protected:
 		bool isViewportRelative() const override { return true; }
 
 		Common::Rect srcRect;			// art while the piece sits at home
+		int16 startZone = -1;			// Nancy17: the zone the piece is in when the puzzle starts
 		Common::Rect destRect;			// where the piece is drawn
 		Common::Rect dragSrcRect;		// art while the piece is on the cursor; empty means srcRect
 		Common::Rect placedSrcRect;		// art once the piece is in a zone; empty means dragSrcRect
@@ -113,8 +133,10 @@ protected:
 		Common::Rect placedDestRect;	// exact spot in a zone; overrides the zone's fill mode
 		byte kind = 0;
 		Common::String imageName;	// only when kind == 3
-		int16 zoneID = -1;			// the only zone this piece may go in, -1 = any
+		int16 zoneID = kAnyZone;	// the only zone this piece may go in, or a PieceZone
 		int16 itemID = 0;			// index into the shared item state, 255 = none
+		int16 placedFlag = -1;		// Nancy17: set while the piece is in a zone
+		int16 inventoryItem = -1;	// Nancy17: holding this item is holding the piece
 		Common::Array<int16> holds;	// the scoops this piece can be taken with, empty = by hand
 		int16 fillVariant = 0;		// which of a scoop's two full images to show
 
@@ -143,6 +165,20 @@ protected:
 	int16 _pieceCursorType = 0;
 	int16 _heldPieceCursorType = 0;
 
+	// Nancy17 only shows the cursor above while the piece is over a zone it can
+	// go into, and this one everywhere else.
+	int16 _carryCursorType = 0;
+
+	bool _isNancy17 = false;
+
+	// Nancy17: the name the board is kept under. Without one the puzzle starts
+	// over every time.
+	Common::String _saveKey;
+
+	// False when no arrangement counts as a solution
+	bool _isSolvable = true;
+	RandomSoundBlock _solveSoundBlock;
+
 	Common::Array<Zone> _zones;
 	Common::Array<HoldSlot> _holds;
 	Common::Array<Piece> _pieces;
@@ -226,6 +262,22 @@ protected:
 	bool _isFailed = false;
 	bool _isInitialized = false;
 
+	void readDataNancy17(Common::SeekableReadStream &stream);
+	// Returns whether the mouse was over something of the board
+	bool handleBoardInputNancy17(const Common::Point &mouseVP, bool clicked);
+	void handleExitInput(NancyInput &input, bool clicked);
+	bool zoneAccepts(const Piece &piece, int16 zoneIdx) const;
+	// The piece that stands for the inventory item in hand, -1 if there is none
+	int16 findInventoryPiece() const;
+	// Light up the zones a piece can go into; -1 turns them all off
+	void updateZoneHighlights(int16 pieceIdx);
+	void placeAtStart(int16 pieceIdx);
+	void takeOutOfZone(int16 pieceIdx);
+	// Set the board up the way it was left, or the way it starts
+	void initBoard();
+	void saveBoard();
+	void restoreBoard(const BuildBoardData &boards);
+
 	void setPieceCursor(bool isHeld);
 	// Draw the carried art at the cursor, or hide it when nothing is carried
 	void updateCursorItem(const Common::Point &mouseVP);
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 86fa885632c..d6add2400b2 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -811,6 +811,53 @@ void BuildPuzzleData::synchronize(Common::Serializer &ser) {
 	syncInt16Array(ser, zones);
 }
 
+int16 BuildBoardData::Board::getPieceInZone(int16 zoneID) const {
+	for (const Piece &piece : pieces) {
+		if (piece.zoneID == zoneID) {
+			return piece.sourceID;
+		}
+	}
+
+	return -1;
+}
+
+void BuildBoardData::synchronize(Common::Serializer &ser) {
+	uint16 numBoards = boards.size();
+	ser.syncAsUint16LE(numBoards);
+
+	Common::Array<Common::String> names;
+	if (ser.isSaving()) {
+		for (auto &entry : boards) {
+			names.push_back(entry._key);
+		}
+	} else {
+		boards.clear();
+		names.resize(numBoards);
+	}
+
+	for (Common::String &name : names) {
+		ser.syncString(name);
+
+		Board &board = boards[name];
+		ser.syncAsSint16LE(board.placedCount);
+		ser.syncAsSint16LE(board.heldPiece);
+
+		uint16 numPieces = board.pieces.size();
+		ser.syncAsUint16LE(numPieces);
+		board.pieces.resize(numPieces);
+
+		for (Piece &piece : board.pieces) {
+			ser.syncAsSint16LE(piece.sourceID);
+			ser.syncAsSint16LE(piece.zoneID);
+			ser.syncAsSint16LE(piece.rect.left);
+			ser.syncAsSint16LE(piece.rect.top);
+			ser.syncAsSint16LE(piece.rect.right);
+			ser.syncAsSint16LE(piece.rect.bottom);
+			ser.syncAsByte(piece.isLocked);
+		}
+	}
+}
+
 void StreamData::synchronize(Common::Serializer &ser) {
 	uint16 numStreams = streams.size();
 	ser.syncAsUint16LE(numStreams);
@@ -896,6 +943,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new PhoneBookData();
 	case StreamData::getTag():
 		return new StreamData();
+	case BuildBoardData::getTag():
+		return new BuildBoardData();
 	case CollectionData::getTag():
 		return new CollectionData();
 	case BuildPuzzleData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index bbafe0c2163..20c8348416d 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -590,6 +590,31 @@ struct BuildPuzzleData : public PuzzleData {
 	Common::Array<int16> zones;		// per zone: numWrong, then one count per ingredient
 };
 
+// Nancy17 BuildPuzzle. The boards the puzzles were left with, by the name each
+// puzzle keeps its board under. GoldbergPuzzle looks at what was built.
+struct BuildBoardData : public PuzzleData {
+	struct Piece {
+		int16 sourceID = -1;	// index of the piece in the puzzle's record
+		int16 zoneID = -1;		// -1 when the piece is not in a zone
+		Common::Rect rect;		// where the piece sits
+		bool isLocked = false;
+	};
+
+	struct Board {
+		int16 placedCount = 0;
+		int16 heldPiece = -1;
+		Common::Array<Piece> pieces;
+
+		// The sourceID of the piece in a zone, -1 when the zone is empty
+		int16 getPieceInZone(int16 zoneID) const;
+	};
+
+	static constexpr uint32 getTag() { return MKTAG('B', 'L', 'D', 'B'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::HashMap<Common::String, Board, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> boards;
+};
+
 // Nancy17. The scenes that run in streams next to the main one, so they can be
 // started again after loading
 struct StreamData : public PuzzleData {


Commit: 860b885e23ac4caa85c8cd91c67267784574a7e0
    https://github.com/scummvm/scummvm/commit/860b885e23ac4caa85c8cd91c67267784574a7e0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:00+03:00

Commit Message:
NANCY: NANCY17: Set the flags movies have at their first and last frame

- A flag's frame can be -1 or -2, for the first and the last frame of
  the movie, like the frames the movie plays from and to
- Fixes the records that wait for a movie to end, e.g. the stages of
  the trap in scene 2052

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


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index efeeffa3bfa..0328368ad26 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -466,6 +466,15 @@ void PlaySecondaryMovie::resolveSentinelFrames() {
 		int frameCount = _decoder.getFrameCount();
 		_lastFrame = frameCount > 0 ? (uint16)(frameCount - 1) : 0;
 	}
+
+	// Nancy17 uses the same values for the frames its flags get set at
+	for (FlagAtFrame &frameFlag : _frameFlags) {
+		if (frameFlag.frameID == -1) {
+			frameFlag.frameID = 0;
+		} else if (frameFlag.frameID == -2) {
+			frameFlag.frameID = MAX<int>(_decoder.getFrameCount() - 1, 0);
+		}
+	}
 }
 
 void PlaySecondaryMovie::stopRandomNow() {
@@ -1452,7 +1461,12 @@ bool PlaySecondaryMovie::skip() {
 
 	// The flags of the frames that won't be played get set, as if the movie had
 	// reached its end. Other records may be waiting for them.
-	int curFrame = _decoder.isVideoLoaded() ? _decoder.getCurFrame() : -1;
+	int curFrame = -1;
+	if (_decoder.isVideoLoaded()) {
+		curFrame = _decoder.getCurFrame();
+		resolveSentinelFrames();
+	}
+
 	for (const FlagAtFrame &frameFlag : _frameFlags) {
 		bool isPending;
 		if (_playDirection == kPlayMovieReverse) {


Commit: 8ee30ea07b97c7ab7b8e4407dcf1ff759c943f9a
    https://github.com/scummvm/scummvm/commit/8ee30ea07b97c7ab7b8e4407dcf1ff759c943f9a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:02+03:00

Commit Message:
NANCY: NANCY17: Fix the carried piece in BuildPuzzle

- Draw it in viewport coordinates, like the pieces on the board; it
  showed above the cursor
- Convert the mouse position through the viewport, which is scaled
  from Nancy16 on

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


diff --git a/engines/nancy/action/puzzle/buildpuzzle.cpp b/engines/nancy/action/puzzle/buildpuzzle.cpp
index e7c8f6faef2..c811fa01e91 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/buildpuzzle.cpp
@@ -1308,8 +1308,8 @@ void BuildPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
-	const VIEW *viewData = GetEngineData(VIEW);
-	if (!viewData || !viewData->screenPosition.contains(input.mousePos)) {
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
 		return;
 	}
 
@@ -1317,8 +1317,9 @@ void BuildPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
-	Common::Point mouseVP(input.mousePos.x - viewData->screenPosition.left,
-							input.mousePos.y - viewData->screenPosition.top);
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point mouseVP = mouseRect.origin();
 	bool clicked = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
 
 	updateCursorItem(mouseVP);
diff --git a/engines/nancy/action/puzzle/buildpuzzle.h b/engines/nancy/action/puzzle/buildpuzzle.h
index a10889d0891..3406f1a2d16 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.h
+++ b/engines/nancy/action/puzzle/buildpuzzle.h
@@ -88,6 +88,13 @@ protected:
 		bool isViewportRelative() const override { return true; }
 	};
 
+	// Follows the mouse over the board, so it is placed like the board is
+	struct CursorItem : RenderObject {
+		CursorItem(uint16 zOrder) : RenderObject(zOrder) {}
+
+		bool isViewportRelative() const override { return true; }
+	};
+
 	// A container pieces are dropped into.
 	struct Zone {
 		Common::Rect hotspot;
@@ -251,7 +258,7 @@ protected:
 	int16 _placedCount = 0;
 
 	// Whatever is currently on the cursor: a scoop, or an ingredient.
-	RenderObject _cursorItem;
+	CursorItem _cursorItem;
 	int16 _activeHold = -1;
 	int8 _lastWrongFlag = -1;
 	int8 _lastSolvedFlag = -1;


Commit: f096d27d5ec47cf7e4f39709dc586a2a84bc8818
    https://github.com/scummvm/scummvm/commit/f096d27d5ec47cf7e4f39709dc586a2a84bc8818
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:04+03:00

Commit Message:
NANCY: NANCY17: Implement GoldbergPuzzle

- Record 208: checks the board a BuildPuzzle kept against a list of
  arrangements, and changes a stream's scene to the one of the first
  arrangement the board matches
- Streams can have their scene changed by scene name

Changed paths:
  A engines/nancy/action/puzzle/goldbergpuzzle.cpp
  A engines/nancy/action/puzzle/goldbergpuzzle.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/module.mk
    engines/nancy/state/scenestream.cpp
    engines/nancy/state/scenestream.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index d548f405738..767675a39d1 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -60,6 +60,7 @@
 #include "engines/nancy/action/puzzle/escapegridpuzzle.h"
 #include "engines/nancy/action/puzzle/fishingpuzzle.h"
 #include "engines/nancy/action/puzzle/foxgeesepuzzle.h"
+#include "engines/nancy/action/puzzle/goldbergpuzzle.h"
 #include "engines/nancy/action/puzzle/gridmappuzzle.h"
 #include "engines/nancy/action/puzzle/magicboxpuzzle.h"
 #include "engines/nancy/action/puzzle/matchpuzzle.h"
@@ -777,7 +778,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 207:
 		return new TangramPuzzle();
 	case 208:
-		return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kPiano);
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kPiano);
+		else
+			return new GoldbergPuzzle();
 	case 209:
 		return new TurningPuzzle();
 	case 210:
diff --git a/engines/nancy/action/puzzle/goldbergpuzzle.cpp b/engines/nancy/action/puzzle/goldbergpuzzle.cpp
new file mode 100644
index 00000000000..637a7a5eb86
--- /dev/null
+++ b/engines/nancy/action/puzzle/goldbergpuzzle.cpp
@@ -0,0 +1,95 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/state/scene.h"
+
+#include "engines/nancy/action/puzzle/goldbergpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void GoldbergPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _saveKey);
+	_matchFlag.label = stream.readSint16LE();
+	_matchFlag.flag = stream.readByte();
+	_notFirstFlag.label = stream.readSint16LE();
+	_notFirstFlag.flag = stream.readByte();
+	readFilename(stream, _streamName);
+
+	_arrangements.resize(stream.readUint16LE());
+	for (Arrangement &arrangement : _arrangements) {
+		arrangement.slots.resize(stream.readUint16LE());
+		for (Slot &slot : arrangement.slots) {
+			slot.zoneID = stream.readSint16LE();
+			slot.pieceID = stream.readSint16LE();
+		}
+
+		readFilename(stream, arrangement.sceneName);
+	}
+}
+
+int GoldbergPuzzle::findArrangement() const {
+	const BuildBoardData *boards = (const BuildBoardData *)NancySceneState.getPuzzleData(BuildBoardData::getTag());
+	if (!boards || !boards->boards.contains(_saveKey)) {
+		return -1;
+	}
+
+	const BuildBoardData::Board &board = boards->boards[_saveKey];
+
+	for (uint i = 0; i < _arrangements.size(); ++i) {
+		bool matches = true;
+
+		for (const Slot &slot : _arrangements[i].slots) {
+			if (board.getPieceInZone(slot.zoneID) != slot.pieceID) {
+				matches = false;
+				break;
+			}
+		}
+
+		if (matches) {
+			return i;
+		}
+	}
+
+	return -1;
+}
+
+void GoldbergPuzzle::execute() {
+	int index = findArrangement();
+
+	if (index != -1) {
+		NancySceneState.setEventFlag(_matchFlag);
+		NancySceneState.getStreams().changeScene(_streamName, _arrangements[index].sceneName, 0);
+	}
+
+	if (index != 0) {
+		NancySceneState.setEventFlag(_notFirstFlag);
+	}
+
+	_isDone = true;
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/goldbergpuzzle.h b/engines/nancy/action/puzzle/goldbergpuzzle.h
new file mode 100644
index 00000000000..871c0f0e8af
--- /dev/null
+++ b/engines/nancy/action/puzzle/goldbergpuzzle.h
@@ -0,0 +1,66 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_GOLDBERGPUZZLE_H
+#define NANCY_ACTION_GOLDBERGPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17. Looks at a board the build puzzle left behind, and sends a stream to
+// the scene of the first arrangement the board matches.
+class GoldbergPuzzle : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "GoldbergPuzzle"; }
+
+	static const int16 kEmptyZone = -1;
+
+	struct Slot {
+		int16 zoneID = 0;
+		int16 pieceID = kEmptyZone;
+	};
+
+	struct Arrangement {
+		Common::Array<Slot> slots;
+		Common::String sceneName;
+	};
+
+	// The index of the first arrangement the board matches, -1 if there is none
+	int findArrangement() const;
+
+	Common::String _saveKey;
+	FlagDescription _matchFlag;
+	FlagDescription _notFirstFlag;	// for every outcome but the first arrangement
+	Common::String _streamName;
+	Common::Array<Arrangement> _arrangements;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_GOLDBERGPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 409b5a818f2..656159468cf 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -46,6 +46,7 @@ MODULE_OBJS = \
   action/puzzle/escapegridpuzzle.o \
   action/puzzle/fishingpuzzle.o \
   action/puzzle/foxgeesepuzzle.o \
+  action/puzzle/goldbergpuzzle.o \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \
   action/puzzle/hangmanpuzzle.o \
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index 1c34a818f02..8acb6e76da4 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -277,6 +277,15 @@ void SceneStreamManager::changeScene(const Common::String &name, const SceneChan
 	}
 }
 
+void SceneStreamManager::changeScene(const Common::String &name, const Common::String &sceneName, uint16 frameID) {
+	SceneStream *stream = name.empty() ? _running : find(name);
+	if (stream) {
+		stream->changeScene(sceneName, frameID);
+	} else {
+		warning("Tried to change the scene of stream %s, which is not running", name.c_str());
+	}
+}
+
 Common::String SceneStreamManager::getSceneName(const Common::String &name) const {
 	if (name.equalsIgnoreCase(kMainStreamName)) {
 		return Common::String::format("s%u", NancySceneState.getMainSceneInfo().sceneID);
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
index 7eacfc4b5d1..9110237517a 100644
--- a/engines/nancy/state/scenestream.h
+++ b/engines/nancy/state/scenestream.h
@@ -125,6 +125,7 @@ public:
 	void begin(const Common::String &name, const Common::String &sceneName, uint16 frameID, const StreamFlags &flags);
 	void end(const Common::String &name);
 	void changeScene(const Common::String &name, const SceneChangeDescription &sceneDescription);
+	void changeScene(const Common::String &name, const Common::String &sceneName, uint16 frameID);
 	Common::String getSceneName(const Common::String &name) const;
 	bool isRunning(const Common::String &name) const { return find(name) != nullptr; }
 


Commit: 2638e295bdc6724d792a198e21ea361d1573c1ae
    https://github.com/scummvm/scummvm/commit/2638e295bdc6724d792a198e21ea361d1573c1ae
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:06+03:00

Commit Message:
NANCY: NANCY17: Implement DialSequencePuzzle

Record 187, the grandfather clock: the dial is turned and the set
button enters its position; the last positions entered are matched
against the solutions

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 767675a39d1..738c324d020 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -54,6 +54,7 @@
 #include "engines/nancy/action/puzzle/cubepuzzle.h"
 #include "engines/nancy/action/puzzle/cuttingpuzzle.h"
 #include "engines/nancy/action/puzzle/decoderpuzzle.h"
+#include "engines/nancy/action/puzzle/dialsequencepuzzle.h"
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
@@ -736,6 +737,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	// -- Nancy16 new puzzles (types 186-199) --
 	case 186:
 		return new FoxGeesePuzzle();
+	case 187:	// Nancy17
+		return new DialSequencePuzzle();
 	case 189:
 		return new QuizPuzzle();		// Moved from 231
 	case 192:
diff --git a/engines/nancy/action/puzzle/dialsequencepuzzle.cpp b/engines/nancy/action/puzzle/dialsequencepuzzle.cpp
new file mode 100644
index 00000000000..f90a33e2839
--- /dev/null
+++ b/engines/nancy/action/puzzle/dialsequencepuzzle.cpp
@@ -0,0 +1,281 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/dialsequencepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+uint DialSequencePuzzle::countPositions(const Common::String &positions) {
+	uint count = 1;
+	for (uint i = 0; i < positions.size(); ++i) {
+		if (positions[i] == ',') {
+			++count;
+		}
+	}
+
+	return count;
+}
+
+void DialSequencePuzzle::readData(Common::SeekableReadStream &stream) {
+	_setCursor = stream.readUint16LE();
+	_forwardCursor = stream.readUint16LE();
+	_backCursor = stream.readUint16LE();
+
+	readFilename(stream, _imageName);
+
+	_dials.resize(stream.readUint16LE());
+	for (Dial &dial : _dials) {
+		readFilename(stream, dial.name);
+		dial.canWrapAround = stream.readByte();
+		readRect(stream, dial.forwardHotspot);
+		readRect(stream, dial.backHotspot);
+
+		dial.srcs.resize(stream.readUint16LE());
+		for (Common::Rect &src : dial.srcs) {
+			readRect(stream, src);
+		}
+	}
+
+	readRect(stream, _dialDest);
+	readFilename(stream, _setMovieName);
+	readRect(stream, _setDest);
+
+	_setSound.readData(stream);
+	_turnSound.readData(stream);
+
+	_solutions.resize(stream.readUint16LE());
+	for (Solution &solution : _solutions) {
+		solution.condition.label = stream.readSint16LE();
+		solution.condition.flag = stream.readByte();
+
+		// A dial name and its positions each count as one
+		solution.sequences.resize(stream.readUint16LE() / 2);
+		for (Sequence &sequence : solution.sequences) {
+			readFilename(stream, sequence.dialName);
+			readFilename(stream, sequence.positions);
+			_maxPositions = MAX(_maxPositions, countPositions(sequence.positions));
+		}
+
+		solution.scene._sceneChange.sceneID = stream.readUint16LE();
+		solution.scene._sceneChange.frameID = stream.readUint16LE();
+		solution.scene._sceneChange.continueSceneSound = kContinueSceneSound;
+		solution.scene._flag.label = stream.readSint16LE();
+		solution.scene._flag.flag = stream.readByte();
+		solution.sound.readData(stream);
+	}
+
+	readExitHotspot(stream);
+}
+
+void DialSequencePuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	if (!_setMovieName.empty()) {
+		_setMovie.loadFile(_setMovieName);
+	}
+
+	redraw();
+}
+
+void DialSequencePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	if (_isSetMovieShown) {
+		_setMovie.drawFrame(_drawSurface, _setDest.origin());
+	}
+
+	for (const Dial &dial : _dials) {
+		if (dial.position < dial.srcs.size()) {
+			_drawSurface.blitFrom(_image, dial.srcs[dial.position], _dialDest.origin());
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+bool DialSequencePuzzle::turnDial(Dial &dial, const Common::Point &mousePos) {
+	const uint numPositions = dial.srcs.size();
+	if (numPositions == 0) {
+		return false;
+	}
+
+	if (dial.forwardHotspot.contains(mousePos) && (dial.canWrapAround || dial.position + 1 < numPositions)) {
+		dial.position = (dial.position + 1) % numPositions;
+		return true;
+	}
+
+	if (dial.backHotspot.contains(mousePos) && (dial.canWrapAround || dial.position > 0)) {
+		dial.position = (dial.position + numPositions - 1) % numPositions;
+		return true;
+	}
+
+	return false;
+}
+
+bool DialSequencePuzzle::isSolution(const Solution &solution) const {
+	if (solution.condition.label != -1 &&
+			!NancySceneState.getEventFlag(solution.condition.label, solution.condition.flag)) {
+		return false;
+	}
+
+	for (const Sequence &sequence : solution.sequences) {
+		bool matches = false;
+
+		for (const Dial &dial : _dials) {
+			if (dial.name.equalsIgnoreCase(sequence.dialName)) {
+				matches = dial.entered == sequence.positions;
+				break;
+			}
+		}
+
+		if (!matches) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+void DialSequencePuzzle::enterPositions() {
+	for (Dial &dial : _dials) {
+		if (!dial.entered.empty()) {
+			dial.entered += ',';
+		}
+
+		dial.entered += Common::String::format("%u", dial.position);
+
+		// Only as many positions as the longest solution has are kept
+		if (countPositions(dial.entered) > _maxPositions) {
+			dial.entered = dial.entered.substr(dial.entered.findFirstOf(',') + 1);
+		}
+	}
+
+	for (uint i = 0; i < _solutions.size() && _solvedSolution == -1; ++i) {
+		if (isSolution(_solutions[i])) {
+			_solvedSolution = i;
+			_state = kActionTrigger;
+		}
+	}
+
+	if (_setMovie.isVideoLoaded()) {
+		_setMovie.playRange(0, _setMovie.getFrameCount() - 1);
+		_isSetMovieShown = true;
+	}
+
+	playSoundBlock(_setSound);
+	redraw();
+}
+
+void DialSequencePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_setMovie.isRangePlaying() && _setMovie.update()) {
+			redraw();
+		}
+
+		break;
+	case kActionTrigger:
+		if (_solvedSolution != -1) {
+			playSoundBlock(_solutions[_solvedSolution].sound);
+			_solutions[_solvedSolution].scene.execute();
+		} else {
+			_exitScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void DialSequencePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	const bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+	if (hoverExitHotspot(input)) {
+		if (clicked) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	for (Dial &dial : _dials) {
+		if (dial.forwardHotspot.contains(pos)) {
+			setDataCursor(_forwardCursor);
+		} else if (dial.backHotspot.contains(pos)) {
+			setDataCursor(_backCursor);
+		} else {
+			continue;
+		}
+
+		if (clicked && turnDial(dial, pos)) {
+			playSoundBlock(_turnSound);
+			redraw();
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	// The button can't be pressed again while it is moving
+	if (_setDest.contains(pos) && !_setMovie.isRangePlaying()) {
+		setDataCursor(_setCursor);
+
+		if (clicked) {
+			enterPositions();
+			input.eatMouseInput();
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/dialsequencepuzzle.h b/engines/nancy/action/puzzle/dialsequencepuzzle.h
new file mode 100644
index 00000000000..a466e1193bb
--- /dev/null
+++ b/engines/nancy/action/puzzle/dialsequencepuzzle.h
@@ -0,0 +1,109 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_DIALSEQUENCEPUZZLE_H
+#define NANCY_ACTION_DIALSEQUENCEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 grandfather clock. Each named dial is turned to a position, and a button
+// enters the positions of all of them. The positions entered last make up the
+// combination. Every solution has a scene of its own, and can ask for an event flag.
+class DialSequencePuzzle : public PuzzleRecord {
+public:
+	DialSequencePuzzle() : PuzzleRecord(7) {}
+	virtual ~DialSequencePuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "DialSequencePuzzle"; }
+
+	struct Dial {
+		Common::String name;
+		bool canWrapAround = false;
+		Common::Rect forwardHotspot;
+		Common::Rect backHotspot;
+		Common::Array<Common::Rect> srcs;	// one per position
+
+		uint position = 0;
+		Common::String entered;	// the positions entered so far, separated by commas
+	};
+
+	struct Sequence {
+		Common::String dialName;
+		Common::String positions;	// separated by commas
+	};
+
+	struct Solution {
+		FlagDescription condition;	// has to hold for the solution to count; label -1 = always
+		Common::Array<Sequence> sequences;
+		SceneChangeWithFlag scene;
+		RandomSoundBlock sound;
+	};
+
+	static uint countPositions(const Common::String &positions);
+
+	bool turnDial(Dial &dial, const Common::Point &mousePos);
+	void enterPositions();
+	bool isSolution(const Solution &solution) const;
+	void redraw();
+
+	// -- File data --
+	uint16 _setCursor = 0;
+	uint16 _forwardCursor = 0;
+	uint16 _backCursor = 0;
+
+	Common::Array<Dial> _dials;
+	Common::Rect _dialDest;
+
+	// The button that enters the positions is a movie, played on every press
+	Common::Path _setMovieName;
+	Common::Rect _setDest;
+
+	RandomSoundBlock _setSound;
+	RandomSoundBlock _turnSound;
+
+	Common::Array<Solution> _solutions;
+
+	// -- Runtime --
+	MoviePlayer _setMovie;
+	bool _isSetMovieShown = false;
+	uint _maxPositions = 0;	// the length of the longest solution
+	int _solvedSolution = -1;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_DIALSEQUENCEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 656159468cf..50bb2334b18 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -40,6 +40,7 @@ MODULE_OBJS = \
   action/puzzle/cubepuzzle.o \
   action/puzzle/cuttingpuzzle.o \
   action/puzzle/decoderpuzzle.o \
+  action/puzzle/dialsequencepuzzle.o \
   action/puzzle/dotconnectpuzzle.o \
   action/puzzle/drivingpuzzle.o \
   action/puzzle/dropsortpuzzle.o \


Commit: 17441970376314ecc5d482eb173829f391ad4e8a
    https://github.com/scummvm/scummvm/commit/17441970376314ecc5d482eb173829f391ad4e8a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:08+03:00

Commit Message:
NANCY: NANCY14: Draw the BuildPuzzle overlays in viewport coordinates

The done overlay, the counter and the pressed button are placed with
the coordinates of the board, like the carried piece.

Fixes the placement of picked up pieces in Nancy16.

Changed paths:
    engines/nancy/action/puzzle/buildpuzzle.h


diff --git a/engines/nancy/action/puzzle/buildpuzzle.h b/engines/nancy/action/puzzle/buildpuzzle.h
index 3406f1a2d16..06f127a6de7 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.h
+++ b/engines/nancy/action/puzzle/buildpuzzle.h
@@ -88,9 +88,9 @@ protected:
 		bool isViewportRelative() const override { return true; }
 	};
 
-	// Follows the mouse over the board, so it is placed like the board is
-	struct CursorItem : RenderObject {
-		CursorItem(uint16 zOrder) : RenderObject(zOrder) {}
+	// Something drawn over the board, which is placed like the board is
+	struct BoardObject : RenderObject {
+		BoardObject(uint16 zOrder) : RenderObject(zOrder) {}
 
 		bool isViewportRelative() const override { return true; }
 	};
@@ -249,16 +249,16 @@ protected:
 	};
 	HeldButton _heldButton = kNoButton;
 	uint32 _buttonTimerEnd = 0;
-	RenderObject _doneOverlay;
-	RenderObject _counter;
+	BoardObject _doneOverlay;
+	BoardObject _counter;
 	int16 _shownCounterValue = -1;
 	Common::Rect _closeupDest;
-	RenderObject _buttonPress;
+	BoardObject _buttonPress;
 
 	int16 _placedCount = 0;
 
 	// Whatever is currently on the cursor: a scoop, or an ingredient.
-	CursorItem _cursorItem;
+	BoardObject _cursorItem;
 	int16 _activeHold = -1;
 	int8 _lastWrongFlag = -1;
 	int8 _lastSolvedFlag = -1;


Commit: bad38ab6c6141d0b11091381a6f8b950f96bfe81
    https://github.com/scummvm/scummvm/commit/bad38ab6c6141d0b11091381a6f8b950f96bfe81
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:09+03:00

Commit Message:
NANCY: Drop the factory checks for unused record types

A game that doesn't use a type needs no nullptr for it: 30, 46, 47 and
134 create the same record for every game again

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 738c324d020..c25ca79751e 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -263,10 +263,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new HideCursor();	// Moved from 27
 	case 30:	// Nancy11
-		if (g_nancy->getGameType() <= kGameTypeNancy16)
-			return new StopPlayerScrolling();
-		else
-			return nullptr;	// SetCursor, which the game doesn't use
+		return new StopPlayerScrolling();
 	case 31:	// Nancy11
 		return new StartPlayerScrolling();
 	case 32:	// Nancy10
@@ -301,19 +298,9 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 44:	// Nancy14
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kMovieWithVolume);
 	case 46:	// Nancy11
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new PlayRandomMovieControl();
-		else if (g_nancy->getGameType() <= kGameTypeNancy16)
-			return nullptr;	// Not used by the game
-		else
-			return new PlayRandomMovieControl();
+		return new PlayRandomMovieControl();
 	case 47:	// Nancy14
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new PlaySecondaryMovie(PlaySecondaryMovie::kInteractiveMovie);
-		else if (g_nancy->getGameType() <= kGameTypeNancy16)
-			return nullptr;	// Not used by the game
-		else
-			return new PlaySecondaryMovie(PlaySecondaryMovie::kInteractiveMovie);
+		return new PlaySecondaryMovie(PlaySecondaryMovie::kInteractiveMovie);
 	case 48:	// Nancy16
 		return new PlaySecondaryMovie(PlaySecondaryMovie::kMovieWithRandomPosition);
 	case 50:
@@ -527,12 +514,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 134:	// Nancy15
 		// Switches the active player character (Nancy / Frank / Joe), the
 		// dual-protagonist mechanic new to The Creature of Kapu Cave
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new PlayChar();
-		else if (g_nancy->getGameType() <= kGameTypeNancy16)
-			return nullptr;	// Nancy16 has a single player character
-		else
-			return new PlayChar();	// Nancy17 switches between Nancy and Bess
+		return new PlayChar();
 	case 140:
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new SetVolume();			// Moved to 149 in Nancy9, empty slot in Nancy9-11


Commit: 92b8af48355b303dfd39b46dc4868f14601c7fb3
    https://github.com/scummvm/scummvm/commit/92b8af48355b303dfd39b46dc4868f14601c7fb3
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:12+03:00

Commit Message:
NANCY: NANCY17: Implement TileCyclePuzzle

- Record 171, the glass eyes: a click turns a tile, and the tiles
  linked to it, on to their next state
- Every goal sets its flag by whether the tiles are in its states, so
  the puzzle never ends by itself

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index c25ca79751e..a259d0179b1 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -108,6 +108,7 @@
 #include "engines/nancy/action/puzzle/stepobjectspuzzle.h"
 #include "engines/nancy/action/puzzle/tangrampuzzle.h"
 #include "engines/nancy/action/puzzle/telephone.h"
+#include "engines/nancy/action/puzzle/tilecyclepuzzle.h"
 #include "engines/nancy/action/puzzle/towerpuzzle.h"
 #include "engines/nancy/action/puzzle/turningpuzzle.h"
 #include "engines/nancy/action/puzzle/twodialpuzzle.h"
@@ -646,7 +647,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return nullptr;
 	case 171:
-		return new TurningPuzzle();	// Moved from 209
+		if (g_nancy->getGameType() <= kGameTypeNancy16)
+			return new TurningPuzzle();	// Moved from 209
+		else
+			return new TileCyclePuzzle();
 	case 172:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new BlocksPuzzle();
diff --git a/engines/nancy/action/puzzle/tilecyclepuzzle.cpp b/engines/nancy/action/puzzle/tilecyclepuzzle.cpp
new file mode 100644
index 00000000000..400fa602805
--- /dev/null
+++ b/engines/nancy/action/puzzle/tilecyclepuzzle.cpp
@@ -0,0 +1,251 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/tilecyclepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void TileCyclePuzzle::readData(Common::SeekableReadStream &stream) {
+	stream.skip(33);	// The name the tiles get saved under; empty in the game data
+	_hoverCursor = stream.readUint16LE();
+	_turnTime = (uint32)(stream.readDoubleLE() * 1000);
+	readFilename(stream, _imageName);
+
+	_templates.resize(stream.readUint16LE());
+	for (Template &tileTemplate : _templates) {
+		readFilename(stream, tileTemplate.name);
+
+		tileTemplate.states.resize(stream.readUint16LE());
+		for (Common::Array<Common::Rect> &frames : tileTemplate.states) {
+			frames.resize(stream.readUint16LE());
+			for (Common::Rect &frame : frames) {
+				readRect(stream, frame);
+			}
+		}
+	}
+
+	_tiles.resize(stream.readUint16LE());
+	for (Tile &tile : _tiles) {
+		readFilename(stream, tile.name);
+
+		Common::String templateName;
+		readFilename(stream, templateName);
+		for (uint i = 0; i < _templates.size(); ++i) {
+			if (_templates[i].name.equalsIgnoreCase(templateName)) {
+				tile.templateID = i;
+				break;
+			}
+		}
+
+		if (tile.templateID == -1) {
+			error("TileCyclePuzzle: tile %s has unknown template %s", tile.name.c_str(), templateName.c_str());
+		}
+
+		tile.linkedTiles.resize(stream.readUint16LE());
+		for (Common::String &linkedTile : tile.linkedTiles) {
+			readFilename(stream, linkedTile);
+		}
+
+		readRect(stream, tile.dest);
+		tile.state = stream.readUint32LE();
+		tile.presentFlag = stream.readSint16LE();
+	}
+
+	_clickSound.readData(stream);
+	_turnedSound.readData(stream);
+
+	_goals.resize(stream.readUint16LE());
+	for (Goal &goal : _goals) {
+		goal.tileStates.resize(stream.readUint16LE());
+		for (TileState &tileState : goal.tileStates) {
+			readFilename(stream, tileState.tileName);
+			tileState.state = stream.readSint32LE();
+		}
+
+		goal.needsAll = stream.readByte();
+		goal.flag.label = stream.readSint16LE();
+		goal.flag.flag = stream.readByte();
+	}
+
+	readExitHotspot(stream);
+}
+
+void TileCyclePuzzle::init() {
+	initViewportSurface();
+	loadImage();
+	redraw();
+}
+
+bool TileCyclePuzzle::isTilePresent(const Tile &tile) const {
+	return tile.presentFlag == -1 || NancySceneState.getEventFlag(tile.presentFlag, g_nancy->_true);
+}
+
+TileCyclePuzzle::Tile *TileCyclePuzzle::findTile(const Common::String &name) {
+	for (Tile &tile : _tiles) {
+		if (tile.name.equalsIgnoreCase(name)) {
+			return &tile;
+		}
+	}
+
+	return nullptr;
+}
+
+void TileCyclePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (const Tile &tile : _tiles) {
+		if (!isTilePresent(tile)) {
+			continue;
+		}
+
+		// The game data only has states of a single frame
+		const Common::Array<Common::Array<Common::Rect> > &states = _templates[tile.templateID].states;
+		if (tile.state < states.size() && !states[tile.state].empty()) {
+			_drawSurface.blitFrom(_image, states[tile.state][0], tile.dest.origin());
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void TileCyclePuzzle::finishTurn() {
+	_isTurning = false;
+	playSoundBlock(_turnedSound);
+
+	for (Tile &tile : _tiles) {
+		if (tile.isTurning) {
+			tile.isTurning = false;
+			tile.state = (tile.state + 1) % _templates[tile.templateID].states.size();
+		}
+	}
+
+	updateGoals();
+	redraw();
+}
+
+void TileCyclePuzzle::updateGoals() {
+	for (const Goal &goal : _goals) {
+		bool isMet = goal.needsAll;
+
+		for (const TileState &tileState : goal.tileStates) {
+			const Tile *tile = findTile(tileState.tileName);
+			bool isInState = tile && (int32)tile->state == tileState.state && isTilePresent(*tile);
+
+			if (goal.needsAll) {
+				isMet = isMet && isInState;
+			} else {
+				isMet = isMet || isInState;
+			}
+		}
+
+		const bool value = isMet == (goal.flag.flag != 0);
+		NancySceneState.setEventFlag(goal.flag.label, value ? g_nancy->_true : g_nancy->_false);
+	}
+}
+
+void TileCyclePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested) {
+			_state = kActionTrigger;
+		} else if (_isTurning && g_nancy->getTotalPlayTime() >= _turnEndTime) {
+			finishTurn();
+		}
+
+		break;
+	case kActionTrigger:
+		_exitScene.execute();
+		finishExecution();
+		break;
+	}
+}
+
+void TileCyclePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	const bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+	if (hoverExitHotspot(input)) {
+		if (clicked) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	// The tiles can't be clicked while some are turning
+	if (_isTurning) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	for (Tile &tile : _tiles) {
+		if (!tile.dest.contains(pos) || !isTilePresent(tile)) {
+			continue;
+		}
+
+		setDataCursor(_hoverCursor);
+
+		if (clicked) {
+			playSoundBlock(_clickSound);
+
+			tile.isTurning = true;
+			for (const Common::String &name : tile.linkedTiles) {
+				Tile *linkedTile = findTile(name);
+				if (linkedTile) {
+					linkedTile->isTurning = true;
+				}
+			}
+
+			_isTurning = true;
+			_turnEndTime = g_nancy->getTotalPlayTime() + _turnTime;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/tilecyclepuzzle.h b/engines/nancy/action/puzzle/tilecyclepuzzle.h
new file mode 100644
index 00000000000..fdf03fddc58
--- /dev/null
+++ b/engines/nancy/action/puzzle/tilecyclepuzzle.h
@@ -0,0 +1,114 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_TILECYCLEPUZZLE_H
+#define NANCY_ACTION_TILECYCLEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 grid of glass eyes. A click moves a tile, and the tiles linked to it, on
+// to their next state. The puzzle never ends by itself: after every turn it sets
+// the event flag of each of its goals to whether the tiles are in the goal's states.
+// A tile can depend on an event flag, and is not there until that is set.
+//
+// The game has a single one, in scene 4450. Every eye is found somewhere in the
+// game, and has a flag of its own. By row, from left to right:
+//   A: 2071 2070 2072 2066 2077
+//   B: 2075 2082 2057 2058 2056
+//   C: 2059 2060 2080 2079 2081
+//   D: 2076 2055 2067 2068 2062
+//   E: 2063 2064 2065 2074 2073
+class TileCyclePuzzle : public PuzzleRecord {
+public:
+	TileCyclePuzzle() : PuzzleRecord(7) {}
+	virtual ~TileCyclePuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "TileCyclePuzzle"; }
+
+	// The states a kind of tile goes through; each is a list of frames
+	struct Template {
+		Common::String name;
+		Common::Array<Common::Array<Common::Rect> > states;
+	};
+
+	struct Tile {
+		Common::String name;
+		int templateID = -1;
+		Common::Array<Common::String> linkedTiles;
+		Common::Rect dest;
+		int16 presentFlag = -1;
+
+		uint state = 0;
+		bool isTurning = false;
+	};
+
+	struct TileState {
+		Common::String tileName;
+		int32 state = 0;
+	};
+
+	struct Goal {
+		Common::Array<TileState> tileStates;
+		bool needsAll = false;	// otherwise one tile in its state is enough
+		FlagDescription flag;	// gets the opposite value while the goal is not met
+	};
+
+	bool isTilePresent(const Tile &tile) const;
+	Tile *findTile(const Common::String &name);
+	void finishTurn();
+	void updateGoals();
+	void redraw();
+
+	// -- File data --
+	uint16 _hoverCursor = 0;
+	uint32 _turnTime = 0;	// in milliseconds
+
+	Common::Array<Template> _templates;
+	Common::Array<Tile> _tiles;
+
+	RandomSoundBlock _clickSound;
+	RandomSoundBlock _turnedSound;
+
+	Common::Array<Goal> _goals;
+
+	// -- Runtime --
+	bool _isTurning = false;
+	uint32 _turnEndTime = 0;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_TILECYCLEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 50bb2334b18..0292f76cd45 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -94,6 +94,7 @@ MODULE_OBJS = \
   action/puzzle/stepobjectspuzzle.o \
   action/puzzle/tangrampuzzle.o \
   action/puzzle/telephone.o \
+  action/puzzle/tilecyclepuzzle.o \
   action/puzzle/towerpuzzle.o \
   action/puzzle/turningpuzzle.o \
   action/puzzle/twodialpuzzle.o \


Commit: c7b356ffa7c1247442530afd89ee27aa531c953b
    https://github.com/scummvm/scummvm/commit/c7b356ffa7c1247442530afd89ee27aa531c953b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:17+03:00

Commit Message:
NANCY: NANCY17: Implement BookSortPuzzle

- Record 201, the book shelf: the books lying around are packed into a
  grid of small cells, each book covering a block of them
- A carried book snaps to the grid while it is over it
- The puzzle is solved once no book is left outside the grid

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index a259d0179b1..f820421982d 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -48,6 +48,7 @@
 #include "engines/nancy/action/puzzle/buildpuzzle.h"
 #include "engines/nancy/action/puzzle/bulpuzzle.h"
 #include "engines/nancy/action/puzzle/bombpuzzle.h"
+#include "engines/nancy/action/puzzle/booksortpuzzle.h"
 #include "engines/nancy/action/puzzle/cardgamepuzzle.h"
 #include "engines/nancy/action/puzzle/collisionpuzzle.h"
 #include "engines/nancy/action/puzzle/cookingpuzzle.h"
@@ -753,7 +754,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new Rain();
 	case 201:
-		return new TowerPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new TowerPuzzle();
+		else
+			return new BookSortPuzzle();
 	case 202:
 		return new BombPuzzle();
 	case 203:
diff --git a/engines/nancy/action/puzzle/booksortpuzzle.cpp b/engines/nancy/action/puzzle/booksortpuzzle.cpp
new file mode 100644
index 00000000000..8b5eba5ea72
--- /dev/null
+++ b/engines/nancy/action/puzzle/booksortpuzzle.cpp
@@ -0,0 +1,312 @@
+/* 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/algorithm.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/booksortpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void BookSortPuzzle::readData(Common::SeekableReadStream &stream) {
+	// The sizes of the books are counted in cells, so the rects are read as they are
+	readExclusiveRect(stream, _firstCell);
+	_numColumns = stream.readSint32LE();
+	_numRows = stream.readSint32LE();
+	stream.skip(1);	// Marks the cells that keep a carried book from fitting; off in the game data
+
+	readFilename(stream, _imageName);
+
+	_placeCursor = stream.readUint16LE();
+	_blockedCursor = stream.readUint16LE();
+	_pickUpCursor = stream.readUint16LE();
+	_carryCursor = stream.readUint16LE();
+
+	const int cellWidth = _firstCell.width();
+	const int cellHeight = _firstCell.height();
+
+	_books.resize(stream.readUint16LE());
+	for (Book &book : _books) {
+		book.attachPoint.x = stream.readSint16LE();
+		book.attachPoint.y = stream.readSint16LE();
+		readExclusiveRect(stream, book.src);
+		readExclusiveRect(stream, book.home);
+
+		book.attachPoint.x -= book.src.left;
+		book.attachPoint.y -= book.src.top;
+
+		// The parts of the picture that take up room
+		uint16 numCovered = stream.readUint16LE();
+		for (uint i = 0; i < numCovered; ++i) {
+			Common::Rect covered;
+			readExclusiveRect(stream, covered);
+			covered.translate(-book.src.left, -book.src.top);
+
+			for (int y = covered.top / cellHeight; y < covered.bottom / cellHeight; ++y) {
+				for (int x = covered.left / cellWidth; x < covered.right / cellWidth; ++x) {
+					Common::Point cell(x, y);
+					if (Common::find(book.cells.begin(), book.cells.end(), cell) == book.cells.end()) {
+						book.cells.push_back(cell);
+					}
+				}
+			}
+		}
+	}
+
+	_pickUpSound.readData(stream);
+	_putBackSound.readData(stream);
+	_placeSound.readData(stream);
+	_takeOutSound.readData(stream);
+	_blockedSound.readData(stream);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_solveSoundBlock.readData(stream);
+
+	readExitHotspot(stream);
+}
+
+void BookSortPuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	_grid.resize(_numColumns * _numRows);
+	for (int16 &cell : _grid) {
+		cell = kNoBook;
+	}
+
+	for (uint i = 0; i < _books.size(); ++i) {
+		_tableBooks.push_back(i);
+	}
+
+	redraw();
+}
+
+Common::Point BookSortPuzzle::getBookCell(const Book &book, uint cellID, const Common::Point &gridPos) const {
+	return Common::Point(book.cells[cellID].x + gridPos.x - book.attachPoint.x / _firstCell.width(),
+						book.cells[cellID].y + gridPos.y - book.attachPoint.y / _firstCell.height());
+}
+
+bool BookSortPuzzle::canPlaceBook(const Book &book, const Common::Point &gridPos) {
+	for (uint i = 0; i < book.cells.size(); ++i) {
+		Common::Point cell = getBookCell(book, i, gridPos);
+		if (!isInGrid(cell) || gridAt(cell) != kNoBook) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+void BookSortPuzzle::setBookCells(uint bookID, int16 value) {
+	const Book &book = _books[bookID];
+	for (uint i = 0; i < book.cells.size(); ++i) {
+		gridAt(getBookCell(book, i, book.gridPos)) = value;
+	}
+}
+
+int16 BookSortPuzzle::findBookOnTable(const Common::Point &pos) const {
+	for (uint bookID : _tableBooks) {
+		const Book &book = _books[bookID];
+		if (!book.home.contains(pos)) {
+			continue;
+		}
+
+		Common::Point cell((pos.x - book.home.left) / _firstCell.width(), (pos.y - book.home.top) / _firstCell.height());
+		if (Common::find(book.cells.begin(), book.cells.end(), cell) != book.cells.end()) {
+			return bookID;
+		}
+	}
+
+	return kNoBook;
+}
+
+void BookSortPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (uint bookID : _tableBooks) {
+		_drawSurface.blitFrom(_image, _books[bookID].src, _books[bookID].home.origin());
+	}
+
+	const int cellWidth = _firstCell.width();
+	const int cellHeight = _firstCell.height();
+
+	for (const Book &book : _books) {
+		if (book.isPlaced) {
+			Common::Point topLeft = getBookCell(book, 0, book.gridPos) - book.cells[0];
+			_drawSurface.blitFrom(_image, book.src,
+				Common::Point(_firstCell.left + topLeft.x * cellWidth, _firstCell.top + topLeft.y * cellHeight));
+		}
+	}
+
+	if (_heldBook != kNoBook) {
+		const Book &book = _books[_heldBook];
+
+		if (_isMouseInGrid) {
+			// Over the grid the book shows where it would go
+			Common::Point topLeft = getBookCell(book, 0, _mouseCell) - book.cells[0];
+			_drawSurface.blitFrom(_image, book.src,
+				Common::Point(_firstCell.left + topLeft.x * cellWidth, _firstCell.top + topLeft.y * cellHeight));
+		} else {
+			_drawSurface.blitFrom(_image, book.src, _mousePos - book.attachPoint);
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void BookSortPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested || _isSolved) {
+			_state = kActionTrigger;
+		}
+
+		break;
+	case kActionTrigger:
+		if (_isSolved) {
+			playSoundBlock(_solveSoundBlock);
+			_solveScene.execute();
+		} else {
+			_exitScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void BookSortPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _exitRequested || _isSolved) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	const bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	_mousePos = mouseRect.origin();
+
+	_mouseCell = Common::Point((_mousePos.x - _firstCell.left) / _firstCell.width(), (_mousePos.y - _firstCell.top) / _firstCell.height());
+	_isMouseInGrid = _mousePos.x >= _firstCell.left && _mousePos.y >= _firstCell.top && isInGrid(_mouseCell);
+
+	if (_heldBook != kNoBook) {
+		Book &book = _books[_heldBook];
+
+		if (!_isMouseInGrid) {
+			setDataCursor(_carryCursor, false);
+
+			if (clicked) {
+				// Back to where it was lying
+				_tableBooks.push_back(_heldBook);
+				_heldBook = kNoBook;
+				playSoundBlock(_putBackSound);
+				input.eatMouseInput();
+			}
+		} else if (canPlaceBook(book, _mouseCell)) {
+			setDataCursor(_placeCursor);
+
+			if (clicked) {
+				book.isPlaced = true;
+				book.gridPos = _mouseCell;
+				setBookCells(_heldBook, _heldBook);
+				_heldBook = kNoBook;
+				playSoundBlock(_placeSound);
+				input.eatMouseInput();
+
+				_isSolved = _tableBooks.empty();
+			}
+		} else {
+			setDataCursor(_blockedCursor, false);
+
+			if (clicked) {
+				playSoundBlock(_blockedSound);
+				input.eatMouseInput();
+			}
+		}
+
+		redraw();
+		return;
+	}
+
+	if (_isMouseInGrid && gridAt(_mouseCell) != kNoBook) {
+		setDataCursor(_pickUpCursor);
+
+		if (clicked) {
+			_heldBook = gridAt(_mouseCell);
+			setBookCells(_heldBook, kNoBook);
+			_books[_heldBook].isPlaced = false;
+			playSoundBlock(_takeOutSound);
+			input.eatMouseInput();
+			redraw();
+		}
+
+		return;
+	}
+
+	int16 bookID = findBookOnTable(_mousePos);
+	if (bookID != kNoBook) {
+		setDataCursor(_pickUpCursor);
+
+		if (clicked) {
+			for (uint i = 0; i < _tableBooks.size(); ++i) {
+				if (_tableBooks[i] == (uint)bookID) {
+					_tableBooks.remove_at(i);
+					break;
+				}
+			}
+
+			_heldBook = bookID;
+			playSoundBlock(_pickUpSound);
+			input.eatMouseInput();
+			redraw();
+		}
+
+		return;
+	}
+
+	if (hoverExitHotspot(input) && clicked) {
+		_exitRequested = true;
+		input.eatMouseInput();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/booksortpuzzle.h b/engines/nancy/action/puzzle/booksortpuzzle.h
new file mode 100644
index 00000000000..c053ecd5776
--- /dev/null
+++ b/engines/nancy/action/puzzle/booksortpuzzle.h
@@ -0,0 +1,107 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_BOOKSORTPUZZLE_H
+#define NANCY_ACTION_BOOKSORTPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 book shelf. The books lying around have to be packed into a grid of
+// small cells, where each of them covers a block of cells. A book is carried by
+// the cell under the cursor, and snaps to the grid while it is over it. The puzzle
+// is solved once no book is left outside of the grid.
+class BookSortPuzzle : public PuzzleRecord {
+public:
+	BookSortPuzzle() : PuzzleRecord(7) {}
+	virtual ~BookSortPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "BookSortPuzzle"; }
+
+	static const int16 kNoBook = -1;
+
+	struct Book {
+		Common::Point attachPoint;	// what the cursor holds the book by, relative to its picture
+		Common::Rect src;
+		Common::Rect home;			// where the book lies while it is not in the grid
+		Common::Array<Common::Point> cells;	// the cells the book covers, relative to its top left one
+
+		bool isPlaced = false;
+		Common::Point gridPos;		// the cell the attach point is in
+	};
+
+	bool isInGrid(const Common::Point &cell) const { return cell.x >= 0 && cell.y >= 0 && cell.x < _numColumns && cell.y < _numRows; }
+	int16 &gridAt(const Common::Point &cell) { return _grid[cell.y * _numColumns + cell.x]; }
+
+	// The grid cell a book's own cell ends up in, with the book held over a grid cell
+	Common::Point getBookCell(const Book &book, uint cellID, const Common::Point &gridPos) const;
+	bool canPlaceBook(const Book &book, const Common::Point &gridPos);
+	void setBookCells(uint bookID, int16 value);
+	// The book lying outside of the grid at a position, kNoBook if there is none
+	int16 findBookOnTable(const Common::Point &pos) const;
+	void redraw();
+
+	// -- File data --
+	Common::Rect _firstCell;	// the top left cell of the grid
+	int32 _numColumns = 0;
+	int32 _numRows = 0;
+
+	uint16 _placeCursor = 0;	// carrying a book that fits where it is
+	uint16 _blockedCursor = 0;	// carrying a book that does not
+	uint16 _pickUpCursor = 0;
+	uint16 _carryCursor = 0;	// carrying a book outside of the grid
+
+	Common::Array<Book> _books;
+
+	RandomSoundBlock _pickUpSound;
+	RandomSoundBlock _putBackSound;
+	RandomSoundBlock _placeSound;
+	RandomSoundBlock _takeOutSound;
+	RandomSoundBlock _blockedSound;
+	RandomSoundBlock _solveSoundBlock;
+
+	// -- Runtime --
+	Common::Array<int16> _grid;			// the book in every cell
+	Common::Array<uint> _tableBooks;	// the books outside of the grid; a click picks the first one it hits
+	int16 _heldBook = kNoBook;
+	Common::Point _mousePos;
+	bool _isMouseInGrid = false;
+	Common::Point _mouseCell;
+	bool _isSolved = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_BOOKSORTPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 0292f76cd45..b032a9ba25b 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -34,6 +34,7 @@ MODULE_OBJS = \
   action/puzzle/buildpuzzle.o \
   action/puzzle/bulpuzzle.o \
   action/puzzle/bombpuzzle.o \
+  action/puzzle/booksortpuzzle.o \
   action/puzzle/cardgamepuzzle.o \
   action/puzzle/collisionpuzzle.o \
   action/puzzle/cookingpuzzle.o \


Commit: 8d8df8fb6086e9a94a81fc90bdcac1ff363c5323
    https://github.com/scummvm/scummvm/commit/8d8df8fb6086e9a94a81fc90bdcac1ff363c5323
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:22+03:00

Commit Message:
NANCY: NANCY17: Implement DragSurfacePuzzle

- Record 207, the cemetery map: a picture larger than the viewport is
  dragged around with the mouse
- Its zones change the cursor, or the scene when clicked
- Add the cursor action zone
- The part of the picture in view is kept while the player visits
  other scenes

Changed paths:
  A engines/nancy/action/puzzle/dragsurfacepuzzle.cpp
  A engines/nancy/action/puzzle/dragsurfacepuzzle.h
    engines/nancy/action/actionzone.cpp
    engines/nancy/action/actionzone.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/module.mk
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h


diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
index 909422d7045..91c52f670ba 100644
--- a/engines/nancy/action/actionzone.cpp
+++ b/engines/nancy/action/actionzone.cpp
@@ -88,6 +88,7 @@ void ActionZone::readSubtype(Common::SeekableReadStream &stream, bool isNancy13)
 		slopeAngle = stream.readSint16LE();
 		break;
 	case kZoneEventFlag:
+	case kZoneCursor:
 		tailId = stream.readSint16LE();
 		tailFlag = stream.readByte();
 		break;
diff --git a/engines/nancy/action/actionzone.h b/engines/nancy/action/actionzone.h
index 9d43b0f0cbb..88189540208 100644
--- a/engines/nancy/action/actionzone.h
+++ b/engines/nancy/action/actionzone.h
@@ -58,7 +58,8 @@ enum ActionZoneType : byte {
 	kZoneBoundary		= 0x14,	// play-area wall
 	kZoneUnknown15		= 0x15,	// Nancy12: on entry, sound + special effect + change to scene specialEffectId; a damage range in Nancy13
 	kZoneBumper			= 0x16,	// Nancy12: an overlay variant; Nancy13: a pachinko hole
-	kZoneFlatTire		= 0x17	// pothole: damages the car driving over it
+	kZoneFlatTire		= 0x17,	// pothole: damages the car driving over it
+	kZoneCursor			= 0x1a	// Nancy17: changes the cursor over the zone
 };
 
 // A polymorphic "ActionZone" record, embedded as a count-prefixed array inside
@@ -92,7 +93,8 @@ struct ActionZone {
 
 	// int16 + byte trailer carried by kZoneEventFlag and kZoneSceneChange. For the
 	// former it is an event-flag id + on/off; for the latter a target scene id + a
-	// flag. Left at their defaults for the other subtypes.
+	// flag. kZoneCursor has a cursor type + whether the cursor is the highlighted
+	// one. Left at their defaults for the other subtypes.
 	int16 tailId = 0;
 	byte tailFlag = 0;
 
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index f820421982d..1ecf0928887 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -57,6 +57,7 @@
 #include "engines/nancy/action/puzzle/decoderpuzzle.h"
 #include "engines/nancy/action/puzzle/dialsequencepuzzle.h"
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
+#include "engines/nancy/action/puzzle/dragsurfacepuzzle.h"
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
 #include "engines/nancy/action/puzzle/escapegridpuzzle.h"
@@ -769,7 +770,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 206:
 		return new RaycastPuzzle();
 	case 207:
-		return new TangramPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new TangramPuzzle();
+		else
+			return new DragSurfacePuzzle();
 	case 208:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kPiano);
diff --git a/engines/nancy/action/puzzle/dragsurfacepuzzle.cpp b/engines/nancy/action/puzzle/dragsurfacepuzzle.cpp
new file mode 100644
index 00000000000..cd921ade09d
--- /dev/null
+++ b/engines/nancy/action/puzzle/dragsurfacepuzzle.cpp
@@ -0,0 +1,225 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/dragsurfacepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void DragSurfacePuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+
+	// The view is as large as its place on screen, so the rects are read as they are
+	readExclusiveRect(stream, _src);
+	readExclusiveRect(stream, _dest);
+	readFilename(stream, _saveName);
+	_hoverCursor = stream.readUint16LE();
+	_dragCursor = stream.readUint16LE();
+
+	readActionZoneArray(stream, _zones, true);
+	readExitHotspot(stream);
+}
+
+void DragSurfacePuzzle::init() {
+	_drawSurface.create(_dest.width(), _dest.height(), g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(_dest);
+
+	loadImage();
+
+	// Back to where the picture was left
+	const DragSurfaceData *data = (const DragSurfaceData *)NancySceneState.getPuzzleData(DragSurfaceData::getTag());
+	assert(data);
+
+	if (!_saveName.empty() && data->viewOrigins.contains(_saveName)) {
+		_src.moveTo(data->viewOrigins[_saveName]);
+	}
+
+	redraw();
+}
+
+void DragSurfacePuzzle::saveView() {
+	if (_saveName.empty()) {
+		return;
+	}
+
+	DragSurfaceData *data = (DragSurfaceData *)NancySceneState.getPuzzleData(DragSurfaceData::getTag());
+	assert(data);
+	data->viewOrigins[_saveName] = _src.origin();
+}
+
+void DragSurfacePuzzle::redraw() {
+	_drawSurface.blitFrom(_image, _src, Common::Point());
+	_needsRedraw = true;
+}
+
+bool DragSurfacePuzzle::isInZone(const ActionZone &zone, const Common::Point &picturePos) const {
+	// A zone can depend on an event flag
+	if (zone.val49 != -1 && !NancySceneState.getEventFlag(zone.val49, zone.val4b)) {
+		return false;
+	}
+
+	return zone.rect.contains(picturePos);
+}
+
+void DragSurfacePuzzle::dragTo(const Common::Point &pos) {
+	// The picture follows the mouse along each direction it has room in
+	int dx = _lastDragPos.x - pos.x;
+	int dy = _lastDragPos.y - pos.y;
+
+	if (dx != 0 && _src.left + dx >= 0 && _src.right + dx < _image.w) {
+		_src.translate(dx, 0);
+		_lastDragPos.x = pos.x;
+	}
+
+	if (dy != 0 && _src.top + dy >= 0 && _src.bottom + dy < _image.h) {
+		_src.translate(0, dy);
+		_lastDragPos.y = pos.y;
+	}
+
+	redraw();
+}
+
+void DragSurfacePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_exitRequested || _zoneSceneRequested) {
+			_state = kActionTrigger;
+		}
+
+		break;
+	case kActionTrigger:
+		if (_zoneSceneRequested) {
+			NancySceneState.changeScene(_zoneScene);
+		} else {
+			_exitScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void DragSurfacePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _exitRequested || _zoneSceneRequested) {
+		return;
+	}
+
+	const bool isReleased = input.input & NancyInput::kLeftMouseButtonUp;
+	if (!(input.input & (NancyInput::kLeftMouseButtonDown | NancyInput::kLeftMouseButtonHeld)) && !isReleased) {
+		_isMouseDown = false;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	if (!_isMouseDown && hoverExitHotspot(input)) {
+		if (isReleased) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	if (!_dest.contains(pos)) {
+		return;
+	}
+
+	if (input.input & NancyInput::kLeftMouseButtonDown) {
+		_isMouseDown = true;
+		_hasDragged = false;
+		_pressPos = pos;
+		_lastDragPos = pos;
+	}
+
+	if (_isMouseDown && !isReleased) {
+		if (!_hasDragged && (ABS(pos.x - _pressPos.x) > kDragThreshold || ABS(pos.y - _pressPos.y) > kDragThreshold)) {
+			_hasDragged = true;
+		}
+
+		if (_hasDragged) {
+			dragTo(pos);
+			setDataCursor(_dragCursor);
+			return;
+		}
+	}
+
+	const bool clicked = isReleased && _isMouseDown && !_hasDragged;
+	if (isReleased) {
+		if (_hasDragged) {
+			saveView();
+		}
+
+		_isMouseDown = false;
+		_hasDragged = false;
+	}
+
+	Common::Point picturePos = pos - _dest.origin() + _src.origin();
+
+	bool hasZoneCursor = false;
+	for (const ActionZone &zone : _zones) {
+		if (!isInZone(zone, picturePos)) {
+			continue;
+		}
+
+		if (zone.type == kZoneCursor && !hasZoneCursor) {
+			setDataCursor(zone.tailId, zone.tailFlag != 0);
+			hasZoneCursor = true;
+		} else if (zone.type == kZoneUnknown13 && clicked && zone.specialEffectId != kNoSceneNancy17) {
+			// These change the scene once they are clicked
+			playSoundBlock(zone._sound);
+			_zoneScene.sceneID = zone.specialEffectId;
+			_zoneScene.frameID = 0;
+			_zoneScene.continueSceneSound = kContinueSceneSound;
+			_zoneSceneRequested = true;
+			input.eatMouseInput();
+			return;
+		}
+	}
+
+	if (!hasZoneCursor) {
+		setDataCursor(_hoverCursor);
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/dragsurfacepuzzle.h b/engines/nancy/action/puzzle/dragsurfacepuzzle.h
new file mode 100644
index 00000000000..36f5d9872eb
--- /dev/null
+++ b/engines/nancy/action/puzzle/dragsurfacepuzzle.h
@@ -0,0 +1,81 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_DRAGSURFACEPUZZLE_H
+#define NANCY_ACTION_DRAGSURFACEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionzone.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 cemetery map. A picture larger than the viewport, moved around by
+// dragging it. Action zones on the picture change the cursor, and the scene when
+// they are clicked. The part of the picture in view is kept under a name.
+class DragSurfacePuzzle : public PuzzleRecord {
+public:
+	DragSurfacePuzzle() : PuzzleRecord(7) {}
+	virtual ~DragSurfacePuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "DragSurfacePuzzle"; }
+
+	// A press that moves less than this is a click
+	static const int kDragThreshold = 3;
+
+	// Whether a zone is active, and has a position on the picture inside of it
+	bool isInZone(const ActionZone &zone, const Common::Point &picturePos) const;
+	void dragTo(const Common::Point &pos);
+	void saveView();
+	void redraw();
+
+	// -- File data --
+	Common::Rect _src;		// the part of the picture that is in view
+	Common::Rect _dest;
+	Common::String _saveName;
+	uint16 _hoverCursor = 0;
+	uint16 _dragCursor = 0;
+	Common::Array<ActionZone> _zones;
+
+	// -- Runtime --
+	bool _isMouseDown = false;
+	bool _hasDragged = false;
+	Common::Point _pressPos;
+	Common::Point _lastDragPos;
+	SceneChangeDescription _zoneScene;
+	bool _zoneSceneRequested = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_DRAGSURFACEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index b032a9ba25b..e576ffc5747 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -43,6 +43,7 @@ MODULE_OBJS = \
   action/puzzle/decoderpuzzle.o \
   action/puzzle/dialsequencepuzzle.o \
   action/puzzle/dotconnectpuzzle.o \
+  action/puzzle/dragsurfacepuzzle.o \
   action/puzzle/drivingpuzzle.o \
   action/puzzle/dropsortpuzzle.o \
   action/puzzle/escapegridpuzzle.o \
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index d6add2400b2..a5fd8b61e3f 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -858,6 +858,29 @@ void BuildBoardData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void DragSurfaceData::synchronize(Common::Serializer &ser) {
+	uint16 numViews = viewOrigins.size();
+	ser.syncAsUint16LE(numViews);
+
+	Common::Array<Common::String> names;
+	if (ser.isSaving()) {
+		for (auto &entry : viewOrigins) {
+			names.push_back(entry._key);
+		}
+	} else {
+		viewOrigins.clear();
+		names.resize(numViews);
+	}
+
+	for (Common::String &name : names) {
+		ser.syncString(name);
+
+		Common::Point &origin = viewOrigins[name];
+		ser.syncAsSint16LE(origin.x);
+		ser.syncAsSint16LE(origin.y);
+	}
+}
+
 void StreamData::synchronize(Common::Serializer &ser) {
 	uint16 numStreams = streams.size();
 	ser.syncAsUint16LE(numStreams);
@@ -947,6 +970,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new BuildBoardData();
 	case CollectionData::getTag():
 		return new CollectionData();
+	case DragSurfaceData::getTag():
+		return new DragSurfaceData();
 	case BuildPuzzleData::getTag():
 		return new BuildPuzzleData();
 	case MirrorLightData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 20c8348416d..c31efde47e3 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -615,6 +615,15 @@ struct BuildBoardData : public PuzzleData {
 	Common::HashMap<Common::String, Board, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> boards;
 };
 
+// Nancy17 DragSurfacePuzzle. The part of each picture that was last looked at,
+// by the name the puzzle keeps it under.
+struct DragSurfaceData : public PuzzleData {
+	static constexpr uint32 getTag() { return MKTAG('D', 'R', 'G', 'S'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::HashMap<Common::String, Common::Point, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> viewOrigins;
+};
+
 // Nancy17. The scenes that run in streams next to the main one, so they can be
 // started again after loading
 struct StreamData : public PuzzleData {


Commit: bae4678acb92df094a127f46445b7c7c72ba3878
    https://github.com/scummvm/scummvm/commit/bae4678acb92df094a127f46445b7c7c72ba3878
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:24+03:00

Commit Message:
NANCY: NANCY17: Implement ShovelMeasurePuzzle

- Record 205, the cemetery plot: a shovel is turned and moved over a
  grid, with the arrow keys and Enter or the buttons on screen
- A measuring tape shows the steps to the end of the next stretch of
  the path in the direction the shovel faces
- Leaving the path too many times in a row loses, reaching its end wins

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 1ecf0928887..f14413cc809 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -101,6 +101,7 @@
 #include "engines/nancy/action/puzzle/scalepuzzle.h"
 #include "engines/nancy/action/puzzle/setplayerclock.h"
 #include "engines/nancy/action/puzzle/sewingmachinepuzzle.h"
+#include "engines/nancy/action/puzzle/shovelmeasurepuzzle.h"
 #include "engines/nancy/action/puzzle/sliderpuzzle.h"
 #include "engines/nancy/action/puzzle/snowmobilepuzzle.h"
 #include "engines/nancy/action/puzzle/sortpuzzle.h"
@@ -766,7 +767,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 204:
 		return new OverrideLockPuzzle();
 	case 205:
-		return new RiddlePuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new RiddlePuzzle();
+		else
+			return new ShovelMeasurePuzzle();
 	case 206:
 		return new RaycastPuzzle();
 	case 207:
diff --git a/engines/nancy/action/puzzle/shovelmeasurepuzzle.cpp b/engines/nancy/action/puzzle/shovelmeasurepuzzle.cpp
new file mode 100644
index 00000000000..213e0895628
--- /dev/null
+++ b/engines/nancy/action/puzzle/shovelmeasurepuzzle.cpp
@@ -0,0 +1,510 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/shovelmeasurepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+ShovelMeasurePuzzle::Direction ShovelMeasurePuzzle::readDirection(Common::SeekableReadStream &stream) {
+	Common::String name;
+	readFilename(stream, name);
+
+	if (name.equalsIgnoreCase("N")) {
+		return kNorth;
+	} else if (name.equalsIgnoreCase("S")) {
+		return kSouth;
+	} else if (name.equalsIgnoreCase("E")) {
+		return kEast;
+	} else if (name.equalsIgnoreCase("W")) {
+		return kWest;
+	}
+
+	error("ShovelMeasurePuzzle: %s is not a direction", name.c_str());
+}
+
+Common::Point ShovelMeasurePuzzle::getNeighbor(const Common::Point &pos, Direction direction) {
+	switch (direction) {
+	case kNorth:
+		return Common::Point(pos.x, pos.y - 1);
+	case kSouth:
+		return Common::Point(pos.x, pos.y + 1);
+	case kEast:
+		return Common::Point(pos.x + 1, pos.y);
+	default:
+		return Common::Point(pos.x - 1, pos.y);
+	}
+}
+
+double ShovelMeasurePuzzle::getAngle(Direction direction) {
+	switch (direction) {
+	case kSouth:
+		return M_PI;
+	case kEast:
+		return M_PI / 2;
+	case kWest:
+		return 3 * M_PI / 2;
+	default:
+		return 0;
+	}
+}
+
+void ShovelMeasurePuzzle::readData(Common::SeekableReadStream &stream) {
+	_buttonCursor = stream.readUint16LE();
+	_maxWrongSteps = stream.readSint32LE();
+	_moveTime = (uint32)(stream.readDoubleLE() * 1000);
+	readFilename(stream, _imageName);
+
+	readRect(stream, _directionButtons[kNorth]);
+	readRect(stream, _directionButtons[kSouth]);
+	readRect(stream, _directionButtons[kEast]);
+	readRect(stream, _directionButtons[kWest]);
+
+	readRect(stream, _dialSrc);
+	readRect(stream, _dialDest);
+	_dialPivot.x = (int16)(stream.readDoubleLE() - _dialDest.left);
+	_dialPivot.y = (int16)(stream.readDoubleLE() - _dialDest.top);
+
+	readRect(stream, _stepPressedSrc);
+	readRect(stream, _stepButton);
+
+	_startPos.x = stream.readSint32LE();
+	_startPos.y = stream.readSint32LE();
+	_startDirection = readDirection(stream);
+
+	readRect(stream, _shovelSrc);
+	readRect(stream, _tapeDest);
+
+	uint16 numLengths = stream.readUint16LE();
+	_maxMeasure = numLengths;
+	for (uint i = 0; i < numLengths; ++i) {
+		uint16 length = stream.readUint16LE();
+		if (length >= _tapeSrcs.size()) {
+			_tapeSrcs.resize(length + 1);
+		}
+
+		_tapeSrcs[length].resize(stream.readUint16LE());
+		for (Common::Rect &src : _tapeSrcs[length]) {
+			readRect(stream, src);
+		}
+	}
+
+	// The top left cell, the one to its right and the one below it
+	Common::Rect cellRight, cellBelow;
+	readRect(stream, _firstCell);
+	readRect(stream, cellRight);
+	readRect(stream, cellBelow);
+	_cellSpacing = Common::Point(cellRight.left - _firstCell.left, cellBelow.top - _firstCell.top);
+
+	// A space is a cell the shovel can be on
+	_numRows = stream.readUint16LE();
+	for (int y = 0; y < _numRows; ++y) {
+		Common::String row;
+		readFilename(stream, row);
+
+		if (y == 0) {
+			_numColumns = row.size();
+			_cells.resize(_numColumns * _numRows);
+		}
+
+		for (int x = 0; x < _numColumns && x < (int)row.size(); ++x) {
+			_cells[y * _numColumns + x].isValid = row[x] == ' ';
+		}
+	}
+
+	if (!isValidCell(_startPos)) {
+		error("ShovelMeasurePuzzle: the shovel does not start on the grid");
+	}
+
+	// The path is given as directions and numbers of steps
+	Common::Point pos = _startPos;
+	_path.resize(stream.readUint16LE());
+	for (Stretch &stretch : _path) {
+		stretch.direction = readDirection(stream);
+		stretch.from = pos;
+
+		int32 numSteps = stream.readSint32LE();
+		for (int32 i = 0; i < numSteps; ++i) {
+			pos = getNeighbor(pos, stretch.direction);
+		}
+
+		if (!isValidCell(pos)) {
+			error("ShovelMeasurePuzzle: the path leaves the grid");
+		}
+
+		stretch.to = pos;
+		getCell(pos).endsStretch[stretch.direction] = true;
+	}
+
+	_turnSound.readData(stream);
+	_stepSound.readData(stream);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_winSound.readData(stream);
+
+	_loseScene._sceneChange.sceneID = stream.readUint16LE();
+	_loseScene._sceneChange.frameID = stream.readUint16LE();
+	_loseScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_loseScene._flag.label = stream.readSint16LE();
+	_loseScene._flag.flag = stream.readByte();
+	_loseSound.readData(stream);
+
+	readExitHotspot(stream);
+}
+
+void ShovelMeasurePuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	_pos = _targetPos = _startPos;
+	_direction = _targetDirection = _startDirection;
+	_lastGuessPos = Common::Point(-1, -1);
+
+	updateMeasure();
+	_shownMeasure = _measure;
+	redraw();
+}
+
+bool ShovelMeasurePuzzle::isValidCell(const Common::Point &pos) const {
+	return pos.x >= 0 && pos.y >= 0 && pos.x < _numColumns && pos.y < _numRows && getCell(pos).isValid;
+}
+
+Common::Point ShovelMeasurePuzzle::getCellCenter(const Common::Point &pos) const {
+	return Common::Point(_firstCell.left + pos.x * _cellSpacing.x + _firstCell.width() / 2,
+						_firstCell.top + pos.y * _cellSpacing.y + _firstCell.height() / 2);
+}
+
+void ShovelMeasurePuzzle::updateMeasure() {
+	const Common::Point start = _move == kStep ? _targetPos : _pos;
+	const Direction direction = _move == kFace ? _targetDirection : _direction;
+
+	Common::Point pos = start;
+	_measure = 0;
+
+	while (true) {
+		pos = getNeighbor(pos, direction);
+		if (!isValidCell(pos) || _measure >= _maxMeasure) {
+			break;
+		}
+
+		++_measure;
+
+		// The tape is right when it gets to the end of a stretch going this way
+		if (pos == _lastGuessPos || getCell(pos).endsStretch[direction]) {
+			return;
+		}
+	}
+
+	if (_measure > 0) {
+		// Nothing to measure to, so the tape shows whatever
+		_lastGuessPos = start;
+		_measure = MAX<uint>(g_nancy->_randomSource->getRandomNumber(MIN(_maxMeasure, _measure)), 1);
+	}
+}
+
+void ShovelMeasurePuzzle::face(Direction direction) {
+	if (direction == _direction) {
+		return;
+	}
+
+	_targetDirection = direction;
+	_move = kFace;
+	_moveStartTime = g_nancy->getTotalPlayTime();
+	playSoundBlock(_turnSound);
+	updateMeasure();
+}
+
+void ShovelMeasurePuzzle::step() {
+	Common::Point next = getNeighbor(_pos, _direction);
+	if (!isValidCell(next)) {
+		return;
+	}
+
+	_targetPos = next;
+	_move = kStep;
+	_moveStartTime = g_nancy->getTotalPlayTime();
+	playSoundBlock(_stepSound);
+	updateMeasure();
+}
+
+void ShovelMeasurePuzzle::finishMove() {
+	if (_move == kFace) {
+		_direction = _targetDirection;
+	} else {
+		_pos = _targetPos;
+
+		// A step that is not on the stretch being followed counts against the player
+		const Stretch &stretch = _path[_currentStretch];
+		bool isOnStretch = false;
+		for (Common::Point pos = stretch.from; ; pos = getNeighbor(pos, stretch.direction)) {
+			if (pos == _pos) {
+				isOnStretch = true;
+				break;
+			}
+
+			if (pos == stretch.to) {
+				break;
+			}
+		}
+
+		if (isOnStretch) {
+			_numWrongSteps = 0;
+		} else {
+			++_numWrongSteps;
+		}
+
+		if (_pos == stretch.to) {
+			++_currentStretch;
+		}
+	}
+
+	_move = kNoCommand;
+	_shownMeasure = _measure;
+
+	if (_numWrongSteps > _maxWrongSteps) {
+		_outcome = kLost;
+	} else if (_currentStretch == _path.size()) {
+		_outcome = kWon;
+	}
+}
+
+void ShovelMeasurePuzzle::drawRotated(const Common::Rect &src, const Common::Point &srcPivot, const Common::Point &destPivot, double angle) {
+	const double cosA = cos(angle);
+	const double sinA = sin(angle);
+	const uint32 transColor = _image.getTransparentColor();
+
+	// Everything the picture can cover, however it is turned
+	const int radius = (int)ceil(sqrt((double)(src.width() * src.width() + src.height() * src.height())));
+	const int startX = MAX<int>(0, destPivot.x - radius);
+	const int startY = MAX<int>(0, destPivot.y - radius);
+	const int endX = MIN<int>(_drawSurface.w, destPivot.x + radius + 1);
+	const int endY = MIN<int>(_drawSurface.h, destPivot.y + radius + 1);
+
+	for (int y = startY; y < endY; ++y) {
+		for (int x = startX; x < endX; ++x) {
+			double dx = x + 0.5 - destPivot.x;
+			double dy = y + 0.5 - destPivot.y;
+			int srcX = (int)floor(dx * cosA + dy * sinA + srcPivot.x);
+			int srcY = (int)floor(-dx * sinA + dy * cosA + srcPivot.y);
+
+			if (srcX < 0 || srcX >= src.width() || srcY < 0 || srcY >= src.height()) {
+				continue;
+			}
+
+			uint32 color = _image.getPixel(src.left + srcX, src.top + srcY);
+			if (color != transColor) {
+				_drawSurface.setPixel(x, y, color);
+			}
+		}
+	}
+}
+
+void ShovelMeasurePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	// How far along the turn or the step is
+	double progress = 1.0;
+	if (_move != kNoCommand && _moveTime > 0) {
+		progress = CLIP((g_nancy->getTotalPlayTime() - _moveStartTime) / (double)_moveTime, 0.0, 1.0);
+	}
+
+	double angle = getAngle(_direction);
+	if (_move == kFace) {
+		// The short way around
+		double delta = getAngle(_targetDirection) - angle;
+		if (delta > M_PI) {
+			delta -= 2 * M_PI;
+		} else if (delta < -M_PI) {
+			delta += 2 * M_PI;
+		}
+
+		angle += delta * progress;
+	}
+
+	Common::Point shovelPos = getCellCenter(_pos);
+	if (_move == kStep) {
+		Common::Point target = getCellCenter(_targetPos);
+		shovelPos.x += (int16)((target.x - shovelPos.x) * progress);
+		shovelPos.y += (int16)((target.y - shovelPos.y) * progress);
+	}
+
+	drawRotated(_shovelSrc, Common::Point(_shovelSrc.width() / 2, _shovelSrc.height() / 2), shovelPos, angle);
+	drawRotated(_dialSrc, _dialPivot, Common::Point(_dialDest.left + _dialPivot.x, _dialDest.top + _dialPivot.y), angle);
+
+	if (_move == kStep) {
+		_drawSurface.blitFrom(_image, _stepPressedSrc, _stepButton.origin());
+	}
+
+	// The tape goes back in during the first half of a move, and out to the new
+	// length during the second one
+	uint measure = _shownMeasure;
+	double tapeProgress = 1.0;
+	if (_move != kNoCommand) {
+		if (progress < 0.5) {
+			tapeProgress = 1.0 - 2 * progress;
+		} else {
+			measure = _measure;
+			tapeProgress = 2 * (progress - 0.5);
+		}
+	}
+
+	if (measure == 0) {
+		// Nothing to show is the shortest tape, all the way in
+		measure = 1;
+		tapeProgress = 0.0;
+	}
+
+	if (measure < _tapeSrcs.size() && !_tapeSrcs[measure].empty()) {
+		const Common::Array<Common::Rect> &srcs = _tapeSrcs[measure];
+		int frame = CLIP<int>((int)(srcs.size() * tapeProgress), 0, srcs.size() - 1);
+		_drawSurface.blitFrom(_image, srcs[frame], _tapeDest.origin());
+	}
+
+	_needsRedraw = true;
+}
+
+void ShovelMeasurePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_move != kNoCommand) {
+			if (g_nancy->getTotalPlayTime() - _moveStartTime >= _moveTime) {
+				finishMove();
+			}
+
+			redraw();
+		}
+
+		if (_outcome != kNone) {
+			_state = kActionTrigger;
+		}
+
+		break;
+	case kActionTrigger:
+		switch (_outcome) {
+		case kWon:
+			playSoundBlock(_winSound);
+			_solveScene.execute();
+			break;
+		case kLost:
+			playSoundBlock(_loseSound);
+			_loseScene.execute();
+			break;
+		default:
+			_exitScene.execute();
+			break;
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void ShovelMeasurePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _outcome != kNone) {
+		return;
+	}
+
+	// A turn or a step has to be over before the next one
+	const bool canMove = _move == kNoCommand;
+
+	Direction direction = kNoDirection;
+	bool wantsStep = false;
+
+	if (input.input & NancyInput::kMoveUp) {
+		direction = kNorth;
+	} else if (input.input & NancyInput::kMoveDown) {
+		direction = kSouth;
+	} else if (input.input & NancyInput::kMoveRight) {
+		direction = kEast;
+	} else if (input.input & NancyInput::kMoveLeft) {
+		direction = kWest;
+	}
+
+	for (const Common::KeyState &key : input.otherKbdInput) {
+		if (key.keycode == Common::KEYCODE_RETURN || key.keycode == Common::KEYCODE_KP_ENTER) {
+			wantsStep = true;
+		}
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		const bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+		Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+			Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+		Common::Point pos = mouseRect.origin();
+
+		bool isOverButton = _stepButton.contains(pos);
+		if (isOverButton && clicked) {
+			wantsStep = true;
+		}
+
+		for (uint i = 0; i < kNumDirections && !isOverButton; ++i) {
+			if (_directionButtons[i].contains(pos)) {
+				isOverButton = true;
+				if (clicked) {
+					direction = (Direction)i;
+				}
+			}
+		}
+
+		if (isOverButton) {
+			setDataCursor(_buttonCursor, canMove);
+			if (clicked) {
+				input.eatMouseInput();
+			}
+		} else if (hoverExitHotspot(input) && clicked) {
+			_outcome = kExited;
+			input.eatMouseInput();
+			return;
+		}
+	}
+
+	if (!canMove) {
+		return;
+	}
+
+	if (wantsStep) {
+		step();
+	} else if (direction != kNoDirection) {
+		face(direction);
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/shovelmeasurepuzzle.h b/engines/nancy/action/puzzle/shovelmeasurepuzzle.h
new file mode 100644
index 00000000000..d357d5db219
--- /dev/null
+++ b/engines/nancy/action/puzzle/shovelmeasurepuzzle.h
@@ -0,0 +1,161 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_SHOVELMEASUREPUZZLE_H
+#define NANCY_ACTION_SHOVELMEASUREPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 cemetery plot. A shovel is turned and moved over a grid, with the arrow
+// keys and Enter or with the buttons on screen. A measuring tape shows how many
+// steps there are, in the direction the shovel faces, to the end of one of the
+// stretches of the path that has to be followed; with no such end ahead the tape
+// shows a number that means nothing. Leaving the path too many times in a row
+// loses, getting to its end wins.
+class ShovelMeasurePuzzle : public PuzzleRecord {
+public:
+	ShovelMeasurePuzzle() : PuzzleRecord(7) {}
+	virtual ~ShovelMeasurePuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "ShovelMeasurePuzzle"; }
+
+	enum Direction {
+		kNorth,
+		kSouth,
+		kEast,
+		kWest,
+		kNumDirections,
+		kNoDirection = -1
+	};
+
+	enum Command {
+		kNoCommand,
+		kFace,
+		kStep
+	};
+
+	struct Cell {
+		bool isValid = false;
+		bool endsStretch[kNumDirections] = { false, false, false, false };
+	};
+
+	// A stretch of the path
+	struct Stretch {
+		Direction direction = kNorth;
+		Common::Point from;
+		Common::Point to;
+	};
+
+	enum Outcome {
+		kNone,
+		kWon,
+		kLost,
+		kExited
+	};
+
+	static Direction readDirection(Common::SeekableReadStream &stream);
+	static Common::Point getNeighbor(const Common::Point &pos, Direction direction);
+	// Clockwise, in radians
+	static double getAngle(Direction direction);
+
+	bool isValidCell(const Common::Point &pos) const;
+	Cell &getCell(const Common::Point &pos) { return _cells[pos.y * _numColumns + pos.x]; }
+	const Cell &getCell(const Common::Point &pos) const { return _cells[pos.y * _numColumns + pos.x]; }
+	Common::Point getCellCenter(const Common::Point &pos) const;
+
+	void face(Direction direction);
+	void step();
+	void finishMove();
+	void updateMeasure();
+
+	// Draws a picture turned clockwise around a point of its own, which goes to a point on screen
+	void drawRotated(const Common::Rect &src, const Common::Point &srcPivot, const Common::Point &destPivot, double angle);
+	void redraw();
+
+	// -- File data --
+	uint16 _buttonCursor = 0;
+	int32 _maxWrongSteps = 0;
+	uint32 _moveTime = 0;	// in milliseconds
+
+	Common::Rect _directionButtons[kNumDirections];
+	Common::Rect _dialSrc;
+	Common::Rect _dialDest;
+	Common::Point _dialPivot;	// relative to the dial
+	Common::Rect _stepPressedSrc;
+	Common::Rect _stepButton;
+
+	Common::Point _startPos;
+	Direction _startDirection = kNorth;
+
+	Common::Rect _shovelSrc;
+	Common::Rect _tapeDest;
+	// The pictures of the tape being pulled out to each length
+	Common::Array<Common::Array<Common::Rect> > _tapeSrcs;
+	uint _maxMeasure = 0;
+
+	Common::Rect _firstCell;
+	Common::Point _cellSpacing;
+	int _numColumns = 0;
+	int _numRows = 0;
+	Common::Array<Cell> _cells;
+	Common::Array<Stretch> _path;
+
+	RandomSoundBlock _turnSound;
+	RandomSoundBlock _stepSound;
+	RandomSoundBlock _winSound;
+	SceneChangeWithFlag _loseScene;
+	RandomSoundBlock _loseSound;
+
+	// -- Runtime --
+	Common::Point _pos;
+	Common::Point _targetPos;
+	Direction _direction = kNorth;
+	Direction _targetDirection = kNorth;
+	Command _move = kNoCommand;
+	uint32 _moveStartTime = 0;
+
+	uint _measure = 0;
+	uint _shownMeasure = 0;
+	// Where the tape last showed a meaningless number from; counting stops there
+	Common::Point _lastGuessPos;
+
+	uint _currentStretch = 0;
+	int32 _numWrongSteps = 0;
+	Outcome _outcome = kNone;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_SHOVELMEASUREPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index e576ffc5747..2dd2744352d 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -87,6 +87,7 @@ MODULE_OBJS = \
   action/puzzle/scalepuzzle.o \
   action/puzzle/setplayerclock.o \
   action/puzzle/sewingmachinepuzzle.o \
+  action/puzzle/shovelmeasurepuzzle.o \
   action/puzzle/sliderpuzzle.o \
   action/puzzle/snowmobilepuzzle.o \
   action/puzzle/sortpuzzle.o \


Commit: ef34b3f0d1344cad46295e38947cf2a3289f09b5
    https://github.com/scummvm/scummvm/commit/ef34b3f0d1344cad46295e38947cf2a3289f09b5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:27+03:00

Commit Message:
NANCY: NANCY17: Implement WhackItPuzzle

- Record 203, the bees in the garden: they fly around, get angry after
  a while and sting when they stay angry for too long
- A puff of the spray can knocks down every bee it touches; a bee that
  is down gets back up after some time
- With every bee down the puzzle sets its flag; too many stings lose
- Add PhysicalObject, which moves things the way the arcade puzzles of
  the game do: speed, friction, a steady force, and stopping at or
  bouncing off its bounds, in ticks of 20 ms

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index f14413cc809..6465b192a9a 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -117,6 +117,7 @@
 #include "engines/nancy/action/puzzle/twodialpuzzle.h"
 #include "engines/nancy/action/puzzle/typingquizpuzzle.h"
 #include "engines/nancy/action/puzzle/weightsortpuzzle.h"
+#include "engines/nancy/action/puzzle/whackitpuzzle.h"
 #include "engines/nancy/action/puzzle/wolfmazepuzzle.h"
 #include "engines/nancy/action/puzzle/whalesurvivorpuzzle.h"
 #include "engines/nancy/action/puzzle/wordfindpuzzle.h"
@@ -763,7 +764,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 202:
 		return new BombPuzzle();
 	case 203:
-		return new RippedLetterPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new RippedLetterPuzzle();
+		else
+			return new WhackItPuzzle();
 	case 204:
 		return new OverrideLockPuzzle();
 	case 205:
diff --git a/engines/nancy/action/physicalobject.cpp b/engines/nancy/action/physicalobject.cpp
new file mode 100644
index 00000000000..683f9535f28
--- /dev/null
+++ b/engines/nancy/action/physicalobject.cpp
@@ -0,0 +1,189 @@
+/* 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/action/physicalobject.h"
+
+namespace Nancy {
+namespace Action {
+
+// Below these a speed or a difference counts as none
+static const double kEpsilon = 1e-5;
+static const double kMinSpeed = 0.01;
+// Faster than this, an object always feels its force
+static const double kRestSpeed = 30.0;
+// How far an object that bounced is pushed back in
+static const double kBounceDistance = 2.0;
+
+double PhysicalObject::getAngle(double x, double y) {
+	double angle = atan2(-y, x);
+	if (angle < 0) {
+		angle += 2 * M_PI;
+	}
+
+	return angle;
+}
+
+Common::Rect PhysicalObject::getRectAt(double x, double y) const {
+	int left = (int)x - _width / 2;
+	int top = (int)y - _height / 2;
+	return Common::Rect(left, top, left + _width, top + _height);
+}
+
+Common::Rect PhysicalObject::getRect() const {
+	return getRectAt(_x, _y);
+}
+
+bool PhysicalObject::isInsideBounds(const Common::Rect &rect) const {
+	return _bounds.isEmpty() || _bounds.contains(rect);
+}
+
+double PhysicalObject::getBoundsNormal(const Common::Rect &rect) const {
+	if (isInsideBounds(rect)) {
+		return -1;
+	}
+
+	if (rect.left <= _bounds.left) {
+		return 0;
+	} else if (rect.right >= _bounds.right) {
+		return M_PI;
+	} else if (rect.top <= _bounds.top) {
+		return 3 * M_PI / 2;
+	} else if (rect.bottom >= _bounds.bottom) {
+		return M_PI / 2;
+	}
+
+	return -1;
+}
+
+void PhysicalObject::setSpeed(double speed) {
+	_speed = speed;
+
+	if (_hasMaxSpeed && _speed > _maxSpeed) {
+		_speed = _maxSpeed;
+	}
+
+	if (fabs(_speed) < kMinSpeed) {
+		_speed = 0;
+	}
+}
+
+void PhysicalObject::setHeading(double heading) {
+	_heading = fmod(heading, 2 * M_PI);
+	if (_heading < 0) {
+		_heading += 2 * M_PI;
+	}
+}
+
+void PhysicalObject::moveTo(double x, double y) {
+	if (_boundsMode == kBounceOffBounds) {
+		bounceOffBounds(x, y);
+	} else {
+		stopAtBounds(x, y);
+	}
+}
+
+void PhysicalObject::stopAtBounds(double x, double y) {
+	if (isInsideBounds(getRectAt(x, y))) {
+		_x = x;
+		_y = y;
+	} else {
+		_speed = 0;
+		_hasHitBounds = true;
+	}
+}
+
+void PhysicalObject::bounceOffBounds(double x, double y) {
+	double normal = getBoundsNormal(getRectAt(x, y));
+	if (normal < 0) {
+		_x = x;
+		_y = y;
+		return;
+	}
+
+	// Back in a little, along the way out of the bounds
+	double normalX = cos(normal);
+	double normalY = -sin(normal);
+	_x += normalX * kBounceDistance;
+	_y += normalY * kBounceDistance;
+
+	// The heading turns as if mirrored by the edge
+	double headingX = cos(_heading);
+	double headingY = -sin(_heading);
+	double dot = 2 * (headingX * normalX + headingY * normalY);
+	_heading = getAngle(headingX - dot * normalX, headingY - dot * normalY);
+
+	_hasHitBounds = true;
+}
+
+void PhysicalObject::accelerate(double numTicks) {
+	if (sqrt(_forceX * _forceX + _forceY * _forceY) <= kEpsilon) {
+		return;
+	}
+
+	double velocityX = cos(_heading) * _speed + _forceX * numTicks;
+	double velocityY = -sin(_heading) * _speed + _forceY * numTicks;
+	_speed = sqrt(velocityX * velocityX + velocityY * velocityY);
+	_heading = getAngle(velocityX, velocityY);
+}
+
+void PhysicalObject::applyFriction(double numTicks) {
+	if (fabs(_speed) <= kEpsilon) {
+		setSpeed(0);
+		return;
+	}
+
+	// Slows the object down, but never turns it around
+	bool wasBackwards = _speed < 0;
+	double change = _friction * numTicks;
+	setSpeed(wasBackwards ? _speed + change : _speed - change);
+
+	if ((_speed < 0) != wasBackwards) {
+		setSpeed(0);
+	}
+}
+
+void PhysicalObject::move(double numTicks) {
+	double dx = cos(_heading) * _speed / 50 * numTicks;
+	double dy = -sin(_heading) * _speed / 50 * numTicks;
+
+	if (fabs(dx) > 1 || fabs(dy) > 1) {
+		// In halves, until each step is no longer than a pixel
+		move(numTicks / 2);
+		move(numTicks / 2);
+		return;
+	}
+
+	moveTo(_x + dx, _y + dy);
+}
+
+void PhysicalObject::update(uint32 numTicks) {
+	// A slow object only feels its force when that changes what it does
+	double forceLength = sqrt(_forceX * _forceX + _forceY * _forceY);
+	if (_speed > kRestSpeed || forceLength - _friction > kEpsilon || getAngle(_forceX, _forceY) - _heading > kEpsilon) {
+		accelerate(numTicks);
+	}
+
+	applyFriction(numTicks);
+	move(numTicks);
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/physicalobject.h b/engines/nancy/action/physicalobject.h
new file mode 100644
index 00000000000..f2b77ac5cec
--- /dev/null
+++ b/engines/nancy/action/physicalobject.h
@@ -0,0 +1,107 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_PHYSICALOBJECT_H
+#define NANCY_ACTION_PHYSICALOBJECT_H
+
+#include "common/rect.h"
+
+namespace Nancy {
+namespace Action {
+
+// Moves things about the way the Nancy17 arcade puzzles do. Time goes by in ticks
+// of 20 ms, speeds are in pixels per second and angles in radians, counterclockwise
+// with the y axis pointing down the screen. Each tick the speed goes down by the
+// friction, a steady force (gravity, a gust) is added to the velocity, and the
+// object moves no more than a pixel at a time, so it can't skip past its bounds.
+class PhysicalObject {
+public:
+	static const uint32 kTickLength = 20;	// in milliseconds
+
+	enum BoundsMode {
+		kStopAtBounds,	// stands still where it would have left them
+		kBounceOffBounds
+	};
+
+	void setSize(int width, int height) { _width = width; _height = height; }
+	// An empty rect means there are none
+	void setBounds(const Common::Rect &bounds) { _bounds = bounds; }
+	void setBoundsMode(BoundsMode mode) { _boundsMode = mode; }
+
+	// Puts the object there, unless that is outside of its bounds
+	void moveTo(double x, double y);
+
+	double getX() const { return _x; }
+	double getY() const { return _y; }
+	// The rect the object takes up, centered on its position
+	Common::Rect getRect() const;
+
+	void setSpeed(double speed);
+	double getSpeed() const { return _speed; }
+	void setMaxSpeed(double maxSpeed) { _hasMaxSpeed = true; _maxSpeed = maxSpeed; setSpeed(_speed); }
+	void setHeading(double heading);
+	double getHeading() const { return _heading; }
+	void setFriction(double friction) { _friction = friction; }
+	void setForce(double x, double y) { _forceX = x; _forceY = y; }
+
+	// Whether the object ran into its bounds since this was last cleared
+	bool hasHitBounds() const { return _hasHitBounds; }
+	void clearHitBounds() { _hasHitBounds = false; }
+
+	void update(uint32 numTicks);
+
+	// The direction of a vector, between 0 and 2 pi
+	static double getAngle(double x, double y);
+
+private:
+	Common::Rect getRectAt(double x, double y) const;
+	bool isInsideBounds(const Common::Rect &rect) const;
+	// The way out of the bounds, -1 while the rect is inside of them
+	double getBoundsNormal(const Common::Rect &rect) const;
+
+	void accelerate(double numTicks);
+	void applyFriction(double numTicks);
+	void move(double numTicks);
+	void stopAtBounds(double x, double y);
+	void bounceOffBounds(double x, double y);
+
+	int _width = 1;
+	int _height = 1;
+	Common::Rect _bounds;
+	BoundsMode _boundsMode = kStopAtBounds;
+
+	double _x = 0;
+	double _y = 0;
+	double _speed = 0;
+	double _heading = 0;
+	double _friction = 0;
+	double _forceX = 0;
+	double _forceY = 0;
+
+	bool _hasMaxSpeed = false;
+	double _maxSpeed = 0;
+	bool _hasHitBounds = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_PHYSICALOBJECT_H
diff --git a/engines/nancy/action/puzzle/whackitpuzzle.cpp b/engines/nancy/action/puzzle/whackitpuzzle.cpp
new file mode 100644
index 00000000000..039d94fc1ca
--- /dev/null
+++ b/engines/nancy/action/puzzle/whackitpuzzle.cpp
@@ -0,0 +1,484 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/whackitpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// How the bees fly
+static const double kMaxBeeSpeed = 200.0;
+static const double kFallForce = 5.0;
+static const int kGustChance = 50;		// one tick in this many changes the bee's gust
+static const int kMaxGust = 20;
+
+// How long the screen flashes red after a sting
+static const uint32 kStingFlashTime = 500;
+
+void WhackItPuzzle::Animation::readData(Common::SeekableReadStream &stream) {
+	int32 framesPerSecond = stream.readSint32LE();
+	frameTime = framesPerSecond > 0 ? 1000 / framesPerSecond : 1000;
+
+	frames.resize(stream.readUint16LE());
+	for (Common::Rect &frame : frames) {
+		readRect(stream, frame);
+	}
+}
+
+void WhackItPuzzle::AnimationPlayer::start(const Animation *newAnimation) {
+	animation = newAnimation;
+	frame = 0;
+	frameRemainder = 0;
+	isDone = false;
+}
+
+void WhackItPuzzle::AnimationPlayer::advance(uint32 time, bool loops) {
+	time += frameRemainder;
+	frame += time / animation->frameTime;
+	frameRemainder = time % animation->frameTime;
+
+	if (loops) {
+		frame %= animation->frames.size();
+	} else {
+		isDone = frame >= animation->frames.size();
+	}
+}
+
+Common::Rect WhackItPuzzle::AnimationPlayer::getDest(const Common::Point &pos) const {
+	const Common::Rect &src = getSrc();
+	int left = pos.x - src.width() / 2;
+	int top = pos.y - src.height() / 2;
+	return Common::Rect(left, top, left + src.width(), top + src.height());
+}
+
+void WhackItPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+	readRect(stream, _canSrc);
+	_cursor = stream.readUint16LE();
+	_canOffset.x = stream.readSint32LE();
+	_canOffset.y = stream.readSint32LE();
+	_startPos.x = stream.readSint32LE();
+	_startPos.y = stream.readSint32LE();
+	readExclusiveRect(stream, _bounds);
+	_minBees = stream.readSint32LE();
+	_maxBees = stream.readSint32LE();
+
+	// In seconds
+	uint32 *times[] = { &_downTime[0], &_downTime[1], &_revivingTime[0], &_revivingTime[1],
+						&_flyingTime[0], &_flyingTime[1], &_angryTime[0], &_angryTime[1] };
+	for (uint32 *time : times) {
+		*time = stream.readUint32LE() * 1000;
+	}
+
+	_allDownFlag.label = stream.readSint16LE();
+	_stopsWhenAllDown = stream.readByte();
+	_hitFrame = stream.readSint32LE();
+
+	_puffAnimation.readData(stream);
+	_flyAnimation.readData(stream);
+	_downAnimation.readData(stream);
+
+	_puffSound.readData(stream);
+	_revivingSound.readData(stream);
+	_flyingSound.readData(stream);
+	_angrySound.readData(stream);
+	_stingSound.readData(stream);
+
+	_maxStings = stream.readSint32LE();
+	_loseScene._sceneChange.sceneID = stream.readUint16LE();
+	_loseScene._sceneChange.frameID = stream.readUint16LE();
+	_loseScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_loseScene._flag.label = stream.readSint16LE();
+	_loseScene._flag.flag = stream.readByte();
+	_loseSound.readData(stream);
+
+	readExitHotspot(stream);
+}
+
+void WhackItPuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	int numBees = _minBees;
+	if (_maxBees > _minBees) {
+		numBees += g_nancy->_randomSource->getRandomNumber(_maxBees - _minBees - 1);
+	}
+
+	_bees.resize(numBees);
+	for (Bee &bee : _bees) {
+		spawnBee(bee);
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	_flash._drawSurface.create(viewportBounds.width(), viewportBounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
+	_flash.moveTo(viewportBounds);
+	_flash.setVisible(false);
+
+	_lastUpdateTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+void WhackItPuzzle::registerGraphics() {
+	PuzzleRecord::registerGraphics();
+	_flash.registerGraphics();
+}
+
+uint32 WhackItPuzzle::getRandomTime(uint32 minTime, uint32 maxTime) const {
+	uint32 range = MAX<int32>((int32)(maxTime - minTime), 1);
+	return g_nancy->getTotalPlayTime() + g_nancy->_randomSource->getRandomNumber(range - 1) + minTime;
+}
+
+void WhackItPuzzle::spawnBee(Bee &bee) {
+	bee.animation.start(&_flyAnimation);
+	bee.state = kFlying;
+
+	const Common::Rect &frame = _flyAnimation.frames[0];
+	bee.body.setSize(frame.width(), frame.height());
+	bee.body.setBounds(_bounds);
+	bee.body.setBoundsMode(PhysicalObject::kBounceOffBounds);
+	bee.body.setMaxSpeed(kMaxBeeSpeed);
+
+	int x = _startPos.x;
+	int y = _startPos.y;
+	if (x < 0) {
+		x = _bounds.left + g_nancy->_randomSource->getRandomNumber(MAX<int>(_bounds.width(), 1) - 1);
+	}
+
+	if (y < 0) {
+		y = _bounds.top + g_nancy->_randomSource->getRandomNumber(MAX<int>(_bounds.height(), 1) - 1);
+	}
+
+	bee.body.moveTo(x, y);
+	bee.body.setSpeed(50 + g_nancy->_randomSource->getRandomNumber(99));
+	bee.body.setHeading(g_nancy->_randomSource->getRandomNumber(359) * M_PI / 180);
+	bee.stateEndTime = getRandomTime(_flyingTime[0], _flyingTime[1]);
+}
+
+void WhackItPuzzle::flyAgain(Bee &bee) {
+	bee.animation.start(&_flyAnimation);
+	bee.body.setSpeed(kMaxBeeSpeed);
+	bee.body.setHeading(3 * M_PI / 2);
+	bee.body.setForce(0, 0);
+	bee.body.setBoundsMode(PhysicalObject::kBounceOffBounds);
+	bee.state = kFlying;
+	bee.stateEndTime = getRandomTime(_flyingTime[0], _flyingTime[1]);
+}
+
+void WhackItPuzzle::knockDown(Bee &bee) {
+	// The bee drops to the ground
+	bee.animation.start(&_downAnimation);
+	bee.body.setSpeed(0);
+	bee.body.setHeading(M_PI / 2);
+	bee.body.setForce(0, kFallForce);
+	bee.body.setBoundsMode(PhysicalObject::kStopAtBounds);
+	bee.state = kDown;
+	bee.stateEndTime = getRandomTime(_downTime[0], _downTime[1]);
+}
+
+void WhackItPuzzle::sting() {
+	++_numStings;
+	playSoundBlock(_stingSound);
+	_lastStingTime = g_nancy->getTotalPlayTime();
+	_hasStung = true;
+
+	if (_numStings > _maxStings) {
+		_isLost = true;
+	}
+}
+
+void WhackItPuzzle::updateBee(Bee &bee, uint32 numTicks) {
+	bee.body.update(numTicks);
+	const uint32 now = g_nancy->getTotalPlayTime();
+
+	switch (bee.state) {
+	case kDown:
+		// A bee that is down doesn't move its wings
+		if (_isActive && now > bee.stateEndTime) {
+			bee.state = kReviving;
+			bee.stateEndTime = getRandomTime(_revivingTime[0], _revivingTime[1]);
+		}
+
+		return;
+	case kReviving:
+		if (!_isActive) {
+			return;
+		}
+
+		if (now > bee.stateEndTime) {
+			flyAgain(bee);
+		}
+
+		break;
+	case kFlying:
+	case kAngry:
+		if (now > bee.stateEndTime) {
+			if (bee.state == kFlying) {
+				bee.state = kAngry;
+				bee.stateEndTime = getRandomTime(_angryTime[0], _angryTime[1]);
+			} else {
+				sting();
+				flyAgain(bee);
+			}
+		}
+
+		// Now and then a gust changes where the bee drifts
+		if (g_nancy->_randomSource->getRandomNumber(kGustChance - 1) == 0) {
+			bee.body.setForce((int)g_nancy->_randomSource->getRandomNumber(2 * kMaxGust - 1) - kMaxGust,
+							(int)g_nancy->_randomSource->getRandomNumber(2 * kMaxGust - 1) - kMaxGust);
+		}
+
+		break;
+	}
+
+	bee.animation.advance(numTicks * PhysicalObject::kTickLength, true);
+}
+
+void WhackItPuzzle::updateSounds(uint numReviving, uint numFlying, uint numAngry) {
+	// The more bees there are of a kind, the louder they get
+	const double numBees = MAX<uint>(_bees.size(), 1);
+	const RandomSoundBlock *sounds[] = { &_revivingSound, &_flyingSound, &_angrySound };
+	uint16 volumes[] = {
+		(uint16)(numReviving ? (0.5 + numReviving / numBees * 0.5) * _revivingSound.volume : 0),
+		(uint16)(numFlying ? (0.5 + numFlying / numBees * 0.5) * _flyingSound.volume : 0),
+		(uint16)(numAngry ? _angrySound.volume : 0)
+	};
+
+	for (uint i = 0; i < ARRAYSIZE(sounds); ++i) {
+		if (sounds[i]->names.empty()) {
+			continue;
+		}
+
+		if (!g_nancy->_sound->isSoundPlaying(sounds[i]->channel)) {
+			playSoundBlock(*sounds[i]);
+		}
+
+		g_nancy->_sound->setVolume(sounds[i]->channel, volumes[i]);
+	}
+}
+
+void WhackItPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+	const uint32 now = g_nancy->getTotalPlayTime();
+
+	for (const Bee &bee : _bees) {
+		const Common::Rect &src = bee.animation.getSrc();
+		Common::Rect dest = bee.animation.getDest(bee.getPos());
+
+		if (bee.state != kAngry) {
+			_drawSurface.blitFrom(_image, src, dest.origin());
+			continue;
+		}
+
+		// An angry bee flickers red
+		uint8 amount = (now - bee.stateEndTime) & 0xFF;
+		for (int y = MAX<int>(dest.top, 0); y < MIN<int>(dest.bottom, _drawSurface.h); ++y) {
+			for (int x = MAX<int>(dest.left, 0); x < MIN<int>(dest.right, _drawSurface.w); ++x) {
+				uint32 color = _image.getPixel(src.left + x - dest.left, src.top + y - dest.top);
+				if (color == _image.getTransparentColor()) {
+					continue;
+				}
+
+				uint8 a, r, g, b;
+				_image.format.colorToARGB(color, a, r, g, b);
+				_drawSurface.setPixel(x, y, _drawSurface.format.ARGBToColor(a, r, g * amount / 255, b * amount / 255));
+			}
+		}
+	}
+
+	for (const Puff &puff : _puffs) {
+		_drawSurface.blitFrom(_image, puff.animation.getSrc(), puff.animation.getDest(puff.pos).origin());
+	}
+
+	if (_isActive && _isMouseInside) {
+		_drawSurface.blitFrom(_image, _canSrc, _mousePos + _canOffset);
+	}
+
+	// A sting turns everything red for a moment
+	if (_hasStung && now < _lastStingTime + kStingFlashTime) {
+		uint8 alpha = (uint8)((1.0 - (now - _lastStingTime) / (double)kStingFlashTime) * 255);
+		_flash._drawSurface.clear(_flash._drawSurface.format.ARGBToColor(alpha, 255, 0, 0));
+		_flash.setVisible(true);
+		_flash.setNeedsRedraw(true);
+	} else {
+		_flash.setVisible(false);
+	}
+
+	_needsRedraw = true;
+}
+
+void WhackItPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		uint32 now = g_nancy->getTotalPlayTime();
+		uint32 numTicks = (now - _lastUpdateTime) / PhysicalObject::kTickLength;
+		_lastUpdateTime += numTicks * PhysicalObject::kTickLength;
+
+		if (numTicks > 0) {
+			uint numReviving = 0;
+			uint numFlying = 0;
+			uint numAngry = 0;
+
+			for (Bee &bee : _bees) {
+				updateBee(bee, numTicks);
+
+				switch (bee.state) {
+				case kReviving:
+					++numReviving;
+					break;
+				case kFlying:
+					++numFlying;
+					break;
+				case kAngry:
+					++numAngry;
+					break;
+				default:
+					break;
+				}
+			}
+
+			updateSounds(numReviving, numFlying, numAngry);
+
+			bool isAllDown = numFlying + numAngry == 0;
+			NancySceneState.setEventFlag(_allDownFlag.label, isAllDown ? g_nancy->_true : g_nancy->_false);
+			if (isAllDown && _stopsWhenAllDown) {
+				_isActive = false;
+			}
+
+			// A puff knocks down every flying bee it touches while it lasts
+			for (uint i = 0; i < _puffs.size(); ) {
+				Puff &puff = _puffs[i];
+				puff.animation.advance(numTicks * PhysicalObject::kTickLength, false);
+				if (puff.animation.isDone) {
+					_puffs.remove_at(i);
+					continue;
+				}
+
+				Common::Rect puffRect = puff.animation.getDest(puff.pos);
+				for (Bee &bee : _bees) {
+					if (bee.state != kFlying && bee.state != kAngry) {
+						continue;
+					}
+
+					// The frame the bee is hit in, or its current one
+					const Common::Array<Common::Rect> &frames = bee.animation.animation->frames;
+					int hitFrame = _hitFrame < 0 ? (int)bee.animation.frame : _hitFrame;
+					const Common::Rect &hitSrc = frames[CLIP<int>(hitFrame, 0, frames.size() - 1)];
+					Common::Point pos = bee.getPos();
+					Common::Rect beeRect(pos.x - hitSrc.width() / 2, pos.y - hitSrc.height() / 2,
+										pos.x - hitSrc.width() / 2 + hitSrc.width(), pos.y - hitSrc.height() / 2 + hitSrc.height());
+
+					if (beeRect.intersects(puffRect)) {
+						knockDown(bee);
+					}
+				}
+
+				++i;
+			}
+		}
+
+		if (_isLost) {
+			_state = kActionTrigger;
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		if (_isLost) {
+			playSoundBlock(_loseSound);
+			_loseScene.execute();
+		} else {
+			_exitScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void WhackItPuzzle::handleInput(NancyInput &input) {
+	_isMouseInside = false;
+
+	if (_state != kRun || _isLost || _exitRequested) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	const bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+	if (hoverExitHotspot(input)) {
+		if (clicked) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	_mousePos = mouseRect.origin();
+	_isMouseInside = true;
+
+	if (!_isActive) {
+		return;
+	}
+
+	// The spray can takes the place of the cursor
+	setDataCursor(_cursor);
+
+	if (clicked) {
+		playSoundBlock(_puffSound);
+
+		Puff puff;
+		puff.pos = _mousePos;
+		puff.animation.start(&_puffAnimation);
+		_puffs.push_back(puff);
+
+		input.eatMouseInput();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/whackitpuzzle.h b/engines/nancy/action/puzzle/whackitpuzzle.h
new file mode 100644
index 00000000000..ea84b9188a4
--- /dev/null
+++ b/engines/nancy/action/puzzle/whackitpuzzle.h
@@ -0,0 +1,163 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_WHACKITPUZZLE_H
+#define NANCY_ACTION_WHACKITPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+#include "engines/nancy/action/physicalobject.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 bees in the garden. The bees fly around, get angry after a while, and
+// sting the player when they stay angry for too long. A puff of the spray can knocks
+// down every bee it touches; a bee that is down gets back up after some time. With
+// every bee down the puzzle sets its flag, and too many stings lose the game.
+class WhackItPuzzle : public PuzzleRecord {
+public:
+	// The bees and the puffs go in front of the berries, which are overlays
+	WhackItPuzzle() : PuzzleRecord(15) {}
+	virtual ~WhackItPuzzle() {}
+
+	void init() override;
+	void registerGraphics() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "WhackItPuzzle"; }
+
+	struct Animation {
+		uint32 frameTime = 0;	// in milliseconds
+		Common::Array<Common::Rect> frames;
+
+		void readData(Common::SeekableReadStream &stream);
+	};
+
+	struct AnimationPlayer {
+		const Animation *animation = nullptr;
+		uint frame = 0;
+		uint32 frameRemainder = 0;
+		bool isDone = false;
+
+		void start(const Animation *newAnimation);
+		void advance(uint32 time, bool loops);
+		// The current frame, centered on a position
+		Common::Rect getDest(const Common::Point &pos) const;
+		const Common::Rect &getSrc() const { return animation->frames[MIN<uint>(frame, animation->frames.size() - 1)]; }
+	};
+
+	enum BeeState {
+		kDown,
+		kReviving,
+		kFlying,
+		kAngry
+	};
+
+	struct Bee {
+		BeeState state = kFlying;
+		uint32 stateEndTime = 0;
+		PhysicalObject body;
+		AnimationPlayer animation;
+
+		Common::Point getPos() const { return Common::Point((int)body.getX(), (int)body.getY()); }
+	};
+
+	// Drawn over the puzzle, so it can be see-through
+	struct FlashOverlay : RenderObject {
+		FlashOverlay() : RenderObject(16) {}
+
+		bool isViewportRelative() const override { return true; }
+	};
+
+	struct Puff {
+		Common::Point pos;
+		AnimationPlayer animation;
+	};
+
+	// A time between the two given ones, counted from now
+	uint32 getRandomTime(uint32 minTime, uint32 maxTime) const;
+	void spawnBee(Bee &bee);
+	void flyAgain(Bee &bee);
+	void knockDown(Bee &bee);
+	void updateBee(Bee &bee, uint32 numTicks);
+	void sting();
+	void updateSounds(uint numReviving, uint numFlying, uint numAngry);
+	void redraw();
+
+	// -- File data --
+	Common::Rect _canSrc;
+	uint16 _cursor = 0;
+	Common::Point _canOffset;	// from the mouse to the can's top left corner
+	Common::Point _startPos;	// a negative coordinate is random
+	Common::Rect _bounds;
+	int32 _minBees = 0;
+	int32 _maxBees = 0;
+
+	// How long the bees stay in each state
+	uint32 _downTime[2] = { 0, 0 };
+	uint32 _revivingTime[2] = { 0, 0 };
+	uint32 _flyingTime[2] = { 0, 0 };
+	uint32 _angryTime[2] = { 0, 0 };
+
+	FlagDescription _allDownFlag;	// gets the opposite value while any bee flies
+	bool _stopsWhenAllDown = false;
+	int32 _hitFrame = 0;	// the frame of a bee that a puff has to touch
+
+	Animation _puffAnimation;
+	Animation _flyAnimation;
+	Animation _downAnimation;
+
+	RandomSoundBlock _puffSound;
+	RandomSoundBlock _revivingSound;
+	RandomSoundBlock _flyingSound;
+	RandomSoundBlock _angrySound;
+	RandomSoundBlock _stingSound;
+
+	int32 _maxStings = 0;
+	SceneChangeWithFlag _loseScene;
+	RandomSoundBlock _loseSound;
+
+	// -- Runtime --
+	Common::Array<Bee> _bees;
+	Common::Array<Puff> _puffs;
+	bool _isActive = true;
+	int32 _numStings = 0;
+	uint32 _lastStingTime = 0;
+	bool _hasStung = false;
+	FlashOverlay _flash;
+	uint32 _lastUpdateTime = 0;
+	Common::Point _mousePos;
+	bool _isMouseInside = false;
+	bool _isLost = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_WHACKITPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 2dd2744352d..f94c93d5f51 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -17,6 +17,7 @@ MODULE_OBJS = \
   action/conversation.o \
   action/interactivevideo.o \
   action/overlay.o \
+  action/physicalobject.o \
   action/puzzlerecord.o \
   action/secondarymovie.o \
   action/secondaryvideo.o \
@@ -103,6 +104,7 @@ MODULE_OBJS = \
   action/puzzle/twodialpuzzle.o \
   action/puzzle/typingquizpuzzle.o \
   action/puzzle/weightsortpuzzle.o \
+  action/puzzle/whackitpuzzle.o \
   action/puzzle/wolfmazepuzzle.o \
   action/puzzle/whalesurvivorpuzzle.o \
   action/puzzle/wordfindpuzzle.o \


Commit: 8accf93f9cfe5f3d5d9b4201aea3458c2eb513c2
    https://github.com/scummvm/scummvm/commit/8accf93f9cfe5f3d5d9b4201aea3458c2eb513c2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:29+03:00

Commit Message:
NANCY: NANCY17: Implement SkeeBallPuzzle

- Record 206: a click on the dispenser brings out a ball, which is
  pulled back and let go to roll it
- Past the ramp the ball jumps, and the target nearest to where it
  comes down spins on to its next picture
- A goal is met once every target shows its picture

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 6465b192a9a..8c74c2b92f0 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -102,6 +102,7 @@
 #include "engines/nancy/action/puzzle/setplayerclock.h"
 #include "engines/nancy/action/puzzle/sewingmachinepuzzle.h"
 #include "engines/nancy/action/puzzle/shovelmeasurepuzzle.h"
+#include "engines/nancy/action/puzzle/skeeballpuzzle.h"
 #include "engines/nancy/action/puzzle/sliderpuzzle.h"
 #include "engines/nancy/action/puzzle/snowmobilepuzzle.h"
 #include "engines/nancy/action/puzzle/sortpuzzle.h"
@@ -776,7 +777,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new ShovelMeasurePuzzle();
 	case 206:
-		return new RaycastPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new RaycastPuzzle();
+		else
+			return new SkeeBallPuzzle();
 	case 207:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new TangramPuzzle();
diff --git a/engines/nancy/action/puzzle/skeeballpuzzle.cpp b/engines/nancy/action/puzzle/skeeballpuzzle.cpp
new file mode 100644
index 00000000000..02a8ca01f91
--- /dev/null
+++ b/engines/nancy/action/puzzle/skeeballpuzzle.cpp
@@ -0,0 +1,516 @@
+/* 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/algorithm.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/skeeballpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// How fast the picture of a rolling ball turns
+static const double kRollFrameRate = 15.0;
+
+SkeeBallPuzzle::~SkeeBallPuzzle() {
+	delete _heldBall;
+	for (Ball *ball : _balls) {
+		delete ball;
+	}
+}
+
+static void readRectList(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &rects) {
+	rects.resize(stream.readUint16LE());
+	for (Common::Rect &rect : rects) {
+		readExclusiveRect(stream, rect);
+	}
+}
+
+static void readSceneBlock(Common::SeekableReadStream &stream, SceneChangeWithFlag &scene, RandomSoundBlock &sound) {
+	scene._sceneChange.sceneID = stream.readUint16LE();
+	scene._sceneChange.frameID = stream.readUint16LE();
+	scene._sceneChange.continueSceneSound = kContinueSceneSound;
+	scene._flag.label = stream.readSint16LE();
+	scene._flag.flag = stream.readByte();
+	sound.readData(stream);
+}
+
+bool SkeeBallPuzzle::compareStateNames(const TargetState &a, const TargetState &b) {
+	return a.name.compareToIgnoreCase(b.name) < 0;
+}
+
+uint SkeeBallPuzzle::findState(const Common::String &name) const {
+	for (uint i = 0; i < _states.size(); ++i) {
+		if (_states[i].name.equalsIgnoreCase(name)) {
+			return i;
+		}
+	}
+
+	error("SkeeBallPuzzle: there is no target picture called %s", name.c_str());
+}
+
+void SkeeBallPuzzle::readData(Common::SeekableReadStream &stream) {
+	// The sizes and positions here are what the balls move by, so the rects are read as they are
+	readFilename(stream, _imageName);
+	_numSpins = stream.readSint32LE();
+	_spinTime = (uint32)(stream.readDoubleLE() * 1000);
+	readRectList(stream, _targetSrcs);
+
+	_states.resize(stream.readUint16LE());
+	for (TargetState &state : _states) {
+		readFilename(stream, state.name);
+		state.frame = stream.readSint32LE();
+	}
+
+	// A target turns to the next picture in the order of their names
+	Common::sort(_states.begin(), _states.end(), compareStateNames);
+
+	Common::String startState;
+	readFilename(stream, startState);
+	uint startStateID = findState(startState);
+
+	_targets.resize(stream.readUint16LE());
+	for (Target &target : _targets) {
+		readExclusiveRect(stream, target.dest);
+		target.stateID = startStateID;
+		target.frame = _states[startStateID].frame;
+	}
+
+	_dispenserCursor = stream.readUint16LE();
+	readExclusiveRect(stream, _dispenserHotspot);
+	readExclusiveRect(stream, _dispenserDest);
+	readFilename(stream, _dispenserMovieName);
+
+	_aimCursor = stream.readUint16LE();
+	readExclusiveRect(stream, _arrowSrc);
+	readExclusiveRect(stream, _aimHotspot);
+	_minAngle = stream.readDoubleLE() * M_PI / 180;
+	_maxAngle = stream.readDoubleLE() * M_PI / 180;
+	_minPower = stream.readDoubleLE();
+	_maxPower = stream.readDoubleLE();
+
+	readExclusiveRect(stream, _laneBounds);
+	readExclusiveRect(stream, _ramp);
+	_friction = stream.readDoubleLE();
+	readExclusiveRect(stream, _ballStart);
+	_gravity = stream.readDoubleLE();
+	_heightScale = stream.readDoubleLE();
+	readRectList(stream, _ballSrcs);
+
+	_pickUpSound.readData(stream);
+	_rollSound.readData(stream);
+	_spinSound.readData(stream);
+
+	_goals.resize(stream.readUint16LE());
+	for (Goal &goal : _goals) {
+		Common::String stateName;
+		readFilename(stream, stateName);
+		goal.stateID = findState(stateName);
+		readSceneBlock(stream, goal.scene, goal.sound);
+	}
+
+	readExitHotspot(stream);
+}
+
+void SkeeBallPuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	_angle = _maxAngle;
+	_power = _maxPower;
+
+	if (!_dispenserMovieName.empty() && _dispenserMovie.loadFile(_dispenserMovieName)) {
+		_dispenserMovie.playRange(0, _dispenserMovie.getFrameCount() - 1);
+	}
+
+	_lastUpdateTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+void SkeeBallPuzzle::createBall() {
+	_heldBall = new Ball();
+	PhysicalObject &body = _heldBall->body;
+	body.setSize(_ballStart.width(), _ballStart.height());
+	body.setFriction(_friction);
+	body.setBounds(Common::Rect(_laneBounds.left, _laneBounds.top, _laneBounds.right + 1, _laneBounds.bottom + 1));
+	body.setBoundsMode(PhysicalObject::kBounceOffBounds);
+	body.moveTo(_ballStart.left + _ballStart.width() * 0.5, _ballStart.top + _ballStart.height() * 0.5);
+	body.setSpeed(0);
+	body.setHeading(_angle);
+}
+
+bool SkeeBallPuzzle::updateBall(Ball &ball, uint32 numTicks) {
+	PhysicalObject &body = ball.body;
+	if (body.getSpeed() == 0) {
+		return true;
+	}
+
+	body.update(numTicks);
+
+	// Off the ramp the ball flies, and falls
+	const double seconds = numTicks * PhysicalObject::kTickLength / 1000.0;
+	if (ball.height != 0 || ball.verticalSpeed != 0) {
+		ball.verticalSpeed -= seconds * _gravity;
+		ball.height += seconds * ball.verticalSpeed;
+	}
+
+	ball.frame += body.getSpeed() / (0.5 * _maxPower + 0.5 * _minPower) * kRollFrameRate * numTicks;
+	while (!_ballSrcs.empty() && ball.frame >= _ballSrcs.size()) {
+		ball.frame -= _ballSrcs.size();
+	}
+
+	// The ball gets off the ground once it is past the ramp, seen from where it started
+	Common::Rect rect = body.getRect();
+	double rampX = _ramp.left + _ramp.width() / 2;
+	double rampY = _ramp.top + _ramp.height() / 2;
+	double startX = _ballStart.left + _ballStart.width() / 2 - rampX;
+	double startY = _ballStart.top + _ballStart.height() / 2 - rampY;
+	double ballX = rect.left + rect.width() / 2 - rampX;
+	double ballY = rect.top + rect.height() / 2 - rampY;
+	if (startX * ballX + startY * ballY < 0 && ball.verticalSpeed == 0) {
+		body.setFriction(0);
+		ball.verticalSpeed = body.getSpeed() * 0.5;
+		body.setSpeed(ball.verticalSpeed);
+	}
+
+	if (ball.height >= 0) {
+		return false;
+	}
+
+	// Down again, on the nearest target
+	Target *nearest = nullptr;
+	int nearestDistance = 0;
+	Common::Point center(rect.left + rect.width() / 2, rect.top + rect.height() / 2);
+	for (Target &target : _targets) {
+		int dx = target.dest.left + target.dest.width() / 2 - center.x;
+		int dy = target.dest.top + target.dest.height() / 2 - center.y;
+		int distance = dx * dx + dy * dy;
+		if (!nearest || distance < nearestDistance) {
+			nearest = ⌖
+			nearestDistance = distance;
+		}
+	}
+
+	if (nearest) {
+		spinTarget(*nearest);
+	}
+
+	return true;
+}
+
+void SkeeBallPuzzle::spinTarget(Target &target) {
+	const int32 numFrames = _targetSrcs.size();
+
+	target.spinStartTime = g_nancy->getTotalPlayTime();
+	target.spinStartFrame = target.frame;
+	target.isSpinning = true;
+	target.stateID = (target.stateID + 1) % _states.size();
+
+	// A few whole turns, then on to the frame of the next picture
+	int32 rest = _states[target.stateID].frame - target.frame;
+	if (rest < 0) {
+		rest += numFrames;
+	}
+
+	target.spinEndFrame = numFrames * _numSpins + target.frame + rest;
+	playSoundBlock(_spinSound);
+}
+
+void SkeeBallPuzzle::updateTargets() {
+	const uint32 now = g_nancy->getTotalPlayTime();
+	bool hasStopped = false;
+
+	for (Target &target : _targets) {
+		if (!target.isSpinning) {
+			continue;
+		}
+
+		if (now < target.spinStartTime + _spinTime) {
+			double progress = (now - target.spinStartTime) / (double)_spinTime;
+			target.frame = target.spinStartFrame + (int32)((target.spinEndFrame - target.spinStartFrame) * progress);
+		} else {
+			target.isSpinning = false;
+			target.frame = _states[target.stateID].frame;
+			hasStopped = true;
+		}
+	}
+
+	if (!hasStopped) {
+		return;
+	}
+
+	// A goal is met once every target shows its picture
+	for (uint i = 0; i < _goals.size(); ++i) {
+		Goal &goal = _goals[i];
+		if (goal.isMet) {
+			continue;
+		}
+
+		bool isMet = true;
+		for (const Target &target : _targets) {
+			if (target.isSpinning || target.frame != _states[goal.stateID].frame) {
+				isMet = false;
+				break;
+			}
+		}
+
+		if (!isMet) {
+			continue;
+		}
+
+		goal.isMet = true;
+		playSoundBlock(goal.sound);
+		NancySceneState.setEventFlag(goal.scene._flag);
+
+		if (goal.scene._sceneChange.sceneID != kNoSceneNancy17) {
+			_metGoal = i;
+		}
+	}
+}
+
+void SkeeBallPuzzle::drawBall(const Ball &ball) {
+	if (_ballSrcs.empty()) {
+		return;
+	}
+
+	const Common::Rect &src = _ballSrcs[CLIP<int>((int)ball.frame, 0, _ballSrcs.size() - 1)];
+	Common::Rect rect = ball.body.getRect();
+
+	// The higher the ball, the larger it looks
+	double scale = _heightScale != 0 ? ball.height / _heightScale + 1.0 : 1.0;
+	Common::Rect dest(rect.left, rect.top, rect.left + (int)(src.width() * scale), rect.top + (int)(src.height() * scale));
+	_drawSurface.transBlitFrom(_image, src, dest, _image.getTransparentColor());
+}
+
+void SkeeBallPuzzle::drawArrow() {
+	// The arrow turns around its left end, which sits on the ball, and gets longer with the power
+	const double cosA = cos(_angle);
+	const double sinA = sin(_angle);
+	const double stretch = (_power - _minPower) / (_maxPower - _minPower) + 0.5;
+	const Common::Point pivot(_aimHotspot.left + _aimHotspot.width() / 2, _aimHotspot.top + _aimHotspot.height() / 2);
+	const double halfHeight = _arrowSrc.height() / 2.0;
+	const int radius = (int)ceil(_arrowSrc.width() * stretch + _arrowSrc.height());
+	const uint32 transColor = _image.getTransparentColor();
+
+	for (int y = MAX<int>(0, pivot.y - radius); y < MIN<int>(_drawSurface.h, pivot.y + radius); ++y) {
+		for (int x = MAX<int>(0, pivot.x - radius); x < MIN<int>(_drawSurface.w, pivot.x + radius); ++x) {
+			// Back to the arrow's own picture, which points right
+			double dx = x + 0.5 - pivot.x;
+			double dy = y + 0.5 - pivot.y;
+			double alongX = dx * cosA - dy * sinA;
+			double alongY = dx * sinA + dy * cosA;
+			int srcX = (int)floor(alongX / stretch);
+			int srcY = (int)floor(alongY + halfHeight);
+
+			if (srcX < 0 || srcX >= _arrowSrc.width() || srcY < 0 || srcY >= _arrowSrc.height()) {
+				continue;
+			}
+
+			uint32 color = _image.getPixel(_arrowSrc.left + srcX, _arrowSrc.top + srcY);
+			if (color != transColor) {
+				_drawSurface.setPixel(x, y, color);
+			}
+		}
+	}
+}
+
+void SkeeBallPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	if (_dispenserMovie.isVideoLoaded()) {
+		_dispenserMovie.drawFrame(_drawSurface, _dispenserDest.origin());
+	}
+
+	for (const Target &target : _targets) {
+		if (!_targetSrcs.empty()) {
+			const Common::Rect &src = _targetSrcs[target.frame % _targetSrcs.size()];
+			_drawSurface.blitFrom(_image, src, target.dest.origin());
+		}
+	}
+
+	if (_heldBall) {
+		drawBall(*_heldBall);
+	}
+
+	for (const Ball *ball : _balls) {
+		drawBall(*ball);
+	}
+
+	if (_isAiming) {
+		drawArrow();
+	}
+
+	_needsRedraw = true;
+}
+
+void SkeeBallPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_dispenserMovie.isRangePlaying()) {
+			_dispenserMovie.update();
+		}
+
+		uint32 now = g_nancy->getTotalPlayTime();
+		uint32 numTicks = (now - _lastUpdateTime) / PhysicalObject::kTickLength;
+		_lastUpdateTime += numTicks * PhysicalObject::kTickLength;
+
+		if (numTicks > 0) {
+			// The rolling sound goes on while any ball rolls
+			if (!_balls.empty() && !isSoundBlockPlaying(_rollSound)) {
+				playSoundBlock(_rollSound);
+			} else if (_balls.empty() && isSoundBlockPlaying(_rollSound)) {
+				g_nancy->_sound->stopSound(_rollSound.channel);
+			}
+
+			for (uint i = 0; i < _balls.size(); ) {
+				if (updateBall(*_balls[i], numTicks)) {
+					delete _balls[i];
+					_balls.remove_at(i);
+				} else {
+					++i;
+				}
+			}
+
+			updateTargets();
+		}
+
+		if (_metGoal != -1) {
+			_state = kActionTrigger;
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		if (_metGoal != -1) {
+			NancySceneState.changeScene(_goals[_metGoal].scene._sceneChange);
+		} else {
+			_exitScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void SkeeBallPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _exitRequested || _metGoal != -1) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	const bool isDispenserReady = !_dispenserMovie.isRangePlaying();
+
+	if (_isAiming) {
+		// The ball is pulled back: it goes the other way, harder the further it is pulled
+		Common::Point center(_aimHotspot.left + _aimHotspot.width() / 2, _aimHotspot.top + _aimHotspot.height() / 2);
+		double dx = center.x - pos.x;
+		double dy = center.y - pos.y;
+		_angle = PhysicalObject::getAngle(dx, dy);
+		if (_angle < _minAngle) {
+			_angle = _minAngle;
+		} else if (_angle > _maxAngle) {
+			_angle = _maxAngle;
+		}
+
+		double halfArrow = _arrowSrc.width() * 0.5;
+		double amount = CLIP((sqrt(dx * dx + dy * dy) - halfArrow) / _arrowSrc.width(), 0.0, 1.0);
+		_power = _minPower + (_maxPower - _minPower) * amount;
+
+		setDataCursor(_aimCursor);
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			// Let go
+			_heldBall->lastUpdateTime = g_nancy->getTotalPlayTime();
+			_heldBall->body.setHeading(_angle);
+			_heldBall->body.setSpeed(_power);
+			_balls.push_back(_heldBall);
+			_heldBall = nullptr;
+			_isAiming = false;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	const bool pressed = input.input & NancyInput::kLeftMouseButtonDown;
+
+	if (!_heldBall && _dispenserHotspot.contains(pos)) {
+		setDataCursor(_dispenserCursor, isDispenserReady);
+
+		if (pressed && isDispenserReady) {
+			createBall();
+			if (_dispenserMovie.isVideoLoaded()) {
+				_dispenserMovie.playRange(0, _dispenserMovie.getFrameCount() - 1);
+			}
+
+			playSoundBlock(_pickUpSound);
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	if (_heldBall && _aimHotspot.contains(pos)) {
+		setDataCursor(_aimCursor);
+
+		if (pressed) {
+			_isAiming = true;
+			_angle = (_minAngle + _maxAngle) / 2;
+			_power = _minPower;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	if (hoverExitHotspot(input) && (input.input & NancyInput::kLeftMouseButtonUp)) {
+		_exitRequested = true;
+		input.eatMouseInput();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/skeeballpuzzle.h b/engines/nancy/action/puzzle/skeeballpuzzle.h
new file mode 100644
index 00000000000..235637c8ec2
--- /dev/null
+++ b/engines/nancy/action/puzzle/skeeballpuzzle.h
@@ -0,0 +1,144 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_SKEEBALLPUZZLE_H
+#define NANCY_ACTION_SKEEBALLPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/puzzlerecord.h"
+#include "engines/nancy/action/physicalobject.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 skee ball. A click on the dispenser brings out a ball; the ball is pulled
+// back and let go to roll it. Past the ramp it jumps, and where it comes down the
+// nearest target spins on to its next picture. Each goal is met once every target
+// shows its picture.
+class SkeeBallPuzzle : public PuzzleRecord {
+public:
+	SkeeBallPuzzle() : PuzzleRecord(7) {}
+	virtual ~SkeeBallPuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "SkeeBallPuzzle"; }
+
+	// A picture a target can show, by the frame that shows it
+	struct TargetState {
+		Common::String name;
+		int32 frame = 0;
+	};
+
+	struct Target {
+		Common::Rect dest;
+		uint stateID = 0;
+		int32 frame = 0;
+
+		bool isSpinning = false;
+		uint32 spinStartTime = 0;
+		int32 spinStartFrame = 0;
+		int32 spinEndFrame = 0;
+	};
+
+	struct Ball {
+		PhysicalObject body;
+		uint32 lastUpdateTime = 0;
+		double height = 0;			// above the lane, in pixels
+		double verticalSpeed = 0;
+		double frame = 0;
+	};
+
+	struct Goal {
+		uint stateID = 0;
+		SceneChangeWithFlag scene;
+		RandomSoundBlock sound;
+		bool isMet = false;
+	};
+
+	static bool compareStateNames(const TargetState &a, const TargetState &b);
+	uint findState(const Common::String &name) const;
+	void createBall();
+	// Returns whether the ball is gone
+	bool updateBall(Ball &ball, uint32 numTicks);
+	void spinTarget(Target &target);
+	void updateTargets();
+	void drawBall(const Ball &ball);
+	void drawArrow();
+	void redraw();
+
+	// -- File data --
+	int32 _numSpins = 0;			// whole turns a target makes before it stops
+	uint32 _spinTime = 0;			// in milliseconds
+	Common::Array<Common::Rect> _targetSrcs;
+	Common::Array<TargetState> _states;
+	Common::Array<Target> _targets;
+
+	uint16 _dispenserCursor = 0;
+	Common::Rect _dispenserHotspot;
+	Common::Rect _dispenserDest;
+	Common::Path _dispenserMovieName;
+
+	uint16 _aimCursor = 0;
+	Common::Rect _arrowSrc;
+	Common::Rect _aimHotspot;
+	double _minAngle = 0;
+	double _maxAngle = 0;
+	double _minPower = 0;
+	double _maxPower = 0;
+
+	Common::Rect _laneBounds;
+	Common::Rect _ramp;
+	double _friction = 0;
+	Common::Rect _ballStart;
+	double _gravity = 0;
+	double _heightScale = 0;		// a ball this high is drawn twice its size
+	Common::Array<Common::Rect> _ballSrcs;
+
+	RandomSoundBlock _pickUpSound;
+	RandomSoundBlock _rollSound;
+	RandomSoundBlock _spinSound;
+	Common::Array<Goal> _goals;
+
+	// -- Runtime --
+	MoviePlayer _dispenserMovie;
+	Ball *_heldBall = nullptr;
+	bool _isAiming = false;
+	double _angle = 0;
+	double _power = 0;
+	Common::Array<Ball *> _balls;
+	uint32 _lastUpdateTime = 0;
+	int _metGoal = -1;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_SKEEBALLPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index f94c93d5f51..975e604bb03 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -89,6 +89,7 @@ MODULE_OBJS = \
   action/puzzle/setplayerclock.o \
   action/puzzle/sewingmachinepuzzle.o \
   action/puzzle/shovelmeasurepuzzle.o \
+  action/puzzle/skeeballpuzzle.o \
   action/puzzle/sliderpuzzle.o \
   action/puzzle/snowmobilepuzzle.o \
   action/puzzle/sortpuzzle.o \


Commit: 8671d647802d1e58990d29874817ab3a5da8b95e
    https://github.com/scummvm/scummvm/commit/8671d647802d1e58990d29874817ab3a5da8b95e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:31+03:00

Commit Message:
NANCY: NANCY17: Implement EscapePuzzle

- Record 204, the laser maze: the player moves one cell at a time with
  the arrow keys or the buttons on screen
- Walking into a ball or a box pushes it: a ball rolls until something
  stops it, a box moves one cell
- A laser shines until a wall or an object is in the way; the player
  can't cross a beam, but balls and boxes block it
- The player wins when reaching the end cell

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 8c74c2b92f0..a57a7df32d3 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -58,6 +58,7 @@
 #include "engines/nancy/action/puzzle/dialsequencepuzzle.h"
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
 #include "engines/nancy/action/puzzle/dragsurfacepuzzle.h"
+#include "engines/nancy/action/puzzle/escapepuzzle.h"
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
 #include "engines/nancy/action/puzzle/escapegridpuzzle.h"
@@ -770,7 +771,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new WhackItPuzzle();
 	case 204:
-		return new OverrideLockPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new OverrideLockPuzzle();
+		else
+			return new EscapePuzzle();
 	case 205:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new RiddlePuzzle();
diff --git a/engines/nancy/action/puzzle/escapepuzzle.cpp b/engines/nancy/action/puzzle/escapepuzzle.cpp
new file mode 100644
index 00000000000..f589df282a7
--- /dev/null
+++ b/engines/nancy/action/puzzle/escapepuzzle.cpp
@@ -0,0 +1,548 @@
+/* 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/algorithm.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/escapepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// The board moves on in ticks of this many milliseconds
+static const uint32 kTickLength = 20;
+
+void EscapePuzzle::DirectionalAnimation::readData(Common::SeekableReadStream &stream) {
+	for (Common::Array<Common::Rect> &directionFrames : frames) {
+		directionFrames.resize(stream.readUint16LE());
+		for (Common::Rect &frame : directionFrames) {
+			readRect(stream, frame);
+		}
+	}
+}
+
+EscapePuzzle::Direction EscapePuzzle::readDirection(Common::SeekableReadStream &stream) {
+	Common::String name;
+	readFilename(stream, name);
+
+	if (name.equalsIgnoreCase("N")) {
+		return kNorth;
+	} else if (name.equalsIgnoreCase("S")) {
+		return kSouth;
+	} else if (name.equalsIgnoreCase("E")) {
+		return kEast;
+	} else if (name.equalsIgnoreCase("W")) {
+		return kWest;
+	}
+
+	error("EscapePuzzle: %s is not a direction", name.c_str());
+}
+
+Common::Point EscapePuzzle::getNeighbor(const Common::Point &pos, Direction direction) {
+	switch (direction) {
+	case kNorth:
+		return Common::Point(pos.x, pos.y - 1);
+	case kSouth:
+		return Common::Point(pos.x, pos.y + 1);
+	case kEast:
+		return Common::Point(pos.x + 1, pos.y);
+	case kWest:
+		return Common::Point(pos.x - 1, pos.y);
+	default:
+		return pos;
+	}
+}
+
+EscapePuzzle::Direction EscapePuzzle::getOpposite(Direction direction) {
+	switch (direction) {
+	case kNorth:
+		return kSouth;
+	case kSouth:
+		return kNorth;
+	case kEast:
+		return kWest;
+	default:
+		return kEast;
+	}
+}
+
+bool EscapePuzzle::compareObjectNames(const Object &a, const Object &b) {
+	return a.name.compareToIgnoreCase(b.name) < 0;
+}
+
+void EscapePuzzle::readData(Common::SeekableReadStream &stream) {
+	_buttonPressTime = (uint32)(stream.readDoubleLE() * 1000);
+
+	const Direction buttonOrder[] = { kNorth, kSouth, kWest, kEast };
+	for (Direction direction : buttonOrder) {
+		Button &button = _buttons[direction];
+		button.direction = direction;
+		readRect(stream, button.pressedSrc);
+		readRect(stream, button.dest);
+	}
+
+	_buttonCursor = stream.readUint16LE();
+
+	// The top left cell, the one to its right and the one below it
+	Common::Rect cellRight, cellBelow;
+	readRect(stream, _firstCell);
+	readRect(stream, cellRight);
+	readRect(stream, cellBelow);
+	_cellSpacing = Common::Point(cellRight.left - _firstCell.left, cellBelow.top - _firstCell.top);
+
+	readFilename(stream, _imageName);
+
+	// A star is a wall
+	_numRows = stream.readUint16LE();
+	Common::Array<Common::String> rows(_numRows);
+	for (Common::String &row : rows) {
+		char line[50];
+		stream.read(line, sizeof(line));
+		line[sizeof(line) - 1] = '\0';
+		row = line;
+		_numColumns = MAX<int>(_numColumns, row.size());
+	}
+
+	_walls.resize(_numColumns * _numRows);
+	for (int y = 0; y < _numRows; ++y) {
+		for (int x = 0; x < (int)rows[y].size(); ++x) {
+			_walls[y * _numColumns + x] = rows[y][x] == '*';
+		}
+	}
+
+	ObjectKind &player = _kinds[kPlayer];
+	player.moveTime = (uint32)(stream.readDoubleLE() * 1000);
+	player.animation.readData(stream);
+	player.secondAnimation.readData(stream);
+	player.moveSound.readData(stream);
+	player.pushSound.readData(stream);
+	player.bumpSound.readData(stream);
+
+	ObjectKind &ball = _kinds[kBall];
+	ball.moveTime = (uint32)(stream.readDoubleLE() * 1000);
+	ball.animation.readData(stream);
+	ball.moveSound.readData(stream);
+
+	ObjectKind &box = _kinds[kBox];
+	box.moveTime = (uint32)(stream.readDoubleLE() * 1000);
+	box.animation.readData(stream);
+	box.moveSound.readData(stream);
+
+	ObjectKind &laser = _kinds[kLaser];
+	laser.animation.readData(stream);
+	laser.secondAnimation.readData(stream);
+
+	_objects.resize(stream.readUint16LE());
+	for (Object &object : _objects) {
+		readFilename(stream, object.name);
+
+		Common::String typeName;
+		readFilename(stream, typeName);
+		if (typeName.equalsIgnoreCase("Player")) {
+			if (!_playerName.empty()) {
+				error("EscapePuzzle: there can only be one player on the board");
+			}
+
+			object.type = kPlayer;
+			_playerName = object.name;
+		} else if (typeName.equalsIgnoreCase("Ball")) {
+			object.type = kBall;
+		} else if (typeName.equalsIgnoreCase("Box")) {
+			object.type = kBox;
+		} else if (typeName.equalsIgnoreCase("Laser")) {
+			object.type = kLaser;
+		} else {
+			error("EscapePuzzle: unknown object type %s", typeName.c_str());
+		}
+
+		object.pos.x = stream.readSint32LE();
+		object.pos.y = stream.readSint32LE();
+		object.facing = readDirection(stream);
+	}
+
+	// The objects take their turns in the order of their names
+	Common::sort(_objects.begin(), _objects.end(), compareObjectNames);
+
+	_goals.resize(stream.readUint16LE());
+	for (Goal &goal : _goals) {
+		readFilename(stream, goal.objectName);
+		goal.pos.x = stream.readSint32LE();
+		goal.pos.y = stream.readSint32LE();
+		goal.scene._sceneChange.sceneID = stream.readUint16LE();
+		goal.scene._sceneChange.frameID = stream.readUint16LE();
+		goal.scene._sceneChange.continueSceneSound = kContinueSceneSound;
+		goal.scene._flag.label = stream.readSint16LE();
+		goal.scene._flag.flag = stream.readByte();
+		goal.sound.readData(stream);
+	}
+
+	readExitHotspot(stream);
+}
+
+void EscapePuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	_lastUpdateTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+Common::Rect EscapePuzzle::getCellRect(const Common::Point &pos) const {
+	Common::Rect rect = _firstCell;
+	rect.translate(pos.x * _cellSpacing.x, pos.y * _cellSpacing.y);
+	return rect;
+}
+
+EscapePuzzle::CellContents EscapePuzzle::getContents(const Common::Point &pos) const {
+	if (pos.x < 0 || pos.y < 0 || pos.x >= _numColumns || pos.y >= _numRows || _walls[pos.y * _numColumns + pos.x]) {
+		return kWall;
+	}
+
+	for (const Object &object : _objects) {
+		if (object.pos == pos) {
+			return kObject;
+		}
+
+		if (object.type == kLaser) {
+			for (const BeamCell &cell : object.beam) {
+				if (cell.pos == pos) {
+					return kLaserBeam;
+				}
+			}
+		}
+	}
+
+	return kEmpty;
+}
+
+EscapePuzzle::Object *EscapePuzzle::findObject(const Common::String &name) {
+	for (Object &object : _objects) {
+		if (object.name.equalsIgnoreCase(name)) {
+			return &object;
+		}
+	}
+
+	return nullptr;
+}
+
+void EscapePuzzle::push(const Common::Point &pos, Direction direction) {
+	for (Object &object : _objects) {
+		if (object.pos != pos) {
+			continue;
+		}
+
+		// Only balls and boxes give way
+		if (object.type == kBall || object.type == kBox) {
+			object.movingTo = direction;
+		}
+
+		return;
+	}
+}
+
+void EscapePuzzle::updateBeam(Object &laser) {
+	laser.beam.clear();
+
+	Common::Point pos = getNeighbor(laser.pos, laser.facing);
+	CellContents contents = getContents(pos);
+	while (contents != kWall && contents != kObject) {
+		bool isLoop = false;
+		for (const BeamCell &cell : laser.beam) {
+			if (cell.pos == pos && cell.direction == laser.facing) {
+				isLoop = true;
+				break;
+			}
+		}
+
+		if (isLoop) {
+			break;
+		}
+
+		BeamCell cell;
+		cell.pos = pos;
+		cell.direction = laser.facing;
+		laser.beam.push_back(cell);
+
+		pos = getNeighbor(pos, laser.facing);
+		contents = getContents(pos);
+	}
+}
+
+bool EscapePuzzle::updateObject(Object &object, uint32 time) {
+	object.time += time;
+	const ObjectKind &kind = _kinds[object.type];
+
+	if (object.type == kLaser) {
+		updateBeam(object);
+		return false;
+	}
+
+	if (object.movingTo == kNoDirection || object.time < kind.moveTime) {
+		return false;
+	}
+
+	const Direction direction = object.movingTo;
+	const Common::Point next = getNeighbor(object.pos, direction);
+	const CellContents contents = getContents(next);
+	object.facing = direction;
+	object.motion = kStill;
+
+	switch (object.type) {
+	case kPlayer:
+		object.movingTo = kNoDirection;
+
+		if (contents == kEmpty) {
+			object.pos = next;
+			object.motion = kMoving;
+			object.time = 0;
+			playSoundBlock(kind.moveSound);
+			return true;
+		} else if (contents == kObject) {
+			playSoundBlock(kind.pushSound);
+			object.motion = kPushing;
+			push(next, direction);
+			object.time = 0;
+			return true;
+		}
+
+		playSoundBlock(kind.bumpSound);
+		return false;
+	case kBall:
+		// A ball keeps rolling until something stops it
+		if (contents == kEmpty || contents == kLaserBeam) {
+			object.pos = next;
+			object.motion = kMoving;
+			object.time = 0;
+			playSoundBlock(kind.moveSound);
+			return true;
+		}
+
+		object.movingTo = kNoDirection;
+		object.time = 0;
+		return false;
+	default:
+		// A box moves one cell
+		object.movingTo = kNoDirection;
+
+		if (contents == kEmpty || contents == kLaserBeam) {
+			object.pos = next;
+			object.motion = kMoving;
+			object.time = 0;
+			playSoundBlock(kind.moveSound);
+			return true;
+		}
+
+		return false;
+	}
+}
+
+void EscapePuzzle::pressButton(Button &button) {
+	if (button.isPressed) {
+		return;
+	}
+
+	Object *player = findObject(_playerName);
+	if (player) {
+		player->movingTo = button.direction;
+	}
+
+	button.isPressed = true;
+	button.pressTime = 0;
+}
+
+void EscapePuzzle::drawObject(const Object &object) {
+	const ObjectKind &kind = _kinds[object.type];
+	const DirectionalAnimation &animation = object.motion == kPushing ? kind.secondAnimation : kind.animation;
+	const Common::Array<Common::Rect> &frames = animation.frames[object.facing];
+	if (frames.empty()) {
+		return;
+	}
+
+	Common::Point dest = getCellRect(object.pos).origin();
+	uint frame = 0;
+
+	if (object.motion != kStill) {
+		double progress = kind.moveTime > 0 ? object.time / (double)kind.moveTime : 1.0;
+		if (object.motion == kMoving) {
+			// On the way over from the cell before
+			progress = CLIP(progress, 0.0, 1.0);
+			Common::Point from = getCellRect(getNeighbor(object.pos, getOpposite(object.facing))).origin();
+			dest.x = from.x + (int)((dest.x - from.x) * progress);
+			dest.y = from.y + (int)((dest.y - from.y) * progress);
+		}
+
+		frame = MIN<uint>(MAX<int>((int)(progress * frames.size()), 0), frames.size() - 1);
+	}
+
+	_drawSurface.blitFrom(_image, frames[frame], dest);
+
+	if (object.type == kLaser) {
+		for (const BeamCell &cell : object.beam) {
+			const Common::Array<Common::Rect> &beamFrames = kind.secondAnimation.frames[cell.direction];
+			if (!beamFrames.empty()) {
+				_drawSurface.blitFrom(_image, beamFrames[0], getCellRect(cell.pos).origin());
+			}
+		}
+	}
+}
+
+void EscapePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (const Object &object : _objects) {
+		drawObject(object);
+	}
+
+	for (const Button &button : _buttons) {
+		if (button.isPressed) {
+			_drawSurface.blitFrom(_image, button.pressedSrc, button.dest.origin());
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void EscapePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		uint32 now = g_nancy->getTotalPlayTime();
+		uint32 numTicks = (now - _lastUpdateTime) / kTickLength;
+		_lastUpdateTime += numTicks * kTickLength;
+		uint32 time = numTicks * kTickLength;
+
+		for (Button &button : _buttons) {
+			if (button.isPressed) {
+				button.pressTime += time;
+				if (button.pressTime > _buttonPressTime) {
+					button.isPressed = false;
+				}
+			}
+		}
+
+		// Everything that can move does, until nothing moves any more
+		bool hasMoved;
+		do {
+			hasMoved = false;
+			for (Object &object : _objects) {
+				hasMoved |= updateObject(object, time);
+			}
+
+			time = 0;
+		} while (hasMoved);
+
+		for (uint i = 0; i < _goals.size() && _reachedGoal == -1; ++i) {
+			Object *object = findObject(_goals[i].objectName);
+			if (object && object->pos == _goals[i].pos && object->movingTo == kNoDirection &&
+					object->time >= _kinds[object->type].moveTime) {
+				_reachedGoal = i;
+				_state = kActionTrigger;
+			}
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		if (_reachedGoal != -1) {
+			playSoundBlock(_goals[_reachedGoal].sound);
+			_goals[_reachedGoal].scene.execute();
+		} else {
+			_exitScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void EscapePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _exitRequested || _reachedGoal != -1) {
+		return;
+	}
+
+	Direction heldKey = kNoDirection;
+	if (input.input & NancyInput::kMoveUp) {
+		heldKey = kNorth;
+	} else if (input.input & NancyInput::kMoveDown) {
+		heldKey = kSouth;
+	} else if (input.input & NancyInput::kMoveRight) {
+		heldKey = kEast;
+	} else if (input.input & NancyInput::kMoveLeft) {
+		heldKey = kWest;
+	}
+
+	// A key moves the player once, when it is let go
+	if (heldKey != _heldKey) {
+		if (_heldKey != kNoDirection) {
+			pressButton(_buttons[_heldKey]);
+		}
+
+		_heldKey = heldKey;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	const bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	for (Button &button : _buttons) {
+		if (!button.dest.contains(pos) || button.isPressed) {
+			continue;
+		}
+
+		setDataCursor(_buttonCursor);
+		if (clicked) {
+			pressButton(button);
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	if (hoverExitHotspot(input) && clicked) {
+		_exitRequested = true;
+		input.eatMouseInput();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/escapepuzzle.h b/engines/nancy/action/puzzle/escapepuzzle.h
new file mode 100644
index 00000000000..1fa870a5eb8
--- /dev/null
+++ b/engines/nancy/action/puzzle/escapepuzzle.h
@@ -0,0 +1,171 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_ESCAPEPUZZLE_H
+#define NANCY_ACTION_ESCAPEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 maze with lasers. The player is walked one cell at a time with the arrow
+// keys or the buttons on screen. Walking into a ball or a box pushes it: a ball rolls
+// until something stops it, a box moves one cell. Each laser shines in the direction
+// it faces until a wall or an object is in the way; the player can't cross a beam,
+// but balls and boxes can block it. Getting an object to its goal cell wins.
+class EscapePuzzle : public PuzzleRecord {
+public:
+	EscapePuzzle() : PuzzleRecord(7) {}
+	virtual ~EscapePuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "EscapePuzzle"; }
+
+	enum Direction {
+		kNorth,
+		kSouth,
+		kEast,
+		kWest,
+		kNumDirections,
+		kNoDirection = -1
+	};
+
+	// What a cell holds, as far as something moving into it is concerned
+	enum CellContents {
+		kEmpty,
+		kObject,
+		kLaserBeam,
+		kWall
+	};
+
+	enum ObjectType {
+		kPlayer,
+		kBall,
+		kBox,
+		kLaser,
+		kNumObjectTypes
+	};
+
+	enum Motion {
+		kStill,
+		kMoving,
+		kPushing
+	};
+
+	// The pictures of an object facing each direction
+	struct DirectionalAnimation {
+		Common::Array<Common::Rect> frames[kNumDirections];
+
+		void readData(Common::SeekableReadStream &stream);
+	};
+
+	// What all objects of a type share
+	struct ObjectKind {
+		uint32 moveTime = 0;	// in milliseconds
+		DirectionalAnimation animation;
+		DirectionalAnimation secondAnimation;	// the player pushing, a laser's beam
+		RandomSoundBlock moveSound;
+		RandomSoundBlock pushSound;
+		RandomSoundBlock bumpSound;
+	};
+
+	struct BeamCell {
+		Common::Point pos;
+		Direction direction = kNorth;
+	};
+
+	struct Object {
+		Common::String name;
+		ObjectType type = kPlayer;
+		Common::Point pos;
+		Direction facing = kNorth;
+		Direction movingTo = kNoDirection;	// where the object is about to go
+		Motion motion = kStill;
+		uint32 time = 0;	// since the object last moved
+		Common::Array<BeamCell> beam;
+	};
+
+	struct Goal {
+		Common::String objectName;
+		Common::Point pos;
+		SceneChangeWithFlag scene;
+		RandomSoundBlock sound;
+	};
+
+	struct Button {
+		Direction direction = kNorth;
+		Common::Rect pressedSrc;
+		Common::Rect dest;
+		bool isPressed = false;
+		uint32 pressTime = 0;
+	};
+
+	static Direction readDirection(Common::SeekableReadStream &stream);
+	static Common::Point getNeighbor(const Common::Point &pos, Direction direction);
+	static Direction getOpposite(Direction direction);
+	static bool compareObjectNames(const Object &a, const Object &b);
+
+	Common::Rect getCellRect(const Common::Point &pos) const;
+	CellContents getContents(const Common::Point &pos) const;
+	Object *findObject(const Common::String &name);
+	void push(const Common::Point &pos, Direction direction);
+	// Returns whether the object moved
+	bool updateObject(Object &object, uint32 time);
+	void updateBeam(Object &laser);
+	void pressButton(Button &button);
+	void drawObject(const Object &object);
+	void redraw();
+
+	// -- File data --
+	uint32 _buttonPressTime = 0;	// in milliseconds
+	Button _buttons[kNumDirections];
+	uint16 _buttonCursor = 0;
+	Common::Rect _firstCell;
+	Common::Point _cellSpacing;
+	int _numColumns = 0;
+	int _numRows = 0;
+	Common::Array<bool> _walls;
+	ObjectKind _kinds[kNumObjectTypes];
+	Common::Array<Object> _objects;	// in the order of their names
+	Common::Array<Goal> _goals;
+
+	// -- Runtime --
+	Common::String _playerName;
+	uint32 _lastUpdateTime = 0;
+	Direction _heldKey = kNoDirection;
+	int _reachedGoal = -1;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_ESCAPEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 975e604bb03..264a9929e93 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -46,6 +46,7 @@ MODULE_OBJS = \
   action/puzzle/dotconnectpuzzle.o \
   action/puzzle/dragsurfacepuzzle.o \
   action/puzzle/drivingpuzzle.o \
+  action/puzzle/escapepuzzle.o \
   action/puzzle/dropsortpuzzle.o \
   action/puzzle/escapegridpuzzle.o \
   action/puzzle/fishingpuzzle.o \


Commit: df69a4211fbd40626b5174b6a767cc0699277779
    https://github.com/scummvm/scummvm/commit/df69a4211fbd40626b5174b6a767cc0699277779
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:33+03:00

Commit Message:
NANCY: NANCY17: Implement FlashMemoryPuzzle

- Record 202, a pairs game in the dark: the pictures can only really
  be seen while lightning flashes
- A click turns a picture over; two that belong together go away, two
  that don't turn back after a moment
- Player wins when the board is cleared

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index a57a7df32d3..d8e61f54e83 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -59,6 +59,7 @@
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
 #include "engines/nancy/action/puzzle/dragsurfacepuzzle.h"
 #include "engines/nancy/action/puzzle/escapepuzzle.h"
+#include "engines/nancy/action/puzzle/flashmemorypuzzle.h"
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
 #include "engines/nancy/action/puzzle/escapegridpuzzle.h"
@@ -764,7 +765,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new BookSortPuzzle();
 	case 202:
-		return new BombPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy15)
+			return new BombPuzzle();
+		else
+			return new FlashMemoryPuzzle();
 	case 203:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new RippedLetterPuzzle();
diff --git a/engines/nancy/action/puzzle/flashmemorypuzzle.cpp b/engines/nancy/action/puzzle/flashmemorypuzzle.cpp
new file mode 100644
index 00000000000..89e3f6bee61
--- /dev/null
+++ b/engines/nancy/action/puzzle/flashmemorypuzzle.cpp
@@ -0,0 +1,418 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/flashmemorypuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// The three kinds of lightning
+static const double kSteadyChance = 0.2;		// one long, even flash
+static const double kDoubleChance = 0.5;		// a strike, a dim afterglow, then dark
+static const double kNoFlicker = 1000.0;
+static const double kFlickerInterval = 1.0 / 15;
+static const double kMaxFlicker = 0.04;
+
+FlashMemoryPuzzle::~FlashMemoryPuzzle() {
+	for (auto &image : _lightImages) {
+		delete image._value;
+	}
+}
+
+double FlashMemoryPuzzle::getRandom() {
+	return g_nancy->_randomSource->getRandomNumber(32767) / 32767.0;
+}
+
+void FlashMemoryPuzzle::Flash::addKeyframe(double keyTime, double keyBrightness) {
+	for (uint i = 0; i < keyframes.size(); ++i) {
+		if (keyframes[i].time == keyTime) {
+			keyframes[i].brightness = keyBrightness;
+			return;
+		}
+
+		if (keyframes[i].time > keyTime) {
+			Keyframe keyframe;
+			keyframe.time = keyTime;
+			keyframe.brightness = keyBrightness;
+			keyframes.insert_at(i, keyframe);
+			return;
+		}
+	}
+
+	Keyframe keyframe;
+	keyframe.time = keyTime;
+	keyframe.brightness = keyBrightness;
+	keyframes.push_back(keyframe);
+}
+
+bool FlashMemoryPuzzle::Flash::update(double seconds) {
+	flickerTime += seconds;
+	time += seconds;
+
+	if (flickerTime > flickerInterval) {
+		// The light wavers, less so at the start and at the end
+		flickerTime = 0;
+		double amount = getRandom() * 2 * kMaxFlicker - kMaxFlicker;
+		double progress = time / duration;
+		if (progress < 0.2) {
+			flicker = progress * 5 * amount;
+		} else if (progress > 0.8) {
+			flicker = (1 - (progress * 5 - 4)) * amount;
+		} else {
+			flicker = amount;
+		}
+	}
+
+	for (uint i = 1; i < keyframes.size(); ++i) {
+		const Keyframe &from = keyframes[i - 1];
+		const Keyframe &to = keyframes[i];
+		if (time < to.time) {
+			brightness = (to.brightness - from.brightness) * ((time - from.time) / (to.time - from.time)) + from.brightness + flicker;
+			brightness = CLIP(brightness, 0.0, 1.0);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+void FlashMemoryPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+
+	_lights.resize(stream.readUint16LE());
+	for (Light &light : _lights) {
+		readFilename(stream, light.backgroundName);
+		readFilename(stream, light.picturesName);
+		light.minWait = stream.readDoubleLE();
+		light.maxWait = stream.readDoubleLE();
+		light.thunder.readData(stream);
+		light.nextFlash = light.minWait + (light.maxWait - light.minWait) * getRandom();
+	}
+
+	// The two pictures of each pair; the pairs are told apart by these numbers
+	struct Pair {
+		int id;
+		Common::Rect pictures[2];
+		bool isFirstTaken;
+	};
+
+	Common::Array<Pair> pairs;
+	for (int remaining = stream.readUint16LE(); remaining > 0; remaining -= 2) {
+		Pair pair;
+		pair.id = remaining;
+		readExclusiveRect(stream, pair.pictures[0]);
+		readExclusiveRect(stream, pair.pictures[1]);
+		pair.isFirstTaken = false;
+		pairs.push_back(pair);
+	}
+
+	// The pictures are dealt out at random
+	_cards.resize(stream.readUint16LE());
+	for (Card &card : _cards) {
+		readExclusiveRect(stream, card.dest);
+
+		if (pairs.empty()) {
+			continue;
+		}
+
+		uint pairIndex = g_nancy->_randomSource->getRandomNumber(pairs.size() - 1);
+		Pair &pair = pairs[pairIndex];
+		card.pairID = pair.id;
+
+		if (!pair.isFirstTaken) {
+			card.src = pair.pictures[0];
+			pair.isFirstTaken = true;
+		} else {
+			card.src = pair.pictures[1];
+			pairs.remove_at(pairIndex);
+		}
+	}
+
+	_cardCursor = stream.readUint16LE();
+	_turnBackTime = stream.readDoubleLE();
+	_clickSound.readData(stream);
+	_matchSound.readData(stream);
+	_noMatchSound.readData(stream);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_solveSoundBlock.readData(stream);
+}
+
+void FlashMemoryPuzzle::init() {
+	// The puzzle draws the whole room, lightning and all
+	const Graphics::ManagedSurface &background = NancySceneState.getViewport().getBackground();
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(viewportBounds.width(), viewportBounds.height(), background.format);
+	setTransparent(false);
+	setVisible(true);
+	moveTo(viewportBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+
+	for (const Light &light : _lights) {
+		const Common::Path *names[] = { &light.backgroundName, &light.picturesName };
+		for (const Common::Path *name : names) {
+			Common::String key = name->toString();
+			if (!_lightImages.contains(key)) {
+				Graphics::ManagedSurface *image = new Graphics::ManagedSurface();
+				g_nancy->_resource->loadImage(*name, *image);
+				_lightImages[key] = image;
+			}
+		}
+	}
+
+	_lastUpdateTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+void FlashMemoryPuzzle::startFlash(uint lightID) {
+	Light &light = _lights[lightID];
+	light.waited = 0;
+	light.nextFlash = light.minWait + (light.maxWait - light.minWait) * getRandom();
+
+	Flash flash;
+	flash.lightID = lightID;
+
+	double kind = getRandom();
+	if (kind < kSteadyChance) {
+		flash.flickerInterval = kNoFlicker;
+		flash.duration = getRandom() * 0.25 + 0.5;
+		flash.addKeyframe(0, 1.0);
+		flash.addKeyframe(flash.duration, 1.0);
+	} else if (kind < kDoubleChance) {
+		flash.flickerInterval = kNoFlicker;
+		flash.duration = getRandom() * 0.5 + 1.0;
+		double strikeEnd = flash.duration * (getRandom() * 0.1 + 0.25);
+		flash.addKeyframe(0, 1.0);
+		flash.addKeyframe(strikeEnd, 1.0);
+		flash.addKeyframe(strikeEnd + 0.001, 0.05);
+		flash.addKeyframe(flash.duration, 0);
+	} else {
+		// A few flickers of random strength
+		flash.flickerInterval = kFlickerInterval;
+		flash.duration = getRandom() + 1.0;
+		double peak = getRandom() * 0.25 + 0.5;
+		flash.addKeyframe(0, peak);
+
+		int numFlickers = g_nancy->_randomSource->getRandomNumber(2) + 1;
+		for (int i = 0; i < numFlickers; ++i) {
+			double keyTime = getRandom() * flash.duration;
+			flash.addKeyframe(keyTime, getRandom() * peak);
+		}
+
+		flash.addKeyframe(flash.duration, 0);
+	}
+
+	_flashes.push_back(flash);
+	playSoundBlock(light.thunder);
+}
+
+void FlashMemoryPuzzle::clickCard(int cardID) {
+	Card &card = _cards[cardID];
+
+	if (!card.isShown) {
+		// Turning a picture back over takes it back
+		_firstCard = -1;
+	} else if (_firstCard == -1) {
+		_firstCard = cardID;
+	} else {
+		_secondCard = cardID;
+	}
+
+	card.isShown = !card.isShown;
+	playSoundBlock(_clickSound);
+}
+
+void FlashMemoryPuzzle::checkPair() {
+	Card &first = _cards[_firstCard];
+	Card &second = _cards[_secondCard];
+
+	if (first.pairID == second.pairID) {
+		first.isMatched = true;
+		second.isMatched = true;
+		playSoundBlock(_matchSound);
+
+		bool isCleared = true;
+		for (const Card &card : _cards) {
+			if (!card.isMatched) {
+				isCleared = false;
+				break;
+			}
+		}
+
+		_isSolved = isCleared;
+	} else {
+		first.isShown = !first.isShown;
+		second.isShown = !second.isShown;
+		playSoundBlock(_noMatchSound);
+	}
+
+	_firstCard = -1;
+	_secondCard = -1;
+	_pairTime = 0;
+}
+
+void FlashMemoryPuzzle::addLight(const Graphics::ManagedSurface &source, const Common::Rect &src, const Common::Point &dest, double brightness) {
+	for (int y = 0; y < src.height(); ++y) {
+		int destY = dest.y + y;
+		if (destY < 0 || destY >= _drawSurface.h || src.top + y >= source.h) {
+			continue;
+		}
+
+		for (int x = 0; x < src.width(); ++x) {
+			int destX = dest.x + x;
+			if (destX < 0 || destX >= _drawSurface.w || src.left + x >= source.w) {
+				continue;
+			}
+
+			uint8 r, g, b, lightR, lightG, lightB;
+			source.format.colorToRGB(source.getPixel(src.left + x, src.top + y), lightR, lightG, lightB);
+			_drawSurface.format.colorToRGB(_drawSurface.getPixel(destX, destY), r, g, b);
+
+			r = (uint8)MIN<int>(255, r + (int)(lightR * brightness));
+			g = (uint8)MIN<int>(255, g + (int)(lightG * brightness));
+			b = (uint8)MIN<int>(255, b + (int)(lightB * brightness));
+			_drawSurface.setPixel(destX, destY, _drawSurface.format.RGBToColor(r, g, b));
+		}
+	}
+}
+
+void FlashMemoryPuzzle::redraw() {
+	_drawSurface.blitFrom(NancySceneState.getViewport().getBackground());
+
+	for (const Flash &flash : _flashes) {
+		const Graphics::ManagedSurface &image = *_lightImages[_lights[flash.lightID].backgroundName.toString()];
+		addLight(image, Common::Rect(image.w, image.h), Common::Point(), flash.brightness);
+	}
+
+	for (const Card &card : _cards) {
+		if (card.isShown) {
+			_drawSurface.blitFrom(_image, card.src, card.dest.origin());
+		}
+	}
+
+	for (const Flash &flash : _flashes) {
+		const Graphics::ManagedSurface &image = *_lightImages[_lights[flash.lightID].picturesName.toString()];
+		for (const Card &card : _cards) {
+			if (card.isShown) {
+				addLight(image, card.src, card.dest.origin(), flash.brightness);
+			}
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void FlashMemoryPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+		double seconds = (now - _lastUpdateTime) / 1000.0;
+		_lastUpdateTime = now;
+
+		for (uint i = 0; i < _lights.size(); ++i) {
+			Light &light = _lights[i];
+			light.waited += seconds;
+			if (light.waited >= light.nextFlash) {
+				startFlash(i);
+			}
+		}
+
+		for (uint i = 0; i < _flashes.size(); ) {
+			if (_flashes[i].update(seconds)) {
+				_flashes.remove_at(i);
+			} else {
+				++i;
+			}
+		}
+
+		// Two pictures stay up for a moment before they go away or turn back
+		if (_firstCard != -1 && _secondCard != -1) {
+			_pairTime += seconds;
+			if (_pairTime >= _turnBackTime) {
+				checkPair();
+			}
+		}
+
+		if (_isSolved) {
+			_state = kActionTrigger;
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		playSoundBlock(_solveSoundBlock);
+		_solveScene.execute();
+		finishExecution();
+		break;
+	}
+}
+
+void FlashMemoryPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _isSolved || (_firstCard != -1 && _secondCard != -1)) {
+		return;
+	}
+
+	Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	for (int i = _cards.size() - 1; i >= 0; --i) {
+		if (_cards[i].isMatched || !_cards[i].dest.contains(pos)) {
+			continue;
+		}
+
+		setDataCursor(_cardCursor);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			clickCard(i);
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/flashmemorypuzzle.h b/engines/nancy/action/puzzle/flashmemorypuzzle.h
new file mode 100644
index 00000000000..fc6f019ceb9
--- /dev/null
+++ b/engines/nancy/action/puzzle/flashmemorypuzzle.h
@@ -0,0 +1,121 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_FLASHMEMORYPUZZLE_H
+#define NANCY_ACTION_FLASHMEMORYPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy17 pairs game in the dark. The pictures can only really be seen while
+// lightning flashes. A click turns a picture over; two that belong together go
+// away, two that don't turn back after a moment. Clearing the board wins.
+class FlashMemoryPuzzle : public PuzzleRecord {
+public:
+	FlashMemoryPuzzle() : PuzzleRecord(7) {}
+	virtual ~FlashMemoryPuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "FlashMemoryPuzzle"; }
+
+	// A window the lightning comes through
+	struct Light {
+		Common::Path backgroundName;	// what the flash adds to the whole room
+		Common::Path picturesName;		// what it adds to the pictures
+		double minWait = 0;				// in seconds
+		double maxWait = 0;
+		RandomSoundBlock thunder;
+
+		double waited = 0;
+		double nextFlash = 0;
+	};
+
+	struct Keyframe {
+		double time = 0;
+		double brightness = 0;
+	};
+
+	struct Flash {
+		uint lightID = 0;
+		double time = 0;
+		double duration = 0;
+		double brightness = 0;
+		Common::Array<Keyframe> keyframes;	// by time
+		double flickerInterval = 0;
+		double flickerTime = 0;
+		double flicker = 0;
+
+		void addKeyframe(double time, double brightness);
+		// Returns whether the flash is over
+		bool update(double seconds);
+	};
+
+	struct Card {
+		bool isShown = true;
+		bool isMatched = false;
+		int pairID = 0;
+		Common::Rect src;
+		Common::Rect dest;
+	};
+
+	static double getRandom();
+	void startFlash(uint lightID);
+	void clickCard(int cardID);
+	void checkPair();
+	// Adds a picture, dimmed by a brightness, to what is drawn
+	void addLight(const Graphics::ManagedSurface &source, const Common::Rect &src, const Common::Point &dest, double brightness);
+	void redraw();
+
+	// -- File data --
+	Common::Array<Light> _lights;
+	Common::Array<Card> _cards;
+	uint16 _cardCursor = 0;
+	double _turnBackTime = 0;	// in seconds
+	RandomSoundBlock _clickSound;
+	RandomSoundBlock _matchSound;
+	RandomSoundBlock _noMatchSound;
+	RandomSoundBlock _solveSoundBlock;
+
+	// -- Runtime --
+	Common::HashMap<Common::String, Graphics::ManagedSurface *, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _lightImages;
+	Common::Array<Flash> _flashes;
+	int _firstCard = -1;
+	int _secondCard = -1;
+	double _pairTime = 0;
+	uint32 _lastUpdateTime = 0;
+	bool _isSolved = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_FLASHMEMORYPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 264a9929e93..1f2a1d619ca 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -47,6 +47,7 @@ MODULE_OBJS = \
   action/puzzle/dragsurfacepuzzle.o \
   action/puzzle/drivingpuzzle.o \
   action/puzzle/escapepuzzle.o \
+  action/puzzle/flashmemorypuzzle.o \
   action/puzzle/dropsortpuzzle.o \
   action/puzzle/escapegridpuzzle.o \
   action/puzzle/fishingpuzzle.o \


Commit: 9b909b90ff600acb772174b6d7d54f210a58d9ae
    https://github.com/scummvm/scummvm/commit/9b909b90ff600acb772174b6d7d54f210a58d9ae
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:35+03:00

Commit Message:
NANCY: NANCY17: Show the Rain and FadeOverlay record graphics

Both drew into their surface without ever being added to the
objects the engine draws

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


diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 1279f922b69..53e5e9001f4 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1371,6 +1371,7 @@ void FadeOverlay::execute() {
 		_drawSurface.create(_bounds.width(), _bounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
 		moveTo(_bounds);
 		RenderObject::init();
+		registerGraphics();
 
 		_startTime = g_nancy->getTotalPlayTime();
 		_state = kRun;
@@ -1676,6 +1677,7 @@ void Rain::execute() {
 	if (_state == kBegin) {
 		g_nancy->_resource->loadImage(_imageName, _image);
 		RenderObject::init();
+		registerGraphics();
 		setVisible(false);
 		_state = kRun;
 	}


Commit: 69e6530df2b7e569499468f562ce760b3eb533fc
    https://github.com/scummvm/scummvm/commit/69e6530df2b7e569499468f562ce760b3eb533fc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:36+03:00

Commit Message:
NANCY: NANCY17: Cut conversation movies to the size of their picture

- The movies are padded to a multiple of 16 pixels by repeating their
  last row and column, which showed as a band under the head
- Only the part the size of the rect in the XSheet is drawn

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 6eaf86c214a..e03555c36e1 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -1194,7 +1194,10 @@ void ConversationCel::updateGraphics() {
 }
 
 void ConversationCel::RenderedCel::setMovieFrame(const Graphics::Surface &frame) {
-	GraphicsManager::copyToManaged(frame, _drawSurface);
+	// Nancy17 movies are padded to a multiple of 16 pixels, by repeating their
+	// last row and column; the picture itself is the size of the tree's rect
+	const Common::Rect picture(MIN<int>(frame.w, _screenPosition.width()), MIN<int>(frame.h, _screenPosition.height()));
+	GraphicsManager::copyToManaged(frame.getSubArea(picture), _drawSurface);
 	_needsRedraw = true;
 }
 


Commit: 10eda80309b45f5e874bcf7738308f7de56a9b9d
    https://github.com/scummvm/scummvm/commit/10eda80309b45f5e874bcf7738308f7de56a9b9d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:38+03:00

Commit Message:
NANCY: Merge the dirty rects that overlap

An area covered by two of them was drawn twice, which blended the
translucent conversation box of Nancy16 and Nancy17 over it twice
and made the scene behind it flicker

Changed paths:
    engines/nancy/graphics.cpp


diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index 10f4cec5651..b2bc7abbac5 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -229,14 +229,23 @@ bool GraphicsManager::isInLayer(const RenderObject &object, Layer layer) const {
 
 // Filter out dirty rects that are completely inside others to reduce overdraw
 void GraphicsManager::filterDirtyRects(Common::List<Common::Rect> &rects) {
-	for (auto outer = rects.begin(); outer != rects.end(); ++outer) {
-		for (auto inner = rects.begin(); inner != rects.end(); ++inner) {
-			if (inner != outer && outer->contains(*inner)) {
-				rects.erase(inner);
-				break;
+	// Rects that overlap are merged into one. An area that gets drawn twice would
+	// have the objects with an alpha channel blended onto it twice.
+	bool hasMerged;
+	do {
+		hasMerged = false;
+
+		for (auto outer = rects.begin(); outer != rects.end() && !hasMerged; ++outer) {
+			for (auto inner = rects.begin(); inner != rects.end(); ++inner) {
+				if (inner != outer && outer->intersects(*inner)) {
+					outer->extend(*inner);
+					rects.erase(inner);
+					hasMerged = true;
+					break;
+				}
 			}
 		}
-	}
+	} while (hasMerged);
 }
 
 // Perform the actual drawing. This checks for cases where something would be fully obscured,


Commit: 255aae1e237265f79b9f7adf046367a0777ad235
    https://github.com/scummvm/scummvm/commit/255aae1e237265f79b9f7adf046367a0777ad235
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:40+03:00

Commit Message:
NANCY: NANCY17: Leave the far edges out of the conversation movie rects

Character movies are a pixel narrower and shorter than the rect was
read as; the extra row showed as a line at the neck

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index e03555c36e1..1f5d8a43d07 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -1309,6 +1309,16 @@ void ConversationCel::readXSheet(Common::SeekableReadStream &stream, const Commo
 			readRectArray(*xsheet, _treeRects, kMaxTrees);
 		}
 
+		// Nancy17 leaves the right and bottom edge out of the rect
+		if (g_nancy->getGameType() >= kGameTypeNancy17) {
+			for (Common::Rect &rect : _treeRects) {
+				if (!rect.isEmpty()) {
+					--rect.right;
+					--rect.bottom;
+				}
+			}
+		}
+
 		_frameTime = xsheet->readUint32LE();
 	} else {
 		xsheet->skip(2);


Commit: a8750f1bfc09c2ad453ef255949ce49e7cfce0e1
    https://github.com/scummvm/scummvm/commit/a8750f1bfc09c2ad453ef255949ce49e7cfce0e1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:42+03:00

Commit Message:
NANCY: NANCY17: Fix the logo screens

- The first one names a picture the game doesn't have; don't try to
  load it
- The intro splash is made for the game's resolution, not the
  screen's: scale it up with the game

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


diff --git a/engines/nancy/state/logo.cpp b/engines/nancy/state/logo.cpp
index 2386dbb46d1..9acf74fbba1 100644
--- a/engines/nancy/state/logo.cpp
+++ b/engines/nancy/state/logo.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "common/archive.h"
 #include "common/config-manager.h"
 
 #include "engines/nancy/nancy.h"
@@ -89,8 +90,7 @@ bool Logo::onStateExit(const NancyState::NancyState nextState) {
 
 void Logo::init() {
 	if (g_nancy->getGameType() >= kGameTypeNancy16) {
-		// The logo screens are made for the screen's resolution, not the game's
-		_logoImage.setNativeResolution(true);
+		// The logo movies are made for the screen's resolution, not the game's
 		_videoObj.setNativeResolution(true);
 		_screenID = 0;
 		_state = kShowScreens;
@@ -175,9 +175,15 @@ void Logo::startScreen() {
 		return;
 	}
 
-	_logoImage.init(screen->imageName);
-	_logoImage.moveTo(screen->position);
-	_logoImage.registerGraphics();
+	// Nancy17 names a picture for its first screen that it doesn't have
+	if (SearchMan.hasFile(screen->imageName) || SearchMan.hasFile(screen->imageName.append(".cif"))) {
+		_logoImage.init(screen->imageName);
+
+		// A picture made for the game's resolution gets scaled up with the game
+		_logoImage.setNativeResolution(_logoImage._drawSurface.w > g_nancy->_graphics->getGameBounds().width());
+		_logoImage.moveTo(screen->position);
+		_logoImage.registerGraphics();
+	}
 
 	_screenMovie.close();
 	_videoObj.setVisible(false);


Commit: b20c7cc5a7c20ddf7c3e640c35d51ed02abf307a
    https://github.com/scummvm/scummvm/commit/b20c7cc5a7c20ddf7c3e640c35d51ed02abf307a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:43+03:00

Commit Message:
NANCY: NANCY16: Hook the ScummVM save/load dialogs to the new NDUI

The load and save dialogs of the NDUI are replaced by the ScummVM
ones when the original save/load screens are turned off, as in the
earlier games. When the player quits or loads and chooses to save
first, the quit or load follows once the scene has saved

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index c589a172e9b..6372fc1f349 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2202,6 +2202,10 @@ void Scene::run() {
 			break;
 		case UI::NDUI::Manager::kRequestSave:
 			g_nancy->saveGameState(request.slot, request.name, false);
+			if (request.hasFollowUpAction) {
+				_ndui->runPendingAction();
+			}
+
 			break;
 		case UI::NDUI::Manager::kRequestQuit:
 			g_nancy->quitGame();
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 36ed9b8fb4e..4274b35b0ae 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -85,12 +85,16 @@ public:
 		RequestType type = kRequestNone;
 		int slot = -1;
 		Common::String name;
+		bool hasFollowUpAction = false;	// a quit or load follows the save
 	};
 
 	~Manager();
 
 	Request takeRequest();
 
+	// Goes on with the quit or load that was put off to ask whether to save first
+	void runPendingAction();
+
 	// Creates every panel in the IFF with the given name, and in the ones it includes
 	void load(const Common::String &name);
 
@@ -168,9 +172,9 @@ private:
 	void onVolumeAction(const Action &action, const char *configKey);
 	void onFlagsAction(const Action &action);
 	void askToSaveFirst(const Action &action);
-	void runPendingAction();
 	uint fillSaveList(const Common::String &listName);
 	int findSaveSlot(const Common::String &name) const;
+	int runScummVMSaveLoadDialog(bool isSave, Common::String &saveName);
 	void setSaveScreenshot(const Graphics::ManagedSurface &image, const Common::Rect &src);
 
 	Panel *findPanel(const Common::String &name) const;
diff --git a/engines/nancy/ui/ndui/saveload.cpp b/engines/nancy/ui/ndui/saveload.cpp
index 7b66dfdb5b2..0aed9577cef 100644
--- a/engines/nancy/ui/ndui/saveload.cpp
+++ b/engines/nancy/ui/ndui/saveload.cpp
@@ -28,8 +28,11 @@
 
 #include "graphics/screen.h"
 
+#include "gui/saveload.h"
+
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
 
 #include "engines/nancy/state/scene.h"
 
@@ -126,11 +129,33 @@ void Manager::setSaveScreenshot(const Graphics::ManagedSurface &image, const Com
 	texture->blitFrom(image, src, Common::Rect(kScreenshotWidth, kScreenshotHeight));
 }
 
+// The ScummVM dialog, when picked over the game's own. Returns the slot, or -1
+// when the player backs out of it
+int Manager::runScummVMSaveLoadDialog(bool isSave, Common::String &saveName) {
+	PauseToken pause = g_nancy->pauseEngine();
+
+	GUI::SaveLoadChooser *dialog = new GUI::SaveLoadChooser(isSave);
+	int slot = dialog->runModalWithCurrentTarget();
+	saveName = dialog->getResultString();
+	delete dialog;
+
+	g_nancy->_input->forceCleanInput();
+
+	return slot;
+}
+
 void Manager::onLoaderAction(const Action &action) {
 	switch (action.type) {
 	case kActionShow:
 		if (action.value >= 0) {
 			askToSaveFirst(action);
+		} else if (!ConfMan.getBool("originalsaveload")) {
+			Common::String saveName;
+			int slot = runScummVMSaveLoadDialog(false, saveName);
+			if (slot >= 0) {
+				_request.type = kRequestLoad;
+				_request.slot = slot;
+			}
 		} else {
 			takeAction(fillSaveList("LoadList") > 0 ? kActionEnable : kActionDisable, "LoadButton", 0.0);
 			takeAction(kActionSetValue, "LoadList", 0.0);
@@ -181,6 +206,23 @@ void Manager::onLoaderAction(const Action &action) {
 void Manager::onSaverAction(const Action &action) {
 	switch (action.type) {
 	case kActionShow: {
+		if (!ConfMan.getBool("originalsaveload")) {
+			Common::String saveName;
+			int slot = runScummVMSaveLoadDialog(true, saveName);
+			if (slot >= 0) {
+				// A quit or load that was put off to save first is a request
+				// of its own, so it waits for the save
+				_request.type = kRequestSave;
+				_request.slot = slot;
+				_request.name = saveName;
+				_request.hasFollowUpAction = true;
+			} else {
+				runPendingAction();
+			}
+
+			break;
+		}
+
 		fillSaveList("SaveList");
 		_typedSaveName = _lastSaveName;
 


Commit: 1e04d63b47325a195e00664a252cbe524a212123
    https://github.com/scummvm/scummvm/commit/1e04d63b47325a195e00664a252cbe524a212123
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:46+03:00

Commit Message:
NANCY: NANCY10: Move the Nancy10-15 UI files into ui/taskbar

The taskbar, the popups it opens, the scrolling textbox and the
camera are only used by Nancy10 to Nancy15. Group them in their
own folder, like the Nancy16+ UI in ui/ndui.

Changed paths:
  A engines/nancy/ui/taskbar/camera.cpp
  A engines/nancy/ui/taskbar/camera.h
  A engines/nancy/ui/taskbar/cellphonepopup.cpp
  A engines/nancy/ui/taskbar/cellphonepopup.h
  A engines/nancy/ui/taskbar/conversationpopup.cpp
  A engines/nancy/ui/taskbar/conversationpopup.h
  A engines/nancy/ui/taskbar/inventorypopup.cpp
  A engines/nancy/ui/taskbar/inventorypopup.h
  A engines/nancy/ui/taskbar/notebookpopup.cpp
  A engines/nancy/ui/taskbar/notebookpopup.h
  A engines/nancy/ui/taskbar/scrolltextbox.cpp
  A engines/nancy/ui/taskbar/scrolltextbox.h
  A engines/nancy/ui/taskbar/taskbar.cpp
  A engines/nancy/ui/taskbar/taskbar.h
  R engines/nancy/ui/camera.cpp
  R engines/nancy/ui/camera.h
  R engines/nancy/ui/cellphonepopup.cpp
  R engines/nancy/ui/cellphonepopup.h
  R engines/nancy/ui/conversationpopup.cpp
  R engines/nancy/ui/conversationpopup.h
  R engines/nancy/ui/inventorypopup.cpp
  R engines/nancy/ui/inventorypopup.h
  R engines/nancy/ui/notebookpopup.cpp
  R engines/nancy/ui/notebookpopup.h
  R engines/nancy/ui/scrolltextbox.cpp
  R engines/nancy/ui/scrolltextbox.h
  R engines/nancy/ui/taskbar.cpp
  R engines/nancy/ui/taskbar.h
    engines/nancy/action/cameraaction.cpp
    engines/nancy/action/datarecords.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/module.mk
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h
    engines/nancy/ui/textbox.cpp


diff --git a/engines/nancy/action/cameraaction.cpp b/engines/nancy/action/cameraaction.cpp
index 5eb8de0b037..3336b387163 100644
--- a/engines/nancy/action/cameraaction.cpp
+++ b/engines/nancy/action/cameraaction.cpp
@@ -29,7 +29,7 @@
 
 #include "engines/nancy/action/cameraaction.h"
 
-#include "engines/nancy/ui/camera.h"
+#include "engines/nancy/ui/taskbar/camera.h"
 
 #include "engines/nancy/state/scene.h"
 
diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index ae3a6c5cd2f..227b62fc411 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -28,7 +28,7 @@
 
 #include "engines/nancy/state/scene.h"
 
-#include "engines/nancy/ui/taskbar.h"
+#include "engines/nancy/ui/taskbar/taskbar.h"
 
 namespace Nancy {
 namespace Action {
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 53e5e9001f4..ac9bcedeada 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -41,7 +41,7 @@
 
 #include "image/png.h"
 
-#include "nancy/ui/taskbar.h"
+#include "nancy/ui/taskbar/taskbar.h"
 
 namespace Nancy {
 namespace Action {
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 1f2a1d619ca..39ea1d4839b 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -115,11 +115,7 @@ MODULE_OBJS = \
   ui/animatedbutton.o \
   ui/button.o \
   ui/clock.o \
-  ui/camera.o \
-  ui/cellphonepopup.o \
-  ui/conversationpopup.o \
   ui/inventorybox.o \
-  ui/inventorypopup.o \
   ui/ndui/button.o \
   ui/ndui/common.o \
   ui/ndui/control.o \
@@ -132,11 +128,15 @@ MODULE_OBJS = \
   ui/ndui/saveload.o \
   ui/ndui/scrollbar.o \
   ui/ndui/slider.o \
-  ui/notebookpopup.o \
   ui/ornaments.o \
   ui/scrollbar.o \
-  ui/scrolltextbox.o \
-  ui/taskbar.o \
+  ui/taskbar/camera.o \
+  ui/taskbar/cellphonepopup.o \
+  ui/taskbar/conversationpopup.o \
+  ui/taskbar/inventorypopup.o \
+  ui/taskbar/notebookpopup.o \
+  ui/taskbar/scrolltextbox.o \
+  ui/taskbar/taskbar.o \
   ui/textbox.o \
   ui/viewport.o \
   state/credits.o \
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 6372fc1f349..99126f41c07 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -41,9 +41,9 @@
 
 #include "engines/nancy/ui/button.h"
 #include "engines/nancy/ui/ornaments.h"
-#include "engines/nancy/ui/camera.h"
+#include "engines/nancy/ui/taskbar/camera.h"
 #include "engines/nancy/ui/clock.h"
-#include "engines/nancy/ui/taskbar.h"
+#include "engines/nancy/ui/taskbar/taskbar.h"
 
 #include "engines/nancy/misc/lightning.h"
 #include "engines/nancy/misc/specialeffect.h"
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index aceed3eabbb..705cd6d9bde 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -37,10 +37,10 @@
 #include "engines/nancy/ui/viewport.h"
 #include "engines/nancy/ui/textbox.h"
 #include "engines/nancy/ui/inventorybox.h"
-#include "engines/nancy/ui/inventorypopup.h"
-#include "engines/nancy/ui/notebookpopup.h"
-#include "engines/nancy/ui/cellphonepopup.h"
-#include "engines/nancy/ui/conversationpopup.h"
+#include "engines/nancy/ui/taskbar/inventorypopup.h"
+#include "engines/nancy/ui/taskbar/notebookpopup.h"
+#include "engines/nancy/ui/taskbar/cellphonepopup.h"
+#include "engines/nancy/ui/taskbar/conversationpopup.h"
 
 namespace Common {
 class SeekableReadStream;
diff --git a/engines/nancy/ui/camera.cpp b/engines/nancy/ui/taskbar/camera.cpp
similarity index 99%
rename from engines/nancy/ui/camera.cpp
rename to engines/nancy/ui/taskbar/camera.cpp
index 4b2371c6ff2..06a57454702 100644
--- a/engines/nancy/ui/camera.cpp
+++ b/engines/nancy/ui/taskbar/camera.cpp
@@ -30,7 +30,7 @@
 #include "engines/nancy/sound.h"
 #include "engines/nancy/util.h"
 
-#include "engines/nancy/ui/camera.h"
+#include "engines/nancy/ui/taskbar/camera.h"
 
 #include "engines/nancy/state/scene.h"
 
diff --git a/engines/nancy/ui/camera.h b/engines/nancy/ui/taskbar/camera.h
similarity index 95%
rename from engines/nancy/ui/camera.h
rename to engines/nancy/ui/taskbar/camera.h
index 1c49fae5e49..0bb08cf79a4 100644
--- a/engines/nancy/ui/camera.h
+++ b/engines/nancy/ui/taskbar/camera.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef NANCY_UI_CAMERA_H
-#define NANCY_UI_CAMERA_H
+#ifndef NANCY_UI_TASKBAR_CAMERA_H
+#define NANCY_UI_TASKBAR_CAMERA_H
 
 #include "engines/nancy/enginedata.h"
 #include "engines/nancy/renderobject.h"
@@ -73,4 +73,4 @@ protected:
 } // End of namespace UI
 } // End of namespace Nancy
 
-#endif // NANCY_UI_CAMERA_H
+#endif // NANCY_UI_TASKBAR_CAMERA_H
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/taskbar/cellphonepopup.cpp
similarity index 99%
rename from engines/nancy/ui/cellphonepopup.cpp
rename to engines/nancy/ui/taskbar/cellphonepopup.cpp
index f331d51f3ec..ac8dafd7eb7 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/taskbar/cellphonepopup.cpp
@@ -35,12 +35,12 @@
 
 #include "engines/nancy/state/scene.h"
 
-#include "engines/nancy/ui/taskbar.h"
+#include "engines/nancy/ui/taskbar/taskbar.h"
 #include "engines/nancy/ui/viewport.h"
 
 #include "graphics/surface.h"
 
-#include "engines/nancy/ui/cellphonepopup.h"
+#include "engines/nancy/ui/taskbar/cellphonepopup.h"
 
 namespace Nancy {
 namespace UI {
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/taskbar/cellphonepopup.h
similarity index 99%
rename from engines/nancy/ui/cellphonepopup.h
rename to engines/nancy/ui/taskbar/cellphonepopup.h
index 7d6717b1a8d..ca0fe42c8ba 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/taskbar/cellphonepopup.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef NANCY_UI_CELLPHONEPOPUP_H
-#define NANCY_UI_CELLPHONEPOPUP_H
+#ifndef NANCY_UI_TASKBAR_CELLPHONEPOPUP_H
+#define NANCY_UI_TASKBAR_CELLPHONEPOPUP_H
 
 #include "engines/nancy/commontypes.h"
 #include "engines/nancy/enginedata.h"
@@ -428,4 +428,4 @@ private:
 } // End of namespace UI
 } // End of namespace Nancy
 
-#endif // NANCY_UI_CELLPHONEPOPUP_H
+#endif // NANCY_UI_TASKBAR_CELLPHONEPOPUP_H
diff --git a/engines/nancy/ui/conversationpopup.cpp b/engines/nancy/ui/taskbar/conversationpopup.cpp
similarity index 99%
rename from engines/nancy/ui/conversationpopup.cpp
rename to engines/nancy/ui/taskbar/conversationpopup.cpp
index 32977ada207..13732440efc 100644
--- a/engines/nancy/ui/conversationpopup.cpp
+++ b/engines/nancy/ui/taskbar/conversationpopup.cpp
@@ -29,7 +29,7 @@
 
 #include "engines/nancy/ui/scrollbar.h"
 
-#include "engines/nancy/ui/conversationpopup.h"
+#include "engines/nancy/ui/taskbar/conversationpopup.h"
 
 namespace Nancy {
 namespace UI {
diff --git a/engines/nancy/ui/conversationpopup.h b/engines/nancy/ui/taskbar/conversationpopup.h
similarity index 96%
rename from engines/nancy/ui/conversationpopup.h
rename to engines/nancy/ui/taskbar/conversationpopup.h
index 2842f8079ed..740ccb2550a 100644
--- a/engines/nancy/ui/conversationpopup.h
+++ b/engines/nancy/ui/taskbar/conversationpopup.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef NANCY_UI_CONVERSATIONPOPUP_H
-#define NANCY_UI_CONVERSATIONPOPUP_H
+#ifndef NANCY_UI_TASKBAR_CONVERSATIONPOPUP_H
+#define NANCY_UI_TASKBAR_CONVERSATIONPOPUP_H
 
 #include "engines/nancy/commontypes.h"
 #include "engines/nancy/misc/hypertext.h"
@@ -112,4 +112,4 @@ private:
 } // End of namespace UI
 } // End of namespace Nancy
 
-#endif // NANCY_UI_CONVERSATIONPOPUP_H
+#endif // NANCY_UI_TASKBAR_CONVERSATIONPOPUP_H
diff --git a/engines/nancy/ui/inventorypopup.cpp b/engines/nancy/ui/taskbar/inventorypopup.cpp
similarity index 99%
rename from engines/nancy/ui/inventorypopup.cpp
rename to engines/nancy/ui/taskbar/inventorypopup.cpp
index 0f0fd97199b..5e0e296c5cb 100644
--- a/engines/nancy/ui/inventorypopup.cpp
+++ b/engines/nancy/ui/taskbar/inventorypopup.cpp
@@ -28,9 +28,9 @@
 
 #include "engines/nancy/state/scene.h"
 
-#include "engines/nancy/ui/inventorypopup.h"
+#include "engines/nancy/ui/taskbar/inventorypopup.h"
 #include "engines/nancy/ui/scrollbar.h"
-#include "engines/nancy/ui/taskbar.h"
+#include "engines/nancy/ui/taskbar/taskbar.h"
 
 namespace Nancy {
 namespace UI {
diff --git a/engines/nancy/ui/inventorypopup.h b/engines/nancy/ui/taskbar/inventorypopup.h
similarity index 97%
rename from engines/nancy/ui/inventorypopup.h
rename to engines/nancy/ui/taskbar/inventorypopup.h
index 5838b0018ee..2b26a40677d 100644
--- a/engines/nancy/ui/inventorypopup.h
+++ b/engines/nancy/ui/taskbar/inventorypopup.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef NANCY_UI_INVENTORYPOPUP_H
-#define NANCY_UI_INVENTORYPOPUP_H
+#ifndef NANCY_UI_TASKBAR_INVENTORYPOPUP_H
+#define NANCY_UI_TASKBAR_INVENTORYPOPUP_H
 
 #include "engines/nancy/commontypes.h"
 #include "engines/nancy/renderobject.h"
@@ -144,4 +144,4 @@ private:
 } // End of namespace UI
 } // End of namespace Nancy
 
-#endif // NANCY_UI_INVENTORYPOPUP_H
+#endif // NANCY_UI_TASKBAR_INVENTORYPOPUP_H
diff --git a/engines/nancy/ui/notebookpopup.cpp b/engines/nancy/ui/taskbar/notebookpopup.cpp
similarity index 99%
rename from engines/nancy/ui/notebookpopup.cpp
rename to engines/nancy/ui/taskbar/notebookpopup.cpp
index ac07e65bd3d..700055d996c 100644
--- a/engines/nancy/ui/notebookpopup.cpp
+++ b/engines/nancy/ui/taskbar/notebookpopup.cpp
@@ -34,9 +34,9 @@
 #include "engines/nancy/state/scene.h"
 
 #include "engines/nancy/ui/scrollbar.h"
-#include "engines/nancy/ui/taskbar.h"
+#include "engines/nancy/ui/taskbar/taskbar.h"
 
-#include "engines/nancy/ui/notebookpopup.h"
+#include "engines/nancy/ui/taskbar/notebookpopup.h"
 
 namespace Nancy {
 namespace UI {
diff --git a/engines/nancy/ui/notebookpopup.h b/engines/nancy/ui/taskbar/notebookpopup.h
similarity index 98%
rename from engines/nancy/ui/notebookpopup.h
rename to engines/nancy/ui/taskbar/notebookpopup.h
index 072247e0f27..c4724f8f7c1 100644
--- a/engines/nancy/ui/notebookpopup.h
+++ b/engines/nancy/ui/taskbar/notebookpopup.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef NANCY_UI_NOTEBOOKPOPUP_H
-#define NANCY_UI_NOTEBOOKPOPUP_H
+#ifndef NANCY_UI_TASKBAR_NOTEBOOKPOPUP_H
+#define NANCY_UI_TASKBAR_NOTEBOOKPOPUP_H
 
 #include "engines/nancy/commontypes.h"
 #include "engines/nancy/renderobject.h"
@@ -164,4 +164,4 @@ private:
 } // End of namespace UI
 } // End of namespace Nancy
 
-#endif // NANCY_UI_NOTEBOOKPOPUP_H
+#endif // NANCY_UI_TASKBAR_NOTEBOOKPOPUP_H
diff --git a/engines/nancy/ui/scrolltextbox.cpp b/engines/nancy/ui/taskbar/scrolltextbox.cpp
similarity index 99%
rename from engines/nancy/ui/scrolltextbox.cpp
rename to engines/nancy/ui/taskbar/scrolltextbox.cpp
index 480fd46a1d1..47a659be258 100644
--- a/engines/nancy/ui/scrolltextbox.cpp
+++ b/engines/nancy/ui/taskbar/scrolltextbox.cpp
@@ -28,7 +28,7 @@
 #include "engines/nancy/state/scene.h"
 
 #include "engines/nancy/ui/scrollbar.h"
-#include "engines/nancy/ui/scrolltextbox.h"
+#include "engines/nancy/ui/taskbar/scrolltextbox.h"
 
 namespace Nancy {
 namespace UI {
diff --git a/engines/nancy/ui/scrolltextbox.h b/engines/nancy/ui/taskbar/scrolltextbox.h
similarity index 97%
rename from engines/nancy/ui/scrolltextbox.h
rename to engines/nancy/ui/taskbar/scrolltextbox.h
index 2cd0b9758bc..38a1e3218ba 100644
--- a/engines/nancy/ui/scrolltextbox.h
+++ b/engines/nancy/ui/taskbar/scrolltextbox.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef NANCY_UI_SCROLLTEXTBOX_H
-#define NANCY_UI_SCROLLTEXTBOX_H
+#ifndef NANCY_UI_TASKBAR_SCROLLTEXTBOX_H
+#define NANCY_UI_TASKBAR_SCROLLTEXTBOX_H
 
 #include "engines/nancy/commontypes.h"
 #include "engines/nancy/misc/hypertext.h"
@@ -120,4 +120,4 @@ private:
 } // End of namespace UI
 } // End of namespace Nancy
 
-#endif // NANCY_UI_SCROLLTEXTBOX_H
+#endif // NANCY_UI_TASKBAR_SCROLLTEXTBOX_H
diff --git a/engines/nancy/ui/taskbar.cpp b/engines/nancy/ui/taskbar/taskbar.cpp
similarity index 99%
rename from engines/nancy/ui/taskbar.cpp
rename to engines/nancy/ui/taskbar/taskbar.cpp
index 97c189cdf65..56fbc95e76c 100644
--- a/engines/nancy/ui/taskbar.cpp
+++ b/engines/nancy/ui/taskbar/taskbar.cpp
@@ -32,7 +32,7 @@
 
 #include "engines/nancy/state/scene.h"
 
-#include "engines/nancy/ui/taskbar.h"
+#include "engines/nancy/ui/taskbar/taskbar.h"
 
 namespace Nancy {
 namespace UI {
diff --git a/engines/nancy/ui/taskbar.h b/engines/nancy/ui/taskbar/taskbar.h
similarity index 98%
rename from engines/nancy/ui/taskbar.h
rename to engines/nancy/ui/taskbar/taskbar.h
index f21d8ef6baa..ed3331d152e 100644
--- a/engines/nancy/ui/taskbar.h
+++ b/engines/nancy/ui/taskbar/taskbar.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef NANCY_UI_TASKBAR_H
-#define NANCY_UI_TASKBAR_H
+#ifndef NANCY_UI_TASKBAR_TASKBAR_H
+#define NANCY_UI_TASKBAR_TASKBAR_H
 
 #include "engines/nancy/renderobject.h"
 
@@ -152,4 +152,4 @@ private:
 } // End of namespace UI
 } // End of namespace Nancy
 
-#endif // NANCY_UI_TASKBAR_H
+#endif // NANCY_UI_TASKBAR_TASKBAR_H
diff --git a/engines/nancy/ui/textbox.cpp b/engines/nancy/ui/textbox.cpp
index 1ed6c861a4b..0ff56dd837c 100644
--- a/engines/nancy/ui/textbox.cpp
+++ b/engines/nancy/ui/textbox.cpp
@@ -31,7 +31,7 @@
 
 #include "engines/nancy/ui/textbox.h"
 #include "engines/nancy/ui/scrollbar.h"
-#include "engines/nancy/ui/scrolltextbox.h"
+#include "engines/nancy/ui/taskbar/scrolltextbox.h"
 #include "engines/nancy/ui/ndui/manager.h"
 
 namespace Nancy {


Commit: 65961062046a8293e59938da4deec739710a9f3e
    https://github.com/scummvm/scummvm/commit/65961062046a8293e59938da4deec739710a9f3e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:48+03:00

Commit Message:
NANCY: NANCY18: Read and write the new CifFile and CifTree format

Trees are now a list of whole CifFiles followed by an index of names
and offsets, and nothing is compressed by the container any more.

Changed paths:
    engines/nancy/cif.cpp
    engines/nancy/cif.h
    engines/nancy/resource.cpp


diff --git a/engines/nancy/cif.cpp b/engines/nancy/cif.cpp
index 1fe36d1eb64..e8603ac7c18 100644
--- a/engines/nancy/cif.cpp
+++ b/engines/nancy/cif.cpp
@@ -99,7 +99,8 @@ enum {
 };
 
 // The version number stored inside CifFile and CifTree headers. Nancy12 bumped it
-// to 2, and Nancy16 to 3, without changing the layout of either structure
+// to 2, and Nancy16 to 3, without changing the layout of either structure.
+// Nancy18 uses a different header, which is handled separately
 static uint16 getCifVersion() {
 	GameType gameType = g_nancy->getGameType();
 
@@ -114,6 +115,50 @@ static uint16 getCifVersion() {
 	return 3;
 }
 
+// Nancy18 rewrote the format. A tree is now a list of whole CifFiles, followed
+// by an index of their names and offsets, and both start with the same header
+static bool syncCifHeaderNancy18(Common::Serializer &ser) {
+	if (!ser.matchBytes("CIF FILE HerInteractive", 24)) {
+		return false;
+	}
+
+	uint16 ver = 3;
+	uint16 verHi = 0;
+	ser.syncAsUint16LE(ver);
+	ser.syncAsUint16LE(verHi);
+
+	return ver == 3 && verHi == 0;
+}
+
+// Reads a whole Nancy18 CifFile header. The data is never compressed by the
+// container, since images are stored as PNG files
+static bool syncCifFileNancy18(Common::Serializer &ser, CifInfo &info) {
+	if (!syncCifHeaderNancy18(ser)) {
+		return false;
+	}
+
+	uint32 hasAlpha = info.depth == 32;
+
+	ser.syncAsUint32LE(info.type);
+	ser.syncAsUint32LE(info.width);
+	ser.syncAsUint32LE(info.height);
+	ser.syncAsUint32LE(hasAlpha);
+	ser.syncAsUint32LE(info.size);
+
+	if (info.type == CifInfo::kResTypeImage) {
+		info.depth = hasAlpha ? 32 : 24;
+	} else {
+		info.depth = 0;
+	}
+
+	info.pitch = info.width * info.depth / 8;
+	info.comp = CifInfo::kResCompressionNone;
+	info.compressedSize = info.size;
+	info.dataOffset = ser.bytesSynced();
+
+	return true;
+}
+
 CifFile::CifFile(Common::SeekableReadStream *stream, const Common::Path &name) {
 	assert(stream);
 	_stream = stream;
@@ -173,6 +218,15 @@ Common::SeekableReadStream *CifFile::createReadStreamRaw() const {
 }
 
 bool CifFile::sync(Common::Serializer &ser) {
+	if (g_nancy->getGameType() >= kGameTypeNancy18) {
+		if (!syncCifFileNancy18(ser, _info)) {
+			warning("Invalid id string or version found in CifFile '%s'", _info.name.toString().c_str());
+			return false;
+		}
+
+		return true;
+	}
+
 	if (g_nancy->getGameType() <= kGameTypeNancy11 && ser.matchBytes("CIF FILE WayneSikes", 20)) {
 		ser.skip(4);	// 4 bytes unused
 	} else if (g_nancy->getGameType() >= kGameTypeNancy12 && ser.matchBytes("CIF FILE HerInteractive", 24)) {
@@ -348,8 +402,14 @@ CifTree *CifTree::makeCifTreeArchive(const Common::String &name, const Common::S
 		return nullptr;
 	}
 
-	Common::Serializer ser(headerStream, nullptr);
-	bool synced = ret->sync(ser);
+	bool synced;
+	if (g_nancy->getGameType() >= kGameTypeNancy18) {
+		synced = ret->readIndex(*headerStream);
+	} else {
+		Common::Serializer ser(headerStream, nullptr);
+		synced = ret->sync(ser);
+	}
+
 	ret->closeStream(headerStream);
 
 	if (!synced) {
@@ -411,6 +471,98 @@ bool CifTree::sync(Common::Serializer &ser) {
 	return true;
 }
 
+bool CifTree::readIndex(Common::SeekableReadStream &stream) {
+	const uint kNameSize = 33;
+	const uint kIndexEntrySize = kNameSize + 4;
+
+	Common::Serializer headerSer(&stream, nullptr);
+	if (!syncCifHeaderNancy18(headerSer)) {
+		warning("Invalid id string or version found in CifTree '%s'", _name.toString().c_str());
+		return false;
+	}
+
+	// The index sits at the end of the tree, and is followed by its own size,
+	// which includes the file count in front of it
+	if (!stream.seek(-4, SEEK_END)) {
+		warning("Failed to read the index of CifTree '%s'", _name.toString().c_str());
+		return false;
+	}
+
+	const uint32 indexSize = stream.readUint32LE();
+	if (indexSize + 4 > stream.size() || !stream.seek(stream.size() - 4 - indexSize)) {
+		warning("Failed to read the index of CifTree '%s'", _name.toString().c_str());
+		return false;
+	}
+
+	const uint32 numFiles = stream.readUint32LE();
+	if ((uint64)numFiles * kIndexEntrySize + 4 != indexSize) {
+		warning("Invalid index found in CifTree '%s'", _name.toString().c_str());
+		return false;
+	}
+
+	Common::Array<CifInfo> infos(numFiles);
+	for (CifInfo &info : infos) {
+		char name[kNameSize + 1];
+		stream.read(name, kNameSize);
+		name[kNameSize] = 0;
+		info.name = name;
+		info.dataOffset = stream.readUint32LE();
+	}
+
+	// The rest of the info is inside the header of each file
+	for (CifInfo &info : infos) {
+		const uint32 fileOffset = info.dataOffset;
+		if (!stream.seek(fileOffset)) {
+			warning("Failed to read '%s' from CifTree '%s'", info.name.toString().c_str(), _name.toString().c_str());
+			return false;
+		}
+
+		Common::Serializer ser(&stream, nullptr);
+		if (!syncCifFileNancy18(ser, info)) {
+			warning("Invalid file header for '%s' in CifTree '%s'", info.name.toString().c_str(), _name.toString().c_str());
+			return false;
+		}
+
+		info.dataOffset += fileOffset;
+
+		if (info.size && info.type != CifInfo::kResTypeEmpty) {
+			_fileMap.setVal(info.name, info);
+		}
+	}
+
+	return true;
+}
+
+void CifTree::write(Common::SeekableWriteStream &stream, const Common::Array<Common::SeekableReadStream *> &fileData) {
+	const uint kNameSize = 33;
+	const uint kIndexEntrySize = kNameSize + 4;
+
+	assert(fileData.size() == _writeFileMap.size());
+
+	Common::Serializer ser(nullptr, &stream);
+	syncCifHeaderNancy18(ser);
+
+	for (uint i = 0; i < _writeFileMap.size(); ++i) {
+		// The index points to the header of each file, not to its data
+		_writeFileMap[i].dataOffset = stream.pos();
+
+		CifInfo info = _writeFileMap[i];
+		syncCifFileNancy18(ser, info);
+		stream.writeStream(fileData[i]);
+	}
+
+	stream.writeUint32LE(_writeFileMap.size());
+
+	for (const CifInfo &info : _writeFileMap) {
+		char name[kNameSize] = {};
+		Common::strlcpy(name, info.name.baseName().c_str(), kNameSize);
+		stream.write(name, kNameSize);
+		stream.writeUint32LE(info.dataOffset);
+	}
+
+	stream.writeUint32LE(_writeFileMap.size() * kIndexEntrySize + 4);
+}
+
 Common::Array<Common::Path> CifTree::getPathsForType(CifInfo::ResType type) const {
 	Common::Array<Common::Path> pathList;
 
diff --git a/engines/nancy/cif.h b/engines/nancy/cif.h
index bbbc8c7e03f..a7c6d02fc1f 100644
--- a/engines/nancy/cif.h
+++ b/engines/nancy/cif.h
@@ -28,6 +28,7 @@
 
 namespace Common {
 class SeekableReadStream;
+class SeekableWriteStream;
 class Serializer;
 }
 
@@ -39,7 +40,8 @@ struct CifInfo {
 		// Type 1 seems to be obsolete
 		kResTypeImage = 2,
 		kResTypeScript = 3,
-		kResTypeEmpty = 4
+		kResTypeEmpty = 4,
+		kResTypeXSheet = 6 // Nancy18+
 	};
 
 	enum ResCompression {
@@ -77,6 +79,8 @@ private:
 };
 
 // Container type comprising of multiple CIF files. Contrary to its name it contains no tree structure.
+// Up to Nancy17 a tree starts with a table of info blocks, and holds only the files' data. From Nancy18
+// on it holds whole CIF files, and ends with an index of their names and offsets.
 class CifTree : public Common::Archive {
 public:
 	CifTree() : _stream(nullptr) {}
@@ -100,6 +104,10 @@ public:
 	Common::SeekableReadStream *createReadStreamRaw(const Common::Path &path) const;
 	bool sync(Common::Serializer &ser);
 
+	// Nancy18+
+	bool readIndex(Common::SeekableReadStream &stream);
+	void write(Common::SeekableWriteStream &stream, const Common::Array<Common::SeekableReadStream *> &fileData);
+
 	void addInfo(const CifInfo &info) { _writeFileMap.push_back(info); }
 	uint writeFileMapSize() const { return _writeFileMap.size(); }
 	uint32 getDataOffset(uint i) const { return _writeFileMap[i].dataOffset; }
diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index 2794a657f44..3ee088213c6 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -528,22 +528,29 @@ bool ResourceManager::exportCifTree(const Common::String &treeName, const Common
 		file.addInfo(info);
 	}
 
-	uint16 dataOffset = headerSize + file.writeFileMapSize() * infoSize; // Initial offset after header/infos
-	for (uint i = 0; i < file.writeFileMapSize(); ++i) {
-		file.setDataOffset(i, dataOffset);
-		for (uint j = 0; j < i; ++j) {
-			file.setDataOffset(i, file.getDataOffset(i) + resStreams[j]->size()); // Final offset, following raw data of previous files
-		}
-	}
-
 	Common::DumpFile dump;
 	dump.open(Common::Path(treeName + ".dat"));
 
-	Common::Serializer ser(nullptr, &dump);
-	file.sync(ser);
+	if (g_nancy->getGameType() >= kGameTypeNancy18) {
+		file.write(dump, resStreams);
+	} else {
+		uint16 dataOffset = headerSize + file.writeFileMapSize() * infoSize; // Initial offset after header/infos
+		for (uint i = 0; i < file.writeFileMapSize(); ++i) {
+			file.setDataOffset(i, dataOffset);
+			for (uint j = 0; j < i; ++j) {
+				file.setDataOffset(i, file.getDataOffset(i) + resStreams[j]->size()); // Final offset, following raw data of previous files
+			}
+		}
+
+		Common::Serializer ser(nullptr, &dump);
+		file.sync(ser);
+
+		for (uint i = 0; i < resStreams.size(); ++i) {
+			dump.writeStream(resStreams[i]);
+		}
+	}
 
 	for (uint i = 0; i < resStreams.size(); ++i) {
-		dump.writeStream(resStreams[i]);
 		delete resStreams[i];
 	}
 


Commit: 9e4cad9e34f6bf2dce3539f470489b8f995b2148
    https://github.com/scummvm/scummvm/commit/9e4cad9e34f6bf2dce3539f470489b8f995b2148
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:51+03:00

Commit Message:
NANCY: NANCY18: Load images stored as PNG files

Builds without PNG support refuse to start Nancy18 and later games.

Changed paths:
    engines/nancy/nancy.cpp
    engines/nancy/resource.cpp


diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 306c19e3ebc..249e2549f65 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -475,6 +475,13 @@ void NancyEngine::addDeferredLoader(Common::SharedPtr<DeferredLoader> &loaderPtr
 }
 
 Common::Error NancyEngine::run() {
+#ifndef USE_PNG
+	// Nancy18 stores its images as PNG files
+	if (getGameType() >= kGameTypeNancy18) {
+		return Common::Error(Common::kUnknownError, _s("This game requires PNG support, which is not compiled in"));
+	}
+#endif
+
 	setDebugger(new NancyConsole());
 
 	// Set the default number of saves for earlier games
diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index 3ee088213c6..08960ba43d4 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -23,6 +23,7 @@
 #include "common/config-manager.h"
 
 #include "image/bmp.h"
+#include "image/png.h"
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/resource.h"
@@ -162,6 +163,25 @@ bool ResourceManager::loadImage(const Common::Path &name, Graphics::ManagedSurfa
 		*outDest = info.dest;
 	}
 
+#ifdef USE_PNG
+	// Nancy18 stores its images as PNG files
+	if (g_nancy->getGameType() >= kGameTypeNancy18) {
+		Image::PNGDecoder pngDec;
+		if (!pngDec.loadStream(*stream)) {
+			warning("Couldn't decode PNG image '%s'", name.toString().c_str());
+			delete stream;
+			return false;
+		}
+
+		Graphics::Surface *converted = pngDec.getSurface()->convertTo(g_nancy->_graphics->getInputPixelFormat(32));
+		GraphicsManager::copyToManaged(*converted, surf);
+		converted->free();
+		delete converted;
+		delete stream;
+		return true;
+	}
+#endif
+
 	// Nancy16 stores its images as uncompressed, top-down TGA files. Only the header
 	// needs skipping; the image's size and depth come from the CIF info.
 	if (g_nancy->getGameType() >= kGameTypeNancy16) {


Commit: b27a11f691229885045a01720c5d620c3f4d7e19
    https://github.com/scummvm/scummvm/commit/b27a11f691229885045a01720c5d620c3f4d7e19
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:52+03:00

Commit Message:
NANCY: NANCY18: Read the shorter BSUM chunk

The game's title is no longer part of the header, and the block
before the conversation texts filename has been removed.

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index a239df2e510..469693b221e 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -37,15 +37,16 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	Common::Serializer s(chunkStream, nullptr);
 	s.setVersion(g_nancy->getGameType());
 
-	// The header is used to verify savegames
-	s.syncBytes(header, 90);
+	// The header is used to verify savegames. Nancy18 dropped the game's title from it
+	s.syncBytes(header, 90, kGameTypeVampire, kGameTypeNancy17);
+	s.syncBytes(header, 60, kGameTypeNancy18);
 
 	s.skip(0x17, kGameTypeVampire, kGameTypeVampire);
 	s.skip(0x49, kGameTypeNancy1, kGameTypeNancy1);
 	s.skip(0x43, kGameTypeNancy2, kGameTypeNancy9);
 	s.skip(0x41, kGameTypeNancy10, kGameTypeNancy15);
 	s.skip(0x55, kGameTypeNancy16, kGameTypeNancy16);
-	s.skip(0x51, kGameTypeNancy17);
+	s.skip(0x51, kGameTypeNancy17, kGameTypeNancy17);
 
 	readFilename(s, conversationTextsFilename, kGameTypeNancy6);
 	readFilename(s, autotextFilename, kGameTypeNancy6);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 70a321ae5a4..68b64a47e1d 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -41,7 +41,7 @@ struct EngineData {
 struct BSUM : public EngineData {
 	BSUM(Common::SeekableReadStream *chunkStream);
 
-	byte header[90];
+	byte header[90] = {};
 
 	Common::Path conversationTextsFilename;
 	Common::Path autotextFilename;


Commit: aa02ae97b0d425fcef28fd062a0cca364869444f
    https://github.com/scummvm/scummvm/commit/aa02ae97b0d425fcef28fd062a0cca364869444f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:54+03:00

Commit Message:
NANCY: Fix a crash when the image of an object is missing

- An overlay in Nancy16 scenes 3509 and 3532 has a dot at the end of its image
  name ("LOB_Ia_OVL."), so its image never loaded, and drawing it crashed
- Objects without an image are no longer drawn
- Drop the dot, so that the overlay shows up

Changed paths:
    engines/nancy/action/overlay.cpp
    engines/nancy/graphics.cpp


diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index efaa58b54de..829f7a3e747 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -425,6 +425,15 @@ Common::String Overlay::getRecordTypeName() const {
 
 void OverlayStaticTerse::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);
+
+	// Workaround: the image name of an overlay in Nancy16 scenes 3509 and 3532 ends
+	// with a dot ("LOB_Ia_OVL."), which keeps the image from being found
+	Common::String imageName = _imageName.toString();
+	if (imageName.hasSuffix(".")) {
+		imageName.deleteLastChar();
+		_imageName = Common::Path(imageName);
+	}
+
 	_transparency = stream.readUint16LE();
 	_z = stream.readUint16LE();
 
diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index b2bc7abbac5..a98b67d8b9b 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -718,6 +718,11 @@ void GraphicsManager::screenshotScreen(Graphics::ManagedSurface &inSurf) {
 
 // Draw a given screen-space rectangle of an object
 void GraphicsManager::blitObject(Graphics::ManagedSurface &target, const RenderObject &src, Common::Rect screenRect) {
+	// An object whose image is missing has nothing to draw
+	if (src._drawSurface.empty()) {
+		return;
+	}
+
 	target.blitFrom(src._drawSurface, src._drawSurface.getBounds().findIntersectingRect(src.convertToLocal(screenRect)), screenRect);
 }
 


Commit: e5c270322f074ea2655f73a0a4e8bca08a6e8ecf
    https://github.com/scummvm/scummvm/commit/e5c270322f074ea2655f73a0a4e8bca08a6e8ecf
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:55+03:00

Commit Message:
NANCY: NANCY16: Start the parts of a conversation line on a new line

The captions of a line made of several sounds were shown with nothing between them

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 1f5d8a43d07..c5c44729a5c 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -237,7 +237,14 @@ void ConversationSound::readDataNancy13(Common::SeekableReadStream &stream) {
 		_text = resolveConversationText(_sound.name);
 	} else {
 		for (uint i = 0; i < _concatSounds.size(); ++i) {
-			_text += resolveConversationText(_concatSounds[i]);
+			Common::String part = resolveConversationText(_concatSounds[i]);
+
+			// Nancy16 starts every part on a line of its own
+			if (g_nancy->getGameType() >= kGameTypeNancy16 && !part.empty() && !_text.empty()) {
+				_text += "<n>";
+			}
+
+			_text += part;
 		}
 	}
 


Commit: c279653ea15970fdb5af2a0c5dd87762aaa2014f
    https://github.com/scummvm/scummvm/commit/c279653ea15970fdb5af2a0c5dd87762aaa2014f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:57+03:00

Commit Message:
NANCY: NANCY16: Show the captions of muffled lines

- A few lines Nancy says under the snow are muffled takes with no caption of their
  own (e.g. "NIC312_muffled" in scene 4735); show the caption of the clear take
- This is an enhancement: the original shows no caption for them

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


diff --git a/engines/nancy/action/soundrecords.cpp b/engines/nancy/action/soundrecords.cpp
index 7e61964b90f..420b1f8b3d9 100644
--- a/engines/nancy/action/soundrecords.cpp
+++ b/engines/nancy/action/soundrecords.cpp
@@ -93,6 +93,13 @@ static Common::String resolveSoundSubtitle(const Common::String &soundName) {
 		}
 	}
 
+	// Enhancement: the muffled takes of a few Nancy16 lines have no text of their
+	// own (e.g. "NIC312_muffled" in scene 4735), so show the text of the clear take
+	static const char *const kMuffledSuffix = "_muffled";
+	if (soundName.hasSuffixIgnoreCase(kMuffledSuffix)) {
+		return resolveSoundSubtitle(soundName.substr(0, soundName.size() - strlen(kMuffledSuffix)));
+	}
+
 	return Common::String();
 }
 


Commit: e17164d39bcd9d36decdb017b0c22bdfef9e93c4
    https://github.com/scummvm/scummvm/commit/e17164d39bcd9d36decdb017b0c22bdfef9e93c4
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:19:58+03:00

Commit Message:
NANCY: NANCY16: Hide overlays whose dependencies no longer hold

Overlays are only shown for as long as their dependencies hold, e.g.
the snowmobile key in scene 1383, which stayed on the table after being
picked up

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


diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index 829f7a3e747..8498ae9267b 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -103,6 +103,15 @@ void Overlay::updateGraphics() {
 		setVisible(_isActive);
 	}
 
+	// Nancy16 only shows an overlay for as long as its dependencies hold, e.g. the key
+	// in scene 1383, which goes away when picked up. It comes back once they hold again.
+	if (g_nancy->getGameType() >= kGameTypeNancy16 && _state == kRun && _overlayType == kPlayOverlayStatic &&
+			!_isActive && isVisible()) {
+		setVisible(false);
+		_hasHotspot = false;
+		_currentViewportFrame = -1;
+	}
+
 	// Update inactive animated overlays
 	if (!_isActive && _state == kRun && !_blitDescriptions.empty() && _overlayType == kPlayOverlayAnimated) {
 		uint16 newFrame = NancySceneState.getSceneInfo().frameID;


Commit: 8f3932bfb6cfd8e6665cfbc618101d75cc803373
    https://github.com/scummvm/scummvm/commit/8f3932bfb6cfd8e6665cfbc618101d75cc803373
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:00+03:00

Commit Message:
NANCY: NANCY16: Show the time of day right away

- The clock only got its text when the game minute changed, so it was
  empty for a while after starting or loading a game
- It now also follows the time set by a scene without waiting for the
  next minute

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 99126f41c07..1bb9fc360c8 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -398,6 +398,10 @@ void Scene::setPlayerTime(Time time, byte relative) {
 	}
 
 	_timers.playerTimeNextMinute = g_nancy->getTotalPlayTime() + bootSummary->playerTimeMinuteLength;
+
+	if (_ndui) {
+		_ndui->updatePlayerTime();
+	}
 }
 
 uint Scene::getPlayerTimeMinutes() const {
@@ -2151,8 +2155,7 @@ void Scene::run() {
 		}
 
 		if (_ndui) {
-			uint minutes = _timers.playerTime.getHours() * 60 + _timers.playerTime.getMinutes();
-			_ndui->setPlayerTime(minutes, minutes >= bootSummary->dayStartMinutes && minutes <= bootSummary->dayEndMinutes);
+			_ndui->updatePlayerTime();
 		}
 	}
 
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index d5929927ab9..67d2ec03221 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -61,6 +61,7 @@ void Manager::takeEngineAction(const Action &action) {
 			}
 
 			_clockDisplays.push_back(action.sender);
+			updatePlayerTime();
 		}
 	} else if (action.target.equalsIgnoreCase("Engine_ClosedCaption")) {
 		if (action.type == kActionQuery) {
@@ -494,7 +495,13 @@ void Manager::setCaption(const Common::String &text) {
 }
 
 // The time of day is shown as "1:05 pm", grayed out at night
-void Manager::setPlayerTime(uint32 minutesOfDay, bool isDaytime) {
+void Manager::updatePlayerTime() {
+	const BSUM *bootSummary = GetEngineData(BSUM);
+	assert(bootSummary);
+
+	const Time playerTime = NancySceneState.getPlayerTime();
+	uint32 minutesOfDay = playerTime.getHours() * 60 + playerTime.getMinutes();
+	bool isDaytime = minutesOfDay >= (uint32)bootSummary->dayStartMinutes && minutesOfDay <= (uint32)bootSummary->dayEndMinutes;
 	uint32 hours = minutesOfDay / 60;
 	uint32 displayHours = hours % 12 == 0 ? 12 : hours % 12;
 	Common::String text = Common::String::format("%u:%02u %s", displayHours, minutesOfDay % 60, hours < 12 ? "am" : "pm");
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 4274b35b0ae..8d65de24e1d 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -116,7 +116,7 @@ public:
 	// Engine state shown by the UI
 	void update();
 	void setCaption(const Common::String &text);
-	void setPlayerTime(uint32 minutesOfDay, bool isDaytime);
+	void updatePlayerTime();
 	void createItemButtons();
 	void clickInventoryItem(int16 itemID) { onInventoryClicked(itemID); }
 


Commit: 8b32d76489cd8e3491b65200639c0dbf52df94b9
    https://github.com/scummvm/scummvm/commit/8b32d76489cd8e3491b65200639c0dbf52df94b9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:01+03:00

Commit Message:
NANCY: NANCY16: Close the dialog of a conversation cut short

- A line that a scene change ends before it is over never closed the
  dialog, which then stayed up after the conversation, e.g. after
  calling Carl Jenkins (scene 1875)
- Clear the UI pointer when the scene goes away, for the records
  destroyed after it

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index c5c44729a5c..1b9d92105e2 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -56,6 +56,12 @@ ConversationSound::~ConversationSound() {
 	if (NancySceneState.getActiveConversation() == this) {
 		NancySceneState.setActiveConversation(nullptr);
 	}
+
+	// A line that never got to its end still closes the dialog it opened, e.g. the
+	// one of Nancy16 scene 1875, which changes the scene as soon as it starts
+	if (_isDialogOpen && NancySceneState.getNDUI()) {
+		NancySceneState.getNDUI()->endConversation();
+	}
 }
 
 void ConversationSound::init() {
@@ -462,6 +468,7 @@ void ConversationSound::execute() {
 
 			if (ndui) {
 				ndui->startConversation(ConfMan.getBool("subtitles") ? _text : Common::String(), responseTexts);
+				_isDialogOpen = true;
 			}
 		}
 
@@ -574,6 +581,7 @@ void ConversationSound::execute() {
 
 			if (NancySceneState.getNDUI()) {
 				NancySceneState.getNDUI()->endConversation();
+				_isDialogOpen = false;
 			} else if (g_nancy->getGameType() >= kGameTypeNancy10) {
 				NancySceneState.getConversationPopup().close();
 			}
diff --git a/engines/nancy/action/conversation.h b/engines/nancy/action/conversation.h
index 31f3b056e87..e959a339743 100644
--- a/engines/nancy/action/conversation.h
+++ b/engines/nancy/action/conversation.h
@@ -150,6 +150,7 @@ protected:
 	Common::Array<SceneBranchStruct> _sceneBranchStructs;
 
 	bool _hasDrawnTextbox;
+	bool _isDialogOpen = false;	// in the Nancy16 UI
 	int16 _pickedResponse;
 	bool _isSkipped = false;
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 1bb9fc360c8..fea41a13f20 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -157,7 +157,10 @@ Scene::~Scene() {
 	delete _camera;
 	delete _lightning;
 	delete _viewportFader;
+
+	// The records, which go away after this, can't reach the UI anymore
 	delete _ndui;
+	_ndui = nullptr;
 
 	_streams.clear();
 	clearPuzzleData();


Commit: 3345e1a9a8b0ea72a3e21a05ec25d0fc2893fb85
    https://github.com/scummvm/scummvm/commit/3345e1a9a8b0ea72a3e21a05ec25d0fc2893fb85
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:03+03:00

Commit Message:
NANCY: NANCY18: Set the item and cursor type counts

Changed paths:
    engines/nancy/nancy.cpp


diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 249e2549f65..9c0dea30ab5 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -1019,7 +1019,9 @@ void NancyEngine::populateStaticData() {
 	case kGameTypeNancy15:
 	case kGameTypeNancy16:
 	case kGameTypeNancy17:
-		// Nancy16 and Nancy17 have 40 items, but their cursor table still has room for 49
+	case kGameTypeNancy18:
+		// Nancy16 and Nancy17 have 40 items, but their cursor table still has room for 49,
+		// and so does the one of Nancy18
 		_staticData.numItems = 49;
 		_staticData.numCursorTypes = 45;
 		break;


Commit: 0ac15749a7a297070e87aca5a82b5607d2d6b4ce
    https://github.com/scummvm/scummvm/commit/0ac15749a7a297070e87aca5a82b5607d2d6b4ce
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:05+03:00

Commit Message:
NANCY: NANCY18: Run the leave script of an environment

The ENVS chunk names a script for every environment that is run
once the main stream leaves it. The tutorial uses it to switch the
player back to Nancy.

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 469693b221e..591eefa8819 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1508,7 +1508,7 @@ ENVS::ENVS(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 		readFilename(*chunkStream, env.code);
 		readFilename(*chunkStream, env.name);
 		readFilename(*chunkStream, env.ambientScriptName);
-		chunkStream->skip(33);	// A third name, empty in the game data
+		readFilename(*chunkStream, env.leaveScriptName);
 
 		const uint16 numTracks = chunkStream->readUint16LE();
 		readFilenameArray(*chunkStream, env.musicNames, numTracks);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 68b64a47e1d..b0ccb350076 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -944,6 +944,7 @@ struct ENVS : public EngineData {
 		Common::String code;
 		Common::String name;
 		Common::Path ambientScriptName;
+		Common::Path leaveScriptName;	// only used from Nancy18 on
 		Common::Array<Common::String> musicNames;
 	};
 
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index 8acb6e76da4..3db31092d25 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -383,7 +383,8 @@ void SceneStreamManager::onMainSceneChange() {
 }
 
 // Every environment has a script of its own, with its ambient sounds for example,
-// which runs for as long as the main stream stays in it
+// which runs for as long as the main stream stays in it. It can have another one
+// that is run once the main stream leaves it.
 void SceneStreamManager::changeEnvironment(const Common::String &newEnvironment) {
 	if (newEnvironment.equalsIgnoreCase(_environment)) {
 		return;
@@ -397,6 +398,12 @@ void SceneStreamManager::changeEnvironment(const Common::String &newEnvironment)
 	for (const ENVS::Environment &env : envs->environments) {
 		if (env.code.equalsIgnoreCase(_environment)) {
 			end(env.code + "_Enter_Stream");
+
+			if (!env.leaveScriptName.empty()) {
+				StreamFlags flags;
+				flags.runsFirst = true;
+				begin(env.code + "_Leave_Stream", env.leaveScriptName.toString(), 0, flags);
+			}
 		}
 	}
 


Commit: f11d08dc774e7d9409e909b53c384bea77cd053a
    https://github.com/scummvm/scummvm/commit/f11d08dc774e7d9409e909b53c384bea77cd053a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:07+03:00

Commit Message:
NANCY: NANCY18: Let collections hold names

The collection records can keep a list of names next to the list
of numbers. The names are only saved for Nancy18 and later, so the
savegame version stays the same.

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


diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index ac9bcedeada..e8f7821a239 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1452,22 +1452,31 @@ void AddPhoneEntry::execute() {
 	_isDone = true;
 }
 
-// The numbers of a collection record take up the rest of its data. The original
-// also has collections of flags and of names, which no scene makes use of.
-static void readCollectionValues(Common::SeekableReadStream &stream, Common::Array<double> &values) {
+// The items of a collection record take up the rest of its data, and are either
+// numbers or names. The original also has collections of flags, which no scene
+// makes use of.
+static void readCollectionValues(Common::SeekableReadStream &stream, CollectionData::Values &values) {
+	const uint kNumberSize = 8;
+	const uint kNameSize = 33;
+
 	const uint16 numValues = stream.readUint16LE();
 	if (!numValues) {
 		return;
 	}
 
-	if ((stream.size() - stream.pos()) / numValues != 8) {
+	const uint itemSize = (stream.size() - stream.pos()) / numValues;
+	if (itemSize == kNumberSize) {
+		values.numbers.resize(numValues);
+		for (double &value : values.numbers) {
+			value = stream.readDoubleLE();
+		}
+	} else if (itemSize == kNameSize) {
+		values.names.resize(numValues);
+		for (Common::String &name : values.names) {
+			readFilename(stream, name);
+		}
+	} else {
 		warning("Unsupported collection item type");
-		return;
-	}
-
-	values.resize(numValues);
-	for (double &value : values) {
-		value = stream.readDoubleLE();
 	}
 }
 
@@ -1509,11 +1518,16 @@ void CollectionEdit::execute() {
 
 	if (data->collections.contains(_name)) {
 		CollectionData::Collection &collection = data->collections[_name];
-		collection.values.push_back(_values);
+		collection.values.numbers.push_back(_values.numbers);
+		collection.values.names.push_back(_values.names);
 
 		// The oldest values make room for the new ones
-		while (collection.values.size() > collection.maxSize) {
-			collection.values.remove_at(0);
+		while (collection.values.numbers.size() > collection.maxSize) {
+			collection.values.numbers.remove_at(0);
+		}
+
+		while (collection.values.names.size() > collection.maxSize) {
+			collection.values.names.remove_at(0);
 		}
 	} else {
 		warning("%s: tried to add but collection does not exist", _name.c_str());
@@ -1539,36 +1553,45 @@ void CollectionCheck::readData(Common::SeekableReadStream &stream) {
 	}
 }
 
-bool CollectionCheck::check(const Common::Array<double> &collection) const {
-	switch (_checkType) {
-	case kContainsAll: {
-		Common::Array<double> remaining = collection;
-		for (double value : _values) {
-			bool found = false;
-			for (uint i = 0; i < remaining.size() && !found; ++i) {
-				if (remaining[i] == value) {
-					remaining.remove_at(i);
-					found = true;
-				}
+template<typename T>
+static bool containsAllValues(const Common::Array<T> &collection, const Common::Array<T> &values) {
+	Common::Array<T> remaining = collection;
+	for (const T &value : values) {
+		bool found = false;
+		for (uint i = 0; i < remaining.size() && !found; ++i) {
+			if (remaining[i] == value) {
+				remaining.remove_at(i);
+				found = true;
 			}
+		}
 
-			if (!found) {
-				return false;
-			}
+		if (!found) {
+			return false;
 		}
+	}
+
+	return true;
+}
 
-		return true;
+template<typename T>
+static bool followsValuePattern(const Common::Array<T> &collection, const Common::Array<T> &values) {
+	for (uint i = 0; i < collection.size(); ++i) {
+		if (values.empty() || collection[i] != values[i % values.size()]) {
+			return false;
+		}
 	}
+
+	return true;
+}
+
+bool CollectionCheck::check(const CollectionData::Values &collection) const {
+	switch (_checkType) {
+	case kContainsAll:
+		return containsAllValues(collection.numbers, _values.numbers) && containsAllValues(collection.names, _values.names);
 	case kIsEqual:
-		return collection == _values;
+		return collection.numbers == _values.numbers && collection.names == _values.names;
 	case kFollowsPattern:
-		for (uint i = 0; i < collection.size(); ++i) {
-			if (_values.empty() || collection[i] != _values[i % _values.size()]) {
-				return false;
-			}
-		}
-
-		return true;
+		return followsValuePattern(collection.numbers, _values.numbers) && followsValuePattern(collection.names, _values.names);
 	default:
 		return false;
 	}
@@ -1578,12 +1601,13 @@ void CollectionCheck::execute() {
 	auto *data = (CollectionData *)NancySceneState.getPuzzleData(CollectionData::getTag());
 
 	if (data->collections.contains(_name)) {
-		const Common::Array<double> &collection = data->collections[_name].values;
+		const CollectionData::Values &collection = data->collections[_name].values;
 
 		if (_checkType == kSize) {
-			if (_size < (int32)collection.size()) {
+			const int32 collectionSize = collection.numbers.size() + collection.names.size();
+			if (_size < collectionSize) {
 				NancySceneState.setEventFlag(_flags[0]);
-			} else if (_size == (int32)collection.size()) {
+			} else if (_size == collectionSize) {
 				NancySceneState.setEventFlag(_flags[1]);
 			} else {
 				NancySceneState.setEventFlag(_flags[2]);
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 1ad57923c87..bd81dfb9282 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -681,8 +681,8 @@ protected:
 	Common::Array<Choice> _choices;
 };
 
-// Nancy17. A collection is a named list of numbers, which keeps the most recent
-// ones once it is full. Scenes use them for combinations, e.g. to add every
+// Nancy17. A collection is a named list of numbers (or of names, from Nancy18 on),
+// which keeps the most recent ones once it is full. Scenes use them for combinations, e.g. to add every
 // button the player presses and check the last few against the solution.
 class CollectionControl : public ActionRecord {
 public:
@@ -710,7 +710,7 @@ protected:
 	Common::String getRecordTypeName() const override { return "CollectionEdit"; }
 
 	Common::String _name;
-	Common::Array<double> _values;
+	CollectionData::Values _values;
 };
 
 // Nancy17. Compares a collection against a list of numbers or a size, and sets
@@ -730,7 +730,7 @@ protected:
 
 	Common::String getRecordTypeName() const override { return "CollectionCheck"; }
 
-	bool check(const Common::Array<double> &collection) const;
+	bool check(const CollectionData::Values &collection) const;
 
 	byte _checkType = kContainsAll;
 	Common::String _name;
@@ -739,7 +739,7 @@ protected:
 	// smaller collection
 	FlagDescription _flags[3];
 	int32 _size = 0;
-	Common::Array<double> _values;
+	CollectionData::Values _values;
 };
 
 // Nancy17. Adds a contact to the cell phone of the character being played
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index a5fd8b61e3f..aa463ae7502 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -906,6 +906,25 @@ void StreamData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+static void syncCollection(Common::Serializer &ser, CollectionData::Collection &collection) {
+	ser.syncAsUint32LE(collection.maxSize);
+
+	uint16 numNumbers = collection.values.numbers.size();
+	ser.syncAsUint16LE(numNumbers);
+	collection.values.numbers.resize(numNumbers);
+	ser.syncArray(collection.values.numbers.data(), numNumbers, Common::Serializer::DoubleLE);
+
+	// Collections of names were added in Nancy18
+	if (g_nancy->getGameType() >= kGameTypeNancy18) {
+		uint16 numNames = collection.values.names.size();
+		ser.syncAsUint16LE(numNames);
+		collection.values.names.resize(numNames);
+		for (Common::String &name : collection.values.names) {
+			ser.syncString(name);
+		}
+	}
+}
+
 void CollectionData::synchronize(Common::Serializer &ser) {
 	uint16 numCollections = collections.size();
 	ser.syncAsUint16LE(numCollections);
@@ -914,25 +933,14 @@ void CollectionData::synchronize(Common::Serializer &ser) {
 		for (auto &entry : collections) {
 			Common::String name = entry._key;
 			ser.syncString(name);
-			ser.syncAsUint32LE(entry._value.maxSize);
-
-			uint16 numValues = entry._value.values.size();
-			ser.syncAsUint16LE(numValues);
-			ser.syncArray(entry._value.values.data(), numValues, Common::Serializer::DoubleLE);
+			syncCollection(ser, entry._value);
 		}
 	} else {
 		collections.clear();
 		for (uint i = 0; i < numCollections; ++i) {
 			Common::String name;
 			ser.syncString(name);
-
-			Collection &collection = collections[name];
-			ser.syncAsUint32LE(collection.maxSize);
-
-			uint16 numValues = 0;
-			ser.syncAsUint16LE(numValues);
-			collection.values.resize(numValues);
-			ser.syncArray(collection.values.data(), numValues, Common::Serializer::DoubleLE);
+			syncCollection(ser, collections[name]);
 		}
 	}
 }
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index c31efde47e3..df23449d54b 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -22,6 +22,7 @@
 #include "common/serializer.h"
 #include "common/array.h"
 #include "common/hashmap.h"
+#include "common/str-array.h"
 
 #include "engines/nancy/commontypes.h"
 #include "engines/nancy/enginedata.h"
@@ -651,11 +652,18 @@ struct StreamData : public PuzzleData {
 };
 
 // Nancy17. Named lists of numbers that the scenes fill and check, e.g. the
-// buttons the player pressed so far
+// buttons the player pressed so far. Nancy18 also has lists of names.
 struct CollectionData : public PuzzleData {
+	struct Values {
+		Common::Array<double> numbers;
+		Common::StringArray names;
+
+		void clear() { numbers.clear(); names.clear(); }
+	};
+
 	struct Collection {
 		uint32 maxSize = 0;
-		Common::Array<double> values;
+		Values values;
 	};
 
 	static constexpr uint32 getTag() { return MKTAG('C', 'O', 'L', 'L'); }


Commit: cfaf485b01ce03e488f07174a69dad94b579ab9b
    https://github.com/scummvm/scummvm/commit/cfaf485b01ce03e488f07174a69dad94b579ab9b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:09+03:00

Commit Message:
NANCY: NANCY18: Add the saved data puzzles keep under a name

Puzzles can keep their state under a name of their own, and the
ClearSavedData record throws away what a name holds.

Changed paths:
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/action/miscrecords.h
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index d8e61f54e83..7624dba42df 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -133,7 +133,7 @@ namespace Nancy {
 namespace Action {
 
 ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableReadStream *recordStream) {
-	// The types returning nullptr in Nancy16 and Nancy17 were changed there, and their
+	// The types returning nullptr in Nancy16 and later were changed there, and their
 	// new data hasn't been analyzed yet. This keeps the readers of the older games from
 	// misparsing it.
 	switch (type) {
@@ -458,8 +458,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 108:
 		if (g_nancy->getGameType() <= kGameTypeNancy6)
 			return new OrderingPuzzle(OrderingPuzzle::kOrdering);
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy17)
 			return new GotoMenu();
+		else
+			return new ClearSavedData();
 	case 109:
 		return new LoseGame();
 	case 110:
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index e8f7821a239..107e34fd777 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1676,6 +1676,16 @@ void HeldItemFlags::execute() {
 	finishExecution();
 }
 
+void ClearSavedData::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _name);
+}
+
+void ClearSavedData::execute() {
+	auto *data = (SavedStateData *)NancySceneState.getPuzzleData(SavedStateData::getTag());
+	data->states.erase(_name);
+	_isDone = true;
+}
+
 void Rain::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);
 
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index bd81dfb9282..0b87b2f5866 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -810,6 +810,18 @@ protected:
 	uint32 _nextDrawTime = 0;
 };
 
+// Nancy18. Throws away what a puzzle has kept under a name
+class ClearSavedData : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "ClearSavedData"; }
+
+	Common::String _name;
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index aa463ae7502..d6220b5f1ee 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -953,6 +953,31 @@ Common::String PhoneBookData::Entry::getDisplayText() const {
 	return number;
 }
 
+void SavedStateData::synchronize(Common::Serializer &ser) {
+	uint16 numStates = states.size();
+	ser.syncAsUint16LE(numStates);
+
+	Common::Array<Common::String> names;
+	if (ser.isSaving()) {
+		for (auto &entry : states) {
+			names.push_back(entry._key);
+		}
+	} else {
+		states.clear();
+		names.resize(numStates);
+	}
+
+	for (Common::String &name : names) {
+		ser.syncString(name);
+
+		Common::Array<int32> &values = states[name];
+		uint16 numValues = values.size();
+		ser.syncAsUint16LE(numValues);
+		values.resize(numValues);
+		ser.syncArray(values.data(), numValues, Common::Serializer::Sint32LE);
+	}
+}
+
 void PhoneBookData::synchronize(Common::Serializer &ser) {
 	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
 		uint16 numEntries = entries[i].size();
@@ -972,6 +997,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 	switch(tag) {
 	case PhoneBookData::getTag():
 		return new PhoneBookData();
+	case SavedStateData::getTag():
+		return new SavedStateData();
 	case StreamData::getTag():
 		return new StreamData();
 	case BuildBoardData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index df23449d54b..921df0f3df4 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -672,6 +672,16 @@ struct CollectionData : public PuzzleData {
 	Common::HashMap<Common::String, Collection, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> collections;
 };
 
+// Nancy18. What puzzles keep under a name of their own, like where the player
+// stands in each of the sentry rooms, or a card game that was left unfinished.
+// The scenes can throw away what a name holds.
+struct SavedStateData : public PuzzleData {
+	static constexpr uint32 getTag() { return MKTAG('S', 'V', 'S', 'T'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::HashMap<Common::String, Common::Array<int32>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> states;
+};
+
 // Nancy17. The contacts in the cell phone of every player character
 struct PhoneBookData : public PuzzleData {
 	struct Entry {


Commit: 8a36107a96e79a3e0b43284bb4b5b733f7f15828
    https://github.com/scummvm/scummvm/commit/8a36107a96e79a3e0b43284bb4b5b733f7f15828
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:11+03:00

Commit Message:
NANCY: NANCY18: Add the records that save and load a game by name

Scenes use them for the save made when a new game starts, and for
second chances.

Changed paths:
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/action/miscrecords.h
    engines/nancy/ui/ndui/manager.h
    engines/nancy/ui/ndui/saveload.cpp


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 7624dba42df..94b93648f2a 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -473,9 +473,15 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 113:
 		return new DifficultyLevel();
 	case 114:
-		return new RotatingLockPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new RotatingLockPuzzle();
+		else
+			return new SaveNamedGame();
 	case 115:
-		return new LeverPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new LeverPuzzle();
+		else
+			return new LoadNamedGame();
 	case 116:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new Telephone(Telephone::kTelephone);
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 107e34fd777..29aa926e6e0 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1686,6 +1686,24 @@ void ClearSavedData::execute() {
 	_isDone = true;
 }
 
+void SaveNamedGame::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _name);
+}
+
+void SaveNamedGame::execute() {
+	NancySceneState.getNDUI()->requestSave(_name);
+	_isDone = true;
+}
+
+void LoadNamedGame::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _name);
+}
+
+void LoadNamedGame::execute() {
+	NancySceneState.getNDUI()->requestLoad(_name);
+	_isDone = true;
+}
+
 void Rain::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);
 
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 0b87b2f5866..a155a2dd5a1 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -822,6 +822,31 @@ protected:
 	Common::String _name;
 };
 
+// Nancy18. Saves the game under a name, replacing the save that has it. The
+// scenes make their own second chance saves with it.
+class SaveNamedGame : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "SaveNamedGame"; }
+
+	Common::String _name;
+};
+
+// Nancy18. Loads the game that was saved under a name
+class LoadNamedGame : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "LoadNamedGame"; }
+
+	Common::String _name;
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 8d65de24e1d..403bf02ba3f 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -92,6 +92,10 @@ public:
 
 	Request takeRequest();
 
+	// Nancy18 scenes save and load games by name, which is how it does second chances
+	void requestSave(const Common::String &name);
+	void requestLoad(const Common::String &name);
+
 	// Goes on with the quit or load that was put off to ask whether to save first
 	void runPendingAction();
 
diff --git a/engines/nancy/ui/ndui/saveload.cpp b/engines/nancy/ui/ndui/saveload.cpp
index 0aed9577cef..100bbbd3295 100644
--- a/engines/nancy/ui/ndui/saveload.cpp
+++ b/engines/nancy/ui/ndui/saveload.cpp
@@ -105,6 +105,34 @@ uint Manager::fillSaveList(const Common::String &listName) {
 	return numSaves;
 }
 
+// A name that is in use gets saved over; a new one gets the first free slot
+void Manager::requestSave(const Common::String &name) {
+	int slot = findSaveSlot(name);
+	for (int i = 1; slot == -1 && i < g_nancy->getMetaEngine()->getMaximumSaveSlot(); ++i) {
+		if (i != g_nancy->getAutosaveSlot() && !g_nancy->getMetaEngine()->querySaveMetaInfos(ConfMan.getActiveDomainName().c_str(), i).isValid()) {
+			slot = i;
+		}
+	}
+
+	if (slot != -1) {
+		_request.type = kRequestSave;
+		_request.slot = slot;
+		_request.name = name;
+	} else {
+		warning("No free save slot for %s", name.c_str());
+	}
+}
+
+void Manager::requestLoad(const Common::String &name) {
+	int slot = findSaveSlot(name);
+	if (slot != -1) {
+		_request.type = kRequestLoad;
+		_request.slot = slot;
+	} else {
+		warning("There is no saved game named %s", name.c_str());
+	}
+}
+
 int Manager::findSaveSlot(const Common::String &name) const {
 	SaveStateList saves = g_nancy->getMetaEngine()->listSaves(ConfMan.getActiveDomainName().c_str());
 	for (const SaveStateDescriptor &save : saves) {
@@ -269,22 +297,7 @@ void Manager::onSaverAction(const Action &action) {
 		takeAction(kActionHide, "SaveDialog", 0.0);
 		takeAction(kActionSetText, "SaveList", Common::String());
 
-		// A name that is in use gets saved over; a new one gets the first free slot
-		int slot = findSaveSlot(_typedSaveName);
-		for (int i = 1; slot == -1 && i < g_nancy->getMetaEngine()->getMaximumSaveSlot(); ++i) {
-			if (i != g_nancy->getAutosaveSlot() && !g_nancy->getMetaEngine()->querySaveMetaInfos(ConfMan.getActiveDomainName().c_str(), i).isValid()) {
-				slot = i;
-			}
-		}
-
-		if (slot != -1) {
-			_request.type = kRequestSave;
-			_request.slot = slot;
-			_request.name = _typedSaveName;
-		} else {
-			warning("No free save slot for %s", _typedSaveName.c_str());
-		}
-
+		requestSave(_typedSaveName);
 		_lastSaveName = _typedSaveName;
 		_typedSaveName.clear();
 		break;


Commit: 1ebb10794925ee174b1402c508a962be172b0982
    https://github.com/scummvm/scummvm/commit/1ebb10794925ee174b1402c508a962be172b0982
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:13+03:00

Commit Message:
NANCY: NANCY18: Add the UTurn and GraphChase records

UTurn turns the player around in a node. GraphChase moves the one
being chased on the map to a random neighboring node, away from
the player.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 94b93648f2a..abb1cf879dc 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -273,7 +273,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 30:	// Nancy11
 		return new StopPlayerScrolling();
 	case 31:	// Nancy11
-		return new StartPlayerScrolling();
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new StartPlayerScrolling();
+		else
+			return new UTurn();
 	case 32:	// Nancy10
 		return new UIPopupPrepScene();
 	case 34:	// Nancy17
@@ -816,7 +819,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 212:
 		return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kOrderItems);
 	case 213:
-		return new CollisionPuzzle(CollisionPuzzle::PuzzleType::kTileMove);
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new CollisionPuzzle(CollisionPuzzle::PuzzleType::kTileMove);
+		else
+			return new GraphChase();
 	case 214:
 		return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kKeypad);
 	case 215:
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 29aa926e6e0..f2c746c2c3b 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1676,6 +1676,125 @@ void HeldItemFlags::execute() {
 	finishExecution();
 }
 
+void UTurn::readData(Common::SeekableReadStream &stream) {
+	readExclusiveRect(stream, _hotspotRect);
+	_hoverCursor = (CursorManager::CursorType)stream.readSint16LE();
+	_excludesFrames = stream.readByte();
+
+	_frames.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (int16 &frame : _frames) {
+		frame = stream.readSint16LE();
+	}
+}
+
+bool UTurn::isActiveOnFrame(uint16 frame) const {
+	bool isListed = false;
+	for (int16 listed : _frames) {
+		if (listed == frame) {
+			isListed = true;
+		}
+	}
+
+	if (_excludesFrames) {
+		return !isListed;
+	}
+
+	return isListed || _frames.empty();
+}
+
+void UTurn::execute() {
+	// A node is a full turn made out of this many frames
+	const uint kFramesPerTurn = 20;
+
+	if (_state == kActionTrigger) {
+		UI::Viewport &viewport = NancySceneState.getViewport();
+		uint frame = (viewport.getCurFrame() + kFramesPerTurn / 2) % kFramesPerTurn;
+		if (frame < viewport.getFrameCount()) {
+			viewport.setFrame(frame);
+		}
+	}
+
+	_state = kRun;
+	_hasHotspot = isActiveOnFrame(NancySceneState.getSceneInfo().frameID);
+	_hotspot = _hotspotRect;
+}
+
+void GraphChase::readData(Common::SeekableReadStream &stream) {
+	_chaserTableIndex = stream.readSint16LE();
+	_chasedTableIndex = stream.readSint16LE();
+	_avoidsAdjacentNodes = stream.readByte();
+
+	for (uint i = 0; i < kNumLinkTypes; ++i) {
+		_linkTypeFlags[i] = stream.readSint16LE();
+	}
+
+	_nodes.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (Node &node : _nodes) {
+		stream.skip(2);	// The node's ID, which is its place in the list
+
+		node.resize(MAX<int16>(stream.readSint16LE(), 0));
+		for (Link &link : node) {
+			link.nodeID = stream.readSint32LE();
+			link.type = stream.readByte();
+		}
+	}
+}
+
+bool GraphChase::canMoveTo(const Link &link, int16 chaserNode) const {
+	if (link.nodeID == chaserNode) {
+		return false;
+	}
+
+	if (_avoidsAdjacentNodes) {
+		for (const Link &chaserLink : _nodes[chaserNode - 1]) {
+			if (chaserLink.nodeID == link.nodeID) {
+				return false;
+			}
+		}
+	}
+
+	return true;
+}
+
+void GraphChase::execute() {
+	_isDone = true;
+
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	if (!table) {
+		return;
+	}
+
+	const int16 chaserNode = table->getSingleValue(_chaserTableIndex);
+	const int16 chasedNode = table->getSingleValue(_chasedTableIndex);
+	if (chaserNode < 1 || chaserNode > (int16)_nodes.size() || chasedNode < 1 || chasedNode > (int16)_nodes.size()) {
+		warning("GraphChase: nodes %d and %d are not on the map", chaserNode, chasedNode);
+		return;
+	}
+
+	// Links get tried at random until one will do. Once none of them does,
+	// the places next to the player's stop being avoided, for good.
+	Node remaining = _nodes[chasedNode - 1];
+	while (!remaining.empty()) {
+		uint pick = g_nancy->_randomSource->getRandomNumber(remaining.size() - 1);
+		const Link link = remaining[pick];
+
+		if (canMoveTo(link, chaserNode)) {
+			table->setSingleValue(_chasedTableIndex, link.nodeID);
+			for (uint i = 0; i < kNumLinkTypes; ++i) {
+				NancySceneState.setEventFlag(_linkTypeFlags[i], link.type == i ? g_nancy->_true : g_nancy->_false);
+			}
+
+			return;
+		}
+
+		remaining.remove_at(pick);
+		if (remaining.empty() && _avoidsAdjacentNodes) {
+			_avoidsAdjacentNodes = false;
+			remaining = _nodes[chasedNode - 1];
+		}
+	}
+}
+
 void ClearSavedData::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _name);
 }
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index a155a2dd5a1..f114c30b8ac 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -810,6 +810,56 @@ protected:
 	uint32 _nextDrawTime = 0;
 };
 
+// Nancy18. A hotspot that turns the player around to face the opposite way
+class UTurn : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	CursorManager::CursorType getHoverCursor() const override { return _hoverCursor; }
+	bool canHaveHotspot() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "UTurn"; }
+
+	bool isActiveOnFrame(uint16 frame) const;
+
+	Common::Rect _hotspotRect;
+	CursorManager::CursorType _hoverCursor = CursorManager::kHotspot;
+	bool _excludesFrames = false;	// the hotspot is on every frame but the listed ones
+	Common::Array<int16> _frames;
+};
+
+// Nancy18. A chase over a map: the places are linked to each other, and the
+// record moves the one being chased to a place next to theirs, picked at random,
+// that the player isn't at. Both places are kept in the value table, and the
+// kind of link that got taken sets one of three event flags.
+class GraphChase : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	static const uint kNumLinkTypes = 3;
+
+	struct Link {
+		int32 nodeID = 0;	// counted from 1, as are the places in the value table
+		byte type = 0;
+	};
+
+	typedef Common::Array<Link> Node;
+
+	Common::String getRecordTypeName() const override { return "GraphChase"; }
+
+	bool canMoveTo(const Link &link, int16 chaserNode) const;
+
+	int16 _chaserTableIndex = 0;
+	int16 _chasedTableIndex = 0;
+	bool _avoidsAdjacentNodes = false;	// keeps a place away from the player's, while it can
+	int16 _linkTypeFlags[kNumLinkTypes] = { -1, -1, -1 };
+	Common::Array<Node> _nodes;
+};
+
 // Nancy18. Throws away what a puzzle has kept under a name
 class ClearSavedData : public ActionRecord {
 public:


Commit: 64cd5fb6f9d693d9b743a5d38e5e1fd3fa4d774b
    https://github.com/scummvm/scummvm/commit/64cd5fb6f9d693d9b743a5d38e5e1fd3fa4d774b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:15+03:00

Commit Message:
NANCY: NANCY18: Add the overlay that picks a random color

The record draws its image in one of the colors it lists, and sets
the event flag that goes with the color.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index abb1cf879dc..14c50c1024c 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -331,8 +331,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 54:
 		if (g_nancy->getGameType() <= kGameTypeNancy1)
 			return new Overlay(Overlay::kStaticAnimation);
-		else
+		else if (g_nancy->getGameType() <= kGameTypeNancy17)
 			return new Overlay(Overlay::kInterruptibleAnimation);
+		else
+			return new OverlayRandomColor();
 	case 55:
 		if (g_nancy->getGameType() <= kGameTypeNancy1)
 			return new Overlay(Overlay::kInterruptibleAnimation);
diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index 8498ae9267b..462f6a3b9b7 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -482,6 +482,57 @@ void OverlayMultiframeTerse::readData(Common::SeekableReadStream &stream) {
 	_overlayType = kPlayOverlayStatic;
 }
 
+void OverlayRandomColor::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+	_z = stream.readUint16LE();
+
+	_choices.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (ColorChoice &choice : _choices) {
+		choice.color = stream.readUint32BE();
+		choice.flag = stream.readSint16LE();
+	}
+
+	uint16 numBlitDescriptions = stream.readUint16LE();
+	_blitDescriptions.resize(numBlitDescriptions);
+	for (auto &bm : _blitDescriptions) {
+		bm.readData(stream);
+	}
+
+	_srcRects.push_back(Common::Rect());
+
+	_overlayType = kPlayOverlayStatic;
+}
+
+void OverlayRandomColor::init() {
+	Overlay::init();
+
+	if (_choices.empty() || _fullSurface.empty()) {
+		return;
+	}
+
+	const ColorChoice &choice = _choices[g_nancy->_randomSource->getRandomNumber(_choices.size() - 1)];
+	const uint colorA = choice.color >> 24;
+	const uint colorR = (choice.color >> 16) & 0xFF;
+	const uint colorG = (choice.color >> 8) & 0xFF;
+	const uint colorB = choice.color & 0xFF;
+
+	for (int y = 0; y < _fullSurface.h; ++y) {
+		for (int x = 0; x < _fullSurface.w; ++x) {
+			byte a, r, g, b;
+			_fullSurface.format.colorToARGB(_fullSurface.getPixel(x, y), a, r, g, b);
+			if (a == 0) {
+				continue;
+			}
+
+			_fullSurface.setPixel(x, y, _fullSurface.format.ARGBToColor(a * colorA / 255, r * colorR / 255, g * colorG / 255, b * colorB / 255));
+		}
+	}
+
+	if (choice.flag != -1) {
+		NancySceneState.setEventFlag(choice.flag, g_nancy->_true);
+	}
+}
+
 void OverlayAnimTerse::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);
 	stream.skip(2); // VIDEO_STOP_RENDERING, VIDEO_CONTINUE_RENDERING
diff --git a/engines/nancy/action/overlay.h b/engines/nancy/action/overlay.h
index fc11e969b30..5b172d3a38f 100644
--- a/engines/nancy/action/overlay.h
+++ b/engines/nancy/action/overlay.h
@@ -124,6 +124,27 @@ protected:
 	Common::String getRecordTypeName() const override { return "OverlayMultiframeTerse"; }
 };
 
+// Nancy18. A static overlay that gets drawn in one of several colors, picked at
+// random. Every color comes with an event flag, which tells the scene the pick.
+class OverlayRandomColor : public OverlayMultiframeTerse {
+public:
+	virtual ~OverlayRandomColor() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "OverlayRandomColor"; }
+
+	struct ColorChoice {
+		uint32 color = 0xFFFFFFFF;	// ARGB, multiplied with the image
+		int16 flag = -1;
+	};
+
+	Common::Array<ColorChoice> _choices;
+};
+
 // Short version of an animated overlay; assumes scene background doesn't move
 class OverlayAnimTerse : public Overlay {
 public:


Commit: 65fcd09cb66d231dc3f1538d7d6b76b9075521c0
    https://github.com/scummvm/scummvm/commit/65fcd09cb66d231dc3f1538d7d6b76b9075521c0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:17+03:00

Commit Message:
NANCY: NANCY18: Handle changes of existing records

The second text scroll record has a drawing order, and the phone
record of Nancy16 is back under a new number.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 14c50c1024c..9070ad4bd64 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -593,7 +593,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new RotatingLockPuzzle();	// Moved from 114
 	case 157:
-		return new PlaySoundCC();
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new PlaySoundCC();
+		else
+			return new KeypadPhone();	// Moved from 116
 	case 158:
 		return new PlayRandomSound();
 	case 159:
@@ -766,7 +769,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 198:
 		return new WolfCommands();
 	case 199:
-		return new WolfMazePuzzle();	// Driven by the commands given through 198
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new WolfMazePuzzle();	// Driven by the commands given through 198
+		else
+			return new AutotextScroll(true);
 	case 200:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new SoundEqualizerPuzzle();
diff --git a/engines/nancy/action/autotextscroll.cpp b/engines/nancy/action/autotextscroll.cpp
index 08c20c4baf4..3f62093a7ce 100644
--- a/engines/nancy/action/autotextscroll.cpp
+++ b/engines/nancy/action/autotextscroll.cpp
@@ -64,6 +64,10 @@ AutotextScroll::~AutotextScroll() {
 }
 
 void AutotextScroll::readData(Common::SeekableReadStream &stream) {
+	if (_hasDrawingOrder) {
+		_z = stream.readSint32LE();
+	}
+
 	_startDepth = stream.readSint32LE();
 	readRect(stream, _bounds);
 
diff --git a/engines/nancy/action/autotextscroll.h b/engines/nancy/action/autotextscroll.h
index aaaf7b93208..8348667daae 100644
--- a/engines/nancy/action/autotextscroll.h
+++ b/engines/nancy/action/autotextscroll.h
@@ -28,12 +28,12 @@
 namespace Nancy {
 namespace Action {
 
-// Nancy16 scrolling text (AR 197), used for the end credits. Each of the listed AUTOTEXT
+// Nancy16 scrolling text, used for the end credits. Each of the listed AUTOTEXT
 // entries is rendered as hypertext and scrolled through a rectangle, one after another.
 // Once they have all scrolled out of view, an event flag gets set.
 class AutotextScroll : public RenderActionRecord {
 public:
-	AutotextScroll() : RenderActionRecord(7) {}
+	AutotextScroll(bool hasDrawingOrder = false) : RenderActionRecord(7), _hasDrawingOrder(hasDrawingOrder) {}
 	virtual ~AutotextScroll();
 
 	void init() override;
@@ -59,6 +59,7 @@ protected:
 	void redraw();
 
 	// -- File data --
+	bool _hasDrawingOrder;			// Nancy18 has a type that can be put above or below other records
 	int32 _startDepth = 0;			// how far below the top of the rect the text starts
 	Common::Rect _bounds;
 	int32 _intervalTicks = 0;		// between two entries starting


Commit: 343c554cb657b7bb919b672026d45a12fc7adba5
    https://github.com/scummvm/scummvm/commit/343c554cb657b7bb919b672026d45a12fc7adba5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:19+03:00

Commit Message:
NANCY: NANCY18: Read the new fields of BuildPuzzle

- The record has a drawing order, and a piece has a hotspot for
  picking it up at home and an event flag it is only there with.
  Fixes a crash when dressing up in scene 3601.
- Play all sounds in sound blocks - this affects older games
  (Nancy14 and Nancy17), too

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


diff --git a/engines/nancy/action/puzzle/buildpuzzle.cpp b/engines/nancy/action/puzzle/buildpuzzle.cpp
index c811fa01e91..50afd961340 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/buildpuzzle.cpp
@@ -36,18 +36,6 @@
 namespace Nancy {
 namespace Action {
 
-// Reads one of the puzzle's grouped sound blocks into a plain SoundDescription,
-// keeping only the first of the random alternatives.
-static void readSoundBlock(Common::SeekableReadStream &stream, SoundDescription &out) {
-	RandomSoundBlock block;
-	block.readData(stream);
-
-	out.name = block.names.empty() ? "NO SOUND" : block.names[0];
-	out.channelID = block.channel;
-	out.numLoops = block.numLoops;
-	out.volume = block.volume;
-}
-
 void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	if (g_nancy->getGameType() >= kGameTypeNancy17) {
 		readDataNancy17(stream);
@@ -65,10 +53,10 @@ void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	_stateItemID = stream.readUint16LE();
 	readRect(stream, _submitSrcRect);
 	readRect(stream, _submitHotspot);
-	readSoundBlock(stream, _submitSound);
+	_submitSound.readData(stream);
 	readRect(stream, _startOverSrcRect);
 	readRect(stream, _startOverHotspot);
-	readSoundBlock(stream, _startOverSound);
+	_startOverSound.readData(stream);
 	readRect(stream, _doneSrcRect);
 	readRect(stream, _doneDestRect);
 
@@ -113,7 +101,7 @@ void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
 
 	// Only present when the puzzle has hold slots.
 	if (numHolds > 0)
-		readSoundBlock(stream, _holdSound);
+		_holdSound.readData(stream);
 
 	uint16 numPieces = stream.readUint16LE();
 	_pieces.resize(numPieces);
@@ -158,10 +146,10 @@ void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
 		_counterSpacing = stream.readSint32LE();
 	}
 
-	readSoundBlock(stream, _pickupSound);
-	readSoundBlock(stream, _dropSound);
-	readSoundBlock(stream, _notebookSound);
-	readSoundBlock(stream, _putDownSound);
+	_pickupSound.readData(stream);
+	_dropSound.readData(stream);
+	_notebookSound.readData(stream);
+	_putDownSound.readData(stream);
 
 	_wrongIngredientFlag = stream.readSint16LE();
 	_solvedFlag = stream.readSint16LE();
@@ -171,8 +159,8 @@ void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	_solveScene._flag.label = stream.readSint16LE();
 	_solveScene._flag.flag = stream.readByte();
 
-	SoundDescription unused;
-	readSoundBlock(stream, unused);
+	RandomSoundBlock unused;
+	unused.readData(stream);
 
 	_failScene.sceneID = stream.readUint16LE();
 	_failScene.frameID = stream.readUint16LE();
@@ -180,7 +168,7 @@ void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	_failFlag.label = stream.readSint16LE();
 	_failFlag.flag = stream.readByte();
 
-	readSoundBlock(stream, unused);
+	unused.readData(stream);
 
 	// The count-prefixed 23-byte hotspot records shared by the later puzzles.
 	readExitHotspots(stream, _exitHotspots);
@@ -214,6 +202,12 @@ void BuildPuzzle::readDataNancy17(Common::SeekableReadStream &stream) {
 	_heldPieceCursorType = stream.readSint16LE();
 	_carryCursorType = stream.readSint16LE();
 
+	// Nancy18 has several of these puzzles on top of each other
+	const bool isNancy18 = g_nancy->getGameType() >= kGameTypeNancy18;
+	if (isNancy18) {
+		_z = stream.readSint32LE();
+	}
+
 	uint16 numZones = stream.readUint16LE() / 2;
 	_zones.resize(numZones);
 	for (uint i = 0; i < numZones; ++i) {
@@ -247,7 +241,7 @@ void BuildPuzzle::readDataNancy17(Common::SeekableReadStream &stream) {
 	}
 
 	if (numHolds > 0)
-		readSoundBlock(stream, _holdSound);
+		_holdSound.readData(stream);
 
 	uint16 numPieces = stream.readUint16LE();
 	_pieces.resize(numPieces);
@@ -256,6 +250,10 @@ void BuildPuzzle::readDataNancy17(Common::SeekableReadStream &stream) {
 		readRect(stream, piece.srcRect);
 		piece.startZone = stream.readSint16LE();
 		readRect(stream, piece.destRect);
+		if (isNancy18) {
+			readRect(stream, piece.homeHotspot);
+		}
+
 		readRect(stream, piece.dragSrcRect);
 		readRect(stream, piece.placedSrcRect);
 		readRect(stream, piece.closeupSrcRect);
@@ -265,6 +263,10 @@ void BuildPuzzle::readDataNancy17(Common::SeekableReadStream &stream) {
 		piece.kind = stream.readByte();
 		piece.zoneID = stream.readSint16LE();
 		piece.itemID = stream.readSint16LE();
+		if (isNancy18) {
+			piece.availableFlag = stream.readSint16LE();
+		}
+
 		piece.placedFlag = stream.readSint16LE();
 		piece.inventoryItem = stream.readSint16LE();
 
@@ -279,10 +281,10 @@ void BuildPuzzle::readDataNancy17(Common::SeekableReadStream &stream) {
 		}
 	}
 
-	readSoundBlock(stream, _pickupSound);
-	readSoundBlock(stream, _dropSound);
-	readSoundBlock(stream, _notebookSound);
-	readSoundBlock(stream, _putDownSound);
+	_pickupSound.readData(stream);
+	_dropSound.readData(stream);
+	_notebookSound.readData(stream);
+	_putDownSound.readData(stream);
 
 	_wrongIngredientFlag = stream.readSint16LE();
 	_solvedFlag = stream.readSint16LE();
@@ -299,8 +301,8 @@ void BuildPuzzle::readDataNancy17(Common::SeekableReadStream &stream) {
 	_failFlag.label = stream.readSint16LE();
 	_failFlag.flag = stream.readByte();
 
-	SoundDescription unused;
-	readSoundBlock(stream, unused);
+	RandomSoundBlock unused;
+	unused.readData(stream);
 
 	readExitHotspots(stream, _exitHotspots);
 	for (uint i = 0; i < _exitHotspots.size(); ++i) {
@@ -402,7 +404,9 @@ void BuildPuzzle::init() {
 			piece.placedSrcRect = piece.dragSrcRect;
 		}
 
-		piece.inUse = (i < _numDefined);
+		// Nancy18 leaves out the pieces whose flag isn't set, like clothes that aren't owned yet
+		piece.inUse = i < _numDefined &&
+			(piece.availableFlag == -1 || NancySceneState.getEventFlag(piece.availableFlag, g_nancy->_true));
 		piece.sourceID = (int16)i;
 		piece.liveRect = piece.destRect;
 		piece.setZOrder(_z + (uint16)i + 1);
@@ -725,8 +729,7 @@ void BuildPuzzle::pickUpPiece(int16 pieceIdx) {
 	_heldPiece = pieceIdx;
 	_pieces[pieceIdx].setZOrder((uint16)(_z + _pieces.size() + 1));
 
-	g_nancy->_sound->loadSound(_pickupSound);
-	g_nancy->_sound->playSound(_pickupSound);
+	playSoundBlock(_pickupSound);
 	updatePieceRender(pieceIdx);
 	saveBoard();
 }
@@ -813,8 +816,7 @@ void BuildPuzzle::placePiece(int16 pieceIdx, int16 zoneIdx, const Common::Point
 		NancySceneState.setEventFlag(piece.placedFlag, g_nancy->_true);
 	}
 
-	g_nancy->_sound->loadSound(_dropSound);
-	g_nancy->_sound->playSound(_dropSound);
+	playSoundBlock(_dropSound);
 
 	_heldPiece = -1;
 	_pieces[placedIdx].setZOrder((uint16)(_z + placedIdx + 1));
@@ -898,7 +900,7 @@ void BuildPuzzle::addItemValue(int16 itemID, int16 delta) {
 void BuildPuzzle::pressButton(HeldButton button) {
 	const Common::Rect &src = button == kSubmitButton ? _submitSrcRect : _startOverSrcRect;
 	const Common::Rect &dest = button == kSubmitButton ? _submitHotspot : _startOverHotspot;
-	SoundDescription &sound = button == kSubmitButton ? _submitSound : _startOverSound;
+	const RandomSoundBlock &sound = button == kSubmitButton ? _submitSound : _startOverSound;
 
 	// Both buttons draw their pressed art out of the main image.
 	if (!src.isEmpty()) {
@@ -908,8 +910,7 @@ void BuildPuzzle::pressButton(HeldButton button) {
 		_buttonPress.setVisible(true);
 	}
 
-	g_nancy->_sound->loadSound(sound);
-	g_nancy->_sound->playSound(sound);
+	playSoundBlock(sound);
 
 	_heldButton = button;
 	// Submit holds a little longer than the button that clears the board.
@@ -1484,8 +1485,7 @@ void BuildPuzzle::handleInput(NancyInput &input) {
 				_holds[i].setVisible(false);
 			}
 
-			g_nancy->_sound->loadSound(_pickupSound);
-			g_nancy->_sound->playSound(_pickupSound);
+			playSoundBlock(_pickupSound);
 			updateCursorItem(mouseVP);
 		}
 
@@ -1558,7 +1558,10 @@ bool BuildPuzzle::handleBoardInputNancy17(const Common::Point &mouseVP, bool cli
 			continue;
 		}
 
-		if (piece.locked || !piece.liveRect.contains(mouseVP)) {
+		// At home only a part of the piece may be there to click on
+		const bool isAtHome = piece.assignedZone == -1 && piece.liveRect == piece.destRect;
+		const Common::Rect &hotspot = isAtHome && !piece.homeHotspot.isEmpty() ? piece.homeHotspot : piece.liveRect;
+		if (piece.locked || !hotspot.contains(mouseVP)) {
 			continue;
 		}
 
@@ -1613,15 +1616,13 @@ bool BuildPuzzle::handleBoardInputNancy17(const Common::Point &mouseVP, bool cli
 			return true;
 		}
 
-		g_nancy->_sound->loadSound(_putDownSound);
-		g_nancy->_sound->playSound(_putDownSound);
+		playSoundBlock(_putDownSound);
 		returnPiece(_heldPiece);
 	}
 
 	if (hovered != -1) {
 		if (!_pieces[hovered].closeupSrcRect.isEmpty()) {
-			g_nancy->_sound->loadSound(_pickupSound);
-			g_nancy->_sound->playSound(_pickupSound);
+			playSoundBlock(_pickupSound);
 			openCloseup(hovered);
 		} else {
 			pickUpPiece(hovered);
diff --git a/engines/nancy/action/puzzle/buildpuzzle.h b/engines/nancy/action/puzzle/buildpuzzle.h
index 06f127a6de7..37096b51295 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.h
+++ b/engines/nancy/action/puzzle/buildpuzzle.h
@@ -133,6 +133,7 @@ protected:
 		Common::Rect srcRect;			// art while the piece sits at home
 		int16 startZone = -1;			// Nancy17: the zone the piece is in when the puzzle starts
 		Common::Rect destRect;			// where the piece is drawn
+		Common::Rect homeHotspot;		// Nancy18: the part of that to click on; empty means all of it
 		Common::Rect dragSrcRect;		// art while the piece is on the cursor; empty means srcRect
 		Common::Rect placedSrcRect;		// art once the piece is in a zone; empty means dragSrcRect
 		Common::Rect closeupSrcRect;
@@ -142,6 +143,7 @@ protected:
 		Common::String imageName;	// only when kind == 3
 		int16 zoneID = kAnyZone;	// the only zone this piece may go in, or a PieceZone
 		int16 itemID = 0;			// index into the shared item state, 255 = none
+		int16 availableFlag = -1;	// Nancy18: the piece is only there while this is set
 		int16 placedFlag = -1;		// Nancy17: set while the piece is in a zone
 		int16 inventoryItem = -1;	// Nancy17: holding this item is holding the piece
 		Common::Array<int16> holds;	// the scoops this piece can be taken with, empty = by hand
@@ -197,20 +199,20 @@ protected:
 	Common::Point _counterPos;
 	int32 _counterSpacing = 0;
 
-	SoundDescription _pickupSound;
-	SoundDescription _dropSound;
-	SoundDescription _notebookSound;
-	SoundDescription _putDownSound;
-	SoundDescription _holdSound;
+	RandomSoundBlock _pickupSound;
+	RandomSoundBlock _dropSound;
+	RandomSoundBlock _notebookSound;
+	RandomSoundBlock _putDownSound;
+	RandomSoundBlock _holdSound;
 
 	// Two on-screen buttons: one hands the puzzle in, one clears it. Only the
 	// parfait puzzle has them; the rest are handed in through an exit hotspot.
 	Common::Rect _submitSrcRect;
 	Common::Rect _submitHotspot;
-	SoundDescription _submitSound;
+	RandomSoundBlock _submitSound;
 	Common::Rect _startOverSrcRect;
 	Common::Rect _startOverHotspot;
-	SoundDescription _startOverSound;
+	RandomSoundBlock _startOverSound;
 
 	// Drawn over the zones once every one of them is full.
 	Common::Rect _doneSrcRect;


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

Commit Message:
NANCY: NANCY18: Mark the game as 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 19b5d0f2fb4..d349eb38bba 100644
--- a/engines/nancy/detection_tables.h
+++ b/engines/nancy/detection_tables.h
@@ -1003,7 +1003,7 @@ static const NancyGameDescription gameDescriptions[] = {
 			AD_ENTRY1s("ciftree.dat", "f414a802b64d3a9e28ba8e69e953a2b8", 50123969),
 			Common::EN_ANY,
 			Common::kPlatformWindows,
-			ADGF_UNSUPPORTED | ADGF_DROPPLATFORM,
+			ADGF_UNSTABLE | ADGF_DROPPLATFORM,
 			NANCY8_GUIOPTIONS
 		},
 		kGameTypeNancy18


Commit: bf5365fa6ac2b0118b8fa8db7cf9beb8a0b7e845
    https://github.com/scummvm/scummvm/commit/bf5365fa6ac2b0118b8fa8db7cf9beb8a0b7e845
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:22+03:00

Commit Message:
NANCY: NANCY16: Use the item names of the game in the console

- Nancy16 lists its inventory items next to the event flags, instead
  of in inventory data of their own
- get_inventory and set_inventory crashed for lack of that data
- The dependencies of action records show the names of the items too

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


diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index 8a82b4998f4..bcbc9ce2d86 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -529,12 +529,32 @@ static uint numPlayerCharacters() {
 	return pcui ? MIN<uint>(pcui->characters.size(), kMaxPlayerCharacters) : 1;
 }
 
+// The name of an inventory item, from wherever the game keeps it: the inventory
+// data, Nancy17's item table, or Nancy16's list of names next to the event flags
+static Common::String inventoryItemName(uint itemID) {
+	const INV *inventoryData = GetEngineData(INV);
+	if (inventoryData) {
+		return itemID < inventoryData->itemDescriptions.size() ? inventoryData->itemDescriptions[itemID].name : "Invalid";
+	}
+
+	const INVD *itemTable = GetEngineData(INVD);
+	if (itemTable) {
+		for (const INVD::Item &item : itemTable->items) {
+			if (item.id == (int16)itemID) {
+				return item.name;
+			}
+		}
+
+		return "Invalid";
+	}
+
+	const EVNT *names = (const EVNT *)g_nancy->getEngineData("EVNT");
+	return names && itemID < names->itemNames.size() ? names->itemNames[itemID] : "Invalid";
+}
+
 void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &record) {
 	using namespace Action;
 
-	// Nancy16 has no item descriptions
-	auto *inventoryData = GetEngineData(INV);
-
 	for (const DependencyRecord &dep : record.children) {
 		debugPrintf("\n\t\t");
 		switch (dep.type) {
@@ -544,9 +564,7 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
 		case DependencyType::kInventory:
 			debugPrintf("kInventory, item %u, %s, %s",
 				dep.label,
-				inventoryData && (uint16)dep.label < inventoryData->itemDescriptions.size() ?
-					inventoryData->itemDescriptions[dep.label].name.c_str() :
-					"Invalid",
+				inventoryItemName((uint16)dep.label).c_str(),
 				dep.condition == g_nancy->_true ? "true" : "false");
 
 			// Nancy15+ can ask about a character other than the one being played
@@ -601,9 +619,7 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
 		case DependencyType::kCursorType:
 			debugPrintf("kCursorType, item %u, %s",
 				dep.label,
-				inventoryData && (uint16)dep.label < inventoryData->itemDescriptions.size() ?
-					inventoryData->itemDescriptions[dep.label].name.c_str() :
-					"Invalid");
+				inventoryItemName((uint16)dep.label).c_str());
 			break;
 		case DependencyType::kPlayerTOD:
 			debugPrintf("kPlayerTOD, %s",
@@ -1108,14 +1124,14 @@ bool NancyConsole::Cmd_setEventFlags(int argc, const char **argv) {
 }
 
 void NancyConsole::printInventoryItem(uint itemID) {
-	auto *inventoryData = GetEngineData(INV);
-	assert(inventoryData);
+	debugPrintf("\nItem %u, %s", itemID, inventoryItemName(itemID).c_str());
 
-	byte keep = inventoryData->itemDescriptions[itemID].keepItem;
-	debugPrintf("\nItem %u, %s, %s",
-		itemID,
-		inventoryData->itemDescriptions[itemID].name.c_str(),
-		keep == 0 ? "UseThenLose" : keep == 1 ? "KeepAlways" : keep == 2 ? "ReturnToInventory" : "NewSceneView");
+	// Only the games with inventory data say what happens to a used item
+	auto *inventoryData = GetEngineData(INV);
+	if (inventoryData) {
+		byte keep = inventoryData->itemDescriptions[itemID].keepItem;
+		debugPrintf(", %s", keep == 0 ? "UseThenLose" : keep == 1 ? "KeepAlways" : keep == 2 ? "ReturnToInventory" : "NewSceneView");
+	}
 
 	uint numCharacters = numPlayerCharacters();
 	if (numCharacters == 1) {
@@ -1168,9 +1184,6 @@ bool NancyConsole::Cmd_getInventory(int argc, const char **argv) {
 }
 
 bool NancyConsole::Cmd_setInventory(int argc, const char **argv) {
-	auto *inventoryData = GetEngineData(INV);
-	assert(inventoryData);
-
 	if (g_nancy->getState() != NancyState::kScene) {
 		debugPrintf("Not in the kScene state\n");
 		return true;
@@ -1219,13 +1232,13 @@ bool NancyConsole::Cmd_setInventory(int argc, const char **argv) {
 			NancySceneState.addItemToCharacterInventory(characterIndex, itemID);
 			debugPrintf("Added item %i, %s, to %s\n",
 				itemID,
-				inventoryData->itemDescriptions[itemID].name.c_str(),
+				inventoryItemName(itemID).c_str(),
 				targetDescription.c_str());
 		} else if (Common::String(argv[i + 1]).compareTo("false") == 0) {
 			NancySceneState.removeItemFromCharacterInventory(characterIndex, itemID);
 			debugPrintf("Removed item %i, %s, from %s\n",
 				itemID,
-				inventoryData->itemDescriptions[itemID].name.c_str(),
+				inventoryItemName(itemID).c_str(),
 				targetDescription.c_str());
 		} else {
 			debugPrintf("Invalid value %s\n", argv[i + 1]);
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 591eefa8819..61fb001b0ba 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1410,6 +1410,7 @@ EVNT::EVNT(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	Common::Array<Common::String> names(numRecords);
 	Common::Array<uint16> ids(numRecords);
 	uint numFlags = 0;
+	uint numItems = 0;
 
 	for (uint16 i = 0; i < numRecords; ++i) {
 		readFilename(*chunkStream, names[i]);
@@ -1417,14 +1418,19 @@ EVNT::EVNT(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 
 		if (ids[i] >= kFirstEventFlag) {
 			numFlags = MAX<uint>(numFlags, ids[i] - kFirstEventFlag + 1);
+		} else {
+			numItems = MAX<uint>(numItems, ids[i] + 1);
 		}
 	}
 
 	eventFlagNames.resize(numFlags);
+	itemNames.resize(numItems);
 
 	for (uint16 i = 0; i < numRecords; ++i) {
 		if (ids[i] >= kFirstEventFlag) {
 			eventFlagNames[ids[i] - kFirstEventFlag] = names[i];
+		} else {
+			itemNames[ids[i]] = names[i];
 		}
 	}
 }
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index b0ccb350076..5591097a7a4 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -886,6 +886,9 @@ struct EVNT : public EngineData {
 	// always follow the ids: Nancy13 and up list some flags twice or out of order,
 	// and Nancy16 starts with the inventory items, which have ids of their own.
 	Common::Array<Common::String> eventFlagNames;
+
+	// Nancy16: the names of the inventory items, indexed by item id
+	Common::Array<Common::String> itemNames;
 };
 
 // UI overlay element table. Introduced in Nancy 12. Each record describes one UI


Commit: ef56198d6c42c08c764b1aa6476684468fa87b27
    https://github.com/scummvm/scummvm/commit/ef56198d6c42c08c764b1aa6476684468fa87b27
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:24+03:00

Commit Message:
NANCY: Make time and value dependencies readable in the console

- Time ranges are shown as a start and an end time
- Nancy16 value tests show both values, and what they hold right now

Changed paths:
    engines/nancy/console.cpp


diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index bcbc9ce2d86..b5955675eb4 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -600,12 +600,15 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
 				dep.milliseconds);
 			break;
 		case DependencyType::kElapsedPlayerTime:
-			debugPrintf("kPlayerTime, player time %s %i hours, %i minutes, %i seconds, %i milliseconds",
-				dep.condition == 0 ? "at or after" : (dep.condition == 1 ? "at or before" : (dep.condition == 2 ? "equals" : "between")),
-				dep.hours,
-				dep.minutes,
-				dep.seconds,
-				dep.milliseconds);
+			if (dep.condition == 3) {
+				// A range keeps its end in the seconds and milliseconds fields
+				debugPrintf("kPlayerTime, player time between %i:%02i and %i:%02i",
+					dep.hours, dep.minutes, dep.seconds, dep.milliseconds);
+			} else {
+				debugPrintf("kPlayerTime, player time %s %i:%02i",
+					dep.condition == 0 ? "at or after" : (dep.condition == 1 ? "at or before" : "equals"),
+					dep.hours, dep.minutes);
+			}
 			break;
 		case DependencyType::kSceneCount:
 			debugPrintf("kSceneCount, scene ID %i, hit count %s %i",
@@ -629,9 +632,24 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
 			if (g_nancy->getGameType() >= kGameTypeNancy14) {
 				// Repurposed as a value-table test in Nancy14
 				static const char *const comparisons[] = { "==", ">", ">=", "<", "<=" };
-				debugPrintf("kValueTest, value %u %s %i", dep.label,
-					dep.condition < ARRAYSIZE(comparisons) ? comparisons[dep.condition] : "?",
-					dep.milliseconds);
+				const char *comparison = dep.condition < ARRAYSIZE(comparisons) ? comparisons[dep.condition] : "?";
+				const TableData *table = State::Scene::hasInstance() ?
+					(const TableData *)NancySceneState.getPuzzleData(TableData::getTag()) : nullptr;
+
+				if (g_nancy->getGameType() >= kGameTypeNancy16 && table) {
+					// Nancy16 compares against another value when the hours field is 1; values
+					// are indexed with a byte, and the last one is the day. Show what they hold.
+					if (dep.hours == 1) {
+						debugPrintf("kValueTest, value %u (now %i) %s value %u (now %i)", (byte)dep.label,
+							table->getValueOrZero((byte)dep.label), comparison,
+							(byte)dep.milliseconds, table->getValueOrZero((byte)dep.milliseconds));
+					} else {
+						debugPrintf("kValueTest, value %u (now %i) %s %i", (byte)dep.label,
+							table->getValueOrZero((byte)dep.label), comparison, dep.milliseconds);
+					}
+				} else {
+					debugPrintf("kValueTest, value %u %s %i", dep.label, comparison, dep.milliseconds);
+				}
 			} else {
 				debugPrintf("kTimerLessThanDependencyTime");
 			}


Commit: a81013b721fc548010df46aa553c89524037f520
    https://github.com/scummvm/scummvm/commit/a81013b721fc548010df46aa553c89524037f520
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:25+03:00

Commit Message:
NANCY: NANCY16: Start the next day when the clock is moved past midnight

Bumping the clock by an amount of time, like the alarm clock does, did
not advance the day, so the meals of the next day never became due
and the kitchen stayed closed (scene 3546)

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index fea41a13f20..bbef948ebf3 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -391,7 +391,14 @@ void Scene::setPlayerTime(Time time, byte relative) {
 			return;
 		}
 
+		uint16 day = _timers.playerTime.getDays();
 		_timers.playerTime += time;
+
+		// Moving the clock past midnight starts the next day, like the clock getting
+		// there on its own, e.g. when the alarm clock wakes Nancy up in the morning
+		if (g_nancy->getGameType() >= kGameTypeNancy16 && _timers.playerTime.getDays() != day) {
+			advanceDayValue();
+		}
 	} else if (bootSummary->endOfDayFlag != kEvNoEvent) {
 		// Absolute, the clock only holds the time of the current day
 		_timers.playerTime = time;


Commit: 729a7f26f5be2caae20b44275d3da34cf2df70c0
    https://github.com/scummvm/scummvm/commit/729a7f26f5be2caae20b44275d3da34cf2df70c0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:27+03:00

Commit Message:
NANCY: NANCY18: Add the mosaic variant of the PaintPuzzle

Nancy18 reuses the record for a mosaic that gets filled with colored
gems, with a different data layout. Each area is a rectangle that shows
the gem placed in it, the gem being carried is drawn next to the
cursor, and the numbers of empty and of correctly filled areas are kept
in the value table for the scenes to check.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 9070ad4bd64..8a78e036677 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -721,10 +721,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return nullptr;
 	case 181:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new PaintPuzzle();
-		else
-			return nullptr;
+		return new PaintPuzzle();
 	case 182:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new DecoderPuzzle();
diff --git a/engines/nancy/action/puzzle/paintpuzzle.cpp b/engines/nancy/action/puzzle/paintpuzzle.cpp
index 7547a564da8..9e1f4970727 100644
--- a/engines/nancy/action/puzzle/paintpuzzle.cpp
+++ b/engines/nancy/action/puzzle/paintpuzzle.cpp
@@ -36,7 +36,74 @@
 namespace Nancy {
 namespace Action {
 
+void PaintPuzzle::readDataNancy18(Common::SeekableReadStream &stream) {
+	_hasRegionSprites = true;
+
+	stream.skip(33);	// Name of the puzzle
+	readFilename(stream, _imageName);
+	readFilename(stream, _objectsImageName);
+	_heldCursorType = stream.readSint16LE();
+	_hoverCursorType = stream.readSint16LE();
+	_offset.x = stream.readSint32LE();
+	_offset.y = stream.readSint32LE();
+	readExclusiveRect(stream, _canvasRect);
+	_unpaintedCountIndex = stream.readSint16LE();
+	_correctCountIndex = stream.readSint16LE();
+
+	_colors.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (PaintColor &color : _colors) {
+		// The sprite of a color gets multiplied by it
+		stream.skip(1);
+		color.r = stream.readByte();
+		color.g = stream.readByte();
+		color.b = stream.readByte();
+		readExclusiveRect(stream, color.swatchRect);
+		readExclusiveRect(stream, color.fillRect);
+		readExclusiveRect(stream, color.objectRect);
+		color.flagLabel = stream.readSint16LE();
+	}
+
+	// Set for a puzzle that is still being laid out, which has no colors yet
+	const bool hasNoRegionColors = stream.readByte();
+
+	_regions.resize(stream.readUint16LE());
+	for (PaintRegion &region : _regions) {
+		readExclusiveRect(stream, region.rect);
+
+		// Colors are counted from 0 here, and -1 is a region without a color,
+		// which some regions have to stay
+		if (!hasNoRegionColors) {
+			int16 currentColor = stream.readSint16LE();
+			int16 targetColor = stream.readSint16LE();
+			region.currentColor = currentColor < 0 ? -1 : currentColor + 1;
+			region.targetColor = targetColor < 0 ? -1 : targetColor + 1;
+		}
+	}
+
+	_unorderedSolution = stream.readByte();
+	_paintOnce = stream.readByte();
+	stream.skip(1);	// Input outside the canvas rect
+
+	_sounds[0].readData(stream);
+	_sounds[1].readData(stream);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+
+	_solveSoundBlock.readData(stream);
+
+	readExitHotspots(stream, _exitHotspots);
+}
+
 void PaintPuzzle::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy18) {
+		readDataNancy18(stream);
+		return;
+	}
+
 	const bool isNancy15 = g_nancy->getGameType() >= kGameTypeNancy15;
 
 	readFilename(stream, _imageName);		// 0x3d
@@ -127,9 +194,35 @@ void PaintPuzzle::init() {
 	_solveHandled = false;
 	_takenExit = -1;
 
+	updateRegionCounts();
 	redraw();
 }
 
+void PaintPuzzle::updateRegionCounts() {
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	if (!table) {
+		return;
+	}
+
+	int16 numUnpainted = 0;
+	int16 numCorrect = 0;
+	for (const PaintRegion &region : _regions) {
+		if (region.currentColor == region.targetColor) {
+			++numCorrect;
+		} else if (region.currentColor <= 0) {
+			++numUnpainted;
+		}
+	}
+
+	if (_unpaintedCountIndex != kNoTableValueIndex) {
+		table->setSingleValue(_unpaintedCountIndex, numUnpainted);
+	}
+
+	if (_correctCountIndex != kNoTableValueIndex) {
+		table->setSingleValue(_correctCountIndex, numCorrect);
+	}
+}
+
 int PaintPuzzle::colorSwatchAtCursor(const Common::Point &mousePos) const {
 	for (uint i = 0; i < _colors.size(); ++i) {
 		if (_colors[i].swatchRect.isEmpty()) {
@@ -165,6 +258,11 @@ int PaintPuzzle::regionAtCursor(const Common::Point &mousePos) const {
 		if (!screenRect.contains(mousePos)) {
 			continue;
 		}
+
+		if (_hasRegionSprites) {
+			return (int)i;
+		}
+
 		// Hit-test the region's actual shape, not just its bounding rect, so
 		// overlapping bounding boxes don't register clicks for the wrong region.
 		if (shapeAlpha(_regionImages[i], mousePos.x - screenRect.left, mousePos.y - screenRect.top) != 0) {
@@ -182,6 +280,11 @@ void PaintPuzzle::drawRegion(uint regionIndex) {
 		return;
 	}
 
+	if (_hasRegionSprites) {
+		drawSprite(_objectsImage, _colors[c].fillRect, Common::Point(region.rect.left, region.rect.top), &_colors[c]);
+		return;
+	}
+
 	const Graphics::ManagedSurface &img = _regionImages[regionIndex];
 	if (img.w == 0 || img.h == 0) {
 		return;
@@ -207,11 +310,14 @@ void PaintPuzzle::drawRegion(uint regionIndex) {
 
 // Draws a sprite from img, skipping transparent pixels and compositing
 // anti-aliased edges over what is already drawn.
-void PaintPuzzle::drawSprite(const Graphics::ManagedSurface &img, const Common::Rect &src, const Common::Point &dest) {
+void PaintPuzzle::drawSprite(const Graphics::ManagedSurface &img, const Common::Rect &src, const Common::Point &dest, const PaintColor *tint) {
 	if (img.w == 0 || src.isEmpty()) {
 		return;
 	}
 
+	// Up to Nancy15 the images also have a color that stands for transparent.
+	// The later ones only have their alpha channel, and black is a color like any other.
+	const bool hasColorKey = g_nancy->getGameType() <= kGameTypeNancy15;
 	byte tr, tg, tb;
 	g_nancy->_graphics->getInputPixelFormat().colorToRGB(g_nancy->_graphics->getTransColor(), tr, tg, tb);
 
@@ -229,10 +335,16 @@ void PaintPuzzle::drawSprite(const Graphics::ManagedSurface &img, const Common::
 
 			byte a, r, g, b;
 			img.format.colorToARGB(img.getPixel(src.left + x, src.top + y), a, r, g, b);
-			if (a == 0 || (r == tr && g == tg && b == tb)) {
+			if (a == 0 || (hasColorKey && r == tr && g == tg && b == tb)) {
 				continue;
 			}
 
+			if (tint) {
+				r = r * tint->r / 255;
+				g = g * tint->g / 255;
+				b = b * tint->b / 255;
+			}
+
 			blendPixel(_drawSurface, dx, dy, a, r, g, b);
 		}
 	}
@@ -257,8 +369,8 @@ void PaintPuzzle::drawBrush() {
 
 	// The sprite is centered on the cursor, then shifted by the offset.
 	const Common::Rect &src = _colors[_heldColor].fillRect;
-	drawSprite(_image, src, Common::Point(_brushPos.x - src.width() / 2 - _offset.x,
-		_brushPos.y - src.height() / 2 - _offset.y));
+	drawSprite(_hasRegionSprites ? _objectsImage : _image, src, Common::Point(_brushPos.x - src.width() / 2 - _offset.x,
+		_brushPos.y - src.height() / 2 - _offset.y), _hasRegionSprites ? &_colors[_heldColor] : nullptr);
 }
 
 void PaintPuzzle::redraw() {
@@ -333,6 +445,7 @@ void PaintPuzzle::paintRegion(uint regionIndex, int colorIndex) {
 	_regions[regionIndex].currentColor = (int16)(colorIndex + 1);
 	playSoundBlock(_sounds[1]);
 	updateColorFlags();
+	updateRegionCounts();
 	if (isSolved()) {
 		_solved = true;
 	}
@@ -358,7 +471,12 @@ void PaintPuzzle::handleInput(NancyInput &input) {
 			redraw();
 		}
 
-		g_nancy->_cursor->setCursorType(CursorManager::kNancy13Blank, true, false);
+		if (_heldCursorType == -1) {
+			g_nancy->_cursor->setCursorType(CursorManager::kNancy13Blank, true, false);
+		} else {
+			// Nancy18 keeps a cursor next to the sprite, lit up over a region that can be painted
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_heldCursorType, true, regionAtCursor(input.mousePos) >= 0);
+		}
 	}
 
 	// Exit hotspots: leave the puzzle. In Nancy15 they only work with empty hands,
diff --git a/engines/nancy/action/puzzle/paintpuzzle.h b/engines/nancy/action/puzzle/paintpuzzle.h
index e795a59f1f5..1c05efcd23b 100644
--- a/engines/nancy/action/puzzle/paintpuzzle.h
+++ b/engines/nancy/action/puzzle/paintpuzzle.h
@@ -35,8 +35,13 @@ namespace Action {
 // its target color. A filled region is its shape recolored to the chosen color.
 // Nancy15 adds per-color objects (paint jars) drawn at the swatches, per-color
 // event flags, order-independent solutions and single-paint regions.
+// Nancy18 uses it for a mosaic of gems: a region is a plain rectangle that gets
+// the sprite of its color, and the number of correct and of empty regions is
+// kept in the value table.
 class PaintPuzzle : public PuzzleRecord {
 public:
+	static const int16 kNoTableValueIndex = -4;
+
 	PaintPuzzle() : PuzzleRecord(7) {}
 	virtual ~PaintPuzzle() {}
 
@@ -86,16 +91,20 @@ protected:
 	void paintRegion(uint regionIndex, int colorIndex);
 	void dropColor();
 	void drawRegion(uint regionIndex);
-	void drawSprite(const Graphics::ManagedSurface &img, const Common::Rect &src, const Common::Point &dest);
+	void drawSprite(const Graphics::ManagedSurface &img, const Common::Rect &src, const Common::Point &dest, const PaintColor *tint = nullptr);
 	void drawObjects();
 	void drawBrush();
 	void updateColorFlags();
 	void redraw();
 	bool isSolved() const;
 
+	void readDataNancy18(Common::SeekableReadStream &stream);
+	void updateRegionCounts();
+
 	// -- File data --
 	Common::Path _objectsImageName;
 	uint16 _hoverCursorType = 0;	// 0x5e - raw Nancy14 cursor type, shown over a color swatch
+	int16 _heldCursorType = -1;		// Nancy18, shown while a color is held
 	Common::Point _offset;			// 0x60 (two int32) - brush sprite offset from the cursor
 	Common::Rect _canvasRect;		// 0x68
 
@@ -104,6 +113,12 @@ protected:
 
 	bool _unorderedSolution = false;	// Region targets may be matched in any order
 	bool _paintOnce = false;			// A painted region can't be repainted
+	bool _hasRegionSprites = false;		// Nancy18: regions show the sprite of their color
+
+	// Nancy18: value table entries that get the number of unpainted regions,
+	// and of regions with the right color
+	int16 _unpaintedCountIndex = kNoTableValueIndex;
+	int16 _correctCountIndex = kNoTableValueIndex;
 
 	RandomSoundBlock _sounds[2];	// 0xa4/0xfa
 


Commit: c61e107e358a272965440722801ee8768c7087a0
    https://github.com/scummvm/scummvm/commit/c61e107e358a272965440722801ee8768c7087a0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:28+03:00

Commit Message:
NANCY: NANCY18: Add the TypewriterText record

It types a text into a rectangle, a few characters at a time, with
the sound of a key for each step.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 8a78e036677..db12c904563 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -33,6 +33,7 @@
 #include "engines/nancy/action/overlay.h"
 #include "engines/nancy/action/secondaryvideo.h"
 #include "engines/nancy/action/secondarymovie.h"
+#include "engines/nancy/action/typewritertext.h"
 #include "engines/nancy/action/wolfcommands.h"
 
 #include "engines/nancy/action/puzzle/adjustpuzzle.h"
@@ -816,7 +817,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new GoldbergPuzzle();
 	case 209:
-		return new TurningPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new TurningPuzzle();
+		else
+			return new TypewriterText();
 	case 210:
 		return new SafeDialPuzzle();
 	case 211:
diff --git a/engines/nancy/action/typewritertext.cpp b/engines/nancy/action/typewritertext.cpp
new file mode 100644
index 00000000000..8036dba7d95
--- /dev/null
+++ b/engines/nancy/action/typewritertext.cpp
@@ -0,0 +1,146 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/enginedata.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/misc/hypertext.h"
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/typewritertext.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint32 kMsPerTick = 20;
+static const double kTicksPerSecond = 50.0;
+
+class TypewriterTextRenderer : public Misc::HypertextParser {
+public:
+	void render(const Common::String &text, uint fontID, uint colorID, Graphics::ManagedSurface &image);
+};
+
+void TypewriterTextRenderer::render(const Common::String &text, uint fontID, uint colorID, Graphics::ManagedSurface &image) {
+	_fullSurface.create(image.w, image.h, image.format);
+	_fullSurface.clear(g_nancy->_graphics->getTransColor());
+	_defaultTextColor = colorID;
+
+	addTextLine(text);
+	drawAllText(_fullSurface.getBounds(), 0, fontID, fontID);
+
+	image.blitFrom(_fullSurface, Common::Point());
+}
+
+void TypewriterText::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _textKey);
+	readExclusiveRect(stream, _bounds);
+	_charactersPerSecond = stream.readDoubleLE();
+	_fontID = stream.readUint32LE();
+	_colorID = stream.readUint32LE();
+	_keySound.readData(stream);
+
+	_doneFlag.label = stream.readSint16LE();
+	_doneFlag.flag = stream.readByte();
+}
+
+void TypewriterText::init() {
+	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+	assert(autotext);
+	_text = autotext->texts.getValOrDefault(_textKey);
+
+	_drawSurface.create(_bounds.width(), _bounds.height(), g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(_bounds);
+
+	_lastTickTime = g_nancy->getTotalPlayTime();
+}
+
+// A tag gets written in one go, and doesn't count as a character
+uint TypewriterText::typeCharacters(uint numCharacters) {
+	uint numAdded = 0;
+	bool isInsideTag = false;
+
+	while ((numAdded < numCharacters || isInsideTag) && _numTypedCharacters < _text.size()) {
+		char c = _text[_numTypedCharacters];
+		if (c == '<') {
+			isInsideTag = true;
+		} else if (c == '>') {
+			isInsideTag = false;
+		}
+
+		++_numTypedCharacters;
+		++numAdded;
+	}
+
+	return numAdded;
+}
+
+void TypewriterText::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	TypewriterTextRenderer renderer;
+	renderer.render(_text.substr(0, _numTypedCharacters), _fontID, _colorID, _drawSurface);
+
+	_needsRedraw = true;
+}
+
+void TypewriterText::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+		uint32 ticks = (now - _lastTickTime) / kMsPerTick;
+		_lastTickTime += ticks * kMsPerTick;
+		_pendingTicks += ticks;
+
+		// The time that passed only gets used up once it is worth a character
+		uint numCharacters = (uint)(_pendingTicks * _charactersPerSecond / kTicksPerSecond);
+		if (numCharacters > 0) {
+			_pendingTicks = 0;
+
+			if (typeCharacters(numCharacters) > 0) {
+				_keySound.play();
+				redraw();
+			}
+
+			if (_numTypedCharacters >= _text.size()) {
+				_state = kActionTrigger;
+			}
+		}
+
+		break;
+	}
+	case kActionTrigger:
+		// The text stays up for as long as the scene does
+		NancySceneState.setEventFlag(_doneFlag);
+		_isDone = true;
+		break;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/typewritertext.h b/engines/nancy/action/typewritertext.h
new file mode 100644
index 00000000000..ddd531b3c5b
--- /dev/null
+++ b/engines/nancy/action/typewritertext.h
@@ -0,0 +1,70 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_TYPEWRITERTEXT_H
+#define NANCY_ACTION_TYPEWRITERTEXT_H
+
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy18. Writes an AUTOTEXT entry into a rectangle a few characters at a time,
+// with a key sound for every batch. An event flag gets set once it is all there.
+class TypewriterText : public RenderActionRecord {
+public:
+	TypewriterText() : RenderActionRecord(7) {}
+	virtual ~TypewriterText() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "TypewriterText"; }
+
+	// Returns how many characters got added
+	uint typeCharacters(uint numCharacters);
+	void redraw();
+
+	// -- File data --
+	Common::String _textKey;
+	Common::Rect _bounds;
+	double _charactersPerSecond = 0;
+	uint32 _fontID = 0;
+	uint32 _colorID = 0;
+	RandomSoundBlock _keySound;
+	FlagDescription _doneFlag;
+
+	// -- Runtime state --
+	Common::String _text;
+	uint _numTypedCharacters = 0;
+	uint32 _lastTickTime = 0;
+	uint32 _pendingTicks = 0;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_TYPEWRITERTEXT_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 39ea1d4839b..2fd7ba5a24f 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -20,6 +20,7 @@ MODULE_OBJS = \
   action/physicalobject.o \
   action/puzzlerecord.o \
   action/secondarymovie.o \
+  action/typewritertext.o \
   action/secondaryvideo.o \
   action/wolfcommands.o \
   action/puzzle/adjustpuzzle.o \


Commit: e66f7a9565138056dc0cb3d6ec95366d18e44cc0
    https://github.com/scummvm/scummvm/commit/e66f7a9565138056dc0cb3d6ec95366d18e44cc0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:29+03:00

Commit Message:
NANCY: NANCY18: Add the ChessChatPuzzle

The player types chess moves into a chat window. The opponent
answers with moves of its own, and the letters the moves stand for
spell out words it reacts to.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index db12c904563..9bdccb8b84f 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -51,6 +51,7 @@
 #include "engines/nancy/action/puzzle/bombpuzzle.h"
 #include "engines/nancy/action/puzzle/booksortpuzzle.h"
 #include "engines/nancy/action/puzzle/cardgamepuzzle.h"
+#include "engines/nancy/action/puzzle/chesschatpuzzle.h"
 #include "engines/nancy/action/puzzle/collisionpuzzle.h"
 #include "engines/nancy/action/puzzle/cookingpuzzle.h"
 #include "engines/nancy/action/puzzle/cubepuzzle.h"
@@ -746,6 +747,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new FoxGeesePuzzle();
 	case 187:	// Nancy17
 		return new DialSequencePuzzle();
+	case 188: // Nancy18
+		return new ChessChatPuzzle();
 	case 189:
 		return new QuizPuzzle();		// Moved from 231
 	case 192:
@@ -760,7 +763,6 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new AnalogClock();
 	case 197:
 		return new AutotextScroll();	// Used for the end credits
-	case 188:	// Empty in Nancy16, added for completeness
 	case 190:	// MemoryPuzzle, moved from 238, unused in Nancy16
 	case 191:	// BeadPuzzle, moved from 243, unused in Nancy16
 		return nullptr;
diff --git a/engines/nancy/action/puzzle/chesschatpuzzle.cpp b/engines/nancy/action/puzzle/chesschatpuzzle.cpp
new file mode 100644
index 00000000000..9c111088815
--- /dev/null
+++ b/engines/nancy/action/puzzle/chesschatpuzzle.cpp
@@ -0,0 +1,347 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/misc/hypertext.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/chesschatpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// The fonts of the chat itself aren't in the record
+static const uint kChatFontID = 1;
+static const uint kChatColorID = 1;
+
+static const uint32 kCaretBlinkTime = 500;
+
+// Upper limit for the height of the rendered chat
+static const uint kMaxChatHeight = 2000;
+
+class ChessChatRenderer : public Misc::HypertextParser {
+public:
+	// Returns the height of the text
+	uint render(const Common::String &text, uint width, uint maxHeight, uint fontID, uint colorID, Graphics::ManagedSurface &image);
+};
+
+uint ChessChatRenderer::render(const Common::String &text, uint width, uint maxHeight, uint fontID, uint colorID, Graphics::ManagedSurface &image) {
+	_fullSurface.create(width, maxHeight, g_nancy->_graphics->getInputPixelFormat());
+	_fullSurface.clear(g_nancy->_graphics->getTransColor());
+	_defaultTextColor = colorID;
+
+	addTextLine(text);
+	drawAllText(_fullSurface.getBounds(), 0, fontID, fontID);
+
+	uint height = CLIP<uint>(_drawnTextHeight, 1, maxHeight);
+	image.create(width, height, _fullSurface.format);
+	image.blitFrom(_fullSurface, Common::Rect(width, height), Common::Point());
+	image.setTransparentColor(g_nancy->_graphics->getTransColor());
+
+	return height;
+}
+
+// The record names the AUTOTEXT entries of its texts
+Common::String ChessChatPuzzle::readText(Common::SeekableReadStream &stream) {
+	Common::String key;
+	readFilename(stream, key);
+
+	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+	assert(autotext);
+	return autotext->texts.getValOrDefault(key);
+}
+
+void ChessChatPuzzle::readData(Common::SeekableReadStream &stream) {
+	stream.skip(33);	// A name, empty in the game data
+	_caret = stream.readByte();
+	_maxTypedLength = stream.readUint32LE();
+
+	_wrongKeySound.readData(stream);
+	_sendSound.readData(stream);
+	_replySound.readData(stream);
+
+	readExclusiveRect(stream, _chatRect);
+	readExclusiveRect(stream, _typingRect);
+	_typingFontID = stream.readSint16LE();
+	_typingColorID = stream.readSint16LE();
+	_minReplyDelay = stream.readSint32LE();
+	_maxReplyDelay = stream.readSint32LE();
+	readFilename(stream, _waitCharacters);
+	_waitCharactersPerSecond = stream.readSint32LE();
+	_endFlag = stream.readSint16LE();
+
+	_playerPrefix = readText(stream);
+	_opponentPrefix = readText(stream);
+	_greeting = readText(stream);
+
+	_moveCharacters.resize(stream.readUint16LE());
+	for (Common::String &characters : _moveCharacters) {
+		characters = readText(stream);
+	}
+
+	_confusedReplies.resize(stream.readUint16LE());
+	for (Common::String &reply : _confusedReplies) {
+		reply = readText(stream);
+	}
+
+	_letterSound.readData(stream);
+
+	_letterGrid.resize(stream.readUint16LE());
+	for (Common::String &row : _letterGrid) {
+		row = readText(stream);
+	}
+
+	_maxWordLength = stream.readUint32LE();
+
+	// One more list of texts, which nothing was found to use
+	stream.skip(stream.readUint16LE() * 33);
+
+	_words.resize(stream.readUint16LE());
+	for (Word &word : _words) {
+		word.word = readText(stream);
+		word.reply = readText(stream);
+		word.flag = stream.readSint16LE();
+	}
+}
+
+void ChessChatPuzzle::init() {
+	initViewportSurface();
+
+	_chat = _opponentPrefix + _greeting;
+	_nextCaretTime = g_nancy->getTotalPlayTime() + kCaretBlinkTime;
+
+	g_nancy->_input->setVKEnabled(true);
+	redraw();
+}
+
+void ChessChatPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	// The newest lines of the chat stay in view
+	ChessChatRenderer renderer;
+	Graphics::ManagedSurface chatImage;
+	const int height = renderer.render(_chat, _chatRect.width(), kMaxChatHeight, kChatFontID, kChatColorID, chatImage);
+	const int top = MAX<int>(height - _chatRect.height(), 0);
+	_drawSurface.blitFrom(chatImage, Common::Rect(0, top, chatImage.w, MIN<int>(height, top + _chatRect.height())), _chatRect.origin());
+
+	Common::String typing = _typed;
+	if (_isWaitingForReply) {
+		if (_shownWaitCharacter) {
+			typing = Common::String(_shownWaitCharacter);
+		}
+	} else if (_isCaretShown) {
+		typing += _caret;
+	}
+
+	if (!typing.empty()) {
+		ChessChatRenderer typingRenderer;
+		Graphics::ManagedSurface typingImage;
+		typingRenderer.render(typing, _typingRect.width(), _typingRect.height(), _typingFontID, _typingColorID, typingImage);
+		_drawSurface.blitFrom(typingImage, Common::Rect(typingImage.w, MIN<int>(typingImage.h, _typingRect.height())), _typingRect.origin());
+	}
+
+	_needsRedraw = true;
+}
+
+// The sound of a letter is named after it
+void ChessChatPuzzle::sayLetter(char letter) {
+	if (_letterSound.names.empty()) {
+		return;
+	}
+
+	SoundDescription sound;
+	sound.name = _letterSound.names[0] + letter;
+	sound.channelID = _letterSound.channel;
+	sound.numLoops = MAX<int32>(_letterSound.numLoops, 1);
+	sound.volume = _letterSound.volume;
+
+	g_nancy->_sound->loadSound(sound);
+	g_nancy->_sound->playSound(sound);
+	showSubtitle(resolveSubtitleText(sound.name));
+}
+
+Common::String ChessChatPuzzle::getReply(const Common::String &move) {
+	const uint kPiece = 0;
+	const uint kFile = 1;
+	const uint kRank = 2;
+	const uint kMoveLength = 3;
+
+	bool isMove = move.size() == kMoveLength && _moveCharacters.size() >= kMoveLength;
+	size_t file = 0;
+	size_t rank = 0;
+
+	if (isMove) {
+		file = _moveCharacters[kFile].findFirstOf(move[kFile]);
+		rank = _moveCharacters[kRank].findFirstOf(move[kRank]);
+		isMove = _moveCharacters[kPiece].findFirstOf(move[kPiece]) != Common::String::npos &&
+				file != Common::String::npos && rank != Common::String::npos &&
+				rank < _letterGrid.size() && file < _letterGrid[rank].size();
+	}
+
+	if (isMove) {
+		const char letter = _letterGrid[rank][file];
+		_word += letter;
+
+		if (_word.size() <= _maxWordLength) {
+			sayLetter(letter);
+
+			for (const Word &word : _words) {
+				if (_word.equalsIgnoreCase(word.word)) {
+					_pendingFlag = word.flag;
+					return word.reply;
+				}
+			}
+
+			// A move made up at random
+			Common::String reply;
+			for (const Common::String &characters : _moveCharacters) {
+				if (!characters.empty()) {
+					reply += characters[g_nancy->_randomSource->getRandomNumber(characters.size() - 1)];
+				}
+			}
+
+			return reply;
+		}
+
+		_pendingFlag = _endFlag;
+	}
+
+	if (_confusedReplies.empty()) {
+		return Common::String();
+	}
+
+	return _confusedReplies[g_nancy->_randomSource->getRandomNumber(_confusedReplies.size() - 1)];
+}
+
+void ChessChatPuzzle::sendMove() {
+	_pendingReply = getReply(_typed);
+	playSoundBlock(_sendSound);
+
+	_chat += _playerPrefix + _typed;
+	_typed.clear();
+
+	_isWaitingForReply = true;
+	_shownWaitCharacter = 0;
+	_waitStartTime = g_nancy->getTotalPlayTime();
+	_replyTime = _waitStartTime + _minReplyDelay;
+	if (_maxReplyDelay > _minReplyDelay) {
+		_replyTime += g_nancy->_randomSource->getRandomNumber(_maxReplyDelay - _minReplyDelay - 1);
+	}
+
+	redraw();
+}
+
+void ChessChatPuzzle::showReply() {
+	_chat += _opponentPrefix + _pendingReply;
+	playSoundBlock(_replySound);
+	_isWaitingForReply = false;
+
+	if (_pendingFlag != -1) {
+		NancySceneState.setEventFlag(_pendingFlag, g_nancy->_true);
+		_pendingFlag = -1;
+	}
+
+	redraw();
+}
+
+void ChessChatPuzzle::typeKey(const Common::KeyState &key) {
+	if (key.keycode == Common::KEYCODE_BACKSPACE) {
+		if (!_typed.empty()) {
+			_typed.deleteLastChar();
+		}
+	} else if (key.keycode == Common::KEYCODE_RETURN || key.keycode == Common::KEYCODE_KP_ENTER) {
+		sendMove();
+		return;
+	} else if (key.ascii < 0x80 && Common::isAlnum(key.ascii) && _typed.size() < _maxTypedLength) {
+		_typed += (char)key.ascii;
+	} else {
+		playSoundBlock(_wrongKeySound);
+		return;
+	}
+
+	redraw();
+}
+
+void ChessChatPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		const uint32 now = g_nancy->getTotalPlayTime();
+
+		if (_isWaitingForReply) {
+			if (now > _replyTime) {
+				showReply();
+			} else if (!_waitCharacters.empty()) {
+				const uint frame = (now - _waitStartTime) * _waitCharactersPerSecond / 1000;
+				const char character = _waitCharacters[frame % _waitCharacters.size()];
+				if (character != _shownWaitCharacter) {
+					_shownWaitCharacter = character;
+					redraw();
+				}
+			}
+
+			// The chat is over once its flag has been set
+			if (NancySceneState.getEventFlag(_endFlag, g_nancy->_true)) {
+				_isCaretShown = false;
+				redraw();
+				_state = kActionTrigger;
+			}
+		} else if (now >= _nextCaretTime) {
+			_isCaretShown = !_isCaretShown;
+			_nextCaretTime = now + kCaretBlinkTime;
+			redraw();
+		}
+
+		break;
+	}
+	case kActionTrigger:
+		g_nancy->_input->setVKEnabled(false);
+		_isDone = true;
+		break;
+	}
+}
+
+void ChessChatPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _isWaitingForReply) {
+		return;
+	}
+
+	for (const Common::KeyState &key : input.otherKbdInput) {
+		typeKey(key);
+
+		if (_isWaitingForReply) {
+			break;
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/chesschatpuzzle.h b/engines/nancy/action/puzzle/chesschatpuzzle.h
new file mode 100644
index 00000000000..2ad61f5e5ec
--- /dev/null
+++ b/engines/nancy/action/puzzle/chesschatpuzzle.h
@@ -0,0 +1,112 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_CHESSCHATPUZZLE_H
+#define NANCY_ACTION_CHESSCHATPUZZLE_H
+
+#include "common/keyboard.h"
+#include "common/str-array.h"
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy18 chess game played over a chat. The player types moves (a piece, a file
+// and a rank), and gets one back after a moment. The square of every move stands
+// for a letter, which gets read out loud; the letters make up a word, and the
+// words the record knows are answered with a line and an event flag in place of
+// a move. Anything that isn't a move, or a word that got too long, ends the chat.
+class ChessChatPuzzle : public PuzzleRecord {
+public:
+	ChessChatPuzzle() : PuzzleRecord(7) {}
+	virtual ~ChessChatPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "ChessChatPuzzle"; }
+
+	struct Word {
+		Common::String word;
+		Common::String reply;
+		int16 flag = -1;
+	};
+
+	static Common::String readText(Common::SeekableReadStream &stream);
+
+	void typeKey(const Common::KeyState &key);
+	void sendMove();
+	Common::String getReply(const Common::String &move);
+	void sayLetter(char letter);
+	void showReply();
+	void redraw();
+
+	// -- File data --
+	char _caret = '_';
+	uint32 _maxTypedLength = 0;
+	RandomSoundBlock _wrongKeySound;
+	RandomSoundBlock _sendSound;
+	RandomSoundBlock _replySound;
+	Common::Rect _chatRect;
+	Common::Rect _typingRect;
+	int16 _typingFontID = 0;
+	int16 _typingColorID = 0;
+	int32 _minReplyDelay = 0;	// in milliseconds
+	int32 _maxReplyDelay = 0;
+	Common::String _waitCharacters;	// shown one after the other while waiting for the reply
+	int32 _waitCharactersPerSecond = 0;
+	int16 _endFlag = -1;
+	Common::String _playerPrefix;
+	Common::String _opponentPrefix;
+	Common::String _greeting;
+	Common::StringArray _moveCharacters;	// what the characters of a move can be
+	Common::StringArray _confusedReplies;
+	RandomSoundBlock _letterSound;			// its name gets the letter added
+	Common::StringArray _letterGrid;		// one row of letters per rank
+	uint32 _maxWordLength = 0;
+	Common::Array<Word> _words;
+
+	// -- Runtime --
+	Common::String _chat;
+	Common::String _typed;
+	Common::String _word;
+	Common::String _pendingReply;
+	int16 _pendingFlag = -1;
+	bool _isWaitingForReply = false;
+	uint32 _replyTime = 0;
+	uint32 _waitStartTime = 0;
+	bool _isCaretShown = true;
+	uint32 _nextCaretTime = 0;
+	char _shownWaitCharacter = 0;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_CHESSCHATPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 2fd7ba5a24f..d1fa0f56d96 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -38,6 +38,7 @@ MODULE_OBJS = \
   action/puzzle/bombpuzzle.o \
   action/puzzle/booksortpuzzle.o \
   action/puzzle/cardgamepuzzle.o \
+  action/puzzle/chesschatpuzzle.o \
   action/puzzle/collisionpuzzle.o \
   action/puzzle/cookingpuzzle.o \
   action/puzzle/cubepuzzle.o \


Commit: 2a55a54ac6497596c227f5f7f370e10446cc1386
    https://github.com/scummvm/scummvm/commit/2a55a54ac6497596c227f5f7f370e10446cc1386
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:31+03:00

Commit Message:
NANCY: NANCY18: Add the SequenceLockPuzzle

A row of switches that have to be pressed in an order picked at
random; a wrong one resets them all.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 9bdccb8b84f..fb9a093951f 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -104,6 +104,7 @@
 #include "engines/nancy/action/puzzle/safedialpuzzle.h"
 #include "engines/nancy/action/puzzle/scalepuzzle.h"
 #include "engines/nancy/action/puzzle/setplayerclock.h"
+#include "engines/nancy/action/puzzle/sequencelockpuzzle.h"
 #include "engines/nancy/action/puzzle/sewingmachinepuzzle.h"
 #include "engines/nancy/action/puzzle/shovelmeasurepuzzle.h"
 #include "engines/nancy/action/puzzle/skeeballpuzzle.h"
@@ -763,7 +764,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new AnalogClock();
 	case 197:
 		return new AutotextScroll();	// Used for the end credits
-	case 190:	// MemoryPuzzle, moved from 238, unused in Nancy16
+	case 190:	// Nancy18
+		return new SequenceLockPuzzle();
 	case 191:	// BeadPuzzle, moved from 243, unused in Nancy16
 		return nullptr;
 	case 198:
diff --git a/engines/nancy/action/puzzle/sequencelockpuzzle.cpp b/engines/nancy/action/puzzle/sequencelockpuzzle.cpp
new file mode 100644
index 00000000000..81bb30dbd74
--- /dev/null
+++ b/engines/nancy/action/puzzle/sequencelockpuzzle.cpp
@@ -0,0 +1,254 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/sequencelockpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void SequenceLockPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);
+	_hoverCursor = stream.readUint16LE();
+
+	_switches.resize(stream.readUint16LE());
+	for (Switch &lockSwitch : _switches) {
+		readExclusiveRect(stream, lockSwitch.dest);
+	}
+
+	const uint16 numHotspots = stream.readUint16LE();
+	if (numHotspots != _switches.size()) {
+		error("SequenceLockPuzzle: %u hotspots for %u switches", numHotspots, _switches.size());
+	}
+
+	for (Switch &lockSwitch : _switches) {
+		readExclusiveRect(stream, lockSwitch.hotspot);
+	}
+
+	_resetFrame = stream.readSint32LE();
+	_framesPerSecond = stream.readSint32LE();
+
+	_frameSrcs.resize(stream.readUint16LE());
+	for (Common::Rect &src : _frameSrcs) {
+		readExclusiveRect(stream, src);
+	}
+
+	_pressSound.readData(stream);
+	_resetSound.readData(stream);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._sceneChange.continueSceneSound = kContinueSceneSound;
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_solveSoundBlock.readData(stream);
+
+	readExitHotspot(stream);
+}
+
+void SequenceLockPuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	// The order the switches have to be thrown in
+	Common::Array<uint> remaining(_switches.size());
+	for (uint i = 0; i < remaining.size(); ++i) {
+		remaining[i] = i;
+	}
+
+	_order.clear();
+	while (!remaining.empty()) {
+		uint pick = g_nancy->_randomSource->getRandomNumber(remaining.size() - 1);
+		_order.push_back(remaining[pick]);
+		remaining.remove_at(pick);
+	}
+
+	_lastFrameTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+void SequenceLockPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (const Switch &lockSwitch : _switches) {
+		if (lockSwitch.frame < (int)_frameSrcs.size()) {
+			_drawSurface.blitFrom(_image, _frameSrcs[lockSwitch.frame], lockSwitch.dest.origin());
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void SequenceLockPuzzle::pressSwitch(uint switchID) {
+	++_numPressed;
+	_switches[switchID].direction = kForward;
+	playSoundBlock(_pressSound);
+
+	if (_order[_numPressed - 1] != switchID) {
+		_wrongSwitch = switchID;
+	}
+}
+
+void SequenceLockPuzzle::resetSwitches() {
+	for (Switch &lockSwitch : _switches) {
+		lockSwitch.direction = kBackward;
+	}
+
+	playSoundBlock(_resetSound);
+	_wrongSwitch = -1;
+	_numPressed = 0;
+}
+
+// Returns true when a switch has changed
+bool SequenceLockPuzzle::animateSwitches(uint numFrames) {
+	bool hasChanged = false;
+	const int lastFrame = (int)_frameSrcs.size() - 1;
+
+	for (Switch &lockSwitch : _switches) {
+		if (lockSwitch.direction == kStopped) {
+			continue;
+		}
+
+		int frame = CLIP<int>(lockSwitch.frame + lockSwitch.direction * (int)numFrames, 0, MAX(lastFrame, 0));
+		if (frame != lockSwitch.frame) {
+			lockSwitch.frame = frame;
+			hasChanged = true;
+		}
+
+		if ((lockSwitch.direction == kForward && frame >= lastFrame) || (lockSwitch.direction == kBackward && frame == 0)) {
+			lockSwitch.direction = kStopped;
+		}
+	}
+
+	return hasChanged;
+}
+
+void SequenceLockPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		const uint32 frameTime = 1000 / MAX<int32>(_framesPerSecond, 1);
+		const uint32 now = g_nancy->getTotalPlayTime();
+		const uint numFrames = (now - _lastFrameTime) / frameTime;
+		_lastFrameTime += numFrames * frameTime;
+
+		if (numFrames > 0 && animateSwitches(numFrames)) {
+			redraw();
+		}
+
+		if (_wrongSwitch != -1) {
+			if (_switches[_wrongSwitch].frame > _resetFrame) {
+				resetSwitches();
+			}
+
+			break;
+		}
+
+		bool isMoving = false;
+		for (const Switch &lockSwitch : _switches) {
+			if (lockSwitch.direction != kStopped) {
+				isMoving = true;
+			}
+		}
+
+		if (_numPressed == _switches.size() && !isMoving) {
+			_isSolved = true;
+			playSoundBlock(_solveSoundBlock);
+			_state = kActionTrigger;
+		}
+
+		break;
+	}
+	case kActionTrigger:
+		if (_isSolved) {
+			// The solve sound gets to finish first
+			if (isSoundBlockPlaying(_solveSoundBlock)) {
+				break;
+			}
+
+			_solveScene.execute();
+		} else {
+			_exitScene.execute();
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void SequenceLockPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _exitRequested) {
+		return;
+	}
+
+	const bool clicked = input.input & NancyInput::kLeftMouseButtonUp;
+
+	if (hoverExitHotspot(input)) {
+		if (clicked) {
+			_exitRequested = true;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	// Nothing can be thrown while a wrong switch is on its way
+	if (_wrongSwitch != -1) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	for (uint i = 0; i < _switches.size(); ++i) {
+		// Only a switch that is all the way back can be thrown
+		if (_switches[i].hotspot.contains(pos) && _switches[i].frame == 0) {
+			setDataCursor(_hoverCursor);
+
+			if (clicked && _numPressed < _order.size()) {
+				pressSwitch(i);
+				input.eatMouseInput();
+			}
+
+			return;
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/sequencelockpuzzle.h b/engines/nancy/action/puzzle/sequencelockpuzzle.h
new file mode 100644
index 00000000000..9f388429cfc
--- /dev/null
+++ b/engines/nancy/action/puzzle/sequencelockpuzzle.h
@@ -0,0 +1,88 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_SEQUENCELOCKPUZZLE_H
+#define NANCY_ACTION_SEQUENCELOCKPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy18 sensor lock. A row of switches has to be thrown in an order that is
+// picked at random. A switch moves once it is clicked; the wrong one makes all
+// of them go back, and the player starts over, with the order staying as it was.
+class SequenceLockPuzzle : public PuzzleRecord {
+public:
+	SequenceLockPuzzle() : PuzzleRecord(7) {}
+	virtual ~SequenceLockPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "SequenceLockPuzzle"; }
+
+	enum Direction { kBackward = -1, kStopped = 0, kForward = 1 };
+
+	struct Switch {
+		Common::Rect dest;
+		Common::Rect hotspot;
+
+		int frame = 0;
+		int direction = kStopped;
+	};
+
+	void pressSwitch(uint switchID);
+	void resetSwitches();
+	bool animateSwitches(uint numFrames);
+	void redraw();
+
+	// -- File data --
+	uint16 _hoverCursor = 0;
+	Common::Array<Switch> _switches;
+	int32 _resetFrame = 0;		// a wrong switch gets this far before they all go back
+	int32 _framesPerSecond = 0;
+	Common::Array<Common::Rect> _frameSrcs;
+
+	RandomSoundBlock _pressSound;
+	RandomSoundBlock _resetSound;
+	RandomSoundBlock _solveSoundBlock;
+
+	// -- Runtime --
+	Common::Array<uint> _order;
+	uint _numPressed = 0;
+	int _wrongSwitch = -1;
+	uint32 _lastFrameTime = 0;
+	bool _isSolved = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_SEQUENCELOCKPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index d1fa0f56d96..af985bd46e7 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -89,6 +89,7 @@ MODULE_OBJS = \
   action/puzzle/rotatinglockpuzzle.o \
   action/puzzle/rowmatchpuzzle.o \
   action/puzzle/safedialpuzzle.o \
+  action/puzzle/sequencelockpuzzle.o \
   action/puzzle/scalepuzzle.o \
   action/puzzle/setplayerclock.o \
   action/puzzle/sewingmachinepuzzle.o \


Commit: 41bd608fa57593ca6837602859f7094506eb0ef4
    https://github.com/scummvm/scummvm/commit/41bd608fa57593ca6837602859f7094506eb0ef4
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:32+03:00

Commit Message:
NANCY: NANCY18: Add the sentry rooms - SentryPuzzle/SentryStartPosition

The player walks through rooms guarded by robot sentries that sweep a
beam around. If the beams touch the player, the player loses. A second
record sets where the robot starts in the next room.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index fb9a093951f..8eb72baf54d 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -104,6 +104,7 @@
 #include "engines/nancy/action/puzzle/safedialpuzzle.h"
 #include "engines/nancy/action/puzzle/scalepuzzle.h"
 #include "engines/nancy/action/puzzle/setplayerclock.h"
+#include "engines/nancy/action/puzzle/sentrypuzzle.h"
 #include "engines/nancy/action/puzzle/sequencelockpuzzle.h"
 #include "engines/nancy/action/puzzle/sewingmachinepuzzle.h"
 #include "engines/nancy/action/puzzle/shovelmeasurepuzzle.h"
@@ -655,10 +656,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new SnowmobilePuzzle(SnowmobilePuzzle::kChase);
 	case 168:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
 			return new Set3DSoundListenerPosition();
 		else
-			return nullptr;
+			return new SentryStartPosition();
 	// -- Nancy 13 new/relocated puzzles (types 169-176) --
 	case 169:
 		if (g_nancy->getGameType() <= kGameTypeNancy15)
@@ -826,7 +827,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new TypewriterText();
 	case 210:
-		return new SafeDialPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new SafeDialPuzzle();
+		else
+			return new SentryPuzzle();
 	case 211:
 		return new CollisionPuzzle(CollisionPuzzle::PuzzleType::kCollision);
 	case 212:
diff --git a/engines/nancy/action/puzzle/sentrypuzzle.cpp b/engines/nancy/action/puzzle/sentrypuzzle.cpp
new file mode 100644
index 00000000000..66e9f665093
--- /dev/null
+++ b/engines/nancy/action/puzzle/sentrypuzzle.cpp
@@ -0,0 +1,561 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/sentrypuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// What a room keeps of the player under its name
+enum {
+	kSavedLeft		= 0,
+	kSavedTop		= 1,
+	kSavedRight		= 2,
+	kSavedBottom	= 3,
+	kSavedHeading	= 4,
+	kNumSavedValues	= 5
+};
+
+static const double kWalkFramesPerSecond = 15.0;
+
+// How far past the middle of its cell the player has to be asked to go
+static const double kSnapNudge = 0.1;
+
+static const int kMaxBeamLength = 1000;
+
+static void storeSentryPosition(const Common::String &puzzleName, const Common::Rect &rect, int32 heading) {
+	auto *data = (SavedStateData *)NancySceneState.getPuzzleData(SavedStateData::getTag());
+	assert(data);
+
+	Common::Array<int32> &values = data->states[puzzleName];
+	values.resize(kNumSavedValues);
+	values[kSavedLeft] = rect.left;
+	values[kSavedTop] = rect.top;
+	values[kSavedRight] = rect.right;
+	values[kSavedBottom] = rect.bottom;
+	values[kSavedHeading] = heading;
+}
+
+void SentryStartPosition::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _puzzleName);
+	readExclusiveRect(stream, _rect);
+	_heading = stream.readSint32LE();
+}
+
+void SentryStartPosition::execute() {
+	if (!_puzzleName.empty()) {
+		storeSentryPosition(_puzzleName, _rect, _heading);
+	}
+
+	_isDone = true;
+}
+
+void SentryPuzzle::readBeam(Common::SeekableReadStream &stream, Beam &beam) {
+	beam.width = stream.readSint32LE();
+	readExclusiveRect(stream, beam.src);
+	readExclusiveRect(stream, beam.endSrc);
+}
+
+void SentryPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _name);
+	readFilename(stream, _imageName);
+	readFilename(stream, _mapName);
+	_seenFlag = stream.readSint16LE();
+	readExclusiveRect(stream, _startRect);
+	_startHeading = stream.readSint32LE();
+	_playerSpeed = stream.readSint32LE();
+	_cellSize = MAX<int32>(stream.readSint32LE(), 1);
+
+	_playerFrameSrcs.resize(stream.readUint16LE());
+	for (Common::Rect &src : _playerFrameSrcs) {
+		readExclusiveRect(stream, src);
+	}
+
+	_sentries.resize(stream.readUint16LE());
+	for (Sentry &sentry : _sentries) {
+		readFilename(stream, sentry.imageName);
+
+		sentry.frameSrcs.resize(stream.readUint16LE());
+		for (Common::Rect &src : sentry.frameSrcs) {
+			readExclusiveRect(stream, src);
+		}
+
+		sentry.alertFrameSrcs.resize(stream.readUint16LE());
+		for (Common::Rect &src : sentry.alertFrameSrcs) {
+			readExclusiveRect(stream, src);
+		}
+
+		readBeam(stream, sentry.beam);
+		readBeam(stream, sentry.alertBeam);
+
+		// The path goes through the middle of each of its rects
+		sentry.pathMode = stream.readByte();
+		sentry.path.resize(stream.readUint16LE());
+		for (Common::Point &point : sentry.path) {
+			Common::Rect rect;
+			readExclusiveRect(stream, rect);
+			point.x = (rect.left + rect.right) / 2;
+			point.y = (rect.top + rect.bottom) / 2;
+		}
+
+		sentry.speed = stream.readSint32LE();
+		sentry.turnsPerSecond = stream.readDoubleLE();
+		sentry.numBeamSteps = MAX<int32>(stream.readSint32LE(), 1);
+	}
+
+	readActionZoneArray(stream, _zones, true);
+	readExitHotspot(stream);
+}
+
+void SentryPuzzle::init() {
+	initViewportSurface();
+	loadImage();
+	g_nancy->_resource->loadImage(_mapName, _map);
+
+	if (!_playerFrameSrcs.empty()) {
+		// The player takes up a little less room than their picture
+		_playerSize.x = _playerFrameSrcs[0].width() - 4;
+		_playerSize.y = _playerFrameSrcs[0].height() - 4;
+	}
+
+	// The player is where the room last had them, or where the record
+	// they came in through put them
+	Common::Rect startRect = _startRect;
+	int32 startHeading = _startHeading;
+
+	const auto *data = (const SavedStateData *)NancySceneState.getPuzzleData(SavedStateData::getTag());
+	assert(data);
+	if (!_name.empty() && data->states.contains(_name) && data->states[_name].size() == kNumSavedValues) {
+		const Common::Array<int32> &values = data->states[_name];
+		startRect = Common::Rect(values[kSavedLeft], values[kSavedTop], values[kSavedRight], values[kSavedBottom]);
+		startHeading = values[kSavedHeading];
+	}
+
+	_playerX = _targetX = (startRect.left + startRect.right) / 2;
+	_playerY = _targetY = (startRect.top + startRect.bottom) / 2;
+	_playerHeading = startHeading * M_PI / 180.0;
+
+	for (Sentry &sentry : _sentries) {
+		g_nancy->_resource->loadImage(sentry.imageName, sentry.image);
+
+		if (!sentry.path.empty()) {
+			sentry.x = sentry.path[0].x;
+			sentry.y = sentry.path[0].y;
+		}
+
+		sentry.nextPoint = 1;
+		sentry.hasStopped = sentry.path.size() < 2;
+		sentry.beamStepOffset = g_nancy->_randomSource->getRandomNumber(sentry.numBeamSteps - 1);
+		castBeam(sentry);
+	}
+
+	_startTime = _lastUpdateTime = g_nancy->getTotalPlayTime();
+	redraw();
+}
+
+Common::Rect SentryPuzzle::getPlayerRect(double x, double y) const {
+	Common::Rect rect(_playerSize.x, _playerSize.y);
+	rect.moveTo((int)x - _playerSize.x / 2, (int)y - _playerSize.y / 2);
+	return rect;
+}
+
+// The map shows where the floor is; everything else is a wall
+bool SentryPuzzle::isWalkable(int x, int y) const {
+	if (x < 0 || y < 0 || x >= _map.w || y >= _map.h) {
+		return false;
+	}
+
+	byte a, r, g, b;
+	_map.format.colorToARGB(_map.getPixel(x, y), a, r, g, b);
+	return a != 0;
+}
+
+bool SentryPuzzle::canPlayerBeAt(double x, double y) const {
+	const Common::Rect rect = getPlayerRect(x, y);
+
+	for (int row = rect.top; row < rect.bottom; ++row) {
+		for (int column = rect.left; column < rect.right; ++column) {
+			if (!isWalkable(column, row)) {
+				return false;
+			}
+		}
+	}
+
+	// A zone can close off a part of the floor, for as long as its event flag says so
+	for (const ActionZone &zone : _zones) {
+		if (zone.type == kZoneBoundary && zone.rect.intersects(rect) &&
+				(zone.val49 == -1 || NancySceneState.getEventFlag(zone.val49, zone.val4b))) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+// The middle of the last cell that a position is at or past
+int32 SentryPuzzle::snapToCell(double position) const {
+	const int32 half = _cellSize / 2;
+	return ((int32)(position + half) / _cellSize) * _cellSize + half - _cellSize;
+}
+
+void SentryPuzzle::movePlayer(uint32 elapsed) {
+	bool goesRight = _heldKeys & NancyInput::kMoveRight;
+	bool goesLeft = _heldKeys & NancyInput::kMoveLeft;
+	bool goesUp = _heldKeys & NancyInput::kMoveUp;
+	bool goesDown = _heldKeys & NancyInput::kMoveDown;
+
+	// The mouse pulls the player along once it is half a cell away
+	if (_isMouseHeld) {
+		const double reach = _cellSize * 0.5;
+		goesRight = goesRight || _mousePos.x - _playerX > reach;
+		goesLeft = goesLeft || _mousePos.x - _playerX < -reach;
+		goesUp = goesUp || _mousePos.y - _playerY < -reach;
+		goesDown = goesDown || _mousePos.y - _playerY > reach;
+	}
+
+	// The player heads for the middle of the next cell, along each direction on its own
+	bool hasNewTarget = false;
+	if (goesRight || goesLeft) {
+		double x = goesRight ? snapToCell(_playerX + kSnapNudge) + _cellSize : snapToCell(_playerX - kSnapNudge);
+		if (canPlayerBeAt(x, _playerY)) {
+			_targetX = x;
+			hasNewTarget = true;
+		}
+	}
+
+	if (goesUp || goesDown) {
+		double y = goesDown ? snapToCell(_playerY + kSnapNudge) + _cellSize : snapToCell(_playerY - kSnapNudge);
+		if (canPlayerBeAt(_playerX, y)) {
+			_targetY = y;
+			hasNewTarget = true;
+		}
+	}
+
+	const double dx = _targetX - _playerX;
+	const double dy = _targetY - _playerY;
+	const double distance = sqrt(dx * dx + dy * dy);
+
+	if (distance <= 1.0 && !hasNewTarget) {
+		if (canPlayerBeAt(_targetX, _targetY)) {
+			_playerX = _targetX;
+			_playerY = _targetY;
+		}
+
+		_isPlayerMoving = false;
+		return;
+	}
+
+	_playerHeading = atan2(-dy, dx);
+	if (_playerHeading < 0) {
+		_playerHeading += 2 * M_PI;
+	}
+
+	// The player slows down on the way into a cell they weren't asked to leave
+	double speed = _playerSpeed;
+	if (!hasNewTarget) {
+		speed *= distance / (_cellSize * 0.5);
+	}
+
+	const double step = MIN(speed * elapsed / 1000.0, distance);
+	double x = _playerX;
+	double y = _playerY;
+	if (distance > 0) {
+		x += dx / distance * step;
+		y += dy / distance * step;
+	}
+
+	if (canPlayerBeAt(x, y)) {
+		_playerX = x;
+		_playerY = y;
+	} else if (canPlayerBeAt(x, _playerY)) {
+		_playerX = x;
+	} else if (canPlayerBeAt(_playerX, y)) {
+		_playerY = y;
+	}
+
+	if (!_isPlayerMoving) {
+		_isPlayerMoving = true;
+		_walkStartTime = g_nancy->getTotalPlayTime();
+	}
+}
+
+void SentryPuzzle::moveSentry(Sentry &sentry, uint32 elapsed) {
+	double step = sentry.speed * elapsed / 1000.0;
+
+	while (step > 0 && !sentry.hasStopped) {
+		const Common::Point &target = sentry.path[sentry.nextPoint];
+		const double dx = target.x - sentry.x;
+		const double dy = target.y - sentry.y;
+		const double distance = sqrt(dx * dx + dy * dy);
+
+		if (distance > step) {
+			sentry.x += dx / distance * step;
+			sentry.y += dy / distance * step;
+			break;
+		}
+
+		sentry.x = target.x;
+		sentry.y = target.y;
+		step -= distance;
+
+		if (sentry.nextPoint + 1 < sentry.path.size()) {
+			++sentry.nextPoint;
+		} else if (sentry.pathMode == kPathStartsOver) {
+			sentry.nextPoint = 0;
+		} else if (sentry.pathMode == kPathTurnsAround) {
+			for (uint i = 0; i < sentry.path.size() / 2; ++i) {
+				SWAP(sentry.path[i], sentry.path[sentry.path.size() - 1 - i]);
+			}
+
+			sentry.nextPoint = 1;
+		} else {
+			sentry.hasStopped = true;
+		}
+	}
+}
+
+// The beam turns around the sentry, and reaches as far as the floor does
+void SentryPuzzle::castBeam(Sentry &sentry) {
+	const uint32 elapsed = g_nancy->getTotalPlayTime() - _startTime;
+	const uint32 step = (sentry.beamStepOffset + (uint32)(elapsed * sentry.numBeamSteps * sentry.turnsPerSecond / 1000.0)) % sentry.numBeamSteps;
+	const double angle = step * 2 * M_PI / sentry.numBeamSteps;
+	const double dirX = cos(angle);
+	const double dirY = -sin(angle);
+
+	sentry.beamEndX = sentry.x;
+	sentry.beamEndY = sentry.y;
+
+	for (int i = 1; i <= kMaxBeamLength; ++i) {
+		const double x = sentry.x + dirX * i;
+		const double y = sentry.y + dirY * i;
+		if (!isWalkable((int)x, (int)y)) {
+			break;
+		}
+
+		sentry.beamEndX = x;
+		sentry.beamEndY = y;
+	}
+}
+
+bool SentryPuzzle::doesBeamHit(const Sentry &sentry, const Common::Rect &rect) const {
+	const double dx = sentry.beamEndX - sentry.x;
+	const double dy = sentry.beamEndY - sentry.y;
+	const int length = (int)sqrt(dx * dx + dy * dy);
+
+	for (int i = 0; i <= length; ++i) {
+		const int x = (int)(sentry.x + (length ? dx * i / length : 0));
+		const int y = (int)(sentry.y + (length ? dy * i / length : 0));
+		if (rect.contains(x, y)) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+void SentryPuzzle::checkZones() {
+	const Common::Rect rect = getPlayerRect(_playerX, _playerY);
+
+	for (const ActionZone &zone : _zones) {
+		if (zone.type != kZoneEventFlag || !zone.rect.intersects(rect)) {
+			continue;
+		}
+
+		if (zone.val49 != -1 && !NancySceneState.getEventFlag(zone.val49, zone.val4b)) {
+			continue;
+		}
+
+		NancySceneState.setEventFlag(zone.tailId, zone.tailFlag ? g_nancy->_true : g_nancy->_false);
+	}
+}
+
+void SentryPuzzle::storePlayerPosition() {
+	if (!_name.empty()) {
+		storeSentryPosition(_name, getPlayerRect(_playerX, _playerY), (int32)(_playerHeading * 180.0 / M_PI));
+	}
+}
+
+// The player's picture looks down the screen, and gets turned to where they are heading
+void SentryPuzzle::drawPlayer() {
+	if (_playerFrameSrcs.empty() || _image.empty()) {
+		return;
+	}
+
+	uint frame = _playerFrameSrcs.size() - 1;
+	if (_isPlayerMoving && _playerFrameSrcs.size() > 1) {
+		const uint32 elapsed = g_nancy->getTotalPlayTime() - _walkStartTime;
+		frame = (uint)(elapsed * kWalkFramesPerSecond / 1000.0) % (_playerFrameSrcs.size() - 1);
+	}
+
+	const Common::Rect src = _playerFrameSrcs[frame].findIntersectingRect(_image.getBounds());
+	const double angle = -(_playerHeading + M_PI / 2);
+	const double cosAngle = cos(angle);
+	const double sinAngle = sin(angle);
+	const int radius = (int)sqrt((double)(src.width() * src.width() + src.height() * src.height())) / 2 + 1;
+
+	for (int dy = -radius; dy <= radius; ++dy) {
+		for (int dx = -radius; dx <= radius; ++dx) {
+			const int destX = (int)_playerX + dx;
+			const int destY = (int)_playerY + dy;
+			if (destX < 0 || destY < 0 || destX >= _drawSurface.w || destY >= _drawSurface.h) {
+				continue;
+			}
+
+			// Back to where this pixel comes from in the picture
+			const int srcX = src.left + src.width() / 2 + (int)floor(dx * cosAngle + dy * sinAngle + 0.5);
+			const int srcY = src.top + src.height() / 2 + (int)floor(-dx * sinAngle + dy * cosAngle + 0.5);
+			if (!src.contains(srcX, srcY)) {
+				continue;
+			}
+
+			byte a, r, g, b;
+			_image.format.colorToARGB(_image.getPixel(srcX, srcY), a, r, g, b);
+			if (a >= 0x80) {
+				_drawSurface.setPixel(destX, destY, _drawSurface.format.ARGBToColor(0xFF, r, g, b));
+			}
+		}
+	}
+}
+
+void SentryPuzzle::drawSentry(const Sentry &sentry) {
+	if (sentry.image.empty()) {
+		return;
+	}
+
+	// The beam is a line in the color of its picture, as wide as the two halves of it
+	const Beam &beam = sentry.hasSeenPlayer ? sentry.alertBeam : sentry.beam;
+	const Common::Rect beamSrc = beam.src.findIntersectingRect(sentry.image.getBounds());
+	if (!beamSrc.isEmpty()) {
+		byte a, r, g, b;
+		sentry.image.format.colorToARGB(sentry.image.getPixel(beamSrc.left + beamSrc.width() / 2, beamSrc.top), a, r, g, b);
+		const int width = beamSrc.width() * 2 + beam.width;
+		_drawSurface.drawThickLine((int)sentry.x, (int)sentry.y, (int)sentry.beamEndX, (int)sentry.beamEndY,
+			width / 2, width / 2, _drawSurface.format.ARGBToColor(0xFF, r, g, b));
+	}
+
+	const Common::Array<Common::Rect> &srcs = sentry.hasSeenPlayer ? sentry.alertFrameSrcs : sentry.frameSrcs;
+	if (srcs.empty()) {
+		return;
+	}
+
+	const uint32 elapsed = g_nancy->getTotalPlayTime() - _startTime;
+	const Common::Rect &src = srcs[(uint)(elapsed * srcs.size() * sentry.turnsPerSecond / 1000.0) % srcs.size()];
+	_drawSurface.blitFrom(sentry.image, src, Common::Point((int)sentry.x - src.width() / 2, (int)sentry.y - src.height() / 2));
+}
+
+void SentryPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	drawPlayer();
+	for (const Sentry &sentry : _sentries) {
+		drawSentry(sentry);
+	}
+
+	_needsRedraw = true;
+}
+
+void SentryPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		const uint32 now = g_nancy->getTotalPlayTime();
+		const uint32 elapsed = now - _lastUpdateTime;
+		_lastUpdateTime = now;
+
+		if (!_isSeen) {
+			movePlayer(elapsed);
+			storePlayerPosition();
+			checkZones();
+		}
+
+		const Common::Rect playerRect = getPlayerRect(_playerX, _playerY);
+		for (Sentry &sentry : _sentries) {
+			// A sentry that has seen the player stays as it is
+			if (sentry.hasSeenPlayer) {
+				continue;
+			}
+
+			moveSentry(sentry, elapsed);
+			castBeam(sentry);
+
+			if (doesBeamHit(sentry, playerRect)) {
+				sentry.hasSeenPlayer = true;
+				_isSeen = true;
+				_isPlayerMoving = false;
+				NancySceneState.setEventFlag(_seenFlag, g_nancy->_true);
+			}
+		}
+
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		_exitScene.execute();
+		finishExecution();
+		break;
+	}
+}
+
+void SentryPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun) {
+		return;
+	}
+
+	_heldKeys = input.input & (NancyInput::kMoveUp | NancyInput::kMoveDown | NancyInput::kMoveLeft | NancyInput::kMoveRight);
+
+	if (hoverExitHotspot(input)) {
+		_isMouseHeld = false;
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_state = kActionTrigger;
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+
+	const Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	const bool isInViewport = NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos);
+	_isMouseHeld = isInViewport && (input.input & (NancyInput::kLeftMouseButtonDown | NancyInput::kLeftMouseButtonHeld));
+
+	if (_isMouseHeld) {
+		const Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+			Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+		_mousePos = mouseRect.origin();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/sentrypuzzle.h b/engines/nancy/action/puzzle/sentrypuzzle.h
new file mode 100644
index 00000000000..a2453625873
--- /dev/null
+++ b/engines/nancy/action/puzzle/sentrypuzzle.h
@@ -0,0 +1,159 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_SENTRYPUZZLE_H
+#define NANCY_ACTION_SENTRYPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionzone.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy18. Tells the sentry puzzle of a room where the player comes in, by
+// writing over the place that room has kept for them.
+class SentryStartPosition : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	Common::String getRecordTypeName() const override { return "SentryStartPosition"; }
+
+	Common::String _puzzleName;
+	Common::Rect _rect;
+	int32 _heading = 0;	// in degrees
+};
+
+// Nancy18 rooms full of robot sentries, seen from above. The player walks from
+// cell to cell of a map, with the arrow keys or by holding the mouse button down
+// where they want to go. Each sentry follows a path and sweeps a beam around,
+// which walls stop; the beam touching the player sets an event flag. Zones on
+// the map set flags as well, which is how a room is left.
+class SentryPuzzle : public PuzzleRecord {
+public:
+	SentryPuzzle() : PuzzleRecord(7) {}
+	virtual ~SentryPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "SentryPuzzle"; }
+
+	enum PathMode {
+		kPathStopsAtEnd		= 0,
+		kPathStartsOver		= 1,
+		kPathTurnsAround	= 2
+	};
+
+	struct Beam {
+		int32 width = 0;
+		Common::Rect src;
+		Common::Rect endSrc;
+	};
+
+	struct Sentry {
+		// -- File data --
+		Common::Path imageName;
+		Common::Array<Common::Rect> frameSrcs;
+		Common::Array<Common::Rect> alertFrameSrcs;
+		Beam beam;
+		Beam alertBeam;
+		byte pathMode = kPathStopsAtEnd;
+		Common::Array<Common::Point> path;
+		int32 speed = 0;			// in pixels per second
+		double turnsPerSecond = 0;	// of the beam
+		int32 numBeamSteps = 1;		// that a turn of the beam is made of
+
+		// -- Runtime --
+		Graphics::ManagedSurface image;
+		double x = 0;
+		double y = 0;
+		uint nextPoint = 0;
+		bool hasStopped = false;
+		uint32 beamStepOffset = 0;
+		double beamEndX = 0;
+		double beamEndY = 0;
+		bool hasSeenPlayer = false;
+	};
+
+	static void readBeam(Common::SeekableReadStream &stream, Beam &beam);
+
+	Common::Rect getPlayerRect(double x, double y) const;
+	bool isWalkable(int x, int y) const;
+	bool canPlayerBeAt(double x, double y) const;
+	int32 snapToCell(double position) const;
+
+	void movePlayer(uint32 elapsed);
+	void moveSentry(Sentry &sentry, uint32 elapsed);
+	void castBeam(Sentry &sentry);
+	bool doesBeamHit(const Sentry &sentry, const Common::Rect &rect) const;
+	void checkZones();
+	void storePlayerPosition();
+
+	void drawPlayer();
+	void drawSentry(const Sentry &sentry);
+	void redraw();
+
+	// -- File data --
+	Common::String _name;
+	Common::Path _mapName;
+	int16 _seenFlag = -1;
+	Common::Rect _startRect;
+	int32 _startHeading = 0;	// in degrees
+	int32 _playerSpeed = 0;		// in pixels per second
+	int32 _cellSize = 1;
+	Common::Array<Common::Rect> _playerFrameSrcs;
+	Common::Array<Sentry> _sentries;
+	Common::Array<ActionZone> _zones;
+
+	// -- Runtime --
+	Graphics::ManagedSurface _map;
+	Common::Point _playerSize;
+	double _playerX = 0;
+	double _playerY = 0;
+	double _playerHeading = 0;	// in radians, counterclockwise
+	double _targetX = 0;
+	double _targetY = 0;
+	bool _isPlayerMoving = false;
+	bool _isSeen = false;
+
+	// What the player asks for
+	bool _isMouseHeld = false;
+	Common::Point _mousePos;
+	uint16 _heldKeys = 0;
+
+	uint32 _startTime = 0;
+	uint32 _lastUpdateTime = 0;
+	uint32 _walkStartTime = 0;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_SENTRYPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index af985bd46e7..2870c542f8a 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -89,6 +89,7 @@ MODULE_OBJS = \
   action/puzzle/rotatinglockpuzzle.o \
   action/puzzle/rowmatchpuzzle.o \
   action/puzzle/safedialpuzzle.o \
+  action/puzzle/sentrypuzzle.o \
   action/puzzle/sequencelockpuzzle.o \
   action/puzzle/scalepuzzle.o \
   action/puzzle/setplayerclock.o \


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

Commit Message:
NANCY: NANCY18: Add the stakeout mini game (StakeOutPuzzle)

The agents say over the radio where they hide, which a UI dialog
lists, and then peek out of their hiding places; the player has to
point out the one who didn't say anything.

Changed paths:
  A engines/nancy/action/puzzle/stakeoutpuzzle.cpp
  A engines/nancy/action/puzzle/stakeoutpuzzle.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/module.mk
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/manager.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 8eb72baf54d..25d49246b9b 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -105,6 +105,7 @@
 #include "engines/nancy/action/puzzle/scalepuzzle.h"
 #include "engines/nancy/action/puzzle/setplayerclock.h"
 #include "engines/nancy/action/puzzle/sentrypuzzle.h"
+#include "engines/nancy/action/puzzle/stakeoutpuzzle.h"
 #include "engines/nancy/action/puzzle/sequencelockpuzzle.h"
 #include "engines/nancy/action/puzzle/sewingmachinepuzzle.h"
 #include "engines/nancy/action/puzzle/shovelmeasurepuzzle.h"
@@ -834,7 +835,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 211:
 		return new CollisionPuzzle(CollisionPuzzle::PuzzleType::kCollision);
 	case 212:
-		return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kOrderItems);
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kOrderItems);
+		else
+			return new StakeOutPuzzle();
 	case 213:
 		if (g_nancy->getGameType() <= kGameTypeNancy17)
 			return new CollisionPuzzle(CollisionPuzzle::PuzzleType::kTileMove);
diff --git a/engines/nancy/action/puzzle/stakeoutpuzzle.cpp b/engines/nancy/action/puzzle/stakeoutpuzzle.cpp
new file mode 100644
index 00000000000..78e304ab1ac
--- /dev/null
+++ b/engines/nancy/action/puzzle/stakeoutpuzzle.cpp
@@ -0,0 +1,660 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/ui/ndui/manager.h"
+#include "engines/nancy/action/puzzle/stakeoutpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const double kFramesPerSecond = 15.0;
+static const uint kMaxRandom = 32767;
+
+// How far into looking at the target's place the one who looks says there is nobody
+static const double kNotThereFraction = 0.5;
+
+static const uint16 kAnnouncementVolume = 100;
+static const uint16 kWaitCursor = 23;
+
+StakeOutPuzzle::~StakeOutPuzzle() {
+	UI::NDUI::Manager *ndui = NancySceneState.getNDUI();
+	if (!_isDialogShown || !ndui) {
+		return;
+	}
+
+	ndui->takeAction(UI::NDUI::kActionHide, _dialogName, 1.0);
+	removeTexts();
+}
+
+void StakeOutPuzzle::readData(Common::SeekableReadStream &stream) {
+	_rightCallsTableIndex = stream.readSint16LE();
+	_wrongCallsTableIndex = stream.readSint16LE();
+	readFilename(stream, _dialogName);
+	readFilename(stream, _textTemplate);
+	_hoverCursor = stream.readUint16LE();
+
+	_peekChance = stream.readDoubleLE();
+	_lookTime = stream.readDoubleLE();
+	_targetHideFactor = stream.readDoubleLE();
+	_minPeekWindow = stream.readDoubleLE();
+	_maxPeekWindow = stream.readDoubleLE();
+	_minAnnouncePause = stream.readDoubleLE();
+	_maxAnnouncePause = stream.readDoubleLE();
+	_minPeekDelay = stream.readDoubleLE();
+	_maxPeekDelay = stream.readDoubleLE();
+	_minRoundPause = stream.readDoubleLE();
+	_maxRoundPause = stream.readDoubleLE();
+
+	_places.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (Place &place : _places) {
+		readFilename(stream, place.name);
+		readExclusiveRect(stream, place.dest);
+		readFilename(stream, place.imageName);
+
+		place.showSrcs.resize(MAX<int16>(stream.readSint16LE(), 0));
+		for (Common::Rect &src : place.showSrcs) {
+			readExclusiveRect(stream, src);
+		}
+
+		place.hideSrcs.resize(MAX<int16>(stream.readSint16LE(), 0));
+		for (Common::Rect &src : place.hideSrcs) {
+			readExclusiveRect(stream, src);
+		}
+
+		place.sound.readData(stream);
+	}
+
+	_persons.resize(MAX<int16>(stream.readSint16LE(), 0));
+	for (Person &person : _persons) {
+		readFilename(stream, person.name);
+		person.isTarget = stream.readByte();
+		person.channel = stream.readUint16LE();
+		person.wrongCallSound.readData(stream);
+		person.notThereSound.readData(stream);
+		person.stopSound.readData(stream);
+	}
+}
+
+void StakeOutPuzzle::init() {
+	initViewportSurface();
+
+	for (Place &place : _places) {
+		g_nancy->_resource->loadImage(place.imageName, place.image);
+		place.image.setTransparentColor(_drawSurface.getTransparentColor());
+	}
+
+	_shownFrames.resize(_persons.size(), -1);
+
+	UI::NDUI::Manager *ndui = NancySceneState.getNDUI();
+	if (ndui) {
+		ndui->takeAction(UI::NDUI::kActionShow, _dialogName, 1.0);
+		_isDialogShown = true;
+	}
+
+	shuffleAnnouncements();
+	_lastUpdateTime = g_nancy->getTotalPlayTime();
+}
+
+StakeOutPuzzle::Phase StakeOutPuzzle::getPhase(const Person &person, double &fraction) const {
+	if (person.time < person.showStart) {
+		fraction = person.time / person.showStart;
+		return kWaiting;
+	}
+
+	if (person.time < person.shownTime) {
+		fraction = (person.time - person.showStart) / (person.shownTime - person.showStart);
+		return kShowing;
+	}
+
+	if (person.time < person.hideStart) {
+		fraction = (person.time - person.shownTime) / (person.hideStart - person.shownTime);
+		return kShown;
+	}
+
+	if (person.time < person.goneTime) {
+		fraction = (person.time - person.hideStart) / (person.goneTime - person.hideStart);
+		return kHiding;
+	}
+
+	fraction = 1.0;
+	return kGone;
+}
+
+// Puts the person's time at a point of a phase
+void StakeOutPuzzle::setPhase(Person &person, Phase phase, double fraction) {
+	switch (phase) {
+	case kWaiting:
+		person.time = person.showStart * fraction;
+		break;
+	case kShowing:
+		person.time = person.showStart + (person.shownTime - person.showStart) * fraction;
+		break;
+	case kShown:
+		person.time = person.shownTime + (person.hideStart - person.shownTime) * fraction;
+		break;
+	case kHiding:
+		person.time = person.hideStart + (person.goneTime - person.hideStart) * fraction;
+		break;
+	case kGone:
+		person.time = person.goneTime;
+		break;
+	}
+}
+
+// A person who is coming out goes back in from as far as they got
+void StakeOutPuzzle::hideNow(Person &person, Phase phase, double fraction) {
+	if (phase == kShowing) {
+		setPhase(person, kHiding, 1.0 - fraction);
+	} else if (phase == kShown) {
+		setPhase(person, kHiding, 0.0);
+	}
+}
+
+double StakeOutPuzzle::getRandomTime(double min, double max) const {
+	return min + g_nancy->_randomSource->getRandomNumber(kMaxRandom) / (double)kMaxRandom * (max - min);
+}
+
+// The time it takes to come out of a place, or to go back in
+double StakeOutPuzzle::getPeekTime(const Place &place) const {
+	return place.showSrcs.size() / kFramesPerSecond;
+}
+
+int StakeOutPuzzle::getRandomFreePlace() const {
+	Common::Array<int> freePlaces;
+	for (uint i = 0; i < _places.size(); ++i) {
+		if (!_places[i].isTaken) {
+			freePlaces.push_back(i);
+		}
+	}
+
+	if (freePlaces.empty()) {
+		return -1;
+	}
+
+	return freePlaces[g_nancy->_randomSource->getRandomNumber(freePlaces.size() - 1)];
+}
+
+// Everybody says where they are at the start of a round, in a new order every time
+void StakeOutPuzzle::shuffleAnnouncements() {
+	_announcements.clear();
+	for (uint i = 0; i < _persons.size(); ++i) {
+		_announcements.push_back(i);
+	}
+
+	for (uint left = _announcements.size(); left > 1; --left) {
+		const uint index = g_nancy->_randomSource->getRandomNumber(left - 1);
+		_announcements.push_back(_announcements.remove_at(index));
+	}
+}
+
+// The person picks a place and says so over the radio, which the dialog also
+// shows. The target does it without a word. Returns true once the person is done.
+bool StakeOutPuzzle::announce(Person &person) {
+	if (person.isAnnounced) {
+		return !g_nancy->_sound->isSoundPlaying(person.channel);
+	}
+
+	person.isAnnounced = true;
+	person.place = getRandomFreePlace();
+	if (person.place == -1) {
+		return true;
+	}
+
+	_places[person.place].isTaken = true;
+	if (person.isTarget) {
+		return true;
+	}
+
+	// There is a line for every person in every place
+	const Common::String key = person.name + _places[person.place].name;
+
+	SoundDescription sound;
+	sound.name = key;
+	sound.channelID = person.channel;
+	sound.numLoops = 1;
+	sound.volume = kAnnouncementVolume;
+	g_nancy->_sound->loadSound(sound);
+	g_nancy->_sound->playSound(sound);
+
+	addText(key);
+	return false;
+}
+
+void StakeOutPuzzle::addText(const Common::String &key) {
+	// The lines of the agents are kept with the conversation texts
+	Common::String text = resolveSubtitleText(key);
+	if (text.empty()) {
+		text = resolveSubtitleText(key, Common::String(), "CONVO");
+	}
+
+	UI::NDUI::Manager *ndui = NancySceneState.getNDUI();
+	if (text.empty() || !ndui) {
+		return;
+	}
+
+	++_numTexts;
+	const Common::String name = Common::String::format("StakeOut%i", _numTexts);
+	ndui->createObject(name, _textTemplate, _dialogName);
+	ndui->takeAction(UI::NDUI::kActionSetText, name, text);
+}
+
+void StakeOutPuzzle::removeTexts() {
+	UI::NDUI::Manager *ndui = NancySceneState.getNDUI();
+
+	for (; _numTexts > 0; --_numTexts) {
+		if (ndui) {
+			ndui->destroyObject(Common::String::format("StakeOut%i", _numTexts));
+		}
+	}
+}
+
+// Takes the caption of a line off the screen, unless another one has replaced it
+void StakeOutPuzzle::clearCaption(const SoundDescription &line) {
+	UI::NDUI::Manager *ndui = NancySceneState.getNDUI();
+	if (!ndui || ndui->getCaption().empty()) {
+		return;
+	}
+
+	Common::String caption = resolveSubtitleText(line.name);
+	if (caption.empty()) {
+		caption = resolveSubtitleText(line.name, Common::String(), "CONVO");
+	}
+
+	if (caption == ndui->getCaption()) {
+		NancySceneState.getTextbox().clear();
+	}
+}
+
+// Everybody who peeks out in this round does it once, within the same span of time
+void StakeOutPuzzle::startPeeking() {
+	_roundState = kPeeking;
+	_clock = 0.0;
+	_peekStart = getRandomTime(_minPeekDelay, _maxPeekDelay);
+	_peekEnd = getRandomTime(_minPeekWindow, _maxPeekWindow) + _peekStart;
+	_nextTime = getRandomTime(_minRoundPause, _maxRoundPause) + _peekEnd;
+
+	const double peekTime = _places.empty() ? 0.0 : getPeekTime(_places[0]);
+
+	for (Person &person : _persons) {
+		person.time = 0.0;
+
+		if (getRandomTime(0.0, 1.0) >= _peekChance) {
+			person.showStart = person.shownTime = person.hideStart = person.goneTime = 0.0;
+		} else {
+			person.showStart = getRandomTime(_peekStart, _peekEnd - 2 * peekTime);
+			person.shownTime = person.showStart + peekTime;
+			person.hideStart = getRandomTime(person.shownTime, _peekEnd - peekTime);
+			person.goneTime = person.hideStart + peekTime;
+		}
+	}
+}
+
+// The target has left by the time somebody gets there. It hides in a new place,
+// and the one who went to look shows up where it was for a moment.
+void StakeOutPuzzle::moveToTargetPlace(Person &person) {
+	Person *target = nullptr;
+	for (Person &other : _persons) {
+		if (other.isTarget) {
+			target = &other;
+			break;
+		}
+	}
+
+	if (!target || target->time < target->goneTime) {
+		return;
+	}
+
+	const int seenPlace = target->place;
+	const int newPlace = getRandomFreePlace();
+	if (newPlace == -1) {
+		return;
+	}
+
+	target->time = target->goneTime;
+	target->place = newPlace;
+	_places[newPlace].isTaken = true;
+	_places[person.place].isTaken = false;
+	person.place = seenPlace;
+
+	const double peekTime = getPeekTime(_places[seenPlace]);
+	person.showStart = 0.0;
+	person.shownTime = peekTime;
+	person.hideStart = peekTime + _lookTime;
+	person.goneTime = person.hideStart + peekTime;
+	person.time = 0.0;
+	person.hasMoved = true;
+}
+
+// Returns true once the person is out of sight for the rest of the round
+bool StakeOutPuzzle::updatePerson(Person &person, double elapsed) {
+	double fraction = 0.0;
+	Phase phase = getPhase(person, fraction);
+
+	if (person.wasCalledOut) {
+		// Stays in sight to complain, and hides after that
+		const bool isComplaining = isSoundBlockPlaying(person.wrongCallSound);
+		if (!isComplaining) {
+			clearCaption(person.wrongCallLine);
+
+			if (phase == kShowing || phase == kShown) {
+				hideNow(person, phase, fraction);
+			} else {
+				person.time += elapsed;
+			}
+		}
+
+		return !isComplaining && phase == kGone;
+	}
+
+	person.time += elapsed;
+	phase = getPhase(person, fraction);
+
+	if (!person.isCatching) {
+		return phase == kGone;
+	}
+
+	if (phase == kShown) {
+		if (fraction > kNotThereFraction && person.hasMoved && !_hasSaidNotThere) {
+			person.notThereLine = playSoundBlock(person.notThereSound);
+			_hasSaidNotThere = true;
+		}
+	} else {
+		_hasSaidNotThere = false;
+	}
+
+	if (phase == kGone) {
+		if (!person.hasMoved) {
+			moveToTargetPlace(person);
+		} else if (_roundState == kCatching) {
+			// Back from looking: what is left of the round is the pause after it
+			person.isCatching = false;
+			_clock = _peekEnd;
+			_roundState = kPeeking;
+		}
+	}
+
+	return phase == kGone;
+}
+
+// The player is done saying where the person is
+void StakeOutPuzzle::resolveCall(uint personID) {
+	Person &called = _persons[personID];
+	called.wrongCallLine = playSoundBlock(called.wrongCallSound);
+
+	if (!called.isTarget) {
+		_isInputEnabled = true;
+		called.wasCalledOut = true;
+		return;
+	}
+
+	if (_persons.size() < 2) {
+		return;
+	}
+
+	// Somebody goes after the target, and everybody gets out of sight
+	uint catcherID = personID;
+	while (catcherID == personID) {
+		catcherID = g_nancy->_randomSource->getRandomNumber(_persons.size() - 1);
+	}
+
+	Person &catcher = _persons[catcherID];
+	catcher.isCatching = true;
+	catcher.wasCalledOut = false;
+	playSoundBlock(catcher.stopSound);
+	_roundState = kCatching;
+
+	for (Person &person : _persons) {
+		double fraction = 0.0;
+		const Phase phase = getPhase(person, fraction);
+
+		if (&person == &called) {
+			person.goneTime = person.hideStart + (person.goneTime - person.hideStart) * _targetHideFactor;
+			setPhase(person, phase, fraction);
+		}
+
+		if (phase == kWaiting) {
+			person.time = person.goneTime;
+		} else {
+			hideNow(person, phase, fraction);
+		}
+	}
+}
+
+void StakeOutPuzzle::endRound() {
+	_roundState = kAnnouncing;
+	_clock = 0.0;
+	_peekStart = 0.0;
+	_peekEnd = 0.0;
+	_nextTime = 0.0;
+	_isInputEnabled = true;
+
+	removeTexts();
+
+	for (Person &person : _persons) {
+		if (person.place != -1) {
+			_places[person.place].isTaken = false;
+		}
+
+		clearCaption(person.notThereLine);
+
+		person.place = -1;
+		person.isAnnounced = false;
+		person.wasCalledOut = false;
+		person.hasMoved = false;
+	}
+
+	shuffleAnnouncements();
+}
+
+void StakeOutPuzzle::update(double elapsed) {
+	if (_calledPerson != -1 && !isSoundBlockPlaying(_places[_persons[_calledPerson].place].sound)) {
+		resolveCall(_calledPerson);
+		_calledPerson = -1;
+	}
+
+	if (_roundState == kAnnouncing) {
+		if (_clock > 0.0) {
+			// The pause between two persons
+			_clock += elapsed;
+			if (_clock >= _nextTime) {
+				_clock = 0.0;
+			}
+		} else if (!_announcements.empty() && announce(_persons[_announcements[0]])) {
+			_announcements.remove_at(0);
+
+			if (_announcements.empty()) {
+				startPeeking();
+			} else {
+				_clock += elapsed;
+				_nextTime = getRandomTime(_minAnnouncePause, _maxAnnouncePause) + _clock;
+			}
+		}
+
+		return;
+	}
+
+	// Everything stops while the player speaks
+	bool isEverybodyGone = true;
+	if (_isInputEnabled || _calledPerson == -1) {
+		_clock += elapsed;
+
+		for (Person &person : _persons) {
+			if (!updatePerson(person, elapsed)) {
+				isEverybodyGone = false;
+			}
+		}
+	}
+
+	if (_clock >= _nextTime && _roundState == kPeeking && isEverybodyGone) {
+		endRound();
+	}
+}
+
+bool StakeOutPuzzle::isClickable(const Person &person, const Common::Point &pos) const {
+	if (person.place == -1 || person.wasCalledOut) {
+		return false;
+	}
+
+	double fraction = 0.0;
+	const Phase phase = getPhase(person, fraction);
+	return (phase == kShowing || phase == kShown || phase == kHiding) && _places[person.place].dest.contains(pos);
+}
+
+// The frame to draw of the person's place: the ones for coming out,
+// followed by the ones for going back in. -1 while out of sight.
+int StakeOutPuzzle::getFrame(const Person &person) const {
+	if (_roundState == kAnnouncing || person.place == -1) {
+		return -1;
+	}
+
+	const Place &place = _places[person.place];
+	double fraction = 0.0;
+	const Phase phase = getPhase(person, fraction);
+
+	if (phase == kShown) {
+		fraction = 1.0;
+	} else if (phase != kShowing && phase != kHiding) {
+		return -1;
+	}
+
+	const int numFrames = phase == kHiding ? place.hideSrcs.size() : place.showSrcs.size();
+	if (numFrames == 0) {
+		return -1;
+	}
+
+	const int frame = MIN((int)(numFrames * CLIP(fraction, 0.0, 1.0)), numFrames - 1);
+	return phase == kHiding ? frame + place.showSrcs.size() : frame;
+}
+
+void StakeOutPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (uint i = 0; i < _persons.size(); ++i) {
+		const int frame = _shownFrames[i];
+		if (frame == -1) {
+			continue;
+		}
+
+		const Place &place = _places[_persons[i].place];
+		const int numShowFrames = place.showSrcs.size();
+		const Common::Rect &src = frame < numShowFrames ? place.showSrcs[frame] : place.hideSrcs[frame - numShowFrames];
+		_drawSurface.blitFrom(place.image, src, Common::Point(place.dest.left, place.dest.top));
+	}
+
+	_needsRedraw = true;
+}
+
+void StakeOutPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		const uint32 now = g_nancy->getTotalPlayTime();
+		const uint32 elapsed = now - _lastUpdateTime;
+		_lastUpdateTime = now;
+
+		if (elapsed != 0) {
+			update(elapsed / 1000.0);
+		}
+
+		bool hasChanged = false;
+		for (uint i = 0; i < _persons.size(); ++i) {
+			const int frame = getFrame(_persons[i]);
+			if (frame != _shownFrames[i]) {
+				_shownFrames[i] = frame;
+				hasChanged = true;
+			}
+		}
+
+		if (hasChanged) {
+			redraw();
+		}
+
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+void StakeOutPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun) {
+		return;
+	}
+
+	const Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	if (!_isInputEnabled) {
+		setDataCursor(kWaitCursor, false);
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	for (uint i = 0; i < _persons.size(); ++i) {
+		const Person &person = _persons[i];
+		if (!isClickable(person, pos)) {
+			continue;
+		}
+
+		setDataCursor(_hoverCursor);
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			// The player tells the others where the person is; whether that was
+			// the one to catch shows once the line is said
+			_calledPerson = i;
+			_isInputEnabled = false;
+			playSoundBlock(_places[person.place].sound);
+
+			TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+			if (table) {
+				const int16 index = person.isTarget ? _rightCallsTableIndex : _wrongCallsTableIndex;
+				table->setSingleValue(index, table->getValueOrZero(index) + 1);
+			}
+
+			input.eatMouseInput();
+		}
+
+		return;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/stakeoutpuzzle.h b/engines/nancy/action/puzzle/stakeoutpuzzle.h
new file mode 100644
index 00000000000..4e371143f9b
--- /dev/null
+++ b/engines/nancy/action/puzzle/stakeoutpuzzle.h
@@ -0,0 +1,172 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_STAKEOUTPUZZLE_H
+#define NANCY_ACTION_STAKEOUTPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy18 stakeout. A few people hide in a courtyard, one of them the person to
+// catch. A round starts with the others calling out over the radio where they
+// are, which a dialog lists; then everybody may peek out of their hiding place
+// for a while. Clicking a person who peeks out tells the others where that
+// person is. If it is the right one, somebody goes to look there, by which time
+// the person has moved on; if not, the one who got pointed out complains.
+// Either way a value of the value table counts it, and the scene takes it from there.
+class StakeOutPuzzle : public PuzzleRecord {
+public:
+	StakeOutPuzzle() : PuzzleRecord(7) {}
+	virtual ~StakeOutPuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "StakeOutPuzzle"; }
+
+	enum RoundState {
+		kAnnouncing,
+		kPeeking,
+		kCatching
+	};
+
+	// What a person is doing at a point of their time in a round
+	enum Phase {
+		kWaiting	= 0,
+		kShowing	= 1,
+		kShown		= 2,
+		kHiding		= 3,
+		kGone		= 4
+	};
+
+	struct Place {
+		// -- File data --
+		Common::String name;
+		Common::Rect dest;
+		Common::Path imageName;
+		Common::Array<Common::Rect> showSrcs;
+		Common::Array<Common::Rect> hideSrcs;
+		RandomSoundBlock sound;	// the player tells the others about this place
+
+		// -- Runtime --
+		Graphics::ManagedSurface image;
+		bool isTaken = false;
+	};
+
+	struct Person {
+		// -- File data --
+		Common::String name;
+		bool isTarget = false;
+		uint16 channel = 0;
+		RandomSoundBlock wrongCallSound;
+		RandomSoundBlock notThereSound;
+		RandomSoundBlock stopSound;
+
+		// -- Runtime --
+		int place = -1;
+		bool isAnnounced = false;
+		bool wasCalledOut = false;	// by mistake
+		bool isCatching = false;	// goes to look where the target was seen
+		bool hasMoved = false;		// to that place
+		double time = 0.0;			// all in seconds
+		double showStart = 0.0;
+		double shownTime = 0.0;
+		double hideStart = 0.0;
+		double goneTime = 0.0;
+		SoundDescription wrongCallLine;	// the ones that got played
+		SoundDescription notThereLine;
+	};
+
+	Phase getPhase(const Person &person, double &fraction) const;
+	void setPhase(Person &person, Phase phase, double fraction);
+	void hideNow(Person &person, Phase phase, double fraction);
+
+	double getRandomTime(double min, double max) const;
+	double getPeekTime(const Place &place) const;
+	int getRandomFreePlace() const;
+	void shuffleAnnouncements();
+
+	bool announce(Person &person);
+	void addText(const Common::String &key);
+	void removeTexts();
+	void clearCaption(const SoundDescription &line);
+
+	void update(double elapsed);
+	void startPeeking();
+	bool updatePerson(Person &person, double elapsed);
+	void moveToTargetPlace(Person &person);
+	void resolveCall(uint personID);
+	void endRound();
+
+	bool isClickable(const Person &person, const Common::Point &pos) const;
+	int getFrame(const Person &person) const;
+	void redraw();
+
+	// -- File data --
+	int16 _rightCallsTableIndex = -1;
+	int16 _wrongCallsTableIndex = -1;
+	Common::String _dialogName;
+	Common::String _textTemplate;
+	uint16 _hoverCursor = 0;
+	double _peekChance = 0.0;
+	double _lookTime = 0.0;				// how long the place of the target gets looked at
+	double _targetHideFactor = 0.0;		// of the time it takes to hide, for a target that got seen
+	double _minPeekWindow = 0.0;
+	double _maxPeekWindow = 0.0;
+	double _minAnnouncePause = 0.0;
+	double _maxAnnouncePause = 0.0;
+	double _minPeekDelay = 0.0;
+	double _maxPeekDelay = 0.0;
+	double _minRoundPause = 0.0;
+	double _maxRoundPause = 0.0;
+	Common::Array<Place> _places;
+	Common::Array<Person> _persons;
+
+	// -- Runtime --
+	RoundState _roundState = kAnnouncing;
+	Common::Array<uint> _announcements;	// the persons who still have to say where they are
+	double _clock = 0.0;
+	double _peekStart = 0.0;
+	double _peekEnd = 0.0;
+	double _nextTime = 0.0;				// the end of a pause, or of the round
+	bool _isInputEnabled = true;
+	int _calledPerson = -1;				// the player is saying where this one is
+	bool _hasSaidNotThere = false;
+	uint _numTexts = 0;
+	bool _isDialogShown = false;
+
+	uint32 _lastUpdateTime = 0;
+	Common::Array<int> _shownFrames;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_STAKEOUTPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 2870c542f8a..f9aab531555 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -102,6 +102,7 @@ MODULE_OBJS = \
   action/puzzle/soundequalizerpuzzle.o \
   action/puzzle/soundmatchpuzzle.o \
   action/puzzle/spigotpuzzle.o \
+  action/puzzle/stakeoutpuzzle.o \
   action/puzzle/stepobjectspuzzle.o \
   action/puzzle/tangrampuzzle.o \
   action/puzzle/telephone.o \
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index 67d2ec03221..f228e937fb8 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -491,6 +491,7 @@ void Manager::endConversation() {
 }
 
 void Manager::setCaption(const Common::String &text) {
+	_caption = text;
 	takeAction(kActionSetText, "CCText", text);
 }
 
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 403bf02ba3f..59fec5e0c13 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -120,6 +120,7 @@ public:
 	// Engine state shown by the UI
 	void update();
 	void setCaption(const Common::String &text);
+	const Common::String &getCaption() const { return _caption; }
 	void updatePlayerTime();
 	void createItemButtons();
 	void clickInventoryItem(int16 itemID) { onInventoryClicked(itemID); }
@@ -185,6 +186,7 @@ private:
 	InvButton *findItemButton(int16 itemID) const;
 
 	Common::Array<Panel *> _panels;
+	Common::String _caption;
 	Common::HashMap<Common::String, Graphics::ManagedSurface *, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _textures;
 	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _templates;
 


Commit: e98ceec88795128811c5f4728fff8d3c598d1b47
    https://github.com/scummvm/scummvm/commit/e98ceec88795128811c5f4728fff8d3c598d1b47
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:36+03:00

Commit Message:
NANCY: NANCY18: Add the Scopa card game

Scopa is the Italian card game Nancy plays against Enrico. Each player
holds three cards and four lie on the table. A card either takes table
cards that add up to its value or is left on the table, and taking the
last cards of the table is a "scopa". Once the deck runs out the round
is scored: most cards, most coins, the seven of coins, the best
"primes" and one point per scopa.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 25d49246b9b..c0cfbb282d6 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -103,6 +103,7 @@
 #include "engines/nancy/action/puzzle/rowmatchpuzzle.h"
 #include "engines/nancy/action/puzzle/safedialpuzzle.h"
 #include "engines/nancy/action/puzzle/scalepuzzle.h"
+#include "engines/nancy/action/puzzle/scopapuzzle.h"
 #include "engines/nancy/action/puzzle/setplayerclock.h"
 #include "engines/nancy/action/puzzle/sentrypuzzle.h"
 #include "engines/nancy/action/puzzle/stakeoutpuzzle.h"
@@ -833,7 +834,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new SentryPuzzle();
 	case 211:
-		return new CollisionPuzzle(CollisionPuzzle::PuzzleType::kCollision);
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new CollisionPuzzle(CollisionPuzzle::PuzzleType::kCollision);
+		else
+			return new ScopaPuzzle();
 	case 212:
 		if (g_nancy->getGameType() <= kGameTypeNancy17)
 			return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kOrderItems);
diff --git a/engines/nancy/action/puzzle/scopapuzzle.cpp b/engines/nancy/action/puzzle/scopapuzzle.cpp
new file mode 100644
index 00000000000..e0aecfffd62
--- /dev/null
+++ b/engines/nancy/action/puzzle/scopapuzzle.cpp
@@ -0,0 +1,1400 @@
+/* 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/algorithm.h"
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/font.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/scopapuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// What gets kept of a match between two rounds
+enum {
+	kSavedHumanPoints		= 0,
+	kSavedOpponentPoints	= 1,
+	kSavedHumanStarted		= 2,
+	kSavedSaidLastCardsLong	= 3,
+	kSavedSaidNoLastScopa	= 4,
+	kNumSavedValues			= 5
+};
+
+static const uint kNumSuits = 4;
+static const uint kNumValues = 10;
+static const uint kNumCards = kNumSuits * kNumValues;
+static const uint kHandSize = 3;
+static const uint kNumTableCards = 4;
+
+static const int kCoins = 0;	// the suit that counts for points
+static const int kKing = 10;
+static const int kSevenOfCoins = 6;
+
+// Three kings among the first cards on the table make a round unplayable
+static const uint kMaxKingsOnTable = 2;
+
+static const uint16 kWaitCursor = 23;
+
+static const uint32 kNoTint = 0xFFFFFFFF;
+static const uint32 kPrimesLineColor = 0xFFC8C83C;
+
+// The suits, in the order their best cards get shown in
+static const int kPrimeSuitOrder[kNumSuits] = { 0, 1, 3, 2 };
+
+static int getCardValue(int card) {
+	return card % kNumValues + 1;
+}
+
+static int getCardSuit(int card) {
+	return card / kNumValues;
+}
+
+// What a card counts for when the best one of each suit gets added up
+static int getPrimeValue(int card) {
+	static const int kPrimeValues[kNumValues] = { 16, 12, 13, 14, 15, 18, 21, 10, 10, 10 };
+	return kPrimeValues[getCardValue(card) - 1];
+}
+
+// How much the opponent wants a card
+static int getCardWorth(int card) {
+	static const int kWorths[kNumValues] = { 12, 4, 5, 6, 7, 23, 34, 3, 2, 1 };
+	return kWorths[getCardValue(card) - 1] + (getCardSuit(card) == kCoins ? 10 : 0);
+}
+
+// The opponent goes for the play with the most valuable card in it, then for
+// the one that takes the most cards
+struct ScopaPlayOrder {
+	template<typename T>
+	bool operator()(const T &a, const T &b) const {
+		if (a.taken.empty() != b.taken.empty()) {
+			return !a.taken.empty();
+		}
+
+		if (a.worth != b.worth) {
+			return a.worth > b.worth;
+		}
+
+		if (a.taken.size() != b.taken.size()) {
+			return a.taken.size() > b.taken.size();
+		}
+
+		if (a.handIndex != b.handIndex) {
+			return a.handIndex > b.handIndex;
+		}
+
+		for (uint i = 0; i < a.taken.size(); ++i) {
+			if (a.taken[i] != b.taken[i]) {
+				return a.taken[i] < b.taken[i];
+			}
+		}
+
+		return false;
+	}
+};
+
+// The order a player's cards get laid out in: by suit, the most wanted first
+struct ScopaCardOrder {
+	bool operator()(int a, int b) const {
+		if (getCardSuit(a) != getCardSuit(b)) {
+			return getCardSuit(a) < getCardSuit(b);
+		}
+
+		return getCardWorth(a) > getCardWorth(b);
+	}
+};
+
+void ScopaPuzzle::Fader::show(bool instantly) {
+	isShown = true;
+
+	if (fadeInTime == 0 || instantly) {
+		isFading = false;
+		alpha = 1.0f;
+	} else {
+		isFading = true;
+		lastTime = g_nancy->getTotalPlayTime();
+	}
+}
+
+void ScopaPuzzle::Fader::hide(bool instantly) {
+	isShown = false;
+
+	if (fadeOutTime == 0 || instantly) {
+		isFading = false;
+		alpha = 0.0f;
+	} else {
+		isFading = true;
+		lastTime = g_nancy->getTotalPlayTime();
+	}
+}
+
+float ScopaPuzzle::Fader::update() {
+	if (!isFading) {
+		return alpha;
+	}
+
+	const uint32 now = g_nancy->getTotalPlayTime();
+	const float elapsed = now - lastTime;
+	lastTime = now;
+
+	if (isShown) {
+		alpha += elapsed / fadeInTime;
+		if (alpha >= 1.0f) {
+			alpha = 1.0f;
+			isFading = false;
+		}
+	} else {
+		alpha -= elapsed / fadeOutTime;
+		if (alpha <= 0.0f) {
+			alpha = 0.0f;
+			isFading = false;
+		}
+	}
+
+	return alpha;
+}
+
+bool ScopaPuzzle::Card::isFading() {
+	current.fader.update();
+	previous.fader.update();
+	return current.fader.isFading || previous.fader.isFading;
+}
+
+void ScopaPuzzle::readPlayer(Common::SeekableReadStream &stream, Player &player) {
+	player.cursor = stream.readUint16LE();
+	player.winFlag = stream.readSint16LE();
+	player.nextStarterFlag = stream.readSint16LE();
+	readExclusiveRect(stream, player.pileSrc);
+	readExclusiveRect(stream, player.pileDest);
+	readExclusiveRect(stream, player.pointsRect);
+	player.fontID = stream.readSint32LE();
+	player.colorID = stream.readSint32LE();
+
+	for (uint i = 0; i < kNumPlayerSounds; ++i) {
+		player.sounds[i].readData(stream);
+	}
+}
+
+void ScopaPuzzle::readSlots(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &slots) {
+	slots.resize(stream.readUint16LE());
+	for (Common::Rect &slot : slots) {
+		readExclusiveRect(stream, slot);
+	}
+}
+
+void ScopaPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _saveName);
+	_pointsToWin = stream.readSint32LE();
+	_roundOverFlag = stream.readSint16LE();
+	_redealFlag = stream.readSint16LE();
+	readFilename(stream, _imageName);
+	_primesFontID = stream.readSint32LE();
+	_primesColorID = stream.readSint32LE();
+
+	for (uint i = 0; i < kNumPlayers; ++i) {
+		readPlayer(stream, _players[i]);
+	}
+
+	readExclusiveRect(stream, _takeSrc);
+	readExclusiveRect(stream, _takeDest);
+	readExclusiveRect(stream, _discardSrc);
+	readExclusiveRect(stream, _discardDest);
+
+	_thinkTime = stream.readSint32LE();
+	_dealTime = stream.readSint32LE();
+	_layOutTime = stream.readSint32LE();
+
+	_dealSound.readData(stream);
+	_discardSound.readData(stream);
+	_takeSound.readData(stream);
+	_wrongSumSound.readData(stream);
+	_hasToTakeSound.readData(stream);
+	_hasToTakeOneCardSound.readData(stream);
+	_noLastScopaSound.readData(stream);
+	_primesSound.readData(stream);
+	_primesTieSound.readData(stream);
+	_scopasSound.readData(stream);
+
+	readSlots(stream, _slots[kHumanHand]);
+	readSlots(stream, _slots[kOpponentHand]);
+	readSlots(stream, _slots[kTable]);
+	readSlots(stream, _slots[kLaidOut]);
+	readSlots(stream, _slots[kPrimes]);
+	readSlots(stream, _slots[kPrimeValues]);
+
+	readFilename(stream, _cardsImageName);
+	_fadeInTime = stream.readSint32LE();
+	_fadeOutTime = stream.readSint32LE();
+	_highlightColor = stream.readUint32BE();
+	readExclusiveRect(stream, _cardBackSrc);
+	readSlots(stream, _cardSrcs);
+
+	// Hands for testing, two 40 character lines each
+	stream.skip((stream.readUint16LE() / 2) * 80);
+}
+
+void ScopaPuzzle::init() {
+	// The cards fade, so the surface has an alpha channel instead of a transparent color
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(vpBounds.width(), vpBounds.height(), g_nancy->_graphics->getTransparentPixelFormat());
+	_drawSurface.clear(0);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+	g_nancy->_resource->loadImage(_cardsImageName, _cardsImage);
+
+	// The player who didn't start the last round starts this one
+	const auto *data = (const SavedStateData *)NancySceneState.getPuzzleData(SavedStateData::getTag());
+	assert(data);
+	if (!_saveName.empty() && data->states.contains(_saveName) && data->states[_saveName].size() == kNumSavedValues) {
+		const Common::Array<int32> &values = data->states[_saveName];
+		_players[kHuman].points = values[kSavedHumanPoints];
+		_players[kOpponent].points = values[kSavedOpponentPoints];
+		_starter = values[kSavedHumanStarted] ? kOpponent : kHuman;
+		_hasSaidLastCardsLong = values[kSavedSaidLastCardsLong];
+		_hasSaidNoLastScopa = values[kSavedSaidNoLastScopa];
+	}
+
+	_cards.clear();
+	_cards.resize(kNumCards);
+	for (Card &card : _cards) {
+		card.current.fader.fadeInTime = card.previous.fader.fadeInTime = _fadeInTime;
+		card.current.fader.fadeOutTime = card.previous.fader.fadeOutTime = _fadeOutTime;
+	}
+
+	_takeFader.fadeInTime = _discardFader.fadeInTime = _fadeInTime;
+	_takeFader.fadeOutTime = _discardFader.fadeOutTime = _fadeOutTime;
+
+	Common::Array<int> &deck = _piles[kDeck];
+	deck.resize(kNumCards);
+	for (uint i = 0; i < kNumCards; ++i) {
+		deck[i] = i;
+	}
+
+	for (uint i = kNumCards - 1; i > 0; --i) {
+		SWAP(deck[i], deck[g_nancy->_randomSource->getRandomNumber(i)]);
+	}
+
+	_piles[kLaidOut].resize(kNumCards, -1);
+
+	for (Player &player : _players) {
+		player.bestPrimeCards.resize(kNumSuits, -1);
+	}
+
+	_currentPlayer = _starter;
+	_phase = kDeal;
+	_isBusy = true;
+}
+
+// A pile of this kind keeps the place of a card that left it
+bool ScopaPuzzle::hasFixedPlaces(int pile) {
+	return pile == kHumanHand || pile == kOpponentHand || pile == kTable || pile == kLaidOut;
+}
+
+uint ScopaPuzzle::countCards(int pile) const {
+	uint count = 0;
+	for (int card : _piles[pile]) {
+		if (card != -1) {
+			++count;
+		}
+	}
+
+	return count;
+}
+
+// What the card showed until now fades out, unless the new picture just goes over it
+void ScopaPuzzle::setCardState(int cardID, CardMode mode, const Common::Rect &rect) {
+	Card &card = _cards[cardID];
+	if (card.current.mode == mode && card.current.rect == rect) {
+		return;
+	}
+
+	card.previous.mode = card.current.mode;
+	card.previous.rect = card.current.rect;
+	card.previous.fader.show(true);
+	if (card.current.rect != rect || mode == kHidden) {
+		card.previous.fader.hide(false);
+	}
+
+	card.current.fader.hide(true);
+	card.current.fader.show(false);
+	card.current.mode = mode;
+	card.current.rect = rect;
+
+	_hasChanged = true;
+}
+
+void ScopaPuzzle::setCardMode(int cardID, CardMode mode) {
+	setCardState(cardID, mode, Common::Rect(_cards[cardID].current.rect));
+}
+
+// Returns the card that got moved, -1 when there is none in that place
+int ScopaPuzzle::doMove(const Move &move) {
+	Common::Array<int> &src = _piles[move.srcPile];
+	if (move.srcIndex >= src.size() || src[move.srcIndex] == -1) {
+		return -1;
+	}
+
+	const int cardID = src[move.srcIndex];
+	if (hasFixedPlaces(move.srcPile)) {
+		src[move.srcIndex] = -1;
+	} else {
+		src.remove_at(move.srcIndex);
+	}
+
+	Common::Array<int> &dst = _piles[move.dstPile];
+	uint index = dst.size();
+
+	if (move.dstPile == kLaidOut) {
+		// A row for every suit
+		index = getCardSuit(cardID) * kNumValues;
+		for (int other : dst) {
+			if (other != -1 && getCardSuit(other) == getCardSuit(cardID)) {
+				++index;
+			}
+		}
+	} else if (hasFixedPlaces(move.dstPile)) {
+		for (uint i = 0; i < dst.size(); ++i) {
+			if (dst[i] == -1) {
+				index = i;
+				break;
+			}
+		}
+	}
+
+	if (index < dst.size()) {
+		dst[index] = cardID;
+	} else {
+		dst.push_back(cardID);
+	}
+
+	const Common::Array<Common::Rect> &slots = _slots[move.dstPile];
+	if (index < slots.size()) {
+		setCardState(cardID, move.mode, slots[index]);
+	} else {
+		setCardMode(cardID, move.mode);
+	}
+
+	return cardID;
+}
+
+void ScopaPuzzle::queueMove(int srcPile, uint srcIndex, int32 delay, int dstPile, CardMode mode) {
+	Move move;
+	move.srcPile = srcPile;
+	move.srcIndex = srcIndex;
+	move.delay = delay;
+	move.dstPile = dstPile;
+	move.mode = mode;
+	_moves.push_back(move);
+}
+
+// The next move only starts once there is nothing left to wait for.
+// Returns whether a card got moved.
+bool ScopaPuzzle::runMoves() {
+	bool hasMoved = false;
+
+	while (!_moves.empty() && updateWaits()) {
+		const Move move = _moves.front();
+		_moves.remove_at(0);
+		const int cardID = doMove(move);
+
+		if (move.delay != kWaitForCard) {
+			waitForTime(move.delay);
+		} else if (cardID != -1) {
+			Wait wait;
+			wait.type = Wait::kCard;
+			wait.card = cardID;
+			_waits.push_back(wait);
+		}
+
+		hasMoved = true;
+	}
+
+	return hasMoved;
+}
+
+// Returns whether everything the game waited for is over
+bool ScopaPuzzle::updateWaits() {
+	while (!_waits.empty()) {
+		const Wait &wait = _waits.front();
+
+		bool isOver = true;
+		switch (wait.type) {
+		case Wait::kCard:
+			isOver = !_cards[wait.card].isFading();
+			break;
+		case Wait::kTime:
+			isOver = g_nancy->getTotalPlayTime() >= wait.endTime;
+			break;
+		case Wait::kSound:
+			isOver = !g_nancy->_sound->isSoundPlaying(wait.sound);
+			break;
+		}
+
+		if (!isOver) {
+			return false;
+		}
+
+		if (wait.player != -1) {
+			_players[wait.player].points += wait.points;
+			_hasChanged = true;
+		}
+
+		_waits.remove_at(0);
+	}
+
+	return true;
+}
+
+void ScopaPuzzle::waitForTime(uint32 time, int player, int32 points) {
+	Wait wait;
+	wait.type = Wait::kTime;
+	wait.endTime = g_nancy->getTotalPlayTime() + time;
+	wait.player = player;
+	wait.points = points;
+	_waits.push_back(wait);
+}
+
+void ScopaPuzzle::waitForSound(const RandomSoundBlock &sound, int player, int32 points) {
+	Wait wait;
+	wait.type = Wait::kSound;
+	wait.sound = playSoundBlock(sound);
+	wait.player = player;
+	wait.points = points;
+	_waits.push_back(wait);
+}
+
+// The sound of cards being dealt goes on until the last one is out
+void ScopaPuzzle::startDealSound() {
+	if (_dealSound.names.empty()) {
+		return;
+	}
+
+	_playingDealSound.name = _dealSound.names[g_nancy->_randomSource->getRandomNumber(_dealSound.names.size() - 1)];
+	_playingDealSound.channelID = _dealSound.channel;
+	_playingDealSound.numLoops = _dealSound.numLoops;
+	_playingDealSound.volume = _dealSound.volume;
+
+	g_nancy->_sound->loadSound(_playingDealSound);
+	g_nancy->_sound->playSound(_playingDealSound);
+}
+
+void ScopaPuzzle::stopDealSound() {
+	g_nancy->_sound->stopSound(_playingDealSound);
+	_playingDealSound = SoundDescription();
+}
+
+// Three cards each, one at a time, and four more on the table when the round begins
+void ScopaPuzzle::dealCards() {
+	const uint other = getOtherPlayer(_starter);
+
+	for (uint i = 0; i < kHandSize; ++i) {
+		queueMove(kDeck, 0, _dealTime, getHandPile(_starter), _starter == kHuman ? kFaceUp : kFaceDown);
+		queueMove(kDeck, 0, _dealTime, getHandPile(other), other == kHuman ? kFaceUp : kFaceDown);
+	}
+
+	if (countCards(kTable) == 0 && countCards(kHumanTaken) == 0 && countCards(kOpponentTaken) == 0) {
+		const Common::Array<int> &deck = _piles[kDeck];
+		const uint firstTableCard = kHandSize * kNumPlayers;
+		uint numKings = 0;
+
+		for (uint i = firstTableCard; i < firstTableCard + kNumTableCards && i < deck.size(); ++i) {
+			if (getCardValue(deck[i]) == kKing) {
+				++numKings;
+			}
+		}
+
+		if (numKings > kMaxKingsOnTable) {
+			_needsRedeal = true;
+		}
+
+		for (uint i = 0; i < kNumTableCards; ++i) {
+			queueMove(kDeck, 0, _dealTime, kTable, kFaceUp);
+		}
+	}
+
+	_isBusy = true;
+}
+
+ScopaPuzzle::PlayError ScopaPuzzle::checkPlay(const Play &play) const {
+	const Common::Array<int> &table = _piles[kTable];
+	const int value = getCardValue(play.card);
+
+	// The cards on the table that could be taken with this one
+	Common::Array<int> values;
+	for (int card : table) {
+		if (card != -1) {
+			values.push_back(getCardValue(card));
+		}
+	}
+
+	if (play.taken.empty()) {
+		for (uint mask = 1; mask < (1U << values.size()); ++mask) {
+			int sum = 0;
+			for (uint i = 0; i < values.size(); ++i) {
+				if (mask & (1 << i)) {
+					sum += values[i];
+				}
+			}
+
+			if (sum == value) {
+				return kHasToTake;
+			}
+		}
+
+		return kPlayAllowed;
+	}
+
+	int sum = 0;
+	for (uint index : play.taken) {
+		sum += getCardValue(table[index]);
+	}
+
+	if (sum != value) {
+		return kWrongSum;
+	}
+
+	if (play.taken.size() > 1) {
+		for (int tableValue : values) {
+			if (tableValue == value) {
+				return kHasToTakeOneCard;
+			}
+		}
+	}
+
+	return kPlayAllowed;
+}
+
+// Every play the rules allow the current player
+void ScopaPuzzle::findPlays(Common::Array<Play> &plays) const {
+	const Common::Array<int> &table = _piles[kTable];
+	const Common::Array<int> &hand = _piles[getHandPile(_currentPlayer)];
+
+	Common::Array<uint> places;
+	for (uint i = 0; i < table.size(); ++i) {
+		if (table[i] != -1) {
+			places.push_back(i);
+		}
+	}
+
+	for (uint handIndex = 0; handIndex < hand.size(); ++handIndex) {
+		if (hand[handIndex] == -1) {
+			continue;
+		}
+
+		for (uint mask = 0; mask < (1U << places.size()); ++mask) {
+			Play play;
+			play.card = hand[handIndex];
+			play.handIndex = handIndex;
+			play.worth = getCardWorth(play.card);
+
+			for (uint i = 0; i < places.size(); ++i) {
+				if (mask & (1 << i)) {
+					play.taken.push_back(places[i]);
+					play.worth = MAX(play.worth, getCardWorth(table[places[i]]));
+				}
+			}
+
+			if (checkPlay(play) == kPlayAllowed) {
+				plays.push_back(play);
+			}
+		}
+	}
+}
+
+// The cards a player takes go out of sight, a card that takes nothing goes to the table
+void ScopaPuzzle::makePlay(const Play &play) {
+	int dstPile = kTable;
+	CardMode mode = kFaceUp;
+
+	if (play.taken.empty()) {
+		playSoundBlock(_discardSound);
+	} else {
+		_lastTaker = _currentPlayer;
+		dstPile = getTrickPile(_currentPlayer);
+		mode = kHidden;
+		playSoundBlock(_takeSound);
+	}
+
+	queueMove(getHandPile(_currentPlayer), play.handIndex, 0, dstPile, mode);
+	for (uint index : play.taken) {
+		queueMove(kTable, index, 0, dstPile, mode);
+	}
+
+	_moves.back().delay = kWaitForCard;
+	_isBusy = true;
+}
+
+// The opponent shows what it is going to play, and then takes its time
+void ScopaPuzzle::startTurn() {
+	if (_currentPlayer != kOpponent) {
+		return;
+	}
+
+	Common::Array<Play> plays;
+	findPlays(plays);
+	if (plays.empty()) {
+		return;
+	}
+
+	// With nothing to take, the card that is wanted the least gets left on the table
+	Common::sort(plays.begin(), plays.end(), ScopaPlayOrder());
+	const Play play = plays[0].taken.empty() ? plays.back() : plays[0];
+
+	setCardMode(play.card, kHighlighted);
+	for (uint index : play.taken) {
+		setCardMode(_piles[kTable][index], kHighlighted);
+	}
+
+	waitForTime(_thinkTime);
+	makePlay(play);
+}
+
+void ScopaPuzzle::switchPlayer() {
+	_currentPlayer = getOtherPlayer(_currentPlayer);
+}
+
+// A player with cards has a pile of them lying next to the table
+void ScopaPuzzle::showPilePictures(bool show) {
+	for (uint i = 0; i < kNumPlayers; ++i) {
+		Fader &fader = _players[i].pileFader;
+
+		if (!show) {
+			fader.hide(false);
+		} else if (countCards(getTakenPile(i)) > 0 || (countCards(getTrickPile(i)) > 0 && !fader.isVisible())) {
+			fader.show(false);
+		}
+	}
+
+	_hasChanged = true;
+}
+
+// One of the two buttons is there while a card of the hand is picked,
+// depending on whether cards of the table are, too
+void ScopaPuzzle::updateButtons() {
+	Common::Array<uint> handCards, tableCards;
+	findSelectedCards(getHandPile(_currentPlayer), handCards);
+	findSelectedCards(kTable, tableCards);
+
+	const bool showsDiscard = !handCards.empty() && tableCards.empty();
+	if (showsDiscard != _showsDiscard) {
+		_showsDiscard = showsDiscard;
+		if (showsDiscard) {
+			_discardFader.show(false);
+		} else {
+			_discardFader.hide(false);
+		}
+	}
+
+	const bool showsTake = !handCards.empty() && !tableCards.empty();
+	if (showsTake != _showsTake) {
+		_showsTake = showsTake;
+		if (showsTake) {
+			_takeFader.show(false);
+		} else {
+			_takeFader.hide(false);
+		}
+	}
+
+	_hasChanged = true;
+}
+
+void ScopaPuzzle::moveTricksToTaken() {
+	for (uint i = 0; i < kNumPlayers; ++i) {
+		for (uint j = 0; j < _piles[getTrickPile(i)].size(); ++j) {
+			queueMove(getTrickPile(i), 0, 0, getTakenPile(i), kHidden);
+		}
+	}
+}
+
+// What is left on the table goes to whoever took cards last
+void ScopaPuzzle::takeLastCards() {
+	if (_lastTaker == -1) {
+		return;
+	}
+
+	const Common::Array<int> &table = _piles[kTable];
+	bool hasCardsLeft = false;
+
+	for (uint i = 0; i < table.size(); ++i) {
+		if (table[i] != -1) {
+			queueMove(kTable, i, 0, getTakenPile(_lastTaker), kHidden);
+			hasCardsLeft = true;
+		}
+	}
+
+	if (hasCardsLeft) {
+		_moves.back().delay = kWaitForCard;
+		waitForSound(_players[_lastTaker].sounds[_hasSaidLastCardsLong ? kSoundLastCards : kSoundLastCardsLong]);
+		_hasSaidLastCardsLong = true;
+	}
+}
+
+// Clearing the table is a scopa, but not with the last card of the round
+void ScopaPuzzle::afterPlay() {
+	const bool isDeckEmpty = countCards(kDeck) == 0;
+	const bool isLastHandEmpty = countCards(getHandPile(getOtherPlayer(_starter))) == 0;
+
+	if (!_hasCheckedScopa && countCards(kTable) == 0) {
+		if (!isDeckEmpty || !isLastHandEmpty) {
+			++_players[_currentPlayer].numScopas;
+			waitForSound(_players[_currentPlayer].sounds[kSoundScopa]);
+		} else if (!_hasSaidNoLastScopa) {
+			waitForSound(_noLastScopaSound);
+			_hasSaidNoLastScopa = true;
+		}
+
+		_hasCheckedScopa = true;
+		_isBusy = true;
+		return;
+	}
+
+	_hasCheckedScopa = false;
+
+	if (!isLastHandEmpty) {
+		showPilePictures(true);
+		switchPlayer();
+		startTurn();
+		return;
+	}
+
+	moveTricksToTaken();
+
+	if (!isDeckEmpty) {
+		_phase = kDeal;
+	} else {
+		takeLastCards();
+		_phase = kTally;
+	}
+
+	_isBusy = true;
+}
+
+void ScopaPuzzle::tallyPlayer(Player &player, int takenPile) {
+	Common::Array<int> bestPrimes;
+	bestPrimes.resize(kNumSuits, 0);
+
+	for (int card : _piles[takenPile]) {
+		const int suit = getCardSuit(card);
+		++player.numCards;
+
+		if (suit == kCoins) {
+			++player.numCoins;
+			if (card == kSevenOfCoins) {
+				player.scoresSevenOfCoins = true;
+			}
+		}
+
+		if (getPrimeValue(card) > bestPrimes[suit]) {
+			bestPrimes[suit] = getPrimeValue(card);
+			player.bestPrimeCards[suit] = card;
+		}
+	}
+
+	for (uint suit = 0; suit < kNumSuits; ++suit) {
+		player.primes += bestPrimes[suit];
+	}
+}
+
+void ScopaPuzzle::tallyRound() {
+	Player &human = _players[kHuman];
+	Player &opponent = _players[kOpponent];
+
+	tallyPlayer(human, kHumanTaken);
+	tallyPlayer(opponent, kOpponentTaken);
+
+	if (human.numCards > opponent.numCards) {
+		human.scoresMostCards = human.hasScoreLine = true;
+	} else if (opponent.numCards > human.numCards) {
+		opponent.scoresMostCards = opponent.hasScoreLine = true;
+	}
+
+	if (human.numCoins > opponent.numCoins) {
+		human.scoresMostCoins = human.hasScoreLine = true;
+	} else if (opponent.numCoins > human.numCoins) {
+		opponent.scoresMostCoins = opponent.hasScoreLine = true;
+	}
+
+	if (human.scoresSevenOfCoins) {
+		human.hasScoreLine = true;
+	} else {
+		opponent.hasScoreLine = true;
+	}
+
+	if (human.primes > opponent.primes) {
+		human.scoresPrimes = true;
+	} else if (opponent.primes > human.primes) {
+		opponent.scoresPrimes = true;
+	}
+}
+
+// For the scoring a player's cards get laid out in the place of the game
+void ScopaPuzzle::layOutTakenCards(uint playerID) {
+	Common::Array<int> &taken = _piles[getTakenPile(playerID)];
+	Common::sort(taken.begin(), taken.end(), ScopaCardOrder());
+
+	for (uint i = 0; i < taken.size(); ++i) {
+		queueMove(getTakenPile(playerID), 0, _layOutTime, kLaidOut, kFaceUp);
+	}
+
+	startDealSound();
+}
+
+void ScopaPuzzle::highlightLaidOutCards(Highlight highlight) {
+	for (int card : _piles[kLaidOut]) {
+		if (card == -1) {
+			continue;
+		}
+
+		bool isHighlighted = true;
+		if (highlight == kCoinCards) {
+			isHighlighted = getCardSuit(card) == kCoins;
+		} else if (highlight == kSevenOfCoinsCard) {
+			isHighlighted = card == kSevenOfCoins;
+		}
+
+		setCardMode(card, isHighlighted ? kHighlighted : kFaceUp);
+	}
+}
+
+// The best card of every suit is put aside, out of sight for now, and the rest goes away
+void ScopaPuzzle::movePrimes(uint playerID) {
+	Common::Array<int> &laidOut = _piles[kLaidOut];
+
+	for (uint i = 0; i < kNumSuits; ++i) {
+		const int card = _players[playerID].bestPrimeCards[kPrimeSuitOrder[i]];
+
+		for (uint j = 0; j < laidOut.size(); ++j) {
+			if (card != -1 && laidOut[j] == card) {
+				Move move;
+				move.srcPile = kLaidOut;
+				move.srcIndex = j;
+				move.dstPile = kPrimes;
+				move.mode = kHidden;
+				doMove(move);
+				break;
+			}
+		}
+	}
+
+	for (uint i = 0; i < laidOut.size(); ++i) {
+		if (laidOut[i] != -1) {
+			Card &card = _cards[laidOut[i]];
+			card.current.mode = kHidden;
+			card.current.fader.hide(true);
+			card.previous.fader.hide(true);
+			laidOut[i] = -1;
+		}
+	}
+
+	_hasChanged = true;
+}
+
+// A line for every point the cards of the player are worth, with the cards the
+// point is for picked out. Returns true once both players are through.
+bool ScopaPuzzle::scorePlayerStep() {
+	stopDealSound();
+
+	Player &player = _players[_currentPlayer];
+	const RandomSoundBlock *sound = nullptr;
+	int32 points = 1;
+
+	if (player.scoresMostCards) {
+		highlightLaidOutCards(kAllCards);
+		sound = &player.sounds[kSoundMostCards];
+		player.scoresMostCards = false;
+	} else if (player.scoresMostCoins) {
+		highlightLaidOutCards(kCoinCards);
+		sound = &player.sounds[kSoundMostCoins];
+		player.scoresMostCoins = false;
+	} else if (player.scoresSevenOfCoins) {
+		highlightLaidOutCards(kSevenOfCoinsCard);
+		sound = &player.sounds[kSoundSevenOfCoins];
+		player.scoresSevenOfCoins = false;
+	} else if (!player.hasScoreLine) {
+		sound = &player.sounds[kSoundNoPoints];
+		points = 0;
+		player.hasScoreLine = true;
+	}
+
+	if (sound) {
+		waitForSound(*sound, _currentPlayer, points);
+		return false;
+	}
+
+	movePrimes(_currentPlayer);
+	if (_currentPlayer != kHuman) {
+		return true;
+	}
+
+	switchPlayer();
+	layOutTakenCards(_currentPlayer);
+	return false;
+}
+
+// The cards put aside are shown with what they count for, then the sums, and
+// last come the scopas. Returns true once there is nothing left to score.
+bool ScopaPuzzle::scorePrimesStep() {
+	if (!_showsPrimes) {
+		_showsPrimes = true;
+
+		for (int card : _piles[kPrimes]) {
+			setCardMode(card, kFaceUp);
+		}
+
+		waitForSound(_primesSound);
+		_hasChanged = true;
+		return false;
+	}
+
+	if (!_showsPrimeTotals) {
+		_showsPrimeTotals = true;
+
+		if (_players[kHuman].scoresPrimes) {
+			waitForSound(_players[kHuman].sounds[kSoundBestPrimes], kHuman, 1);
+		} else if (_players[kOpponent].scoresPrimes) {
+			waitForSound(_players[kOpponent].sounds[kSoundBestPrimes], kOpponent, 1);
+		} else {
+			waitForSound(_primesTieSound);
+		}
+
+		_hasChanged = true;
+		return false;
+	}
+
+	if (_players[kHuman].numScopas < 1 && _players[kOpponent].numScopas < 1) {
+		return true;
+	}
+
+	waitForSound(_scopasSound, kHuman, _players[kHuman].numScopas);
+	waitForTime(0, kOpponent, _players[kOpponent].numScopas);
+	_players[kHuman].numScopas = 0;
+	_players[kOpponent].numScopas = 0;
+	return false;
+}
+
+// The match is won with enough points, and more of them than the other player has
+void ScopaPuzzle::finishRound() {
+	const int32 humanPoints = _players[kHuman].points;
+	const int32 opponentPoints = _players[kOpponent].points;
+
+	int16 flag = _roundOverFlag;
+	if (humanPoints >= _pointsToWin && humanPoints > opponentPoints) {
+		flag = _players[kHuman].winFlag;
+	} else if (opponentPoints >= _pointsToWin && opponentPoints > humanPoints) {
+		flag = _players[kOpponent].winFlag;
+	}
+
+	NancySceneState.setEventFlag(flag, g_nancy->_true);
+	NancySceneState.setEventFlag(_players[getOtherPlayer(_starter)].nextStarterFlag, g_nancy->_true);
+
+	if (!_saveName.empty()) {
+		auto *data = (SavedStateData *)NancySceneState.getPuzzleData(SavedStateData::getTag());
+		Common::Array<int32> &values = data->states[_saveName];
+		values.resize(kNumSavedValues);
+		values[kSavedHumanPoints] = humanPoints;
+		values[kSavedOpponentPoints] = opponentPoints;
+		values[kSavedHumanStarted] = _starter == kHuman;
+		values[kSavedSaidLastCardsLong] = _hasSaidLastCardsLong;
+		values[kSavedSaidNoLastScopa] = _hasSaidNoLastScopa;
+	}
+}
+
+// What comes next once the cards have stopped and the lines are said
+void ScopaPuzzle::runPhase() {
+	switch (_phase) {
+	case kDeal:
+		dealCards();
+		startDealSound();
+		_hasCheckedScopa = false;
+		_phase = kDealing;
+		break;
+	case kDealing:
+		if (!_moves.empty()) {
+			break;
+		}
+
+		stopDealSound();
+		_currentPlayer = _starter;
+		_phase = kPlaying;
+
+		if (_needsRedeal) {
+			NancySceneState.setEventFlag(_redealFlag, g_nancy->_true);
+			_state = kActionTrigger;
+		} else {
+			startTurn();
+		}
+
+		break;
+	case kPlaying:
+		afterPlay();
+		break;
+	case kTally:
+		tallyRound();
+		_currentPlayer = kHuman;
+		layOutTakenCards(kHuman);
+		showPilePictures(false);
+		_phase = kScorePlayers;
+		_isBusy = true;
+		break;
+	case kScorePlayers:
+		if (scorePlayerStep()) {
+			_phase = kScorePrimes;
+		}
+
+		_isBusy = true;
+		break;
+	case kScorePrimes:
+		if (scorePrimesStep()) {
+			finishRound();
+			_state = kActionTrigger;
+		} else {
+			_isBusy = true;
+		}
+
+		break;
+	}
+}
+
+void ScopaPuzzle::blendPixel(int x, int y, byte a, byte r, byte g, byte b) {
+	if (a == 0 || x < 0 || y < 0 || x >= _drawSurface.w || y >= _drawSurface.h) {
+		return;
+	}
+
+	uint32 *dst = (uint32 *)_drawSurface.getBasePtr(x, y);
+	byte dstA, dstR, dstG, dstB;
+	_drawSurface.format.colorToARGB(*dst, dstA, dstR, dstG, dstB);
+
+	const uint under = dstA * (255 - a) / 255;
+	const uint outA = a + under;
+	*dst = _drawSurface.format.ARGBToColor(outA,
+		(r * a + dstR * under) / outA,
+		(g * a + dstG * under) / outA,
+		(b * a + dstB * under) / outA);
+}
+
+// Draws a part of an image, with its colors and its opacity scaled
+void ScopaPuzzle::blitFaded(const Graphics::ManagedSurface &image, const Common::Rect &src, const Common::Point &dest, uint32 argb, float alpha) {
+	if (image.empty() || alpha <= 0.0f) {
+		return;
+	}
+
+	byte tintA, tintR, tintG, tintB;
+	kARGBFormat.colorToARGB(argb, tintA, tintR, tintG, tintB);
+	const uint scaledA = (uint)(tintA * MIN(alpha, 1.0f));
+
+	Common::Rect clipped = src;
+	clipped.clip(Common::Rect(image.w, image.h));
+
+	for (int y = clipped.top; y < clipped.bottom; ++y) {
+		for (int x = clipped.left; x < clipped.right; ++x) {
+			byte a, r, g, b;
+			image.format.colorToARGB(image.getPixel(x, y), a, r, g, b);
+			if (a == 0) {
+				continue;
+			}
+
+			blendPixel(dest.x + x - src.left, dest.y + y - src.top, a * scaledA / 255,
+				r * tintR / 255, g * tintG / 255, b * tintB / 255);
+		}
+	}
+}
+
+void ScopaPuzzle::drawCardState(int cardID, CardState &state) {
+	const float alpha = state.fader.update();
+	const Common::Point dest(state.rect.left, state.rect.top);
+
+	switch (state.mode) {
+	case kFaceUp:
+		blitFaded(_cardsImage, _cardSrcs[cardID], dest, kNoTint, alpha);
+		break;
+	case kFaceDown:
+		blitFaded(_cardsImage, _cardBackSrc, dest, kNoTint, alpha);
+		break;
+	case kHighlighted:
+		blitFaded(_cardsImage, _cardSrcs[cardID], dest, _highlightColor, alpha);
+		break;
+	case kHidden:
+		break;
+	}
+}
+
+// The text is drawn in white on black first, which tells how much of every pixel it covers
+void ScopaPuzzle::drawText(const Common::String &text, const Common::Rect &rect, uint fontID, uint colorID, Graphics::TextAlign align) {
+	const Font *font = g_nancy->_graphics->getFont(fontID);
+	const Graphics::Font *systemFont = font ? font->getSystemFont() : nullptr;
+	if (!systemFont || rect.isEmpty()) {
+		return;
+	}
+
+	Graphics::ManagedSurface mask(rect.width(), rect.height(), _drawSurface.format);
+	mask.clear(mask.format.ARGBToColor(0xFF, 0, 0, 0));
+	systemFont->drawString(&mask, text, 0, (rect.height() - systemFont->getFontHeight()) / 2, rect.width(),
+		mask.format.ARGBToColor(0xFF, 0xFF, 0xFF, 0xFF), align);
+
+	byte a, r, g, b;
+	_drawSurface.format.colorToARGB(g_nancy->_graphics->getFontColor(colorID, _drawSurface.format), a, r, g, b);
+
+	for (int y = 0; y < mask.h; ++y) {
+		for (int x = 0; x < mask.w; ++x) {
+			byte coverA, coverR, coverG, coverB;
+			mask.format.colorToARGB(mask.getPixel(x, y), coverA, coverR, coverG, coverB);
+			blendPixel(rect.left + x, rect.top + y, coverG, r, g, b);
+		}
+	}
+}
+
+void ScopaPuzzle::redraw() {
+	_drawSurface.clear(0);
+	_isFading = false;
+
+	// The cards a player took make a pile next to them
+	for (Player &player : _players) {
+		blitFaded(_image, player.pileSrc, Common::Point(player.pileDest.left, player.pileDest.top), kNoTint, player.pileFader.update());
+		_isFading = _isFading || player.pileFader.isFading;
+	}
+
+	for (const Player &player : _players) {
+		drawText(Common::String::format("%d", player.points), player.pointsRect, player.fontID, player.colorID, Graphics::kTextAlignRight);
+	}
+
+	for (uint pile = 0; pile < kNumPiles; ++pile) {
+		for (int cardID : _piles[pile]) {
+			if (cardID == -1) {
+				continue;
+			}
+
+			Card &card = _cards[cardID];
+			drawCardState(cardID, card.previous);
+			drawCardState(cardID, card.current);
+			_isFading = _isFading || card.current.fader.isFading || card.previous.fader.isFading;
+		}
+	}
+
+	blitFaded(_image, _takeSrc, Common::Point(_takeDest.left, _takeDest.top), kNoTint, _takeFader.update());
+	blitFaded(_image, _discardSrc, Common::Point(_discardDest.left, _discardDest.top), kNoTint, _discardFader.update());
+	_isFading = _isFading || _takeFader.isFading || _discardFader.isFading;
+
+	if (_showsPrimes) {
+		// Next to every card that got put aside, what it counts for
+		const Common::Array<Common::Rect> &slots = _slots[kPrimeValues];
+		const Common::Array<int> &primes = _piles[kPrimes];
+
+		for (uint i = 0; i < primes.size() && i < slots.size(); ++i) {
+			drawText(Common::String::format("%d", getPrimeValue(primes[i])), slots[i], _primesFontID, _primesColorID, Graphics::kTextAlignCenter);
+		}
+
+		// The sums go under a line, in the two places that come after
+		if (_showsPrimeTotals && primes.size() + 1 < slots.size()) {
+			const Common::Rect &humanRect = slots[primes.size()];
+			const Common::Rect &opponentRect = slots[primes.size() + 1];
+
+			_drawSurface.fillRect(Common::Rect(opponentRect.left, humanRect.top, humanRect.right, humanRect.top + 1),
+				argbToColor(_drawSurface.format, kPrimesLineColor));
+
+			drawText(Common::String::format("%d", _players[kHuman].primes), humanRect, _primesFontID, _primesColorID, Graphics::kTextAlignCenter);
+			drawText(Common::String::format("%d", _players[kOpponent].primes), opponentRect, _primesFontID, _primesColorID, Graphics::kTextAlignCenter);
+		}
+	}
+
+	_hasChanged = false;
+	_needsRedraw = true;
+}
+
+void ScopaPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		_isBusy = runMoves() || _isBusy;
+
+		if (_isBusy && updateWaits()) {
+			_isBusy = false;
+			runPhase();
+		}
+
+		if (_hasChanged || _isFading) {
+			redraw();
+		}
+
+		break;
+	case kActionTrigger:
+		_isDone = true;
+		break;
+	}
+}
+
+void ScopaPuzzle::findSelectedCards(int pile, Common::Array<uint> &indices) const {
+	const Common::Array<int> &cards = _piles[pile];
+	for (uint i = 0; i < cards.size(); ++i) {
+		if (cards[i] != -1 && _cards[cards[i]].current.mode == kHighlighted) {
+			indices.push_back(i);
+		}
+	}
+}
+
+int ScopaPuzzle::getHoveredCard(int pile, const Common::Point &pos) const {
+	const Common::Array<int> &cards = _piles[pile];
+	for (uint i = 0; i < cards.size(); ++i) {
+		if (cards[i] != -1 && _cards[cards[i]].current.rect.contains(pos)) {
+			return i;
+		}
+	}
+
+	return -1;
+}
+
+// A click picks a card or lets go of it. Only one card of the hand can be picked.
+void ScopaPuzzle::clickCard(int pile, uint index) {
+	const int cardID = _piles[pile][index];
+	const CardMode mode = _cards[cardID].current.mode;
+
+	if (pile != kTable && mode != kHighlighted) {
+		for (int other : _piles[pile]) {
+			if (other != -1) {
+				setCardMode(other, kFaceUp);
+			}
+		}
+	}
+
+	if (mode == kFaceUp) {
+		setCardMode(cardID, kHighlighted);
+	} else if (mode == kHighlighted) {
+		setCardMode(cardID, kFaceUp);
+	}
+
+	updateButtons();
+}
+
+void ScopaPuzzle::tryPlay(bool sayWhyNotToDiscard) {
+	const int handPile = getHandPile(_currentPlayer);
+	Common::Array<uint> handCards;
+	findSelectedCards(handPile, handCards);
+	if (handCards.empty()) {
+		return;
+	}
+
+	Play play;
+	play.handIndex = handCards[0];
+	play.card = _piles[handPile][play.handIndex];
+	findSelectedCards(kTable, play.taken);
+
+	switch (checkPlay(play)) {
+	case kPlayAllowed:
+		// The line about the play that wasn't allowed is cut short
+		g_nancy->_sound->stopSound(_errorSound);
+		_errorSound = SoundDescription();
+		makePlay(play);
+
+		_showsTake = _showsDiscard = false;
+		_takeFader.hide(false);
+		_discardFader.hide(false);
+		_hasChanged = true;
+		break;
+	case kWrongSum:
+		_errorSound = playSoundBlock(_wrongSumSound);
+		break;
+	case kHasToTake:
+		if (sayWhyNotToDiscard) {
+			_errorSound = playSoundBlock(_hasToTakeSound);
+		}
+
+		break;
+	case kHasToTakeOneCard:
+		_errorSound = playSoundBlock(_hasToTakeOneCardSound);
+		break;
+	}
+}
+
+void ScopaPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun) {
+		return;
+	}
+
+	const Common::Rect viewportBounds = NancySceneState.getViewport().getBounds();
+	if (!NancySceneState.getViewport().convertViewportToScreen(viewportBounds).contains(input.mousePos)) {
+		return;
+	}
+
+	if (_phase != kPlaying) {
+		setDataCursor(kWaitCursor, false);
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	// Only the player's own cards and the ones on the table can be picked
+	int hoveredPile = -1;
+	int hoveredIndex = -1;
+	if (_currentPlayer == kHuman) {
+		hoveredPile = kHumanHand;
+		hoveredIndex = getHoveredCard(kHumanHand, pos);
+		if (hoveredIndex == -1) {
+			hoveredPile = kTable;
+			hoveredIndex = getHoveredCard(kTable, pos);
+		}
+	}
+
+	const bool isOverDiscard = _showsDiscard && _discardDest.contains(pos);
+	const bool isOverTake = _showsTake && _takeDest.contains(pos);
+
+	setDataCursor(_players[_currentPlayer].cursor, (hoveredIndex != -1 && !_hasCheckedScopa) || isOverDiscard || isOverTake);
+
+	if (_isBusy || !(input.input & NancyInput::kLeftMouseButtonUp)) {
+		return;
+	}
+
+	if (hoveredIndex != -1) {
+		clickCard(hoveredPile, hoveredIndex);
+		input.eatMouseInput();
+		return;
+	}
+
+	// A card can also be left on the table by clicking a place of the table
+	bool isOverTable = false;
+	if (_showsDiscard) {
+		for (const Common::Rect &slot : _slots[kTable]) {
+			isOverTable = isOverTable || slot.contains(pos);
+		}
+	}
+
+	if (isOverDiscard || isOverTake || isOverTable) {
+		tryPlay(!isOverTable);
+		input.eatMouseInput();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/scopapuzzle.h b/engines/nancy/action/puzzle/scopapuzzle.h
new file mode 100644
index 00000000000..0f052b26174
--- /dev/null
+++ b/engines/nancy/action/puzzle/scopapuzzle.h
@@ -0,0 +1,340 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_SCOPAPUZZLE_H
+#define NANCY_ACTION_SCOPAPUZZLE_H
+
+#include "graphics/font.h"
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Scopa is the Italian card game Nancy plays against Enrico in Nancy18. Each
+// player holds three cards and four lie on the table. A card either takes table
+// cards that add up to its value or is left on the table, and taking the last
+// cards of the table is a "scopa". Once the deck runs out the round is scored:
+// most cards, most coins, the seven of coins, the best "primes" and one point
+// per scopa.
+//
+// The record plays one round and then ends, with an event flag telling whether
+// somebody has won the match. The points are kept under the puzzle's name for
+// the next round.
+//
+// The cards lie in numbered piles, and get from one to another through a queue
+// of moves. A card doesn't travel: it fades out where it was and in where it
+// goes, and a pile without places of its own leaves the card where it is.
+class ScopaPuzzle : public PuzzleRecord {
+public:
+	ScopaPuzzle() : PuzzleRecord(7) {}
+	virtual ~ScopaPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "ScopaPuzzle"; }
+
+	static const uint kNumPlayers = 2;
+
+	enum { kHuman = 0, kOpponent = 1 };
+
+	// A player's piles follow each other: hand, taken, trick
+	enum Pile {
+		kHumanHand		= 0,
+		kHumanTaken		= 1,
+		kHumanTrick		= 2,	// taken since the last deal
+		kOpponentHand	= 3,
+		kOpponentTaken	= 4,
+		kOpponentTrick	= 5,
+		kDeck			= 6,
+		kTable			= 7,
+		kLaidOut		= 8,	// a player's cards, shown for the scoring
+		kPrimes			= 9,	// the best card of every suit, of both players
+		kNumPiles		= 10,
+		kPrimeValues	= 10,	// only places, for the numbers next to those cards
+		kNumSlotLists	= 11
+	};
+
+	enum CardMode {
+		kFaceUp			= 0,
+		kFaceDown		= 1,
+		kHidden			= 2,
+		kHighlighted	= 3
+	};
+
+	enum Phase {
+		kDeal,
+		kDealing,
+		kPlaying,
+		kTally,
+		kScorePlayers,
+		kScorePrimes
+	};
+
+	// The cards a line of the scoring is about
+	enum Highlight {
+		kAllCards,
+		kCoinCards,
+		kSevenOfCoinsCard
+	};
+
+	// Why a play isn't allowed
+	enum PlayError {
+		kPlayAllowed,
+		kWrongSum,			// the cards don't add up to the card played
+		kHasToTake,			// a card that can take something may not be left on the table
+		kHasToTakeOneCard	// a single card of the same value comes before several
+	};
+
+	// The lines a player has, in the order of the data
+	enum PlayerSound {
+		kSoundScopa,
+		kSoundLastCardsLong,
+		kSoundLastCards,
+		kSoundMostCards,
+		kSoundMostCoins,
+		kSoundSevenOfCoins,
+		kSoundBestPrimes,
+		kSoundNoPoints,
+		kSoundWin,
+		kNumPlayerSounds
+	};
+
+	// Something that shows up and goes away over a set time
+	struct Fader {
+		uint32 fadeInTime = 0;
+		uint32 fadeOutTime = 0;
+		bool isShown = false;
+		bool isFading = false;
+		float alpha = 0.0f;
+		uint32 lastTime = 0;
+
+		void show(bool instantly);
+		void hide(bool instantly);
+		float update();
+		bool isVisible() { return update() > 0.0f; }
+	};
+
+	struct CardState {
+		CardMode mode = kHidden;
+		Common::Rect rect;
+		Fader fader;
+	};
+
+	// The state a card had before stays on screen until it has faded away
+	struct Card {
+		CardState current;
+		CardState previous;
+
+		bool isFading();
+	};
+
+	static const int32 kWaitForCard = -1;
+
+	struct Move {
+		int srcPile = 0;
+		uint srcIndex = 0;
+		int32 delay = 0;	// until the next move, in milliseconds
+		int dstPile = 0;
+		CardMode mode = kFaceUp;
+	};
+
+	// What holds up the game. A player can get points once it is over.
+	struct Wait {
+		enum Type { kCard, kTime, kSound };
+
+		Type type = kTime;
+		int card = -1;
+		uint32 endTime = 0;
+		SoundDescription sound;
+		int player = -1;
+		int32 points = 0;
+	};
+
+	struct Player {
+		// -- File data --
+		uint16 cursor = 0;
+		int16 winFlag = -1;
+		int16 nextStarterFlag = -1;	// set after a round the other player started
+		Common::Rect pileSrc;
+		Common::Rect pileDest;
+		Common::Rect pointsRect;
+		int32 fontID = 0;
+		int32 colorID = 0;
+		RandomSoundBlock sounds[kNumPlayerSounds];
+
+		// -- Runtime --
+		int32 points = 0;
+		int32 numScopas = 0;
+		Fader pileFader;
+
+		// What the cards taken in the round come to
+		int32 numCards = 0;
+		int32 numCoins = 0;
+		int32 primes = 0;
+		Common::Array<int> bestPrimeCards;	// by suit, -1 for none
+		bool hasScoreLine = false;
+		bool scoresMostCards = false;
+		bool scoresMostCoins = false;
+		bool scoresSevenOfCoins = false;
+		bool scoresPrimes = false;
+	};
+
+	struct Play {
+		int card = -1;		// from the hand
+		uint handIndex = 0;
+		Common::Array<uint> taken;	// places on the table, in order
+		int worth = 0;
+	};
+
+	static void readPlayer(Common::SeekableReadStream &stream, Player &player);
+	static void readSlots(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &slots);
+
+	static int getHandPile(uint playerID) { return playerID * 3; }
+	static int getTakenPile(uint playerID) { return playerID * 3 + 1; }
+	static int getTrickPile(uint playerID) { return playerID * 3 + 2; }
+	static bool hasFixedPlaces(int pile);
+
+	uint getOtherPlayer(uint playerID) const { return (playerID + 1) % kNumPlayers; }
+	uint countCards(int pile) const;
+
+	// Card moves and what the game waits for
+	void setCardState(int cardID, CardMode mode, const Common::Rect &rect);
+	void setCardMode(int cardID, CardMode mode);
+	int doMove(const Move &move);
+	void queueMove(int srcPile, uint srcIndex, int32 delay, int dstPile, CardMode mode);
+	bool runMoves();
+	bool updateWaits();
+	void waitForTime(uint32 time, int player = -1, int32 points = 0);
+	void waitForSound(const RandomSoundBlock &sound, int player = -1, int32 points = 0);
+	void startDealSound();
+	void stopDealSound();
+
+	// The game
+	void runPhase();
+	void dealCards();
+	void startTurn();
+	void switchPlayer();
+	PlayError checkPlay(const Play &play) const;
+	void findPlays(Common::Array<Play> &plays) const;
+	void makePlay(const Play &play);
+	void afterPlay();
+	void moveTricksToTaken();
+	void takeLastCards();
+	void showPilePictures(bool show);
+	void updateButtons();
+
+	// The scoring
+	void tallyPlayer(Player &player, int takenPile);
+	void tallyRound();
+	void layOutTakenCards(uint playerID);
+	void highlightLaidOutCards(Highlight highlight);
+	void movePrimes(uint playerID);
+	bool scorePlayerStep();
+	bool scorePrimesStep();
+	void finishRound();
+
+	void findSelectedCards(int pile, Common::Array<uint> &indices) const;
+	int getHoveredCard(int pile, const Common::Point &pos) const;
+	void clickCard(int pile, uint index);
+	void tryPlay(bool sayWhyNotToDiscard);
+
+	void blendPixel(int x, int y, byte a, byte r, byte g, byte b);
+	void blitFaded(const Graphics::ManagedSurface &image, const Common::Rect &src, const Common::Point &dest, uint32 argb, float alpha);
+	void drawCardState(int cardID, CardState &state);
+	void drawText(const Common::String &text, const Common::Rect &rect, uint fontID, uint colorID, Graphics::TextAlign align);
+	void redraw();
+
+	// -- File data --
+	Common::String _saveName;
+	int32 _pointsToWin = 0;
+	int16 _roundOverFlag = -1;
+	int16 _redealFlag = -1;
+	int32 _primesFontID = 0;
+	int32 _primesColorID = 0;
+	Player _players[kNumPlayers];
+	Common::Rect _takeSrc;
+	Common::Rect _takeDest;
+	Common::Rect _discardSrc;
+	Common::Rect _discardDest;
+	int32 _thinkTime = 0;	// in milliseconds
+	int32 _dealTime = 0;
+	int32 _layOutTime = 0;
+	RandomSoundBlock _dealSound;
+	RandomSoundBlock _discardSound;
+	RandomSoundBlock _takeSound;
+	RandomSoundBlock _wrongSumSound;
+	RandomSoundBlock _hasToTakeSound;
+	RandomSoundBlock _hasToTakeOneCardSound;
+	RandomSoundBlock _noLastScopaSound;
+	RandomSoundBlock _primesSound;
+	RandomSoundBlock _primesTieSound;
+	RandomSoundBlock _scopasSound;
+	Common::Array<Common::Rect> _slots[kNumSlotLists];
+	Common::Path _cardsImageName;
+	int32 _fadeInTime = 0;
+	int32 _fadeOutTime = 0;
+	uint32 _highlightColor = 0;	// ARGB
+	Common::Rect _cardBackSrc;
+	Common::Array<Common::Rect> _cardSrcs;
+
+	// -- Runtime --
+	Graphics::ManagedSurface _cardsImage;
+	Common::Array<Card> _cards;
+	Common::Array<int> _piles[kNumPiles];	// card ids, -1 for a place that got empty
+	Common::Array<Move> _moves;
+	Common::Array<Wait> _waits;
+
+	Phase _phase = kDeal;
+	bool _isBusy = true;
+	uint _starter = kHuman;
+	uint _currentPlayer = kHuman;
+	int _lastTaker = -1;
+	bool _needsRedeal = false;
+	bool _hasCheckedScopa = false;
+	bool _hasSaidLastCardsLong = false;
+	bool _hasSaidNoLastScopa = false;
+	bool _showsPrimes = false;
+	bool _showsPrimeTotals = false;
+
+	bool _showsTake = false;
+	bool _showsDiscard = false;
+	Fader _takeFader;
+	Fader _discardFader;
+
+	SoundDescription _playingDealSound;
+	SoundDescription _errorSound;
+	bool _hasChanged = true;
+	bool _isFading = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_SCOPAPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index f9aab531555..ed3b62661ec 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -92,6 +92,7 @@ MODULE_OBJS = \
   action/puzzle/sentrypuzzle.o \
   action/puzzle/sequencelockpuzzle.o \
   action/puzzle/scalepuzzle.o \
+  action/puzzle/scopapuzzle.o \
   action/puzzle/setplayerclock.o \
   action/puzzle/sewingmachinepuzzle.o \
   action/puzzle/shovelmeasurepuzzle.o \


Commit: 2b611f596329112f8582cc49a0eafb233acd116d
    https://github.com/scummvm/scummvm/commit/2b611f596329112f8582cc49a0eafb233acd116d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:38+03:00

Commit Message:
NANCY: NANCY18: Fix overlays being one pixel too large

In Nancy18 the right and bottom edges of an overlay's rectangles are no
longer part of it. Reading them the old way drew an extra column and
row of the image, like the green lines next to the card piles in the
Scopa instructions (scenes 4469 and 4474).

Changed paths:
    engines/nancy/commontypes.cpp


diff --git a/engines/nancy/commontypes.cpp b/engines/nancy/commontypes.cpp
index 939a176b613..e71cfa74fee 100644
--- a/engines/nancy/commontypes.cpp
+++ b/engines/nancy/commontypes.cpp
@@ -113,8 +113,14 @@ void FrameBlitDescription::readData(Common::SeekableReadStream &stream, bool lon
 		hasHotspot = stream.readUint16LE();
 	}
 
-	readRect(stream, src);
-	readRect(stream, dest);
+	// The right and bottom edges of a Nancy18 rect are no longer a part of it
+	if (g_nancy->getGameType() >= kGameTypeNancy18) {
+		readExclusiveRect(stream, src);
+		readExclusiveRect(stream, dest);
+	} else {
+		readRect(stream, src);
+		readRect(stream, dest);
+	}
 }
 
 void MultiEventFlagDescription::readData(Common::SeekableReadStream &stream) {


Commit: 92537e01486deee090675f77b655271639a59719
    https://github.com/scummvm/scummvm/commit/92537e01486deee090675f77b655271639a59719
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:40+03:00

Commit Message:
NANCY: NANCY18: Fix movies being one pixel too large

In Nancy18 the right and bottom edges of a movie's rectangles are no
longer part of it. Reading them the old way stretched every movie by a
pixel, which showed as a ripple in Nancy's dance moves in DancePuzzle

Changed paths:
    engines/nancy/commontypes.cpp


diff --git a/engines/nancy/commontypes.cpp b/engines/nancy/commontypes.cpp
index e71cfa74fee..c3fbaac91b9 100644
--- a/engines/nancy/commontypes.cpp
+++ b/engines/nancy/commontypes.cpp
@@ -138,8 +138,16 @@ void MultiEventFlagDescription::execute() {
 
 void SecondaryVideoDescription::readData(Common::SeekableReadStream &stream, bool hasTrailingRects) {
 	frameID = stream.readUint16LE();
-	readRect(stream, srcRect);
-	readRect(stream, destRect);
+
+	// The right and bottom edges of a Nancy18 rect are no longer a part of it
+	if (g_nancy->getGameType() >= kGameTypeNancy18) {
+		readExclusiveRect(stream, srcRect);
+		readExclusiveRect(stream, destRect);
+	} else {
+		readRect(stream, srcRect);
+		readRect(stream, destRect);
+	}
+
 	// PlaySecondaryVideo keeps the two trailing rects in every game, while
 	// PlaySecondaryMovie only has them up to Nancy 9 and dropped them afterwards.
 	if (hasTrailingRects || g_nancy->getGameType() <= kGameTypeNancy9) {


Commit: 2c58ebacf81a0e647445b56b41efaa6b1424eb35
    https://github.com/scummvm/scummvm/commit/2c58ebacf81a0e647445b56b41efaa6b1424eb35
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:42+03:00

Commit Message:
NANCY: NANCY18: Read the pause flag of random movie sequences

In Nancy18 every sequence of a random movie has an event flag that
is set while the sequence pauses. Without it, the sequences were
misread and the movies did not play, like Nancy's dance moves in
the dance minigame and the idle animations of characters.

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


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 0328368ad26..e49c7bfa887 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -161,6 +161,7 @@ CursorManager::CursorType PlaySecondaryMovie::getHoverCursor() const {
 
 void PlaySecondaryMovie::readRandomSequence(Common::Serializer &ser, RandomSequence &seq) {
 	readFilename(ser, seq.name);
+	ser.syncAsSint16LE(seq.pauseFlag, kGameTypeNancy18);
 	ser.syncAsUint16LE(seq.startFrame);
 	ser.syncAsUint16LE(seq.lastFrame);
 	ser.syncAsSint32LE(seq.minPauseMs);
@@ -517,6 +518,11 @@ void PlaySecondaryMovie::playRandomSequence() {
 int PlaySecondaryMovie::beginRandomPause(const RandomSequence &seq) {
 	_randomChainState = kRandomPaused;
 
+	_randomPauseFlag = seq.pauseFlag;
+	if (_randomPauseFlag != -1) {
+		NancySceneState.setEventFlag(_randomPauseFlag, g_nancy->_true);
+	}
+
 	// Two of the pause values are sentinels: instead of a duration they hold
 	// the sequence on its last frame until the mouse enters or leaves the
 	// movie, which is how a character keeps looking at the player for as long
@@ -1267,6 +1273,12 @@ void PlaySecondaryMovie::execute() {
 			if (!randomPauseElapsed()) {
 				break;
 			}
+
+			if (_randomPauseFlag != -1) {
+				NancySceneState.setEventFlag(_randomPauseFlag, g_nancy->_false);
+				_randomPauseFlag = -1;
+			}
+
 			_randomChainState = kRandomPlaying;
 			int picked = g_nancy->getGameType() >= kGameTypeNancy13 ? pickNextSequence() : rollNextSequence();
 			if (picked >= 0) {
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index eafdb3cd8fc..0b4d093f6d7 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -85,6 +85,7 @@ public:
 	// `name` is both the sequence id and the movie filename.
 	struct RandomSequence {
 		Common::Path name;
+		int16 pauseFlag = -1;	// Nancy18: an event flag that is set for as long as the sequence pauses
 		uint16 startFrame = 0;
 		uint16 lastFrame = 0;
 		int32 minPauseMs = 0;
@@ -244,6 +245,7 @@ public:
 	RandomChainState _randomChainState = kRandomPlaying;
 	RandomPauseMode _randomPauseMode = kPauseTimed;
 	uint32 _randomPauseEndTime = 0;
+	int16 _randomPauseFlag = -1;	// the flag of the sequence that is pausing
 	bool _randomStopRequested = false;
 	bool _randomPaused = false;
 


Commit: fbe629fe218acdd9adf5c715f3d1ba6ce23f339e
    https://github.com/scummvm/scummvm/commit/fbe629fe218acdd9adf5c715f3d1ba6ce23f339e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:45+03:00

Commit Message:
NANCY: NANCY18: Hide random movies whose dependencies no longer hold

The movie of Nancy dancing in DancePuzzle depends on the music playing.
It stayed on screen after the song ended, on top of the movie of her
leaving the stage, which is drawn below it.

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


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index e49c7bfa887..4871cf5a9ea 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -1079,6 +1079,18 @@ void PlaySecondaryMovie::updateGraphics() {
 		return;
 	}
 
+	// A Nancy18 random movie is only there for as long as its dependencies hold,
+	// like Nancy on the dance floor, who is gone once the music stops
+	if (g_nancy->getGameType() >= kGameTypeNancy18 && isRandom() && _state == kRun && !_isDone && !_isActive) {
+		if (!_isHiddenByDependencies) {
+			_isHiddenByDependencies = true;
+			_decoder.pauseVideo(true);
+			setVisible(false);
+		}
+
+		return;
+	}
+
 	if (_isDone || !_isActive) {
 		updateViewportFrame();
 	}
@@ -1204,6 +1216,12 @@ void PlaySecondaryMovie::onPause(bool pause) {
 }
 
 void PlaySecondaryMovie::execute() {
+	if (_isHiddenByDependencies) {
+		_isHiddenByDependencies = false;
+		_decoder.pauseVideo(_randomPaused);
+		setVisible(true);
+	}
+
 	switch (_state) {
 	case kBegin:
 		// WORKAROUND: Skip playing a known broken overlay animation in Nancy14
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 0b4d093f6d7..93e8db5ce6f 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -248,6 +248,7 @@ public:
 	int16 _randomPauseFlag = -1;	// the flag of the sequence that is pausing
 	bool _randomStopRequested = false;
 	bool _randomPaused = false;
+	bool _isHiddenByDependencies = false;	// Nancy18: taken off screen until the dependencies hold again
 
 	// Whether the mouse is over the movie (which drives both the hover-based
 	// sequence chain and the click that opens a character's conversation), and


Commit: 77594e63ca75e21c90acbb6595d3c231dfb0dc9a
    https://github.com/scummvm/scummvm/commit/77594e63ca75e21c90acbb6595d3c231dfb0dc9a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:46+03:00

Commit Message:
NANCY: NANCY18: Implement the dance minigame (DancePuzzle)

A song plays and calls for dance moves at set times. The sooner
the player presses the button of the right move the more points
it gives

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index c0cfbb282d6..1d9730ee67d 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -56,6 +56,7 @@
 #include "engines/nancy/action/puzzle/cookingpuzzle.h"
 #include "engines/nancy/action/puzzle/cubepuzzle.h"
 #include "engines/nancy/action/puzzle/cuttingpuzzle.h"
+#include "engines/nancy/action/puzzle/dancepuzzle.h"
 #include "engines/nancy/action/puzzle/decoderpuzzle.h"
 #include "engines/nancy/action/puzzle/dialsequencepuzzle.h"
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
@@ -849,7 +850,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new GraphChase();
 	case 214:
-		return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kKeypad);
+		if (g_nancy->getGameType() <= kGameTypeNancy17)
+			return new OrderingPuzzle(OrderingPuzzle::PuzzleType::kKeypad);
+		else
+			return new DancePuzzle();
 	case 215:
 		return new MazeChasePuzzle();
 	case 216:
diff --git a/engines/nancy/action/puzzle/dancepuzzle.cpp b/engines/nancy/action/puzzle/dancepuzzle.cpp
new file mode 100644
index 00000000000..1f20a3e0702
--- /dev/null
+++ b/engines/nancy/action/puzzle/dancepuzzle.cpp
@@ -0,0 +1,265 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/dancepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void DancePuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _movieRecordName);
+	_hoverCursor = stream.readUint16LE();
+	_pointsTableIndex = stream.readSint16LE();
+	readFilename(stream, _rootMovieName);
+	readFilename(stream, _imageName);
+
+	_moves.resize(stream.readUint16LE());
+	for (Move &move : _moves) {
+		readExclusiveRect(stream, move.hoverSrc);
+		readExclusiveRect(stream, move.src);
+		readExclusiveRect(stream, move.dest);
+		readFilename(stream, move.movieName);
+	}
+
+	_music.readData(stream);
+
+	_steps.resize(stream.readUint16LE());
+	for (Step &step : _steps) {
+		readFilename(stream, step.movieName);
+		step.flag = stream.readSint16LE();
+		step.startTime = stream.readSint32LE();
+		step.endTime = stream.readSint32LE();
+
+		// The data has the points and the time they start to count from;
+		// they hold until the next ones do, the last ones to the end of the step
+		const uint16 numPoints = stream.readUint16LE();
+		int16 points = 0;
+		for (uint i = 0; i < numPoints; ++i) {
+			const int16 nextPoints = stream.readSint16LE();
+			TimedPoints timed;
+			timed.time = stream.readSint32LE();
+			timed.points = i == 0 ? nextPoints : points;
+			step.points.push_back(timed);
+			points = nextPoints;
+		}
+
+		if (numPoints > 0) {
+			TimedPoints last;
+			last.time = step.endTime;
+			last.points = points;
+			step.points.push_back(last);
+		}
+	}
+
+	_reactions.resize(stream.readUint16LE());
+	for (Reaction &reaction : _reactions) {
+		reaction.minPoints = stream.readSint16LE();
+		reaction.maxPoints = stream.readSint16LE();
+		reaction.sound.readData(stream);
+	}
+}
+
+void DancePuzzle::init() {
+	initViewportSurface();
+	loadImage();
+
+	playSoundBlock(_music);
+	_currentMovieName = _rootMovieName;
+	_startTime = g_nancy->getTotalPlayTime();
+
+	redraw();
+}
+
+void DancePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (uint i = 0; i < _moves.size(); ++i) {
+		const Move &move = _moves[i];
+		_drawSurface.blitFrom(_image, (int)i == _hoveredMove ? move.hoverSrc : move.src, move.dest.origin());
+	}
+
+	_needsRedraw = true;
+}
+
+// The movie record plays what is left for it under its name
+void DancePuzzle::playMovie(const Common::String &movieName) {
+	Common::Array<Common::String> movies;
+	movies.push_back(movieName);
+	NancySceneState.setMoviePlaylist(_movieRecordName, movies);
+	_currentMovieName = movieName;
+}
+
+void DancePuzzle::addPoints(int16 points) {
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	if (table) {
+		table->setSingleValue(_pointsTableIndex, table->getValueOrZero(_pointsTableIndex) + points);
+	}
+}
+
+// A step is scored once with the right move. A wrong one costs what the latest
+// right one would, also only once, and leaves nothing more to lose by being late.
+int16 DancePuzzle::scoreMove(Step &step, int32 time, const Common::String &movieName) {
+	if (step.hasScored) {
+		return 0;
+	}
+
+	const bool isRightMove = step.movieName.equalsIgnoreCase(movieName);
+	if (step.hasHadWrongMove && !isRightMove) {
+		return 0;
+	}
+
+	int16 points = 0;
+	if (isRightMove) {
+		for (const TimedPoints &timed : step.points) {
+			if (time <= timed.time) {
+				step.hasScored = true;
+				points = timed.points;
+				break;
+			}
+		}
+
+		if (step.hasScored && step.hasHadWrongMove && points < 0) {
+			return 0;
+		}
+	} else {
+		// A wrong move costs what being as late as possible does
+		if (!step.points.empty()) {
+			points = step.points.back().points;
+		}
+
+		step.hasHadWrongMove = true;
+	}
+
+	for (const Reaction &reaction : _reactions) {
+		if (points >= reaction.minPoints && points <= reaction.maxPoints) {
+			playSoundBlock(reaction.sound);
+			break;
+		}
+	}
+
+	return points;
+}
+
+void DancePuzzle::endStep(int32 time) {
+	Step &step = _steps[_activeStep];
+	addPoints(scoreMove(step, time, _currentMovieName));
+	NancySceneState.setEventFlag(step.flag, g_nancy->_false);
+	_activeStep = -1;
+}
+
+void DancePuzzle::startStep(uint stepID) {
+	Step &step = _steps[stepID];
+	_activeStep = stepID;
+
+	// The move may be the one that is being danced already
+	if (step.movieName.equalsIgnoreCase(_currentMovieName)) {
+		addPoints(scoreMove(step, step.startTime, _currentMovieName));
+	}
+
+	NancySceneState.setEventFlag(step.flag, g_nancy->_true);
+}
+
+void DancePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		const int32 time = g_nancy->getTotalPlayTime() - _startTime;
+
+		// Once the time of a step is up it is back to the root movie
+		if (_activeStep != -1 && time >= _steps[_activeStep].endTime) {
+			endStep(time);
+
+			if (!_rootMovieName.empty()) {
+				playMovie(_rootMovieName);
+			}
+		}
+
+		while (_nextStep < _steps.size() && time >= _steps[_nextStep].startTime) {
+			if (time < _steps[_nextStep].endTime) {
+				if (_activeStep != -1) {
+					endStep(time);
+				}
+
+				startStep(_nextStep);
+			}
+
+			++_nextStep;
+		}
+
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+void DancePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun) {
+		return;
+	}
+
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	Common::Point pos = mouseRect.origin();
+
+	int hovered = -1;
+	for (uint i = 0; i < _moves.size(); ++i) {
+		if (_moves[i].dest.contains(pos)) {
+			hovered = i;
+		}
+	}
+
+	if (hovered != _hoveredMove) {
+		_hoveredMove = hovered;
+		redraw();
+	}
+
+	if (hovered == -1) {
+		return;
+	}
+
+	setDataCursor(_hoverCursor);
+
+	if (input.input & NancyInput::kLeftMouseButtonUp) {
+		playMovie(_moves[hovered].movieName);
+
+		if (_activeStep != -1) {
+			const int32 time = g_nancy->getTotalPlayTime() - _startTime;
+			addPoints(scoreMove(_steps[_activeStep], time, _currentMovieName));
+		}
+
+		input.eatMouseInput();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/dancepuzzle.h b/engines/nancy/action/puzzle/dancepuzzle.h
new file mode 100644
index 00000000000..5d72d67dca6
--- /dev/null
+++ b/engines/nancy/action/puzzle/dancepuzzle.h
@@ -0,0 +1,114 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_DANCEPUZZLE_H
+#define NANCY_ACTION_DANCEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/puzzlerecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy18 dance floor. A song plays, and at set times one of the dance moves is
+// called for, which an event flag lets the scene show. The player has a button
+// for every move; the sooner the right one is pressed the more points it is
+// worth, and a wrong one, or none, costs some. The points go into the value
+// table, and the audience reacts to them. The dancing itself is done by a movie
+// record of the scene, which gets told what to play.
+class DancePuzzle : public PuzzleRecord {
+public:
+	// Drawn above the stage light movies, which cover the buttons
+	DancePuzzle() : PuzzleRecord(15) {}
+	virtual ~DancePuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "DancePuzzle"; }
+
+	struct Move {
+		Common::Rect hoverSrc;
+		Common::Rect src;
+		Common::Rect dest;
+		Common::String movieName;
+	};
+
+	// What a move is worth up to a point in time
+	struct TimedPoints {
+		int32 time = 0;	// in milliseconds from the start of the song
+		int16 points = 0;
+	};
+
+	struct Step {
+		// -- File data --
+		Common::String movieName;	// of the move that is called for
+		int16 flag = -1;
+		int32 startTime = 0;
+		int32 endTime = 0;
+		Common::Array<TimedPoints> points;
+
+		// -- Runtime --
+		bool hasScored = false;
+		bool hasHadWrongMove = false;
+	};
+
+	struct Reaction {
+		int16 minPoints = 0;
+		int16 maxPoints = 0;
+		RandomSoundBlock sound;
+	};
+
+	int16 scoreMove(Step &step, int32 time, const Common::String &movieName);
+	void addPoints(int16 points);
+	void endStep(int32 time);
+	void startStep(uint stepID);
+	void playMovie(const Common::String &movieName);
+	void redraw();
+
+	// -- File data --
+	Common::String _movieRecordName;
+	uint16 _hoverCursor = 0;
+	int16 _pointsTableIndex = 0;
+	Common::String _rootMovieName;	// played whenever no move is
+	Common::Array<Move> _moves;
+	RandomSoundBlock _music;
+	Common::Array<Step> _steps;
+	Common::Array<Reaction> _reactions;
+
+	// -- Runtime --
+	uint32 _startTime = 0;
+	int _activeStep = -1;
+	uint _nextStep = 0;
+	Common::String _currentMovieName;
+	int _hoveredMove = -1;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_DANCEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index ed3b62661ec..ac21fde1a5c 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -43,6 +43,7 @@ MODULE_OBJS = \
   action/puzzle/cookingpuzzle.o \
   action/puzzle/cubepuzzle.o \
   action/puzzle/cuttingpuzzle.o \
+  action/puzzle/dancepuzzle.o \
   action/puzzle/decoderpuzzle.o \
   action/puzzle/dialsequencepuzzle.o \
   action/puzzle/dotconnectpuzzle.o \


Commit: c5a5d24a546a107e8dbf087653fb8c70c2bbe14d
    https://github.com/scummvm/scummvm/commit/c5a5d24a546a107e8dbf087653fb8c70c2bbe14d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:48+03:00

Commit Message:
NANCY: Clean up the AR factory switch

Now that all the AR records have been mapped for Nancy16-18, the
AR factory switch can be simplified

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 1d9730ee67d..02516a0ab18 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -140,9 +140,6 @@ namespace Nancy {
 namespace Action {
 
 ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableReadStream *recordStream) {
-	// The types returning nullptr in Nancy16 and later were changed there, and their
-	// new data hasn't been analyzed yet. This keeps the readers of the older games from
-	// misparsing it.
 	switch (type) {
 	case 10:
 		if (g_nancy->getGameType() <= kGameTypeNancy9)
@@ -252,8 +249,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new InteractiveVideo();
 		else if (g_nancy->getGameType() <= kGameTypeNancy15)
 			return new HotMultiframeMultiSceneChange();	// Moved from 13
-		else if (g_nancy->getGameType() <= kGameTypeNancy16)
-			return nullptr;	// TODO: InvCursorAR
+		else if (g_nancy->getGameType() == kGameTypeNancy16)
+			return nullptr;	// InvCursorAR, unused
 		else
 			return new BeginStream();
 	case 27:	// Nancy10
@@ -504,10 +501,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 118:
 		return new PasswordPuzzle();
 	case 119:	// Nancy7
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new OrderingPuzzle(OrderingPuzzle::kOrdering);
-		else
-			return nullptr;
+		return new OrderingPuzzle(OrderingPuzzle::kOrdering);
 	case 120:
 		return new AddInventoryNoHS();
 	case 121:
@@ -534,11 +528,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new AddSearchLink();
 	case 132:	// Nancy12
 		return new ResourceUse();
-	case 133:	// Nancy14
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new CameraAction();
-		else
-			return nullptr;
+	case 133:	// Nancy14 - only used for that one
+		return new CameraAction();
 	case 134:	// Nancy15
 		// Switches the active player character (Nancy / Frank / Joe), the
 		// dual-protagonist mechanic new to The Creature of Kapu Cave
@@ -606,13 +597,11 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new KeypadPhone();	// Moved from 116
 	case 158:
 		return new PlayRandomSound();
-	case 159:
+	case 159:	// Nancy7
 		if (g_nancy->getGameType() <= kGameTypeNancy13)
 			return new PlaySoundTerse();
-		else if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new LetterGridPuzzle();
 		else
-			return nullptr;
+			return new LetterGridPuzzle();
 	case 160:
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new HintSystem();
@@ -620,34 +609,20 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new DrivingPuzzle(DrivingPuzzle::kDriving);
 		else
 			return new SnowmobilePuzzle(SnowmobilePuzzle::kDriving);
-	case 161:
+	case 161:	// Nancy11
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new PlaySoundEventFlagTerse();
-		else if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new MinigolfPuzzle();
 		else
-			return nullptr;
+			return new MinigolfPuzzle();
 	// -- Nancy 12 new puzzles/action records --
 	case 162:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new SewingMachinePuzzle();
-		else
-			return nullptr;
+		return new SewingMachinePuzzle();
 	case 163:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new MirrorLightPuzzle();
-		else
-			return nullptr;
+		return new MirrorLightPuzzle();
 	case 164:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new BoardGamePuzzle();
-		else
-			return nullptr;
+		return new BoardGamePuzzle();
 	case 165:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new MindPuzzle();
-		else
-			return nullptr;
+		return new MindPuzzle();
 	case 166:
 		if (g_nancy->getGameType() <= kGameTypeNancy13)
 			return new OneBuildPuzzle();	// Moved from 234
@@ -665,88 +640,47 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new SentryStartPosition();
 	// -- Nancy 13 new/relocated puzzles (types 169-176) --
 	case 169:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new StepObjectsPuzzle();
-		else
-			return nullptr;
+		return new StepObjectsPuzzle();
 	case 170:
 		if (g_nancy->getGameType() <= kGameTypeNancy12)
 			return new SetPlayerClock();	// Moved to 140 in Nancy12, unused slot in Nancy12
-		else if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new WordFindPuzzle();
 		else
-			return nullptr;
+			return new WordFindPuzzle();
 	case 171:
 		if (g_nancy->getGameType() <= kGameTypeNancy16)
 			return new TurningPuzzle();	// Moved from 209
 		else
 			return new TileCyclePuzzle();
 	case 172:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new BlocksPuzzle();
-		else
-			return nullptr;
+		return new BlocksPuzzle();
 	case 173:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new PegsPuzzle();
-		else
-			return nullptr;
+		return new PegsPuzzle();
 	case 174:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new ScalePuzzle();
-		else
-			return nullptr;
+		return new ScalePuzzle();
 	case 175:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new PachinkoPuzzle();
-		else
-			return nullptr;
+		return new PachinkoPuzzle();
 	case 176:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new DropSortPuzzle();
-		else
-			return nullptr;
+		return new DropSortPuzzle();
 	// -- Nancy14 new puzzles (types 177-182) --
 	case 177:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new HangmanPuzzle();
-		else
-			return nullptr;
+		return new HangmanPuzzle();
 	case 178:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new AdjustPuzzle();
-		else
-			return nullptr;
+		return new AdjustPuzzle();
 	case 179:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new MeterPuzzle();
-		else
-			return nullptr;
+		return new MeterPuzzle();
 	case 180:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new BlockingPuzzle();
-		else
-			return nullptr;
+		return new BlockingPuzzle();
 	case 181:
 		return new PaintPuzzle();
 	case 182:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new DecoderPuzzle();
-		else
-			return nullptr;
+		return new DecoderPuzzle();
 	// -- Nancy15 new puzzles (types 183-185) --
 	case 183:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new MagicBoxPuzzle();
-		else
-			return nullptr;
+		return new MagicBoxPuzzle();
 	case 184:
 		return new EscapeGridPuzzle();
 	case 185:
-		if (g_nancy->getGameType() <= kGameTypeNancy15)
-			return new WeightSortPuzzle();
-		else
-			return nullptr;
+		return new WeightSortPuzzle();
 	// -- Nancy16 new puzzles (types 186-199) --
 	case 186:
 		return new FoxGeesePuzzle();


Commit: d04607f990febbf4fded7782f93b2037e05c91fc
    https://github.com/scummvm/scummvm/commit/d04607f990febbf4fded7782f93b2037e05c91fc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:49+03:00

Commit Message:
NANCY: NANCY16: Update the meters of the UI that show a timer

The hypothermia and Toasty Pack meters never moved; they show how
much of their timer is left until its first trigger, in percent

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index bbef948ebf3..20b25c05175 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2395,6 +2395,31 @@ uint32 Scene::getSoftwareTimerElapsed(uint16 index) const {
 	return ((const TimerData *)_puzzleData.getVal(TimerData::getTag()))->timers[index].currentTimeMs;
 }
 
+int32 Scene::getSoftwareTimerPercentLeft(uint16 index) const {
+	if (index >= TimerData::kNumTimers || !_puzzleData.contains(TimerData::getTag())) {
+		return -1;
+	}
+
+	const TimerData::Timer &timer = ((const TimerData *)_puzzleData.getVal(TimerData::getTag()))->timers[index];
+	if (timer.state == TimerData::Timer::kIdle) {
+		return -1;
+	}
+
+	for (const TimerData::Trigger &trigger : timer.triggers) {
+		if (trigger.type == TimerData::Trigger::kOneShot && trigger.hasFired) {
+			continue;
+		}
+
+		if (trigger.durationMs == 0) {
+			return -1;
+		}
+
+		return (int32)(100.0 - (double)timer.currentTimeMs / trigger.durationMs * 100.0);
+	}
+
+	return -1;
+}
+
 void Scene::fireSoftwareTimer(TimerData::Timer &timer) {
 	timer.hasFired = true;
 
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 705cd6d9bde..a3f2eb69cd1 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -215,6 +215,10 @@ public:
 	int32 getSoftwareTimerState(uint16 index) const;
 	uint32 getSoftwareTimerElapsed(uint16 index) const;
 
+	// Nancy16: what is left of a timer until its first trigger, in percent, for the
+	// meters of the UI. -1 when the timer isn't in use, or has nothing to wait for.
+	int32 getSoftwareTimerPercentLeft(uint16 index) const;
+
 	// Nancy 12+ UI resource values (the UIRC boot chunk). Resource 0 is the
 	// coin purse amount in cents. Backed by the lazily-created, saved
 	// UIResourceData puzzle chunk, seeded from UIRC on first use and mutated by
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index f228e937fb8..80e7be8a00d 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -53,7 +53,18 @@ void Manager::takeEngineAction(const Action &action) {
 		_engineTargets[action.target]->onEngineAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_Timer")) {
 		// A control asks to show the value of a timer
-		if (action.type == kActionSetText && (int)action.value == kPlayerClockTimer) {
+		if (action.type == kActionSetText && (int)action.value >= 0) {
+			for (const ValueDisplay &display : _timerDisplays) {
+				if (display.name.equalsIgnoreCase(action.sender)) {
+					return;
+				}
+			}
+
+			ValueDisplay timerDisplay;
+			timerDisplay.index = (int16)action.value;
+			timerDisplay.name = action.sender;
+			_timerDisplays.push_back(timerDisplay);
+		} else if (action.type == kActionSetText && (int)action.value == kPlayerClockTimer) {
 			for (const Common::String &display : _clockDisplays) {
 				if (display.equalsIgnoreCase(action.sender)) {
 					return;
@@ -291,15 +302,22 @@ void Manager::registerValueDisplay(int16 index, const Common::String &display) {
 	_valueDisplays.push_back(valueDisplay);
 }
 
-// Value displays follow the value table
+// Value displays follow the value table, and timer displays what is left of their timer
 void Manager::update() {
 	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
-	if (!table) {
-		return;
+	if (table) {
+		for (ValueDisplay &display : _valueDisplays) {
+			int16 value = table->getValue(display.index);
+			if (!display.hasShownValue || display.shownValue != value) {
+				display.hasShownValue = true;
+				display.shownValue = value;
+				takeAction(kActionSetValue, display.name, (double)value);
+			}
+		}
 	}
 
-	for (ValueDisplay &display : _valueDisplays) {
-		int16 value = table->getValue(display.index);
+	for (ValueDisplay &display : _timerDisplays) {
+		int16 value = (int16)NancySceneState.getSoftwareTimerPercentLeft(display.index);
 		if (!display.hasShownValue || display.shownValue != value) {
 			display.hasShownValue = true;
 			display.shownValue = value;
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 59fec5e0c13..a0852d567b2 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -202,6 +202,7 @@ private:
 
 	Common::Array<Common::String> _clockDisplays;
 	Common::Array<ValueDisplay> _valueDisplays;
+	Common::Array<ValueDisplay> _timerDisplays;		// meters showing what is left of a timer
 
 	StringMap _journalPages;		// page title -> page panel
 	Common::String _shownJournalPage;


Commit: 4453d4a08e366ece65f8feaa06d83879b463542f
    https://github.com/scummvm/scummvm/commit/4453d4a08e366ece65f8feaa06d83879b463542f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:51+03:00

Commit Message:
NANCY: NANCY16: Don't cut off controls placed outside of their panel

- Controls can be placed past the background of their panel, like the
  meters of the hypothermia dialog, which were cut off at its edge
- Such a panel is now as large as it takes to draw its controls, and
  the pointer of a slider can stick out at either end of its track
- The mouse only counts as over a panel when it is over the background
  or one of the controls

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


diff --git a/engines/nancy/ui/ndui/control.h b/engines/nancy/ui/ndui/control.h
index c9dd3ee4539..7f42c482733 100644
--- a/engines/nancy/ui/ndui/control.h
+++ b/engines/nancy/ui/ndui/control.h
@@ -52,6 +52,9 @@ public:
 	// The mouse, in the coordinates of the panel's controls. A control that returns
 	// true from onMouseDown() keeps getting the mouse until the button is released.
 	virtual bool containsPoint(const Common::Point &pos) const { return false; }
+
+	// The area the control draws to, which can reach past its bounds
+	virtual Common::Rect getDrawBounds() const { return _bounds; }
 	virtual void onMouseHover(const Common::Point &pos) {}
 	virtual bool onMouseDown(const Common::Point &pos) { return false; }
 	virtual void onMouseMove(const Common::Point &pos) {}
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index c59a25509ca..383823a8771 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -90,6 +90,19 @@ void Panel::read(Common::SeekableReadStream &stream) {
 
 	Common::Rect screenPosition = _background->_bounds;
 	_background->_bounds.moveTo(0, 0);
+
+	// Controls aren't clipped to a panel that doesn't scroll, and some are placed past
+	// the right or the bottom of its background, like the meters of the hypothermia
+	// dialog, so the panel is as large as it takes to draw them. Panels that arrange
+	// their controls keep their width, since the rows are laid out by it.
+	if (_scrollBarName.empty() && _arrangement == kArrangeNone) {
+		for (const Control *control : _controls) {
+			Common::Rect drawBounds = control->getDrawBounds();
+			screenPosition.right = MAX<int16>(screenPosition.right, screenPosition.left + drawBounds.right);
+			screenPosition.bottom = MAX<int16>(screenPosition.bottom, screenPosition.top + drawBounds.bottom);
+		}
+	}
+
 	moveTo(screenPosition);
 	_drawSurface.create(screenPosition.width(), screenPosition.height(), g_nancy->_graphics->getTransparentPixelFormat());
 
@@ -579,8 +592,14 @@ bool Panel::handleInput(NancyInput &input, bool canHover, bool &isOverControl) {
 		return true;
 	}
 
+	// The panel can be larger than its background, to hold controls that reach past it;
+	// the mouse is only over the panel when it is over the background or one of those
 	bool isInside = canHover && isVisible() && _screenPosition.contains(input.screenMousePos);
 	Control *hovered = (isInside && _enabled) ? getControlAt(pos) : nullptr;
+	if (isInside && !hovered) {
+		Common::Point unscrolledPos(pos.x, pos.y - _scrollOffset);
+		isInside = _background->_bounds.contains(unscrolledPos);
+	}
 
 	for (Control *control : _controls) {
 		if (control->_isMouseOver != (control == hovered)) {
diff --git a/engines/nancy/ui/ndui/slider.cpp b/engines/nancy/ui/ndui/slider.cpp
index 440f3502d63..1dfa3639692 100644
--- a/engines/nancy/ui/ndui/slider.cpp
+++ b/engines/nancy/ui/ndui/slider.cpp
@@ -66,6 +66,12 @@ Common::Rect Slider::getThumbBounds() const {
 	return Common::Rect(x, _bounds.top, x + width, _bounds.bottom);
 }
 
+// At either end of the track, half of the thumb sticks out
+Common::Rect Slider::getDrawBounds() const {
+	int overhang = (_elements[1]._src.width() + 1) / 2;
+	return Common::Rect(_bounds.left - overhang, _bounds.top, _bounds.right + overhang, _bounds.bottom);
+}
+
 void Slider::setValue(int value, bool byPlayer) {
 	value = CLIP<int>(value, _min, _max);
 	if (value == _value) {
diff --git a/engines/nancy/ui/ndui/slider.h b/engines/nancy/ui/ndui/slider.h
index 4e3318332a5..d5e5ff53736 100644
--- a/engines/nancy/ui/ndui/slider.h
+++ b/engines/nancy/ui/ndui/slider.h
@@ -36,6 +36,7 @@ public:
 	void draw(Graphics::ManagedSurface &surface, const Common::Point &offset) const override;
 
 	bool containsPoint(const Common::Point &pos) const override { return _bounds.contains(pos); }
+	Common::Rect getDrawBounds() const override;
 	bool onMouseDown(const Common::Point &pos) override;
 	void onMouseMove(const Common::Point &pos) override;
 	void onMouseUp(const Common::Point &pos) override;


Commit: 0b9c6dd94a55802b9d43b18427b70d678da3434b
    https://github.com/scummvm/scummvm/commit/0b9c6dd94a55802b9d43b18427b70d678da3434b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:53+03:00

Commit Message:
NANCY: NANCY16: Fix typing the answers of Tino's survey (QuizPuzzle)

- A field whose text becomes a right answer while it is typed only
  passes the cursor on when the record says so
- A field that lists an empty answer counts as right when nothing is
  typed in it, like Guadalupe's answer about paprika
- The typed answers are always brought back when the form is reopened
- Enhancement: Let Shift+Tab go to the previous field of a quiz, for
  the games where Tab goes to the next field (Nancy15 and later)

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


diff --git a/engines/nancy/action/puzzle/quizpuzzle.cpp b/engines/nancy/action/puzzle/quizpuzzle.cpp
index bed9f9f7a59..ace4563d334 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.cpp
+++ b/engines/nancy/action/puzzle/quizpuzzle.cpp
@@ -626,6 +626,7 @@ void QuizPuzzle::executeNancy9() {
 					saveAnswer(_currentBox);
 					if (_autoCheck && checkAnswerForCurrentBox()) {
 						_internalState = kStartCorrect;
+						_wasCheckedByTyping = true;
 					}
 				}
 
@@ -656,6 +657,7 @@ void QuizPuzzle::executeNancy9() {
 					if (_autoCheck) {
 						if (checkAnswerForCurrentBox()) {
 							_internalState = kStartCorrect;
+							_wasCheckedByTyping = true;
 						} else if (!isNancy15 && text.size() >= box.maxAnswerLength) {
 							_internalState = kStartWrong;
 						}
@@ -701,7 +703,14 @@ void QuizPuzzle::executeNancy9() {
 			showBoxSubtitle(box.correctText, _recordCorrectText);
 		}
 
-		advanceToNextBox();
+		// In Nancy16, an answer that turns out right while it is being typed keeps the
+		// cursor, unless the record holds on to wrong answers: scene 6275 also takes
+		// "Los Angele", and the player has to be able to go on typing
+		if (g_nancy->getGameType() <= kGameTypeNancy15 || !_wasCheckedByTyping || _keepBoxOnWrongAnswer) {
+			advanceToNextBox();
+		}
+
+		_wasCheckedByTyping = false;
 
 		// Nancy 15 does not wait for the feedback sound before carrying on
 		if (isNancy15) {
@@ -937,6 +946,9 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 
 	// The keyboard is read whatever the mouse happens to be over
 	for (auto &key : input.otherKbdInput) {
+		// Enhancement: Shift+Tab goes to the previous box, the way Tab goes to the next
+		_movesToPreviousBox = isNancy15 && key.keycode == Common::KEYCODE_TAB && key.hasFlags(Common::KBD_SHIFT);
+
 		if (key.keycode == Common::KEYCODE_BACKSPACE) {
 			_pendingBackspace = true;
 			_hasNewKey = true;
@@ -1032,8 +1044,10 @@ void QuizPuzzle::drawTextNancy16() {
 }
 
 void QuizPuzzle::advanceToNextBox() {
+	const uint step = _movesToPreviousBox ? _boxes.size() - 1 : 1;
+
 	for (uint n = 0; n < _boxes.size(); ++n) {
-		_currentBox = (_currentBox + 1) % _boxes.size();
+		_currentBox = (_currentBox + step) % _boxes.size();
 		if (_cycleSolvedBoxes || !_boxes[_currentBox].correct)
 			return;
 	}
@@ -1057,9 +1071,12 @@ bool QuizPuzzle::checkAnswerForCurrentBox(bool silent) {
 	if (!input.empty() && input.lastChar() == cursorChar)
 		input.deleteLastChar();
 
+	// Nancy16 can list an empty answer, for a box that may be left empty
+	const bool matchesEmptyAnswers = g_nancy->getGameType() >= kGameTypeNancy16;
+
 	bool correct = false;
 	for (uint j = 0; j < box.answers.size(); ++j) {
-		if (!box.answers[j].empty() && input.equalsIgnoreCase(box.answers[j])) {
+		if ((matchesEmptyAnswers || !box.answers[j].empty()) && input.equalsIgnoreCase(box.answers[j])) {
 			correct = true;
 			break;
 		}
@@ -1127,9 +1144,10 @@ void QuizPuzzle::loadSavedAnswers() {
 				break;
 			}
 		}
-	} else if (g_nancy->getGameType() >= kGameTypeNancy15 &&
+	} else if (g_nancy->getGameType() == kGameTypeNancy15 &&
 			(_restoreFlag == -1 || !NancySceneState.getEventFlag(_restoreFlag, g_nancy->_true))) {
-		// Without the restore flag set the puzzle starts over
+		// Without the restore flag set the puzzle starts over. Nancy16 has no such
+		// flag, and always brings back what was typed.
 		data->boxCorrect.erase(key);
 		data->typedText.erase(key);
 		return;
diff --git a/engines/nancy/action/puzzle/quizpuzzle.h b/engines/nancy/action/puzzle/quizpuzzle.h
index 99a0109eb81..cb713b55af7 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.h
+++ b/engines/nancy/action/puzzle/quizpuzzle.h
@@ -137,6 +137,7 @@ private:
 	// ---- Data (Nancy 15) ----
 	int16 _restoreFlag = -1;			// when set, previous answers are restored
 	bool _keepBoxOnWrongAnswer = false;	// don't move on after a wrong answer
+	bool _wasCheckedByTyping = false;	// the box got right through a typed key, not Enter or Tab
 	bool _cycleSolvedBoxes = false;		// cycling also stops on solved boxes
 	uint16 _displaySourceScene = kNoScene;	// see _isDisplayOnly
 	bool _isDisplayOnly = false;		// no cursor character: shows another quiz's answers
@@ -175,6 +176,7 @@ private:
 	bool _hasNewKey = false;
 	bool _pendingReturn = false;
 	bool _pendingBackspace = false;
+	bool _movesToPreviousBox = false;	// the last key was Shift+Tab
 	char _pendingChar = 0;
 
 	Time _nextBlinkTime = 0;


Commit: bc6c47c9c49440b14287540d6f2058337ebf1547
    https://github.com/scummvm/scummvm/commit/bc6c47c9c49440b14287540d6f2058337ebf1547
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:55+03:00

Commit Message:
NANCY: NANCY16: Fix a crash when an item is shown after pickup

The record that opens an item's scene right after it is picked up
(e.g. the notes in scene 4823) now goes through the NDUI inventory

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


diff --git a/engines/nancy/action/inventoryrecords.cpp b/engines/nancy/action/inventoryrecords.cpp
index 6fb685c34b0..b42bce142b3 100644
--- a/engines/nancy/action/inventoryrecords.cpp
+++ b/engines/nancy/action/inventoryrecords.cpp
@@ -259,9 +259,10 @@ void GoInvViewScene::readData(Common::SeekableReadStream &stream) {
 }
 
 void GoInvViewScene::execute() {
-	if (g_nancy->getGameType() >= kGameTypeNancy17) {
-		// The same as clicking the item in the inventory
-		NancySceneState.getNDUI()->clickInventoryItem(_itemID);
+	if (g_nancy->getGameType() >= kGameTypeNancy16) {
+		// The inventory of the UI has taken over what the item data of the earlier
+		// games said about the items. Every record of Nancy16 adds its item.
+		NancySceneState.getNDUI()->viewInventoryItem(_itemID);
 		_isDone = true;
 		return;
 	}
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index 80e7be8a00d..db4becc3314 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -167,8 +167,7 @@ void Manager::onInventoryClicked(int16 itemID) {
 
 	switch (button->getKind()) {
 	case InvButton::kItemView:
-		NancySceneState.viewInventoryItem(itemID);
-		NancySceneState.changeScene(scene);
+		viewInventoryItem(itemID);
 		break;
 	case InvButton::kItemOpenCountedScene:
 	case InvButton::kItemOpenScene:
@@ -181,6 +180,26 @@ void Manager::onInventoryClicked(int16 itemID) {
 	}
 }
 
+void Manager::viewInventoryItem(int16 itemID) {
+	// Nancy17 does it the same way as for a click on the item
+	if (g_nancy->getGameType() >= kGameTypeNancy17) {
+		onInventoryClicked(itemID);
+		return;
+	}
+
+	InvButton *button = findItemButton(itemID);
+	if (!button || button->getKind() != InvButton::kItemView) {
+		return;
+	}
+
+	SceneChangeDescription scene;
+	scene.sceneID = button->getScene();
+	scene.continueSceneSound = kContinueSceneSound;
+
+	NancySceneState.viewInventoryItem(itemID);
+	NancySceneState.changeScene(scene);
+}
+
 // Nancy17 shows the scene of an item in a stream of its own, on top of the scene
 // the player is in. That scene ends its stream, and puts back an item that was
 // taken out to be looked at.
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index a0852d567b2..c17e1de4778 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -123,7 +123,8 @@ public:
 	const Common::String &getCaption() const { return _caption; }
 	void updatePlayerTime();
 	void createItemButtons();
-	void clickInventoryItem(int16 itemID) { onInventoryClicked(itemID); }
+	// Takes an item out of the inventory, and opens the scene that shows it
+	void viewInventoryItem(int16 itemID);
 
 	// Nancy17 cell phone
 	void createPhoneBook();


Commit: 020b8a76c17950b58878b5767c6c9b2aff9fd8ae
    https://github.com/scummvm/scummvm/commit/020b8a76c17950b58878b5767c6c9b2aff9fd8ae
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:57+03:00

Commit Message:
NANCY: NANCY16: Change the scene as soon as a sound asks for it

A sound that changes the scene without waiting for its end now does so
when it starts (e.g. Nancy's yelp in scene 2343, which got stuck)

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


diff --git a/engines/nancy/action/soundrecords.cpp b/engines/nancy/action/soundrecords.cpp
index 420b1f8b3d9..c3851587b56 100644
--- a/engines/nancy/action/soundrecords.cpp
+++ b/engines/nancy/action/soundrecords.cpp
@@ -305,6 +305,16 @@ void PlaySound::execute() {
 			break;
 		}
 
+		// Nancy16 asks for the scene change as soon as the sound has started. Waiting for
+		// the next pass wouldn't do: the flags set above can be among the record's own
+		// dependencies (scene 2343, where Nancy first meets the wolf), and the record
+		// then never runs again.
+		if (g_nancy->getGameType() >= kGameTypeNancy16 && _changeSceneImmediately) {
+			_state = kActionTrigger;
+			execute();
+			break;
+		}
+
 		_state = kRun;
 		break;
 	case kRun:


Commit: 3cf4cfe32f6dfd4ededaacfce8cb092f32474c9b
    https://github.com/scummvm/scummvm/commit/3cf4cfe32f6dfd4ededaacfce8cb092f32474c9b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:58+03:00

Commit Message:
COMMON: LUA: Restore lundump (loader of precompiled script chunks)

Brings back the loader of precompiled chunks, which was removed in
984f53ac14c since no engine needed it. The files are as they were
then; nothing calls the loader yet.

Changed paths:
  A common/lua/lundump.cpp
  A common/lua/lundump.h
    common/lua/module.mk


diff --git a/common/lua/lundump.cpp b/common/lua/lundump.cpp
new file mode 100644
index 00000000000..4ffc623575b
--- /dev/null
+++ b/common/lua/lundump.cpp
@@ -0,0 +1,225 @@
+/*
+** $Id$
+** load precompiled Lua chunks
+** See Copyright Notice in lua.h
+*/
+
+#include <string.h>
+
+#define lundump_c
+#define LUA_CORE
+
+#include "lua.h"
+
+#include "ldebug.h"
+#include "ldo.h"
+#include "lfunc.h"
+#include "lmem.h"
+#include "lobject.h"
+#include "lstring.h"
+#include "lundump.h"
+#include "lzio.h"
+
+typedef struct {
+ lua_State* L;
+ ZIO* Z;
+ Mbuffer* b;
+ const char* name;
+} LoadState;
+
+#ifdef LUAC_TRUST_BINARIES
+#define IF(c,s)
+#define error(S,s)
+#else
+#define IF(c,s)		if (c) error(S,s)
+
+static void error(LoadState* S, const char* why)
+{
+ luaO_pushfstring(S->L,"%s: %s in precompiled chunk",S->name,why);
+ luaD_throw(S->L,LUA_ERRSYNTAX);
+}
+#endif
+
+#define LoadMem(S,b,n,size)	LoadBlock(S,b,(n)*(size))
+#define	LoadByte(S)		(lu_byte)LoadChar(S)
+#define LoadVar(S,x)		LoadMem(S,&x,1,sizeof(x))
+#define LoadVector(S,b,n,size)	LoadMem(S,b,n,size)
+
+static void LoadBlock(LoadState* S, void* b, size_t size)
+{
+ size_t r=luaZ_read(S->Z,b,size);
+ UNUSED(r);
+ IF (r!=0, "unexpected end");
+}
+
+static int LoadChar(LoadState* S)
+{
+ char x;
+ LoadVar(S,x);
+ return x;
+}
+
+static int LoadInt(LoadState* S)
+{
+ int x;
+ LoadVar(S,x);
+ IF (x<0, "bad integer");
+ return x;
+}
+
+static lua_Number LoadNumber(LoadState* S)
+{
+ lua_Number x;
+ LoadVar(S,x);
+ return x;
+}
+
+static TString* LoadString(LoadState* S)
+{
+ size_t size;
+ LoadVar(S,size);
+ if (size==0)
+  return NULL;
+ else
+ {
+  char* s=luaZ_openspace(S->L,S->b,size);
+  LoadBlock(S,s,size);
+  return luaS_newlstr(S->L,s,size-1);		/* remove trailing '\0' */
+ }
+}
+
+static void LoadCode(LoadState* S, Proto* f)
+{
+ int n=LoadInt(S);
+ f->code=luaM_newvector(S->L,n,Instruction);
+ f->sizecode=n;
+ LoadVector(S,f->code,n,sizeof(Instruction));
+}
+
+static Proto* LoadFunction(LoadState* S, TString* p);
+
+static void LoadConstants(LoadState* S, Proto* f)
+{
+ int i,n;
+ n=LoadInt(S);
+ f->k=luaM_newvector(S->L,n,TValue);
+ f->sizek=n;
+ for (i=0; i<n; i++) setnilvalue(&f->k[i]);
+ for (i=0; i<n; i++)
+ {
+  TValue* o=&f->k[i];
+  int t=LoadChar(S);
+  switch (t)
+  {
+   case LUA_TNIL:
+   	setnilvalue(o);
+	break;
+   case LUA_TBOOLEAN:
+   	setbvalue(o,LoadChar(S));
+	break;
+   case LUA_TNUMBER:
+	setnvalue(o,LoadNumber(S));
+	break;
+   case LUA_TSTRING:
+	setsvalue2n(S->L,o,LoadString(S));
+	break;
+   default:
+	error(S,"bad constant");
+	break;
+  }
+ }
+ n=LoadInt(S);
+ f->p=luaM_newvector(S->L,n,Proto*);
+ f->sizep=n;
+ for (i=0; i<n; i++) f->p[i]=NULL;
+ for (i=0; i<n; i++) f->p[i]=LoadFunction(S,f->source);
+}
+
+static void LoadDebug(LoadState* S, Proto* f)
+{
+ int i,n;
+ n=LoadInt(S);
+ f->lineinfo=luaM_newvector(S->L,n,int);
+ f->sizelineinfo=n;
+ LoadVector(S,f->lineinfo,n,sizeof(int));
+ n=LoadInt(S);
+ f->locvars=luaM_newvector(S->L,n,LocVar);
+ f->sizelocvars=n;
+ for (i=0; i<n; i++) f->locvars[i].varname=NULL;
+ for (i=0; i<n; i++)
+ {
+  f->locvars[i].varname=LoadString(S);
+  f->locvars[i].startpc=LoadInt(S);
+  f->locvars[i].endpc=LoadInt(S);
+ }
+ n=LoadInt(S);
+ f->upvalues=luaM_newvector(S->L,n,TString*);
+ f->sizeupvalues=n;
+ for (i=0; i<n; i++) f->upvalues[i]=NULL;
+ for (i=0; i<n; i++) f->upvalues[i]=LoadString(S);
+}
+
+static Proto* LoadFunction(LoadState* S, TString* p)
+{
+ Proto* f=luaF_newproto(S->L);
+ setptvalue2s(S->L,S->L->top,f); incr_top(S->L);
+ f->source=LoadString(S); if (f->source==NULL) f->source=p;
+ f->linedefined=LoadInt(S);
+ f->lastlinedefined=LoadInt(S);
+ f->nups=LoadByte(S);
+ f->numparams=LoadByte(S);
+ f->is_vararg=LoadByte(S);
+ f->maxstacksize=LoadByte(S);
+ LoadCode(S,f);
+ LoadConstants(S,f);
+ LoadDebug(S,f);
+ IF (!luaG_checkcode(f), "bad code");
+ S->L->top--;
+ return f;
+}
+
+static void LoadHeader(LoadState* S)
+{
+ char h[LUAC_HEADERSIZE];
+ char s[LUAC_HEADERSIZE];
+ luaU_header(h);
+ LoadBlock(S,s,LUAC_HEADERSIZE);
+ IF (memcmp(h,s,LUAC_HEADERSIZE)!=0, "bad header");
+}
+
+/*
+** load precompiled chunk
+*/
+Proto* luaU_undump (lua_State* L, ZIO* Z, Mbuffer* buff, const char* name)
+{
+ LoadState S;
+ if (*name=='@' || *name=='=')
+  S.name=name+1;
+ else if (*name==LUA_SIGNATURE[0])
+  S.name="binary string";
+ else
+  S.name=name;
+ S.L=L;
+ S.Z=Z;
+ S.b=buff;
+ LoadHeader(&S);
+ return LoadFunction(&S,luaS_newliteral(L,"=?"));
+}
+
+/*
+* make header
+*/
+void luaU_header (char* h)
+{
+ int x=1;
+ memcpy(h,LUA_SIGNATURE,sizeof(LUA_SIGNATURE)-1);
+ h+=sizeof(LUA_SIGNATURE)-1;
+ *h++=(char)LUAC_VERSION;
+ *h++=(char)LUAC_FORMAT;
+ *h++=(char)*(char*)&x;				/* endianness */
+ *h++=(char)sizeof(int);
+ *h++=(char)sizeof(size_t);
+ *h++=(char)sizeof(Instruction);
+ *h++=(char)sizeof(lua_Number);
+ *h++=(char)(((lua_Number)0.5)==0);		/* is lua_Number integral? */
+}
diff --git a/common/lua/lundump.h b/common/lua/lundump.h
new file mode 100644
index 00000000000..c293c56ce92
--- /dev/null
+++ b/common/lua/lundump.h
@@ -0,0 +1,31 @@
+/*
+** $Id$
+** load precompiled Lua chunks
+** See Copyright Notice in lua.h
+*/
+
+#ifndef lundump_h
+#define lundump_h
+
+#include "lobject.h"
+#include "lzio.h"
+
+/* load one chunk; from lundump.c */
+LUAI_FUNC Proto* luaU_undump (lua_State* L, ZIO* Z, Mbuffer* buff, const char* name);
+
+/* make header; from lundump.c */
+LUAI_FUNC void luaU_header (char* h);
+
+/* dump one chunk; from ldump.c */
+LUAI_FUNC int luaU_dump (lua_State* L, const Proto* f, lua_Writer w, void* data, int strip);
+
+/* for header of binary files -- this is Lua 5.1 */
+#define LUAC_VERSION		0x51
+
+/* for header of binary files -- this is the official format */
+#define LUAC_FORMAT		0
+
+/* size of header of binary files */
+#define LUAC_HEADERSIZE		12
+
+#endif
diff --git a/common/lua/module.mk b/common/lua/module.mk
index 7c117805332..ac401e7a156 100644
--- a/common/lua/module.mk
+++ b/common/lua/module.mk
@@ -31,6 +31,7 @@ MODULE_OBJS := \
 	lua_persist.o \
 	lua_persistence_util.o \
 	lua_unpersist.o \
+	lundump.o \
 	lvm.o \
 	lzio.o \
 	scummvm_file.o


Commit: 1299041a32406fadc29dfb4bf5eb8188b70ac21b
    https://github.com/scummvm/scummvm/commit/1299041a32406fadc29dfb4bf5eb8188b70ac21b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:20:59+03:00

Commit Message:
COMMON: LUA: Load precompiled chunks made on other platforms

The loader only took chunks whose header matched the platform it ran
on. It now reads the byte order and the sizes of int and size_t from
the header of the chunk, and converts the values as it loads them.
Chunks still need 4 byte instructions and 8 byte floating point numbers.

Hooks the loader back into the parser, and drops the unused
luaU_header().

Changed paths:
    common/lua/ldo.cpp
    common/lua/lundump.cpp
    common/lua/lundump.h


diff --git a/common/lua/ldo.cpp b/common/lua/ldo.cpp
index 6dd29a71fd4..5cacb2e0379 100644
--- a/common/lua/ldo.cpp
+++ b/common/lua/ldo.cpp
@@ -34,6 +34,7 @@
 #include "lstring.h"
 #include "ltable.h"
 #include "ltm.h"
+#include "lundump.h"
 #include "lvm.h"
 #include "lzio.h"
 #include "common/textconsole.h"
@@ -519,9 +520,8 @@ static void f_parser (lua_State *L, void *ud) {
   struct SParser *p = cast(struct SParser *, ud);
   int c = luaZ_lookahead(p->z);
   luaC_checkGC(L);
-  if (c == LUA_SIGNATURE[0])
-	  error("Handling of precompiled LUA scripts has been removed in ScummVM");
-  tf = luaY_parser(L, p->z, &p->buff, p->name);
+  tf = ((c == LUA_SIGNATURE[0]) ? luaU_undump : luaY_parser)(L, p->z,
+                                                             &p->buff, p->name);
   cl = luaF_newLclosure(L, tf->nups, hvalue(gt(L)));
   cl->l.p = tf;
   for (i = 0; i < tf->nups; i++)  /* initialize eventual upvalues */
diff --git a/common/lua/lundump.cpp b/common/lua/lundump.cpp
index 4ffc623575b..90231dee46f 100644
--- a/common/lua/lundump.cpp
+++ b/common/lua/lundump.cpp
@@ -4,6 +4,23 @@
 ** See Copyright Notice in lua.h
 */
 
+// ScummVM changes to the stock loader:
+//
+// The stock loader only accepts chunks made on a machine like the one it runs
+// on: it compares the header of a chunk with one built for the host, and then
+// reads integers, sizes, numbers and instructions as raw memory. Games ship
+// chunks compiled for the machine they were released on (32-bit, little
+// endian), which have to load on every platform ScummVM runs on.
+//
+// So LoadHeader() takes the byte order and the sizes of int and size_t from the
+// header of the chunk, and everything is read one value at a time through
+// LoadUnsigned(), which puts the bytes together in that order. Instructions
+// have to be 4 bytes, and numbers 8-byte IEEE doubles.
+//
+// Chunks can only be loaded; ldump.c is not part of ScummVM, so luaU_dump()
+// has no implementation and lua_dump() fails. luaU_header(), which made the
+// header of the host for the comparison and for ldump.c, is gone as well.
+
 #include <string.h>
 
 #define lundump_c
@@ -25,6 +42,9 @@ typedef struct {
  ZIO* Z;
  Mbuffer* b;
  const char* name;
+ int littleEndian;				/* ScummVM: the format of the chunk */
+ int sizeInt;
+ int sizeSizeT;
 } LoadState;
 
 #ifdef LUAC_TRUST_BINARIES
@@ -59,25 +79,38 @@ static int LoadChar(LoadState* S)
  return x;
 }
 
-static int LoadInt(LoadState* S)
+/* ScummVM: reads an unsigned integer of the given size, in the byte order of the chunk */
+static uint64 LoadUnsigned(LoadState* S, int size)
 {
- int x;
- LoadVar(S,x);
- IF (x<0, "bad integer");
+ lu_byte b[8];
+ uint64 x=0;
+ int i;
+ LoadBlock(S,b,size);
+ for (i=0; i<size; i++)
+  x|=((uint64)b[i])<<(8*(S->littleEndian ? i : size-1-i));
  return x;
 }
 
+static int LoadInt(LoadState* S)
+{
+ uint64 x=LoadUnsigned(S,S->sizeInt);
+ IF (x>(uint64)MAX_INT, "bad integer");
+ return (int)x;
+}
+
 static lua_Number LoadNumber(LoadState* S)
 {
- lua_Number x;
- LoadVar(S,x);
- return x;
+ uint64 bits=LoadUnsigned(S,8);
+ double x;
+ memcpy(&x,&bits,sizeof(x));
+ return (lua_Number)x;
 }
 
 static TString* LoadString(LoadState* S)
 {
- size_t size;
- LoadVar(S,size);
+ uint64 size64=LoadUnsigned(S,S->sizeSizeT);
+ size_t size=(size_t)size64;
+ IF (size64>(uint64)MAX_SIZET, "bad size");
  if (size==0)
   return NULL;
  else
@@ -90,10 +123,11 @@ static TString* LoadString(LoadState* S)
 
 static void LoadCode(LoadState* S, Proto* f)
 {
+ int i;
  int n=LoadInt(S);
  f->code=luaM_newvector(S->L,n,Instruction);
  f->sizecode=n;
- LoadVector(S,f->code,n,sizeof(Instruction));
+ for (i=0; i<n; i++) f->code[i]=(Instruction)LoadUnsigned(S,4);
 }
 
 static Proto* LoadFunction(LoadState* S, TString* p);
@@ -141,7 +175,7 @@ static void LoadDebug(LoadState* S, Proto* f)
  n=LoadInt(S);
  f->lineinfo=luaM_newvector(S->L,n,int);
  f->sizelineinfo=n;
- LoadVector(S,f->lineinfo,n,sizeof(int));
+ for (i=0; i<n; i++) f->lineinfo[i]=LoadInt(S);
  n=LoadInt(S);
  f->locvars=luaM_newvector(S->L,n,LocVar);
  f->sizelocvars=n;
@@ -180,11 +214,18 @@ static Proto* LoadFunction(LoadState* S, TString* p)
 
 static void LoadHeader(LoadState* S)
 {
- char h[LUAC_HEADERSIZE];
- char s[LUAC_HEADERSIZE];
- luaU_header(h);
+ /* ScummVM: the chunk says how it was written, instead of having to match the host */
+ lu_byte s[LUAC_HEADERSIZE];
  LoadBlock(S,s,LUAC_HEADERSIZE);
- IF (memcmp(h,s,LUAC_HEADERSIZE)!=0, "bad header");
+ IF (memcmp(s,LUA_SIGNATURE,sizeof(LUA_SIGNATURE)-1)!=0, "bad header");
+ IF (s[4]!=LUAC_VERSION || s[5]!=LUAC_FORMAT, "bad header");
+ S->littleEndian=s[6];
+ S->sizeInt=s[7];
+ S->sizeSizeT=s[8];
+ IF (S->sizeInt!=4, "unsupported integer size");
+ IF (S->sizeSizeT!=4 && S->sizeSizeT!=8, "unsupported size_t size");
+ IF (s[9]!=4, "unsupported instruction size");
+ IF (s[10]!=8 || s[11]!=0 || sizeof(lua_Number)!=8, "unsupported number format");
 }
 
 /*
@@ -205,21 +246,3 @@ Proto* luaU_undump (lua_State* L, ZIO* Z, Mbuffer* buff, const char* name)
  LoadHeader(&S);
  return LoadFunction(&S,luaS_newliteral(L,"=?"));
 }
-
-/*
-* make header
-*/
-void luaU_header (char* h)
-{
- int x=1;
- memcpy(h,LUA_SIGNATURE,sizeof(LUA_SIGNATURE)-1);
- h+=sizeof(LUA_SIGNATURE)-1;
- *h++=(char)LUAC_VERSION;
- *h++=(char)LUAC_FORMAT;
- *h++=(char)*(char*)&x;				/* endianness */
- *h++=(char)sizeof(int);
- *h++=(char)sizeof(size_t);
- *h++=(char)sizeof(Instruction);
- *h++=(char)sizeof(lua_Number);
- *h++=(char)(((lua_Number)0.5)==0);		/* is lua_Number integral? */
-}
diff --git a/common/lua/lundump.h b/common/lua/lundump.h
index c293c56ce92..3ab5511981e 100644
--- a/common/lua/lundump.h
+++ b/common/lua/lundump.h
@@ -13,9 +13,6 @@
 /* load one chunk; from lundump.c */
 LUAI_FUNC Proto* luaU_undump (lua_State* L, ZIO* Z, Mbuffer* buff, const char* name);
 
-/* make header; from lundump.c */
-LUAI_FUNC void luaU_header (char* h);
-
 /* dump one chunk; from ldump.c */
 LUAI_FUNC int luaU_dump (lua_State* L, const Proto* f, lua_Writer w, void* data, int strip);
 


Commit: 550358268f287dfc03fd2928f26c756dd33aeab5
    https://github.com/scummvm/scummvm/commit/550358268f287dfc03fd2928f26c756dd33aeab5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:01+03:00

Commit Message:
NANCY: NANCY18: Move to a subengine that depends on libpng

Nancy18 stores its images as PNG files, so it needs libpng, which
the earlier games don't. It is now a subengine with that dependency,
which takes the place of the check when the game starts.

Changed paths:
    engines/nancy/configure.engine
    engines/nancy/metaengine.cpp
    engines/nancy/nancy.cpp


diff --git a/engines/nancy/configure.engine b/engines/nancy/configure.engine
index 10f4e4783f1..1d5079218e7 100644
--- a/engines/nancy/configure.engine
+++ b/engines/nancy/configure.engine
@@ -1,3 +1,4 @@
 # This file is included from the main "configure" script
 # add_engine [name] [desc] [build-by-default] [subengines] [base games] [deps] [components]
-add_engine nancy "Nancy Drew" yes "" "" "16bit highres vorbis bink"
+add_engine nancy "Nancy Drew" yes "nancy_v3a" "The Vampire Diaries, Nancy Drew 1-17" "16bit highres vorbis bink"
+add_engine nancy_v3a "Nancy Drew 18" yes "" "" "png"
diff --git a/engines/nancy/metaengine.cpp b/engines/nancy/metaengine.cpp
index c2a94795d50..8dcfa7c440c 100644
--- a/engines/nancy/metaengine.cpp
+++ b/engines/nancy/metaengine.cpp
@@ -146,6 +146,12 @@ bool NancyMetaEngine::hasFeature(MetaEngineFeature f) const {
 }
 
 Common::Error NancyMetaEngine::createInstance(OSystem *syst, Engine **engine, const Nancy::NancyGameDescription *gd) const {
+#ifndef ENABLE_NANCY_V3A
+	if (gd && gd->gameType == Nancy::kGameTypeNancy18) {
+		return Common::Error(Common::kUnsupportedGameidError, _s("Support for Nancy Drew 18 is not compiled in"));
+	}
+#endif
+
 	if (gd) {
 		*engine = Nancy::NancyEngine::create(gd->gameType, syst, gd);
 	}
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 9c0dea30ab5..b96e884e360 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -475,13 +475,6 @@ void NancyEngine::addDeferredLoader(Common::SharedPtr<DeferredLoader> &loaderPtr
 }
 
 Common::Error NancyEngine::run() {
-#ifndef USE_PNG
-	// Nancy18 stores its images as PNG files
-	if (getGameType() >= kGameTypeNancy18) {
-		return Common::Error(Common::kUnknownError, _s("This game requires PNG support, which is not compiled in"));
-	}
-#endif
-
 	setDebugger(new NancyConsole());
 
 	// Set the default number of saves for earlier games


Commit: 868302077641b8f6c5fd5c564b686c9a39b1f89f
    https://github.com/scummvm/scummvm/commit/868302077641b8f6c5fd5c564b686c9a39b1f89f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:03+03:00

Commit Message:
NANCY: NANCY19: Add the nancy_v4 subengine

Nancy19 and newer keep their data and logic in compiled Lua scripts
in place of IFF chunks. Their support goes into a subengine of its
own, since it depends on Lua, and can be left out of a build.

Adds the interpreter that runs the scripts, the helpers that expose
engine objects to them, and the loading of scripts from the game's
resources. Nothing is exposed to the scripts yet.

Changed paths:
  A engines/nancy/script/api.cpp
  A engines/nancy/script/api.h
  A engines/nancy/script/luabind.cpp
  A engines/nancy/script/luabind.h
  A engines/nancy/script/luastate.cpp
  A engines/nancy/script/luastate.h
    engines/nancy/configure.engine
    engines/nancy/metaengine.cpp
    engines/nancy/module.mk
    engines/nancy/resource.cpp
    engines/nancy/resource.h


diff --git a/engines/nancy/configure.engine b/engines/nancy/configure.engine
index 1d5079218e7..c290e1ee04b 100644
--- a/engines/nancy/configure.engine
+++ b/engines/nancy/configure.engine
@@ -1,4 +1,5 @@
 # This file is included from the main "configure" script
 # add_engine [name] [desc] [build-by-default] [subengines] [base games] [deps] [components]
-add_engine nancy "Nancy Drew" yes "nancy_v3a" "The Vampire Diaries, Nancy Drew 1-17" "16bit highres vorbis bink"
+add_engine nancy "Nancy Drew" yes "nancy_v3a nancy_v4" "The Vampire Diaries, Nancy Drew 1-17" "16bit highres vorbis bink"
 add_engine nancy_v3a "Nancy Drew 18" yes "" "" "png"
+add_engine nancy_v4 "Nancy Drew 19 and newer" yes "" "" "png lua"
diff --git a/engines/nancy/metaengine.cpp b/engines/nancy/metaengine.cpp
index 8dcfa7c440c..1a809f26a11 100644
--- a/engines/nancy/metaengine.cpp
+++ b/engines/nancy/metaengine.cpp
@@ -152,6 +152,12 @@ Common::Error NancyMetaEngine::createInstance(OSystem *syst, Engine **engine, co
 	}
 #endif
 
+#ifndef ENABLE_NANCY_V4
+	if (gd && gd->gameType >= Nancy::kGameTypeNancy19) {
+		return Common::Error(Common::kUnsupportedGameidError, _s("Support for Nancy Drew 19 and newer is not compiled in"));
+	}
+#endif
+
 	if (gd) {
 		*engine = Nancy::NancyEngine::create(gd->gameType, syst, gd);
 	}
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index ac21fde1a5c..dfab471d1fc 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -182,6 +182,14 @@ MODULE_OBJS = \
   util.o \
   video.o
 
+# Nancy19 and newer, whose scenes are Lua scripts
+ifdef ENABLE_NANCY_V4
+MODULE_OBJS += \
+  script/api.o \
+  script/luabind.o \
+  script/luastate.o
+endif
+
 # This module can be built as a plugin
 ifeq ($(ENABLE_NANCY), DYNAMIC_PLUGIN)
 PLUGIN := 1
diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index 08960ba43d4..1233af6ebd7 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -275,6 +275,21 @@ IFF *ResourceManager::loadIFF(const Common::Path &name) {
 	return nullptr;
 }
 
+Common::SeekableReadStream *ResourceManager::loadScript(const Common::Path &name) {
+	Common::SeekableReadStream *stream = SearchMan.createReadStreamForMember(name.append(".lua"));
+	if (stream) {
+		return stream;
+	}
+
+	stream = SearchMan.createReadStreamForMember(name.append(".cif"));
+	if (stream) {
+		CifFile cifFile(stream, name); // cifFile takes ownership of the current stream
+		return cifFile.createReadStream();
+	}
+
+	return SearchMan.createReadStreamForMember(Common::Path(name));
+}
+
 bool ResourceManager::readCifTree(const Common::String &name, const Common::String &ext, int priority) {
 	// Nancy15+ asks for a player character's tree again on every switch back
 	// to that character, so make sure each tree is only ever added once
diff --git a/engines/nancy/resource.h b/engines/nancy/resource.h
index 9607ddc4c79..a21c47ec430 100644
--- a/engines/nancy/resource.h
+++ b/engines/nancy/resource.h
@@ -46,6 +46,11 @@ public:
 	// Loads a single IFF file. These can either be inside standalone .cif files, or embedded inside a ciftree
 	IFF *loadIFF(const Common::Path &name);
 
+	// Loads a Lua script, which is what Nancy19 and up have in place of IFFs. These
+	// can be loose text files, standalone .cif files, or embedded inside a ciftree.
+	// The caller owns the stream.
+	Common::SeekableReadStream *loadScript(const Common::Path &name);
+
 	// Load a new ciftree
 	bool readCifTree(const Common::String &name, const Common::String &ext, int priority);
 
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
new file mode 100644
index 00000000000..207e243367a
--- /dev/null
+++ b/engines/nancy/script/api.cpp
@@ -0,0 +1,33 @@
+/* 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/api.h"
+#include "engines/nancy/script/luabind.h"
+
+namespace Nancy {
+namespace Script {
+
+// The objects the scripts use get added here
+void registerAPI(lua_State *L) {
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/api.h b/engines/nancy/script/api.h
new file mode 100644
index 00000000000..7ce8f2b7712
--- /dev/null
+++ b/engines/nancy/script/api.h
@@ -0,0 +1,36 @@
+/* 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_API_H
+#define NANCY_SCRIPT_API_H
+
+struct lua_State;
+
+namespace Nancy {
+namespace Script {
+
+// Puts everything scripts can use into a new state
+void registerAPI(lua_State *L);
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_API_H
diff --git a/engines/nancy/script/luabind.cpp b/engines/nancy/script/luabind.cpp
new file mode 100644
index 00000000000..335ed400044
--- /dev/null
+++ b/engines/nancy/script/luabind.cpp
@@ -0,0 +1,176 @@
+/* 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/array.h"
+
+#include "engines/nancy/script/luabind.h"
+
+namespace Nancy {
+namespace Script {
+
+void registerType(lua_State *L, const char *typeName, const luaL_Reg *functions) {
+	if (luaL_newmetatable(L, typeName)) {
+		luaL_register(L, nullptr, functions);
+
+		lua_pushstring(L, typeName);
+		lua_setfield(L, -2, "__metatable");
+	}
+
+	lua_pop(L, 1);
+}
+
+void registerGlobal(lua_State *L, const char *globalName, const char *typeName, const luaL_Reg *functions) {
+	registerType(L, typeName, functions);
+	pushUserData(L, 1, typeName);
+	lua_setglobal(L, globalName);
+}
+
+void *pushUserData(lua_State *L, size_t size, const char *typeName) {
+	void *data = lua_newuserdata(L, size);
+	luaL_getmetatable(L, typeName);
+	lua_setmetatable(L, -2);
+	return data;
+}
+
+void *testUserData(lua_State *L, int index, const char *typeName) {
+	void *data = lua_touserdata(L, index);
+	if (!data || !lua_getmetatable(L, index)) {
+		return nullptr;
+	}
+
+	luaL_getmetatable(L, typeName);
+	if (!lua_rawequal(L, -1, -2)) {
+		data = nullptr;
+	}
+
+	lua_pop(L, 2);
+	return data;
+}
+
+void *checkUserData(lua_State *L, int index, const char *typeName) {
+	return luaL_checkudata(L, index, typeName);
+}
+
+int indexFunctions(lua_State *L) {
+	if (!lua_getmetatable(L, 1)) {
+		lua_pushnil(L);
+		return 1;
+	}
+
+	lua_pushvalue(L, 2);
+	lua_rawget(L, -2);
+	return 1;
+}
+
+int absoluteIndex(lua_State *L, int index) {
+	if (index < 0 && index > LUA_REGISTRYINDEX) {
+		return lua_gettop(L) + index + 1;
+	}
+
+	return index;
+}
+
+bool pushField(lua_State *L, int tableIndex, const char *name) {
+	lua_getfield(L, tableIndex, name);
+	if (lua_isnil(L, -1)) {
+		lua_pop(L, 1);
+		return false;
+	}
+
+	return true;
+}
+
+bool readField(lua_State *L, int tableIndex, const char *name, Common::String &value) {
+	if (!pushField(L, tableIndex, name)) {
+		return false;
+	}
+
+	if (!lua_isstring(L, -1)) {
+		luaL_error(L, "field %s must be a string", name);
+	}
+
+	value = lua_tostring(L, -1);
+	lua_pop(L, 1);
+	return true;
+}
+
+bool readField(lua_State *L, int tableIndex, const char *name, double &value) {
+	if (!pushField(L, tableIndex, name)) {
+		return false;
+	}
+
+	if (!lua_isnumber(L, -1)) {
+		luaL_error(L, "field %s must be a number", name);
+	}
+
+	value = lua_tonumber(L, -1);
+	lua_pop(L, 1);
+	return true;
+}
+
+bool readField(lua_State *L, int tableIndex, const char *name, int &value) {
+	double number;
+	if (!readField(L, tableIndex, name, number)) {
+		return false;
+	}
+
+	value = (int)number;
+	return true;
+}
+
+bool readField(lua_State *L, int tableIndex, const char *name, bool &value) {
+	if (!pushField(L, tableIndex, name)) {
+		return false;
+	}
+
+	if (!lua_isboolean(L, -1)) {
+		luaL_error(L, "field %s must be a boolean", name);
+	}
+
+	value = lua_toboolean(L, -1) != 0;
+	lua_pop(L, 1);
+	return true;
+}
+
+void readStringList(lua_State *L, int index, Common::Array<Common::String> &list) {
+	index = absoluteIndex(L, index);
+
+	if (lua_type(L, index) == LUA_TSTRING) {
+		list.push_back(lua_tostring(L, index));
+		return;
+	}
+
+	luaL_checktype(L, index, LUA_TTABLE);
+
+	for (int i = 1; ; ++i) {
+		lua_rawgeti(L, index, i);
+		if (lua_isnil(L, -1)) {
+			lua_pop(L, 1);
+			break;
+		}
+
+		list.push_back(luaL_checkstring(L, -1));
+		lua_pop(L, 1);
+	}
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/luabind.h b/engines/nancy/script/luabind.h
new file mode 100644
index 00000000000..9414869b0da
--- /dev/null
+++ b/engines/nancy/script/luabind.h
@@ -0,0 +1,89 @@
+/* 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_LUABIND_H
+#define NANCY_SCRIPT_LUABIND_H
+
+#include "common/array.h"
+#include "common/str.h"
+
+#include "common/lua/lua.h"
+#include "common/lua/lauxlib.h"
+
+namespace Nancy {
+namespace Script {
+
+// Helpers for exposing engine objects to the scripts of Nancy19 and up.
+//
+// Every kind of object has a metatable of its own, kept in the registry under the
+// name of the type ("ND.Rect", "ND.NavLogic"...). Scripts tell objects apart by
+// that name: getmetatable() hands it out instead of the table.
+
+// Makes the metatable of a type out of the given functions
+void registerType(lua_State *L, const char *typeName, const luaL_Reg *functions);
+
+// Makes one of the objects scripts reach through a global ("Rect", "Flags", "Scene"...),
+// which carries nothing but the functions of its type
+void registerGlobal(lua_State *L, const char *globalName, const char *typeName, const luaL_Reg *functions);
+
+// Pushes a new object of a type. The memory belongs to the script, and goes away
+// without a destructor call unless the type has a __gc function.
+void *pushUserData(lua_State *L, size_t size, const char *typeName);
+
+// The object at a stack index, or null when something else is there
+void *testUserData(lua_State *L, int index, const char *typeName);
+
+// Same, but something else is a script error
+void *checkUserData(lua_State *L, int index, const char *typeName);
+
+// __index for the types that have nothing but functions, and the last step of
+// an __index function for a type with fields of its own: pushes the function of
+// the type that is named by the key at index 2, or nil
+int indexFunctions(lua_State *L);
+
+// Pushes a field of the table at an index. A missing field pushes nothing and
+// returns false.
+bool pushField(lua_State *L, int tableIndex, const char *name);
+
+// Reads a field of the table at an index. A missing field leaves the value alone
+// and returns false, one of the wrong type is a script error.
+bool readField(lua_State *L, int tableIndex, const char *name, Common::String &value);
+bool readField(lua_State *L, int tableIndex, const char *name, double &value);
+bool readField(lua_State *L, int tableIndex, const char *name, int &value);
+bool readField(lua_State *L, int tableIndex, const char *name, bool &value);
+
+// A string, or the strings of an array, into a list
+void readStringList(lua_State *L, int index, Common::Array<Common::String> &list);
+
+// Turns a stack index into one that stays valid when values get pushed
+int absoluteIndex(lua_State *L, int index);
+
+template<class T>
+T *pushValue(lua_State *L, const char *typeName, const T &value) {
+	T *data = (T *)pushUserData(L, sizeof(T), typeName);
+	*data = value;
+	return data;
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_LUABIND_H
diff --git a/engines/nancy/script/luastate.cpp b/engines/nancy/script/luastate.cpp
new file mode 100644
index 00000000000..ffef799d184
--- /dev/null
+++ b/engines/nancy/script/luastate.cpp
@@ -0,0 +1,141 @@
+/* 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/stream.h"
+#include "common/textconsole.h"
+
+#include "common/lua/lua.h"
+#include "common/lua/lauxlib.h"
+#include "common/lua/lualib.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/resource.h"
+
+#include "engines/nancy/script/api.h"
+#include "engines/nancy/script/luastate.h"
+
+namespace Nancy {
+namespace Script {
+
+// Registry key of the scene a state belongs to
+static const char kContextKey = 0;
+
+static void openLibrary(lua_State *L, lua_CFunction open, const char *name) {
+	lua_pushcfunction(L, open);
+	lua_pushstring(L, name);
+	lua_call(L, 1, 0);
+}
+
+static void removeGlobal(lua_State *L, const char *name) {
+	lua_pushnil(L);
+	lua_setglobal(L, name);
+}
+
+LuaState::LuaState(SceneContext *context) {
+	_state = luaL_newstate();
+	if (!_state) {
+		error("Could not create a Lua state");
+	}
+
+	// Scripts get the base, table, string and math libraries, minus what
+	// would let them run other code or reach outside of the game
+	openLibrary(_state, luaopen_base, "");
+	openLibrary(_state, luaopen_table, LUA_TABLIBNAME);
+	openLibrary(_state, luaopen_string, LUA_STRLIBNAME);
+	openLibrary(_state, luaopen_math, LUA_MATHLIBNAME);
+
+	removeGlobal(_state, "collectgarbage");
+	removeGlobal(_state, "dofile");
+	removeGlobal(_state, "load");
+	removeGlobal(_state, "loadfile");
+	removeGlobal(_state, "print");
+
+	// The random numbers of the library come from one generator that all
+	// states share, so it is seeded with the first one
+	static bool isSeeded = false;
+	if (!isSeeded) {
+		isSeeded = true;
+
+		lua_getglobal(_state, "math");
+		lua_getfield(_state, -1, "randomseed");
+		lua_pushinteger(_state, g_nancy->_randomSource->getRandomNumber(0x7FFFFFFE));
+		lua_call(_state, 1, 0);
+		lua_pop(_state, 1);
+	}
+
+	lua_pushlightuserdata(_state, (void *)const_cast<char *>(&kContextKey));
+	lua_pushlightuserdata(_state, context);
+	lua_rawset(_state, LUA_REGISTRYINDEX);
+
+	registerAPI(_state);
+}
+
+LuaState::~LuaState() {
+	lua_close(_state);
+}
+
+SceneContext *LuaState::getContext(lua_State *L) {
+	lua_pushlightuserdata(L, (void *)const_cast<char *>(&kContextKey));
+	lua_rawget(L, LUA_REGISTRYINDEX);
+	SceneContext *context = (SceneContext *)lua_touserdata(L, -1);
+	lua_pop(L, 1);
+	return context;
+}
+
+bool LuaState::runScript(const Common::String &name) {
+	Common::SeekableReadStream *stream = g_nancy->_resource->loadScript(Common::Path(name));
+	if (!stream) {
+		warning("Could not find script %s", name.c_str());
+		return false;
+	}
+
+	uint32 size = stream->size();
+	char *buffer = new char[size];
+	stream->read(buffer, size);
+	delete stream;
+
+	Common::String chunkName = "=" + name;
+	int status = luaL_loadbuffer(_state, buffer, size, chunkName.c_str());
+	delete[] buffer;
+
+	if (status != 0) {
+		warning("Could not load script %s: %s", name.c_str(), lua_tostring(_state, -1));
+		lua_pop(_state, 1);
+		return false;
+	}
+
+	Common::String what = "Failed initial run-through of script " + name;
+	return call(_state, 0, 0, what.c_str());
+}
+
+bool LuaState::call(lua_State *L, int numArgs, int numResults, const char *what) {
+	if (lua_pcall(L, numArgs, numResults, 0) != 0) {
+		warning("%s: %s", what, lua_tostring(L, -1));
+		lua_pop(L, 1);
+		return false;
+	}
+
+	return true;
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/luastate.h b/engines/nancy/script/luastate.h
new file mode 100644
index 00000000000..fbdfbb389cf
--- /dev/null
+++ b/engines/nancy/script/luastate.h
@@ -0,0 +1,64 @@
+/* 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_LUASTATE_H
+#define NANCY_SCRIPT_LUASTATE_H
+
+#include "common/str.h"
+
+struct lua_State;
+
+namespace Nancy {
+namespace Script {
+
+class SceneContext;
+
+// A Lua interpreter with the engine's objects in it. Nancy19 and up keep all of
+// their game data and logic in compiled Lua scripts; every script that gets run
+// by itself (the boot files, and each scene of each stream) has a state of its own,
+// so scripts only share what the engine keeps for them (flags, values, inventory).
+class LuaState {
+public:
+	// The context is the scene the scripts of this state belong to, if any
+	LuaState(SceneContext *context = nullptr);
+	~LuaState();
+
+	lua_State *get() const { return _state; }
+
+	// Loads the script of that name and runs its main chunk. Returns false when
+	// there is no such script, or when it fails.
+	bool runScript(const Common::String &name);
+
+	// Calls the function that sits below its arguments on the stack. A failure is
+	// reported together with what was being done, and leaves no results.
+	static bool call(lua_State *L, int numArgs, int numResults, const char *what);
+
+	// The scene a state was made for; null for the boot scripts
+	static SceneContext *getContext(lua_State *L);
+
+private:
+	lua_State *_state;
+};
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_LUASTATE_H


Commit: 2447dea099b9d73797cf1dd39aeac54f17ba1935
    https://github.com/scummvm/scummvm/commit/2447dea099b9d73797cf1dd39aeac54f17ba1935
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:04+03:00

Commit Message:
NANCY: Document the major engine versions and update supported games

Changed paths:
    engines/nancy/nancy.h


diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index 78b32056a6a..f499fc253ac 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -43,9 +43,19 @@ class Serializer;
  *
  * Status of this engine:
  * The Vampire Diaries and all Nancy Drew games up to and including
- * Nancy Drew: Danger on Deception Island are fully completable.
+ * Nancy Drew: The Creature of Kapu Cave are fully completable.
  * Every other game is untested but definitely unplayable.
  *
+ * The engine has the following major versions:
+ * - v1: TVD, Nancy1-9
+ * - v2: Nancy10-11 (revamped interface)
+ * - v2a: Nancy12 (moved hardcoded dialog logic)
+ * - v2b: Nancy13-14 (32bpp graphics, scene include scripts)
+ * - v2c: Nancy15 (multiple characters and UI interfaces)
+ * - v3: Nancy16-17 (revamped interface)
+ * - v3a: Nancy18 (PNG graphics)
+ * - v4: Nancy19 and newer (Lua scripting)
+ *
  * Games using this engine:
  *	- The Vampire Diaries (1996)
  *	- Almost every mainline Nancy Drew game by HeR Interactive,


Commit: e1a217438acdbc13249e676cc2965d85eedeb66e
    https://github.com/scummvm/scummvm/commit/e1a217438acdbc13249e676cc2965d85eedeb66e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:05+03:00

Commit Message:
NANCY: NANCY19: Add the value types of the scripts

Rect, Point and Color, which scripts make and pass around. Rects are
kept the way scripts build them, also when they are empty or inside
out, and only become engine rects where they get used.

Changed paths:
  A engines/nancy/script/types.cpp
  A engines/nancy/script/types.h
    engines/nancy/module.mk
    engines/nancy/script/api.cpp


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index dfab471d1fc..c0a437b56d2 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -187,7 +187,8 @@ ifdef ENABLE_NANCY_V4
 MODULE_OBJS += \
   script/api.o \
   script/luabind.o \
-  script/luastate.o
+  script/luastate.o \
+  script/types.o
 endif
 
 # This module can be built as a plugin
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
index 207e243367a..8ffac2b0dd0 100644
--- a/engines/nancy/script/api.cpp
+++ b/engines/nancy/script/api.cpp
@@ -21,12 +21,13 @@
 
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/types.h"
 
 namespace Nancy {
 namespace Script {
 
-// The objects the scripts use get added here
 void registerAPI(lua_State *L) {
+	registerTypes(L);
 }
 
 } // End of namespace Script
diff --git a/engines/nancy/script/types.cpp b/engines/nancy/script/types.cpp
new file mode 100644
index 00000000000..64ae727d6d9
--- /dev/null
+++ b/engines/nancy/script/types.cpp
@@ -0,0 +1,418 @@
+/* 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/luabind.h"
+#include "engines/nancy/script/types.h"
+
+namespace Nancy {
+namespace Script {
+
+static const char *const kRectType = "ND.Rect";
+static const char *const kRectHelperType = "ND.RectHelper";
+static const char *const kPointType = "ND.Point";
+static const char *const kPointHelperType = "ND.PointHelper";
+static const char *const kColorType = "ND.Color";
+static const char *const kColorHelperType = "ND.ColorHelper";
+
+Common::Rect ScriptRect::toRect() const {
+	if (isEmpty()) {
+		return Common::Rect();
+	}
+
+	return Common::Rect(CLIP<int32>(left, -32768, 32767), CLIP<int32>(top, -32768, 32767),
+						CLIP<int32>(right, -32768, 32767), CLIP<int32>(bottom, -32768, 32767));
+}
+
+// Rect
+
+void pushRect(lua_State *L, const ScriptRect &rect) {
+	pushValue(L, kRectType, rect);
+}
+
+ScriptRect *testRect(lua_State *L, int index) {
+	return (ScriptRect *)testUserData(L, index, kRectType);
+}
+
+ScriptRect &checkRect(lua_State *L, int index) {
+	return *(ScriptRect *)checkUserData(L, index, kRectType);
+}
+
+// The functions that take a place accept either a point or two numbers
+static ScriptPoint checkPointArguments(lua_State *L, int index) {
+	ScriptPoint point;
+
+	if (lua_gettop(L) == index) {
+		if (!readPoint(L, index, point)) {
+			luaL_argerror(L, index, "Single parameter must be a Point or a table with x and y fields");
+		}
+	} else {
+		point.x = luaL_checkint(L, index);
+		point.y = luaL_checkint(L, index + 1);
+	}
+
+	return point;
+}
+
+static int rectNew(lua_State *L) {
+	ScriptRect rect;
+	rect.left = luaL_checkint(L, 2);
+	rect.top = luaL_checkint(L, 3);
+	rect.right = luaL_checkint(L, 4);
+	rect.bottom = luaL_checkint(L, 5);
+	pushRect(L, rect);
+	return 1;
+}
+
+static int rectNewLTWH(lua_State *L) {
+	ScriptRect rect;
+	rect.left = luaL_checkint(L, 2);
+	rect.top = luaL_checkint(L, 3);
+	rect.right = rect.left + luaL_checkint(L, 4);
+	rect.bottom = rect.top + luaL_checkint(L, 5);
+	pushRect(L, rect);
+	return 1;
+}
+
+static int rectIndex(lua_State *L) {
+	const ScriptRect &rect = checkRect(L, 1);
+	Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "left") {
+		lua_pushinteger(L, rect.left);
+	} else if (key == "right") {
+		lua_pushinteger(L, rect.right);
+	} else if (key == "top") {
+		lua_pushinteger(L, rect.top);
+	} else if (key == "bottom") {
+		lua_pushinteger(L, rect.bottom);
+	} else if (key == "width") {
+		lua_pushinteger(L, rect.width());
+	} else if (key == "height") {
+		lua_pushinteger(L, rect.height());
+	} else if (key == "center") {
+		pushPoint(L, ScriptPoint(rect.left + rect.width() / 2, rect.top + rect.height() / 2));
+	} else {
+		return indexFunctions(L);
+	}
+
+	return 1;
+}
+
+static int rectNewIndex(lua_State *L) {
+	ScriptRect &rect = checkRect(L, 1);
+	Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "left") {
+		rect.left = luaL_checkint(L, 3);
+	} else if (key == "right") {
+		rect.right = luaL_checkint(L, 3);
+	} else if (key == "top") {
+		rect.top = luaL_checkint(L, 3);
+	} else if (key == "bottom") {
+		rect.bottom = luaL_checkint(L, 3);
+	} else if (key == "width") {
+		rect.right = rect.left + luaL_checkint(L, 3);
+	} else if (key == "height") {
+		rect.bottom = rect.top + luaL_checkint(L, 3);
+	} else {
+		return luaL_error(L, "Object %s does not have field '%s'", kRectType, key.c_str());
+	}
+
+	return 0;
+}
+
+static int rectEquals(lua_State *L) {
+	lua_pushboolean(L, checkRect(L, 1) == checkRect(L, 2));
+	return 1;
+}
+
+static int rectContains(lua_State *L) {
+	const ScriptRect &rect = checkRect(L, 1);
+
+	ScriptPoint point;
+	if (!readPoint(L, 2, point)) {
+		return luaL_error(L, "Single parameter must be a Point, a Vector or a table with x and y fields");
+	}
+
+	lua_pushboolean(L, rect.contains(point.x, point.y));
+	return 1;
+}
+
+static int rectOffset(lua_State *L) {
+	ScriptRect rect = checkRect(L, 1);
+	ScriptPoint offset = checkPointArguments(L, 2);
+	rect.translate(offset.x, offset.y);
+	pushRect(L, rect);
+	return 1;
+}
+
+// Returns whether the two overlap, and the part they share
+static int rectIntersect(lua_State *L) {
+	const ScriptRect &a = checkRect(L, 1);
+	const ScriptRect &b = checkRect(L, 2);
+
+	ScriptRect shared(MAX(a.left, b.left), MAX(a.top, b.top), MIN(a.right, b.right), MIN(a.bottom, b.bottom));
+	if (a.isEmpty() || b.isEmpty() || shared.isEmpty()) {
+		shared = ScriptRect();
+	}
+
+	lua_pushboolean(L, !shared.isEmpty());
+	pushRect(L, shared);
+	return 2;
+}
+
+static int rectCenteredOn(lua_State *L) {
+	ScriptRect rect = checkRect(L, 1);
+	ScriptPoint center = checkPointArguments(L, 2);
+	rect.translate(center.x - (rect.left + rect.width() / 2), center.y - (rect.top + rect.height() / 2));
+	pushRect(L, rect);
+	return 1;
+}
+
+static const luaL_Reg kRectFunctions[] = {
+	{ "__index", rectIndex },
+	{ "__newindex", rectNewIndex },
+	{ "__eq", rectEquals },
+	{ "Contains", rectContains },
+	{ "Offset", rectOffset },
+	{ "Intersect", rectIntersect },
+	{ "CenteredOn", rectCenteredOn },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kRectHelperFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "New", rectNew },
+	{ "LTRB", rectNew },
+	{ "LTWH", rectNewLTWH },
+	{ nullptr, nullptr }
+};
+
+// Point
+
+void pushPoint(lua_State *L, const ScriptPoint &point) {
+	pushValue(L, kPointType, point);
+}
+
+static ScriptPoint &checkPoint(lua_State *L, int index) {
+	return *(ScriptPoint *)checkUserData(L, index, kPointType);
+}
+
+bool readPoint(lua_State *L, int index, ScriptPoint &point) {
+	int type = lua_type(L, index);
+	if (type != LUA_TUSERDATA && type != LUA_TTABLE) {
+		return false;
+	}
+
+	index = absoluteIndex(L, index);
+
+	lua_getfield(L, index, "x");
+	lua_getfield(L, index, "y");
+	bool hasBoth = lua_isnumber(L, -1) && lua_isnumber(L, -2);
+	if (hasBoth) {
+		point.x = (int32)lua_tointeger(L, -2);
+		point.y = (int32)lua_tointeger(L, -1);
+	}
+
+	lua_pop(L, 2);
+	return hasBoth;
+}
+
+static int pointNew(lua_State *L) {
+	ScriptPoint point;
+	point.x = luaL_checkint(L, 2);
+	point.y = luaL_checkint(L, 3);
+	pushPoint(L, point);
+	return 1;
+}
+
+static int pointIndex(lua_State *L) {
+	const ScriptPoint &point = checkPoint(L, 1);
+	Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "x") {
+		lua_pushinteger(L, point.x);
+	} else if (key == "y") {
+		lua_pushinteger(L, point.y);
+	} else {
+		return indexFunctions(L);
+	}
+
+	return 1;
+}
+
+static int pointNewIndex(lua_State *L) {
+	ScriptPoint &point = checkPoint(L, 1);
+	Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "x") {
+		point.x = luaL_checkint(L, 3);
+	} else if (key == "y") {
+		point.y = luaL_checkint(L, 3);
+	} else {
+		return luaL_error(L, "Object %s does not have field '%s'", kPointType, key.c_str());
+	}
+
+	return 0;
+}
+
+static int pointEquals(lua_State *L) {
+	const ScriptPoint &a = checkPoint(L, 1);
+	const ScriptPoint &b = checkPoint(L, 2);
+	lua_pushboolean(L, a.x == b.x && a.y == b.y);
+	return 1;
+}
+
+static int pointAdd(lua_State *L) {
+	const ScriptPoint &a = checkPoint(L, 1);
+	const ScriptPoint &b = checkPoint(L, 2);
+	pushPoint(L, ScriptPoint(a.x + b.x, a.y + b.y));
+	return 1;
+}
+
+static int pointSubtract(lua_State *L) {
+	const ScriptPoint &a = checkPoint(L, 1);
+	const ScriptPoint &b = checkPoint(L, 2);
+	pushPoint(L, ScriptPoint(a.x - b.x, a.y - b.y));
+	return 1;
+}
+
+static const luaL_Reg kPointFunctions[] = {
+	{ "__index", pointIndex },
+	{ "__newindex", pointNewIndex },
+	{ "__eq", pointEquals },
+	{ "__add", pointAdd },
+	{ "__sub", pointSubtract },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kPointHelperFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "New", pointNew },
+	{ nullptr, nullptr }
+};
+
+// Color
+
+void pushColor(lua_State *L, uint32 argb) {
+	pushValue(L, kColorType, argb);
+}
+
+uint32 *testColor(lua_State *L, int index) {
+	return (uint32 *)testUserData(L, index, kColorType);
+}
+
+uint32 checkColor(lua_State *L, int index) {
+	return *(uint32 *)checkUserData(L, index, kColorType);
+}
+
+// The shift of a color's component, or -1 when there is none by that name
+static int getColorComponentShift(const Common::String &name) {
+	if (name == "r") {
+		return 16;
+	} else if (name == "g") {
+		return 8;
+	} else if (name == "b") {
+		return 0;
+	} else if (name == "a") {
+		return 24;
+	}
+
+	return -1;
+}
+
+static int colorNew(lua_State *L) {
+	uint32 a = luaL_checkint(L, 2) & 0xFF;
+	uint32 r = luaL_checkint(L, 3) & 0xFF;
+	uint32 g = luaL_checkint(L, 4) & 0xFF;
+	uint32 b = luaL_checkint(L, 5) & 0xFF;
+	pushColor(L, (a << 24) | (r << 16) | (g << 8) | b);
+	return 1;
+}
+
+static int colorIndex(lua_State *L) {
+	uint32 color = checkColor(L, 1);
+	int shift = getColorComponentShift(luaL_checkstring(L, 2));
+	if (shift < 0) {
+		return indexFunctions(L);
+	}
+
+	lua_pushnumber(L, (color >> shift) & 0xFF);
+	return 1;
+}
+
+static int colorNewIndex(lua_State *L) {
+	uint32 *color = (uint32 *)checkUserData(L, 1, kColorType);
+	const char *key = luaL_checkstring(L, 2);
+	uint32 value = (uint32)(int32)luaL_checknumber(L, 3) & 0xFF;
+
+	int shift = getColorComponentShift(key);
+	if (shift < 0) {
+		return luaL_error(L, "color does not have %s as a field", key);
+	}
+
+	*color = (*color & ~((uint32)0xFF << shift)) | (value << shift);
+	return 0;
+}
+
+static int colorEquals(lua_State *L) {
+	lua_pushboolean(L, checkColor(L, 1) == checkColor(L, 2));
+	return 1;
+}
+
+static const luaL_Reg kColorFunctions[] = {
+	{ "__index", colorIndex },
+	{ "__newindex", colorNewIndex },
+	{ "__eq", colorEquals },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kColorHelperFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "New", colorNew },
+	{ nullptr, nullptr }
+};
+
+void registerTypes(lua_State *L) {
+	registerType(L, kRectType, kRectFunctions);
+	registerGlobal(L, "Rect", kRectHelperType, kRectHelperFunctions);
+
+	// The helpers also carry the constants of their type, which can't go into the
+	// userdata, so they live in the metatable next to the functions
+	luaL_getmetatable(L, kRectHelperType);
+	pushRect(L, ScriptRect());
+	lua_setfield(L, -2, "empty");
+	lua_pop(L, 1);
+
+	registerType(L, kPointType, kPointFunctions);
+	registerGlobal(L, "Point", kPointHelperType, kPointHelperFunctions);
+
+	luaL_getmetatable(L, kPointHelperType);
+	pushPoint(L, ScriptPoint());
+	lua_setfield(L, -2, "origin");
+	lua_pop(L, 1);
+
+	registerType(L, kColorType, kColorFunctions);
+	registerGlobal(L, "Color", kColorHelperType, kColorHelperFunctions);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/types.h b/engines/nancy/script/types.h
new file mode 100644
index 00000000000..52493c1802c
--- /dev/null
+++ b/engines/nancy/script/types.h
@@ -0,0 +1,92 @@
+/* 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_TYPES_H
+#define NANCY_SCRIPT_TYPES_H
+
+#include "common/rect.h"
+
+struct lua_State;
+
+namespace Nancy {
+namespace Script {
+
+// The plain values scripts pass around: rectangles, points and colors.
+//
+// Scripts are free to build rectangles that are empty or inside out, and to move
+// them far off the screen, so they are kept as they are and only turned into the
+// engine's own type where they get used.
+
+struct ScriptRect {
+	int32 left = 0;
+	int32 top = 0;
+	int32 right = 0;
+	int32 bottom = 0;
+
+	ScriptRect() {}
+	ScriptRect(int32 l, int32 t, int32 r, int32 b) : left(l), top(t), right(r), bottom(b) {}
+	ScriptRect(const Common::Rect &rect) : left(rect.left), top(rect.top), right(rect.right), bottom(rect.bottom) {}
+
+	int32 width() const { return right - left; }
+	int32 height() const { return bottom - top; }
+	bool isEmpty() const { return right <= left || bottom <= top; }
+	bool contains(int32 x, int32 y) const { return x >= left && x < right && y >= top && y < bottom; }
+	void translate(int32 dx, int32 dy) { left += dx; right += dx; top += dy; bottom += dy; }
+	bool operator==(const ScriptRect &other) const {
+		return left == other.left && top == other.top && right == other.right && bottom == other.bottom;
+	}
+
+	// An empty or inside out rectangle becomes an empty one
+	Common::Rect toRect() const;
+};
+
+struct ScriptPoint {
+	int32 x = 0;
+	int32 y = 0;
+
+	ScriptPoint() {}
+	ScriptPoint(int32 px, int32 py) : x(px), y(py) {}
+	ScriptPoint(const Common::Point &point) : x(point.x), y(point.y) {}
+
+	Common::Point toPoint() const { return Common::Point(x, y); }
+};
+
+void registerTypes(lua_State *L);
+
+void pushRect(lua_State *L, const ScriptRect &rect);
+ScriptRect *testRect(lua_State *L, int index);		// null when it is something else
+ScriptRect &checkRect(lua_State *L, int index);
+
+void pushPoint(lua_State *L, const ScriptPoint &point);
+
+// Reads anything that has an x and a y: a point, a vector or a table.
+// Returns false when the value has neither.
+bool readPoint(lua_State *L, int index, ScriptPoint &point);
+
+// Colors are 0xAARRGGBB
+void pushColor(lua_State *L, uint32 argb);
+uint32 *testColor(lua_State *L, int index);
+uint32 checkColor(lua_State *L, int index);
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_TYPES_H


Commit: e796dc625cf32da76055701b555f054b4b83fab0
    https://github.com/scummvm/scummvm/commit/e796dc625cf32da76055701b555f054b4b83fab0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:07+03:00

Commit Message:
NANCY: NANCY19: Add vectors and matrices

Scripts place and move what they show with vectors of two to four
components and 4x4 matrices, which transform row vectors. Adds both,
and the transforming of a Rect by a matrix.

Changed paths:
  A engines/nancy/script/mathtypes.cpp
  A engines/nancy/script/mathtypes.h
    engines/nancy/module.mk
    engines/nancy/script/api.cpp


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index c0a437b56d2..fb8c8f0c78a 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -188,6 +188,7 @@ MODULE_OBJS += \
   script/api.o \
   script/luabind.o \
   script/luastate.o \
+  script/mathtypes.o \
   script/types.o
 endif
 
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
index 8ffac2b0dd0..a11a20f8260 100644
--- a/engines/nancy/script/api.cpp
+++ b/engines/nancy/script/api.cpp
@@ -21,6 +21,7 @@
 
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/mathtypes.h"
 #include "engines/nancy/script/types.h"
 
 namespace Nancy {
@@ -28,6 +29,7 @@ namespace Script {
 
 void registerAPI(lua_State *L) {
 	registerTypes(L);
+	registerMathTypes(L);
 }
 
 } // End of namespace Script
diff --git a/engines/nancy/script/mathtypes.cpp b/engines/nancy/script/mathtypes.cpp
new file mode 100644
index 00000000000..b7775ffdec1
--- /dev/null
+++ b/engines/nancy/script/mathtypes.cpp
@@ -0,0 +1,671 @@
+/* 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/scummsys.h"
+#include "common/util.h"
+
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/mathtypes.h"
+
+namespace Nancy {
+namespace Script {
+
+static const char *const kVectorType = "ND.Vector";
+static const char *const kVectorHelperType = "ND.VectorHelper";
+static const char *const kMatrixType = "ND.Matrix";
+static const char *const kMatrixHelperType = "ND.MatrixHelper";
+
+ScriptMatrix::ScriptMatrix() {
+	for (int row = 0; row < 4; ++row) {
+		for (int column = 0; column < 4; ++column) {
+			m[row][column] = row == column ? 1.0 : 0.0;
+		}
+	}
+}
+
+bool ScriptMatrix::operator==(const ScriptMatrix &other) const {
+	for (int row = 0; row < 4; ++row) {
+		for (int column = 0; column < 4; ++column) {
+			if (m[row][column] != other.m[row][column]) {
+				return false;
+			}
+		}
+	}
+
+	return true;
+}
+
+ScriptMatrix ScriptMatrix::operator*(const ScriptMatrix &other) const {
+	ScriptMatrix result;
+
+	for (int row = 0; row < 4; ++row) {
+		for (int column = 0; column < 4; ++column) {
+			double sum = 0.0;
+			for (int i = 0; i < 4; ++i) {
+				sum += m[row][i] * other.m[i][column];
+			}
+
+			result.m[row][column] = sum;
+		}
+	}
+
+	return result;
+}
+
+// Moves a vector by a matrix; w is 1 for a place and 0 for a direction
+static void transformVector(const ScriptMatrix &matrix, const double *in, double w, double *out) {
+	for (int column = 0; column < 4; ++column) {
+		out[column] = in[0] * matrix.m[0][column] + in[1] * matrix.m[1][column] +
+					  in[2] * matrix.m[2][column] + w * matrix.m[3][column];
+	}
+}
+
+void ScriptMatrix::transformPoint(double &x, double &y) const {
+	const double in[3] = { x, y, 0.0 };
+	double out[4];
+	transformVector(*this, in, 1.0, out);
+
+	if (out[3] != 0.0 && out[3] != 1.0) {
+		out[0] /= out[3];
+		out[1] /= out[3];
+	}
+
+	x = out[0];
+	y = out[1];
+}
+
+bool ScriptMatrix::isTranslation() const {
+	ScriptMatrix withoutOffset = *this;
+	withoutOffset.m[3][0] = 0.0;
+	withoutOffset.m[3][1] = 0.0;
+	withoutOffset.m[3][2] = 0.0;
+	return withoutOffset == ScriptMatrix();
+}
+
+ScriptRect transformRect(const ScriptRect &rect, const ScriptMatrix &matrix) {
+	double left = rect.left;
+	double top = rect.top;
+	double right = rect.right;
+	double bottom = rect.bottom;
+
+	matrix.transformPoint(left, top);
+	matrix.transformPoint(right, bottom);
+
+	return ScriptRect((int32)left, (int32)top, (int32)right, (int32)bottom);
+}
+
+// Vector
+
+void pushVector(lua_State *L, const ScriptVector &vector) {
+	pushValue(L, kVectorType, vector);
+}
+
+ScriptVector *testVector(lua_State *L, int index) {
+	return (ScriptVector *)testUserData(L, index, kVectorType);
+}
+
+static ScriptVector &checkVector(lua_State *L, int index) {
+	return *(ScriptVector *)checkUserData(L, index, kVectorType);
+}
+
+// Vector:New(x, y[, z[, w]])
+static int vectorNew(lua_State *L) {
+	ScriptVector vector;
+	vector.size = lua_gettop(L) - 1;
+	if (vector.size < 2 || vector.size > 4) {
+		return luaL_error(L, "Only 2D, 3D and 4D vectors are supported");
+	}
+
+	for (int i = 0; i < vector.size; ++i) {
+		vector.v[i] = luaL_checknumber(L, i + 2);
+	}
+
+	pushVector(L, vector);
+	return 1;
+}
+
+// The component a key stands for: a number from 1, or one of the letters of a
+// place or a color. Returns -1 for anything else.
+static int getVectorComponent(lua_State *L, int keyIndex) {
+	if (lua_type(L, keyIndex) == LUA_TNUMBER) {
+		const int component = (int)lua_tointeger(L, keyIndex) - 1;
+		return component >= 0 && component < 4 ? component : -1;
+	}
+
+	if (lua_type(L, keyIndex) != LUA_TSTRING) {
+		return -1;
+	}
+
+	static const char *const kNames[4][2] = { { "x", "r" }, { "y", "g" }, { "z", "b" }, { "w", "a" } };
+
+	const char *key = lua_tostring(L, keyIndex);
+	for (int i = 0; i < 4; ++i) {
+		if (strcmp(key, kNames[i][0]) == 0 || strcmp(key, kNames[i][1]) == 0) {
+			return i;
+		}
+	}
+
+	return -1;
+}
+
+static double getVectorLength(const ScriptVector &vector) {
+	double sum = 0.0;
+	for (int i = 0; i < vector.size; ++i) {
+		sum += vector.v[i] * vector.v[i];
+	}
+
+	return sqrt(sum);
+}
+
+static int vectorIndex(lua_State *L) {
+	const ScriptVector &vector = checkVector(L, 1);
+
+	if (lua_type(L, 2) == LUA_TSTRING) {
+		const char *key = lua_tostring(L, 2);
+		if (strcmp(key, "len") == 0 || strcmp(key, "length") == 0) {
+			lua_pushnumber(L, getVectorLength(vector));
+			return 1;
+		}
+	}
+
+	const int component = getVectorComponent(L, 2);
+	if (component >= 0) {
+		if (component >= vector.size) {
+			return luaL_error(L, "Vector does not have field requested");
+		}
+
+		lua_pushnumber(L, vector.v[component]);
+		return 1;
+	}
+
+	return indexFunctions(L);
+}
+
+static int vectorNewIndex(lua_State *L) {
+	ScriptVector &vector = checkVector(L, 1);
+
+	const int component = getVectorComponent(L, 2);
+	if (component < 0 || component >= vector.size) {
+		return luaL_error(L, "Vector does not have field requested");
+	}
+
+	vector.v[component] = luaL_checknumber(L, 3);
+	return 0;
+}
+
+static void checkSameSize(lua_State *L, const ScriptVector &a, const ScriptVector &b) {
+	if (a.size != b.size) {
+		luaL_error(L, "Vector 1 has %d dimensions but vector 2 has %d dimensions", a.size, b.size);
+	}
+}
+
+static int vectorAdd(lua_State *L) {
+	ScriptVector result = checkVector(L, 1);
+	const ScriptVector &other = checkVector(L, 2);
+	checkSameSize(L, result, other);
+
+	for (int i = 0; i < result.size; ++i) {
+		result.v[i] += other.v[i];
+	}
+
+	pushVector(L, result);
+	return 1;
+}
+
+static int vectorSubtract(lua_State *L) {
+	ScriptVector result = checkVector(L, 1);
+	const ScriptVector &other = checkVector(L, 2);
+	checkSameSize(L, result, other);
+
+	for (int i = 0; i < result.size; ++i) {
+		result.v[i] -= other.v[i];
+	}
+
+	pushVector(L, result);
+	return 1;
+}
+
+// A vector times a number, either way around, or two vectors part by part
+static int vectorMultiply(lua_State *L) {
+	const ScriptVector *left = testVector(L, 1);
+	const ScriptVector *right = testVector(L, 2);
+
+	ScriptVector result;
+	if (left && right) {
+		checkSameSize(L, *left, *right);
+		result = *left;
+		for (int i = 0; i < result.size; ++i) {
+			result.v[i] *= right->v[i];
+		}
+	} else {
+		result = left ? *left : checkVector(L, 2);
+		const double factor = luaL_checknumber(L, left ? 2 : 1);
+		for (int i = 0; i < result.size; ++i) {
+			result.v[i] *= factor;
+		}
+	}
+
+	pushVector(L, result);
+	return 1;
+}
+
+static int vectorDivide(lua_State *L) {
+	if (!testVector(L, 1)) {
+		return luaL_error(L, "Cannot divide a scalar by a vector");
+	}
+
+	ScriptVector result = checkVector(L, 1);
+	const ScriptVector *right = testVector(L, 2);
+
+	if (right) {
+		checkSameSize(L, result, *right);
+		for (int i = 0; i < result.size; ++i) {
+			result.v[i] /= right->v[i];
+		}
+	} else {
+		const double divisor = luaL_checknumber(L, 2);
+		for (int i = 0; i < result.size; ++i) {
+			result.v[i] /= divisor;
+		}
+	}
+
+	pushVector(L, result);
+	return 1;
+}
+
+static int vectorNegate(lua_State *L) {
+	ScriptVector result = checkVector(L, 1);
+	for (int i = 0; i < result.size; ++i) {
+		result.v[i] = -result.v[i];
+	}
+
+	pushVector(L, result);
+	return 1;
+}
+
+static int vectorDot(lua_State *L) {
+	const ScriptVector &a = checkVector(L, 1);
+	const ScriptVector &b = checkVector(L, 2);
+	checkSameSize(L, a, b);
+
+	double sum = 0.0;
+	for (int i = 0; i < a.size; ++i) {
+		sum += a.v[i] * b.v[i];
+	}
+
+	lua_pushnumber(L, sum);
+	return 1;
+}
+
+static int vectorCross(lua_State *L) {
+	const ScriptVector &a = checkVector(L, 1);
+	const ScriptVector &b = checkVector(L, 2);
+	if (a.size != 3 || b.size != 3) {
+		return luaL_error(L, "Both vectors must be 3D");
+	}
+
+	pushVector(L, ScriptVector(a.v[1] * b.v[2] - a.v[2] * b.v[1],
+							   a.v[2] * b.v[0] - a.v[0] * b.v[2],
+							   a.v[0] * b.v[1] - a.v[1] * b.v[0]));
+	return 1;
+}
+
+// The vector made one unit long
+static int vectorNormalize(lua_State *L) {
+	ScriptVector result = checkVector(L, 1);
+
+	const double length = getVectorLength(result);
+	if (length != 0.0) {
+		for (int i = 0; i < result.size; ++i) {
+			result.v[i] /= length;
+		}
+	}
+
+	pushVector(L, result);
+	return 1;
+}
+
+// vector:Lerp(other, amount)
+static int vectorLerp(lua_State *L) {
+	ScriptVector result = checkVector(L, 1);
+	const ScriptVector &other = checkVector(L, 2);
+	const double amount = luaL_checknumber(L, 3);
+	checkSameSize(L, result, other);
+
+	for (int i = 0; i < result.size; ++i) {
+		result.v[i] += (other.v[i] - result.v[i]) * amount;
+	}
+
+	pushVector(L, result);
+	return 1;
+}
+
+// Moves a vector by a matrix, as a place ("coord") or as a direction ("normal")
+static int pushTransformedVector(lua_State *L, const ScriptVector &vector, const ScriptMatrix &matrix, const char *kind) {
+	bool isPlace;
+	if (strcmp(kind, "coord") == 0) {
+		isPlace = true;
+	} else if (strcmp(kind, "normal") == 0) {
+		isPlace = false;
+	} else {
+		return luaL_error(L, "Transform type must be coord or normal");
+	}
+
+	if (vector.size < 3) {
+		return luaL_error(L, "Vector transform must be on a 3D or 4D vector");
+	}
+
+	ScriptVector result = vector;
+	double out[4];
+	transformVector(matrix, vector.v, vector.size == 4 ? vector.v[3] : (isPlace ? 1.0 : 0.0), out);
+
+	if (vector.size == 3 && isPlace && out[3] != 0.0) {
+		for (int i = 0; i < 3; ++i) {
+			out[i] /= out[3];
+		}
+	}
+
+	for (int i = 0; i < result.size; ++i) {
+		result.v[i] = out[i];
+	}
+
+	pushVector(L, result);
+	return 1;
+}
+
+// vector:Transform(kind, matrix)
+static int vectorTransform(lua_State *L) {
+	const ScriptVector &vector = checkVector(L, 1);
+	const char *kind = luaL_checkstring(L, 2);
+	return pushTransformedVector(L, vector, checkMatrix(L, 3), kind);
+}
+
+static const luaL_Reg kVectorFunctions[] = {
+	{ "__index", vectorIndex },
+	{ "__newindex", vectorNewIndex },
+	{ "__add", vectorAdd },
+	{ "__sub", vectorSubtract },
+	{ "__mul", vectorMultiply },
+	{ "__div", vectorDivide },
+	{ "__unm", vectorNegate },
+	{ "Dot", vectorDot },
+	{ "Cross", vectorCross },
+	{ "Norm", vectorNormalize },
+	{ "Normalize", vectorNormalize },
+	{ "Lerp", vectorLerp },
+	{ "Transform", vectorTransform },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kVectorHelperFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "New", vectorNew },
+	{ nullptr, nullptr }
+};
+
+// Matrix
+
+void pushMatrix(lua_State *L, const ScriptMatrix &matrix) {
+	pushValue(L, kMatrixType, matrix);
+}
+
+ScriptMatrix *testMatrix(lua_State *L, int index) {
+	return (ScriptMatrix *)testUserData(L, index, kMatrixType);
+}
+
+ScriptMatrix &checkMatrix(lua_State *L, int index) {
+	return *(ScriptMatrix *)checkUserData(L, index, kMatrixType);
+}
+
+// Matrix:New(sixteen numbers, row by row)
+static int matrixNew(lua_State *L) {
+	ScriptMatrix matrix;
+	for (int i = 0; i < 16; ++i) {
+		matrix.m[i / 4][i % 4] = luaL_checknumber(L, i + 2);
+	}
+
+	pushMatrix(L, matrix);
+	return 1;
+}
+
+// Matrix:Translate(x, y, z)
+static int matrixTranslate(lua_State *L) {
+	ScriptMatrix matrix;
+	matrix.m[3][0] = luaL_checknumber(L, 2);
+	matrix.m[3][1] = luaL_checknumber(L, 3);
+	matrix.m[3][2] = luaL_checknumber(L, 4);
+	pushMatrix(L, matrix);
+	return 1;
+}
+
+// Matrix:Scale(x, y, z)
+static int matrixScale(lua_State *L) {
+	ScriptMatrix matrix;
+	matrix.m[0][0] = luaL_checknumber(L, 2);
+	matrix.m[1][1] = luaL_checknumber(L, 3);
+	matrix.m[2][2] = luaL_checknumber(L, 4);
+	pushMatrix(L, matrix);
+	return 1;
+}
+
+// Matrix:RotateAngle(axis, angle), with the axis one of "x", "y" and "z"
+// and the angle in radians
+static int matrixRotateAngle(lua_State *L) {
+	const char *axis = luaL_checkstring(L, 2);
+	const double angle = luaL_checknumber(L, 3);
+	const double sine = sin(angle);
+	const double cosine = cos(angle);
+
+	// The two axes the rotation turns into each other
+	int first, second;
+	if (scumm_stricmp(axis, "x") == 0) {
+		first = 1;
+		second = 2;
+	} else if (scumm_stricmp(axis, "y") == 0) {
+		first = 2;
+		second = 0;
+	} else if (scumm_stricmp(axis, "z") == 0) {
+		first = 0;
+		second = 1;
+	} else {
+		return luaL_error(L, "Axis must be x, y or z");
+	}
+
+	ScriptMatrix matrix;
+	matrix.m[first][first] = cosine;
+	matrix.m[first][second] = sine;
+	matrix.m[second][first] = -sine;
+	matrix.m[second][second] = cosine;
+	pushMatrix(L, matrix);
+	return 1;
+}
+
+// A row of the matrix, as a vector
+static int matrixIndex(lua_State *L) {
+	const ScriptMatrix &matrix = checkMatrix(L, 1);
+
+	if (lua_type(L, 2) != LUA_TNUMBER) {
+		return indexFunctions(L);
+	}
+
+	const int row = (int)lua_tointeger(L, 2) - 1;
+	if (row < 0 || row >= 4) {
+		return luaL_error(L, "Matrix does not have field %d", row + 1);
+	}
+
+	ScriptVector vector;
+	vector.size = 4;
+	for (int i = 0; i < 4; ++i) {
+		vector.v[i] = matrix.m[row][i];
+	}
+
+	pushVector(L, vector);
+	return 1;
+}
+
+static int matrixNewIndex(lua_State *L) {
+	ScriptMatrix &matrix = checkMatrix(L, 1);
+
+	const int row = luaL_checkint(L, 2) - 1;
+	if (row < 0 || row >= 4) {
+		return luaL_error(L, "Matrix does not have field %d", row + 1);
+	}
+
+	const ScriptVector &vector = checkVector(L, 3);
+	if (vector.size != 4) {
+		return luaL_error(L, "Matrix rows are 4D not %dD", vector.size);
+	}
+
+	for (int i = 0; i < 4; ++i) {
+		matrix.m[row][i] = vector.v[i];
+	}
+
+	return 0;
+}
+
+static int matrixMultiply(lua_State *L) {
+	pushMatrix(L, checkMatrix(L, 1) * checkMatrix(L, 2));
+	return 1;
+}
+
+static int matrixEquals(lua_State *L) {
+	lua_pushboolean(L, checkMatrix(L, 1) == checkMatrix(L, 2));
+	return 1;
+}
+
+static int matrixTranspose(lua_State *L) {
+	const ScriptMatrix &matrix = checkMatrix(L, 1);
+
+	ScriptMatrix result;
+	for (int row = 0; row < 4; ++row) {
+		for (int column = 0; column < 4; ++column) {
+			result.m[row][column] = matrix.m[column][row];
+		}
+	}
+
+	pushMatrix(L, result);
+	return 1;
+}
+
+// The matrix that undoes this one; one that can't be undone comes back as it is
+static int matrixInverse(lua_State *L) {
+	ScriptMatrix work = checkMatrix(L, 1);
+	ScriptMatrix result;
+
+	for (int column = 0; column < 4; ++column) {
+		// The row with the largest value in this column goes on the diagonal
+		int pivot = column;
+		for (int row = column + 1; row < 4; ++row) {
+			if (fabs(work.m[row][column]) > fabs(work.m[pivot][column])) {
+				pivot = row;
+			}
+		}
+
+		if (work.m[pivot][column] == 0.0) {
+			pushMatrix(L, checkMatrix(L, 1));
+			return 1;
+		}
+
+		for (int i = 0; i < 4; ++i) {
+			SWAP(work.m[column][i], work.m[pivot][i]);
+			SWAP(result.m[column][i], result.m[pivot][i]);
+		}
+
+		const double divisor = work.m[column][column];
+		for (int i = 0; i < 4; ++i) {
+			work.m[column][i] /= divisor;
+			result.m[column][i] /= divisor;
+		}
+
+		for (int row = 0; row < 4; ++row) {
+			if (row == column) {
+				continue;
+			}
+
+			const double factor = work.m[row][column];
+			for (int i = 0; i < 4; ++i) {
+				work.m[row][i] -= factor * work.m[column][i];
+				result.m[row][i] -= factor * result.m[column][i];
+			}
+		}
+	}
+
+	pushMatrix(L, result);
+	return 1;
+}
+
+// matrix:Transform(kind, vector)
+static int matrixTransform(lua_State *L) {
+	const ScriptMatrix &matrix = checkMatrix(L, 1);
+	const char *kind = luaL_checkstring(L, 2);
+	return pushTransformedVector(L, checkVector(L, 3), matrix, kind);
+}
+
+static const luaL_Reg kMatrixFunctions[] = {
+	{ "__index", matrixIndex },
+	{ "__newindex", matrixNewIndex },
+	{ "__mul", matrixMultiply },
+	{ "__eq", matrixEquals },
+	{ "Inverse", matrixInverse },
+	{ "Transpose", matrixTranspose },
+	{ "Transform", matrixTransform },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kMatrixHelperFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "New", matrixNew },
+	{ "Translation", matrixTranslate },
+	{ "Translate", matrixTranslate },
+	{ "Scaling", matrixScale },
+	{ "Scale", matrixScale },
+	{ "RotationAngle", matrixRotateAngle },
+	{ "RotateAngle", matrixRotateAngle },
+	{ nullptr, nullptr }
+};
+
+// rect:Transform(matrix)
+static int rectTransform(lua_State *L) {
+	pushRect(L, transformRect(checkRect(L, 1), checkMatrix(L, 2)));
+	return 1;
+}
+
+void registerMathTypes(lua_State *L) {
+	registerType(L, kVectorType, kVectorFunctions);
+	registerGlobal(L, "Vector", kVectorHelperType, kVectorHelperFunctions);
+
+	registerType(L, kMatrixType, kMatrixFunctions);
+	registerGlobal(L, "Matrix", kMatrixHelperType, kMatrixHelperFunctions);
+
+	luaL_getmetatable(L, kMatrixHelperType);
+	pushMatrix(L, ScriptMatrix());
+	lua_setfield(L, -2, "identity");
+	lua_pop(L, 1);
+
+	// Rectangles can be moved by a matrix as well
+	luaL_getmetatable(L, "ND.Rect");
+	lua_pushcfunction(L, rectTransform);
+	lua_setfield(L, -2, "Transform");
+	lua_pop(L, 1);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/mathtypes.h b/engines/nancy/script/mathtypes.h
new file mode 100644
index 00000000000..b852b6acabd
--- /dev/null
+++ b/engines/nancy/script/mathtypes.h
@@ -0,0 +1,78 @@
+/* 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_MATHTYPES_H
+#define NANCY_SCRIPT_MATHTYPES_H
+
+#include "engines/nancy/script/types.h"
+
+struct lua_State;
+
+namespace Nancy {
+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.
+
+// Two to four numbers
+struct ScriptVector {
+	int size = 3;
+	double v[4] = { 0.0, 0.0, 0.0, 0.0 };
+
+	ScriptVector() {}
+	ScriptVector(double x, double y, double z) { v[0] = x; v[1] = y; v[2] = z; }
+};
+
+// Four rows of four. Vectors are rows, so the offset a matrix moves things by is
+// its last row, and a product does what its left side does first.
+struct ScriptMatrix {
+	double m[4][4];
+
+	ScriptMatrix();	// the identity
+
+	bool operator==(const ScriptMatrix &other) const;
+	ScriptMatrix operator*(const ScriptMatrix &other) const;
+
+	// A point moved by the matrix
+	void transformPoint(double &x, double &y) const;
+
+	// Whether the matrix does nothing but move things
+	bool isTranslation() const;
+};
+
+void registerMathTypes(lua_State *L);
+
+void pushVector(lua_State *L, const ScriptVector &vector);
+ScriptVector *testVector(lua_State *L, int index);
+
+void pushMatrix(lua_State *L, const ScriptMatrix &matrix);
+ScriptMatrix *testMatrix(lua_State *L, int index);
+ScriptMatrix &checkMatrix(lua_State *L, int index);
+
+// The rectangle between two opposite corners of a rectangle after they were
+// moved by a matrix
+ScriptRect transformRect(const ScriptRect &rect, const ScriptMatrix &matrix);
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_MATHTYPES_H


Commit: 9413d198e99eebd7e840057ccec2da015189d319
    https://github.com/scummvm/scummvm/commit/9413d198e99eebd7e840057ccec2da015189d319
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:09+03:00

Commit Message:
NANCY: NANCY19: Read the boot data

Nancy19 has no boot IFF. What its chunks used to hold is spread over
a handful of scripts, which are run in the order the original runs
them, and fill the same tables: flags, inventory, environments,
player characters, viewport and texts. What is new to Nancy19 goes
into a table of its own.

Cursors and item cursors are named by the scripts, and the system
fonts and their colors come from them as well. Sound channels have
names and a fixed layout.

Changed paths:
  A engines/nancy/script/bootapi.cpp
    engines/nancy/cursor.cpp
    engines/nancy/cursor.h
    engines/nancy/enginedata.h
    engines/nancy/font.cpp
    engines/nancy/font.h
    engines/nancy/graphics.cpp
    engines/nancy/graphics.h
    engines/nancy/module.mk
    engines/nancy/nancy.cpp
    engines/nancy/nancy.h
    engines/nancy/script/api.cpp
    engines/nancy/script/api.h


diff --git a/engines/nancy/cursor.cpp b/engines/nancy/cursor.cpp
index e21ca26bf80..9e25cc44521 100644
--- a/engines/nancy/cursor.cpp
+++ b/engines/nancy/cursor.cpp
@@ -136,6 +136,92 @@ void CursorManager::init(Common::SeekableReadStream *chunkStream) {
 	delete chunkStream;
 }
 
+void CursorManager::init(const ScriptBootData &boot) {
+	_numCursorTypes = boot.cursors.size();
+	_cursors.clear();
+	_cursorNames.clear();
+	_cursorOverridesItem.clear();
+
+	// The cursors of the game go first and the ones of the items after them, both
+	// in pairs: the regular one followed by the one for hovering something
+	addScriptedCursors(boot.cursors);
+	addScriptedCursors(boot.itemCursors);
+
+	for (const ScriptBootData::Cursor &cursor : boot.cursors) {
+		_cursorNames.push_back(cursor.name);
+		_cursorOverridesItem.push_back(cursor.overridesItem);
+	}
+
+	_defaultCursorType = MAX(getCursorTypeByName(boot.defaultCursorName), 0);
+
+	g_nancy->_resource->loadImage(boot.cursorImageName, _uiCursorsSurface);
+	g_nancy->_resource->loadImage(boot.itemCursorImageName, _invCursorsSurface);
+
+	_isInitialized = true;
+
+	setCursor(kNormalArrow, -1, false);
+	showCursor(false);
+}
+
+void CursorManager::addScriptedCursors(const Common::Array<ScriptBootData::Cursor> &cursors) {
+	for (const ScriptBootData::Cursor &cursor : cursors) {
+		for (uint i = 0; i < ScriptBootData::kNumCursorVariants; ++i) {
+			Cursor slot;
+			slot.bounds = cursor.sources[i];
+			slot.hotspot = cursor.hotspots[i];
+
+			// The rects of the cursor sheet include their last row and column
+			if (!slot.bounds.isEmpty()) {
+				++slot.bounds.right;
+				++slot.bounds.bottom;
+			}
+
+			_cursors.push_back(slot);
+		}
+	}
+}
+
+int CursorManager::getCursorTypeByName(const Common::String &name) const {
+	for (uint i = 0; i < _cursorNames.size(); ++i) {
+		if (_cursorNames[i].equalsIgnoreCase(name)) {
+			return i;
+		}
+	}
+
+	return -1;
+}
+
+uint CursorManager::resolveScriptedCursorID(CursorType type, int16 itemID, bool setFromScript, bool hotspotVariant) {
+	// Scripts pick a cursor by name, and get the place of the name in the list. Anything
+	// else asks for one of the engine's own types, which only tell the default cursor
+	// from the one for hovering something.
+	uint cursorType = _defaultCursorType;
+	if (setFromScript) {
+		if ((uint)type * ScriptBootData::kNumCursorVariants < _cursors.size()) {
+			cursorType = (uint)type;
+		}
+	} else {
+		hotspotVariant = type == kHotspot || type == kHotspotArrow;
+	}
+
+	const uint variant = hotspotVariant ? ScriptBootData::kCursorHot : ScriptBootData::kCursorRegular;
+
+	// A held item replaces every cursor that doesn't ask to be kept
+	const uint itemSlot = ((uint)_numCursorTypes + (uint)itemID) * ScriptBootData::kNumCursorVariants + variant;
+	const bool keepsCursor = cursorType < _cursorOverridesItem.size() && _cursorOverridesItem[cursorType];
+	if (itemID != -1 && !keepsCursor && itemSlot < _cursors.size() && !_cursors[itemSlot].bounds.isEmpty()) {
+		_hasItem = true;
+		return itemSlot;
+	}
+
+	// A script can also ask for the cursor of an item
+	if (cursorType >= (uint)_numCursorTypes) {
+		_hasItem = true;
+	}
+
+	return cursorType * ScriptBootData::kNumCursorVariants + variant;
+}
+
 uint CursorManager::resolveNancy10CursorID(CursorType type, int16 itemID, bool setFromScript, bool hotspotVariant) {
 	// Item-held variants. The Nancy 10+ chunk reserves `numItems × 2`
 	// slots after the two 37-entry system arrays (= _numCursorTypes * 2),
@@ -278,6 +364,11 @@ void CursorManager::setCursor(CursorType type, int16 itemID, bool setFromScript,
 	_curHotspotVariant = hotspotVariant;
 	_hasItem = false;
 
+	if (gameType >= kGameTypeNancy19) {
+		_curCursorID = resolveScriptedCursorID(type, itemID, setFromScript, hotspotVariant);
+		return;
+	}
+
 	if (gameType >= kGameTypeNancy13) {
 		_curCursorID = resolveNancy13CursorID(type, itemID, setFromScript, hotspotVariant);
 		return;
diff --git a/engines/nancy/cursor.h b/engines/nancy/cursor.h
index bdf11540a6a..c4907db2b9f 100644
--- a/engines/nancy/cursor.h
+++ b/engines/nancy/cursor.h
@@ -27,6 +27,8 @@
 
 #include "graphics/managed_surface.h"
 
+#include "engines/nancy/enginedata.h"
+
 namespace Nancy {
 
 class CursorManager {
@@ -141,6 +143,14 @@ public:
 
 	void init(Common::SeekableReadStream *chunkStream);
 
+	// Nancy19 and up describe their cursors in a boot script, and call them by name.
+	// A script picks one by passing the place of the name in the list as the type.
+	void init(const ScriptBootData &boot);
+	int getCursorTypeByName(const Common::String &name) const;
+
+	// The cursors of the items come after the others, one for every item
+	uint getItemCursorType(int16 itemID) const { return _numCursorTypes + itemID; }
+
 	// Change the current cursor ID. Does not change the graphic.
 	// When setFromScript is set, type is a raw Nancy 10+ system cursor type
 	// index resolved to its flat slot; hotspotVariant then picks the hotspot
@@ -173,6 +183,9 @@ private:
 	// Nancy13 slot table.
 	uint resolveNancy13CursorID(CursorType type, int16 itemID, bool setFromScript, bool hotspotVariant);
 
+	uint resolveScriptedCursorID(CursorType type, int16 itemID, bool setFromScript, bool hotspotVariant);
+	void addScriptedCursors(const Common::Array<ScriptBootData::Cursor> &cursors);
+
 	struct Cursor {
 		Common::Rect bounds;
 		Common::Point hotspot;
@@ -181,6 +194,11 @@ private:
 	// CURS data
 	Common::Array<Cursor> _cursors;
 
+	// Nancy19+
+	Common::Array<Common::String> _cursorNames;
+	Common::Array<bool> _cursorOverridesItem;
+	uint _defaultCursorType = 0;
+
 	Common::Rect _primaryVideoInactiveZone;
 	Common::Point _primaryVideoInitialPos;
 
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 5591097a7a4..e2188ed7d24 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -33,12 +33,14 @@ namespace Nancy {
 // Data types corresponding to chunks found inside BOOT
 
 struct EngineData {
+	EngineData() {}
 	EngineData(Common::SeekableReadStream *chunkStream);
 	virtual ~EngineData() {}
 };
 
 // Boot summary. Contains data for the UI, game clock, starting a new game.
 struct BSUM : public EngineData {
+	BSUM() {}
 	BSUM(Common::SeekableReadStream *chunkStream);
 
 	byte header[90] = {};
@@ -55,8 +57,8 @@ struct BSUM : public EngineData {
 
 	// Game start section
 	SceneChangeDescription firstScene;
-	uint16 startTimeHours;
-	uint16 startTimeMinutes;
+	uint16 startTimeHours = 0;
+	uint16 startTimeMinutes = 0;
 
 	// Nancy11-13: on the day after the game starts, once the clock reaches
 	// lateNightHour, lateNightFlag gets raised
@@ -95,19 +97,19 @@ struct BSUM : public EngineData {
 	byte gTrans = 0;
 	byte bTrans = 0;
 
-	uint16 horizontalEdgesSize;
-	uint16 verticalEdgesSize;
+	uint16 horizontalEdgesSize = 0;
+	uint16 verticalEdgesSize = 0;
 
 	// Nancy16 switched to system fonts, and no longer stores a count here
 	uint16 numFonts = 0;
 
-	uint16 playerTimeMinuteLength;
-	uint16 buttonPressTimeDelay;
+	uint16 playerTimeMinuteLength = 0;
+	uint16 buttonPressTimeDelay = 0;
 	uint16 dayStartMinutes = 0;
 	uint16 dayEndMinutes = 0;
-	byte overrideMovementTimeDeltas;
-	uint16 slowMovementTimeDelta;
-	uint16 fastMovementTimeDelta;
+	byte overrideMovementTimeDeltas = 0;
+	uint16 slowMovementTimeDelta = 0;
+	uint16 fastMovementTimeDelta = 0;
 
 	// Nancy9-11: timer dependencies with a seconds value of kTimerDurationIndexBase
 	// or above take their seconds from this table instead. Nancy12+ timer
@@ -117,6 +119,7 @@ struct BSUM : public EngineData {
 
 // Contains rects defining the in-game viewport
 struct VIEW : public EngineData {
+	VIEW() {}
 	VIEW(Common::SeekableReadStream *chunkStream);
 
 	Common::Rect screenPosition;
@@ -517,6 +520,7 @@ struct LogoScreen : public EngineData {
 // NOT found inside BOOT; these are stored in their own cifs, the names of which
 // can be found inside BSUM. Introduced in nancy6.
 struct CVTX : public EngineData {
+	CVTX() {}
 	CVTX(Common::SeekableReadStream *chunkStream);
 
 	Common::HashMap<Common::String, Common::String, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> texts;
@@ -873,6 +877,7 @@ struct UINB : public EngineData {
 // Event flags table. Introduced in Nancy 12, and replaces the event flag names
 // that were hardcoded in the executable. Each record is a name followed by an id.
 struct EVNT : public EngineData {
+	EVNT() {}
 	EVNT(Common::SeekableReadStream *chunkStream);
 
 	static const uint kEventRecordSize = 35;
@@ -951,6 +956,7 @@ struct ENVS : public EngineData {
 		Common::Array<Common::String> musicNames;
 	};
 
+	ENVS() {}
 	ENVS(Common::SeekableReadStream *chunkStream);
 
 	Common::Array<Environment> environments;
@@ -965,9 +971,11 @@ struct INVD : public EngineData {
 		int32 kind = 0;				// what clicking the item does
 		Common::Path sceneName;		// e.g. "s6200", for the kinds that open a scene
 		int16 valueIndex = 0;
+		Common::String valueName;	// Nancy19+ names the value instead
 		Common::Rect sourceRect;
 	};
 
+	INVD() {}
 	INVD(Common::SeekableReadStream *chunkStream);
 
 	Common::Array<Item> items;
@@ -993,6 +1001,7 @@ struct PCUI : public EngineData {
 		uint16 id = 0;
 	};
 
+	PCUI() {}
 	PCUI(Common::SeekableReadStream *chunkStream);
 
 	byte flag = 0;
@@ -1078,6 +1087,82 @@ struct PUIV : public EngineData {
 	uint16 volume = 0;			// shared volume (inferred)
 	Common::Array<SoundGroup> soundGroups;
 };
+
+// Nancy19 and up keep their data in Lua scripts instead of chunks. The boot
+// scripts fill the tables that came from the chunks of Nancy17 and Nancy18
+// (EVNT, INVD, ENVS, PCUI, VIEW and the texts); this holds everything else
+// they describe.
+struct ScriptBootData : public EngineData {
+	enum CursorVariant { kCursorRegular = 0, kCursorHot = 1, kNumCursorVariants = 2 };
+
+	struct Cursor {
+		Common::String name;	// the name of the item, for the cursors of the inventory
+		Common::Rect sources[kNumCursorVariants];
+		Common::Point hotspots[kNumCursorVariants];
+		bool overridesItem = false;	// shown even while an item is held
+	};
+
+	struct SystemFont {
+		int id = 0;
+		Common::String name;
+		Common::String faceName;
+		int height = 0;
+		bool isBold = false;
+		bool isScaled = false;	// follows the font scale of the options
+	};
+
+	struct FontColor {
+		int id = 0;
+		uint32 normal = 0;
+		uint32 disabled = 0;
+		uint32 mouseOver = 0;
+		uint32 pressed = 0;
+	};
+
+	// What the boot script in a player character's own tree holds
+	struct Player {
+		Common::Path becomeSceneName;	// run when the character starts being played
+		Common::Path leaveSceneName;
+		Common::Path autotextName;
+		Common::Array<Common::String> taskDoneSounds;
+		Common::Array<Common::String> taskUndoneSounds;
+	};
+
+	ScriptBootData() {}
+
+	Common::String windowName;
+	Common::String saveFolderName;
+	double playerTimeScale = 1.0;
+	int diagnosticFontID = 0;
+
+	Common::String cheatSavePrefix;
+	Common::String saveExtension;
+	Common::String defaultSaveName;
+	Common::String savePromptFlag;
+	bool savePromptFlagState = false;
+
+	Common::Rect leftNodeHotspot;	// in the viewport
+	Common::Rect rightNodeHotspot;
+	Common::Rect uturnNodeHotspot;
+
+	Common::Path cursorImageName;
+	Common::String defaultCursorName;
+	Common::String rightNodeCursorName;
+	Common::String leftNodeCursorName;
+	Common::String uturnNodeCursorName;
+	Common::Array<Cursor> cursors;
+
+	Common::Path itemCursorImageName;
+	Common::Array<Cursor> itemCursors;
+
+	Common::Path inventoryImageName;
+
+	Common::Array<SystemFont> fonts;
+	Common::Array<FontColor> fontColors;
+
+	Common::Array<Player> players;	// in the order of PCUI::characters
+};
+
 } // End of namespace Nancy
 
 #endif // NANCY_ENGINEDATA_H
diff --git a/engines/nancy/font.cpp b/engines/nancy/font.cpp
index 2934405bb3e..24c77501a6c 100644
--- a/engines/nancy/font.cpp
+++ b/engines/nancy/font.cpp
@@ -181,17 +181,24 @@ void Font::read(Common::SeekableReadStream &stream) {
 void Font::readSystemFont(Common::SeekableReadStream &stream, bool bold) {
 	const int32 kBoldExtraWeight = 300;
 
+	Common::String name;
 	Common::String faceName;
-	readFilename(stream, _description);
+	readFilename(stream, name);
 	readFilename(stream, faceName);
 	int32 height = stream.readSint32LE();
 	int32 weight = stream.readSint32LE();
 
 	if (bold) {
-		_description += "_Bold";
+		name += "_Bold";
 		weight += kBoldExtraWeight;
 	}
 
+	setSystemFont(name, faceName, height, weight);
+}
+
+void Font::setSystemFont(const Common::String &name, const Common::String &faceName, int32 height, int32 weight) {
+	_description = name;
+
 	// None of the Windows fonts the game asks for (Tahoma, Arial, Lucida Handwriting)
 	// ship with ScummVM, so they all get replaced with the same sans-serif font
 #ifdef USE_FREETYPE2
diff --git a/engines/nancy/font.h b/engines/nancy/font.h
index 97e37a8f64e..f342b570cda 100644
--- a/engines/nancy/font.h
+++ b/engines/nancy/font.h
@@ -48,6 +48,7 @@ public:
 	// The color passed when drawing is then a font color ID. Every font also
 	// comes in bold, which gets "_Bold" added to its name.
 	void readSystemFont(Common::SeekableReadStream &stream, bool bold);
+	void setSystemFont(const Common::String &name, const Common::String &faceName, int32 height, int32 weight);
 	const Common::String &getName() const { return _description; }
 	// Nancy16+: the substitute font, for drawing with an explicit color instead of a color ID
 	const Graphics::Font *getSystemFont() const { return _systemFont.get(); }
diff --git a/engines/nancy/graphics.cpp b/engines/nancy/graphics.cpp
index a98b67d8b9b..1e044a29f95 100644
--- a/engines/nancy/graphics.cpp
+++ b/engines/nancy/graphics.cpp
@@ -431,6 +431,46 @@ void GraphicsManager::loadSystemFonts(const IFF &fontIFF) {
 	}
 }
 
+void GraphicsManager::loadSystemFonts(const ScriptBootData &boot) {
+	const int32 kNormalWeight = 400;
+	const int32 kBoldWeight = 700;
+
+	for (const ScriptBootData::SystemFont &font : boot.fonts) {
+		if (font.id < 0) {
+			continue;
+		}
+
+		if ((uint)font.id >= _fonts.size()) {
+			_fonts.resize(font.id + 1);
+		}
+
+		_fonts[font.id].setSystemFont(font.name, font.faceName, font.height, font.isBold ? kBoldWeight : kNormalWeight);
+	}
+
+	// Every font also comes in bold, after the ones with a number of their own
+	for (const ScriptBootData::SystemFont &font : boot.fonts) {
+		_fonts.resize(_fonts.size() + 1);
+		_fonts.back().setSystemFont(font.name + "_Bold", font.faceName, font.height, kBoldWeight);
+	}
+
+	for (const ScriptBootData::FontColor &color : boot.fontColors) {
+		if (color.id < 0) {
+			continue;
+		}
+
+		if ((uint)color.id >= _fontColors.size()) {
+			_fontColors.resize(color.id + 1);
+		}
+
+		FontColor &fontColor = _fontColors[color.id];
+		fontColor.colors[kFontColorNormal] = color.normal;
+		fontColor.colors[kFontColorFocus] = color.normal;
+		fontColor.colors[kFontColorDisabled] = color.disabled;
+		fontColor.colors[kFontColorMouseOver] = color.mouseOver;
+		fontColor.colors[kFontColorPressed] = color.pressed;
+	}
+}
+
 const Font *GraphicsManager::getFont(const Common::String &name) const {
 	int id = getFontID(name);
 	return id >= 0 ? &_fonts[id] : nullptr;
diff --git a/engines/nancy/graphics.h b/engines/nancy/graphics.h
index e0c83dc8b45..ee5699c940a 100644
--- a/engines/nancy/graphics.h
+++ b/engines/nancy/graphics.h
@@ -32,6 +32,7 @@ namespace Nancy {
 
 class IFF;
 class RenderObject;
+struct ScriptBootData;
 
 // Packed 0xAARRGGBB colors, independent of the byte order in memory
 extern const Graphics::PixelFormat kARGBFormat;
@@ -76,6 +77,7 @@ public:
 
 	void loadFonts(Common::SeekableReadStream *chunkStream);
 	void loadSystemFonts(const IFF &fontIFF);
+	void loadSystemFonts(const ScriptBootData &boot);	// Nancy19+
 
 	void addObject(RenderObject *object);
 	void removeObject(RenderObject *object);
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index fb8c8f0c78a..45a5f38276f 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -186,6 +186,7 @@ MODULE_OBJS = \
 ifdef ENABLE_NANCY_V4
 MODULE_OBJS += \
   script/api.o \
+  script/bootapi.o \
   script/luabind.o \
   script/luastate.o \
   script/mathtypes.o \
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index b96e884e360..0ebbdfd4ee2 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -42,6 +42,10 @@
 
 #include "engines/nancy/action/conversation.h"
 
+#ifdef ENABLE_NANCY_V4
+#include "engines/nancy/script/api.h"
+#endif
+
 #include "engines/nancy/state/logo.h"
 #include "engines/nancy/state/scene.h"
 #include "engines/nancy/state/help.h"
@@ -282,6 +286,32 @@ bool NancyEngine::setPlayerCharacter(uint characterIndex) {
 		_resource->setCifTreePriority(treeName, basePriority + 1);
 	}
 
+#ifdef ENABLE_NANCY_V4
+	if (getGameType() >= kGameTypeNancy19) {
+		// The character's own data is a script, which only names a few files
+		ScriptBootData *boot = (ScriptBootData *)_engineData.getValOrDefault("ScriptBootData", nullptr);
+		assert(boot);
+
+		Script::BootTables tables;
+		tables.boot = boot;
+		tables.playerIndex = characterIndex;
+
+		const Common::String scriptName = treeName + "_boot";
+		if (!Script::runBootScript(scriptName.c_str(), tables)) {
+			error("Failed to load boot script for player character %s", treeName.c_str());
+		}
+
+		if (characterIndex < boot->players.size() && !boot->players[characterIndex].autotextName.empty()) {
+			addAutotext(boot->players[characterIndex].autotextName);
+		}
+
+		_playerCharacter = characterIndex;
+		_playerCharacterTree = treeName;
+
+		return true;
+	}
+#endif
+
 	// The UI description chunks live in the character's own boot IFF, e.g.
 	// PUI_CRE_NANCY_DEFAULT_BOOT (PUI_ICE_NANCY_DEFAULT_BOOT in Nancy16)
 	IFF *iff = _resource->loadIFF(Common::Path(treeName + "_boot"));
@@ -341,6 +371,16 @@ bool NancyEngine::setPlayerCharacter(uint characterIndex) {
 
 void NancyEngine::addAutotext(const Common::Path &name) {
 	CVTX *autotext = (CVTX *)_engineData.getValOrDefault("AUTOTEXT", nullptr);
+
+#ifdef ENABLE_NANCY_V4
+	if (autotext && getGameType() >= kGameTypeNancy19) {
+		Script::BootTables tables;
+		tables.texts = autotext;
+		Script::runBootScript(name.toString().c_str(), tables);
+		return;
+	}
+#endif
+
 	IFF *iff = _resource->loadIFF(name);
 	if (!autotext || !iff) {
 		delete iff;
@@ -651,6 +691,13 @@ void NancyEngine::bootGameEngine() {
 	// Setup mixer
 	syncSoundSettings();
 
+#ifdef ENABLE_NANCY_V4
+	if (getGameType() >= kGameTypeNancy19) {
+		bootScriptedGameEngine();
+		return;
+	}
+#endif
+
 	IFF *iff = _resource->loadIFF("boot");
 	if (!iff)
 		error("Failed to load boot script");
@@ -854,6 +901,95 @@ void NancyEngine::bootGameEngine() {
 	#undef LOAD_BOOT
 }
 
+#ifdef ENABLE_NANCY_V4
+static void runRequiredBootScript(const char *name, const Script::BootTables &tables) {
+	if (!Script::runBootScript(name, tables)) {
+		error("Failed to load boot script %s", name);
+	}
+}
+
+void NancyEngine::bootScriptedGameEngine() {
+	// Nancy19 has no boot IFF. What its chunks used to hold is spread over a
+	// handful of Lua scripts, which fill the same tables.
+	EVNT *flags = new EVNT();
+	INVD *inventory = new INVD();
+	ENVS *environments = new ENVS();
+	PCUI *players = new PCUI();
+	VIEW *viewport = new VIEW();
+	CVTX *autotext = new CVTX();
+	CVTX *convo = new CVTX();
+	BSUM *bootSummary = new BSUM();
+	ScriptBootData *boot = new ScriptBootData();
+
+	_engineData.setVal("EVNT", flags);
+	_engineData.setVal("INVD", inventory);
+	_engineData.setVal("ENVS", environments);
+	_engineData.setVal("PCUI", players);
+	_engineData.setVal("VIEW", viewport);
+	_engineData.setVal("AUTOTEXT", autotext);
+	_engineData.setVal("CONVO", convo);
+	_engineData.setVal("BSUM", bootSummary);
+	_engineData.setVal("ScriptBootData", boot);
+
+	// The order matters: the cursors of the inventory go by item
+	Script::BootTables fontTables;
+	fontTables.boot = boot;
+	runRequiredBootScript("Font", fontTables);
+
+	Script::BootTables flagTables;
+	flagTables.flags = flags;
+	runRequiredBootScript("Flags", flagTables);
+
+	Script::BootTables inventoryTables;
+	inventoryTables.inventory = inventory;
+	inventoryTables.boot = boot;
+	runRequiredBootScript("Inventory", inventoryTables);
+	runRequiredBootScript("Cursors", inventoryTables);
+
+	Script::BootTables textTables;
+	textTables.texts = autotext;
+	runRequiredBootScript("Autotext", textTables);
+
+	textTables.texts = convo;
+	runRequiredBootScript("Convo", textTables);
+
+	Script::BootTables bootTables;
+	bootTables.boot = boot;
+	bootTables.players = players;
+	bootTables.environments = environments;
+	bootTables.viewport = viewport;
+	runRequiredBootScript("Boot", bootTables);
+
+	_staticData.numItems = inventory->items.size();
+	_staticData.numEventFlags = (uint16)(kNumGenericEventFlags + flags->numRecords);
+
+	// Saved games get matched against the name of the game
+	Common::strlcpy((char *)bootSummary->header, boot->saveFolderName.c_str(), sizeof(bootSummary->header));
+	bootSummary->firstScene.sceneID = 0;
+
+	// The clock of the game runs a little faster than a real one
+	if (boot->playerTimeScale > 0.0) {
+		bootSummary->playerTimeMinuteLength = (uint16)(60000.0 / boot->playerTimeScale);
+	}
+
+	_cursor->init(*boot);
+
+	_graphics->init();
+	_graphics->loadSystemFonts(*boot);
+
+	_sound->initSoundChannels();
+
+	uint startCharacter = 0;
+	for (uint i = 0; i < players->characters.size(); ++i) {
+		if (players->characters[i].name.equalsIgnoreCase(players->startCharacterName)) {
+			startCharacter = i;
+		}
+	}
+
+	setPlayerCharacter(startCharacter);
+}
+#endif
+
 State::State *NancyEngine::getStateObject(NancyState::NancyState state) const {
 	switch (state) {
 	case NancyState::kLogo:
@@ -1043,6 +1179,18 @@ void NancyEngine::populateStaticData() {
 	sci.speechChannels = { 12, 13, 30 };
 	sci.musicChannels = { 0, 1, 2, 3, 19, 26, 27, 29 };
 	sci.sfxChannels = { 4, 5, 6, 7, 8, 9, 10, 11, 17, 18, 20, 21, 22, 23, 24, 25, 31 };
+
+	if (getGameType() >= kGameTypeNancy19) {
+		// The scripts of Nancy19 call the channels by name, and number them by
+		// kind: six for music, eight for ambience, ten for effects and six for
+		// voices. No channel belongs to a scene anymore; the scripts stop what
+		// they don't want to keep.
+		sci.numChannels = 30;
+		sci.numSceneSpecificChannels = 0;
+		sci.musicChannels = { 0, 1, 2, 3, 4, 5 };
+		sci.sfxChannels = { 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 };
+		sci.speechChannels = { 24, 25, 26, 27, 28, 29 };
+	}
 }
 
 Common::Error NancyEngine::synchronize(Common::Serializer &ser) {
diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index f499fc253ac..a743df8a05e 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -188,6 +188,9 @@ private:
 	};
 
 	void bootGameEngine();
+#ifdef ENABLE_NANCY_V4
+	void bootScriptedGameEngine();	// Nancy19+
+#endif
 
 	State::State *getStateObject(NancyState::NancyState state) const;
 	void destroyState(NancyState::NancyState state) const;
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
index a11a20f8260..e8223bc2f35 100644
--- a/engines/nancy/script/api.cpp
+++ b/engines/nancy/script/api.cpp
@@ -19,6 +19,9 @@
  *
  */
 
+#include "engines/nancy/enginedata.h"
+#include "engines/nancy/nancy.h"
+
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/luabind.h"
 #include "engines/nancy/script/mathtypes.h"
@@ -27,9 +30,53 @@
 namespace Nancy {
 namespace Script {
 
+static const char *const kInventoryType = "ND.Inventory";
+static const char *const kInventoryItemType = "ND.InventoryItem";
+
+void pushInventoryItem(lua_State *L, int16 itemID) {
+	pushValue(L, kInventoryItemType, itemID);
+}
+
+int16 checkInventoryItem(lua_State *L, int index) {
+	return *(int16 *)checkUserData(L, index, kInventoryItemType);
+}
+
+// Inventory.<name of an item>
+static int inventoryIndex(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	const INVD *inventory = GetEngineData(INVD);
+	if (inventory) {
+		for (const INVD::Item &item : inventory->items) {
+			if (item.name.equalsIgnoreCase(name)) {
+				pushInventoryItem(L, item.id);
+				return 1;
+			}
+		}
+	}
+
+	return luaL_error(L, "There is no inventory item %s", name);
+}
+
+void registerInventoryAPI(lua_State *L) {
+	static const luaL_Reg kInventoryFunctions[] = {
+		{ "__index", inventoryIndex },
+		{ nullptr, nullptr }
+	};
+
+	static const luaL_Reg kInventoryItemFunctions[] = {
+		{ nullptr, nullptr }
+	};
+
+	registerType(L, kInventoryItemType, kInventoryItemFunctions);
+	registerGlobal(L, "Inventory", kInventoryType, kInventoryFunctions);
+}
+
 void registerAPI(lua_State *L) {
 	registerTypes(L);
 	registerMathTypes(L);
+	registerBootAPI(L);
+	registerInventoryAPI(L);
 }
 
 } // End of namespace Script
diff --git a/engines/nancy/script/api.h b/engines/nancy/script/api.h
index 7ce8f2b7712..72e639d3738 100644
--- a/engines/nancy/script/api.h
+++ b/engines/nancy/script/api.h
@@ -22,14 +22,50 @@
 #ifndef NANCY_SCRIPT_API_H
 #define NANCY_SCRIPT_API_H
 
+#include "common/scummsys.h"
+#include "common/str.h"
+
 struct lua_State;
 
 namespace Nancy {
+
+struct CVTX;
+struct ENVS;
+struct EVNT;
+struct INVD;
+struct PCUI;
+struct VIEW;
+struct ScriptBootData;
+
 namespace Script {
 
 // Puts everything scripts can use into a new state
 void registerAPI(lua_State *L);
 
+// The tables the boot scripts fill. A script can only fill the ones that are set
+// while it runs.
+struct BootTables {
+	EVNT *flags = nullptr;
+	INVD *inventory = nullptr;
+	ENVS *environments = nullptr;
+	PCUI *players = nullptr;
+	VIEW *viewport = nullptr;
+	CVTX *texts = nullptr;
+	ScriptBootData *boot = nullptr;
+	int playerIndex = -1;	// the character whose own boot script is running
+};
+
+// Runs one of the boot scripts. Returns false when there is no such script.
+bool runBootScript(const char *name, const BootTables &tables);
+
+void registerBootAPI(lua_State *L);
+
+// Inventory items are handed to scripts by name ("Inventory.INV_Board") as objects
+// of their own, which hold the id of the item
+void registerInventoryAPI(lua_State *L);
+void pushInventoryItem(lua_State *L, int16 itemID);
+int16 checkInventoryItem(lua_State *L, int index);
+
 } // End of namespace Script
 } // End of namespace Nancy
 
diff --git a/engines/nancy/script/bootapi.cpp b/engines/nancy/script/bootapi.cpp
new file mode 100644
index 00000000000..10a42e276eb
--- /dev/null
+++ b/engines/nancy/script/bootapi.cpp
@@ -0,0 +1,578 @@
+/* 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/algorithm.h"
+
+#include "engines/nancy/enginedata.h"
+
+#include "engines/nancy/script/api.h"
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/luastate.h"
+#include "engines/nancy/script/types.h"
+
+#include "engines/nancy/ui/ndui/button.h"
+
+namespace Nancy {
+namespace Script {
+
+// The boot scripts describe the game by handing tables to the "Init" objects:
+//
+//   FlagsInit:Create({ "Met_KM", "Saw_Printer", ... })
+//   EnvInit:Create({ LIB = { fullName = "Library", medley = { ... } }, ... })
+//
+// A table with names for keys has no order of its own, so what comes out of one
+// is put in the order of the names. That is the order items and characters get
+// their numbers in, which end up in saved games.
+
+static const BootTables *s_tables = nullptr;
+
+bool runBootScript(const char *name, const BootTables &tables) {
+	LuaState state;
+
+	s_tables = &tables;
+	bool result = state.runScript(name);
+	s_tables = nullptr;
+
+	return result;
+}
+
+// Every Create function takes the object itself and one table
+static void checkCreateArguments(lua_State *L, const char *typeName, bool hasTable) {
+	checkUserData(L, 1, typeName);
+	luaL_checktype(L, 2, LUA_TTABLE);
+
+	if (!s_tables || !hasTable) {
+		luaL_error(L, "%s cannot be used by this script", typeName);
+	}
+}
+
+static void requireField(lua_State *L, bool found, const char *name) {
+	if (!found) {
+		luaL_error(L, "Field '%s' must be specified", name);
+	}
+}
+
+static Common::Rect readRectField(lua_State *L, int tableIndex, const char *name) {
+	Common::Rect rect;
+	if (pushField(L, tableIndex, name)) {
+		rect = checkRect(L, -1).toRect();
+		lua_pop(L, 1);
+	}
+
+	return rect;
+}
+
+static bool compareNames(const Common::String &a, const Common::String &b) {
+	return a.compareToIgnoreCase(b) < 0;
+}
+
+// The keys of a table that are strings, in alphabetical order
+static void getSortedKeys(lua_State *L, int tableIndex, Common::Array<Common::String> &keys) {
+	lua_pushnil(L);
+	while (lua_next(L, tableIndex)) {
+		if (lua_type(L, -2) == LUA_TSTRING) {
+			keys.push_back(lua_tostring(L, -2));
+		}
+
+		lua_pop(L, 1);
+	}
+
+	Common::sort(keys.begin(), keys.end(), compareNames);
+}
+
+// BootInit
+
+static int bootCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.BootInit", s_tables && s_tables->boot);
+	ScriptBootData &boot = *s_tables->boot;
+
+	readField(L, 2, "windowName", boot.windowName);
+	requireField(L, readField(L, 2, "saveFolderName", boot.saveFolderName), "saveFolderName");
+	requireField(L, readField(L, 2, "playerTimeScale", boot.playerTimeScale), "playerTimeScale");
+	requireField(L, readField(L, 2, "diagFontNum", boot.diagnosticFontID), "diagFontNum");
+	return 0;
+}
+
+// PlayersInit
+
+static int playersCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.PlayersInit", s_tables && s_tables->players);
+	PCUI &players = *s_tables->players;
+
+	requireField(L, readField(L, 2, "startingPlayer", players.startCharacterName), "startingPlayer");
+
+	Common::String uiName;
+	readField(L, 2, "masterUIFile", uiName);
+	players.uiName = Common::Path(uiName);
+
+	// Every character is the name of the tree their UI is in
+	if (pushField(L, 2, "characters")) {
+		luaL_checktype(L, -1, LUA_TTABLE);
+		int characters = lua_gettop(L);
+
+		Common::Array<Common::String> names;
+		getSortedKeys(L, characters, names);
+
+		players.characters.resize(names.size());
+		for (uint i = 0; i < names.size(); ++i) {
+			PCUI::Character &character = players.characters[i];
+			character.name = names[i];
+			character.id = (uint16)i;
+			readField(L, characters, names[i].c_str(), character.defaultImageName);
+			character.imageName = character.defaultImageName;
+		}
+
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+// PlayerInit, which is in the boot script of a character's own tree
+
+static int playerCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.PlayerInit", s_tables && s_tables->boot && s_tables->playerIndex >= 0);
+	ScriptBootData &boot = *s_tables->boot;
+
+	if ((uint)s_tables->playerIndex >= boot.players.size()) {
+		boot.players.resize(s_tables->playerIndex + 1);
+	}
+
+	// A character's script runs again every time they get switched to
+	ScriptBootData::Player &player = boot.players[s_tables->playerIndex];
+	player = ScriptBootData::Player();
+
+	Common::String name;
+	requireField(L, readField(L, 2, "switchToScene", name), "switchToScene");
+	player.becomeSceneName = Common::Path(name);
+
+	name.clear();
+	readField(L, 2, "switchAwayScene", name);
+	player.leaveSceneName = Common::Path(name);
+
+	name.clear();
+	readField(L, 2, "autotextFile", name);
+	player.autotextName = Common::Path(name);
+
+	if (pushField(L, 2, "taskYesSounds")) {
+		readStringList(L, -1, player.taskDoneSounds);
+		lua_pop(L, 1);
+	}
+
+	if (pushField(L, 2, "taskNoSounds")) {
+		readStringList(L, -1, player.taskUndoneSounds);
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+// EnvInit
+
+static int envCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.EnvInit", s_tables && s_tables->environments);
+	ENVS &envs = *s_tables->environments;
+
+	Common::Array<Common::String> codes;
+	getSortedKeys(L, 2, codes);
+
+	for (const Common::String &code : codes) {
+		lua_getfield(L, 2, code.c_str());
+		luaL_checktype(L, -1, LUA_TTABLE);
+		int env = lua_gettop(L);
+
+		ENVS::Environment environment;
+		environment.code = code;
+
+		if (!readField(L, env, "fullName", environment.name)) {
+			luaL_error(L, "Environments must specify the 'fullName' field");
+		}
+
+		Common::String name;
+		readField(L, env, "onEnterScene", name);
+		environment.ambientScriptName = Common::Path(name);
+
+		name.clear();
+		readField(L, env, "onLeaveScene", name);
+		environment.leaveScriptName = Common::Path(name);
+
+		if (pushField(L, env, "medley")) {
+			readStringList(L, -1, environment.musicNames);
+			lua_pop(L, 1);
+		}
+
+		envs.environments.push_back(environment);
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+// LoadSaveInit
+
+static int loadSaveCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.LoadSaveInit", s_tables && s_tables->boot);
+	ScriptBootData &boot = *s_tables->boot;
+
+	requireField(L, readField(L, 2, "cheatSavePrefix", boot.cheatSavePrefix), "cheatSavePrefix");
+	requireField(L, readField(L, 2, "saveExtension", boot.saveExtension), "saveExtension");
+	requireField(L, readField(L, 2, "defaultSaveName", boot.defaultSaveName), "defaultSaveName");
+	requireField(L, readField(L, 2, "savePromptFlag", boot.savePromptFlag), "savePromptFlag");
+	requireField(L, readField(L, 2, "savePromptFlagState", boot.savePromptFlagState), "savePromptFlagState");
+	return 0;
+}
+
+// ViewportInit
+
+static int viewportCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.ViewportInit", s_tables && s_tables->viewport && s_tables->boot);
+	VIEW &viewport = *s_tables->viewport;
+	ScriptBootData &boot = *s_tables->boot;
+
+	// The viewport, and the resolution the game gets drawn at
+	viewport.screenPosition = readRectField(L, 2, "location");
+	viewport.bounds = readRectField(L, 2, "windowRes");
+
+	boot.leftNodeHotspot = readRectField(L, 2, "leftNodeHotspot");
+	boot.rightNodeHotspot = readRectField(L, 2, "rightNodeHotspot");
+	boot.uturnNodeHotspot = readRectField(L, 2, "uturnNodeHotspot");
+	return 0;
+}
+
+// FlagsInit
+
+static int flagsCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.FlagsInit", s_tables && s_tables->flags);
+	EVNT &flags = *s_tables->flags;
+
+	readStringList(L, 2, flags.eventFlagNames);
+	flags.numRecords = flags.eventFlagNames.size();
+	return 0;
+}
+
+// InventoryInit
+
+static int inventoryCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.InventoryInit", s_tables && s_tables->inventory && s_tables->boot);
+	INVD &inventory = *s_tables->inventory;
+
+	Common::String imageName;
+	if (!readField(L, 2, "ovl", imageName)) {
+		luaL_error(L, "InventoryInit:Create fed with table that has no 'ovl' field");
+	}
+
+	s_tables->boot->inventoryImageName = Common::Path(imageName);
+
+	Common::Array<Common::String> names;
+	getSortedKeys(L, 2, names);
+
+	for (const Common::String &name : names) {
+		lua_getfield(L, 2, name.c_str());
+		if (!lua_istable(L, -1)) {
+			// The image
+			lua_pop(L, 1);
+			continue;
+		}
+
+		int itemTable = lua_gettop(L);
+
+		INVD::Item item;
+		item.id = (int16)inventory.items.size();
+		item.name = name;
+		item.sourceRect = readRectField(L, itemTable, "srcRect");
+
+		bool isViewable = false;
+		if (!readField(L, itemTable, "isViewable", isViewable)) {
+			luaL_error(L, "Inventory item %s does not set required 'isViewable' field", name.c_str());
+		}
+
+		Common::String sceneName;
+		readField(L, itemTable, "popUpScene", sceneName);
+		item.sceneName = Common::Path(sceneName);
+
+		readField(L, itemTable, "tableIndex", item.valueName);
+
+		// What a click on the item does follows from what it has
+		if (!item.valueName.empty()) {
+			item.kind = UI::NDUI::InvButton::kItemOpenCountedScene;
+		} else if (sceneName.empty()) {
+			item.kind = UI::NDUI::InvButton::kItemPickUp;
+		} else if (isViewable) {
+			item.kind = UI::NDUI::InvButton::kItemView;
+		} else {
+			item.kind = UI::NDUI::InvButton::kItemOpenScene;
+		}
+
+		inventory.items.push_back(item);
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+// CursorInit
+
+// Reads the cursors of a table into a list; the keys are names for the cursors
+// of the game and items for the ones of the inventory
+static void readCursor(lua_State *L, int cursorTable, ScriptBootData::Cursor &cursor) {
+	bool hasAll = true;
+
+	static const char *const kSourceNames[] = { "regSrc", "hotSrc" };
+	static const char *const kHotspotNames[] = { "regActivePt", "hotActivePt" };
+
+	for (uint i = 0; i < ScriptBootData::kNumCursorVariants; ++i) {
+		if (pushField(L, cursorTable, kSourceNames[i])) {
+			cursor.sources[i] = checkRect(L, -1).toRect();
+			lua_pop(L, 1);
+		} else {
+			hasAll = false;
+		}
+
+		ScriptPoint hotspot;
+		if (pushField(L, cursorTable, kHotspotNames[i])) {
+			if (!readPoint(L, -1, hotspot)) {
+				hasAll = false;
+			}
+
+			cursor.hotspots[i] = hotspot.toPoint();
+			lua_pop(L, 1);
+		} else {
+			hasAll = false;
+		}
+	}
+
+	if (!hasAll) {
+		luaL_error(L, "Expecting fields regSrc, hotSrc, regActivePt and hotActivePt");
+	}
+
+	readField(L, cursorTable, "overrideInv", cursor.overridesItem);
+}
+
+static void requireCursorField(lua_State *L, const char *name, Common::String &value) {
+	if (!readField(L, 2, name, value)) {
+		luaL_error(L, "CursorInit:Create fed with table that has no '%s' field", name);
+	}
+}
+
+static bool hasCursor(const ScriptBootData &boot, const Common::String &name) {
+	for (const ScriptBootData::Cursor &cursor : boot.cursors) {
+		if (cursor.name.equalsIgnoreCase(name)) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+static int cursorCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.CursorInit", s_tables && s_tables->boot);
+	ScriptBootData &boot = *s_tables->boot;
+
+	Common::String imageName;
+	requireCursorField(L, "ovl", imageName);
+	boot.cursorImageName = Common::Path(imageName);
+
+	requireCursorField(L, "default", boot.defaultCursorName);
+	requireCursorField(L, "rightNode", boot.rightNodeCursorName);
+	requireCursorField(L, "leftNode", boot.leftNodeCursorName);
+	requireCursorField(L, "uturnNode", boot.uturnNodeCursorName);
+
+	Common::Array<Common::String> names;
+	getSortedKeys(L, 2, names);
+
+	for (const Common::String &name : names) {
+		lua_getfield(L, 2, name.c_str());
+		if (lua_istable(L, -1)) {
+			ScriptBootData::Cursor cursor;
+			cursor.name = name;
+			readCursor(L, lua_gettop(L), cursor);
+			boot.cursors.push_back(cursor);
+		}
+
+		lua_pop(L, 1);
+	}
+
+	if (!hasCursor(boot, boot.defaultCursorName)) {
+		luaL_error(L, "CursorInit:Create specified default as %s but there's no cursor by that name", boot.defaultCursorName.c_str());
+	}
+
+	return 0;
+}
+
+// The cursors of the items that can be held. The keys are the items themselves.
+static int cursorCreateInv(lua_State *L) {
+	checkCreateArguments(L, "ND.CursorInit", s_tables && s_tables->boot && s_tables->inventory);
+	ScriptBootData &boot = *s_tables->boot;
+	const INVD &inventory = *s_tables->inventory;
+
+	Common::String imageName;
+	requireCursorField(L, "ovl", imageName);
+	boot.itemCursorImageName = Common::Path(imageName);
+
+	// One for every item, so they can be found by the id of the item
+	boot.itemCursors.resize(inventory.items.size());
+
+	lua_pushnil(L);
+	while (lua_next(L, 2)) {
+		if (lua_isuserdata(L, -2)) {
+			int16 itemID = checkInventoryItem(L, -2);
+			luaL_checktype(L, -1, LUA_TTABLE);
+
+			ScriptBootData::Cursor &cursor = boot.itemCursors[itemID];
+			cursor.name = inventory.items[itemID].name;
+			readCursor(L, lua_gettop(L), cursor);
+		}
+
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+// FontsInit and ColorsInit, whose tables go by number
+
+static int fontsCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.FontsInit", s_tables && s_tables->boot);
+	ScriptBootData &boot = *s_tables->boot;
+
+	lua_pushnil(L);
+	while (lua_next(L, 2)) {
+		ScriptBootData::SystemFont font;
+		font.id = luaL_checkint(L, -2);
+		luaL_checktype(L, -1, LUA_TTABLE);
+		int fontTable = lua_gettop(L);
+
+		readField(L, fontTable, "ndName", font.name);
+		readField(L, fontTable, "winName", font.faceName);
+		readField(L, fontTable, "size", font.height);
+		readField(L, fontTable, "scale", font.isScaled);
+
+		Common::String weight;
+		readField(L, fontTable, "weight", weight);
+		if (weight == "bold") {
+			font.isBold = true;
+		} else if (weight != "normal") {
+			luaL_error(L, "Font weight must be normal or bold");
+		}
+
+		boot.fonts.push_back(font);
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+static uint32 readColorField(lua_State *L, int tableIndex, const char *name) {
+	uint32 color = 0;
+	if (pushField(L, tableIndex, name)) {
+		color = checkColor(L, -1);
+		lua_pop(L, 1);
+	}
+
+	return color;
+}
+
+static int colorsCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.ColorsInit", s_tables && s_tables->boot);
+	ScriptBootData &boot = *s_tables->boot;
+
+	lua_pushnil(L);
+	while (lua_next(L, 2)) {
+		ScriptBootData::FontColor color;
+		color.id = luaL_checkint(L, -2);
+		if (!lua_istable(L, -1)) {
+			luaL_argerror(L, 2, "Expected all fields of parameter to be tables");
+		}
+
+		int colorTable = lua_gettop(L);
+		color.normal = readColorField(L, colorTable, "normal");
+		color.disabled = readColorField(L, colorTable, "disabled");
+		color.mouseOver = readColorField(L, colorTable, "mouseOver");
+		color.pressed = readColorField(L, colorTable, "pressed");
+
+		boot.fontColors.push_back(color);
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+// AutotextInit, which holds texts by their keys
+
+static int autotextCreate(lua_State *L) {
+	checkCreateArguments(L, "ND.AutotextInit", s_tables && s_tables->texts);
+	CVTX &texts = *s_tables->texts;
+
+	lua_pushnil(L);
+	while (lua_next(L, 2)) {
+		if (lua_type(L, -2) == LUA_TSTRING && lua_type(L, -1) == LUA_TSTRING) {
+			texts.texts.setVal(lua_tostring(L, -2), lua_tostring(L, -1));
+		}
+
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+struct InitObject {
+	const char *globalName;
+	const char *typeName;
+	lua_CFunction create;
+};
+
+static const InitObject kInitObjects[] = {
+	{ "BootInit", "ND.BootInit", bootCreate },
+	{ "PlayersInit", "ND.PlayersInit", playersCreate },
+	{ "PlayerInit", "ND.PlayerInit", playerCreate },
+	{ "EnvInit", "ND.EnvInit", envCreate },
+	{ "LoadSaveInit", "ND.LoadSaveInit", loadSaveCreate },
+	{ "ViewportInit", "ND.ViewportInit", viewportCreate },
+	{ "FlagsInit", "ND.FlagsInit", flagsCreate },
+	{ "InventoryInit", "ND.InventoryInit", inventoryCreate },
+	{ "FontsInit", "ND.FontsInit", fontsCreate },
+	{ "ColorsInit", "ND.ColorsInit", colorsCreate },
+	{ "AutotextInit", "ND.AutotextInit", autotextCreate }
+};
+
+void registerBootAPI(lua_State *L) {
+	for (const InitObject &object : kInitObjects) {
+		const luaL_Reg functions[] = {
+			{ "__index", indexFunctions },
+			{ "Create", object.create },
+			{ nullptr, nullptr }
+		};
+
+		registerGlobal(L, object.globalName, object.typeName, functions);
+	}
+
+	static const luaL_Reg kCursorInitFunctions[] = {
+		{ "__index", indexFunctions },
+		{ "Create", cursorCreate },
+		{ "CreateInv", cursorCreateInv },
+		{ nullptr, nullptr }
+	};
+
+	registerGlobal(L, "CursorInit", "ND.CursorInit", kCursorInitFunctions);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy


Commit: b4c91d7841015ca071bbdd36db7132b71116e106
    https://github.com/scummvm/scummvm/commit/b4c91d7841015ca071bbdd36db7132b71116e106
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:12+03:00

Commit Message:
NANCY: NANCY19: Run scenes from scripts

A Nancy19 scene is a script, which makes the records of the scene
when it is run, and is kept for the functions those records call.
The records take the place of the action records: a record is active
while its "active" value or function says so, runs every frame until
it is done, and keeps whatever else a script stores in it. Input goes
to the records last made first.

Adds the Summary, NavLogic and Override records, and the objects the
scripts reach the game through: Flags, VarTable, Z, Cursor, Key, Game,
Draw, Scene, Player, Viewport and Log. The main scene and the scenes
of streams both run from scripts. The values scripts keep by name are
saved with the game.

The game has no UI yet: its panels are made by scripts as well, which
will be handled in a subsequent commit.

Changed paths:
  A engines/nancy/script/gameapi.cpp
  A engines/nancy/script/record.cpp
  A engines/nancy/script/record.h
  A engines/nancy/script/records.cpp
  A engines/nancy/script/records.h
  A engines/nancy/script/scenecontext.cpp
  A engines/nancy/script/scenecontext.h
    engines/nancy/module.mk
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h
    engines/nancy/script/api.cpp
    engines/nancy/script/api.h
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h
    engines/nancy/state/scenestream.cpp
    engines/nancy/state/scenestream.h


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 45a5f38276f..7aef7ebdb21 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -187,9 +187,13 @@ ifdef ENABLE_NANCY_V4
 MODULE_OBJS += \
   script/api.o \
   script/bootapi.o \
+  script/gameapi.o \
   script/luabind.o \
   script/luastate.o \
   script/mathtypes.o \
+  script/record.o \
+  script/records.o \
+  script/scenecontext.o \
   script/types.o
 endif
 
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index d6220b5f1ee..3077dfd1a55 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -978,6 +978,26 @@ void SavedStateData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void ScriptValueData::synchronize(Common::Serializer &ser) {
+	uint16 numValues = values.size();
+	ser.syncAsUint16LE(numValues);
+
+	Common::Array<Common::String> names;
+	if (ser.isSaving()) {
+		for (auto &entry : values) {
+			names.push_back(entry._key);
+		}
+	} else {
+		values.clear();
+		names.resize(numValues);
+	}
+
+	for (Common::String &name : names) {
+		ser.syncString(name);
+		ser.syncAsSint32LE(values[name]);
+	}
+}
+
 void PhoneBookData::synchronize(Common::Serializer &ser) {
 	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
 		uint16 numEntries = entries[i].size();
@@ -999,6 +1019,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new PhoneBookData();
 	case SavedStateData::getTag():
 		return new SavedStateData();
+	case ScriptValueData::getTag():
+		return new ScriptValueData();
 	case StreamData::getTag():
 		return new StreamData();
 	case BuildBoardData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 921df0f3df4..04f96a340cd 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -682,6 +682,15 @@ struct SavedStateData : public PuzzleData {
 	Common::HashMap<Common::String, Common::Array<int32>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> states;
 };
 
+// Nancy19. The values the scripts keep by name, which take the place of the
+// numbered ones of the table
+struct ScriptValueData : public PuzzleData {
+	static constexpr uint32 getTag() { return MKTAG('S', 'C', 'V', 'L'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::HashMap<Common::String, int32, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> values;
+};
+
 // Nancy17. The contacts in the cell phone of every player character
 struct PhoneBookData : public PuzzleData {
 	struct Entry {
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
index e8223bc2f35..17e47eea833 100644
--- a/engines/nancy/script/api.cpp
+++ b/engines/nancy/script/api.cpp
@@ -25,6 +25,7 @@
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/luabind.h"
 #include "engines/nancy/script/mathtypes.h"
+#include "engines/nancy/script/record.h"
 #include "engines/nancy/script/types.h"
 
 namespace Nancy {
@@ -77,6 +78,8 @@ void registerAPI(lua_State *L) {
 	registerMathTypes(L);
 	registerBootAPI(L);
 	registerInventoryAPI(L);
+	registerGameAPI(L);
+	registerRecordAPI(L);
 }
 
 } // End of namespace Script
diff --git a/engines/nancy/script/api.h b/engines/nancy/script/api.h
index 72e639d3738..0f1ac87db3e 100644
--- a/engines/nancy/script/api.h
+++ b/engines/nancy/script/api.h
@@ -22,6 +22,7 @@
 #ifndef NANCY_SCRIPT_API_H
 #define NANCY_SCRIPT_API_H
 
+#include "common/keyboard.h"
 #include "common/scummsys.h"
 #include "common/str.h"
 
@@ -66,6 +67,23 @@ void registerInventoryAPI(lua_State *L);
 void pushInventoryItem(lua_State *L, int16 itemID);
 int16 checkInventoryItem(lua_State *L, int index);
 
+// The objects scripts reach the state of the game through: Flags, VarTable,
+// Player, Scene, Sound, Cursor...
+void registerGameAPI(lua_State *L);
+
+// Flags go by name. Both return false when there is no flag of that name.
+bool setFlagByName(const Common::String &name, bool value);
+bool getFlagByName(const Common::String &name, bool &value);
+
+// Reads a cursor a script picked with "Cursor.<name>", "Cursor.<name>.hot"...
+// Returns false when the value is something else.
+bool testCursor(lua_State *L, int index, uint &cursorType, bool &isHot);
+void pushCursor(lua_State *L, uint cursorType, bool isHot);
+
+// Keys, as picked with "Key.<name>"
+void pushKey(lua_State *L, Common::KeyCode key);
+Common::KeyCode checkKey(lua_State *L, int index);
+
 } // End of namespace Script
 } // End of namespace Nancy
 
diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
new file mode 100644
index 00000000000..4e8e98e2703
--- /dev/null
+++ b/engines/nancy/script/gameapi.cpp
@@ -0,0 +1,709 @@
+/* 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/debug.h"
+
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/enginedata.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/script/api.h"
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/luastate.h"
+#include "engines/nancy/script/record.h"
+#include "engines/nancy/script/records.h"
+#include "engines/nancy/script/scenecontext.h"
+#include "engines/nancy/script/types.h"
+
+#include "engines/nancy/state/scene.h"
+
+namespace Nancy {
+namespace Script {
+
+// Flags.<name>
+
+// The label of a flag, or -1 when there is none of that name
+static int16 findFlag(const Common::String &name) {
+	const EVNT *flags = GetEngineData(EVNT);
+	if (flags) {
+		for (uint i = 0; i < flags->eventFlagNames.size(); ++i) {
+			if (flags->eventFlagNames[i].equalsIgnoreCase(name)) {
+				return (int16)(EVNT::kFirstEventFlag + i);
+			}
+		}
+	}
+
+	return -1;
+}
+
+bool setFlagByName(const Common::String &name, bool value) {
+	const int16 label = findFlag(name);
+	if (label < 0) {
+		return false;
+	}
+
+	NancySceneState.setEventFlag(label, value ? g_nancy->_true : g_nancy->_false);
+	return true;
+}
+
+bool getFlagByName(const Common::String &name, bool &value) {
+	const int16 label = findFlag(name);
+	if (label < 0) {
+		return false;
+	}
+
+	value = NancySceneState.getEventFlag(label, g_nancy->_true);
+	return true;
+}
+
+static int flagsIndex(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	bool value = false;
+	if (!getFlagByName(name, value)) {
+		return luaL_error(L, "Flag \"%s\" referenced but does not exist.", name);
+	}
+
+	lua_pushboolean(L, value);
+	return 1;
+}
+
+static int flagsNewIndex(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	if (!setFlagByName(name, lua_toboolean(L, 3) != 0)) {
+		return luaL_error(L, "Attempted to set flag \"%s\" but it does not exist.", name);
+	}
+
+	return 0;
+}
+
+static const luaL_Reg kFlagsFunctions[] = {
+	{ "__index", flagsIndex },
+	{ "__newindex", flagsNewIndex },
+	{ nullptr, nullptr }
+};
+
+// VarTable.<name>, the whole numbers scripts keep by name
+
+static int varTableIndex(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	ScriptValueData *values = (ScriptValueData *)NancySceneState.getPuzzleData(ScriptValueData::getTag());
+	lua_pushnumber(L, values->values.getValOrDefault(name, 0));
+	return 1;
+}
+
+static int varTableNewIndex(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+	const int32 value = (int32)luaL_checknumber(L, 3);
+
+	ScriptValueData *values = (ScriptValueData *)NancySceneState.getPuzzleData(ScriptValueData::getTag());
+	values->values.setVal(name, value);
+	return 0;
+}
+
+static const luaL_Reg kVarTableFunctions[] = {
+	{ "__index", varTableIndex },
+	{ "__newindex", varTableNewIndex },
+	{ nullptr, nullptr }
+};
+
+// Z, which makes the numbers records are ordered by. Higher is closer to the player;
+// both functions can take the number to count from in place of the middle.
+
+static int zCloser(lua_State *L) {
+	const double amount = luaL_checknumber(L, 2);
+	const double from = lua_gettop(L) >= 3 ? luaL_checknumber(L, 3) : 0.0;
+	lua_pushnumber(L, from + amount);
+	return 1;
+}
+
+static int zFarther(lua_State *L) {
+	const double amount = luaL_checknumber(L, 2);
+	const double from = lua_gettop(L) >= 3 ? luaL_checknumber(L, 3) : 0.0;
+	lua_pushnumber(L, from - amount);
+	return 1;
+}
+
+static const luaL_Reg kZFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "Closer", zCloser },
+	{ "Farther", zFarther },
+	{ nullptr, nullptr }
+};
+
+// Cursor.<name>, and the variants of a cursor: Cursor.<name>.hot
+
+static const char *const kCursorIndexType = "ND.CursorIndex";
+
+struct CursorIndex {
+	uint type;
+	bool isHot;
+};
+
+bool testCursor(lua_State *L, int index, uint &cursorType, bool &isHot) {
+	const CursorIndex *cursor = (const CursorIndex *)testUserData(L, index, kCursorIndexType);
+	if (!cursor) {
+		return false;
+	}
+
+	cursorType = cursor->type;
+	isHot = cursor->isHot;
+	return true;
+}
+
+void pushCursor(lua_State *L, uint cursorType, bool isHot) {
+	CursorIndex cursor;
+	cursor.type = cursorType;
+	cursor.isHot = isHot;
+	pushValue(L, kCursorIndexType, cursor);
+}
+
+// What "Cursor.currentInv" is while no item is held
+static const uint kNoCursorType = 0xFFFFFFFF;
+
+// Cursor.<name>: one of the cursors of the game or of an item, or what the
+// mouse is up to
+static int cursorIndex(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	const int type = g_nancy->_cursor->getCursorTypeByName(name);
+	if (type >= 0) {
+		pushCursor(L, type, false);
+		return 1;
+	}
+
+	const INVD *inventory = GetEngineData(INVD);
+	if (inventory) {
+		for (const INVD::Item &item : inventory->items) {
+			if (item.name.equalsIgnoreCase(name)) {
+				pushCursor(L, g_nancy->_cursor->getItemCursorType(item.id), false);
+				return 1;
+			}
+		}
+	}
+
+	const Common::String key = name;
+	if (key == "currentInv") {
+		// The cursor of the item that is being held
+		const int16 heldItem = NancySceneState.getHeldItem();
+		pushCursor(L, heldItem >= 0 ? g_nancy->_cursor->getItemCursorType(heldItem) : kNoCursorType, false);
+		return 1;
+	}
+
+	if (key == "activePoint") {
+		// Where the mouse is in the viewport, while it is in there
+		ScriptPoint point;
+		if (!SceneContext::getViewportMouse(g_nancy->_input->getInput(), point)) {
+			point = ScriptPoint(-1, -1);
+		}
+
+		pushPoint(L, point);
+		return 1;
+	}
+
+	if (key == "activeUIPoint") {
+		// Where the mouse is on the screen
+		pushPoint(L, ScriptPoint(g_nancy->_input->getInput().mousePos));
+		return 1;
+	}
+
+	return luaL_error(L, "Tried to change the cursor to %s, which is not valid.", name);
+}
+
+static int cursorEquals(lua_State *L) {
+	const CursorIndex &a = *(const CursorIndex *)checkUserData(L, 1, kCursorIndexType);
+	const CursorIndex &b = *(const CursorIndex *)checkUserData(L, 2, kCursorIndexType);
+	lua_pushboolean(L, a.type == b.type && a.isHot == b.isHot);
+	return 1;
+}
+
+static int cursorVariantIndex(lua_State *L) {
+	CursorIndex cursor = *(const CursorIndex *)checkUserData(L, 1, kCursorIndexType);
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key.equalsIgnoreCase("Hot")) {
+		cursor.isHot = true;
+	} else if (key.equalsIgnoreCase("Regular")) {
+		cursor.isHot = false;
+	} else {
+		lua_pushnil(L);
+		return 1;
+	}
+
+	pushValue(L, kCursorIndexType, cursor);
+	return 1;
+}
+
+static const luaL_Reg kCursorFunctions[] = {
+	{ "__index", cursorIndex },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kCursorIndexFunctions[] = {
+	{ "__index", cursorVariantIndex },
+	{ "__eq", cursorEquals },
+	{ nullptr, nullptr }
+};
+
+// Key.<name>
+
+static const char *const kKeyType = "ND.Key";
+
+struct KeyName {
+	const char *name;
+	Common::KeyCode key;
+};
+
+static const KeyName kKeyNames[] = {
+	{ "SPACE", Common::KEYCODE_SPACE },
+	{ "END", Common::KEYCODE_END },
+	{ "HOME", Common::KEYCODE_HOME },
+	{ "LEFT", Common::KEYCODE_LEFT },
+	{ "UP", Common::KEYCODE_UP },
+	{ "RIGHT", Common::KEYCODE_RIGHT },
+	{ "DOWN", Common::KEYCODE_DOWN },
+	{ "INSERT", Common::KEYCODE_INSERT },
+	{ "DELETE", Common::KEYCODE_DELETE },
+	{ "ESC", Common::KEYCODE_ESCAPE },
+	{ "ESCAPE", Common::KEYCODE_ESCAPE },
+	{ "RETURN", Common::KEYCODE_RETURN },
+	{ "TAB", Common::KEYCODE_TAB },
+	{ "CONTROL", Common::KEYCODE_LCTRL },
+	{ "CTRL", Common::KEYCODE_LCTRL },
+	{ "SHIFT", Common::KEYCODE_LSHIFT }
+};
+
+void pushKey(lua_State *L, Common::KeyCode key) {
+	pushValue(L, kKeyType, (int)key);
+}
+
+Common::KeyCode checkKey(lua_State *L, int index) {
+	return (Common::KeyCode)*(const int *)checkUserData(L, index, kKeyType);
+}
+
+static int keyIndex(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	for (const KeyName &key : kKeyNames) {
+		if (scumm_stricmp(key.name, name) == 0) {
+			pushKey(L, key.key);
+			return 1;
+		}
+	}
+
+	// A letter or a digit stands for its own key
+	if (name[0] != 0 && name[1] == 0 && Common::isAlnum(name[0])) {
+		pushKey(L, (Common::KeyCode)tolower(name[0]));
+		return 1;
+	}
+
+	lua_pushnil(L);
+	return 1;
+}
+
+static int keyEquals(lua_State *L) {
+	lua_pushboolean(L, checkKey(L, 1) == checkKey(L, 2));
+	return 1;
+}
+
+static const luaL_Reg kKeyHelperFunctions[] = {
+	{ "__index", keyIndex },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kKeyFunctions[] = {
+	{ "__eq", keyEquals },
+	{ nullptr, nullptr }
+};
+
+// Game
+
+// The two levels the game can be played at
+static const uint kDifficultyJunior = 0;
+static const uint kDifficultySenior = 2;
+
+static int gameIndex(lua_State *L) {
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "difficulty") {
+		lua_pushstring(L, NancySceneState.getDifficulty() == kDifficultySenior ? "senior" : "junior");
+		return 1;
+	}
+
+	lua_pushnil(L);
+	return 1;
+}
+
+static int gameNewIndex(lua_State *L) {
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key != "difficulty") {
+		return luaL_error(L, "Game does not have field %s", key.c_str());
+	}
+
+	const Common::String value = luaL_checkstring(L, 3);
+	if (value == "senior") {
+		NancySceneState.setDifficulty(kDifficultySenior);
+	} else if (value == "junior") {
+		NancySceneState.setDifficulty(kDifficultyJunior);
+	} else {
+		return luaL_error(L, "difficulty must be set to \"senior\" or \"junior\"");
+	}
+
+	return 0;
+}
+
+static const luaL_Reg kGameFunctions[] = {
+	{ "__index", gameIndex },
+	{ "__newindex", gameNewIndex },
+	{ nullptr, nullptr }
+};
+
+// Draw, for the "Render" functions of the records
+
+// Draw:Rect(rect, color)
+static int drawRect(lua_State *L) {
+	const ScriptRect &rect = checkRect(L, 2);
+	const uint32 color = checkColor(L, 3);
+
+	Graphics::ManagedSurface *target = getDrawTarget();
+	if (!target) {
+		return luaL_error(L, "Draw can only be used while a record is rendering");
+	}
+
+	Common::Rect area = rect.toRect();
+	area.clip(Common::Rect(target->w, target->h));
+	if (!area.isEmpty()) {
+		target->fillRect(area, target->format.ARGBToColor(color >> 24, (color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF));
+	}
+
+	return 0;
+}
+
+static const luaL_Reg kDrawFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "Rect", drawRect },
+	{ nullptr, nullptr }
+};
+
+// Scene, which is the scene the script belongs to
+
+static SceneContext *checkContext(lua_State *L) {
+	SceneContext *context = LuaState::getContext(L);
+	if (!context) {
+		luaL_error(L, "The Scene object can only be used by the script of a scene");
+	}
+
+	return context;
+}
+
+static int sceneIndex(lua_State *L) {
+	SceneContext *context = checkContext(L);
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "name") {
+		lua_pushstring(L, context->getSceneName().c_str());
+	} else if (key == "time") {
+		lua_pushnumber(L, context->getTime());
+	} else if (key == "frame") {
+		lua_pushinteger(L, context->getFrame());
+	} else if (key == "streamName") {
+		lua_pushstring(L, context->getStreamName().c_str());
+	} else {
+		return indexFunctions(L);
+	}
+
+	return 1;
+}
+
+// The parts of a scene a script can take over
+static const char *const kSceneFunctionNames[] = {
+	"RunOnce", "Run", "RenderAttachment", "ReceiveMessage", "OnSave"
+};
+
+static int sceneNewIndex(lua_State *L) {
+	SceneContext *context = checkContext(L);
+	const char *key = luaL_checkstring(L, 2);
+
+	if (!lua_isfunction(L, 3)) {
+		return luaL_error(L, "Can only set functions to the Scene object");
+	}
+
+	for (const char *name : kSceneFunctionNames) {
+		if (strcmp(name, key) == 0) {
+			lua_pushvalue(L, 3);
+			context->setSceneFunction(name);
+			return 0;
+		}
+	}
+
+	return luaL_error(L, "Field %s is not overridable from script", key);
+}
+
+// Scene:Include(script)
+static int sceneInclude(lua_State *L) {
+	SceneContext *context = checkContext(L);
+	const char *name = luaL_checkstring(L, 2);
+
+	// A scene can't include itself
+	if (!context->getSceneName().equalsIgnoreCase(name)) {
+		context->include(name);
+	}
+
+	return 0;
+}
+
+// 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;
+	if (lifetime == "manual") {
+		flags.lifetime = State::StreamFlags::kIndependent;
+	} else if (lifetime == "withScene") {
+		flags.lifetime = State::StreamFlags::kEndsWithParentScene;
+	} else if (lifetime == "withStream") {
+		flags.lifetime = State::StreamFlags::kEndsWithParent;
+	} else {
+		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);
+	streams.begin(name, sceneName, (uint16)frame, flags);
+
+	lua_pushboolean(L, isNew);
+	return 1;
+}
+
+// Scene:EndStream(name)
+static int sceneEndStream(lua_State *L) {
+	checkContext(L);
+	const char *name = luaL_checkstring(L, 2);
+
+	State::SceneStreamManager &streams = NancySceneState.getStreams();
+	const bool wasRunning = streams.isRunning(name);
+	streams.end(name);
+
+	lua_pushboolean(L, wasRunning);
+	return 1;
+}
+
+// What "for key, ar in Scene:ARs(direction)" calls for every round: gives the
+// next record, and its place in the order asked for
+static int sceneNextRecord(lua_State *L) {
+	SceneContext *context = checkContext(L);
+	const bool isForward = lua_toboolean(L, lua_upvalueindex(1)) != 0;
+	const uint numDone = (uint)lua_tointeger(L, lua_upvalueindex(2));
+
+	const Common::Array<Record *> &records = context->getRecords();
+	if (numDone >= records.size()) {
+		return 0;
+	}
+
+	lua_pushinteger(L, numDone + 1);
+	lua_replace(L, lua_upvalueindex(2));
+
+	lua_pushinteger(L, numDone + 1);
+	records[isForward ? numDone : records.size() - 1 - numDone]->push();
+	return 2;
+}
+
+// Scene:ARs(direction), which goes over the records of the scene, in the order
+// they were made in ("forward") or the other way around ("backward")
+static int sceneRecords(lua_State *L) {
+	checkContext(L);
+	const Common::String direction = luaL_checkstring(L, 2);
+
+	if (direction != "forward" && direction != "backward") {
+		return luaL_error(L, "Iteration direction (must be forward or backward) is %s", direction.c_str());
+	}
+
+	lua_pushboolean(L, direction == "forward");
+	lua_pushinteger(L, 0);
+	lua_pushcclosure(L, sceneNextRecord, 2);
+	return 1;
+}
+
+static const luaL_Reg kSceneFunctions[] = {
+	{ "__index", sceneIndex },
+	{ "__newindex", sceneNewIndex },
+	{ "ARs", sceneRecords },
+	{ "Include", sceneInclude },
+	{ "BeginStream", sceneBeginStream },
+	{ "EndStream", sceneEndStream },
+	{ nullptr, nullptr }
+};
+
+// Player, who is the character being played unless a function is told otherwise
+
+// The number of a character, or -1 when there is none of that name
+static int findCharacter(const char *name) {
+	const PCUI *players = GetEngineData(PCUI);
+	if (players) {
+		for (uint i = 0; i < players->characters.size(); ++i) {
+			if (players->characters[i].name.equalsIgnoreCase(name)) {
+				return i;
+			}
+		}
+	}
+
+	return -1;
+}
+
+// The character named by an optional argument
+static uint checkCharacter(lua_State *L, int index) {
+	if (lua_gettop(L) < index) {
+		return g_nancy->getPlayerCharacter();
+	}
+
+	const char *name = luaL_checkstring(L, index);
+	const int character = findCharacter(name);
+	if (character < 0) {
+		luaL_error(L, "Invalid player %s", name);
+	}
+
+	return character;
+}
+
+// Player:Switch(character), which hands the game over to another character.
+// Nothing happens when they are the one being played, or when there is no such
+// character.
+static int playerSwitch(lua_State *L) {
+	const int character = findCharacter(luaL_checkstring(L, 2));
+	if (character >= 0) {
+		NancySceneState.changePlayerCharacter(character);
+	}
+
+	return 0;
+}
+
+// Player:HasInventoryItem(item[, character])
+static int playerHasInventoryItem(lua_State *L) {
+	const int16 itemID = checkInventoryItem(L, 2);
+	const uint character = checkCharacter(L, 3);
+
+	lua_pushboolean(L, NancySceneState.hasCharacterItem(character, itemID) == g_nancy->_true);
+	return 1;
+}
+
+// Player:AddInventoryItem(item, intoHand[, character])
+static int playerAddInventoryItem(lua_State *L) {
+	const int16 itemID = checkInventoryItem(L, 2);
+	luaL_checktype(L, 3, LUA_TBOOLEAN);
+	const bool intoHand = lua_toboolean(L, 3) != 0;
+	const uint character = checkCharacter(L, 4);
+
+	NancySceneState.giveItemToCharacter(character, itemID, intoHand, false);
+	return 0;
+}
+
+// Player:RemoveInventoryItem(item[, character])
+static int playerRemoveInventoryItem(lua_State *L) {
+	const int16 itemID = checkInventoryItem(L, 2);
+	const uint character = checkCharacter(L, 3);
+
+	NancySceneState.removeItemFromCharacterInventory(character, itemID);
+	return 0;
+}
+
+static const luaL_Reg kPlayerFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "Switch", playerSwitch },
+	{ "HasInventoryItem", playerHasInventoryItem },
+	{ "AddInventoryItem", playerAddInventoryItem },
+	{ "RemoveInventoryItem", playerRemoveInventoryItem },
+	{ nullptr, nullptr }
+};
+
+// Viewport
+
+static int viewportIndex(lua_State *L) {
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "size") {
+		const VIEW *viewport = GetEngineData(VIEW);
+		assert(viewport);
+		pushRect(L, ScriptRect(0, 0, viewport->screenPosition.width(), viewport->screenPosition.height()));
+		return 1;
+	}
+
+	lua_pushnil(L);
+	return 1;
+}
+
+static const luaL_Reg kViewportFunctions[] = {
+	{ "__index", viewportIndex },
+	{ nullptr, nullptr }
+};
+
+// Log:Write(text)
+static int logWrite(lua_State *L) {
+	debugC(1, kDebugScene, "Script: %s", luaL_checkstring(L, 2));
+	return 0;
+}
+
+static const luaL_Reg kLogFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "Write", logWrite },
+	{ nullptr, nullptr }
+};
+
+void registerGameAPI(lua_State *L) {
+	registerGlobal(L, "Flags", "ND.Flags", kFlagsFunctions);
+	registerGlobal(L, "VarTable", "ND.VarTable", kVarTableFunctions);
+
+	registerGlobal(L, "Z", "ND.ZHelper", kZFunctions);
+	luaL_getmetatable(L, "ND.ZHelper");
+	lua_pushnumber(L, 0.0);
+	lua_setfield(L, -2, "middle");
+	lua_pop(L, 1);
+
+	registerType(L, kCursorIndexType, kCursorIndexFunctions);
+	registerGlobal(L, "Cursor", "ND.Cursor", kCursorFunctions);
+
+	registerGlobal(L, "Scene", "ND.Scene", kSceneFunctions);
+	registerGlobal(L, "Player", "ND.CurPlayer", kPlayerFunctions);
+	registerGlobal(L, "Viewport", "ND.Viewport", kViewportFunctions);
+
+	registerType(L, kKeyType, kKeyFunctions);
+	registerGlobal(L, "Key", "ND.KeyHelper", kKeyHelperFunctions);
+
+	registerGlobal(L, "Game", "ND.Game", kGameFunctions);
+	registerGlobal(L, "Draw", "ND.Draw", kDrawFunctions);
+	registerGlobal(L, "Log", "ND.Log", kLogFunctions);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
new file mode 100644
index 00000000000..b487651c8e4
--- /dev/null
+++ b/engines/nancy/script/record.cpp
@@ -0,0 +1,540 @@
+/* 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/api.h"
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/luastate.h"
+#include "engines/nancy/script/record.h"
+#include "engines/nancy/script/records.h"
+#include "engines/nancy/script/scenecontext.h"
+
+namespace Nancy {
+namespace Script {
+
+// Set in the metatable of every kind of record
+static const char *const kRecordMarker = "__record";
+
+Record::Record(SceneContext &context, const char *typeName) :
+		_context(context),
+		_typeName(typeName) {}
+
+Record::~Record() {}
+
+lua_State *Record::getLua() const {
+	return _context.getLua();
+}
+
+// What scripts store in a record is kept in a table of the registry
+void Record::pushStorage() {
+	lua_State *L = getLua();
+
+	lua_pushlightuserdata(L, this);
+	lua_rawget(L, LUA_REGISTRYINDEX);
+	if (lua_istable(L, -1)) {
+		return;
+	}
+
+	lua_pop(L, 1);
+	lua_newtable(L);
+	lua_pushlightuserdata(L, this);
+	lua_pushvalue(L, -2);
+	lua_rawset(L, LUA_REGISTRYINDEX);
+}
+
+void Record::pushStoredValue(const char *name) {
+	lua_State *L = getLua();
+
+	pushStorage();
+	lua_getfield(L, -1, name);
+	lua_remove(L, -2);
+}
+
+void Record::storeValue(const char *name) {
+	lua_State *L = getLua();
+
+	pushStorage();
+	lua_insert(L, -2);
+	lua_setfield(L, -2, name);
+	lua_pop(L, 1);
+}
+
+void Record::create(int tableIndex) {
+	lua_State *L = getLua();
+
+	// Everything in the table that has a name is kept, whether the record
+	// knows the name or not
+	lua_pushnil(L);
+	while (lua_next(L, tableIndex)) {
+		if (lua_type(L, -2) == LUA_TSTRING) {
+			lua_pushvalue(L, -1);
+			storeValue(lua_tostring(L, -3));
+		}
+
+		lua_pop(L, 1);
+	}
+
+	readField(L, tableIndex, "z", _z);
+
+	lua_getfield(L, tableIndex, "active");
+	if (lua_isboolean(L, -1)) {
+		_isActive = lua_toboolean(L, -1) != 0;
+	} else if (lua_isfunction(L, -1)) {
+		_hasActiveFunction = true;
+	}
+
+	lua_pop(L, 1);
+
+	if (hasTransform() && pushField(L, tableIndex, "transform")) {
+		_transform = checkMatrix(L, -1);
+		lua_pop(L, 1);
+	}
+
+	readTable(tableIndex);
+}
+
+void Record::push() {
+	Record **object = (Record **)pushUserData(getLua(), sizeof(Record *), _typeName);
+	*object = this;
+}
+
+void Record::update(double sceneTime, double elapsedTime) {
+	if (_isDone) {
+		return;
+	}
+
+	lua_State *L = getLua();
+
+	if (_hasActiveFunction) {
+		pushStoredValue("active");
+		push();
+		if (LuaState::call(L, 1, 1, "Error in AR's active function")) {
+			_isActive = lua_toboolean(L, -1) != 0;
+			lua_pop(L, 1);
+		}
+	}
+
+	if (_isActive && !_wasActive) {
+		onActivation();
+	} else if (!_isActive && _wasActive) {
+		onDeactivation();
+	}
+
+	_wasActive = _isActive;
+
+	if (!_isActive) {
+		return;
+	}
+
+	_hasStarted = true;
+
+	if (!_hasRunOnce) {
+		_hasRunOnce = true;
+		runOnce();
+		callFunction("RunOnce");
+	}
+
+	if (isScripted()) {
+		lua_pushnumber(L, sceneTime);
+		lua_pushnumber(L, elapsedTime);
+		callFunction("Run", 2);
+	} else {
+		run(sceneTime, elapsedTime);
+	}
+}
+
+void Record::finish() {
+	_isDone = true;
+	callFunction("OnDone");
+}
+
+bool Record::reset() {
+	if (!prepareReset()) {
+		return false;
+	}
+
+	_hasRunOnce = false;
+	_isDone = false;
+	_hasStarted = false;
+	callFunction("OnReset");
+	return true;
+}
+
+bool Record::callFunction(const char *name, int numArgs, int numResults) {
+	lua_State *L = getLua();
+
+	pushStoredValue(name);
+	if (!lua_isfunction(L, -1)) {
+		lua_pop(L, numArgs + 1);
+		return false;
+	}
+
+	// The function and the record go below the arguments
+	push();
+	if (numArgs > 0) {
+		lua_insert(L, -2 - numArgs);
+		lua_insert(L, -2 - numArgs);
+	}
+
+	const Common::String what = Common::String::format("Error running override '%s'", name);
+	return LuaState::call(L, numArgs + 1, numResults, what.c_str());
+}
+
+int Record::getScriptField() {
+	lua_State *L = getLua();
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "active") {
+		lua_pushboolean(L, isActive());
+		return 1;
+	} else if (key == "started") {
+		lua_pushboolean(L, _hasStarted);
+		return 1;
+	} else if (key == "done") {
+		lua_pushboolean(L, _isDone);
+		return 1;
+	} else if (key == "z") {
+		lua_pushnumber(L, _z);
+		return 1;
+	} else if (key == "transform" && hasTransform()) {
+		pushMatrix(L, _transform);
+		return 1;
+	}
+
+	// The functions every record has
+	indexFunctions(L);
+	if (!lua_isnil(L, -1)) {
+		return 1;
+	}
+
+	lua_pop(L, 1);
+
+	if (getField(key)) {
+		return 1;
+	}
+
+	pushStoredValue(key.c_str());
+	return 1;
+}
+
+int Record::setScriptField() {
+	lua_State *L = getLua();
+	const Common::String key = luaL_checkstring(L, 2);
+
+	if (key == "active") {
+		if (lua_isfunction(L, 3)) {
+			lua_pushvalue(L, 3);
+			storeValue("active");
+			_hasActiveFunction = true;
+		} else {
+			_isActive = lua_toboolean(L, 3) != 0;
+			_hasActiveFunction = false;
+		}
+	} else if (key == "z") {
+		_z = luaL_checknumber(L, 3);
+		onZChanged();
+	} else if (key == "transform" && hasTransform()) {
+		_transform = checkMatrix(L, 3);
+		onTransformChanged();
+	} else if (!setField(key)) {
+		lua_pushvalue(L, 3);
+		storeValue(key.c_str());
+	}
+
+	return 0;
+}
+
+// The functions scripts can give a record for what the player does
+static const char *const kEventFunctionNames[kNumRecordEvents] = {
+	"OnLeftClick", "OnRightClick", "OnLeftDown", "OnLeftUp", "OnRightDown", "OnRightUp",
+	"OnKeyDown", "OnKey", "OnKeyUp"
+};
+
+void Record::sendMouseMove(const ScriptPoint &point, bool inViewport) {
+	if (!isActive()) {
+		return;
+	}
+
+	_hasStarted = true;
+
+	if (isScripted()) {
+		lua_State *L = getLua();
+		pushPoint(L, point);
+		lua_pushboolean(L, inViewport);
+		callFunction("OnMouseMove", 2);
+	} else {
+		onMouseMove(point, inViewport);
+	}
+}
+
+bool Record::sendChangeCursor(const ScriptPoint &point, bool inViewport, uint &cursorType, bool &isHot) {
+	if (!isActive()) {
+		return false;
+	}
+
+	_hasStarted = true;
+
+	if (!isScripted()) {
+		return getCursor(point, inViewport, cursorType, isHot);
+	}
+
+	// The function returns the cursor it wants, if any
+	lua_State *L = getLua();
+	pushPoint(L, point);
+	lua_pushboolean(L, inViewport);
+	if (!callFunction("ChangeCursor", 2, 1)) {
+		return false;
+	}
+
+	const bool hasCursor = testCursor(L, -1, cursorType, isHot);
+	lua_pop(L, 1);
+	return hasCursor;
+}
+
+bool Record::sendMouseEvent(RecordEvent event, const ScriptPoint &point, bool inViewport) {
+	if (!isActive()) {
+		return false;
+	}
+
+	_hasStarted = true;
+
+	if (!isScripted()) {
+		return onMouseEvent(event, point, inViewport);
+	}
+
+	lua_State *L = getLua();
+	pushPoint(L, point);
+	lua_pushboolean(L, inViewport);
+	if (!callFunction(kEventFunctionNames[event], 2, 1)) {
+		return false;
+	}
+
+	const bool isHandled = lua_toboolean(L, -1) != 0;
+	lua_pop(L, 1);
+	return isHandled;
+}
+
+bool Record::sendKeyEvent(RecordEvent event, Common::KeyCode key) {
+	// Only the records made of script functions care about the keyboard
+	if (!isActive() || !isScripted()) {
+		return false;
+	}
+
+	_hasStarted = true;
+
+	lua_State *L = getLua();
+	pushKey(L, key);
+	if (!callFunction(kEventFunctionNames[event], 1, 1)) {
+		return false;
+	}
+
+	const bool isHandled = lua_toboolean(L, -1) != 0;
+	lua_pop(L, 1);
+	return isHandled;
+}
+
+Record *checkRecord(lua_State *L, int index) {
+	Record **object = (Record **)lua_touserdata(L, index);
+	if (object && lua_getmetatable(L, index)) {
+		lua_getfield(L, -1, kRecordMarker);
+		const bool isRecord = lua_toboolean(L, -1) != 0;
+		lua_pop(L, 2);
+
+		if (isRecord) {
+			return *object;
+		}
+	}
+
+	luaL_typerror(L, index, "AR");
+	return nullptr;
+}
+
+static int recordIndex(lua_State *L) {
+	return checkRecord(L, 1)->getScriptField();
+}
+
+static int recordNewIndex(lua_State *L) {
+	return checkRecord(L, 1)->setScriptField();
+}
+
+// Every access makes a new object for the same record
+static int recordEquals(lua_State *L) {
+	lua_pushboolean(L, checkRecord(L, 1) == checkRecord(L, 2));
+	return 1;
+}
+
+static int recordReset(lua_State *L) {
+	if (!checkRecord(L, 1)->reset()) {
+		return luaL_error(L, "Cannot reset AR");
+	}
+
+	return 0;
+}
+
+// ar:Run(sceneTime, elapsedTime) runs a whole frame of the record
+static int recordRun(lua_State *L) {
+	Record *record = checkRecord(L, 1);
+	const double sceneTime = luaL_checknumber(L, 2);
+	const double elapsedTime = luaL_checknumber(L, 3);
+	record->update(sceneTime, elapsedTime);
+	return 0;
+}
+
+// ar:OnMouseMove(point, inViewport)
+static int recordOnMouseMove(lua_State *L) {
+	Record *record = checkRecord(L, 1);
+
+	ScriptPoint point;
+	if (!readPoint(L, 2, point)) {
+		return luaL_argerror(L, 2, "Single parameter must be a Point or a table with x and y fields");
+	}
+
+	record->sendMouseMove(point, lua_toboolean(L, 3) != 0);
+	return 0;
+}
+
+// ar:ChangeCursor(point, inViewport), which returns the cursor the record wants
+static int recordChangeCursor(lua_State *L) {
+	Record *record = checkRecord(L, 1);
+
+	ScriptPoint point;
+	if (!readPoint(L, 2, point)) {
+		return luaL_argerror(L, 2, "Single parameter must be a Point or a table with x and y fields");
+	}
+
+	uint cursorType = 0;
+	bool isHot = false;
+	if (!record->sendChangeCursor(point, lua_toboolean(L, 3) != 0, cursorType, isHot)) {
+		return 0;
+	}
+
+	pushCursor(L, cursorType, isHot);
+	return 1;
+}
+
+// ar:OnLeftClick(point, inViewport) and the like; the event is kept with the function
+static int recordOnMouseEvent(lua_State *L) {
+	const RecordEvent event = (RecordEvent)lua_tointeger(L, lua_upvalueindex(1));
+	Record *record = checkRecord(L, 1);
+
+	ScriptPoint point;
+	if (!readPoint(L, 2, point)) {
+		return luaL_argerror(L, 2, "Single parameter must be a Point or a table with x and y fields");
+	}
+
+	lua_pushboolean(L, record->sendMouseEvent(event, point, lua_toboolean(L, 3) != 0));
+	return 1;
+}
+
+// ar:OnKeyDown(key) and the like
+static int recordOnKeyEvent(lua_State *L) {
+	const RecordEvent event = (RecordEvent)lua_tointeger(L, lua_upvalueindex(1));
+	Record *record = checkRecord(L, 1);
+
+	lua_pushboolean(L, record->sendKeyEvent(event, checkKey(L, 2)));
+	return 1;
+}
+
+static const luaL_Reg kRecordFunctions[] = {
+	{ "__index", recordIndex },
+	{ "__newindex", recordNewIndex },
+	{ "__eq", recordEquals },
+	{ "Reset", recordReset },
+	{ "Run", recordRun },
+	{ "OnMouseMove", recordOnMouseMove },
+	{ "ChangeCursor", recordChangeCursor },
+	{ nullptr, nullptr }
+};
+
+// AR:<kind>(table)
+static int createRecord(lua_State *L) {
+	const RecordKind kind = (RecordKind)lua_tointeger(L, lua_upvalueindex(1));
+	const char *kindName = lua_tostring(L, lua_upvalueindex(2));
+
+	SceneContext *context = LuaState::getContext(L);
+	if (!context) {
+		return luaL_error(L, "AR:%s can only be used by the script of a scene", kindName);
+	}
+
+	// Only the table is left, at index 1
+	lua_remove(L, 1);
+	lua_settop(L, 1);
+	if (!lua_istable(L, 1)) {
+		return luaL_argerror(L, 1, "Expected a table as the parameter");
+	}
+
+	if (kind != kRecordSummary && !context->hasRecords()) {
+		return luaL_error(L, "Summary AR must be the first AR in the scene");
+	}
+
+	Record *record = makeRecord(kind, *context);
+	if (!record) {
+		return luaL_error(L, "AR:%s is not supported yet", kindName);
+	}
+
+	context->addRecord(record);
+	record->create(1);
+	record->push();
+	return 1;
+}
+
+void registerRecordAPI(lua_State *L) {
+	for (uint i = 0; i < kNumRecordKinds; ++i) {
+		const char *typeName = getRecordTypeName((RecordKind)i);
+		if (luaL_newmetatable(L, typeName)) {
+			luaL_register(L, nullptr, kRecordFunctions);
+
+			lua_pushstring(L, typeName);
+			lua_setfield(L, -2, "__metatable");
+
+			lua_pushboolean(L, true);
+			lua_setfield(L, -2, kRecordMarker);
+
+			for (int event = 0; event < kNumRecordEvents; ++event) {
+				lua_pushinteger(L, event);
+				lua_pushcclosure(L, event >= kEventKeyDown ? recordOnKeyEvent : recordOnMouseEvent, 1);
+				lua_setfield(L, -2, kEventFunctionNames[event]);
+			}
+		}
+
+		lua_pop(L, 1);
+	}
+
+	static const luaL_Reg kFactoryFunctions[] = {
+		{ "__index", indexFunctions },
+		{ nullptr, nullptr }
+	};
+
+	registerGlobal(L, "AR", "ND.AR", kFactoryFunctions);
+
+	// One function for every kind, which knows the kind it makes
+	luaL_getmetatable(L, "ND.AR");
+	for (uint i = 0; i < kNumRecordKinds; ++i) {
+		lua_pushinteger(L, i);
+		lua_pushstring(L, getRecordKindName((RecordKind)i));
+		lua_pushcclosure(L, createRecord, 2);
+		lua_setfield(L, -2, getRecordKindName((RecordKind)i));
+	}
+
+	lua_pop(L, 1);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
new file mode 100644
index 00000000000..60469fb8e7e
--- /dev/null
+++ b/engines/nancy/script/record.h
@@ -0,0 +1,186 @@
+/* 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_RECORD_H
+#define NANCY_SCRIPT_RECORD_H
+
+#include "common/keyboard.h"
+#include "common/str.h"
+
+#include "engines/nancy/script/mathtypes.h"
+
+struct lua_State;
+
+namespace Nancy {
+
+struct NancyInput;
+
+namespace Script {
+
+class SceneContext;
+
+// What the player does that records get told about
+enum RecordEvent {
+	kEventLeftClick		= 0,
+	kEventRightClick	= 1,
+	kEventLeftDown		= 2,
+	kEventLeftUp		= 3,
+	kEventRightDown		= 4,
+	kEventRightUp		= 5,
+	kEventKeyDown		= 6,
+	kEventKey			= 7,
+	kEventKeyUp			= 8,
+
+	kNumRecordEvents	= 9
+};
+
+// One of the things a scene of Nancy19 and up is made of, the way action records
+// were before. A scene's script makes its records by handing tables to the "AR"
+// object:
+//
+//   door_HS = AR:NavLogic({ onScreen = Rect:New(...), scene = 2010,
+//                           active = function() return Flags.Got_Key end })
+//
+// There are only a handful of kinds, since what used to take a record type of its
+// own is now written in the scripts themselves. A record is active while its
+// "active" value, or the function in its place, says so; an active record runs
+// every frame until it calls itself done. Scripts read each other's "active" and
+// "done" to chain records together.
+//
+// Besides the fields its kind knows, a record keeps whatever else a script
+// stores in it. That includes the functions the engine calls at certain points
+// (RunOnce, OnDone, OnReset...), which every kind of record can have.
+class Record {
+public:
+	Record(SceneContext &context, const char *typeName);
+	virtual ~Record();
+
+	const char *getTypeName() const { return _typeName; }
+	SceneContext &getContext() const { return _context; }
+
+	// Takes over the fields of the table that made the record
+	void create(int tableIndex);
+
+	// Pushes an object for the scripts that stands for this record
+	void push();
+
+	// One frame. Times are in seconds.
+	void update(double sceneTime, double elapsedTime);
+
+	// The record has done its job, and stops running
+	void finish();
+
+	// Makes a record that ran start over. Only some kinds can.
+	bool reset();
+
+	bool isActive() const { return _isActive && !_isDone; }
+	bool isDone() const { return _isDone; }
+	bool hasStarted() const { return _hasStarted; }
+	double getZ() const { return _z; }
+
+	// Reads or writes one of the fields of the record on behalf of a script.
+	// The key is at stack index 2, and a new value at 3.
+	int getScriptField();
+	int setScriptField();
+
+	// Calls a function a script stored in the record, with the record itself in
+	// front of the arguments on the stack. Returns false when there is no such
+	// function, or when it failed; no results are left then.
+	bool callFunction(const char *name, int numArgs = 0, int numResults = 0);
+
+	// What the player does goes to every record that is active, as a call to
+	// the function a script gave the record for it, or to what the kind of
+	// record does by itself. Positions are in the coordinates of the viewport.
+	// A click, a key and the cursor are up to the first record that takes them.
+	void sendMouseMove(const ScriptPoint &point, bool inViewport);
+	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);
+
+	// Where the record puts what it shows, for the kinds that show something
+	const ScriptMatrix &getTransform() const { return _transform; }
+
+	// Shows or hides what the record draws, once the records of a frame have run
+	virtual void updateGraphics() {}
+
+protected:
+	lua_State *getLua() const;
+
+	// The fields of the kind of record, from the table that made it
+	virtual void readTable(int tableIndex) {}
+
+	// What the record does when no script took its place
+	virtual void runOnce() {}
+	virtual void run(double sceneTime, double elapsedTime) {}
+	virtual void onActivation() {}
+	virtual void onDeactivation() {}
+	virtual void onZChanged() {}
+
+	// Whether the record can start over, after getting itself ready for it
+	virtual bool prepareReset() { return false; }
+
+	// Whether the functions of the scripts take the place of what the record does
+	// by itself: Run, Render, and everything the player does
+	virtual bool isScripted() const { return false; }
+
+	virtual void onMouseMove(const ScriptPoint &point, bool inViewport) {}
+	virtual bool getCursor(const ScriptPoint &point, bool inViewport, uint &cursorType, bool &isHot) { return false; }
+	virtual bool onMouseEvent(RecordEvent event, const ScriptPoint &point, bool inViewport) { return false; }
+
+	// Whether the kind of record has a "transform", and what to do when it changes
+	virtual bool hasTransform() const { return false; }
+	virtual void onTransformChanged() {}
+
+	// The fields of the kind of record. Reading pushes the value and returns true;
+	// writing takes the value at index 3. Both return false for unknown fields.
+	virtual bool getField(const Common::String &key) { return false; }
+	virtual bool setField(const Common::String &key) { return false; }
+
+	// What scripts stored in the record
+	void pushStoredValue(const char *name);
+	void storeValue(const char *name);	// pops the value
+
+private:
+	void pushStorage();
+
+	SceneContext &_context;
+	const char *_typeName;
+
+	double _z = 0.0;
+	ScriptMatrix _transform;
+	bool _hasActiveFunction = false;
+	bool _hasStarted = false;
+	bool _isActive = true;
+	bool _wasActive = false;
+	bool _isDone = false;
+	bool _hasRunOnce = false;
+};
+
+// Makes the "AR" object, and the types of the records
+void registerRecordAPI(lua_State *L);
+
+// The record a script handed in, or a script error
+Record *checkRecord(lua_State *L, int index);
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_RECORD_H
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
new file mode 100644
index 00000000000..6e36212d124
--- /dev/null
+++ b/engines/nancy/script/records.cpp
@@ -0,0 +1,497 @@
+/* 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/textconsole.h"
+
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/enginedata.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/nancy.h"
+
+#include "engines/nancy/script/api.h"
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/records.h"
+#include "engines/nancy/script/scenecontext.h"
+#include "engines/nancy/script/types.h"
+
+#include "engines/nancy/ui/viewport.h"
+
+namespace Nancy {
+namespace Script {
+
+struct RecordKindNames {
+	const char *kindName;
+	const char *typeName;
+};
+
+static const RecordKindNames kRecordKindNames[kNumRecordKinds] = {
+	{ "NavLogic", "ND.NavLogic" },
+	{ "Summary", "ND.Summary" },
+	{ "Overlay", "ND.Overlay" },
+	{ "Movie", "ND.Movie" },
+	{ "Sound", "ND.SoundAR" },
+	{ "VideoConvo", "ND.ConvoAR" },
+	{ "SoundConvo", "ND.ConvoAR" },
+	{ "Text", "ND.TextAR" },
+	{ "Override", "ND.OverrideAR" },
+	{ "Particle", "ND.ParticleAR" },
+	{ "AnimOverlay", "ND.AnimOvlAR" }
+};
+
+const char *getRecordKindName(RecordKind kind) {
+	return kRecordKindNames[kind].kindName;
+}
+
+const char *getRecordTypeName(RecordKind kind) {
+	return kRecordKindNames[kind].typeName;
+}
+
+Record *makeRecord(RecordKind kind, SceneContext &context) {
+	switch (kind) {
+	case kRecordNavLogic:
+		return new NavLogicRecord(context);
+	case kRecordSummary:
+		return new SummaryRecord(context);
+	case kRecordOverride:
+		return new OverrideRecord(context);
+	default:
+		return nullptr;
+	}
+}
+
+// The background of a scene is as far back as anything gets, and the records
+// default to the middle. The viewport has its place among what the engine draws,
+// so the numbers of the scripts are counted from there.
+static const double kBackgroundScriptZ = -5.0;
+static const int kBackgroundZ = 6;
+
+void RecordGraphic::setScriptZ(double z, int32 streamZOffset) {
+	const int engineZ = kBackgroundZ + (int)(z - kBackgroundScriptZ) + streamZOffset;
+	setZOrder((uint16)CLIP<int>(engineZ, 0, 0xFFFF));
+}
+
+static Graphics::ManagedSurface *s_drawTarget = nullptr;
+
+Graphics::ManagedSurface *getDrawTarget() {
+	return s_drawTarget;
+}
+
+// Summary
+
+void SummaryRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	if (!readField(L, tableIndex, "env", _environment)) {
+		luaL_error(L, "No environment specified");
+	}
+
+	if (!readField(L, tableIndex, "bg", _backgroundName)) {
+		luaL_error(L, "No background specified");
+	}
+}
+
+void SummaryRecord::setBackground(UI::Viewport *background) {
+	_background = background;
+	_numFrames = background ? background->getFrameCount() : 0;
+	_frame = getContext().getFrame();
+}
+
+void SummaryRecord::setFrame(uint frame) {
+	_frame = frame;
+	_lastTurnTime = _sceneTime;
+	getContext().setFrame(frame);
+
+	if (_background) {
+		_background->setFrame(frame);
+	}
+}
+
+SummaryRecord::Turn SummaryRecord::getTurnAt(const ScriptPoint &point, bool inViewport) const {
+	// Only a background with several frames can be turned around in
+	if (_numFrames <= 1 || !inViewport) {
+		return kNoTurn;
+	}
+
+	const ScriptBootData *boot = GetEngineData(ScriptBootData);
+	assert(boot);
+
+	const Common::Point place = point.toPoint();
+	if (boot->leftNodeHotspot.contains(place)) {
+		return kTurnLeft;
+	} else if (boot->rightNodeHotspot.contains(place)) {
+		return kTurnRight;
+	} else if (boot->uturnNodeHotspot.contains(place)) {
+		return kTurnAround;
+	}
+
+	return kNoTurn;
+}
+
+void SummaryRecord::onMouseMove(const ScriptPoint &point, bool inViewport) {
+	// The view turns by itself while the mouse stays on a side, after a first wait
+	Turn turn = getTurnAt(point, inViewport);
+	if (turn == kTurnAround) {
+		turn = kNoTurn;
+	}
+
+	if (turn != kNoTurn && turn != _turn) {
+		_lastTurnTime = _sceneTime;
+	}
+
+	_turn = turn;
+}
+
+bool SummaryRecord::getCursor(const ScriptPoint &point, bool inViewport, uint &cursorType, bool &isHot) {
+	const ScriptBootData *boot = GetEngineData(ScriptBootData);
+	assert(boot);
+
+	int type = -1;
+	switch (getTurnAt(point, inViewport)) {
+	case kTurnLeft:
+		type = g_nancy->_cursor->getCursorTypeByName(boot->leftNodeCursorName);
+		break;
+	case kTurnRight:
+		type = g_nancy->_cursor->getCursorTypeByName(boot->rightNodeCursorName);
+		break;
+	case kTurnAround:
+		type = g_nancy->_cursor->getCursorTypeByName(boot->uturnNodeCursorName);
+		break;
+	default:
+		break;
+	}
+
+	if (type < 0) {
+		return false;
+	}
+
+	cursorType = type;
+	isHot = false;
+	return true;
+}
+
+bool SummaryRecord::onMouseEvent(RecordEvent event, const ScriptPoint &point, bool inViewport) {
+	switch (event) {
+	case kEventRightDown:
+		_isTurningFast = true;
+		return false;
+	case kEventRightUp:
+		_isTurningFast = false;
+		return false;
+	case kEventLeftClick:
+		break;
+	default:
+		return false;
+	}
+
+	switch (getTurnAt(point, inViewport)) {
+	case kTurnLeft:
+	case kTurnRight:
+		// A click doesn't wait for the turn
+		_lastTurnTime = _sceneTime - 0.5;
+		return true;
+	case kTurnAround:
+		setFrame((_frame + _numFrames / 2) % _numFrames);
+		return true;
+	default:
+		return false;
+	}
+}
+
+void SummaryRecord::run(double sceneTime, double elapsedTime) {
+	_sceneTime = sceneTime;
+
+	if (_numFrames <= 1 || _turn == kNoTurn) {
+		return;
+	}
+
+	if (_lastTurnTime + 0.5 < sceneTime || (_isTurningFast && _lastTurnTime + 0.2 < sceneTime)) {
+		if (_turn == kTurnLeft) {
+			setFrame((_frame + 1) % _numFrames);
+		} else {
+			setFrame((_frame + _numFrames - 1) % _numFrames);
+		}
+	}
+}
+
+bool SummaryRecord::getField(const Common::String &key) {
+	if (key == "frame") {
+		lua_pushinteger(getLua(), _frame);
+		return true;
+	}
+
+	return false;
+}
+
+// NavLogic
+
+void NavLogicRecord::readScene(int valueIndex) {
+	lua_State *L = getLua();
+
+	// Scenes that go by number are named after it
+	if (lua_type(L, valueIndex) == LUA_TNUMBER) {
+		_sceneName = Common::String::format("s%d", (int)lua_tointeger(L, valueIndex));
+	} else if (lua_isnil(L, valueIndex)) {
+		_sceneName.clear();
+	} else {
+		_sceneName = luaL_checkstring(L, valueIndex);
+	}
+}
+
+void NavLogicRecord::readCursor(int valueIndex) {
+	_hasCursor = testCursor(getLua(), valueIndex, _cursorType, _cursorIsHot);
+}
+
+void NavLogicRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	if (pushField(L, tableIndex, "scene")) {
+		readScene(-1);
+		lua_pop(L, 1);
+	}
+
+	if (pushField(L, tableIndex, "cursor")) {
+		readCursor(-1);
+		lua_pop(L, 1);
+	}
+
+	readField(L, tableIndex, "frame", _frame);
+	readField(L, tableIndex, "stream", _streamName);
+
+	if (pushField(L, tableIndex, "onScreen")) {
+		_hotspot = checkRect(L, -1);
+		lua_pop(L, 1);
+	}
+
+	// The flags to set, by name
+	if (pushField(L, tableIndex, "flags")) {
+		luaL_checktype(L, -1, LUA_TTABLE);
+		int flags = lua_gettop(L);
+
+		lua_pushnil(L);
+		while (lua_next(L, flags)) {
+			if (lua_type(L, -2) == LUA_TSTRING) {
+				Flag flag;
+				flag.name = lua_tostring(L, -2);
+				flag.value = lua_toboolean(L, -1) != 0;
+				_flags.push_back(flag);
+			}
+
+			lua_pop(L, 1);
+		}
+
+		lua_pop(L, 1);
+	}
+}
+
+void NavLogicRecord::run(double sceneTime, double elapsedTime) {
+	// Without a place to click, there is nothing to wait for
+	if (_hotspot.isEmpty()) {
+		trigger();
+	}
+}
+
+bool NavLogicRecord::isOver(const ScriptPoint &point) const {
+	return transformRect(_hotspot, getTransform()).contains(point.x, point.y);
+}
+
+bool NavLogicRecord::getCursor(const ScriptPoint &point, bool inViewport, uint &cursorType, bool &isHot) {
+	if (!_hasCursor || !isOver(point)) {
+		return false;
+	}
+
+	cursorType = _cursorType;
+	isHot = _cursorIsHot;
+	return true;
+}
+
+bool NavLogicRecord::onMouseEvent(RecordEvent event, const ScriptPoint &point, bool inViewport) {
+	if (event != kEventLeftClick || !isOver(point)) {
+		return false;
+	}
+
+	trigger();
+	return true;
+}
+
+void NavLogicRecord::trigger() {
+	for (const Flag &flag : _flags) {
+		if (!setFlagByName(flag.name, flag.value)) {
+			warning("Attempted to set flag \"%s\" but it does not exist.", flag.name.c_str());
+		}
+	}
+
+	if (!_sceneName.empty()) {
+		getContext().changeScene(_streamName, _sceneName, (uint16)_frame);
+	}
+
+	finish();
+}
+
+bool NavLogicRecord::getField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "scene") {
+		lua_pushstring(L, _sceneName.c_str());
+	} else if (key == "stream") {
+		lua_pushstring(L, _streamName.c_str());
+	} else if (key == "frame") {
+		lua_pushinteger(L, _frame);
+	} else if (key == "onScreen") {
+		pushRect(L, _hotspot);
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
+bool NavLogicRecord::setField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "scene") {
+		readScene(3);
+	} else if (key == "stream") {
+		_streamName = luaL_checkstring(L, 3);
+	} else if (key == "cursor") {
+		readCursor(3);
+	} else if (key == "frame") {
+		_frame = luaL_checkint(L, 3);
+	} else if (key == "onScreen") {
+		_hotspot = checkRect(L, 3);
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
+// Override
+
+// What the engine calls in an override, all of which have to be functions
+static const char *const kOverrideFunctionNames[] = {
+	"OnReset", "OnActivation", "OnDeactivation", "OnDone", "OnMouseMove", "ChangeCursor",
+	"OnLeftClick", "OnRightClick", "OnMouseScroll", "OnLeftDown", "OnLeftUp", "OnRightDown",
+	"OnRightUp", "OnKey", "Run", "RunOnce", "Render", "OnSave"
+};
+
+static bool isOverrideFunctionName(const Common::String &name) {
+	for (const char *functionName : kOverrideFunctionNames) {
+		if (name == functionName) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+OverrideRecord::~OverrideRecord() {
+	delete _canvas;
+}
+
+void OverrideRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	for (const char *functionName : kOverrideFunctionNames) {
+		lua_getfield(L, tableIndex, functionName);
+		if (!lua_isnil(L, -1) && !lua_isfunction(L, -1)) {
+			luaL_error(L, "%s must be set to a function", functionName);
+		}
+
+		lua_pop(L, 1);
+	}
+}
+
+void OverrideRecord::onZChanged() {
+	if (_canvas) {
+		_canvas->setScriptZ(getZ(), getContext().getZOffset());
+	}
+}
+
+// An override draws by having its "Render" function called every frame, which
+// draws through the "Draw" object. Here that goes onto a picture as large as
+// the viewport, which is started over every frame.
+void OverrideRecord::updateGraphics() {
+	lua_State *L = getLua();
+
+	pushStoredValue("Render");
+	const bool hasRender = lua_isfunction(L, -1);
+	lua_pop(L, 1);
+
+	if (!hasRender || !isActive()) {
+		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().getZOffset());
+		_canvas->registerGraphics();
+	}
+
+	_canvas->_drawSurface.clear(0);
+
+	s_drawTarget = &_canvas->_drawSurface;
+	callFunction("Render");
+	s_drawTarget = nullptr;
+
+	_canvas->setVisible(true);
+	_canvas->setNeedsRedraw(true);
+}
+
+// this:Done()
+static int overrideDone(lua_State *L) {
+	checkRecord(L, 1)->finish();
+	return 0;
+}
+
+bool OverrideRecord::getField(const Common::String &key) {
+	if (key == "Done") {
+		lua_pushcfunction(getLua(), overrideDone);
+		return true;
+	}
+
+	return false;
+}
+
+bool OverrideRecord::setField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (isOverrideFunctionName(key) && !lua_isnil(L, 3) && !lua_isfunction(L, 3)) {
+		luaL_error(L, "%s must be set to a function or to nil", key.c_str());
+	}
+
+	// Kept with everything else scripts store in the record
+	return false;
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
new file mode 100644
index 00000000000..7dca67286d2
--- /dev/null
+++ b/engines/nancy/script/records.h
@@ -0,0 +1,190 @@
+/* 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_RECORDS_H
+#define NANCY_SCRIPT_RECORDS_H
+
+#include "common/array.h"
+#include "common/rect.h"
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/renderobject.h"
+
+#include "engines/nancy/script/record.h"
+
+namespace Nancy {
+
+namespace UI {
+class Viewport;
+}
+
+namespace Script {
+
+// The kinds of records a script can make, in the order of the names below
+enum RecordKind {
+	kRecordNavLogic		= 0,
+	kRecordSummary		= 1,
+	kRecordOverlay		= 2,
+	kRecordMovie		= 3,
+	kRecordSound		= 4,
+	kRecordVideoConvo	= 5,
+	kRecordSoundConvo	= 6,
+	kRecordText			= 7,
+	kRecordOverride		= 8,
+	kRecordParticle		= 9,
+	kRecordAnimOverlay	= 10,
+
+	kNumRecordKinds		= 11
+};
+
+// What scripts call the kind ("NavLogic"), and the type of its objects ("ND.NavLogic")
+const char *getRecordKindName(RecordKind kind);
+const char *getRecordTypeName(RecordKind kind);
+
+// Returns null for the kinds that are not supported
+Record *makeRecord(RecordKind kind, SceneContext &context);
+
+// What the "Draw" object of the scripts draws on: the record whose "Render"
+// function is running. Null at any other time.
+Graphics::ManagedSurface *getDrawTarget();
+
+// What a record draws. Records are placed in the coordinates of the viewport.
+class RecordGraphic : public RenderObject {
+public:
+	RecordGraphic() : RenderObject(0) {}
+	virtual ~RecordGraphic() {}
+
+	// Scripts order what they draw with numbers of their own
+	void setScriptZ(double z, int32 streamZOffset);
+
+protected:
+	bool isViewportRelative() const override { return true; }
+};
+
+// The first record of every scene, which names the area the scene belongs to
+// and its background. A background with more than one frame is a node the
+// player can turn around in.
+class SummaryRecord : public Record {
+public:
+	SummaryRecord(SceneContext &context) : Record(context, "ND.Summary") {}
+
+	const Common::String &getEnvironment() const { return _environment; }
+	const Common::String &getBackgroundName() const { return _backgroundName; }
+
+	// The viewport that shows the background, once it has been loaded
+	void setBackground(UI::Viewport *background);
+
+protected:
+	void readTable(int tableIndex) override;
+	void run(double sceneTime, double elapsedTime) override;
+	bool hasTransform() const override { return true; }
+	bool getField(const Common::String &key) override;
+
+	void onMouseMove(const ScriptPoint &point, bool inViewport) override;
+	bool getCursor(const ScriptPoint &point, bool inViewport, uint &cursorType, bool &isHot) override;
+	bool onMouseEvent(RecordEvent event, const ScriptPoint &point, bool inViewport) override;
+
+private:
+	enum Turn { kNoTurn, kTurnLeft, kTurnRight, kTurnAround };
+
+	// The side of the node the mouse is on
+	Turn getTurnAt(const ScriptPoint &point, bool inViewport) const;
+	void setFrame(uint frame);
+
+	Common::String _environment;
+	Common::String _backgroundName;
+
+	UI::Viewport *_background = nullptr;
+	uint _numFrames = 0;
+	uint _frame = 0;
+
+	Turn _turn = kNoTurn;
+	bool _isTurningFast = false;
+	double _lastTurnTime = 0.0;
+	double _sceneTime = 0.0;
+};
+
+// Changes the scene and sets flags, either right away or when its hotspot gets clicked
+class NavLogicRecord : public Record {
+public:
+	NavLogicRecord(SceneContext &context) : Record(context, "ND.NavLogic") {}
+
+protected:
+	void readTable(int tableIndex) override;
+	void run(double sceneTime, double elapsedTime) override;
+	bool prepareReset() override { return true; }
+	bool hasTransform() const override { return true; }
+	bool getField(const Common::String &key) override;
+	bool setField(const Common::String &key) override;
+
+	bool getCursor(const ScriptPoint &point, bool inViewport, uint &cursorType, bool &isHot) override;
+	bool onMouseEvent(RecordEvent event, const ScriptPoint &point, bool inViewport) override;
+
+private:
+	struct Flag {
+		Common::String name;
+		bool value = false;
+	};
+
+	void readScene(int valueIndex);
+	void readCursor(int valueIndex);
+	bool isOver(const ScriptPoint &point) const;
+	void trigger();
+
+	Common::String _sceneName;
+	Common::String _streamName;
+	int _frame = 0;
+
+	ScriptRect _hotspot;
+	bool _hasCursor = false;
+	uint _cursorType = 0;
+	bool _cursorIsHot = false;
+
+	Common::Array<Flag> _flags;
+};
+
+// A record that only does what the functions a script gave it do
+class OverrideRecord : public Record {
+public:
+	OverrideRecord(SceneContext &context) : Record(context, "ND.OverrideAR") {}
+	virtual ~OverrideRecord();
+
+	void updateGraphics() override;
+
+protected:
+	void readTable(int tableIndex) override;
+	bool prepareReset() override { return true; }
+	bool isScripted() const override { return true; }
+	void onActivation() override { callFunction("OnActivation"); }
+	void onDeactivation() override { callFunction("OnDeactivation"); }
+	void onZChanged() override;
+	bool getField(const Common::String &key) override;
+	bool setField(const Common::String &key) override;
+
+private:
+	// What the "Render" function draws on, for the records that have one
+	RecordGraphic *_canvas = nullptr;
+};
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_RECORDS_H
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
new file mode 100644
index 00000000000..f8c81e234cf
--- /dev/null
+++ b/engines/nancy/script/scenecontext.cpp
@@ -0,0 +1,293 @@
+/* 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/textconsole.h"
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/nancy.h"
+
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/luastate.h"
+#include "engines/nancy/script/record.h"
+#include "engines/nancy/script/records.h"
+#include "engines/nancy/script/scenecontext.h"
+
+#include "engines/nancy/state/scene.h"
+
+#include "engines/nancy/ui/viewport.h"
+
+namespace Nancy {
+namespace Script {
+
+SceneContext::SceneContext(const Common::String &streamName, const Common::String &sceneName, uint frame, int32 zOffset) :
+		_streamName(streamName),
+		_sceneName(sceneName),
+		_frame(frame),
+		_zOffset(zOffset) {
+	_lua = new LuaState(this);
+}
+
+SceneContext::~SceneContext() {
+	for (Record *record : _records) {
+		delete record;
+	}
+
+	for (Record *record : _newRecords) {
+		delete record;
+	}
+
+	delete _lua;
+}
+
+lua_State *SceneContext::getLua() const {
+	return _lua->get();
+}
+
+bool SceneContext::load() {
+	if (!_lua->runScript(_sceneName)) {
+		return false;
+	}
+
+	// Nothing can be made before there is a summary. A scene can go on to make
+	// more of them, and so can the scripts it includes; the last one that names
+	// a background is the one that shows.
+	_summary = _newRecords.empty() ? nullptr : dynamic_cast<SummaryRecord *>(_newRecords[0]);
+	for (Record *record : _newRecords) {
+		SummaryRecord *summary = dynamic_cast<SummaryRecord *>(record);
+		if (_summary && summary && !summary->getBackgroundName().empty()) {
+			_summary = summary;
+		}
+	}
+
+	if (!_summary) {
+		warning("Scene %s: No summary AR specified", _sceneName.c_str());
+		return false;
+	}
+
+	return true;
+}
+
+bool SceneContext::include(const Common::String &name) {
+	return _lua->runScript(name);
+}
+
+// The functions set on the "Scene" object are kept in a table of the registry
+void SceneContext::pushSceneFunctions() {
+	lua_State *L = getLua();
+
+	lua_pushlightuserdata(L, this);
+	lua_rawget(L, LUA_REGISTRYINDEX);
+	if (lua_istable(L, -1)) {
+		return;
+	}
+
+	lua_pop(L, 1);
+	lua_newtable(L);
+	lua_pushlightuserdata(L, this);
+	lua_pushvalue(L, -2);
+	lua_rawset(L, LUA_REGISTRYINDEX);
+}
+
+void SceneContext::setSceneFunction(const char *name) {
+	lua_State *L = getLua();
+
+	pushSceneFunctions();
+	lua_insert(L, -2);
+	lua_setfield(L, -2, name);
+	lua_pop(L, 1);
+}
+
+bool SceneContext::callSceneFunction(const char *name, int numArgs) {
+	lua_State *L = getLua();
+
+	pushSceneFunctions();
+	lua_getfield(L, -1, name);
+	lua_remove(L, -2);
+
+	if (!lua_isfunction(L, -1)) {
+		lua_pop(L, numArgs + 1);
+		return false;
+	}
+
+	if (numArgs > 0) {
+		lua_insert(L, -1 - numArgs);
+	}
+
+	const Common::String what = Common::String::format("Error during Scene %s", name);
+	LuaState::call(L, numArgs, 0, what.c_str());
+	return true;
+}
+
+void SceneContext::run(double elapsedTime) {
+	lua_State *L = getLua();
+
+	_time += elapsedTime;
+
+	// The records made since the last frame join in
+	_records.push_back(_newRecords);
+	_newRecords.clear();
+
+	if (!_hasRunOnce) {
+		_hasRunOnce = true;
+		callSceneFunction("RunOnce", 0);
+	}
+
+	// A scene that has a function for it runs that in place of its records
+	lua_pushnumber(L, _time);
+	lua_pushnumber(L, elapsedTime);
+	if (!callSceneFunction("Run", 2)) {
+		for (Record *record : _records) {
+			record->update(_time, elapsedTime);
+		}
+	}
+
+	for (Record *record : _records) {
+		record->updateGraphics();
+	}
+}
+
+bool SceneContext::getViewportMouse(const NancyInput &input, ScriptPoint &point) {
+	const UI::Viewport &viewport = NancySceneState.getViewport();
+	const Common::Rect screenPosition = viewport.getScreenPosition();
+
+	const Common::Rect local = viewport.convertScreenToViewport(
+		Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
+	point.x = local.left;
+	point.y = local.top;
+
+	return screenPosition.contains(input.mousePos);
+}
+
+// Everything the player does goes to the records made last first, which leaves
+// the turning around in the background for when nothing else takes a click.
+// Only clicks and keys can be taken; the rest goes to every record.
+void SceneContext::sendMouseEvent(int event, const ScriptPoint &point, bool inViewport) {
+	const bool canBeTaken = event == kEventLeftClick || event == kEventRightClick;
+
+	for (int i = (int)_records.size() - 1; i >= 0; --i) {
+		if (_records[i]->sendMouseEvent((RecordEvent)event, point, inViewport) && canBeTaken) {
+			return;
+		}
+	}
+}
+
+void SceneContext::sendKeyEvent(int event, Common::KeyCode key) {
+	for (int i = (int)_records.size() - 1; i >= 0; --i) {
+		if (_records[i]->sendKeyEvent((RecordEvent)event, key) && event == kEventKey) {
+			return;
+		}
+	}
+}
+
+struct MoveKey {
+	uint16 input;
+	Common::KeyCode key;
+};
+
+// The arrow keys are bound to moving around, and arrive as that
+static const MoveKey kMoveKeys[] = {
+	{ NancyInput::kMoveLeft, Common::KEYCODE_LEFT },
+	{ NancyInput::kMoveRight, Common::KEYCODE_RIGHT },
+	{ NancyInput::kMoveUp, Common::KEYCODE_UP },
+	{ NancyInput::kMoveDown, Common::KEYCODE_DOWN }
+};
+
+void SceneContext::handleInput(NancyInput &input) {
+	ScriptPoint point;
+	const bool inViewport = getViewportMouse(input, point);
+
+	for (int i = (int)_records.size() - 1; i >= 0; --i) {
+		_records[i]->sendMouseMove(point, inViewport);
+	}
+
+	for (int i = (int)_records.size() - 1; i >= 0; --i) {
+		uint cursorType = 0;
+		bool isHot = false;
+		if (_records[i]->sendChangeCursor(point, inViewport, cursorType, isHot)) {
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, isHot);
+			break;
+		}
+	}
+
+	if (input.input & NancyInput::kLeftMouseButtonDown) {
+		sendMouseEvent(kEventLeftDown, point, inViewport);
+	}
+
+	if (input.input & NancyInput::kLeftMouseButtonUp) {
+		sendMouseEvent(kEventLeftUp, point, inViewport);
+		sendMouseEvent(kEventLeftClick, point, inViewport);
+	}
+
+	if (input.input & NancyInput::kRightMouseButtonDown) {
+		sendMouseEvent(kEventRightDown, point, inViewport);
+	}
+
+	if (input.input & NancyInput::kRightMouseButtonUp) {
+		sendMouseEvent(kEventRightUp, point, inViewport);
+		sendMouseEvent(kEventRightClick, point, inViewport);
+	}
+
+	// The keys that went down last frame have no release of their own
+	for (Common::KeyCode key : _pressedKeys) {
+		sendKeyEvent(kEventKeyUp, key);
+	}
+
+	_pressedKeys.clear();
+
+	for (const MoveKey &moveKey : kMoveKeys) {
+		const bool isHeld = (input.input & moveKey.input) != 0;
+		const bool wasHeld = (_heldMoveKeys & moveKey.input) != 0;
+
+		if (isHeld && !wasHeld) {
+			sendKeyEvent(kEventKeyDown, moveKey.key);
+		} else if (!isHeld && wasHeld) {
+			sendKeyEvent(kEventKeyUp, moveKey.key);
+		}
+	}
+
+	_heldMoveKeys = input.input;
+
+	for (const Common::KeyState &key : input.otherKbdInput) {
+		sendKeyEvent(kEventKeyDown, key.keycode);
+		sendKeyEvent(kEventKey, key.keycode);
+		_pressedKeys.push_back(key.keycode);
+	}
+}
+
+void SceneContext::changeScene(const Common::String &streamName, const Common::String &sceneName, uint16 frame) {
+	State::SceneStreamManager &streams = NancySceneState.getStreams();
+
+	// The scenes of the main stream go by number
+	if (sceneName.size() > 1 && tolower(sceneName[0]) == 's' && Common::isDigit(sceneName[1])) {
+		SceneChangeDescription scene;
+		scene.sceneID = (uint16)atoi(sceneName.c_str() + 1);
+		scene.frameID = frame;
+		scene.continueSceneSound = kContinueSceneSound;
+		streams.changeScene(streamName, scene);
+	} else {
+		streams.changeScene(streamName, sceneName, frame);
+	}
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
new file mode 100644
index 00000000000..6e87bd02960
--- /dev/null
+++ b/engines/nancy/script/scenecontext.h
@@ -0,0 +1,122 @@
+/* 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_SCENECONTEXT_H
+#define NANCY_SCRIPT_SCENECONTEXT_H
+
+#include "common/array.h"
+#include "common/keyboard.h"
+#include "common/str.h"
+
+#include "engines/nancy/script/types.h"
+
+struct lua_State;
+
+namespace Nancy {
+
+struct NancyInput;
+
+namespace Script {
+
+class LuaState;
+class Record;
+class SummaryRecord;
+
+// One scene of a stream in Nancy19 and up, where it takes the place of the action
+// records read from a scene's IFF. A scene is a Lua script, which is run once to
+// make its records, and then kept around for the functions the records call.
+// Every scene has an interpreter of its own, so the scripts of two scenes share
+// nothing but what the engine keeps for them.
+class SceneContext {
+public:
+	SceneContext(const Common::String &streamName, const Common::String &sceneName, uint frame, int32 zOffset);
+	~SceneContext();
+
+	// Runs the script of the scene. Returns false when it is missing, or when it
+	// doesn't begin with a summary.
+	bool load();
+
+	// One frame, which lasted the given number of seconds
+	void run(double elapsedTime);
+	void handleInput(NancyInput &input);
+
+	lua_State *getLua() const;
+
+	// A new record only joins the scene at the start of the next frame. Until then
+	// it doesn't run, isn't drawn, and scripts that go over the records of the scene
+	// don't come across it.
+	void addRecord(Record *record) { _newRecords.push_back(record); }
+	bool hasRecords() const { return !_records.empty() || !_newRecords.empty(); }
+	const Common::Array<Record *> &getRecords() const { return _records; }
+
+	// The record that describes the scene, which is always the first one
+	SummaryRecord *getSummary() const { return _summary; }
+
+	const Common::String &getStreamName() const { return _streamName; }
+	const Common::String &getSceneName() const { return _sceneName; }
+	uint getFrame() const { return _frame; }
+	void setFrame(uint frame) { _frame = frame; }
+	int32 getZOffset() const { return _zOffset; }
+
+	// The seconds the scene has been running for
+	double getTime() const { return _time; }
+
+	// Runs another script as a part of this scene
+	bool include(const Common::String &name);
+
+	// Changes the scene of a stream; no name stands for the one this scene is in
+	void changeScene(const Common::String &streamName, const Common::String &sceneName, uint16 frame);
+
+	// Scripts can take over parts of what a scene does by setting functions
+	// on the "Scene" object. Takes the function at the top of the stack.
+	void setSceneFunction(const char *name);
+
+	// Where the mouse is in the viewport. Returns whether it is inside of it.
+	static bool getViewportMouse(const NancyInput &input, ScriptPoint &point);
+
+private:
+	bool callSceneFunction(const char *name, int numArgs);
+	void pushSceneFunctions();
+	void sendMouseEvent(int event, const ScriptPoint &point, bool inViewport);
+	void sendKeyEvent(int event, Common::KeyCode key);
+
+	Common::String _streamName;
+	Common::String _sceneName;
+	uint _frame;
+	int32 _zOffset;
+
+	LuaState *_lua;
+	Common::Array<Record *> _records;
+	Common::Array<Record *> _newRecords;
+	SummaryRecord *_summary = nullptr;
+
+	double _time = 0.0;
+	bool _hasRunOnce = false;
+
+	// The keys records were told are down
+	uint16 _heldMoveKeys = 0;
+	Common::Array<Common::KeyCode> _pressedKeys;
+};
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_SCENECONTEXT_H
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 20b25c05175..432dbd78d90 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -49,6 +49,10 @@
 #include "engines/nancy/misc/specialeffect.h"
 #include "engines/nancy/misc/viewportfader.h"
 #include "engines/nancy/ui/ndui/manager.h"
+#ifdef ENABLE_NANCY_V4
+#include "engines/nancy/script/records.h"
+#include "engines/nancy/script/scenecontext.h"
+#endif
 
 namespace Common {
 DECLARE_SINGLETON(Nancy::State::Scene);
@@ -162,6 +166,11 @@ Scene::~Scene() {
 	delete _ndui;
 	_ndui = nullptr;
 
+#ifdef ENABLE_NANCY_V4
+	delete _scriptScene;
+	_scriptScene = nullptr;
+#endif
+
 	_streams.clear();
 	clearPuzzleData();
 }
@@ -1303,7 +1312,7 @@ void Scene::registerGraphics() {
 	} else if (g_nancy->getGameType() <= kGameTypeNancy9) {
 		_textbox.registerGraphics();
 		_inventoryBox.registerGraphics();
-	} else {
+	} else if (g_nancy->getGameType() <= kGameTypeNancy15) {
 		_textbox.registerGraphics();
 		_inventoryPopup.registerGraphics();
 		_notebookPopup.registerGraphics();
@@ -1490,6 +1499,12 @@ bool Scene::applyPlayerCharacter(uint characterIndex) {
 		return false;
 	}
 
+	// Nancy19 has its UI made by scripts, and the scripts a character takes over
+	// and leaves with belong to it
+	if (g_nancy->getGameType() >= kGameTypeNancy19) {
+		return g_nancy->setPlayerCharacter(characterIndex);
+	}
+
 	// The open popups describe the outgoing character, so get them off the
 	// screen while the data they were built from is still around
 	closeActivePopups();
@@ -1983,6 +1998,13 @@ void Scene::load(bool fromSaveFile) {
 		_specialEffects.front().onSceneChange();
 	}
 
+#ifdef ENABLE_NANCY_V4
+	if (g_nancy->getGameType() >= kGameTypeNancy19) {
+		loadScriptedScene(fromSaveFile);
+		return;
+	}
+#endif
+
 	// Scene IDs are prefixed with S inside the cif tree; e.g 100 -> S100
 	Common::Path sceneName(Common::String::format("S%u", _sceneState.nextScene.sceneID));
 	IFF *sceneIFF = g_nancy->_resource->loadIFF(sceneName);
@@ -2130,6 +2152,83 @@ void Scene::load(bool fromSaveFile) {
 	_state = kStartSound;
 }
 
+#ifdef ENABLE_NANCY_V4
+// Nancy19 scenes are Lua scripts. A script makes the records of its scene when
+// it is run, the first of which names the background.
+void Scene::loadScriptedScene(bool fromSaveFile) {
+	const Common::String sceneName = Common::String::format("s%u", _sceneState.nextScene.sceneID);
+
+	Script::SceneContext *scriptScene = new Script::SceneContext("MainStream", sceneName, _sceneState.nextScene.frameID, 0);
+	if (!scriptScene->load()) {
+		// A scene that can't be loaded leaves the player where they are
+		warning("Failed to load scene %s", sceneName.c_str());
+		delete scriptScene;
+
+		if (!_scriptScene) {
+			error("There is no scene to go back to");
+		}
+
+		_state = kRun;
+		return;
+	}
+
+	clearSceneData();
+
+	delete _scriptScene;
+	_scriptScene = scriptScene;
+
+	_streams.onMainSceneChange();
+	g_nancy->_graphics->suppressNextDraw();
+
+	debugC(0, kDebugScene, "Loading new scene %s, frame %i", sceneName.c_str(), _sceneState.nextScene.frameID);
+
+	_sceneState.currentScene = _sceneState.nextScene;
+
+	Script::SummaryRecord *summary = _scriptScene->getSummary();
+	_sceneState.summary.description = sceneName;
+	_sceneState.summary.videoFile = Common::Path(summary->getBackgroundName());
+	_sceneState.summary.sound.name = summary->getEnvironment();
+	_sceneState.summary.panningType = kPanNone;
+
+	const bool hasBackground = !summary->getBackgroundName().empty();
+	_viewport.setVisible(hasBackground);
+
+	if (hasBackground) {
+		_viewport.loadVideo(_sceneState.summary.videoFile, _sceneState.currentScene.frameID, 0, kPanNone, kLargeVideoFormat);
+	}
+
+	// Turning around in a scene is up to its summary
+	_viewport.disableEdges(kLeft | kRight | kUp | kDown);
+	summary->setBackground(hasBackground ? &_viewport : nullptr);
+
+	_timers.sceneTime = 0;
+	g_nancy->_sound->clearListenerPositionOverride();
+
+	if (!fromSaveFile) {
+		_flags.sceneCounts.getOrCreateVal(_sceneState.currentScene.sceneID)++;
+	}
+
+	if (_ndui) {
+		_ndui->onSceneChange(_sceneState.currentScene.sceneID);
+	}
+
+	_streams.changeEnvironment(summary->getEnvironment());
+
+	_state = kStartSound;
+}
+#endif
+
+void Scene::processMainStream() {
+#ifdef ENABLE_NANCY_V4
+	if (_scriptScene) {
+		_scriptScene->run(_frameSeconds);
+		return;
+	}
+#endif
+
+	_actionManager.processActionRecords();
+}
+
 void Scene::run() {
 	if (_gameStateRequested != NancyState::kNone) {
 		g_nancy->setState(_gameStateRequested);
@@ -2146,6 +2245,7 @@ void Scene::run() {
 	}
 
 	_timers.sceneTime += deltaTime;
+	_frameSeconds = (uint32)deltaTime / 1000.0;
 
 	// Advance the Nancy 11+ software timers before processing action records,
 	// so any flags they fire this frame are visible to record dependencies
@@ -2547,7 +2647,7 @@ void Scene::handleInput() {
 			_activeConversation->skipLine();
 		} else if (_activeMovie) {
 			_hasSkippedMovie = _activeMovie->skip();
-		} else if (!_ndui) {
+		} else if (g_nancy->getGameType() <= kGameTypeNancy15) {
 			// Nancy16 has no main menu state
 			g_nancy->setState(NancyState::kMainMenu);
 			return;
@@ -2562,7 +2662,7 @@ void Scene::handleInput() {
 	// a conversation response.
 	if (_ndui) {
 		_ndui->handleInput(input);
-	} else if (g_nancy->getGameType() >= kGameTypeNancy10) {
+	} else if (g_nancy->getGameType() >= kGameTypeNancy10 && g_nancy->getGameType() <= kGameTypeNancy15) {
 		// Confine the cursor to whichever popup is open so the player
 		// can't drag it into the underlying scene UI.
 		const Common::Rect confine = activePopupConfinement();
@@ -2579,7 +2679,7 @@ void Scene::handleInput() {
 		_cellPhonePopup.handleInput(input);
 	}
 
-	if (!_ndui) {
+	if (g_nancy->getGameType() <= kGameTypeNancy15) {
 		_textbox.handleInput(input);
 	}
 
@@ -2640,7 +2740,15 @@ void Scene::handleInput() {
 	}
 
 	if (!cameraActive && !isInputCaptured) {
-		_actionManager.handleInput(input);
+#ifdef ENABLE_NANCY_V4
+		if (_scriptScene) {
+			_scriptScene->handleInput(input);
+		} else
+#endif
+		{
+			_actionManager.handleInput(input);
+		}
+
 		_streams.handleInput(input, false);
 	}
 
@@ -2763,7 +2871,10 @@ void Scene::initStaticData() {
 			_streams.clear();
 		}
 
-		initUI();
+		// The UI of Nancy19 is made by the scripts of a stream of its own
+		if (g_nancy->getGameType() <= kGameTypeNancy18) {
+			initUI();
+		}
 
 		// The popups of the earlier games go unused, and must never count as open
 		_inventoryPopup.setVisible(false);
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index a3f2eb69cd1..56d64341c4b 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -58,6 +58,10 @@ class ConversationSound;
 class PlaySecondaryMovie;
 }
 
+namespace Script {
+class SceneContext;
+}
+
 namespace Misc {
 class Lightning;
 class SpecialEffect;
@@ -290,6 +294,11 @@ public:
 	Action::ActionManager &getActionManager() { return _streams.getRunning() ? _streams.getRunning()->getActionManager() : _actionManager; }
 	Action::ActionManager &getMainActionManager() { return _actionManager; }
 
+	// Runs a frame of what the scene of the main stream is made of: its action
+	// records, or the records of its script in Nancy19 and up
+	void processMainStream();
+	double getFrameSeconds() const { return _frameSeconds; }
+
 	SceneChangeDescription &getSceneInfo() { return _streams.getRunning() ? _streams.getRunning()->getSceneInfo() : _sceneState.currentScene; }
 	const SceneChangeDescription &getMainSceneInfo() const { return _sceneState.currentScene; }
 	SceneChangeDescription &getNextSceneInfo() { return _sceneState.nextScene; }
@@ -350,6 +359,9 @@ public:
 private:
 	void init();
 	void load(bool fromSaveFile = false);
+#ifdef ENABLE_NANCY_V4
+	void loadScriptedScene(bool fromSaveFile);	// Nancy19+
+#endif
 	void run();
 	void handleInput();
 
@@ -494,6 +506,12 @@ private:
 
 	// Nancy17: the scenes running next to this one
 	SceneStreamManager _streams;
+
+#ifdef ENABLE_NANCY_V4
+	// Nancy19: the script of the scene, which takes the place of the action records
+	Script::SceneContext *_scriptScene = nullptr;
+#endif
+	double _frameSeconds = 0.0;	// how long the last frame took
 	Action::PlaySecondaryMovie *_activeMovie;
 	Action::ConversationSound *_activeConversation;
 
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index 3db31092d25..475e4bb9e44 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -32,6 +32,11 @@
 
 #include "engines/nancy/ui/viewport.h"
 
+#ifdef ENABLE_NANCY_V4
+#include "engines/nancy/script/records.h"
+#include "engines/nancy/script/scenecontext.h"
+#endif
+
 namespace Nancy {
 namespace State {
 
@@ -63,13 +68,13 @@ bool SceneStream::update() {
 		return false;
 	}
 
-	_actionManager.processActionRecords();
+	processRecords();
 
 	if (_hasNextScene && !_isEnded) {
 		_hasNextScene = false;
 
 		if (loadScene()) {
-			_actionManager.processActionRecords();
+			processRecords();
 		} else if (_sceneName.empty()) {
 			// There was nothing to run in the first place
 			_isEnded = true;
@@ -79,10 +84,30 @@ bool SceneStream::update() {
 	return !_isEnded;
 }
 
+void SceneStream::processRecords() {
+#ifdef ENABLE_NANCY_V4
+	if (_scriptScene) {
+		_scriptScene->run(NancySceneState.getFrameSeconds());
+		return;
+	}
+#endif
+
+	_actionManager.processActionRecords();
+}
+
 void SceneStream::handleInput(NancyInput &input) {
-	if (!_isEnded) {
-		_actionManager.handleInput(input);
+	if (_isEnded) {
+		return;
 	}
+
+#ifdef ENABLE_NANCY_V4
+	if (_scriptScene) {
+		_scriptScene->handleInput(input);
+		return;
+	}
+#endif
+
+	_actionManager.handleInput(input);
 }
 
 void SceneStream::changeScene(const Common::String &sceneName, uint16 frameID) {
@@ -116,25 +141,31 @@ byte SceneStream::getEventFlag(int16 index) const {
 	return g_nancy->_false;
 }
 
-bool SceneStream::loadScene() {
-	IFF *sceneIFF = g_nancy->_resource->loadIFF(Common::Path(_nextSceneName));
-	if (!sceneIFF) {
-		warning("Stream %s: missing scene file %s", _name.c_str(), _nextSceneName.c_str());
+#ifdef ENABLE_NANCY_V4
+// Nancy19 scenes are Lua scripts, which make their own records
+bool SceneStream::loadScriptedScene() {
+	Script::SceneContext *scriptScene = new Script::SceneContext(_name, _nextSceneName, _nextFrameID, _zOffset);
+	if (!scriptScene->load()) {
+		warning("Stream %s: could not load scene %s", _name.c_str(), _nextSceneName.c_str());
+		delete scriptScene;
 		return false;
 	}
 
-	Common::SeekableReadStream *summaryChunk = sceneIFF->getChunkStream("TSUM");
-	if (!summaryChunk) {
-		warning("Stream %s: scene file %s has no summary", _name.c_str(), _nextSceneName.c_str());
-		delete sceneIFF;
-		return false;
+	beginScene();
+	_scriptScene = scriptScene;
+
+	Script::SummaryRecord *summary = _scriptScene->getSummary();
+	if (!summary->getBackgroundName().empty()) {
+		loadBackground(Common::Path(summary->getBackgroundName()));
 	}
 
-	Common::Path videoFile;
-	summaryChunk->skip(0x32);	// Description
-	readFilename(*summaryChunk, videoFile);
-	delete summaryChunk;
+	summary->setBackground(_background);
+	return true;
+}
+#endif
 
+// Takes the stream from the scene it was running to the one it asked for
+void SceneStream::beginScene() {
 	// The streams that were started for the outgoing scene end with it
 	const bool hadScene = !_sceneName.empty();
 	clearScene();
@@ -161,13 +192,45 @@ bool SceneStream::loadScene() {
 	}
 
 	debugC(0, kDebugScene, "Stream %s: loading scene %s, frame %u", _name.c_str(), _sceneName.c_str(), _nextFrameID);
+}
+
+void SceneStream::loadBackground(const Common::Path &videoFile) {
+	_background = new UI::Viewport();
+	_background->init();
+	_background->setZOrder(_background->getZOrder() + _zOffset);
+	_background->loadVideo(videoFile, _sceneInfo.frameID, 0, kPanNone, kLargeVideoFormat);
+	_background->registerGraphics();
+}
+
+bool SceneStream::loadScene() {
+#ifdef ENABLE_NANCY_V4
+	if (g_nancy->getGameType() >= kGameTypeNancy19) {
+		return loadScriptedScene();
+	}
+#endif
+
+	IFF *sceneIFF = g_nancy->_resource->loadIFF(Common::Path(_nextSceneName));
+	if (!sceneIFF) {
+		warning("Stream %s: missing scene file %s", _name.c_str(), _nextSceneName.c_str());
+		return false;
+	}
+
+	Common::SeekableReadStream *summaryChunk = sceneIFF->getChunkStream("TSUM");
+	if (!summaryChunk) {
+		warning("Stream %s: scene file %s has no summary", _name.c_str(), _nextSceneName.c_str());
+		delete sceneIFF;
+		return false;
+	}
+
+	Common::Path videoFile;
+	summaryChunk->skip(0x32);	// Description
+	readFilename(*summaryChunk, videoFile);
+	delete summaryChunk;
+
+	beginScene();
 
 	if (!videoFile.empty() && !videoFile.equalsIgnoreCase("NO_BG")) {
-		_background = new UI::Viewport();
-		_background->init();
-		_background->setZOrder(_background->getZOrder() + _zOffset);
-		_background->loadVideo(videoFile, _sceneInfo.frameID, 0, kPanNone, kLargeVideoFormat);
-		_background->registerGraphics();
+		loadBackground(videoFile);
 	}
 
 	Common::SeekableReadStream *actionRecordChunk = nullptr;
@@ -195,6 +258,11 @@ void SceneStream::clearScene() {
 	_actionManager._records.clear();
 	_actionManager.clearActionRecords();
 
+#ifdef ENABLE_NANCY_V4
+	delete _scriptScene;
+	_scriptScene = nullptr;
+#endif
+
 	delete _background;
 	_background = nullptr;
 
@@ -297,7 +365,7 @@ Common::String SceneStreamManager::getSceneName(const Common::String &name) cons
 
 void SceneStreamManager::process() {
 	if (_streams.empty()) {
-		NancySceneState.getMainActionManager().processActionRecords();
+		NancySceneState.processMainStream();
 		return;
 	}
 
@@ -305,7 +373,7 @@ void SceneStreamManager::process() {
 	const Common::Array<SceneStream *> streams = _streams;
 	for (SceneStream *stream : streams) {
 		if (!stream) {
-			NancySceneState.getMainActionManager().processActionRecords();
+			NancySceneState.processMainStream();
 			continue;
 		}
 
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
index 9110237517a..6a592c53e9d 100644
--- a/engines/nancy/state/scenestream.h
+++ b/engines/nancy/state/scenestream.h
@@ -42,6 +42,10 @@ namespace UI {
 class Viewport;
 }
 
+namespace Script {
+class SceneContext;
+}
+
 namespace State {
 
 // What a stream gets started with
@@ -97,7 +101,13 @@ public:
 
 private:
 	bool loadScene();
+#ifdef ENABLE_NANCY_V4
+	bool loadScriptedScene();	// Nancy19+
+#endif
+	void beginScene();
+	void loadBackground(const Common::Path &videoFile);
 	void clearScene();
+	void processRecords();
 
 	Common::String _name;
 	StreamFlags _flags;
@@ -108,6 +118,11 @@ private:
 	Action::ActionManager _actionManager;
 	UI::Viewport *_background = nullptr;
 
+#ifdef ENABLE_NANCY_V4
+	// Nancy19: the script of the scene, which takes the place of the action records
+	Script::SceneContext *_scriptScene = nullptr;
+#endif
+
 	// Every stream has generic event flags of its own
 	Common::Array<byte> _genericFlags;
 


Commit: 283c7f59eef71120a75c15c82db89050ae192f22
    https://github.com/scummvm/scummvm/commit/283c7f59eef71120a75c15c82db89050ae192f22
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:13+03:00

Commit Message:
NANCY: NANCY19: Implement the Overlay and AnimOverlay records

An Overlay shows a part of an image, and an AnimOverlay goes through
several parts of one over time, a number of times or until it is
paused. Both go where the transform of their record puts them.

TODO: Only the offset of a transform is applied, not its rotation
or scale.

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 6e36212d124..5a4c1403664 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -25,6 +25,7 @@
 #include "engines/nancy/enginedata.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/nancy.h"
+#include "engines/nancy/resource.h"
 
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/luabind.h"
@@ -70,8 +71,12 @@ Record *makeRecord(RecordKind kind, SceneContext &context) {
 		return new NavLogicRecord(context);
 	case kRecordSummary:
 		return new SummaryRecord(context);
+	case kRecordOverlay:
+		return new OverlayRecord(context);
 	case kRecordOverride:
 		return new OverrideRecord(context);
+	case kRecordAnimOverlay:
+		return new AnimOverlayRecord(context);
 	default:
 		return nullptr;
 	}
@@ -88,6 +93,23 @@ void RecordGraphic::setScriptZ(double z, int32 streamZOffset) {
 	setZOrder((uint16)CLIP<int>(engineZ, 0, 0xFFFF));
 }
 
+// Where something that was placed in the viewport ends up once the transform
+// of its record has moved it.
+// TODO: Only the place is taken from the transform. Draw what it rotates and
+// scales as well.
+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;
+
+	double x = dest.left;
+	double y = dest.top;
+	transform.transformPoint(x, y);
+
+	const int left = CLIP<int>((int)x, -kLimit, kLimit);
+	const int top = CLIP<int>((int)y, -kLimit, kLimit);
+	return Common::Rect(left, top, left + width, top + height);
+}
+
 static Graphics::ManagedSurface *s_drawTarget = nullptr;
 
 Graphics::ManagedSurface *getDrawTarget() {
@@ -493,5 +515,310 @@ bool OverrideRecord::setField(const Common::String &key) {
 	return false;
 }
 
+// Overlay
+
+void OverlayRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	Common::String imageName;
+	if (!readField(L, tableIndex, "ovl", imageName)) {
+		luaL_error(L, "OverlayAR must be created with a ovl parameter");
+	}
+
+	if (!g_nancy->_resource->loadImage(Common::Path(imageName), _image)) {
+		luaL_error(L, "Could not load overlay %s", imageName.c_str());
+	}
+
+	// Both default to the whole image
+	_source = ScriptRect(0, 0, _image.w, _image.h);
+	if (pushField(L, tableIndex, "source")) {
+		_source = checkRect(L, -1);
+		lua_pop(L, 1);
+	}
+
+	_dest = _source;
+	if (pushField(L, tableIndex, "onScreen")) {
+		_dest = checkRect(L, -1);
+		lua_pop(L, 1);
+	}
+
+	_graphic.init();
+	_graphic.setVisible(false);
+	applyRects();
+	onZChanged();
+	_graphic.registerGraphics();
+}
+
+void OverlayRecord::applyRects() {
+	Common::Rect source = _source.toRect();
+	source.clip(Common::Rect(_image.w, _image.h));
+
+	// A script can leave the picture out until it knows what to show
+	_hasPicture = !source.isEmpty();
+	if (!_hasPicture) {
+		return;
+	}
+
+	_graphic._drawSurface.create(_image, source);
+	_graphic.setTransparent(true);
+	_graphic.moveTo(getTransformedDest(_dest, getTransform(), source.width(), source.height()));
+	_graphic.setNeedsRedraw(true);
+}
+
+void OverlayRecord::onZChanged() {
+	_graphic.setScriptZ(getZ(), getContext().getZOffset());
+}
+
+void OverlayRecord::updateGraphics() {
+	_graphic.setVisible(isActive() && _hasPicture);
+}
+
+bool OverlayRecord::getField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "onScreen") {
+		pushRect(L, _dest);
+	} else if (key == "source") {
+		pushRect(L, _source);
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
+bool OverlayRecord::setField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "onScreen") {
+		_dest = checkRect(L, 3);
+	} else if (key == "source") {
+		_source = checkRect(L, 3);
+	} else {
+		return false;
+	}
+
+	applyRects();
+	return true;
+}
+
+// AnimOverlay
+
+void AnimOverlayRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	readField(L, tableIndex, "firstFrame", _firstFrame);
+	readField(L, tableIndex, "lastFrame", _lastFrame);
+	readField(L, tableIndex, "fps", _framesPerSecond);
+	readField(L, tableIndex, "pauseOnLastFrame", _pausesOnLastFrame);
+	readField(L, tableIndex, "visible", _isVisible);
+
+	if (!pushField(L, tableIndex, "onScreen")) {
+		luaL_error(L, "AnimOverlays must have a onScreen field");
+	}
+
+	_dest = checkRect(L, -1);
+	lua_pop(L, 1);
+
+	// Either whether to loop forever, or how many times to play
+	if (pushField(L, tableIndex, "loop")) {
+		if (lua_isboolean(L, -1)) {
+			_numLoops = lua_toboolean(L, -1) ? 0 : 1;
+		} else if (lua_isnumber(L, -1)) {
+			_numLoops = (int)lua_tointeger(L, -1);
+		} else {
+			luaL_error(L, "loop must be either a boolean or a number");
+		}
+
+		lua_pop(L, 1);
+	}
+
+	if (!pushField(L, tableIndex, "sources")) {
+		luaL_error(L, "AnimOverlays must have a sources field");
+	}
+
+	luaL_checktype(L, -1, LUA_TTABLE);
+	for (int i = 1; ; ++i) {
+		lua_rawgeti(L, -1, i);
+		if (lua_isnil(L, -1)) {
+			lua_pop(L, 1);
+			break;
+		}
+
+		_sources.push_back(checkRect(L, -1));
+		lua_pop(L, 1);
+	}
+
+	lua_pop(L, 1);
+
+	if (_sources.empty()) {
+		luaL_error(L, "Field sources must be a table with Rects in it");
+	}
+
+	Common::String imageName;
+	if (!readField(L, tableIndex, "ovl", imageName)) {
+		luaL_error(L, "AnimOverlays must have a ovl field");
+	}
+
+	if (!g_nancy->_resource->loadImage(Common::Path(imageName), _image)) {
+		luaL_error(L, "Could not load overlay %s", imageName.c_str());
+	}
+
+	_graphic.init();
+	_graphic.setVisible(false);
+	setFrame(getFirstFrame());
+	onZChanged();
+	_graphic.registerGraphics();
+}
+
+void AnimOverlayRecord::setFrame(int frame) {
+	_frame = CLIP<int>(frame, 1, _sources.size());
+	applyFrame();
+}
+
+void AnimOverlayRecord::applyFrame() {
+	Common::Rect source = _sources[_frame - 1].toRect();
+	source.clip(Common::Rect(_image.w, _image.h));
+
+	_hasPicture = !source.isEmpty();
+	if (!_hasPicture) {
+		return;
+	}
+
+	_graphic._drawSurface.create(_image, source);
+	_graphic.setTransparent(true);
+	_graphic.moveTo(getTransformedDest(_dest, getTransform(), source.width(), source.height()));
+	_graphic.setNeedsRedraw(true);
+}
+
+// Starts over from the first frame
+void AnimOverlayRecord::beginLoop() {
+	_progress = 0.0;
+	setFrame(getFirstFrame());
+
+	const int numSteps = getLastFrame() - getFirstFrame();
+	_endTime = getContext().getTime() + (_framesPerSecond > 0.0 ? numSteps / _framesPerSecond : 0.0);
+}
+
+void AnimOverlayRecord::runOnce() {
+	_numLoopsDone = 0;
+	beginLoop();
+}
+
+void AnimOverlayRecord::run(double sceneTime, double elapsedTime) {
+	if (_isPaused) {
+		return;
+	}
+
+	const int numSteps = getLastFrame() - getFirstFrame();
+	if (numSteps > 0) {
+		_progress = CLIP(_progress + _framesPerSecond * elapsedTime / numSteps, 0.0, 1.0);
+		setFrame(getFirstFrame() + (int)floor(_progress * numSteps));
+	}
+
+	if (_endTime >= sceneTime) {
+		return;
+	}
+
+	++_numLoopsDone;
+	if (_numLoops != 0 && _numLoopsDone >= _numLoops) {
+		if (_pausesOnLastFrame) {
+			_isPaused = true;
+		} else {
+			finish();
+		}
+	} else {
+		beginLoop();
+	}
+}
+
+bool AnimOverlayRecord::prepareReset() {
+	setFrame(getFirstFrame());
+	_isPaused = false;
+	return true;
+}
+
+void AnimOverlayRecord::onZChanged() {
+	_graphic.setScriptZ(getZ(), getContext().getZOffset());
+}
+
+void AnimOverlayRecord::updateGraphics() {
+	_graphic.setVisible(isActive() && _isVisible && _hasPicture);
+}
+
+// anim:Play() and anim:Pause(), for the records that play something
+static int animOverlayPlay(lua_State *L) {
+	AnimOverlayRecord *record = dynamic_cast<AnimOverlayRecord *>(checkRecord(L, 1));
+	if (record) {
+		record->setPaused(false);
+	}
+
+	return 0;
+}
+
+static int animOverlayPause(lua_State *L) {
+	AnimOverlayRecord *record = dynamic_cast<AnimOverlayRecord *>(checkRecord(L, 1));
+	if (record) {
+		record->setPaused(true);
+	}
+
+	return 0;
+}
+
+bool AnimOverlayRecord::getField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "onScreen") {
+		pushRect(L, _dest);
+	} else if (key == "Play") {
+		lua_pushcfunction(L, animOverlayPlay);
+	} else if (key == "Pause") {
+		lua_pushcfunction(L, animOverlayPause);
+	} else if (key == "paused") {
+		lua_pushboolean(L, _isPaused);
+	} else if (key == "pauseOnLastFrame") {
+		lua_pushboolean(L, _pausesOnLastFrame);
+	} else if (key == "visible") {
+		lua_pushboolean(L, _isVisible);
+	} else if (key == "frame") {
+		lua_pushinteger(L, _frame);
+	} else if (key == "endTime") {
+		lua_pushnumber(L, _endTime);
+	} else if (key == "firstFrame") {
+		lua_pushinteger(L, getFirstFrame());
+	} else if (key == "lastFrame") {
+		lua_pushinteger(L, getLastFrame());
+	} else if (key == "frames") {
+		lua_pushinteger(L, _sources.size());
+	} else if (key == "fps") {
+		lua_pushnumber(L, _framesPerSecond);
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
+bool AnimOverlayRecord::setField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "onScreen") {
+		_dest = checkRect(L, 3);
+		applyFrame();
+	} else if (key == "pauseOnLastFrame") {
+		_pausesOnLastFrame = lua_toboolean(L, 3) != 0;
+	} else if (key == "visible") {
+		_isVisible = lua_toboolean(L, 3) != 0;
+	} else if (key == "frame") {
+		setFrame(luaL_checkint(L, 3));
+	} else if (key == "fps") {
+		_framesPerSecond = luaL_checknumber(L, 3);
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
 } // End of namespace Script
 } // End of namespace Nancy
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 7dca67286d2..edb65767691 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -184,6 +184,79 @@ private:
 	RecordGraphic *_canvas = nullptr;
 };
 
+// Shows a part of an image
+class OverlayRecord : public Record {
+public:
+	OverlayRecord(SceneContext &context) : Record(context, "ND.Overlay") {}
+
+	void updateGraphics() override;
+
+protected:
+	void readTable(int tableIndex) override;
+	void onZChanged() override;
+	bool hasTransform() const override { return true; }
+	void onTransformChanged() override { applyRects(); }
+	bool getField(const Common::String &key) override;
+	bool setField(const Common::String &key) override;
+
+private:
+	void applyRects();
+
+	Graphics::ManagedSurface _image;
+	ScriptRect _source;
+	ScriptRect _dest;
+	bool _hasPicture = false;
+	RecordGraphic _graphic;
+};
+
+// Shows the parts of an image one after the other
+class AnimOverlayRecord : public Record {
+public:
+	AnimOverlayRecord(SceneContext &context) : Record(context, "ND.AnimOvlAR") {}
+
+	void updateGraphics() override;
+
+	void setPaused(bool isPaused) { _isPaused = isPaused; }
+
+protected:
+	void readTable(int tableIndex) override;
+	void runOnce() override;
+	void run(double sceneTime, double elapsedTime) override;
+	void onZChanged() override;
+	bool prepareReset() override;
+	bool hasTransform() const override { return true; }
+	void onTransformChanged() override { applyFrame(); }
+	bool getField(const Common::String &key) override;
+	bool setField(const Common::String &key) override;
+
+private:
+	// Frames are counted from 1
+	int getFirstFrame() const { return _firstFrame > 0 ? _firstFrame : 1; }
+	int getLastFrame() const { return _lastFrame > 0 ? _lastFrame : (int)_sources.size(); }
+	void setFrame(int frame);
+	void applyFrame();
+	void beginLoop();
+
+	Graphics::ManagedSurface _image;
+	Common::Array<ScriptRect> _sources;
+	ScriptRect _dest;
+	bool _hasPicture = false;
+	RecordGraphic _graphic;
+
+	int _firstFrame = -1;
+	int _lastFrame = -1;
+	int _frame = 1;
+	double _framesPerSecond = 15.0;
+	double _progress = 0.0;	// through the frames, from 0 to 1
+	double _endTime = 0.0;
+
+	int _numLoops = 1;		// 0 loops forever
+	int _numLoopsDone = 0;
+	bool _pausesOnLastFrame = false;
+	bool _isPaused = false;
+	bool _isVisible = true;
+};
+
 } // End of namespace Script
 } // End of namespace Nancy
 


Commit: 03c1b26755e717e2d65210706df8a7e50e760115
    https://github.com/scummvm/scummvm/commit/03c1b26755e717e2d65210706df8a7e50e760115
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:17+03:00

Commit Message:
NANCY: NANCY19: Mark what the scripts can't use yet with TODOs

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


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 4e8e98e2703..db9a81cbbd0 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -339,6 +339,7 @@ static const luaL_Reg kKeyFunctions[] = {
 };
 
 // Game
+// TODO: Game.time
 
 // The two levels the game can be played at
 static const uint kDifficultyJunior = 0;
@@ -382,6 +383,7 @@ static const luaL_Reg kGameFunctions[] = {
 };
 
 // Draw, for the "Render" functions of the records
+// TODO: Draw:Tri
 
 // Draw:Rect(rect, color)
 static int drawRect(lua_State *L) {
@@ -567,6 +569,7 @@ static const luaL_Reg kSceneFunctions[] = {
 };
 
 // Player, who is the character being played unless a function is told otherwise
+// TODO: AddTask, LoadTaskFlag, AddJournalEntry and AddCellPhoneNumber
 
 // The number of a character, or -1 when there is none of that name
 static int findCharacter(const char *name) {
@@ -680,6 +683,7 @@ static const luaL_Reg kLogFunctions[] = {
 	{ nullptr, nullptr }
 };
 
+// TODO: The Autotext, Streams, Generics and UIMgr objects, and Log:WriteAll
 void registerGameAPI(lua_State *L) {
 	registerGlobal(L, "Flags", "ND.Flags", kFlagsFunctions);
 	registerGlobal(L, "VarTable", "ND.VarTable", kVarTableFunctions);
diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index b487651c8e4..6d27f084624 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -262,6 +262,7 @@ 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"
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index 5a4c1403664..c9a9850c50a 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -78,6 +78,7 @@ Record *makeRecord(RecordKind kind, SceneContext &context) {
 	case kRecordAnimOverlay:
 		return new AnimOverlayRecord(context);
 	default:
+		// TODO: The Text and Particle records
 		return nullptr;
 	}
 }
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index edb65767691..07e979b9e2e 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -82,6 +82,7 @@ protected:
 // The first record of every scene, which names the area the scene belongs to
 // and its background. A background with more than one frame is a node the
 // player can turn around in.
+// TODO: Handle the loopTheme and modColor fields.
 class SummaryRecord : public Record {
 public:
 	SummaryRecord(SceneContext &context) : Record(context, "ND.Summary") {}
@@ -162,6 +163,8 @@ private:
 };
 
 // A record that only does what the functions a script gave it do
+// TODO: Call RenderAttachment, OnSave, OnLoad and ReceiveMessage, and handle
+// messagingName and Send.
 class OverrideRecord : public Record {
 public:
 	OverrideRecord(SceneContext &context) : Record(context, "ND.OverrideAR") {}
@@ -185,6 +188,7 @@ private:
 };
 
 // Shows a part of an image
+// TODO: Handle the modColor field.
 class OverlayRecord : public Record {
 public:
 	OverlayRecord(SceneContext &context) : Record(context, "ND.Overlay") {}
@@ -210,6 +214,7 @@ private:
 };
 
 // Shows the parts of an image one after the other
+// TODO: Handle the modColor field.
 class AnimOverlayRecord : public Record {
 public:
 	AnimOverlayRecord(SceneContext &context) : Record(context, "ND.AnimOvlAR") {}


Commit: 2321ae5a1e9221e5451e6a816769c860948a46f5
    https://github.com/scummvm/scummvm/commit/2321ae5a1e9221e5451e6a816769c860948a46f5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:18+03:00

Commit Message:
NANCY: NANCY19: Implement the Sound record

Plays one of its sounds, picked at random, on a channel. The record
is done once the sound has played through, or when something else
takes over its channel.

Adds the SoundChannel object, which names the channels and the
groups of them, and the Sound object, which stops channels and sets
their volume.

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


diff --git a/engines/nancy/script/api.h b/engines/nancy/script/api.h
index 0f1ac87db3e..3424afbd18b 100644
--- a/engines/nancy/script/api.h
+++ b/engines/nancy/script/api.h
@@ -84,6 +84,10 @@ void pushCursor(lua_State *L, uint cursorType, bool isHot);
 void pushKey(lua_State *L, Common::KeyCode key);
 Common::KeyCode checkKey(lua_State *L, int index);
 
+// Sound channels, as picked with "SoundChannel.<name>"
+void pushSoundChannel(lua_State *L, uint16 channel);
+uint16 checkSoundChannel(lua_State *L, int index);
+
 } // End of namespace Script
 } // End of namespace Nancy
 
diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index db9a81cbbd0..048e324041a 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -26,6 +26,7 @@
 #include "engines/nancy/input.h"
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/puzzledata.h"
+#include "engines/nancy/sound.h"
 
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/luabind.h"
@@ -410,6 +411,133 @@ static const luaL_Reg kDrawFunctions[] = {
 	{ nullptr, nullptr }
 };
 
+// SoundChannel.<name> and Sound
+
+static const char *const kSoundChannelType = "ND.SoundChannel";
+
+enum SoundChannelID {
+	kChannelFirstMusic		= 0,	// Theme
+	kChannelFirstAmbient	= 6,
+	kChannelFirstFX			= 14,
+	kChannelFirstVoice		= 24,
+	kNumSoundChannels		= 30,
+
+	// These stand for every channel of a kind
+	kChannelAll				= 0x21,
+	kChannelAllMusic		= 0x22,
+	kChannelAllFX			= 0x23,
+	kChannelAllAmbient		= 0x24,
+	kChannelAllVoice		= 0x25
+};
+
+struct SoundChannelName {
+	const char *name;
+	uint16 channel;
+};
+
+static const SoundChannelName kSoundChannelNames[] = {
+	{ "Theme", 0 }, { "Music1", 1 }, { "Music2", 2 }, { "Music3", 3 }, { "UIMusic", 4 }, { "EngineMusic", 5 },
+	{ "Ambient1", 6 }, { "Ambient2", 7 }, { "Ambient3", 8 }, { "Ambient4", 9 },
+	{ "Ambient5", 10 }, { "Ambient6", 11 }, { "Ambient7", 12 }, { "Ambient8", 13 },
+	{ "FX1", 14 }, { "FX2", 15 }, { "FX3", 16 }, { "FX4", 17 }, { "FX5", 18 }, { "FX6", 19 }, { "FX7", 20 }, { "FX8", 21 },
+	{ "UIFX", 22 }, { "EngineFX", 23 },
+	{ "NPCVoice", 24 }, { "PlayerVoice", 25 }, { "Voice1", 26 }, { "Voice2", 27 }, { "UIVoice", 28 }, { "EngineVoice", 29 },
+	{ "All", kChannelAll }, { "AllMusic", kChannelAllMusic }, { "AllFX", kChannelAllFX },
+	{ "AllAmbient", kChannelAllAmbient }, { "AllVoice", kChannelAllVoice }
+};
+
+void pushSoundChannel(lua_State *L, uint16 channel) {
+	pushValue(L, kSoundChannelType, channel);
+}
+
+uint16 checkSoundChannel(lua_State *L, int index) {
+	return *(const uint16 *)checkUserData(L, index, kSoundChannelType);
+}
+
+static int soundChannelIndex(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	for (const SoundChannelName &channel : kSoundChannelNames) {
+		if (scumm_stricmp(channel.name, name) == 0) {
+			pushSoundChannel(L, channel.channel);
+			return 1;
+		}
+	}
+
+	lua_pushnil(L);
+	return 1;
+}
+
+// The channels a channel object stands for
+static void getChannelRange(uint16 channel, uint16 &first, uint16 &last) {
+	switch (channel) {
+	case kChannelAll:
+		first = 0;
+		last = kNumSoundChannels - 1;
+		break;
+	case kChannelAllMusic:
+		first = kChannelFirstMusic;
+		last = kChannelFirstAmbient - 1;
+		break;
+	case kChannelAllAmbient:
+		first = kChannelFirstAmbient;
+		last = kChannelFirstFX - 1;
+		break;
+	case kChannelAllFX:
+		first = kChannelFirstFX;
+		last = kChannelFirstVoice - 1;
+		break;
+	case kChannelAllVoice:
+		first = kChannelFirstVoice;
+		last = kNumSoundChannels - 1;
+		break;
+	default:
+		first = last = channel;
+		break;
+	}
+}
+
+// Sound:Stop(channel)
+static int soundStop(lua_State *L) {
+	uint16 first, last;
+	getChannelRange(checkSoundChannel(L, 2), first, last);
+
+	for (uint16 channel = first; channel <= last; ++channel) {
+		g_nancy->_sound->stopSound(channel);
+	}
+
+	return 0;
+}
+
+// Sound:SetVolume(channel, volume), with the volume going from 0 to 1
+static int soundSetVolume(lua_State *L) {
+	uint16 first, last;
+	getChannelRange(checkSoundChannel(L, 2), first, last);
+	const uint16 volume = (uint16)CLIP<int>((int)(luaL_checknumber(L, 3) * 100.0), 0, 100);
+
+	for (uint16 channel = first; channel <= last; ++channel) {
+		g_nancy->_sound->setVolume(channel, volume);
+	}
+
+	return 0;
+}
+
+static const luaL_Reg kSoundChannelHelperFunctions[] = {
+	{ "__index", soundChannelIndex },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kSoundChannelFunctions[] = {
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kSoundFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "Stop", soundStop },
+	{ "SetVolume", soundSetVolume },
+	{ nullptr, nullptr }
+};
+
 // Scene, which is the scene the script belongs to
 
 static SceneContext *checkContext(lua_State *L) {
@@ -697,6 +825,10 @@ void registerGameAPI(lua_State *L) {
 	registerType(L, kCursorIndexType, kCursorIndexFunctions);
 	registerGlobal(L, "Cursor", "ND.Cursor", kCursorFunctions);
 
+	registerType(L, kSoundChannelType, kSoundChannelFunctions);
+	registerGlobal(L, "SoundChannel", "ND.SoundHelper", kSoundChannelHelperFunctions);
+	registerGlobal(L, "Sound", "ND.Sound", kSoundFunctions);
+
 	registerGlobal(L, "Scene", "ND.Scene", kSceneFunctions);
 	registerGlobal(L, "Player", "ND.CurPlayer", kPlayerFunctions);
 	registerGlobal(L, "Viewport", "ND.Viewport", kViewportFunctions);
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index c9a9850c50a..c106d785386 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "common/random.h"
 #include "common/textconsole.h"
 
 #include "engines/nancy/cursor.h"
@@ -26,6 +27,7 @@
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
 
 #include "engines/nancy/script/api.h"
 #include "engines/nancy/script/luabind.h"
@@ -73,6 +75,8 @@ Record *makeRecord(RecordKind kind, SceneContext &context) {
 		return new SummaryRecord(context);
 	case kRecordOverlay:
 		return new OverlayRecord(context);
+	case kRecordSound:
+		return new SoundRecord(context);
 	case kRecordOverride:
 		return new OverrideRecord(context);
 	case kRecordAnimOverlay:
@@ -821,5 +825,84 @@ bool AnimOverlayRecord::setField(const Common::String &key) {
 	return true;
 }
 
+// Sound
+
+void SoundRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	if (pushField(L, tableIndex, "channel")) {
+		_channel = checkSoundChannel(L, -1);
+		lua_pop(L, 1);
+	}
+
+	readField(L, tableIndex, "volume", _volume);
+	readField(L, tableIndex, "loop", _isLooping);
+
+	if (pushField(L, tableIndex, "sounds")) {
+		readStringList(L, -1, _names);
+		lua_pop(L, 1);
+	}
+
+	if (_names.empty()) {
+		luaL_error(L, "sounds must have one or more fields in its table");
+	}
+}
+
+void SoundRecord::runOnce() {
+	_sound = SoundDescription();
+	_sound.name = _names[g_nancy->_randomSource->getRandomNumber(_names.size() - 1)];
+	_sound.channelID = _channel;
+	_sound.numLoops = _isLooping ? 0 : 1;
+	_sound.volume = (uint16)CLIP<int>((int)(_volume * 100.0), 0, 100);
+
+	g_nancy->_sound->loadSound(_sound);
+	g_nancy->_sound->playSound(_sound);
+
+	_isPlaying = g_nancy->_sound->isSoundPlaying(_sound);
+	_wasPreempted = false;
+
+	// The record is done once the sound has played through, whether it loops or not
+	const double length = _isPlaying ? g_nancy->_sound->getLength(_sound).msecs() / 1000.0 : 0.0;
+	_endTime = getContext().getTime() + length;
+}
+
+void SoundRecord::run(double sceneTime, double elapsedTime) {
+	// Something else taking over the channel ends the record early
+	if (_isPlaying && !_isLooping && !g_nancy->_sound->isSoundPlaying(_sound) && sceneTime < _endTime) {
+		_wasPreempted = true;
+	}
+
+	if (_endTime <= sceneTime || _wasPreempted) {
+		finish();
+	}
+}
+
+bool SoundRecord::prepareReset() {
+	_endTime = 0.0;
+	_wasPreempted = false;
+	_isPlaying = false;
+	return true;
+}
+
+bool SoundRecord::getField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "channel") {
+		pushSoundChannel(L, _channel);
+	} else if (key == "volume") {
+		lua_pushnumber(L, _volume);
+	} else if (key == "loop") {
+		lua_pushboolean(L, _isLooping);
+	} else if (key == "endTime") {
+		lua_pushnumber(L, _endTime);
+	} else if (key == "preempted") {
+		lua_pushboolean(L, _wasPreempted);
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
 } // End of namespace Script
 } // End of namespace Nancy
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 07e979b9e2e..9b9ac4efe02 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -262,6 +262,30 @@ private:
 	bool _isVisible = true;
 };
 
+// Plays a sound, picked at random when it has several
+class SoundRecord : public Record {
+public:
+	SoundRecord(SceneContext &context) : Record(context, "ND.SoundAR") {}
+
+protected:
+	void readTable(int tableIndex) override;
+	void runOnce() override;
+	void run(double sceneTime, double elapsedTime) override;
+	bool prepareReset() override;
+	bool getField(const Common::String &key) override;
+
+private:
+	Common::Array<Common::String> _names;
+	uint16 _channel = 14;
+	double _volume = 1.0;
+	bool _isLooping = false;
+
+	SoundDescription _sound;
+	bool _isPlaying = false;
+	bool _wasPreempted = false;
+	double _endTime = 0.0;
+};
+
 } // End of namespace Script
 } // End of namespace Nancy
 


Commit: ce80cfe38280dfb976baf7b492a410f4cb1a2f86
    https://github.com/scummvm/scummvm/commit/ce80cfe38280dfb976baf7b492a410f4cb1a2f86
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:20+03:00

Commit Message:
NANCY: NANCY19: Implement the Movie record

Plays a range of the frames of a movie in the viewport, and is done
when it gets to the last one, unless it is told to stay on it.
Scripts read the frame a movie is at to time what goes with it, and
can pause it, hide it and send it to a frame.

TODO: The source, volume, loop and modColor fields.

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 c106d785386..a3299dc9d2f 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -75,6 +75,8 @@ Record *makeRecord(RecordKind kind, SceneContext &context) {
 		return new SummaryRecord(context);
 	case kRecordOverlay:
 		return new OverlayRecord(context);
+	case kRecordMovie:
+		return new MovieRecord(context);
 	case kRecordSound:
 		return new SoundRecord(context);
 	case kRecordOverride:
@@ -904,5 +906,159 @@ bool SoundRecord::getField(const Common::String &key) {
 	return true;
 }
 
+// Movie
+
+void MovieRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	if (!readField(L, tableIndex, "movie", _name)) {
+		luaL_error(L, "MovieAR must be created with a movie parameter");
+	}
+
+	if (!pushField(L, tableIndex, "onScreen")) {
+		luaL_error(L, "MovieAR must be created with an 'onScreen' parameter");
+	}
+
+	_dest = checkRect(L, -1);
+	lua_pop(L, 1);
+
+	if (!_player.loadFile(Common::Path(_name))) {
+		luaL_error(L, "Could not load movie %s", _name.c_str());
+	}
+
+	_firstFrame = 0;
+	_lastFrame = _player.getFrameCount() - 1;
+	readField(L, tableIndex, "firstFrame", _firstFrame);
+	readField(L, tableIndex, "lastFrame", _lastFrame);
+	readField(L, tableIndex, "pauseOnLastFrame", _pausesOnLastFrame);
+	readField(L, tableIndex, "visible", _isVisible);
+
+	_graphic.init();
+	_graphic._drawSurface.create(_player.getWidth(), _player.getHeight(), g_nancy->_graphics->getScreenPixelFormat());
+	_graphic.setVisible(false);
+	applyPosition();
+	onZChanged();
+	_graphic.registerGraphics();
+
+	_player.goToFrame(_firstFrame);
+	drawFrame();
+}
+
+void MovieRecord::applyPosition() {
+	_graphic.moveTo(getTransformedDest(_dest, getTransform(), _player.getWidth(), _player.getHeight()));
+}
+
+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);
+}
+
+void MovieRecord::runOnce() {
+	_player.playRange(_firstFrame, _lastFrame);
+	drawFrame();
+}
+
+void MovieRecord::run(double sceneTime, double elapsedTime) {
+	if (!_isPaused && _player.update()) {
+		drawFrame();
+	}
+
+	if (!_player.isRangePlaying() && !_pausesOnLastFrame) {
+		_endTime = sceneTime;
+		finish();
+	}
+}
+
+bool MovieRecord::prepareReset() {
+	_endTime = 0.0;
+	_isPaused = false;
+	_player.goToFrame(_firstFrame);
+	drawFrame();
+	return true;
+}
+
+void MovieRecord::onZChanged() {
+	_graphic.setScriptZ(getZ(), getContext().getZOffset());
+}
+
+void MovieRecord::updateGraphics() {
+	_graphic.setVisible(isActive() && _isVisible);
+}
+
+// movie:Play() and movie:Pause()
+static int moviePlay(lua_State *L) {
+	MovieRecord *record = dynamic_cast<MovieRecord *>(checkRecord(L, 1));
+	if (record) {
+		record->setPaused(false);
+	}
+
+	return 0;
+}
+
+static int moviePause(lua_State *L) {
+	MovieRecord *record = dynamic_cast<MovieRecord *>(checkRecord(L, 1));
+	if (record) {
+		record->setPaused(true);
+	}
+
+	return 0;
+}
+
+bool MovieRecord::getField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "movie") {
+		lua_pushstring(L, _name.c_str());
+	} else if (key == "onScreen") {
+		pushRect(L, _dest);
+	} else if (key == "Play") {
+		lua_pushcfunction(L, moviePlay);
+	} else if (key == "Pause") {
+		lua_pushcfunction(L, moviePause);
+	} else if (key == "paused") {
+		lua_pushboolean(L, _isPaused || !_player.isRangePlaying());
+	} else if (key == "pauseOnLastFrame") {
+		lua_pushboolean(L, _pausesOnLastFrame);
+	} else if (key == "visible") {
+		lua_pushboolean(L, _isVisible);
+	} else if (key == "frame") {
+		lua_pushinteger(L, _player.getCurrentFrame());
+	} else if (key == "endTime") {
+		lua_pushnumber(L, _endTime);
+	} else if (key == "firstFrame") {
+		lua_pushinteger(L, _firstFrame);
+	} else if (key == "lastFrame") {
+		lua_pushinteger(L, _lastFrame);
+	} else if (key == "frames") {
+		lua_pushinteger(L, _player.getFrameCount());
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
+bool MovieRecord::setField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "onScreen") {
+		_dest = checkRect(L, 3);
+		applyPosition();
+	} else if (key == "pauseOnLastFrame") {
+		_pausesOnLastFrame = lua_toboolean(L, 3) != 0;
+	} else if (key == "visible") {
+		_isVisible = lua_toboolean(L, 3) != 0;
+	} else if (key == "frame") {
+		_player.goToFrame(luaL_checkint(L, 3));
+		drawFrame();
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
 } // End of namespace Script
 } // End of namespace Nancy
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 9b9ac4efe02..20c395f8c9f 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -26,6 +26,7 @@
 #include "common/rect.h"
 
 #include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
 #include "engines/nancy/renderobject.h"
 
 #include "engines/nancy/script/record.h"
@@ -286,6 +287,44 @@ private:
 	double _endTime = 0.0;
 };
 
+// Plays a movie in the viewport
+// TODO: Handle the source, volume, loop and modColor fields.
+class MovieRecord : public Record {
+public:
+	MovieRecord(SceneContext &context) : Record(context, "ND.Movie") {}
+
+	void updateGraphics() override;
+
+	void setPaused(bool isPaused) { _isPaused = isPaused; }
+
+protected:
+	void readTable(int tableIndex) override;
+	void runOnce() override;
+	void run(double sceneTime, double elapsedTime) override;
+	void onZChanged() override;
+	bool prepareReset() override;
+	bool hasTransform() const override { return true; }
+	void onTransformChanged() override { applyPosition(); }
+	bool getField(const Common::String &key) override;
+	bool setField(const Common::String &key) override;
+
+private:
+	void drawFrame();
+	void applyPosition();
+
+	Common::String _name;
+	MoviePlayer _player;
+	RecordGraphic _graphic;
+	ScriptRect _dest;
+
+	int _firstFrame = 0;
+	int _lastFrame = 0;
+	bool _pausesOnLastFrame = false;
+	bool _isPaused = false;
+	bool _isVisible = true;
+	double _endTime = 0.0;
+};
+
 } // End of namespace Script
 } // End of namespace Nancy
 


Commit: de16b8476e4ec3666bfc37074ffa60b6f559ad5b
    https://github.com/scummvm/scummvm/commit/de16b8476e4ec3666bfc37074ffa60b6f559ad5b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:22+03:00

Commit Message:
NANCY: NANCY19: Implement Ref images, and saving the data of scripts

A Ref is an image a script looks things up in instead of showing it:
the colors of its pixels say what is where. The map the player walks
around on uses them to tell where one can go.

Save:Data and Load:Data keep a number, a text or a table of those
under a name, which goes into the saved game. Save:Game saves the
game under a name.

Changed paths:
  A engines/nancy/script/ref.cpp
    engines/nancy/module.mk
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h
    engines/nancy/script/api.cpp
    engines/nancy/script/api.h
    engines/nancy/script/gameapi.cpp


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 7aef7ebdb21..32660fc41b1 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -193,6 +193,7 @@ MODULE_OBJS += \
   script/mathtypes.o \
   script/record.o \
   script/records.o \
+  script/ref.o \
   script/scenecontext.o \
   script/types.o
 endif
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 3077dfd1a55..0a83cd0775b 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -998,6 +998,31 @@ void ScriptValueData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void ScriptSavedData::synchronize(Common::Serializer &ser) {
+	uint16 numValues = values.size();
+	ser.syncAsUint16LE(numValues);
+
+	Common::Array<Common::String> names;
+	if (ser.isSaving()) {
+		for (auto &entry : values) {
+			names.push_back(entry._key);
+		}
+	} else {
+		values.clear();
+		names.resize(numValues);
+	}
+
+	for (Common::String &name : names) {
+		ser.syncString(name);
+
+		Common::Array<byte> &value = values[name];
+		uint32 size = value.size();
+		ser.syncAsUint32LE(size);
+		value.resize(size);
+		ser.syncBytes(value.data(), size);
+	}
+}
+
 void PhoneBookData::synchronize(Common::Serializer &ser) {
 	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
 		uint16 numEntries = entries[i].size();
@@ -1021,6 +1046,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new SavedStateData();
 	case ScriptValueData::getTag():
 		return new ScriptValueData();
+	case ScriptSavedData::getTag():
+		return new ScriptSavedData();
 	case StreamData::getTag():
 		return new StreamData();
 	case BuildBoardData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 04f96a340cd..a5ebc47a3cc 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -691,6 +691,15 @@ struct ScriptValueData : public PuzzleData {
 	Common::HashMap<Common::String, int32, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> values;
 };
 
+// Nancy19. What the scripts ask to have kept under a name: a number, a text,
+// or a table of those, already packed into bytes
+struct ScriptSavedData : public PuzzleData {
+	static constexpr uint32 getTag() { return MKTAG('S', 'C', 'S', 'D'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> values;
+};
+
 // Nancy17. The contacts in the cell phone of every player character
 struct PhoneBookData : public PuzzleData {
 	struct Entry {
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
index 17e47eea833..3fa86f9b6e9 100644
--- a/engines/nancy/script/api.cpp
+++ b/engines/nancy/script/api.cpp
@@ -76,6 +76,7 @@ void registerInventoryAPI(lua_State *L) {
 void registerAPI(lua_State *L) {
 	registerTypes(L);
 	registerMathTypes(L);
+	registerRefAPI(L);
 	registerBootAPI(L);
 	registerInventoryAPI(L);
 	registerGameAPI(L);
diff --git a/engines/nancy/script/api.h b/engines/nancy/script/api.h
index 3424afbd18b..67e185ea067 100644
--- a/engines/nancy/script/api.h
+++ b/engines/nancy/script/api.h
@@ -84,6 +84,9 @@ void pushCursor(lua_State *L, uint cursorType, bool isHot);
 void pushKey(lua_State *L, Common::KeyCode key);
 Common::KeyCode checkKey(lua_State *L, int index);
 
+// The images scripts look colors up in
+void registerRefAPI(lua_State *L);
+
 // Sound channels, as picked with "SoundChannel.<name>"
 void pushSoundChannel(lua_State *L, uint16 channel);
 uint16 checkSoundChannel(lua_State *L, int index);
diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 048e324041a..0895a77900c 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -38,6 +38,8 @@
 
 #include "engines/nancy/state/scene.h"
 
+#include "engines/nancy/ui/ndui/manager.h"
+
 namespace Nancy {
 namespace Script {
 
@@ -411,6 +413,202 @@ static const luaL_Reg kDrawFunctions[] = {
 	{ nullptr, nullptr }
 };
 
+// Save and Load, which keep what a script hands them in the saved game
+
+enum SavedValueTag {
+	kSavedNil		= 0,
+	kSavedFalse		= 1,
+	kSavedTrue		= 2,
+	kSavedNumber	= 3,
+	kSavedString	= 4,
+	kSavedTable		= 5
+};
+
+static void writeSavedUint32(Common::Array<byte> &out, uint32 value) {
+	for (int i = 0; i < 4; ++i) {
+		out.push_back((byte)(value >> (8 * i)));
+	}
+}
+
+// Packs the value at a stack index. Returns false for what can't be kept.
+static bool writeSavedValue(lua_State *L, int index, Common::Array<byte> &out, int depth) {
+	index = absoluteIndex(L, index);
+
+	switch (lua_type(L, index)) {
+	case LUA_TNIL:
+		out.push_back(kSavedNil);
+		return true;
+	case LUA_TBOOLEAN:
+		out.push_back(lua_toboolean(L, index) ? kSavedTrue : kSavedFalse);
+		return true;
+	case LUA_TNUMBER: {
+		const double number = lua_tonumber(L, index);
+		uint64 bits;
+		memcpy(&bits, &number, sizeof(bits));
+
+		out.push_back(kSavedNumber);
+		writeSavedUint32(out, (uint32)bits);
+		writeSavedUint32(out, (uint32)(bits >> 32));
+		return true;
+	}
+	case LUA_TSTRING: {
+		size_t length;
+		const char *text = lua_tolstring(L, index, &length);
+
+		out.push_back(kSavedString);
+		writeSavedUint32(out, (uint32)length);
+		for (size_t i = 0; i < length; ++i) {
+			out.push_back((byte)text[i]);
+		}
+
+		return true;
+	}
+	case LUA_TTABLE: {
+		// Tables that hold themselves would never end
+		if (depth > 16) {
+			return false;
+		}
+
+		Common::Array<byte> pairs;
+		uint32 numPairs = 0;
+		bool isValid = true;
+
+		lua_pushnil(L);
+		while (lua_next(L, index)) {
+			// Copies the key, so reading a number as a string can't confuse lua_next
+			lua_pushvalue(L, -2);
+			isValid = isValid && writeSavedValue(L, -1, pairs, depth + 1) && writeSavedValue(L, -2, pairs, depth + 1);
+			lua_pop(L, 2);
+			++numPairs;
+		}
+
+		if (!isValid) {
+			return false;
+		}
+
+		out.push_back(kSavedTable);
+		writeSavedUint32(out, numPairs);
+		out.push_back(pairs);
+		return true;
+	}
+	default:
+		return false;
+	}
+}
+
+static uint32 readSavedUint32(const Common::Array<byte> &in, uint &pos) {
+	uint32 value = 0;
+	for (int i = 0; i < 4 && pos < in.size(); ++i) {
+		value |= (uint32)in[pos++] << (8 * i);
+	}
+
+	return value;
+}
+
+// Pushes the value packed at a position
+static void readSavedValue(lua_State *L, const Common::Array<byte> &in, uint &pos) {
+	const byte tag = pos < in.size() ? in[pos++] : (byte)kSavedNil;
+
+	switch (tag) {
+	case kSavedFalse:
+		lua_pushboolean(L, false);
+		break;
+	case kSavedTrue:
+		lua_pushboolean(L, true);
+		break;
+	case kSavedNumber: {
+		uint64 bits = readSavedUint32(in, pos);
+		bits |= (uint64)readSavedUint32(in, pos) << 32;
+
+		double number;
+		memcpy(&number, &bits, sizeof(number));
+		lua_pushnumber(L, number);
+		break;
+	}
+	case kSavedString: {
+		const uint32 length = MIN<uint32>(readSavedUint32(in, pos), in.size() - pos);
+		lua_pushlstring(L, (const char *)in.data() + pos, length);
+		pos += length;
+		break;
+	}
+	case kSavedTable: {
+		const uint32 numPairs = readSavedUint32(in, pos);
+		lua_newtable(L);
+		luaL_checkstack(L, 4, "saved data is nested too deeply");
+
+		for (uint32 i = 0; i < numPairs && pos < in.size(); ++i) {
+			readSavedValue(L, in, pos);
+			readSavedValue(L, in, pos);
+			if (lua_isnil(L, -2)) {
+				lua_pop(L, 2);
+			} else {
+				lua_rawset(L, -3);
+			}
+		}
+
+		break;
+	}
+	default:
+		lua_pushnil(L);
+		break;
+	}
+}
+
+// Save:Data(name, value)
+static int saveData(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+	luaL_checkany(L, 3);
+
+	Common::Array<byte> packed;
+	if (!writeSavedValue(L, 3, packed, 0)) {
+		return luaL_error(L, "Save:Data can only save tables, numbers, booleans, strings and nils");
+	}
+
+	ScriptSavedData *saved = (ScriptSavedData *)NancySceneState.getPuzzleData(ScriptSavedData::getTag());
+	saved->values.setVal(name, packed);
+	return 0;
+}
+
+// Load:Data(name), which gives nil for a name nothing was saved under
+static int loadData(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	ScriptSavedData *saved = (ScriptSavedData *)NancySceneState.getPuzzleData(ScriptSavedData::getTag());
+	if (!saved->values.contains(name)) {
+		lua_pushnil(L);
+		return 1;
+	}
+
+	uint pos = 0;
+	readSavedValue(L, saved->values[name], pos);
+	return 1;
+}
+
+// Save:Game(name), which saves the game under a name of the scripts' choosing
+static int saveGame(lua_State *L) {
+	const char *name = luaL_checkstring(L, 2);
+
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	if (ui) {
+		ui->requestSave(name);
+	}
+
+	return 0;
+}
+
+static const luaL_Reg kSaveFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "Data", saveData },
+	{ "Game", saveGame },
+	{ nullptr, nullptr }
+};
+
+static const luaL_Reg kLoadFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "Data", loadData },
+	{ nullptr, nullptr }
+};
+
 // SoundChannel.<name> and Sound
 
 static const char *const kSoundChannelType = "ND.SoundChannel";
@@ -838,6 +1036,8 @@ void registerGameAPI(lua_State *L) {
 
 	registerGlobal(L, "Game", "ND.Game", kGameFunctions);
 	registerGlobal(L, "Draw", "ND.Draw", kDrawFunctions);
+	registerGlobal(L, "Save", "ND.Save", kSaveFunctions);
+	registerGlobal(L, "Load", "ND.Load", kLoadFunctions);
 	registerGlobal(L, "Log", "ND.Log", kLogFunctions);
 }
 
diff --git a/engines/nancy/script/ref.cpp b/engines/nancy/script/ref.cpp
new file mode 100644
index 00000000000..aa3e456dc38
--- /dev/null
+++ b/engines/nancy/script/ref.cpp
@@ -0,0 +1,213 @@
+/* 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 "graphics/managed_surface.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/resource.h"
+
+#include "engines/nancy/script/api.h"
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/types.h"
+
+namespace Nancy {
+namespace Script {
+
+// A "Ref" is an image scripts look things up in instead of showing it: the
+// colors of its pixels say what is where. The map the player walks around on
+// has one for every part of it, painted in the colors of where one can go.
+
+static const char *const kRefType = "ND.Ref";
+
+// What to do about a place that is outside of the image
+enum RefEdgeMode {
+	kRefClamp	= 0,	// take the nearest pixel
+	kRefWrap	= 1,	// start over at the other side
+	kRefDismiss	= 2		// there is nothing there
+};
+
+struct RefImage {
+	Graphics::ManagedSurface image;
+	RefEdgeMode edgeMode = kRefClamp;
+
+	// Returns false when there is no pixel at that place
+	bool getPixel(int x, int y, uint32 &argb) const;
+};
+
+bool RefImage::getPixel(int x, int y, uint32 &argb) const {
+	if (image.w == 0 || image.h == 0) {
+		return false;
+	}
+
+	switch (edgeMode) {
+	case kRefClamp:
+		x = CLIP<int>(x, 0, image.w - 1);
+		y = CLIP<int>(y, 0, image.h - 1);
+		break;
+	case kRefWrap:
+		x = ((x % image.w) + image.w) % image.w;
+		y = ((y % image.h) + image.h) % image.h;
+		break;
+	case kRefDismiss:
+		if (x < 0 || y < 0 || x >= image.w || y >= image.h) {
+			return false;
+		}
+
+		break;
+	}
+
+	byte a, r, g, b;
+	image.format.colorToARGB(image.getPixel(x, y), a, r, g, b);
+	argb = ((uint32)a << 24) | ((uint32)r << 16) | ((uint32)g << 8) | b;
+	return true;
+}
+
+static RefImage *checkRef(lua_State *L, int index) {
+	return *(RefImage **)checkUserData(L, index, kRefType);
+}
+
+static ScriptPoint checkPlace(lua_State *L, int index) {
+	ScriptPoint point;
+	if (!readPoint(L, index, point)) {
+		luaL_argerror(L, index, "Single parameter must be a Point, a Vector or a table with x and y fields");
+	}
+
+	return point;
+}
+
+// Ref:New(image, edgeMode)
+static int refNew(lua_State *L) {
+	const char *imageName = luaL_checkstring(L, 2);
+	const Common::String edgeMode = luaL_optstring(L, 3, "clamp");
+
+	RefImage *ref = new RefImage();
+	if (edgeMode == "wrap") {
+		ref->edgeMode = kRefWrap;
+	} else if (edgeMode == "dismiss") {
+		ref->edgeMode = kRefDismiss;
+	}
+
+	g_nancy->_resource->loadImage(Common::Path(imageName), ref->image);
+
+	RefImage **object = (RefImage **)pushUserData(L, sizeof(RefImage *), kRefType);
+	*object = ref;
+	return 1;
+}
+
+static int refCollect(lua_State *L) {
+	RefImage **object = (RefImage **)checkUserData(L, 1, kRefType);
+	delete *object;
+	*object = nullptr;
+	return 0;
+}
+
+// ref:IsAt(place, color)
+static int refIsAt(lua_State *L) {
+	const RefImage *ref = checkRef(L, 1);
+	const ScriptPoint place = checkPlace(L, 2);
+	const uint32 color = checkColor(L, 3);
+
+	uint32 pixel;
+	lua_pushboolean(L, ref->getPixel(place.x, place.y, pixel) && pixel == color);
+	return 1;
+}
+
+// ref:IsOpaqueAt(place)
+static int refIsOpaqueAt(lua_State *L) {
+	const RefImage *ref = checkRef(L, 1);
+	const ScriptPoint place = checkPlace(L, 2);
+
+	uint32 pixel;
+	lua_pushboolean(L, ref->getPixel(place.x, place.y, pixel) && (pixel >> 24) != 0);
+	return 1;
+}
+
+// How much of the square of the given size around a place passes a test, from 0 to 1.
+// The places that have no pixel count as failing it.
+static double getAreaShare(const RefImage *ref, const ScriptPoint &place, int size, bool testColor, uint32 color) {
+	if (size <= 0) {
+		return 0.0;
+	}
+
+	const int left = place.x - size / 2;
+	const int top = place.y - size / 2;
+	int numMatches = 0;
+
+	for (int y = top; y < top + size; ++y) {
+		for (int x = left; x < left + size; ++x) {
+			uint32 pixel;
+			if (!ref->getPixel(x, y, pixel)) {
+				continue;
+			}
+
+			if (testColor ? pixel == color : (pixel >> 24) != 0) {
+				++numMatches;
+			}
+		}
+	}
+
+	return (double)numMatches / (size * size);
+}
+
+// ref:IsInArea(place, color, size)
+static int refIsInArea(lua_State *L) {
+	const RefImage *ref = checkRef(L, 1);
+	const ScriptPoint place = checkPlace(L, 2);
+	const uint32 color = checkColor(L, 3);
+	const int size = luaL_checkint(L, 4);
+
+	lua_pushnumber(L, getAreaShare(ref, place, size, true, color));
+	return 1;
+}
+
+// ref:IsOpaqueInArea(place, size)
+static int refIsOpaqueInArea(lua_State *L) {
+	const RefImage *ref = checkRef(L, 1);
+	const ScriptPoint place = checkPlace(L, 2);
+	const int size = luaL_checkint(L, 3);
+
+	lua_pushnumber(L, getAreaShare(ref, place, size, false, 0));
+	return 1;
+}
+
+void registerRefAPI(lua_State *L) {
+	static const luaL_Reg kRefFunctions[] = {
+		{ "__index", indexFunctions },
+		{ "__gc", refCollect },
+		{ "IsAt", refIsAt },
+		{ "IsOpaqueAt", refIsOpaqueAt },
+		{ "IsInArea", refIsInArea },
+		{ "IsOpaqueInArea", refIsOpaqueInArea },
+		{ nullptr, nullptr }
+	};
+
+	static const luaL_Reg kRefHelperFunctions[] = {
+		{ "__index", indexFunctions },
+		{ "New", refNew },
+		{ nullptr, nullptr }
+	};
+
+	registerType(L, kRefType, kRefFunctions);
+	registerGlobal(L, "Ref", "ND.RefHelper", kRefHelperFunctions);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy


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

Commit Message:
NANCY: NANCY16: Read NDUI panels a part at a time

Splits the reading of a panel into its header, its background, its
scrolling and its placing on the screen, so that the scripts of
Nancy19 can put a panel together step by step. Nancy16-18 read their
panels in the same steps as before, so their UI is meant to stay
as it is.

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


diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 383823a8771..618c23cbfa0 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -56,21 +56,10 @@ Panel::~Panel() {
 }
 
 void Panel::read(Common::SeekableReadStream &stream) {
-	stream.skip(33); // version
-
-	_initialState = stream.readSint32LE();
-	_arrangement = (Arrangement)stream.readSint32LE();
-	stream.skip(4);
-	_spacing.x = stream.readSint32LE();
-	_spacing.y = stream.readSint32LE();
-	readExclusiveRect(stream, _margins);
-	_backgroundColor = stream.readUint32BE();
-	stream.skip(g_nancy->getGameType() >= kGameTypeNancy17 ? 2 : 1);
+	readHeader(stream);
 
-	// The background is a static that also gives the panel its name, position and events
-	_background = new Static(this);
 	if (stream.readSint32LE() == kControlStatic) {
-		_background->read(stream);
+		readBackground(stream);
 	}
 
 	if (g_nancy->getGameType() >= kGameTypeNancy17 && stream.readSint32LE() == kControlTooltip) {
@@ -84,27 +73,29 @@ void Panel::read(Common::SeekableReadStream &stream) {
 	}
 
 	if (_kind == kObjectScrollPanel || _kind == kObjectInvPanel) {
-		readFilename(stream, _scrollBarName);
-		_scrollStep = MAX<int32>(stream.readSint32LE(), 1);
+		readScrolling(stream);
 	}
 
-	Common::Rect screenPosition = _background->_bounds;
-	_background->_bounds.moveTo(0, 0);
+	place();
+}
 
-	// Controls aren't clipped to a panel that doesn't scroll, and some are placed past
-	// the right or the bottom of its background, like the meters of the hypothermia
-	// dialog, so the panel is as large as it takes to draw them. Panels that arrange
-	// their controls keep their width, since the rows are laid out by it.
-	if (_scrollBarName.empty() && _arrangement == kArrangeNone) {
-		for (const Control *control : _controls) {
-			Common::Rect drawBounds = control->getDrawBounds();
-			screenPosition.right = MAX<int16>(screenPosition.right, screenPosition.left + drawBounds.right);
-			screenPosition.bottom = MAX<int16>(screenPosition.bottom, screenPosition.top + drawBounds.bottom);
-		}
-	}
+void Panel::readHeader(Common::SeekableReadStream &stream) {
+	stream.skip(33); // version
 
-	moveTo(screenPosition);
-	_drawSurface.create(screenPosition.width(), screenPosition.height(), g_nancy->_graphics->getTransparentPixelFormat());
+	_initialState = stream.readSint32LE();
+	_arrangement = (Arrangement)stream.readSint32LE();
+	stream.skip(4);
+	_spacing.x = stream.readSint32LE();
+	_spacing.y = stream.readSint32LE();
+	readExclusiveRect(stream, _margins);
+	_backgroundColor = stream.readUint32BE();
+	stream.skip(g_nancy->getGameType() >= kGameTypeNancy17 ? 2 : 1);
+
+	// The background is a static that also gives the panel its name, position and events
+	_background = new Static(this);
+
+	// Nothing gets drawn until the panel has a place on the screen
+	setVisible(false);
 
 	switch (_initialState) {
 	case kStartVisible:
@@ -117,11 +108,52 @@ void Panel::read(Common::SeekableReadStream &stream) {
 	default:
 		break;
 	}
+}
+
+void Panel::readScrolling(Common::SeekableReadStream &stream) {
+	readFilename(stream, _scrollBarName);
+	_scrollStep = MAX<int32>(stream.readSint32LE(), 1);
+}
+
+void Panel::readBackground(Common::SeekableReadStream &stream) {
+	_background->read(stream);
+}
+
+void Panel::place() {
+	Common::Rect screenPosition = _background->_bounds;
+	_background->_bounds.moveTo(0, 0);
+
+	moveTo(screenPosition);
+	_drawSurface.create(screenPosition.width(), screenPosition.height(), g_nancy->_graphics->getTransparentPixelFormat());
 
 	setVisible(_showCount > 0);
+	growToControls();
 	arrangeControls();
 }
 
+// Controls aren't clipped to a panel that doesn't scroll, and some are placed past
+// the right or the bottom of its background, like the meters of the hypothermia
+// dialog, so the panel is as large as it takes to draw them. Panels that arrange
+// their controls keep their width, since the rows are laid out by it.
+void Panel::growToControls() {
+	if (!_scrollBarName.empty() || _arrangement != kArrangeNone) {
+		return;
+	}
+
+	Common::Rect screenPosition = _screenPosition;
+	for (const Control *control : _controls) {
+		Common::Rect drawBounds = control->getDrawBounds();
+		screenPosition.right = MAX<int16>(screenPosition.right, screenPosition.left + drawBounds.right);
+		screenPosition.bottom = MAX<int16>(screenPosition.bottom, screenPosition.top + drawBounds.bottom);
+	}
+
+	if (screenPosition != _screenPosition) {
+		moveTo(screenPosition);
+		_drawSurface.create(screenPosition.width(), screenPosition.height(), g_nancy->_graphics->getTransparentPixelFormat());
+		_isDirty = true;
+	}
+}
+
 void Panel::readControl(Common::SeekableReadStream &stream) {
 	int32 type = stream.readSint32LE();
 	addNewControl(type, stream);
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index 7eeb224000d..ca969cf7d84 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -55,6 +55,17 @@ public:
 
 	void read(Common::SeekableReadStream &stream);
 
+	// Nancy19 panels are put together by a script, a part at a time: the header
+	// when the panel is made, then its background, which gives it a place on the
+	// screen, and then each of its controls
+	void readHeader(Common::SeekableReadStream &stream);
+	void readScrolling(Common::SeekableReadStream &stream);
+	void readBackground(Common::SeekableReadStream &stream);
+	void place();
+
+	// A panel without controls to arrange is as large as it takes to draw them all
+	void growToControls();
+
 	// 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: 637c024be8df4c9cbf3bdd473ec6912bf1e38bb3
    https://github.com/scummvm/scummvm/commit/637c024be8df4c9cbf3bdd473ec6912bf1e38bb3
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:25+03:00

Commit Message:
NANCY: NANCY19: Build the UI from scripts

The UI of Nancy19 is made of the panels and controls of Nancy16, but
scripts describe them where NDUI chunks used to. A stream of its own
runs the scripts that make the panels. A description is written out
the way a chunk has it, and read by the panel or control it is for.

A control hands its events to a function of the script that made it,
in place of a list of actions, and the controls that get created
while the game runs, like the buttons of the inventory, come from a
function of their panel's script. Message:Send lets scripts act on
panels and controls. Value displays go by the name of their value,
and the cursor over the UI by its name.

TODO: Tooltips, 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

Changed paths:
  A engines/nancy/script/uiapi.cpp
    engines/nancy/module.mk
    engines/nancy/script/api.cpp
    engines/nancy/script/api.h
    engines/nancy/script/gameapi.cpp
    engines/nancy/script/scenecontext.cpp
    engines/nancy/script/scenecontext.h
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h
    engines/nancy/ui/ndui/common.h
    engines/nancy/ui/ndui/control.cpp
    engines/nancy/ui/ndui/control.h
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/manager.cpp
    engines/nancy/ui/ndui/manager.h
    engines/nancy/ui/ndui/panel.cpp
    engines/nancy/ui/ndui/panel.h


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 32660fc41b1..d16b1e9a187 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -195,7 +195,8 @@ MODULE_OBJS += \
   script/records.o \
   script/ref.o \
   script/scenecontext.o \
-  script/types.o
+  script/types.o \
+  script/uiapi.o
 endif
 
 # This module can be built as a plugin
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
index 3fa86f9b6e9..0dece898cb4 100644
--- a/engines/nancy/script/api.cpp
+++ b/engines/nancy/script/api.cpp
@@ -80,6 +80,7 @@ void registerAPI(lua_State *L) {
 	registerBootAPI(L);
 	registerInventoryAPI(L);
 	registerGameAPI(L);
+	registerUIAPI(L);
 	registerRecordAPI(L);
 }
 
diff --git a/engines/nancy/script/api.h b/engines/nancy/script/api.h
index 67e185ea067..f4160935f68 100644
--- a/engines/nancy/script/api.h
+++ b/engines/nancy/script/api.h
@@ -84,6 +84,13 @@ void pushCursor(lua_State *L, uint cursorType, bool isHot);
 void pushKey(lua_State *L, Common::KeyCode key);
 Common::KeyCode checkKey(lua_State *L, int index);
 
+// The text kept under a key: what a sound says, or a text of the UI.
+// Returns false when there is none.
+bool findText(const Common::String &key, Common::String &text);
+
+// The panels and controls scripts build the UI out of
+void registerUIAPI(lua_State *L);
+
 // The images scripts look colors up in
 void registerRefAPI(lua_State *L);
 
diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 0895a77900c..24587603704 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -38,6 +38,7 @@
 
 #include "engines/nancy/state/scene.h"
 
+#include "engines/nancy/ui/ndui/common.h"
 #include "engines/nancy/ui/ndui/manager.h"
 
 namespace Nancy {
@@ -976,6 +977,99 @@ static const luaL_Reg kPlayerFunctions[] = {
 	{ nullptr, nullptr }
 };
 
+bool findText(const Common::String &key, Common::String &text) {
+	static const char *const kTableNames[] = { "AUTOTEXT", "CONVO" };
+
+	for (const char *tableName : kTableNames) {
+		const CVTX *table = (const CVTX *)g_nancy->getEngineData(tableName);
+		if (table && table->texts.contains(key)) {
+			text = table->texts[key];
+			return true;
+		}
+	}
+
+	return false;
+}
+
+// Message, which has the UI do something to one of its panels or controls
+// TODO: Message:EncodeRect
+
+struct MessageActionName {
+	const char *name;
+	int32 type;
+};
+
+static const MessageActionName kMessageActionNames[] = {
+	{ "show", UI::NDUI::kActionShow },
+	{ "hide", UI::NDUI::kActionHide },
+	{ "toggleHidden", UI::NDUI::kActionToggle },
+	{ "enable", UI::NDUI::kActionEnable },
+	{ "disable", UI::NDUI::kActionDisable },
+	{ "toggleDisabled", UI::NDUI::kActionToggleEnabled },
+	{ "addText", UI::NDUI::kActionAddText },
+	{ "setText", UI::NDUI::kActionSetText },
+	{ "setValue", UI::NDUI::kActionSetValue },
+	{ "getValue", UI::NDUI::kActionQuery },
+	{ "showItem", UI::NDUI::kActionShowItem },
+	{ "hideItem", UI::NDUI::kActionHideItem }
+};
+
+// Message:Send(action, target[, value[, text[, isTextKey]]])
+// The text is the key of an autotext entry, unless it says otherwise
+static int messageSend(lua_State *L) {
+	const char *actionName = luaL_checkstring(L, 2);
+	const char *target = luaL_checkstring(L, 3);
+
+	int32 type = 0;
+	for (const MessageActionName &action : kMessageActionNames) {
+		if (scumm_stricmp(action.name, actionName) == 0) {
+			type = action.type;
+		}
+	}
+
+	if (type == 0) {
+		return luaL_error(L, "Action type '%s' invalid", actionName);
+	}
+
+	UI::NDUI::Action action;
+	action.type = type;
+	action.target = target;
+	action.hasValue = true;
+
+	const int numArgs = lua_gettop(L);
+	if (numArgs > 3) {
+		action.value = luaL_checknumber(L, 4);
+	}
+
+	if (numArgs > 4) {
+		action.text = luaL_checkstring(L, 5);
+	}
+
+	const bool isTextKey = numArgs > 5 ? lua_toboolean(L, 6) : true;
+	if (isTextKey && !action.text.empty()) {
+		const Common::String key = action.text;
+		if (!findText(key, action.text)) {
+			return luaL_error(L, "Autotext reference '%s' invalid", key.c_str());
+		}
+	}
+
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	if (!ui) {
+		return 0;
+	}
+
+	// The targets that answer do so to the control whose event is being handled
+	action.sender = ui->getEventSender();
+	ui->takeAction(action);
+	return 0;
+}
+
+static const luaL_Reg kMessageFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "Send", messageSend },
+	{ nullptr, nullptr }
+};
+
 // Viewport
 
 static int viewportIndex(lua_State *L) {
@@ -1029,6 +1123,7 @@ void registerGameAPI(lua_State *L) {
 
 	registerGlobal(L, "Scene", "ND.Scene", kSceneFunctions);
 	registerGlobal(L, "Player", "ND.CurPlayer", kPlayerFunctions);
+	registerGlobal(L, "Message", "ND.Message", kMessageFunctions);
 	registerGlobal(L, "Viewport", "ND.Viewport", kViewportFunctions);
 
 	registerType(L, kKeyType, kKeyFunctions);
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index f8c81e234cf..9dc7f7a06bc 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -35,6 +35,7 @@
 #include "engines/nancy/state/scene.h"
 
 #include "engines/nancy/ui/viewport.h"
+#include "engines/nancy/ui/ndui/manager.h"
 
 namespace Nancy {
 namespace Script {
@@ -48,6 +49,11 @@ SceneContext::SceneContext(const Common::String &streamName, const Common::Strin
 }
 
 SceneContext::~SceneContext() {
+	UI::NDUI::Manager *ui = _makesUI ? NancySceneState.getNDUI() : nullptr;
+	if (ui) {
+		ui->clearPanels();
+	}
+
 	for (Record *record : _records) {
 		delete record;
 	}
@@ -84,6 +90,10 @@ bool SceneContext::load() {
 		return false;
 	}
 
+	if (_makesUI) {
+		NancySceneState.onScriptedUILoaded();
+	}
+
 	return true;
 }
 
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index 6e87bd02960..60a4c0c27fb 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -79,6 +79,9 @@ public:
 	// The seconds the scene has been running for
 	double getTime() const { return _time; }
 
+	// The panels a scene adds to the UI call into its scripts, so they go when it does
+	void setMakesUI() { _makesUI = true; }
+
 	// Runs another script as a part of this scene
 	bool include(const Common::String &name);
 
@@ -110,6 +113,7 @@ private:
 
 	double _time = 0.0;
 	bool _hasRunOnce = false;
+	bool _makesUI = false;
 
 	// The keys records were told are down
 	uint16 _heldMoveKeys = 0;
diff --git a/engines/nancy/script/uiapi.cpp b/engines/nancy/script/uiapi.cpp
new file mode 100644
index 00000000000..0f118a0bd5c
--- /dev/null
+++ b/engines/nancy/script/uiapi.cpp
@@ -0,0 +1,734 @@
+/* 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/memstream.h"
+
+#include "engines/nancy/enginedata.h"
+#include "engines/nancy/nancy.h"
+
+#include "engines/nancy/script/api.h"
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/luastate.h"
+#include "engines/nancy/script/scenecontext.h"
+#include "engines/nancy/script/types.h"
+
+#include "engines/nancy/state/scene.h"
+
+#include "engines/nancy/ui/ndui/common.h"
+#include "engines/nancy/ui/ndui/control.h"
+#include "engines/nancy/ui/ndui/manager.h"
+#include "engines/nancy/ui/ndui/panel.h"
+
+namespace Nancy {
+namespace Script {
+
+// The UI of Nancy19 and up is made of the same panels and controls as the one of
+// Nancy16, but scripts describe them, with tables, where NDUI chunks used to.
+// A description gets written out the way a chunk has it, and read by the panel
+// or control it is for.
+//
+// TODO: Tooltips, 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: sliderReleased, listboxItemDblClick, enable, disable, mouseEnter
+// and mouseLeave.
+
+static const char *const kPanelType = "ND.Panel";
+
+static const uint32 kDefaultColor = 0xFFFFFFFF;
+static const uint32 kDefaultDisabledColor = 0xC8808080;
+static const char *const kDefaultFontName = "UIFont";
+
+struct NamedValue {
+	const char *name;
+	int32 value;
+};
+
+// The names scripts know events by
+static const NamedValue kEventNames[] = {
+	{ "buttonClicked", UI::NDUI::kEventClicked },
+	{ "radioButtonChanged", UI::NDUI::kEventRadioChanged },
+	{ "checkboxChecked", UI::NDUI::kEventChecked },
+	{ "checkboxUnchecked", UI::NDUI::kEventUnchecked },
+	{ "sliderValueChanged", UI::NDUI::kEventSliderChanged },
+	{ "editboxString", UI::NDUI::kEventTextEntered },
+	{ "editboxChange", UI::NDUI::kEventTextChanged },
+	{ "listboxSelection", UI::NDUI::kEventListSelected },
+	{ "scrollBarValueChanged", UI::NDUI::kEventScrolled },
+	{ "show", UI::NDUI::kEventShow },
+	{ "hide", UI::NDUI::kEventHide },
+	{ "load", UI::NDUI::kEventUpdate }
+};
+
+static const NamedValue kInitialStates[] = {
+	{ "normal", UI::NDUI::kStartVisible },
+	{ "disabled", UI::NDUI::kStartDisabled },
+	{ "hidden", UI::NDUI::kStartHidden }
+};
+
+// The ways a panel can arrange its controls
+static const NamedValue kLayoutOrders[] = {
+	{ "manual", 0 },
+	{ "leftToRight", 1 },
+	{ "rightToLeft", 2 },
+	{ "topToBottom", 3 },
+	{ "bottomToTop", 4 },
+	{ "centered", 5 }
+};
+
+// Where text goes in its control, as the NDUI chunks have it
+enum HorizontalAlignment {
+	kAlignLeft		= 0,
+	kAlignRight		= 1,
+	kAlignCenter	= 2
+};
+
+enum VerticalAlignment {
+	kAlignVCenter	= 2,
+	kAlignTop		= 3,
+	kAlignBottom	= 4
+};
+
+static const NamedValue kHorizontalAlignments[] = {
+	{ "left", kAlignLeft },
+	{ "right", kAlignRight },
+	{ "center", kAlignCenter }
+};
+
+static const NamedValue kVerticalAlignments[] = {
+	{ "center", kAlignVCenter },
+	{ "top", kAlignTop },
+	{ "bottom", kAlignBottom }
+};
+
+// A function of a script, kept for as long as the UI can call it
+class ScriptFunction {
+public:
+	// Takes the function at the top of the stack
+	ScriptFunction(lua_State *L) : _lua(L), _reference(luaL_ref(L, LUA_REGISTRYINDEX)) {}
+	~ScriptFunction() { luaL_unref(_lua, LUA_REGISTRYINDEX, _reference); }
+
+	lua_State *push() const {
+		lua_rawgeti(_lua, LUA_REGISTRYINDEX, _reference);
+		return _lua;
+	}
+
+private:
+	lua_State *_lua;
+	int _reference;
+};
+
+// onEvent(eventName, value, text)
+class ScriptEventHandler : public UI::NDUI::EventHandler {
+public:
+	ScriptEventHandler(lua_State *L) : _function(L) {}
+
+	void onEvent(int32 eventType, bool isOwnEvent, double value, const Common::String &text) override;
+
+private:
+	ScriptFunction _function;
+};
+
+void ScriptEventHandler::onEvent(int32 eventType, bool isOwnEvent, double value, const Common::String &text) {
+	// What a control reports about itself has a name of its own
+	const char *eventName = isOwnEvent ? "default" : nullptr;
+	for (uint i = 0; !eventName && i < ARRAYSIZE(kEventNames); ++i) {
+		if (kEventNames[i].value == eventType) {
+			eventName = kEventNames[i].name;
+		}
+	}
+
+	if (!eventName) {
+		return;
+	}
+
+	// The function can take its control down, and this handler with it
+	lua_State *L = _function.push();
+	lua_pushstring(L, eventName);
+	lua_pushnumber(L, value);
+	lua_pushstring(L, text.c_str());
+	LuaState::call(L, 3, 0, "Error during UI event");
+}
+
+// createObject(name, templateName), which returns whether it made the control
+class ScriptObjectFactory : public UI::NDUI::ObjectFactory {
+public:
+	ScriptObjectFactory(lua_State *L) : _function(L) {}
+
+	bool createObject(const Common::String &name, const Common::String &templateName) override;
+
+private:
+	ScriptFunction _function;
+};
+
+bool ScriptObjectFactory::createObject(const Common::String &name, const Common::String &templateName) {
+	lua_State *L = _function.push();
+	lua_pushstring(L, name.c_str());
+	lua_pushstring(L, templateName.c_str());
+	if (!LuaState::call(L, 2, 1, "Error during UI object creation")) {
+		return false;
+	}
+
+	bool created = lua_toboolean(L, -1);
+	lua_pop(L, 1);
+	return created;
+}
+
+static UI::NDUI::Panel *checkPanel(lua_State *L, int index) {
+	return *(UI::NDUI::Panel **)checkUserData(L, index, kPanelType);
+}
+
+// Pushes a field that is a table, or an empty table when there is no such field
+static void pushTableField(lua_State *L, int tableIndex, const char *name) {
+	if (!pushField(L, tableIndex, name)) {
+		lua_newtable(L);
+	} else if (!lua_istable(L, -1)) {
+		luaL_error(L, "field %s must be a table", name);
+	}
+}
+
+static void readRectField(lua_State *L, int tableIndex, const char *name, ScriptRect &rect) {
+	if (pushField(L, tableIndex, name)) {
+		rect = checkRect(L, -1);
+		lua_pop(L, 1);
+	}
+}
+
+static void readPointField(lua_State *L, int tableIndex, const char *name, ScriptPoint &point) {
+	if (pushField(L, tableIndex, name)) {
+		if (!readPoint(L, -1, point)) {
+			luaL_error(L, "field %s must be a point", name);
+		}
+
+		lua_pop(L, 1);
+	}
+}
+
+static bool readColorField(lua_State *L, int tableIndex, const char *name, uint32 &color) {
+	if (!pushField(L, tableIndex, name)) {
+		return false;
+	}
+
+	color = checkColor(L, -1);
+	lua_pop(L, 1);
+	return true;
+}
+
+// Reads a field that holds one of the given names; it keeps its value when it is missing
+static void readNamedField(lua_State *L, int tableIndex, const char *name, const NamedValue *values, uint numValues, int32 &value) {
+	Common::String valueName;
+	if (!readField(L, tableIndex, name, valueName)) {
+		return;
+	}
+
+	for (uint i = 0; i < numValues; ++i) {
+		if (valueName.equalsIgnoreCase(values[i].name)) {
+			value = values[i].value;
+			return;
+		}
+	}
+
+	luaL_error(L, "field %s can't be \"%s\"", name, valueName.c_str());
+}
+
+static void writeName(Common::WriteStream &out, const Common::String &name) {
+	char buf[33] = {};
+	Common::strlcpy(buf, name.c_str(), sizeof(buf));
+	out.write(buf, sizeof(buf));
+}
+
+static void writeRect(Common::WriteStream &out, const ScriptRect &rect) {
+	out.writeSint32LE(rect.left);
+	out.writeSint32LE(rect.top);
+	out.writeSint32LE(rect.right);
+	out.writeSint32LE(rect.bottom);
+}
+
+// { ovl = <image name>, source = <rect> }
+static void writeOverlay(lua_State *L, int tableIndex, const char *name, Common::WriteStream &out) {
+	Common::String textureName;
+	ScriptRect source;
+
+	pushTableField(L, tableIndex, name);
+	readField(L, -1, "ovl", textureName);
+	readRectField(L, -1, "source", source);
+	lua_pop(L, 1);
+
+	writeName(out, textureName);
+	writeRect(out, source);
+}
+
+// The colors something has in each state of its control:
+// { normal = <color>, disabled = <color>, mouseOver = <color>, pressed = <color> }.
+// A font takes its normal color in the other states that aren't given one.
+static void writeStateColors(lua_State *L, int tableIndex, const char *name, bool isFont, Common::WriteStream &out) {
+	uint32 normal = kDefaultColor;
+	uint32 disabled = kDefaultDisabledColor;
+	uint32 mouseOver = kDefaultColor;
+	uint32 pressed = kDefaultColor;
+
+	pushTableField(L, tableIndex, name);
+	if (readColorField(L, -1, "normal", normal) && isFont) {
+		mouseOver = normal;
+		pressed = normal;
+	}
+
+	readColorField(L, -1, "disabled", disabled);
+	readColorField(L, -1, "mouseOver", mouseOver);
+	readColorField(L, -1, "pressed", pressed);
+	lua_pop(L, 1);
+
+	out.writeUint32BE(normal);
+	out.writeUint32BE(disabled);
+	out.writeUint32BE(mouseOver);
+	out.writeUint32BE(pressed);
+}
+
+// The fields a table has for one of the things a control draws:
+// overlay, renderStates, font = { name, bold, horzAlign, vertAlign, wrap }, fontStates
+static void writeElement(lua_State *L, int tableIndex, HorizontalAlignment defaultAlignment, Common::WriteStream &out) {
+	tableIndex = absoluteIndex(L, tableIndex);
+
+	writeOverlay(L, tableIndex, "overlay", out);
+	writeStateColors(L, tableIndex, "renderStates", false, out);
+
+	Common::String fontName = kDefaultFontName;
+	bool isBold = false;
+	bool wraps = false;
+	int32 horizontalAlignment = defaultAlignment;
+	int32 verticalAlignment = kAlignTop;
+
+	pushTableField(L, tableIndex, "font");
+	readField(L, -1, "name", fontName);
+	readField(L, -1, "bold", isBold);
+	readNamedField(L, -1, "horzAlign", kHorizontalAlignments, ARRAYSIZE(kHorizontalAlignments), horizontalAlignment);
+	readNamedField(L, -1, "vertAlign", kVerticalAlignments, ARRAYSIZE(kVerticalAlignments), verticalAlignment);
+	readField(L, -1, "wrap", wraps);
+	lua_pop(L, 1);
+
+	writeName(out, fontName);
+	out.writeByte(isBold);
+	out.writeSint32LE(horizontalAlignment);
+	out.writeSint32LE(verticalAlignment);
+	out.writeByte(wraps);
+
+	writeStateColors(L, tableIndex, "fontStates", true, out);
+	out.writeByte(0);
+}
+
+// The fields every control has: initialState, name, onScreen, text, tooltipText,
+// and the ones of what it draws
+static void writeControl(lua_State *L, int tableIndex, HorizontalAlignment defaultAlignment, Common::WriteStream &out) {
+	tableIndex = absoluteIndex(L, tableIndex);
+	luaL_checktype(L, tableIndex, LUA_TTABLE);
+
+	int32 initialState = UI::NDUI::kStartVisible;
+	Common::String name;
+	Common::String text;
+	Common::String tooltip;
+	ScriptRect bounds;
+
+	readNamedField(L, tableIndex, "initialState", kInitialStates, ARRAYSIZE(kInitialStates), initialState);
+	if (!readField(L, tableIndex, "name", name)) {
+		luaL_error(L, "A control must have a name field");
+	}
+
+	readRectField(L, tableIndex, "onScreen", bounds);
+	readField(L, tableIndex, "text", text);
+	readField(L, tableIndex, "tooltipText", tooltip);
+
+	writeName(out, Common::String());	// version
+	out.writeSint32LE(initialState);
+	writeName(out, name);
+	writeRect(out, bounds);
+	writeName(out, text);
+	writeName(out, tooltip);
+	out.writeSint16LE(0);				// the events go to the script
+	writeElement(L, tableIndex, defaultAlignment, out);
+}
+
+// upArrow, downArrow and handle are described like the scroll bar itself
+static void writeScrollBar(lua_State *L, int tableIndex, Common::WriteStream &out) {
+	static const char *const kPartNames[] = { "upArrow", "downArrow", "handle" };
+
+	tableIndex = absoluteIndex(L, tableIndex);
+	writeControl(L, tableIndex, kAlignCenter, out);
+
+	for (const char *partName : kPartNames) {
+		pushTableField(L, tableIndex, partName);
+		writeElement(L, -1, kAlignCenter, out);
+		lua_pop(L, 1);
+	}
+
+	int pixelsToScroll = 0;
+	readField(L, tableIndex, "pixelsToScroll", pixelsToScroll);
+	out.writeSint32LE(pixelsToScroll);
+}
+
+// Gives a control the function of its description that handles its events
+static void setEventHandler(lua_State *L, int tableIndex, UI::NDUI::Control *control) {
+	if (!pushField(L, tableIndex, "onEvent")) {
+		return;
+	}
+
+	if (!lua_isfunction(L, -1)) {
+		luaL_error(L, "onEvent field must be a function");
+	}
+
+	control->setEventHandler(new ScriptEventHandler(L));
+}
+
+// Makes the control described by the table at index 2, whose data has been written out
+static UI::NDUI::Control *addControl(lua_State *L, int32 type, Common::MemoryWriteStreamDynamic &data) {
+	UI::NDUI::Panel *panel = checkPanel(L, 1);
+	if (!panel->isPlaced()) {
+		luaL_error(L, "A panel needs a background before it can have controls");
+	}
+
+	Common::MemoryReadStream stream(data.getData(), data.size());
+	UI::NDUI::Control *control = panel->addNewControl(type, stream);
+	setEventHandler(L, 2, control);
+
+	panel->growToControls();
+	panel->arrangeControls();
+	return control;
+}
+
+// The text kept under a key, or the key itself when there is none
+static Common::String resolveText(const Common::String &key) {
+	Common::String text = key;
+	findText(key, text);
+	return text;
+}
+
+// panel:AddBg{...}: the background names the panel and says where it goes
+static int panelAddBg(lua_State *L) {
+	UI::NDUI::Panel *panel = checkPanel(L, 1);
+	if (panel->isPlaced()) {
+		return luaL_error(L, "The panel already has a background");
+	}
+
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignCenter, data);
+
+	Common::MemoryReadStream stream(data.getData(), data.size());
+	panel->readBackground(stream);
+	setEventHandler(L, 2, panel->getBackground());
+
+	panel->place();
+	panel->registerGraphics();
+	return 0;
+}
+
+static int panelAddStatic(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignCenter, data);
+	addControl(L, UI::NDUI::kControlStatic, data);
+	return 0;
+}
+
+// Adds foregroundOverlay, pressedOffset
+static int panelAddButton(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignCenter, data);
+	writeOverlay(L, 2, "foregroundOverlay", data);
+
+	ScriptPoint pressedOffset;
+	readPointField(L, 2, "pressedOffset", pressedOffset);
+	data.writeSint32LE(pressedOffset.x);
+	data.writeSint32LE(pressedOffset.y);
+
+	addControl(L, UI::NDUI::kControlButton, data);
+	return 0;
+}
+
+// Adds checkOverlay, initiallyChecked
+static int panelAddCheckBox(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignCenter, data);
+	writeOverlay(L, 2, "checkOverlay", data);
+
+	bool isChecked = false;
+	readField(L, 2, "initiallyChecked", isChecked);
+	data.writeByte(isChecked);
+
+	addControl(L, UI::NDUI::kControlCheckBox, data);
+	return 0;
+}
+
+// Adds radioOverlay, groupName
+static int panelAddRadioButton(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignCenter, data);
+	writeOverlay(L, 2, "radioOverlay", data);
+
+	Common::String groupName;
+	if (!readField(L, 2, "groupName", groupName)) {
+		return luaL_error(L, "Radio button must have a groupName field");
+	}
+
+	writeName(data, groupName);
+
+	addControl(L, UI::NDUI::kControlRadioButton, data);
+	return 0;
+}
+
+// Adds upArrow, downArrow, handle, pixelsToScroll
+static int panelAddScrollBar(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeScrollBar(L, 2, data);
+	addControl(L, UI::NDUI::kControlScrollBar, data);
+	return 0;
+}
+
+// Adds handleOverlay, fillOverlay, min, max, initialValue
+static int panelAddSlider(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignCenter, data);
+	writeOverlay(L, 2, "handleOverlay", data);
+	writeOverlay(L, 2, "fillOverlay", data);
+
+	int minValue = 0;
+	int maxValue = 0;
+	int initialValue = 0;
+	readField(L, 2, "min", minValue);
+	readField(L, 2, "max", maxValue);
+	readField(L, 2, "initialValue", initialValue);
+	data.writeSint32LE(minValue);
+	data.writeSint32LE(maxValue);
+	data.writeSint32LE(initialValue);
+
+	addControl(L, UI::NDUI::kControlSlider, data);
+	return 0;
+}
+
+// Adds selected (how the selected line is drawn), scrollBar (a scroll bar of
+// its own), border, margin
+static int panelAddListBox(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignCenter, data);
+
+	pushTableField(L, 2, "selected");
+	writeElement(L, -1, kAlignCenter, data);
+	lua_pop(L, 1);
+
+	data.writeSint32LE(UI::NDUI::kControlScrollBar);
+	pushTableField(L, 2, "scrollBar");
+	writeScrollBar(L, -1, data);
+	lua_pop(L, 1);
+
+	int border = 0;
+	int margin = 0;
+	readField(L, 2, "border", border);
+	readField(L, 2, "margin", margin);
+	data.writeSint32LE(border);
+	data.writeSint32LE(margin);
+
+	data.writeSint16LE(0);				// the lines get added as the game runs
+	writeName(data, Common::String());
+
+	addControl(L, UI::NDUI::kControlListBox, data);
+	return 0;
+}
+
+// Adds selectedTextColor, selectedTextBackColor, caretColor, spacing.
+// The text starts at the left unless the font says otherwise.
+static int panelAddEditBox(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignLeft, data);
+
+	uint32 selectedTextColor = 0;
+	uint32 selectionColor = 0;
+	uint32 caretColor = 0;
+	int spacing = 0;
+	readColorField(L, 2, "selectedTextColor", selectedTextColor);
+	readColorField(L, 2, "selectedTextBackColor", selectionColor);
+	readColorField(L, 2, "caretColor", caretColor);
+	readField(L, 2, "spacing", spacing);
+	data.writeUint32BE(selectedTextColor);
+	data.writeUint32BE(selectionColor);
+	data.writeUint32BE(caretColor);
+	data.writeSint32LE(spacing);
+
+	addControl(L, UI::NDUI::kControlEditBox, data);
+	return 0;
+}
+
+// Adds preText, postText, preDecimal, postDecimal: the texts around the number,
+// and the digits it has
+static int panelAddVarStatic(lua_State *L) {
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeControl(L, 2, kAlignCenter, data);
+
+	int preDecimal = 0;
+	int postDecimal = 0;
+	Common::String preText;
+	Common::String postText;
+	readField(L, 2, "preDecimal", preDecimal);
+	readField(L, 2, "postDecimal", postDecimal);
+	readField(L, 2, "preText", preText);
+	readField(L, 2, "postText", postText);
+	data.writeSint32LE(preDecimal);
+	data.writeSint32LE(postDecimal);
+
+	UI::NDUI::VarStatic *control = (UI::NDUI::VarStatic *)addControl(L, UI::NDUI::kControlVarStatic, data);
+	control->setAffixes(resolveText(preText), resolveText(postText));
+	return 0;
+}
+
+// Makes a panel out of: initialState, layout = { primaryOrder, secondaryOrder,
+// offset, border }, color, save, createObject, and for the ones that scroll,
+// scrollBarName and scrollPixels
+static int newPanel(lua_State *L, byte kind) {
+	luaL_checktype(L, 2, LUA_TTABLE);
+
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	if (!ui) {
+		return luaL_error(L, "There is no UI to add a panel to");
+	}
+
+	int32 initialState = UI::NDUI::kStartVisible;
+	int32 primaryOrder = 0;
+	int32 secondaryOrder = 0;
+	ScriptPoint spacing;
+	ScriptRect margins;
+	uint32 color = 0;
+
+	readNamedField(L, 2, "initialState", kInitialStates, ARRAYSIZE(kInitialStates), initialState);
+
+	pushTableField(L, 2, "layout");
+	readNamedField(L, -1, "primaryOrder", kLayoutOrders, ARRAYSIZE(kLayoutOrders), primaryOrder);
+	readNamedField(L, -1, "secondaryOrder", kLayoutOrders, ARRAYSIZE(kLayoutOrders), secondaryOrder);
+	readPointField(L, -1, "offset", spacing);
+	readRectField(L, -1, "border", margins);
+	lua_pop(L, 1);
+
+	readColorField(L, 2, "color", color);
+
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeName(data, Common::String());	// version
+	data.writeSint32LE(initialState);
+	data.writeSint32LE(primaryOrder);
+	data.writeSint32LE(secondaryOrder);
+	data.writeSint32LE(spacing.x);
+	data.writeSint32LE(spacing.y);
+	writeRect(data, margins);
+	data.writeUint32BE(color);
+	data.writeUint16LE(0);
+
+	if (kind == UI::NDUI::kObjectScrollPanel) {
+		Common::String scrollBarName;
+		int scrollPixels = 0;
+		if (!readField(L, 2, "scrollBarName", scrollBarName) || !readField(L, 2, "scrollPixels", scrollPixels)) {
+			return luaL_error(L, "A scroll panel must have the scrollBarName and scrollPixels fields");
+		}
+
+		writeName(data, scrollBarName);
+		data.writeSint32LE(scrollPixels);
+	}
+
+	UI::NDUI::Panel *panel = ui->addPanel(kind);
+	Common::MemoryReadStream stream(data.getData(), data.size());
+	panel->readHeader(stream);
+	if (kind == UI::NDUI::kObjectScrollPanel) {
+		panel->readScrolling(stream);
+	}
+
+	bool savesState = true;
+	if (readField(L, 2, "save", savesState)) {
+		panel->setSavesState(savesState);
+	}
+
+	if (pushField(L, 2, "createObject")) {
+		if (!lua_isfunction(L, -1)) {
+			return luaL_error(L, "field 'createObject' must be a function");
+		}
+
+		panel->setObjectFactory(new ScriptObjectFactory(L));
+	}
+
+	// The panels have to go before the scripts they call into do
+	SceneContext *context = LuaState::getContext(L);
+	if (context) {
+		context->setMakesUI();
+	}
+
+	*(UI::NDUI::Panel **)pushUserData(L, sizeof(UI::NDUI::Panel *), kPanelType) = panel;
+	return 1;
+}
+
+// panel.createObject = function: the one field that can be set once the panel is there
+static int panelNewIndex(lua_State *L) {
+	UI::NDUI::Panel *panel = checkPanel(L, 1);
+	const char *key = luaL_checkstring(L, 2);
+
+	if (strcmp(key, "createObject") != 0) {
+		return luaL_error(L, "A panel has no field '%s' to set", key);
+	}
+
+	if (!lua_isfunction(L, 3)) {
+		return luaL_error(L, "field 'createObject' must be a function");
+	}
+
+	lua_pushvalue(L, 3);
+	panel->setObjectFactory(new ScriptObjectFactory(L));
+	return 0;
+}
+
+// Panel:Regular{...}
+static int panelRegular(lua_State *L) {
+	return newPanel(L, UI::NDUI::kObjectPanel);
+}
+
+// Panel:Scroll{...}
+static int panelScroll(lua_State *L) {
+	return newPanel(L, UI::NDUI::kObjectScrollPanel);
+}
+
+void registerUIAPI(lua_State *L) {
+	static const luaL_Reg kPanelFunctions[] = {
+		{ "__index", indexFunctions },
+		{ "__newindex", panelNewIndex },
+		{ "AddBg", panelAddBg },
+		{ "AddStatic", panelAddStatic },
+		{ "AddButton", panelAddButton },
+		{ "AddCheckBox", panelAddCheckBox },
+		{ "AddRadioButton", panelAddRadioButton },
+		{ "AddScrollBar", panelAddScrollBar },
+		{ "AddSlider", panelAddSlider },
+		{ "AddListBox", panelAddListBox },
+		{ "AddEditBox", panelAddEditBox },
+		{ "AddVarStatic", panelAddVarStatic },
+		{ nullptr, nullptr }
+	};
+
+	static const luaL_Reg kPanelHelperFunctions[] = {
+		{ "__index", indexFunctions },
+		{ "Regular", panelRegular },
+		{ "Scroll", panelScroll },
+		{ nullptr, nullptr }
+	};
+
+	registerType(L, kPanelType, kPanelFunctions);
+	registerGlobal(L, "Panel", "ND.PanelHelper", kPanelHelperFunctions);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 432dbd78d90..1ed417c1e0e 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2871,10 +2871,7 @@ void Scene::initStaticData() {
 			_streams.clear();
 		}
 
-		// The UI of Nancy19 is made by the scripts of a stream of its own
-		if (g_nancy->getGameType() <= kGameTypeNancy18) {
-			initUI();
-		}
+		initUI();
 
 		// The popups of the earlier games go unused, and must never count as open
 		_inventoryPopup.setVisible(false);
@@ -2983,11 +2980,21 @@ void Scene::initStaticData() {
 // task list; the records that fill the last two run again as their conditions allow
 void Scene::initUI() {
 	auto *pcui = GetEngineData(PCUI);
-	auto *puih = GetEngineData(PUIH);
-	assert(pcui && puih);
+	assert(pcui);
 
 	delete _ndui;
 	_ndui = new UI::NDUI::Manager();
+
+	// The UI of Nancy19 is made by the scripts of a stream of its own, which
+	// gets back to the scene once the panels are there
+	if (g_nancy->getGameType() >= kGameTypeNancy19) {
+		_streams.begin("UIStream", pcui->uiName.toString(), 0, StreamFlags());
+		return;
+	}
+
+	auto *puih = GetEngineData(PUIH);
+	assert(puih);
+
 	_ndui->load(pcui->uiName.toString());
 
 	if (g_nancy->getGameType() >= kGameTypeNancy17) {
@@ -3022,6 +3029,23 @@ void Scene::initUI() {
 	_ndui->registerGraphics();
 }
 
+#ifdef ENABLE_NANCY_V4
+void Scene::onScriptedUILoaded() {
+	_ndui->createItemButtons();
+
+	// The most recent items come first
+	const Common::Array<int16> &order = _inventoryBox.getOrder();
+	for (int i = (int)order.size() - 1; i >= 0; --i) {
+		if (order[i] != -1 && _flags.items[order[i]] == g_nancy->_true) {
+			_ndui->showInventoryItem(order[i], true);
+		}
+	}
+
+	_ndui->restoreState(((UIStateData *)getPuzzleData(UIStateData::getTag()))->changes);
+	_ndui->applyMatteColor();
+}
+#endif
+
 void Scene::loadUIPrepRecords(const Common::Path &name) {
 	IFF *iff = g_nancy->_resource->loadIFF(name);
 	if (!iff) {
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 56d64341c4b..aea75aaabe7 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -319,6 +319,11 @@ public:
 	Misc::ViewportFader *getViewportFader() { return _viewportFader; }
 	UI::NDUI::Manager *getNDUI() { return _ndui; }
 
+#ifdef ENABLE_NANCY_V4
+	// Nancy19: the scripts of the UI stream have made the panels
+	void onScriptedUILoaded();
+#endif
+
 	// Used from nancy2 onwards
 	void specialEffect(byte type, uint16 fadeToBlackTime, uint16 frameTime);
 	void specialEffect(byte type, uint16 totalTime, uint16 fadeToBlackTime, Common::Rect rect);
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index 38ecd547ffc..d3bce7cfb31 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -90,7 +90,7 @@ enum EventType {
 	kEventScrolled		= 15,
 	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
+	kEventUpdate		= 18,	// sent every frame while the panel is visible; "load" to Nancy19 scripts
 	kEventActionRecord	= 100	// sent by a scene's action record
 };
 
@@ -123,6 +123,23 @@ struct Action {
 	Common::String sender;		// the control that fired the event
 };
 
+// Nancy19 describes its UI with scripts in place of NDUI chunks. A control hands
+// its events to a function of the script that made it, which stands in for the
+// actions bound to them...
+class EventHandler {
+public:
+	virtual ~EventHandler() {}
+	virtual void onEvent(int32 eventType, bool isOwnEvent, double value, const Common::String &text) = 0;
+};
+
+// ...and a function of a panel's script stands in for the templates of the controls
+// that get created while the game runs
+class ObjectFactory {
+public:
+	virtual ~ObjectFactory() {}
+	virtual bool createObject(const Common::String &name, const Common::String &templateName) = 0;
+};
+
 // 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 5d5053b6b46..03b4ee903e6 100644
--- a/engines/nancy/ui/ndui/control.cpp
+++ b/engines/nancy/ui/ndui/control.cpp
@@ -145,6 +145,17 @@ void Control::fitHeightToText() {
 }
 
 void Control::fireEvents(int32 eventType, bool isOwnEvent, double value, const Common::String &text) {
+	if (_eventHandler) {
+		// What the handler sends comes from this control. The handler can go as
+		// far as taking the control down, so nothing of it gets used afterwards.
+		Manager *manager = _panel->getManager();
+		const Common::String previousSender = manager->getEventSender();
+		manager->setEventSender(_name);
+		_eventHandler->onEvent(eventType, isOwnEvent, value, text);
+		manager->setEventSender(previousSender);
+		return;
+	}
+
 	for (const Action &event : _events) {
 		if (event.eventType != eventType && !(isOwnEvent && event.eventType == kEventOwn)) {
 			continue;
@@ -270,7 +281,7 @@ void VarStatic::readExtra(Common::SeekableReadStream &stream) {
 
 bool VarStatic::doAction(const Action &action) {
 	if (action.type == kActionSetValue) {
-		setText(Common::String::format("%0*.*f", (int)_width, (int)_precision, action.value));
+		setText(_prefix + Common::String::format("%0*.*f", (int)_width, (int)_precision, action.value) + _suffix);
 		return true;
 	}
 
diff --git a/engines/nancy/ui/ndui/control.h b/engines/nancy/ui/ndui/control.h
index 7f42c482733..734ae14a0dc 100644
--- a/engines/nancy/ui/ndui/control.h
+++ b/engines/nancy/ui/ndui/control.h
@@ -38,10 +38,13 @@ class Panel;
 class Control {
 public:
 	Control(Panel *panel) : _panel(panel) {}
-	virtual ~Control() {}
+	virtual ~Control() { delete _eventHandler; }
 
 	void read(Common::SeekableReadStream &stream);
 
+	// Nancy19: the control's events go to the handler, which the control takes over
+	void setEventHandler(EventHandler *handler) { delete _eventHandler; _eventHandler = handler; }
+
 	// Returns true when the action was meant for this control
 	bool takeAction(const Action &action);
 
@@ -108,6 +111,7 @@ protected:
 	bool _enabled = true;
 	bool _autoHeight = false;	// controls without a height are as tall as their text
 	Common::Array<Action> _events;
+	EventHandler *_eventHandler = nullptr;
 	Common::Array<Element> _elements;
 };
 
@@ -149,12 +153,17 @@ class VarStatic : public Static {
 public:
 	VarStatic(Panel *panel) : Static(panel) {}
 
+	// Nancy19: the text that goes before and after the number
+	void setAffixes(const Common::String &prefix, const Common::String &suffix) { _prefix = prefix; _suffix = suffix; }
+
 protected:
 	void readExtra(Common::SeekableReadStream &stream) override;
 	bool doAction(const Action &action) override;
 
 	int32 _width = 0;
 	int32 _precision = 0;
+	Common::String _prefix;
+	Common::String _suffix;
 };
 
 } // End of namespace NDUI
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index db4becc3314..050e2d965dc 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -82,7 +82,9 @@ void Manager::takeEngineAction(const Action &action) {
 			takeAction(action.value != 0 ? kActionShow : kActionHide, "CCText", action.value);
 		}
 	} else if (action.target.equalsIgnoreCase("Engine_Index")) {
-		if (action.type == kActionSetText) {
+		if (action.type == kActionSetText && g_nancy->getGameType() >= kGameTypeNancy19) {
+			registerValueDisplay(action.text, action.sender);
+		} else if (action.type == kActionSetText) {
 			registerValueDisplay((int16)action.value, action.sender);
 		} else if (action.type == kActionDisable) {
 			for (uint i = 0; i < _valueDisplays.size(); ++i) {
@@ -305,6 +307,19 @@ void Manager::onSceneChange(uint16 sceneID) {
 // Nancy17 makes the buttons of the inventory out of the item table
 void Manager::createItemButtons() {
 	Panel *panel = findPanel("InvDialog");
+
+	// Nancy19 makes them one at a time, with the script of the panel
+	if (panel && g_nancy->getGameType() >= kGameTypeNancy19) {
+		const INVD *items = GetEngineData(INVD);
+		assert(items);
+
+		for (const INVD::Item &item : items->items) {
+			panel->addItemButton(item);
+		}
+
+		return;
+	}
+
 	if (panel && _templates.contains("InvButton")) {
 		panel->addItemButtons(_templates["InvButton"]);
 	}
@@ -321,8 +336,41 @@ void Manager::registerValueDisplay(int16 index, const Common::String &display) {
 	_valueDisplays.push_back(valueDisplay);
 }
 
+// A control keeps asking for its value, and goes on showing the one it asked for last
+void Manager::registerValueDisplay(const Common::String &valueName, const Common::String &display) {
+	for (ValueDisplay &valueDisplay : _valueDisplays) {
+		if (valueDisplay.name.equalsIgnoreCase(display)) {
+			if (!valueDisplay.valueName.equalsIgnoreCase(valueName)) {
+				valueDisplay.valueName = valueName;
+				valueDisplay.hasShownValue = false;
+			}
+
+			return;
+		}
+	}
+
+	ValueDisplay valueDisplay;
+	valueDisplay.valueName = valueName;
+	valueDisplay.name = display;
+	_valueDisplays.push_back(valueDisplay);
+}
+
 // Value displays follow the value table, and timer displays what is left of their timer
 void Manager::update() {
+	if (g_nancy->getGameType() >= kGameTypeNancy19) {
+		ScriptValueData *values = (ScriptValueData *)NancySceneState.getPuzzleData(ScriptValueData::getTag());
+		for (ValueDisplay &display : _valueDisplays) {
+			int32 value = values ? values->values.getValOrDefault(display.valueName, 0) : 0;
+			if (!display.hasShownValue || display.shownValue != value) {
+				display.hasShownValue = true;
+				display.shownValue = value;
+				takeAction(kActionSetValue, display.name, (double)value);
+			}
+		}
+
+		return;
+	}
+
 	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
 	if (table) {
 		for (ValueDisplay &display : _valueDisplays) {
diff --git a/engines/nancy/ui/ndui/manager.cpp b/engines/nancy/ui/ndui/manager.cpp
index 6f777e905e5..cd3256245a5 100644
--- a/engines/nancy/ui/ndui/manager.cpp
+++ b/engines/nancy/ui/ndui/manager.cpp
@@ -43,8 +43,9 @@ namespace NDUI {
 // Panels are drawn over everything else, in the order they were created
 static const uint16 kFirstPanelZ = 1000;
 
-// The cursor shown while the mouse is over the UI
+// The cursor shown while the mouse is over the UI; Nancy19 has it by name
 static const uint16 kUICursor = 14;
+static const char *const kUICursorName = "Menu";
 
 Manager::~Manager() {
 	for (Panel *panel : _panels) {
@@ -92,6 +93,24 @@ void Manager::load(const Common::String &name) {
 	delete iff;
 }
 
+Panel *Manager::addPanel(byte kind) {
+	Panel *panel = new Panel(this, (uint16)(kFirstPanelZ + _panels.size()), kind);
+	_panels.push_back(panel);
+	return panel;
+}
+
+void Manager::clearPanels() {
+	for (Panel *panel : _panels) {
+		delete panel;
+	}
+
+	_panels.clear();
+	_hoveredControl = nullptr;
+	_hoveredTooltip = nullptr;
+	_tooltipControl = nullptr;
+	_tooltipWindow.setVisible(false);
+}
+
 void Manager::registerGraphics() {
 	for (Panel *panel : _panels) {
 		panel->registerGraphics();
@@ -201,6 +220,16 @@ Manager::Request Manager::takeRequest() {
 
 void Manager::createObject(const Common::String &name, const Common::String &templateName, const Common::String &parentName) {
 	Panel *parent = findPanel(parentName);
+
+	// Nancy19 has the script of the panel make the control
+	if (parent && g_nancy->getGameType() >= kGameTypeNancy19) {
+		if (!parent->createObject(name, templateName)) {
+			warning("Couldn't create UI object %s from %s in %s", name.c_str(), templateName.c_str(), parentName.c_str());
+		}
+
+		return;
+	}
+
 	if (!parent || !_templates.contains(templateName)) {
 		warning("Couldn't create UI object %s from %s in %s", name.c_str(), templateName.c_str(), parentName.c_str());
 		return;
@@ -275,7 +304,12 @@ void Manager::handleInput(NancyInput &input) {
 	updateTooltip(input.screenMousePos);
 
 	if (isOverUI) {
-		g_nancy->_cursor->setCursorType((CursorManager::CursorType)kUICursor, true, isOverControl);
+		int cursor = kUICursor;
+		if (g_nancy->getGameType() >= kGameTypeNancy19) {
+			cursor = MAX(g_nancy->_cursor->getCursorTypeByName(kUICursorName), 0);
+		}
+
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursor, true, isOverControl);
 		input.eatMouseInput();
 	}
 }
@@ -284,6 +318,13 @@ const Graphics::ManagedSurface *Manager::getTexture(const Common::String &name)
 	Graphics::ManagedSurface *texture = _textures.getValOrDefault(name, nullptr);
 	if (!texture) {
 		texture = new Graphics::ManagedSurface();
+
+		// The textures the engine makes are empty until it does
+		if (name.hasPrefixIgnoreCase("Engine_")) {
+			_textures.setVal(name, texture);
+			return nullptr;
+		}
+
 		if (!g_nancy->_resource->loadImage(Common::Path(name), *texture)) {
 			warning("Couldn't load UI texture %s", name.c_str());
 		}
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index c17e1de4778..616176d5dbc 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -102,6 +102,15 @@ public:
 	// Creates every panel in the IFF with the given name, and in the ones it includes
 	void load(const Common::String &name);
 
+	// Nancy19 panels are made by scripts, which fill them in afterwards. They have to
+	// be taken down before the scripts go away, since their controls call into them.
+	Panel *addPanel(byte kind);
+	void clearPanels();
+
+	// The control whose event is being handled by a script
+	const Common::String &getEventSender() const { return _eventSender; }
+	void setEventSender(const Common::String &sender) { _eventSender = sender; }
+
 	void takeAction(const Action &action);
 	void takeAction(int32 type, const Common::String &target, double value);
 	void takeAction(int32 type, const Common::String &target, const Common::String &text);
@@ -138,6 +147,8 @@ public:
 
 	// Displays a value of the value table, until it gets unregistered
 	void registerValueDisplay(int16 index, const Common::String &display);
+	// Nancy19 has the values by name
+	void registerValueDisplay(const Common::String &valueName, const Common::String &display);
 
 	// What scenes and the player changed about the panels and controls, kept in saved games
 	void saveState(Common::Array<UIStateData::Change> &changes) const;
@@ -154,9 +165,10 @@ public:
 private:
 	struct ValueDisplay {
 		int16 index = 0;
+		Common::String valueName;	// Nancy19
 		Common::String name;
 		bool hasShownValue = false;
-		int16 shownValue = 0;
+		int32 shownValue = 0;
 	};
 
 	typedef Common::HashMap<Common::String, Common::String, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> StringMap;
@@ -187,6 +199,7 @@ private:
 	InvButton *findItemButton(int16 itemID) const;
 
 	Common::Array<Panel *> _panels;
+	Common::String _eventSender;
 	Common::String _caption;
 	Common::HashMap<Common::String, Graphics::ManagedSurface *, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _textures;
 	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _templates;
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 618c23cbfa0..899850f813d 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -49,6 +49,7 @@ Panel::Panel(Manager *manager, uint16 z, byte kind) : RenderObject(z), _manager(
 Panel::~Panel() {
 	delete _background;
 	delete _tooltip;
+	delete _objectFactory;
 
 	for (Control *control : _controls) {
 		delete control;
@@ -125,6 +126,7 @@ void Panel::place() {
 
 	moveTo(screenPosition);
 	_drawSurface.create(screenPosition.width(), screenPosition.height(), g_nancy->_graphics->getTransparentPixelFormat());
+	_isPlaced = true;
 
 	setVisible(_showCount > 0);
 	growToControls();
@@ -154,6 +156,22 @@ void Panel::growToControls() {
 	}
 }
 
+bool Panel::createObject(const Common::String &name, const Common::String &templateName) {
+	return _objectFactory && _objectFactory->createObject(name, templateName);
+}
+
+// The script adds a button like any other, which becomes the item's on its way in
+void Panel::addItemButton(const INVD::Item &item) {
+	_kind = kObjectInvPanel;
+	_newItem = &item;
+
+	if (!createObject(item.name, "InvButton")) {
+		warning("Couldn't create the UI button of item %s", item.name.c_str());
+	}
+
+	_newItem = nullptr;
+}
+
 void Panel::readControl(Common::SeekableReadStream &stream) {
 	int32 type = stream.readSint32LE();
 	addNewControl(type, stream);
@@ -163,6 +181,15 @@ Control *Panel::addNewControl(int32 type, Common::SeekableReadStream &stream) {
 	Control *control = nullptr;
 	switch (type) {
 	case kControlButton:
+		if (_newItem) {
+			InvButton *button = new InvButton(this);
+			button->read(stream);
+			button->setItem(*_newItem);
+			button->setVisible(false);
+			addControl(button);
+			return button;
+		}
+
 		control = new Button(this);
 		break;
 	case kControlStatic:
@@ -326,6 +353,11 @@ void Panel::arrangeControls() {
 }
 
 bool Panel::takeAction(const Action &action) {
+	// A panel whose script never got to its background has nothing to act on
+	if (!_isPlaced) {
+		return false;
+	}
+
 	if (!getName().equalsIgnoreCase(action.target)) {
 		for (Control *control : _controls) {
 			if (control->takeAction(action)) {
@@ -407,6 +439,10 @@ void Panel::showItem(int16 itemID, bool show) {
 }
 
 void Panel::saveState(Common::Array<UIStateData::Change> &changes) const {
+	if (!_savesState || !_isPlaced) {
+		return;
+	}
+
 	for (const Control *control : _controls) {
 		addStateChanges(changes, control->getName(), control->getInitialState(), control->isVisible(), control->isEnabled());
 	}
@@ -415,6 +451,10 @@ void Panel::saveState(Common::Array<UIStateData::Change> &changes) const {
 }
 
 void Panel::finishBlending() {
+	if (!_isPlaced) {
+		return;
+	}
+
 	_background->update(kFinishBlendingTime);
 	for (Control *control : _controls) {
 		control->update(kFinishBlendingTime);
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index ca969cf7d84..0b1cdf8f549 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -22,6 +22,7 @@
 #ifndef NANCY_UI_NDUI_PANEL_H
 #define NANCY_UI_NDUI_PANEL_H
 
+#include "engines/nancy/enginedata.h"
 #include "engines/nancy/renderobject.h"
 
 #include "engines/nancy/ui/ndui/common.h"
@@ -62,10 +63,22 @@ public:
 	void readScrolling(Common::SeekableReadStream &stream);
 	void readBackground(Common::SeekableReadStream &stream);
 	void place();
+	bool isPlaced() const { return _isPlaced; }
+	Static *getBackground() const { return _background; }
 
 	// A panel without controls to arrange is as large as it takes to draw them all
 	void growToControls();
 
+	// Nancy19: makes the controls that get created while the game runs. The panel takes it over.
+	void setObjectFactory(ObjectFactory *factory) { delete _objectFactory; _objectFactory = factory; }
+	bool createObject(const Common::String &name, const Common::String &templateName);
+
+	// Nancy19: has the panel's script make the button of an item
+	void addItemButton(const INVD::Item &item);
+
+	// Whether saved games keep what was done to the panel and its controls
+	void setSavesState(bool savesState) { _savesState = savesState; }
+
 	// 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);
@@ -125,6 +138,10 @@ private:
 
 	Manager *_manager;
 	byte _kind;
+	bool _isPlaced = false;
+	bool _savesState = true;
+	ObjectFactory *_objectFactory = nullptr;
+	const INVD::Item *_newItem = nullptr;	// the item whose button is being made
 
 	uint32 _backgroundColor = 0;
 	Static *_background = nullptr;


Commit: 7d4908a3a3a7a27d1711e5fd3d13b4193173d0ae
    https://github.com/scummvm/scummvm/commit/7d4908a3a3a7a27d1711e5fd3d13b4193173d0ae
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:27+03:00

Commit Message:
NANCY: NANCY19: Show the captions of sounds

What a sound says is kept in the texts under the name of the sound.
A Sound record shows it while the sound plays, unless it is told not
to with "useCC", and takes it down again when the sound is over,
unless another caption has replaced it by then.

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 a3299dc9d2f..c07c84c006e 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -35,7 +35,10 @@
 #include "engines/nancy/script/scenecontext.h"
 #include "engines/nancy/script/types.h"
 
+#include "engines/nancy/state/scene.h"
+
 #include "engines/nancy/ui/viewport.h"
+#include "engines/nancy/ui/ndui/manager.h"
 
 namespace Nancy {
 namespace Script {
@@ -839,6 +842,7 @@ void SoundRecord::readTable(int tableIndex) {
 
 	readField(L, tableIndex, "volume", _volume);
 	readField(L, tableIndex, "loop", _isLooping);
+	readField(L, tableIndex, "useCC", _usesCaption);
 
 	if (pushField(L, tableIndex, "sounds")) {
 		readStringList(L, -1, _names);
@@ -866,6 +870,13 @@ void SoundRecord::runOnce() {
 	// The record is done once the sound has played through, whether it loops or not
 	const double length = _isPlaying ? g_nancy->_sound->getLength(_sound).msecs() / 1000.0 : 0.0;
 	_endTime = getContext().getTime() + length;
+
+	// What the sound says is kept under its name
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	_caption.clear();
+	if (_isPlaying && _usesCaption && ui && findText(_sound.name, _caption)) {
+		ui->setCaption(_caption);
+	}
 }
 
 void SoundRecord::run(double sceneTime, double elapsedTime) {
@@ -875,6 +886,13 @@ void SoundRecord::run(double sceneTime, double elapsedTime) {
 	}
 
 	if (_endTime <= sceneTime || _wasPreempted) {
+		// The caption goes with the sound, unless another one has replaced it
+		UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+		if (!_caption.empty() && ui && ui->getCaption() == _caption) {
+			ui->setCaption(Common::String());
+		}
+
+		_caption.clear();
 		finish();
 	}
 }
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 20c395f8c9f..4810fe5d6ac 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -280,8 +280,10 @@ private:
 	uint16 _channel = 14;
 	double _volume = 1.0;
 	bool _isLooping = false;
+	bool _usesCaption = true;
 
 	SoundDescription _sound;
+	Common::String _caption;	// what the sound says, while it is the one on screen
 	bool _isPlaying = false;
 	bool _wasPreempted = false;
 	double _endTime = 0.0;


Commit: 012bf2372217047cdbc3bc89f8a5b5e0f0aa087d
    https://github.com/scummvm/scummvm/commit/012bf2372217047cdbc3bc89f8a5b5e0f0aa087d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:29+03:00

Commit Message:
NANCY: NANCY19: Implement conversations

Adds the VideoConvo and SoundConvo records, and the responses that
scripts make for them with ConvoResponse:New and Convo:Add.

A video conversation shows the character with a movie for each of
its parts, picking their frames from an XSheet. A sound conversation
plays its sounds one after another.

Changed paths:
  A engines/nancy/script/convo.cpp
  A engines/nancy/script/convo.h
    engines/nancy/module.mk
    engines/nancy/script/api.cpp
    engines/nancy/script/record.cpp
    engines/nancy/script/record.h
    engines/nancy/script/records.cpp
    engines/nancy/script/scenecontext.cpp
    engines/nancy/script/scenecontext.h
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/manager.h


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index d16b1e9a187..d28bafd29ba 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -187,6 +187,7 @@ ifdef ENABLE_NANCY_V4
 MODULE_OBJS += \
   script/api.o \
   script/bootapi.o \
+  script/convo.o \
   script/gameapi.o \
   script/luabind.o \
   script/luastate.o \
diff --git a/engines/nancy/script/api.cpp b/engines/nancy/script/api.cpp
index 0dece898cb4..3972debe3f5 100644
--- a/engines/nancy/script/api.cpp
+++ b/engines/nancy/script/api.cpp
@@ -23,6 +23,7 @@
 #include "engines/nancy/nancy.h"
 
 #include "engines/nancy/script/api.h"
+#include "engines/nancy/script/convo.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) {
 	registerInventoryAPI(L);
 	registerGameAPI(L);
 	registerUIAPI(L);
+	registerConvoAPI(L);
 	registerRecordAPI(L);
 }
 
diff --git a/engines/nancy/script/convo.cpp b/engines/nancy/script/convo.cpp
new file mode 100644
index 00000000000..eefc7a5a377
--- /dev/null
+++ b/engines/nancy/script/convo.cpp
@@ -0,0 +1,564 @@
+/* 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/archive.h"
+#include "common/stream.h"
+#include "common/textconsole.h"
+
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/script/api.h"
+#include "engines/nancy/script/convo.h"
+#include "engines/nancy/script/luabind.h"
+#include "engines/nancy/script/luastate.h"
+#include "engines/nancy/script/scenecontext.h"
+
+#include "engines/nancy/state/scene.h"
+
+namespace Nancy {
+namespace Script {
+
+static const char *const kConvoResponseType = "ND.ConvoResponse";
+static const char *const kNavLogicType = "ND.NavLogic";
+
+static const uint16 kCharacterChannel = 24;
+static const uint16 kPlayerChannel = 25;
+static const uint16 kConvoVolume = 85;
+
+// The parts of an XSheet
+static const uint kXSheetFrameCountOffset = 0x22;
+static const uint kXSheetMaxTrees = 4;
+static const uint kXSheetNameSize = 33;
+
+// The record a conversation or a response keeps for later, which has to be a NavLogic
+static Record *checkHeldRecord(lua_State *L, int index) {
+	Record *record = checkRecord(L, index);
+	if (strcmp(record->getTypeName(), kNavLogicType) != 0) {
+		luaL_typerror(L, index, kNavLogicType);
+	}
+
+	record->hold();
+	return record;
+}
+
+ConvoRecord::ConvoRecord(SceneContext &context, bool isVideo) :
+		Record(context, getRecordTypeName(isVideo ? kRecordVideoConvo : kRecordSoundConvo)),
+		_isVideo(isVideo) {}
+
+ConvoRecord::~ConvoRecord() {
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	if (_hasDialog && ui) {
+		ui->removeEngineTarget("Engine_Convo");
+		ui->endConversation();
+	}
+
+	for (Tree *tree : _trees) {
+		delete tree;
+	}
+}
+
+void ConvoRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	if (pushField(L, tableIndex, "responses")) {
+		if (!lua_istable(L, -1)) {
+			luaL_error(L, "field %s must be a table", "responses");
+		}
+
+		const int numResponses = (int)lua_objlen(L, -1);
+		for (int i = 1; i <= numResponses; ++i) {
+			lua_rawgeti(L, -1, i);
+			_responses.push_back(*(ConvoResponse **)checkUserData(L, -1, kConvoResponseType));
+			lua_pop(L, 1);
+		}
+
+		lua_pop(L, 1);
+	}
+
+	// The sound of a video conversation is named after its XSheet
+	Common::String xsheetName;
+	if (readField(L, tableIndex, "xsheet", xsheetName)) {
+		_soundNames.push_back(xsheetName);
+	}
+
+	if (pushField(L, tableIndex, "sounds")) {
+		readStringList(L, -1, _soundNames);
+		lua_pop(L, 1);
+	}
+
+	if (pushField(L, tableIndex, "responseFiles")) {
+		readStringList(L, -1, _responseFiles);
+		lua_pop(L, 1);
+	}
+
+	if (pushField(L, tableIndex, "OnDone")) {
+		if (!lua_isfunction(L, -1)) {
+			luaL_error(L, "field %s must be a function", "OnDone");
+		}
+
+		lua_pop(L, 1);
+	}
+
+	if (pushField(L, tableIndex, "default")) {
+		_default = checkHeldRecord(L, -1);
+		lua_pop(L, 1);
+	}
+
+	if (_soundNames.empty()) {
+		luaL_error(L, "A conversation needs an xsheet or sounds");
+	}
+}
+
+bool ConvoRecord::loadXSheet(const Common::String &name) {
+	Common::SeekableReadStream *xsheet = SearchMan.createReadStreamForMember(Common::Path(name));
+	if (!xsheet) {
+		warning("Could not open XSheet file %s", name.c_str());
+		return false;
+	}
+
+	xsheet->seek(kXSheetFrameCountOffset);
+	_numFrames = xsheet->readUint16LE();
+	const uint numTrees = MIN<uint>(xsheet->readUint16LE(), kXSheetMaxTrees);
+
+	Common::Array<Common::String> treeNames(kXSheetMaxTrees);
+	for (Common::String &treeName : treeNames) {
+		readFilename(*xsheet, treeName);
+	}
+
+	// Where each part goes in the viewport; the right and bottom edge are left out
+	Common::Array<Common::Rect> treeRects;
+	readRectArray(*xsheet, treeRects, kXSheetMaxTrees);
+
+	_framesPerSecond = xsheet->readUint32LE();
+
+	for (uint i = 0; i < numTrees; ++i) {
+		Tree *tree = new Tree();
+		tree->frames.resize(_numFrames);
+		_trees.push_back(tree);
+	}
+
+	// Every frame has a movie frame for each of the parts there can be
+	for (uint frame = 0; frame < _numFrames; ++frame) {
+		for (uint i = 0; i < kXSheetMaxTrees; ++i) {
+			const int32 movieFrame = xsheet->readSint32LE();
+			if (i < numTrees) {
+				_trees[i]->frames[frame] = movieFrame;
+			}
+		}
+
+		xsheet->skip(8);
+	}
+
+	delete xsheet;
+
+	for (uint i = 0; i < numTrees; ++i) {
+		Tree &tree = *_trees[i];
+		Common::Rect dest = treeRects[i];
+		if (!dest.isEmpty()) {
+			--dest.right;
+			--dest.bottom;
+		}
+
+		if (dest.isEmpty() || !tree.player.loadFile(Common::Path(treeNames[i]))) {
+			warning("Could not load conversation movie %s of XSheet %s", treeNames[i].c_str(), name.c_str());
+			return false;
+		}
+
+		// The movies come with an alpha plane, which they are drawn by
+		tree.dest = dest;
+		tree.graphic.init();
+		tree.graphic.moveTo(dest);
+		tree.graphic.setTransparent(false);
+		tree.graphic.setVisible(false);
+		tree.graphic.registerGraphics();
+
+		showMovieFrame(tree, (tree.frames.empty() || isTreeFreeRunning(tree)) ? 0 : tree.frames[0]);
+	}
+
+	onZChanged();
+	return true;
+}
+
+// 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());
+	}
+}
+
+void ConvoRecord::updateGraphics() {
+	for (Tree *tree : _trees) {
+		tree->graphic.setVisible(hasStarted());
+	}
+}
+
+// The movies are padded to a multiple of 16 pixels; the picture itself is as
+// large as the place it goes to
+void ConvoRecord::drawTree(Tree &tree, const Graphics::Surface &frame) {
+	const Common::Rect picture(MIN<int>(frame.w, tree.dest.width()), MIN<int>(frame.h, tree.dest.height()));
+	GraphicsManager::copyToManaged(frame.getSubArea(picture), tree.graphic._drawSurface);
+	tree.graphic.setNeedsRedraw(true);
+}
+
+void ConvoRecord::showMovieFrame(Tree &tree, int32 movieFrame) {
+	if (movieFrame == tree.shownFrame) {
+		return;
+	}
+
+	const Graphics::Surface *frame = tree.player.decodeNextFrame(movieFrame);
+	if (frame) {
+		drawTree(tree, *frame);
+		tree.shownFrame = movieFrame;
+	}
+}
+
+bool ConvoRecord::isTreeFreeRunning(const Tree &tree) const {
+	return _frame < tree.frames.size() && tree.frames[_frame] == kFreeRunning;
+}
+
+void ConvoRecord::showFrame(uint frame) {
+	if (frame == _frame) {
+		return;
+	}
+
+	_frame = frame;
+
+	for (Tree *tree : _trees) {
+		if (frame < tree->frames.size() && tree->frames[frame] != kFreeRunning) {
+			showMovieFrame(*tree, tree->frames[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 {
+	for (const Tree *tree : _trees) {
+		if (isTreeFreeRunning(*tree) && !tree->player.endOfVideo()) {
+			return false;
+		}
+	}
+
+	return _numFrames == 0 || _frame == _numFrames - 1;
+}
+
+void ConvoRecord::playSound(const Common::String &name, uint16 channel) {
+	_sound = SoundDescription();
+	_sound.name = name;
+	_sound.channelID = channel;
+	_sound.numLoops = 1;
+	_sound.volume = kConvoVolume;
+
+	g_nancy->_sound->loadSound(_sound);
+	g_nancy->_sound->playSound(_sound);
+	_hasSoundStarted = g_nancy->_sound->isSoundPlaying(_sound);
+}
+
+bool ConvoRecord::isResponseOnOffer(const ConvoResponse &response) {
+	if (response.activeFunction < 0) {
+		return true;
+	}
+
+	lua_State *L = getLua();
+	lua_rawgeti(L, LUA_REGISTRYINDEX, response.activeFunction);
+	if (!LuaState::call(L, 0, 1, "Error in active function")) {
+		return false;
+	}
+
+	const bool isOnOffer = lua_toboolean(L, -1) != 0;
+	lua_pop(L, 1);
+	return isOnOffer;
+}
+
+void ConvoRecord::runOnce() {
+	lua_State *L = getLua();
+	SceneContext &context = getContext();
+
+	// A scene only gets to have one conversation
+	if (context.getConvo()) {
+		lua_pushnil(L);
+		storeValue("OnDone");
+		finish();
+		return;
+	}
+
+	context.setConvo(this);
+
+	if (_isVideo) {
+		loadXSheet(_soundNames[0]);
+	}
+
+	// The scripts named by the record add the responses they have to its own
+	for (const Common::String &fileName : _responseFiles) {
+		context.include(fileName);
+	}
+
+	for (ConvoResponse *response : _responses) {
+		if (isResponseOnOffer(*response)) {
+			_offeredResponses.push_back(response);
+		}
+	}
+
+	if (_offeredResponses.empty() && !_default) {
+		warning("Conversation %s has neither responses nor a default", _soundNames[0].c_str());
+	}
+
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	if (ui) {
+		Common::String caption;
+		findText(_soundNames[0], caption);
+
+		Common::Array<Common::String> responseTexts;
+		for (const ConvoResponse *response : _offeredResponses) {
+			Common::String text = response->soundName;
+			if (!findText(response->soundName, text)) {
+				warning("Missing convo text for %s", response->soundName.c_str());
+			}
+
+			responseTexts.push_back(text);
+		}
+
+		ui->startConversation(caption, responseTexts);
+		ui->addEngineTarget("Engine_Convo", this);
+		_hasDialog = true;
+	}
+}
+
+void ConvoRecord::onEngineAction(const UI::NDUI::Action &action) {
+	const int index = (int)action.value;
+	if (action.type == UI::NDUI::kActionSetValue && _pickedResponse < 0 && index >= 0 && index < (int)_offeredResponses.size()) {
+		_pickedResponse = index;
+	}
+}
+
+void ConvoRecord::run(double sceneTime, double elapsedTime) {
+	if (isDone()) {
+		// There was a conversation in the scene already
+		return;
+	}
+
+	const bool isSoundPlaying = g_nancy->_sound->isSoundPlaying(_sound);
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+
+	switch (_state) {
+	case kStartLine:
+		playSound(_soundNames[_curSound], kCharacterChannel);
+
+		if (_curSound > 0 && ui) {
+			Common::String caption;
+			findText(_soundNames[_curSound], caption);
+			ui->takeAction(UI::NDUI::kActionSetText, "ConvoCCText", caption);
+		}
+
+		_state = kWaitForLine;
+		break;
+	case kWaitForLine:
+		// A sound that is missing counts as one that is over
+		if (_hasSoundStarted && !isSoundPlaying) {
+			break;
+		}
+
+		_lineStartTime = sceneTime;
+		for (Tree *tree : _trees) {
+			// The parts that play by themselves keep their own time
+			if (isTreeFreeRunning(*tree)) {
+				tree->player.start();
+			}
+		}
+
+		_state = kPlayLine;
+		break;
+	case kPlayLine: {
+		for (Tree *tree : _trees) {
+			if (isTreeFreeRunning(*tree) && tree->player.needsUpdate()) {
+				const Graphics::Surface *movieFrame = tree->player.decodeNextFrame();
+				if (movieFrame) {
+					drawTree(*tree, *movieFrame);
+				}
+			}
+		}
+
+		if (_isVideo && _numFrames > 0) {
+			// The frames keep up with the sound, and go on one at a time after it
+			uint frame = _frame + 1;
+			if (isSoundPlaying) {
+				frame = (uint)MAX(0.0, (sceneTime - _lineStartTime) * _framesPerSecond);
+			}
+
+			showFrame(MIN<uint>(frame, _numFrames - 1));
+		}
+
+		// The player has to pick a response before anything else happens. The
+		// sounds of a conversation without a video play on in the meantime.
+		if (!_offeredResponses.empty() && _pickedResponse < 0) {
+			if (!_isVideo && !isSoundPlaying && _curSound + 1 < _soundNames.size()) {
+				++_curSound;
+				_state = kStartLine;
+			}
+
+			break;
+		}
+
+		if (_isVideo ? isVideoDone() : !isSoundPlaying) {
+			if (!_isVideo && _curSound + 1 < _soundNames.size()) {
+				++_curSound;
+				_state = kStartLine;
+			} else {
+				_state = kWaitForLineEnd;
+			}
+		}
+
+		break;
+	}
+	case kWaitForLineEnd:
+		if (!isSoundPlaying) {
+			_state = kStartResponse;
+		}
+
+		break;
+	case kStartResponse:
+		if (_pickedResponse < 0) {
+			conclude();
+			break;
+		}
+
+		playSound(_offeredResponses[_pickedResponse]->soundName, kPlayerChannel);
+		if (ui) {
+			ui->disableOtherResponses((uint)_pickedResponse);
+		}
+
+		_state = _hasSoundStarted ? kWaitForResponse : kPlayResponse;
+		break;
+	case kWaitForResponse:
+		if (isSoundPlaying) {
+			_state = kPlayResponse;
+		}
+
+		break;
+	case kPlayResponse:
+		if (!isSoundPlaying) {
+			conclude();
+		}
+
+		break;
+	}
+}
+
+// The record of the response that was picked runs, or the default without one
+void ConvoRecord::conclude() {
+	g_nancy->_sound->stopSound(kCharacterChannel);
+	g_nancy->_sound->stopSound(kPlayerChannel);
+
+	Record *next = _pickedResponse >= 0 ? _offeredResponses[_pickedResponse]->onFinished : _default;
+	if (next) {
+		next->release();
+	}
+
+	finish();
+}
+
+// ConvoResponse:New{ sounds = <name>, onFinished = <NavLogic>, active = <function> }
+static int convoResponseNew(lua_State *L) {
+	if (!lua_istable(L, 2)) {
+		return luaL_argerror(L, 2, "Can only make a ConvoResponse from a table");
+	}
+
+	SceneContext *context = LuaState::getContext(L);
+	if (!context) {
+		return luaL_error(L, "A ConvoResponse can only be made in a scene");
+	}
+
+	Common::Array<Common::String> soundNames;
+	if (pushField(L, 2, "sounds")) {
+		if (!lua_isstring(L, -1) && !lua_istable(L, -1)) {
+			return luaL_error(L, "'sounds' field in ConvoResponse must be a string or a table");
+		}
+
+		readStringList(L, -1, soundNames);
+		lua_pop(L, 1);
+	}
+
+	if (soundNames.empty()) {
+		return luaL_error(L, "'sounds' table must have at least one field");
+	}
+
+	ConvoResponse *response = new ConvoResponse();
+	context->addConvoResponse(response);
+	response->soundName = soundNames[0];
+
+	if (pushField(L, 2, "onFinished")) {
+		response->onFinished = checkHeldRecord(L, -1);
+		lua_pop(L, 1);
+	}
+
+	if (pushField(L, 2, "active")) {
+		if (!lua_isfunction(L, -1)) {
+			return luaL_argerror(L, 2, "'active' field in ConvoResponse must be a function");
+		}
+
+		response->activeFunction = luaL_ref(L, LUA_REGISTRYINDEX);
+	}
+
+	*(ConvoResponse **)pushUserData(L, sizeof(ConvoResponse *), kConvoResponseType) = response;
+	return 1;
+}
+
+// Convo:Add(response), for the scripts a conversation runs to get its responses
+static int convoAdd(lua_State *L) {
+	ConvoResponse *response = *(ConvoResponse **)checkUserData(L, 2, kConvoResponseType);
+
+	SceneContext *context = LuaState::getContext(L);
+	ConvoRecord *convo = context ? context->getConvo() : nullptr;
+	if (!convo) {
+		return luaL_error(L, "There is no conversation to add a response to");
+	}
+
+	convo->addResponse(response);
+	return 0;
+}
+
+void registerConvoAPI(lua_State *L) {
+	static const luaL_Reg kResponseFunctions[] = {
+		{ nullptr, nullptr }
+	};
+
+	static const luaL_Reg kResponseHelperFunctions[] = {
+		{ "__index", indexFunctions },
+		{ "New", convoResponseNew },
+		{ nullptr, nullptr }
+	};
+
+	static const luaL_Reg kConvoFunctions[] = {
+		{ "__index", indexFunctions },
+		{ "Add", convoAdd },
+		{ nullptr, nullptr }
+	};
+
+	registerType(L, kConvoResponseType, kResponseFunctions);
+	registerGlobal(L, "ConvoResponse", "ND.ConvoResponseHelper", kResponseHelperFunctions);
+	registerGlobal(L, "Convo", "ND.Convo", kConvoFunctions);
+}
+
+} // End of namespace Script
+} // End of namespace Nancy
diff --git a/engines/nancy/script/convo.h b/engines/nancy/script/convo.h
new file mode 100644
index 00000000000..674514826b9
--- /dev/null
+++ b/engines/nancy/script/convo.h
@@ -0,0 +1,138 @@
+/* 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_CONVO_H
+#define NANCY_SCRIPT_CONVO_H
+
+#include "common/array.h"
+#include "common/str.h"
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+
+#include "engines/nancy/script/records.h"
+
+#include "engines/nancy/ui/ndui/manager.h"
+
+namespace Nancy {
+namespace Script {
+
+// Something the player can say in a conversation: the sound of it, whose name is
+// also the key of its text, and the record that runs once it has been said.
+// A response can come with a function that says whether it is on offer.
+struct ConvoResponse {
+	Common::String soundName;
+	Record *onFinished = nullptr;
+	int activeFunction = -1;	// in the registry of the scene's scripts
+};
+
+// A line of a conversation: a character says something, the player picks one of
+// the responses on offer, says it, and the record that goes with the response
+// runs, which as a rule moves on to the scene with the next line. A line without
+// responses runs its default record when it is over.
+//
+// A video conversation shows the character with movies, one for each part of it
+// (body, head). An XSheet with the name of the line's sound lists the parts, and
+// the frame of each part's movie to show over time; a part without a frame number
+// plays through by itself. A sound conversation plays its sounds one after another.
+//
+// Only one conversation runs in a scene: the ones that turn active afterwards are
+// done at once. Its responses stay up until the scene goes away.
+class ConvoRecord : public Record, public UI::NDUI::EngineTarget {
+public:
+	ConvoRecord(SceneContext &context, bool isVideo);
+	virtual ~ConvoRecord();
+
+	void updateGraphics() override;
+
+	// Responses can be added while the scripts named by the record are run
+	void addResponse(ConvoResponse *response) { _responses.push_back(response); }
+
+	// The UI reports the response the player picked
+	void onEngineAction(const UI::NDUI::Action &action) override;
+
+protected:
+	void readTable(int tableIndex) override;
+	void runOnce() override;
+	void run(double sceneTime, double elapsedTime) override;
+	void onZChanged() override;
+
+private:
+	enum State {
+		kStartLine,
+		kWaitForLine,		// until the sound has started
+		kPlayLine,
+		kWaitForLineEnd,
+		kStartResponse,
+		kWaitForResponse,	// until the sound has started
+		kPlayResponse
+	};
+
+	// One part of the character
+	struct Tree {
+		MoviePlayer player;
+		RecordGraphic graphic;
+		Common::Rect dest;
+		Common::Array<int32> frames;	// the movie frame to show at each frame of the XSheet
+		int32 shownFrame = -1;
+	};
+
+	static const int32 kFreeRunning = -1;
+
+	bool loadXSheet(const Common::String &name);
+	void showFrame(uint frame);
+	void showMovieFrame(Tree &tree, int32 movieFrame);
+	void drawTree(Tree &tree, const Graphics::Surface &frame);
+	bool isTreeFreeRunning(const Tree &tree) const;
+	bool isVideoDone() const;
+
+	void playSound(const Common::String &name, uint16 channel);
+	bool isResponseOnOffer(const ConvoResponse &response);
+	void conclude();
+
+	bool _isVideo;
+	Common::Array<Common::String> _soundNames;
+	Common::Array<Common::String> _responseFiles;
+	Common::Array<ConvoResponse *> _responses;
+	Record *_default = nullptr;
+
+	Common::Array<Tree *> _trees;
+	uint _numFrames = 0;
+	uint32 _framesPerSecond = 0;
+	uint _frame = 0;
+
+	State _state = kStartLine;
+	uint _curSound = 0;
+	SoundDescription _sound;
+	bool _hasSoundStarted = false;
+	double _lineStartTime = 0.0;
+
+	Common::Array<ConvoResponse *> _offeredResponses;
+	int _pickedResponse = -1;
+	bool _hasDialog = false;
+};
+
+void registerConvoAPI(lua_State *L);
+
+} // End of namespace Script
+} // End of namespace Nancy
+
+#endif // NANCY_SCRIPT_CONVO_H
diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index 6d27f084624..84fdf5293b2 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -160,6 +160,17 @@ void Record::update(double sceneTime, double elapsedTime) {
 	}
 }
 
+void Record::hold() {
+	_isActive = false;
+	_hasActiveFunction = false;
+}
+
+void Record::release() {
+	_isActive = true;
+	_hasActiveFunction = false;
+	update(0.0, 0.0);
+}
+
 void Record::finish() {
 	_isDone = true;
 	callFunction("OnDone");
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index 60469fb8e7e..c2166493608 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -91,6 +91,11 @@ public:
 	// Makes a record that ran start over. Only some kinds can.
 	bool reset();
 
+	// A record another one keeps for later stays out of the way until it is
+	// released, and then runs right away
+	void hold();
+	void release();
+
 	bool isActive() const { return _isActive && !_isDone; }
 	bool isDone() const { return _isDone; }
 	bool hasStarted() const { return _hasStarted; }
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index c07c84c006e..27400517f03 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -30,6 +30,7 @@
 #include "engines/nancy/sound.h"
 
 #include "engines/nancy/script/api.h"
+#include "engines/nancy/script/convo.h"
 #include "engines/nancy/script/luabind.h"
 #include "engines/nancy/script/records.h"
 #include "engines/nancy/script/scenecontext.h"
@@ -86,6 +87,10 @@ Record *makeRecord(RecordKind kind, SceneContext &context) {
 		return new OverrideRecord(context);
 	case kRecordAnimOverlay:
 		return new AnimOverlayRecord(context);
+	case kRecordVideoConvo:
+		return new ConvoRecord(context, true);
+	case kRecordSoundConvo:
+		return new ConvoRecord(context, false);
 	default:
 		// TODO: The Text and Particle records
 		return nullptr;
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index 9dc7f7a06bc..d5db647a961 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -26,6 +26,7 @@
 #include "engines/nancy/input.h"
 #include "engines/nancy/nancy.h"
 
+#include "engines/nancy/script/convo.h"
 #include "engines/nancy/script/luabind.h"
 #include "engines/nancy/script/luastate.h"
 #include "engines/nancy/script/record.h"
@@ -62,6 +63,10 @@ SceneContext::~SceneContext() {
 		delete record;
 	}
 
+	for (ConvoResponse *response : _convoResponses) {
+		delete response;
+	}
+
 	delete _lua;
 }
 
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index 60a4c0c27fb..612c3990b0e 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -38,6 +38,8 @@ namespace Script {
 
 class LuaState;
 class Record;
+class ConvoRecord;
+struct ConvoResponse;
 class SummaryRecord;
 
 // One scene of a stream in Nancy19 and up, where it takes the place of the action
@@ -79,6 +81,13 @@ public:
 	// The seconds the scene has been running for
 	double getTime() const { return _time; }
 
+	// The conversation of the scene, once one has begun
+	ConvoRecord *getConvo() const { return _convo; }
+	void setConvo(ConvoRecord *convo) { _convo = convo; }
+
+	// Keeps a response the scripts made for as long as the scene lasts
+	void addConvoResponse(ConvoResponse *response) { _convoResponses.push_back(response); }
+
 	// The panels a scene adds to the UI call into its scripts, so they go when it does
 	void setMakesUI() { _makesUI = true; }
 
@@ -110,6 +119,8 @@ private:
 	Common::Array<Record *> _records;
 	Common::Array<Record *> _newRecords;
 	SummaryRecord *_summary = nullptr;
+	ConvoRecord *_convo = nullptr;
+	Common::Array<ConvoResponse *> _convoResponses;
 
 	double _time = 0.0;
 	bool _hasRunOnce = false;
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index 050e2d965dc..7487d9266c2 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -558,6 +558,14 @@ void Manager::startConversation(const Common::String &caption, const Common::Arr
 	takeAction(kActionShow, "ConvoDialog", 0.0);
 }
 
+void Manager::disableOtherResponses(uint pickedResponse) {
+	for (uint i = 0; i < _responseNames.size(); ++i) {
+		if (i != pickedResponse) {
+			takeAction(kActionDisable, _responseNames[i], 0.0);
+		}
+	}
+}
+
 void Manager::endConversation() {
 	if (!_isInConversation) {
 		return;
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 616176d5dbc..98248ee3cab 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -144,6 +144,8 @@ public:
 	void addTasklistEntry(const Common::String &textKey, int16 flag);
 	void startConversation(const Common::String &caption, const Common::Array<Common::String> &responses);
 	void endConversation();
+	// Once the player has picked a response, the others can't be picked anymore
+	void disableOtherResponses(uint pickedResponse);
 
 	// Displays a value of the value table, until it gets unregistered
 	void registerValueDisplay(int16 index, const Common::String &display);


Commit: 946e40c2ab86793ccc2b42232e1cb3989985662e
    https://github.com/scummvm/scummvm/commit/946e40c2ab86793ccc2b42232e1cb3989985662e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:32+03:00

Commit Message:
NANCY: NANCY19: Let the player skip movies

Enhancement, as in the earlier games. Scripts tie sounds and flags
to the frames of a movie, so a skipped movie is played through a
frame at a time, without its sounds, instead of being cut off.

Changed paths:
    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


diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index c2166493608..16c6be280fa 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -126,6 +126,11 @@ public:
 	// Shows or hides what the record draws, once the records of a frame have run
 	virtual void updateGraphics() {}
 
+	// Enhancement: the player can skip what a scene plays. Whether the record is
+	// something that can be skipped, and what it does when the skipping begins.
+	virtual bool isSkippable() const { return false; }
+	virtual void onSkip() {}
+
 protected:
 	lua_State *getLua() const;
 
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index 27400517f03..ea6f14b6196 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -859,7 +859,26 @@ void SoundRecord::readTable(int tableIndex) {
 	}
 }
 
+// A sound that is playing while a movie gets skipped is cut short
+void SoundRecord::onSkip() {
+	if (isActive() && hasStarted() && !_isLooping) {
+		g_nancy->_sound->stopSound(_sound);
+		_endTime = 0.0;
+	}
+}
+
 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
+	if (getContext().isSkipping() && !_isLooping) {
+		_sound = SoundDescription();
+		_isPlaying = false;
+		_wasPreempted = false;
+		_caption.clear();
+		_endTime = getContext().getTime();
+		return;
+	}
+
 	_sound = SoundDescription();
 	_sound.name = _names[g_nancy->_randomSource->getRandomNumber(_names.size() - 1)];
 	_sound.channelID = _channel;
@@ -983,7 +1002,33 @@ void MovieRecord::runOnce() {
 	drawFrame();
 }
 
+// A movie that plays through and ends
+bool MovieRecord::isSkippable() const {
+	return isActive() && hasStarted() && !_isPaused && !_pausesOnLastFrame;
+}
+
 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);
+		}
+
+		if (hasEnded) {
+			_player.goToFrame(_lastFrame);
+			drawFrame();
+			_endTime = sceneTime;
+			finish();
+		}
+
+		return;
+	}
+
 	if (!_isPaused && _player.update()) {
 		drawFrame();
 	}
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 4810fe5d6ac..5a90d8b1fe4 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -268,6 +268,8 @@ class SoundRecord : public Record {
 public:
 	SoundRecord(SceneContext &context) : Record(context, "ND.SoundAR") {}
 
+	void onSkip() override;
+
 protected:
 	void readTable(int tableIndex) override;
 	void runOnce() override;
@@ -296,6 +298,7 @@ public:
 	MovieRecord(SceneContext &context) : Record(context, "ND.Movie") {}
 
 	void updateGraphics() override;
+	bool isSkippable() const override;
 
 	void setPaused(bool isPaused) { _isPaused = isPaused; }
 
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index d5db647a961..728a29e29e4 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -153,7 +153,52 @@ bool SceneContext::callSceneFunction(const char *name, int numArgs) {
 	return true;
 }
 
+// The frames a skipped movie moves on by every time the scene runs, and how
+// long each of them counts for
+static const uint kSkipFramesPerRun = 30;
+static const double kSkipFrameSeconds = 1.0 / 15.0;
+
+bool SceneContext::hasSkippableRecord() const {
+	for (const Record *record : _records) {
+		if (record->isSkippable()) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+bool SceneContext::skipMovies() {
+	if (_isSkipping || !hasSkippableRecord()) {
+		return false;
+	}
+
+	_isSkipping = true;
+	for (Record *record : _records) {
+		record->onSkip();
+	}
+
+	return true;
+}
+
 void SceneContext::run(double elapsedTime) {
+	runFrame(elapsedTime);
+
+	// The skipping is over with the movies, or with the scene
+	for (uint i = 0; _isSkipping && i < kSkipFramesPerRun; ++i) {
+		if (_hasChangedScene || !hasSkippableRecord()) {
+			_isSkipping = false;
+		} else {
+			runFrame(kSkipFrameSeconds);
+		}
+	}
+
+	for (Record *record : _records) {
+		record->updateGraphics();
+	}
+}
+
+void SceneContext::runFrame(double elapsedTime) {
 	lua_State *L = getLua();
 
 	_time += elapsedTime;
@@ -175,10 +220,6 @@ void SceneContext::run(double elapsedTime) {
 			record->update(_time, elapsedTime);
 		}
 	}
-
-	for (Record *record : _records) {
-		record->updateGraphics();
-	}
 }
 
 bool SceneContext::getViewportMouse(const NancyInput &input, ScriptPoint &point) {
@@ -292,6 +333,8 @@ void SceneContext::handleInput(NancyInput &input) {
 void SceneContext::changeScene(const Common::String &streamName, const Common::String &sceneName, uint16 frame) {
 	State::SceneStreamManager &streams = NancySceneState.getStreams();
 
+	_hasChangedScene = true;
+
 	// The scenes of the main stream go by number
 	if (sceneName.size() > 1 && tolower(sceneName[0]) == 's' && Common::isDigit(sceneName[1])) {
 		SceneChangeDescription scene;
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index 612c3990b0e..22995e706b0 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -81,6 +81,12 @@ public:
 	// The seconds the scene has been running for
 	double getTime() const { return _time; }
 
+	// Enhancement: skips the movies that are playing. They are played through
+	// a frame at a time, without their sounds, so that everything the scripts
+	// tie to them still happens. Returns false when there is nothing to skip.
+	bool skipMovies();
+	bool isSkipping() const { return _isSkipping; }
+
 	// The conversation of the scene, once one has begun
 	ConvoRecord *getConvo() const { return _convo; }
 	void setConvo(ConvoRecord *convo) { _convo = convo; }
@@ -106,6 +112,8 @@ public:
 
 private:
 	bool callSceneFunction(const char *name, int numArgs);
+	void runFrame(double elapsedTime);
+	bool hasSkippableRecord() const;
 	void pushSceneFunctions();
 	void sendMouseEvent(int event, const ScriptPoint &point, bool inViewport);
 	void sendKeyEvent(int event, Common::KeyCode key);
@@ -125,6 +133,8 @@ private:
 	double _time = 0.0;
 	bool _hasRunOnce = false;
 	bool _makesUI = false;
+	bool _isSkipping = false;
+	bool _hasChangedScene = false;
 
 	// The keys records were told are down
 	uint16 _heldMoveKeys = 0;
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 1ed417c1e0e..2ec9fd1a6de 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -2647,6 +2647,10 @@ void Scene::handleInput() {
 			_activeConversation->skipLine();
 		} else if (_activeMovie) {
 			_hasSkippedMovie = _activeMovie->skip();
+#ifdef ENABLE_NANCY_V4
+		} else if (_scriptScene) {
+			_scriptScene->skipMovies();
+#endif
 		} else if (g_nancy->getGameType() <= kGameTypeNancy15) {
 			// Nancy16 has no main menu state
 			g_nancy->setState(NancyState::kMainMenu);


Commit: 1b4084744061c359150bec501d474f583f0bc9d5
    https://github.com/scummvm/scummvm/commit/1b4084744061c359150bec501d474f583f0bc9d5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:35+03:00

Commit Message:
NANCY: NANCY19: 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 d349eb38bba..799641b1d61 100644
--- a/engines/nancy/detection_tables.h
+++ b/engines/nancy/detection_tables.h
@@ -1014,7 +1014,7 @@ static const NancyGameDescription gameDescriptions[] = {
 			AD_ENTRY1s("ciftree.dat", "e1d50b6fc3b72335863a45fdaf6f3da7", 53613856),
 			Common::EN_ANY,
 			Common::kPlatformWindows,
-			ADGF_UNSUPPORTED | ADGF_DROPPLATFORM,
+			ADGF_UNSTABLE | ADGF_DROPPLATFORM,
 			NANCY8_GUIOPTIONS
 		},
 		kGameTypeNancy19


Commit: aeba00c68afff6f35e5b329bd989f3d09f944680
    https://github.com/scummvm/scummvm/commit/aeba00c68afff6f35e5b329bd989f3d09f944680
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:36+03:00

Commit Message:
NANCY: NANCY11: Fix the plant puzzle (MultiBuildPuzzle) not completing

Picking a placed piece back up now undoes its placement, so that
pieces which get repositioned are no longer counted twice. Pieces
also keep their rotation when they are picked up again.

Fix #17330

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


diff --git a/engines/nancy/action/puzzle/multibuildpuzzle.cpp b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
index 6424e8e0cd8..e78a9fd3abb 100644
--- a/engines/nancy/action/puzzle/multibuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
@@ -645,6 +645,7 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 				if (addMode) {
 					// Ingredient goes back to its shelf slot, still pickable.
 					pp.isPlaced = false;
+					pp.curRotation = 0;
 					pp.gameRect = pp.homeRect;
 				} else {
 					pp.isPlaced = true;
@@ -660,6 +661,7 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 				// Missed: the ingredient was thrown away. Return it to the shelf
 				// and play the "Missed." feedback (sound + caption).
 				pp.isPlaced = false;
+				pp.curRotation = 0;
 				pp.gameRect = pp.homeRect;
 				if (isNancy10 && _missedSound.name != "NO SOUND") {
 					g_nancy->_sound->playSound(_missedSound);
@@ -789,8 +791,10 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			Piece &pp = *_pieces[topmost];
+			// Taking a placed piece back out undoes its placement.
+			if (g_nancy->getGameType() >= kGameTypeNancy10 && pp.counterByte == 0 && pp.placeCount > 0)
+				pp.placeCount--;
 			pp.isPlaced = false;
-			pp.curRotation = 0;
 			pp.setZOrder((uint16)(_z + (int)_pieces.size() * 2));
 			pp.registerGraphics();
 
@@ -806,8 +810,8 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 				// Direct drag on first click.
 				_isDragging = true;
 				_pickedUpPiece = topmost;
-				_pickedUpWidth  = pp.rotateSurfaces[0].w;
-				_pickedUpHeight = pp.rotateSurfaces[0].h;
+				_pickedUpWidth  = pp.rotateSurfaces[pp.curRotation].w;
+				_pickedUpHeight = pp.rotateSurfaces[pp.curRotation].h;
 				g_nancy->_sound->playSound(_pickupSound);
 			}
 			updatePieceRender(topmost);


Commit: 746e98bc19772ca72bd42c80d2c04a8b10590bdf
    https://github.com/scummvm/scummvm/commit/746e98bc19772ca72bd42c80d2c04a8b10590bdf
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:38+03:00

Commit Message:
NANCY: NANCY10: Fix stale task list checkbox hotspots after scrolling

The checkbox hit areas were only cleared on a full text layout, so
scrolling the task list kept adding new ones on top of the old.

Fix #17310

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


diff --git a/engines/nancy/ui/taskbar/notebookpopup.cpp b/engines/nancy/ui/taskbar/notebookpopup.cpp
index 700055d996c..10ca5f86740 100644
--- a/engines/nancy/ui/taskbar/notebookpopup.cpp
+++ b/engines/nancy/ui/taskbar/notebookpopup.cpp
@@ -545,8 +545,6 @@ void NotebookPopup::layoutText() {
 	}
 
 	HypertextParser::clear();
-	_checkboxRects.clear();
-	_checkboxEntryIndices.clear();
 	_markEntryIndices.clear();
 
 	// Only the Tasklist has clickable checkboxes; record their glyph rects.
@@ -593,6 +591,11 @@ void NotebookPopup::paintVisibleText() {
 	_drawSurface.blitFrom(_fullSurface, srcSlice,
 							Common::Point(localTextRect.left, localTextRect.top));
 
+	// The checkbox hit areas follow the scroll position, so rebuild them on
+	// every repaint
+	_checkboxRects.clear();
+	_checkboxEntryIndices.clear();
+
 	const UIButtonSlot &activeTab = _uinbData->tabs[_activeTab];
 	const bool tasksTab = activeTab.enabled && activeTab.id != notebookJournalTabId();
 	if (tasksTab) {


Commit: 085c6b843a3c159491286cea0d18f32577a9b7af
    https://github.com/scummvm/scummvm/commit/085c6b843a3c159491286cea0d18f32577a9b7af
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:39+03:00

Commit Message:
COMMON: LUA: Update Lua from 5.1.3 to 5.1.5

Apply the upstream changes between the two releases on top of our
modified copy. All of our local changes are kept.

Changed paths:
    common/lua/COPYRIGHT
    common/lua/lapi.cpp
    common/lua/lbaselib.cpp
    common/lua/lcode.cpp
    common/lua/ldblib.cpp
    common/lua/ldebug.cpp
    common/lua/ldo.cpp
    common/lua/lgc.cpp
    common/lua/liolib.cpp
    common/lua/llex.cpp
    common/lua/loadlib.cpp
    common/lua/lobject.h
    common/lua/lparser.cpp
    common/lua/lstrlib.cpp
    common/lua/ltablib.cpp
    common/lua/lua.h
    common/lua/luaconf.h
    common/lua/lundump.cpp
    common/lua/lvm.cpp


diff --git a/common/lua/COPYRIGHT b/common/lua/COPYRIGHT
index 3a53e741e03..a86026803a0 100644
--- a/common/lua/COPYRIGHT
+++ b/common/lua/COPYRIGHT
@@ -9,7 +9,7 @@ For details and rationale, see http://www.lua.org/license.html .
 
 ===============================================================================
 
-Copyright (C) 1994-2008 Lua.org, PUC-Rio.
+Copyright (C) 1994-2012 Lua.org, PUC-Rio.
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/common/lua/lapi.cpp b/common/lua/lapi.cpp
index d7ebdcbe12d..dc41d3f78e6 100644
--- a/common/lua/lapi.cpp
+++ b/common/lua/lapi.cpp
@@ -95,15 +95,14 @@ void luaA_pushobject (lua_State *L, const TValue *o) {
 
 
 LUA_API int lua_checkstack (lua_State *L, int size) {
-  int res;
+  int res = 1;
   lua_lock(L);
-  if ((L->top - L->base + size) > LUAI_MAXCSTACK)
+  if (size > LUAI_MAXCSTACK || (L->top - L->base + size) > LUAI_MAXCSTACK)
     res = 0;  /* stack overflow */
-  else {
+  else if (size > 0) {
     luaD_checkstack(L, size);
     if (L->ci->top < L->top + size)
       L->ci->top = L->top + size;
-    res = 1;
   }
   lua_unlock(L);
   return res;
@@ -923,10 +922,13 @@ LUA_API int lua_gc (lua_State *L, int what, int data) {
         g->GCthreshold = g->totalbytes - a;
       else
         g->GCthreshold = 0;
-      while (g->GCthreshold <= g->totalbytes)
+      while (g->GCthreshold <= g->totalbytes) {
         luaC_step(L);
-      if (g->gcstate == GCSpause)  /* end of cycle? */
-        res = 1;  /* signal it */
+        if (g->gcstate == GCSpause) {  /* end of cycle? */
+          res = 1;  /* signal it */
+          break;
+        }
+      }
       break;
     }
     case LUA_GCSETPAUSE: {
diff --git a/common/lua/lbaselib.cpp b/common/lua/lbaselib.cpp
index 22af019c0a6..5d70310ece0 100644
--- a/common/lua/lbaselib.cpp
+++ b/common/lua/lbaselib.cpp
@@ -342,10 +342,12 @@ static int luaB_unpack (lua_State *L) {
   luaL_checktype(L, 1, LUA_TTABLE);
   i = luaL_optint(L, 2, 1);
   e = luaL_opt(L, luaL_checkint, 3, luaL_getn(L, 1));
+  if (i > e) return 0;  /* empty range */
   n = e - i + 1;  /* number of elements */
-  if (n <= 0) return 0;  /* empty range */
-  luaL_checkstack(L, n, "table too big to unpack");
-  for (; i<=e; i++)  /* push arg[i...e] */
+  if (n <= 0 || !lua_checkstack(L, n))  /* n <= 0 means arith. overflow */
+    return luaL_error(L, "too many results to unpack");
+  lua_rawgeti(L, 1, i);  /* push arg[i] (avoiding overflow problems) */
+  while (i++ < e)  /* push arg[i + 1...e] */
     lua_rawgeti(L, 1, i);
   return n;
 }
@@ -524,7 +526,7 @@ static int auxresume (lua_State *L, lua_State *co, int narg) {
   status = lua_resume(co, narg);
   if (status == 0 || status == LUA_YIELD) {
     int nres = lua_gettop(co);
-    if (!lua_checkstack(L, nres))
+    if (!lua_checkstack(L, nres + 1))
       luaL_error(L, "too many results to resume");
     lua_xmove(co, L, nres);  /* move yielded values */
     return nres;
@@ -634,7 +636,7 @@ static void base_open (lua_State *L) {
   luaL_register(L, "_G", base_funcs);
   lua_pushliteral(L, LUA_VERSION);
   lua_setglobal(L, "_VERSION");  /* set global _VERSION */
-  /* `ipairs' and `pairs' need auxliliary functions as upvalues */
+  /* `ipairs' and `pairs' need auxiliary functions as upvalues */
   auxopen(L, "ipairs", luaB_ipairs, ipairsaux);
   auxopen(L, "pairs", luaB_pairs, luaB_next);
   /* `newproxy' needs a weaktable as upvalue */
diff --git a/common/lua/lcode.cpp b/common/lua/lcode.cpp
index 394530f3f4b..9be9c638540 100644
--- a/common/lua/lcode.cpp
+++ b/common/lua/lcode.cpp
@@ -542,10 +542,6 @@ void luaK_goiftrue (FuncState *fs, expdesc *e) {
       pc = NO_JUMP;  /* always true; do nothing */
       break;
     }
-    case VFALSE: {
-      pc = luaK_jump(fs);  /* always jump */
-      break;
-    }
     case VJMP: {
       invertjump(fs, e);
       pc = e->u.s.info;
@@ -570,10 +566,6 @@ static void luaK_goiffalse (FuncState *fs, expdesc *e) {
       pc = NO_JUMP;  /* always false; do nothing */
       break;
     }
-    case VTRUE: {
-      pc = luaK_jump(fs);  /* always jump */
-      break;
-    }
     case VJMP: {
       pc = e->u.s.info;
       break;
diff --git a/common/lua/ldblib.cpp b/common/lua/ldblib.cpp
index 9ba41f14391..237b7b69946 100644
--- a/common/lua/ldblib.cpp
+++ b/common/lua/ldblib.cpp
@@ -49,6 +49,7 @@ static int db_setmetatable (lua_State *L) {
 
 
 static int db_getfenv (lua_State *L) {
+  luaL_checkany(L, 1);
   lua_getfenv(L, 1);
   return 1;
 }
diff --git a/common/lua/ldebug.cpp b/common/lua/ldebug.cpp
index 91a8c3f9a4d..047ba206943 100644
--- a/common/lua/ldebug.cpp
+++ b/common/lua/ldebug.cpp
@@ -270,12 +270,12 @@ LUA_API int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar) {
 
 static int precheck (const Proto *pt) {
   check(pt->maxstacksize <= MAXSTACK);
-  lua_assert(pt->numparams+(pt->is_vararg & VARARG_HASARG) <= pt->maxstacksize);
-  lua_assert(!(pt->is_vararg & VARARG_NEEDSARG) ||
+  check(pt->numparams+(pt->is_vararg & VARARG_HASARG) <= pt->maxstacksize);
+  check(!(pt->is_vararg & VARARG_NEEDSARG) ||
               (pt->is_vararg & VARARG_HASARG));
   check(pt->sizeupvalues <= pt->nups);
   check(pt->sizelineinfo == pt->sizecode || pt->sizelineinfo == 0);
-  check(GET_OPCODE(pt->code[pt->sizecode-1]) == OP_RETURN);
+  check(pt->sizecode > 0 && GET_OPCODE(pt->code[pt->sizecode-1]) == OP_RETURN);
   return 1;
 }
 
@@ -343,9 +343,18 @@ static Instruction symbexec (const Proto *pt, int lastpc, int reg) {
           int dest = pc+1+b;
           check(0 <= dest && dest < pt->sizecode);
           if (dest > 0) {
-            /* cannot jump to a setlist count */
-            Instruction d = pt->code[dest-1];
-            check(!(GET_OPCODE(d) == OP_SETLIST && GETARG_C(d) == 0));
+            int j;
+            /* check that it does not jump to a setlist count; this
+               is tricky, because the count from a previous setlist may
+               have the same value of an invalid setlist; so, we must
+               go all the way back to the first of them (if any) */
+            for (j = 0; j < dest; j++) {
+              Instruction d = pt->code[dest-1-j];
+              if (!(GET_OPCODE(d) == OP_SETLIST && GETARG_C(d) == 0)) break;
+            }
+            /* if 'j' is even, previous value is not a setlist (even if
+               it looks like one) */
+            check((j&1) == 0);
           }
         }
         break;
@@ -362,7 +371,11 @@ static Instruction symbexec (const Proto *pt, int lastpc, int reg) {
     }
     switch (op) {
       case OP_LOADBOOL: {
-        check(c == 0 || pc+2 < pt->sizecode);  /* check its jump */
+        if (c == 1) {  /* does it jump? */
+          check(pc+2 < pt->sizecode);  /* check its jump */
+          check(GET_OPCODE(pt->code[pc+1]) != OP_SETLIST ||
+                GETARG_C(pt->code[pc+1]) != 0);
+        }
         break;
       }
       case OP_LOADNIL: {
@@ -427,7 +440,10 @@ static Instruction symbexec (const Proto *pt, int lastpc, int reg) {
       }
       case OP_SETLIST: {
         if (b > 0) checkreg(pt, a + b);
-        if (c == 0) pc++;
+        if (c == 0) {
+          pc++;
+          check(pc < pt->sizecode - 1);
+        }
         break;
       }
       case OP_CLOSURE: {
diff --git a/common/lua/ldo.cpp b/common/lua/ldo.cpp
index 5cacb2e0379..a3218a248be 100644
--- a/common/lua/ldo.cpp
+++ b/common/lua/ldo.cpp
@@ -243,6 +243,7 @@ static StkId adjust_varargs (lua_State *L, Proto *p, int actual) {
     int nvar = actual - nfixargs;  /* number of extra arguments */
     lua_assert(p->is_vararg & VARARG_HASARG);
     luaC_checkGC(L);
+    luaD_checkstack(L, p->maxstacksize);
     htab = luaH_new(L, nvar, 1);  /* create `arg' table */
     for (i=0; i<nvar; i++)  /* put extra arguments into `arg' table */
       setobj2n(L, luaH_setnum(L, htab, i+1), L->top - nvar + i);
diff --git a/common/lua/lgc.cpp b/common/lua/lgc.cpp
index 53f512280ac..4f5134a5ed5 100644
--- a/common/lua/lgc.cpp
+++ b/common/lua/lgc.cpp
@@ -625,7 +625,6 @@ void luaC_step (lua_State *L) {
     }
   }
   else {
-    lua_assert(g->totalbytes >= g->estimate);
     setthreshold(g);
   }
 }
diff --git a/common/lua/liolib.cpp b/common/lua/liolib.cpp
index 35e986f7da0..e6ed723555e 100644
--- a/common/lua/liolib.cpp
+++ b/common/lua/liolib.cpp
@@ -298,7 +298,10 @@ static int read_number (lua_State *L, Lua::LuaFileProxy *f) {
     lua_pushnumber(L, d);
     return 1;
   }
-  else return 0;  // read fails
+  else {
+    lua_pushnil(L);  // "result" to be removed
+    return 0;  // read fails
+  }
 }
 
 
diff --git a/common/lua/llex.cpp b/common/lua/llex.cpp
index beaee300eab..af6b4337bc1 100644
--- a/common/lua/llex.cpp
+++ b/common/lua/llex.cpp
@@ -116,8 +116,10 @@ TString *luaX_newstring (LexState *ls, const char *str, size_t l) {
   lua_State *L = ls->L;
   TString *ts = luaS_newlstr(L, str, l);
   TValue *o = luaH_setstr(L, ls->fs->h, ts);  /* entry for `str' */
-  if (ttisnil(o))
+  if (ttisnil(o)) {
     setbvalue(o, 1);  /* make sure `str' will not be collected */
+    luaC_checkGC(L);
+  }
   return ts;
 }
 
diff --git a/common/lua/loadlib.cpp b/common/lua/loadlib.cpp
index 4ae3e7daa1e..7b0261ea4b3 100644
--- a/common/lua/loadlib.cpp
+++ b/common/lua/loadlib.cpp
@@ -186,8 +186,10 @@ static int ll_require (lua_State *L) {
 
 static void setfenv (lua_State *L) {
   lua_Debug ar;
-  lua_getstack(L, 1, &ar);
-  lua_getinfo(L, "f", &ar);
+  if (lua_getstack(L, 1, &ar) == 0 ||
+      lua_getinfo(L, "f", &ar) == 0 ||  /* get calling function */
+      lua_iscfunction(L, -1))
+    luaL_error(L, LUA_QL("module") " not called from a Lua function");
   lua_pushvalue(L, -2);
   lua_setfenv(L, -2);
   lua_pop(L, 1);
@@ -305,7 +307,7 @@ LUALIB_API int luaopen_package (lua_State *L) {
   lua_pushvalue(L, -1);
   lua_replace(L, LUA_ENVIRONINDEX);
   /* create `loaders' table */
-  lua_createtable(L, 0, sizeof(loaders)/sizeof(loaders[0]) - 1);
+  lua_createtable(L, sizeof(loaders)/sizeof(loaders[0]) - 1, 0);
   /* fill it with pre-defined loaders */
   for (i=0; loaders[i] != NULL; i++) {
     lua_pushcfunction(L, loaders[i]);
diff --git a/common/lua/lobject.h b/common/lua/lobject.h
index 70b2c754ea8..56b93e1d37f 100644
--- a/common/lua/lobject.h
+++ b/common/lua/lobject.h
@@ -208,7 +208,7 @@ typedef union TString {
 
 
 #define getstr(ts)	cast(const char *, (ts) + 1)
-#define svalue(o)       getstr(tsvalue(o))
+#define svalue(o)       getstr(rawtsvalue(o))
 
 
 
diff --git a/common/lua/lparser.cpp b/common/lua/lparser.cpp
index 0c88992e79e..4961614364d 100644
--- a/common/lua/lparser.cpp
+++ b/common/lua/lparser.cpp
@@ -372,9 +372,9 @@ static void close_func (LexState *ls) {
   lua_assert(luaG_checkcode(f));
   lua_assert(fs->bl == NULL);
   ls->fs = fs->prev;
-  L->top -= 2;  /* remove table and prototype from the stack */
   /* last token read was anchored in defunct function; must reanchor it */
   if (fs) anchor_token(ls);
+  L->top -= 2;  /* remove table and prototype from the stack */
 }
 
 
diff --git a/common/lua/lstrlib.cpp b/common/lua/lstrlib.cpp
index 9d43ba43dab..095dfd23714 100644
--- a/common/lua/lstrlib.cpp
+++ b/common/lua/lstrlib.cpp
@@ -33,7 +33,8 @@ static int str_len (lua_State *L) {
 
 static ptrdiff_t posrelat (ptrdiff_t pos, size_t len) {
   /* relative string position: negative means back from end */
-  return (pos>=0) ? pos : (ptrdiff_t)len+pos+1;
+  if (pos < 0) pos += (ptrdiff_t)len + 1;
+  return (pos >= 0) ? pos : 0;
 }
 
 
@@ -753,6 +754,7 @@ static void addintlen (char *form) {
 
 
 static int str_format (lua_State *L) {
+  int top = lua_gettop(L);
   int arg = 1;
   size_t sfl;
   const char *strfrmt = luaL_checklstring(L, arg, &sfl);
@@ -767,7 +769,8 @@ static int str_format (lua_State *L) {
     else { /* format item */
       char form[MAX_FORMAT];  /* to store the format (`%...') */
       char buff[MAX_ITEM];  /* to store the formatted item */
-      arg++;
+      if (++arg > top)
+        luaL_argerror(L, arg, "no value");
       strfrmt = scanformat(L, strfrmt, form);
       switch (*strfrmt++) {
         case 'c': {
diff --git a/common/lua/ltablib.cpp b/common/lua/ltablib.cpp
index 064c33c005d..11fb660adbf 100644
--- a/common/lua/ltablib.cpp
+++ b/common/lua/ltablib.cpp
@@ -130,6 +130,15 @@ static int tremove (lua_State *L) {
 }
 
 
+static void addfield (lua_State *L, luaL_Buffer *b, int i) {
+  lua_rawgeti(L, 1, i);
+  if (!lua_isstring(L, -1))
+    luaL_error(L, "invalid value (%s) at index %d in table for "
+                  LUA_QL("concat"), luaL_typename(L, -1), i);
+    luaL_addvalue(b);
+}
+
+
 static int tconcat (lua_State *L) {
   luaL_Buffer b;
   size_t lsep;
@@ -139,13 +148,12 @@ static int tconcat (lua_State *L) {
   i = luaL_optint(L, 3, 1);
   last = luaL_opt(L, luaL_checkint, 4, luaL_getn(L, 1));
   luaL_buffinit(L, &b);
-  for (; i <= last; i++) {
-    lua_rawgeti(L, 1, i);
-    luaL_argcheck(L, lua_isstring(L, -1), 1, "table contains non-strings");
-    luaL_addvalue(&b);
-    if (i != last)
-      luaL_addlstring(&b, sep, lsep);
+  for (; i < last; i++) {
+    addfield(L, &b, i);
+    luaL_addlstring(&b, sep, lsep);
   }
+  if (i == last)  /* add last value (if interval was not empty) */
+    addfield(L, &b, i);
   luaL_pushresult(&b);
   return 1;
 }
diff --git a/common/lua/lua.h b/common/lua/lua.h
index 2c20f5fd08f..74d168c0c5a 100644
--- a/common/lua/lua.h
+++ b/common/lua/lua.h
@@ -19,9 +19,9 @@
 
 
 #define LUA_VERSION	"Lua 5.1"
-#define LUA_RELEASE	"Lua 5.1.3"
+#define LUA_RELEASE	"Lua 5.1.5"
 #define LUA_VERSION_NUM	501
-#define LUA_COPYRIGHT	"Copyright (C) 1994-2008 Lua.org, PUC-Rio"
+#define LUA_COPYRIGHT	"Copyright (C) 1994-2012 Lua.org, PUC-Rio"
 #define LUA_AUTHORS	"R. Ierusalimschy, L. H. de Figueiredo & W. Celes"
 
 
@@ -363,7 +363,7 @@ struct lua_Debug {
 
 
 /******************************************************************************
-* Copyright (C) 1994-2008 Lua.org, PUC-Rio.  All rights reserved.
+* Copyright (C) 1994-2012 Lua.org, PUC-Rio.  All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files (the
diff --git a/common/lua/luaconf.h b/common/lua/luaconf.h
index d5048e46384..b8eaf043d7d 100644
--- a/common/lua/luaconf.h
+++ b/common/lua/luaconf.h
@@ -439,10 +439,10 @@
 @* can use.
 ** CHANGE it if you need lots of (Lua) stack space for your C
 ** functions. This limit is arbitrary; its only purpose is to stop C
-** functions to consume unlimited stack space.
+** functions to consume unlimited stack space. (must be smaller than
+** -LUA_REGISTRYINDEX)
 */
-#define LUAI_MCS_AUX	((int)(INT_MAX / (4*sizeof(LUA_NUMBER))))
-#define LUAI_MAXCSTACK	(LUAI_MCS_AUX > SHRT_MAX ? SHRT_MAX : LUAI_MCS_AUX)
+#define LUAI_MAXCSTACK	8000
 
 
 
diff --git a/common/lua/lundump.cpp b/common/lua/lundump.cpp
index 90231dee46f..7e85c4c0582 100644
--- a/common/lua/lundump.cpp
+++ b/common/lua/lundump.cpp
@@ -68,7 +68,6 @@ static void error(LoadState* S, const char* why)
 static void LoadBlock(LoadState* S, void* b, size_t size)
 {
  size_t r=luaZ_read(S->Z,b,size);
- UNUSED(r);
  IF (r!=0, "unexpected end");
 }
 
@@ -149,7 +148,7 @@ static void LoadConstants(LoadState* S, Proto* f)
    	setnilvalue(o);
 	break;
    case LUA_TBOOLEAN:
-   	setbvalue(o,LoadChar(S));
+   	setbvalue(o,LoadChar(S)!=0);
 	break;
    case LUA_TNUMBER:
 	setnvalue(o,LoadNumber(S));
@@ -195,7 +194,9 @@ static void LoadDebug(LoadState* S, Proto* f)
 
 static Proto* LoadFunction(LoadState* S, TString* p)
 {
- Proto* f=luaF_newproto(S->L);
+ Proto* f;
+ if (++S->L->nCcalls > LUAI_MAXCCALLS) error(S,"code too deep");
+ f=luaF_newproto(S->L);
  setptvalue2s(S->L,S->L->top,f); incr_top(S->L);
  f->source=LoadString(S); if (f->source==NULL) f->source=p;
  f->linedefined=LoadInt(S);
@@ -209,6 +210,7 @@ static Proto* LoadFunction(LoadState* S, TString* p)
  LoadDebug(S,f);
  IF (!luaG_checkcode(f), "bad code");
  S->L->top--;
+ S->L->nCcalls--;
  return f;
 }
 
diff --git a/common/lua/lvm.cpp b/common/lua/lvm.cpp
index 8b7b799354b..d34a89151d2 100644
--- a/common/lua/lvm.cpp
+++ b/common/lua/lvm.cpp
@@ -134,6 +134,7 @@ void luaV_gettable (lua_State *L, const TValue *t, TValue *key, StkId val) {
 
 void luaV_settable (lua_State *L, const TValue *t, TValue *key, StkId val) {
   int loop;
+  TValue temp;
   for (loop = 0; loop < MAXTAGLOOP; loop++) {
     const TValue *tm;
     if (ttistable(t)) {  /* `t' is a table? */
@@ -142,6 +143,7 @@ void luaV_settable (lua_State *L, const TValue *t, TValue *key, StkId val) {
       if (!ttisnil(oldval) ||  /* result is no nil? */
           (tm = fasttm(L, h->metatable, TM_NEWINDEX)) == NULL) { /* or no TM? */
         setobj2t(L, oldval, val);
+        h->flags = 0;
         luaC_barriert(L, h, val);
         return;
       }
@@ -153,7 +155,9 @@ void luaV_settable (lua_State *L, const TValue *t, TValue *key, StkId val) {
       callTM(L, tm, t, key, val);
       return;
     }
-    t = tm;  /* else repeat with `tm' */
+    /* else repeat with `tm' */
+    setobj(L, &temp, tm);  /* avoid pointing inside table (may rehash) */
+    t = &temp;
   }
   luaG_runerror(L, "loop in settable");
 }


Commit: 0345e3bce226ac5f76aca199441c68a1d6186c59
    https://github.com/scummvm/scummvm/commit/0345e3bce226ac5f76aca199441c68a1d6186c59
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:42+03:00

Commit Message:
NANCY: NANCY19: Sort records by z when handling input

Records still run in the order they were created, but input is now
handed to them front to back: highest z first, and for records with
the same z, the most recently created one first.

Scenes depend on this. For example, a full-screen record placed
behind the hotspots catches the clicks that missed all of them.

The scene background always gets the lowest z.

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


diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index 84fdf5293b2..139cd495e79 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -171,6 +171,13 @@ void Record::release() {
 	update(0.0, 0.0);
 }
 
+void Record::limitZ(double z) {
+	if (_z > z) {
+		_z = z;
+		onZChanged();
+	}
+}
+
 void Record::finish() {
 	_isDone = true;
 	callFunction("OnDone");
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index 16c6be280fa..17010a58837 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -101,6 +101,9 @@ public:
 	bool hasStarted() const { return _hasStarted; }
 	double getZ() const { return _z; }
 
+	// Puts the record no closer to the player than the given z
+	void limitZ(double z);
+
 	// Reads or writes one of the fields of the record on behalf of a script.
 	// The key is at stack index 2, and a new value at 3.
 	int getScriptField();
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index ea6f14b6196..4629e4b2b3e 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -143,6 +143,9 @@ void SummaryRecord::readTable(int tableIndex) {
 	if (!readField(L, tableIndex, "bg", _backgroundName)) {
 		luaL_error(L, "No background specified");
 	}
+
+	// The background is behind everything else
+	limitZ(kBackgroundScriptZ);
 }
 
 void SummaryRecord::setBackground(UI::Viewport *background) {
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index 728a29e29e4..f23b1a41800 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -207,6 +207,25 @@ void SceneContext::runFrame(double elapsedTime) {
 	_records.push_back(_newRecords);
 	_newRecords.clear();
 
+	// The records run in the order they were made, but what the player does
+	// goes to the ones closest to them first, and among the ones just as close
+	// to the one made last. A z can change at any time.
+	_inputOrder.clear();
+	for (int i = (int)_records.size() - 1; i >= 0; --i) {
+		_inputOrder.push_back(_records[i]);
+	}
+
+	for (uint i = 1; i < _inputOrder.size(); ++i) {
+		Record *record = _inputOrder[i];
+		uint j = i;
+		while (j > 0 && _inputOrder[j - 1]->getZ() < record->getZ()) {
+			_inputOrder[j] = _inputOrder[j - 1];
+			--j;
+		}
+
+		_inputOrder[j] = record;
+	}
+
 	if (!_hasRunOnce) {
 		_hasRunOnce = true;
 		callSceneFunction("RunOnce", 0);
@@ -234,22 +253,23 @@ bool SceneContext::getViewportMouse(const NancyInput &input, ScriptPoint &point)
 	return screenPosition.contains(input.mousePos);
 }
 
-// Everything the player does goes to the records made last first, which leaves
-// the turning around in the background for when nothing else takes a click.
+// Everything the player does goes to the records in their order for input,
+// which leaves the turning around in the background for when nothing else
+// takes a click.
 // Only clicks and keys can be taken; the rest goes to every record.
 void SceneContext::sendMouseEvent(int event, const ScriptPoint &point, bool inViewport) {
 	const bool canBeTaken = event == kEventLeftClick || event == kEventRightClick;
 
-	for (int i = (int)_records.size() - 1; i >= 0; --i) {
-		if (_records[i]->sendMouseEvent((RecordEvent)event, point, inViewport) && canBeTaken) {
+	for (Record *record : _inputOrder) {
+		if (record->sendMouseEvent((RecordEvent)event, point, inViewport) && canBeTaken) {
 			return;
 		}
 	}
 }
 
 void SceneContext::sendKeyEvent(int event, Common::KeyCode key) {
-	for (int i = (int)_records.size() - 1; i >= 0; --i) {
-		if (_records[i]->sendKeyEvent((RecordEvent)event, key) && event == kEventKey) {
+	for (Record *record : _inputOrder) {
+		if (record->sendKeyEvent((RecordEvent)event, key) && event == kEventKey) {
 			return;
 		}
 	}
@@ -272,14 +292,14 @@ void SceneContext::handleInput(NancyInput &input) {
 	ScriptPoint point;
 	const bool inViewport = getViewportMouse(input, point);
 
-	for (int i = (int)_records.size() - 1; i >= 0; --i) {
-		_records[i]->sendMouseMove(point, inViewport);
+	for (Record *record : _inputOrder) {
+		record->sendMouseMove(point, inViewport);
 	}
 
-	for (int i = (int)_records.size() - 1; i >= 0; --i) {
+	for (Record *record : _inputOrder) {
 		uint cursorType = 0;
 		bool isHot = false;
-		if (_records[i]->sendChangeCursor(point, inViewport, cursorType, isHot)) {
+		if (record->sendChangeCursor(point, inViewport, cursorType, isHot)) {
 			g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, isHot);
 			break;
 		}
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index 22995e706b0..f68050308b6 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -126,6 +126,7 @@ private:
 	LuaState *_lua;
 	Common::Array<Record *> _records;
 	Common::Array<Record *> _newRecords;
+	Common::Array<Record *> _inputOrder;	// the records, in the order the player's input goes to them
 	SummaryRecord *_summary = nullptr;
 	ConvoRecord *_convo = nullptr;
 	Common::Array<ConvoResponse *> _convoResponses;


Commit: d9c774af7812d72af38e2fb426ff45d1e8f2b3c6
    https://github.com/scummvm/scummvm/commit/d9c774af7812d72af38e2fb426ff45d1e8f2b3c6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:44+03:00

Commit Message:
NANCY: NANCY19: Implement the journal and the task list

Entries are added by the scripts that run when the player character
changes, and are stored per character in the saved game.

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


diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 0a83cd0775b..1bda9db86df 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -1023,6 +1023,28 @@ void ScriptSavedData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void ScriptJournalData::synchronize(Common::Serializer &ser) {
+	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
+		uint16 numEntries = journalEntries[i].size();
+		ser.syncAsUint16LE(numEntries);
+		journalEntries[i].resize(numEntries);
+
+		for (JournalEntry &entry : journalEntries[i]) {
+			ser.syncString(entry.heading);
+			ser.syncString(entry.text);
+		}
+
+		uint16 numTasks = tasks[i].size();
+		ser.syncAsUint16LE(numTasks);
+		tasks[i].resize(numTasks);
+
+		for (Task &task : tasks[i]) {
+			ser.syncString(task.text);
+			ser.syncString(task.flagName);
+		}
+	}
+}
+
 void PhoneBookData::synchronize(Common::Serializer &ser) {
 	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
 		uint16 numEntries = entries[i].size();
@@ -1048,6 +1070,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new ScriptValueData();
 	case ScriptSavedData::getTag():
 		return new ScriptSavedData();
+	case ScriptJournalData::getTag():
+		return new ScriptJournalData();
 	case StreamData::getTag():
 		return new StreamData();
 	case BuildBoardData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index a5ebc47a3cc..7c3df873307 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -700,6 +700,27 @@ struct ScriptSavedData : public PuzzleData {
 	Common::HashMap<Common::String, Common::Array<byte>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> values;
 };
 
+// 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.
+struct ScriptJournalData : public PuzzleData {
+	struct JournalEntry {
+		Common::String heading;	// the keys of the texts
+		Common::String text;
+	};
+
+	struct Task {
+		Common::String text;
+		Common::String flagName;	// the flag that has to be set to check the task off
+	};
+
+	static constexpr uint32 getTag() { return MKTAG('S', 'C', 'J', 'N'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::Array<JournalEntry> journalEntries[kMaxPlayerCharacters];
+	Common::Array<Task> tasks[kMaxPlayerCharacters];
+};
+
 // Nancy17. The contacts in the cell phone of every player character
 struct PhoneBookData : public PuzzleData {
 	struct Entry {
diff --git a/engines/nancy/script/api.h b/engines/nancy/script/api.h
index f4160935f68..2bfa7af370d 100644
--- a/engines/nancy/script/api.h
+++ b/engines/nancy/script/api.h
@@ -75,6 +75,10 @@ void registerGameAPI(lua_State *L);
 bool setFlagByName(const Common::String &name, bool value);
 bool getFlagByName(const Common::String &name, bool &value);
 
+// Puts the journal entries and the tasks the character being played was given
+// into the UI, once it has been made
+void showPlayerEntries();
+
 // Reads a cursor a script picked with "Cursor.<name>", "Cursor.<name>.hot"...
 // Returns false when the value is something else.
 bool testCursor(lua_State *L, int index, uint &cursorType, bool &isHot);
diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 24587603704..173206c71eb 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -896,7 +896,7 @@ static const luaL_Reg kSceneFunctions[] = {
 };
 
 // Player, who is the character being played unless a function is told otherwise
-// TODO: AddTask, LoadTaskFlag, AddJournalEntry and AddCellPhoneNumber
+// TODO: AddCellPhoneNumber
 
 // The number of a character, or -1 when there is none of that name
 static int findCharacter(const char *name) {
@@ -968,12 +968,124 @@ static int playerRemoveInventoryItem(lua_State *L) {
 	return 0;
 }
 
+// The journal and the task list are filled by scripts, which hand out an entry
+// when its conditions hold. Every entry is handed out once per character; the UI
+// only shows the ones of the character being played.
+
+static ScriptJournalData *getJournalData(uint character) {
+	if (character >= kMaxPlayerCharacters) {
+		return nullptr;
+	}
+
+	return (ScriptJournalData *)NancySceneState.getPuzzleData(ScriptJournalData::getTag());
+}
+
+// Player:AddJournalEntry(heading, text[, character])
+static int playerAddJournalEntry(lua_State *L) {
+	const Common::String heading = luaL_checkstring(L, 2);
+	const Common::String text = luaL_checkstring(L, 3);
+	const uint character = checkCharacter(L, 4);
+
+	ScriptJournalData *data = getJournalData(character);
+	if (!data) {
+		return 0;
+	}
+
+	for (const ScriptJournalData::JournalEntry &entry : data->journalEntries[character]) {
+		if (entry.heading.equalsIgnoreCase(heading) && entry.text.equalsIgnoreCase(text)) {
+			return 0;
+		}
+	}
+
+	ScriptJournalData::JournalEntry entry;
+	entry.heading = heading;
+	entry.text = text;
+	data->journalEntries[character].push_back(entry);
+
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	if (ui && character == g_nancy->getPlayerCharacter()) {
+		ui->addJournalEntry(heading, text);
+	}
+
+	return 0;
+}
+
+// Player:AddTask(text, flag[, character]): the task can be checked off once the flag is set
+static int playerAddTask(lua_State *L) {
+	const Common::String text = luaL_checkstring(L, 2);
+	const Common::String flagName = luaL_checkstring(L, 3);
+	const uint character = checkCharacter(L, 4);
+
+	ScriptJournalData *data = getJournalData(character);
+	if (!data) {
+		return 0;
+	}
+
+	for (const ScriptJournalData::Task &task : data->tasks[character]) {
+		if (task.text.equalsIgnoreCase(text)) {
+			return 0;
+		}
+	}
+
+	ScriptJournalData::Task task;
+	task.text = text;
+	task.flagName = flagName;
+	data->tasks[character].push_back(task);
+
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	if (ui && character == g_nancy->getPlayerCharacter()) {
+		ui->addTasklistEntry(text, findFlag(flagName));
+	}
+
+	return 0;
+}
+
+// Player:LoadTaskFlag(text, flag[, character]): gives a task that has none its flag
+static int playerLoadTaskFlag(lua_State *L) {
+	const Common::String text = luaL_checkstring(L, 2);
+	const Common::String flagName = luaL_checkstring(L, 3);
+	const uint character = checkCharacter(L, 4);
+
+	ScriptJournalData *data = getJournalData(character);
+	if (!data) {
+		return 0;
+	}
+
+	for (ScriptJournalData::Task &task : data->tasks[character]) {
+		if (task.text.equalsIgnoreCase(text) && task.flagName.empty()) {
+			task.flagName = flagName;
+		}
+	}
+
+	return 0;
+}
+
+void showPlayerEntries() {
+	UI::NDUI::Manager *ui = NancySceneState.getNDUI();
+	const uint character = g_nancy->getPlayerCharacter();
+	ScriptJournalData *data = getJournalData(character);
+	if (!ui || !data) {
+		return;
+	}
+
+	for (const ScriptJournalData::JournalEntry &entry : data->journalEntries[character]) {
+		ui->addJournalEntry(entry.heading, entry.text);
+	}
+
+	for (const ScriptJournalData::Task &task : data->tasks[character]) {
+		ui->addTasklistEntry(task.text, findFlag(task.flagName));
+	}
+}
+
 static const luaL_Reg kPlayerFunctions[] = {
 	{ "__index", indexFunctions },
 	{ "Switch", playerSwitch },
 	{ "HasInventoryItem", playerHasInventoryItem },
 	{ "AddInventoryItem", playerAddInventoryItem },
 	{ "RemoveInventoryItem", playerRemoveInventoryItem },
+	{ "AddJournalEntry", playerAddJournalEntry },
+	{ "AddTask", playerAddTask },
+	{ "LoadTaskFlag", playerLoadTaskFlag },
 	{ nullptr, nullptr }
 };
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 2ec9fd1a6de..7229dd2df89 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -50,6 +50,7 @@
 #include "engines/nancy/misc/viewportfader.h"
 #include "engines/nancy/ui/ndui/manager.h"
 #ifdef ENABLE_NANCY_V4
+#include "engines/nancy/script/api.h"
 #include "engines/nancy/script/records.h"
 #include "engines/nancy/script/scenecontext.h"
 #endif
@@ -1499,11 +1500,42 @@ bool Scene::applyPlayerCharacter(uint characterIndex) {
 		return false;
 	}
 
-	// Nancy19 has its UI made by scripts, and the scripts a character takes over
-	// and leaves with belong to it
+#ifdef ENABLE_NANCY_V4
+	// Nancy19 keeps the UI its scripts made. The script the outgoing character
+	// took over with ends, the one they leave with runs, and so does the one the
+	// new character takes over with. A UI that isn't there yet starts the last
+	// one itself when it is.
 	if (g_nancy->getGameType() >= kGameTypeNancy19) {
-		return g_nancy->setPlayerCharacter(characterIndex);
+		const ScriptBootData *boot = (const ScriptBootData *)g_nancy->getEngineData("ScriptBootData");
+		assert(boot);
+
+		ScriptBootData::Player outgoing;
+		if (g_nancy->getPlayerCharacter() < boot->players.size()) {
+			outgoing = boot->players[g_nancy->getPlayerCharacter()];
+		}
+
+		if (!g_nancy->setPlayerCharacter(characterIndex)) {
+			return false;
+		}
+
+		if (_isScriptedUIReady) {
+			if (!outgoing.becomeSceneName.empty()) {
+				_streams.end(outgoing.becomeSceneName.toString() + "_Stream");
+			}
+
+			beginPlayerScript(outgoing.leaveSceneName);
+
+			// TODO: Take the journal entries and the tasks of the outgoing character down
+			Script::showPlayerEntries();
+
+			if (characterIndex < boot->players.size()) {
+				beginPlayerScript(boot->players[characterIndex].becomeSceneName);
+			}
+		}
+
+		return true;
 	}
+#endif
 
 	// The open popups describe the outgoing character, so get them off the
 	// screen while the data they were built from is still around
@@ -2992,6 +3024,9 @@ void Scene::initUI() {
 	// The UI of Nancy19 is made by the scripts of a stream of its own, which
 	// gets back to the scene once the panels are there
 	if (g_nancy->getGameType() >= kGameTypeNancy19) {
+#ifdef ENABLE_NANCY_V4
+		_isScriptedUIReady = false;
+#endif
 		_streams.begin("UIStream", pcui->uiName.toString(), 0, StreamFlags());
 		return;
 	}
@@ -3034,8 +3069,19 @@ void Scene::initUI() {
 }
 
 #ifdef ENABLE_NANCY_V4
+void Scene::beginPlayerScript(const Common::Path &sceneName) {
+	if (sceneName.empty()) {
+		return;
+	}
+
+	StreamFlags flags;
+	flags.runsFirst = true;
+	_streams.begin(sceneName.toString() + "_Stream", sceneName.toString(), 0, flags);
+}
+
 void Scene::onScriptedUILoaded() {
 	_ndui->createItemButtons();
+	Script::showPlayerEntries();
 
 	// The most recent items come first
 	const Common::Array<int16> &order = _inventoryBox.getOrder();
@@ -3047,6 +3093,14 @@ void Scene::onScriptedUILoaded() {
 
 	_ndui->restoreState(((UIStateData *)getPuzzleData(UIStateData::getTag()))->changes);
 	_ndui->applyMatteColor();
+
+	// The character being played takes over now that there is a UI to fill
+	_isScriptedUIReady = true;
+
+	const ScriptBootData *boot = (const ScriptBootData *)g_nancy->getEngineData("ScriptBootData");
+	if (boot && g_nancy->getPlayerCharacter() < boot->players.size()) {
+		beginPlayerScript(boot->players[g_nancy->getPlayerCharacter()].becomeSceneName);
+	}
 }
 #endif
 
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index aea75aaabe7..e5d31ea33c6 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -366,6 +366,9 @@ private:
 	void load(bool fromSaveFile = false);
 #ifdef ENABLE_NANCY_V4
 	void loadScriptedScene(bool fromSaveFile);	// Nancy19+
+
+	// Nancy19: starts the script a character takes over or leaves with, as a stream
+	void beginPlayerScript(const Common::Path &sceneName);
 #endif
 	void run();
 	void handleInput();
@@ -515,6 +518,7 @@ private:
 #ifdef ENABLE_NANCY_V4
 	// Nancy19: the script of the scene, which takes the place of the action records
 	Script::SceneContext *_scriptScene = nullptr;
+	bool _isScriptedUIReady = false;
 #endif
 	double _frameSeconds = 0.0;	// how long the last frame took
 	Action::PlaySecondaryMovie *_activeMovie;


Commit: 82b382fb61315f5c83efc72d7c858c2e9727726a
    https://github.com/scummvm/scummvm/commit/82b382fb61315f5c83efc72d7c858c2e9727726a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:46+03:00

Commit Message:
NANCY: NANCY19: Play a test sound when a volume slider is released

Sliders now send an event when the mouse button is let go, which
the options screen uses to play a sound at the new volume.

Changed paths:
    engines/nancy/script/uiapi.cpp
    engines/nancy/ui/ndui/common.h
    engines/nancy/ui/ndui/enginehandlers.cpp
    engines/nancy/ui/ndui/manager.h
    engines/nancy/ui/ndui/saveload.cpp
    engines/nancy/ui/ndui/slider.cpp


diff --git a/engines/nancy/script/uiapi.cpp b/engines/nancy/script/uiapi.cpp
index 0f118a0bd5c..1348d0d825a 100644
--- a/engines/nancy/script/uiapi.cpp
+++ b/engines/nancy/script/uiapi.cpp
@@ -47,7 +47,7 @@ namespace Script {
 //
 // TODO: Tooltips, 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: sliderReleased, listboxItemDblClick, enable, disable, mouseEnter
+// don't send yet: listboxItemDblClick, enable, disable, mouseEnter
 // and mouseLeave.
 
 static const char *const kPanelType = "ND.Panel";
@@ -68,6 +68,7 @@ static const NamedValue kEventNames[] = {
 	{ "checkboxChecked", UI::NDUI::kEventChecked },
 	{ "checkboxUnchecked", UI::NDUI::kEventUnchecked },
 	{ "sliderValueChanged", UI::NDUI::kEventSliderChanged },
+	{ "sliderReleased", UI::NDUI::kEventSliderReleased },
 	{ "editboxString", UI::NDUI::kEventTextEntered },
 	{ "editboxChange", UI::NDUI::kEventTextChanged },
 	{ "listboxSelection", UI::NDUI::kEventListSelected },
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index d3bce7cfb31..c9da1ce9df9 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -84,6 +84,7 @@ enum EventType {
 	kEventChecked		= 4,
 	kEventUnchecked		= 5,
 	kEventSliderChanged	= 6,
+	kEventSliderReleased	= 7,
 	kEventTextEntered	= 8,	// the player pressed Enter in an edit box
 	kEventTextChanged	= 9,
 	kEventListSelected	= 11,
diff --git a/engines/nancy/ui/ndui/enginehandlers.cpp b/engines/nancy/ui/ndui/enginehandlers.cpp
index 7487d9266c2..bd6925d8af9 100644
--- a/engines/nancy/ui/ndui/enginehandlers.cpp
+++ b/engines/nancy/ui/ndui/enginehandlers.cpp
@@ -37,6 +37,11 @@ namespace Nancy {
 namespace UI {
 namespace NDUI {
 
+// The Nancy19 channels the UI plays a sound on for the player to hear a new volume with
+static const uint16 kSampleChannelMusic = 4;
+static const uint16 kSampleChannelFX = 22;
+static const uint16 kSampleChannelVoice = 28;
+
 // The timer whose displays show the time of day
 static const int kPlayerClockTimer = -1;
 
@@ -130,11 +135,11 @@ void Manager::takeEngineAction(const Action &action) {
 	} else if (action.target.equalsIgnoreCase("Engine_CellPhone")) {
 		onCellPhoneAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_VoiceVolume")) {
-		onVolumeAction(action, "speech_volume");
+		onVolumeAction(action, "speech_volume", kSampleChannelVoice);
 	} else if (action.target.equalsIgnoreCase("Engine_FXVolume")) {
-		onVolumeAction(action, "sfx_volume");
+		onVolumeAction(action, "sfx_volume", kSampleChannelFX);
 	} else if (action.target.equalsIgnoreCase("Engine_MusicVolume")) {
-		onVolumeAction(action, "music_volume");
+		onVolumeAction(action, "music_volume", kSampleChannelMusic);
 	} else if (action.target.equalsIgnoreCase("Engine_Flags")) {
 		onFlagsAction(action);
 	} else if (action.target.equalsIgnoreCase("Engine_Sound")) {
diff --git a/engines/nancy/ui/ndui/manager.h b/engines/nancy/ui/ndui/manager.h
index 98248ee3cab..00a08ef8fb7 100644
--- a/engines/nancy/ui/ndui/manager.h
+++ b/engines/nancy/ui/ndui/manager.h
@@ -189,7 +189,7 @@ private:
 	void onGameWindowAction(const Action &action);
 	void onCellPhoneAction(const Action &action);
 	void onItemStreamClicked(const InvButton &button);
-	void onVolumeAction(const Action &action, const char *configKey);
+	void onVolumeAction(const Action &action, const char *configKey, uint16 sampleChannel);
 	void onFlagsAction(const Action &action);
 	void askToSaveFirst(const Action &action);
 	uint fillSaveList(const Common::String &listName);
diff --git a/engines/nancy/ui/ndui/saveload.cpp b/engines/nancy/ui/ndui/saveload.cpp
index 100bbbd3295..66502a3188b 100644
--- a/engines/nancy/ui/ndui/saveload.cpp
+++ b/engines/nancy/ui/ndui/saveload.cpp
@@ -33,6 +33,7 @@
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/input.h"
+#include "engines/nancy/sound.h"
 
 #include "engines/nancy/state/scene.h"
 
@@ -345,12 +346,22 @@ void Manager::applyMatteColor() {
 }
 
 // The sliders go from 0 to 100, and have to read back what they set
-void Manager::onVolumeAction(const Action &action, const char *configKey) {
+void Manager::onVolumeAction(const Action &action, const char *configKey, uint16 sampleChannel) {
 	if (action.type == kActionQuery) {
 		takeAction(kActionSetValue, action.sender, (double)((ConfMan.getInt(configKey) * 100 + Audio::Mixer::kMaxMixerVolume / 2) / Audio::Mixer::kMaxMixerVolume));
 	} else if (action.type == kActionSetValue) {
 		ConfMan.setInt(configKey, (int)action.value * Audio::Mixer::kMaxMixerVolume / 100);
 		g_nancy->syncSoundSettings();
+	} else if (action.type == kActionSetText && g_nancy->getGameType() >= kGameTypeNancy19 && !action.text.empty()) {
+		// Nancy19 names a sound to play once a slider is let go of
+		SoundDescription sound;
+		sound.name = action.text;
+		sound.channelID = sampleChannel;
+		sound.numLoops = 1;
+		sound.volume = 100;
+
+		g_nancy->_sound->loadSound(sound);
+		g_nancy->_sound->playSound(sound);
 	}
 }
 
diff --git a/engines/nancy/ui/ndui/slider.cpp b/engines/nancy/ui/ndui/slider.cpp
index 1dfa3639692..ef657c51e4d 100644
--- a/engines/nancy/ui/ndui/slider.cpp
+++ b/engines/nancy/ui/ndui/slider.cpp
@@ -155,6 +155,7 @@ void Slider::onMouseMove(const Common::Point &pos) {
 void Slider::onMouseUp(const Common::Point &pos) {
 	_isPressed = false;
 	sendEvent(kEventSliderChanged);
+	sendEvent(kEventSliderReleased);
 }
 
 bool Slider::doAction(const Action &action) {
@@ -167,7 +168,7 @@ bool Slider::doAction(const Action &action) {
 }
 
 bool Slider::getValue(int32 eventType, double &value, Common::String &text) const {
-	if (eventType != kEventSliderChanged) {
+	if (eventType != kEventSliderChanged && eventType != kEventSliderReleased) {
 		return false;
 	}
 


Commit: 0919044c2e012e269fadd6a78227f123bae8d0bc
    https://github.com/scummvm/scummvm/commit/0919044c2e012e269fadd6a78227f123bae8d0bc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:47+03:00

Commit Message:
NANCY: NANCY19: Implement the Text record

It shows text in the viewport, either all at once or revealed one
line at a time.

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


diff --git a/engines/nancy/script/api.h b/engines/nancy/script/api.h
index 2bfa7af370d..ade3faf24fc 100644
--- a/engines/nancy/script/api.h
+++ b/engines/nancy/script/api.h
@@ -28,6 +28,10 @@
 
 struct lua_State;
 
+namespace Common {
+class WriteStream;
+}
+
 namespace Nancy {
 
 struct CVTX;
@@ -95,6 +99,10 @@ bool findText(const Common::String &key, Common::String &text);
 // The panels and controls scripts build the UI out of
 void registerUIAPI(lua_State *L);
 
+// Writes out how a text gets drawn, the way an element of an NDUI chunk has it:
+// in a font and a color, and placed as the "font" field of the table at an index says
+void writeTextElement(lua_State *L, int tableIndex, const Common::String &fontName, bool isBold, uint32 color, Common::WriteStream &out);
+
 // The images scripts look colors up in
 void registerRefAPI(lua_State *L);
 
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index 4629e4b2b3e..ed954bdcdfa 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "common/memstream.h"
 #include "common/random.h"
 #include "common/textconsole.h"
 
@@ -91,8 +92,10 @@ Record *makeRecord(RecordKind kind, SceneContext &context) {
 		return new ConvoRecord(context, true);
 	case kRecordSoundConvo:
 		return new ConvoRecord(context, false);
+	case kRecordText:
+		return new TextRecord(context);
 	default:
-		// TODO: The Text and Particle records
+		// TODO: The Particle record
 		return nullptr;
 	}
 }
@@ -951,6 +954,213 @@ bool SoundRecord::getField(const Common::String &key) {
 	return true;
 }
 
+// Text
+
+// How quickly a text fades in; a lower rate is faster
+static const float kTextBlendRate = 0.7f;
+
+void TextRecord::readTable(int tableIndex) {
+	lua_State *L = getLua();
+
+	if (pushField(L, tableIndex, "onScreen")) {
+		_dest = checkRect(L, -1);
+		lua_pop(L, 1);
+	}
+
+	readField(L, tableIndex, "yOffset", _yOffset);
+	readField(L, tableIndex, "yVelocity", _yVelocity);
+	readField(L, tableIndex, "useAutotext", _usesAutotext);
+	readField(L, tableIndex, "revealAll", _revealsAll);
+
+	_initialDest = _dest;
+	_initialYOffset = _yOffset;
+	_initialYVelocity = _yVelocity;
+
+	int fontNumber = 0;
+	int colorNumber = 0;
+	bool isBold = false;
+	readField(L, tableIndex, "fontNum", fontNumber);
+	readField(L, tableIndex, "fontColor", colorNumber);
+	readField(L, tableIndex, "bold", isBold);
+
+	// Fonts and their colors go by number
+	Common::String fontName;
+	uint32 color = 0xFFFFFFFF;
+	const ScriptBootData *boot = (const ScriptBootData *)g_nancy->getEngineData("ScriptBootData");
+	if (boot) {
+		for (const ScriptBootData::SystemFont &font : boot->fonts) {
+			if (font.id == fontNumber) {
+				fontName = font.name;
+			}
+		}
+
+		for (const ScriptBootData::FontColor &fontColor : boot->fontColors) {
+			if (fontColor.id == colorNumber) {
+				color = fontColor.normal;
+			}
+		}
+	}
+
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeTextElement(L, tableIndex, fontName, isBold, color, data);
+	Common::MemoryReadStream stream(data.getData(), data.size());
+	_element.read(stream);
+
+	Common::String text;
+	if (!readField(L, tableIndex, "text", text)) {
+		luaL_error(L, "Text AR requires field %s", "text");
+	}
+
+	setText(text);
+
+	_graphic.init();
+	_graphic.setVisible(false);
+	applyDest();
+	onZChanged();
+	_graphic.registerGraphics();
+}
+
+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;
+}
+
+void TextRecord::setText(const Common::String &text) {
+	_text = text;
+	if (_usesAutotext && !findText(text, _text)) {
+		_text = "Missing text: " + text;
+	}
+
+	_numRevealed = 0;
+	_shownText = _revealsAll ? _text : Common::String();
+	_isDirty = true;
+}
+
+bool TextRecord::revealNext() {
+	if (_revealsAll || _numRevealed >= _text.size()) {
+		return false;
+	}
+
+	// Markup is never shown in part
+	uint end = _numRevealed;
+	bool isInTag = false;
+	while (end < _text.size()) {
+		if (isInTag) {
+			isInTag = _text[end] != '>';
+		} else if (_text[end] == '<') {
+			isInTag = true;
+		} else {
+			break;
+		}
+
+		++end;
+	}
+
+	end = MIN<uint>(end + 1, _text.size());
+	_shownText += _text.substr(_numRevealed, end - _numRevealed);
+	_numRevealed = end;
+	_isDirty = true;
+	return true;
+}
+
+void TextRecord::run(double sceneTime, double elapsedTime) {
+	if (_element.blend(UI::NDUI::kStateNormal, (float)elapsedTime, kTextBlendRate)) {
+		_isDirty = true;
+	}
+
+	if (_yVelocity == 0.0) {
+		return;
+	}
+
+	_yOffset += _yVelocity * elapsedTime;
+	_isDirty = true;
+
+	// A text that moves is done once it has left its place
+	const Common::Rect dest = _dest.toRect();
+	const int textHeight = _element.measureTextHeight(_shownText, dest.width());
+	if ((_yOffset > textHeight && _yVelocity > 0.0) || (_yOffset < -dest.height() && _yVelocity < 0.0)) {
+		finish();
+	}
+}
+
+void TextRecord::onZChanged() {
+	_graphic.setScriptZ(getZ(), getContext().getZOffset());
+}
+
+void TextRecord::updateGraphics() {
+	_graphic.setVisible(isActive());
+
+	if (_isDirty) {
+		_isDirty = false;
+
+		Common::Rect area(_graphic._drawSurface.w, _graphic._drawSurface.h);
+		area.translate(0, -(int)_yOffset);
+
+		_graphic._drawSurface.clear(0);
+		_element.drawText(_graphic._drawSurface, _shownText, area, false);
+		_graphic.setNeedsRedraw(true);
+	}
+}
+
+bool TextRecord::prepareReset() {
+	_dest = _initialDest;
+	_yOffset = _initialYOffset;
+	_yVelocity = _initialYVelocity;
+	applyDest();
+	return true;
+}
+
+// text:RevealNext()
+static int textRevealNext(lua_State *L) {
+	TextRecord *text = dynamic_cast<TextRecord *>(checkRecord(L, 1));
+	if (!text) {
+		return luaL_typerror(L, 1, "ND.TextAR");
+	}
+
+	lua_pushboolean(L, text->revealNext());
+	return 1;
+}
+
+bool TextRecord::getField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "onScreen") {
+		pushRect(L, _dest);
+	} else if (key == "yOffset") {
+		lua_pushnumber(L, _yOffset);
+	} else if (key == "yVelocity") {
+		lua_pushnumber(L, _yVelocity);
+	} else if (key == "RevealNext") {
+		lua_pushcfunction(L, textRevealNext);
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
+bool TextRecord::setField(const Common::String &key) {
+	lua_State *L = getLua();
+
+	if (key == "onScreen") {
+		_dest = checkRect(L, 3);
+		applyDest();
+	} else if (key == "text") {
+		setText(luaL_checkstring(L, 3));
+	} else if (key == "yOffset") {
+		_yOffset = luaL_checknumber(L, 3);
+		_isDirty = true;
+	} else if (key == "yVelocity") {
+		_yVelocity = luaL_checknumber(L, 3);
+	} else {
+		return false;
+	}
+
+	return true;
+}
+
 // Movie
 
 void MovieRecord::readTable(int tableIndex) {
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 5a90d8b1fe4..7881e823abf 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -31,6 +31,8 @@
 
 #include "engines/nancy/script/record.h"
 
+#include "engines/nancy/ui/ndui/element.h"
+
 namespace Nancy {
 
 namespace UI {
@@ -291,6 +293,52 @@ private:
 	double _endTime = 0.0;
 };
 
+// Shows a text in the viewport, in one of the fonts and font colors of the game.
+// The text is the key of one of the game's texts, unless the record says
+// otherwise. It can be shown a letter at a time, and can move up or down.
+// TODO: Handle the useBackground, bg and frame fields.
+class TextRecord : public Record {
+public:
+	TextRecord(SceneContext &context) : Record(context, "ND.TextAR") {}
+
+	void updateGraphics() override;
+
+	// Shows the next letter of a text that isn't shown at once, along with the
+	// markup in front of it. Returns false when there is nothing left to show.
+	bool revealNext();
+
+protected:
+	void readTable(int tableIndex) override;
+	void run(double sceneTime, double elapsedTime) override;
+	void onZChanged() override;
+	bool prepareReset() override;
+	bool getField(const Common::String &key) override;
+	bool setField(const Common::String &key) override;
+
+private:
+	void setText(const Common::String &text);
+	void applyDest();
+
+	UI::NDUI::Element _element;
+	RecordGraphic _graphic;
+	bool _isDirty = true;
+
+	ScriptRect _dest;
+	double _yOffset = 0.0;
+	double _yVelocity = 0.0;	// per second
+
+	// What the record goes back to when it starts over
+	ScriptRect _initialDest;
+	double _initialYOffset = 0.0;
+	double _initialYVelocity = 0.0;
+
+	bool _usesAutotext = true;
+	bool _revealsAll = true;
+	Common::String _text;
+	Common::String _shownText;
+	uint _numRevealed = 0;
+};
+
 // Plays a movie in the viewport
 // TODO: Handle the source, volume, loop and modColor fields.
 class MovieRecord : public Record {
diff --git a/engines/nancy/script/uiapi.cpp b/engines/nancy/script/uiapi.cpp
index 1348d0d825a..4890706486c 100644
--- a/engines/nancy/script/uiapi.cpp
+++ b/engines/nancy/script/uiapi.cpp
@@ -334,6 +334,39 @@ static void writeElement(lua_State *L, int tableIndex, HorizontalAlignment defau
 	out.writeByte(0);
 }
 
+void writeTextElement(lua_State *L, int tableIndex, const Common::String &fontName, bool isBold, uint32 color, Common::WriteStream &out) {
+	tableIndex = absoluteIndex(L, tableIndex);
+
+	bool wraps = false;
+	int32 horizontalAlignment = kAlignCenter;
+	int32 verticalAlignment = kAlignTop;
+
+	pushTableField(L, tableIndex, "font");
+	readNamedField(L, -1, "horzAlign", kHorizontalAlignments, ARRAYSIZE(kHorizontalAlignments), horizontalAlignment);
+	readNamedField(L, -1, "vertAlign", kVerticalAlignments, ARRAYSIZE(kVerticalAlignments), verticalAlignment);
+	readField(L, -1, "wrap", wraps);
+	lua_pop(L, 1);
+
+	// Nothing but the text gets drawn
+	writeName(out, Common::String());
+	writeRect(out, ScriptRect());
+	for (uint i = 0; i < 4; ++i) {
+		out.writeUint32BE(kDefaultColor);
+	}
+
+	writeName(out, fontName);
+	out.writeByte(isBold);
+	out.writeSint32LE(horizontalAlignment);
+	out.writeSint32LE(verticalAlignment);
+	out.writeByte(wraps);
+
+	for (uint i = 0; i < 4; ++i) {
+		out.writeUint32BE(color);
+	}
+
+	out.writeByte(0);
+}
+
 // The fields every control has: initialState, name, onScreen, text, tooltipText,
 // and the ones of what it draws
 static void writeControl(lua_State *L, int tableIndex, HorizontalAlignment defaultAlignment, Common::WriteStream &out) {


Commit: 611f398ac1ec161e97b29f8c4c5fae79dba9d290
    https://github.com/scummvm/scummvm/commit/611f398ac1ec161e97b29f8c4c5fae79dba9d290
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:49+03:00

Commit Message:
NANCY: NANCY19: Draw rotated, scaled and tinted overlays

Overlays now apply the rotation and scale of their transform, and
their modColor, which tints the picture or makes it translucent.

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 ed954bdcdfa..e87b6230456 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -113,8 +113,8 @@ void RecordGraphic::setScriptZ(double z, int32 streamZOffset) {
 
 // Where something that was placed in the viewport ends up once the transform
 // of its record has moved it.
-// TODO: Only the place is taken from the transform. Draw what it rotates and
-// scales as well.
+// TODO: Movies only take their place from the transform. Draw what it rotates
+// and scales as well.
 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;
@@ -128,6 +128,118 @@ static Common::Rect getTransformedDest(const ScriptRect &dest, const ScriptMatri
 	return Common::Rect(left, top, left + width, top + height);
 }
 
+static const uint32 kNoModColor = 0xFFFFFFFF;
+
+// Shows a part of an image where the transform of its record puts it. A transform
+// that turns or resizes the picture has it drawn anew, into the area its corners
+// end up around, and so does a color the picture gets multiplied with, which
+// can darken it or make it see-through.
+static void placePicture(RecordGraphic &graphic, Graphics::ManagedSurface &image, const Common::Rect &source, const ScriptRect &dest, const ScriptMatrix &transform, uint32 modColor) {
+	// Larger than anything a script shows, and small enough to draw quickly
+	const int kMaxSize = 2048;
+	const int kLimit = 16000;
+
+	const bool isMovedOnly = transform.isTranslation();
+	if (isMovedOnly && modColor == kNoModColor) {
+		graphic._drawSurface.create(image, source);
+		graphic.setTransparent(true);
+		graphic.moveTo(getTransformedDest(dest, transform, source.width(), source.height()));
+		graphic.setNeedsRedraw(true);
+		return;
+	}
+
+	// Points are rows, so the matrix turns (x, y) into (x * a + y * c, x * b + y * d),
+	// and then moves it
+	const double a = transform.m[0][0];
+	const double b = transform.m[0][1];
+	const double c = transform.m[1][0];
+	const double d = transform.m[1][1];
+	const double offsetX = transform.m[3][0];
+	const double offsetY = transform.m[3][1];
+	const double determinant = a * d - b * c;
+
+	Common::Rect area = getTransformedDest(dest, transform, source.width(), source.height());
+	if (!isMovedOnly) {
+		double minX = 0.0;
+		double minY = 0.0;
+		double maxX = 0.0;
+		double maxY = 0.0;
+		for (uint corner = 0; corner < 4; ++corner) {
+			double x = (corner & 1) ? dest.right : dest.left;
+			double y = (corner & 2) ? dest.bottom : dest.top;
+			transform.transformPoint(x, y);
+
+			minX = corner == 0 ? x : MIN(minX, x);
+			maxX = corner == 0 ? x : MAX(maxX, x);
+			minY = corner == 0 ? y : MIN(minY, y);
+			maxY = corner == 0 ? y : MAX(maxY, y);
+		}
+
+		area.left = CLIP<int>((int)floor(minX), -kLimit, kLimit);
+		area.top = CLIP<int>((int)floor(minY), -kLimit, kLimit);
+		area.right = area.left + CLIP<int>((int)ceil(maxX) - area.left, 1, kMaxSize);
+		area.bottom = area.top + CLIP<int>((int)ceil(maxY) - area.top, 1, kMaxSize);
+	}
+
+	// Drawn with an alpha of its own, in place of the color that stands for
+	// see-through in the image
+	Graphics::ManagedSurface &out = graphic._drawSurface;
+	out.create(area.width(), area.height(), g_nancy->_graphics->getTransparentPixelFormat());
+	out.clear(0);
+	graphic.setTransparent(false);
+
+	byte transR, transG, transB;
+	image.format.colorToRGB(g_nancy->_graphics->getTransColor(), transR, transG, transB);
+	const bool hasAlpha = image.format.aBits() != 0;
+
+	const uint modA = modColor >> 24;
+	const uint modR = (modColor >> 16) & 0xFF;
+	const uint modG = (modColor >> 8) & 0xFF;
+	const uint modB = modColor & 0xFF;
+
+	// A picture squashed flat, or one without a size, shows nothing
+	const bool canDraw = isMovedOnly || (fabs(determinant) > 1e-9 && dest.width() != 0 && dest.height() != 0);
+	const double scaleX = dest.width() != 0 ? (double)source.width() / dest.width() : 0.0;
+	const double scaleY = dest.height() != 0 ? (double)source.height() / dest.height() : 0.0;
+
+	for (int y = 0; canDraw && y < out.h; ++y) {
+		for (int x = 0; x < out.w; ++x) {
+			int sourceX = x;
+			int sourceY = y;
+
+			if (!isMovedOnly) {
+				// Back from the viewport to where the pixel is in the picture
+				const double movedX = area.left + x + 0.5 - offsetX;
+				const double movedY = area.top + y + 0.5 - offsetY;
+				const double placedX = (movedX * d - movedY * c) / determinant;
+				const double placedY = (movedY * a - movedX * b) / determinant;
+
+				sourceX = (int)floor((placedX - dest.left) * scaleX);
+				sourceY = (int)floor((placedY - dest.top) * scaleY);
+			}
+
+			if (sourceX < 0 || sourceX >= source.width() || sourceY < 0 || sourceY >= source.height()) {
+				continue;
+			}
+
+			byte alpha, red, green, blue;
+			image.format.colorToARGB(image.getPixel(source.left + sourceX, source.top + sourceY), alpha, red, green, blue);
+			if (red == transR && green == transG && blue == transB) {
+				continue;
+			}
+
+			if (!hasAlpha) {
+				alpha = 0xFF;
+			}
+
+			out.setPixel(x, y, out.format.ARGBToColor(alpha * modA / 255, red * modR / 255, green * modG / 255, blue * modB / 255));
+		}
+	}
+
+	graphic.moveTo(area);
+	graphic.setNeedsRedraw(true);
+}
+
 static Graphics::ManagedSurface *s_drawTarget = nullptr;
 
 Graphics::ManagedSurface *getDrawTarget() {
@@ -563,6 +675,11 @@ void OverlayRecord::readTable(int tableIndex) {
 		lua_pop(L, 1);
 	}
 
+	if (pushField(L, tableIndex, "modColor")) {
+		_modColor = checkColor(L, -1);
+		lua_pop(L, 1);
+	}
+
 	_graphic.init();
 	_graphic.setVisible(false);
 	applyRects();
@@ -580,10 +697,7 @@ void OverlayRecord::applyRects() {
 		return;
 	}
 
-	_graphic._drawSurface.create(_image, source);
-	_graphic.setTransparent(true);
-	_graphic.moveTo(getTransformedDest(_dest, getTransform(), source.width(), source.height()));
-	_graphic.setNeedsRedraw(true);
+	placePicture(_graphic, _image, source, _dest, getTransform(), _modColor);
 }
 
 void OverlayRecord::onZChanged() {
@@ -601,6 +715,8 @@ bool OverlayRecord::getField(const Common::String &key) {
 		pushRect(L, _dest);
 	} else if (key == "source") {
 		pushRect(L, _source);
+	} else if (key == "modColor") {
+		pushColor(L, _modColor);
 	} else {
 		return false;
 	}
@@ -615,6 +731,8 @@ bool OverlayRecord::setField(const Common::String &key) {
 		_dest = checkRect(L, 3);
 	} else if (key == "source") {
 		_source = checkRect(L, 3);
+	} else if (key == "modColor") {
+		_modColor = checkColor(L, 3);
 	} else {
 		return false;
 	}
@@ -634,6 +752,11 @@ void AnimOverlayRecord::readTable(int tableIndex) {
 	readField(L, tableIndex, "pauseOnLastFrame", _pausesOnLastFrame);
 	readField(L, tableIndex, "visible", _isVisible);
 
+	if (pushField(L, tableIndex, "modColor")) {
+		_modColor = checkColor(L, -1);
+		lua_pop(L, 1);
+	}
+
 	if (!pushField(L, tableIndex, "onScreen")) {
 		luaL_error(L, "AnimOverlays must have a onScreen field");
 	}
@@ -706,10 +829,7 @@ void AnimOverlayRecord::applyFrame() {
 		return;
 	}
 
-	_graphic._drawSurface.create(_image, source);
-	_graphic.setTransparent(true);
-	_graphic.moveTo(getTransformedDest(_dest, getTransform(), source.width(), source.height()));
-	_graphic.setNeedsRedraw(true);
+	placePicture(_graphic, _image, source, _dest, getTransform(), _modColor);
 }
 
 // Starts over from the first frame
@@ -826,6 +946,9 @@ bool AnimOverlayRecord::setField(const Common::String &key) {
 	if (key == "onScreen") {
 		_dest = checkRect(L, 3);
 		applyFrame();
+	} else if (key == "modColor") {
+		_modColor = checkColor(L, 3);
+		applyFrame();
 	} 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 7881e823abf..c5752841083 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -191,7 +191,6 @@ private:
 };
 
 // Shows a part of an image
-// TODO: Handle the modColor field.
 class OverlayRecord : public Record {
 public:
 	OverlayRecord(SceneContext &context) : Record(context, "ND.Overlay") {}
@@ -212,12 +211,12 @@ private:
 	Graphics::ManagedSurface _image;
 	ScriptRect _source;
 	ScriptRect _dest;
+	uint32 _modColor = 0xFFFFFFFF;	// what the colors of the picture get multiplied with
 	bool _hasPicture = false;
 	RecordGraphic _graphic;
 };
 
 // Shows the parts of an image one after the other
-// TODO: Handle the modColor field.
 class AnimOverlayRecord : public Record {
 public:
 	AnimOverlayRecord(SceneContext &context) : Record(context, "ND.AnimOvlAR") {}
@@ -248,6 +247,7 @@ private:
 	Graphics::ManagedSurface _image;
 	Common::Array<ScriptRect> _sources;
 	ScriptRect _dest;
+	uint32 _modColor = 0xFFFFFFFF;
 	bool _hasPicture = false;
 	RecordGraphic _graphic;
 


Commit: 0ca782db0deae5035dbfd748fd13866a167c3d3a
    https://github.com/scummvm/scummvm/commit/0ca782db0deae5035dbfd748fd13866a167c3d3a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:51+03:00

Commit Message:
NANCY: NANCY19: Show tooltips

Panels made by scripts don't describe a tooltip of their own, so
each one gets the tooltip that is built into the game's UI.

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 4890706486c..25a4623351b 100644
--- a/engines/nancy/script/uiapi.cpp
+++ b/engines/nancy/script/uiapi.cpp
@@ -45,10 +45,9 @@ namespace Script {
 // A description gets written out the way a chunk has it, and read by the panel
 // or control it is for.
 //
-// TODO: Tooltips, 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: listboxItemDblClick, enable, disable, mouseEnter
-// and mouseLeave.
+// 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: listboxItemDblClick, enable, disable, mouseEnter and mouseLeave.
 
 static const char *const kPanelType = "ND.Panel";
 
@@ -685,6 +684,8 @@ static int newPanel(lua_State *L, byte kind) {
 		panel->readScrolling(stream);
 	}
 
+	panel->addBuiltInTooltip();
+
 	bool savesState = true;
 	if (readField(L, 2, "save", savesState)) {
 		panel->setSavesState(savesState);
diff --git a/engines/nancy/ui/ndui/panel.cpp b/engines/nancy/ui/ndui/panel.cpp
index 899850f813d..1bc2ecd7d54 100644
--- a/engines/nancy/ui/ndui/panel.cpp
+++ b/engines/nancy/ui/ndui/panel.cpp
@@ -156,6 +156,81 @@ void Panel::growToControls() {
 	}
 }
 
+static void writeTooltipName(Common::WriteStream &out, const char *name) {
+	char buf[33] = {};
+	Common::strlcpy(buf, name, sizeof(buf));
+	out.write(buf, sizeof(buf));
+}
+
+void Panel::addBuiltInTooltip() {
+	// A tooltip comes up after the mouse has been over a control for a second, and
+	// stays for ten at most. Setting a flag keeps tooltips from showing.
+	static const double kShowAfter = 1.0;
+	static const double kHideAfter = 10.0;
+	static const char *const kFontName = "UIFont";
+	static const char *const kPreventFlagName = "PreventTooltips";
+
+	// 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;
+
+	// Centered, and on a single line
+	static const int32 kAlignCenter = 2;
+
+	int16 preventFlag = -1;
+	const EVNT *flags = GetEngineData(EVNT);
+	if (flags) {
+		for (uint i = 0; i < flags->eventFlagNames.size(); ++i) {
+			if (flags->eventFlagNames[i].equalsIgnoreCase(kPreventFlagName)) {
+				preventFlag = (int16)(EVNT::kFirstEventFlag + i);
+			}
+		}
+	}
+
+	// Written out the way an NDUI chunk describes a tooltip
+	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
+	writeTooltipName(data, "");			// version
+	data.writeSint32LE(kStartVisible);
+	writeTooltipName(data, "");			// name
+	for (uint i = 0; i < 4; ++i) {
+		data.writeSint32LE(0);			// bounds
+	}
+
+	writeTooltipName(data, "");			// text
+	writeTooltipName(data, "");			// tooltip
+	data.writeSint16LE(0);				// events
+
+	writeTooltipName(data, "");			// texture
+	for (uint i = 0; i < 4; ++i) {
+		data.writeSint32LE(0);			// part of the texture
+	}
+
+	for (uint i = 0; i < 4; ++i) {
+		data.writeUint32BE(kBackgroundColor);
+	}
+
+	writeTooltipName(data, kFontName);
+	data.writeByte(0);					// bold
+	data.writeSint32LE(kAlignCenter);
+	data.writeSint32LE(kAlignCenter);
+	data.writeByte(0);					// word wrap
+	for (uint i = 0; i < 4; ++i) {
+		data.writeUint32BE(kTextColor);
+	}
+
+	data.writeByte(0);
+
+	data.writeDoubleLE(kShowAfter);
+	data.writeDoubleLE(kHideAfter);
+	data.writeSint16LE(preventFlag);
+
+	Common::MemoryReadStream stream(data.getData(), data.size());
+	delete _tooltip;
+	_tooltip = new Tooltip(this);
+	_tooltip->read(stream);
+}
+
 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 0b1cdf8f549..fffee53e0da 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -76,6 +76,10 @@ public:
 	// Nancy19: has the panel's script make the button of an item
 	void addItemButton(const INVD::Item &item);
 
+	// Nancy19: gives the panel the tooltip that is built into its UI, where the
+	// earlier games describe one with every panel
+	void addBuiltInTooltip();
+
 	// Whether saved games keep what was done to the panel and its controls
 	void setSavesState(bool savesState) { _savesState = savesState; }
 


Commit: 291880913db4707953b0a71cd8080ec7d2e5dce1
    https://github.com/scummvm/scummvm/commit/291880913db4707953b0a71cd8080ec7d2e5dce1
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:52+03:00

Commit Message:
NANCY: NANCY19: Don't draw the background picture of scrolling panels

The task list names the picture of its parent panel as its own
background, which drew the frame of the task list twice.

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


diff --git a/engines/nancy/script/uiapi.cpp b/engines/nancy/script/uiapi.cpp
index 25a4623351b..7419787f494 100644
--- a/engines/nancy/script/uiapi.cpp
+++ b/engines/nancy/script/uiapi.cpp
@@ -458,6 +458,15 @@ static int panelAddBg(lua_State *L) {
 		return luaL_error(L, "The panel already has a background");
 	}
 
+	// The background of a panel that scrolls only gives it its name and its place.
+	// The task list names the picture of the panel around it there, which
+	// isn't meant to show a second time.
+	luaL_checktype(L, 2, LUA_TTABLE);
+	if (panel->isScrolling()) {
+		lua_pushnil(L);
+		lua_setfield(L, 2, "overlay");
+	}
+
 	Common::MemoryWriteStreamDynamic data(DisposeAfterUse::YES);
 	writeControl(L, 2, kAlignCenter, data);
 
diff --git a/engines/nancy/ui/ndui/panel.h b/engines/nancy/ui/ndui/panel.h
index fffee53e0da..5660ebaa1da 100644
--- a/engines/nancy/ui/ndui/panel.h
+++ b/engines/nancy/ui/ndui/panel.h
@@ -64,6 +64,7 @@ public:
 	void readBackground(Common::SeekableReadStream &stream);
 	void place();
 	bool isPlaced() const { return _isPlaced; }
+	bool isScrolling() const { return !_scrollBarName.empty(); }
 	Static *getBackground() const { return _background; }
 
 	// A panel without controls to arrange is as large as it takes to draw them all


Commit: 5eef0a06091166242ab76335776a8f98c7d2660e
    https://github.com/scummvm/scummvm/commit/5eef0a06091166242ab76335776a8f98c7d2660e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:54+03:00

Commit Message:
NANCY: NANCY19: Draw the puzzle piece held on the cursor

Scenes draw it themselves every frame, in a RenderAttachment()
function that calls Render() on the overlay of the piece.

Changed paths:
    engines/nancy/script/gameapi.cpp
    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


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 173206c71eb..1619a7424ef 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -1192,6 +1192,12 @@ static int viewportIndex(lua_State *L) {
 		assert(viewport);
 		pushRect(L, ScriptRect(0, 0, viewport->screenPosition.width(), viewport->screenPosition.height()));
 		return 1;
+	} else if (key == "location") {
+		// Where the viewport is on the screen
+		const VIEW *viewport = GetEngineData(VIEW);
+		assert(viewport);
+		pushRect(L, ScriptRect(viewport->screenPosition));
+		return 1;
 	}
 
 	lua_pushnil(L);
diff --git a/engines/nancy/script/record.cpp b/engines/nancy/script/record.cpp
index 139cd495e79..76bdc1de2cf 100644
--- a/engines/nancy/script/record.cpp
+++ b/engines/nancy/script/record.cpp
@@ -171,6 +171,12 @@ void Record::release() {
 	update(0.0, 0.0);
 }
 
+void Record::render() {
+	if (isActive()) {
+		renderNow();
+	}
+}
+
 void Record::limitZ(double z) {
 	if (_z > z) {
 		_z = z;
@@ -407,6 +413,12 @@ static int recordReset(lua_State *L) {
 	return 0;
 }
 
+// ar:Render()
+static int recordRender(lua_State *L) {
+	checkRecord(L, 1)->render();
+	return 0;
+}
+
 // ar:Run(sceneTime, elapsedTime) runs a whole frame of the record
 static int recordRun(lua_State *L) {
 	Record *record = checkRecord(L, 1);
@@ -477,6 +489,7 @@ static const luaL_Reg kRecordFunctions[] = {
 	{ "__eq", recordEquals },
 	{ "Reset", recordReset },
 	{ "Run", recordRun },
+	{ "Render", recordRender },
 	{ "OnMouseMove", recordOnMouseMove },
 	{ "ChangeCursor", recordChangeCursor },
 	{ nullptr, nullptr }
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index 17010a58837..58e4280fe71 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -91,6 +91,10 @@ public:
 	// Makes a record that ran start over. Only some kinds can.
 	bool reset();
 
+	// Draws an active record right away, where its transform puts it on the
+	// screen. Scripts do this for what hangs off the cursor.
+	void render();
+
 	// A record another one keeps for later stays out of the way until it is
 	// released, and then runs right away
 	void hold();
@@ -146,6 +150,7 @@ protected:
 	virtual void onActivation() {}
 	virtual void onDeactivation() {}
 	virtual void onZChanged() {}
+	virtual void renderNow() {}
 
 	// Whether the record can start over, after getting itself ready for it
 	virtual bool prepareReset() { return false; }
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index e87b6230456..f7c51283be8 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -706,6 +706,43 @@ void OverlayRecord::onZChanged() {
 
 void OverlayRecord::updateGraphics() {
 	_graphic.setVisible(isActive() && _hasPicture);
+
+	if (_hasAttachment) {
+		_attachment.setVisible(_isAttachmentDrawn);
+		_isAttachmentDrawn = false;
+	}
+}
+
+// What a script draws by itself goes over everything else the scene shows
+static const uint16 kAttachmentZ = 999;
+
+// TODO: The picture is cut off at the edges of the viewport
+void OverlayRecord::renderNow() {
+	Common::Rect source = _source.toRect();
+	source.clip(Common::Rect(_image.w, _image.h));
+	if (source.isEmpty()) {
+		return;
+	}
+
+	if (!_hasAttachment) {
+		_hasAttachment = true;
+		_attachment.init();
+		_attachment.setVisible(false);
+		_attachment.setZOrder(kAttachmentZ);
+		_attachment.registerGraphics();
+	}
+
+	// The transform places the picture on the screen, and the records draw
+	// in the viewport
+	const VIEW *viewport = GetEngineData(VIEW);
+	assert(viewport);
+
+	ScriptMatrix transform = getTransform();
+	transform.m[3][0] -= viewport->screenPosition.left;
+	transform.m[3][1] -= viewport->screenPosition.top;
+
+	placePicture(_attachment, _image, source, _dest, transform, _modColor);
+	_isAttachmentDrawn = true;
 }
 
 bool OverlayRecord::getField(const Common::String &key) {
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index c5752841083..af2bb5e7765 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -202,6 +202,7 @@ protected:
 	void onZChanged() override;
 	bool hasTransform() const override { return true; }
 	void onTransformChanged() override { applyRects(); }
+	void renderNow() override;
 	bool getField(const Common::String &key) override;
 	bool setField(const Common::String &key) override;
 
@@ -214,6 +215,11 @@ private:
 	uint32 _modColor = 0xFFFFFFFF;	// what the colors of the picture get multiplied with
 	bool _hasPicture = false;
 	RecordGraphic _graphic;
+
+	// The picture as a script had it drawn by itself, which lasts for a frame
+	RecordGraphic _attachment;
+	bool _hasAttachment = false;
+	bool _isAttachmentDrawn = false;
 };
 
 // Shows the parts of an image one after the other
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index f23b1a41800..8f97f9fad5d 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -23,6 +23,7 @@
 
 #include "engines/nancy/commontypes.h"
 #include "engines/nancy/cursor.h"
+#include "engines/nancy/enginedata.h"
 #include "engines/nancy/input.h"
 #include "engines/nancy/nancy.h"
 
@@ -193,11 +194,30 @@ void SceneContext::run(double elapsedTime) {
 		}
 	}
 
+	renderAttachment();
+
 	for (Record *record : _records) {
 		record->updateGraphics();
 	}
 }
 
+// What hangs off the cursor is drawn by a function of the scene, which gets
+// handed where the cursor is on the screen
+void SceneContext::renderAttachment() {
+	const VIEW *viewport = GetEngineData(VIEW);
+	assert(viewport);
+
+	ScriptPoint mouse;
+	getViewportMouse(g_nancy->_input->getInput(), mouse);
+
+	ScriptMatrix transform;
+	transform.m[3][0] = mouse.x + viewport->screenPosition.left;
+	transform.m[3][1] = mouse.y + viewport->screenPosition.top;
+
+	pushMatrix(getLua(), transform);
+	callSceneFunction("RenderAttachment", 1);
+}
+
 void SceneContext::runFrame(double elapsedTime) {
 	lua_State *L = getLua();
 
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index f68050308b6..c6abfb9cfa0 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -113,6 +113,7 @@ public:
 private:
 	bool callSceneFunction(const char *name, int numArgs);
 	void runFrame(double elapsedTime);
+	void renderAttachment();
 	bool hasSkippableRecord() const;
 	void pushSceneFunctions();
 	void sendMouseEvent(int event, const ScriptPoint &point, bool inViewport);


Commit: 62ad35513686280a498f7d76de83d13a087b201d
    https://github.com/scummvm/scummvm/commit/62ad35513686280a498f7d76de83d13a087b201d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:56+03:00

Commit Message:
NANCY: NANCY16: Send an event when a list box item is double-clicked

Nancy19 uses it to load a saved game by double-clicking it in the
load screen.

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


diff --git a/engines/nancy/script/uiapi.cpp b/engines/nancy/script/uiapi.cpp
index 7419787f494..67e2ee832ce 100644
--- a/engines/nancy/script/uiapi.cpp
+++ b/engines/nancy/script/uiapi.cpp
@@ -47,7 +47,7 @@ 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: listboxItemDblClick, enable, disable, mouseEnter and mouseLeave.
+// send yet: enable, disable, mouseEnter and mouseLeave.
 
 static const char *const kPanelType = "ND.Panel";
 
@@ -70,6 +70,7 @@ static const NamedValue kEventNames[] = {
 	{ "sliderReleased", UI::NDUI::kEventSliderReleased },
 	{ "editboxString", UI::NDUI::kEventTextEntered },
 	{ "editboxChange", UI::NDUI::kEventTextChanged },
+	{ "listboxItemDblClick", UI::NDUI::kEventListDoubleClicked },
 	{ "listboxSelection", UI::NDUI::kEventListSelected },
 	{ "scrollBarValueChanged", UI::NDUI::kEventScrolled },
 	{ "show", UI::NDUI::kEventShow },
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index c9da1ce9df9..0de95349759 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -87,6 +87,7 @@ enum EventType {
 	kEventSliderReleased	= 7,
 	kEventTextEntered	= 8,	// the player pressed Enter in an edit box
 	kEventTextChanged	= 9,
+	kEventListDoubleClicked	= 10,
 	kEventListSelected	= 11,
 	kEventScrolled		= 15,
 	kEventShow			= 16,	// the panel holding the control was shown
diff --git a/engines/nancy/ui/ndui/listbox.cpp b/engines/nancy/ui/ndui/listbox.cpp
index 4aa15b191ae..b1048fb8c73 100644
--- a/engines/nancy/ui/ndui/listbox.cpp
+++ b/engines/nancy/ui/ndui/listbox.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "engines/nancy/graphics.h"
+#include "engines/nancy/nancy.h"
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/ui/ndui/listbox.h"
@@ -187,10 +188,18 @@ bool ListBox::onMouseDown(const Common::Point &pos) {
 		return false;
 	}
 
+	// A second click on the same item, shortly after the first
+	static const uint32 kDoubleClickTime = 500;
+
+	const uint32 now = g_nancy->getTotalPlayTime();
+	const bool isDoubleClick = item == _lastClicked && now - _lastClickTime <= kDoubleClickTime;
+	_lastClicked = isDoubleClick ? -1 : item;
+	_lastClickTime = now;
+
 	_selected = item;
 	_isDragging = true;
 	_panel->setDirty();
-	sendEvent(kEventListSelected);
+	sendEvent(isDoubleClick ? kEventListDoubleClicked : kEventListSelected);
 	return true;
 }
 
@@ -265,7 +274,7 @@ bool ListBox::doAction(const Action &action) {
 }
 
 bool ListBox::getValue(int32 eventType, double &value, Common::String &text) const {
-	if (eventType != kEventListSelected) {
+	if (eventType != kEventListSelected && eventType != kEventListDoubleClicked) {
 		return false;
 	}
 
diff --git a/engines/nancy/ui/ndui/listbox.h b/engines/nancy/ui/ndui/listbox.h
index bcf6acc5708..47e5d4b7198 100644
--- a/engines/nancy/ui/ndui/listbox.h
+++ b/engines/nancy/ui/ndui/listbox.h
@@ -71,6 +71,8 @@ protected:
 	int _rowHeight = 0;
 	bool _isDragging = false;
 	bool _isScrollBarDragging = false;
+	int _lastClicked = -1;
+	uint32 _lastClickTime = 0;
 };
 
 } // End of namespace NDUI


Commit: fb4623a372370a13f16216bf32d39faa60f8e0fa
    https://github.com/scummvm/scummvm/commit/fb4623a372370a13f16216bf32d39faa60f8e0fa
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:57+03:00

Commit Message:
NANCY: NANCY19: Add Streams:GetScene()

It returns the name of the scene a stream is showing. The nursery
ambience and the printing puzzle scripts failed without it.

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


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 1619a7424ef..5f9f44824e4 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -895,6 +895,22 @@ static const luaL_Reg kSceneFunctions[] = {
 	{ nullptr, nullptr }
 };
 
+// Streams
+// TODO: Streams.environment and Streams.mainName
+
+// Streams:GetScene(stream), which gives no name for a stream that isn't running
+static int streamsGetScene(lua_State *L) {
+	const Common::String streamName = luaL_checkstring(L, 2);
+	lua_pushstring(L, NancySceneState.getStreams().getSceneName(streamName).c_str());
+	return 1;
+}
+
+static const luaL_Reg kStreamsFunctions[] = {
+	{ "__index", indexFunctions },
+	{ "GetScene", streamsGetScene },
+	{ nullptr, nullptr }
+};
+
 // Player, who is the character being played unless a function is told otherwise
 // TODO: AddCellPhoneNumber
 
@@ -1221,7 +1237,7 @@ static const luaL_Reg kLogFunctions[] = {
 	{ nullptr, nullptr }
 };
 
-// TODO: The Autotext, Streams, Generics and UIMgr objects, and Log:WriteAll
+// TODO: The Autotext, Generics and UIMgr objects, and Log:WriteAll
 void registerGameAPI(lua_State *L) {
 	registerGlobal(L, "Flags", "ND.Flags", kFlagsFunctions);
 	registerGlobal(L, "VarTable", "ND.VarTable", kVarTableFunctions);
@@ -1240,6 +1256,7 @@ void registerGameAPI(lua_State *L) {
 	registerGlobal(L, "Sound", "ND.Sound", kSoundFunctions);
 
 	registerGlobal(L, "Scene", "ND.Scene", kSceneFunctions);
+	registerGlobal(L, "Streams", "ND.Streams", kStreamsFunctions);
 	registerGlobal(L, "Player", "ND.CurPlayer", kPlayerFunctions);
 	registerGlobal(L, "Message", "ND.Message", kMessageFunctions);
 	registerGlobal(L, "Viewport", "ND.Viewport", kViewportFunctions);


Commit: 64c9a83a5a02ff95fc0ee37a0ae489ca254ceff2
    https://github.com/scummvm/scummvm/commit/64c9a83a5a02ff95fc0ee37a0ae489ca254ceff2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:21:59+03:00

Commit Message:
NANCY: NANCY19: Call the OnSave functions of scripts when saving

Puzzles such as drink mixing, sheep herding and the map use them to
store their state in the saved game.

Changed paths:
    engines/nancy/script/record.h
    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/record.h b/engines/nancy/script/record.h
index 58e4280fe71..a5998c0a577 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -100,6 +100,10 @@ public:
 	void hold();
 	void release();
 
+	// The game is about to be saved, which is when the records that keep
+	// something across scenes store it
+	virtual void onSave() {}
+
 	bool isActive() const { return _isActive && !_isDone; }
 	bool isDone() const { return _isDone; }
 	bool hasStarted() const { return _hasStarted; }
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index af2bb5e7765..bd082d9ff72 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -166,7 +166,7 @@ private:
 };
 
 // A record that only does what the functions a script gave it do
-// TODO: Call RenderAttachment, OnSave, OnLoad and ReceiveMessage, and handle
+// TODO: Call RenderAttachment, OnLoad and ReceiveMessage, and handle
 // messagingName and Send.
 class OverrideRecord : public Record {
 public:
@@ -174,6 +174,7 @@ public:
 	virtual ~OverrideRecord();
 
 	void updateGraphics() override;
+	void onSave() override { callFunction("OnSave"); }
 
 protected:
 	void readTable(int tableIndex) override;
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index 8f97f9fad5d..7d94620fa91 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -182,6 +182,19 @@ bool SceneContext::skipMovies() {
 	return true;
 }
 
+// A scene that has a function for it runs that in place of its records
+void SceneContext::onSave() {
+	if (callSceneFunction("OnSave", 0)) {
+		return;
+	}
+
+	// A record can make others, or take some down, while it stores its state
+	const Common::Array<Record *> records = _records;
+	for (Record *record : records) {
+		record->onSave();
+	}
+}
+
 void SceneContext::run(double elapsedTime) {
 	runFrame(elapsedTime);
 
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index c6abfb9cfa0..bb338496f7d 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -60,6 +60,9 @@ public:
 	void run(double elapsedTime);
 	void handleInput(NancyInput &input);
 
+	// Tells the records that the game is about to be saved
+	void onSave();
+
 	lua_State *getLua() const;
 
 	// A new record only joins the scene at the start of the next frame. Until then
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 7229dd2df89..c9d9919e6b3 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1630,6 +1630,12 @@ void Scene::synchronize(Common::Serializer &ser) {
 	if (_flags.eventFlags.empty())
 		init();
 
+#ifdef ENABLE_NANCY_V4
+	if (ser.isSaving() && _scriptScene) {
+		_scriptScene->onSave();
+	}
+#endif
+
 	if (ser.isSaving() && !_streams.isEmpty()) {
 		_streams.store();
 	}
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index 475e4bb9e44..1cb67afb5b6 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -95,6 +95,14 @@ void SceneStream::processRecords() {
 	_actionManager.processActionRecords();
 }
 
+void SceneStream::onSave() {
+#ifdef ENABLE_NANCY_V4
+	if (_scriptScene) {
+		_scriptScene->onSave();
+	}
+#endif
+}
+
 void SceneStream::handleInput(NancyInput &input) {
 	if (_isEnded) {
 		return;
@@ -499,6 +507,7 @@ void SceneStreamManager::store() {
 				continue;
 			}
 
+			stream->onSave();
 			saved.name = stream->getName();
 			saved.lifetime = stream->getFlags().lifetime;
 			saved.capturesInput = stream->getFlags().capturesInput;
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
index 6a592c53e9d..a146c438b35 100644
--- a/engines/nancy/state/scenestream.h
+++ b/engines/nancy/state/scenestream.h
@@ -83,6 +83,9 @@ public:
 	bool update();
 	void handleInput(NancyInput &input);
 
+	// Nancy19: the scripts of the scene store what they keep in a saved game
+	void onSave();
+
 	void changeScene(const Common::String &sceneName, uint16 frameID);
 	void end() { _isEnded = true; }
 	bool isEnded() const { return _isEnded; }


Commit: c084fccf0548e9187e8551baf41df6f5514dadfc
    https://github.com/scummvm/scummvm/commit/c084fccf0548e9187e8551baf41df6f5514dadfc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:22:01+03:00

Commit Message:
NANCY: NANCY19: Let scripts send messages to scenes and records

Message:Send() now reaches the ReceiveMessage function of a scene,
or of a record that was given a messagingName, before trying the UI.
Also accept the "none" action, which scene 2045 sends.

Changed paths:
    engines/nancy/script/gameapi.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/script/types.h
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h
    engines/nancy/state/scenestream.cpp
    engines/nancy/state/scenestream.h
    engines/nancy/ui/ndui/common.h


diff --git a/engines/nancy/script/gameapi.cpp b/engines/nancy/script/gameapi.cpp
index 5f9f44824e4..269231a8a4a 100644
--- a/engines/nancy/script/gameapi.cpp
+++ b/engines/nancy/script/gameapi.cpp
@@ -1128,6 +1128,7 @@ struct MessageActionName {
 };
 
 static const MessageActionName kMessageActionNames[] = {
+	{ "none", UI::NDUI::kActionNone },
 	{ "show", UI::NDUI::kActionShow },
 	{ "hide", UI::NDUI::kActionHide },
 	{ "toggleHidden", UI::NDUI::kActionToggle },
@@ -1142,25 +1143,30 @@ 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 text is the key of an autotext entry, unless it says otherwise
+// 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.
+// The text is the key of an autotext entry, unless it says otherwise.
 static int messageSend(lua_State *L) {
 	const char *actionName = luaL_checkstring(L, 2);
 	const char *target = luaL_checkstring(L, 3);
 
-	int32 type = 0;
+	const MessageActionName *named = nullptr;
 	for (const MessageActionName &action : kMessageActionNames) {
 		if (scumm_stricmp(action.name, actionName) == 0) {
-			type = action.type;
+			named = &action;
 		}
 	}
 
-	if (type == 0) {
+	if (!named) {
 		return luaL_error(L, "Action type '%s' invalid", actionName);
 	}
 
 	UI::NDUI::Action action;
-	action.type = type;
+	action.type = named->type;
 	action.target = target;
 	action.hasValue = true;
 
@@ -1181,15 +1187,34 @@ 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) {
-		return 0;
+	if (ui) {
+		action.sender = ui->getEventSender();
 	}
 
-	// The targets that answer do so to the control whose event is being handled
-	action.sender = ui->getEventSender();
-	ui->takeAction(action);
-	return 0;
+	// 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);
+	return 1;
 }
 
 static const luaL_Reg kMessageFunctions[] = {
diff --git a/engines/nancy/script/record.h b/engines/nancy/script/record.h
index a5998c0a577..fccbee20c11 100644
--- a/engines/nancy/script/record.h
+++ b/engines/nancy/script/record.h
@@ -104,6 +104,9 @@ public:
 	// something across scenes store it
 	virtual void onSave() {}
 
+	// Whether messages sent to the given name are for this record
+	virtual bool hasMessagingName(const Common::String &name) { return false; }
+
 	bool isActive() const { return _isActive && !_isDone; }
 	bool isDone() const { return _isDone; }
 	bool hasStarted() const { return _hasStarted; }
diff --git a/engines/nancy/script/records.cpp b/engines/nancy/script/records.cpp
index f7c51283be8..5afc7994e00 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -544,7 +544,7 @@ bool NavLogicRecord::setField(const Common::String &key) {
 static const char *const kOverrideFunctionNames[] = {
 	"OnReset", "OnActivation", "OnDeactivation", "OnDone", "OnMouseMove", "ChangeCursor",
 	"OnLeftClick", "OnRightClick", "OnMouseScroll", "OnLeftDown", "OnLeftUp", "OnRightDown",
-	"OnRightUp", "OnKey", "Run", "RunOnce", "Render", "OnSave"
+	"OnRightUp", "OnKey", "Run", "RunOnce", "Render", "OnSave", "ReceiveMessage"
 };
 
 static bool isOverrideFunctionName(const Common::String &name) {
@@ -574,6 +574,15 @@ void OverrideRecord::readTable(int tableIndex) {
 	}
 }
 
+bool OverrideRecord::hasMessagingName(const Common::String &name) {
+	lua_State *L = getLua();
+
+	pushStoredValue("messagingName");
+	const bool isNamed = lua_type(L, -1) == LUA_TSTRING && name.equalsIgnoreCase(lua_tostring(L, -1));
+	lua_pop(L, 1);
+	return isNamed;
+}
+
 void OverrideRecord::onZChanged() {
 	if (_canvas) {
 		_canvas->setScriptZ(getZ(), getContext().getZOffset());
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index bd082d9ff72..3c5281b72c5 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -166,8 +166,7 @@ private:
 };
 
 // A record that only does what the functions a script gave it do
-// TODO: Call RenderAttachment, OnLoad and ReceiveMessage, and handle
-// messagingName and Send.
+// TODO: Call RenderAttachment and OnLoad, and handle Send.
 class OverrideRecord : public Record {
 public:
 	OverrideRecord(SceneContext &context) : Record(context, "ND.OverrideAR") {}
@@ -175,6 +174,7 @@ public:
 
 	void updateGraphics() override;
 	void onSave() override { callFunction("OnSave"); }
+	bool hasMessagingName(const Common::String &name) override;
 
 protected:
 	void readTable(int tableIndex) override;
diff --git a/engines/nancy/script/scenecontext.cpp b/engines/nancy/script/scenecontext.cpp
index 7d94620fa91..da63e39b28a 100644
--- a/engines/nancy/script/scenecontext.cpp
+++ b/engines/nancy/script/scenecontext.cpp
@@ -51,6 +51,9 @@ SceneContext::SceneContext(const Common::String &streamName, const Common::Strin
 }
 
 SceneContext::~SceneContext() {
+	// Taking the records down can have the UI send messages
+	_isEnding = true;
+
 	UI::NDUI::Manager *ui = _makesUI ? NancySceneState.getNDUI() : nullptr;
 	if (ui) {
 		ui->clearPanels();
@@ -195,6 +198,72 @@ void SceneContext::onSave() {
 	}
 }
 
+void SceneContext::pushMessage(const Message &message) {
+	lua_State *L = getLua();
+
+	lua_newtable(L);
+	lua_pushstring(L, message.action.c_str());
+	lua_setfield(L, -2, "action");
+	lua_pushstring(L, message.target.c_str());
+	lua_setfield(L, -2, "victim");
+	lua_pushstring(L, message.sender.c_str());
+	lua_setfield(L, -2, "sender");
+	lua_pushstring(L, message.text.c_str());
+	lua_setfield(L, -2, "text");
+	lua_pushnumber(L, message.value);
+	lua_setfield(L, -2, "value");
+}
+
+// TODO: The "cause" field of a message
+bool SceneContext::receiveMessage(const Message &message, bool &wasTaken) {
+	lua_State *L = getLua();
+	wasTaken = false;
+
+	if (_isEnding) {
+		return false;
+	}
+
+	if (message.target.equalsIgnoreCase(_sceneName)) {
+		pushSceneFunctions();
+		lua_getfield(L, -1, "ReceiveMessage");
+		lua_remove(L, -2);
+
+		if (!lua_isfunction(L, -1)) {
+			lua_pop(L, 1);
+			return true;
+		}
+
+		pushMessage(message);
+		if (LuaState::call(L, 1, 1, "Error during Scene ReceiveMessage")) {
+			wasTaken = lua_toboolean(L, -1) != 0;
+			lua_pop(L, 1);
+		}
+
+		return true;
+	}
+
+	bool hasReceiver = false;
+	const Common::Array<Record *> records = _records;
+	for (Record *record : records) {
+		if (!record->hasMessagingName(message.target)) {
+			continue;
+		}
+
+		hasReceiver = true;
+		pushMessage(message);
+		if (record->callFunction("ReceiveMessage", 1, 1)) {
+			wasTaken = lua_toboolean(L, -1) != 0;
+			lua_pop(L, 1);
+		}
+
+		if (wasTaken) {
+			break;
+		}
+	}
+
+	return hasReceiver;
+}
+
 void SceneContext::run(double elapsedTime) {
 	runFrame(elapsedTime);
 
diff --git a/engines/nancy/script/scenecontext.h b/engines/nancy/script/scenecontext.h
index bb338496f7d..d8d49307937 100644
--- a/engines/nancy/script/scenecontext.h
+++ b/engines/nancy/script/scenecontext.h
@@ -63,6 +63,11 @@ public:
 	// Tells the records that the game is about to be saved
 	void onSave();
 
+	// Hands a message to the scene when it is the one it names, and to the
+	// records that go by that name otherwise, until one of them takes it.
+	// Returns false when the message is for neither.
+	bool receiveMessage(const Message &message, bool &wasTaken);
+
 	lua_State *getLua() const;
 
 	// A new record only joins the scene at the start of the next frame. Until then
@@ -115,6 +120,7 @@ public:
 
 private:
 	bool callSceneFunction(const char *name, int numArgs);
+	void pushMessage(const Message &message);
 	void runFrame(double elapsedTime);
 	void renderAttachment();
 	bool hasSkippableRecord() const;
@@ -124,6 +130,7 @@ private:
 
 	Common::String _streamName;
 	Common::String _sceneName;
+	bool _isEnding = false;
 	uint _frame;
 	int32 _zOffset;
 
diff --git a/engines/nancy/script/types.h b/engines/nancy/script/types.h
index 52493c1802c..e64c91b605f 100644
--- a/engines/nancy/script/types.h
+++ b/engines/nancy/script/types.h
@@ -23,6 +23,7 @@
 #define NANCY_SCRIPT_TYPES_H
 
 #include "common/rect.h"
+#include "common/str.h"
 
 struct lua_State;
 
@@ -86,6 +87,15 @@ void pushColor(lua_State *L, uint32 argb);
 uint32 *testColor(lua_State *L, int index);
 uint32 checkColor(lua_State *L, int index);
 
+// What a script sends to a scene, or to the records of one that go by a name
+struct Message {
+	Common::String action;
+	Common::String target;
+	Common::String sender;
+	Common::String text;
+	double value = 0;
+};
+
 } // End of namespace Script
 } // End of namespace Nancy
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index c9d9919e6b3..0a8669f7274 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -3085,6 +3085,19 @@ void Scene::beginPlayerScript(const Common::Path &sceneName) {
 	_streams.begin(sceneName.toString() + "_Stream", sceneName.toString(), 0, flags);
 }
 
+bool Scene::receiveMessage(const Script::Message &message, bool &wasTaken) {
+	if (_streams.isEmpty()) {
+		return receiveMainSceneMessage(message, wasTaken);
+	}
+
+	return _streams.receiveMessage(message, wasTaken);
+}
+
+bool Scene::receiveMainSceneMessage(const Script::Message &message, bool &wasTaken) {
+	wasTaken = false;
+	return _scriptScene && _scriptScene->receiveMessage(message, wasTaken);
+}
+
 void Scene::onScriptedUILoaded() {
 	_ndui->createItemButtons();
 	Script::showPlayerEntries();
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index e5d31ea33c6..2a0b67f4277 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -60,6 +60,7 @@ class PlaySecondaryMovie;
 
 namespace Script {
 class SceneContext;
+struct Message;
 }
 
 namespace Misc {
@@ -322,6 +323,11 @@ public:
 #ifdef ENABLE_NANCY_V4
 	// Nancy19: the scripts of the UI stream have made the panels
 	void onScriptedUILoaded();
+
+	// Nancy19: hands a message a script sent to the scenes it is for, of the main
+	// stream and of the others. Returns false when there is none.
+	bool receiveMessage(const Script::Message &message, bool &wasTaken);
+	bool receiveMainSceneMessage(const Script::Message &message, bool &wasTaken);
 #endif
 
 	// Used from nancy2 onwards
diff --git a/engines/nancy/state/scenestream.cpp b/engines/nancy/state/scenestream.cpp
index 1cb67afb5b6..e6a834b44cc 100644
--- a/engines/nancy/state/scenestream.cpp
+++ b/engines/nancy/state/scenestream.cpp
@@ -103,6 +103,16 @@ void SceneStream::onSave() {
 #endif
 }
 
+bool SceneStream::receiveMessage(const Script::Message &message, bool &wasTaken) {
+#ifdef ENABLE_NANCY_V4
+	if (_scriptScene) {
+		return _scriptScene->receiveMessage(message, wasTaken);
+	}
+#endif
+
+	return false;
+}
+
 void SceneStream::handleInput(NancyInput &input) {
 	if (_isEnded) {
 		return;
@@ -494,6 +504,29 @@ void SceneStreamManager::changeEnvironment(const Common::String &newEnvironment)
 	}
 }
 
+bool SceneStreamManager::receiveMessage(const Script::Message &message, bool &wasTaken) {
+	bool hasReceiver = false;
+	wasTaken = false;
+
+#ifdef ENABLE_NANCY_V4
+	// A scene can end its stream when it gets a message
+	const Common::Array<SceneStream *> streams = _streams;
+	for (SceneStream *stream : streams) {
+		if (wasTaken) {
+			break;
+		}
+
+		if (!stream) {
+			hasReceiver |= NancySceneState.receiveMainSceneMessage(message, wasTaken);
+		} else if (!stream->isEnded()) {
+			hasReceiver |= stream->receiveMessage(message, wasTaken);
+		}
+	}
+#endif
+
+	return hasReceiver;
+}
+
 // Only the scenes of the streams get saved; their records start over
 void SceneStreamManager::store() {
 	StreamData *data = (StreamData *)NancySceneState.getPuzzleData(StreamData::getTag());
diff --git a/engines/nancy/state/scenestream.h b/engines/nancy/state/scenestream.h
index a146c438b35..bb17e9a6f00 100644
--- a/engines/nancy/state/scenestream.h
+++ b/engines/nancy/state/scenestream.h
@@ -44,6 +44,7 @@ class Viewport;
 
 namespace Script {
 class SceneContext;
+struct Message;
 }
 
 namespace State {
@@ -86,6 +87,9 @@ public:
 	// Nancy19: the scripts of the scene store what they keep in a saved game
 	void onSave();
 
+	// Nancy19: hands a message to the scene. Returns false when it isn't for it.
+	bool receiveMessage(const Script::Message &message, bool &wasTaken);
+
 	void changeScene(const Common::String &sceneName, uint16 frameID);
 	void end() { _isEnded = true; }
 	bool isEnded() const { return _isEnded; }
@@ -159,6 +163,10 @@ public:
 	void onMainSceneChange();
 	void changeEnvironment(const Common::String &newEnvironment);
 
+	// Nancy19: hands a message to the scenes of the streams it is for. Returns
+	// false when there is none.
+	bool receiveMessage(const Script::Message &message, bool &wasTaken);
+
 	// Keeps the streams in the saved game, and starts them again after loading
 	void store();
 	void restore();
diff --git a/engines/nancy/ui/ndui/common.h b/engines/nancy/ui/ndui/common.h
index 0de95349759..c105d9c5a2d 100644
--- a/engines/nancy/ui/ndui/common.h
+++ b/engines/nancy/ui/ndui/common.h
@@ -97,6 +97,7 @@ enum EventType {
 };
 
 enum ActionType {
+	kActionNone				= 0,	// only means something to the scripts of Nancy19
 	kActionShow				= 1,
 	kActionHide				= 2,
 	kActionToggle			= 3,


Commit: be7f7ec7c5ac0e3fd98255032efa854f6a78ed74
    https://github.com/scummvm/scummvm/commit/be7f7ec7c5ac0e3fd98255032efa854f6a78ed74
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:22:03+03:00

Commit Message:
NANCY: NANCY19: Play the theme music of each area

A scene starts a random theme of its area when none is playing.
Scenes that set loopTheme start another one when it ends.

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 5afc7994e00..8bfc1e3aeb0 100644
--- a/engines/nancy/script/records.cpp
+++ b/engines/nancy/script/records.cpp
@@ -259,10 +259,77 @@ void SummaryRecord::readTable(int tableIndex) {
 		luaL_error(L, "No background specified");
 	}
 
+	// Either whether themes keep playing, or the pause between them as well
+	lua_getfield(L, tableIndex, "loopTheme");
+	if (lua_type(L, -1) == LUA_TNUMBER) {
+		_loopsTheme = true;
+		_themePause = ABS(lua_tonumber(L, -1));
+	} else if (!lua_isnil(L, -1)) {
+		_loopsTheme = lua_toboolean(L, -1) != 0;
+	}
+
+	lua_pop(L, 1);
+
 	// The background is behind everything else
 	limitZ(kBackgroundScriptZ);
 }
 
+// The channel and the volume of the themes
+static const uint16 kThemeChannel = 0;
+static const uint16 kThemeVolume = 85;
+
+// Starts one of the themes of the area, unless one is playing
+void SummaryRecord::playTheme() {
+	if (g_nancy->_sound->isSoundPlaying(kThemeChannel)) {
+		return;
+	}
+
+	const ENVS *environments = GetEngineData(ENVS);
+	if (!environments) {
+		return;
+	}
+
+	for (const ENVS::Environment &environment : environments->environments) {
+		if (!environment.code.equalsIgnoreCase(_environment) || environment.musicNames.empty()) {
+			continue;
+		}
+
+		SoundDescription sound;
+		sound.name = environment.musicNames[g_nancy->_randomSource->getRandomNumber(environment.musicNames.size() - 1)];
+		sound.channelID = kThemeChannel;
+		sound.numLoops = 1;
+		sound.volume = kThemeVolume;
+
+		g_nancy->_sound->loadSound(sound);
+		g_nancy->_sound->playSound(sound);
+		return;
+	}
+}
+
+void SummaryRecord::updateTheme(double sceneTime) {
+	// How often the scene looks whether the theme is over
+	static const double kThemeCheckSeconds = 1.0;
+
+	if (!_loopsTheme || sceneTime <= _lastThemeCheck + kThemeCheckSeconds) {
+		return;
+	}
+
+	_lastThemeCheck = sceneTime;
+
+	if (_themeEndTime == 0.0 && !g_nancy->_sound->isSoundPlaying(kThemeChannel)) {
+		_themeEndTime = sceneTime;
+	}
+
+	if (_themeEndTime != 0.0 && _themeEndTime + _themePause <= sceneTime) {
+		_themeEndTime = 0.0;
+		playTheme();
+	}
+}
+
+void SummaryRecord::runOnce() {
+	playTheme();
+}
+
 void SummaryRecord::setBackground(UI::Viewport *background) {
 	_background = background;
 	_numFrames = background ? background->getFrameCount() : 0;
@@ -372,6 +439,7 @@ bool SummaryRecord::onMouseEvent(RecordEvent event, const ScriptPoint &point, bo
 
 void SummaryRecord::run(double sceneTime, double elapsedTime) {
 	_sceneTime = sceneTime;
+	updateTheme(sceneTime);
 
 	if (_numFrames <= 1 || _turn == kNoTurn) {
 		return;
diff --git a/engines/nancy/script/records.h b/engines/nancy/script/records.h
index 3c5281b72c5..25bd89fb524 100644
--- a/engines/nancy/script/records.h
+++ b/engines/nancy/script/records.h
@@ -85,7 +85,9 @@ protected:
 // The first record of every scene, which names the area the scene belongs to
 // and its background. A background with more than one frame is a node the
 // player can turn around in.
-// TODO: Handle the loopTheme and modColor fields.
+// The area has a set of music themes. One of them starts with the scene when
+// no theme is playing, and a scene can ask for another one once it is over.
+// TODO: Handle the modColor field.
 class SummaryRecord : public Record {
 public:
 	SummaryRecord(SceneContext &context) : Record(context, "ND.Summary") {}
@@ -98,6 +100,7 @@ public:
 
 protected:
 	void readTable(int tableIndex) override;
+	void runOnce() override;
 	void run(double sceneTime, double elapsedTime) override;
 	bool hasTransform() const override { return true; }
 	bool getField(const Common::String &key) override;
@@ -112,10 +115,17 @@ private:
 	// The side of the node the mouse is on
 	Turn getTurnAt(const ScriptPoint &point, bool inViewport) const;
 	void setFrame(uint frame);
+	void playTheme();
+	void updateTheme(double sceneTime);
 
 	Common::String _environment;
 	Common::String _backgroundName;
 
+	bool _loopsTheme = false;
+	double _themePause = 0.0;		// the seconds of silence between two themes
+	double _themeEndTime = 0.0;		// when the theme was found to be over; 0 while one plays
+	double _lastThemeCheck = 0.0;
+
 	UI::Viewport *_background = nullptr;
 	uint _numFrames = 0;
 	uint _frame = 0;


Commit: 6cded30d2d0420d950843a16881d0d4d11c13dae
    https://github.com/scummvm/scummvm/commit/6cded30d2d0420d950843a16881d0d4d11c13dae
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:22:04+03:00

Commit Message:
NANCY: NANCY10: Fix the cellphone search and e-mail lists

The scroll arrows now scroll these lists one row per click, instead
of moving the hidden selection of the contacts directory.

Re-adding a listed entry now replaces it in place, so a topic picks
up its new event flag. A re-added e-mail becomes unread again, and
new e-mails go to the top of the inbox.

Fix #17344

Changed paths:
    engines/nancy/ui/taskbar/cellphonepopup.cpp
    engines/nancy/ui/taskbar/cellphonepopup.h


diff --git a/engines/nancy/ui/taskbar/cellphonepopup.cpp b/engines/nancy/ui/taskbar/cellphonepopup.cpp
index ac8dafd7eb7..4b6557ccd6a 100644
--- a/engines/nancy/ui/taskbar/cellphonepopup.cpp
+++ b/engines/nancy/ui/taskbar/cellphonepopup.cpp
@@ -259,15 +259,29 @@ void CellPhonePopup::addSearchLink(int16 mode, const SearchLink &link) {
 	Common::Array<SearchLink> &list =
 		isSearch ? cellData->active().searchLinks : cellData->active().emailMessages;
 
-	// Skip duplicates (matched by key) so re-running the scene doesn't
-	// pile up the same entries.
+	// An entry whose key is already listed keeps its place, but takes over the
+	// new data: scripts re-add a topic to change the event flag it sets.
+	// A re-added e-mail also counts as unread again.
+	bool replaced = false;
 	for (uint i = 0; i < list.size(); ++i) {
-		if (list[i].key.equalsIgnoreCase(link.key)) {
-			return;
+		if (list[i].key == link.key) {
+			list[i] = link;
+			list[i].read = false;
+			replaced = true;
+			break;
 		}
 	}
 
-	list.push_back(link);
+	if (!replaced) {
+		// New e-mails go to the top of the inbox, new topics to the end of
+		// the search list.
+		if (isSearch) {
+			list.push_back(link);
+		} else {
+			list.insert_at(0, link);
+			list[0].read = false;
+		}
+	}
 
 	if (_isVisible &&
 			((isSearch && _screenState == kWebList) ||
@@ -2112,6 +2126,27 @@ void CellPhonePopup::moveDirectorySelection(int delta) {
 	drawScreenContent();
 }
 
+void CellPhonePopup::scrollLinkList(int delta) {
+	const uint total = currentListEntryCount();
+	const uint totalRows = maxDirectoryRows();
+	const uint titleRows = listTitleRows();
+	const uint pageRows = totalRows > titleRows ? totalRows - titleRows : 0;
+
+	if (delta < 0) {
+		if (_directoryScroll == 0) {
+			return;
+		}
+		--_directoryScroll;
+	} else {
+		if (_directoryScroll + pageRows >= total) {
+			return;
+		}
+		++_directoryScroll;
+	}
+
+	drawScreenContent();
+}
+
 uint CellPhonePopup::directoryRowAt(const Common::Point &chunkMouse) const {
 	const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
 									chunkMouse.y - _screenPosition.top);
@@ -2589,14 +2624,14 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		if (upDst.contains(chunkMouse)) {
 			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 			if (input.input & NancyInput::kLeftMouseButtonUp) {
-				moveDirectorySelection(-1);
+				scrollLinkList(-1);
 				input.eatMouseInput();
 				return;
 			}
 		} else if (downDst.contains(chunkMouse)) {
 			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 			if (input.input & NancyInput::kLeftMouseButtonUp) {
-				moveDirectorySelection(+1);
+				scrollLinkList(+1);
 				input.eatMouseInput();
 				return;
 			}
@@ -2648,9 +2683,6 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 				const uint absolute = visible[visIdx];
 				g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 				if (input.input & NancyInput::kLeftMouseButtonUp) {
-					// Move the selection indicator to the clicked row, then
-					// act on the entry.
-					_directorySelection = entryRow;
 					SearchLink &e = (*list)[absolute];
 					// Original sets the event flag when an entry is opened;
 					// it does not scene-change from a list click.
diff --git a/engines/nancy/ui/taskbar/cellphonepopup.h b/engines/nancy/ui/taskbar/cellphonepopup.h
index ca0fe42c8ba..c31475fec32 100644
--- a/engines/nancy/ui/taskbar/cellphonepopup.h
+++ b/engines/nancy/ui/taskbar/cellphonepopup.h
@@ -308,6 +308,9 @@ private:
 	bool isBrowserArticle() const;
 	// Move the directory selection by delta, scrolling as needed.
 	void moveDirectorySelection(int delta);
+	// Scroll the e-mail / search list by one row. These lists have no
+	// selection, so the arrows move the list itself.
+	void scrollLinkList(int delta);
 
 	Common::Point mouseToChunkCoords(const Common::Point &mouse) const;
 


Commit: c27d1a43b0a982fa964aadfab44166e0cdb8bc0e
    https://github.com/scummvm/scummvm/commit/c27d1a43b0a982fa964aadfab44166e0cdb8bc0e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:22:05+03:00

Commit Message:
NANCY: NANCY10: Fix the magnet maze puzzle to behave like the original

Pieces are no longer clicked. With the magnet in hand, a piece is
picked up when the cursor moves over it and dropped when the cursor
gets away from it, e.g. when a wall holds the piece back. The piece
heads straight for the cursor at a limited speed and does not slide
along walls. A piece locks as soon as it touches its target zone.

- Show the highlighted item cursor over a piece instead of the
  drag hand
- Play the drop sound, and the lock sound when a held piece snaps
  into place; the sounds in the record were mislabeled
- Load the puzzle's sounds right before playing them, as they
  share their channels with the UI's click sound
- Show the "picked up" and "dropped" captions stored in the record
- Read the reset button as a UI button record: show its pressed
  sprite, play its click sound, and reset the pieces afterwards
- Only reset the pieces that are not locked in place
- Read the record sequentially instead of seeking

The last field of the UI button record is the time its pressed
sprite stays up, so name it accordingly.

Fix #17343

Changed paths:
    engines/nancy/action/puzzle/magnetmazepuzzle.cpp
    engines/nancy/action/puzzle/magnetmazepuzzle.h
    engines/nancy/commontypes.h
    engines/nancy/util.cpp


diff --git a/engines/nancy/action/puzzle/magnetmazepuzzle.cpp b/engines/nancy/action/puzzle/magnetmazepuzzle.cpp
index cba9863b7ee..2b297711b69 100644
--- a/engines/nancy/action/puzzle/magnetmazepuzzle.cpp
+++ b/engines/nancy/action/puzzle/magnetmazepuzzle.cpp
@@ -33,9 +33,10 @@
 namespace Nancy {
 namespace Action {
 
-void MagnetMazePuzzle::readData(Common::SeekableReadStream &stream) {
-	const int64 start = stream.pos();
+// Speed of a held piece, in pixels per millisecond
+static const float kMagnetSpeed = 1.0f / 3.0f;
 
+void MagnetMazePuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _boardImageName);
 	readFilename(stream, _mazeImageName);
 
@@ -48,7 +49,7 @@ void MagnetMazePuzzle::readData(Common::SeekableReadStream &stream) {
 	_hideOverlays       = (stream.readByte() != 0);
 
 	_requiredItem = stream.readSint16LE();
-	stream.skip(1); // hit-test mode flag (unused in this port)
+	_hoverUsesCursorCenter = (stream.readByte() != 0);
 
 	for (int i = 0; i < kNumMagnets; ++i)
 		readRect(stream, _magnetSrcRects[i]);
@@ -57,51 +58,52 @@ void MagnetMazePuzzle::readData(Common::SeekableReadStream &stream) {
 	for (int i = 0; i < kNumMagnets; ++i)
 		readRect(stream, _magnetTargetRects[i]);
 
-	// CUIButton block at AR+0x1c7 (239 bytes). Internal layout: 15-byte
-	// shared-art name, 4 × 16-byte state src rects at +0x46, hot rect at +0x96,
-	// click sound at +0xbe. Only the hot rect is needed for hit-testing.
-	stream.seek(start + 0x1c7 + 0x96);
-	readRect(stream, _resetButtonHotspot);
+	readUIButton(stream, _resetButton);
 
 	// Skip the decorative highlight RGB; read the wall RGB used for collision.
-	stream.seek(start + 0x2b9);
+	stream.skip(3);
 	_wallColorR = stream.readByte();
 	_wallColorG = stream.readByte();
 	_wallColorB = stream.readByte();
 
-	stream.seek(start + 0x2bc);
+	char textBuf[100];
 
 	_pickupSound.readNormal(stream);
-	stream.seek(start + 0x372);
-	_placeSound.readNormal(stream);
-	stream.seek(start + 0x428);
-	_resetSound.readNormal(stream);
-	stream.seek(start + 0x459);
-	_bumpSound.readNormal(stream);
-
-	stream.seek(start + 0x48a);
+	readFilename(stream, _pickupTextKey);
+	stream.read(textBuf, sizeof(textBuf));
+	textBuf[sizeof(textBuf) - 1] = '\0';
+	_pickupText = textBuf;
+
+	_dropSound.readNormal(stream);
+	readFilename(stream, _dropTextKey);
+	stream.read(textBuf, sizeof(textBuf));
+	textBuf[sizeof(textBuf) - 1] = '\0';
+	_dropText = textBuf;
+
+	_moveSound.readNormal(stream);
+	_lockSound.readNormal(stream);
+
 	_solveScene._sceneChange.readData(stream);
-	stream.seek(start + 0x4a0);
+	stream.skip(2);
 	_solveScene._flag.label = stream.readSint16LE();
 	_solveScene._flag.flag  = stream.readByte();
 	_solveSoundDelay   = stream.readUint16LE();
 	_solveSound.readNormal(stream);
 
-	stream.seek(start + 0x4d6);
 	_exitScene._sceneChange.readData(stream);
-	stream.seek(start + 0x4ec);
+	stream.skip(2);
 	_exitScene._flag.label = stream.readSint16LE();
 	_exitScene._flag.flag  = stream.readByte();
 
 	readRect(stream, _exitHotspot);
 }
 
+// Only the pieces that are not locked in place go back home
 void MagnetMazePuzzle::resetMagnets() {
 	for (int i = 0; i < kNumMagnets; ++i) {
-		_magnetPos[i]    = _magnetHomeRects[i];
-		_magnetLocked[i] = false;
+		if (!_magnetLocked[i])
+			_magnetPos[i] = _magnetHomeRects[i];
 	}
-	_heldMagnet = -1;
 }
 
 void MagnetMazePuzzle::init() {
@@ -118,6 +120,9 @@ void MagnetMazePuzzle::init() {
 
 	g_nancy->_resource->loadImage(_mazeImageName, _mazeImage);
 
+	g_nancy->_resource->loadImage(_resetButton.primaryImageName, _resetButtonImage);
+	_resetButtonImage.setTransparentColor(_drawSurface.getTransparentColor());
+
 	MagnetMazePuzzleData *mmd = (MagnetMazePuzzleData *)NancySceneState.getPuzzleData(MagnetMazePuzzleData::getTag());
 	bool restored = false;
 	if (mmd && mmd->magnetState.size() >= kNumMagnets * 5) {
@@ -133,7 +138,11 @@ void MagnetMazePuzzle::init() {
 		restored = true;
 	}
 	if (!restored) {
-		resetMagnets();
+		for (int i = 0; i < kNumMagnets; ++i) {
+			_magnetPos[i]    = _magnetHomeRects[i];
+			_magnetLocked[i] = false;
+		}
+		_heldMagnet = -1;
 		persistState();
 	}
 
@@ -154,6 +163,16 @@ void MagnetMazePuzzle::execute() {
 	case kRun:
 		switch (_subState) {
 		case kPlaying:
+			// The reset takes effect once the button's press is over
+			if (_resetPressed && g_system->getMillis() > _resetPressEndTime &&
+					!g_nancy->_sound->isSoundPlaying(_resetButton.clickSound)) {
+				_resetPressed = false;
+				resetMagnets();
+				persistState();
+				redraw();
+			}
+
+			lockMagnetsOnTargets();
 			break;
 		case kWaitWinDelay:
 			if (g_system->getMillis() >= _winDelayEndTime) {
@@ -180,9 +199,10 @@ void MagnetMazePuzzle::execute() {
 
 	case kActionTrigger:
 		g_nancy->_sound->stopSound(_pickupSound);
-		g_nancy->_sound->stopSound(_placeSound);
-		g_nancy->_sound->stopSound(_resetSound);
-		g_nancy->_sound->stopSound(_bumpSound);
+		g_nancy->_sound->stopSound(_dropSound);
+		g_nancy->_sound->stopSound(_moveSound);
+		g_nancy->_sound->stopSound(_lockSound);
+		g_nancy->_sound->stopSound(_resetButton.clickSound);
 		g_nancy->_sound->stopSound(_solveSound);
 		if (_subState == kExitToWin) {
 			MagnetMazePuzzleData *mmd = (MagnetMazePuzzleData *)NancySceneState.getPuzzleData(MagnetMazePuzzleData::getTag());
@@ -231,52 +251,123 @@ bool MagnetMazePuzzle::collidesAt(const Common::Rect &r) const {
 	return false;
 }
 
-void MagnetMazePuzzle::stepMagnetToward(Common::Rect &cur, const Common::Rect &target) const {
-	int dx = target.left - cur.left;
-	int dy = target.top - cur.top;
-
-	// Single-pixel walk toward the target with axis-only fallback so a
-	// diagonal blocked by a wall can still slide along it.
-	while (dx != 0 || dy != 0) {
-		const int sx = (dx > 0) ? 1 : (dx < 0 ? -1 : 0);
-		const int sy = (dy > 0) ? 1 : (dy < 0 ? -1 : 0);
-
-		if (sx != 0 && sy != 0) {
-			Common::Rect cand(cur.left + sx, cur.top + sy, cur.right + sx, cur.bottom + sy);
-			if (!collidesAt(cand)) {
-				cur = cand;
-				dx -= sx;
-				dy -= sy;
-				continue;
-			}
-		}
-		if (sx != 0) {
-			Common::Rect cand(cur.left + sx, cur.top, cur.right + sx, cur.bottom);
-			if (!collidesAt(cand)) {
-				cur = cand;
-				dx -= sx;
-				continue;
-			}
-		}
-		if (sy != 0) {
-			Common::Rect cand(cur.left, cur.top + sy, cur.right, cur.bottom + sy);
-			if (!collidesAt(cand)) {
-				cur = cand;
-				dy -= sy;
-				continue;
-			}
-		}
-		break; // boxed in on every axis
+// A loose piece that touches its target zone snaps to the middle of it and locks
+void MagnetMazePuzzle::lockMagnetsOnTargets() {
+	bool changed = false;
+
+	for (int i = 0; i < kNumMagnets; ++i) {
+		if (_magnetLocked[i] || !_magnetPos[i].intersects(_magnetTargetRects[i]))
+			continue;
+
+		const Common::Rect &tgt = _magnetTargetRects[i];
+		const Common::Rect &src = _magnetHomeRects[i];
+		int x = tgt.left + tgt.width() / 2 - src.width() / 2;
+		int y = tgt.top + tgt.height() / 2 - src.height() / 2;
+		_magnetPos[i] = Common::Rect(x, y, x + src.width(), y + src.height());
+		_magnetLocked[i] = true;
+		_magnetGrabbed[i] = false;
+
+		if (_heldMagnet == i)
+			playPuzzleSound(_lockSound);
+		_heldMagnet = -1;
+		changed = true;
+	}
+
+	if (!changed)
+		return;
+
+	persistState();
+	redraw();
+
+	for (int i = 0; i < kNumMagnets; ++i) {
+		if (!_magnetLocked[i])
+			return;
+	}
+
+	_isSolved = true;
+	_subState = kWaitWinDelay;
+	_winDelayEndTime = g_system->getMillis() + (uint32)_solveSoundDelay * 1000;
+}
+
+void MagnetMazePuzzle::dropMagnets() {
+	for (int i = 0; i < kNumMagnets; ++i) {
+		if (_magnetLocked[i] || !_magnetGrabbed[i])
+			continue;
+
+		_magnetGrabbed[i] = false;
+		_heldMagnet = -1;
+		_wasDropped = true;
+
+		playPuzzleSound(_dropSound);
+		showSubtitle(resolveSubtitleText(_dropTextKey, _dropText));
+		persistState();
+		redraw();
+	}
+}
+
+// The held piece heads straight for the center of the cursor image, at a
+// limited speed, and stops at the first wall in its way; it does not slide
+// along walls. Returns false when it could not get any closer.
+bool MagnetMazePuzzle::moveHeldMagnet(const Common::Point &cursorCenter) {
+	Common::Rect &pos = _magnetPos[_heldMagnet];
+	const int w = pos.width();
+	const int h = pos.height();
+	float dx = cursorCenter.x - w / 2 - pos.left;
+	float dy = cursorCenter.y - h / 2 - pos.top;
+
+	const uint32 now = g_system->getMillis();
+	const float dist = sqrt(dx * dx + dy * dy);
+	if (dist < 1.0f) {
+		_lastMoveTime = now;
+		return false;
+	}
+
+	// Wait for the next frame when not even a pixel's worth of time has passed
+	const float maxDist = MIN<float>(dist, kMagnetSpeed * (now - _lastMoveTime));
+	if (maxDist < 1.0f)
+		return false;
+
+	_lastMoveTime = now;
+	dx /= dist;
+	dy /= dist;
+
+	Common::Rect reached = pos;
+	float x = pos.left;
+	float y = pos.top;
+	for (int i = 0; i < (int)maxDist; ++i) {
+		x += dx;
+		y += dy;
+		Common::Rect next((int)x, (int)y, (int)x + w, (int)y + h);
+		if (collidesAt(next))
+			break;
+		reached = next;
 	}
+
+	if (reached == pos)
+		return false;
+
+	pos = reached;
+	if (!g_nancy->_sound->isSoundPlaying(_moveSound))
+		playPuzzleSound(_moveSound);
+	redraw();
+	return true;
 }
 
-void MagnetMazePuzzle::snapMagnet(int idx) {
-	const Common::Rect &tgt = _magnetTargetRects[idx];
-	const Common::Rect &src = _magnetHomeRects[idx];
-	int x = tgt.left + (tgt.width()  - src.width())  / 2;
-	int y = tgt.top  + (tgt.height() - src.height()) / 2;
-	_magnetPos[idx] = Common::Rect(x, y, x + src.width(), y + src.height());
-	_magnetLocked[idx] = true;
+bool MagnetMazePuzzle::isMagnetHovered(int idx, const Common::Rect &cursorRect, const Common::Point &cursorCenter) const {
+	if (_hoverUsesCursorCenter)
+		return _magnetPos[idx].contains(cursorCenter);
+
+	return _magnetPos[idx].intersects(cursorRect);
+}
+
+// The sounds share their channels with the UI's (e.g. the taskbar's click), so
+// they are loaded right before they play
+void MagnetMazePuzzle::playPuzzleSound(SoundDescription &sound) {
+	if (sound.name == "NO SOUND")
+		return;
+
+	g_nancy->_sound->loadSound(sound);
+	g_nancy->_sound->playSound(sound);
 }
 
 void MagnetMazePuzzle::handleInput(NancyInput &input) {
@@ -286,17 +377,20 @@ void MagnetMazePuzzle::handleInput(NancyInput &input) {
 	Common::Rect vpScreen = NancySceneState.getViewport().getScreenPosition();
 	Common::Point mouseVP = input.mousePos - Common::Point(vpScreen.left, vpScreen.top);
 
-	if (_heldMagnet != -1 && _heldDrawPos != mouseVP) {
-		_heldDrawPos = mouseVP;
-		const Common::Rect &src = _magnetHomeRects[_heldMagnet];
-		int w = src.width();
-		int h = src.height();
-		Common::Rect target(mouseVP.x - w / 2, mouseVP.y - h / 2,
-		                    mouseVP.x - w / 2 + w, mouseVP.y - h / 2 + h);
-		Common::Rect prev = _magnetPos[_heldMagnet];
-		stepMagnetToward(_magnetPos[_heldMagnet], target);
-		if (_magnetPos[_heldMagnet] != prev)
+	if (_resetButton.destRect.contains(mouseVP)) {
+		if (_resetButton.hoverCursorFlag)
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+
+		if ((input.input & NancyInput::kLeftMouseButtonUp) && !_resetPressed) {
+			_resetPressed = true;
+			_resetPressEndTime = g_system->getMillis() + _resetButton.pressedDuration;
+			if (_resetButton.clickSound.name != "NO SOUND") {
+				g_nancy->_sound->loadSound(_resetButton.clickSound);
+				g_nancy->_sound->playSound(_resetButton.clickSound);
+			}
 			redraw();
+		}
+		return;
 	}
 
 	if (hoverExitHotspot(input)) {
@@ -305,65 +399,51 @@ void MagnetMazePuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
-	if (!_resetButtonHotspot.isEmpty() && _resetButtonHotspot.contains(mouseVP)) {
-		g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
-		if (input.input & NancyInput::kLeftMouseButtonUp) {
-			resetMagnets();
-			if (_resetSound.name != "NO SOUND") {
-				g_nancy->_sound->loadSound(_resetSound);
-				g_nancy->_sound->playSound(_resetSound);
-			}
-			persistState();
-			redraw();
+	// The pieces react to the cursor's image, not to the mouse position itself
+	Common::Rect cursorRect = g_nancy->_cursor->getCurrentCursorBounds();
+	cursorRect.moveTo(mouseVP - g_nancy->_cursor->getCurrentCursorHotspot());
+	const Common::Point cursorCenter(cursorRect.left + (cursorRect.width() - 1) / 2,
+	                                 cursorRect.top + (cursorRect.height() - 1) / 2);
+
+	// A piece that is stuck is let go of, but is taken again right below if the
+	// cursor is still on it
+	if (_heldMagnet != -1 && !moveHeldMagnet(cursorCenter))
+		_heldMagnet = -1;
+
+	int hovered = -1;
+	for (int i = 0; i < kNumMagnets; ++i) {
+		if (!_magnetLocked[i] && isMagnetHovered(i, cursorRect, cursorCenter)) {
+			hovered = i;
+			break;
 		}
+	}
+
+	if (hovered == -1) {
+		dropMagnets();
 		return;
 	}
 
-	if (_heldMagnet == -1) {
-		for (int i = 0; i < kNumMagnets; ++i) {
-			if (_magnetLocked[i])
-				continue;
-			if (!_magnetPos[i].contains(mouseVP))
-				continue;
-			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
-			if (input.input & NancyInput::kLeftMouseButtonUp) {
-				if (_requiredItem != -1 && NancySceneState.getHeldItem() != _requiredItem) {
-					if (!_cantPlayed[i]) {
-						NancySceneState.playItemCantSound(_requiredItem);
-						_cantPlayed[i] = true;
-					}
-					return;
-				}
-				_heldMagnet = i;
-				_heldDrawPos = mouseVP;
-				if (_pickupSound.name != "NO SOUND") {
-					g_nancy->_sound->loadSound(_pickupSound);
-					g_nancy->_sound->playSound(_pickupSound);
-				}
-			}
-			return;
+	if (_requiredItem != -1 && NancySceneState.getHeldItem() != _requiredItem) {
+		if (!_cantPlayed[hovered]) {
+			NancySceneState.playItemCantSound(_requiredItem);
+			_cantPlayed[hovered] = true;
 		}
 		return;
 	}
 
-	g_nancy->_cursor->setCursorType(CursorManager::kDragHand);
-	if (!(input.input & NancyInput::kLeftMouseButtonUp))
-		return;
+	g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
 
-	const Common::Rect &tgt = _magnetTargetRects[_heldMagnet];
-	if (_magnetPos[_heldMagnet].intersects(tgt)) {
-		snapMagnet(_heldMagnet);
-		if (_placeSound.name != "NO SOUND") {
-			g_nancy->_sound->loadSound(_placeSound);
-			g_nancy->_sound->playSound(_placeSound);
+	if (_heldMagnet == -1) {
+		_heldMagnet = hovered;
+		_magnetGrabbed[hovered] = true;
+		_lastMoveTime = g_system->getMillis();
+		if (_wasDropped) {
+			_wasDropped = false;
+			playPuzzleSound(_pickupSound);
+			showSubtitle(resolveSubtitleText(_pickupTextKey, _pickupText));
 		}
+		redraw();
 	}
-
-	persistState();
-
-	_heldMagnet = -1;
-	checkSolved();
-	redraw();
 }
 
 void MagnetMazePuzzle::persistState() {
@@ -381,25 +461,20 @@ void MagnetMazePuzzle::persistState() {
 	}
 }
 
-void MagnetMazePuzzle::checkSolved() {
-	for (int i = 0; i < kNumMagnets; ++i) {
-		if (!_magnetLocked[i])
-			return;
-	}
-	_isSolved = true;
-	_subState = kWaitWinDelay;
-	_winDelayEndTime = g_system->getMillis() + (uint32)_solveSoundDelay * 1000;
-}
-
 void MagnetMazePuzzle::redraw() {
 	_drawSurface.clear(_drawSurface.getTransparentColor());
 
-	for (int i = 0; i < kNumMagnets; ++i) {
-		const Common::Rect &src = _magnetSrcRects[i];
-		const Common::Rect &dst = _magnetPos[i];
-		if (src.isEmpty() || dst.isEmpty())
-			continue;
-		_drawSurface.blitFrom(_boardImage, src, Common::Point(dst.left, dst.top));
+	// The held piece goes on top of the others
+	for (int pass = 0; pass < 2; ++pass) {
+		for (int i = 0; i < kNumMagnets; ++i) {
+			if ((i == _heldMagnet) != (pass == 1))
+				continue;
+			const Common::Rect &src = _magnetSrcRects[i];
+			const Common::Rect &dst = _magnetPos[i];
+			if (src.isEmpty() || dst.isEmpty())
+				continue;
+			_drawSurface.blitFrom(_boardImage, src, Common::Point(dst.left, dst.top));
+		}
 	}
 
 	if (!_hideOverlays) {
@@ -412,6 +487,12 @@ void MagnetMazePuzzle::redraw() {
 		}
 	}
 
+	if (_resetPressed) {
+		const Common::Rect &src = _resetButton.sourceRects[kUIButtonPressed];
+		if (!src.isEmpty())
+			_drawSurface.blitFrom(_resetButtonImage, src, Common::Point(_resetButton.destRect.left, _resetButton.destRect.top));
+	}
+
 	_needsRedraw = true;
 }
 
diff --git a/engines/nancy/action/puzzle/magnetmazepuzzle.h b/engines/nancy/action/puzzle/magnetmazepuzzle.h
index 284da19e995..5753cc0ba4a 100644
--- a/engines/nancy/action/puzzle/magnetmazepuzzle.h
+++ b/engines/nancy/action/puzzle/magnetmazepuzzle.h
@@ -28,12 +28,14 @@
 namespace Nancy {
 namespace Action {
 
-// Four magnet pieces, four targets on a maze board. The player picks a piece
-// from its home slot and drops it onto its target zone; an overlap snaps the
-// piece to a fixed position and locks it. Reset button returns all pieces
-// home. Wins when every piece is locked into its target.
-// Called in Nancy10 from scenes 3280 (normal mode, with overlaid pieces) and
-// 3281 (easy mode, without overlaid pieces, triggered as a cheat mode when
+// Four metal pieces lie in a maze, partly hidden under wooden boards. With
+// the magnet in hand, a piece is picked up by moving the cursor over it and
+// then heads for the cursor at a limited speed; it is dropped as soon as the
+// cursor gets away from it, e.g. because a wall holds the piece back. A piece
+// that reaches its target zone snaps into place and locks. The reset button
+// returns all loose pieces home. Wins when every piece is locked.
+// Called in Nancy10 from scenes 3280 (normal mode, with overlaid boards) and
+// 3281 (easy mode, without overlaid boards, triggered as a cheat mode when
 // visiting the puzzle multiple times).
 class MagnetMazePuzzle : public PuzzleRecord {
 public:
@@ -71,12 +73,20 @@ protected:
 
 	int16 _requiredItem = -1;
 
-	Common::Rect _resetButtonHotspot;
+	// When set, a piece is hovered while the center of the cursor image is
+	// inside it; otherwise the whole cursor image is tested against the piece
+	bool _hoverUsesCursorCenter = false;
+
+	UIButtonRecord _resetButton;
 
 	SoundDescription _pickupSound;
-	SoundDescription _placeSound;
-	SoundDescription _resetSound;
-	SoundDescription _bumpSound;
+	Common::String _pickupTextKey;
+	Common::String _pickupText;
+	SoundDescription _dropSound;
+	Common::String _dropTextKey;
+	Common::String _dropText;
+	SoundDescription _moveSound;
+	SoundDescription _lockSound;
 
 	// Runtime state
 
@@ -92,16 +102,21 @@ protected:
 
 	Common::Rect _magnetPos[kNumMagnets];
 	bool _magnetLocked[kNumMagnets] = {};
+	bool _magnetGrabbed[kNumMagnets] = {};
 
 	int  _heldMagnet  = -1;
+	bool _wasDropped  = false; // the pickup sound only plays after a drop
 	bool _isSolved    = false;
 	bool _cantPlayed[kNumMagnets] = {};
 	uint32 _winDelayEndTime = 0;
+	uint32 _lastMoveTime = 0;
 
-	Common::Point _heldDrawPos;
+	bool _resetPressed = false;
+	uint32 _resetPressEndTime = 0;
 
 	Graphics::ManagedSurface _boardImage;
 	Graphics::ManagedSurface _mazeImage; // wall mask for pixel-collision tests
+	Graphics::ManagedSurface _resetButtonImage;
 
 	// RGB of the maze image's "wall" pixel color; a magnet rect overlapping
 	// any pixel of this color in the maze image is treated as colliding.
@@ -110,12 +125,14 @@ protected:
 	byte _wallColorB = 0;
 
 	void resetMagnets();
-	void snapMagnet(int idx);
+	void lockMagnetsOnTargets();
+	void dropMagnets();
+	bool moveHeldMagnet(const Common::Point &cursorCenter);
+	bool isMagnetHovered(int idx, const Common::Rect &cursorRect, const Common::Point &cursorCenter) const;
+	void playPuzzleSound(SoundDescription &sound);
 	void persistState();
 	void redraw();
-	void checkSolved();
 	bool collidesAt(const Common::Rect &r) const;
-	void stepMagnetToward(Common::Rect &cur, const Common::Rect &target) const;
 };
 
 } // End of namespace Action
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index e352bbaa7e6..01fc6a52df6 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -439,7 +439,7 @@ struct UIButtonRecord {
 	uint32 hoverCursorFlag = 0; // swap cursor sprite on hover when set
 	uint32 secondaryStateField = 0;
 	uint32 initialState = 0; // 0=idle, 1=hover, 2=pressed, 3/4=disabled
-	uint32 reservedField = 0;
+	uint32 pressedDuration = 0; // milliseconds the pressed sprite stays up after a click
 	SoundDescription clickSound;
 };
 
diff --git a/engines/nancy/util.cpp b/engines/nancy/util.cpp
index 4248a9823f1..14b7ea6f312 100644
--- a/engines/nancy/util.cpp
+++ b/engines/nancy/util.cpp
@@ -322,7 +322,7 @@ void readUIButton(Common::SeekableReadStream &stream, UIButtonRecord &dst) {
 	dst.hoverCursorFlag = stream.readUint32LE();
 	dst.secondaryStateField = stream.readUint32LE();
 	dst.initialState = stream.readUint32LE();
-	dst.reservedField = stream.readUint32LE();
+	dst.pressedDuration = stream.readUint32LE();
 
 	dst.clickSound.readNormal(stream);
 }


Commit: 41b1020f2a5057603a64ab082c193cbb492b361a
    https://github.com/scummvm/scummvm/commit/41b1020f2a5057603a64ab082c193cbb492b361a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:22:07+03:00

Commit Message:
NANCY: Let physical objects run into obstacles

A moving object can now be given a list of pins and blocks inside its
bounds, which it bounces off or stops at just like at the bounds
themselves. Needed for the pin board of the Nancy13 Gold Rush game

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


diff --git a/engines/nancy/action/physicalobject.cpp b/engines/nancy/action/physicalobject.cpp
index 683f9535f28..b020849f444 100644
--- a/engines/nancy/action/physicalobject.cpp
+++ b/engines/nancy/action/physicalobject.cpp
@@ -19,6 +19,9 @@
  *
  */
 
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
 #include "engines/nancy/action/physicalobject.h"
 
 namespace Nancy {
@@ -31,6 +34,8 @@ static const double kMinSpeed = 0.01;
 static const double kRestSpeed = 30.0;
 // How far an object that bounced is pushed back in
 static const double kBounceDistance = 2.0;
+// A pin sends an object off at a slightly different angle each time
+static const double kPinNormalJitter = 0.0001;
 
 double PhysicalObject::getAngle(double x, double y) {
 	double angle = atan2(-y, x);
@@ -73,6 +78,61 @@ double PhysicalObject::getBoundsNormal(const Common::Rect &rect) const {
 	return -1;
 }
 
+bool PhysicalObject::isClearOfObstacle(const Obstacle &obstacle, const Common::Rect &rect) const {
+	if (obstacle.type == Obstacle::kBlock) {
+		return !rect.intersects(obstacle.rect);
+	}
+
+	double dx = (rect.left + rect.width() * 0.5) - (obstacle.rect.left + obstacle.rect.width() * 0.5);
+	double dy = (rect.top + rect.height() * 0.5) - (obstacle.rect.top + obstacle.rect.height() * 0.5);
+	return sqrt(dx * dx + dy * dy) > rect.width();
+}
+
+bool PhysicalObject::isClearOfObstacles(const Common::Rect &rect) const {
+	if (!_obstacles) {
+		return true;
+	}
+
+	for (const Obstacle &obstacle : *_obstacles) {
+		if (!isClearOfObstacle(obstacle, rect)) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+double PhysicalObject::getObstacleNormal(const Common::Rect &rect) const {
+	if (!_obstacles) {
+		return -1;
+	}
+
+	for (const Obstacle &obstacle : *_obstacles) {
+		if (isClearOfObstacle(obstacle, rect)) {
+			continue;
+		}
+
+		const Common::Rect &block = obstacle.rect;
+		if (obstacle.type == Obstacle::kPin) {
+			double dx = (rect.left + rect.width() * 0.5) - (block.left + block.width() * 0.5);
+			double dy = (rect.top + rect.height() * 0.5) - (block.top + block.height() * 0.5);
+			return getAngle(dx, dy) + g_nancy->_randomSource->getRandomNumber(999) * kPinNormalJitter;
+		}
+
+		// Off the side of the block that the rect sticks out of. One coming
+		// down on the block from right above it is let through.
+		if (rect.left <= block.right && block.right < rect.right) {
+			return 0;
+		} else if (block.left <= rect.right && rect.left < block.left) {
+			return M_PI;
+		} else if (rect.top <= block.bottom && block.bottom < rect.bottom) {
+			return 3 * M_PI / 2;
+		}
+	}
+
+	return -1;
+}
+
 void PhysicalObject::setSpeed(double speed) {
 	_speed = speed;
 
@@ -101,7 +161,8 @@ void PhysicalObject::moveTo(double x, double y) {
 }
 
 void PhysicalObject::stopAtBounds(double x, double y) {
-	if (isInsideBounds(getRectAt(x, y))) {
+	Common::Rect rect = getRectAt(x, y);
+	if (isInsideBounds(rect) && isClearOfObstacles(rect)) {
 		_x = x;
 		_y = y;
 	} else {
@@ -111,7 +172,12 @@ void PhysicalObject::stopAtBounds(double x, double y) {
 }
 
 void PhysicalObject::bounceOffBounds(double x, double y) {
-	double normal = getBoundsNormal(getRectAt(x, y));
+	Common::Rect rect = getRectAt(x, y);
+	double normal = getBoundsNormal(rect);
+	if (normal < 0) {
+		normal = getObstacleNormal(rect);
+	}
+
 	if (normal < 0) {
 		_x = x;
 		_y = y;
diff --git a/engines/nancy/action/physicalobject.h b/engines/nancy/action/physicalobject.h
index f2b77ac5cec..e4c2946d231 100644
--- a/engines/nancy/action/physicalobject.h
+++ b/engines/nancy/action/physicalobject.h
@@ -22,6 +22,7 @@
 #ifndef NANCY_ACTION_PHYSICALOBJECT_H
 #define NANCY_ACTION_PHYSICALOBJECT_H
 
+#include "common/array.h"
 #include "common/rect.h"
 
 namespace Nancy {
@@ -41,10 +42,23 @@ public:
 		kBounceOffBounds
 	};
 
+	// Something in the way of an object, inside of its bounds
+	struct Obstacle {
+		enum Type {
+			kPin,	// a point the object's middle keeps the object's width away from
+			kBlock	// a rect the object can't overlap
+		};
+
+		Type type = kPin;
+		Common::Rect rect;
+	};
+
 	void setSize(int width, int height) { _width = width; _height = height; }
 	// An empty rect means there are none
 	void setBounds(const Common::Rect &bounds) { _bounds = bounds; }
 	void setBoundsMode(BoundsMode mode) { _boundsMode = mode; }
+	// Shared by the objects of a puzzle, which keeps the list
+	void setObstacles(const Common::Array<Obstacle> *obstacles) { _obstacles = obstacles; }
 
 	// Puts the object there, unless that is outside of its bounds
 	void moveTo(double x, double y);
@@ -76,6 +90,10 @@ private:
 	bool isInsideBounds(const Common::Rect &rect) const;
 	// The way out of the bounds, -1 while the rect is inside of them
 	double getBoundsNormal(const Common::Rect &rect) const;
+	bool isClearOfObstacle(const Obstacle &obstacle, const Common::Rect &rect) const;
+	bool isClearOfObstacles(const Common::Rect &rect) const;
+	// The way off the first obstacle the rect ran into, -1 when there is none
+	double getObstacleNormal(const Common::Rect &rect) const;
 
 	void accelerate(double numTicks);
 	void applyFriction(double numTicks);
@@ -86,6 +104,7 @@ private:
 	int _width = 1;
 	int _height = 1;
 	Common::Rect _bounds;
+	const Common::Array<Obstacle> *_obstacles = nullptr;
 	BoundsMode _boundsMode = kStopAtBounds;
 
 	double _x = 0;


Commit: faae5ace2f4869b8133d135a1b397d3b828f440f
    https://github.com/scummvm/scummvm/commit/faae5ace2f4869b8133d135a1b397d3b828f440f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:22:08+03:00

Commit Message:
NANCY: NANCY13: Fix the frame and the lever of the Gold Rush game

In the arcade game at Buell's, balls are fired across a board of pins
and drop into holes that move the Miner or the Yeti up the mountain.

- The balls now bounce off all four sides of the board, so one knocked
  upwards by a pin no longer flies off the screen
- The lever is drawn, and is pulled by holding the mouse button down on
  it. It comes out further the longer it is held, and letting go fires
  the ball, faster after a longer pull
- The balls also bounce off the cups under the holes, and are removed
  once they reach the bottom of the board
- The balls move with the gravity and friction set in the game data

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


diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
index 49a3a7eb7a0..efe95122eb0 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.cpp
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -36,20 +36,12 @@ namespace Nancy {
 namespace Action {
 
 // -- Physics constants --
-static const double kGravity = 50.0;			// downward accel (px/s^2)
 static const double kRestitution = 0.85;		// bounce energy retained
-static const double kTwoPi = 6.283185307179586;
 static const double kDeg2Rad = 0.017453292519943295;
 static const double kSpawnParamMax = 0.95;
-static const double kSpawnParamJitter = 0.002;
+static const double kSpawnParamJitter = 0.0005;
 static const double kSpawnYJitter = 0.01;
-static const double kDrag = 12.0;				// px/s per second
-
-// Pins are stored as 1x1 points on a staggered ~30px grid but drawn as small round bumpers,
-// so they collide as circles at the drawn pin + ball radii.
-static const double kPinRadius = 4.0;
-static const double kBallRadius = 5.0;
-static const int kPhysicsSubsteps = 4;			// per frame, to avoid tunnelling the pins
+static const uint32 kMaxTicksPerFrame = 5;		// after a stall, don't catch up all at once
 
 static const uint32 kHoleLitMs = 400;			// how long a hole stays lit after a catch
 
@@ -57,16 +49,6 @@ static const uint32 kHoleLitMs = 400;			// how long a hole stays lit after a cat
 // is 2 (Miner) / 3 (Yeti), so the Yeti climbs faster.
 static const int kClimbGoal = 30;
 
-static double wrapAngle(double a) {
-	while (a < 0.0) {
-		a += kTwoPi;
-	}
-	while (a >= kTwoPi) {
-		a -= kTwoPi;
-	}
-	return a;
-}
-
 void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
 	readFilename(stream, _imageName);			// board overlay
 
@@ -77,16 +59,16 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
 	_spawnYMin = stream.readSint32LE();			// launch-heading (deg) range
 	_spawnYMax = stream.readSint32LE();
 	stream.skip(4);
-	_launchVecLen = stream.readSint32LE();
-	stream.skip(4);
-	readRect(stream, _panelBounds);				// pin-panel bounds (walls + floor)
+	_gravity = stream.readSint32LE();
+	_friction = stream.readSint32LE();
+	readRect(stream, _panelBounds);				// the frame of the pin panel
 	_eventFlag = stream.readSint16LE();			// "in progress" flag
 	readFilename(stream, _ballImageName);		// ball sprite sheet
-	readRect(stream, _launcherBallSrc);
-	readRect(stream, _launcherBallDest);
+	readRect(stream, _leverSrc);
+	readRect(stream, _leverDest);
 	readRect(stream, _launcherHotspot);			// the clickable launcher
-	_spawnWindowMin = stream.readSint32LE();
-	_spawnWindowMax = stream.readSint32LE();	// spawn window (ms)
+	_pullTimeMin = stream.readSint32LE();
+	_pullTimeMax = stream.readSint32LE();
 
 	// The random "plink" cues played each time a ball is launched (LeverPull*).
 	_plinkSounds.readData(stream);
@@ -212,6 +194,29 @@ void PachinkoPuzzle::buildHoles() {
 	}
 }
 
+// The balls bounce off the pins and off the cups under the holes.
+void PachinkoPuzzle::buildObstacles() {
+	_obstacles.clear();
+
+	for (const Common::Rect &pin : _pins) {
+		PhysicalObject::Obstacle obstacle;
+		obstacle.type = PhysicalObject::Obstacle::kPin;
+		obstacle.rect = pin;
+		_obstacles.push_back(obstacle);
+	}
+
+	for (const ActionZone &z : _zones) {
+		if (z.type != kZoneBoundary) {
+			continue;
+		}
+
+		PhysicalObject::Obstacle obstacle;
+		obstacle.type = PhysicalObject::Obstacle::kBlock;
+		obstacle.rect = Common::Rect(z.rect.left, z.rect.top, z.rect.right + 1, z.rect.bottom + 1);
+		_obstacles.push_back(obstacle);
+	}
+}
+
 // The climber's current on-screen anchor: the point on its climb path from the bottom
 // (moverStart) to the pot (moverEnd), advanced by how many balls it has caught.
 Common::Point PachinkoPuzzle::climberAnchor(const Machine &m) const {
@@ -237,6 +242,8 @@ void PachinkoPuzzle::init() {
 	loadMachineImage(_loseMachine);
 
 	buildHoles();
+	buildObstacles();
+	releaseLever();
 
 	NancySceneState.setNoHeldItem();
 
@@ -253,18 +260,13 @@ void PachinkoPuzzle::init() {
 }
 
 void PachinkoPuzzle::spawnBall() {
-	uint32 now = g_nancy->getTotalPlayTime();
-	uint32 elapsed = now - _spawnClickTime;
+	uint32 elapsed = g_nancy->getTotalPlayTime() - _leverPullTime;
 
-	// The launch parameter grows across the spawn window, plus a small random jitter.
+	// The longer the lever was pulled the faster the ball, plus a small random jitter.
 	double launchParam = kSpawnParamMax;
-	int span = _spawnWindowMax - _spawnWindowMin;
-	if ((int)elapsed <= _spawnWindowMax && span > 0) {
-		int rel = (int)elapsed - _spawnWindowMin;
-		if (rel < 0) {
-			rel = 0;
-		}
-		launchParam = ((double)rel / (double)span) * kSpawnParamMax;
+	int span = _pullTimeMax - _pullTimeMin;
+	if ((int)elapsed <= _pullTimeMax && span > 0) {
+		launchParam = ((double)((int)elapsed - _pullTimeMin) / (double)span) * kSpawnParamMax;
 	}
 	launchParam += (double)(g_nancy->_randomSource->getRandomNumber(99)) * kSpawnParamJitter;
 
@@ -272,103 +274,61 @@ void PachinkoPuzzle::spawnBall() {
 	double headingDeg = (double)_spawnYMin +
 		(double)(_spawnYMax - _spawnYMin) * (double)(g_nancy->_randomSource->getRandomNumber(99)) * kSpawnYJitter;
 
-	Ball ball;
-	ball.speed = (double)(_velMax - _velMin) * launchParam + (double)_velMin;
-	ball.angle = wrapAngle(headingDeg * kDeg2Rad);
 	// Balls appear at the top-right entry chute and fall left/down through the pins.
-	ball.x = (_ballEntry.left + _ballEntry.right) / 2.0;
-	ball.y = (_ballEntry.top + _ballEntry.bottom) / 2.0;
-	ball.active = true;
+	Ball ball;
+	ball.body.setSize(_ballSrc.width(), _ballSrc.height());
+	ball.body.setBounds(Common::Rect(_panelBounds.left, _panelBounds.top, _panelBounds.right + 1, _panelBounds.bottom + 1));
+	ball.body.setBoundsMode(PhysicalObject::kBounceOffBounds);
+	ball.body.setFriction(_friction);
+	ball.body.setForce(0, _gravity);
+	ball.body.moveTo(_ballEntry.left + _ballEntry.width() * 0.5, _ballEntry.top + _ballEntry.height() * 0.5);
+	ball.body.setObstacles(&_obstacles);
+	ball.body.setSpeed((double)(_velMax - _velMin) * launchParam + (double)_velMin);
+	ball.body.setHeading(headingDeg * kDeg2Rad);
 	_balls.push_back(ball);
 
 	playSoundBlock(_plinkSounds);
 }
 
-// Reflect the ball off any pin it now overlaps. Returns true if a bounce happened.
-bool PachinkoPuzzle::collidePins(Ball &ball) const {
-	const double hitDist = kPinRadius + kBallRadius;
-	for (const Common::Rect &pin : _pins) {
-		double px = (pin.left + pin.right) / 2.0;
-		double py = (pin.top + pin.bottom) / 2.0;
-		double dx = ball.x - px;
-		double dy = ball.y - py;
-		double d2 = dx * dx + dy * dy;
-		if (d2 >= hitDist * hitDist || d2 < 1e-6) {
-			continue;
-		}
+// At rest only the end of the lever shows; more of it comes out the longer it is pulled.
+void PachinkoPuzzle::updateLever() {
+	if (!_leverPulled) {
+		return;
+	}
 
-		// Reflect about the surface normal and push the ball back out to contact distance.
-		double d = sqrt(d2);
-		double nxn = dx / d;
-		double nyn = dy / d;
-		double vx = cos(ball.angle) * ball.speed;
-		double vy = -sin(ball.angle) * ball.speed;
-		double dot = vx * nxn + vy * nyn;
-		vx -= 2.0 * dot * nxn;
-		vy -= 2.0 * dot * nyn;
-
-		ball.x = px + nxn * hitDist;
-		ball.y = py + nyn * hitDist;
-		ball.speed = sqrt(vx * vx + vy * vy) * kRestitution;
-		ball.angle = wrapAngle(atan2(-vy, vx));
-		return true;
+	int restTop = _leverSrc.bottom - _leverDest.height();
+	int elapsed = (int)(g_nancy->getTotalPlayTime() - _leverPullTime);
+	int span = _pullTimeMax - _pullTimeMin;
+	if (elapsed > _pullTimeMin && elapsed <= _pullTimeMax && span > 0) {
+		double pulled = (double)(elapsed - _pullTimeMin) / (double)span;
+		_leverCurSrc.top = _leverSrc.top + (int)((1.0 - pulled) * (restTop - _leverSrc.top));
 	}
-	return false;
 }
 
-int PachinkoPuzzle::catchInHole(const Ball &ball) const {
-	Common::Point p((int16)ball.x, (int16)ball.y);
+void PachinkoPuzzle::releaseLever() {
+	_leverPulled = false;
+	_leverCurSrc = _leverSrc;
+	_leverCurSrc.top = _leverSrc.bottom - _leverDest.height();
+}
+
+int PachinkoPuzzle::catchInHole(const Common::Point &pos) const {
 	for (uint i = 0; i < _holes.size(); ++i) {
-		if (_holes[i].rect.contains(p)) {
+		if (_holes[i].rect.contains(pos)) {
 			return (int)i;
 		}
 	}
 	return -1;
 }
 
-void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
-	// Gravity: convert the polar velocity to cartesian, add gravity, convert back.
-	double vx = cos(ball.angle) * ball.speed;
-	double vy = -sin(ball.angle) * ball.speed;
-	vy += kGravity * dt;
-	ball.speed = sqrt(vx * vx + vy * vy);
-	ball.angle = wrapAngle(atan2(-vy, vx));
-
-	// Deceleration (drag).
-	ball.speed -= kDrag * dt;
-	if (ball.speed < 0.0) {
-		ball.speed = 0.0;
-	}
-
-	// Advance in sub-steps so the ball cannot tunnel between the pins.
-	double subDt = dt / kPhysicsSubsteps;
-	for (int s = 0; s < kPhysicsSubsteps; ++s) {
-		ball.x += cos(ball.angle) * ball.speed * subDt;
-		ball.y -= sin(ball.angle) * ball.speed * subDt;
-
-		if (collidePins(ball)) {
-			continue;
-		}
-
-		// The side walls keep the ball in play; the ball enters from the right of the right
-		// wall, so that wall only reflects once the ball is inside.
-		double vx2 = cos(ball.angle) * ball.speed;
-		double vy2 = -sin(ball.angle) * ball.speed;
-		bool reflected = false;
-		if (ball.x < _panelBounds.left + kBallRadius && vx2 < 0.0) {
-			ball.x = _panelBounds.left + kBallRadius;
-			vx2 = -vx2;
-			reflected = true;
-		} else if (ball.x < _panelBounds.right && ball.x > _panelBounds.right - kBallRadius && vx2 > 0.0) {
-			ball.x = _panelBounds.right - kBallRadius;
-			vx2 = -vx2;
-			reflected = true;
-		}
-		if (reflected) {
-			ball.speed = sqrt(vx2 * vx2 + vy2 * vy2) * kRestitution;
-			ball.angle = wrapAngle(atan2(-vy2, vx2));
+// A ball is taken off the board in the zones that have no behavior of their own: the holes
+// and the strip along the bottom of the panel.
+bool PachinkoPuzzle::isInDrain(const Common::Point &pos) const {
+	for (const ActionZone &z : _zones) {
+		if (z.type == kZoneBaseOnly && z.rect.contains(pos)) {
+			return true;
 		}
 	}
+	return false;
 }
 
 void PachinkoPuzzle::advanceMachine(Machine &m, uint32 now) {
@@ -400,17 +360,17 @@ void PachinkoPuzzle::redraw() {
 
 	// The balls in flight.
 	if (!_ballImage.empty() && !_ballSrc.isEmpty()) {
-		int w = _ballSrc.width();
-		int h = _ballSrc.height();
 		for (const Ball &ball : _balls) {
-			if (!ball.active) {
-				continue;
-			}
-			Common::Point pos((int16)ball.x - w / 2, (int16)ball.y - h / 2);
-			_drawSurface.blitFrom(_ballImage, _ballSrc, pos);
+			Common::Rect dest = ball.body.getRect();
+			_drawSurface.blitFrom(_ballImage, _ballSrc, Common::Point(dest.left, dest.top));
 		}
 	}
 
+	// The lever, hanging down from its resting place.
+	if (!_ballImage.empty() && !_leverCurSrc.isEmpty()) {
+		_drawSurface.blitFrom(_ballImage, _leverCurSrc, Common::Point(_leverDest.left, _leverDest.top));
+	}
+
 	// Holes lit by a recent catch.
 	if (!_litImage.empty()) {
 		uint32 now = g_nancy->getTotalPlayTime();
@@ -437,10 +397,10 @@ void PachinkoPuzzle::execute() {
 		// fall through
 	case kRun: {
 		uint32 now = g_nancy->getTotalPlayTime();
-		double dt = (now - _lastUpdate) / 1000.0;
-		_lastUpdate = now;
-		if (dt > 0.1) {
-			dt = 0.1;	// clamp long stalls
+		uint32 numTicks = (now - _lastUpdate) / PhysicalObject::kTickLength;
+		_lastUpdate += numTicks * PhysicalObject::kTickLength;
+		if (numTicks > kMaxTicksPerFrame) {
+			numTicks = kMaxTicksPerFrame;
 		}
 
 		if (_exitRequested) {
@@ -449,36 +409,35 @@ void PachinkoPuzzle::execute() {
 
 		switch (_pzState) {
 		case kRunning: {
-			if (_spawnPending) {
-				spawnBall();
-				_spawnPending = false;
-			}
+			updateLever();
 
 			advanceMachine(_winMachine, now);
 			advanceMachine(_loseMachine, now);
 
-			for (uint i = 0; i < _balls.size(); ++i) {
+			for (uint i = 0; i < _balls.size();) {
 				Ball &ball = _balls[i];
-				if (!ball.active) {
-					continue;
+				if (numTicks > 0) {
+					ball.body.update(numTicks);
+					if (ball.body.hasHitBounds()) {
+						ball.body.clearHitBounds();
+						ball.body.setSpeed(ball.body.getSpeed() * kRestitution);
+					}
 				}
-				stepBall(ball, dt);
 
-				// A ball that drops in a hole is removed and climbs its machine a step.
-				int hole = catchInHole(ball);
+				// A ball that drops in a hole climbs its machine a step.
+				Common::Point pos((int16)ball.body.getX(), (int16)ball.body.getY());
+				int hole = catchInHole(pos);
 				if (hole >= 0) {
-					ball.active = false;
 					playSoundBlock(_holes[hole].sound);
 					_holes[hole].litUntil = now + kHoleLitMs;
 					Machine *climber = _holes[hole].climber;
 					climber->climbSteps += climber->moverSpeed;
-					continue;
 				}
 
-				// A ball that reaches the floor or leaves the panel is discarded (a miss).
-				if (ball.y > _panelBounds.bottom || ball.x < _panelBounds.left - 32 ||
-						ball.x > _panelBounds.right + 64 || ball.y > _drawSurface.h + 32) {
-					ball.active = false;
+				if (hole >= 0 || isInDrain(pos)) {
+					_balls.remove_at(i);
+				} else {
+					++i;
 				}
 			}
 
@@ -567,18 +526,31 @@ void PachinkoPuzzle::handleInput(NancyInput &input) {
 
 	const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
 
+	// Letting go of the lever, wherever the mouse is by then, launches a ball.
+	if (_leverPulled && !(input.input & NancyInput::kLeftMouseButtonHeld)) {
+		if ((int)(g_nancy->getTotalPlayTime() - _leverPullTime) >= _pullTimeMin) {
+			spawnBall();
+		}
+		releaseLever();
+		input.eatMouseInput();
+		return;
+	}
+
 	if (!_launcherHotspot.isEmpty() &&
 			NancySceneState.getViewport().convertViewportToScreen(_launcherHotspot).contains(input.mousePos)) {
 		setDataCursor(_exitCursorType, false);
-		if (click) {
-			// One launcher click queues one ball.
-			_spawnPending = true;
-			_spawnClickTime = g_nancy->getTotalPlayTime();
+		if (input.input & NancyInput::kLeftMouseButtonDown) {
+			_leverPulled = true;
+			_leverPullTime = g_nancy->getTotalPlayTime();
 		}
 		input.eatMouseInput();
 		return;
 	}
 
+	if (_leverPulled) {
+		return;
+	}
+
 	if (hoverExitHotspot(input)) {
 		if (click) {
 			_exitRequested = true;
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.h b/engines/nancy/action/puzzle/pachinkopuzzle.h
index 4d79cbdef2b..2dc2cfe19cf 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.h
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.h
@@ -26,15 +26,17 @@
 #include "engines/nancy/movieplayer.h"
 #include "engines/nancy/action/puzzlerecord.h"
 #include "engines/nancy/action/actionzone.h"
+#include "engines/nancy/action/physicalobject.h"
 
 namespace Nancy {
 namespace Action {
 
 // Pachinko / pinball ball-drop puzzle, new in Nancy13 (AR 175).
 //
-// The player clicks a spring launcher on the right of the board to fire a ball leftward
-// across a pin field. The ball falls under gravity, bouncing off the pins and the bumper
-// zones, until it drops into one of four holes. Each hole feeds one of two climbers racing
+// The player pulls the lever on the right of the board and lets go of it to fire a ball
+// leftward across a pin field; the longer the pull, the faster the ball. The ball falls
+// under gravity, bouncing off the pins and the frame of the board, until it drops into
+// one of four holes or reaches the bottom. Each hole feeds one of two climbers racing
 // up the mountain to the pot: the Miner (a win) or the Yeti (a loss). The first to reach
 // the pot plays its result animation, then exits through its own scene and event flag.
 class PachinkoPuzzle : public PuzzleRecord {
@@ -78,12 +80,7 @@ protected:
 
 	// A single launched ball, moving in viewport space.
 	struct Ball {
-		double x = 0.0;
-		double y = 0.0;
-		double speed = 0.0;			// px / second
-		double angle = 0.0;			// radians; velocity = (cos a, -sin a) * speed
-		uint frame = 0;
-		bool active = false;
+		PhysicalObject body;
 	};
 
 	// One of the four holes on the panel. A ball that drops in advances its climber and
@@ -100,13 +97,15 @@ protected:
 	void readMachine(Common::SeekableReadStream &stream, Machine &m);
 	void loadMachineImage(Machine &m);
 	void buildHoles();
+	void buildObstacles();
 	Common::Point climberAnchor(const Machine &m) const;
 
 	void redraw();
 	void spawnBall();
-	void stepBall(Ball &ball, double dt);
-	bool collidePins(Ball &ball) const;
-	int catchInHole(const Ball &ball) const;	// hole index the ball fell into, or -1
+	void updateLever();
+	void releaseLever();
+	int catchInHole(const Common::Point &pos) const;	// hole index the ball fell into, or -1
+	bool isInDrain(const Common::Point &pos) const;
 	void advanceMachine(Machine &m, uint32 now);
 
 	// -- File data --
@@ -117,15 +116,16 @@ protected:
 	int32 _velMax = 0;						// launch-speed ceiling
 	int32 _spawnYMin = 0;					// launch-heading (deg) range
 	int32 _spawnYMax = 0;
-	int32 _launchVecLen = 0;				// decorative launch nub length
-	Common::Rect _panelBounds;				// pin-panel bounds (side walls + floor)
+	int32 _gravity = 0;						// downward pull on the balls
+	int32 _friction = 0;					// how fast the balls slow down
+	Common::Rect _panelBounds;				// the frame of the pin panel, which the balls bounce off
 	int16 _eventFlag = 0;					// "in progress" flag id
 	Common::Path _ballImageName;			// the ball sprite sheet
-	Common::Rect _launcherBallSrc;			// ball-in-launcher sprite src
-	Common::Rect _launcherBallDest;			// ball-in-launcher dest
+	Common::Rect _leverSrc;					// the lever, pulled all the way out
+	Common::Rect _leverDest;				// where the lever is when at rest
 	Common::Rect _launcherHotspot;			// the clickable launcher
-	int32 _spawnWindowMin = 0;
-	int32 _spawnWindowMax = 0;				// spawn window (ms)
+	int32 _pullTimeMin = 0;					// shortest pull that launches a ball (ms)
+	int32 _pullTimeMax = 0;					// a pull this long launches at full speed (ms)
 
 	RandomSoundBlock _plinkSounds;			// random ball-launch cues
 
@@ -135,6 +135,7 @@ protected:
 	Common::Array<Common::Rect> _pins;		// static pin collision rects
 	Common::Array<ActionZone> _zones;		// bumpers / walls / overlays
 	Common::Array<Hole> _holes;				// the four catch holes (built from _zones)
+	Common::Array<PhysicalObject::Obstacle> _obstacles;	// the pins and the cups under the holes
 
 	// -- Runtime state --
 	enum State {
@@ -146,8 +147,9 @@ protected:
 	State _pzState = kRunning;
 
 	Common::Array<Ball> _balls;
-	bool _spawnPending = false;				// a launcher click awaiting a spawn
-	uint32 _spawnClickTime = 0;
+	bool _leverPulled = false;				// the mouse button is down on the lever
+	uint32 _leverPullTime = 0;				// when the pull started
+	Common::Rect _leverCurSrc;				// the part of the lever showing right now
 	Machine *_activeMachine = nullptr;		// the climber that reached the pot
 	bool _exitRequested = false;
 	uint32 _lastUpdate = 0;


Commit: 4714ce654acb3997cb31e3e6a5c7a9e8ae3f7823
    https://github.com/scummvm/scummvm/commit/4714ce654acb3997cb31e3e6a5c7a9e8ae3f7823
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:22:09+03:00

Commit Message:
COMMON: LUA: Whitespace fixes

Changed paths:
    common/lua/ltablib.cpp


diff --git a/common/lua/ltablib.cpp b/common/lua/ltablib.cpp
index 11fb660adbf..eebdb684603 100644
--- a/common/lua/ltablib.cpp
+++ b/common/lua/ltablib.cpp
@@ -135,7 +135,7 @@ static void addfield (lua_State *L, luaL_Buffer *b, int i) {
   if (!lua_isstring(L, -1))
     luaL_error(L, "invalid value (%s) at index %d in table for "
                   LUA_QL("concat"), luaL_typename(L, -1), i);
-    luaL_addvalue(b);
+  luaL_addvalue(b);
 }
 
 


Commit: 362cbfc838d4129745be6f60f8e0e079f8642dec
    https://github.com/scummvm/scummvm/commit/362cbfc838d4129745be6f60f8e0e079f8642dec
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-10-13T19:22:10+03:00

Commit Message:
NANCY: Fix signed/unsigned warnings

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


diff --git a/engines/nancy/ui/taskbar/cellphonepopup.cpp b/engines/nancy/ui/taskbar/cellphonepopup.cpp
index 4b6557ccd6a..55b59699da8 100644
--- a/engines/nancy/ui/taskbar/cellphonepopup.cpp
+++ b/engines/nancy/ui/taskbar/cellphonepopup.cpp
@@ -748,7 +748,7 @@ void CellPhonePopup::drawScreenContent() {
 		drawLinkList();
 		drawDirectoryArrows();
 		// Zoomed-list Back: subButtons[7] before Nancy 13, [8] in Nancy 13.
-		drawBackButton(g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubBackFull : kSubEmailBack);
+		drawBackButton(g_nancy->getGameType() >= kGameTypeNancy13 ? (uint)kN13SubBackFull : (uint)kSubEmailBack);
 		break;
 
 	case kContentView:
@@ -2019,7 +2019,7 @@ bool CellPhonePopup::hasCameraFeature() const {
 
 Common::Rect CellPhonePopup::hubEmailRect() const {
 	// The Email option button: subButtons[3] before Nancy 13, [4] in Nancy 13.
-	const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubEmail : kSubEmail;
+	const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? (uint)kN13SubEmail : (uint)kSubEmail;
 	Common::Rect r = _uiclData->subButtons[slot].destRect;
 	r.translate(-_screenPosition.left, -_screenPosition.top);
 	return r;
@@ -2028,7 +2028,7 @@ Common::Rect CellPhonePopup::hubEmailRect() const {
 Common::Rect CellPhonePopup::hubWebRect() const {
 	// The lower LCD option button: Web (subButtons[4]) before Nancy 13, View
 	// Pictures (subButtons[3]) in Nancy 13.
-	const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubViewPics : kSubWeb;
+	const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? (uint)kN13SubViewPics : (uint)kSubWeb;
 	Common::Rect r = _uiclData->subButtons[slot].destRect;
 	r.translate(-_screenPosition.left, -_screenPosition.top);
 	return r;
@@ -2459,8 +2459,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		// pictures" option in the same slot is not signal-gated, but it is
 		// absent on a phone with no camera of its own.
 		const bool webDisabled = n13Hub ? !hasCameraFeature() : _noSignal;
-		const int emailSlot = n13Hub ? kN13SubEmail : kSubEmail;
-		const int webSlot = n13Hub ? kN13SubViewPics : kSubWeb;
+		const int emailSlot = n13Hub ? (int)kN13SubEmail : (int)kSubEmail;
+		const int webSlot = n13Hub ? (int)kN13SubViewPics : (int)kSubWeb;
 		const int newHubHover = emailR.contains(popupMouse) ? emailSlot
 								: (!webDisabled && webR.contains(popupMouse)) ? webSlot : -1;
 		if (newHubHover != _hoveredHubButton) {




More information about the Scummvm-git-logs mailing list