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

bluegr noreply at scummvm.org
Sat Jul 18 22:03:56 UTC 2026


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

Summary:
ec55373eee NANCY: NANCY13: Implement DropSortPuzzle
1466951785 NANCY: Disallow loading of saves created with a newer ScummVM version
99f43263c0 NANCY: NANCY10: Add arrow hover highlight on cellphone links
84ae5658d0 NANCY: NANCY10: Highlight items in the inventory on hover
7349ababbf NANCY: NANCY10: Fix sticky highlight on inventory/notebook tabs
e660521ff2 NANCY: NANCY10: Remove margin in bottom of journal
d66e833739 NANCY: NANCY10: Fix CPU spike when scrollbar is held
a14113a28e NANCY: NANCY10: Fix cursor of OverrideLockPuzzle
e8ccbb06f6 NANCY: NANCY10: Use cursors from puzzle data for RotatingLockPuzzle
b3c33f8a69 NANCY: NANCY10: Show the "Back" button in the phone ringing out screen
dafa34cf13 NANCY: NANCY10: Only clear taskbar badges when opening popups


Commit: ec55373eeeeaa1f4e08ca1eca5f0e60518cdc95d
    https://github.com/scummvm/scummvm/commit/ec55373eeeeaa1f4e08ca1eca5f0e60518cdc95d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:34+03:00

Commit Message:
NANCY: NANCY13: Implement DropSortPuzzle

Conveyor-belt candy-sorting puzzle, new in Nancy13 (AR 176). A hose on
the left drops random candies onto a belt that carries them left to
right; the player picks a candy up and drops it in a bin. Each sorting
bin takes one candy type; the "Rejects" bin at the belt's end takes the
types with no bin. A candy dropped in the wrong bin, or left to fall
off into the Rejects bin, is a mistake unless the Rejects bin accepts
it; three mistakes lose. Win once all candies are dispensed and the
belt is empty. The scene ships several copies as a difficulty ramp
(faster belt, shorter dispense interval).

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 386be86607a..b4d518e4925 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -47,6 +47,7 @@
 #include "engines/nancy/action/puzzle/cuttingpuzzle.h"
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
+#include "engines/nancy/action/puzzle/dropsortpuzzle.h"
 #include "engines/nancy/action/puzzle/gridmappuzzle.h"
 #include "engines/nancy/action/puzzle/matchpuzzle.h"
 #include "engines/nancy/action/puzzle/hamradiopuzzle.h"
@@ -205,8 +206,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new PlaySecondaryMovie();
 	case 42:	// Nancy14
 	case 43:	// Nancy14
-		return new PlaySecondaryMovie(true);
-	case 45:	// Nancy11 - random-movie variant of PlaySecondaryMovie
+	case 45:	// Nancy11
 		return new PlaySecondaryMovie(true);
 	case 46:	// Nancy11
 		return new PlayRandomMovieControl();
@@ -490,9 +490,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// TODO: not yet implemented
 		return nullptr;
 	case 176:
-		// DropSortPuzzle (drag-drop sort activity), new in Nancy13
-		// TODO: not yet implemented
-		return nullptr;
+		return new DropSortPuzzle();	// conveyor-belt candy sorting
 	// -- Nancy14 new puzzles (types 177-182) --
 	case 177:	// HangmanPuzzle
 		// TODO: not yet implemented
diff --git a/engines/nancy/action/puzzle/dropsortpuzzle.cpp b/engines/nancy/action/puzzle/dropsortpuzzle.cpp
new file mode 100644
index 00000000000..259039f320b
--- /dev/null
+++ b/engines/nancy/action/puzzle/dropsortpuzzle.cpp
@@ -0,0 +1,489 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+#include "common/util.h"
+
+#include "graphics/font.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/dropsortpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// Reads a count-prefixed array of int32 rects.
+static void readRects(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &rects) {
+	uint16 count = stream.readUint16LE();
+	rects.resize(count);
+	for (uint i = 0; i < count; ++i) {
+		readRect(stream, rects[i]);
+	}
+}
+
+void DropSortPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);			// 0x00
+	readFilename(stream, _hoseMovieName);		// 0x21
+	readRect(stream, _hoseRect);				// 0x42
+	_dispenseFrame = stream.readUint32LE();		// 0x52
+	readFilename(stream, _conveyorMovieName);	// 0x56
+	readRect(stream, _conveyorRect);			// 0x77
+	_totalItems = stream.readUint32LE();		// 0x87
+
+	// Belt path (left/right ends) and speed.
+	readRect(stream, _beltLeft);
+	readRect(stream, _beltRight);
+	_beltSpeed = stream.readSint32LE();
+
+	_dispenseInterval = stream.readSint32LE();
+	_hoverCursorType = stream.readUint16LE();	// raw Nancy13 cursor type ids
+	_dragCursorType = stream.readUint16LE();
+
+	readRects(stream, _itemSrcRects);			// candy sprites in the overlay image
+	_unk1ad = stream.readSint32LE();
+
+	uint16 numBins = stream.readUint16LE();
+	_bins.resize(numBins);
+	for (uint i = 0; i < numBins; ++i) {
+		Bin &b = _bins[i];
+		readRect(stream, b.region);
+		b.enabled = stream.readByte();
+		uint16 numAccepted = stream.readUint16LE();
+		b.accepted.resize(numAccepted);
+		for (uint j = 0; j < numAccepted; ++j) {
+			b.accepted[j] = stream.readSint16LE();
+		}
+	}
+
+	_showCounter = stream.readByte();
+	_counterX = stream.readSint32LE();		// viewport position of the "Taffy to sort" number
+	_counterY = stream.readSint32LE();
+	_atlasId = stream.readUint16LE();
+
+	readRects(stream, _strikeSrcRects);			// strike marker sprites in the overlay image
+	readRects(stream, _strikeDestRects);		// where the strike markers are drawn
+
+	_pickupSound.readData(stream);
+	_dropSound.readData(stream);
+	_hornSound.readData(stream);
+
+	// Win scene + flag. frameID 0xffff means "no specific frame" (target may be a video) - keep 0.
+	_winScene.sceneID = stream.readUint16LE();
+	uint16 winFrame = stream.readUint16LE();
+	_winScene.frameID = (winFrame == 0xffff) ? 0 : winFrame;
+	_winScene.continueSceneSound = kContinueSceneSound;
+	_winFlag.label = stream.readSint16LE();
+	_winFlag.flag = stream.readByte();
+
+	_winSound.readData(stream);
+
+	_loseScene.sceneID = stream.readUint16LE();
+	uint16 loseFrame = stream.readUint16LE();
+	_loseScene.frameID = (loseFrame == 0xffff) ? 0 : loseFrame;
+	_loseScene.continueSceneSound = kContinueSceneSound;
+	_loseFlag.label = stream.readSint16LE();
+	_loseFlag.flag = stream.readByte();
+
+	_loseSound.readData(stream);
+
+	// Count-prefixed 23-byte hotspot records; the first is the "give up / exit" hotspot.
+	int16 numZones = stream.readSint16LE();
+	for (int16 i = 0; i < numZones; ++i) {
+		Common::Rect r;
+		readRect(stream, r);
+		uint16 cursorType = stream.readUint16LE();
+		uint16 sceneID = stream.readUint16LE();
+		uint16 frameID = stream.readUint16LE();
+		stream.skip(1);							// trailing flag
+
+		if (i == 0) {
+			_exitHotspot = r;
+			_exitCursorType = cursorType;
+			_exitScene.sceneID = sceneID;
+			_exitScene.frameID = (frameID == 0xffff) ? 0 : frameID;
+		}
+	}
+}
+
+void DropSortPuzzle::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(_imageName, _image);
+	_image.setTransparentColor(_drawSurface.getTransparentColor());
+
+	// Both animations loop for the whole puzzle.
+	if (_conveyorMovie.loadFile(_conveyorMovieName)) {
+		_conveyorMovie.playRange(0, _conveyorMovie.getFrameCount() - 1);
+	}
+	if (_hoseMovie.loadFile(_hoseMovieName)) {
+		_hoseMovie.playRange(0, _hoseMovie.getFrameCount() - 1);
+	}
+
+	// Time to cross the belt, from its length and speed (guarded against bad data).
+	int dist = ABS(_beltRight.left - _beltLeft.left);
+	int speed = _beltSpeed > 0 ? _beltSpeed : 60;
+	_travelDuration = dist > 0 ? (uint32)((int64)dist * 1000 / speed) : 8000;
+	if (_travelDuration < 1000) {
+		_travelDuration = 8000;
+	}
+
+	_lastDispenseTime = g_nancy->getTotalPlayTime();
+
+	// The reject bin sits at the belt's end, so candies that fall off land in it.
+	Common::Point fallPoint = beltPosition(1.0f);
+	for (uint i = 0; i < _bins.size(); ++i) {
+		if (_bins[i].region.contains(fallPoint)) {
+			_rejectBin = (int)i;
+			break;
+		}
+	}
+
+	NancySceneState.setNoHeldItem();
+
+	redraw();
+	registerGraphics();
+}
+
+Common::Point DropSortPuzzle::beltPosition(float progress) const {
+	int16 x = (int16)(_beltLeft.left + (_beltRight.left - _beltLeft.left) * progress);
+	int16 y = (int16)((_beltLeft.top + _beltLeft.bottom) / 2);
+	return Common::Point(x, y);
+}
+
+Common::Rect DropSortPuzzle::itemDestAt(const Common::Point &pos, int16 type) const {
+	int w = _itemSrcRects[type].width();
+	int h = _itemSrcRects[type].height();
+	return Common::Rect((int16)(pos.x - w / 2), (int16)(pos.y - h / 2),
+		(int16)(pos.x - w / 2 + w), (int16)(pos.y - h / 2 + h));
+}
+
+int DropSortPuzzle::itemAtCursor(const Common::Point &mousePos) const {
+	uint32 now = g_nancy->getTotalPlayTime();
+	// Oldest (front-most) candy first, so overlapping sprites pick the one nearest the bins.
+	for (uint i = 0; i < _items.size(); ++i) {
+		float progress = (float)(now - _items[i].spawnTime) / (float)_travelDuration;
+		Common::Rect dest = itemDestAt(beltPosition(progress), _items[i].type);
+		if (NancySceneState.getViewport().convertViewportToScreen(dest).contains(mousePos)) {
+			return (int)i;
+		}
+	}
+	return -1;
+}
+
+int DropSortPuzzle::binAtCursor(const Common::Point &mousePos) const {
+	for (uint i = 0; i < _bins.size(); ++i) {
+		if (NancySceneState.getViewport().convertViewportToScreen(_bins[i].region).contains(mousePos)) {
+			return (int)i;
+		}
+	}
+	return -1;
+}
+
+Common::Point DropSortPuzzle::cursorToViewport(const Common::Point &mousePos) const {
+	Common::Rect screenPt(mousePos.x, mousePos.y, mousePos.x + 1, mousePos.y + 1);
+	Common::Rect vpPt = NancySceneState.getViewport().convertScreenToViewport(screenPt);
+	return Common::Point(vpPt.left, vpPt.top);
+}
+
+void DropSortPuzzle::applyDrop(int binIndex, int16 type) {
+	if (binIndex < 0 || binIndex >= (int)_bins.size()) {
+		return;
+	}
+
+	const Bin &b = _bins[binIndex];
+	bool accepted = false;
+	for (uint i = 0; i < b.accepted.size(); ++i) {
+		if (b.accepted[i] == type) {
+			accepted = true;
+			break;
+		}
+	}
+
+	if (accepted) {
+		playSoundBlock(_dropSound);
+	} else if (b.enabled) {
+		// Wrong bin: a mistake.
+		++_strikes;
+		playSoundBlock(_hornSound);
+	}
+}
+
+void DropSortPuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	uint32 now = g_nancy->getTotalPlayTime();
+
+	// The conveyor belt, behind everything.
+	if (_conveyorMovie.isVideoLoaded()) {
+		_conveyorMovie.drawFrame(_drawSurface, Common::Point(_conveyorRect.left, _conveyorRect.top));
+	}
+
+	// The candies riding the belt, drawn on top of the belt.
+	for (uint i = 0; i < _items.size(); ++i) {
+		float progress = (float)(now - _items[i].spawnTime) / (float)_travelDuration;
+		Common::Rect dest = itemDestAt(beltPosition(progress), _items[i].type);
+		_drawSurface.blitFrom(_image, _itemSrcRects[_items[i].type], Common::Point(dest.left, dest.top));
+	}
+
+	// The hose, over the candies: a fresh candy stays hidden under it until the belt carries it out.
+	if (_hoseMovie.isVideoLoaded()) {
+		_hoseMovie.drawFrame(_drawSurface, Common::Point(_hoseRect.left, _hoseRect.top));
+	}
+
+	// The strike markers earned so far.
+	for (int i = 0; i < _strikes && i < (int)_strikeDestRects.size() && i < (int)_strikeSrcRects.size(); ++i) {
+		_drawSurface.blitFrom(_image, _strikeSrcRects[i],
+			Common::Point(_strikeDestRects[i].left, _strikeDestRects[i].top));
+	}
+
+	// The candy currently being carried, following the cursor.
+	if (_carriedType != kNoItem) {
+		const Common::Rect &src = _itemSrcRects[_carriedType];
+		int w = src.width();
+		int h = src.height();
+		_drawSurface.blitFrom(_image, src, Common::Point(_dragPos.x - w / 2, _dragPos.y - h / 2));
+	}
+
+	drawCounter();
+
+	_needsRedraw = true;
+}
+
+// The "Taffy to sort" counter: candies still to be dispensed, drawn at the AR-data position
+// with the AR-data font id.
+void DropSortPuzzle::drawCounter() {
+	if (!_showCounter) {
+		return;
+	}
+
+	const Graphics::Font *font = g_nancy->_graphics->getFont(_atlasId);
+	if (!font) {
+		font = g_nancy->_graphics->getFont(0);
+	}
+	if (!font) {
+		return;
+	}
+
+	int remaining = (int)_totalItems - (int)_numDispensed;
+	if (remaining < 0) {
+		remaining = 0;
+	}
+
+	Common::String str = Common::String::format("%d", remaining);
+	int w = font->getStringWidth(str);
+	if (w <= 0) {
+		w = 24;
+	}
+
+	font->drawString(&_drawSurface, str, _counterX, _counterY, w + 4, 0);
+}
+
+SoundDescription DropSortPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	SoundDescription desc;
+	if (block.names.empty()) {
+		return desc;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return desc;
+	}
+
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+	return desc;
+}
+
+void DropSortPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (!_ended) {
+			// Keep both animations looping.
+			if (_conveyorMovie.isVideoLoaded() && !_conveyorMovie.isRangePlaying()) {
+				_conveyorMovie.playRange(0, _conveyorMovie.getFrameCount() - 1);
+			}
+			if (_hoseMovie.isVideoLoaded() && !_hoseMovie.isRangePlaying()) {
+				_hoseMovie.playRange(0, _hoseMovie.getFrameCount() - 1);
+			}
+			bool moviesUpdated = false;
+			if (_conveyorMovie.isVideoLoaded() && _conveyorMovie.update()) {
+				moviesUpdated = true;
+			}
+			if (_hoseMovie.isVideoLoaded() && _hoseMovie.update()) {
+				moviesUpdated = true;
+			}
+
+			// Dispense on the data's interval (ramps with the difficulty variant).
+			int interval = _dispenseInterval > 0 ? _dispenseInterval : 2000;
+			if (now - _lastDispenseTime >= (uint32)interval &&
+					_numDispensed < _totalItems && !_itemSrcRects.empty()) {
+				BeltItem item;
+				item.type = (int16)g_nancy->_randomSource->getRandomNumber(_itemSrcRects.size() - 1);
+				item.spawnTime = now;
+				_items.push_back(item);
+				++_numDispensed;
+				_lastDispenseTime = now;
+			}
+
+			// Candies reaching the belt's end fall into the reject bin (a mistake unless it
+			// accepts them).
+			for (uint i = 0; i < _items.size();) {
+				if (now - _items[i].spawnTime >= _travelDuration) {
+					applyDrop(_rejectBin, _items[i].type);
+					_items.remove_at(i);
+				} else {
+					++i;
+				}
+			}
+
+			// The belt and candies move every frame, so keep the overlay in sync.
+			if (moviesUpdated || !_items.empty() || _carriedType != kNoItem) {
+				redraw();
+			}
+
+			// Lose on three strikes; win once all candies are dispensed and the belt is empty.
+			if (_strikes >= (int)_strikeDestRects.size() && !_strikeDestRects.empty()) {
+				_ended = true;
+				_lost = true;
+				_endSound = playSoundBlock(_loseSound);
+				_endTime = now;
+			} else if (_numDispensed >= _totalItems && _items.empty() && _carriedType == kNoItem) {
+				_ended = true;
+				_solved = true;
+				_endSound = playSoundBlock(_winSound);
+				_endTime = now;
+			}
+		}
+
+		if (_ended) {
+			bool soundDone = _endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound);
+			if (soundDone && now - _endTime > 500) {
+				_state = kActionTrigger;
+			}
+		}
+
+		break;
+	}
+	case kActionTrigger:
+		if (_exitRequested) {
+			if (_exitScene.sceneID != kNoScene) {
+				NancySceneState.changeScene(_exitScene);
+			}
+		} else if (_solved) {
+			NancySceneState.setEventFlag(_winFlag);
+			if (_winScene.sceneID != kNoScene) {
+				NancySceneState.changeScene(_winScene);
+			}
+		} else {
+			NancySceneState.setEventFlag(_loseFlag);
+			if (_loseScene.sceneID != kNoScene) {
+				NancySceneState.changeScene(_loseScene);
+			}
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void DropSortPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _ended) {
+		return;
+	}
+
+	const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+	// -- Carrying a candy: it follows the cursor; drop it into a bin. --
+	if (_carriedType != kNoItem) {
+		// Raw Nancy13 cursor type ids from the AR data, applied via the "set from script" path.
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_dragCursorType, true);
+		_dragPos = cursorToViewport(input.mousePos);
+		redraw();
+
+		if (click) {
+			int bin = binAtCursor(input.mousePos);
+			if (bin >= 0) {
+				applyDrop(bin, _carriedType);
+				_carriedType = kNoItem;
+				redraw();
+			}
+		}
+
+		input.eatMouseInput();
+		return;
+	}
+
+	// -- Not carrying: pick a candy off the belt, or leave via the exit hotspot. --
+	int item = itemAtCursor(input.mousePos);
+	if (item >= 0) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
+		if (click) {
+			_carriedType = _items[item].type;
+			_items.remove_at(item);
+			_dragPos = cursorToViewport(input.mousePos);
+			playSoundBlock(_pickupSound);
+			redraw();
+		}
+		input.eatMouseInput();
+		return;
+	}
+
+	if (!_exitHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+		if (click) {
+			_exitRequested = true;
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/dropsortpuzzle.h b/engines/nancy/action/puzzle/dropsortpuzzle.h
new file mode 100644
index 00000000000..64603d2c668
--- /dev/null
+++ b/engines/nancy/action/puzzle/dropsortpuzzle.h
@@ -0,0 +1,159 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_DROPSORTPUZZLE_H
+#define NANCY_ACTION_DROPSORTPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Conveyor-belt candy-sorting puzzle, new in Nancy13 (AR 176). A hose on
+// the left drops random candies onto a belt that carries them left to
+// right; the player picks a candy up and drops it in a bin. Each sorting
+// bin takes one candy type; the "Rejects" bin at the belt's end takes the
+// types with no bin. A candy dropped in the wrong bin, or left to fall
+// off into the Rejects bin, is a mistake unless the Rejects bin accepts
+// it; three mistakes lose. Win once all candies are dispensed and the
+// belt is empty. The scene ships several copies as a difficulty ramp
+// (faster belt, shorter dispense interval).
+class DropSortPuzzle : public RenderActionRecord {
+public:
+	DropSortPuzzle() : RenderActionRecord(7) {}
+	virtual ~DropSortPuzzle() {}
+
+	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 "DropSortPuzzle"; }
+
+	static const int16 kNoItem = -1;
+
+	struct Bin {
+		Common::Rect region;			// the clickable drop zone
+		byte enabled = 0;				// whether a wrong drop here counts as a strike
+		Common::Array<int16> accepted;	// candy types this bin accepts
+	};
+
+	struct BeltItem {
+		int16 type = 0;					// index into _itemSrcRects
+		uint32 spawnTime = 0;			// dispense time, used to interpolate belt position
+	};
+
+	// Belt position (viewport space) at normalized progress: 0 = dispensed at the left,
+	// 1 = about to fall off at the right.
+	Common::Point beltPosition(float progress) const;
+	Common::Rect itemDestAt(const Common::Point &pos, int16 type) const;
+	int itemAtCursor(const Common::Point &mousePos) const;	// belt item under the cursor, or -1
+	int binAtCursor(const Common::Point &mousePos) const;	// bin under the cursor, or -1
+	Common::Point cursorToViewport(const Common::Point &mousePos) const;
+
+	// Drops a candy into a bin: correct-bin sound if accepted, else a mistake (for an
+	// enabled bin). Also handles candies falling off the belt into the reject bin.
+	void applyDrop(int binIndex, int16 type);
+
+	void redraw();
+	void drawCounter();
+	SoundDescription playSoundBlock(const RandomSoundBlock &block);
+
+	// -- File data --
+	Common::Path _imageName;			// 0x00 - overlay sprite sheet (candies, strikes)
+	Common::Path _hoseMovieName;		// 0x21 - hose (dispenser) animation
+	Common::Rect _hoseRect;				// 0x42
+	uint32 _dispenseFrame = 0;			// 0x52 - unused in this port
+	Common::Path _conveyorMovieName;	// 0x56 - belt (conveyor) animation
+	Common::Rect _conveyorRect;			// 0x77
+	uint32 _totalItems = 0;				// 0x87 - candies dispensed over the puzzle
+
+	Common::Rect _beltLeft;				// belt left end (candies dispensed here)
+	Common::Rect _beltRight;			// belt right end (candies fall off here)
+	int32 _beltSpeed = 0;				// pixels per second
+	int32 _dispenseInterval = 0;		// ms between candy dispenses
+	uint16 _hoverCursorType = 0;		// raw Nancy13 cursor over a candy
+	uint16 _dragCursorType = 0;			// raw Nancy13 cursor while carrying
+
+	Common::Array<Common::Rect> _itemSrcRects;	// candy sprites in the overlay image
+	int32 _unk1ad = 0;
+
+	Common::Array<Bin> _bins;
+
+	byte _showCounter = 0;
+	int32 _counterX = 0;				// viewport position of the "Taffy to sort" number
+	int32 _counterY = 0;
+	uint16 _atlasId = 0;				// font id for the counter digits
+
+	Common::Array<Common::Rect> _strikeSrcRects;	// strike marker sprites in the overlay image
+	Common::Array<Common::Rect> _strikeDestRects;	// where the strike markers are drawn
+
+	RandomSoundBlock _pickupSound;		// candy picked up
+	RandomSoundBlock _dropSound;		// candy dropped in the correct bin
+	RandomSoundBlock _hornSound;		// candy dropped in the wrong bin (a strike)
+
+	SceneChangeDescription _winScene;
+	FlagDescription _winFlag;
+	RandomSoundBlock _winSound;
+
+	SceneChangeDescription _loseScene;
+	FlagDescription _loseFlag;
+	RandomSoundBlock _loseSound;
+
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+
+	// -- Runtime state --
+	Common::Array<BeltItem> _items;		// candies currently on the belt, oldest first
+	uint _numDispensed = 0;
+	uint32 _lastDispenseTime = 0;
+	uint32 _travelDuration = 0;			// ms for a candy to cross the belt
+	int _rejectBin = -1;				// bin at the end of the belt (unsorted candies land here)
+
+	int16 _carriedType = kNoItem;
+	Common::Point _dragPos;
+
+	int _strikes = 0;
+	bool _solved = false;
+	bool _lost = false;
+	bool _ended = false;
+	bool _exitRequested = false;
+	uint32 _endTime = 0;
+	SoundDescription _endSound;
+
+	// Decorative; both loop for the whole puzzle.
+	MoviePlayer _conveyorMovie;
+	MoviePlayer _hoseMovie;
+
+	Graphics::ManagedSurface _image;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_DROPSORTPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 97625d02f65..19aeacaeb11 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -31,6 +31,7 @@ MODULE_OBJS = \
   action/puzzle/cuttingpuzzle.o \
   action/puzzle/dotconnectpuzzle.o \
   action/puzzle/drivingpuzzle.o \
+  action/puzzle/dropsortpuzzle.o \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \
   action/puzzle/leverpuzzle.o \


Commit: 14669517857467a88f2acedd5404703e845979d9
    https://github.com/scummvm/scummvm/commit/14669517857467a88f2acedd5404703e845979d9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:35+03:00

Commit Message:
NANCY: Disallow loading of saves created with a newer ScummVM version

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


diff --git a/engines/nancy/POTFILES b/engines/nancy/POTFILES
index f1e708064c5..97e82493e5e 100644
--- a/engines/nancy/POTFILES
+++ b/engines/nancy/POTFILES
@@ -1,3 +1,4 @@
 engines/nancy/input.cpp
 engines/nancy/metaengine.cpp
+engines/nancy/nancy.cpp
 engines/nancy/state/loadsave.cpp
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index b4ee347a02c..01630698704 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -26,6 +26,9 @@
 #include "common/memstream.h"
 #include "common/compression/installshield_cab.h"
 #include "common/serializer.h"
+#include "common/translation.h"
+
+#include "gui/message.h"
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/resource.h"
@@ -803,11 +806,18 @@ void NancyEngine::populateStaticData() {
 }
 
 Common::Error NancyEngine::synchronize(Common::Serializer &ser) {
-	auto *bootSummary = GetEngineData(BSUM);
+	auto *bootSummary = GetEngineData(BSUM)
 	assert(bootSummary);
 
 	// Sync boot summary header, which includes full game title
 	ser.syncVersion(kSavegameVersion);
+
+	if (ser.getVersion() > kSavegameVersion) {
+		GUI::MessageDialog dialog(_s("Saved game was created with a newer version of ScummVM. Unable to load."));
+		dialog.runModal();
+		return Common::kUnknownError;
+	}
+
 	ser.matchBytes((const char *)bootSummary->header, 90);
 
 	// Sync scene and action records


Commit: 99f43263c0985569c49053919ca4a0381d38f16f
    https://github.com/scummvm/scummvm/commit/99f43263c0985569c49053919ca4a0381d38f16f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:36+03:00

Commit Message:
NANCY: NANCY10: Add arrow hover highlight on cellphone links

Fix #16963

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


diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index e99f40c8897..874b3086ae4 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -252,6 +252,8 @@ void CellPhonePopup::open() {
 	_closeButtonHovered = false;
 	_scrollUpHovered = false;
 	_scrollDownHovered = false;
+	_helpButtonHovered = false;
+	_backButtonHovered = false;
 	_autoDialPending = false;
 	_pressedSlot = -1;
 
@@ -466,7 +468,7 @@ void CellPhonePopup::drawChrome() {
 	// it once a call is being placed (the connecting / "We're sorry" screens)
 	// and on every sub-screen that shows its own heading.
 	if (_screenState == kWelcome || _screenState == kDialing) {
-		drawHelpButton(0);
+		drawHelpButton(_helpButtonHovered ? 1 : 0);
 	}
 	_needsRedraw = true;
 }
@@ -1087,12 +1089,22 @@ void CellPhonePopup::drawBackButton(uint subButtonIndex) {
 	// subButtons[0] is the Back button in the lower ribbon (help / sub-screens);
 	// subButtons[7] is the Back button at the bottom of the zoomed content view.
 	const UICL::ThreeRectWidget &back = _uiclData->subButtons[subButtonIndex];
-	if (back.srcRectIdle.isEmpty() || back.destRect.isEmpty()) {
+	if (back.destRect.isEmpty()) {
+		return;
+	}
+
+	// Highlight (pressed sprite, green arrow) when the cursor is over this
+	// button and it's the one currently accepting input.
+	const bool hovered = _backButtonHovered && (int)subButtonIndex == currentBackButtonIndex();
+	const Common::Rect &src = (hovered && !back.srcRectPressed.isEmpty())
+								? back.srcRectPressed
+								: back.srcRectIdle;
+	if (src.isEmpty()) {
 		return;
 	}
 
 	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
-	_drawSurface.blitFrom(_spritesImage, back.srcRectIdle,
+	_drawSurface.blitFrom(_spritesImage, src,
 							Common::Point(back.destRect.left - chunkOrigin.x,
 											back.destRect.top - chunkOrigin.y));
 }
@@ -1142,6 +1154,24 @@ Common::Rect CellPhonePopup::backButtonHitRect(uint subButtonIndex) const {
 	return r;
 }
 
+int CellPhonePopup::currentBackButtonIndex() const {
+	// Mirrors the drawBackButton() calls in drawScreenContent: which
+	// subButtons slot holds the visible Back / HOME button per screen.
+	switch (_screenState) {
+	case kDirectory:
+	case kOnlineHub:
+		return 0;
+	case kWebList:
+		return 9;
+	case kEmailList:
+		return 7;
+	case kContentView:
+		return isHelpContentView() ? 0 : 7;
+	default:
+		return -1;
+	}
+}
+
 const UICL::ThreeRectWidget &CellPhonePopup::scrollUpButton() const {
 	// Directory and help both scroll with the small-LCD arrow pair
 	// (subButtons[1]/[2]); the zoomed email / browser articles use [5]/[6].
@@ -1219,6 +1249,8 @@ void CellPhonePopup::enterScreenState(ScreenState newState) {
 	// Always redraw, so successive digit entries refresh the readout.
 	_screenState = newState;
 	_hoveredHubButton = -1;
+	_helpButtonHovered = false;
+	_backButtonHovered = false;
 	if (newState != kContentView) {
 		// Cancel a pending email-open flash unless we're completing it.
 		_openingEmailRow = -1;
@@ -1724,6 +1756,22 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		drawScreenContent();
 	}
 
+	// Green-arrow highlight for the captioned "> HELP" and "< BACK" / HOME
+	// buttons: swap to the pressed sprite while the cursor is over them.
+	const bool helpVisible = (_screenState == kWelcome || _screenState == kDialing);
+	const bool overHelp = helpVisible &&
+			!_uiclData->helpButton.destRect.isEmpty() &&
+			_uiclData->helpButton.destRect.contains(chunkMouse);
+	const int backIndex = currentBackButtonIndex();
+	const bool overBack = backIndex >= 0 &&
+			!_uiclData->subButtons[backIndex].destRect.isEmpty() &&
+			_uiclData->subButtons[backIndex].destRect.contains(chunkMouse);
+	if (overHelp != _helpButtonHovered || overBack != _backButtonHovered) {
+		_helpButtonHovered = overHelp;
+		_backButtonHovered = overBack;
+		drawScreenContent();
+	}
+
 	// The keypad is only on screen in the non-zoomed chrome (welcome / dialing /
 	// directory / help), so its slots are only interactive there. The zoomed
 	// web / email / browser views hide the keypad but keep the slots' dest rects
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/cellphonepopup.h
index 25a6156f8cf..92342936589 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/cellphonepopup.h
@@ -219,6 +219,9 @@ private:
 	Common::Rect backLabelHitRect() const;
 	// Popup-local rect of a visible Back sub-button (subButtons[index]).
 	Common::Rect backButtonHitRect(uint subButtonIndex) const;
+	// subButtons index of the Back / HOME button visible in the current state,
+	// or -1 when none is shown. Used to drive its hover highlight.
+	int currentBackButtonIndex() const;
 	// Move the directory selection by delta, scrolling as needed.
 	void moveDirectorySelection(int delta);
 
@@ -237,6 +240,10 @@ private:
 	bool _closeButtonHovered = false;
 	bool _scrollUpHovered = false;
 	bool _scrollDownHovered = false;
+	// Green-arrow highlight state for the captioned "> HELP" (welcome / dialing)
+	// and "< BACK" / HOME sub-buttons: each swaps to its pressed sprite on hover.
+	bool _helpButtonHovered = false;
+	bool _backButtonHovered = false;
 
 	ScreenState _screenState = kWelcome;
 


Commit: 84ae5658d00eb1d80566da7a31b58e462ead2078
    https://github.com/scummvm/scummvm/commit/84ae5658d00eb1d80566da7a31b58e462ead2078
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:37+03:00

Commit Message:
NANCY: NANCY10: Highlight items in the inventory on hover

Fix #16974

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


diff --git a/engines/nancy/ui/inventorypopup.cpp b/engines/nancy/ui/inventorypopup.cpp
index c54c840c592..2c9c6a7ac2d 100644
--- a/engines/nancy/ui/inventorypopup.cpp
+++ b/engines/nancy/ui/inventorypopup.cpp
@@ -113,6 +113,10 @@ void InventoryPopup::close() {
 void InventoryPopup::refreshGrid() {
 	rebuildVisibleList();
 
+	// The whole grid is redrawn below with every slot in its plain state, so
+	// any previous hover highlight is gone.
+	_hoveredSlot = -1;
+
 	drawBackground();
 	drawFilterTabs();
 	drawFilterCaption();
@@ -347,7 +351,7 @@ void InventoryPopup::playButtonClickSound(const UIButtonRecord &button) {
 	g_nancy->_sound->playSound(sound);
 }
 
-void InventoryPopup::drawSlot(uint slotIndex, int16 itemId) {
+void InventoryPopup::drawSlot(uint slotIndex, int16 itemId, bool highlighted) {
 	if (slotIndex >= _uiivData->slotDestRects.size())
 		return;
 
@@ -355,13 +359,20 @@ void InventoryPopup::drawSlot(uint slotIndex, int16 itemId) {
 		return;
 
 	const INV::ItemDescription &desc = _invData->itemDescriptions[itemId];
-	if (desc.sourceRect.isEmpty())
+
+	// Hovering an item shows its highlighted sprite (the icon with a blue glow
+	// baked in) instead of the plain one. Both are opaque and cell-sized, so
+	// one fully overwrites the other. Fall back to the plain sprite if the game
+	// has no highlighted variant.
+	const Common::Rect &src = (highlighted && !desc.highlightedSourceRect.isEmpty())
+									? desc.highlightedSourceRect : desc.sourceRect;
+	if (src.isEmpty())
 		return;
 
 	const Common::Point chunkOrigin(_uiivData->header.normalDestRect.left,
 									_uiivData->header.normalDestRect.top);
 	const Common::Rect &slotDst = _uiivData->slotDestRects[slotIndex];
-	_drawSurface.blitFrom(_itemIcons, desc.sourceRect,
+	_drawSurface.blitFrom(_itemIcons, src,
 							Common::Point(slotDst.left - chunkOrigin.x,
 											slotDst.top - chunkOrigin.y));
 }
@@ -488,6 +499,18 @@ void InventoryPopup::handleInput(NancyInput &input) {
 		break;
 	}
 
+	// Swap the glow between slots when the hovered slot changes: restore the
+	// slot we left to its plain sprite and draw the newly hovered one with its
+	// highlighted sprite.
+	if (hoveredSlot != _hoveredSlot) {
+		if (_hoveredSlot != -1)
+			drawSlot((uint)_hoveredSlot, _slotItemIDs[_hoveredSlot], false);
+		if (hoveredSlot != -1)
+			drawSlot((uint)hoveredSlot, _slotItemIDs[hoveredSlot], true);
+		_hoveredSlot = hoveredSlot;
+		_needsRedraw = true;
+	}
+
 	if (hoveredSlot != -1) {
 		g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 
diff --git a/engines/nancy/ui/inventorypopup.h b/engines/nancy/ui/inventorypopup.h
index 383354bd4d5..5838b0018ee 100644
--- a/engines/nancy/ui/inventorypopup.h
+++ b/engines/nancy/ui/inventorypopup.h
@@ -77,7 +77,7 @@ private:
 	static const uint kNumFilters = 3;
 
 	void drawBackground();
-	void drawSlot(uint slotIndex, int16 itemId);
+	void drawSlot(uint slotIndex, int16 itemId, bool highlighted = false);
 	void drawFilterTabs();
 	void drawFilterTab(uint index, bool drawHover = false);
 	void drawFilterCaption();
@@ -128,6 +128,10 @@ private:
 	// Item ID currently shown in each of the 16 slots (-1 if empty).
 	int16 _slotItemIDs[kSlotsPerPage];
 
+	// Slot the cursor is currently hovering over (-1 if none). The hovered
+	// occupied slot is drawn with the item's highlighted (blue-glow) sprite.
+	int _hoveredSlot = -1;
+
 	// Slider state. Driven by header.slider.
 	float _scrollPos = 0.0f;        // [0, 1]: 0 = top page, 1 = bottom page
 	bool _scrollbarDragging = false;


Commit: 7349ababbf4052a243b40cadddaaa219b23aa5f5
    https://github.com/scummvm/scummvm/commit/7349ababbf4052a243b40cadddaaa219b23aa5f5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:38+03:00

Commit Message:
NANCY: NANCY10: Fix sticky highlight on inventory/notebook tabs

Fix #16969

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


diff --git a/engines/nancy/ui/inventorypopup.cpp b/engines/nancy/ui/inventorypopup.cpp
index 2c9c6a7ac2d..5c57e45d451 100644
--- a/engines/nancy/ui/inventorypopup.cpp
+++ b/engines/nancy/ui/inventorypopup.cpp
@@ -582,7 +582,6 @@ void InventoryPopup::handleInput(NancyInput &input) {
 			input.eatMouseInput();
 			return;
 		}
-		break;
 	}
 
 	if (_filterHovered || wasHovered)
diff --git a/engines/nancy/ui/notebookpopup.cpp b/engines/nancy/ui/notebookpopup.cpp
index 32e2b78bcfe..ccad888f2bd 100644
--- a/engines/nancy/ui/notebookpopup.cpp
+++ b/engines/nancy/ui/notebookpopup.cpp
@@ -416,7 +416,6 @@ void NotebookPopup::handleInput(NancyInput &input) {
 			input.eatMouseInput();
 			return;
 		}
-		break;
 	}
 
 	// Swallow clicks on the popup itself so they don't fall through.


Commit: e660521ff2b52306e75dc630aa8d9ead1cb0e879
    https://github.com/scummvm/scummvm/commit/e660521ff2b52306e75dc630aa8d9ead1cb0e879
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:39+03:00

Commit Message:
NANCY: NANCY10: Remove margin in bottom of journal

Fix #16964

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


diff --git a/engines/nancy/ui/notebookpopup.cpp b/engines/nancy/ui/notebookpopup.cpp
index ccad888f2bd..ef632b1bda7 100644
--- a/engines/nancy/ui/notebookpopup.cpp
+++ b/engines/nancy/ui/notebookpopup.cpp
@@ -530,18 +530,17 @@ void NotebookPopup::drawContent() {
 	buildTextLines();
 
 	// Chunk's textRect already provides top padding from the chrome.
-	// A small left inset gives breathing room; the bottom strip is
-	// reserved so the last line clears the inner bevel.
+	// A small left inset gives breathing room. The original draws text
+	// across the full text-rect height (source blit height == dest rect
+	// height), so no bottom strip is reserved.
 	const uint16 fontID = _uinbData->primaryFontID;
 	const Font *font = g_nancy->_graphics->getFont(fontID);
-	const int oW = font ? font->getCharWidth('o') : 0;
-	const int leftInset   = oW;
-	const int bottomInset = oW;
+	const int leftInset = font ? font->getCharWidth('o') : 0;
 
 	Common::Rect hypertextBounds(leftInset, 0, _fullSurface.w, _fullSurface.h);
 	drawAllText(hypertextBounds, 0, fontID, fontID);
 
-	const int visibleH = MAX<int>(0, localTextRect.height() - bottomInset);
+	const int visibleH = localTextRect.height();
 	const int maxScroll = MAX<int>(0, (int)_drawnTextHeight - visibleH);
 	const int safeMax = MAX<int>(0, (int)_fullSurface.h - visibleH);
 	int scrollY = (int)(_scrollPos * maxScroll);


Commit: d66e8337393b4875b67affd79cf7ddc123a4e851
    https://github.com/scummvm/scummvm/commit/d66e8337393b4875b67affd79cf7ddc123a4e851
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:40+03:00

Commit Message:
NANCY: NANCY10: Fix CPU spike when scrollbar is held

Fix #16973

Changed paths:
    engines/nancy/ui/scrolltextbox.cpp


diff --git a/engines/nancy/ui/scrolltextbox.cpp b/engines/nancy/ui/scrolltextbox.cpp
index 311deb4a184..c3d415ea3c9 100644
--- a/engines/nancy/ui/scrolltextbox.cpp
+++ b/engines/nancy/ui/scrolltextbox.cpp
@@ -326,13 +326,23 @@ void ScrollTextBox::handleInput(NancyInput &input) {
 
 			const int newThumbTop = localMouse.y - _scrollbarGrabOffset;
 			const int clamped = CLIP<int>(newThumbTop, trackLocal.top, trackLocal.top + travel);
-			_scrollPos = travel > 0 ? (float)(clamped - trackLocal.top) / (float)travel : 0.0f;
+			const float newScrollPos = travel > 0 ? (float)(clamped - trackLocal.top) / (float)travel : 0.0f;
 
+			bool released = false;
 			if (input.input & NancyInput::kLeftMouseButtonUp) {
 				_scrollbarDragging = false;
+				released = true;
+			}
+
+			// Only re-render when the thumb actually moves (or the drag just ended, to
+			// repaint the thumb out of its pressed state). drawContent() re-lays out
+			// the whole text surface, so calling it every frame while the button is
+			// merely held down pins the CPU and makes dragging choppy.
+			if (newScrollPos != _scrollPos || released) {
+				_scrollPos = newScrollPos;
+				drawContent();
 			}
 
-			drawContent();
 			input.eatMouseInput();
 			return;
 		}


Commit: a14113a28ec9234ca5960fbc71dc6e4a72839931
    https://github.com/scummvm/scummvm/commit/a14113a28ec9234ca5960fbc71dc6e4a72839931
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:40+03:00

Commit Message:
NANCY: NANCY10: Fix cursor of OverrideLockPuzzle

Fix #16976

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


diff --git a/engines/nancy/action/puzzle/overridelockpuzzle.cpp b/engines/nancy/action/puzzle/overridelockpuzzle.cpp
index 3c364f93910..94590f376a9 100644
--- a/engines/nancy/action/puzzle/overridelockpuzzle.cpp
+++ b/engines/nancy/action/puzzle/overridelockpuzzle.cpp
@@ -194,7 +194,8 @@ void OverrideLockPuzzle::handleInput(NancyInput &input) {
 		}
 
 		if (NancySceneState.getViewport().convertViewportToScreen(_hotspots[i]).contains(input.mousePos)) {
-			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+			g_nancy->_cursor->setCursorType(g_nancy->getGameType() >= kGameTypeNancy10 ?
+				CursorManager::kPuzzleArrow : CursorManager::kHotspot);
 
 			if (!g_nancy->_sound->isSoundPlaying(_buttonSound) && input.input & NancyInput::kLeftMouseButtonUp) {
 				drawButton(i, false);


Commit: e8ccbb06f61d55f5620ba05d5766cca7041ef690
    https://github.com/scummvm/scummvm/commit/e8ccbb06f61d55f5620ba05d5766cca7041ef690
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:41+03:00

Commit Message:
NANCY: NANCY10: Use cursors from puzzle data for RotatingLockPuzzle

Fix #16968

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


diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
index d8f9b4608c7..f31fd18169c 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
@@ -95,8 +95,20 @@ void RotatingLockPuzzle::readData(Common::SeekableReadStream &stream) {
 		_correctSequence.push_back(stream.readByte());
 	}
 
-	const uint padding = isNancy10 ? 12 : 8;
-	stream.skip(padding - numDials);
+	// The correct sequence occupies a fixed 8-byte slot regardless of dial count.
+	stream.skip(8 - numDials);
+
+	if (isNancy10) {
+		// Nancy 10 added per-puzzle cursor types for the up/down hotspots,
+		// stored right after the sequence slot. A value of 0 means "use the
+		// default movement cursor".
+		int16 upType = stream.readSint16LE();
+		int16 downType = stream.readSint16LE();
+		if (upType != 0)
+			_upCursorType = (CursorManager::CursorType)upType;
+		if (downType != 0)
+			_downCursorType = (CursorManager::CursorType)downType;
+	}
 
 	_clickSound.readNormal(stream);
 
@@ -212,7 +224,7 @@ void RotatingLockPuzzle::handleInput(NancyInput &input) {
 
 	for (uint i = 0; i < _upHotspots.size(); ++i) {
 		if (NancySceneState.getViewport().convertViewportToScreen(_upHotspots[i]).contains(input.mousePos)) {
-			g_nancy->_cursor->setCursorType(CursorManager::kMoveUp);
+			g_nancy->_cursor->setCursorType(_upCursorType, true);
 
 			if (!g_nancy->_sound->isSoundPlaying(_clickSound) && input.input & NancyInput::kLeftMouseButtonUp) {
 				g_nancy->_sound->playSound(_clickSound);
@@ -230,7 +242,7 @@ void RotatingLockPuzzle::handleInput(NancyInput &input) {
 
 	for (uint i = 0; i < _downHotspots.size(); ++i) {
 		if (NancySceneState.getViewport().convertViewportToScreen(_downHotspots[i]).contains(input.mousePos)) {
-			g_nancy->_cursor->setCursorType(CursorManager::kMoveDown);
+			g_nancy->_cursor->setCursorType(_downCursorType, true);
 
 			if (!g_nancy->_sound->isSoundPlaying(_clickSound) && input.input & NancyInput::kLeftMouseButtonUp) {
 				g_nancy->_sound->playSound(_clickSound);
diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.h b/engines/nancy/action/puzzle/rotatinglockpuzzle.h
index 1e5f4402584..a0246bd50fc 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.h
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.h
@@ -23,6 +23,7 @@
 #define NANCY_ACTION_ROTATINGLOCKPUZZLE_H
 
 #include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/cursor.h"
 
 namespace Nancy {
 namespace Action {
@@ -46,6 +47,11 @@ public:
 	Common::Array<Common::Rect> _downHotspots;
 	Common::Array<byte> _correctSequence;
 	uint16 _iconsPerDial = 10;
+	// Cursor types shown while hovering a dial's up/down hotspot. Nancy 10+
+	// stores these per-puzzle (e.g. a crank uses the rotate-clockwise cursor);
+	// older games and unset fields default to the up/down movement cursors.
+	CursorManager::CursorType _upCursorType = CursorManager::kMoveUp;
+	CursorManager::CursorType _downCursorType = CursorManager::kMoveDown;
 	SoundDescription _clickSound;
 	SceneChangeWithFlag _solveExitScene;
 	uint16 _solveSoundDelay = 0;


Commit: b3c33f8a696b8c0bcadf7dda0f28043b1fbc698f
    https://github.com/scummvm/scummvm/commit/b3c33f8a696b8c0bcadf7dda0f28043b1fbc698f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:41+03:00

Commit Message:
NANCY: NANCY10: Show the "Back" button in the phone ringing out screen

Fix #16975

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


diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 874b3086ae4..e80477a277f 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -511,12 +511,19 @@ void CellPhonePopup::drawScreenContent() {
 			// no digits to display, just the connecting animation.
 			drawConnectingSprite();
 		}
+		// Back button on the connecting strip cancels the ringing call.
+		if (isCallBackButtonActive()) {
+			drawBackButton(0);
+		}
 		break;
 
 	case kWaitPickup:
 	case kConnected:
 		drawConnectedLabel();
 		drawConnectingSprite();
+		if (isCallBackButtonActive()) {
+			drawBackButton(0);
+		}
 		break;
 
 	case kInvalidNumber:
@@ -1157,6 +1164,9 @@ Common::Rect CellPhonePopup::backButtonHitRect(uint subButtonIndex) const {
 int CellPhonePopup::currentBackButtonIndex() const {
 	// Mirrors the drawBackButton() calls in drawScreenContent: which
 	// subButtons slot holds the visible Back / HOME button per screen.
+	if (isCallBackButtonActive()) {
+		return 0;
+	}
 	switch (_screenState) {
 	case kDirectory:
 	case kOnlineHub:
@@ -1258,6 +1268,17 @@ void CellPhonePopup::enterScreenState(ScreenState newState) {
 	drawScreenContent();
 }
 
+void CellPhonePopup::cancelCall() {
+	if (!_callSound.name.empty()) {
+		g_nancy->_sound->stopSound(_callSound);
+	}
+	_autoDialPending = false;
+	_resolvedContact = -1;
+	_pressedSlot = -1;
+	resetDialPad();
+	enterScreenState(kWelcome);
+}
+
 void CellPhonePopup::appendDigit(byte slotIndex) {
 	if (_dialedNumber.size() >= 11) {
 		return;
@@ -1734,6 +1755,25 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 	}
 
 	if (transientCallState) {
+		// While ringing, only the Back button is live (cancels the call).
+		if (isCallBackButtonActive()) {
+			const Common::Rect backHit = backButtonHitRect(0);
+			const Common::Point popupMouse(input.mousePos.x - _screenPosition.left,
+											input.mousePos.y - _screenPosition.top);
+			const bool overBack = !backHit.isEmpty() && backHit.contains(popupMouse);
+			if (overBack != _backButtonHovered) {
+				_backButtonHovered = overBack;
+				drawScreenContent();
+			}
+			if (overBack) {
+				g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+				if (input.input & NancyInput::kLeftMouseButtonUp) {
+					input.eatMouseInput();
+					cancelCall();
+					return;
+				}
+			}
+		}
 		// Block the viewport from seeing the cursor (edge-pan, etc.).
 		input.eatMouseInput();
 		return;
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/cellphonepopup.h
index 92342936589..3cc25bda5da 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/cellphonepopup.h
@@ -173,6 +173,17 @@ private:
 
 	void resetDialPad();
 	void enterScreenState(ScreenState newState);
+	// True while a player-placed call is ringing / waiting for pickup, so the
+	// connecting strip shows a Back button (subButtons[0]) that cancels it.
+	// Incoming calls have no Back button.
+	bool isCallBackButtonActive() const {
+		return (_screenState == kPlaceCall || _screenState == kWaitOutgoingRing ||
+				_screenState == kLookupContact || _screenState == kWaitPickup) &&
+				!_hasPendingCallScene && _uiclData &&
+				!_uiclData->subButtons[0].destRect.isEmpty();
+	}
+	// Cancel a ringing / waiting call and return to the welcome screen.
+	void cancelCall();
 	void appendDigit(byte slotIndex);
 	// Play a dial-pad key's DTMF tone. The name is a raw sound filename, so it
 	// is played through the phone's call-sound channel rather than the common


Commit: dafa34cf1351b4abb8fa7f4233c847068de4c92d
    https://github.com/scummvm/scummvm/commit/dafa34cf1351b4abb8fa7f4233c847068de4c92d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-19T01:03:42+03:00

Commit Message:
NANCY: NANCY10: Only clear taskbar badges when opening popups

Fix #16970

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


diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index e80477a277f..7f93ea15f7a 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -264,7 +264,8 @@ void CellPhonePopup::open() {
 	g_nancy->_cursor->warpCursor(Common::Point(_screenPosition.left + _screenPosition.width() / 2,
 												_screenPosition.top + _screenPosition.height() / 2));
 
-	NancySceneState.getTaskbar()->clearAllNotifications(kTaskButtonCellphone);
+	// Badge sub-categories clear when their list is viewed (enterScreenState),
+	// not on open.
 
 	if (!_uiclData->header.sounds[0].name.empty()) {
 		g_nancy->_sound->loadSound(_uiclData->header.sounds[0]);
@@ -1256,6 +1257,23 @@ void CellPhonePopup::resetDialPad() {
 }
 
 void CellPhonePopup::enterScreenState(ScreenState newState) {
+	// Viewing a list clears its badge: directory = sub 0, email = sub 1, web = sub 2.
+	if (UI::Taskbar *taskbar = NancySceneState.getTaskbar()) {
+		switch (newState) {
+		case kDirectory:
+			taskbar->clearNotification(kTaskButtonCellphone, 0);
+			break;
+		case kEmailList:
+			taskbar->clearNotification(kTaskButtonCellphone, 1);
+			break;
+		case kWebList:
+			taskbar->clearNotification(kTaskButtonCellphone, 2);
+			break;
+		default:
+			break;
+		}
+	}
+
 	// Always redraw, so successive digit entries refresh the readout.
 	_screenState = newState;
 	_hoveredHubButton = -1;
diff --git a/engines/nancy/ui/notebookpopup.cpp b/engines/nancy/ui/notebookpopup.cpp
index ef632b1bda7..82f8d215d02 100644
--- a/engines/nancy/ui/notebookpopup.cpp
+++ b/engines/nancy/ui/notebookpopup.cpp
@@ -131,7 +131,8 @@ void NotebookPopup::open() {
 	g_nancy->_cursor->warpCursor(Common::Point(_screenPosition.left + _screenPosition.width() / 2,
 												_screenPosition.top + _screenPosition.height() / 2));
 
-	NancySceneState.getTaskbar()->clearAllNotifications(kTaskButtonNotebook);
+	// Only the tab being shown is acknowledged; the other keeps its badge.
+	clearActiveTabNotification();
 
 	// JournalData entries may have changed since the last open (added by
 	// ModifyListEntry, marked complete, etc.) — re-render content.
@@ -410,7 +411,7 @@ void NotebookPopup::handleInput(NancyInput &input) {
 				_scrollbarDragging = false;
 
 				playButtonClickSound(tab.button);
-
+				clearActiveTabNotification();
 				refreshContent();
 			}
 			input.eatMouseInput();
@@ -440,6 +441,19 @@ uint16 NotebookPopup::notebookJournalTabId() const {
 	return g_nancy->getGameType() >= kGameTypeNancy13 ? 0 : 1;
 }
 
+void NotebookPopup::clearActiveTabNotification() {
+	if (!_uinbData || (uint)_activeTab >= kNumTabs) {
+		return;
+	}
+	UI::Taskbar *taskbar = NancySceneState.getTaskbar();
+	if (!taskbar) {
+		return;
+	}
+	// Journal = sub 0, Tasks = sub 1 (see ModifyListEntry).
+	const bool journalActive = (_uinbData->tabs[_activeTab].id == notebookJournalTabId());
+	taskbar->clearNotification(kTaskButtonNotebook, journalActive ? 0 : 1);
+}
+
 void NotebookPopup::buildTextLines() {
 	if (!_uinbData)
 		return;
diff --git a/engines/nancy/ui/notebookpopup.h b/engines/nancy/ui/notebookpopup.h
index d592858e60a..16d383ad03c 100644
--- a/engines/nancy/ui/notebookpopup.h
+++ b/engines/nancy/ui/notebookpopup.h
@@ -98,6 +98,9 @@ private:
 	// ids from {1,2} to {0,1}, so the Journal id dropped from 1 to 0.
 	uint16 notebookJournalTabId() const;
 
+	// Clear the taskbar badge for the active tab (Journal = sub 0, Tasks = sub 1).
+	void clearActiveTabNotification();
+
 	// Populate HypertextParser's text-line list with the active tab's
 	// entries.
 	void buildTextLines();
diff --git a/engines/nancy/ui/taskbar.cpp b/engines/nancy/ui/taskbar.cpp
index 13e89c82143..4690fa7272e 100644
--- a/engines/nancy/ui/taskbar.cpp
+++ b/engines/nancy/ui/taskbar.cpp
@@ -483,11 +483,7 @@ void Taskbar::handleInput(NancyInput &input) {
 	} else if (input.input & NancyInput::kLeftMouseButtonUp) {
 		// Mouse released over the button: trigger the click action and
 		// snap the sprite back to hover (the cursor is still over it).
-		// Acknowledging the click also clears any pending notifications
-		// for this button — the popup will read them on entry.
-		for (uint s = 0; s < kNumNotificationSubCategories; ++s) {
-			_notifications[newHovered][s] = false;
-		}
+		// Badges are cleared per-view inside the popup, not on click.
 		drawButton(newHovered, kButtonHover);
 		_clickedButton = newHovered;
 




More information about the Scummvm-git-logs mailing list