[Scummvm-git-logs] scummvm master -> 8bb23229ebd7edc8691b04b25b01c82cca90d3a0

bluegr noreply at scummvm.org
Sat Sep 19 14:04:19 UTC 2026


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

Summary:
0a8c70971c NANCY: NANCY14: Fix the parfait puzzle in BuildPuzzle
4d8f15d068 NANCY: NANCY14: Implement USE chunk includes
0161370283 NANCY: NANCY14: Implement the timer mode of MeterPuzzle
aa71ed5e97 NANCY: NANCY12-15: Fix software timer behavior
ef6328c84e NANCY: NANCY14: Implement the eye grid puzzle (LetterGridPuzzle)
9ca7794b5f NANCY: NANCY14: Fix the data layout of KeypadTersePuzzle
8bb23229eb NANCY: NANCY8+: Fix lowercase letters in Russian fonts


Commit: 0a8c70971cf291c6eda8490f1cfc4aad77720631
    https://github.com/scummvm/scummvm/commit/0a8c70971cf291c6eda8490f1cfc4aad77720631
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-19T17:02:50+03:00

Commit Message:
NANCY: NANCY14: Fix the parfait puzzle in BuildPuzzle

- Draw the sorbet counter in the top right
- Ingredients placed in a zone that marks them placed are locked
- Show the "done" overlay, the whipped cream top, when all
  ingredients are placed

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 7ebb24af643..c55d82fee57 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/buildpuzzle.cpp
@@ -63,7 +63,8 @@ void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
 	readRect(stream, _startOverSrcRect);
 	readRect(stream, _startOverHotspot);
 	readSoundBlock(stream, _startOverSound);
-	stream.skip(32);						// 0x8e: the "done" overlay gate and its rect
+	readRect(stream, _doneSrcRect);
+	readRect(stream, _doneDestRect);
 
 	readFilename(stream, _anim1Name);
 	readRect(stream, _anim1Rect);
@@ -286,6 +287,18 @@ void BuildPuzzle::init() {
 	_buttonPress.setTransparent(true);
 	_buttonPress.setVisible(false);
 
+	if (!_doneSrcRect.isEmpty()) {
+		_doneOverlay._drawSurface.create(_altImage, _doneSrcRect);
+		_doneOverlay.setTransparent(true);
+		_doneOverlay.moveTo(_doneDestRect);
+		// Above the pieces in the zones, below a close-up
+		_doneOverlay.setZOrder(_z + (uint16)_pieces.size() + 1);
+	}
+
+	_doneOverlay.setVisible(false);
+	_counter.setTransparent(true);
+	_counter.setVisible(false);
+
 	if (resume) {
 		restoreState(*data);
 	} else {
@@ -300,6 +313,8 @@ void BuildPuzzle::init() {
 	}
 
 	_isInitialized = true;
+	updateDoneOverlay();
+	updateCounter();
 }
 
 void BuildPuzzle::registerGraphics() {
@@ -317,6 +332,8 @@ void BuildPuzzle::registerGraphics() {
 
 	_cursorItem.registerGraphics();
 	_buttonPress.registerGraphics();
+	_doneOverlay.registerGraphics();
+	_counter.registerGraphics();
 }
 
 byte BuildPuzzle::carriedAmount() const {
@@ -517,6 +534,9 @@ void BuildPuzzle::openCloseup(int16 pieceIdx) {
 	piece.moveTo(dest);
 	piece.setVisible(true);
 	_pieces[pieceIdx].setZOrder((uint16)(_z + _pieces.size() + 2));
+
+	_closeupDest = dest;
+	updateCounter();
 }
 
 void BuildPuzzle::closeCloseup() {
@@ -528,6 +548,7 @@ void BuildPuzzle::closeCloseup() {
 	_closeupPiece = -1;
 	_pieces[pieceIdx].setZOrder((uint16)(_z + pieceIdx + 1));
 	updatePieceRender(pieceIdx);
+	updateCounter();
 }
 
 void BuildPuzzle::pickUpPiece(int16 pieceIdx) {
@@ -540,6 +561,7 @@ void BuildPuzzle::pickUpPiece(int16 pieceIdx) {
 		addItemValue(piece.itemID, -1);
 		setPlacedCount(_placedCount - 1);
 		piece.assignedZone = -1;
+		updateDoneOverlay();
 
 		if (pieceIdx >= (int16)_numDefined) {
 			piece.inUse = false;
@@ -583,6 +605,7 @@ void BuildPuzzle::placePiece(int16 pieceIdx, int16 zoneIdx, const Common::Point
 		}
 
 		_pieces[placedIdx].assignedZone = zoneIdx;
+		_pieces[placedIdx].locked = zone.marksPlaced != 0;
 	}
 
 	Piece &piece = _pieces[placedIdx];
@@ -624,6 +647,7 @@ void BuildPuzzle::placePiece(int16 pieceIdx, int16 zoneIdx, const Common::Point
 	updatePieceRender(placedIdx);
 
 	setPlacedCount(_placedCount + 1);
+	updateDoneOverlay();
 
 	bool solved = checkSolved();
 	setFlagOnChange(_solvedFlag, solved, _lastSolvedFlag);
@@ -738,6 +762,7 @@ void BuildPuzzle::resetPuzzle() {
 	for (uint i = 0; i < _pieces.size(); ++i) {
 		Piece &piece = _pieces[i];
 		piece.assignedZone = -1;
+		piece.locked = false;
 
 		// The copies made while filling the zones go away again.
 		if (i >= _numDefined) {
@@ -755,11 +780,77 @@ void BuildPuzzle::resetPuzzle() {
 	_closeupPiece = -1;
 	_activeHold = -1;
 	setPlacedCount(0);
+	updateDoneOverlay();
 
 	setFlagOnChange(_solvedFlag, false, _lastSolvedFlag);
 	setFlagOnChange(_wrongIngredientFlag, false, _lastWrongFlag);
 }
 
+void BuildPuzzle::updateCounter() {
+	if (_counterItemID == 255 || !_isInitialized) {
+		return;
+	}
+
+	_counter.setVisible(_closeupPiece == -1 || !_closeupDest.contains(_counterPos));
+
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	int16 value = table ? table->getValue(_counterItemID) : 0;
+	if (value == kNoTableValue || value < 0) {
+		value = 0;
+	}
+
+	if (value == _shownCounterValue) {
+		return;
+	}
+
+	_shownCounterValue = value;
+	Common::String digits = Common::String::format("%d", value);
+
+	// The digits hang from the counter position, each the spacing past the last.
+	int16 width = 0;
+	int16 height = 0;
+	for (uint i = 0; i < digits.size(); ++i) {
+		const Common::Rect &src = _digitSrcRects[digits[i] - '0'];
+		if (i > 0) {
+			width += (int16)_counterSpacing;
+		}
+
+		width += src.width();
+		height = MAX<int16>(height, src.height());
+	}
+
+	const uint32 transColor = g_nancy->_graphics->getTransColor();
+	_counter._drawSurface.create(width, height, _image.format);
+	_counter._drawSurface.clear(transColor);
+	_counter._drawSurface.setTransparentColor(transColor);
+
+	int16 x = 0;
+	for (uint i = 0; i < digits.size(); ++i) {
+		const Common::Rect &src = _digitSrcRects[digits[i] - '0'];
+		_counter._drawSurface.blitFrom(_image, src, Common::Point(x, 0));
+		x += src.width() + (int16)_counterSpacing;
+	}
+
+	_counter.moveTo(Common::Rect(_counterPos.x, _counterPos.y, _counterPos.x + width, _counterPos.y + height));
+	_counter.setNeedsRedraw(true);
+}
+
+void BuildPuzzle::updateDoneOverlay() {
+	if (_doneSrcRect.isEmpty()) {
+		return;
+	}
+
+	bool allFull = true;
+	for (uint i = 0; i < _zones.size(); ++i) {
+		if (_zones[i].numHeld < (int16)_zones[i].capacity) {
+			allFull = false;
+			break;
+		}
+	}
+
+	_doneOverlay.setVisible(allFull);
+}
+
 void BuildPuzzle::saveState() {
 	BuildPuzzleData *data = (BuildPuzzleData *)NancySceneState.getPuzzleData(BuildPuzzleData::getTag());
 	assert(data);
@@ -813,6 +904,8 @@ void BuildPuzzle::restoreState(const BuildPuzzleData &data) {
 
 		Piece &piece = _pieces[pieceIdx];
 		piece.assignedZone = data.pieces[i + 1];
+		piece.locked = piece.assignedZone >= 0 && piece.assignedZone < (int16)_zones.size() &&
+						_zones[piece.assignedZone].marksPlaced != 0;
 		piece.liveRect = Common::Rect(data.pieces[i + 2], data.pieces[i + 3], data.pieces[i + 4], data.pieces[i + 5]);
 		updatePieceRender(pieceIdx);
 	}
@@ -842,6 +935,8 @@ void BuildPuzzle::execute() {
 		_state = kRun;
 		break;
 	case kRun:
+		updateCounter();
+
 		if (_heldButton != kNoButton && g_system->getMillis() >= _buttonTimerEnd) {
 			HeldButton button = _heldButton;
 			_heldButton = kNoButton;
@@ -987,7 +1082,7 @@ void BuildPuzzle::handleInput(NancyInput &input) {
 	int16 hovered = -1;
 	for (uint i = 0; i < _pieces.size(); ++i) {
 		const Piece &piece = _pieces[i];
-		if (!piece.inUse || !piece.liveRect.contains(mouseVP)) {
+		if (!piece.inUse || piece.locked || !piece.liveRect.contains(mouseVP)) {
 			continue;
 		}
 
diff --git a/engines/nancy/action/puzzle/buildpuzzle.h b/engines/nancy/action/puzzle/buildpuzzle.h
index f61893489ad..c237dea73e8 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.h
+++ b/engines/nancy/action/puzzle/buildpuzzle.h
@@ -37,7 +37,7 @@ namespace Action {
 // being assigned a zone index rather than by matching a rect.
 class BuildPuzzle : public RenderActionRecord {
 public:
-	BuildPuzzle() : RenderActionRecord(7), _buttonPress(98), _cursorItem(99) {}
+	BuildPuzzle() : RenderActionRecord(7), _doneOverlay(0), _counter(97), _buttonPress(98), _cursorItem(99) {}
 	virtual ~BuildPuzzle() {}
 
 	void init() override;
@@ -123,6 +123,7 @@ protected:
 		int16 sourceID = -1;		// index of the definition this piece was cloned from
 		int16 assignedZone = -1;
 		bool inUse = false;			// false for the spare slots kept for clones
+		bool locked = false;		// kept by a zone that marks its pieces placed
 	};
 
 	Common::Path _imageName;
@@ -169,6 +170,10 @@ protected:
 	Common::Rect _startOverHotspot;
 	SoundDescription _startOverSound;
 
+	// Drawn over the zones once every one of them is full.
+	Common::Rect _doneSrcRect;
+	Common::Rect _doneDestRect;
+
 	// With _saveState set, the board is saved after every drop. It is restored when
 	// the puzzle's scene runs again while _resumeFlag is set.
 	byte _saveState = 0;
@@ -206,6 +211,10 @@ protected:
 	};
 	HeldButton _heldButton = kNoButton;
 	uint32 _buttonTimerEnd = 0;
+	RenderObject _doneOverlay;
+	RenderObject _counter;
+	int16 _shownCounterValue = -1;
+	Common::Rect _closeupDest;
 	RenderObject _buttonPress;
 
 	int16 _placedCount = 0;
@@ -255,6 +264,9 @@ protected:
 	void takeOutcome();
 	// Empty every zone and put all the pieces back
 	void resetPuzzle();
+	// Redraw the count when its value changes; a close-up over it hides it
+	void updateCounter();
+	void updateDoneOverlay();
 	// Save the board, and put it back when the puzzle resumes
 	void saveState();
 	void restoreState(const BuildPuzzleData &data);


Commit: 4d8f15d068e214f808e58cd221b7cee071cc3215
    https://github.com/scummvm/scummvm/commit/4d8f15d068e214f808e58cd221b7cee071cc3215
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-19T17:02:53+03:00

Commit Message:
NANCY: NANCY14: Implement USE chunk includes

Nancy14 and later games can name other script files in a USE chunk of
an IFF, so that action records shared by many scenes can be defined
once. For example, all sewer scenes in Nancy14 include CAT_METER, which
holds the oxygen meter.

Like the original, only the ACT chunks of an included file are merged
in. Each one gets an ordering key of the USE chunk's key plus a tenth
of its own key, so an included file with ten or more chunks interleaves
with the chunks following the USE chunk.

Also add the console commands list_includes and find_include, show
which include each action record came from in list_actionrecords, and
allow list_actionrecords to list the records of an included script by
name.

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


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index d95daa58bc0..990b03f5c9a 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -132,7 +132,7 @@ static bool isTimerDurationDependency(DependencyType type) {
 		(type == DependencyType::kTimerLessThanDependencyTime || type == DependencyType::kTimerGreaterThanDependencyTime);
 }
 
-void ActionManager::addNewActionRecord(Common::SeekableReadStream &inputData) {
+void ActionManager::addNewActionRecord(Common::SeekableReadStream &inputData, const Common::String &includeSource) {
 	ActionRecord *newRecord = createAndLoadNewRecord(inputData);
 	if (!newRecord) {
 		inputData.seek(0x30);
@@ -141,6 +141,7 @@ void ActionManager::addNewActionRecord(Common::SeekableReadStream &inputData) {
 		warning("Action Record type %i is unimplemented or invalid!", ARType);
 		return;
 	}
+	newRecord->_includeSource = includeSource;
 	_records.push_back(newRecord);
 }
 
diff --git a/engines/nancy/action/actionmanager.h b/engines/nancy/action/actionmanager.h
index 25735c9a679..eea85bf937f 100644
--- a/engines/nancy/action/actionmanager.h
+++ b/engines/nancy/action/actionmanager.h
@@ -59,7 +59,7 @@ public:
 	void processActionRecords();
 	void processDependency(DependencyRecord &dep, ActionRecord &record, bool doNotCheckCursor);
 
-	void addNewActionRecord(Common::SeekableReadStream &inputData);
+	void addNewActionRecord(Common::SeekableReadStream &inputData, const Common::String &includeSource = Common::String());
 	Common::Array<ActionRecord *> &getActionRecords() { return _records; }
 	ActionRecord *getActionRecord(uint id) { if (id < _records.size()) return _records[id]; else return nullptr;}
 	void clearActionRecords(bool nextIsNoArt = false);
diff --git a/engines/nancy/action/actionrecord.h b/engines/nancy/action/actionrecord.h
index 9034ab20c75..15d0b8ef48a 100644
--- a/engines/nancy/action/actionrecord.h
+++ b/engines/nancy/action/actionrecord.h
@@ -148,6 +148,7 @@ protected:
 
 public:
 	Common::String _description;
+	Common::String _includeSource; // the included file this record came from, empty for the scene's own
 	byte _type;
 	ExecutionType _execType;
 	// 0x32 data
diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index 9bed7b359b8..ecb71740011 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -22,6 +22,9 @@
 #include "common/system.h"
 #include "common/events.h"
 #include "common/config-manager.h"
+#include "common/hash-str.h"
+#include "common/hashmap.h"
+#include "common/util.h"
 
 #include "audio/audiostream.h"
 #include "image/bmp.h"
@@ -60,6 +63,8 @@ NancyConsole::NancyConsole() : GUI::Debugger() {
 	registerCmd("list_actionrecords", WRAP_METHOD(NancyConsole, Cmd_listActionRecords));
 	registerCmd("actionrecord_export", WRAP_METHOD(NancyConsole, Cmd_actionRecordExport));
 	registerCmd("scan_ar_type", WRAP_METHOD(NancyConsole, Cmd_scanForActionRecordType));
+	registerCmd("list_includes", WRAP_METHOD(NancyConsole, Cmd_listIncludes));
+	registerCmd("find_include", WRAP_METHOD(NancyConsole, Cmd_findInclude));
 	registerCmd("get_eventflags", WRAP_METHOD(NancyConsole, Cmd_getEventFlags));
 	registerCmd("set_eventflags", WRAP_METHOD(NancyConsole, Cmd_setEventFlags));
 	registerCmd("get_inventory", WRAP_METHOD(NancyConsole, Cmd_getInventory));
@@ -629,21 +634,29 @@ bool NancyConsole::Cmd_listActionRecords(int argc, const char **argv) {
 
 		for (uint i = 0; i < records.size(); ++i) {
 			ActionRecord *rec = records[i];
-			debugPrintf("Record %u:\n", i);
+			if (rec->_includeSource.empty()) {
+				debugPrintf("Record %u:\n", i);
+			} else {
+				debugPrintf("Record %u (from %s):\n", i, rec->_includeSource.c_str());
+			}
 			printActionRecord(rec);
 			debugPrintf("\n\n");
 		}
 	} else if (argc == 2) {
-		// Print a different scene. We need to load all records into a temporary array and read from it
+		// Print a different scene, or an included script given by name. We need to
+		// load all records into a temporary array and read from it
 		Common::String s = argv[1];
+		if (Common::isDigit(s.firstChar())) {
+			s = "S" + s;
+		}
 
 		Common::Array<ActionRecord *> records;
 		Common::Queue<uint> unknownTypes;
 		Common::Queue<Common::String> unknownDescs;
 		Common::SeekableReadStream *chunk;
-		IFF *sceneIFF = g_nancy->_resource->loadIFF(Common::Path("S" + s));
+		IFF *sceneIFF = g_nancy->_resource->loadIFF(Common::Path(s));
 		if (!sceneIFF) {
-			debugPrintf("Invalid scene S%s\n", argv[1]);
+			debugPrintf("Invalid scene or script %s\n", s.c_str());
 			return true;
 		}
 
@@ -664,7 +677,12 @@ bool NancyConsole::Cmd_listActionRecords(int argc, const char **argv) {
 
 		for (uint i = 0; i < records.size(); ++i) {
 			ActionRecord *rec = records[i];
-			debugPrintf("Record %u:\n", i);
+			Common::String source = sceneIFF->getChunkSource("ACT", i);
+			if (source.empty()) {
+				debugPrintf("Record %u:\n", i);
+			} else {
+				debugPrintf("Record %u (from %s):\n", i, source.c_str());
+			}
 
 			if (rec == nullptr) {
 				// For unknown record types, we want to print the typeID and description
@@ -750,6 +768,40 @@ bool NancyConsole::Cmd_actionRecordExport(int argc, const char **argv) {
 	return true;
 }
 
+// The name of an IFF, without any .iff extension
+static Common::String getIFFName(const Common::Path &path) {
+	Common::String name = path.baseName();
+	if (name.hasSuffixIgnoreCase(".iff")) {
+		name = name.substr(0, name.size() - 4);
+	}
+
+	return name;
+}
+
+// Lists all scene IFFs (S#, S##, ...) in the ciftree, the promotree, and loose .iff files
+static void listSceneIFFs(Common::Array<Common::Path> &sceneList) {
+	Common::Array<Common::Path> list;
+	// Action records only appear in the ciftree and promotree
+	g_nancy->_resource->list("ciftree", list, CifInfo::kResTypeScript);
+	g_nancy->_resource->list("promotree", list, CifInfo::kResTypeScript);
+
+	Common::ArchiveMemberList searchManList;
+	SearchMan.listMatchingMembers(searchManList, "*.iff");
+	for (auto &i : searchManList) {
+		list.push_back(i->getPathInArchive());
+	}
+
+	for (Common::Path &path : list) {
+		Common::String name = getIFFName(path);
+		if (name.matchString("S#") ||
+			name.matchString("S##") ||
+			name.matchString("S###") ||
+			name.matchString("S####")) {
+			sceneList.push_back(path);
+		}
+	}
+}
+
 bool NancyConsole::Cmd_scanForActionRecordType(int argc, const char **argv) {
 	if (argc < 2 || argc % 2) {
 		debugPrintf("Scans all IFFs for ActionRecords of the provided type\n");
@@ -786,63 +838,151 @@ bool NancyConsole::Cmd_scanForActionRecordType(int argc, const char **argv) {
 	}
 
 	Common::Array<Common::Path> list;
-	// Action records only appear in the ciftree and promotree
-	g_nancy->_resource->list("ciftree", list, CifInfo::kResTypeScript);
-	g_nancy->_resource->list("promotree", list, CifInfo::kResTypeScript);
+	listSceneIFFs(list);
 
 	char descBuf[0x30];
 
-	Common::ArchiveMemberList searchManList;
-	SearchMan.listMatchingMembers(searchManList, "*.iff");
-	for (auto &i : searchManList) {
-		list.push_back(i->getPathInArchive());
+	for (Common::Path &cifName : list) {
+		IFF *iff = g_nancy->_resource->loadIFF(cifName);
+		if (!iff) {
+			continue;
+		}
+
+		uint num = 0;
+		Common::SeekableReadStream *chunk = nullptr;
+		while (chunk = iff->getChunkStream("ACT", num), chunk != nullptr) {
+			bool isSatisfied = true;
+			for (uint i = 0; i < vals.size(); i += 2) {
+				if ((int64)vals[i] >= chunk->size()) {
+					isSatisfied = false;
+					break;
+				}
+
+				chunk->seek(vals[i]);
+				if (chunk->readByte() != vals[i + 1]) {
+					isSatisfied = false;
+					break;
+				}
+			}
+
+			if (isSatisfied) {
+				chunk->seek(0);
+				chunk->read(descBuf, 0x30);
+				descBuf[0x2F] = '\0';
+
+				Common::String source = iff->getChunkSource("ACT", num);
+				if (source.empty()) {
+					debugPrintf("%s: ACT chunk %u, %s\n", cifName.toString().c_str(), num, descBuf);
+				} else {
+					debugPrintf("%s: ACT chunk %u, %s (from %s)\n", cifName.toString().c_str(), num, descBuf, source.c_str());
+				}
+			}
+
+			++num;
+			delete chunk;
+		}
+
+		delete iff;
+	}
+
+	return true;
+}
+
+bool NancyConsole::Cmd_listIncludes(int argc, const char **argv) {
+	if (argc > 2) {
+		debugPrintf("Lists the files included (via USE chunks) by the current or a specified scene\n");
+		debugPrintf("Usage: %s [sceneID]\n", argv[0]);
+		return true;
 	}
 
+	uint sceneID = 0;
+	if (argc == 2) {
+		sceneID = atoi(argv[1]);
+	} else if (g_nancy->getState() == NancyState::kScene) {
+		sceneID = NancySceneState.getSceneInfo().sceneID;
+	} else {
+		debugPrintf("Not in the kScene state\n");
+		return true;
+	}
+
+	IFF *sceneIFF = g_nancy->_resource->loadIFF(Common::Path(Common::String::format("S%u", sceneID)));
+	if (!sceneIFF) {
+		debugPrintf("Invalid scene S%u\n", sceneID);
+		return true;
+	}
+
+	const Common::Array<Common::String> &includes = sceneIFF->getIncludes();
+	if (includes.empty()) {
+		debugPrintf("Scene S%u has no includes\n", sceneID);
+	} else {
+		debugPrintf("Scene S%u includes:\n", sceneID);
+		for (const Common::String &include : includes) {
+			debugPrintf("\t%s\n", include.c_str());
+		}
+	}
+
+	delete sceneIFF;
+	return true;
+}
+
+bool NancyConsole::Cmd_findInclude(int argc, const char **argv) {
+	if (argc > 2) {
+		debugPrintf("Lists the scenes that include (via USE chunks) the given file,\n");
+		debugPrintf("or every included file and the scenes using it if none is given\n");
+		debugPrintf("Warning: can be quite slow, especially on archived game versions\n");
+		debugPrintf("Usage: %s [filename]\n", argv[0]);
+		return true;
+	}
+
+	Common::Array<Common::Path> list;
+	listSceneIFFs(list);
+
+	// Included file name -> including scenes, in first-seen order
+	Common::Array<Common::String> includeNames;
+	Common::HashMap<Common::String, Common::Array<Common::String>, Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> users;
+
 	for (Common::Path &cifName : list) {
-		Common::String name = cifName.baseName();
-		if (name.hasSuffixIgnoreCase(".iff")) {
-			name = name.substr(0, name.size() - 4);
+		IFF *iff = g_nancy->_resource->loadIFF(cifName);
+		if (!iff) {
+			continue;
 		}
 
-		// Only check inside scenes
-		if (name.matchString("S#") ||
-			name.matchString("S##") ||
-			name.matchString("S###") ||
-			name.matchString("S####")) {
+		for (const Common::String &include : iff->getIncludes()) {
+			if (argc == 2 && !include.equalsIgnoreCase(argv[1])) {
+				continue;
+			}
 
-			IFF *iff = g_nancy->_resource->loadIFF(cifName);
-			if (iff) {
-				uint num = 0;
-				Common::SeekableReadStream *chunk = nullptr;
-				while (chunk = iff->getChunkStream("ACT", num), chunk != nullptr) {
-					bool isSatisfied = true;
-					for (uint i = 0; i < vals.size(); i += 2) {
-						if ((int64)vals[i] >= chunk->size()) {
-							isSatisfied = false;
-							break;
-						}
-
-						chunk->seek(vals[i]);
-						if (chunk->readByte() != vals[i + 1]) {
-							isSatisfied = false;
-							break;
-						}
-					}
+			if (!users.contains(include)) {
+				includeNames.push_back(include);
+			}
 
-					if (isSatisfied) {
-						chunk->seek(0);
-						chunk->read(descBuf, 0x30);
-						descBuf[0x2F] = '\0';
-						debugPrintf("%s: ACT chunk %u, %s\n", cifName.toString().c_str(), num, descBuf);
-					}
+			users[include].push_back(getIFFName(cifName));
+		}
 
-					++num;
-					delete chunk;
-				}
+		delete iff;
+	}
 
-				delete iff;
+	if (includeNames.empty()) {
+		if (argc == 2) {
+			debugPrintf("No scene includes %s\n", argv[1]);
+		} else {
+			debugPrintf("No scene includes any files\n");
+		}
+
+		return true;
+	}
+
+	for (const Common::String &include : includeNames) {
+		const Common::Array<Common::String> &scenes = users[include];
+		debugPrintf("%s is included by %u scene(s):\n", include.c_str(), scenes.size());
+		for (uint i = 0; i < scenes.size(); ++i) {
+			debugPrintf("%-7s", scenes[i].c_str());
+			if ((i % 10) == 9 && i + 1 != scenes.size()) {
+				debugPrintf("\n");
 			}
 		}
+
+		debugPrintf("\n\n");
 	}
 
 	return true;
diff --git a/engines/nancy/console.h b/engines/nancy/console.h
index 9be301cf610..8645d24ce4e 100644
--- a/engines/nancy/console.h
+++ b/engines/nancy/console.h
@@ -58,6 +58,8 @@ private:
 	bool Cmd_listActionRecords(int argc, const char **argv);
 	bool Cmd_actionRecordExport(int argc, const char **argv);
 	bool Cmd_scanForActionRecordType(int argc, const char **argv);
+	bool Cmd_listIncludes(int argc, const char **argv);
+	bool Cmd_findInclude(int argc, const char **argv);
 	bool Cmd_getEventFlags(int argc, const char **argv);
 	bool Cmd_setEventFlags(int argc, const char **argv);
 	bool Cmd_getInventory(int argc, const char **argv);
diff --git a/engines/nancy/iff.cpp b/engines/nancy/iff.cpp
index dc8b8e95923..ad63e103e65 100644
--- a/engines/nancy/iff.cpp
+++ b/engines/nancy/iff.cpp
@@ -25,6 +25,7 @@
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/iff.h"
 #include "engines/nancy/resource.h"
+#include "engines/nancy/util.h"
 
 namespace Nancy {
 
@@ -51,6 +52,8 @@ IFF::IFF(Common::SeekableReadStream *stream) {
 
 	delete _stream;
 	_stream = nullptr;
+
+	resolveIncludes();
 }
 
 IFF::~IFF() {
@@ -85,11 +88,80 @@ bool IFF::callback(Common::IFFChunk &c) {
 		error("IFF::callback: error reading '%s' chunk", idToString(chunk.id).c_str());
 
 	debugN(3, "IFF::callback: Adding '%s' chunk\n", idToString(chunk.id).c_str());
+	chunk.key = _chunks.size();
 	_chunks.push_back(chunk);
 
 	return false;
 }
 
+// A USE chunk (Nancy14+) names other IFF files whose action records get merged
+// in, so e.g. shared action records can be reused by many scenes. Every chunk
+// has an ordering key: an IFF's own chunks are numbered 0, 1, 2... and an
+// included ACT chunk gets the USE chunk's key plus a tenth of its own key. So
+// the records of an include with ten or more chunks interleave with the chunks
+// following the USE chunk, and several includes interleave with each other.
+void IFF::resolveIncludes() {
+	const uint32 useID = stringToId("USE");
+	const uint32 actID = stringToId("ACT");
+
+	// Collect the USE chunks first, since inserting shifts the chunk indices
+	Common::Array<uint> useChunks;
+	for (uint i = 0; i < _chunks.size(); ++i) {
+		if (_chunks[i].id == useID) {
+			useChunks.push_back(i);
+		}
+	}
+
+	Common::Array<Chunk> includedChunks;
+	for (uint useChunk : useChunks) {
+		const Chunk &use = _chunks[useChunk];
+		Common::MemoryReadStream useStream(use.buf, use.size);
+		uint16 numIncludes = useStream.readUint16LE();
+
+		for (uint i = 0; i < numIncludes; ++i) {
+			Common::String includeName;
+			readFilename(useStream, includeName);
+			_includes.push_back(includeName);
+
+			IFF *include = g_nancy->_resource->loadIFF(Common::Path(includeName));
+			if (!include) {
+				warning("IFF::resolveIncludes: failed to load included file '%s'", includeName.c_str());
+				continue;
+			}
+
+			// Only action records are included. Their buffers change owner
+			for (Chunk &chunk : include->_chunks) {
+				if (chunk.id != actID) {
+					continue;
+				}
+
+				Chunk included = chunk;
+				included.key = chunk.key * 0.1 + use.key;
+
+				// Chunks from nested includes keep their innermost source
+				if (included.source.empty()) {
+					included.source = includeName;
+				}
+
+				includedChunks.push_back(included);
+				chunk.buf = nullptr;
+			}
+
+			delete include;
+		}
+	}
+
+	// Insert after all chunks with a lower or equal key
+	for (const Chunk &chunk : includedChunks) {
+		uint pos = _chunks.size();
+		while (pos > 0 && _chunks[pos - 1].key > chunk.key) {
+			--pos;
+		}
+
+		_chunks.insert_at(pos, chunk);
+	}
+}
+
 const byte *IFF::getChunk(uint32 id, uint &size, uint index) const {
 	uint found = 0;
 	for (uint i = 0; i < _chunks.size(); ++i) {
@@ -106,6 +178,21 @@ const byte *IFF::getChunk(uint32 id, uint &size, uint index) const {
 	return nullptr;
 }
 
+Common::String IFF::getChunkSource(const Common::String &id, uint index) const {
+	uint32 chunkID = stringToId(id);
+	uint found = 0;
+	for (const Chunk &chunk : _chunks) {
+		if (chunk.id == chunkID) {
+			if (found == index) {
+				return chunk.source;
+			}
+			++found;
+		}
+	}
+
+	return Common::String();
+}
+
 Common::SeekableReadStream *IFF::getChunkStream(const Common::String &id, uint index) const {
 	uint size;
 	const byte *chunk = getChunk(stringToId(id), size, index);
diff --git a/engines/nancy/iff.h b/engines/nancy/iff.h
index 7402724f4c8..9db5069a47f 100644
--- a/engines/nancy/iff.h
+++ b/engines/nancy/iff.h
@@ -23,6 +23,7 @@
 #define NANCY_IFF_H
 
 #include "common/array.h"
+#include "common/str.h"
 
 namespace Common {
 struct IFFChunk;
@@ -41,20 +42,29 @@ public:
 
 	void list(Common::Array<Common::String> &nameList) const;
 
+	// The files named in this IFF's USE chunks
+	const Common::Array<Common::String> &getIncludes() const { return _includes; }
+	// The included file a chunk came from; empty for the IFF's own chunks
+	Common::String getChunkSource(const Common::String &id, uint index = 0) const;
+
 private:
 	static Common::String idToString(uint32 id);
 	static uint32 stringToId(const Common::String &s);
 
 	bool callback(Common::IFFChunk &chunk);
+	void resolveIncludes();
 
 	struct Chunk {
 		uint32 id;
 		byte *buf;
 		uint32 size;
+		double key;				// ordering key, see resolveIncludes()
+		Common::String source;
 	};
 
 	const Common::SeekableReadStream *_stream;
 	Common::Array<Chunk> _chunks;
+	Common::Array<Common::String> _includes;
 	uint32 _nextDATAChunk;
 };
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 28038c23002..3b9fd5510c3 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1606,7 +1606,7 @@ void Scene::load(bool fromSaveFile) {
 
 	uint numRecords = 0;
 	while (actionRecordChunk = sceneIFF->getChunkStream("ACT", numRecords), actionRecordChunk != nullptr) {
-		_actionManager.addNewActionRecord(*actionRecordChunk);
+		_actionManager.addNewActionRecord(*actionRecordChunk, sceneIFF->getChunkSource("ACT", numRecords));
 		delete actionRecordChunk;
 		++numRecords;
 	}


Commit: 016137028312a169fac44399f4804b343f4afa04
    https://github.com/scummvm/scummvm/commit/016137028312a169fac44399f4804b343f4afa04
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-19T17:02:55+03:00

Commit Message:
NANCY: NANCY14: Implement the timer mode of MeterPuzzle

In mode 0, the meter shows the elapsed time of a software timer. If the
full-scale value is -1, the time of the timer's first one-shot trigger
is used instead. Nancy14 uses this for the oxygen meter when diving in
the sewer.

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


diff --git a/engines/nancy/action/puzzle/meterpuzzle.cpp b/engines/nancy/action/puzzle/meterpuzzle.cpp
index 7107af1c52d..ddcf6e85ca2 100644
--- a/engines/nancy/action/puzzle/meterpuzzle.cpp
+++ b/engines/nancy/action/puzzle/meterpuzzle.cpp
@@ -62,27 +62,56 @@ void MeterPuzzle::init() {
 	redraw();
 }
 
-int32 MeterPuzzle::sampleValue() const {
-	// Modes 1/2 track value-table entry _modeParam; mode 0's source is unused here.
-	if ((_mode == 1 || _mode == 2) && _modeParam != 0xff) {
+double MeterPuzzle::sampleFraction() const {
+	if (_mode == kTimer) {
+		// The elapsed time of software timer _modeParam
+		if (_modeParam < 0 || (uint)_modeParam >= TimerData::kNumTimers) {
+			return 0.0;
+		}
+
+		TimerData *timerData = (TimerData *)NancySceneState.getPuzzleData(TimerData::getTag());
+		if (!timerData) {
+			return 0.0;
+		}
+
+		const TimerData::Timer &timer = timerData->timers[_modeParam];
+		int32 fullScale = _modeValue;
+		if (fullScale == -1) {
+			// Full scale is the time of the timer's first one-shot trigger
+			fullScale = 1;
+			for (const TimerData::Trigger &trigger : timer.triggers) {
+				if (trigger.type == TimerData::Trigger::kOneShot) {
+					if (trigger.durationMs > 0) {
+						fullScale = trigger.durationMs;
+					}
+					break;
+				}
+			}
+		}
+
+		return fullScale ? (double)timer.currentTimeMs / fullScale : 0.0;
+	}
+
+	// Modes 1/2 track value-table entry _modeParam
+	int32 value = _value;
+	if (_modeParam != 0xff) {
 		TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
 		if (table) {
-			int16 value = table->getValue(_modeParam);
-			return value == kNoTableValue ? 0 : value;	// unset reads as empty
+			int16 tableValue = table->getValue(_modeParam);
+			value = tableValue == kNoTableValue ? 0 : tableValue;	// unset reads as empty
 		}
 	}
 
-	return _value;
+	return _modeValue ? (double)value / _modeValue : 0.0;
 }
 
 int MeterPuzzle::computeFrame() const {
 	int frameCount = _animation.getFrameCount();
-	if (frameCount <= 0 || _modeValue == 0) {
+	if (frameCount <= 0) {
 		return 0;
 	}
 
-	// frame = round(frameCount * value / modeValue).
-	int frame = (int)((double)frameCount * (double)sampleValue() / (double)_modeValue + 0.5);
+	int frame = (int)((double)frameCount * sampleFraction() + 0.5);
 	return CLIP(frame, 0, frameCount - 1);
 }
 
diff --git a/engines/nancy/action/puzzle/meterpuzzle.h b/engines/nancy/action/puzzle/meterpuzzle.h
index cf6ecb5a42b..418104503e8 100644
--- a/engines/nancy/action/puzzle/meterpuzzle.h
+++ b/engines/nancy/action/puzzle/meterpuzzle.h
@@ -30,9 +30,10 @@ namespace Nancy {
 namespace Action {
 
 // A meter/gauge display, new in Nancy14 (AR 179): shows one frame of a bar
-// animation. In modes 1/2 the value is a shared value table entry, driven by
-// BlockingPuzzle (AR 180). In Nancy14, it handles the health meters for the
-// final fight.
+// animation. In mode 0 the bar tracks a software timer's elapsed time (e.g.
+// Nancy14's oxygen meter while diving in the sewer). In modes 1/2 the value is
+// a shared value table entry, driven by BlockingPuzzle (AR 180), used by
+// Nancy14's health meters in the final fight.
 class MeterPuzzle : public RenderActionRecord {
 public:
 	MeterPuzzle() : RenderActionRecord(7) {}
@@ -49,13 +50,19 @@ protected:
 	Common::String getRecordTypeName() const override { return "MeterPuzzle"; }
 
 	void redraw();
-	int32 sampleValue() const;	// the tracked value (a value-table entry in modes 1/2)
-	int computeFrame() const;	// the frame for the current value
+	double sampleFraction() const;	// the tracked value, divided by its full-scale value
+	int computeFrame() const;		// the frame for the current value
+
+	// Mode 2 behaves the same as kTableValue
+	enum Mode {
+		kTimer = 0,
+		kTableValue = 1
+	};
 
 	// -- File data --
-	int16 _mode = 0;			// 0x00 - mode (0/1/2)
-	int16 _modeParam = 0;		// 0x02
-	int32 _modeValue = 0;		// 0x04
+	int16 _mode = kTimer;		// 0x00
+	int16 _modeParam = 0;		// 0x02 - timer slot or value-table index
+	int32 _modeValue = 0;		// 0x04 - full-scale value; -1 = the timer's one-shot trigger time
 
 	Common::Path _animName;		// 0x08 - the bar animation
 	uint16 _videoFormat = 0;	// 0x29


Commit: aa71ed5e979be52f371552d764f3d6c5e2f0b034
    https://github.com/scummvm/scummvm/commit/aa71ed5e979be52f371552d764f3d6c5e2f0b034
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-19T17:02:56+03:00

Commit Message:
NANCY: NANCY12-15: Fix software timer behavior

Match the original engines' software timers:
- ResetAndStartTimer in Nancy14+ adds the milliseconds field to the
  duration, and uses the BSUM duration table for seconds values above
  5000 (Nancy12-13: from 5000 up)
- In Nancy12-13, adding, subtracting and setting time works on the
  hours, minutes and seconds of the elapsed time separately
- After subtracting or setting time, triggers whose target time is now
  later than the timer's elapsed time are reset, so they can fire again
  once the timer reaches them
- Timer records can now repeat. Nancy14 uses this to refill the oxygen
  meter when surfacing in the sewer, which then pauses the timer
- A paused timer counts as active for timer dependencies in Nancy12+
- Nancy14+ have 20 software timers instead of 10. Only Nancy15 uses
  more than 10, so only its saves store all 20. Save version isn't
  bumped, since the saved timers in Nancy14 remain the same, and
  only Nancy15 saves are broken - but Nancy15 is still not working, so
  breaking saves for that one is better than changing the save version
  now

This fixes the Nancy14 oxygen timer not stopping after surfacing in
the sewer, which let Nancy drown out of the water.

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


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 990b03f5c9a..1cb8ee2f809 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -672,7 +672,7 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
 
 			break;
 		case DependencyType::kTimerIsActive:
-			// Nancy11+ only: satisfied while the software-timer slot is running/counting
+			// Nancy11+ only: satisfied while the software-timer slot is active
 			dep.satisfied = NancySceneState.isSoftwareTimerActive(dep.label);
 
 			break;
diff --git a/engines/nancy/action/actionrecord.h b/engines/nancy/action/actionrecord.h
index 15d0b8ef48a..b81595a1326 100644
--- a/engines/nancy/action/actionrecord.h
+++ b/engines/nancy/action/actionrecord.h
@@ -67,7 +67,7 @@ enum struct DependencyType : int16 {
 	kCloseParenthesis				= 19,
 	kRandom							= 20,
 	kDefaultAR						= 21,
-	kTimerIsActive					= 22,	// Nancy11+ software-timer slot is running/counting
+	kTimerIsActive					= 22,	// Nancy11+ software-timer slot is running (or paused, in Nancy12+)
 	kTimerEqualsDependencyTime		= 23,	// The next three compare a running software
 	kTimerBelowDependencyTime		= 24,	// timer's elapsed time against the dependency's
 	kTimerAboveDependencyTime		= 25,	// own time, and only while that slot is running
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index cdb7bc88189..9bef435cc67 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -523,6 +523,37 @@ static void addTimerTrigger(TimerData::Timer &timer, TimerData::Trigger::Type ty
 	timer.triggers.push_back(trigger);
 }
 
+// Nancy 12-13 add to, subtract from or set the elapsed time's hours, minutes and
+// seconds separately (clamping each at 0 when subtracting), dropping any
+// fraction of a second
+static void adjustTimerComponents(TimerData::Timer &timer, int16 command, int16 hours, int16 minutes, int16 seconds) {
+	int32 h = timer.currentTimeMs / 3600000;
+	int32 m = (timer.currentTimeMs / 60000) % 60;
+	int32 s = (timer.currentTimeMs / 1000) % 60;
+
+	switch (command) {
+	case ResetAndStartTimer::kAddTime:
+		h += hours;
+		m += minutes;
+		s += seconds;
+		break;
+	case ResetAndStartTimer::kSubtractTime:
+		h = MAX<int32>(h - hours, 0);
+		m = MAX<int32>(m - minutes, 0);
+		s = MAX<int32>(s - seconds, 0);
+		break;
+	case ResetAndStartTimer::kSetTime:
+		h = hours;
+		m = minutes;
+		s = seconds;
+		break;
+	default:
+		return;
+	}
+
+	timer.currentTimeMs = MAX<int32>((h * 60 + m) * 60 + s, 0) * 1000;
+}
+
 // Reads exactly size bytes from the stream, returning the text up to the first
 // null byte. Used for the fixed-size string fields inside a TimerControl chunk.
 static Common::String readFixedSizeString(Common::SeekableReadStream &stream, uint size) {
@@ -554,17 +585,17 @@ void ResetAndStartTimer::readData(Common::SeekableReadStream &stream) {
 	case kAddTime:
 	case kSubtractTime:
 	case kSetTime:
-		_hours = stream.readSint16LE();      // 0x04
-		_minutes = stream.readSint16LE();    // 0x06
-		_seconds = stream.readSint16LE();    // 0x08
-		stream.skip(2);                      // 0x0a, unused
+		_hours = stream.readSint16LE();          // 0x04
+		_minutes = stream.readSint16LE();        // 0x06
+		_seconds = stream.readSint16LE();        // 0x08
+		_milliseconds = stream.readSint16LE();   // 0x0a
 		break;
 	case kConfigOneShot:
 	case kConfigRepeating: {
 		_hours = stream.readSint16LE();          // 0x04
 		_minutes = stream.readSint16LE();        // 0x06
 		_seconds = stream.readSint16LE();        // 0x08
-		stream.skip(2);                          // 0x0a, unused
+		_milliseconds = stream.readSint16LE();   // 0x0a
 		_sound.volume = stream.readUint16LE();   // 0x0c
 		_sound.channelID = stream.readUint16LE(); // 0x0e
 		_sound.numLoops = 1;
@@ -611,7 +642,7 @@ void ResetAndStartTimer::execute() {
 			}
 		}
 
-		_isDone = true;
+		finishExecution();
 		return;
 	}
 
@@ -622,7 +653,22 @@ void ResetAndStartTimer::execute() {
 		if (_command == kStart) {
 			timer->state = TimerData::Timer::kRunning;
 		} else if (timer->state == TimerData::Timer::kRunning) {
-			const uint32 durationMs = ((uint32)_hours * 3600 + (uint32)_minutes * 60 + (uint32)_seconds) * 1000;
+			const bool isNancy14 = g_nancy->getGameType() >= kGameTypeNancy14;
+
+			// Seconds from kTimerDurationIndexBase (Nancy14+: above it) index
+			// the BSUM duration table
+			int16 seconds = _seconds;
+			if (isNancy14 ? seconds > kTimerDurationIndexBase : seconds >= kTimerDurationIndexBase) {
+				auto *bootSummary = GetEngineData(BSUM);
+				uint index = seconds - kTimerDurationIndexBase;
+				if (bootSummary && index < bootSummary->timerDurations.size()) {
+					seconds = bootSummary->timerDurations[index];
+				}
+			}
+
+			// Only Nancy14+ uses the milliseconds field
+			const uint32 durationMs = ((uint32)_hours * 3600 + (uint32)_minutes * 60 + (uint32)seconds) * 1000 +
+				(isNancy14 ? _milliseconds : 0);
 
 			switch (_command) {
 			case kClear:
@@ -632,13 +678,31 @@ void ResetAndStartTimer::execute() {
 				timer->state = TimerData::Timer::kPaused;
 				break;
 			case kAddTime:
-				timer->currentTimeMs += durationMs;
+				if (!isNancy14) {
+					adjustTimerComponents(*timer, _command, _hours, _minutes, seconds);
+				} else {
+					timer->currentTimeMs += durationMs;
+				}
+
 				break;
 			case kSubtractTime:
-				timer->currentTimeMs = timer->currentTimeMs > durationMs ? timer->currentTimeMs - durationMs : 0;
-				break;
 			case kSetTime:
-				timer->currentTimeMs = durationMs;
+				if (!isNancy14) {
+					adjustTimerComponents(*timer, _command, _hours, _minutes, seconds);
+				} else if (_command == kSubtractTime) {
+					timer->currentTimeMs = timer->currentTimeMs > durationMs ? timer->currentTimeMs - durationMs : 0;
+				} else {
+					timer->currentTimeMs = durationMs;
+				}
+
+				// Reset triggers whose target time is now later than the
+				// elapsed time, so they fire again once the timer reaches them
+				for (TimerData::Trigger &trigger : timer->triggers) {
+					if (timer->currentTimeMs < trigger.durationMs) {
+						trigger.hasFired = false;
+					}
+				}
+
 				break;
 			case kConfigOneShot:
 			case kConfigRepeating:
@@ -652,7 +716,8 @@ void ResetAndStartTimer::execute() {
 		}
 	}
 
-	_isDone = true;
+	// Repeating records (e.g. gradually refilling a timer) run again next frame
+	finishExecution();
 }
 
 void StopTimer::readData(Common::SeekableReadStream &stream) {
@@ -669,7 +734,7 @@ void StopTimer::execute() {
 		}
 	}
 
-	_isDone = true;
+	finishExecution();
 }
 
 void TimerControl::readData(Common::SeekableReadStream &stream) {
@@ -704,7 +769,7 @@ void TimerControl::readData(Common::SeekableReadStream &stream) {
 void TimerControl::execute() {
 	TimerData::Timer *timer = getSoftwareTimer(_timerIndex);
 	if (!timer) {
-		_isDone = true;
+		finishExecution();
 		return;
 	}
 
@@ -751,7 +816,7 @@ void TimerControl::execute() {
 		break;
 	}
 
-	_isDone = true;
+	finishExecution();
 }
 
 void StopPlayerScrolling::readData(Common::SeekableReadStream &stream) {
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index c3eebb04c24..d15c29503b2 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -333,6 +333,7 @@ public:
 	int16 _hours = 0;
 	int16 _minutes = 0;
 	int16 _seconds = 0;
+	int16 _milliseconds = 0;
 	SoundDescription _sound;               // Played on expiry when configured
 	Common::Array<FlagDescription> _flags; // Fired on expiry when configured
 
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 27151ba8e84..024b51df01a 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -141,8 +141,8 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	s.syncAsSint16LE(slowMovementTimeDelta);
 	s.syncAsSint16LE(fastMovementTimeDelta);
 
-	s.skip(4, kGameTypeNancy9, kGameTypeNancy11); // Unknown
-	if (s.getVersion() >= kGameTypeNancy9 && s.getVersion() <= kGameTypeNancy11) {
+	s.skip(4, kGameTypeNancy9, kGameTypeNancy15); // Unknown
+	if (s.getVersion() >= kGameTypeNancy9 && s.getVersion() <= kGameTypeNancy15) {
 		timerDurations.resize(10);
 		for (uint i = 0; i < timerDurations.size(); ++i) {
 			s.syncAsUint16LE(timerDurations[i]);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index ee7f92d6338..da26eef7920 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -106,7 +106,8 @@ struct BSUM : public EngineData {
 	uint16 fastMovementTimeDelta;
 
 	// Nancy9-11: timer dependencies with a seconds value of kTimerDurationIndexBase
-	// or above take their seconds from this table instead
+	// or above take their seconds from this table instead. Nancy12+ timer
+	// triggers (AR 104) use it for seconds values above kTimerDurationIndexBase
 	Common::Array<uint16> timerDurations;
 };
 
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index d1a03ee28ae..a0697870b99 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -510,7 +510,9 @@ void CellPhonePictureData::synchronize(Common::Serializer &ser) {
 }
 
 void TimerData::synchronize(Common::Serializer &ser) {
-	for (uint i = 0; i < kNumTimers; ++i) {
+	// Nancy14 only saves 10 of its 20 timers, see kNumSavedTimers
+	const uint numTimers = g_nancy->getGameType() >= kGameTypeNancy15 ? kNumTimers : kNumSavedTimers;
+	for (uint i = 0; i < numTimers; ++i) {
 		Timer &t = timers[i];
 		ser.syncAsSint32LE(t.state);
 		ser.syncAsUint32LE(t.currentTimeMs);
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index d711dfe5e85..18ae0dd8abf 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -337,7 +337,13 @@ struct TimerData : public PuzzleData {
 		void reset() { *this = Timer(); }
 	};
 
-	static const uint kNumTimers = 10;
+	// Nancy11-13 have 10 timers, and Nancy14+ have 20. However, only Nancy15+
+	// save all 20: Nancy14 has 20 timers, but its scripts never use any past
+	// the first 10, so its saves keep storing 10 timers. Since the TimerData chunk isn't
+	// length-prefixed, storing more timers for Nancy14 would break existing
+	// saves unless the savegame version is bumped.
+	static const uint kNumTimers = 20;
+	static const uint kNumSavedTimers = 10;		// Timers stored in saves before Nancy15
 	static const uint kNumTriggers = 20;
 
 	TimerData() {}
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 3b9fd5510c3..260e31747ef 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1844,6 +1844,12 @@ bool Scene::isSoftwareTimerActive(uint16 index) const {
 	}
 
 	const TimerData::Timer &timer = ((const TimerData *)_puzzleData.getVal(TimerData::getTag()))->timers[index];
+
+	// Nancy12+ also counts a paused timer as active
+	if (g_nancy->getGameType() >= kGameTypeNancy12) {
+		return timer.state != TimerData::Timer::kIdle;
+	}
+
 	return timer.state == TimerData::Timer::kRunning ||
 		timer.state == TimerData::Timer::kOneShot ||
 		timer.state == TimerData::Timer::kRepeating;


Commit: ef6328c84e28f9cd1ca765845cb575333132ac89
    https://github.com/scummvm/scummvm/commit/ef6328c84e28f9cd1ca765845cb575333132ac89
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-19T17:03:06+03:00

Commit Message:
NANCY: NANCY14: Implement the eye grid puzzle (LetterGridPuzzle)

AR 159 in Nancy14 is a new puzzle that only shares the "Grid Map" name
with the Nancy10 GridMapPuzzle (AR 244), so it gets its own class.

Each grid column stands for a letter and each row for a position in the
message strip below the grid. Marking a clickable cell writes its
column's letter at its row's position, clearing any other mark in that
row. The puzzle is solved once every row matches its target. The marks
persist across visits until a new game, as in the original.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 56ff4ca9f18..45c6f7fd382 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -59,6 +59,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/lettergridpuzzle.h"
 #include "engines/nancy/action/puzzle/leverpuzzle.h"
 #include "engines/nancy/action/puzzle/magnetmazepuzzle.h"
 #include "engines/nancy/action/puzzle/mazechasepuzzle.h"
@@ -471,7 +472,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		if (g_nancy->getGameType() <= kGameTypeNancy13)
 			return new PlaySoundTerse();
 		else
-			return new GridMapPuzzle();	// Moved from 244
+			return new LetterGridPuzzle();
 	case 160:
 		if (g_nancy->getGameType() <= kGameTypeNancy11)
 			return new HintSystem();
diff --git a/engines/nancy/action/puzzle/lettergridpuzzle.cpp b/engines/nancy/action/puzzle/lettergridpuzzle.cpp
new file mode 100644
index 00000000000..a6d93b27429
--- /dev/null
+++ b/engines/nancy/action/puzzle/lettergridpuzzle.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/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/lettergridpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// readRect() makes rects inclusive; the cell and strip layouts step by the raw sizes
+static int16 rawWidth(const Common::Rect &rect) {
+	return rect.isEmpty() ? 0 : rect.width() - 1;
+}
+
+static int16 rawHeight(const Common::Rect &rect) {
+	return rect.isEmpty() ? 0 : rect.height() - 1;
+}
+
+void LetterGridPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _letterImageName);
+	readFilename(stream, _markImageName);
+
+	stream.skip(1);
+	_hoverCursorType = stream.readUint16LE();
+
+	readRect(stream, _markSrc);
+	_gridOrigin.x = stream.readSint32LE();
+	_gridOrigin.y = stream.readSint32LE();
+	_spacingY = stream.readSint16LE();
+	_spacingX = stream.readSint16LE();
+
+	int16 numColumns = stream.readSint16LE();
+	for (int16 i = 0; i < numColumns; ++i) {
+		Column column;
+		readRect(stream, column.letterSrc);
+		int16 numRows = stream.readSint16LE();
+		for (int16 j = 0; j < numRows; ++j) {
+			column.rows.push_back(stream.readSint16LE());
+		}
+		_columns.push_back(column);
+	}
+
+	int16 numRows = stream.readSint16LE();
+	for (int16 i = 0; i < numRows; ++i) {
+		_targets.push_back(stream.readSint16LE());
+	}
+
+	_stripOrigin.x = stream.readSint32LE();
+	_stripOrigin.y = stream.readSint32LE();
+	_stripSpacing = stream.readSint16LE();
+
+	_clickSound.readData(stream);
+
+	_solveScene.sceneID = stream.readUint16LE();
+	_solveScene.frameID = stream.readUint16LE();
+	_solveScene.continueSceneSound = kContinueSceneSound;
+	_solveFlag.label = stream.readSint16LE();
+	_solveFlag.flag = stream.readByte();
+
+	_solveSound.readData(stream);
+
+	readExitHotspot(stream, _exitHotspot, _exitCursorType, _exitScene, _exitFlag);
+	_exitScene.continueSceneSound = kContinueSceneSound;
+
+	// The marks live in a fixed-size global grid
+	if (_columns.size() > kMaxColumns) {
+		_columns.resize(kMaxColumns);
+	}
+	if (_targets.size() > kMaxRows) {
+		_targets.resize(kMaxRows);
+	}
+}
+
+void LetterGridPuzzle::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(vpBounds.width(), vpBounds.height(),
+		g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_letterImageName, _letterImage);
+	_letterImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+	if (!_markImageName.empty()) {
+		g_nancy->_resource->loadImage(_markImageName, _markImage);
+		_markImage.setTransparentColor(_drawSurface.getTransparentColor());
+	}
+
+	GridMapPuzzleData *data = (GridMapPuzzleData *)NancySceneState.getPuzzleData(GridMapPuzzleData::getTag());
+	assert(data);
+	if (data->itemState.size() != kMaxRows) {
+		data->itemState.resize(kMaxRows);
+		for (uint i = 0; i < kMaxRows; ++i) {
+			data->itemState[i] = -1;
+		}
+	}
+
+	_markedColumn = data->itemState;
+
+	_solved = false;
+	_solveSoundStarted = false;
+	_exitRequested = false;
+
+	redraw();
+}
+
+Common::Rect LetterGridPuzzle::cellRect(uint column, uint row) const {
+	int16 width = rawWidth(_markSrc);
+	int16 height = rawHeight(_markSrc);
+	int16 x = _gridOrigin.x + column * (width + _spacingX);
+	int16 y = _gridOrigin.y + row * (height + _spacingY);
+	return Common::Rect(x, y, x + width, y + height);
+}
+
+Common::Rect LetterGridPuzzle::stripRect(uint row) const {
+	if (_columns.empty()) {
+		return Common::Rect();
+	}
+
+	int16 width = rawWidth(_columns[0].letterSrc);
+	int16 height = rawHeight(_columns[0].letterSrc);
+	int16 x = _stripOrigin.x + row * (width + _stripSpacing);
+	return Common::Rect(x, _stripOrigin.y, x + width, _stripOrigin.y + height);
+}
+
+bool LetterGridPuzzle::cellAt(const Common::Point &point, uint &column, uint &row) const {
+	for (uint c = 0; c < _columns.size(); ++c) {
+		for (uint r = 0; r < _targets.size(); ++r) {
+			if (!cellRect(c, r).contains(point)) {
+				continue;
+			}
+
+			// Only the listed cells of a column are clickable
+			const Common::Array<int16> &rows = _columns[c].rows;
+			for (uint i = 0; i < rows.size(); ++i) {
+				if (rows[i] == (int16)r) {
+					column = c;
+					row = r;
+					return true;
+				}
+			}
+		}
+	}
+
+	return false;
+}
+
+void LetterGridPuzzle::toggleCell(uint column, uint row) {
+	// A row holds at most one mark
+	if (_markedColumn[row] == (int16)column) {
+		_markedColumn[row] = -1;
+	} else {
+		_markedColumn[row] = column;
+	}
+
+	GridMapPuzzleData *data = (GridMapPuzzleData *)NancySceneState.getPuzzleData(GridMapPuzzleData::getTag());
+	data->itemState = _markedColumn;
+
+	playSoundBlock(_clickSound);
+	checkSolved();
+	redraw();
+}
+
+void LetterGridPuzzle::checkSolved() {
+	for (uint row = 0; row < _targets.size(); ++row) {
+		int16 target = _targets[row];
+		if (target == kAnyColumn) {
+			continue;
+		}
+
+		int16 marked = _markedColumn[row];
+		if (marked == -1 ? target != kNoColumn : marked != target) {
+			return;
+		}
+	}
+
+	_solved = true;
+}
+
+void LetterGridPuzzle::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 LetterGridPuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (uint row = 0; row < _targets.size(); ++row) {
+		int16 column = _markedColumn[row];
+		if (column < 0 || column >= (int16)_columns.size()) {
+			continue;
+		}
+
+		Common::Rect cell = cellRect(column, row);
+		if (!_markImage.empty()) {
+			Common::Rect src(_markSrc.left, _markSrc.top, _markSrc.left + cell.width(), _markSrc.top + cell.height());
+			_drawSurface.blitFrom(_markImage, src, Common::Point(cell.left, cell.top));
+		}
+
+		Common::Rect strip = stripRect(row);
+		const Common::Rect &letter = _columns[column].letterSrc;
+		Common::Rect src(letter.left, letter.top, letter.left + strip.width(), letter.top + strip.height());
+		_drawSurface.blitFrom(_letterImage, src, Common::Point(strip.left, strip.top));
+	}
+
+	_needsRedraw = true;
+}
+
+void LetterGridPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_solved || _exitRequested) {
+			_state = kActionTrigger;
+		}
+
+		break;
+	case kActionTrigger:
+		if (_exitRequested) {
+			NancySceneState.setEventFlag(_exitFlag);
+			NancySceneState.changeScene(_exitScene);
+			finishExecution();
+			break;
+		}
+
+		// The solve sound plays out before the scene changes
+		if (!_solveSoundStarted) {
+			playSoundBlock(_solveSound);
+			_solveSoundStarted = true;
+			break;
+		}
+
+		if (!_solveSound.names.empty() && g_nancy->_sound->isSoundPlaying((uint16)_solveSound.channel)) {
+			break;
+		}
+
+		NancySceneState.setEventFlag(_solveFlag);
+		NancySceneState.changeScene(_solveScene);
+		finishExecution();
+		break;
+	}
+}
+
+void LetterGridPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _solved || _exitRequested) {
+		return;
+	}
+
+	if (!_exitHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_exitRequested = true;
+		}
+		input.eatMouseInput();
+		return;
+	}
+
+	Common::Rect vpScreen = NancySceneState.getViewport().getScreenPosition();
+	Common::Point mouseVP = input.mousePos - Common::Point(vpScreen.left, vpScreen.top);
+
+	uint column = 0, row = 0;
+	if (!cellAt(mouseVP, column, row)) {
+		return;
+	}
+
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
+	if (input.input & NancyInput::kLeftMouseButtonUp) {
+		toggleCell(column, row);
+	}
+	input.eatMouseInput();
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/lettergridpuzzle.h b/engines/nancy/action/puzzle/lettergridpuzzle.h
new file mode 100644
index 00000000000..26f7e604d08
--- /dev/null
+++ b/engines/nancy/action/puzzle/lettergridpuzzle.h
@@ -0,0 +1,119 @@
+/* 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_LETTERGRIDPUZZLE_H
+#define NANCY_ACTION_LETTERGRIDPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Grid of clickable cells spelling out a message, new in Nancy14 (AR 159, the
+// original's "Grid Map"; unrelated to the Nancy10 GridMapPuzzle). Each column
+// stands for one letter, each row for one position in the message strip below
+// the grid. Only some cells of a column are clickable. Clicking one marks it and
+// writes its column's letter at its row's strip position, clearing any other
+// mark in that row; clicking a marked cell clears it. Solved once every row
+// matches its target.
+//
+// The marks are global game state: they survive leaving the puzzle and are only
+// reset by a new game. They are kept in GridMapPuzzleData.
+class LetterGridPuzzle : public RenderActionRecord {
+public:
+	LetterGridPuzzle() : RenderActionRecord(7) {}
+	virtual ~LetterGridPuzzle() {}
+
+	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 "LetterGridPuzzle"; }
+
+	static const uint kMaxColumns = 20;
+	static const uint kMaxRows = 20;
+
+	// Row targets
+	static const int16 kAnyColumn = -1;		// row is not checked
+	static const int16 kNoColumn = -2;		// row must stay unmarked
+
+	struct Column {
+		Common::Rect letterSrc;			// in the letter image
+		Common::Array<int16> rows;		// rows with a clickable cell
+	};
+
+	Common::Rect cellRect(uint column, uint row) const;
+	Common::Rect stripRect(uint row) const;
+	bool cellAt(const Common::Point &point, uint &column, uint &row) const;
+	void toggleCell(uint column, uint row);
+	void checkSolved();
+	void playSoundBlock(const RandomSoundBlock &block);
+	void redraw();
+
+	// -- File data --
+	Common::Path _letterImageName;
+	Common::Path _markImageName;
+
+	uint16 _hoverCursorType = 0;	// raw Nancy14 cursor type, over a clickable cell
+
+	Common::Rect _markSrc;			// in the mark image; also sets the cell size
+	Common::Point _gridOrigin;
+	int16 _spacingY = 0;
+	int16 _spacingX = 0;
+
+	Common::Array<Column> _columns;
+	Common::Array<int16> _targets;	// per row: target column, kAnyColumn or kNoColumn
+
+	Common::Point _stripOrigin;		// strip cells are as big as the first letter
+	int16 _stripSpacing = 0;
+
+	RandomSoundBlock _clickSound;
+
+	SceneChangeDescription _solveScene;
+	FlagDescription _solveFlag;
+	RandomSoundBlock _solveSound;	// plays out before the scene change
+
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+	FlagDescription _exitFlag;
+
+	// -- Runtime state --
+	Graphics::ManagedSurface _letterImage;
+	Graphics::ManagedSurface _markImage;
+
+	Common::Array<int16> _markedColumn;	// per row, -1 = unmarked
+
+	bool _solved = false;
+	bool _solveSoundStarted = false;
+	bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_LETTERGRIDPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index acfbd3cf4bb..19b49196c07 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -40,6 +40,7 @@ MODULE_OBJS = \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \
   action/puzzle/hangmanpuzzle.o \
+  action/puzzle/lettergridpuzzle.o \
   action/puzzle/leverpuzzle.o \
   action/puzzle/magnetmazepuzzle.o \
   action/puzzle/mazechasepuzzle.o \
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 18ae0dd8abf..f8406b5d61f 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -156,8 +156,9 @@ struct MagnetMazePuzzleData : public PuzzleData {
 	Common::Array<int16> magnetState;
 };
 
-// Per-item (inMap, inItems, mapRow, mapCol, itemsRow, itemsCol) packed as
-// 6 int16s.
+// Nancy10 GridMapPuzzle: per-item (inMap, inItems, mapRow, mapCol, itemsRow,
+// itemsCol) packed as 6 int16s.
+// Nancy14 LetterGridPuzzle: the marked column of each row, -1 = unmarked.
 struct GridMapPuzzleData : public PuzzleData {
 	GridMapPuzzleData() {}
 	virtual ~GridMapPuzzleData() {}


Commit: 9ca7794b5fb58dc906bb863a192658a708a5277f
    https://github.com/scummvm/scummvm/commit/9ca7794b5fb58dc906bb863a192658a708a5277f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-19T17:03:15+03:00

Commit Message:
NANCY: NANCY14: Fix the data layout of KeypadTersePuzzle

>From Nancy 12 on, the terse keypad stores the button and exit hover
cursors before the advance scene, like the non-terse keypad. These
were previously read as the advance scene and grid dimensions, so the
button grid was invalid and none of the buttons could be clicked.
Fixes the Cross of Lorraine date puzzle in scene 4165.

Also, from Nancy 12 on the advance scene is only used when the puzzle
ends unsolved with no buttons pressed, so it no longer overrides the
solve scene.

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


diff --git a/engines/nancy/action/puzzle/orderingpuzzle.cpp b/engines/nancy/action/puzzle/orderingpuzzle.cpp
index 2531afd394e..9850584683e 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/orderingpuzzle.cpp
@@ -308,10 +308,18 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
 		} else if (_puzzleType == kKeypadTerse) {
 			// Terse elements are the same size & placed on a grid (in the source image AND on screen)
 
+			// Nancy 12 added the button and exit hover cursors, same as the non-terse keypad
+			if (g_nancy->getGameType() >= kGameTypeNancy12) {
+				_buttonCursorID = stream.readUint16LE();
+				_exitCursorID = stream.readUint16LE();
+			}
+
 			// In Nancy 11 the grid block is preceded by the scene to advance to on solving, which
 			// overrides the solve scene's target (the rest of the solve scene change is reused).
 			// 0 and 9999 mean "none", falling back to the solve scene read earlier.
-			if (g_nancy->getGameType() >= kGameTypeNancy11) {
+			// From Nancy 12 on, this scene is only taken when the puzzle ends unsolved with no
+			// buttons pressed, so it doesn't override the solve scene.
+			if (g_nancy->getGameType() == kGameTypeNancy11) {
 				uint16 advanceSceneID = stream.readUint16LE();
 
 				// HACK: In Nancy11, in the Betty automaton scene, this is set to scene 2721, but
@@ -325,6 +333,8 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
 				if (advanceSceneID != 0 && advanceSceneID != kNoScene) {
 					_solveExitScene._sceneChange.sceneID = advanceSceneID;
 				}
+			} else if (g_nancy->getGameType() >= kGameTypeNancy12) {
+				stream.skip(2); // advance scene
 			}
 
 			uint16 columns = stream.readUint16LE();
@@ -376,11 +386,6 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
 			}
 		}
 
-		if (g_nancy->getGameType() >= kGameTypeNancy12 && _puzzleType == kKeypadTerse) {
-			// Nancy 12 keypad-terse grew by 4 bytes (exact layout not yet mapped).
-			stream.skip(4);
-		}
-
 		_hotspots = _destRects;
 	}
 


Commit: 8bb23229ebd7edc8691b04b25b01c82cca90d3a0
    https://github.com/scummvm/scummvm/commit/8bb23229ebd7edc8691b04b25b01c82cca90d3a0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-19T17:03:16+03:00

Commit Message:
NANCY: NANCY8+: Fix lowercase letters in Russian fonts

The Russian versions of Nancy8+ keep the first extended FONT offset
pointing to a Latin glyph, so lowercase Cyrillic letters were drawn
with accented Latin ones. Derive the lowercase offset from the
uppercase one instead, since lowercase letters directly follow the
uppercase ones in the font data.

Fix #17124, #17128

Changed paths:
    engines/nancy/font.cpp


diff --git a/engines/nancy/font.cpp b/engines/nancy/font.cpp
index ab93bbfb524..684756e68c1 100644
--- a/engines/nancy/font.cpp
+++ b/engines/nancy/font.cpp
@@ -74,10 +74,12 @@ void Font::read(Common::SeekableReadStream &stream) {
 	_slashOffset						= stream.readUint16LE();
 
 	if (g_nancy->getGameLanguage() == Common::RU_RUS && g_nancy->getGameType() >= kGameTypeNancy5 && g_nancy->getGameType() != kGameTypeNancy6) {
-		// Only extract the lowercase/uppercase offsets, since the letters are in order in the FONT data
-		_cyrillicLowercaseOffset 		= stream.readUint16LE();
-		stream.skip(72);
+		// Only extract the uppercase offset, since the letters are in order in the FONT data,
+		// with lowercase directly following uppercase. The first offset can't be used for
+		// lowercase letters: in nancy8 it still points to a Latin glyph
+		stream.skip(74);
 		_cyrillicUppercaseOffset 		= stream.readUint16LE();
+		_cyrillicLowercaseOffset 		= _cyrillicUppercaseOffset + 32;
 		stream.skip(2);
 
 		numCharacters = 179;




More information about the Scummvm-git-logs mailing list