[Scummvm-git-logs] scummvm master -> 71bc6adac11850c1fcee130018bf93960f33e247

bluegr noreply at scummvm.org
Thu Sep 24 06:21:45 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:
2cd47d4688 NANCY: NANCY15: Implement new QuizPuzzle functionality / format - 1/3
bacb437568 NANCY: NANCY15: Implement new cellphone functionality - 2/3
a3cb030d4a NANCY: NANCY15: Implement savegame data changes for new features - 3/3
217e5d2b42 NANCY: NANCY15: Enable the coin wallet feature for the Hardy boys
6063599a97 NANCY: Add debug commands for getting/setting character money
728d586cec NANCY: NANCY8-15: Verify QuizPuzzle against all games that use it
71bc6adac1 NANCY: NANCY15: Adapt the flag categories and generic event flags


Commit: 2cd47d4688fcb4f304264a61c57569c14c67cab5
    https://github.com/scummvm/scummvm/commit/2cd47d4688fcb4f304264a61c57569c14c67cab5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-24T09:21:24+03:00

Commit Message:
NANCY: NANCY15: Implement new QuizPuzzle functionality / format - 1/3

Fixes the GPS location input in the map

This changes the savegame format, but because there's another pending
change that also affects the savegame format (per-character cellphone),
the savegame format changes will be done for both new features together
in another commit

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


diff --git a/engines/nancy/action/puzzle/quizpuzzle.cpp b/engines/nancy/action/puzzle/quizpuzzle.cpp
index e86f4dc367d..8e2f088435c 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.cpp
+++ b/engines/nancy/action/puzzle/quizpuzzle.cpp
@@ -19,6 +19,8 @@
  *
  */
 
+#include "common/random.h"
+
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/cursor.h"
 #include "engines/nancy/graphics.h"
@@ -27,6 +29,7 @@
 #include "engines/nancy/util.h"
 
 #include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/actionmanager.h"
 #include "engines/nancy/action/puzzle/quizpuzzle.h"
 #include "engines/nancy/puzzledata.h"
 
@@ -51,7 +54,7 @@ void QuizPuzzle::init() {
 	_drawSurface.clear(g_nancy->_graphics->getTransColor());
 	setTransparent(true);
 
-	g_nancy->_input->setVKEnabled(true);
+	g_nancy->_input->setVKEnabled(!_isDisplayOnly);
 	RenderActionRecord::init();
 }
 
@@ -70,23 +73,28 @@ void QuizPuzzle::init() {
 // 0x243   49    doneSound (readNormal)
 // 0x274   30    done subtitle (skip)
 // 0x292   25    cancelScene
-void QuizPuzzle::readDataOld(Common::SeekableReadStream &stream) {
+void QuizPuzzle::readDataNancy8(Common::SeekableReadStream &stream) {
 	_fontID            = stream.readUint16LE();
 	_cursorBlinkInterval = stream.readUint16LE();
-	_numBoxes          = stream.readUint16LE();
+	uint16 numBoxes    = stream.readUint16LE();
+
+	_boxes.resize(5);
 
-	for (int i = 0; i < 5; ++i) {
-		readRect(stream, _boxRects[i]);
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		readRect(stream, _boxes[i].rect);
 	}
 
 	char answerBuf[20];
-	for (int i = 0; i < 5; ++i) {
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		Box &box = _boxes[i];
 		for (int j = 0; j < 3; ++j) {
 			stream.read(answerBuf, 20);
 			answerBuf[19] = '\0';
-			_answers[i][j] = answerBuf;
+			if (answerBuf[0]) {
+				box.answers.push_back(answerBuf);
+			}
 		}
-		_answerFlags[i] = stream.readSint16LE();
+		box.correctFlag = stream.readSint16LE();
 	}
 
 	_correctSound.readNormal(stream);
@@ -100,9 +108,13 @@ void QuizPuzzle::readDataOld(Common::SeekableReadStream &stream) {
 	_doneText = readSubtitleText(stream);
 
 	_cancelScene.readData(stream);
+
+	if (numBoxes < _boxes.size()) {
+		_boxes.resize(numBoxes);
+	}
 }
 
-// ---- Nancy 9+ data format ----
+// ---- Nancy 9 - Nancy 14 data format ----
 // Header (0xB2 bytes):
 // 0x00  2   fontID
 // 0x02  2   blinkInterval
@@ -128,7 +140,7 @@ void QuizPuzzle::readDataOld(Common::SeekableReadStream &stream) {
 // +0x8F  33  wrong sound name
 // +0xB0  2   wrong sound volume
 // +0xB2  30  wrong subtitle (skip)
-void QuizPuzzle::readDataNew(Common::SeekableReadStream &stream) {
+void QuizPuzzle::readDataNancy9(Common::SeekableReadStream &stream) {
 	_fontID            = stream.readUint16LE();
 	_cursorBlinkInterval = stream.readUint16LE();
 	_cursorChar        = stream.readByte();
@@ -145,67 +157,217 @@ void QuizPuzzle::readDataNew(Common::SeekableReadStream &stream) {
 	_cancelScene.readData(stream);
 	readRect(stream, _exitHotspot);
 
-	_correctSoundChannel = stream.readUint16LE();
-	_wrongSoundChannel   = stream.readUint16LE();
+	uint16 correctSoundChannel = stream.readUint16LE();
+	uint16 wrongSoundChannel   = stream.readUint16LE();
 	_skipEmptyOnEnter    = (stream.readByte() != 0);
 	_autoCheck           = (stream.readByte() == 0); // 0 = auto-check ON
-	_numBoxes            = stream.readUint16LE();
+	uint16 numBoxes      = stream.readUint16LE();
 
-	if (_numBoxes > (uint16)kMaxBoxes) {
-		warning("QuizPuzzle: numBoxes %d exceeds maximum %d, clamping", _numBoxes, kMaxBoxes);
-		_numBoxes = kMaxBoxes;
+	if (numBoxes > kMaxBoxes) {
+		warning("QuizPuzzle: numBoxes %d exceeds maximum %d, clamping", numBoxes, (int)kMaxBoxes);
+		numBoxes = kMaxBoxes;
 	}
 
-	char soundNameBuf[33];
+	_boxes.resize(numBoxes);
+
 	char answerBuf[20];
-	for (int i = 0; i < _numBoxes; ++i) {
-		readRect(stream, _boxRects[i]);
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		Box &box = _boxes[i];
+		readRect(stream, box.rect);
 
 		for (int j = 0; j < 3; ++j) {
 			stream.read(answerBuf, 20);
 			answerBuf[19] = '\0';
-			_answers[i][j] = answerBuf;
+			if (answerBuf[0]) {
+				box.answers.push_back(answerBuf);
+				box.maxAnswerLength = MAX<uint16>(box.maxAnswerLength, box.answers.back().size());
+			}
 		}
 
-		_answerFlags[i] = stream.readSint16LE();
+		box.correctFlag = stream.readSint16LE();
+
+		// The per-box sounds name a single file and take their channel from the
+		// record-wide channels read above, so they fold into a RandomSoundBlock.
+		box.correctSound.names.resize(1);
+		readFilename(stream, box.correctSound.names[0]);
+		box.correctSound.channel = correctSoundChannel;
+		box.correctSound.numLoops = 1;
+		box.correctSound.volume = stream.readUint16LE();
+		box.correctText = readSubtitleText(stream);
+
+		box.wrongSound.names.resize(1);
+		readFilename(stream, box.wrongSound.names[0]);
+		box.wrongSound.channel = wrongSoundChannel;
+		box.wrongSound.numLoops = 1;
+		box.wrongSound.volume = stream.readUint16LE();
+		box.wrongText = readSubtitleText(stream);
+	}
+}
 
-		stream.read(soundNameBuf, 33);
-		soundNameBuf[32] = '\0';
-		_boxCorrectSoundName[i] = soundNameBuf;
-		_boxCorrectSoundVolume[i] = stream.readUint16LE();
-		_boxCorrectText[i] = readSubtitleText(stream);
+// ---- Nancy 15 data format ----
+// Header (99 bytes, copied into the record in one go):
+// 0x00  2   fontID
+// 0x02  2   blinkInterval
+// 0x04  1   cursor char (0 = display-only record)
+// 0x05  1   unused
+// 0x06  2   restore flag (int16)
+// 0x08  1   0 = move on to the next box after a wrong answer
+// 0x09  1   0 = check the answer after every keystroke
+// 0x0A  1   non-zero = box cycling also stops on solved boxes
+//           (with no cursor char, 0x08/0x09 are a source scene id instead)
+// 0x0B  33  AUTOTEXT key for the "right answer" caption
+// 0x2C  33  AUTOTEXT key for the "wrong answer" caption
+// 0x4D  20  allowed chars (null-terminated)
+// 0x61  2   unused
+// Then: invalid-key sound block, solve scene (terse) + solve sound block,
+// the give-up hotspot, the flag-setting hotspot array, and the boxes.
+void QuizPuzzle::readDataNancy15(Common::SeekableReadStream &stream) {
+	_fontID              = stream.readUint16LE();
+	_cursorBlinkInterval = stream.readUint16LE();
+	_cursorChar          = stream.readByte();
+	stream.skip(1);
+	_restoreFlag         = stream.readSint16LE();
+
+	byte flagA = stream.readByte();
+	byte flagB = stream.readByte();
+	_cycleSolvedBoxes = (stream.readByte() != 0);
+
+	_isDisplayOnly = (_cursorChar == 0);
+	if (_isDisplayOnly) {
+		_displaySourceScene = flagA | (flagB << 8);
+	} else {
+		_keepBoxOnWrongAnswer = (flagA != 0);
+		_autoCheck = (flagB == 0); // 0 = auto-check ON
+	}
 
-		stream.read(soundNameBuf, 33);
-		soundNameBuf[32] = '\0';
-		_boxWrongSoundName[i] = soundNameBuf;
-		_boxWrongSoundVolume[i] = stream.readUint16LE();
-		_boxWrongText[i] = readSubtitleText(stream);
+	Common::String captionKey;
+	readFilename(stream, captionKey);
+	_recordCorrectText = resolveSubtitleText(captionKey, Common::String());
+	readFilename(stream, captionKey);
+	_recordWrongText = resolveSubtitleText(captionKey, Common::String());
 
-		// Precompute max answer length for auto-check mode
-		_boxMaxLen[i] = 0;
-		for (int j = 0; j < 3; ++j) {
-			uint16 len = (uint16)_answers[i][j].size();
-			if (len > _boxMaxLen[i])
-				_boxMaxLen[i] = len;
+	char allowedBuf[20];
+	stream.read(allowedBuf, 20);
+	allowedBuf[19] = '\0';
+	_allowedChars = allowedBuf;
+	stream.skip(2);
+
+	_invalidKeySound.readData(stream);
+
+	_solveScene._sceneChange.sceneID = stream.readUint16LE();
+	_solveScene._sceneChange.frameID = stream.readUint16LE();
+	_solveScene._flag.label = stream.readSint16LE();
+	_solveScene._flag.flag = stream.readByte();
+	_doneSoundBlock.readData(stream);
+
+	// The give-up hotspot is stored inline here, not as one of the count-prefixed
+	// records the other puzzles use, and its flag is always set to true.
+	readRect(stream, _exitHotspot);
+	_exitCursorType = stream.readUint16LE();
+	_cancelScene._sceneChange.sceneID = stream.readUint16LE();
+	_cancelScene._flag.label = stream.readSint16LE();
+	_cancelScene._flag.flag = g_nancy->_true;
+
+	readExitHotspots(stream, _hotspots);
+
+	// The box count is stored doubled
+	uint16 numBoxes = stream.readUint16LE() / 2;
+
+	if (numBoxes > kMaxBoxes) {
+		warning("QuizPuzzle: numBoxes %d exceeds maximum %d, clamping", numBoxes, (int)kMaxBoxes);
+		numBoxes = kMaxBoxes;
+	}
+
+	_boxes.resize(numBoxes);
+
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		Box &box = _boxes[i];
+		readRect(stream, box.rect);
+
+		uint16 numAnswers = stream.readUint16LE();
+		box.answers.resize(numAnswers);
+		for (uint j = 0; j < numAnswers; ++j) {
+			readFilename(stream, box.answers[j]);
+			box.maxAnswerLength = MAX<uint16>(box.maxAnswerLength, box.answers[j].size());
 		}
+
+		box.correctFlag = stream.readSint16LE();
+		box.nonEmptyFlag = stream.readSint16LE();
+
+		box.correctSound.readData(stream);
+		box.wrongSound.readData(stream);
 	}
 }
 
 void QuizPuzzle::readData(Common::SeekableReadStream &stream) {
-	if (g_nancy->getGameType() == kGameTypeNancy8)
-		readDataOld(stream);
-	else
-		readDataNew(stream);
+	if (g_nancy->getGameType() == kGameTypeNancy8) {
+		readDataNancy8(stream);
+	} else if (g_nancy->getGameType() >= kGameTypeNancy15) {
+		readDataNancy15(stream);
+	} else {
+		readDataNancy9(stream);
+	}
+}
+
+char QuizPuzzle::getCursorChar() const {
+	return g_nancy->getGameType() == kGameTypeNancy8 ? '-' : _cursorChar;
+}
+
+uint QuizPuzzle::getMaxTypedLength() const {
+	return g_nancy->getGameType() >= kGameTypeNancy15 ? kMaxTypedLengthNancy15 : kMaxTypedLength;
+}
+
+bool QuizPuzzle::acceptsChar(char c) const {
+	if (_allowedChars.empty()) {
+		return true;
+	}
+
+	for (uint i = 0; i < _allowedChars.size(); ++i) {
+		if (_allowedChars[i] == c) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+bool QuizPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	if (block.names.empty()) {
+		return false;
+	}
+
+	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 false;
+	}
+
+	_activeBoxSound.name = name;
+	_activeBoxSound.channelID = block.channel;
+	_activeBoxSound.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	_activeBoxSound.volume = block.volume;
+	_activeBoxSound.playCommands = 1;
+
+	g_nancy->_sound->loadSound(_activeBoxSound);
+	g_nancy->_sound->playSound(_activeBoxSound);
+	return true;
+}
+
+void QuizPuzzle::showBoxSubtitle(const Common::String &perBoxText, const Common::String &perRecordText) {
+	// Nancy 15 moved the captions out of the boxes and into the record
+	showSubtitle(g_nancy->getGameType() >= kGameTypeNancy15 ? perRecordText : perBoxText);
 }
 
 // ---- Nancy 8 state machine ----
-void QuizPuzzle::executeOld() {
+void QuizPuzzle::executeNancy8() {
+	Box &box = _boxes[_currentBox];
+
 	switch (_internalState) {
 	case kTyping: {
 		if (_hasNewKey) {
 			_hasNewKey = false;
 
-			Common::String &text = _typedText[_currentBox];
+			Common::String &text = box.typedText;
 			bool hadCursor = !text.empty() && text.lastChar() == '-';
 			if (hadCursor)
 				text.deleteLastChar();
@@ -225,7 +387,7 @@ void QuizPuzzle::executeOld() {
 				}
 				_pendingReturn = false;
 			} else if (_pendingChar != 0) {
-				if (text.size() < 16)
+				if (text.size() < kMaxTypedLength)
 					text += _pendingChar;
 				drawText();
 				_pendingChar = 0;
@@ -236,7 +398,7 @@ void QuizPuzzle::executeOld() {
 		Time now = g_nancy->getTotalPlayTime();
 		if (now >= _nextBlinkTime) {
 			_nextBlinkTime = now + _cursorBlinkInterval;
-			Common::String &text = _typedText[_currentBox];
+			Common::String &text = _boxes[_currentBox].typedText;
 			if (!text.empty() && text.lastChar() == '-')
 				text.deleteLastChar();
 			else
@@ -283,7 +445,7 @@ void QuizPuzzle::executeOld() {
 		break;
 
 	case kStartWrong: {
-		_typedText[_currentBox].clear();
+		box.typedText.clear();
 		drawText();
 
 		if (_wrongSound.name == "NO SOUND") {
@@ -331,13 +493,33 @@ void QuizPuzzle::executeOld() {
 }
 
 // ---- Nancy 9+ state machine ----
-void QuizPuzzle::executeNew() {
+void QuizPuzzle::executeNancy9() {
+	const bool isNancy15 = g_nancy->getGameType() >= kGameTypeNancy15;
+	Box &box = _boxes[_currentBox];
+
+	// A Nancy 15 record without a give-up hotspot completes as soon as every box
+	// is right, and keeps its solve flag in step with that. With a give-up
+	// hotspot, that hotspot doubles as the submit button instead.
+	if (isNancy15 && _internalState == kTyping && _exitHotspot.isEmpty()) {
+		bool allSolved = checkAllSolved();
+		if (allSolved != _solved) {
+			_solved = allSolved;
+			if (!allSolved && _solveScene._flag.label != -1) {
+				NancySceneState.setEventFlag(_solveScene._flag.label, g_nancy->_false);
+			}
+		}
+
+		if (_solved) {
+			_internalState = kStartDone;
+		}
+	}
+
 	switch (_internalState) {
 	case kTyping: {
 		if (_hasNewKey) {
 			_hasNewKey = false;
 
-			Common::String &text = _typedText[_currentBox];
+			Common::String &text = box.typedText;
 			bool hadCursor = !text.empty() && text.lastChar() == _cursorChar;
 			if (hadCursor)
 				text.deleteLastChar();
@@ -345,6 +527,16 @@ void QuizPuzzle::executeNew() {
 			if (_pendingBackspace) {
 				if (!text.empty())
 					text.deleteLastChar();
+
+				// Nancy 15 re-checks the shortened answer, and clears the flag
+				// of a box that is no longer right
+				if (isNancy15) {
+					saveAnswer(_currentBox);
+					if (_autoCheck && checkAnswerForCurrentBox()) {
+						_internalState = kStartCorrect;
+					}
+				}
+
 				drawText();
 				_pendingBackspace = false;
 			} else if (_pendingReturn) {
@@ -358,23 +550,20 @@ void QuizPuzzle::executeNew() {
 				}
 				_pendingReturn = false;
 			} else if (_pendingChar != 0) {
-				bool charAllowed = _allowedChars.empty();
-				if (!charAllowed) {
-					for (uint ci = 0; ci < _allowedChars.size(); ++ci) {
-						if (_allowedChars[ci] == _pendingChar) {
-							charAllowed = true;
-							break;
-						}
-					}
-				}
-				if (charAllowed && text.size() < 16) {
+				if (!acceptsChar(_pendingChar)) {
+					// Nancy 15 buzzes at characters the record does not allow
+					playSoundBlock(_invalidKeySound);
+				} else if (text.size() < getMaxTypedLength()) {
 					text += _pendingChar;
 
+					if (isNancy15) {
+						saveAnswer(_currentBox);
+					}
+
 					if (_autoCheck) {
-						bool correct = checkAnswerForCurrentBox();
-						if (correct) {
+						if (checkAnswerForCurrentBox()) {
 							_internalState = kStartCorrect;
-						} else if ((uint16)text.size() >= _boxMaxLen[_currentBox]) {
+						} else if (!isNancy15 && text.size() >= box.maxAnswerLength) {
 							_internalState = kStartWrong;
 						}
 					}
@@ -389,7 +578,7 @@ void QuizPuzzle::executeNew() {
 			Time now = g_nancy->getTotalPlayTime();
 			if (now >= _nextBlinkTime) {
 				_nextBlinkTime = now + _cursorBlinkInterval;
-				Common::String &text = _typedText[_currentBox];
+				Common::String &text = _boxes[_currentBox].typedText;
 				if (!text.empty() && text.lastChar() == _cursorChar)
 					text.deleteLastChar();
 				else
@@ -407,23 +596,23 @@ void QuizPuzzle::executeNew() {
 	}
 
 	case kStartCorrect: {
-		_activeBoxSound.name         = _boxCorrectSoundName[_currentBox];
-		_activeBoxSound.channelID    = _correctSoundChannel;
-		_activeBoxSound.volume       = _boxCorrectSoundVolume[_currentBox];
-		_activeBoxSound.playCommands = 1;
-		_activeBoxSound.numLoops     = 1;
+		bool playing = playSoundBlock(box.correctSound);
+		if (playing) {
+			showBoxSubtitle(box.correctText, _recordCorrectText);
+		}
 
-		if (_activeBoxSound.name == "NO SOUND") {
-			advanceToNextBox();
+		advanceToNextBox();
+
+		// Nancy 15 does not wait for the feedback sound before carrying on
+		if (isNancy15) {
+			_internalState = kTyping;
+		} else if (!playing) {
 			_solved = checkAllSolved();
 			_internalState = _solved ? kStartDone : kTyping;
 		} else {
-			g_nancy->_sound->loadSound(_activeBoxSound);
-			g_nancy->_sound->playSound(_activeBoxSound);
-			showSubtitle(_boxCorrectText[_currentBox]);
-			advanceToNextBox();
 			_internalState = kWaitCorrect;
 		}
+
 		_nextBlinkTime = 0;
 		drawText();
 		break;
@@ -440,22 +629,26 @@ void QuizPuzzle::executeNew() {
 		break;
 
 	case kStartWrong: {
-		_typedText[_currentBox].clear();
+		// Nancy 15 leaves the wrong answer in place for the player to correct
+		if (!isNancy15) {
+			box.typedText.clear();
+		}
 		drawText();
 
-		_activeBoxSound.name         = _boxWrongSoundName[_currentBox];
-		_activeBoxSound.channelID    = _wrongSoundChannel;
-		_activeBoxSound.volume       = _boxWrongSoundVolume[_currentBox];
-		_activeBoxSound.playCommands = 1;
-		_activeBoxSound.numLoops     = 1;
+		bool playing = playSoundBlock(box.wrongSound);
+		if (playing) {
+			showBoxSubtitle(box.wrongText, _recordWrongText);
+		}
 
-		if (_activeBoxSound.name == "NO SOUND") {
+		if (isNancy15) {
+			if (!_keepBoxOnWrongAnswer) {
+				advanceToNextBox();
+				drawText();
+			}
 			_internalState = kTyping;
+			_nextBlinkTime = 0;
 		} else {
-			g_nancy->_sound->loadSound(_activeBoxSound);
-			g_nancy->_sound->playSound(_activeBoxSound);
-			showSubtitle(_boxWrongText[_currentBox]);
-			_internalState = kWaitWrong;
+			_internalState = playing ? kWaitWrong : kTyping;
 		}
 		break;
 	}
@@ -469,7 +662,9 @@ void QuizPuzzle::executeNew() {
 		break;
 
 	case kStartDone: {
-		if (_doneSound.name == "NO SOUND") {
+		if (isNancy15) {
+			_internalState = playSoundBlock(_doneSoundBlock) ? kWaitDone : kFinish;
+		} else if (_doneSound.name == "NO SOUND") {
 			_internalState = kFinish;
 		} else {
 			g_nancy->_sound->loadSound(_doneSound);
@@ -480,12 +675,14 @@ void QuizPuzzle::executeNew() {
 		break;
 	}
 
-	case kWaitDone:
-		if (!g_nancy->_sound->isSoundPlaying(_doneSound)) {
-			g_nancy->_sound->stopSound(_doneSound);
+	case kWaitDone: {
+		SoundDescription &sound = isNancy15 ? _activeBoxSound : _doneSound;
+		if (!g_nancy->_sound->isSoundPlaying(sound)) {
+			g_nancy->_sound->stopSound(sound);
 			_internalState = kFinish;
 		}
 		break;
+	}
 
 	case kFinish:
 		_state = kActionTrigger;
@@ -503,26 +700,16 @@ void QuizPuzzle::execute() {
 			g_nancy->_sound->loadSound(_correctSound);
 			g_nancy->_sound->loadSound(_wrongSound);
 		}
-		g_nancy->_sound->loadSound(_doneSound);
-
-		// Restore previously correct boxes from persistent puzzle data
-		QuizPuzzleData *qpd = (QuizPuzzleData *)NancySceneState.getPuzzleData(QuizPuzzleData::getTag());
-		if (qpd) {
-			uint16 key = _solveScene._sceneChange.sceneID;
-			if (qpd->boxCorrect.contains(key)) {
-				const auto &bc = qpd->boxCorrect[key];
-				const auto &tt = qpd->typedText[key];
-				for (uint i = 0; i < _numBoxes && i < bc.size(); ++i) {
-					if (bc[i]) {
-						_boxCorrect[i] = true;
-						_typedText[i] = tt[i];
-					}
-				}
-			}
+
+		if (g_nancy->getGameType() < kGameTypeNancy15) {
+			g_nancy->_sound->loadSound(_doneSound);
 		}
-		// Start cursor on first unsolved box
-		for (uint i = 0; i < _numBoxes; ++i) {
-			if (!_boxCorrect[i]) {
+
+		loadSavedAnswers();
+
+		// Start the cursor on the first unsolved box
+		for (uint i = 0; i < _boxes.size(); ++i) {
+			if (!_boxes[i].correct) {
 				_currentBox = i;
 				break;
 			}
@@ -533,17 +720,24 @@ void QuizPuzzle::execute() {
 	}
 		// fall through
 	case kRun:
-		if (g_nancy->getGameType() == kGameTypeNancy8)
-			executeOld();
-		else
-			executeNew();
+		if (_boxes.empty() || _isDisplayOnly) {
+			// Nothing to type into; the record only draws
+			break;
+		}
+
+		if (g_nancy->getGameType() == kGameTypeNancy8) {
+			executeNancy8();
+		} else {
+			executeNancy9();
+		}
 		break;
 
 	case kActionTrigger:
-		if (_cancelled)
+		if (_cancelled) {
 			_cancelScene.execute();
-		else if (_solved)
+		} else if (_solved) {
 			_solveScene.execute();
+		}
 
 		g_nancy->_input->setVKEnabled(false);
 		finishExecution();
@@ -552,30 +746,66 @@ void QuizPuzzle::execute() {
 }
 
 void QuizPuzzle::handleInput(NancyInput &input) {
-	if (_internalState != kTyping)
+	if (_internalState != kTyping || _isDisplayOnly || _boxes.empty()) {
 		return;
+	}
 
-	char cursorChar = (g_nancy->getGameType() == kGameTypeNancy8) ? '-' : _cursorChar;
+	const char cursorChar = getCursorChar();
 
-	// Nancy 9+: exit hotspot at chunk +0x9A. Click cancels the puzzle.
+	// Nancy 9+: give-up hotspot. Clicking it cancels the puzzle.
 	if (g_nancy->getGameType() != kGameTypeNancy8 && !_exitHotspot.isEmpty()) {
 		Common::Rect exitScreen = NancySceneState.getViewport().convertViewportToScreen(_exitHotspot);
 		if (exitScreen.contains(input.mousePos)) {
-			g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
+			if (_exitCursorType != 0) {
+				g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+			} else {
+				g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
+			}
+
 			if (input.input & NancyInput::kLeftMouseButtonUp) {
-				_cancelled = true;
-				_state = kActionTrigger;
+				// From Nancy 15 the give-up hotspot submits the quiz: with every
+				// box answered it leads to the solve scene instead
+				if (g_nancy->getGameType() >= kGameTypeNancy15 && checkAllSolved()) {
+					_solved = true;
+					_internalState = kStartDone;
+				} else {
+					_cancelled = true;
+					_state = kActionTrigger;
+				}
 			}
 			return;
 		}
 	}
 
+	// Nancy 15: regions around the boxes that only set an event flag when clicked
+	for (uint i = 0; i < _hotspots.size(); ++i) {
+		const ExitHotspot &hotspot = _hotspots[i];
+		if (hotspot.hotspot.isEmpty() ||
+				!NancySceneState.getViewport().convertViewportToScreen(hotspot.hotspot).contains(input.mousePos)) {
+			continue;
+		}
+
+		if (hotspot.cursorType != 0) {
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)hotspot.cursorType, true);
+		} else {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+		}
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			NancySceneState.setEventFlag(hotspot.flag);
+			if (hotspot.scene.sceneID != kNoScene) {
+				NancySceneState.changeScene(hotspot.scene);
+			}
+		}
+		return;
+	}
+
 	// Hover over an unsolved text box: show the hotspot cursor and, on click,
 	// move the typing focus to that box.
-	for (uint i = 0; i < _numBoxes; ++i) {
-		if (_boxCorrect[i])
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		if (_boxes[i].correct)
 			continue;
-		Common::Rect screenRect = NancySceneState.getViewport().convertViewportToScreen(_boxRects[i]);
+		Common::Rect screenRect = NancySceneState.getViewport().convertViewportToScreen(_boxes[i].rect);
 		if (!screenRect.contains(input.mousePos))
 			continue;
 
@@ -583,7 +813,7 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			if (i != _currentBox) {
-				Common::String &oldText = _typedText[_currentBox];
+				Common::String &oldText = _boxes[_currentBox].typedText;
 				if (!oldText.empty() && oldText.lastChar() == cursorChar)
 					oldText.deleteLastChar();
 				_currentBox = i;
@@ -600,7 +830,7 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 			_hasNewKey = true;
 		} else if (key.keycode == Common::KEYCODE_TAB) {
 			// Tab: advance to next unsolved box without submitting
-			Common::String &oldText = _typedText[_currentBox];
+			Common::String &oldText = _boxes[_currentBox].typedText;
 			if (!oldText.empty() && oldText.lastChar() == cursorChar)
 				oldText.deleteLastChar();
 			advanceToNextBox();
@@ -627,7 +857,7 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 }
 
 void QuizPuzzle::onPause(bool paused) {
-	g_nancy->_input->setVKEnabled(!paused);
+	g_nancy->_input->setVKEnabled(!paused && !_isDisplayOnly);
 	RenderActionRecord::onPause(paused);
 }
 
@@ -638,76 +868,169 @@ void QuizPuzzle::drawText() {
 	if (!font)
 		return;
 
-	for (uint i = 0; i < _numBoxes; ++i) {
-		const Common::String &text = _typedText[i];
+	// Nancy 15 draws a box's text into its rect widened by the cursor character
+	const int extraWidth = g_nancy->getGameType() >= kGameTypeNancy15 ? 8 : 0;
+
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		const Common::String &text = _boxes[i].typedText;
 		if (text.empty())
 			continue;
 
-		Common::Rect bounds = _boxRects[i];
+		Common::Rect bounds = _boxes[i].rect;
 		bounds = NancySceneState.getViewport().convertViewportToScreen(bounds);
 		bounds = convertToLocal(bounds);
 
 		int y = bounds.bottom - font->getFontHeight();
-		font->drawString(&_drawSurface, text, bounds.left - 1, y, bounds.width(), 0);
+		font->drawString(&_drawSurface, text, bounds.left - 1, y, bounds.width() + extraWidth, 0);
 	}
 
 	_needsRedraw = true;
 }
 
 void QuizPuzzle::advanceToNextBox() {
-	for (uint n = 0; n < _numBoxes; ++n) {
-		_currentBox = (_currentBox + 1) % _numBoxes;
-		if (!_boxCorrect[_currentBox])
+	for (uint n = 0; n < _boxes.size(); ++n) {
+		_currentBox = (_currentBox + 1) % _boxes.size();
+		if (_cycleSolvedBoxes || !_boxes[_currentBox].correct)
 			return;
 	}
 }
 
 bool QuizPuzzle::checkAllSolved() const {
-	for (uint i = 0; i < _numBoxes; ++i) {
-		if (!_boxCorrect[i])
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		if (!_boxes[i].correct)
 			return false;
 	}
-	return true;
+	return !_boxes.empty();
 }
 
 bool QuizPuzzle::checkAnswerForCurrentBox() {
-	Common::String input = _typedText[_currentBox];
-	char cursorChar = (g_nancy->getGameType() == kGameTypeNancy8) ? '-' : _cursorChar;
+	Box &box = _boxes[_currentBox];
+	const char cursorChar = getCursorChar();
+
+	Common::String input = box.typedText;
 	if (!input.empty() && input.lastChar() == cursorChar)
 		input.deleteLastChar();
 
 	bool correct = false;
-	for (int j = 0; j < 3; ++j) {
-		if (!_answers[_currentBox][j].empty() &&
-		        input.equalsIgnoreCase(_answers[_currentBox][j])) {
+	for (uint j = 0; j < box.answers.size(); ++j) {
+		if (!box.answers[j].empty() && input.equalsIgnoreCase(box.answers[j])) {
 			correct = true;
 			break;
 		}
 	}
 
 	if (correct) {
-		// Strip cursor from actual buffer (matches original game state-1 behaviour)
-		if (!_typedText[_currentBox].empty() && _typedText[_currentBox].lastChar() == cursorChar)
-			_typedText[_currentBox].deleteLastChar();
-		_boxCorrect[_currentBox] = true;
-		if (_answerFlags[_currentBox] != -1)
-			NancySceneState.setEventFlag(_answerFlags[_currentBox], g_nancy->_true);
-
-		// Persist so the answered box survives scene re-entry
-		QuizPuzzleData *qpd = (QuizPuzzleData *)NancySceneState.getPuzzleData(QuizPuzzleData::getTag());
-		if (qpd) {
-			uint16 key = _solveScene._sceneChange.sceneID;
-			auto &bc = qpd->boxCorrect[key];
-			auto &tt = qpd->typedText[key];
-			if (bc.size() < _numBoxes) {
-				bc.resize(_numBoxes, false);
-				tt.resize(_numBoxes);
+		// Strip the cursor from the actual buffer, as the original does
+		if (!box.typedText.empty() && box.typedText.lastChar() == cursorChar)
+			box.typedText.deleteLastChar();
+		box.correct = true;
+		if (box.correctFlag != -1)
+			NancySceneState.setEventFlag(box.correctFlag, g_nancy->_true);
+
+		saveAnswer(_currentBox);
+	} else if (g_nancy->getGameType() >= kGameTypeNancy15) {
+		// Nancy 15 clears the flag of a box that is no longer right
+		box.correct = false;
+		if (box.correctFlag != -1)
+			NancySceneState.setEventFlag(box.correctFlag, g_nancy->_false);
+	}
+
+	if (box.nonEmptyFlag != -1) {
+		NancySceneState.setEventFlag(box.nonEmptyFlag, box.typedText.empty() ? g_nancy->_false : g_nancy->_true);
+	}
+
+	return correct;
+}
+
+uint32 QuizPuzzle::getSaveKey() {
+	if (g_nancy->getGameType() < kGameTypeNancy15) {
+		return _solveScene._sceneChange.sceneID;
+	}
+
+	// Nancy 15 keys its save slots by scene and record index, so that the several
+	// quiz records a scene may carry each keep their own answers.
+	const Common::Array<ActionRecord *> &records = NancySceneState.getActionManager().getActionRecords();
+	uint index = 0;
+	for (uint i = 0; i < records.size(); ++i) {
+		if (records[i] == this) {
+			index = i;
+			break;
+		}
+	}
+
+	return ((uint32)NancySceneState.getSceneInfo().sceneID << 16) | index;
+}
+
+void QuizPuzzle::loadSavedAnswers() {
+	QuizPuzzleData *data = (QuizPuzzleData *)NancySceneState.getPuzzleData(QuizPuzzleData::getTag());
+	if (!data) {
+		return;
+	}
+
+	uint32 key = getSaveKey();
+
+	if (_isDisplayOnly) {
+		// A display-only record shows the answers typed into the quiz of another
+		// scene, whichever of that scene's records they came from
+		key = 0;
+		for (auto &entry : data->typedText) {
+			if ((entry._key >> 16) == _displaySourceScene) {
+				key = entry._key;
+				break;
 			}
-			bc[_currentBox] = true;
-			tt[_currentBox] = _typedText[_currentBox];
 		}
+	} else if (g_nancy->getGameType() >= kGameTypeNancy15 &&
+			(_restoreFlag == -1 || !NancySceneState.getEventFlag(_restoreFlag, g_nancy->_true))) {
+		// Without the restore flag set the puzzle starts over
+		data->boxCorrect.erase(key);
+		data->typedText.erase(key);
+		return;
 	}
-	return correct;
+
+	if (!data->typedText.contains(key)) {
+		return;
+	}
+
+	const Common::Array<Common::String> &saved = data->typedText[key];
+	for (uint i = 0; i < _boxes.size() && i < saved.size(); ++i) {
+		_boxes[i].typedText = saved[i];
+	}
+
+	if (_isDisplayOnly) {
+		// Another quiz's answers are only shown, never scored
+		return;
+	}
+
+	// The original re-checks every restored answer instead of trusting the slot
+	uint currentBox = _currentBox;
+	for (uint i = 0; i < _boxes.size(); ++i) {
+		_currentBox = i;
+		checkAnswerForCurrentBox();
+	}
+	_currentBox = currentBox;
+}
+
+void QuizPuzzle::saveAnswer(uint boxIndex) {
+	if (_isDisplayOnly) {
+		return;
+	}
+
+	QuizPuzzleData *data = (QuizPuzzleData *)NancySceneState.getPuzzleData(QuizPuzzleData::getTag());
+	if (!data) {
+		return;
+	}
+
+	uint32 key = getSaveKey();
+	Common::Array<bool> &boxCorrect = data->boxCorrect[key];
+	Common::Array<Common::String> &typedText = data->typedText[key];
+
+	if (boxCorrect.size() < _boxes.size()) {
+		boxCorrect.resize(_boxes.size(), false);
+		typedText.resize(_boxes.size());
+	}
+
+	boxCorrect[boxIndex] = _boxes[boxIndex].correct;
+	typedText[boxIndex] = _boxes[boxIndex].typedText;
 }
 
 } // End of namespace Action
diff --git a/engines/nancy/action/puzzle/quizpuzzle.h b/engines/nancy/action/puzzle/quizpuzzle.h
index d95a3d5bf30..777d56ec0fc 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.h
+++ b/engines/nancy/action/puzzle/quizpuzzle.h
@@ -24,13 +24,14 @@
 
 #include "engines/nancy/action/actionrecord.h"
 #include "engines/nancy/commontypes.h"
+#include "engines/nancy/util.h"
 
 namespace Nancy {
 namespace Action {
 
-// Text-entry quiz with multiple text boxes.
-// Different implementation for Nancy 8 vs Nancy 9+ (which has more
-// features and a different data format)
+// Text-entry quiz with multiple text boxes. Three data formats: Nancy 8,
+// Nancy 9 - Nancy 14, and Nancy 15, which rewrote the record around
+// count-prefixed answer lists and RandomSoundBlocks.
 class QuizPuzzle : public RenderActionRecord {
 public:
 	QuizPuzzle() : RenderActionRecord(7) {}
@@ -47,72 +48,104 @@ protected:
 	Common::String getRecordTypeName() const override { return "QuizPuzzle"; }
 
 private:
-	static const int kMaxBoxes = 13;
+	// Sanity cap. Nancy 8 and Nancy 9 store a fixed five boxes; Nancy 15 keeps
+	// 30 slots of 27 bytes per save slot, so 30 is the engine's own maximum.
+	static const uint kMaxBoxes = 30;
+	// Nancy 15 rejects a keystroke once the typed text no longer fits a save
+	// slot's 27-byte text buffer, with room left for the cursor character.
+	static const uint kMaxTypedLengthNancy15 = 24;
+	static const uint kMaxTypedLength = 16;
+
+	// One answer box: its screen rect, the answers accepted for it (matched
+	// case-insensitively), the flags it drives, and the text typed into it.
+	struct Box {
+		Common::Rect rect;
+		Common::Array<Common::String> answers;
+		int16 correctFlag = -1;		// set when this box is answered correctly
+		int16 nonEmptyFlag = -1;	// Nancy 15: mirrors "this box has text"
+
+		RandomSoundBlock correctSound;	// Nancy 9+
+		RandomSoundBlock wrongSound;	// Nancy 9+
+		Common::String correctText;		// Nancy 9 - Nancy 14 per-box caption
+		Common::String wrongText;
+
+		uint16 maxAnswerLength = 0;		// longest answer, used in auto-check mode
+
+		// -- Runtime state --
+		Common::String typedText;	// may end with the cursor character
+		bool correct = false;
+	};
+
+	// Format-specific read implementations
+	void readDataNancy8(Common::SeekableReadStream &stream);
+	void readDataNancy9(Common::SeekableReadStream &stream);
+	void readDataNancy15(Common::SeekableReadStream &stream);
 
-	// Format-specific read/execute implementations
-	void readDataOld(Common::SeekableReadStream &stream); // Nancy 8
-	void readDataNew(Common::SeekableReadStream &stream); // Nancy 9+
-	void executeOld(); // Nancy 8 state machine
-	void executeNew(); // Nancy 9+ state machine
+	// Format-specific state machines
+	void executeNancy8();
+	void executeNancy9();
 
-	// Helpers (shared by Nancy 8 and Nancy 9)
+	// Helpers
 	void drawText();
 	void advanceToNextBox();
 	bool checkAllSolved() const;
-	bool checkAnswerForCurrentBox(); // checks, marks correct, sets event flag
+	bool checkAnswerForCurrentBox();	// checks, marks correct, sets event flag
+	char getCursorChar() const;
+	uint getMaxTypedLength() const;
+	bool acceptsChar(char c) const;
 
-	// ---- Data (Nancy 8) ----
+	// Picks one name out of `block` at random and starts it, tracking it in
+	// _activeBoxSound so the state machine can wait for it. Returns false when
+	// the block is empty or names no sound.
+	bool playSoundBlock(const RandomSoundBlock &block);
+	void showBoxSubtitle(const Common::String &perBoxText, const Common::String &perRecordText);
+
+	uint32 getSaveKey();
+	void loadSavedAnswers();
+	void saveAnswer(uint boxIndex);
+
+	Common::Array<Box> _boxes;
+
+	// ---- Data (all formats) ----
 	uint16 _fontID = 0;
 	uint16 _cursorBlinkInterval = 500;
-	uint16 _numBoxes = 0;
 
-	// Text-box screen rects (viewport-relative), up to kMaxBoxes stored
-	Common::Rect _boxRects[kMaxBoxes];
+	SoundDescription _doneSound;		// Nancy 8 - Nancy 14 solve sound
+	Common::String _doneText;
+	RandomSoundBlock _doneSoundBlock;	// Nancy 15 solve sound
 
-	// Per-box answer data: up to 3 valid answers (case-insensitive match), plus
-	// an optional event flag to set when that box is answered correctly.
-	Common::String _answers[kMaxBoxes][3];
-	int16 _answerFlags[kMaxBoxes] = { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 };
+	SceneChangeWithFlag _solveScene;	// entered when all boxes are solved
+	SceneChangeWithFlag _cancelScene;	// entered when the puzzle is given up on
+	Common::Rect _exitHotspot;			// Nancy 9+: viewport-relative give-up hotspot
+	uint16 _exitCursorType = 0;			// Nancy 15
 
-	SoundDescription _correctSound;  // Nancy 8: global correct sound
+	// ---- Data (Nancy 8) ----
+	SoundDescription _correctSound;		// one sound shared by every box
 	Common::String _correctText;
-	SoundDescription _wrongSound;    // Nancy 8: global wrong sound
+	SoundDescription _wrongSound;
 	Common::String _wrongText;
 
-	SoundDescription _doneSound;     // done sound (both Nancy 8 and Nancy 9)
-	Common::String _doneText;
-
-	SceneChangeWithFlag _solveScene;   // scene to go to when all boxes are solved
-	SceneChangeWithFlag _cancelScene;  // scene to go to on cancel
-	Common::Rect _exitHotspot;         // Nancy 9+: viewport-relative cancel hotspot
-
 	// ---- Data (Nancy 9+) ----
-	char _cursorChar = '-';              // cursor character (configurable in Nancy 9)
-	Common::String _allowedChars;        // allowed typing chars (empty = all allowed)
-	bool _autoCheck = false;             // check answer after each char typed
-	bool _skipEmptyOnEnter = false;      // if true, Enter on empty box does nothing
-
-	uint16 _correctSoundChannel = 0;    // global channel for per-box correct sounds
-	uint16 _wrongSoundChannel = 0;      // global channel for per-box wrong sounds
-
-	// Per-box sounds for Nancy 9 (name + volume; channel from global above)
-	Common::String _boxCorrectSoundName[kMaxBoxes];
-	uint16 _boxCorrectSoundVolume[kMaxBoxes] = {};
-	Common::String _boxCorrectText[kMaxBoxes];
-
-	Common::String _boxWrongSoundName[kMaxBoxes];
-	uint16 _boxWrongSoundVolume[kMaxBoxes] = {};
-	Common::String _boxWrongText[kMaxBoxes];
-
-	// Per-box max answer length (computed from answer strings, used in auto-check mode)
-	uint16 _boxMaxLen[kMaxBoxes] = {};
-
-	// ---- Runtime state (shared) ----
-	Common::String _typedText[kMaxBoxes]; // current text in each box (may end with cursor char)
-	bool _boxCorrect[kMaxBoxes] = {};     // true when box i has been answered correctly
-	uint16 _currentBox = 0;              // which box currently receives keyboard input
-	bool _solved = false;                 // all boxes answered correctly
-	bool _cancelled = false;             // user cancelled
+	char _cursorChar = '-';			// cursor character (configurable from Nancy 9)
+	Common::String _allowedChars;	// typeable characters (empty = all allowed)
+	bool _autoCheck = false;		// check the answer after each character typed
+	bool _skipEmptyOnEnter = false;	// if true, Enter on an empty box does nothing
+
+	// ---- Data (Nancy 15) ----
+	int16 _restoreFlag = -1;			// when set, previous answers are restored
+	bool _keepBoxOnWrongAnswer = false;	// don't move on after a wrong answer
+	bool _cycleSolvedBoxes = false;		// cycling also stops on solved boxes
+	uint16 _displaySourceScene = kNoScene;	// see _isDisplayOnly
+	bool _isDisplayOnly = false;		// no cursor character: shows another quiz's answers
+	Common::String _recordCorrectText;	// captions shared by every box
+	Common::String _recordWrongText;
+	RandomSoundBlock _invalidKeySound;	// played when a character is not allowed
+	Common::Array<ExitHotspot> _hotspots;	// flag-setting regions around the boxes
+
+	// ---- Runtime state ----
+	uint _currentBox = 0;		// which box currently receives keyboard input
+	bool _solved = false;		// all boxes answered correctly
+	bool _cancelled = false;	// user gave up
 
 	enum SolveState {
 		kTyping          = 0, // waiting for key input; cursor blinks
@@ -137,7 +170,7 @@ private:
 
 	Time _nextBlinkTime = 0;
 
-	// Runtime: assembled sound description for current per-box sound (Nancy 9)
+	// The per-box sound currently playing, so it can be waited on and stopped
 	SoundDescription _activeBoxSound;
 };
 


Commit: bacb4375686ded584807807eb61fa35553d4a0f6
    https://github.com/scummvm/scummvm/commit/bacb4375686ded584807807eb61fa35553d4a0f6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-24T09:21:26+03:00

Commit Message:
NANCY: NANCY15: Implement new cellphone functionality - 2/3

- Use separate phone directory contacts, signal and battery indicator
  per character
- Disable the cellphone camera for this game

Fixes the phone interface for different characters

This changes the savegame format, but because there is another pending
feature that also affects the savegame format (QuizPuzzle changes), the
savegame format changes will be done for both new features together in
another commit

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


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 4a185b9f257..de96777306e 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1290,7 +1290,8 @@ void Scene::inheritBrotherProgress(uint characterIndex) {
 	// The Hardy boys work the case as a team, so whichever brother is played
 	// second takes over the notes the other has already made instead of
 	// starting a fresh journal. Nancy always keeps her own. Their resources
-	// (the money they carry) pass over the same way; their items don't.
+	// (the money they carry) and their shared phone pass over the same way;
+	// their items don't.
 	const uint brother = characterIndex == kPlayerCharacterFrank ? kPlayerCharacterJoe : kPlayerCharacterFrank;
 	if (!playerChar->getInventory(characterIndex).isValid && playerChar->getInventory(brother).isValid) {
 		journalData->inheritEntries(brother, characterIndex);
@@ -1299,6 +1300,11 @@ void Scene::inheritBrotherProgress(uint characterIndex) {
 		if (resourceData) {
 			resourceData->getCharacterValues(characterIndex) = resourceData->getCharacterValues(brother);
 		}
+
+		auto *cellData = (CellPhoneData *)getPuzzleData(CellPhoneData::getTag());
+		if (cellData) {
+			cellData->phones[characterIndex] = cellData->phones[brother];
+		}
 	}
 }
 
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 81d53451128..f331d51f3ec 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -174,14 +174,16 @@ void CellPhonePopup::init() {
 	bounds.moveTo(0, 0);
 	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getScreenPixelFormat());
 
-	// Persistent state lives in CellPhoneData (saved across the game).
-	// First-time init seeds the runtime contact list from the chunk;
-	// subsequent inits (e.g. after a load) restore the saved state.
+	// Persistent state lives in CellPhoneData (saved across the game), one
+	// phone per player character. First-time init seeds the runtime contact
+	// list from the chunk of whoever is being played; subsequent inits (e.g.
+	// after a load or a character switch) restore the saved state.
 	CellPhoneData *cellData = (CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
 	if (cellData) {
-		if (!cellData->seeded) {
-			cellData->contacts = _uiclData->contacts;
-			cellData->seeded = true;
+		CellPhoneData::Phone &phone = cellData->active();
+		if (!phone.seeded) {
+			phone.contacts = _uiclData->contacts;
+			phone.seeded = true;
 
 			// The UICL chunk can ship one initial email and one initial
 			// web-search entry, populated at new-game start (an empty key
@@ -193,9 +195,9 @@ void CellPhonePopup::init() {
 				addSearchLink(1, _uiclData->initialSearch);
 			}
 		}
-		_contacts = cellData->contacts;
-		_noSignal = cellData->noSignal;
-		_batteryLow = cellData->batteryLow;
+		_contacts = phone.contacts;
+		_noSignal = phone.noSignal;
+		_batteryLow = phone.batteryLow;
 	} else {
 		_contacts = _uiclData->contacts;
 	}
@@ -227,7 +229,7 @@ void CellPhonePopup::setNoSignal(bool noSignal) {
 	_noSignal = noSignal;
 	CellPhoneData *cellData = (CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
 	if (cellData) {
-		cellData->noSignal = noSignal;
+		cellData->active().noSignal = noSignal;
 	}
 	if (_isVisible) {
 		drawScreenContent();
@@ -238,7 +240,7 @@ void CellPhonePopup::setBatteryLow(bool low) {
 	_batteryLow = low;
 	CellPhoneData *cellData = (CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
 	if (cellData) {
-		cellData->batteryLow = low;
+		cellData->active().batteryLow = low;
 	}
 	if (_isVisible) {
 		drawScreenContent();
@@ -255,7 +257,7 @@ void CellPhonePopup::addSearchLink(int16 mode, const SearchLink &link) {
 	// vs anything else (search) — not specifically mode == 1.
 	const bool isSearch = (mode != 0);
 	Common::Array<SearchLink> &list =
-		isSearch ? cellData->searchLinks : cellData->emailMessages;
+		isSearch ? cellData->active().searchLinks : cellData->active().emailMessages;
 
 	// Skip duplicates (matched by key) so re-running the scene doesn't
 	// pile up the same entries.
@@ -293,7 +295,7 @@ void CellPhonePopup::upsertContact(const UICL::Contact &c) {
 
 	CellPhoneData *cellData = (CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
 	if (cellData) {
-		cellData->contacts = _contacts;
+		cellData->active().contacts = _contacts;
 	}
 
 	if (_isVisible && _screenState == kDirectory) {
@@ -308,10 +310,11 @@ void CellPhonePopup::open() {
 
 	// Re-pull persistent state in case a save was loaded after init().
 	CellPhoneData *cellData = (CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
-	if (cellData && cellData->seeded) {
-		_contacts = cellData->contacts;
-		_noSignal = cellData->noSignal;
-		_batteryLow = cellData->batteryLow;
+	if (cellData && cellData->active().seeded) {
+		const CellPhoneData::Phone &phone = cellData->active();
+		_contacts = phone.contacts;
+		_noSignal = phone.noSignal;
+		_batteryLow = phone.batteryLow;
 		Common::sort(_contacts.begin(), _contacts.end(), contactNameLess);
 	}
 
@@ -618,7 +621,9 @@ void CellPhonePopup::drawScreenContent() {
 			// Nancy 13's top row is Cam / Menu / Dir; drawWebDirLabels() paints
 			// Menu + Dir, so add the Cam label. The "Welcome / River Heights
 			// Wireless" picture sits over the plain keyboard background.
-			drawRibbonLabel(_uiclData->dialLabel);
+			if (hasCameraFeature()) {
+				drawRibbonLabel(_uiclData->dialLabel);
+			}
 			if (!_noSignal) {
 				drawLcdTile(kN13MsgWelcome);
 			}
@@ -700,7 +705,10 @@ void CellPhonePopup::drawScreenContent() {
 		// the removed web browser was subButtons[5].
 		if (g_nancy->getGameType() >= kGameTypeNancy13) {
 			drawHubButton(kN13SubEmail);
-			drawHubButton(kN13SubViewPics);
+			// A phone without a camera leaves E-mail as the only option.
+			if (hasCameraFeature()) {
+				drawHubButton(kN13SubViewPics);
+			}
 		} else {
 			drawHubButton(kSubEmail);
 			// No cellular signal locks the phone to "Old Email Only", so the
@@ -1004,21 +1012,23 @@ Common::Array<uint> CellPhonePopup::listVisibleIndices() const {
 		return out;
 	}
 
+	const CellPhoneData::Phone &phone = cellData->active();
+
 	if (_screenState == kWebList) {
-		for (uint i = 0; i < cellData->searchLinks.size(); ++i) {
+		for (uint i = 0; i < phone.searchLinks.size(); ++i) {
 			out.push_back(i);
 		}
 	} else if (_screenState == kEmailList) {
 		const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
-		for (uint i = 0; i < cellData->emailMessages.size(); ++i) {
+		for (uint i = 0; i < phone.emailMessages.size(); ++i) {
 			// "Old Email Only" (no-signal) hides messages not yet read.
-			if (_noSignal && !cellData->emailMessages[i].read) {
+			if (_noSignal && !phone.emailMessages[i].read) {
 				continue;
 			}
 			// A subject with no text drops the whole row, letting the next
 			// message move up. Nancy15 reuses Nancy14's UICL chunk, whose
 			// initial e-mail has no text in Nancy15.
-			if (!autotext || !autotext->texts.contains(cellData->emailMessages[i].key)) {
+			if (!autotext || !autotext->texts.contains(phone.emailMessages[i].key)) {
 				continue;
 			}
 			out.push_back(i);
@@ -1032,8 +1042,9 @@ void CellPhonePopup::drawLinkList() {
 	if (!cellData) {
 		return;
 	}
+	const CellPhoneData::Phone &phone = cellData->active();
 	const Common::Array<SearchLink> &list =
-		_screenState == kWebList ? cellData->searchLinks : cellData->emailMessages;
+		_screenState == kWebList ? phone.searchLinks : phone.emailMessages;
 	const Common::Array<uint> visible = listVisibleIndices();
 	if (visible.empty()) {
 		return;
@@ -1984,6 +1995,14 @@ bool CellPhonePopup::isContactVisible(const UICL::Contact &c) const {
 	return NancySceneState.getEventFlag((int16)c.visibility, g_nancy->_true);
 }
 
+bool CellPhonePopup::hasCameraFeature() const {
+	// Nancy 13 and 14 keep the camera inside the phone. Nancy 15 moved it out
+	// to the standalone camera device and never raises the flag its phone
+	// checks before showing the Cam label or the Menu's "View Pictures"
+	// option, so both stay hidden even though the chunk still describes them.
+	return g_nancy->getGameType() < kGameTypeNancy15;
+}
+
 Common::Rect CellPhonePopup::hubEmailRect() const {
 	// The Email option button: subButtons[3] before Nancy 13, [4] in Nancy 13.
 	const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubEmail : kSubEmail;
@@ -2401,9 +2420,10 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 
 		// Highlight whichever option button the cursor is over.
 		const bool n13Hub = g_nancy->getGameType() >= kGameTypeNancy13;
-		// No signal removes the Internet Browser option (the Nancy 13 "view
-		// pictures" option in the same slot is not signal-gated).
-		const bool webDisabled = _noSignal && !n13Hub;
+		// No signal removes the Internet Browser option; the Nancy 13 "view
+		// pictures" option in the same slot is not signal-gated, but it is
+		// absent on a phone with no camera of its own.
+		const bool webDisabled = n13Hub ? !hasCameraFeature() : _noSignal;
 		const int emailSlot = n13Hub ? kN13SubEmail : kSubEmail;
 		const int webSlot = n13Hub ? kN13SubViewPics : kSubWeb;
 		const int newHubHover = emailR.contains(popupMouse) ? emailSlot
@@ -2618,8 +2638,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 			CellPhoneData *cellData = (CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
 			Common::Array<SearchLink> *list = nullptr;
 			if (cellData) {
-				list = (_screenState == kWebList) ? &cellData->searchLinks
-												  : &cellData->emailMessages;
+				list = (_screenState == kWebList) ? &cellData->active().searchLinks
+												  : &cellData->active().emailMessages;
 			}
 			// Map the visible row through the active filter to a real index.
 			const Common::Array<uint> visible = listVisibleIndices();
@@ -2813,7 +2833,7 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			playDialPadSound(_uiclData->dialPadSlots[UICL::kDialKeyTalk].soundName);
-			if (g_nancy->getGameType() >= kGameTypeNancy13 &&
+			if (g_nancy->getGameType() >= kGameTypeNancy13 && hasCameraFeature() &&
 					(_screenState == kWelcome || _screenState == kDialing)) {
 				// The Talk key doubles as the camera button on the welcome /
 				// dialing screen; in the directory it dials, so fall through.
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/cellphonepopup.h
index a0b97b067e4..7d6717b1a8d 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/cellphonepopup.h
@@ -276,6 +276,10 @@ private:
 	// needs an in-LCD title row.
 	uint listTitleRows() const { return 0; }
 
+	// Whether the phone itself takes and shows pictures, i.e. whether the Menu
+	// screen offers "View Pictures" and the top row carries the Cam label.
+	bool hasCameraFeature() const;
+
 	// Layout for the two clickable labels on the Online hub.
 	Common::Rect hubEmailRect() const;
 	Common::Rect hubWebRect() const;


Commit: a3cb030d4a036fc7ffc5f4a3a0846e6496387753
    https://github.com/scummvm/scummvm/commit/a3cb030d4a036fc7ffc5f4a3a0846e6496387753
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-24T09:21:28+03:00

Commit Message:
NANCY: NANCY15: Implement savegame data changes for new features - 3/3

- QuizPuzzle slots are keyed by a 32-bit id
- Protagonists carry their own cell phone

This is the third commit in the series, which implements the savegame
changes for the two new features. This has been deliberately done in a
separate commit that modifies the savgame format once for both the two
new features

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


diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index 44966f582f2..74cbeecae4b 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -68,7 +68,9 @@ namespace Nancy {
 // - 11: Nancy12+ TableData holds 100 single values instead of 30, and
 //       the Nancy14/15 player clock only holds the time of the current
 //       day, with the day itself stored separately
-static const int kSavegameVersion = 11;
+// - 12: QuizPuzzle slots are keyed by a 32-bit id, and the Nancy15
+//       protagonists each carry their own cell phone
+static const int kSavegameVersion = 12;
 
 struct NancyGameDescription;
 
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 918e3d656ab..cbfad7203be 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -143,16 +143,16 @@ void SimplePuzzleData::synchronize(Common::Serializer &ser) {
 
 // PCUI has room for more characters than any game actually ships, so the
 // active one is kept inside the journals we keep
-static uint activeJournalSlot() {
+static uint activePlayerCharacterSlot() {
 	return MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1);
 }
 
 Common::Array<JournalData::Entry> &JournalData::entries(uint16 surfaceID) {
-	return journalEntries[activeJournalSlot()][surfaceID];
+	return journalEntries[activePlayerCharacterSlot()][surfaceID];
 }
 
 bool JournalData::hasEntries(uint16 surfaceID) const {
-	return journalEntries[activeJournalSlot()].contains(surfaceID);
+	return journalEntries[activePlayerCharacterSlot()].contains(surfaceID);
 }
 
 void JournalData::inheritEntries(uint from, uint to) {
@@ -298,20 +298,26 @@ void GridMapPuzzleData::synchronize(Common::Serializer &ser) {
 }
 
 void QuizPuzzleData::synchronize(Common::Serializer &ser) {
-	// Serialize as: numScenes, then for each scene: sceneID, numBoxes, box data
-	uint16 numScenes = (uint16)boxCorrect.size();
-	ser.syncAsUint16LE(numScenes);
+	// Serialize as: numSlots, then for each slot: key, numBoxes, box data
+	uint16 numSlots = (uint16)boxCorrect.size();
+	ser.syncAsUint16LE(numSlots);
 
 	if (ser.isLoading()) {
 		boxCorrect.clear();
 		typedText.clear();
-		for (uint16 s = 0; s < numScenes; ++s) {
-			uint16 sceneID = 0;
-			ser.syncAsUint16LE(sceneID);
+		for (uint16 s = 0; s < numSlots; ++s) {
+			uint32 key = 0;
+			if (ser.getVersion() >= 12) {
+				ser.syncAsUint32LE(key);
+			} else {
+				uint16 sceneID = 0;
+				ser.syncAsUint16LE(sceneID);
+				key = sceneID;
+			}
 			byte num = 0;
 			ser.syncAsByte(num);
-			auto &bc = boxCorrect[sceneID];
-			auto &tt = typedText[sceneID];
+			auto &bc = boxCorrect[key];
+			auto &tt = typedText[key];
 			bc.resize(num, false);
 			tt.resize(num);
 			for (uint i = 0; i < num; ++i) {
@@ -323,11 +329,11 @@ void QuizPuzzleData::synchronize(Common::Serializer &ser) {
 		}
 	} else {
 		for (auto &entry : boxCorrect) {
-			uint16 sceneID = entry._key;
-			ser.syncAsUint16LE(sceneID);
+			uint32 key = entry._key;
+			ser.syncAsUint32LE(key);
 			byte num = (byte)entry._value.size();
 			ser.syncAsByte(num);
-			auto &tt = typedText[sceneID];
+			auto &tt = typedText[key];
 			for (uint i = 0; i < num; ++i) {
 				byte b = entry._value[i] ? 1 : 0;
 				ser.syncAsByte(b);
@@ -418,11 +424,32 @@ void TableData::setValue(uint16 index, int16 value) {
 	}
 }
 
+CellPhoneData::Phone &CellPhoneData::active() {
+	return phones[activePlayerCharacterSlot()];
+}
+
+const CellPhoneData::Phone &CellPhoneData::active() const {
+	return phones[activePlayerCharacterSlot()];
+}
+
 void CellPhoneData::synchronize(Common::Serializer &ser) {
-	ser.syncAsByte(noSignal);
-	ser.syncAsByte(batteryLow);
-	ser.syncAsByte(seeded);
+	syncPhone(ser, phones[0]);
+
+	// Nancy15+ protagonists each carry their own phone. Only their slots are
+	// written, so the save format of every earlier game is untouched.
+	if (g_nancy->getGameType() >= kGameTypeNancy15 && ser.getVersion() >= 12) {
+		for (uint i = 1; i < kMaxPlayerCharacters; ++i) {
+			syncPhone(ser, phones[i]);
+		}
+	}
+}
+
+void CellPhoneData::syncPhone(Common::Serializer &ser, Phone &phone) {
+	ser.syncAsByte(phone.noSignal);
+	ser.syncAsByte(phone.batteryLow);
+	ser.syncAsByte(phone.seeded);
 
+	Common::Array<UICL::Contact> &contacts = phone.contacts;
 	uint16 numContacts = (uint16)contacts.size();
 	ser.syncAsUint16LE(numContacts);
 
@@ -457,8 +484,8 @@ void CellPhoneData::synchronize(Common::Serializer &ser) {
 		}
 	}
 
-	syncLinkArray(ser, emailMessages);
-	syncLinkArray(ser, searchLinks);
+	syncLinkArray(ser, phone.emailMessages);
+	syncLinkArray(ser, phone.searchLinks);
 }
 
 void CellPhoneData::syncLinkArray(Common::Serializer &ser, Common::Array<SearchLink> &arr) {
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index dd739c748e5..28f1973ccd7 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -176,10 +176,12 @@ struct QuizPuzzleData : public PuzzleData {
 	static constexpr uint32 getTag() { return MKTAG('Q', 'U', 'I', 'Z'); }
 	virtual void synchronize(Common::Serializer &ser);
 
-	// Keyed by solve-scene ID so that multiple QuizPuzzle instances
-	// (e.g. a two-page Nancy 9 puzzle) each maintain their own state.
-	Common::HashMap<uint16, Common::Array<bool>> boxCorrect;
-	Common::HashMap<uint16, Common::Array<Common::String>> typedText;
+	// Keyed by solve-scene ID up to Nancy 14, so that multiple QuizPuzzle
+	// instances (e.g. a two-page Nancy 9 puzzle) each maintain their own state.
+	// Nancy 15 keys by (scene ID << 16 | record index), as the original does,
+	// since one scene can hold several quiz records sharing a solve scene.
+	Common::HashMap<uint32, Common::Array<bool>> boxCorrect;
+	Common::HashMap<uint32, Common::Array<Common::String>> typedText;
 };
 
 struct JournalData : public PuzzleData {
@@ -260,21 +262,34 @@ struct CellPhoneData : public PuzzleData {
 	static constexpr uint32 getTag() { return MKTAG('C', 'E', 'L', 'L'); }
 	virtual void synchronize(Common::Serializer &ser);
 
-	bool noSignal = false;
-	bool batteryLow = false;
-	// Loaded set to true once the popup has seeded the contact list from
-	// the UICL chunk; we then own it as runtime data.
-	bool seeded = false;
-	Common::Array<UICL::Contact> contacts;
+	// Everything one handset holds. From Nancy15 each protagonist carries
+	// their own phone, with their own contacts, mail and reception, so the
+	// state is kept per player character. Earlier games have a single
+	// character and only ever touch slot 0.
+	struct Phone {
+		bool noSignal = false;
+		bool batteryLow = false;
+		// Set to true once the popup has seeded the contact list from the
+		// UICL chunk; we then own it as runtime data.
+		bool seeded = false;
+		Common::Array<UICL::Contact> contacts;
+
+		// Populated by AR 131 (AddSearchLink). Mode 0 → emailMessages (each
+		// with a body-text CVTX key + read flag); any non-zero mode →
+		// searchLinks (web search topics).
+		Common::Array<SearchLink> emailMessages;
+		Common::Array<SearchLink> searchLinks;
+	};
+
+	// The phone belonging to the character currently being played.
+	Phone &active();
+	const Phone &active() const;
 
-	// Populated by AR 131 (AddSearchLink). Mode 0 → emailMessages (each
-	// with a body-text CVTX key + read flag); any non-zero mode →
-	// searchLinks (web search topics).
-	Common::Array<SearchLink> emailMessages;
-	Common::Array<SearchLink> searchLinks;
+	Phone phones[kMaxPlayerCharacters];
 
 private:
-	void syncLinkArray(Common::Serializer &ser, Common::Array<SearchLink> &arr);
+	static void syncPhone(Common::Serializer &ser, Phone &phone);
+	static void syncLinkArray(Common::Serializer &ser, Common::Array<SearchLink> &arr);
 };
 
 // A cell-phone camera snapshot (Nancy 13). Stored as raw BGRA32 pixels so it


Commit: 217e5d2b422d83577cddff16a94eafab162b8ba7
    https://github.com/scummvm/scummvm/commit/217e5d2b422d83577cddff16a94eafab162b8ba7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-24T09:21:30+03:00

Commit Message:
NANCY: NANCY15: Enable the coin wallet feature for the Hardy boys

Frank & Joe Hardy have a coin wallet (unlike Nancy) as a fourth icon,
so handle it accordingly

Changed paths:
    engines/nancy/action/miscrecords.cpp
    engines/nancy/enginedata.cpp
    engines/nancy/enginedata.h
    engines/nancy/ui/taskbar.cpp
    engines/nancy/ui/taskbar.h


diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 3f7bfd7e92c..5bd810f380f 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1044,12 +1044,17 @@ void ResourceUse::init() {
 	}
 
 	if (haveItem && _drawResourceValue) {
+		// Unlike the taskbar coin purse, the overlay prints the bare number:
+		// no currency symbol and no decimal point, whatever the record says
 		const UIRC::ItemRecord &item = uirc->items[_resourceIndex];
 		const Font *font = g_nancy->_graphics->getFont(item.fontID);
-		if (font && item.numDecimals >= 0) {
+		if (font) {
 			const Common::String text =
-				formatUIResourceValue(item, NancySceneState.getUIResource(_resourceIndex, getCharacterIndex()));
-			font->drawString(&_drawSurface, text, _valueDest.x, _valueDest.y, screenBounds.width() - _valueDest.x, 0);
+				Common::String::format("%d", NancySceneState.getUIResource(_resourceIndex, getCharacterIndex()));
+
+			// The record's y is the bottom row the glyphs are aligned on
+			const int y = _valueDest.y - font->getFontHeight() + 1;
+			font->drawString(&_drawSurface, text, _valueDest.x, y, screenBounds.width() - _valueDest.x, 0);
 		}
 	}
 
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 46926373ac9..9373a0f73b8 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1415,27 +1415,35 @@ UIRC::UIRC(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 
 Common::String formatUIResourceValue(const UIRC::ItemRecord &item, int32 value) {
 	// Nancy 12 counts cents and shows a dollar amount, Nancy 14 counts whole
-	// euros. 0x80 is the euro sign in the games' extended ASCII character set.
-	const char currencySymbol = g_nancy->getGameType() >= kGameTypeNancy14 ? '\x80' : '$';
+	// euros, and Nancy 15 is back to dollars. 0x80 is the euro sign in the
+	// games' extended ASCII character set.
+	const char currencySymbol = g_nancy->getGameType() == kGameTypeNancy14 ? '\x80' : '$';
 
-	int32 divisor = 1;
-	for (int16 i = 0; i < item.numDecimals; ++i) {
-		divisor *= 10;
+	// The value is simply printed, then split so that its last numDecimals
+	// digits become the fraction; an amount below one unit is zero-padded to
+	// that many digits and printed without a leading zero (".05", not "0.05").
+	const uint numDecimals = MAX<int16>(item.numDecimals, 0);
+	Common::String digits = Common::String::format("%d", value);
+	while (digits.size() < numDecimals) {
+		digits = "0" + digits;
 	}
 
-	Common::String ret = Common::String::format("%c%d", currencySymbol, value / divisor);
+	const uint split = digits.size() - numDecimals;
+	Common::String ret(currencySymbol);
+	ret += Common::String(digits.c_str(), split);
 
-	if (item.numDecimals > 0) {
-		Common::String decimals = Common::String::format("%d", value % divisor);
-		while ((int16)decimals.size() < item.numDecimals) {
-			decimals = "0" + decimals;
-		}
-		ret += "." + decimals;
+	if (numDecimals > 0) {
+		ret += "." + Common::String(digits.c_str() + split);
 	}
 
 	return ret;
 }
 
+bool hasMoneyResource() {
+	const GameType gameType = g_nancy->getGameType();
+	return gameType == kGameTypeNancy12 || gameType == kGameTypeNancy14 || gameType == kGameTypeNancy15;
+}
+
 MMIX::MMIX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	const uint16 count = chunkStream->readUint16LE();
 	records.resize(count);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index ca92e71864f..e7f43bded43 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -545,10 +545,11 @@ enum TaskButton {
 	kTaskButtonInventory = 1,
 	kTaskButtonNotebook = 2,
 	kTaskButtonCellphone = 3,
-	// Nancy12 only: a non-clickable coin purse that shows Nancy's money on
-	// hover, inserted before HELP. HELP is therefore always the last taskbar
-	// button (index 4 in games without the coin purse, index 5 in Nancy12) and
-	// has no fixed constant.
+	// Nancy12, Nancy14 and Nancy15: a non-clickable coin purse (a wallet for
+	// the Hardy boys) that shows the played character's money on hover,
+	// inserted before HELP. HELP is therefore always the last taskbar button
+	// (index 4 in games without the coin purse, index 5 in the others) and has
+	// no fixed constant.
 	kTaskButtonCoinPurse = 4
 };
 
@@ -893,6 +894,10 @@ struct UIRC : public EngineData {
 // followed by the value, split into whole units and decimals as the record asks.
 Common::String formatUIResourceValue(const UIRC::ItemRecord &item, int32 value);
 
+// True in the games that keep the played character's money in UI resource 0 and
+// show it on the taskbar's coin purse (a wallet for the Hardy boys).
+bool hasMoneyResource();
+
 // Music mix table. Introduced in Nancy 13. Each record maps a short location
 // code (e.g. "BRI", "CAM", "TUT") to the set of music / ambience tracks that
 // may play there ("TacitA"/"TacitB" are the silence variants).
diff --git a/engines/nancy/ui/taskbar.cpp b/engines/nancy/ui/taskbar.cpp
index bb51bbf7a6b..97c189cdf65 100644
--- a/engines/nancy/ui/taskbar.cpp
+++ b/engines/nancy/ui/taskbar.cpp
@@ -158,8 +158,7 @@ bool Taskbar::isButtonActive(uint index) const {
 }
 
 bool Taskbar::isMoneyDisplay(uint index) const {
-	const GameType gameType = g_nancy->getGameType();
-	return (gameType == kGameTypeNancy12 || gameType == kGameTypeNancy14) && index == kTaskButtonCoinPurse;
+	return hasMoneyResource() && index == kTaskButtonCoinPurse;
 }
 
 void Taskbar::drawMoney() {
@@ -172,6 +171,8 @@ void Taskbar::drawMoney() {
 	// The coin purse displays UI resource 0: its current value, rendered in the
 	// font the record selects. The live value lives in the scene state (seeded
 	// from UIRC, changed by AR 132); UIRC only supplies the formatting config.
+	// From Nancy15 both are per player character, so the wallet each Hardy boy
+	// carries draws from his own chunk and his own resource set.
 	const UIRC::ItemRecord &res = uirc->items[0];
 	if (res.numDecimals < 0) {
 		return;
@@ -186,13 +187,27 @@ void Taskbar::drawMoney() {
 	Common::Rect dst = taskData->buttons[kTaskButtonCoinPurse].button.destRect;
 	dst.translate(-_screenPosition.left, -_screenPosition.top);
 
-	// A small inset from the left, and a little below the button's vertical
-	// center. That vertical coordinate is the bottom row the glyphs are aligned
-	// on, while drawString() takes the top of the line, so shift it up by the
-	// height of a line.
-	const bool isNancy14 = g_nancy->getGameType() == kGameTypeNancy14;
-	const int x = dst.left + (isNancy14 ? 15 : 12);
-	const int y = dst.top + dst.height() / 2 + (isNancy14 ? 8 : 10) - font->getFontHeight() + 1;
+	// A small inset from the left, and (up to Nancy14) a little below the
+	// button's vertical center. That vertical coordinate is the bottom row the
+	// glyphs are aligned on, while drawString() takes the top of the line, so
+	// shift it up by the height of a line.
+	int inset = 12;
+	int drop = 10;
+	switch (g_nancy->getGameType()) {
+	case kGameTypeNancy14 :
+		inset = 15;
+		drop = 8;
+		break;
+	case kGameTypeNancy15 :
+		inset = 20;
+		drop = 0;
+		break;
+	default :
+		break;
+	}
+
+	const int x = dst.left + inset;
+	const int y = dst.top + dst.height() / 2 + drop - font->getFontHeight() + 1;
 	font->drawString(&_drawSurface, text, x, y, dst.right - x, 0, Graphics::kTextAlignLeft);
 	_needsRedraw = true;
 }
@@ -460,8 +475,8 @@ void Taskbar::handleInput(NancyInput &input) {
 
 	g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 
-	// The coin purse shows Nancy's money on hover but isn't clickable, so it
-	// skips the press/click handling below.
+	// The coin purse shows the played character's money on hover but isn't
+	// clickable, so it skips the press/click handling below.
 	if (isMoneyDisplay(newHovered)) {
 		return;
 	}
diff --git a/engines/nancy/ui/taskbar.h b/engines/nancy/ui/taskbar.h
index f5d18a54962..f21d8ef6baa 100644
--- a/engines/nancy/ui/taskbar.h
+++ b/engines/nancy/ui/taskbar.h
@@ -123,10 +123,11 @@ private:
 	bool isButtonActive(uint index) const;
 
 	// Nancy12 replaces the (era-inappropriate) cell phone with a coin purse that
-	// is not clickable but shows Nancy's money when hovered. True only for that
-	// game and button slot.
+	// is not clickable but shows Nancy's money when hovered. Nancy14 does the
+	// same, and in Nancy15 the Hardy boys carry a wallet in that slot. True
+	// only for those games and that button slot.
 	bool isMoneyDisplay(uint index) const;
-	// Draw Nancy's current money over the coin purse button (Nancy12 only).
+	// Draw the played character's current money over the coin purse button.
 	void drawMoney();
 
 	// Play a normal click sound (the button's clickSound), or the "popup


Commit: 6063599a9728dfb8479a65878fb66a3c8fa4cedf
    https://github.com/scummvm/scummvm/commit/6063599a9728dfb8479a65878fb66a3c8fa4cedf
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-24T09:21:31+03:00

Commit Message:
NANCY: Add debug commands for getting/setting character money

Applies to games that use a wallet/purse and money, which include
Nancy12, Nancy14 and Nancy15, for now

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


diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index f10fc30727e..66b2d5edf38 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -69,6 +69,8 @@ NancyConsole::NancyConsole() : GUI::Debugger() {
 	registerCmd("set_eventflags", WRAP_METHOD(NancyConsole, Cmd_setEventFlags));
 	registerCmd("get_inventory", WRAP_METHOD(NancyConsole, Cmd_getInventory));
 	registerCmd("set_inventory", WRAP_METHOD(NancyConsole, Cmd_setInventory));
+	registerCmd("get_money", WRAP_METHOD(NancyConsole, Cmd_getMoney));
+	registerCmd("set_money", WRAP_METHOD(NancyConsole, Cmd_setMoney));
 	registerCmd("get_player_time", WRAP_METHOD(NancyConsole, Cmd_getPlayerTime));
 	registerCmd("set_player_time", WRAP_METHOD(NancyConsole, Cmd_setPlayerTime));
 	registerCmd("get_difficulty", WRAP_METHOD(NancyConsole, Cmd_getDifficulty));
@@ -1232,6 +1234,123 @@ bool NancyConsole::Cmd_setInventory(int argc, const char **argv) {
 	return cmdExit(0, nullptr);
 }
 
+bool NancyConsole::Cmd_getMoney(int argc, const char **argv) {
+	if (g_nancy->getState() != NancyState::kScene) {
+		debugPrintf("Not in the kScene state\n");
+		return true;
+	}
+
+	if (!hasMoneyResource()) {
+		debugPrintf("This game doesn't have a coin purse\n");
+		return true;
+	}
+
+	auto *resourceData = GetEngineData(UIRC);
+	if (!resourceData || resourceData->items.empty()) {
+		debugPrintf("No UI resource data loaded\n");
+		return true;
+	}
+
+	// Money is UI resource 0; the purse formats it using that record's settings
+	const UIRC::ItemRecord &item = resourceData->items[0];
+	uint numCharacters = numPlayerCharacters();
+
+	if (numCharacters == 1) {
+		int32 value = NancySceneState.getUIResource(0);
+		debugPrintf("Money: %s (%d)\n", formatUIResourceValue(item, value).c_str(), value);
+		return true;
+	}
+
+	// From Nancy15 every player character carries their own money
+	debugPrintf("Playing as %s\n", playerCharacterName(g_nancy->getPlayerCharacter()).c_str());
+
+	for (uint i = 0; i < numCharacters; ++i) {
+		int32 value = NancySceneState.getUIResource(0, (byte)i);
+		debugPrintf("%s: %s (%d)\n",
+			playerCharacterName(i).c_str(),
+			formatUIResourceValue(item, value).c_str(),
+			value);
+	}
+
+	return true;
+}
+
+bool NancyConsole::Cmd_setMoney(int argc, const char **argv) {
+	if (g_nancy->getState() != NancyState::kScene) {
+		debugPrintf("Not in the kScene state\n");
+		return true;
+	}
+
+	if (!hasMoneyResource()) {
+		debugPrintf("This game doesn't have a coin purse\n");
+		return true;
+	}
+
+	auto *resourceData = GetEngineData(UIRC);
+	if (!resourceData || resourceData->items.empty()) {
+		debugPrintf("No UI resource data loaded\n");
+		return true;
+	}
+
+	// Without -c the money goes to whoever is being played, which is the only
+	// purse the games before Nancy15 have
+	uint characterIndex = g_nancy->getPlayerCharacter();
+	int valueArg = 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;
+		valueArg = 3;
+	}
+
+	const UIRC::ItemRecord &item = resourceData->items[0];
+
+	if (argc != valueArg + 1) {
+		debugPrintf("Sets the money a player character carries.\n");
+		debugPrintf("Usage: %s [-c <characterIndex>] <value>\n", argv[0]);
+		debugPrintf("-c picks the player character to give the money to (Nancy15+); the character being played is the default.\n");
+		debugPrintf("The value is a whole number in the purse's smallest unit, so 1234 means %s\n",
+			formatUIResourceValue(item, 1234).c_str());
+		return true;
+	}
+
+	int32 value = atoi(argv[valueArg]);
+
+	if (value < 0) {
+		debugPrintf("Invalid value %s\n", argv[valueArg]);
+		return true;
+	}
+
+	// Nancy14 added a maximum that empties the purse instead of capping it,
+	// so setting a value above it would leave the character with nothing
+	if (g_nancy->getGameType() >= kGameTypeNancy14 && value > (int32)item.maxValue) {
+		debugPrintf("Value %d is above the maximum of %u, which would empty the purse\n", value, item.maxValue);
+		return true;
+	}
+
+	NancySceneState.setUIResource(0, value, (byte)characterIndex);
+
+	if (numPlayerCharacters() > 1) {
+		debugPrintf("Set the money %s carries to %s\n",
+			playerCharacterName(characterIndex).c_str(),
+			formatUIResourceValue(item, value).c_str());
+	} else {
+		debugPrintf("Set money to %s\n", formatUIResourceValue(item, value).c_str());
+	}
+
+	return cmdExit(0, nullptr);
+}
+
 bool NancyConsole::Cmd_getPlayerTime(int argc, const char **argv) {
 	if (g_nancy->getState() != NancyState::kScene) {
 		debugPrintf("Not in the kScene state\n");
diff --git a/engines/nancy/console.h b/engines/nancy/console.h
index 5c00b0d693f..3f5b3d027b0 100644
--- a/engines/nancy/console.h
+++ b/engines/nancy/console.h
@@ -64,6 +64,8 @@ private:
 	bool Cmd_setEventFlags(int argc, const char **argv);
 	bool Cmd_getInventory(int argc, const char **argv);
 	bool Cmd_setInventory(int argc, const char **argv);
+	bool Cmd_getMoney(int argc, const char **argv);
+	bool Cmd_setMoney(int argc, const char **argv);
 	bool Cmd_getPlayerTime(int argc, const char **argv);
 	bool Cmd_setPlayerTime(int argc, const char **argv);
 	bool Cmd_getDifficulty(int argc, const char **argv);


Commit: 728d586cec2651ac361a210fdeb2370b05755d5b
    https://github.com/scummvm/scummvm/commit/728d586cec2651ac361a210fdeb2370b05755d5b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-24T09:21:32+03:00

Commit Message:
NANCY: NANCY8-15: Verify QuizPuzzle against all games that use it

Checked the functionality of QuizPuzzle against Nancy8, Nancy9,
Nancy10, Nancy12 and Nancy15.

- Check textbox value when it changes in Nancy15, like the original -
  fixes clicking on the submit button while the last text box is still
  focused
- Only accept characters that fit in the box and clear a margin at the
  right side of the screen
- Remove changing to another textbox with the tab key in games earlier
  than Nancy15 and only allow the enter key to move between text boxes,
  like the original
- Skip unused bytes at the end of the data record in Nancy8

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


diff --git a/engines/nancy/action/puzzle/quizpuzzle.cpp b/engines/nancy/action/puzzle/quizpuzzle.cpp
index 8e2f088435c..39c2e53e104 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.cpp
+++ b/engines/nancy/action/puzzle/quizpuzzle.cpp
@@ -73,6 +73,7 @@ void QuizPuzzle::init() {
 // 0x243   49    doneSound (readNormal)
 // 0x274   30    done subtitle (skip)
 // 0x292   25    cancelScene
+// 0x2AB   16    unused; the record is a fixed 699 bytes
 void QuizPuzzle::readDataNancy8(Common::SeekableReadStream &stream) {
 	_fontID            = stream.readUint16LE();
 	_cursorBlinkInterval = stream.readUint16LE();
@@ -109,6 +110,10 @@ void QuizPuzzle::readDataNancy8(Common::SeekableReadStream &stream) {
 
 	_cancelScene.readData(stream);
 
+	// The record is a fixed 699 bytes and nothing reads this tail. Leaving it
+	// unread would make the action record loader mistake it for a dependency.
+	stream.skip(16);
+
 	if (numBoxes < _boxes.size()) {
 		_boxes.resize(numBoxes);
 	}
@@ -331,6 +336,17 @@ bool QuizPuzzle::acceptsChar(char c) const {
 	return false;
 }
 
+// From Nancy 9 to Nancy 14 the original measures the text already in the box
+// and refuses the keystroke unless it still leaves a margin at the right edge.
+bool QuizPuzzle::fitsInBox(const Box &box, const Common::String &text) const {
+	const Graphics::Font *font = g_nancy->_graphics->getFont(_fontID);
+	if (!font) {
+		return true;
+	}
+
+	return font->getStringWidth(text) < box.rect.width() - kBoxTextMargin;
+}
+
 bool QuizPuzzle::playSoundBlock(const RandomSoundBlock &block) {
 	if (block.names.empty()) {
 		return false;
@@ -540,20 +556,21 @@ void QuizPuzzle::executeNancy9() {
 				drawText();
 				_pendingBackspace = false;
 			} else if (_pendingReturn) {
-				if (text.empty()) {
-					if (hadCursor) text += _cursorChar;
-					if (!_skipEmptyOnEnter)
-						advanceToNextBox();
-				} else {
-					if (hadCursor) text += _cursorChar;
+				if (hadCursor) text += _cursorChar;
+
+				if (isNancy15 || !text.empty()) {
+					// Nancy 15 checks the box whatever it holds, empty included
 					_internalState = kCheckAnswer;
+				} else if (!_skipEmptyOnEnter) {
+					advanceToNextBox();
 				}
 				_pendingReturn = false;
 			} else if (_pendingChar != 0) {
 				if (!acceptsChar(_pendingChar)) {
 					// Nancy 15 buzzes at characters the record does not allow
 					playSoundBlock(_invalidKeySound);
-				} else if (text.size() < getMaxTypedLength()) {
+				} else if (text.size() < getMaxTypedLength() &&
+						(isNancy15 || fitsInBox(box, text))) {
 					text += _pendingChar;
 
 					if (isNancy15) {
@@ -571,6 +588,13 @@ void QuizPuzzle::executeNancy9() {
 				drawText();
 				_pendingChar = 0;
 			}
+
+			// Nancy 15 re-scores the box the cursor is still in after every key,
+			// so a box that is filled in and then left with the mouse - by
+			// clicking the submit hotspot, say - has already been graded
+			if (isNancy15 && _internalState == kTyping) {
+				checkAnswerForCurrentBox(true);
+			}
 		}
 
 		// Cursor blink (only while still typing)
@@ -751,6 +775,8 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 	}
 
 	const char cursorChar = getCursorChar();
+	const bool isNancy15 = g_nancy->getGameType() >= kGameTypeNancy15;
+	bool mouseOverHotspot = false;
 
 	// Nancy 9+: give-up hotspot. Clicking it cancels the puzzle.
 	if (g_nancy->getGameType() != kGameTypeNancy8 && !_exitHotspot.isEmpty()) {
@@ -765,45 +791,52 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 			if (input.input & NancyInput::kLeftMouseButtonUp) {
 				// From Nancy 15 the give-up hotspot submits the quiz: with every
 				// box answered it leads to the solve scene instead
-				if (g_nancy->getGameType() >= kGameTypeNancy15 && checkAllSolved()) {
+				if (isNancy15 && checkAllSolved()) {
 					_solved = true;
 					_internalState = kStartDone;
 				} else {
 					_cancelled = true;
 					_state = kActionTrigger;
 				}
+				return;
 			}
-			return;
+
+			mouseOverHotspot = true;
 		}
 	}
 
 	// Nancy 15: regions around the boxes that only set an event flag when clicked
-	for (uint i = 0; i < _hotspots.size(); ++i) {
-		const ExitHotspot &hotspot = _hotspots[i];
-		if (hotspot.hotspot.isEmpty() ||
-				!NancySceneState.getViewport().convertViewportToScreen(hotspot.hotspot).contains(input.mousePos)) {
-			continue;
-		}
+	if (!mouseOverHotspot) {
+		for (uint i = 0; i < _hotspots.size(); ++i) {
+			const ExitHotspot &hotspot = _hotspots[i];
+			if (hotspot.hotspot.isEmpty() ||
+					!NancySceneState.getViewport().convertViewportToScreen(hotspot.hotspot).contains(input.mousePos)) {
+				continue;
+			}
 
-		if (hotspot.cursorType != 0) {
-			g_nancy->_cursor->setCursorType((CursorManager::CursorType)hotspot.cursorType, true);
-		} else {
-			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
-		}
+			if (hotspot.cursorType != 0) {
+				g_nancy->_cursor->setCursorType((CursorManager::CursorType)hotspot.cursorType, true);
+			} else {
+				g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+			}
 
-		if (input.input & NancyInput::kLeftMouseButtonUp) {
-			NancySceneState.setEventFlag(hotspot.flag);
-			if (hotspot.scene.sceneID != kNoScene) {
-				NancySceneState.changeScene(hotspot.scene);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				NancySceneState.setEventFlag(hotspot.flag);
+				if (hotspot.scene.sceneID != kNoScene) {
+					NancySceneState.changeScene(hotspot.scene);
+					return;
+				}
 			}
+
+			mouseOverHotspot = true;
+			break;
 		}
-		return;
 	}
 
-	// Hover over an unsolved text box: show the hotspot cursor and, on click,
-	// move the typing focus to that box.
-	for (uint i = 0; i < _boxes.size(); ++i) {
-		if (_boxes[i].correct)
+	// Hover over a text box: show the hotspot cursor and, on click, move the
+	// typing focus to that box. From Nancy 15 an answered box can be edited again.
+	for (uint i = 0; i < _boxes.size() && !mouseOverHotspot; ++i) {
+		if (_boxes[i].correct && !isNancy15)
 			continue;
 		Common::Rect screenRect = NancySceneState.getViewport().convertViewportToScreen(_boxes[i].rect);
 		if (!screenRect.contains(input.mousePos))
@@ -816,6 +849,13 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 				Common::String &oldText = _boxes[_currentBox].typedText;
 				if (!oldText.empty() && oldText.lastChar() == cursorChar)
 					oldText.deleteLastChar();
+
+				if (isNancy15) {
+					// Nancy 15 scores the box being left, so a form filled in by
+					// clicking from box to box is graded like a tabbed one
+					checkAnswerForCurrentBox(true);
+				}
+
 				_currentBox = i;
 				_nextBlinkTime = 0;
 				drawText();
@@ -824,18 +864,18 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 		break;
 	}
 
+	// The keyboard is read whatever the mouse happens to be over
 	for (auto &key : input.otherKbdInput) {
 		if (key.keycode == Common::KEYCODE_BACKSPACE) {
 			_pendingBackspace = true;
 			_hasNewKey = true;
 		} else if (key.keycode == Common::KEYCODE_TAB) {
-			// Tab: advance to next unsolved box without submitting
-			Common::String &oldText = _boxes[_currentBox].typedText;
-			if (!oldText.empty() && oldText.lastChar() == cursorChar)
-				oldText.deleteLastChar();
-			advanceToNextBox();
-			_nextBlinkTime = 0;
-			drawText();
+			// Nancy 15 submits the box on Tab, exactly as it does on Enter. The
+			// earlier games have no Tab handling at all, so it does nothing there.
+			if (isNancy15) {
+				_pendingReturn = true;
+				_hasNewKey = true;
+			}
 		} else if (key.keycode == Common::KEYCODE_RETURN ||
 		           key.keycode == Common::KEYCODE_KP_ENTER) {
 			_pendingReturn = true;
@@ -843,8 +883,8 @@ void QuizPuzzle::handleInput(NancyInput &input) {
 		} else if (key.ascii != 0 && key.ascii != (byte)cursorChar) {
 			bool accept = false;
 			if (g_nancy->getGameType() == kGameTypeNancy8) {
-				accept = Common::isAlnum(key.ascii) || key.ascii == '.' || key.ascii == '-' ||
-				         key.ascii == '\'' || Common::isSpace(key.ascii);
+				// Nancy 8 takes letters, digits and the space bar, nothing else
+				accept = Common::isAlnum(key.ascii) || key.ascii == ' ';
 			} else {
 				accept = key.ascii >= 0x20 && key.ascii < 0x7f;
 			}
@@ -865,8 +905,10 @@ void QuizPuzzle::drawText() {
 	_drawSurface.clear(g_nancy->_graphics->getTransColor());
 
 	const Graphics::Font *font = g_nancy->_graphics->getFont(_fontID);
-	if (!font)
+	if (!font) {
+		warning("QuizPuzzle: font %u is missing, no answer text will be drawn", _fontID);
 		return;
+	}
 
 	// Nancy 15 draws a box's text into its rect widened by the cursor character
 	const int extraWidth = g_nancy->getGameType() >= kGameTypeNancy15 ? 8 : 0;
@@ -900,10 +942,12 @@ bool QuizPuzzle::checkAllSolved() const {
 		if (!_boxes[i].correct)
 			return false;
 	}
-	return !_boxes.empty();
+
+	// A record with no boxes counts as solved, as it does in the original
+	return true;
 }
 
-bool QuizPuzzle::checkAnswerForCurrentBox() {
+bool QuizPuzzle::checkAnswerForCurrentBox(bool silent) {
 	Box &box = _boxes[_currentBox];
 	const char cursorChar = getCursorChar();
 
@@ -924,7 +968,9 @@ bool QuizPuzzle::checkAnswerForCurrentBox() {
 		if (!box.typedText.empty() && box.typedText.lastChar() == cursorChar)
 			box.typedText.deleteLastChar();
 		box.correct = true;
-		if (box.correctFlag != -1)
+
+		// A silent check only scores the box; its flag is set by the Enter/Tab path
+		if (!silent && box.correctFlag != -1)
 			NancySceneState.setEventFlag(box.correctFlag, g_nancy->_true);
 
 		saveAnswer(_currentBox);
@@ -1005,7 +1051,7 @@ void QuizPuzzle::loadSavedAnswers() {
 	uint currentBox = _currentBox;
 	for (uint i = 0; i < _boxes.size(); ++i) {
 		_currentBox = i;
-		checkAnswerForCurrentBox();
+		checkAnswerForCurrentBox(true);
 	}
 	_currentBox = currentBox;
 }
diff --git a/engines/nancy/action/puzzle/quizpuzzle.h b/engines/nancy/action/puzzle/quizpuzzle.h
index 777d56ec0fc..ca6f619a4e4 100644
--- a/engines/nancy/action/puzzle/quizpuzzle.h
+++ b/engines/nancy/action/puzzle/quizpuzzle.h
@@ -55,6 +55,9 @@ private:
 	// slot's 27-byte text buffer, with room left for the cursor character.
 	static const uint kMaxTypedLengthNancy15 = 24;
 	static const uint kMaxTypedLength = 16;
+	// From Nancy 9 to Nancy 14 a character is only accepted while the text
+	// still clears this margin at the right edge of its box
+	static const int kBoxTextMargin = 20;
 
 	// One answer box: its screen rect, the answers accepted for it (matched
 	// case-insensitively), the flags it drives, and the text typed into it.
@@ -89,10 +92,13 @@ private:
 	void drawText();
 	void advanceToNextBox();
 	bool checkAllSolved() const;
-	bool checkAnswerForCurrentBox();	// checks, marks correct, sets event flag
+	// Scores the current box. A silent check only marks the box solved, as the
+	// original does after every keystroke; otherwise the box's flag is set too.
+	bool checkAnswerForCurrentBox(bool silent = false);
 	char getCursorChar() const;
 	uint getMaxTypedLength() const;
 	bool acceptsChar(char c) const;
+	bool fitsInBox(const Box &box, const Common::String &text) const;
 
 	// Picks one name out of `block` at random and starts it, tracking it in
 	// _activeBoxSound so the state machine can wait for it. Returns false when


Commit: 71bc6adac11850c1fcee130018bf93960f33e247
    https://github.com/scummvm/scummvm/commit/71bc6adac11850c1fcee130018bf93960f33e247
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-24T09:21:33+03:00

Commit Message:
NANCY: NANCY15: Adapt the flag categories and generic event flags

Fixes the plant fertilizing puzzle

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


diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index f72a16662ca..d6b51298ac4 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -350,6 +350,12 @@ static const GenericEventFlagCategory kGenericEventFlagCategories[] = {
 	{ 1559, 1658, "Empty" }
 };
 
+// Nancy15 widened the Generic and Timer blocks and dropped the other categories
+static const GenericEventFlagCategory kGenericEventFlagCategoriesNancy15[] = {
+	{ 1010, 1060, "Generic" },
+	{ 1100, 1120, "Timer" }
+};
+
 const Common::String NancyEngine::getEventFlagName(uint flagID) const {
 	if (getGameType() <= kGameTypeNancy11) {
 		// All flag names are stored in the executable
@@ -370,8 +376,15 @@ const Common::String NancyEngine::getEventFlagName(uint flagID) const {
 		return (flagID < flagNames.size()) ? flagNames[flagID] : "";
 	}
 
-	for (uint i = 0; i < ARRAYSIZE(kGenericEventFlagCategories); ++i) {
-		const GenericEventFlagCategory &category = kGenericEventFlagCategories[i];
+	const GenericEventFlagCategory *categories = kGenericEventFlagCategories;
+	uint numCategories = ARRAYSIZE(kGenericEventFlagCategories);
+	if (getGameType() >= kGameTypeNancy15) {
+		categories = kGenericEventFlagCategoriesNancy15;
+		numCategories = ARRAYSIZE(kGenericEventFlagCategoriesNancy15);
+	}
+
+	for (uint i = 0; i < numCategories; ++i) {
+		const GenericEventFlagCategory &category = categories[i];
 		if (flagID >= category.firstLabel && flagID <= category.lastLabel) {
 			return Common::String::format("%s%u", category.name, flagID - category.firstLabel);
 		}
@@ -944,11 +957,12 @@ void NancyEngine::populateStaticData() {
 		break;
 	}
 
-	// Generic event flags occupy labels 1010-1040 (indices 10-40), and the
+	// Generic event flags occupy labels 1010-1040 (indices 10-40), or 1010-1060
+	// (indices 10-60) in Nancy15, and are cleared on every scene change. The
 	// won-game flag is label 1042 (index 42). numEventFlags is computed from the
 	// EVNT chunk later in bootGameEngine; this is just a fallback if it is absent.
 	_staticData.numEventFlags = kNumGenericEventFlags;
-	_staticData.genericEventFlags.resize(31);
+	_staticData.genericEventFlags.resize(getGameType() >= kGameTypeNancy15 ? 51 : 31);
 	for (uint i = 0; i < _staticData.genericEventFlags.size(); ++i) {
 		_staticData.genericEventFlags[i] = 10 + i;
 	}
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index de96777306e..aadbf6ba5d9 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1146,7 +1146,13 @@ void Scene::setUIResource(uint index, int32 value, byte characterIndex) {
 // flag (eventData[0x21] in the original). It persists across scenes and is
 // saved/restored together with the rest of the event flags. Nancy12 shifted the
 // engine's generic flag numbering up by 10, moving this flag from 1033 to 1043.
+// Nancy15 widened the scene-cleared generic block over 1043 and moved the flag
+// to 1002.
 static int16 playerScrollingDisabledFlag() {
+	if (g_nancy->getGameType() >= kGameTypeNancy15) {
+		return 1002;
+	}
+
 	return g_nancy->getGameType() >= kGameTypeNancy12 ? 1043 : 1033;
 }
 




More information about the Scummvm-git-logs mailing list