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

bluegr noreply at scummvm.org
Mon Sep 21 22:05:39 UTC 2026


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

Summary:
11828cf532 NANCY: NANCY15: Play the "can't" sounds from the PUIV bank
59e5fa495f NANCY: NANCY15: Handle multiple player chars in inventory commands
a5eac8e599 NANCY: NANCY15: Honor the player character in inventory action records
37177b74dc NANCY: NANCY15: Implement new functionality for the Movie Control AR
41f3931723 NANCY: NANCY15: Add new functionality for conversation cels
d0f9a7401c NANCY: NANCY15: Handle the player character in ResourceUse records
8656e29e7f NANCY: NANCY13: Properly convert 24bpp images to 32bpp
9c2a253e15 NANCY: NANCY10: Correct mapping for new font glyphs
ab349cd205 NANCY: NANCY15: Implement NecklacePuzzle


Commit: 11828cf5321890064300f89dbf3c7b16f5c0039a
    https://github.com/scummvm/scummvm/commit/11828cf5321890064300f89dbf3c7b16f5c0039a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:00:51+03:00

Commit Message:
NANCY: NANCY15: Play the "can't" sounds from the PUIV bank

They moved out of the inventory data and into the active character's own
bank: one group of interchangeable sounds per item, keyed by item ID,
plus the character's generic response. Items with no group stay silent.

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 260e31747ef..0e1c4e88ad5 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -642,11 +642,102 @@ static Common::String getSoundSubtitle(const Common::String &soundName, const Co
 	return fallback;
 }
 
+// Nancy15 moved the "can't" responses out of the inventory data and into the
+// active player character's PUIV bank: one group of interchangeable sounds per
+// item, keyed by item ID, plus the character's generic response, which is the
+// one the InventorySoundOverride record installs for its default command. They
+// all share the bank's channel and volume.
+bool Scene::getPlayerCantSound(int16 itemID, SoundDescription &sound) const {
+	auto *puivData = GetEngineData(PUIV);
+	if (!puivData) {
+		return false;
+	}
+
+	Common::String name;
+
+	if (itemID < 0) {
+		name = puivData->name;
+	} else {
+		for (uint i = 0; i < puivData->soundGroups.size(); ++i) {
+			const PUIV::SoundGroup &group = puivData->soundGroups[i];
+			if (group.tag == itemID && group.variants.size()) {
+				// The variants are interchangeable, so one is picked at random
+				name = group.variants[g_nancy->_randomSource->getRandomNumber(group.variants.size() - 1)];
+				break;
+			}
+		}
+	}
+
+	if (name.empty() || name.equalsIgnoreCase("NO SOUND")) {
+		return false;
+	}
+
+	sound.name = name;
+	sound.channelID = puivData->channelID;
+	sound.volume = puivData->volume;
+
+	return true;
+}
+
+void Scene::playPlayerCantSound(int16 itemID) {
+	auto *inventoryData = GetEngineData(INV);
+	assert(inventoryData);
+
+	SoundDescription sound;
+	Common::String caption;
+
+	if (itemID >= 0 && _inventorySoundOverrides.contains(itemID)) {
+		InventorySoundOverride &override = _inventorySoundOverrides[itemID];
+
+		if (override.isDefault) {
+			// Back to the character's generic response
+			if (!getPlayerCantSound(-1, sound)) {
+				return;
+			}
+		} else {
+			sound = override.sound;
+			caption = override.caption;
+		}
+	} else if (!getPlayerCantSound(itemID, sound)) {
+		// An item with no response of its own stays silent
+		return;
+	}
+
+	if (sound.name.empty() || sound.name.equalsIgnoreCase("NO SOUND")) {
+		// Silenced by an override
+		return;
+	}
+
+	// One response at a time: if the bank's channel is busy, the sound (and its
+	// caption) are left alone instead of being restarted or overlapped
+	if (g_nancy->_sound->isSoundPlaying(sound.channelID)) {
+		return;
+	}
+
+	if (ConfMan.getBool("subtitles")) {
+		_textbox.clear();
+	}
+
+	g_nancy->_sound->loadSound(sound);
+	g_nancy->_sound->playSound(sound);
+
+	if (ConfMan.getBool("subtitles")) {
+		_textbox.addTextLine(getSoundSubtitle(sound.name, caption), inventoryData->captionAutoClearTime);
+	}
+}
+
 void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 	// Improvement: nancy2 never shows the caption text, even though it exists in the data; we show it
 	auto *inventoryData = GetEngineData(INV);
 	assert(inventoryData);
 
+	// Nancy15 keeps no "can't" sounds in the inventory data; they come from the
+	// player character's own bank instead
+	if (g_nancy->getGameType() >= kGameTypeNancy15) {
+		playPlayerCantSound(itemID);
+		return;
+	}
+
 	// Nancy9 and newer play every "can't" sound on the same dedicated sound-effects
 	// channel as the default "can't" sound. If one is already playing, leave it (and
 	// the current caption) alone instead of restarting it or overlapping a new one.
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 5340cec5437..2c7b5d6a456 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -319,6 +319,11 @@ private:
 	// Rect of the open Nancy 10+ taskbar popup, or empty if none.
 	Common::Rect activePopupConfinement() const;
 
+	// Nancy15's "can't" responses live in the active player character's PUIV
+	// bank instead of the inventory data
+	bool getPlayerCantSound(int16 itemID, SoundDescription &sound) const;
+	void playPlayerCantSound(int16 itemID);
+
 	void initStaticData();
 
 	void clearSceneData(bool nextIsNoArt = false);


Commit: 59e5fa495fd18f340128df7a9bf5cf56297050e9
    https://github.com/scummvm/scummvm/commit/59e5fa495fd18f340128df7a9bf5cf56297050e9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:36+03:00

Commit Message:
NANCY: NANCY15: Handle multiple player chars in inventory commands

Nancy15 gives every playable character their own inventory, and scene
dependencies can test any of them. get_inventory now reports ownership
per character, set_inventory takes an optional -c <index>, and the action
record dump names the character an inventory dependency asks about.

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


diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index ecb71740011..f10fc30727e 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -498,6 +498,35 @@ void NancyConsole::printActionRecord(const Action::ActionRecord *record, bool no
 	}
 }
 
+// Nancy15 gives every playable character their own inventory, so the console
+// has to name the one it reports on. The PCUI chunk holds the image names the
+// characters are known by, e.g. "PUI_CRE_Nancy".
+static Common::String playerCharacterName(uint characterIndex) {
+	const PCUI *pcui = GetEngineData(PCUI);
+	if (pcui && characterIndex < pcui->characters.size()) {
+		const Common::String &imageName = pcui->characters[characterIndex].imageName;
+		size_t lastSeparator = imageName.findLastOf('_');
+		if (lastSeparator != Common::String::npos) {
+			return imageName.substr(lastSeparator + 1);
+		}
+
+		if (!imageName.empty()) {
+			return imageName;
+		}
+	}
+
+	return Common::String::format("character %u", characterIndex);
+}
+
+static uint numPlayerCharacters() {
+	if (g_nancy->getGameType() < kGameTypeNancy15) {
+		return 1;
+	}
+
+	const PCUI *pcui = GetEngineData(PCUI);
+	return pcui ? MIN<uint>(pcui->characters.size(), kMaxPlayerCharacters) : 1;
+}
+
 void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &record) {
 	using namespace Action;
 
@@ -517,6 +546,13 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
 					inventoryData->itemDescriptions[dep.label].name.c_str() :
 					"Invalid",
 				dep.condition == g_nancy->_true ? "true" : "false");
+
+			// Nancy15+ can ask about a character other than the one being played
+			if (g_nancy->getGameType() >= kGameTypeNancy15 && dep.hours >= 0 &&
+					dep.hours != kPlayerCharacterActive) {
+				debugPrintf(", %s", playerCharacterName(dep.hours).c_str());
+			}
+
 			break;
 		case DependencyType::kEvent:
 			debugPrintf("kEvent, flag %u, %s, %s",
@@ -1067,6 +1103,31 @@ bool NancyConsole::Cmd_setEventFlags(int argc, const char **argv) {
 	return cmdExit(0, nullptr);
 }
 
+void NancyConsole::printInventoryItem(uint itemID) {
+	auto *inventoryData = GetEngineData(INV);
+	assert(inventoryData);
+
+	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");
+
+	uint numCharacters = numPlayerCharacters();
+	if (numCharacters == 1) {
+		debugPrintf(", %s", NancySceneState.hasItem(itemID) == g_nancy->_true ? "true" : "false");
+		return;
+	}
+
+	// Every character keeps their own copy of the item flags, and scene
+	// dependencies can ask about any of them, so list them all
+	for (uint i = 0; i < numCharacters; ++i) {
+		debugPrintf(", %s: %s",
+			playerCharacterName(i).c_str(),
+			NancySceneState.hasCharacterItem(i, itemID) == g_nancy->_true ? "true" : "false");
+	}
+}
+
 bool NancyConsole::Cmd_getInventory(int argc, const char **argv) {
 	if (g_nancy->getState() != NancyState::kScene) {
 		debugPrintf("Not in the kScene state\n");
@@ -1074,34 +1135,26 @@ bool NancyConsole::Cmd_getInventory(int argc, const char **argv) {
 	}
 
 	uint numItems = g_nancy->getStaticData().numItems;
-	auto *inventoryData = GetEngineData(INV);
-	assert(inventoryData);
 
 	debugPrintf("Total number of inventory items: %u\n", numItems);
 
+	if (numPlayerCharacters() > 1) {
+		debugPrintf("Playing as %s\n", playerCharacterName(g_nancy->getPlayerCharacter()).c_str());
+	}
+
 	if (argc == 1) {
 		for (uint i = 0; i < numItems; ++i) {
-			byte keep = inventoryData->itemDescriptions[i].keepItem;
-			debugPrintf("\nItem %u, %s, %s, %s",
-				i,
-				inventoryData->itemDescriptions[i].name.c_str(),
-				keep == 0 ? "UseThenLose" : keep == 1 ? "KeepAlways" : keep == 2 ? "ReturnToInventory" : "NewSceneView",
-				NancySceneState.hasItem(i) == g_nancy->_true ? "true" : "false");
+			printInventoryItem(i);
 		}
 	} else {
 		for (int i = 1; i < argc; ++i) {
-			int flagID = atoi(argv[i]);
-			if (flagID < 0 || flagID >= (int)numItems) {
-				debugPrintf("\nInvalid flag %s", argv[i]);
+			int itemID = atoi(argv[i]);
+			if (itemID < 0 || itemID >= (int)numItems) {
+				debugPrintf("\nInvalid item %s", argv[i]);
 				continue;
 			}
-			byte keep = inventoryData->itemDescriptions[flagID].keepItem;
-			debugPrintf("\nItem %u, %s, %s, %s",
-				flagID,
-				inventoryData->itemDescriptions[flagID].name.c_str(),
-				keep == 0 ? "UseThenLose" : keep == 1 ? "KeepAlways" : keep == 2 ? "ReturnToInventory" : "NewSceneView",
-				NancySceneState.hasItem(flagID) == g_nancy->_true ? "true" : "false");
 
+			printInventoryItem(itemID);
 		}
 	}
 
@@ -1119,13 +1172,39 @@ bool NancyConsole::Cmd_setInventory(int argc, const char **argv) {
 		return true;
 	}
 
-	if (argc < 2 || argc % 2 == 0) {
+	// Without -c the items go to whoever is being played, which is the only
+	// inventory the games before Nancy15 have
+	uint characterIndex = g_nancy->getPlayerCharacter();
+	int firstItemArg = 1;
+
+	if (argc > 1 && Common::String(argv[1]).equalsIgnoreCase("-c")) {
+		if (argc < 3) {
+			debugPrintf("Missing character index after -c\n");
+			return true;
+		}
+
+		int requestedCharacter = atoi(argv[2]);
+		if (requestedCharacter < 0 || requestedCharacter >= (int)numPlayerCharacters()) {
+			debugPrintf("Invalid character %s\n", argv[2]);
+			return true;
+		}
+
+		characterIndex = requestedCharacter;
+		firstItemArg = 3;
+	}
+
+	if (argc < firstItemArg + 2 || (argc - firstItemArg) % 2 != 0) {
 		debugPrintf("Sets one or more inventory items to the provided value.\n");
-		debugPrintf("Usage: %s <itemID> <true/false>...\n", argv[0]);
+		debugPrintf("Usage: %s [-c <characterIndex>] <itemID> <true/false>...\n", argv[0]);
+		debugPrintf("-c picks the player character to give the items to (Nancy15+); the character being played is the default.\n");
 		return true;
 	}
 
-	for (int i = 1; i < argc; i += 2) {
+	Common::String targetDescription = numPlayerCharacters() > 1 ?
+		Common::String::format("the inventory of %s", playerCharacterName(characterIndex).c_str()) :
+		Common::String("inventory");
+
+	for (int i = firstItemArg; i < argc; i += 2) {
 		int itemID = atoi(argv[i]);
 		if (itemID < 0 || itemID >= (int)g_nancy->getStaticData().numItems) {
 			debugPrintf("Invalid item %s\n", argv[i]);
@@ -1133,15 +1212,17 @@ bool NancyConsole::Cmd_setInventory(int argc, const char **argv) {
 		}
 
 		if (Common::String(argv[i + 1]).compareTo("true") == 0) {
-			NancySceneState.addItemToInventory(itemID);
-			debugPrintf("Added item %i, %s, to inventory\n",
+			NancySceneState.addItemToCharacterInventory(characterIndex, itemID);
+			debugPrintf("Added item %i, %s, to %s\n",
 				itemID,
-				inventoryData->itemDescriptions[itemID].name.c_str());
+				inventoryData->itemDescriptions[itemID].name.c_str(),
+				targetDescription.c_str());
 		} else if (Common::String(argv[i + 1]).compareTo("false") == 0) {
-			NancySceneState.removeItemFromInventory(itemID, false);
-			debugPrintf("Removed item %i, %s, from inventory\n",
+			NancySceneState.removeItemFromCharacterInventory(characterIndex, itemID);
+			debugPrintf("Removed item %i, %s, from %s\n",
 				itemID,
-				inventoryData->itemDescriptions[itemID].name.c_str());
+				inventoryData->itemDescriptions[itemID].name.c_str(),
+				targetDescription.c_str());
 		} else {
 			debugPrintf("Invalid value %s\n", argv[i + 1]);
 			continue;
diff --git a/engines/nancy/console.h b/engines/nancy/console.h
index 8645d24ce4e..5c00b0d693f 100644
--- a/engines/nancy/console.h
+++ b/engines/nancy/console.h
@@ -71,6 +71,8 @@ private:
 	bool Cmd_soundInfo(int argc, const char **argv);
 	bool Cmd_showHotspots(int argc, const char **argv);
 
+	void printInventoryItem(uint itemID);
+
 	void printActionRecord(const Action::ActionRecord *record, bool noDependencies = false);
 	void recursePrintDependencies(const Action::DependencyRecord &record);
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 0e1c4e88ad5..90e3cf25683 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -487,6 +487,51 @@ void Scene::removeItemFromInventory(int16 id, bool pickUp) {
 	}
 }
 
+void Scene::addItemToCharacterInventory(uint characterIndex, int16 id) {
+	if (id == -1) {
+		return;
+	}
+
+	if (characterIndex == g_nancy->getPlayerCharacter()) {
+		if (hasItem(id) == g_nancy->_false) {
+			addItemToInventory(id);
+		}
+
+		return;
+	}
+
+	auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+	if (!playerChar) {
+		return;
+	}
+
+	PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+
+	const uint numItems = g_nancy->getStaticData().numItems;
+	inventory.items.resize(numItems, g_nancy->_false);
+	inventory.disabledItems.resize(numItems, 0);
+
+	if ((uint)id >= inventory.items.size() || inventory.items[id] == g_nancy->_true ||
+			inventory.heldItem == id) {
+		return;
+	}
+
+	inventory.items[id] = g_nancy->_true;
+
+	// Handing an item to a character who hasn't been played yet makes their
+	// inventory real; it would be thrown away on the next switch otherwise
+	inventory.isValid = true;
+
+	for (uint i = 0; i < inventory.order.size(); ++i) {
+		if (inventory.order[i] == id) {
+			inventory.order.remove_at(i);
+			break;
+		}
+	}
+
+	inventory.order.insert_at(0, id);
+}
+
 void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id) {
 	if (characterIndex == g_nancy->getPlayerCharacter()) {
 		if (hasItem(id) == g_nancy->_true) {
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 2c7b5d6a456..8c12e095da2 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -153,6 +153,7 @@ public:
 	// Nancy15+ inventory action records and dependencies pick the character to
 	// act on, which needn't be the one being played. Anyone else is served from
 	// their parked inventory instead of the live one.
+	void addItemToCharacterInventory(uint characterIndex, int16 id);
 	void removeItemFromCharacterInventory(uint characterIndex, int16 id);
 	byte hasCharacterItem(uint characterIndex, int16 id);
 	int32 getCharacterUIResource(uint characterIndex, uint index);


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

Commit Message:
NANCY: NANCY15: Honor the player character in inventory action records

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


diff --git a/engines/nancy/action/inventoryrecords.cpp b/engines/nancy/action/inventoryrecords.cpp
index 308cb7835dd..edecaf00911 100644
--- a/engines/nancy/action/inventoryrecords.cpp
+++ b/engines/nancy/action/inventoryrecords.cpp
@@ -34,40 +34,22 @@ namespace Action {
 void AddInventoryNoHS::readData(Common::SeekableReadStream &stream) {
 	_itemID = stream.readUint16LE();
 
-	if (g_nancy->getGameType() >= kGameTypeNancy6) {
+	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();
+		_forceCursor = stream.readUint16LE();
+	} else if (g_nancy->getGameType() >= kGameTypeNancy6) {
 		_setCursor = stream.readUint16LE();
 		_forceCursor = stream.readUint16LE();
 	}
 }
 
 void AddInventoryNoHS::execute() {
-	if (_setCursor) {
-		if (NancySceneState.getHeldItem() == _itemID) {
-			// Already holding the item, e.g. when the scene reloads itself
-		} else if (NancySceneState.getHeldItem() != -1) {
-			// Currently holding another item
-			if (_forceCursor) {
-				NancySceneState.addItemToInventory(NancySceneState.getHeldItem());
-				if (NancySceneState.hasItem(_itemID) == g_nancy->_true) {
-					NancySceneState.removeItemFromInventory(_itemID, true);
-				} else {
-					NancySceneState.setHeldItem(_itemID);
-				}
-			} else {
-				NancySceneState.addItemToInventory(_itemID);
-			}
-		} else {
-			if (NancySceneState.hasItem(_itemID) == g_nancy->_true) {
-				NancySceneState.removeItemFromInventory(_itemID, true);
-			} else {
-				NancySceneState.setHeldItem(_itemID);
-			}
-		}
-	} else {
-		if (NancySceneState.hasItem(_itemID) == g_nancy->_false) {
-			NancySceneState.addItemToInventory(_itemID);
-		}
-	}
+	uint characterIndex = _characterIndex == kPlayerCharacterActive ?
+		g_nancy->getPlayerCharacter() : _characterIndex;
+
+	NancySceneState.giveItemToCharacter(characterIndex, _itemID, _setCursor, _forceCursor);
 
 	_isDone = true;
 }
@@ -166,7 +148,14 @@ void ShowInventoryItem::execute() {
 void InventorySoundOverride::readData(Common::SeekableReadStream &stream) {
 	_command = stream.readByte();
 	_itemID = stream.readUint16LE();
-	stream.skip(2);
+
+	if (g_nancy->getGameType() >= kGameTypeNancy15) {
+		_characterIndex = stream.readByte();
+		stream.skip(1);
+	} else {
+		stream.skip(2);
+	}
+
 	char buf[61];
 	stream.read(buf, 60);
 	buf[60] = '\0';
@@ -175,12 +164,17 @@ void InventorySoundOverride::readData(Common::SeekableReadStream &stream) {
 }
 
 void InventorySoundOverride::execute() {
-	NancySceneState.installInventorySoundOverride(_command, _sound, _caption, _itemID);
+	NancySceneState.installInventorySoundOverride(_command, _sound, _caption, _itemID, _characterIndex);
 	_isDone = true;
 }
 
 void EnableDisableInventory::readData(Common::SeekableReadStream &stream) {
 	_itemID = stream.readUint16LE();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy15) {
+		_characterIndex = stream.readByte();
+	}
+
 	bool disabled = stream.readUint16LE();
 	bool playSound = stream.readUint16LE();
 
@@ -193,7 +187,29 @@ void EnableDisableInventory::readData(Common::SeekableReadStream &stream) {
 }
 
 void EnableDisableInventory::execute() {
-	NancySceneState.setItemDisabledState(_itemID, _disabledState);
+	uint characterIndex = _characterIndex == kPlayerCharacterActive ?
+		g_nancy->getPlayerCharacter() : _characterIndex;
+
+	if (g_nancy->getGameType() >= kGameTypeNancy15 &&
+			_itemID >= kInvItemGroupAll && _itemID <= kInvItemGroupPortable) {
+		// Nancy15 can enable or disable a whole group of items at once
+		auto *inventoryData = GetEngineData(INV);
+		assert(inventoryData);
+
+		for (uint i = 0; i < inventoryData->itemDescriptions.size(); ++i) {
+			bool isViewable = inventoryData->itemDescriptions[i].keepItem == kInvItemNewSceneView;
+
+			if ((_itemID == kInvItemGroupViewable && !isViewable) ||
+					(_itemID == kInvItemGroupPortable && isViewable)) {
+				continue;
+			}
+
+			NancySceneState.setCharacterItemDisabledState(characterIndex, i, _disabledState);
+		}
+	} else {
+		NancySceneState.setCharacterItemDisabledState(characterIndex, _itemID, _disabledState);
+	}
+
 	_isDone = true;
 }
 
diff --git a/engines/nancy/action/inventoryrecords.h b/engines/nancy/action/inventoryrecords.h
index 8977901ea1b..ea7cbb50461 100644
--- a/engines/nancy/action/inventoryrecords.h
+++ b/engines/nancy/action/inventoryrecords.h
@@ -34,6 +34,8 @@ public:
 	void execute() override;
 
 	uint16 _itemID = 0;
+	// Nancy15+, kPlayerCharacterActive means the character being played
+	byte _characterIndex = kPlayerCharacterActive;
 	bool _setCursor = false;
 	bool _forceCursor = false;
 
@@ -95,6 +97,8 @@ public:
 
 	byte _command = 0;
 	uint16 _itemID = 0;
+	// Nancy15+, kPlayerCharacterActive means the character being played
+	byte _characterIndex = kPlayerCharacterActive;
 	SoundDescription _sound;
 	Common::String _caption;
 
@@ -109,6 +113,8 @@ public:
 	void execute() override;
 
 	uint16 _itemID = 0;
+	// Nancy15+, kPlayerCharacterActive means the character being played
+	byte _characterIndex = kPlayerCharacterActive;
 	byte _disabledState = 0;
 
 protected:
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index 56bdf45c8b2..ddf62428c09 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -76,6 +76,12 @@ static const byte kInvItemKeepAlways				= 1;
 static const byte kInvItemReturn					= 2;
 static const byte kInvItemNewSceneView				= 3;
 
+// Nancy15 EnableDisableInventory records can name a whole group of items
+// instead of a single one
+static const uint16 kInvItemGroupAll				= 100;
+static const uint16 kInvItemGroupViewable			= 101;
+static const uint16 kInvItemGroupPortable			= 102;
+
 // Inventory item sound override commands
 static const byte kInvSoundOverrideCommandNoSound	= 0;
 static const byte kInvSoundOverrideCommandTurnOff	= 1;
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 90e3cf25683..29509fd9732 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -532,10 +532,10 @@ void Scene::addItemToCharacterInventory(uint characterIndex, int16 id) {
 	inventory.order.insert_at(0, id);
 }
 
-void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id) {
+void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id, bool pickUp) {
 	if (characterIndex == g_nancy->getPlayerCharacter()) {
 		if (hasItem(id) == g_nancy->_true) {
-			removeItemFromInventory(id, false);
+			removeItemFromInventory(id, pickUp);
 		}
 
 		return;
@@ -563,11 +563,112 @@ void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id) {
 		}
 	}
 
-	if (inventory.heldItem == id) {
+	if (pickUp) {
+		inventory.heldItem = id;
+	} else if (inventory.heldItem == id) {
 		inventory.heldItem = -1;
 	}
 }
 
+int16 Scene::getCharacterHeldItem(uint characterIndex) {
+	if (characterIndex == g_nancy->getPlayerCharacter()) {
+		return getHeldItem();
+	}
+
+	auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+	return playerChar ? playerChar->getInventory(characterIndex).heldItem : -1;
+}
+
+void Scene::setCharacterHeldItem(uint characterIndex, int16 id) {
+	if (characterIndex == g_nancy->getPlayerCharacter()) {
+		setHeldItem(id);
+		return;
+	}
+
+	auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+	if (!playerChar) {
+		return;
+	}
+
+	PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+	inventory.heldItem = id;
+
+	if (id != -1) {
+		// An item waiting in a character's hand makes their inventory real
+		inventory.isValid = true;
+	}
+}
+
+void Scene::returnCharacterHeldItem(uint characterIndex) {
+	const int16 heldItem = getCharacterHeldItem(characterIndex);
+	if (heldItem == -1) {
+		return;
+	}
+
+	if (characterIndex == g_nancy->getPlayerCharacter()) {
+		addItemToInventory(heldItem);
+		return;
+	}
+
+	setCharacterHeldItem(characterIndex, -1);
+	addItemToCharacterInventory(characterIndex, heldItem);
+}
+
+void Scene::giveItemToCharacter(uint characterIndex, int16 id, bool intoHand, bool forceIntoHand) {
+	if (id == -1) {
+		return;
+	}
+
+	if (!intoHand) {
+		addItemToCharacterInventory(characterIndex, id);
+		return;
+	}
+
+	const int16 heldItem = getCharacterHeldItem(characterIndex);
+	if (heldItem == id) {
+		// Already holding the item, e.g. when the scene reloads itself
+		return;
+	}
+
+	if (heldItem != -1) {
+		if (!forceIntoHand) {
+			// Their hand is full and the record doesn't insist
+			addItemToCharacterInventory(characterIndex, id);
+			return;
+		}
+
+		returnCharacterHeldItem(characterIndex);
+	}
+
+	// Into the hand, out of the inventory if that is where the item was
+	if (hasCharacterItem(characterIndex, id) == g_nancy->_true) {
+		removeItemFromCharacterInventory(characterIndex, id, true);
+	} else {
+		setCharacterHeldItem(characterIndex, id);
+	}
+}
+
+void Scene::setCharacterItemDisabledState(uint characterIndex, int16 id, byte state) {
+	if (characterIndex == g_nancy->getPlayerCharacter()) {
+		setItemDisabledState(id, state);
+		return;
+	}
+
+	auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+	if (!playerChar || id < 0) {
+		return;
+	}
+
+	// Unlike an item, a disabled state doesn't make a character's inventory
+	// real: one who has never been played is set up from scratch when they are
+	PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+	inventory.disabledItems.resize(g_nancy->getStaticData().numItems, 0);
+
+	if ((uint)id < inventory.disabledItems.size()) {
+		inventory.disabledItems[id] = state;
+	}
+}
+
 void Scene::setHeldItem(int16 id) {
 	_flags.heldItem = id; g_nancy->_cursor->setCursorItemID(id);
 }
@@ -630,30 +731,37 @@ int32 Scene::getCharacterUIResource(uint characterIndex, uint index) {
 	return index < characterSet.size() ? characterSet[index] : 0;
 }
 
-void Scene::installInventorySoundOverride(byte command, const SoundDescription &sound, const Common::String &caption, uint16 itemID) {
+void Scene::installInventorySoundOverride(byte command, const SoundDescription &sound,
+		const Common::String &caption, uint16 itemID, byte characterIndex) {
 	InventorySoundOverride newOverride;
 
+	// An override can be installed on a character who isn't being played, so it
+	// waits in their own set until they are
+	uint targetCharacter = characterIndex < kMaxPlayerCharacters ?
+		characterIndex : MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1);
+	Common::HashMap<uint16, InventorySoundOverride> &overrides = _inventorySoundOverrides[targetCharacter];
+
 	switch (command) {
 	case kInvSoundOverrideCommandNoSound :
 		// Make the sound silent
 		newOverride.sound = sound;
 		newOverride.sound.name = "NO SOUND";
 		newOverride.caption = caption; // Assumes the caption will be empty
-		_inventorySoundOverrides.setVal(itemID, newOverride);
+		overrides.setVal(itemID, newOverride);
 		break;
 	case kInvSoundOverrideCommandNewSound :
 		newOverride.sound = sound;
 		newOverride.caption = caption;
-		_inventorySoundOverrides.setVal(itemID, newOverride);
+		overrides.setVal(itemID, newOverride);
 		break;
 	case kInvSoundOverrideCommandICant :
 		// Make the sound the default "I can't use that here"
 		newOverride.isDefault = true;
-		_inventorySoundOverrides.setVal(itemID, newOverride);
+		overrides.setVal(itemID, newOverride);
 		break;
 	case kInvSoundOverrideCommandTurnOff :
 		// Remove any previous override
-		_inventorySoundOverrides.erase(itemID);
+		overrides.erase(itemID);
 		break;
 	default :
 		return;
@@ -687,6 +795,10 @@ static Common::String getSoundSubtitle(const Common::String &soundName, const Co
 	return fallback;
 }
 
+Common::HashMap<uint16, Scene::InventorySoundOverride> &Scene::activeSoundOverrides() {
+	return _inventorySoundOverrides[MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1)];
+}
+
 // Nancy15 moved the "can't" responses out of the inventory data and into the
 // active player character's PUIV bank: one group of interchangeable sounds per
 // item, keyed by item ID, plus the character's generic response, which is the
@@ -731,8 +843,8 @@ void Scene::playPlayerCantSound(int16 itemID) {
 	SoundDescription sound;
 	Common::String caption;
 
-	if (itemID >= 0 && _inventorySoundOverrides.contains(itemID)) {
-		InventorySoundOverride &override = _inventorySoundOverrides[itemID];
+	if (itemID >= 0 && activeSoundOverrides().contains(itemID)) {
+		InventorySoundOverride &override = activeSoundOverrides()[itemID];
 
 		if (override.isDefault) {
 			// Back to the character's generic response
@@ -810,9 +922,9 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
 			g_nancy->_sound->playSound("CANT");
 		}
 	} else if ((uint)itemID < _flags.items.size()) {
-		if (_inventorySoundOverrides.contains(itemID)) {
+		if (activeSoundOverrides().contains(itemID)) {
 			// We have an override installed
-			InventorySoundOverride &override = _inventorySoundOverrides[itemID];
+			InventorySoundOverride &override = activeSoundOverrides()[itemID];
 			if (!override.isDefault) {
 				// Not set to the default sound, play the override
 				g_nancy->_sound->loadSound(override.sound);
@@ -1788,10 +1900,12 @@ void Scene::load(bool fromSaveFile) {
 		}
 	}
 
-	for (auto &override : _inventorySoundOverrides) {
-		g_nancy->_sound->stopSound(override._value.sound);
+	for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
+		for (auto &override : _inventorySoundOverrides[i]) {
+			g_nancy->_sound->stopSound(override._value.sound);
+		}
+		_inventorySoundOverrides[i].clear();
 	}
-	_inventorySoundOverrides.clear();
 
 	_timers.sceneTime = 0;
 	g_nancy->_sound->clearListenerPositionOverride();
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 8c12e095da2..63a36a5d02a 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -154,8 +154,14 @@ public:
 	// act on, which needn't be the one being played. Anyone else is served from
 	// their parked inventory instead of the live one.
 	void addItemToCharacterInventory(uint characterIndex, int16 id);
-	void removeItemFromCharacterInventory(uint characterIndex, int16 id);
+	void removeItemFromCharacterInventory(uint characterIndex, int16 id, bool pickUp = false);
 	byte hasCharacterItem(uint characterIndex, int16 id);
+	void setCharacterItemDisabledState(uint characterIndex, int16 id, byte state);
+
+	// AddInventoryNoHS (AR 120): hand the item over, into the character's hand
+	// when the record asks for it and their hand is free or forceIntoHand is set
+	void giveItemToCharacter(uint characterIndex, int16 id, bool intoHand, bool forceIntoHand);
+
 	int32 getCharacterUIResource(uint characterIndex, uint index);
 	int16 getHeldItem() const { return _flags.heldItem; }
 	void setHeldItem(int16 id);
@@ -167,7 +173,10 @@ public:
 			_flags.disabledItems[id] = state;
 	}
 
-	void installInventorySoundOverride(byte command, const SoundDescription &sound, const Common::String &caption, uint16 itemID);
+	// Nancy15 records name the player character the override belongs to;
+	// kPlayerCharacterActive (and every earlier game) means whoever is played
+	void installInventorySoundOverride(byte command, const SoundDescription &sound,
+		const Common::String &caption, uint16 itemID, byte characterIndex = kPlayerCharacterActive);
 	void playItemCantSound(int16 itemID = -1, bool notHoldingSound = false);
 
 	void setEventFlag(int16 label, byte flag);
@@ -320,6 +329,10 @@ private:
 	// Rect of the open Nancy 10+ taskbar popup, or empty if none.
 	Common::Rect activePopupConfinement() const;
 
+	int16 getCharacterHeldItem(uint characterIndex);
+	void setCharacterHeldItem(uint characterIndex, int16 id);
+	void returnCharacterHeldItem(uint characterIndex);
+
 	// Nancy15's "can't" responses live in the active player character's PUIV
 	// bank instead of the inventory data
 	bool getPlayerCantSound(int16 itemID, SoundDescription &sound) const;
@@ -382,6 +395,9 @@ private:
 		Common::String caption;
 	};
 
+	// The overrides of the character being played
+	Common::HashMap<uint16, InventorySoundOverride> &activeSoundOverrides();
+
 	// UI
 	UI::FullScreenImage _frame;
 	UI::Viewport _viewport;
@@ -418,7 +434,9 @@ private:
 	int16 _lastHintCharacter;
 	int16 _lastHintID;
 	NancyState::NancyState _gameStateRequested;
-	Common::HashMap<uint16, InventorySoundOverride> _inventorySoundOverrides;
+	// One set of overrides per player character; everything before Nancy15 only
+	// ever touches the first
+	Common::HashMap<uint16, InventorySoundOverride> _inventorySoundOverrides[kMaxPlayerCharacters];
 
 	Misc::Lightning *_lightning;
 	Common::Queue<Misc::SpecialEffect> _specialEffects;


Commit: 37177b74dcd595aa48a32026bddc1b76e2ad1f24
    https://github.com/scummvm/scummvm/commit/37177b74dcd595aa48a32026bddc1b76e2ad1f24
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:38+03:00

Commit Message:
NANCY: NANCY15: Implement new functionality for the Movie Control AR

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 b9e0e7e3d70..a3545870503 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -1344,6 +1344,14 @@ void PlaySecondaryMovie::skip() {
 // --- PlayRandomMovieControl --------------------------------------------
 
 void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy15) {
+		// Nancy15 names the movie to control. The literal "RandomMovie" keeps the
+		// old behavior of addressing the scene's random movie; any other name is
+		// a movie file, which is looked up among the loaded movies.
+		readFilename(stream, _movieName);
+		_isRandomMovie = _movieName.baseName().equalsIgnoreCase("RandomMovie");
+	}
+
 	_mode = stream.readByte();
 
 	_hasSceneChange = g_nancy->getGameType() < kGameTypeNancy13;
@@ -1353,24 +1361,62 @@ void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
 }
 
 void PlayRandomMovieControl::execute() {
-	PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
-	if (target && target->isRandom()) {
-		if (_hasSceneChange) {
-			target->stopRandom();
-		} else {
+	if (_isRandomMovie) {
+		PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
+		if (target && target->isRandom()) {
+			if (_hasSceneChange) {
+				target->stopRandom();
+			} else {
+				switch (_mode) {
+				case kStopNow:
+					target->stopRandomNow();
+					break;
+				case kPauseMovie:
+					target->pauseRandom(true);
+					break;
+				case kResumeMovie:
+					target->pauseRandom(false);
+					break;
+				case kStopAtEnd:
+					target->stopRandom();
+					break;
+				case kPauseAtEnd:
+					// The original defers the pause until the movie reaches its
+					// end; nothing in the shipped data uses it
+					warning("Unimplemented PlayRandomMovieControl mode kPauseAtEnd");
+					break;
+				default:
+					warning("Unknown PlayRandomMovieControl mode %u", _mode);
+					break;
+				}
+			}
+		}
+	} else {
+		MoviePlayer *movie = MoviePlayer::findLoadedMovie(_movieName);
+		if (movie) {
 			switch (_mode) {
 			case kStopNow:
-				target->stopRandomNow();
+				movie->stop();
 				break;
 			case kPauseMovie:
-				target->pauseRandom(true);
+				movie->pauseVideo(true);
 				break;
 			case kResumeMovie:
-				target->pauseRandom(false);
+				movie->pauseVideo(false);
+				break;
+			case kStopAtEnd:
+			case kPauseAtEnd:
+				warning("Unimplemented deferred PlayRandomMovieControl mode %u for movie %s",
+					_mode, _movieName.toString().c_str());
 				break;
 			default:
+				warning("Unknown PlayRandomMovieControl mode %u", _mode);
 				break;
 			}
+		} else if (_mode == kResumeMovie) {
+			// The original loads the movie and starts playing it here
+			warning("PlayRandomMovieControl can't play movie %s, it is not loaded",
+				_movieName.toString().c_str());
 		}
 	}
 
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index a7719e7842f..71803408bda 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -376,7 +376,9 @@ protected:
 
 // Companion AR for the random-movie variant of PlaySecondaryMovie. When
 // executed it stops the currently-active random PlaySecondaryMovie and
-// optionally performs a scene change / event-flag set.
+// optionally performs a scene change / event-flag set. From Nancy15 on the
+// record names the movie it controls, so it can also address an already-loaded
+// movie by filename.
 class PlayRandomMovieControl : public ActionRecord {
 public:
 	PlayRandomMovieControl() {}
@@ -387,7 +389,9 @@ public:
 	enum RandomMovieControlMode : byte {
 		kStopNow = 0,
 		kPauseMovie = 1,
-		kResumeMovie = 2
+		kResumeMovie = 2,
+		kStopAtEnd = 3,
+		kPauseAtEnd = 4
 	};
 
 protected:
@@ -398,6 +402,9 @@ protected:
 	// Nancy13's record is the mode byte alone; earlier games append a scene
 	// change to it.
 	bool _hasSceneChange = true;
+	// Nancy15 prefixes the mode with the name of the movie to control.
+	Common::Path _movieName;
+	bool _isRandomMovie = true;
 };
 
 } // End of namespace Action


Commit: 41f393172322352c8ed7aa155f2b3331d137d813
    https://github.com/scummvm/scummvm/commit/41f393172322352c8ed7aa155f2b3331d137d813
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:39+03:00

Commit Message:
NANCY: NANCY15: Add new functionality for conversation cels

- Handle new rects in the conversation XSheet
- Handle Bink movies used for character animations

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


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 19faca812af..4947ab70bef 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -1044,6 +1044,34 @@ void ConversationCel::init() {
 		} else break;
 	}
 
+	// A Nancy15 tree that isn't a loaded cel archive names a movie - the character's
+	// talking head, which is played into the tree's rect while the body keeps
+	// animating from cels.
+	_treeMovies.resize(_celRObjects.size());
+	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
+		if (i >= _treeRects.size() || g_nancy->_resource->hasCifTree(_treeNames[i])) {
+			continue;
+		}
+
+		if (_treeRects[i].isEmpty()) {
+			warning("Cel tree '%s' is neither a loaded .cal nor a movie with a destination rect",
+				_treeNames[i].c_str());
+			continue;
+		}
+
+		Common::SharedPtr<MoviePlayer> movie(new MoviePlayer());
+		if (!movie->loadFile(Common::Path(_treeNames[i]))) {
+			warning("Couldn't load conversation movie '%s'", _treeNames[i].c_str());
+			continue;
+		}
+
+		movie->start();
+		_treeMovies[i] = movie;
+		_celRObjects[i].moveTo(_treeRects[i]);
+	}
+
 	registerGraphics();
 }
 
@@ -1051,7 +1079,9 @@ void ConversationCel::registerGraphics() {
 	for (uint i = 0; i < _celRObjects.size(); ++i) {
 		_celRObjects[i].setZOrder(9 + _drawingOrder[i]);
 		_celRObjects[i].setVisible(true);
-		_celRObjects[i].setTransparent(true);
+		// Head movies carry an alpha plane, so they are composited by it rather
+		// than by the transparent color key the cels use.
+		_celRObjects[i].setTransparent(i >= _treeMovies.size() || !_treeMovies[i]);
 		_celRObjects[i].registerGraphics();
 	}
 
@@ -1061,8 +1091,22 @@ void ConversationCel::registerGraphics() {
 void ConversationCel::updateGraphics() {
 	uint32 currentTime = g_nancy->getTotalPlayTime();
 
+	for (uint i = 0; i < _treeMovies.size(); ++i) {
+		if (_treeMovies[i] && _treeMovies[i]->needsUpdate()) {
+			const Graphics::Surface *frame = _treeMovies[i]->decodeNextFrame();
+			if (frame) {
+				_celRObjects[i].setMovieFrame(*frame);
+			}
+		}
+	}
+
 	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
+				continue;
+			}
+
 			Cel &cel = loadCel(_celNames[i][_curFrame], _treeNames[i]);
 			if (_overrideTreeRects[i] == kCelOverrideTreeRectsOn) {
 				_celRObjects[i]._drawSurface.create(cel.surf, _overrideRectSrcs[i]);
@@ -1078,6 +1122,21 @@ void ConversationCel::updateGraphics() {
 	}
 }
 
+void ConversationCel::RenderedCel::setMovieFrame(const Graphics::Surface &frame) {
+	GraphicsManager::copyToManaged(frame, _drawSurface);
+	_needsRedraw = true;
+}
+
+void ConversationCel::onPause(bool pause) {
+	for (uint i = 0; i < _treeMovies.size(); ++i) {
+		if (_treeMovies[i]) {
+			_treeMovies[i]->pauseVideo(pause);
+		}
+	}
+
+	RenderActionRecord::onPause(pause);
+}
+
 void ConversationCel::readData(Common::SeekableReadStream &stream) {
 	if (g_nancy->getGameType() >= kGameTypeNancy13) {
 		readDataNancy13(stream);
@@ -1169,6 +1228,13 @@ void ConversationCel::readXSheet(Common::SeekableReadStream &stream, const Commo
 		// Skip any unused tree-name slots so the frame time is read from its fixed
 		// offset regardless of numTrees.
 		xsheet->skip((kMaxTrees - numTrees) * kNameSize);
+
+		// Nancy15 inserted a destination rect per tree slot ahead of the frame time.
+		// It is only filled in for trees that name a movie instead of a cel archive.
+		if (g_nancy->getGameType() >= kGameTypeNancy15) {
+			readRectArray(*xsheet, _treeRects, kMaxTrees);
+		}
+
 		_frameTime = xsheet->readUint32LE();
 	} else {
 		xsheet->skip(2);
@@ -1205,6 +1271,10 @@ ConversationCel::Cel &ConversationCel::loadCel(const Common::Path &name, const C
 bool ConversationCel::load() {
 	for (uint i = _curFrame; i < _celNames[0].size(); ++i) {
 		for (uint j = 0; j < _celRObjects.size(); ++j) {
+			if (j < _treeMovies.size() && _treeMovies[j]) {
+				continue;
+			}
+
 			if (!_celCache.contains(_celNames[j][i])) {
 				loadCel(_celNames[j][i], _treeNames[j]);
 				return false;
diff --git a/engines/nancy/action/conversation.h b/engines/nancy/action/conversation.h
index 6e6f023ad0c..278e2cf284e 100644
--- a/engines/nancy/action/conversation.h
+++ b/engines/nancy/action/conversation.h
@@ -185,6 +185,7 @@ public:
 	void init() override;
 	void registerGraphics() override;
 	void updateGraphics() override;
+	void onPause(bool pause) override;
 
 	void readData(Common::SeekableReadStream &stream) override;
 
@@ -204,6 +205,9 @@ protected:
 	public:
 		RenderedCel() : RenderObject(9) {}
 		bool isViewportRelative() const override { return true; }
+
+		// Nancy15 head movies: show a decoded movie frame instead of a cel
+		void setMovieFrame(const Graphics::Surface &frame);
 	};
 
 	static const byte kCelOverrideTreeRectsOff	= 1;
@@ -229,6 +233,12 @@ protected:
 	Common::Array<Common::Rect> _overrideRectSrcs;
 	Common::Array<Common::Rect> _overrideRectDests;
 
+	// Nancy15 gave every cel tree a destination rect inside the XSheet. A tree that
+	// names a movie instead of a loaded cel archive (the character's head) is played
+	// into that rect; _treeMovies holds one player per such tree, null for cel trees.
+	Common::Array<Common::Rect> _treeRects;
+	Common::Array<Common::SharedPtr<MoviePlayer>> _treeMovies;
+
 	uint _curFrame = 0;
 	uint32 _nextFrameTime = 0;
 
diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index 089bf8e88d4..cc06dc9cc08 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -213,10 +213,8 @@ IFF *ResourceManager::loadIFF(const 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
-	for (const Common::String &loaded : _cifTreeNames) {
-		if (loaded.equalsIgnoreCase(name)) {
-			return true;
-		}
+	if (hasCifTree(name)) {
+		return true;
 	}
 
 	CifTree *tree = CifTree::makeCifTreeArchive(name, ext);
@@ -234,6 +232,16 @@ bool ResourceManager::readCifTree(const Common::String &name, const Common::Stri
 	return true;
 }
 
+bool ResourceManager::hasCifTree(const Common::String &name) const {
+	for (const Common::String &loaded : _cifTreeNames) {
+		if (loaded.equalsIgnoreCase(name)) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
 void ResourceManager::setCifTreePriority(const Common::String &name, int priority) {
 	Common::String upper = name;
 	upper.toUppercase();
diff --git a/engines/nancy/resource.h b/engines/nancy/resource.h
index c436d71408c..9607ddc4c79 100644
--- a/engines/nancy/resource.h
+++ b/engines/nancy/resource.h
@@ -49,6 +49,10 @@ public:
 	// Load a new ciftree
 	bool readCifTree(const Common::String &name, const Common::String &ext, int priority);
 
+	// Whether a ciftree of that name has been loaded. Nancy15 conversations use it
+	// to tell a cel archive apart from a movie of the same name (see ConversationCel).
+	bool hasCifTree(const Common::String &name) const;
+
 	// Change the search priority of an already loaded ciftree. Nancy15+ ships one
 	// copy of the popup UI resources per player character, so the tree belonging
 	// to the active character has to win name lookups against all the others.


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

Commit Message:
NANCY: NANCY15: Handle the player character in ResourceUse records

Changed paths:
    engines/nancy/action/miscrecords.cpp
    engines/nancy/action/miscrecords.h
    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 9bef435cc67..3f7bfd7e92c 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -989,9 +989,13 @@ void HintSystem::selectHint() {
 }
 
 void ResourceUse::readData(Common::SeekableReadStream &stream) {
+	if (g_nancy->getGameType() >= kGameTypeNancy15) {
+		_characterIndex = stream.readByte();     // whose resources are changed
+	}
+
 	_resourceIndex = stream.readSint16LE();      // which UIRC resource to change
-	_amount = stream.readSint16LE();             // value / delta
-	_mode = stream.readByte();                   // 0 = set, non-zero = add
+	_amount = stream.readSint16LE();             // value / delta, or a table index
+	_mode = stream.readByte();                   // see ResourceUseMode
 	_flag.label = stream.readSint16LE();         // event flag set on success
 	_flag.flag = stream.readByte();
 
@@ -1013,6 +1017,10 @@ void ResourceUse::readData(Common::SeekableReadStream &stream) {
 	_valueDest.y = stream.readSint32LE();
 }
 
+byte ResourceUse::getCharacterIndex() const {
+	return _characterIndex == kPlayerCharacterActive ? (byte)g_nancy->getPlayerCharacter() : _characterIndex;
+}
+
 void ResourceUse::init() {
 	Common::Rect screenBounds = NancySceneState.getViewport().getBounds();
 	_drawSurface.create(screenBounds.width(), screenBounds.height(), g_nancy->_graphics->getInputPixelFormat());
@@ -1040,7 +1048,7 @@ void ResourceUse::init() {
 		const Font *font = g_nancy->_graphics->getFont(item.fontID);
 		if (font && item.numDecimals >= 0) {
 			const Common::String text =
-				formatUIResourceValue(item, NancySceneState.getUIResource(_resourceIndex));
+				formatUIResourceValue(item, NancySceneState.getUIResource(_resourceIndex, getCharacterIndex()));
 			font->drawString(&_drawSurface, text, _valueDest.x, _valueDest.y, screenBounds.width() - _valueDest.x, 0);
 		}
 	}
@@ -1050,18 +1058,31 @@ void ResourceUse::init() {
 }
 
 void ResourceUse::applyChange() {
-	if (_mode == 0) {
+	const byte characterIndex = getCharacterIndex();
+
+	if (_mode > kSetTableValue) {
+		warning("Unknown ResourceUse mode %u, treating it as an addition", _mode);
+	}
+
+	// In the table modes the amount is a table index, not the amount itself.
+	int32 amount = _amount;
+	if (_mode == kAddTableValue || _mode == kSetTableValue) {
+		auto *tableData = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+		amount = (tableData && _amount != tableData->getNoIndex()) ? tableData->getValue(_amount) : 0;
+	}
+
+	if (_mode == kSetValue || _mode == kSetTableValue) {
 		// Set the resource outright.
-		NancySceneState.setUIResource(_resourceIndex, _amount);
+		NancySceneState.setUIResource(_resourceIndex, amount, characterIndex);
 		NancySceneState.setEventFlag(_flag);
 		_paymentApplied = true;
 	} else {
 		// Add the (signed) amount, but never let the resource go negative —
 		// the original skips the change (e.g. when Nancy can't afford it).
-		const int32 result = NancySceneState.getUIResource(_resourceIndex) + _amount;
+		const int32 result = NancySceneState.getUIResource(_resourceIndex, characterIndex) + amount;
 		_paymentApplied = result >= 0;
 		if (_paymentApplied) {
-			NancySceneState.setUIResource(_resourceIndex, result);
+			NancySceneState.setUIResource(_resourceIndex, result, characterIndex);
 			NancySceneState.setEventFlag(_flag);
 		}
 	}
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index d15c29503b2..434a79991a0 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -507,9 +507,23 @@ protected:
 	// flag and starts the matching outcome sound.
 	void applyChange();
 
+	// The protagonist whose resources this record changes
+	byte getCharacterIndex() const;
+
+	enum ResourceUseMode : byte {
+		kSetValue = 0,			// set the resource to _amount
+		kAddValue = 1,			// add _amount, if the resource can cover it
+		kAddTableValue = 2,		// same, with the amount taken from the table
+		kSetTableValue = 3		// set to the table value at _amount
+	};
+
+	// Nancy15 pays from a named protagonist's resources; kPlayerCharacterActive
+	// (and every earlier game) means whoever is being played.
+	byte _characterIndex = kPlayerCharacterActive;
+
 	int16 _resourceIndex = 0;
-	int16 _amount = 0;
-	byte _mode = 0;        // 0 = set the resource, non-zero = add (clamped to >= 0)
+	int16 _amount = 0;     // an amount, or a table index in the table modes
+	byte _mode = kSetValue;
 	FlagDescription _flag; // event flag set when the change is applied
 
 	Common::String _failSoundName;    // played when the change can't be applied
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 29509fd9732..4a185b9f257 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1079,19 +1079,53 @@ static void seedUIResourceData(UIResourceData *data) {
 	}
 }
 
-int32 Scene::getUIResource(uint index) {
+// The character being played keeps their resources in `values`; the other
+// protagonists' sets are parked in `characterValues` until they are played.
+// A character who has never been played starts from the UIRC starting values.
+static Common::Array<int32> *characterResourceValues(UIResourceData *data, byte characterIndex) {
+	if (!data) {
+		return nullptr;
+	}
+
+	if (characterIndex == kPlayerCharacterActive || characterIndex == g_nancy->getPlayerCharacter()) {
+		return &data->values;
+	}
+
+	if (characterIndex >= kMaxPlayerCharacters) {
+		warning("UI resource change for unknown player character %u, using the active one", characterIndex);
+		return &data->values;
+	}
+
+	Common::Array<int32> &stored = data->getCharacterValues(characterIndex);
+	if (stored.empty()) {
+		stored.resize(data->values.size(), 0);
+
+		const UIRC *uirc = GetEngineData(UIRC)
+		if (uirc) {
+			for (uint i = 0; i < stored.size() && i < uirc->items.size(); ++i) {
+				stored[i] = uirc->items[i].startingValue;
+			}
+		}
+	}
+
+	return &stored;
+}
+
+int32 Scene::getUIResource(uint index, byte characterIndex) {
 	UIResourceData *data = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
 	seedUIResourceData(data);
-	if (!data || index >= data->values.size()) {
+	Common::Array<int32> *values = characterResourceValues(data, characterIndex);
+	if (!values || index >= values->size()) {
 		return 0;
 	}
-	return data->values[index];
+	return (*values)[index];
 }
 
-void Scene::setUIResource(uint index, int32 value) {
+void Scene::setUIResource(uint index, int32 value, byte characterIndex) {
 	UIResourceData *data = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
 	seedUIResourceData(data);
-	if (!data || index >= data->values.size()) {
+	Common::Array<int32> *values = characterResourceValues(data, characterIndex);
+	if (!values || index >= values->size()) {
 		return;
 	}
 
@@ -1105,7 +1139,7 @@ void Scene::setUIResource(uint index, int32 value) {
 		}
 	}
 
-	data->values[index] = MAX<int32>(value, 0);
+	(*values)[index] = MAX<int32>(value, 0);
 }
 
 // Nancy 11+ AR 30/31 store the "player scrolling disabled" state in an event
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 63a36a5d02a..44426060bf6 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -192,8 +192,10 @@ public:
 	// coin purse amount in cents. Backed by the lazily-created, saved
 	// UIResourceData puzzle chunk, seeded from UIRC on first use and mutated by
 	// AR 132 (ResourceUse). Non-const because the first access creates/seeds it.
-	int32 getUIResource(uint index);
-	void setUIResource(uint index, int32 value);
+	// Nancy15 records name the protagonist whose resources they change;
+	// kPlayerCharacterActive (and every earlier game) means whoever is played.
+	int32 getUIResource(uint index, byte characterIndex = kPlayerCharacterActive);
+	void setUIResource(uint index, int32 value, byte characterIndex = kPlayerCharacterActive);
 
 	void setLogicCondition(int16 label, byte flag);
 	bool getLogicCondition(int16 label, byte flag) const;


Commit: 8656e29e7f7b92626bc683a3802ce368c212fecc
    https://github.com/scummvm/scummvm/commit/8656e29e7f7b92626bc683a3802ce368c212fecc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:43+03:00

Commit Message:
NANCY: NANCY13: Properly convert 24bpp images to 32bpp

The target surface may already be in 32bpp, in which case the previous
in-place surface conversion would fail. Fixes setting the interface in
Nancy15.

Changed paths:
    engines/nancy/resource.cpp


diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index cc06dc9cc08..4a985c28c14 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -174,10 +174,29 @@ bool ResourceManager::loadImage(const Common::Path &name, Graphics::ManagedSurfa
 	}
 	#endif
 
-	GraphicsManager::copyToManaged(buf, surf, info.width, info.height, g_nancy->_graphics->getInputPixelFormat(info.depth));
+	if (info.depth == 24 && surf.format.bpp() == 32) {
+		// Nancy13+ uses 32bpp surfaces for 24bpp images, so we need to convert the data
+		// to 32bpp before copying it into the surface
+		uint32 newBufSize = info.width * info.height * 4;
+		byte *newBuf = new byte[newBufSize];
+		for (uint y = 0; y < info.height; ++y) {
+			for (uint x = 0; x < info.width; ++x) {
+				uint32 srcIndex = y * info.pitch + x * 3;
+				uint32 destIndex = y * info.width * 4 + x * 4;
+				newBuf[destIndex + 0] = buf[srcIndex + 0];
+				newBuf[destIndex + 1] = buf[srcIndex + 1];
+				newBuf[destIndex + 2] = buf[srcIndex + 2];
+				newBuf[destIndex + 3] = 0xFF; // alpha channel
+			}
+		}
+		delete[] buf;
+		buf = newBuf;
+		bufSize = newBufSize;
+		info.pitch = info.width * 4;
+		info.depth = 32;
+	}
 
-	if (info.depth == 24)
-		surf.convertToInPlace(g_nancy->_graphics->getInputPixelFormat(32));
+	GraphicsManager::copyToManaged(buf, surf, info.width, info.height, g_nancy->_graphics->getInputPixelFormat(info.depth));
 
 	delete[] buf;
 	delete stream;


Commit: 9c2a253e15d6b9f3900f157ef45a0f6ee89c0dba
    https://github.com/scummvm/scummvm/commit/9c2a253e15d6b9f3900f157ef45a0f6ee89c0dba
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:46+03:00

Commit Message:
NANCY: NANCY10: Correct mapping for new font glyphs

- Fix the underscore glyph in Nancy10+
- Map the two new font glyphs used in Nancy15

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


diff --git a/engines/nancy/font.cpp b/engines/nancy/font.cpp
index 684756e68c1..81fd8594c48 100644
--- a/engines/nancy/font.cpp
+++ b/engines/nancy/font.cpp
@@ -125,6 +125,7 @@ void Font::read(Common::SeekableReadStream &stream) {
 
 		if (g_nancy->getGameType() >= kGameTypeNancy10) {
 			// Nancy10 added even more characters to its fonts
+			_underscoreOffset				= stream.readUint16LE();
 			if (g_nancy->getGameLanguage() == Common::FR_FRA) {
 				_oeLigatureOffset			= stream.readUint16LE();
 				_iWithDiaeresisOffset		= stream.readUint16LE();
@@ -132,7 +133,6 @@ void Font::read(Common::SeekableReadStream &stream) {
 				_uppercaseAWithDotOffset	= stream.readUint16LE();
 				_aWithDotOffset				= stream.readUint16LE();
 			}
-			_underscoreOffset				= stream.readUint16LE();
 			_hashOffset						= stream.readUint16LE();
 			_dollarOffset					= stream.readUint16LE();
 			_lessThanOffset					= stream.readUint16LE();
@@ -145,9 +145,9 @@ void Font::read(Common::SeekableReadStream &stream) {
 		}
 
 		if (g_nancy->getGameType() >= kGameTypeNancy15) {
-			// Nancy15 added two more characters. Their glyphs are only present in a
-			// few of the fonts, and it is not yet known which characters they are
-			stream.skip(4);
+			// Nancy15 added two more characters. Their glyphs are only present in a few of the fonts
+			_caretOffset					= stream.readUint16LE();
+			_atSignOffset					= stream.readUint16LE();
 
 			numCharacters = 117;
 		}
@@ -392,9 +392,15 @@ Common::Rect Font::getCharacterSourceRect(char chr) const {
 			case '\x80':
 				offset = _euroOffset;
 				break;
-			// TODO: _uppercaseAWithDotOffset
-			// TODO: _aWithDotOffset
-			// TODO: _oeLigatureOffset
+			case '\xc5':
+				offset = _uppercaseAWithDotOffset;
+				break;
+			case '\xe5':
+				offset = _aWithDotOffset;
+				break;
+			case '\x9c':
+				offset = _oeLigatureOffset;
+				break;
 			default:
 				offset = -1;
 				break;
@@ -485,6 +491,13 @@ Common::Rect Font::getCharacterSourceRect(char chr) const {
 		case '}':
 			offset = _rightCurlyBracketOffset;
 			break;
+		// ASCII punctuation whose glyphs were added in nancy15
+		case '^':
+			offset = _caretOffset;
+			break;
+		case '@':
+			offset = _atSignOffset;
+			break;
 		default:
 			offset = -1;
 			break;
diff --git a/engines/nancy/font.h b/engines/nancy/font.h
index 26147d95a25..6a76ac4f188 100644
--- a/engines/nancy/font.h
+++ b/engines/nancy/font.h
@@ -140,6 +140,10 @@ private:
 	int16 _rightCurlyBracketOffset			= -1;
 	int16 _euroOffset						= -1;
 
+	// Two more characters, introduced in nancy15
+	int16 _caretOffset						= -1;
+	int16 _atSignOffset						= -1;
+
 	// Specific offsets in French variant of nancy12
 	int16 _uppercaseCWithCedillaOffset		= -1;
 	int16 _uWithCircumflexOffset			= -1;


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

Commit Message:
NANCY: NANCY15: Implement NecklacePuzzle

This is a rewritten version of BeadPuzzle, and functionally it's quite
similar. It's used in the necklace puzzle, where Nancy has to make an
"Aloha necklace" for Big Mike

Changed paths:
  A engines/nancy/action/puzzle/necklacepuzzle.cpp
  A engines/nancy/action/puzzle/necklacepuzzle.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 45c6f7fd382..06afed6e8d9 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -70,6 +70,7 @@
 #include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
 #include "engines/nancy/action/puzzle/mouselightpuzzle.h"
 #include "engines/nancy/action/puzzle/multibuildpuzzle.h"
+#include "engines/nancy/action/puzzle/necklacepuzzle.h"
 #include "engines/nancy/action/puzzle/onebuildpuzzle.h"
 #include "engines/nancy/action/puzzle/orderingpuzzle.h"
 #include "engines/nancy/action/puzzle/overridelockpuzzle.h"
@@ -627,7 +628,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 242:
 		return new MagnetMazePuzzle();
 	case 243:
-		return new BeadPuzzle();
+		if (g_nancy->getGameType() <= kGameTypeNancy14)
+			return new BeadPuzzle();
+		else
+			return new NecklacePuzzle();
 	case 244:
 		return new GridMapPuzzle();
 	// -- Nancy 11 and up --
diff --git a/engines/nancy/action/puzzle/necklacepuzzle.cpp b/engines/nancy/action/puzzle/necklacepuzzle.cpp
new file mode 100644
index 00000000000..194be940a3c
--- /dev/null
+++ b/engines/nancy/action/puzzle/necklacepuzzle.cpp
@@ -0,0 +1,636 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+#include "common/util.h"
+
+#include "graphics/font.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/cursor.h"
+#include "engines/nancy/util.h"
+#include "engines/nancy/font.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/necklacepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+NecklacePuzzle::~NecklacePuzzle() {
+	refundBeads();
+}
+
+void NecklacePuzzle::readData(Common::SeekableReadStream &stream) {
+	_refundOnExit = stream.readByte();
+	_overlapPlaced = stream.readSint32LE();
+	_overlapFalling = stream.readSint32LE();
+	_dropFlag.label = stream.readSint16LE();
+	_dropFlag.flag = stream.readByte();
+	_heldCursorType = stream.readUint16LE();
+	_hoverCursorType = stream.readUint16LE();
+	_heldOffsetX = stream.readSint32LE();
+	_heldOffsetY = stream.readSint32LE();
+	_countFontID = stream.readUint16LE();
+	_unknownA0 = stream.readUint16LE();
+	_countTextExtraY = stream.readUint16LE();
+	_countTextX = stream.readSint32LE();
+	_countTextY = stream.readSint32LE();
+
+	uint16 numTypes = stream.readUint16LE();
+	_beadTypes.resize(numTypes);
+	for (uint16 i = 0; i < numTypes; ++i) {
+		BeadType &type = _beadTypes[i];
+		type.key = stream.readSint32LE();
+		type.overlaps = stream.readByte();
+
+		Common::Path names[3];
+		for (uint j = 0; j < 3; ++j) {
+			readFilename(stream, names[j]);
+		}
+
+		// The three sheets repeat across kinds, so each distinct one is loaded once.
+		int *slots[3] = { &type.cursorImage, &type.strandImage, &type.boxImage };
+		for (uint j = 0; j < 3; ++j) {
+			uint k = 0;
+			for (; k < _imageNames.size(); ++k) {
+				if (_imageNames[k] == names[j]) {
+					break;
+				}
+			}
+
+			if (k == _imageNames.size()) {
+				_imageNames.push_back(names[j]);
+			}
+
+			*slots[j] = (int)k;
+		}
+	}
+
+	uint16 numBeads = stream.readUint16LE();
+	_beads.resize(numBeads);
+	for (uint16 i = 0; i < numBeads; ++i) {
+		Bead &bead = _beads[i];
+		bead.type = stream.readByte();
+		readRect(stream, bead.cursorSrc);
+		readRect(stream, bead.strandSrc);
+		readRect(stream, bead.boxSrc);
+		readRect(stream, bead.boxDest);
+		bead.tableIndex = stream.readSint16LE();
+	}
+
+	uint16 numStrands = stream.readUint16LE();
+	_strands.resize(numStrands);
+	for (uint16 i = 0; i < numStrands; ++i) {
+		Strand &strand = _strands[i];
+		strand.grabByTopBead = stream.readByte();
+		strand.allowSwap = stream.readByte();
+
+		uint16 numTypesAllowed = stream.readUint16LE();
+		strand.allowedTypes.resize(numTypesAllowed);
+		for (uint16 j = 0; j < numTypesAllowed; ++j) {
+			strand.allowedTypes[j] = stream.readByte();
+		}
+
+		uint16 numSolution = stream.readUint16LE();
+		strand.solution.resize(numSolution);
+		for (uint16 j = 0; j < numSolution; ++j) {
+			strand.solution[j] = stream.readUint16LE();
+		}
+
+		readRect(stream, strand.needleHotspot);
+		readRect(stream, strand.strandRect);
+
+		strand.dropSeconds = stream.readFloatLE();
+	}
+
+	_pickUpSound.readData(stream);
+	_putDownSound.readData(stream);
+	_unknownSound.readData(stream);
+
+	_solvedScene.sceneID = stream.readUint16LE();
+	_solvedScene.frameID = stream.readUint16LE();
+	int16 solvedOffset = stream.readSint16LE();
+	_solvedScene.verticalOffset = solvedOffset >= 0 ? solvedOffset : 0;
+	_solvedScene.continueSceneSound = stream.readByte();
+	_solvedSound.readData(stream);
+
+	_unsolvedScene.sceneID = stream.readUint16LE();
+	_unsolvedScene.frameID = stream.readUint16LE();
+	int16 unsolvedOffset = stream.readSint16LE();
+	_unsolvedScene.verticalOffset = unsolvedOffset >= 0 ? unsolvedOffset : 0;
+	_unsolvedScene.continueSceneSound = stream.readByte();
+	_unsolvedSound.readData(stream);
+
+	readExitHotspot(stream, _exitHotspot, _exitCursorType, _exitScene, _exitFlag);
+}
+
+void NecklacePuzzle::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);
+
+	_images.resize(_imageNames.size());
+	for (uint i = 0; i < _imageNames.size(); ++i) {
+		g_nancy->_resource->loadImage(_imageNames[i], _images[i]);
+		_images[i].setTransparentColor(_drawSurface.getTransparentColor());
+	}
+
+	NancySceneState.setNoHeldItem();
+
+	redraw();
+}
+
+void NecklacePuzzle::registerGraphics() {
+	RenderObject::registerGraphics();
+	_heldBeadObject.registerGraphics();
+}
+
+int NecklacePuzzle::findBeadType(byte key) const {
+	for (uint i = 0; i < _beadTypes.size(); ++i) {
+		if (_beadTypes[i].key == (int32)key) {
+			return (int)i;
+		}
+	}
+
+	return -1;
+}
+
+bool NecklacePuzzle::strandAccepts(const Strand &strand, byte type) const {
+	for (uint i = 0; i < strand.allowedTypes.size(); ++i) {
+		if (strand.allowedTypes[i] == type) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+int NecklacePuzzle::stackTop(const Strand &strand) const {
+	if (strand.placed.empty()) {
+		return strand.strandRect.bottom;
+	}
+
+	return strand.placed.back().rect.top;
+}
+
+int NecklacePuzzle::stackTopBelow(const Strand &strand) const {
+	if (strand.placed.size() < 2) {
+		return strand.strandRect.bottom;
+	}
+
+	return strand.placed[strand.placed.size() - 2].rect.top;
+}
+
+int16 NecklacePuzzle::beadCount(const Bead &bead) const {
+	if (bead.tableIndex == kNoTableIndex) {
+		return 0;
+	}
+
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	if (!table) {
+		return 0;
+	}
+
+	int16 value = table->getValue(bead.tableIndex);
+	return value == kNoTableValue ? 0 : value;
+}
+
+void NecklacePuzzle::adjustBeadCount(const Bead &bead, int16 delta) {
+	if (bead.tableIndex == kNoTableIndex) {
+		return;
+	}
+
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	if (!table) {
+		return;
+	}
+
+	int16 value = table->getValue(bead.tableIndex);
+	if (value == kNoTableValue) {
+		value = 0;
+	}
+
+	table->setValue(bead.tableIndex, value + delta);
+}
+
+NecklacePuzzle::Hover NecklacePuzzle::hitTest(const Common::Point &mousePos) const {
+	Hover hover;
+	const bool holding = _heldBead != kNoBead;
+
+	for (uint i = 0; i < _strands.size(); ++i) {
+		const Strand &strand = _strands[i];
+
+		if (holding && strand.needleHotspot.contains(mousePos)) {
+			const Bead &held = _beads[_heldBead];
+			const int extent = held.strandSrc.height() - _overlapFalling;
+
+			if (strandAccepts(strand, held.type)) {
+				if (stackTop(strand) - extent > strand.strandRect.top) {
+					hover.strand = (int)i;
+					return hover;
+				}
+
+				// The strand is full, but it can take this shell in place of the one
+				// on top, which goes back on the cursor.
+				if (strand.allowSwap && stackTopBelow(strand) - extent > strand.strandRect.top) {
+					hover.strand = (int)i;
+					hover.swapTop = true;
+					return hover;
+				}
+			}
+
+			continue;
+		}
+
+		if (holding || strand.placed.empty()) {
+			continue;
+		}
+
+		// Some strands hand the top shell back when the needle is clicked, the rest
+		// when the shell itself is.
+		const Common::Rect &grabRect = strand.grabByTopBead ? strand.placed.back().rect : strand.needleHotspot;
+		if (grabRect.contains(mousePos)) {
+			hover.strand = (int)i;
+			return hover;
+		}
+	}
+
+	for (uint i = 0; i < _beads.size(); ++i) {
+		if (_beads[i].boxDest.contains(mousePos) && beadCount(_beads[i]) > 0) {
+			hover.bead = (int)i;
+			return hover;
+		}
+	}
+
+	return hover;
+}
+
+void NecklacePuzzle::holdBead(int16 bead, NancyInput *input) {
+	_heldBead = bead;
+
+	if (_heldBead == kNoBead) {
+		_heldBeadObject.setVisible(false);
+		_heldBeadObject.putDown();
+		return;
+	}
+
+	const Bead &held = _beads[_heldBead];
+	const int typeIndex = findBeadType(held.type);
+	const int image = typeIndex >= 0 ? _beadTypes[typeIndex].cursorImage : -1;
+
+	if (image >= 0 && _images[image].getBounds().contains(held.cursorSrc)) {
+		_heldBeadObject._drawSurface.create(_images[image], held.cursorSrc);
+		_heldBeadObject.setTransparent(true);
+		_heldBeadObject.setVisible(true);
+		_heldBeadObject.pickUp();
+		if (input) {
+			_heldBeadObject.handleInput(*input);
+		}
+	} else {
+		_heldBeadObject.setVisible(false);
+		_heldBeadObject.putDown();
+	}
+}
+
+// Takes the shell off the cursor without putting it anywhere.
+void NecklacePuzzle::releaseBead() {
+	playSoundBlock(_putDownSound);
+	holdBead(kNoBead, nullptr);
+}
+
+void NecklacePuzzle::dropBeadOn(int strandIndex) {
+	Strand &strand = _strands[strandIndex];
+	const Bead &held = _beads[_heldBead];
+	const int typeIndex = findBeadType(held.type);
+	const bool overlaps = typeIndex >= 0 && _beadTypes[typeIndex].overlaps;
+
+	// The shell always starts at the top of the strand, centered across it.
+	int centeredWidth = held.strandSrc.width() - (overlaps ? _overlapPlaced : 0);
+	PlacedBead placed;
+	placed.bead = _heldBead;
+	placed.rect.left = strand.strandRect.left + (strand.strandRect.width() - centeredWidth) / 2;
+	placed.rect.top = strand.strandRect.top;
+	placed.rect.right = placed.rect.left + held.strandSrc.width();
+	placed.rect.bottom = placed.rect.top + held.strandSrc.height();
+
+	// Sliding the full length of the strand takes dropSeconds; this one only has to
+	// cover the gap down to whatever it lands on.
+	const int restOn = stackTop(strand);
+	const int fullDrop = strand.strandRect.height();
+	uint32 duration = 0;
+	if (fullDrop > 0) {
+		duration = (uint32)(strand.dropSeconds * (float)(restOn - strand.strandRect.top) * 1000.0f / (float)fullDrop);
+	}
+
+	strand.placed.push_back(placed);
+
+	_fallingStrand = strandIndex;
+	_fallingStartY = placed.rect.top;
+	_fallingEndY = restOn - held.strandSrc.height() + (overlaps ? _overlapFalling : 0);
+	_fallStartTime = g_nancy->getTotalPlayTime();
+	_fallDuration = MAX<uint32>(duration, 1);
+
+	NancySceneState.setEventFlag(_dropFlag);
+}
+
+void NecklacePuzzle::checkSolved() {
+	for (uint i = 0; i < _strands.size(); ++i) {
+		const Strand &strand = _strands[i];
+		if (strand.placed.size() != strand.solution.size()) {
+			return;
+		}
+
+		// The strand is filled bottom-up, so the design reads from the last shell back.
+		for (uint j = 0; j < strand.solution.size(); ++j) {
+			const PlacedBead &placed = strand.placed[strand.placed.size() - 1 - j];
+			if (_beads[placed.bead].tableIndex != (int16)strand.solution[j]) {
+				return;
+			}
+		}
+	}
+
+	_solved = true;
+}
+
+// Everything on the strands, and anything on the cursor, goes back in the box when
+// the puzzle is left unfinished.
+void NecklacePuzzle::refundBeads() {
+	if (!_refundOnExit || _solved || !g_nancy) {
+		return;
+	}
+
+	for (uint i = 0; i < _strands.size(); ++i) {
+		Strand &strand = _strands[i];
+		for (uint j = 0; j < strand.placed.size(); ++j) {
+			adjustBeadCount(_beads[strand.placed[j].bead], 1);
+		}
+		strand.placed.clear();
+	}
+
+	if (_heldBead != kNoBead) {
+		adjustBeadCount(_beads[_heldBead], 1);
+		_heldBead = kNoBead;
+	}
+
+	_fallingStrand = -1;
+}
+
+void NecklacePuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	if (block.names.empty()) {
+		return;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return;
+	}
+
+	SoundDescription desc;
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+}
+
+void NecklacePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+
+	case kRun:
+		if (_exitRequested || _solved) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_fallingStrand >= 0) {
+			Strand &strand = _strands[_fallingStrand];
+			PlacedBead &falling = strand.placed.back();
+
+			uint32 elapsed = g_nancy->getTotalPlayTime() - _fallStartTime;
+			int y = _fallingEndY;
+			if (elapsed < _fallDuration) {
+				y = _fallingStartY + (int)((int64)(_fallingEndY - _fallingStartY) * elapsed / _fallDuration);
+			}
+
+			if (y != falling.rect.top) {
+				falling.rect.moveTo(falling.rect.left, y);
+				redraw();
+			}
+
+			if (elapsed >= _fallDuration) {
+				_fallingStrand = -1;
+				checkSolved();
+				redraw();
+			}
+		}
+
+		break;
+
+	case kActionTrigger:
+		refundBeads();
+
+		if (_solved) {
+			playSoundBlock(_solvedSound);
+			if (_solvedScene.sceneID != kNoScene) {
+				NancySceneState.changeScene(_solvedScene);
+			}
+		} else {
+			playSoundBlock(_unsolvedSound);
+			NancySceneState.setEventFlag(_exitFlag);
+			NancySceneState.changeScene(_unsolvedScene.sceneID != kNoScene ? _unsolvedScene : _exitScene);
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void NecklacePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _solved || _exitRequested) {
+		return;
+	}
+
+	_heldBeadObject.handleInput(input);
+
+	// Nothing can be clicked while a shell is still sliding down a strand.
+	if (_fallingStrand >= 0) {
+		return;
+	}
+
+	Common::Rect vpScreen = NancySceneState.getViewport().getScreenPosition();
+	Common::Point mouseVP = input.mousePos - Common::Point(vpScreen.left, vpScreen.top);
+	const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+	Hover hover = hitTest(mouseVP);
+
+	if (_heldBead != kNoBead) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_heldCursorType, true,
+			hover.strand >= 0 || hover.bead >= 0);
+	} else if (hover.strand >= 0 || hover.bead >= 0) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
+	}
+
+	if (click && hover.strand >= 0) {
+		Strand &strand = _strands[hover.strand];
+
+		if (_heldBead != kNoBead) {
+			// A swap takes the shell that was on top and hands it straight back.
+			int16 displaced = kNoBead;
+			if (hover.swapTop) {
+				displaced = strand.placed.back().bead;
+				strand.placed.pop_back();
+			}
+
+			dropBeadOn(hover.strand);
+			releaseBead();
+
+			if (displaced != kNoBead) {
+				holdBead(displaced, &input);
+			}
+		} else {
+			// Take the top shell back off the strand.
+			int16 bead = strand.placed.back().bead;
+			strand.placed.pop_back();
+			holdBead(bead, &input);
+		}
+
+		redraw();
+		input.eatMouseInput();
+		return;
+	}
+
+	if (click && hover.bead >= 0) {
+		const int16 wasHeld = _heldBead;
+
+		if (wasHeld != kNoBead) {
+			adjustBeadCount(_beads[wasHeld], 1);
+			releaseBead();
+		}
+
+		// Clicking the shell already on the cursor just puts it back.
+		if (wasHeld != hover.bead) {
+			adjustBeadCount(_beads[hover.bead], -1);
+			holdBead((int16)hover.bead, &input);
+			playSoundBlock(_pickUpSound);
+		}
+
+		redraw();
+		input.eatMouseInput();
+		return;
+	}
+
+	if (click && _heldBead != kNoBead) {
+		adjustBeadCount(_beads[_heldBead], 1);
+		releaseBead();
+		redraw();
+		input.eatMouseInput();
+		return;
+	}
+
+	if (!_exitHotspot.isEmpty() && _exitHotspot.contains(mouseVP)) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, false);
+		if (click) {
+			_exitRequested = true;
+		}
+	}
+}
+
+void NecklacePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (uint i = 0; i < _beads.size(); ++i) {
+		const Bead &bead = _beads[i];
+		const int typeIndex = findBeadType(bead.type);
+		if (typeIndex < 0) {
+			continue;
+		}
+
+		const int image = _beadTypes[typeIndex].boxImage;
+		if (image >= 0 && !bead.boxSrc.isEmpty() && _images[image].getBounds().contains(bead.boxSrc)) {
+			_drawSurface.blitFrom(_images[image], bead.boxSrc, Common::Point(bead.boxDest.left, bead.boxDest.top));
+		}
+
+		drawBeadCount(bead);
+	}
+
+	for (uint i = 0; i < _strands.size(); ++i) {
+		const Strand &strand = _strands[i];
+		for (uint j = 0; j < strand.placed.size(); ++j) {
+			const PlacedBead &placed = strand.placed[j];
+			const Bead &bead = _beads[placed.bead];
+			const int typeIndex = findBeadType(bead.type);
+			if (typeIndex < 0) {
+				continue;
+			}
+
+			const int image = _beadTypes[typeIndex].strandImage;
+			if (image >= 0 && !bead.strandSrc.isEmpty() && _images[image].getBounds().contains(bead.strandSrc)) {
+				_drawSurface.blitFrom(_images[image], bead.strandSrc, Common::Point(placed.rect.left, placed.rect.top));
+			}
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+// How many of this shell are still in the box, drawn next to its box slot.
+void NecklacePuzzle::drawBeadCount(const Bead &bead) {
+	if (bead.tableIndex == kNoTableIndex) {
+		return;
+	}
+
+	const Graphics::Font *font = g_nancy->_graphics->getFont(_countFontID);
+	if (!font) {
+		font = g_nancy->_graphics->getFont(0);
+	}
+	if (!font) {
+		return;
+	}
+
+	Common::String str = Common::String::format("%d", beadCount(bead));
+	int w = font->getStringWidth(str);
+	if (w <= 0) {
+		return;
+	}
+
+	font->drawString(&_drawSurface, str, bead.boxDest.left + _countTextX,
+		bead.boxDest.top + _countTextY + _countTextExtraY, w, 0);
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/necklacepuzzle.h b/engines/nancy/action/puzzle/necklacepuzzle.h
new file mode 100644
index 00000000000..8bbc6a85da8
--- /dev/null
+++ b/engines/nancy/action/puzzle/necklacepuzzle.h
@@ -0,0 +1,183 @@
+/* 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_NECKLACEPUZZLE_H
+#define NANCY_ACTION_NECKLACEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/misc/mousefollow.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy15 rewrote AR 243 into a shell-stringing puzzle: a box of shells on one
+// side, and a set of strands hanging from needles on the other. A shell is taken
+// from the box onto the cursor and dropped on a needle, where it slides down the
+// strand and stacks on the ones already there. Every strand has to end up holding
+// the exact sequence the design calls for.
+//
+// How many of each shell are left is not kept in the record; it lives in the
+// shared table values, so the box counts survive leaving and re-entering. Shells
+// left on a strand are handed back when the puzzle is abandoned unsolved.
+class NecklacePuzzle : public RenderActionRecord {
+public:
+	NecklacePuzzle() : RenderActionRecord(7) {}
+	virtual ~NecklacePuzzle();
+
+	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 "NecklacePuzzle"; }
+
+	static const int16 kNoBead = -1;
+	static const int16 kNoTableIndex = 255;
+
+	// A kind of shell. All shells of a kind share their three sprite sheets: one
+	// for the cursor, one for the strand, one for the box.
+	struct BeadType {
+		int32 key = 0;
+		bool overlaps = false;		// this kind sits into the one below it
+		int cursorImage = -1;		// indices into _images
+		int strandImage = -1;
+		int boxImage = -1;
+	};
+
+	struct Bead {
+		byte type = 0;				// BeadType::key
+		Common::Rect cursorSrc;
+		Common::Rect strandSrc;
+		Common::Rect boxSrc;
+		Common::Rect boxDest;		// also the hotspot for taking one out of the box
+		int16 tableIndex = kNoTableIndex;	// table value holding how many are left
+	};
+
+	struct PlacedBead {
+		int16 bead = kNoBead;
+		Common::Rect rect;
+	};
+
+	struct Strand {
+		bool grabByTopBead = false;	// take the top shell back by clicking it, not the needle
+		bool allowSwap = false;		// a full strand lets its top shell be replaced
+		Common::Array<byte> allowedTypes;
+		Common::Array<uint16> solution;	// required table indices, top of the strand first
+		Common::Rect needleHotspot;
+		Common::Rect strandRect;
+		float dropSeconds = 0.0f;	// time a shell takes to slide the strand's full length
+
+		Common::Array<PlacedBead> placed;
+	};
+
+	// What the cursor is over. Only one of the two is ever set.
+	struct Hover {
+		int strand = -1;
+		int bead = -1;
+		bool swapTop = false;		// dropping here replaces the strand's top shell
+	};
+
+	int findBeadType(byte key) const;
+	bool strandAccepts(const Strand &strand, byte type) const;
+
+	// Y the next shell dropped on `strand` comes to rest against, and the same one
+	// level further down (used to decide whether a full strand can still swap).
+	int stackTop(const Strand &strand) const;
+	int stackTopBelow(const Strand &strand) const;
+
+	Hover hitTest(const Common::Point &mousePos) const;
+
+	int16 beadCount(const Bead &bead) const;
+	void adjustBeadCount(const Bead &bead, int16 delta);
+
+	void holdBead(int16 bead, NancyInput *input);
+	void releaseBead();
+	void dropBeadOn(int strand);
+	void checkSolved();
+	void refundBeads();
+
+	void playSoundBlock(const RandomSoundBlock &block);
+	void redraw();
+	void drawBeadCount(const Bead &bead);
+
+	// -- File data --
+	bool _refundOnExit = false;
+	int32 _overlapPlaced = 0;		// taken off an overlapping shell's width on a strand
+	int32 _overlapFalling = 0;		// how far an overlapping shell sinks into the one below
+	FlagDescription _dropFlag;		// set every time a shell is strung
+	uint16 _heldCursorType = 0;
+	uint16 _hoverCursorType = 0;
+	int32 _heldOffsetX = 0;			// where the held shell sits relative to the cursor
+	int32 _heldOffsetY = 0;
+	uint16 _countFontID = 0;
+	uint16 _unknownA0 = 0;
+	uint16 _countTextExtraY = 0;
+	int32 _countTextX = 0;			// offset from the box rect's top left
+	int32 _countTextY = 0;
+
+	Common::Array<Common::Path> _imageNames;
+	Common::Array<BeadType> _beadTypes;
+	Common::Array<Bead> _beads;
+	Common::Array<Strand> _strands;
+
+	RandomSoundBlock _pickUpSound;
+	RandomSoundBlock _putDownSound;
+	RandomSoundBlock _unknownSound;	// no known trigger; "NO SOUND" in the shipped record
+
+	SceneChangeDescription _solvedScene;
+	RandomSoundBlock _solvedSound;
+
+	// Played when the puzzle is left unfinished. Its scene is 9999 in the shipped
+	// record, so the exit hotspot's own scene is what the player ends up in.
+	SceneChangeDescription _unsolvedScene;
+	RandomSoundBlock _unsolvedSound;
+
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+	FlagDescription _exitFlag;
+
+	// -- Runtime state --
+	Common::Array<Graphics::ManagedSurface> _images;
+
+	int16 _heldBead = kNoBead;
+	Misc::MouseFollowObject _heldBeadObject;
+
+	int _fallingStrand = -1;
+	int _fallingStartY = 0;
+	int _fallingEndY = 0;
+	uint32 _fallStartTime = 0;
+	uint32 _fallDuration = 0;
+
+	bool _solved = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_NECKLACEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 19b49196c07..26302ea8d74 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -53,6 +53,7 @@ MODULE_OBJS = \
   action/puzzle/mirrorlightpuzzle.o \
   action/puzzle/mouselightpuzzle.o \
   action/puzzle/multibuildpuzzle.o \
+  action/puzzle/necklacepuzzle.o \
   action/puzzle/onebuildpuzzle.o \
   action/puzzle/orderingpuzzle.o \
   action/puzzle/overridelockpuzzle.o \




More information about the Scummvm-git-logs mailing list