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

bluegr noreply at scummvm.org
Thu Jun 4 00:09:31 UTC 2026


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

Summary:
48a4be0a38 NANCY: Update some action records for Nancy10+
a16aefca4e NANCY: Implement MagnetMazePuzzle for Nancy10
48cb27af63 NANCY: Implement taskbar button notifications for Nancy10+
cbd22cfc0a NANCY: Implement the rest of the cellphone functionality for Nancy10+
650c5cf32a NANCY: Add new MagnetMazePuzzle to module.mk
dfc12fce98 NANCY: More work on the Nancy10+ textbox
5d21ea0320 NANCY: Remove superfluous parentheses
1a68dd3f18 NANCY: Correct reading of TBOX chunk data for Nancy10+
f82acefa44 NANCY: Update inactive animated overlays


Commit: 48a4be0a388c520fc65d3256aef333d14837ee16
    https://github.com/scummvm/scummvm/commit/48a4be0a388c520fc65d3256aef333d14837ee16
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:08:55+03:00

Commit Message:
NANCY: Update some action records for Nancy10+

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 849380711b4..26b25508099 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -104,7 +104,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		if (g_nancy->getGameType() <= kGameTypeNancy9)
 			return new HotMultiframeMultiSceneChange();
 		else
-			return new Hot1FrSceneChange(CursorManager::kExit); // TODO: cursor
+			return new Hot1FrSceneChange(CursorManager::kHotspot);
 	case 14:
 		return new Hot1FrSceneChange(CursorManager::kExit);
 	case 15:
@@ -241,7 +241,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 66:
 		return new TableIndexPlaySound();
 	case 67:
-		return new TableIndexSetValueHS();
+		if (g_nancy->getGameType() >= kGameTypeNancy10)
+			return new Autotext();		// Moved from 61 in Nancy 10
+		else
+			return new TableIndexSetValueHS();
 	case 68:
 		return new TextScroll(false);
 	case 70:


Commit: a16aefca4ebc6d1a4e2d2a46a3a12dac6be49b1e
    https://github.com/scummvm/scummvm/commit/a16aefca4ebc6d1a4e2d2a46a3a12dac6be49b1e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:08:59+03:00

Commit Message:
NANCY: Implement MagnetMazePuzzle for Nancy10

This is a puzzle with magnets, where the player needs to move four
metal pieces within a maze into its center

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 26b25508099..ac65b5f58fa 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -47,6 +47,7 @@
 #include "engines/nancy/action/puzzle/matchpuzzle.h"
 #include "engines/nancy/action/puzzle/hamradiopuzzle.h"
 #include "engines/nancy/action/puzzle/leverpuzzle.h"
+#include "engines/nancy/action/puzzle/magnetmazepuzzle.h"
 #include "engines/nancy/action/puzzle/mazechasepuzzle.h"
 #include "engines/nancy/action/puzzle/memorypuzzle.h"
 #include "engines/nancy/action/puzzle/mouselightpuzzle.h"
@@ -472,7 +473,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 241:
 		return new DotConnectPuzzle();
 	case 242:
-		// return new MagnetMazePuzzle();
+		return new MagnetMazePuzzle();
 	case 243:
 		return new BeadPuzzle();
 	case 244:
diff --git a/engines/nancy/action/puzzle/magnetmazepuzzle.cpp b/engines/nancy/action/puzzle/magnetmazepuzzle.cpp
new file mode 100644
index 00000000000..710eb2f5263
--- /dev/null
+++ b/engines/nancy/action/puzzle/magnetmazepuzzle.cpp
@@ -0,0 +1,420 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/action/puzzle/magnetmazepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void MagnetMazePuzzle::readData(Common::SeekableReadStream &stream) {
+	const int64 start = stream.pos();
+
+	readFilename(stream, _boardImageName);
+	readFilename(stream, _mazeImageName);
+
+	for (int i = 0; i < kNumOverlays; ++i)
+		readRect(stream, _overlaySrcRects[i]);
+	for (int i = 0; i < kNumOverlays; ++i)
+		readRect(stream, _overlayDestRects[i]);
+
+	_overlayTransparent = (stream.readByte() != 0);
+	_hideOverlays       = (stream.readByte() != 0);
+
+	_requiredItem = stream.readSint16LE();
+	stream.skip(1); // hit-test mode flag (unused in this port)
+
+	for (int i = 0; i < kNumMagnets; ++i)
+		readRect(stream, _magnetSrcRects[i]);
+	for (int i = 0; i < kNumMagnets; ++i)
+		readRect(stream, _magnetHomeRects[i]);
+	for (int i = 0; i < kNumMagnets; ++i)
+		readRect(stream, _magnetTargetRects[i]);
+
+	// CUIButton block at AR+0x1c7 (239 bytes). Internal layout: 15-byte
+	// shared-art name, 4 × 16-byte state src rects at +0x46, hot rect at +0x96,
+	// click sound at +0xbe. Only the hot rect is needed for hit-testing.
+	stream.seek(start + 0x1c7 + 0x96);
+	readRect(stream, _resetButtonHotspot);
+
+	// Skip the decorative highlight RGB; read the wall RGB used for collision.
+	stream.seek(start + 0x2b9);
+	_wallColorR = stream.readByte();
+	_wallColorG = stream.readByte();
+	_wallColorB = stream.readByte();
+
+	stream.seek(start + 0x2bc);
+
+	_pickupSound.readNormal(stream);
+	stream.seek(start + 0x372);
+	_placeSound.readNormal(stream);
+	stream.seek(start + 0x428);
+	_resetSound.readNormal(stream);
+	stream.seek(start + 0x459);
+	_bumpSound.readNormal(stream);
+
+	stream.seek(start + 0x48a);
+	_winScene.readData(stream);
+	stream.seek(start + 0x4a0);
+	_winFlag.label = stream.readSint16LE();
+	_winFlag.flag  = stream.readByte();
+	_winDelaySec   = stream.readUint16LE();
+	_winSound.readNormal(stream);
+
+	stream.seek(start + 0x4d6);
+	_cancelScene.readData(stream);
+	stream.seek(start + 0x4ec);
+	_cancelFlag.label = stream.readSint16LE();
+	_cancelFlag.flag  = stream.readByte();
+
+	readRect(stream, _exitHotspot);
+}
+
+void MagnetMazePuzzle::resetMagnets() {
+	for (int i = 0; i < kNumMagnets; ++i) {
+		_magnetPos[i]    = _magnetHomeRects[i];
+		_magnetLocked[i] = false;
+	}
+	_heldMagnet = -1;
+}
+
+void MagnetMazePuzzle::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(_boardImageName, _boardImage);
+	_boardImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+	g_nancy->_resource->loadImage(_mazeImageName, _mazeImage);
+
+	MagnetMazePuzzleData *mmd = (MagnetMazePuzzleData *)NancySceneState.getPuzzleData(MagnetMazePuzzleData::getTag());
+	bool restored = false;
+	if (mmd && mmd->magnetState.size() >= kNumMagnets * 5) {
+		const Common::Array<int16> &state = mmd->magnetState;
+		for (int i = 0; i < kNumMagnets; ++i) {
+			_magnetPos[i].left   = state[i * 5 + 0];
+			_magnetPos[i].top    = state[i * 5 + 1];
+			_magnetPos[i].right  = state[i * 5 + 2];
+			_magnetPos[i].bottom = state[i * 5 + 3];
+			_magnetLocked[i]     = (state[i * 5 + 4] != 0);
+		}
+		_heldMagnet = -1;
+		restored = true;
+	}
+	if (!restored) {
+		resetMagnets();
+		persistState();
+	}
+
+	_isSolved = false;
+	_subState = kPlaying;
+
+	redraw();
+}
+
+void MagnetMazePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+
+	case kRun:
+		switch (_subState) {
+		case kPlaying:
+			break;
+		case kWaitWinDelay:
+			if (g_system->getMillis() >= _winDelayEndTime) {
+				if (_winSound.name != "NO SOUND") {
+					g_nancy->_sound->loadSound(_winSound);
+					g_nancy->_sound->playSound(_winSound);
+					_subState = kWaitWinSound;
+				} else {
+					_subState = kExitToWin;
+				}
+			}
+			break;
+		case kWaitWinSound:
+			if (!g_nancy->_sound->isSoundPlaying(_winSound)) {
+				g_nancy->_sound->stopSound(_winSound);
+				_subState = kExitToWin;
+			}
+			break;
+		case kExitToWin:
+		case kExitToCancel:
+			_state = kActionTrigger;
+			break;
+		}
+		break;
+
+	case kActionTrigger:
+		g_nancy->_sound->stopSound(_pickupSound);
+		g_nancy->_sound->stopSound(_placeSound);
+		g_nancy->_sound->stopSound(_resetSound);
+		g_nancy->_sound->stopSound(_bumpSound);
+		g_nancy->_sound->stopSound(_winSound);
+		if (_subState == kExitToWin) {
+			MagnetMazePuzzleData *mmd = (MagnetMazePuzzleData *)NancySceneState.getPuzzleData(MagnetMazePuzzleData::getTag());
+			if (mmd)
+				mmd->magnetState.clear();
+			if (_winFlag.label != -1)
+				NancySceneState.setEventFlag(_winFlag);
+			if (_winScene.sceneID != kNoScene)
+				NancySceneState.changeScene(_winScene);
+		} else {
+			if (_cancelFlag.label != -1)
+				NancySceneState.setEventFlag(_cancelFlag);
+			if (_cancelScene.sceneID != kNoScene)
+				NancySceneState.changeScene(_cancelScene);
+		}
+		finishExecution();
+		break;
+	}
+}
+
+bool MagnetMazePuzzle::collidesAt(const Common::Rect &r) const {
+	if (_mazeImage.empty() || _mazeImage.w == 0 || _mazeImage.h == 0)
+		return false;
+
+	const int mazeW = _mazeImage.w;
+	const int mazeH = _mazeImage.h;
+	int x0 = MAX<int>(0, r.left);
+	int y0 = MAX<int>(0, r.top);
+	int x1 = MIN<int>(mazeW, r.right);
+	int y1 = MIN<int>(mazeH, r.bottom);
+
+	// Anything that walks off the maze image counts as a wall.
+	if (x0 >= x1 || y0 >= y1)
+		return true;
+	if (r.left < 0 || r.top < 0 || r.right > mazeW || r.bottom > mazeH)
+		return true;
+
+	const uint32 wall = _mazeImage.format.RGBToColor(_wallColorR, _wallColorG, _wallColorB);
+	for (int y = y0; y < y1; ++y) {
+		for (int x = x0; x < x1; ++x) {
+			if (_mazeImage.getPixel(x, y) == wall)
+				return true;
+		}
+	}
+	return false;
+}
+
+void MagnetMazePuzzle::stepMagnetToward(Common::Rect &cur, const Common::Rect &target) const {
+	int dx = target.left - cur.left;
+	int dy = target.top - cur.top;
+
+	// Single-pixel walk toward the target with axis-only fallback so a
+	// diagonal blocked by a wall can still slide along it.
+	while (dx != 0 || dy != 0) {
+		const int sx = (dx > 0) ? 1 : (dx < 0 ? -1 : 0);
+		const int sy = (dy > 0) ? 1 : (dy < 0 ? -1 : 0);
+
+		if (sx != 0 && sy != 0) {
+			Common::Rect cand(cur.left + sx, cur.top + sy, cur.right + sx, cur.bottom + sy);
+			if (!collidesAt(cand)) {
+				cur = cand;
+				dx -= sx;
+				dy -= sy;
+				continue;
+			}
+		}
+		if (sx != 0) {
+			Common::Rect cand(cur.left + sx, cur.top, cur.right + sx, cur.bottom);
+			if (!collidesAt(cand)) {
+				cur = cand;
+				dx -= sx;
+				continue;
+			}
+		}
+		if (sy != 0) {
+			Common::Rect cand(cur.left, cur.top + sy, cur.right, cur.bottom + sy);
+			if (!collidesAt(cand)) {
+				cur = cand;
+				dy -= sy;
+				continue;
+			}
+		}
+		break; // boxed in on every axis
+	}
+}
+
+void MagnetMazePuzzle::snapMagnet(int idx) {
+	const Common::Rect &tgt = _magnetTargetRects[idx];
+	const Common::Rect &src = _magnetHomeRects[idx];
+	int x = tgt.left + (tgt.width()  - src.width())  / 2;
+	int y = tgt.top  + (tgt.height() - src.height()) / 2;
+	_magnetPos[idx] = Common::Rect(x, y, x + src.width(), y + src.height());
+	_magnetLocked[idx] = true;
+}
+
+void MagnetMazePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _subState != kPlaying)
+		return;
+
+	Common::Rect vpScreen = NancySceneState.getViewport().getScreenPosition();
+	Common::Point mouseVP = input.mousePos - Common::Point(vpScreen.left, vpScreen.top);
+
+	if (_heldMagnet != -1 && _heldDrawPos != mouseVP) {
+		_heldDrawPos = mouseVP;
+		const Common::Rect &src = _magnetHomeRects[_heldMagnet];
+		int w = src.width();
+		int h = src.height();
+		Common::Rect target(mouseVP.x - w / 2, mouseVP.y - h / 2,
+		                    mouseVP.x - w / 2 + w, mouseVP.y - h / 2 + h);
+		Common::Rect prev = _magnetPos[_heldMagnet];
+		stepMagnetToward(_magnetPos[_heldMagnet], target);
+		if (_magnetPos[_heldMagnet] != prev)
+			redraw();
+	}
+
+	if (!_exitHotspot.isEmpty() && _exitHotspot.contains(mouseVP)) {
+		g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
+		if (input.input & NancyInput::kLeftMouseButtonUp)
+			_subState = kExitToCancel;
+		return;
+	}
+
+	if (!_resetButtonHotspot.isEmpty() && _resetButtonHotspot.contains(mouseVP)) {
+		g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			resetMagnets();
+			if (_resetSound.name != "NO SOUND") {
+				g_nancy->_sound->loadSound(_resetSound);
+				g_nancy->_sound->playSound(_resetSound);
+			}
+			persistState();
+			redraw();
+		}
+		return;
+	}
+
+	if (_heldMagnet == -1) {
+		for (int i = 0; i < kNumMagnets; ++i) {
+			if (_magnetLocked[i])
+				continue;
+			if (!_magnetPos[i].contains(mouseVP))
+				continue;
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				if (_requiredItem != -1 && NancySceneState.getHeldItem() != _requiredItem) {
+					if (!_cantPlayed[i]) {
+						NancySceneState.playItemCantSound(_requiredItem);
+						_cantPlayed[i] = true;
+					}
+					return;
+				}
+				_heldMagnet = i;
+				_heldDrawPos = mouseVP;
+				if (_pickupSound.name != "NO SOUND") {
+					g_nancy->_sound->loadSound(_pickupSound);
+					g_nancy->_sound->playSound(_pickupSound);
+				}
+			}
+			return;
+		}
+		return;
+	}
+
+	g_nancy->_cursor->setCursorType(CursorManager::kDragHand);
+	if (!(input.input & NancyInput::kLeftMouseButtonUp))
+		return;
+
+	const Common::Rect &tgt = _magnetTargetRects[_heldMagnet];
+	if (_magnetPos[_heldMagnet].intersects(tgt)) {
+		snapMagnet(_heldMagnet);
+		if (_placeSound.name != "NO SOUND") {
+			g_nancy->_sound->loadSound(_placeSound);
+			g_nancy->_sound->playSound(_placeSound);
+		}
+	}
+
+	persistState();
+
+	_heldMagnet = -1;
+	checkSolved();
+	redraw();
+}
+
+void MagnetMazePuzzle::persistState() {
+	MagnetMazePuzzleData *mmd = (MagnetMazePuzzleData *)NancySceneState.getPuzzleData(MagnetMazePuzzleData::getTag());
+	if (!mmd)
+		return;
+	Common::Array<int16> &state = mmd->magnetState;
+	state.clear();
+	for (int i = 0; i < kNumMagnets; ++i) {
+		state.push_back((int16)_magnetPos[i].left);
+		state.push_back((int16)_magnetPos[i].top);
+		state.push_back((int16)_magnetPos[i].right);
+		state.push_back((int16)_magnetPos[i].bottom);
+		state.push_back(_magnetLocked[i] ? 1 : 0);
+	}
+}
+
+void MagnetMazePuzzle::checkSolved() {
+	for (int i = 0; i < kNumMagnets; ++i) {
+		if (!_magnetLocked[i])
+			return;
+	}
+	_isSolved = true;
+	_subState = kWaitWinDelay;
+	_winDelayEndTime = g_system->getMillis() + (uint32)_winDelaySec * 1000;
+}
+
+void MagnetMazePuzzle::redraw() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	for (int i = 0; i < kNumMagnets; ++i) {
+		const Common::Rect &src = _magnetSrcRects[i];
+		const Common::Rect &dst = _magnetPos[i];
+		if (src.isEmpty() || dst.isEmpty())
+			continue;
+		_drawSurface.blitFrom(_boardImage, src, Common::Point(dst.left, dst.top));
+	}
+
+	if (!_hideOverlays) {
+		for (int i = 0; i < kNumOverlays; ++i) {
+			const Common::Rect &src = _overlaySrcRects[i];
+			const Common::Rect &dst = _overlayDestRects[i];
+			if (src.isEmpty() || dst.isEmpty())
+				continue;
+			_drawSurface.blitFrom(_boardImage, src, Common::Point(dst.left, dst.top));
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/magnetmazepuzzle.h b/engines/nancy/action/puzzle/magnetmazepuzzle.h
new file mode 100644
index 00000000000..0cd4f266941
--- /dev/null
+++ b/engines/nancy/action/puzzle/magnetmazepuzzle.h
@@ -0,0 +1,134 @@
+/* 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_MAGNETMAZEPUZZLE_H
+#define NANCY_ACTION_MAGNETMAZEPUZZLE_H
+
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/commontypes.h"
+
+namespace Nancy {
+namespace Action {
+
+// Four magnet pieces, four targets on a maze board. The player picks a piece
+// from its home slot and drops it onto its target zone; an overlap snaps the
+// piece to a fixed position and locks it. Reset button returns all pieces
+// home. Wins when every piece is locked into its target.
+// Called in Nancy10 from scenes 3280 (normal mode, with overlaid pieces) and
+// 3281 (easy mode, without overlaid pieces, triggered as a cheat mode when
+// visiting the puzzle multiple times).
+class MagnetMazePuzzle : public RenderActionRecord {
+public:
+	MagnetMazePuzzle() : RenderActionRecord(7) {}
+	virtual ~MagnetMazePuzzle() {}
+
+	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 "MagnetMazePuzzle"; }
+
+	static const int kNumMagnets  = 4;
+	static const int kNumOverlays = 6;
+
+	// File data
+
+	Common::Path _boardImageName;
+	Common::Path _mazeImageName;
+
+	Common::Rect _overlaySrcRects[kNumOverlays];
+	Common::Rect _overlayDestRects[kNumOverlays];
+
+	bool _overlayTransparent = false;
+	bool _hideOverlays       = false;
+
+	Common::Rect _magnetSrcRects[kNumMagnets];
+	Common::Rect _magnetHomeRects[kNumMagnets];
+	Common::Rect _magnetTargetRects[kNumMagnets];
+
+	int16 _requiredItem = -1;
+
+	Common::Rect _resetButtonHotspot;
+
+	SoundDescription _pickupSound;
+	SoundDescription _placeSound;
+	SoundDescription _resetSound;
+	SoundDescription _bumpSound;
+
+	SceneChangeDescription _winScene;
+	FlagDescription        _winFlag;
+	uint16                 _winDelaySec = 0;
+	SoundDescription       _winSound;
+
+	SceneChangeDescription _cancelScene;
+	FlagDescription        _cancelFlag;
+
+	Common::Rect _exitHotspot;
+
+	// Runtime state
+
+	enum SubState {
+		kPlaying = 0,
+		kWaitWinDelay,
+		kWaitWinSound,
+		kExitToWin,
+		kExitToCancel
+	};
+
+	SubState _subState = kPlaying;
+
+	Common::Rect _magnetPos[kNumMagnets];
+	bool _magnetLocked[kNumMagnets] = {};
+
+	int  _heldMagnet  = -1;
+	bool _isSolved    = false;
+	bool _cantPlayed[kNumMagnets] = {};
+	uint32 _winDelayEndTime = 0;
+
+	Common::Point _heldDrawPos;
+
+	Graphics::ManagedSurface _boardImage;
+	Graphics::ManagedSurface _mazeImage; // wall mask for pixel-collision tests
+
+	// RGB of the maze image's "wall" pixel color; a magnet rect overlapping
+	// any pixel of this color in the maze image is treated as colliding.
+	byte _wallColorR = 0;
+	byte _wallColorG = 0;
+	byte _wallColorB = 0;
+
+	void resetMagnets();
+	void snapMagnet(int idx);
+	void persistState();
+	void redraw();
+	void checkSolved();
+	bool collidesAt(const Common::Rect &r) const;
+	void stepMagnetToward(Common::Rect &cur, const Common::Rect &target) const;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_MAGNETMAZEPUZZLE_H
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index bb1bc8f3579..7de27e562bc 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -230,6 +230,10 @@ void SortPuzzleData::synchronize(Common::Serializer &ser) {
 	syncInt16Array(ser, solvedState);
 }
 
+void MagnetMazePuzzleData::synchronize(Common::Serializer &ser) {
+	syncInt16Array(ser, magnetState);
+}
+
 void GridMapPuzzleData::synchronize(Common::Serializer &ser) {
 	syncInt16Array(ser, itemState);
 }
@@ -349,6 +353,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new BeadPuzzleData();
 	case SortPuzzleData::getTag():
 		return new SortPuzzleData();
+	case MagnetMazePuzzleData::getTag():
+		return new MagnetMazePuzzleData();
 	case GridMapPuzzleData::getTag():
 		return new GridMapPuzzleData();
 	case JournalData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 88fb64fb7dc..bfcdbbcb6f2 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -140,6 +140,19 @@ struct SortPuzzleData : public PuzzleData {
 	Common::Array<int16> solvedState;
 };
 
+// Per-magnet (left, top, right, bottom, locked) packed as 5 int16s. The
+// puzzle's two scenes (3280, 3281) are the same puzzle with the same data,
+// so a single flat array suffices.
+struct MagnetMazePuzzleData : public PuzzleData {
+	MagnetMazePuzzleData() {}
+	virtual ~MagnetMazePuzzleData() {}
+
+	static constexpr uint32 getTag() { return MKTAG('M', 'M', 'A', 'Z'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::Array<int16> magnetState;
+};
+
 // Per-item (inMap, inItems, mapRow, mapCol, itemsRow, itemsCol) packed as
 // 6 int16s.
 struct GridMapPuzzleData : public PuzzleData {


Commit: 48cb27af63a8f588cf11d518609d382afa8139d6
    https://github.com/scummvm/scummvm/commit/48cb27af63a8f588cf11d518609d382afa8139d6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:09:03+03:00

Commit Message:
NANCY: Implement taskbar button notifications for Nancy10+

Changed paths:
    engines/nancy/action/datarecords.cpp
    engines/nancy/action/miscrecords.cpp
    engines/nancy/state/scene.cpp
    engines/nancy/ui/cellphonepopup.cpp
    engines/nancy/ui/inventorypopup.cpp
    engines/nancy/ui/notebookpopup.cpp
    engines/nancy/ui/taskbar.cpp
    engines/nancy/ui/taskbar.h


diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index 6610f500be0..0f6bf6bcd68 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -26,6 +26,8 @@
 
 #include "engines/nancy/state/scene.h"
 
+#include "engines/nancy/ui/taskbar.h"
+
 namespace Nancy {
 namespace Action {
 
@@ -480,6 +482,18 @@ void ModifyListEntry::execute() {
 		NancySceneState.getNotebookPopup().refreshContent();
 	}
 
+	// Nancy 10+: raise the notebook notification badge on the taskbar when
+	// a new entry is added to one of the tracked lists.
+	if (_type == kAdd && g_nancy->getGameType() >= kGameTypeNancy10) {
+		if (UI::Taskbar *taskbar = NancySceneState.getTaskbar()) {
+			if (_surfaceID == 4) {
+				taskbar->setNotification(kTaskButtonNotebook, 0);
+			} else if (_surfaceID == 3) {
+				taskbar->setNotification(kTaskButtonNotebook, 1);
+			}
+		}
+	}
+
 	finishExecution();
 }
 
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 1c45bbd347f..9dca812d0e4 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -281,6 +281,13 @@ void AddSearchLink::readData(Common::SeekableReadStream &stream) {
 void AddSearchLink::execute() {
 	//NancySceneState.getCellPhonePopup().addSearchLink(
 	//	_mode, _key, _value, _extra, _flag, _eventFlag);
+
+	// Cellphone taskbar badge: mode 0 = new email (sub-cat 1), mode != 0
+	// = new web search topic (sub-cat 2).
+	if (UI::Taskbar *taskbar = NancySceneState.getTaskbar()) {
+		taskbar->setNotification(kTaskButtonCellphone, _mode == 0 ? 1 : 2);
+	}
+
 	finishExecution();
 }
 
@@ -315,6 +322,12 @@ void ChangeCellPhoneInfo::readData(Common::SeekableReadStream &stream) {
 
 void ChangeCellPhoneInfo::execute() {
 	NancySceneState.getCellPhonePopup().upsertContact(_contact);
+
+	// Cellphone taskbar badge: a new/updated contact triggers sub-cat 0.
+	if (UI::Taskbar *taskbar = NancySceneState.getTaskbar()) {
+		taskbar->setNotification(kTaskButtonCellphone, 0);
+	}
+
 	finishExecution();
 }
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 02cefa63196..2a0352af58d 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -349,8 +349,12 @@ void Scene::addItemToInventory(int16 id) {
 
 		if (g_nancy->getGameType() <= kGameTypeNancy9) {
 			_inventoryBox.addItem(id);
-		} else if (_inventoryPopup.isOpen()) {
-			_inventoryPopup.refreshGrid();
+		} else {
+			if (_inventoryPopup.isOpen()) {
+				_inventoryPopup.refreshGrid();
+			} else if (_taskbar) {
+				_taskbar->setNotification(kTaskButtonInventory, 0);
+			}
 		}
 	}
 }
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 0de5e463f4e..bcea4ea19fb 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -30,6 +30,8 @@
 
 #include "engines/nancy/state/scene.h"
 
+#include "engines/nancy/ui/taskbar.h"
+
 #include "engines/nancy/ui/cellphonepopup.h"
 
 namespace Nancy {
@@ -184,6 +186,8 @@ void CellPhonePopup::open() {
 	drawScreenContent();
 	setVisible(true);
 
+	NancySceneState.getTaskbar()->clearAllNotifications(kTaskButtonCellphone);
+
 	if (!_uiclData->header.sounds[0].name.empty()) {
 		g_nancy->_sound->loadSound(_uiclData->header.sounds[0]);
 		g_nancy->_sound->playSound(_uiclData->header.sounds[0]);
diff --git a/engines/nancy/ui/inventorypopup.cpp b/engines/nancy/ui/inventorypopup.cpp
index eb24e8693b9..ddada1f2ef2 100644
--- a/engines/nancy/ui/inventorypopup.cpp
+++ b/engines/nancy/ui/inventorypopup.cpp
@@ -29,6 +29,7 @@
 #include "engines/nancy/state/scene.h"
 
 #include "engines/nancy/ui/inventorypopup.h"
+#include "engines/nancy/ui/taskbar.h"
 
 namespace Nancy {
 namespace UI {
@@ -86,6 +87,8 @@ void InventoryPopup::open() {
 
 	setVisible(true);
 
+	NancySceneState.getTaskbar()->clearAllNotifications(kTaskButtonInventory);
+
 	if (!_uiivData->header.sounds[0].name.empty()) {
 		g_nancy->_sound->loadSound(_uiivData->header.sounds[0]);
 		g_nancy->_sound->playSound(_uiivData->header.sounds[0]);
diff --git a/engines/nancy/ui/notebookpopup.cpp b/engines/nancy/ui/notebookpopup.cpp
index 2c3c658975d..50d79fb3842 100644
--- a/engines/nancy/ui/notebookpopup.cpp
+++ b/engines/nancy/ui/notebookpopup.cpp
@@ -30,6 +30,8 @@
 
 #include "engines/nancy/state/scene.h"
 
+#include "engines/nancy/ui/taskbar.h"
+
 #include "engines/nancy/ui/notebookpopup.h"
 
 namespace Nancy {
@@ -112,6 +114,8 @@ void NotebookPopup::open() {
 
 	setVisible(true);
 
+	NancySceneState.getTaskbar()->clearAllNotifications(kTaskButtonNotebook);
+
 	// JournalData entries may have changed since the last open (added by
 	// ModifyListEntry, marked complete, etc.) — re-render content.
 	refreshContent();
diff --git a/engines/nancy/ui/taskbar.cpp b/engines/nancy/ui/taskbar.cpp
index d266c2a62bd..abd2cb1ce66 100644
--- a/engines/nancy/ui/taskbar.cpp
+++ b/engines/nancy/ui/taskbar.cpp
@@ -39,6 +39,9 @@ Taskbar::Taskbar() :
 	for (uint i = 0; i < TASK::kNumButtons; ++i) {
 		_buttonStates[i] = kButtonIdle;
 		_enabled[i] = true;
+		for (uint s = 0; s < kNumNotificationSubCategories; ++s) {
+			_notifications[i][s] = false;
+		}
 	}
 }
 
@@ -110,9 +113,16 @@ Taskbar::ButtonState Taskbar::restingState(uint index) const {
 	if (!_enabled[index]) {
 		return kButtonDisabled;
 	}
+	// Disable override (ControlUIItems) takes precedence over the badge —
+	// FUN_004d51c3 only draws the notify sprite when state != 3.
 	const ButtonOverride &o = _overrides[index];
 	if (o.active && _currentScene >= o.startScene && _currentScene <= o.endScene) {
-		return o.state;
+		return kButtonDisabled;
+	}
+	for (uint s = 0; s < kNumNotificationSubCategories; ++s) {
+		if (_notifications[index][s]) {
+			return kButtonNotification;
+		}
 	}
 	return kButtonIdle;
 }
@@ -131,17 +141,36 @@ void Taskbar::toggleButton(uint index, bool enabled) {
 	}
 }
 
-void Taskbar::setNotification(uint buttonIndex, int16 startScene, int16 endScene) {
+void Taskbar::setNotification(uint buttonIndex, uint subCategory) {
+	if (buttonIndex >= TASK::kNumButtons || subCategory >= kNumNotificationSubCategories) {
+		return;
+	}
+	_notifications[buttonIndex][subCategory] = true;
+
+	if ((int)buttonIndex != _hoveredButton) {
+		drawButton(buttonIndex, restingState(buttonIndex));
+	}
+}
+
+void Taskbar::clearNotification(uint buttonIndex, uint subCategory) {
+	if (buttonIndex >= TASK::kNumButtons || subCategory >= kNumNotificationSubCategories) {
+		return;
+	}
+	_notifications[buttonIndex][subCategory] = false;
+
+	if ((int)buttonIndex != _hoveredButton) {
+		drawButton(buttonIndex, restingState(buttonIndex));
+	}
+}
+
+void Taskbar::clearAllNotifications(uint buttonIndex) {
 	if (buttonIndex >= TASK::kNumButtons) {
 		return;
 	}
-	_overrides[buttonIndex].active = true;
-	_overrides[buttonIndex].state = kButtonNotification;
-	_overrides[buttonIndex].startScene = startScene;
-	_overrides[buttonIndex].endScene = endScene;
+	for (uint s = 0; s < kNumNotificationSubCategories; ++s) {
+		_notifications[buttonIndex][s] = false;
+	}
 
-	// Re-render this button immediately unless the player is currently
-	// hovering it (hover takes priority over the override sprite).
 	if ((int)buttonIndex != _hoveredButton) {
 		drawButton(buttonIndex, restingState(buttonIndex));
 	}
@@ -152,7 +181,6 @@ void Taskbar::setDisabledRange(uint buttonIndex, int16 startScene, int16 endScen
 		return;
 	}
 	_overrides[buttonIndex].active = true;
-	_overrides[buttonIndex].state = kButtonDisabled;
 	_overrides[buttonIndex].startScene = startScene;
 	_overrides[buttonIndex].endScene = endScene;
 
@@ -226,11 +254,10 @@ 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 a pending notification
-		// for this button, so re-opening the popup doesn't keep blinking.
-		// A scene-ranged disable override is left intact.
-		if (_overrides[newHovered].state == kButtonNotification) {
-			_overrides[newHovered].active = false;
+		// 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;
 		}
 		drawButton(newHovered, kButtonHover);
 		_clickedButton = newHovered;
diff --git a/engines/nancy/ui/taskbar.h b/engines/nancy/ui/taskbar.h
index f8b1d92dd22..3838ed4f3a6 100644
--- a/engines/nancy/ui/taskbar.h
+++ b/engines/nancy/ui/taskbar.h
@@ -46,17 +46,20 @@ public:
 	// in its disabled sprite and ignores clicks.
 	void toggleButton(uint index, bool enabled);
 
-	// Configure a per-button override that is active only while the player
-	// is in a scene whose ID falls in [startScene, endScene]. The button
-	// renders in its notification (badge) sprite, or its disabled sprite,
-	// for that range; outside it reverts to idle.
-	// setDisabledRange is driven by AR 29 (ControlUIItems, _flagB != 0).
-	// setNotification renders the badge sprite; the source that should drive
-	// it has not been identified yet (it is NOT ControlUIItems).
-	void setNotification(uint buttonIndex, int16 startScene, int16 endScene);
+	// Disable override: keep the disabled sprite active while the current
+	// scene is in [startScene, endScene]. Driven by AR 29 (ControlUIItems,
+	// _flagB != 0).
 	void setDisabledRange(uint buttonIndex, int16 startScene, int16 endScene);
 	void clearButtonOverride(uint buttonIndex);
 
+	// Notification badge: each button has up to 3 independent notification
+	// sub-categories. The badge shows when any sub-category is set; it
+	// persists across scene changes until cleared (or all sub-cats are
+	// cleared individually). Disabled state takes precedence over the badge.
+	void setNotification(uint buttonIndex, uint subCategory);
+	void clearNotification(uint buttonIndex, uint subCategory);
+	void clearAllNotifications(uint buttonIndex);
+
 	// Re-evaluate which buttons should currently show their override
 	// sprite. Call after a scene change so the range check kicks in.
 	void updateNotificationStates(int16 currentSceneID);
@@ -74,16 +77,17 @@ private:
 		kButtonNotification = 4    // popup has new content (badge sprite)
 	};
 
-	// A scene-ranged sprite override for one button. While active and the
+	// A scene-ranged disable override for one button. While active and the
 	// current scene is within [startScene, endScene] the button renders in
-	// `state` (kButtonNotification or kButtonDisabled).
+	// the disabled state.
 	struct ButtonOverride {
 		bool active = false;
-		ButtonState state = kButtonIdle;
 		int16 startScene = -1;
 		int16 endScene = -1;
 	};
 
+	static const uint kNumNotificationSubCategories = 3;
+
 	void drawButton(uint index, ButtonState state);
 	ButtonState restingState(uint index) const;
 	// True when the button currently accepts hover/click (not disabled).
@@ -97,6 +101,7 @@ private:
 	bool _enabled[5];
 	ButtonState _buttonStates[5];
 	ButtonOverride _overrides[5];
+	bool _notifications[5][kNumNotificationSubCategories];
 };
 
 } // End of namespace UI


Commit: cbd22cfc0a8a7b0a3941fc1b83c98b7bc9c1141b
    https://github.com/scummvm/scummvm/commit/cbd22cfc0a8a7b0a3941fc1b83c98b7bc9c1141b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:09:08+03:00

Commit Message:
NANCY: Implement the rest of the cellphone functionality for Nancy10+

This *breaks* existing Nancy10+ saved games

- Implemented online email/web screen
- Implemented web search list
- Implemented message list
- Implemented full-text view of emails / web pages

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


diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 9dca812d0e4..35af709604a 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -279,8 +279,8 @@ void AddSearchLink::readData(Common::SeekableReadStream &stream) {
 }
 
 void AddSearchLink::execute() {
-	//NancySceneState.getCellPhonePopup().addSearchLink(
-	//	_mode, _key, _value, _extra, _flag, _eventFlag);
+	NancySceneState.getCellPhonePopup().addSearchLink(
+		_mode, _key, _value, _extra, _flag, _eventFlag);
 
 	// Cellphone taskbar badge: mode 0 = new email (sub-cat 1), mode != 0
 	// = new web search topic (sub-cat 2).
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 2c029526ba6..c4df40aa807 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -938,9 +938,12 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 
 	readFilename(*chunkStream, overlayImageName);
 
-	// Skip shared UIButton template - the sub-fields data is read
-	// separately for each button record below.
-	chunkStream->skip(206);
+	// Shared UIButton template (206 bytes); its sub-fields are read
+	// separately per button below. The last 49 bytes of the block are
+	// the call-sound template (channel / volume / loops for the ring,
+	// pickup and invalid-number cues).
+	chunkStream->skip(157);
+	callSoundTemplate.readNormal(*chunkStream);
 
 	for (uint i = 0; i < kNumDialPadSlots; ++i) {
 		readRect(*chunkStream, dialPadSlots[i].srcRect);
@@ -974,13 +977,13 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	readRect(*chunkStream, dirLabel.srcRect);
 	readRect(*chunkStream, dirLabel.destRect);
 
-	// Call/hang-up widget (3 rects).
-	readRect(*chunkStream, callButton.srcRectIdle);
-	readRect(*chunkStream, callButton.srcRectPressed);
-	readRect(*chunkStream, callButton.destRect);
+	// Help "?" button widget (3 rects).
+	readRect(*chunkStream, helpButton.srcRectIdle);
+	readRect(*chunkStream, helpButton.srcRectPressed);
+	readRect(*chunkStream, helpButton.destRect);
 
 	// Screen-content sprite block
-	readFilename(*chunkStream, phoneUseSound);
+	readFilename(*chunkStream, helpTextKey);
 	readRect(*chunkStream, signalSpriteSrc);
 	readRect(*chunkStream, signalSpriteSrcAlt);
 	readRect(*chunkStream, signalSpriteDest);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index c4310365ae0..23483060220 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -612,6 +612,12 @@ struct UICL : public EngineData {
 
 	UIPopupHeader header;
 	Common::Path overlayImageName;
+
+	// Template the call-flow sounds (ring / pickup / invalid) are played
+	// through. Only the name is swapped per call; channel / volume /
+	// loops come from here.
+	SoundDescription callSoundTemplate;
+
 	DialPadSlot dialPadSlots[kNumDialPadSlots];
 
 	// Screen-frame and label rects
@@ -625,10 +631,12 @@ struct UICL : public EngineData {
 	SrcDestRectPair webLabel;
 	SrcDestRectPair dirLabel;
 
-	ThreeRectWidget callButton;
+	// Help "?" button (original button index 15). The visible Talk/Call key
+	// is dial-pad slot 12, not a separate widget.
+	ThreeRectWidget helpButton;
 
 	// Screen-content sprite block
-	Common::Path phoneUseSound;
+	Common::String helpTextKey;               // CVTX key for the help page text
 	Common::Rect signalSpriteSrc;
 	Common::Rect signalSpriteSrcAlt;
 	Common::Rect signalSpriteDest;
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 7de27e562bc..a11dbc379cd 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -331,6 +331,25 @@ void CellPhoneData::synchronize(Common::Serializer &ser) {
 
 		ser.syncBytes(c.unknownSuffix, sizeof(c.unknownSuffix));
 	}
+
+	syncLinkArray(ser, emailMessages);
+	syncLinkArray(ser, searchLinks);
+}
+
+void CellPhoneData::syncLinkArray(Common::Serializer &ser, Common::Array<LinkEntry> &arr) {
+	uint16 n = (uint16)arr.size();
+	ser.syncAsUint16LE(n);
+	if (ser.isLoading()) {
+		arr.resize(n);
+	}
+	for (uint16 i = 0; i < n; ++i) {
+		ser.syncString(arr[i].key);
+		ser.syncString(arr[i].value);
+		ser.syncAsSint16LE(arr[i].extra);
+		ser.syncAsSint16LE(arr[i].flag);
+		ser.syncAsSint16LE(arr[i].eventFlag);
+		ser.syncAsByte(arr[i].read);
+	}
 }
 
 PuzzleData *makePuzzleData(const uint32 tag) {
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index bfcdbbcb6f2..5932ec92c0e 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -218,9 +218,19 @@ struct TableData : public PuzzleData {
 	Common::Array<float> comboValues;
 };
 
-// Nancy 10+ cellphone state mutated by the ChangeCellPhoneInfo and
-// SetCellPhoneBatteryAndSignal action records, persisted between saves.
+// Nancy 10+ cellphone state mutated by the ChangeCellPhoneInfo,
+// SetCellPhoneBatteryAndSignal and AddSearchLink action records,
+// persisted between saves.
 struct CellPhoneData : public PuzzleData {
+	struct LinkEntry {
+		Common::String key;       // CVTX key whose looked-up text is shown in the list
+		Common::String value;     // CVTX key for the body (email only); unused for search
+		int16 extra = 0;          // search mode: page index (mode-1 only); unused for email
+		int16 flag = -1;          // stored by the AR but unused by the original; reserved
+		int16 eventFlag = -1;     // event-flag index set when the entry is opened
+		bool read = false;        // email only: set once the message is opened
+	};
+
 	CellPhoneData() {}
 	virtual ~CellPhoneData() {}
 
@@ -233,6 +243,15 @@ struct CellPhoneData : public PuzzleData {
 	// 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<LinkEntry> emailMessages;
+	Common::Array<LinkEntry> searchLinks;
+
+private:
+	void syncLinkArray(Common::Serializer &ser, Common::Array<LinkEntry> &arr);
 };
 
 PuzzleData *makePuzzleData(const uint32 tag);
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index bcea4ea19fb..60e3f429414 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -28,6 +28,8 @@
 #include "engines/nancy/resource.h"
 #include "engines/nancy/sound.h"
 
+#include "engines/nancy/misc/hypertext.h"
+
 #include "engines/nancy/state/scene.h"
 
 #include "engines/nancy/ui/taskbar.h"
@@ -37,6 +39,38 @@
 namespace Nancy {
 namespace UI {
 
+// Renders the engine's hypertext markup (colour / formatting tags) into a
+// scratch surface, which the content view then blits into the LCD. Thin
+// wrapper that exposes HypertextParser's protected rendering entry points.
+class CellPhoneHypertext : public Misc::HypertextParser {
+public:
+	// Expose the inherited per-page image hooks so the popup can register
+	// the UIBW image table before render() is called.
+	using Misc::HypertextParser::setImageName;
+	using Misc::HypertextParser::addImage;
+
+	void render(uint width, uint height, uint32 transColor,
+				const Common::String &text, uint fontID) {
+		initSurfaces(width, height, g_nancy->_graphics->getInputPixelFormat(),
+						transColor, transColor);
+		_fullSurface.setTransparentColor(transColor);
+		addTextLine(text);
+
+		const Font *font = g_nancy->_graphics->getFont(fontID);
+		const TBOX *tbox = GetEngineData(TBOX);
+		Common::Rect textBounds(0, 0, (int16)width, (int16)height);
+		const uint d = font ? (font->getFontHeight() + 1) / 2 + 1 : 0;
+		textBounds.left += d;
+		textBounds.top += d + 1;
+		const int leftOffset = tbox ? (int)tbox->leftOffset - textBounds.left : 0;
+		drawAllText(textBounds, (uint)MAX(0, leftOffset), fontID, fontID);
+	}
+
+	const Graphics::ManagedSurface &surface() const { return _fullSurface; }
+	uint16 textHeight() const { return _drawnTextHeight; }
+	const Common::Array<Common::Rect> &hotspots() const { return _hotspots; }
+};
+
 // Chunk destRects are raw screen coords; subtract _screenPosition.topLeft
 // to get popup-local. srcRects are atlas-image coords for _spritesImage
 // and pass through unchanged.
@@ -136,6 +170,43 @@ void CellPhonePopup::setBatteryLow(bool low) {
 	}
 }
 
+void CellPhonePopup::addSearchLink(int16 mode, const Common::String &key,
+									const Common::String &value, int16 extra,
+									int16 flag, int16 eventFlag) {
+	CellPhoneData *cellData = (CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
+	if (!cellData) {
+		return;
+	}
+
+	// Original (AddSearchLink @ 004dac11) branches on `mode == 0` (email)
+	// vs anything else (search) — not specifically mode == 1.
+	const bool isSearch = (mode != 0);
+	Common::Array<CellPhoneData::LinkEntry> &list =
+		isSearch ? cellData->searchLinks : cellData->emailMessages;
+
+	// Skip duplicates (matched by key) so re-running the scene doesn't
+	// pile up the same entries.
+	for (uint i = 0; i < list.size(); ++i) {
+		if (list[i].key.equalsIgnoreCase(key)) {
+			return;
+		}
+	}
+
+	CellPhoneData::LinkEntry e;
+	e.key = key;
+	e.value = value;
+	e.extra = extra;
+	e.flag = flag;
+	e.eventFlag = eventFlag;
+	list.push_back(e);
+
+	if (_isVisible &&
+			((isSearch && _screenState == kWebList) ||
+			 (!isSearch && _screenState == kEmailList))) {
+		drawScreenContent();
+	}
+}
+
 void CellPhonePopup::upsertContact(const UICL::Contact &c) {
 	// Match against the 11-byte dial pattern (prefix[2..12]). If an entry
 	// already carries that pattern, overwrite it; otherwise append.
@@ -221,6 +292,10 @@ void CellPhonePopup::updateGraphics() {
 	case kWelcome:
 	case kDialing:
 	case kDirectory:
+	case kOnlineHub:
+	case kWebList:
+	case kEmailList:
+	case kContentView:
 		break;
 
 	case kPlaceCall:
@@ -297,10 +372,18 @@ void CellPhonePopup::updateGraphics() {
 // --------------------------------------------------------------------
 
 void CellPhonePopup::drawChrome() {
-	_drawSurface.blitFrom(_overlayImage, _uiclData->header.normalSrcRect,
-							Common::Point(0, 0));
+	// The chrome image holds two layouts side-by-side: the normal
+	// phone-with-keypad and a zoomed-in "full screen" variant with the
+	// keypad hidden. fullEmptyScreenSrc (chunk+0x10b5) points at the
+	// latter; the original swaps to it for browser/list/email-content
+	// modes so the LCD can extend down into the keypad area.
+	const Common::Rect &chromeSrc =
+		isZoomedChromeState() && !_uiclData->fullEmptyScreenSrc.isEmpty()
+			? _uiclData->fullEmptyScreenSrc
+			: _uiclData->header.normalSrcRect;
+	_drawSurface.blitFrom(_overlayImage, chromeSrc, Common::Point(0, 0));
 	drawCloseButton(_closeButtonHovered ? 1 : 0);
-	drawCallButtonState(0);
+	drawHelpButton(0);
 	_needsRedraw = true;
 }
 
@@ -343,9 +426,50 @@ void CellPhonePopup::drawScreenContent() {
 		break;
 
 	case kDirectory:
+		drawHeading(_uiclData->dirHeading);
 		drawDirectoryList();
 		drawDirectoryArrows();
 		break;
+
+	case kOnlineHub: {
+		drawHeading(_uiclData->onlineHeading);
+		// Email / Web option buttons (subButtons 3 and 4) sit inside the LCD.
+		const UICL::ThreeRectWidget &emailBtn = _uiclData->subButtons[3];
+		const UICL::ThreeRectWidget &webBtn   = _uiclData->subButtons[4];
+		const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
+		if (!emailBtn.srcRectIdle.isEmpty() && !emailBtn.destRect.isEmpty()) {
+			_drawSurface.blitFrom(_spritesImage, emailBtn.srcRectIdle,
+					Common::Point(emailBtn.destRect.left - chunkOrigin.x,
+									emailBtn.destRect.top - chunkOrigin.y));
+		}
+		if (!webBtn.srcRectIdle.isEmpty() && !webBtn.destRect.isEmpty()) {
+			_drawSurface.blitFrom(_spritesImage, webBtn.srcRectIdle,
+					Common::Point(webBtn.destRect.left - chunkOrigin.x,
+									webBtn.destRect.top - chunkOrigin.y));
+		}
+		break;
+	}
+
+	case kWebList:
+		// Web search-results list (AR-131 mode 1).
+		drawHeading(_uiclData->searchHeading);
+		drawLinkList();
+		drawDirectoryArrows();
+		break;
+
+	case kEmailList:
+		drawHeading(_uiclData->emailHeading);
+		drawLinkList();
+		drawDirectoryArrows();
+		break;
+
+	case kContentView:
+		if (_contentHeading) {
+			drawHeading(*_contentHeading);
+		}
+		drawContentView();
+		drawDirectoryArrows();
+		break;
 	}
 
 	_needsRedraw = true;
@@ -427,12 +551,19 @@ void CellPhonePopup::drawConnectedLabel() {
 }
 
 void CellPhonePopup::drawConnectingSprite() {
-	if (_uiclData->connectingSpriteSrc.isEmpty() || _uiclData->connectingSpriteDest.isEmpty()) {
+	// Invalid-number states swap in the alternate connecting sprite
+	// (the "try again" / red-light variant).
+	const bool useAlt = (_screenState == kInvalidNumber || _screenState == kWaitInvalid) &&
+						!_uiclData->connectingSpriteSrcAlt.isEmpty();
+	const Common::Rect &src = useAlt
+		? _uiclData->connectingSpriteSrcAlt
+		: _uiclData->connectingSpriteSrc;
+	if (src.isEmpty() || _uiclData->connectingSpriteDest.isEmpty()) {
 		return;
 	}
 
 	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
-	_drawSurface.blitFrom(_spritesImage, _uiclData->connectingSpriteSrc,
+	_drawSurface.blitFrom(_spritesImage, src,
 							Common::Point(_uiclData->connectingSpriteDest.left - chunkOrigin.x,
 											_uiclData->connectingSpriteDest.top - chunkOrigin.y));
 }
@@ -454,23 +585,23 @@ void CellPhonePopup::drawDialedNumber() {
 						_screenPosition.width() - x, 0);
 }
 
-void CellPhonePopup::drawCallButtonState(uint state) {
-	const UICL::ThreeRectWidget &cb = _uiclData->callButton;
-	if (cb.destRect.isEmpty()) {
+void CellPhonePopup::drawHelpButton(uint state) {
+	const UICL::ThreeRectWidget &hb = _uiclData->helpButton;
+	if (hb.destRect.isEmpty()) {
 		return;
 	}
 
-	const Common::Rect &src = (state == 1 && !cb.srcRectPressed.isEmpty())
-								? cb.srcRectPressed
-								: cb.srcRectIdle;
+	const Common::Rect &src = (state == 1 && !hb.srcRectPressed.isEmpty())
+								? hb.srcRectPressed
+								: hb.srcRectIdle;
 	if (src.isEmpty()) {
 		return;
 	}
 
 	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
 	_drawSurface.blitFrom(_spritesImage, src,
-							Common::Point(cb.destRect.left - chunkOrigin.x,
-											cb.destRect.top - chunkOrigin.y));
+							Common::Point(hb.destRect.left - chunkOrigin.x,
+											hb.destRect.top - chunkOrigin.y));
 }
 
 void CellPhonePopup::drawCloseButton(uint state) {
@@ -521,16 +652,243 @@ void CellPhonePopup::drawStatusLabels() {
 	}
 }
 
-void CellPhonePopup::drawDirHeading() {
-	const UICL::SrcDestRectPair &dh = _uiclData->dirHeading;
-	if (dh.srcRect.isEmpty() || dh.destRect.isEmpty()) {
+void CellPhonePopup::drawHeading(const UICL::SrcDestRectPair &heading) {
+	if (heading.srcRect.isEmpty() || heading.destRect.isEmpty()) {
 		return;
 	}
-
 	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
-	_drawSurface.blitFrom(_spritesImage, dh.srcRect,
-							Common::Point(dh.destRect.left - chunkOrigin.x,
-											dh.destRect.top - chunkOrigin.y));
+	_drawSurface.blitFrom(_spritesImage, heading.srcRect,
+							Common::Point(heading.destRect.left - chunkOrigin.x,
+											heading.destRect.top - chunkOrigin.y));
+}
+
+Common::Array<uint> CellPhonePopup::listVisibleIndices() const {
+	Common::Array<uint> out;
+	const CellPhoneData *cellData = (const CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
+	if (!cellData) {
+		return out;
+	}
+
+	if (_screenState == kWebList) {
+		for (uint i = 0; i < cellData->searchLinks.size(); ++i) {
+			out.push_back(i);
+		}
+	} else if (_screenState == kEmailList) {
+		// "Old Email Only" (no-signal) hides messages not yet read.
+		for (uint i = 0; i < cellData->emailMessages.size(); ++i) {
+			if (!_noSignal || cellData->emailMessages[i].read) {
+				out.push_back(i);
+			}
+		}
+	}
+	return out;
+}
+
+void CellPhonePopup::drawLinkList() {
+	const CellPhoneData *cellData = (const CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
+	if (!cellData) {
+		return;
+	}
+	const Common::Array<CellPhoneData::LinkEntry> &list =
+		_screenState == kWebList ? cellData->searchLinks : cellData->emailMessages;
+	const Common::Array<uint> visible = listVisibleIndices();
+	if (visible.empty()) {
+		return;
+	}
+
+	const Font *font = g_nancy->_graphics->getFont(_uiclData->fontId2);
+	if (!font) {
+		return;
+	}
+
+	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+
+	const uint titleRows = listTitleRows();
+	const uint totalRows = maxDirectoryRows();
+	const uint maxEntries = totalRows > titleRows ? totalRows - titleRows : 0;
+	for (uint visibleRow = 0;
+			visibleRow < maxEntries && _directoryScroll + visibleRow < visible.size();
+			++visibleRow) {
+		const uint absolute = visible[_directoryScroll + visibleRow];
+		const Common::Rect rowRect = directoryRowRect(titleRows + visibleRow);
+
+		// In email mode, prefix each row with the unread / selected icon.
+		int textX = rowRect.left;
+		if (_screenState == kEmailList) {
+			const Common::Rect &icon = (visibleRow == _directorySelection &&
+										!_uiclData->emailIconSelected.isEmpty())
+				? _uiclData->emailIconSelected
+				: _uiclData->emailIconUnread;
+			if (!icon.isEmpty()) {
+				const int iconX = MAX(0, rowRect.left - icon.width() - 2);
+				_drawSurface.blitFrom(_spritesImage, icon,
+										Common::Point(iconX, rowRect.top));
+				textX = MAX(textX, iconX + icon.width() + 2);
+			}
+		}
+
+		Common::String lookupKey = list[absolute].key;
+		lookupKey.toUppercase();
+		Common::String rowText = (autotext && autotext->texts.contains(lookupKey))
+			? autotext->texts[lookupKey]
+			: lookupKey;
+		// Single-line draw — drop every <n> markup so they don't render as
+		// literal "<n>" glyphs and crowd the row.
+		while (rowText.contains("<n>")) {
+			rowText.erase(rowText.find("<n>"), 3);
+		}
+
+		font->drawString(&_drawSurface, rowText,
+							textX, rowRect.top,
+							rowRect.right - textX, 0);
+	}
+}
+
+void CellPhonePopup::openContentView(const Common::String &key, const UICL::SrcDestRectPair &heading) {
+	_contentReturnState = _screenState;
+	_contentHeading = &heading;
+	_contentKey = key;
+	_contentKey.toUppercase();
+	_contentScroll = 0;
+	enterScreenState(kContentView);
+}
+
+void CellPhonePopup::openBrowserHome() {
+	// Web shows the navigable topic list (clickable rows + arrow-key
+	// selection); clicking a topic opens its article in the content view.
+	enterScreenState(kWebList);
+}
+
+void CellPhonePopup::drawContentView() {
+	if (_contentKey.empty()) {
+		return;
+	}
+
+	const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+	if (!autotext || !autotext->texts.contains(_contentKey)) {
+		return;
+	}
+
+	const Font *font = g_nancy->_graphics->getFont(_uiclData->fontId2);
+	if (!font) {
+		return;
+	}
+
+	// Content view runs under the zoomed-in chrome (drawChrome blits
+	// fullEmptyScreenSrc for kContentView), so the keypad is no longer
+	// visible underneath and we can render into the larger LCD area
+	// that emailListContainer / FUN_004dae28 define.
+	const Common::Rect &ws =
+		_uiclData->emailListContainer.isEmpty()
+			? _uiclData->welcomeScreen.destRect
+			: _uiclData->emailListContainer;
+	const int lcdLeft = ws.left - _screenPosition.left;
+	const int lcdTop  = ws.top  - _screenPosition.top;
+	const int lcdW    = ws.width();
+	const int lcdH    = ws.height();
+	const int textTop = 22;                 // clear the heading sprite
+	const int viewH   = MAX(0, lcdH - textTop);
+	const int rowH    = MAX(font->getFontHeight() + 1, 12);
+
+	// Render the engine's hypertext markup into a tall scratch surface,
+	// then blit a vertically-scrolled window of it into the LCD.
+	const Common::String renderText = autotext->texts[_contentKey];
+
+	// Find this page in the UIBW chunk (browser pages only); its hotspot
+	// records are the per-page image table the article references.
+	const UIBW *browserData = nullptr;
+	int pageIdx = -1;
+	if (_contentHeading == &_uiclData->browserHeading) {
+		browserData = GetEngineData(UIBW);
+		if (browserData) {
+			for (uint p = 0; p < browserData->pages.size(); ++p) {
+				Common::String pageKey = browserData->pages[p].imageName.toString();
+				pageKey.toUppercase();
+				if (pageKey == _contentKey) {
+					pageIdx = (int)p;
+					break;
+				}
+			}
+		}
+	}
+
+	// Parse the <H>...<L> regions out of the body before rendering — each
+	// becomes a clickable in-page hyperlink. The text between the markers
+	// is used as the target article CVTX key.
+	_contentHotspotTargets.clear();
+	{
+		uint32 cursor = 0;
+		while (cursor < renderText.size()) {
+			const uint32 hStart = renderText.find("<H>", cursor);
+			if (hStart >= renderText.size()) {
+				break;
+			}
+			const uint32 linkTextStart = hStart + 3;
+			const uint32 lStart = renderText.find("<L>", linkTextStart);
+			if (lStart >= renderText.size()) {
+				break;
+			}
+			Common::String linkText = renderText.substr(linkTextStart, lStart - linkTextStart);
+			linkText.toUppercase();
+			_contentHotspotTargets.push_back(linkText);
+			cursor = lStart + 3;
+		}
+	}
+
+	CellPhoneHypertext ht;
+	if (pageIdx >= 0 && !browserData->pages[pageIdx].hotspots.empty()) {
+		// UIBW hotspots are misnamed — they're per-page image records
+		// (id = line in the rendered text, rect = source in the atlas).
+		ht.setImageName(browserData->imageName);
+		for (uint i = 0; i < browserData->pages[pageIdx].hotspots.size(); ++i) {
+			const UIBW::Hotspot &h = browserData->pages[pageIdx].hotspots[i];
+			ht.addImage(h.id, h.rect);
+		}
+	}
+	const uint32 trans = g_nancy->_graphics->getTransColor();
+	ht.render(lcdW, 2000, trans, renderText, _uiclData->fontId2);
+
+	// Clamp scroll to the rendered text height.
+	const int textH = ht.textHeight();
+	const int maxScrollPx = MAX(0, textH - viewH);
+	const int maxScroll = maxScrollPx / rowH;
+	if ((int)_contentScroll > maxScroll) {
+		_contentScroll = maxScroll;
+	}
+
+	const int srcTop = (int)_contentScroll * rowH;
+	Common::Rect srcRect(0, srcTop, lcdW, srcTop + viewH);
+	srcRect.clip(Common::Rect(ht.surface().w, ht.surface().h));
+	if (srcRect.isEmpty()) {
+		_contentHotspots.clear();
+		return;
+	}
+
+	_drawSurface.blitFrom(ht.surface(), srcRect,
+							Common::Point(lcdLeft, lcdTop + textTop));
+
+	// Translate the parser's hotspots (surface coords) into popup-local
+	// coords for the current scroll. Drop any that aren't fully visible
+	// inside the LCD window so we don't fire on partially-clipped links.
+	_contentHotspots.clear();
+	const Common::Array<Common::Rect> &surfaceHs = ht.hotspots();
+	const uint linkCount = MIN(surfaceHs.size(), _contentHotspotTargets.size());
+	for (uint i = 0; i < linkCount; ++i) {
+		Common::Rect r = surfaceHs[i];
+		r.translate(lcdLeft, lcdTop + textTop - srcTop);
+		const Common::Rect lcdClip(lcdLeft, lcdTop + textTop,
+									lcdLeft + lcdW, lcdTop + textTop + viewH);
+		Common::Rect clipped = r.findIntersectingRect(lcdClip);
+		if (!clipped.isEmpty()) {
+			_contentHotspots.push_back(clipped);
+		} else {
+			_contentHotspots.push_back(Common::Rect());
+		}
+	}
+	// Resize so indices align even if some links were clipped to empty.
+	if (_contentHotspotTargets.size() > linkCount) {
+		_contentHotspotTargets.resize(linkCount);
+	}
 }
 
 void CellPhonePopup::drawDirectoryList() {
@@ -602,10 +960,24 @@ void CellPhonePopup::drawBackLabel() {
 											back.destRect.top - chunkOrigin.y));
 }
 
+const UICL::ThreeRectWidget &CellPhonePopup::scrollUpButton() const {
+	// Directory uses subButtons[1]; search / email / browser content all
+	// use subButtons[5] (which sits above the taller list LCD area).
+	return _screenState == kDirectory
+		? _uiclData->subButtons[1]
+		: _uiclData->subButtons[5];
+}
+
+const UICL::ThreeRectWidget &CellPhonePopup::scrollDownButton() const {
+	return _screenState == kDirectory
+		? _uiclData->subButtons[2]
+		: _uiclData->subButtons[6];
+}
+
 void CellPhonePopup::drawDirectoryArrows() {
 	// Up/down scroll arrows are not in the chrome image; blit on every redraw.
-	const UICL::ThreeRectWidget &up = _uiclData->subButtons[1];
-	const UICL::ThreeRectWidget &down = _uiclData->subButtons[2];
+	const UICL::ThreeRectWidget &up = scrollUpButton();
+	const UICL::ThreeRectWidget &down = scrollDownButton();
 
 	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
 
@@ -620,18 +992,25 @@ void CellPhonePopup::drawDirectoryArrows() {
 												down.destRect.top - chunkOrigin.y));
 	}
 
-	// Selection indicator next to the active row.
+	// Selection indicator (dirArrowSrc sprite) at the dirCursorSrc column,
+	// stepped down by the active entry's layout row — only meaningful in
+	// directory mode; list and content modes have their own arrow logic
+	// (or none at all) and a stray cursor sprite paints over their LCD.
+	if (_screenState != kDirectory) {
+		return;
+	}
 	const Common::Rect &arrowSrc = _uiclData->dirArrowSrc;
-	if (arrowSrc.isEmpty()) {
+	const Common::Rect &cursor = _uiclData->dirCursorSrc;
+	if (arrowSrc.isEmpty() || cursor.isEmpty()) {
 		return;
 	}
-	const uint maxRows = maxDirectoryRows();
-	if (_directorySelection >= maxRows) {
+	const uint titleRows = listTitleRows();
+	const uint selRow = titleRows + _directorySelection;
+	if (selRow >= maxDirectoryRows()) {
 		return;
 	}
-	const Common::Rect rowRect = directoryRowRect(_directorySelection);
-	const int arrowX = MAX(0, rowRect.left - arrowSrc.width() - 2);
-	const int arrowY = rowRect.top;
+	const int arrowX = cursor.left - _screenPosition.left;
+	const int arrowY = cursor.top - _screenPosition.top + (int)selRow * rowPitch();
 	_drawSurface.blitFrom(_spritesImage, arrowSrc,
 							Common::Point(arrowX, arrowY));
 }
@@ -667,14 +1046,10 @@ bool CellPhonePopup::playSoundIfPresent(const Common::Path &soundName) {
 		return false;
 	}
 
+	// Play through the chunk's call-sound template: channel / volume /
+	// loops come from it, only the filename varies per cue.
+	_callSound = _uiclData->callSoundTemplate;
 	_callSound.name = nameStr;
-	if (_callSound.channelID == 0) {
-		// TODO: should come from the per-call channel slot in the chunk.
-		_callSound.channelID = 28;
-	}
-	_callSound.volume = 100;
-	_callSound.numLoops = 1;
-	_callSound.playCommands = 1;
 
 	g_nancy->_sound->loadSound(_callSound);
 	g_nancy->_sound->playSound(_callSound);
@@ -758,48 +1133,62 @@ void CellPhonePopup::triggerContactCallSceneChange(uint contactIndex) {
 // Directory helpers
 // --------------------------------------------------------------------
 
-uint CellPhonePopup::maxDirectoryRows() const {
-	const Font *font = g_nancy->_graphics->getFont(_uiclData->fontId2);
-	if (!font) {
-		return 0;
+int CellPhonePopup::rowPitch() const {
+	// Original (FUN_004d8476): pitch = dirCursorSrc.height + 8.
+	const Common::Rect &cursor = _uiclData->dirCursorSrc;
+	if (!cursor.isEmpty()) {
+		return cursor.height() + 8;
 	}
+	return MAX(_uiclData->dirArrowSrc.height() + 4, 14);
+}
 
-	const Common::Rect &arrow = _uiclData->dirArrowSrc;
-	if (arrow.isEmpty()) {
-		return 0;
+int CellPhonePopup::rowTopScreen() const {
+	// First row's Y (screen). Original anchors on dirCursorSrc.top - 5.
+	const Common::Rect &cursor = _uiclData->dirCursorSrc;
+	if (!cursor.isEmpty()) {
+		return cursor.top - 5;
 	}
+	return _uiclData->welcomeScreen.destRect.top + 22;
+}
 
-	const int rowH = MAX(arrow.height() + 4, 14);
-	const Common::Rect &ws = _uiclData->welcomeScreen.destRect;
-	const int firstRowOffset = 22;
-	const int lcdH = MAX(0, ws.height() - firstRowOffset);
-	return MAX<int>(1, lcdH / rowH);
+uint CellPhonePopup::maxDirectoryRows() const {
+	const int pitch = rowPitch();
+	if (pitch <= 0) {
+		return 0;
+	}
+	const int yLimit = _uiclData->welcomeScreen.destRect.bottom;
+	int y = rowTopScreen();
+	uint count = 0;
+	while (y + pitch < yLimit) {
+		++count;
+		y += pitch;
+	}
+	return count;
 }
 
 Common::Rect CellPhonePopup::directoryRowRect(uint visibleIndex) const {
-	const Font *font = g_nancy->_graphics->getFont(_uiclData->fontId2);
-	if (!font) {
-		return Common::Rect();
+	const Common::Rect &cursor = _uiclData->dirCursorSrc;
+	const Common::Rect &ws = _uiclData->welcomeScreen.destRect;
+	const int pitch = rowPitch();
+
+	// Row text spans from just right of the arrow cursor to a margin
+	// inside the LCD's right edge (the -30 the original applies).
+	int xLeftScreen, xRightScreen;
+	if (!cursor.isEmpty()) {
+		xLeftScreen  = cursor.right + 5;
+		xRightScreen = ws.right - 30;
+	} else {
+		const Common::Rect &arrow = _uiclData->dirArrowSrc;
+		xLeftScreen  = ws.left + arrow.width() + 4;
+		xRightScreen = ws.right - 2;
 	}
 
-	const Common::Rect &arrow = _uiclData->dirArrowSrc;
-	const int rowH = MAX(arrow.height() + 4, 14);
-	const Common::Rect &ws = _uiclData->welcomeScreen.destRect;
-	const int lcdLeft = ws.left - _screenPosition.left;
-	const int lcdTop  = ws.top  - _screenPosition.top;
-	const int lcdWidth = ws.width();
-	const int firstRowOffset = 22;
-
-	const int x = lcdLeft + arrow.width() + 4;
-	const int y = lcdTop + firstRowOffset + (int)visibleIndex * rowH;
-	const int width = MAX(0, lcdWidth - (arrow.width() + 4) - 2);
-	// Clamp to the LCD so a row rect can't leak onto the keypad below.
-	const int lcdBottom = lcdTop + ws.height();
-	const int rowBottom = MIN(y + rowH, lcdBottom);
-	if (rowBottom <= y) {
-		return Common::Rect();
-	}
-	return Common::Rect(x, y, x + width, rowBottom);
+	const int yTopScreen = rowTopScreen() + (int)visibleIndex * pitch;
+
+	const int x = xLeftScreen - _screenPosition.left;
+	const int y = yTopScreen - _screenPosition.top;
+	const int right = xRightScreen - _screenPosition.left;
+	return Common::Rect(x, y, MAX(right, x), y + pitch);
 }
 
 bool CellPhonePopup::isContactVisible(const UICL::Contact &c) const {
@@ -813,6 +1202,20 @@ bool CellPhonePopup::isContactVisible(const UICL::Contact &c) const {
 	return NancySceneState.getEventFlag((int16)flag, g_nancy->_true);
 }
 
+Common::Rect CellPhonePopup::hubEmailRect() const {
+	// subButtons[3] is the upper LCD option button (Email).
+	Common::Rect r = _uiclData->subButtons[3].destRect;
+	r.translate(-_screenPosition.left, -_screenPosition.top);
+	return r;
+}
+
+Common::Rect CellPhonePopup::hubWebRect() const {
+	// subButtons[4] is the lower LCD option button (Web).
+	Common::Rect r = _uiclData->subButtons[4].destRect;
+	r.translate(-_screenPosition.left, -_screenPosition.top);
+	return r;
+}
+
 Common::Rect CellPhonePopup::backLabelHitRect() const {
 	// Back overlays the Web/Dir label area. Returns popup-local coordinates.
 	Common::Rect hit;
@@ -868,14 +1271,28 @@ int CellPhonePopup::contactIndexForVisibleRow(uint visibleRow) const {
 	return -1;
 }
 
+uint CellPhonePopup::currentListEntryCount() const {
+	switch (_screenState) {
+	case kDirectory:
+		return deduplicatedContactCount();
+	case kWebList:
+	case kEmailList:
+		return listVisibleIndices().size();
+	default:
+		return 0;
+	}
+}
+
 void CellPhonePopup::moveDirectorySelection(int delta) {
-	if (_screenState != kDirectory || delta == 0) {
+	if (delta == 0) {
 		return;
 	}
 
-	const uint total = deduplicatedContactCount();
-	const uint maxRows = maxDirectoryRows();
-	if (total == 0 || maxRows == 0) {
+	const uint total = currentListEntryCount();
+	const uint totalRows = maxDirectoryRows();
+	const uint titleRows = listTitleRows();
+	const uint pageRows = totalRows > titleRows ? totalRows - titleRows : 0;
+	if (total == 0 || pageRows == 0) {
 		return;
 	}
 
@@ -883,11 +1300,7 @@ void CellPhonePopup::moveDirectorySelection(int delta) {
 
 	if (delta < 0) {
 		const uint dec = (uint)(-delta);
-		if (dec >= absolute) {
-			absolute = 0;
-		} else {
-			absolute -= dec;
-		}
+		absolute = dec >= absolute ? 0 : absolute - dec;
 	} else {
 		absolute += (uint)delta;
 		if (absolute >= total) {
@@ -898,8 +1311,8 @@ void CellPhonePopup::moveDirectorySelection(int delta) {
 	if (absolute < _directoryScroll) {
 		_directoryScroll = absolute;
 		_directorySelection = 0;
-	} else if (absolute >= _directoryScroll + maxRows) {
-		_directorySelection = maxRows - 1;
+	} else if (absolute >= _directoryScroll + pageRows) {
+		_directorySelection = pageRows - 1;
 		_directoryScroll = absolute - _directorySelection;
 	} else {
 		_directorySelection = absolute - _directoryScroll;
@@ -1020,9 +1433,22 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 
 	const Common::Point chunkMouse = mouseToChunkCoords(input.mousePos);
 
+	// Help "?" button: opens the help page in the content view. Reachable
+	// from any interactive state (except when already showing it).
+	if (!_uiclData->helpButton.destRect.isEmpty() && !_uiclData->helpTextKey.empty() &&
+			!(_screenState == kContentView && _contentKey == _uiclData->helpTextKey) &&
+			_uiclData->helpButton.destRect.contains(chunkMouse)) {
+		g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			openContentView(_uiclData->helpTextKey, _uiclData->helpHeading);
+			input.eatMouseInput();
+			return;
+		}
+	}
+
 	if (_screenState == kDirectory) {
-		const Common::Rect &upDst = _uiclData->subButtons[1].destRect;
-		const Common::Rect &downDst = _uiclData->subButtons[2].destRect;
+		const Common::Rect &upDst = scrollUpButton().destRect;
+		const Common::Rect &downDst = scrollDownButton().destRect;
 
 		// Up/down move the selection; scrolling kicks in at page edges.
 		if (upDst.contains(chunkMouse)) {
@@ -1077,11 +1503,206 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		// can override the directory by starting a fresh dial.
 	}
 
+	// Online hub: two labels — Email and Web — plus the Back hotspot.
+	if (_screenState == kOnlineHub) {
+		const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
+										chunkMouse.y - _screenPosition.top);
+		const Common::Rect emailR = hubEmailRect();
+		const Common::Rect webR   = hubWebRect();
+		const Common::Rect backHit = backLabelHitRect();
+
+		if (emailR.contains(popupMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				_directoryScroll = 0;
+				_directorySelection = 0;
+				enterScreenState(kEmailList);
+				input.eatMouseInput();
+				return;
+			}
+		} else if (webR.contains(popupMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				_directoryScroll = 0;
+				_directorySelection = 0;
+				openBrowserHome();
+				input.eatMouseInput();
+				return;
+			}
+		} else if (!backHit.isEmpty() && backHit.contains(popupMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				enterScreenState(kWelcome);
+				input.eatMouseInput();
+				return;
+			}
+		}
+	}
+
+	// Link-list modes (web search results / email messages). Up/down +
+	// Back behave like in directory mode; row clicks navigate to the
+	// link's scene and set its event flag.
+	if (isLinkListMode()) {
+		const Common::Rect &upDst = scrollUpButton().destRect;
+		const Common::Rect &downDst = scrollDownButton().destRect;
+
+		if (upDst.contains(chunkMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				moveDirectorySelection(-1);
+				input.eatMouseInput();
+				return;
+			}
+		} else if (downDst.contains(chunkMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				moveDirectorySelection(+1);
+				input.eatMouseInput();
+				return;
+			}
+		}
+
+		const Common::Rect backHit = backLabelHitRect();
+		const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
+										chunkMouse.y - _screenPosition.top);
+		const bool overUpDown =
+			upDst.contains(chunkMouse) || downDst.contains(chunkMouse);
+		if (!overUpDown && !backHit.isEmpty() && backHit.contains(popupMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				_directoryScroll = 0;
+				_directorySelection = 0;
+				enterScreenState(kOnlineHub);
+				input.eatMouseInput();
+				return;
+			}
+		}
+
+		const uint row = directoryRowAt(chunkMouse);
+		const uint titleRows = listTitleRows();
+		if (row != (uint)-1 && row >= titleRows) {
+			const uint entryRow = row - titleRows;
+			CellPhoneData *cellData = (CellPhoneData *)NancySceneState.getPuzzleData(CellPhoneData::getTag());
+			Common::Array<CellPhoneData::LinkEntry> *list = nullptr;
+			if (cellData) {
+				list = (_screenState == kWebList) ? &cellData->searchLinks
+												  : &cellData->emailMessages;
+			}
+			// Map the visible row through the active filter to a real index.
+			const Common::Array<uint> visible = listVisibleIndices();
+			const uint visIdx = _directoryScroll + entryRow;
+			if (list && visIdx < visible.size()) {
+				const uint absolute = visible[visIdx];
+				g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+				if (input.input & NancyInput::kLeftMouseButtonUp) {
+					// Move the selection indicator to the clicked row, then
+					// act on the entry.
+					_directorySelection = entryRow;
+					CellPhoneData::LinkEntry &e = (*list)[absolute];
+					// Original sets the event flag when an entry is opened;
+					// it does not scene-change from a list click.
+					if (e.eventFlag != -1) {
+						NancySceneState.setEventFlag(e.eventFlag, g_nancy->_true);
+					}
+					if (_screenState == kEmailList && !e.value.empty()) {
+						// Email: open the message body and mark as read.
+						e.read = true;
+						openContentView(e.value, _uiclData->emailHeading);
+					} else if (_screenState == kWebList) {
+						// AR-131 mode-1 stores a browser-page INDEX in
+						// `extra`; the page body lives in the UIBW chunk
+						// (UrlPage.imageName is actually the body CVTX
+						// key, despite the field name). Fall back to
+						// `value`/`key` if the index is out of range.
+						Common::String articleKey;
+						const UIBW *browserData = GetEngineData(UIBW);
+						if (browserData && e.extra >= 0 &&
+								(uint)e.extra < browserData->pages.size()) {
+							articleKey = browserData->pages[e.extra].imageName.toString();
+						}
+						if (articleKey.empty()) {
+							articleKey = e.value.empty() ? e.key : e.value;
+						}
+						openContentView(articleKey, _uiclData->browserHeading);
+					} else {
+						drawScreenContent();
+					}
+					input.eatMouseInput();
+					return;
+				}
+			}
+		}
+	}
+
+	// Content view (single email / page text). Up/down scroll the text;
+	// Back returns to the list it was opened from.
+	if (_screenState == kContentView) {
+		// In-page hyperlinks first so they take priority over any
+		// overlapping fallthrough hit (e.g. the back hotspot).
+		const Common::Point popupMouseLink(chunkMouse.x - _screenPosition.left,
+											chunkMouse.y - _screenPosition.top);
+		for (uint i = 0; i < _contentHotspots.size(); ++i) {
+			if (_contentHotspots[i].isEmpty()) {
+				continue;
+			}
+			if (_contentHotspots[i].contains(popupMouseLink)) {
+				g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+				if (input.input & NancyInput::kLeftMouseButtonUp) {
+					if (i < _contentHotspotTargets.size() &&
+							!_contentHotspotTargets[i].empty()) {
+						openContentView(_contentHotspotTargets[i],
+										_uiclData->browserHeading);
+					}
+					input.eatMouseInput();
+					return;
+				}
+				break;
+			}
+		}
+
+		const Common::Rect &upDst = scrollUpButton().destRect;
+		const Common::Rect &downDst = scrollDownButton().destRect;
+
+		if (upDst.contains(chunkMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				if (_contentScroll > 0) {
+					--_contentScroll;
+					drawScreenContent();
+				}
+				input.eatMouseInput();
+				return;
+			}
+		} else if (downDst.contains(chunkMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				++_contentScroll;
+				drawScreenContent();
+				input.eatMouseInput();
+				return;
+			}
+		}
+
+		const Common::Rect backHit = backLabelHitRect();
+		const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
+										chunkMouse.y - _screenPosition.top);
+		const bool overUpDown =
+			upDst.contains(chunkMouse) || downDst.contains(chunkMouse);
+		if (!overUpDown && !backHit.isEmpty() && backHit.contains(popupMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				_contentKey.clear();
+				_contentHeading = nullptr;
+				enterScreenState(_contentReturnState);
+				input.eatMouseInput();
+				return;
+			}
+		}
+	}
+
 	// Call/talk button. Checked before the dial-pad loop so an overlapping
-	// slot can't eat it. The keypad Talk key is slot 12; the callButton
-	// widget covers a different region.
-	if (_uiclData->callButton.destRect.contains(chunkMouse) ||
-			_uiclData->dialPadSlots[12].destRect.contains(chunkMouse)) {
+	// slot can't eat it. The Talk key is dial-pad slot 12.
+	if (_uiclData->dialPadSlots[12].destRect.contains(chunkMouse)) {
 		g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
@@ -1131,10 +1752,22 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 			}
 
 			if (newHovered < 10) {
-				if (_screenState == kDirectory) {
+				if (_screenState == kDirectory || isLinkListMode()) {
 					_dialedNumber.clear();
 				}
 				appendDigit((byte)newHovered);
+			} else if (newHovered == 13) {
+				// Online toggle: opens the Email/Web hub.
+				if (isOnlineMode()) {
+					_directoryScroll = 0;
+					_directorySelection = 0;
+					enterScreenState(kWelcome);
+				} else {
+					_dialedNumber.clear();
+					_directoryScroll = 0;
+					_directorySelection = 0;
+					enterScreenState(kOnlineHub);
+				}
 			} else if (newHovered == 14) {
 				if (_screenState == kDirectory) {
 					_directoryScroll = 0;
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/cellphonepopup.h
index 884795c0e9a..9a0353003bd 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/cellphonepopup.h
@@ -59,6 +59,12 @@ public:
 	// Used by AR 130 to add/modify entries at runtime.
 	void upsertContact(const UICL::Contact &c);
 
+	// Append an entry to the search/email results list (mode 0) or the
+	// web bookmarks list (mode 1). Driven by AR 131 (AddSearchLink).
+	void addSearchLink(int16 mode, const Common::String &key,
+						const Common::String &value, int16 extra,
+						int16 flag, int16 eventFlag);
+
 private:
 	enum ScreenState : int {
 		kWelcome          = 0,
@@ -70,7 +76,11 @@ private:
 		kConnected        = 6,
 		kInvalidNumber    = 7,
 		kWaitInvalid      = 8,
-		kDirectory        = 9
+		kDirectory        = 9,
+		kOnlineHub        = 10,  // Online heading + Email / Web sub-buttons
+		kWebList          = 11,  // web search-results list (AR-131 mode 1)
+		kEmailList        = 12,  // email message list (AR-131 mode 0)
+		kContentView      = 13   // full-text view of a single email / page
 	};
 
 	void drawChrome();
@@ -82,15 +92,40 @@ private:
 	void drawConnectedLabel();
 	void drawConnectingSprite();
 	void drawDialedNumber();
-	void drawCallButtonState(uint state);
+	void drawHelpButton(uint state);
 	void drawCloseButton(uint state);
 	void drawStatusLabels();
 	void drawDirectoryList();
-	void drawDirHeading();
 	void drawDirectoryArrows();
 	void drawWelcomeScreen();
 	void drawBackLabel();
 
+	// Generic list renderer used by web / email modes.
+	void drawLinkList();
+	// Blit a heading sprite (e.g. emailHeading) at its chunk dest. The
+	// heading sits in the title-bar strip above the LCD content.
+	void drawHeading(const UICL::SrcDestRectPair &heading);
+	// Render the opened entry's body text in the LCD area, word-wrapped.
+	void drawContentView();
+	// Enter the content view for a list entry whose AUTOTEXT key is `key`.
+	void openContentView(const Common::String &key, const UICL::SrcDestRectPair &heading);
+	// Web button: open the first url entry as the browser home page (page 0).
+	void openBrowserHome();
+
+	// Up/down scroll buttons differ by mode: subButtons[1]/[2] for the
+	// directory's narrower list area, [5]/[6] for the taller search /
+	// email / browser-content LCD area.
+	const UICL::ThreeRectWidget &scrollUpButton() const;
+	const UICL::ThreeRectWidget &scrollDownButton() const;
+
+	// True when the current screen uses the zoomed-in (no-keypad)
+	// chrome variant: search list, email list, and content view.
+	bool isZoomedChromeState() const {
+		return _screenState == kWebList ||
+				_screenState == kEmailList ||
+				_screenState == kContentView;
+	}
+
 	void resetDialPad();
 	void enterScreenState(ScreenState newState);
 	void appendDigit(byte slotIndex);
@@ -102,10 +137,30 @@ private:
 	uint maxDirectoryRows() const;
 	uint directoryRowAt(const Common::Point &chunkMouse) const;
 	Common::Rect directoryRowRect(uint visibleIndex) const;
+	// Row pitch and first-row Y (screen coords) from the layout data.
+	int rowPitch() const;
+	int rowTopScreen() const;
+	bool isLinkListMode() const { return _screenState == kWebList || _screenState == kEmailList; }
+	bool isOnlineMode() const { return _screenState == kOnlineHub || isLinkListMode(); }
+
+	// Section headings live in the phone's title-bar strip above the LCD,
+	// so they don't consume a list row. Kept as a hook in case a game
+	// needs an in-LCD title row.
+	uint listTitleRows() const { return 0; }
+
+	// Layout for the two clickable labels on the Online hub.
+	Common::Rect hubEmailRect() const;
+	Common::Rect hubWebRect() const;
 	void startCallToContact(uint contactIndex);
 	// Visible (deduplicated) row -> raw contact index, or -1.
 	int contactIndexForVisibleRow(uint visibleRow) const;
 	uint deduplicatedContactCount() const;
+	// Entry count for whichever list the popup is currently showing.
+	uint currentListEntryCount() const;
+	// Absolute indices into the current list's backing array that pass
+	// the active filter. Email applies the "Old Email Only" filter
+	// (no-signal -> read messages only); web shows everything.
+	Common::Array<uint> listVisibleIndices() const;
 	// True when the contact's visibility flag is currently unlocked.
 	bool isContactVisible(const UICL::Contact &c) const;
 	// Popup-local rect of the Back hotspot in directory mode.
@@ -144,6 +199,17 @@ private:
 	uint _directoryScroll = 0;
 	uint _directorySelection = 0;
 
+	// Content-view (single email / page) state.
+	ScreenState _contentReturnState = kOnlineHub;
+	const UICL::SrcDestRectPair *_contentHeading = nullptr;
+	Common::String _contentKey;
+	uint _contentScroll = 0;
+
+	// In-page hyperlinks: rects (popup-local, recomputed every draw) and
+	// the target CVTX key parsed from each <H>...<L> region of the body.
+	Common::Array<Common::Rect> _contentHotspots;
+	Common::Array<Common::String> _contentHotspotTargets;
+
 	bool _noSignal = false;
 	bool _batteryLow = false;
 };


Commit: 650c5cf32a8ed8c788a734686ad424c231cd648d
    https://github.com/scummvm/scummvm/commit/650c5cf32a8ed8c788a734686ad424c231cd648d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:09:10+03:00

Commit Message:
NANCY: Add new MagnetMazePuzzle to module.mk

Changed paths:
    engines/nancy/module.mk


diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 83f82d210bd..af38b27b11e 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -29,6 +29,7 @@ MODULE_OBJS = \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \
   action/puzzle/leverpuzzle.o \
+  action/puzzle/magnetmazepuzzle.o \
   action/puzzle/mazechasepuzzle.o \
   action/puzzle/matchpuzzle.o \
   action/puzzle/memorypuzzle.o \


Commit: dfc12fce980b878a476547516ec84d0abb83c428
    https://github.com/scummvm/scummvm/commit/dfc12fce980b878a476547516ec84d0abb83c428
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:09:12+03:00

Commit Message:
NANCY: More work on the Nancy10+ textbox

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


diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 35af709604a..cacb72796aa 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -186,6 +186,19 @@ void FrameTextBox::execute() {
 	if (!_text.empty()) {
 		tb.addTextLine(_text);
 	}
+
+	// Variant 74 (case 0x4a in ProcessActionRecords) opens the full-width
+	// textbox overlay that covers the taskbar buttons; the original arms a
+	// 15-second timer (DAT_005a7a7d = GetTickCount + 15000) that drops it
+	// back to the closed strip. Variant 75 (case 0x4b) is the legacy
+	// closed/strip path. Variant 81 first appears in Nancy 11; tentatively
+	// route it like 74 until its real semantics are confirmed.
+	if (_variant == kVariant74 || _variant == kVariant81) {
+		tb.setFullMode(true);
+	} else {
+		tb.setFullMode(false);
+	}
+
 	finishExecution();
 }
 
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 2a0352af58d..cb27b1b43cf 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1216,9 +1216,11 @@ void Scene::handleInput() {
 
 	_actionManager.handleInput(input);
 
-	// Menu/help are disabled when a movie is active
+	// Menu/help are disabled when a movie is active. The taskbar is also
+	// skipped while the textbox is in open mode (it visually covers the
+	// buttons, so they should not receive hover/clicks).
 	if (!_activeMovie) {
-		if (_taskbar) {
+		if (_taskbar && !_textbox.isFullMode()) {
 			_taskbar->handleInput(input);
 
 			int clicked = _taskbar->getClickedButton();
diff --git a/engines/nancy/ui/textbox.cpp b/engines/nancy/ui/textbox.cpp
index 19e04f70dce..e7e2d7efe40 100644
--- a/engines/nancy/ui/textbox.cpp
+++ b/engines/nancy/ui/textbox.cpp
@@ -41,7 +41,9 @@ Textbox::Textbox() :
 		_scrollbarPos(0),
 		_highlightRObj(g_nancy->getGameType() >= kGameTypeNancy10 ? 11 : 7),
 		_fontIDOverride(-1),
-		_autoClearTime(0) {}
+		_autoClearTime(0),
+		_isFullMode(false),
+		_fullModeCloseTime(0) {}
 
 Textbox::~Textbox() {
 	delete _scrollbar;
@@ -55,30 +57,40 @@ void Textbox::init() {
 		auto *bsum = GetEngineData(BSUM);
 		assert(bsum);
 
-		Common::Rect textRect = bsum->textboxScreenPosition;
-
-		// Clip the bottom of the text strip to sit above the taskbar.
+		// The bsum rect spans the full taskbar strip (open mode); the
+		// closed mode clips at the taskbar's top edge so the buttons stay
+		// visible. We keep both rects so setFullMode() can toggle between
+		// them without reallocating.
+		_openRect = bsum->textboxScreenPosition;
+		_closedRect = _openRect;
 		const TASK *taskData = GetEngineData(TASK);
-		if (taskData && taskData->dstRect.top > textRect.top &&
-				taskData->dstRect.top < textRect.bottom) {
-			textRect.bottom = taskData->dstRect.top;
+		if (taskData && taskData->dstRect.top > _closedRect.top &&
+				taskData->dstRect.top < _closedRect.bottom) {
+			_closedRect.bottom = taskData->dstRect.top;
 		}
 
-		moveTo(textRect);
-		_highlightRObj.moveTo(textRect);
-
-		// No scrolling for now: the surface is sized to exactly the
-		// visible text rect, so overflow simply clips at the bottom.
-		initSurfaces(textRect.width(), textRect.height(),
+		// Size the backing surface to the OPEN rect so toggling never
+		// reallocates. The visible slice is constrained via _drawSurface.
+		initSurfaces(_openRect.width(), _openRect.height(),
 			g_nancy->_graphics->getScreenPixelFormat(),
 			tbox->textBackground, tbox->highlightTextBackground);
 
-		Common::Rect outerBoundingBox = _screenPosition;
-		outerBoundingBox.moveTo(0, 0);
-		_drawSurface.create(_fullSurface, outerBoundingBox);
+		_isFullMode = false;
+		applyDisplayMode();
 
 		RenderObject::init();
 
+		// Nancy 11+ scrollbar for the OPEN-mode text overflow. Nancy 10
+		// has no scrollbar in either state; left intentionally nullptr.
+		if (g_nancy->getGameType() >= kGameTypeNancy11
+				&& tbox->scrollbarSrcBounds.width() > 0) {
+			_scrollbar = new Scrollbar(11,
+				tbox->scrollbarSrcBounds,
+				tbox->scrollbarDefaultPos,
+				tbox->scrollbarMaxScroll - tbox->scrollbarDefaultPos.y);
+			_scrollbar->init();
+		}
+
 		setVisible(false);
 		return;
 	}
@@ -117,6 +129,13 @@ void Textbox::updateGraphics() {
 	if (_autoClearTime && g_nancy->getTotalPlayTime() > _autoClearTime)
 		clear();
 
+	// Nancy 10+ open-mode auto-close timer (mirrors the DAT_005a7a7d
+	// check in case 0x4a of ProcessActionRecords).
+	if (_isFullMode && _fullModeCloseTime &&
+			g_nancy->getTotalPlayTime() > _fullModeCloseTime) {
+		setFullMode(false);
+	}
+
 	if (_needsTextRedraw)
 		drawTextbox();
 
@@ -129,6 +148,48 @@ void Textbox::updateGraphics() {
 	RenderObject::updateGraphics();
 }
 
+void Textbox::applyDisplayMode() {
+	if (g_nancy->getGameType() < kGameTypeNancy10) {
+		return;
+	}
+
+	const Common::Rect &target = _isFullMode ? _openRect : _closedRect;
+	moveTo(target);
+	_highlightRObj.moveTo(target);
+
+	Common::Rect outerBoundingBox = target;
+	outerBoundingBox.moveTo(0, 0);
+	_drawSurface.create(_fullSurface, outerBoundingBox);
+
+	// Nancy 11+ scrollbar only makes sense in open mode; hide it in
+	// closed mode regardless of game version.
+	if (_scrollbar) {
+		_scrollbar->setVisible(_isFullMode);
+	}
+
+	_needsTextRedraw = true;
+	_needsRedraw = true;
+}
+
+void Textbox::setFullMode(bool open, uint32 timeoutMs) {
+	if (g_nancy->getGameType() < kGameTypeNancy10) {
+		return;
+	}
+	if (_isFullMode == open) {
+		// Re-arm the timer on a repeat open call.
+		if (open && timeoutMs) {
+			_fullModeCloseTime = g_nancy->getTotalPlayTime() + timeoutMs;
+		}
+		return;
+	}
+
+	_isFullMode = open;
+	_fullModeCloseTime = (open && timeoutMs)
+			? g_nancy->getTotalPlayTime() + timeoutMs
+			: 0;
+	applyDisplayMode();
+}
+
 void Textbox::handleInput(NancyInput &input) {
 	if (_scrollbar)
 		_scrollbar->handleInput(input);
@@ -207,9 +268,15 @@ void Textbox::clear() {
 		_autoClearTime = 0;
 
 		// Nancy 10+: the text strip overlaps the taskbar buttons, so
-		// hide it whenever it has no content to show.
-		if (g_nancy->getGameType() >= kGameTypeNancy10)
+		// hide it whenever it has no content to show. Also drop the
+		// open-mode overlay so the next entry starts fresh in closed
+		// mode unless a kVariant74 AR re-opens it.
+		if (g_nancy->getGameType() >= kGameTypeNancy10) {
+			if (_isFullMode) {
+				setFullMode(false);
+			}
 			setVisible(false);
+		}
 	}
 }
 
diff --git a/engines/nancy/ui/textbox.h b/engines/nancy/ui/textbox.h
index ed6b92417a2..c4db8b68f12 100644
--- a/engines/nancy/ui/textbox.h
+++ b/engines/nancy/ui/textbox.h
@@ -51,9 +51,22 @@ public:
 	void addTextLine(const Common::String &text, uint32 autoClearTime = 0);
 	void setOverrideFont(const uint fontID);
 
+	// Nancy 10+ open/full mode. When `open` is true the textbox extends
+	// down to cover the taskbar buttons; `timeoutMs` schedules an automatic
+	// return to closed mode Passing 0 disables the auto-close (the caller is
+	// then responsible for closing). No-op for pre-Nancy 10 games.
+	void setFullMode(bool open, uint32 timeoutMs = 15000);
+	bool isFullMode() const { return _isFullMode; }
+
 private:
+	enum DisplayMode {
+		kModeClosed = 0,
+		kModeOpen   = 1
+	};
+
 	uint16 getInnerHeight() const;
 	void onScrollbarMove();
+	void applyDisplayMode();
 
 	RenderObject _highlightRObj;
 	Scrollbar *_scrollbar;
@@ -62,6 +75,14 @@ private:
 
 	uint32 _autoClearTime;
 	int _fontIDOverride;
+
+	// Nancy 10+ open/closed strip geometry. `_closedRect` is the small
+	// taskbar-clipped strip; `_openRect` is the full bsum textbox area
+	// (extends through the taskbar zone).
+	bool _isFullMode;
+	uint32 _fullModeCloseTime;
+	Common::Rect _closedRect;
+	Common::Rect _openRect;
 };
 
 } // End of namespace UI


Commit: 5d21ea03202b0cd39d3c5d9f11894f7137d24bda
    https://github.com/scummvm/scummvm/commit/5d21ea03202b0cd39d3c5d9f11894f7137d24bda
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:09:15+03:00

Commit Message:
NANCY: Remove superfluous parentheses

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


diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index 0af996c2a7e..e291d3940b6 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -157,9 +157,9 @@ void Overlay::execute() {
 			// Wait until sound stops (if present)
 			if (!g_nancy->_sound->isSoundPlaying(_sound)) {
 				// Check if we're at the last frame
-				if ((_currentFrame == _loopLastFrame) && (_playDirection == kPlayOverlayForward) && (_loop == kPlayOverlayOnce)) {
+				if (_currentFrame == _loopLastFrame && _playDirection == kPlayOverlayForward && _loop == kPlayOverlayOnce) {
 					shouldTrigger = true;
-				} else if ((_currentFrame == _loopFirstFrame) && (_playDirection == kPlayOverlayReverse) && (_loop == kPlayOverlayOnce)) {
+				} else if (_currentFrame == _loopFirstFrame && _playDirection == kPlayOverlayReverse && _loop == kPlayOverlayOnce) {
 					shouldTrigger = true;
 				}
 			}


Commit: 1a68dd3f18072ac97810d90ab574bb7c0e2228e2
    https://github.com/scummvm/scummvm/commit/1a68dd3f18072ac97810d90ab574bb7c0e2228e2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:09:16+03:00

Commit Message:
NANCY: Correct reading of TBOX chunk data for Nancy10+

Changed paths:
    engines/nancy/enginedata.cpp


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index c4df40aa807..482e0c69890 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -278,10 +278,13 @@ TBOX::TBOX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	defaultTextColor = chunkStream->readUint16LE();
 
 	if (g_nancy->getGameType() >= kGameTypeNancy10) {
-		// Nancy 10+ moved the conversation font IDs to a later position;
-		// the 4 bytes here are unrelated, so skip them.
-		chunkStream->skip(4);
-	} else if (g_nancy->getGameType() >= kGameTypeNancy2) {
+		chunkStream->skip(2);	// line-start X cursor
+		chunkStream->skip(2);	// bottom margin
+		chunkStream->skip(2);	// unused
+		chunkStream->skip(2);	// initial color (we use the AR for this)
+	}
+
+	if (g_nancy->getGameType() >= kGameTypeNancy2) {
 		conversationFontID = chunkStream->readUint16LE();
 		highlightConversationFontID = chunkStream->readUint16LE();
 	} else {
@@ -292,12 +295,6 @@ TBOX::TBOX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	tabWidth = chunkStream->readUint16LE();
 	pageScrollPercent = chunkStream->readUint16LE(); // Not implemented yet
 
-	if (g_nancy->getGameType() >= kGameTypeNancy10) {
-		conversationFontID = chunkStream->readUint16LE();
-		highlightConversationFontID = chunkStream->readUint16LE();
-		chunkStream->skip(4); // 2 unknown uint16 fields (values: 4, 75 in nancy10)
-	}
-
 	Graphics::PixelFormat format = g_nancy->_graphics->getInputPixelFormat();
 	if (g_nancy->getGameType() >= kGameTypeNancy2) {
 		byte r, g, b;


Commit: f82acefa44e55675ad03b153eb0b2331f4ef7069
    https://github.com/scummvm/scummvm/commit/f82acefa44e55675ad03b153eb0b2331f4ef7069
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-04T03:09:18+03:00

Commit Message:
NANCY: Update inactive animated overlays

In Nancy8, when meeting Miles for the first time, there's an animation
of his flashing lights that stops when he's done talking. After this,
his animated overlay stays inactive and is no longer updated. Update
the overlay in such cases, to adapt it to the current viewport frame.

Fix #16694 and part of #16723

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


diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index e291d3940b6..f4da96d9787 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -73,6 +73,26 @@ void Overlay::handleInput(NancyInput &input) {
 	}
 }
 
+void Overlay::updateGraphics() {
+	// Update inactive animated overlays
+	if (!_isActive && _state == kRun && !_blitDescriptions.empty() && _overlayType == kPlayOverlayAnimated) {
+		uint16 newFrame = NancySceneState.getSceneInfo().frameID;
+		if (_currentViewportFrame == newFrame)
+			return;
+
+		_currentViewportFrame = (int16)newFrame;
+		setVisible(false);
+
+		for (auto &blit : _blitDescriptions) {
+			if (_currentViewportFrame == blit.frameID) {
+				moveTo(blit.dest);
+				setVisible(true);
+				break;
+			}
+		}
+	}
+}
+
 void Overlay::readData(Common::SeekableReadStream &stream) {
 	Common::Serializer ser(&stream, nullptr);
 	ser.setVersion(g_nancy->getGameType());
diff --git a/engines/nancy/action/overlay.h b/engines/nancy/action/overlay.h
index 3ae78e8f549..888f666ab1d 100644
--- a/engines/nancy/action/overlay.h
+++ b/engines/nancy/action/overlay.h
@@ -48,6 +48,7 @@ public:
 
 	void init() override;
 	void handleInput(NancyInput &input) override;
+	void updateGraphics() override;
 
 	void readData(Common::SeekableReadStream &stream) override;
 	void execute() override;




More information about the Scummvm-git-logs mailing list