[Scummvm-git-logs] scummvm master -> 09e072597305e6dd5006c85381033d4dcc1dcd8e

bluegr noreply at scummvm.org
Thu Jul 23 00:55:11 UTC 2026


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

Summary:
73c586513b NANCY: NANCY13: More work on the Nancy13 cellphone
09e0725973 NANCY: NANCY13: More work on PachinkoPuzzle


Commit: 73c586513b5c348fa79ec85d6d1a2d19535ed2c8
    https://github.com/scummvm/scummvm/commit/73c586513b5c348fa79ec85d6d1a2d19535ed2c8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-23T03:55:01+03:00

Commit Message:
NANCY: NANCY13: More work on the Nancy13 cellphone

- Implement the photo camera functionality and screens
- Draw the Nancy13 background LCD graphics
- Fix the reshuffled buttons
- Name all online sub-button indices through enums
- Extract a drawScrollArrow() helper
- Remove stale TODOs and correct outdated comments

Changed paths:
    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/enginedata.cpp b/engines/nancy/enginedata.cpp
index 8f0434683cb..b41ff97a5f8 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -978,7 +978,9 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 		readFilename(*chunkStream, cameraClickSound);
 		readRect(*chunkStream, pictureDisplayRect);
 
-		// Camera picture-slot / thumbnail data (int16 rects). Not yet mapped.
+		// TODO: Camera picture-slot / thumbnail data (int16 rects, ~9 records) —
+		// skipped for now. Map these if the picture-view thumbnail grid is ever
+		// implemented (the original stores per-slot source rects here).
 		chunkStream->skip(114);
 
 		readRect(*chunkStream, noPictureScreenRect);
@@ -989,17 +991,25 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 		statusTextX = cameraTextX;
 		statusTextY = cameraTextY;
 
-		// Eight dial/web/dir/camera label SrcDestRectPairs across three on-screen
-		// columns (dest x = 437 / 492 / 551), each with alternate glyph variants;
-		// take one pair per column for the dial / web / dir labels.
-		readRect(*chunkStream, dialLabel.srcRect);	// column 1 (x=437)
+		// Eight ribbon-label SrcDestRectPairs across three on-screen columns
+		// (dest x = 437 / 492 / 551). Atlas order is CAM, DIAL, MENU, DIR, DEL,
+		// SEND, YES, NO; each screen shows one label per column.
+		readRect(*chunkStream, dialLabel.srcRect);		// CAM (col 437)
 		readRect(*chunkStream, dialLabel.destRect);
-		chunkStream->skip(32);						// second x=437 variant
-		readRect(*chunkStream, webLabel.srcRect);	// column 2 (x=492)
+		readRect(*chunkStream, dialingLabel.srcRect);	// DIAL (col 437)
+		readRect(*chunkStream, dialingLabel.destRect);
+		readRect(*chunkStream, webLabel.srcRect);		// MENU (col 492)
 		readRect(*chunkStream, webLabel.destRect);
-		readRect(*chunkStream, dirLabel.srcRect);	// column 3 (x=551)
+		readRect(*chunkStream, dirLabel.srcRect);		// DIR (col 551)
 		readRect(*chunkStream, dirLabel.destRect);
-		chunkStream->skip(4 * 32);					// remaining variants
+		readRect(*chunkStream, delLabel.srcRect);		// DEL (col 492)
+		readRect(*chunkStream, delLabel.destRect);
+		readRect(*chunkStream, sendLabel.srcRect);		// SEND (col 551)
+		readRect(*chunkStream, sendLabel.destRect);
+		readRect(*chunkStream, yesLabel.srcRect);		// YES (col 492)
+		readRect(*chunkStream, yesLabel.destRect);
+		readRect(*chunkStream, noLabel.srcRect);		// NO (col 551)
+		readRect(*chunkStream, noLabel.destRect);
 	} else {
 		readRect(*chunkStream, dialHilite.srcRect);
 		readRect(*chunkStream, dialHilite.destRect);
@@ -1083,17 +1093,23 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	readRect(*chunkStream, dirCursorSrc);
 
 	if (isNancy13) {
-		// Online / directory headings — N13 changed these values, not mapped yet.
-		chunkStream->skip(48);
+		// Nancy 13 has 11 online sub-buttons (Ghidra widgets 0x10..0x1a). The
+		// first (0x10, at what older games use for the directory heading) is the
+		// Back button, so the whole array is read contiguously here.
+		for (uint i = 0; i < kNumSubButtonsNancy13; ++i) {
+			readRect(*chunkStream, subButtons[i].srcRectIdle);
+			readRect(*chunkStream, subButtons[i].srcRectPressed);
+			readRect(*chunkStream, subButtons[i].destRect);
+		}
 	} else {
 		readRect(*chunkStream, dirHeading.srcRect);
 		readRect(*chunkStream, dirHeading.destRect);
-	}
 
-	for (uint i = 0; i < kNumSubButtons; ++i) {
-		readRect(*chunkStream, subButtons[i].srcRectIdle);
-		readRect(*chunkStream, subButtons[i].srcRectPressed);
-		readRect(*chunkStream, subButtons[i].destRect);
+		for (uint i = 0; i < kNumSubButtons; ++i) {
+			readRect(*chunkStream, subButtons[i].srcRectIdle);
+			readRect(*chunkStream, subButtons[i].srcRectPressed);
+			readRect(*chunkStream, subButtons[i].destRect);
+		}
 	}
 
 	readRect(*chunkStream, searchHeading.srcRect);
@@ -1155,7 +1171,10 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	}
 
 	if (isNancy13) {
-		// Trailing captured-picture slot table, added in Nancy 13.
+		// Trailing captured-picture slot table, added in Nancy 13. These are the
+		// game's built-in photo slots; runtime captures are persisted separately
+		// (CellPhonePictureData), so this table is parsed but currently unused.
+		// TODO: identify PictureRecord::unknown (6 bytes, identical across slots).
 		const uint16 pictureCount = chunkStream->readUint16LE();
 		pictures.resize(pictureCount);
 		for (uint i = 0; i < pictureCount; ++i) {
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 94c8d725c04..c7e9fd94237 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -614,7 +614,10 @@ struct UICL : public EngineData {
 	};
 
 	static const uint kNumDialPadSlots = 15;
+	// Nancy 10-12 have 10 online sub-buttons; Nancy 13 added an 11th (the Back
+	// button, Ghidra widget 0x10) at the front of the array.
 	static const uint kNumSubButtons = 10;
+	static const uint kNumSubButtonsNancy13 = 11;
 	static const uint kNumStatusLabels = 3;        // No Signal / No Access / Old Email Only
 
 	UICL(Common::SeekableReadStream *chunkStream);
@@ -636,9 +639,17 @@ struct UICL : public EngineData {
 	int32 statusTextY = 0;                    // text Y-baseline
 	SrcDestRectPair welcomeScreen;
 	Common::String statusLabels[kNumStatusLabels]; // "No Signal", "No Access", "Old Email Only"
-	SrcDestRectPair dialLabel;
-	SrcDestRectPair webLabel;
-	SrcDestRectPair dirLabel;
+	// Ribbon labels above the top-row buttons. In Nancy 13 the top row is
+	// Cam/Menu/Dir, and the review / delete screens reuse the same three columns
+	// with the Del/Send and Yes/No variants (all read from the 8-label block).
+	SrcDestRectPair dialLabel;  // Nancy 13: "CAM"
+	SrcDestRectPair webLabel;   // Nancy 13: "MENU"
+	SrcDestRectPair dirLabel;   // Nancy 13: "DIR"
+	SrcDestRectPair dialingLabel; // Nancy 13: "DIAL"
+	SrcDestRectPair delLabel;     // Nancy 13: "DEL"
+	SrcDestRectPair sendLabel;    // Nancy 13: "SEND"
+	SrcDestRectPair yesLabel;     // Nancy 13: "YES"
+	SrcDestRectPair noLabel;      // Nancy 13: "NO"
 
 	// Help "?" button (original button index 15). The visible Talk/Call key
 	// is dial-pad slot 12, not a separate widget.
@@ -664,7 +675,7 @@ struct UICL : public EngineData {
 	Common::Rect dirCursorSrc;
 	SrcDestRectPair dirHeading;
 
-	ThreeRectWidget subButtons[kNumSubButtons];
+	ThreeRectWidget subButtons[kNumSubButtonsNancy13];
 
 	// Heading/icon SRC+DEST pairs
 	SrcDestRectPair searchHeading;
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index e4f0036d52d..797b2cea943 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -352,6 +352,29 @@ void CellPhoneData::syncLinkArray(Common::Serializer &ser, Common::Array<SearchL
 	}
 }
 
+void CellPhonePictureData::synchronize(Common::Serializer &ser) {
+	uint16 numPictures = (uint16)pictures.size();
+	ser.syncAsUint16LE(numPictures);
+	if (ser.isLoading()) {
+		pictures.resize(numPictures);
+	}
+
+	for (uint16 i = 0; i < numPictures; ++i) {
+		CapturedPicture &p = pictures[i];
+		ser.syncAsUint16LE(p.width);
+		ser.syncAsUint16LE(p.height);
+		ser.syncAsByte(p.sent);
+
+		uint32 numBytes = (uint32)p.width * p.height * 4;
+		if (ser.isLoading()) {
+			p.pixels.resize(numBytes);
+		}
+		if (numBytes) {
+			ser.syncBytes(p.pixels.data(), numBytes);
+		}
+	}
+}
+
 void TimerData::synchronize(Common::Serializer &ser) {
 	for (uint i = 0; i < kNumTimers; ++i) {
 		Timer &t = timers[i];
@@ -445,6 +468,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new TableData();
 	case CellPhoneData::getTag():
 		return new CellPhoneData();
+	case CellPhonePictureData::getTag():
+		return new CellPhonePictureData();
 	case TimerData::getTag():
 		return new TimerData();
 	case UIResourceData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 1d8965de105..bc4534db4c3 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -245,6 +245,27 @@ private:
 	void syncLinkArray(Common::Serializer &ser, Common::Array<SearchLink> &arr);
 };
 
+// A cell-phone camera snapshot (Nancy 13). Stored as raw BGRA32 pixels so it
+// survives save/load independently of the scene it was taken from.
+struct CapturedPicture {
+	uint16 width = 0;
+	uint16 height = 0;
+	Common::Array<byte> pixels;   // width * height * 4, BGRA32
+	bool sent = false;            // true once the player has "sent" it
+};
+
+// Nancy 13 camera snapshots. Kept in its own lazily-created PuzzleData chunk so
+// existing saves and the CELL chunk are untouched (no save-version bump).
+struct CellPhonePictureData : public PuzzleData {
+	CellPhonePictureData() {}
+	virtual ~CellPhonePictureData() {}
+
+	static constexpr uint32 getTag() { return MKTAG('C', 'P', 'I', 'C'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Common::Array<CapturedPicture> pictures;
+};
+
 // Nancy 11+ AR 69 (TimerControl). 10 software timers, each counting up from
 // zero. A "configured" timer (state 5/6) fires a set of event flags, plays an
 // optional sound and shows an optional caption once its target duration
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 9094a7c0dc9..870905294ce 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -36,12 +36,64 @@
 #include "engines/nancy/state/scene.h"
 
 #include "engines/nancy/ui/taskbar.h"
+#include "engines/nancy/ui/viewport.h"
+
+#include "graphics/surface.h"
 
 #include "engines/nancy/ui/cellphonepopup.h"
 
 namespace Nancy {
 namespace UI {
 
+// Nancy 13 LCD tiles in the UI_Cell_Xtra atlas. The original composes the LCD
+// background into a pre-rendered per-state surface; these are the fixed source
+// rects for the plain keyboard background, the camera framing box, and the five
+// message screens. The grid is anchored to the two rects stored in the chunk
+// (the "[<?>]" help and "[<->]" framing tiles): 171x164 tiles, 173px column
+// pitch, 166px row pitch.
+static const Common::Rect kN13PlainBg(174, 187, 345, 351);
+static const Common::Rect kN13MsgWelcome(1, 353, 172, 517);
+static const Common::Rect kN13MsgPictureSent(174, 353, 345, 517);
+static const Common::Rect kN13MsgNoPictures(347, 353, 518, 517);
+static const Common::Rect kN13MsgCameraFull(1, 519, 172, 683);
+static const Common::Rect kN13MsgDeleteConfirm(174, 519, 345, 683);
+static const Common::Rect kN13MsgPictureDeleted(347, 519, 518, 683);
+// The two-phones "connecting" LCD graphic, drawn over the plain background while
+// a call is being placed. (This is the rect the chunk stores as the idle sprite
+// source; the "Welcome / River Heights" tile is kN13MsgWelcome.)
+static const Common::Rect kN13CallGraphic(347, 187, 518, 351);
+
+// Nancy 13 online sub-button roles (index = Ghidra widget id − 0x10). Nancy
+// 10-12 use a different 10-button layout, so these apply only when the game is
+// Nancy 13 or newer. Widget 0x10 (the Back button) is the extra one Nancy 13
+// prepended to the array.
+enum {
+	kN13SubBack     = 0,   // small-LCD Back (directory / menu / picture view)
+	kN13SubDirUp    = 1,
+	kN13SubDirDown  = 2,
+	kN13SubViewPics = 3,   // Menu "View Pictures" option (widget 0x13 -> state 0xb)
+	kN13SubEmail    = 4,   // Menu "E-mail / Messaging" option (widget 0x14)
+	kN13SubBrowser  = 5,   // Menu "Internet Browser" option (widget 0x15, removed)
+	kN13SubListUp   = 6,   // zoomed list / picture-view paging
+	kN13SubListDown = 7,
+	kN13SubBackFull = 8    // Back at the bottom of a zoomed (full-screen) list
+};
+
+// Nancy 10-12 online sub-button roles (the 10-button layout, before Nancy 13
+// prepended a dedicated Back button and reshuffled the rest).
+enum {
+	kSubBack      = 0,   // small-LCD Back (directory / online hub)
+	kSubDirUp     = 1,   // directory / help scroll up
+	kSubDirDown   = 2,   // directory / help scroll down
+	kSubEmail     = 3,   // hub "E-mail / Messaging" option
+	kSubWeb       = 4,   // hub "Internet Browser" option
+	kSubListUp    = 5,   // zoomed list scroll up
+	kSubListDown  = 6,   // zoomed list scroll down
+	kSubEmailBack = 7,   // email list / zoomed content Back (to the hub)
+	kSubSearch    = 8,   // browser-page SEARCH button
+	kSubWebHome   = 9    // web search-list HOME (to the browser homepage)
+};
+
 // Contacts are shown alphabetically by name (case-insensitive), matching
 // the original's CCellPhoneSortContacts.
 static bool contactNameLess(const UICL::Contact &a, const UICL::Contact &b) {
@@ -366,7 +418,10 @@ void CellPhonePopup::updateGraphics() {
 		return;
 	}
 
-	// TODO: web/email/search/help/browser modes not implemented.
+	// Per-frame state advancement. Only the call-progress states below drive
+	// themselves (via ring/pickup sounds and timers); the interactive screens
+	// (welcome, directory, online hub, lists, content, camera, ...) stay put
+	// until the user acts, so they do nothing here.
 	switch (_screenState) {
 	case kWelcome:
 	case kDialing:
@@ -375,6 +430,10 @@ void CellPhonePopup::updateGraphics() {
 	case kWebList:
 	case kEmailList:
 	case kContentView:
+	case kCamera:
+	case kPictureView:
+	case kDeleteConfirm:
+	case kMessageScreen:
 		break;
 
 	case kPlaceCall:
@@ -491,8 +550,39 @@ void CellPhonePopup::drawChrome() {
 }
 
 void CellPhonePopup::drawScreenContent() {
+	if (_inCameraFraming) {
+		// Camera framing: the popup covers the viewport and shows only the
+		// movable framing box; the scene shows through the transparent surface.
+		drawCameraFraming();
+		return;
+	}
+
 	drawChrome();
 
+	// Nancy 13 fills the LCD with a background graphic on every keyboard
+	// screen (earlier games left it blank). The expanded/zoomed screens keep
+	// their white background, so only draw it on the non-zoomed states.
+	const bool n13Keyboard = g_nancy->getGameType() >= kGameTypeNancy13 && !isZoomedChromeState();
+	if (n13Keyboard) {
+		drawLcdTile(kN13PlainBg);
+		// While a call is being placed, the LCD shows the two-phones "connecting"
+		// picture over the plain background. (The idle Welcome picture is drawn in
+		// the kWelcome case below.)
+		switch (_screenState) {
+		case kPlaceCall:
+		case kWaitOutgoingRing:
+		case kLookupContact:
+		case kWaitPickup:
+		case kConnected:
+		case kInvalidNumber:
+		case kWaitInvalid:
+			drawLcdTile(kN13CallGraphic);
+			break;
+		default:
+			break;
+		}
+	}
+
 	// Signal + battery indicators show on the welcome screen; the connected
 	// in-call screen keeps the battery but not the signal. Every other state
 	// (dialing, ringing, lists, browser, ...) hides both.
@@ -505,9 +595,20 @@ void CellPhonePopup::drawScreenContent() {
 	switch (_screenState) {
 	case kWelcome:
 		drawWebDirLabels();
+		if (n13Keyboard) {
+			// Nancy 13's top row is Cam / Menu / Dir; drawWebDirLabels() paints
+			// Menu + Dir, so add the Cam label. The "Welcome / River Heights
+			// Wireless" picture sits over the plain keyboard background.
+			drawRibbonLabel(_uiclData->dialLabel);
+			if (!_noSignal) {
+				drawLcdTile(kN13MsgWelcome);
+			}
+		}
 		if (_noSignal) {
 			drawStatusLabels();
-		} else {
+		} else if (!n13Keyboard) {
+			// Earlier games draw the welcome graphic here; Nancy 13 already
+			// painted the keyboard background above.
 			drawWelcomeScreen();
 		}
 		break;
@@ -532,7 +633,7 @@ void CellPhonePopup::drawScreenContent() {
 		}
 		// Back button on the connecting strip cancels the ringing call.
 		if (isCallBackButtonActive()) {
-			drawBackButton(0);
+			drawBackButton(kSubBack);
 		}
 		break;
 
@@ -540,7 +641,7 @@ void CellPhonePopup::drawScreenContent() {
 		// Still ringing — only the connecting sprite, plus the Back button.
 		drawConnectingSprite();
 		if (isCallBackButtonActive()) {
-			drawBackButton(0);
+			drawBackButton(kSubBack);
 		}
 		break;
 
@@ -561,16 +662,29 @@ void CellPhonePopup::drawScreenContent() {
 		drawDirectoryList();
 		drawDirectoryArrows();
 		drawHeading(_uiclData->dialHilite);
-		drawBackButton(0);
+		drawBackButton(kSubBack);
+		if (n13Keyboard) {
+			// Relabel the first bottom button (Cam): "Send" while choosing a photo
+			// recipient, "Dial" while browsing contacts to place a call.
+			drawRibbonLabelAt(_sendingPicture ? _uiclData->sendLabel.srcRect
+											  : _uiclData->dialingLabel.srcRect,
+							  _uiclData->dialLabel.destRect);
+		}
 		break;
 
 	case kOnlineHub: {
 		drawHeading(_uiclData->onlineHeading);
-		drawBackButton(0);
-		// Email / Web option buttons (subButtons 3 and 4) sit inside the LCD.
-		// Each highlights (its pressed sprite) when the cursor is over it.
-		drawHubButton(3);
-		drawHubButton(4);
+		drawBackButton(kSubBack);
+		// The two LCD option buttons highlight (pressed sprite) on hover. Nancy
+		// 13's Menu is View Pictures (subButtons[3]) + E-mail (subButtons[4]);
+		// the removed web browser was subButtons[5].
+		if (g_nancy->getGameType() >= kGameTypeNancy13) {
+			drawHubButton(kN13SubEmail);
+			drawHubButton(kN13SubViewPics);
+		} else {
+			drawHubButton(kSubEmail);
+			drawHubButton(kSubWeb);
+		}
 		break;
 	}
 
@@ -580,14 +694,15 @@ void CellPhonePopup::drawScreenContent() {
 		drawHeading(_uiclData->searchHeading);
 		drawLinkList();
 		drawDirectoryArrows();
-		drawBackButton(9);
+		drawBackButton(kSubWebHome);
 		break;
 
 	case kEmailList:
 		drawHeading(_uiclData->emailHeading);
 		drawLinkList();
 		drawDirectoryArrows();
-		drawBackButton(7);
+		// Zoomed-list Back: subButtons[7] before Nancy 13, [8] in Nancy 13.
+		drawBackButton(g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubBackFull : kSubEmailBack);
 		break;
 
 	case kContentView:
@@ -601,10 +716,49 @@ void CellPhonePopup::drawScreenContent() {
 		drawDirectoryArrows();
 		drawBackButton(contentViewBottomButton());
 		if (_contentHeading == &_uiclData->browserHeading && !isBrowserArticle()) {
-			drawHubButton(8);
+			drawHubButton(kSubSearch);
+		}
+		break;
+
+	case kCamera:
+		// The framing overlay is drawn by the _inCameraFraming short-circuit at
+		// the top of this function.
+		break;
+
+	case kDeleteConfirm:
+		// The "DELETE? YES OR NO" prompt tile plus the Yes / No ribbon labels.
+		drawLcdTile(kN13MsgDeleteConfirm);
+		drawRibbonLabel(_uiclData->yesLabel);
+		drawRibbonLabel(_uiclData->noLabel);
+		break;
+
+	case kMessageScreen:
+		// A transient message tile (Picture Sent / Deleted / Camera Full).
+		if (_messageTileSrc) {
+			drawLcdTile(*_messageTileSrc);
+		}
+		drawBackButton(kSubBack);
+		break;
+
+	case kPictureView: {
+		// Nancy 13 "view pictures": the captured photo (or the "no pictures"
+		// background) fills the LCD, with the Cam / Del / Send ribbon labels and
+		// a Back button.
+		drawPictureView();
+		drawRibbonLabel(_uiclData->dialLabel);	// CAM (retake)
+		drawRibbonLabel(_uiclData->delLabel);
+		drawRibbonLabel(_uiclData->sendLabel);
+		drawBackButton(kSubBack);
+		// Paging arrows appear only when there is more than one photo to leaf
+		// through (they page _pictureIndex; see handleInput).
+		const CellPhonePictureData *pd = pictureData();
+		if (pd && pd->pictures.size() > 1) {
+			drawScrollArrow(scrollUpButton(), _scrollUpHovered);
+			drawScrollArrow(scrollDownButton(), _scrollDownHovered);
 		}
 		break;
 	}
+	}
 
 	// Keypad depress feedback sits on top of everything else.
 	drawPressedDialKey();
@@ -653,15 +807,30 @@ void CellPhonePopup::drawWebDirLabels() {
 }
 
 void CellPhonePopup::drawDialLabel() {
-	const UICL::SrcDestRectPair &dl = _uiclData->dialLabel;
-	if (dl.srcRect.isEmpty() || dl.destRect.isEmpty()) {
+	drawRibbonLabel(_uiclData->dialLabel);
+}
+
+void CellPhonePopup::drawRibbonLabel(const UICL::SrcDestRectPair &label) {
+	if (label.srcRect.isEmpty() || label.destRect.isEmpty()) {
 		return;
 	}
+	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
+	_drawSurface.blitFrom(_spritesImage, label.srcRect,
+							Common::Point(label.destRect.left - chunkOrigin.x,
+											label.destRect.top - chunkOrigin.y));
+}
 
+void CellPhonePopup::drawRibbonLabelAt(const Common::Rect &src, const Common::Rect &dest) {
+	// Draw one ribbon-label sprite at another label's slot — used to relabel the
+	// first bottom button (Cam) as Dial in the directory and Send in the
+	// picture-recipient chooser.
+	if (src.isEmpty() || dest.isEmpty()) {
+		return;
+	}
 	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
-	_drawSurface.blitFrom(_spritesImage, dl.srcRect,
-							Common::Point(dl.destRect.left - chunkOrigin.x,
-											dl.destRect.top - chunkOrigin.y));
+	_drawSurface.blitFrom(_spritesImage, src,
+							Common::Point(dest.left - chunkOrigin.x,
+											dest.top - chunkOrigin.y));
 }
 
 void CellPhonePopup::drawTypeMessage() {
@@ -897,6 +1066,13 @@ void CellPhonePopup::openContentView(const Common::String &key, const UICL::SrcD
 }
 
 void CellPhonePopup::openBrowserHome() {
+	// The web browser was removed in Nancy 13 (its atlas CUR_URLimages_OVL no
+	// longer ships); the Menu's second option there is "view pictures", wired
+	// separately. Never open the (missing) browser for Nancy 13.
+	if (g_nancy->getGameType() >= kGameTypeNancy13) {
+		return;
+	}
+
 	// The Web button opens the browser home page (UIBW page 0 — the "River
 	// Heights Wireless" homepage). Its in-page hyperlinks then navigate to
 	// further pages / the search list.
@@ -1138,9 +1314,171 @@ void CellPhonePopup::drawWelcomeScreen() {
 											ws.destRect.top - chunkOrigin.y));
 }
 
+void CellPhonePopup::drawLcdTile(const Common::Rect &src) {
+	const Common::Rect &lcd = _uiclData->welcomeScreen.destRect;
+	if (src.isEmpty() || lcd.isEmpty()) {
+		return;
+	}
+	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
+	_drawSurface.blitFrom(_spritesImage, src,
+							Common::Point(lcd.left - chunkOrigin.x,
+											lcd.top - chunkOrigin.y));
+}
+
+void CellPhonePopup::showMessageScreen(const Common::Rect &tileSrc, ScreenState returnState) {
+	_messageTileSrc = &tileSrc;
+	_messageReturnState = returnState;
+	enterScreenState(kMessageScreen);
+}
+
+CellPhonePictureData *CellPhonePopup::pictureData() const {
+	// Lazily created (Nancy 13 only) — persists in the 'CPIC' save chunk.
+	return (CellPhonePictureData *)NancySceneState.getPuzzleData(CellPhonePictureData::getTag());
+}
+
+void CellPhonePopup::drawPictureView() {
+	CellPhonePictureData *pd = pictureData();
+	if (!pd || pd->pictures.empty()) {
+		drawLcdTile(kN13MsgNoPictures);
+		return;
+	}
+
+	_pictureIndex = CLIP<int>(_pictureIndex, 0, (int)pd->pictures.size() - 1);
+	const CapturedPicture &pic = pd->pictures[_pictureIndex];
+	if (pic.width == 0 || pic.height == 0 ||
+			pic.pixels.size() < (uint)pic.width * (uint)pic.height * 4) {
+		return;
+	}
+
+	const Common::Rect &lcd = _uiclData->welcomeScreen.destRect;
+	if (lcd.isEmpty()) {
+		return;
+	}
+	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
+
+	// Wrap the stored BGRA32 pixels and scale them into the LCD area.
+	Graphics::Surface src;
+	src.init(pic.width, pic.height, pic.width * 4,
+				const_cast<byte *>(pic.pixels.data()),
+				g_nancy->_graphics->getScreenPixelFormat());
+	Graphics::Surface *scaled = src.scale((int16)lcd.width(), (int16)lcd.height(), false);
+	if (scaled) {
+		_drawSurface.blitFrom(*scaled,
+				Common::Point(lcd.left - chunkOrigin.x, lcd.top - chunkOrigin.y));
+		scaled->free();
+		delete scaled;
+	}
+}
+
+void CellPhonePopup::captureViewport(const Common::Rect &screenRegion) {
+	CellPhonePictureData *pd = pictureData();
+	if (!pd) {
+		return;
+	}
+
+	const Viewport &viewport = NancySceneState.getViewport();
+	const Graphics::ManagedSurface &vp = viewport.getBackground();
+	if (vp.w == 0 || vp.h == 0) {
+		return;
+	}
+
+	// Translate the framed screen region into the (scrolled) viewport background.
+	const Common::Rect vpScreen = viewport.getScreenPosition();
+	const int scrollY = (int)viewport.getCurVerticalScroll();
+	Common::Rect grab;
+	if (screenRegion.isEmpty()) {
+		grab = Common::Rect(0, scrollY, vpScreen.width(), scrollY + vpScreen.height());
+	} else {
+		grab = screenRegion;
+		grab.translate(-vpScreen.left, -vpScreen.top + scrollY);
+	}
+	grab.clip(Common::Rect(vp.w, vp.h));
+	if (grab.isEmpty()) {
+		return;
+	}
+
+	// Copy the sub-area, converted to BGRA32 so it can be re-displayed and saved.
+	Graphics::Surface sub = vp.rawSurface().getSubArea(grab);
+	Graphics::Surface *conv = sub.convertTo(g_nancy->_graphics->getScreenPixelFormat());
+	if (!conv) {
+		return;
+	}
+
+	CapturedPicture pic;
+	pic.width = (uint16)conv->w;
+	pic.height = (uint16)conv->h;
+	pic.pixels.resize((uint)pic.width * (uint)pic.height * 4);
+	for (int y = 0; y < conv->h; ++y) {
+		memcpy(pic.pixels.data() + (uint)y * (uint)pic.width * 4,
+				conv->getBasePtr(0, y), (uint)pic.width * 4);
+	}
+	conv->free();
+	delete conv;
+
+	pd->pictures.push_back(pic);
+	_pictureIndex = (int)pd->pictures.size() - 1;
+}
+
+Common::Rect CellPhonePopup::framingScreenRect() const {
+	const Common::Rect vp = NancySceneState.getViewport().getScreenPosition();
+	const int w = MIN<int>(kFramingWidth, vp.width());
+	const int h = MIN<int>(kFramingHeight, vp.height());
+	const int cx = CLIP<int>(_framingMouse.x, vp.left + w / 2, vp.right - w / 2);
+	const int cy = CLIP<int>(_framingMouse.y, vp.top + h / 2, vp.bottom - h / 2);
+	return Common::Rect(cx - w / 2, cy - h / 2, cx - w / 2 + w, cy - h / 2 + h);
+}
+
+void CellPhonePopup::enterCameraFraming() {
+	if (_inCameraFraming) {
+		return;
+	}
+	_inCameraFraming = true;
+	_savedPhoneRect = _screenPosition;
+
+	// Grow the popup to cover the viewport so the framing box can be drawn
+	// anywhere over the live scene.
+	const Common::Rect vp = NancySceneState.getViewport().getScreenPosition();
+	moveTo(vp);
+	_drawSurface.create(vp.width(), vp.height(), g_nancy->_graphics->getScreenPixelFormat());
+	setTransparent(true);
+	_framingMouse = Common::Point(vp.left + vp.width() / 2, vp.top + vp.height() / 2);
+}
+
+void CellPhonePopup::exitCameraFraming() {
+	if (!_inCameraFraming) {
+		return;
+	}
+	_inCameraFraming = false;
+	moveTo(_savedPhoneRect);
+	Common::Rect bounds = _screenPosition;
+	bounds.moveTo(0, 0);
+	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getScreenPixelFormat());
+	setTransparent(false);
+}
+
+void CellPhonePopup::drawCameraFraming() {
+	const uint32 trans = g_nancy->_graphics->getTransColor();
+	_drawSurface.clear(trans);
+
+	Common::Rect box = framingScreenRect();
+	box.translate(-_screenPosition.left, -_screenPosition.top);
+
+	// TODO: The original swaps the mouse cursor for a framing-rectangle sprite
+	// loaded from the game resources (via the scene-prep capture path, not the
+	// UICL chunk); locate that cursor and blit it here instead of the drawn
+	// outline. The 220x176 framing size is likewise a placeholder to confirm.
+	const uint32 col = _drawSurface.format.RGBToColor(255, 255, 255);
+	_drawSurface.frameRect(box, col);
+	if (box.width() > 4 && box.height() > 4) {
+		box.grow(-1);
+		_drawSurface.frameRect(box, col);
+	}
+	_needsRedraw = true;
+}
+
 void CellPhonePopup::drawBackButton(uint subButtonIndex) {
-	// subButtons[0] is the Back button in the lower ribbon (help / sub-screens);
-	// subButtons[7] is the Back button at the bottom of the zoomed content view.
+	// kSubBack is the Back button in the lower ribbon (help / sub-screens);
+	// kSubEmailBack is the Back button at the bottom of the zoomed content view.
 	const UICL::ThreeRectWidget &back = _uiclData->subButtons[subButtonIndex];
 	if (back.destRect.isEmpty()) {
 		return;
@@ -1216,11 +1554,13 @@ int CellPhonePopup::currentBackButtonIndex() const {
 	switch (_screenState) {
 	case kDirectory:
 	case kOnlineHub:
-		return 0;
+	case kPictureView:
+	case kMessageScreen:
+		return kSubBack;
 	case kWebList:
-		return 9;
+		return kSubWebHome;
 	case kEmailList:
-		return 7;
+		return kSubEmailBack;
 	case kContentView:
 		return (int)contentViewBottomButton();
 	default:
@@ -1231,9 +1571,9 @@ int CellPhonePopup::currentBackButtonIndex() const {
 uint CellPhonePopup::contentViewBottomButton() const {
 	// Help Back (0); browser-article HOME (9); main page / email article Back (7).
 	if (isHelpContentView()) {
-		return 0;
+		return kSubBack;
 	}
-	return isBrowserArticle() ? 9 : 7;
+	return isBrowserArticle() ? kSubWebHome : kSubEmailBack;
 }
 
 bool CellPhonePopup::isBrowserArticle() const {
@@ -1245,47 +1585,47 @@ bool CellPhonePopup::isBrowserArticle() const {
 }
 
 const UICL::ThreeRectWidget &CellPhonePopup::scrollUpButton() const {
-	// Directory and help both scroll with the small-LCD arrow pair
-	// (subButtons[1]/[2]); the zoomed email / browser articles use [5]/[6].
-	return (_screenState == kDirectory || isHelpContentView())
-		? _uiclData->subButtons[1]
-		: _uiclData->subButtons[5];
+	// Directory and help scroll with the small-LCD arrow pair (subButtons[1]/[2]);
+	// the zoomed email / browser lists use a different pair. Nancy 13 keeps the
+	// directory on [1]/[2] and uses the list arrows [6]/[7] elsewhere; earlier
+	// games use [5]/[6] for the zoomed lists.
+	const bool smallLcd = _screenState == kDirectory || isHelpContentView();
+	if (g_nancy->getGameType() >= kGameTypeNancy13) {
+		return smallLcd ? _uiclData->subButtons[kN13SubDirUp]
+						: _uiclData->subButtons[kN13SubListUp];
+	}
+	return smallLcd ? _uiclData->subButtons[kSubDirUp] : _uiclData->subButtons[kSubListUp];
 }
 
 const UICL::ThreeRectWidget &CellPhonePopup::scrollDownButton() const {
-	return (_screenState == kDirectory || isHelpContentView())
-		? _uiclData->subButtons[2]
-		: _uiclData->subButtons[6];
+	const bool smallLcd = _screenState == kDirectory || isHelpContentView();
+	if (g_nancy->getGameType() >= kGameTypeNancy13) {
+		return smallLcd ? _uiclData->subButtons[kN13SubDirDown]
+						: _uiclData->subButtons[kN13SubListDown];
+	}
+	return smallLcd ? _uiclData->subButtons[kSubDirDown] : _uiclData->subButtons[kSubListDown];
 }
 
-void CellPhonePopup::drawDirectoryArrows() {
-	// Up/down scroll arrows are not in the chrome image; blit on every redraw.
+void CellPhonePopup::drawScrollArrow(const UICL::ThreeRectWidget &arrow, bool hovered) {
+	// Scroll/paging arrows are not in the chrome image; blit on every redraw.
 	// The pressed (lit) sprite is used while the cursor is over the arrow.
-	const UICL::ThreeRectWidget &up = scrollUpButton();
-	const UICL::ThreeRectWidget &down = scrollDownButton();
-
-	const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
-
-	if (!up.destRect.isEmpty()) {
-		const Common::Rect &upSrc = (_scrollUpHovered && !up.srcRectPressed.isEmpty())
-			? up.srcRectPressed
-			: up.srcRectIdle;
-		if (!upSrc.isEmpty()) {
-			_drawSurface.blitFrom(_spritesImage, upSrc,
-									Common::Point(up.destRect.left - chunkOrigin.x,
-													up.destRect.top - chunkOrigin.y));
-		}
+	if (arrow.destRect.isEmpty()) {
+		return;
 	}
-	if (!down.destRect.isEmpty()) {
-		const Common::Rect &downSrc = (_scrollDownHovered && !down.srcRectPressed.isEmpty())
-			? down.srcRectPressed
-			: down.srcRectIdle;
-		if (!downSrc.isEmpty()) {
-			_drawSurface.blitFrom(_spritesImage, downSrc,
-									Common::Point(down.destRect.left - chunkOrigin.x,
-													down.destRect.top - chunkOrigin.y));
-		}
+	const Common::Rect &src = (hovered && !arrow.srcRectPressed.isEmpty())
+		? arrow.srcRectPressed
+		: arrow.srcRectIdle;
+	if (src.isEmpty()) {
+		return;
 	}
+	_drawSurface.blitFrom(_spritesImage, src,
+							Common::Point(arrow.destRect.left - _screenPosition.left,
+											arrow.destRect.top - _screenPosition.top));
+}
+
+void CellPhonePopup::drawDirectoryArrows() {
+	drawScrollArrow(scrollUpButton(), _scrollUpHovered);
+	drawScrollArrow(scrollDownButton(), _scrollDownHovered);
 
 	// Selection indicator (dirArrowSrc sprite) — only the contacts directory
 	// shows it. The search-topic and email lists are plain lists in the
@@ -1607,15 +1947,18 @@ bool CellPhonePopup::isContactVisible(const UICL::Contact &c) const {
 }
 
 Common::Rect CellPhonePopup::hubEmailRect() const {
-	// subButtons[3] is the upper LCD option button (Email).
-	Common::Rect r = _uiclData->subButtons[3].destRect;
+	// The Email option button: subButtons[3] before Nancy 13, [4] in Nancy 13.
+	const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubEmail : kSubEmail;
+	Common::Rect r = _uiclData->subButtons[slot].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;
+	// The lower LCD option button: Web (subButtons[4]) before Nancy 13, View
+	// Pictures (subButtons[3]) in Nancy 13.
+	const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubViewPics : kSubWeb;
+	Common::Rect r = _uiclData->subButtons[slot].destRect;
 	r.translate(-_screenPosition.left, -_screenPosition.top);
 	return r;
 }
@@ -1796,6 +2139,33 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// Nancy 13 camera framing: the movable box tracks the mouse; a left click
+	// takes the shot, a right click cancels back to the welcome screen.
+	if (_inCameraFraming) {
+		if (input.mousePos != _framingMouse) {
+			_framingMouse = input.mousePos;
+			drawScreenContent();
+		}
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			const Common::Rect shot = framingScreenRect();
+			exitCameraFraming();
+			CellPhonePictureData *pd = pictureData();
+			if (pd && pd->pictures.size() >= kMaxPictures) {
+				// The camera roll is full — the original refuses the shot and
+				// asks the player to delete one or more pictures.
+				showMessageScreen(kN13MsgCameraFull, kWelcome);
+			} else {
+				captureViewport(shot);
+				enterScreenState(kPictureView);
+			}
+		} else if (input.input & NancyInput::kRightMouseButtonUp) {
+			exitCameraFraming();
+			enterScreenState(kWelcome);
+		}
+		input.eatMouseInput();
+		return;
+	}
+
 	// Mid-call states accept only the close X.
 	const bool transientCallState =
 		_screenState == kPlaceCall || _screenState == kWaitOutgoingRing ||
@@ -1836,7 +2206,7 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 	if (transientCallState) {
 		// While ringing, only the Back button is live (cancels the call).
 		if (isCallBackButtonActive()) {
-			const Common::Rect backHit = backButtonHitRect(0);
+			const Common::Rect backHit = backButtonHitRect(kSubBack);
 			const Common::Point popupMouse(input.mousePos.x - _screenPosition.left,
 											input.mousePos.y - _screenPosition.top);
 			const bool overBack = !backHit.isEmpty() && backHit.contains(popupMouse);
@@ -1861,10 +2231,11 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 	const Common::Point chunkMouse = mouseToChunkCoords(input.mousePos);
 
 	// Light the up/down arrows on hover in any state that uses them (directory,
-	// link lists, and the content view — help included, which scrolls via the
-	// small-LCD arrow pair).
+	// link lists, the content view — help included, which scrolls via the
+	// small-LCD arrow pair — and the Nancy 13 picture-view paging arrows).
 	const bool arrowsActive = _screenState == kDirectory || isLinkListMode() ||
-								_screenState == kContentView;
+								_screenState == kContentView ||
+								_screenState == kPictureView;
 	const bool overUp = arrowsActive &&
 			scrollUpButton().destRect.contains(chunkMouse);
 	const bool overDown = arrowsActive && !overUp &&
@@ -1928,7 +2299,19 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 			_uiclData->helpButton.destRect.contains(chunkMouse)) {
 		g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
-			openContentView(_uiclData->helpTextKey, _uiclData->helpHeading);
+			// Nancy 13 stores two help keys: the phone-help text lives under
+			// helpTextKey2 ("PHNUSE") in AUTOTEXT, not helpTextKey ("NTR025"), so
+			// use whichever actually resolves.
+			Common::String helpKey = _uiclData->helpTextKey;
+			if (g_nancy->getGameType() >= kGameTypeNancy13 && !_uiclData->helpTextKey2.empty()) {
+				const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+				const Common::String help2 = _uiclData->helpTextKey2.toString();
+				if (autotext && !autotext->texts.contains(helpKey) &&
+						autotext->texts.contains(help2)) {
+					helpKey = help2;
+				}
+			}
+			openContentView(helpKey, _uiclData->helpHeading);
 			input.eatMouseInput();
 			return;
 		}
@@ -1957,7 +2340,7 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 
 		// Visible Back button at the bottom of the display. Gated so it can't
 		// intercept up/down clicks.
-		const Common::Rect backHit = backButtonHitRect(0);
+		const Common::Rect backHit = backButtonHitRect(kSubBack);
 		const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
 										chunkMouse.y - _screenPosition.top);
 		const bool overUpDown =
@@ -1974,7 +2357,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 			}
 		}
 
-		// Row click selects (the call button does the actual call).
+		// Row click only selects; the first bottom button places the call (or,
+		// while choosing a photo recipient, sends the picture).
 		const uint row = directoryRowAt(chunkMouse);
 		if (row != (uint)-1) {
 			const int contactIdx = contactIndexForVisibleRow(row);
@@ -1998,11 +2382,14 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 										chunkMouse.y - _screenPosition.top);
 		const Common::Rect emailR = hubEmailRect();
 		const Common::Rect webR   = hubWebRect();
-		const Common::Rect backHit = backButtonHitRect(0);
+		const Common::Rect backHit = backButtonHitRect(kSubBack);
 
 		// Highlight whichever option button the cursor is over.
-		const int newHubHover = emailR.contains(popupMouse) ? 3
-								: webR.contains(popupMouse) ? 4 : -1;
+		const bool n13Hub = g_nancy->getGameType() >= kGameTypeNancy13;
+		const int emailSlot = n13Hub ? kN13SubEmail : kSubEmail;
+		const int webSlot = n13Hub ? kN13SubViewPics : kSubWeb;
+		const int newHubHover = emailR.contains(popupMouse) ? emailSlot
+								: webR.contains(popupMouse) ? webSlot : -1;
 		if (newHubHover != _hoveredHubButton) {
 			_hoveredHubButton = newHubHover;
 			drawScreenContent();
@@ -2022,7 +2409,14 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 			if (input.input & NancyInput::kLeftMouseButtonUp) {
 				_directoryScroll = 0;
 				_directorySelection = 0;
-				openBrowserHome();
+				if (g_nancy->getGameType() >= kGameTypeNancy13) {
+					// Nancy 13's Menu second option is "view pictures" (the web
+					// browser was removed).
+					_pictureIndex = 0;
+					enterScreenState(kPictureView);
+				} else {
+					openBrowserHome();
+				}
 				input.eatMouseInput();
 				return;
 			}
@@ -2036,6 +2430,122 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		}
 	}
 
+	// Nancy 13 "view pictures" review screen. The top-row buttons are relabelled
+	// Cam (retake) / Del / Send; the directory scroll arrows page between photos.
+	if (_screenState == kPictureView) {
+		const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
+										chunkMouse.y - _screenPosition.top);
+		CellPhonePictureData *pd = pictureData();
+		const int numPics = pd ? (int)pd->pictures.size() : 0;
+
+		Common::Rect camR = _uiclData->dialPadSlots[12].destRect;
+		Common::Rect delR = _uiclData->dialPadSlots[13].destRect;
+		Common::Rect sendR = _uiclData->dialPadSlots[14].destRect;
+		Common::Rect upR = _uiclData->subButtons[kN13SubListUp].destRect;
+		Common::Rect downR = _uiclData->subButtons[kN13SubListDown].destRect;
+		const Common::Point origin(_screenPosition.left, _screenPosition.top);
+		camR.translate(-origin.x, -origin.y);
+		delR.translate(-origin.x, -origin.y);
+		sendR.translate(-origin.x, -origin.y);
+		upR.translate(-origin.x, -origin.y);
+		downR.translate(-origin.x, -origin.y);
+		const Common::Rect backHit = backButtonHitRect(kSubBack);
+
+		const bool overButton = camR.contains(popupMouse) ||
+			(numPics > 0 && (delR.contains(popupMouse) || sendR.contains(popupMouse))) ||
+			(numPics > 1 && (upR.contains(popupMouse) || downR.contains(popupMouse))) ||
+			(!backHit.isEmpty() && backHit.contains(popupMouse));
+		if (overButton) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+		}
+		if (overButton != _backButtonHovered) {
+			_backButtonHovered = overButton;
+			drawScreenContent();
+		}
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			if (camR.contains(popupMouse)) {
+				// Retake — back into the framing overlay.
+				_screenState = kCamera;
+				enterCameraFraming();
+				drawScreenContent();
+			} else if (numPics > 0 && delR.contains(popupMouse)) {
+				enterScreenState(kDeleteConfirm);
+			} else if (numPics > 0 && sendR.contains(popupMouse)) {
+				// Pick a recipient in the directory, then confirm "sent".
+				_sendingPicture = true;
+				_directoryScroll = 0;
+				_directorySelection = 0;
+				enterScreenState(kDirectory);
+			} else if (numPics > 1 && upR.contains(popupMouse)) {
+				_pictureIndex = (_pictureIndex + numPics - 1) % numPics;
+				drawScreenContent();
+			} else if (numPics > 1 && downR.contains(popupMouse)) {
+				_pictureIndex = (_pictureIndex + 1) % numPics;
+				drawScreenContent();
+			} else if (!backHit.isEmpty() && backHit.contains(popupMouse)) {
+				enterScreenState(kOnlineHub);
+			}
+			input.eatMouseInput();
+			return;
+		}
+		input.eatMouseInput();
+		return;
+	}
+
+	// Nancy 13 delete-confirm ("DELETE? YES OR NO"): Yes and No are the Menu
+	// (slot 13) and Dir (slot 14) dial-pad keys relabelled (like slot 12's
+	// Cam/Dial/Send), so those slots are the hitboxes. Yes deletes the current
+	// photo; No returns to the picture view.
+	if (_screenState == kDeleteConfirm) {
+		const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
+										chunkMouse.y - _screenPosition.top);
+		Common::Rect yesR = _uiclData->dialPadSlots[13].destRect;
+		Common::Rect noR = _uiclData->dialPadSlots[14].destRect;
+		yesR.translate(-_screenPosition.left, -_screenPosition.top);
+		noR.translate(-_screenPosition.left, -_screenPosition.top);
+		if (yesR.contains(popupMouse) || noR.contains(popupMouse)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+		}
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			CellPhonePictureData *pd = pictureData();
+			if (yesR.contains(popupMouse) && pd && _pictureIndex >= 0 &&
+					_pictureIndex < (int)pd->pictures.size()) {
+				pd->pictures.remove_at(_pictureIndex);
+				if (_pictureIndex >= (int)pd->pictures.size()) {
+					_pictureIndex = MAX(0, (int)pd->pictures.size() - 1);
+				}
+				showMessageScreen(kN13MsgPictureDeleted, kPictureView);
+				input.eatMouseInput();
+				return;
+			}
+			if (noR.contains(popupMouse)) {
+				enterScreenState(kPictureView);
+				input.eatMouseInput();
+				return;
+			}
+		}
+		input.eatMouseInput();
+		return;
+	}
+
+	// Nancy 13 transient message tile (Sent / Deleted / Camera Full): any click
+	// (or the Back button) dismisses it back to the state it was raised from.
+	if (_screenState == kMessageScreen) {
+		g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			ScreenState ret = _messageReturnState;
+			// If the roll is now empty (last photo deleted), fall back to Menu.
+			CellPhonePictureData *pd = pictureData();
+			if (ret == kPictureView && (!pd || pd->pictures.empty())) {
+				ret = kOnlineHub;
+			}
+			enterScreenState(ret);
+		}
+		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.
@@ -2061,8 +2571,12 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 
 		// The list views show a bottom button at the same spot: the email list
 		// a BACK (subButtons[7]) to the hub, the search list a HOME
-		// (subButtons[9]) to the browser homepage. Same dest rect either way.
-		const Common::Rect backHit = backButtonHitRect(_screenState == kWebList ? 9 : 7);
+		// (subButtons[9]) to the browser homepage. Nancy 13 moved the zoomed-list
+		// Back to subButtons[8] (and dropped the web list). Same dest rect either.
+		const uint listBackIdx = g_nancy->getGameType() >= kGameTypeNancy13
+			? (uint)kN13SubBackFull
+			: (uint)(_screenState == kWebList ? kSubWebHome : kSubEmailBack);
+		const Common::Rect backHit = backButtonHitRect(listBackIdx);
 		const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
 										chunkMouse.y - _screenPosition.top);
 		const bool overUpDown =
@@ -2155,10 +2669,10 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		// The main browser page carries the top-row SEARCH button (subButtons[8])
 		// which opens the search list; it highlights green while hovered.
 		if (_contentHeading == &_uiclData->browserHeading && !isBrowserArticle() &&
-				!_uiclData->subButtons[8].destRect.isEmpty()) {
-			Common::Rect searchBtn = _uiclData->subButtons[8].destRect;
+				!_uiclData->subButtons[kSubSearch].destRect.isEmpty()) {
+			Common::Rect searchBtn = _uiclData->subButtons[kSubSearch].destRect;
 			searchBtn.translate(-_screenPosition.left, -_screenPosition.top);
-			const int newHover = searchBtn.contains(popupMouseLink) ? 8 : -1;
+			const int newHover = searchBtn.contains(popupMouseLink) ? kSubSearch : -1;
 			if (newHover != _hoveredHubButton) {
 				_hoveredHubButton = newHover;
 				drawScreenContent();
@@ -2252,6 +2766,31 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			playDialPadSound(_uiclData->dialPadSlots[12].soundName);
+			if (g_nancy->getGameType() >= kGameTypeNancy13 &&
+					(_screenState == kWelcome || _screenState == kDialing)) {
+				// Nancy 13's slot 12 is dual-purpose (Ghidra widget 0xc): on the
+				// welcome / dialing screen it is the camera button — enter the
+				// framing overlay (the phone yields to the viewport and a movable
+				// box marks the shot). In the directory it dials instead, so fall
+				// through to the call logic below.
+				_screenState = kCamera;
+				enterCameraFraming();
+				drawScreenContent();
+				input.eatMouseInput();
+				return;
+			}
+			if (_sendingPicture && _screenState == kDirectory) {
+				// Choosing a photo recipient: the first button is "Send" — mark
+				// the picture sent and show the confirmation.
+				_sendingPicture = false;
+				CellPhonePictureData *pd = pictureData();
+				if (pd && _pictureIndex >= 0 && _pictureIndex < (int)pd->pictures.size()) {
+					pd->pictures[_pictureIndex].sent = true;
+				}
+				showMessageScreen(kN13MsgPictureSent, kPictureView);
+				input.eatMouseInput();
+				return;
+			}
 			if (!_noSignal) {
 				if (_screenState == kDirectory) {
 					const int contactIdx =
@@ -2276,8 +2815,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 	// Dial-pad slot behaviour:
 	//   0..9   - digit input
 	//   10, 11 - *, # (no-op)
-	//   12     - call/talk key (handled above)
-	//   13     - web mode (TODO)
+	//   12     - call/talk key, or Nancy 13 camera/dial/send (handled above)
+	//   13     - Menu: opens the online hub (e-mail + web / view pictures)
 	//   14     - directory toggle
 	int newHovered = -1;
 	if (keypadVisible) {
@@ -2322,9 +2861,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		}
 	}
 
-	// TODO: sub-buttons (email/search/help/browser/in-call menu) not hooked up.
-
-	// Block the viewport from acting on the cursor while the phone is up.
+	// Nothing else claimed the click: block the viewport from acting on the
+	// cursor while the phone is up.
 	input.eatMouseInput();
 }
 
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/cellphonepopup.h
index f7f9f482cd5..c3395fc6130 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/cellphonepopup.h
@@ -32,8 +32,9 @@ struct NancyInput;
 
 namespace UI {
 
-// Nancy 10+ cell phone popup, driven by the UICL chunk.
-// TODO: email, search, help, browser modes; in-call menu; redial.
+// Nancy 10+ cell phone popup, driven by the UICL chunk. Handles dialling and
+// calls, the contacts directory, the online hub (e-mail / web search / browser,
+// or the Nancy 13 camera), the help page, and the Nancy 13 photo camera.
 class CellPhonePopup : public RenderObject {
 public:
 	CellPhonePopup();
@@ -101,7 +102,14 @@ private:
 		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
+		kContentView      = 13,  // full-text view of a single email / page
+
+		// Nancy 13 camera feature (the web browser was removed; Menu offers
+		// "view pictures" instead).
+		kCamera           = 14,  // framing the live viewport before a snapshot
+		kPictureView      = 15,  // reviewing a captured photo (Cam/Del/Send)
+		kDeleteConfirm    = 16,  // "DELETE? YES OR NO" over a photo
+		kMessageScreen    = 17   // a transient message tile (SENT / DELETED / FULL)
 	};
 
 	void drawChrome();
@@ -109,6 +117,10 @@ private:
 	void drawStatusIcons(bool includeSignal = true);
 	void drawWebDirLabels();
 	void drawDialLabel();
+	// Blit one ribbon label sprite (Cam / Menu / Dir / Del / Send / Yes / No)
+	// from the sprite atlas at its chunk dest.
+	void drawRibbonLabel(const UICL::SrcDestRectPair &label);
+	void drawRibbonLabelAt(const Common::Rect &src, const Common::Rect &dest);
 	void drawTypeMessage();
 	void drawConnectedLabel();
 	void drawConnectingSprite();
@@ -118,7 +130,35 @@ private:
 	void drawStatusLabels();
 	void drawDirectoryList();
 	void drawDirectoryArrows();
+	// Blit one scroll/paging arrow (idle, or its pressed sprite when hovered).
+	void drawScrollArrow(const UICL::ThreeRectWidget &arrow, bool hovered);
 	void drawWelcomeScreen();
+	// Nancy 13: blit one UI_Cell_Xtra atlas tile into the LCD area (the plain
+	// keyboard background or one of the message tiles). These are fixed atlas
+	// positions the original bakes into a pre-rendered per-state LCD surface.
+	void drawLcdTile(const Common::Rect &src);
+	// Show a transient message tile (Picture Sent / Deleted / Camera Full),
+	// dismissed by any click back to returnState.
+	void showMessageScreen(const Common::Rect &tileSrc, ScreenState returnState);
+	// Nancy 13: draw the current captured photo (scaled into the LCD) for the
+	// "view pictures" screen, or the "no pictures" tile when there are none.
+	void drawPictureView();
+	// The captured pictures store, lazily created (Nancy 13 only). May be null.
+	struct CellPhonePictureData *pictureData() const;
+	// Nancy 13 camera: grab the given screen-space region of the live viewport
+	// into a new persisted CapturedPicture and select it. Empty rect = the whole
+	// viewport.
+	void captureViewport(const Common::Rect &screenRegion = Common::Rect());
+
+	// Nancy 13 camera framing: while kCamera is active the popup covers the
+	// viewport and draws a movable rectangle marking the shot. Entering saves the
+	// phone's rect and grows the draw surface; exiting restores it.
+	void enterCameraFraming();
+	void exitCameraFraming();
+	void drawCameraFraming();
+	// Screen-space rect of the framing box, centred on the mouse and clamped to
+	// the viewport.
+	Common::Rect framingScreenRect() const;
 	// Blit a sub-button's idle sprite at its chunk dest (used for the visible
 	// Back buttons: subButtons[0] on the help / directory / online screens,
 	// subButtons[7] in the zoomed email / browser content view).
@@ -295,6 +335,27 @@ private:
 	uint _directoryScroll = 0;
 	uint _directorySelection = 0;
 
+	// Nancy 13 "view pictures": index of the currently displayed captured photo.
+	int _pictureIndex = 0;
+
+	// Nancy 13 camera framing state.
+	bool _inCameraFraming = false;
+	Common::Rect _savedPhoneRect;      // phone rect to restore when framing ends
+	Common::Point _framingMouse;       // last mouse pos (screen coords)
+	static const int kFramingWidth  = 220;
+	static const int kFramingHeight = 176;
+
+	// The original caps the persisted camera roll at 50 pictures.
+	static const uint kMaxPictures = 50;
+
+	// True while the directory is open to pick a recipient for the current photo
+	// (reached from the picture-review "Send" button).
+	bool _sendingPicture = false;
+
+	// Active tile + return target for the transient kMessageScreen state.
+	const Common::Rect *_messageTileSrc = nullptr;
+	ScreenState _messageReturnState = kWelcome;
+
 	// Content-view (single email / page) state.
 	ScreenState _contentReturnState = kOnlineHub;
 	const UICL::SrcDestRectPair *_contentHeading = nullptr;


Commit: 09e072597305e6dd5006c85381033d4dcc1dcd8e
    https://github.com/scummvm/scummvm/commit/09e072597305e6dd5006c85381033d4dcc1dcd8e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-23T03:55:03+03:00

Commit Message:
NANCY: NANCY13: More work on PachinkoPuzzle

- Implement panel bounds
- Fix ball bouncing on pins
- Fix drawing of climbers (yeti and gold digger)
- Fix ball entry position
- Implement the functionality of the board holes
- Implement win/lose conditions

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


diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
index 40f3392cbd0..09e0ffb1042 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.cpp
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -48,6 +48,20 @@ static const double kSpawnYJitter = 0.01;
 // a small constant drag is used here as an approximation.
 static const double kDrag = 12.0;				// px/s per second
 
+// Collision geometry. Pins are stored as 1x1 points on a staggered ~30px grid; on screen
+// they are small round bumpers, so the hit distance matches the drawn pin + ball radii (a
+// ball may still thread the wider gaps, which is fine - the stagger catches it a row later).
+static const double kPinRadius = 4.0;
+static const double kBallRadius = 5.0;
+static const int kPhysicsSubsteps = 4;			// per frame, to avoid tunnelling the pins
+
+static const uint32 kHoleLitMs = 400;			// how long a hole stays lit after a catch
+
+// A climber reaches the pot once its accumulated steps hit this goal. moverSpeed per catch
+// is 2 (Miner) / 3 (Yeti), so the Yeti climbs faster - matching the game's "the Yeti
+// usually wins". The exact goal is not recovered from the chunk; this is a tuned value.
+static const int kClimbGoal = 30;
+
 static double wrapAngle(double a) {
 	while (a < 0.0) {
 		a += kTwoPi;
@@ -63,7 +77,7 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
 
 	// 167-byte header blob.
 	readRect(stream, _ballSrc);					// ball sprite source
-	stream.skip(16);							// four unidentified int32
+	readRect(stream, _ballEntry);				// top-right entry chute
 	_velMin = stream.readSint32LE();			// launch-speed floor
 	_velMax = stream.readSint32LE();			// launch-speed ceiling
 	_spawnYMin = stream.readSint32LE();			// launch-heading (deg) range
@@ -71,7 +85,7 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
 	stream.skip(4);
 	_launchVecLen = stream.readSint32LE();
 	stream.skip(4);
-	stream.skip(16);							// four unidentified int32
+	readRect(stream, _panelBounds);				// pin-panel bounds (walls + floor)
 	_eventFlag = stream.readSint16LE();			// "in progress" flag
 	readFilename(stream, _ballImageName);		// ball sprite sheet
 	readRect(stream, _launcherBallSrc);
@@ -143,15 +157,13 @@ void PachinkoPuzzle::readMachine(Common::SeekableReadStream &stream, Machine &m)
 
 	m.winchSound.readData(stream);				// snd1 - the winch-up cue
 
-	// The 55-byte blob: its leading filename is the result movie (MUS_PachinkoWinANIM for
-	// the Miner); an empty filename means no movie.
-	byte blob[55];
-	stream.read(blob, sizeof(blob));
-	int len = 0;
-	while (len < 33 && blob[len] != 0) {
-		++len;
-	}
-	m.movieName = Common::String((const char *)blob, len);
+	stream.skip(1);								// per-machine flag byte
+
+	// The 55-byte blob: a filename[33] (the result movie - MUS_PachinkoWinANIM /
+	// MUS_PachinkoLoseANIM), then its 16-byte destination rect, then int32 + int16.
+	readFilename(stream, m.movieName);
+	readRect(stream, m.movieDest);
+	stream.skip(6);
 
 	m.resultSound.readData(stream);				// snd2 - the win/lose voice cue
 
@@ -168,6 +180,74 @@ void PachinkoPuzzle::loadMachineImage(Machine &m) {
 	}
 }
 
+// The four holes are the bumper zones (type 0x16) that carry a bell sound. The sound name
+// maps each hole to its climber: Miner/Explosion feed the Gold Digger (win), Yeti/Ouch feed
+// the Yeti (lose).
+void PachinkoPuzzle::buildHoles() {
+	_holes.clear();
+	for (const ActionZone &z : _zones) {
+		if (z.type != 0x16 || z._sound.names.empty()) {
+			continue;
+		}
+
+		Machine *climber = nullptr;
+		for (const Common::String &n : z._sound.names) {
+			if (n.contains("Miner") || n.contains("Explosion")) {
+				climber = &_winMachine;
+				break;
+			}
+			if (n.contains("Yeti") || n.contains("Ouch")) {
+				climber = &_loseMachine;
+				break;
+			}
+		}
+		if (!climber) {
+			continue;
+		}
+
+		Hole hole;
+		hole.rect = z.rect;
+		hole.climber = climber;
+		hole.sound = z._sound;
+		_holes.push_back(hole);
+	}
+
+	// Attach each hole's "lit" sprite from the matching overlay (0x0d) zone, which shares
+	// the hole's rect and names the lit board overlay.
+	for (const ActionZone &z : _zones) {
+		if (z.type != 0x0d || z.overlaySrcRects.empty()) {
+			continue;
+		}
+		for (Hole &hole : _holes) {
+			if (hole.rect == z.rect) {
+				hole.litSrc = z.overlaySrcRects[0];
+				hole.litDest = z.overlayDestRect;
+				if (_litImageName.empty()) {
+					_litImageName = Common::Path(z.overlayName.c_str());
+				}
+				break;
+			}
+		}
+	}
+
+	if (!_litImageName.empty()) {
+		g_nancy->_resource->loadImage(_litImageName, _litImage);
+		_litImage.setTransparentColor(_drawSurface.getTransparentColor());
+	}
+}
+
+// The climber's current on-screen anchor: the point on its climb path from the bottom
+// (moverStart) to the pot (moverEnd), advanced by how many balls it has caught.
+Common::Point PachinkoPuzzle::climberAnchor(const Machine &m) const {
+	double t = (double)m.climbSteps / (double)kClimbGoal;
+	if (t > 1.0) {
+		t = 1.0;
+	}
+	Common::Point start((m.moverStart.left + m.moverStart.right) / 2, (m.moverStart.top + m.moverStart.bottom) / 2);
+	Common::Point end((m.moverEnd.left + m.moverEnd.right) / 2, (m.moverEnd.top + m.moverEnd.bottom) / 2);
+	return Common::Point((int16)(start.x + (end.x - start.x) * t), (int16)(start.y + (end.y - start.y) * t));
+}
+
 void PachinkoPuzzle::init() {
 	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
 	_drawSurface.create(vpBounds.width(), vpBounds.height(), g_nancy->_graphics->getInputPixelFormat());
@@ -186,6 +266,8 @@ void PachinkoPuzzle::init() {
 	loadMachineImage(_winMachine);
 	loadMachineImage(_loseMachine);
 
+	buildHoles();
+
 	NancySceneState.setNoHeldItem();
 
 	// In-progress marker: set on entry, cleared once a ball is caught.
@@ -249,63 +331,57 @@ void PachinkoPuzzle::spawnBall() {
 	Ball ball;
 	ball.speed = (double)(_velMax - _velMin) * launchParam + (double)_velMin;
 	ball.angle = wrapAngle(headingDeg * kDeg2Rad);
-	ball.x = (_launcherHotspot.left + _launcherHotspot.right) / 2.0;
-	ball.y = (_launcherBallDest.top + _launcherBallDest.bottom) / 2.0;
+	// Balls appear at the top-right entry chute and fall left/down through the pins.
+	ball.x = (_ballEntry.left + _ballEntry.right) / 2.0;
+	ball.y = (_ballEntry.top + _ballEntry.bottom) / 2.0;
 	ball.active = true;
 	_balls.push_back(ball);
 
 	playSoundBlock(_plinkSounds);
 }
 
-bool PachinkoPuzzle::collideBall(Ball &ball, double nx, double ny) const {
-	// Reflect off any pin or bumper rect the ball would enter. The axis of the smaller
-	// penetration decides which velocity component flips (an AABB response).
-	Common::Point p((int16)nx, (int16)ny);
-
-	const Common::Rect *hit = nullptr;
-	for (const Common::Rect &r : _pins) {
-		if (r.contains(p)) {
-			hit = &r;
-			break;
-		}
-	}
-	if (!hit) {
-		for (const ActionZone &z : _zones) {
-			// Walls (type 1/0x14) and bumpers (0x16) are solid; overlays are not.
-			if ((z.type == 0x01 || z.type == 0x14 || z.type == 0x16) && z.rect.contains(p)) {
-				hit = &z.rect;
-				if (!z._sound.names.empty()) {
-					const_cast<PachinkoPuzzle *>(this)->playSoundBlock(z._sound);
-				}
-				break;
-			}
+// Reflect the ball off any pin it now overlaps (pins collide as circles). Returns true if a
+// bounce happened.
+bool PachinkoPuzzle::collidePins(Ball &ball) const {
+	const double hitDist = kPinRadius + kBallRadius;
+	for (const Common::Rect &pin : _pins) {
+		double px = (pin.left + pin.right) / 2.0;
+		double py = (pin.top + pin.bottom) / 2.0;
+		double dx = ball.x - px;
+		double dy = ball.y - py;
+		double d2 = dx * dx + dy * dy;
+		if (d2 >= hitDist * hitDist || d2 < 1e-6) {
+			continue;
 		}
-	}
 
-	if (!hit) {
-		return false;
+		// Reflect the velocity about the surface normal (pin centre -> ball) and push the
+		// ball back out to the contact distance.
+		double d = sqrt(d2);
+		double nxn = dx / d;
+		double nyn = dy / d;
+		double vx = cos(ball.angle) * ball.speed;
+		double vy = -sin(ball.angle) * ball.speed;
+		double dot = vx * nxn + vy * nyn;
+		vx -= 2.0 * dot * nxn;
+		vy -= 2.0 * dot * nyn;
+
+		ball.x = px + nxn * hitDist;
+		ball.y = py + nyn * hitDist;
+		ball.speed = sqrt(vx * vx + vy * vy) * kRestitution;
+		ball.angle = wrapAngle(atan2(-vy, vx));
+		return true;
 	}
+	return false;
+}
 
-	double vx = cos(ball.angle) * ball.speed;
-	double vy = -sin(ball.angle) * ball.speed;
-
-	// Penetration depth on each axis; flip the axis with the shallower overlap.
-	double penLeft = nx - hit->left;
-	double penRight = hit->right - nx;
-	double penTop = ny - hit->top;
-	double penBottom = hit->bottom - ny;
-	double penX = MIN(penLeft, penRight);
-	double penY = MIN(penTop, penBottom);
-
-	if (penX < penY) {
-		vx = -vx;
-	} else {
-		vy = -vy;
+int PachinkoPuzzle::catchInHole(const Ball &ball) const {
+	Common::Point p((int16)ball.x, (int16)ball.y);
+	for (uint i = 0; i < _holes.size(); ++i) {
+		if (_holes[i].rect.contains(p)) {
+			return (int)i;
+		}
 	}
-
-	ball.speed = sqrt(vx * vx + vy * vy) * kRestitution;
-	ball.angle = wrapAngle(atan2(-vy, vx));
-	return true;
+	return -1;
 }
 
 void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
@@ -322,23 +398,36 @@ void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
 		ball.speed = 0.0;
 	}
 
-	// Move, reflecting on a collision (single sub-step is sufficient at this frame rate).
-	double dist = ball.speed * dt;
-	double nx = ball.x + cos(ball.angle) * dist;
-	double ny = ball.y - sin(ball.angle) * dist;
-	if (!collideBall(ball, nx, ny)) {
-		ball.x = nx;
-		ball.y = ny;
-	}
-}
+	// Advance in small sub-steps so the ball cannot tunnel between the pins, bouncing off a
+	// pin or a side wall on the way.
+	double subDt = dt / kPhysicsSubsteps;
+	for (int s = 0; s < kPhysicsSubsteps; ++s) {
+		ball.x += cos(ball.angle) * ball.speed * subDt;
+		ball.y -= sin(ball.angle) * ball.speed * subDt;
 
-bool PachinkoPuzzle::ballSettled(const Ball &ball, const Machine &m) const {
-	Common::Point c = m.catchPoint();
-	double dx = ball.x - c.x;
-	double dy = ball.y - c.y;
-	// A generous catch radius (the machine's mover end rect half-size).
-	int r = MAX(m.moverEnd.width(), m.moverEnd.height()) / 2 + 8;
-	return (dx * dx + dy * dy) <= (double)(r * r);
+		if (collidePins(ball)) {
+			continue;
+		}
+
+		// Side walls of the panel keep the ball in play; the ball enters from the right of
+		// the right wall, so that wall only reflects once the ball is inside.
+		double vx2 = cos(ball.angle) * ball.speed;
+		double vy2 = -sin(ball.angle) * ball.speed;
+		bool reflected = false;
+		if (ball.x < _panelBounds.left + kBallRadius && vx2 < 0.0) {
+			ball.x = _panelBounds.left + kBallRadius;
+			vx2 = -vx2;
+			reflected = true;
+		} else if (ball.x < _panelBounds.right && ball.x > _panelBounds.right - kBallRadius && vx2 > 0.0) {
+			ball.x = _panelBounds.right - kBallRadius;
+			vx2 = -vx2;
+			reflected = true;
+		}
+		if (reflected) {
+			ball.speed = sqrt(vx2 * vx2 + vy2 * vy2) * kRestitution;
+			ball.angle = wrapAngle(atan2(-vy2, vx2));
+		}
+	}
 }
 
 void PachinkoPuzzle::advanceMachine(Machine &m, uint32 now) {
@@ -355,7 +444,7 @@ void PachinkoPuzzle::advanceMachine(Machine &m, uint32 now) {
 void PachinkoPuzzle::redraw() {
 	_drawSurface.clear(g_nancy->_graphics->getTransColor());
 
-	// The two machines (their current animation frame), drawn at their mover start.
+	// The two climbers, drawn bottom-centred on their current position along the climb path.
 	const Machine *machines[2] = { &_winMachine, &_loseMachine };
 	for (int i = 0; i < 2; ++i) {
 		const Machine &m = *machines[i];
@@ -363,7 +452,8 @@ void PachinkoPuzzle::redraw() {
 			continue;
 		}
 		const Common::Rect &src = m.frames[m.frame % m.frames.size()];
-		Common::Point pos(m.moverStart.left, m.moverStart.top);
+		Common::Point anchor = climberAnchor(m);
+		Common::Point pos(anchor.x - src.width() / 2, anchor.y - src.height());
 		_drawSurface.blitFrom(m.image, src, pos);
 	}
 
@@ -380,9 +470,19 @@ void PachinkoPuzzle::redraw() {
 		}
 	}
 
-	// The result animation, when playing.
-	if (_pzState == kWaitResult && _resultMovie.isVideoLoaded()) {
-		_resultMovie.drawFrame(_drawSurface, Common::Point(0, 0));
+	// Holes lit by a recent catch.
+	if (!_litImage.empty()) {
+		uint32 now = g_nancy->getTotalPlayTime();
+		for (const Hole &hole : _holes) {
+			if (hole.litUntil > now && !hole.litSrc.isEmpty()) {
+				_drawSurface.blitFrom(_litImage, hole.litSrc, Common::Point(hole.litDest.left, hole.litDest.top));
+			}
+		}
+	}
+
+	// The result animation, when playing, drawn at the active machine's movie dest.
+	if (_pzState == kWaitResult && _resultMovie.isVideoLoaded() && _activeMachine) {
+		_resultMovie.drawFrame(_drawSurface, Common::Point(_activeMachine->movieDest.left, _activeMachine->movieDest.top));
 	}
 
 	_needsRedraw = true;
@@ -423,28 +523,35 @@ void PachinkoPuzzle::execute() {
 				}
 				stepBall(ball, dt);
 
-				// A caught ball decides the outcome and starts that machine's result.
-				if (ballSettled(ball, _winMachine)) {
-					ball.active = false;
-					_solved = true;
-					_activeMachine = &_winMachine;
-					_pzState = kPlayResult;
-					break;
-				}
-				if (ballSettled(ball, _loseMachine)) {
+				// A ball that drops in a hole is removed and climbs its machine a step.
+				int hole = catchInHole(ball);
+				if (hole >= 0) {
 					ball.active = false;
-					_solved = false;
-					_activeMachine = &_loseMachine;
-					_pzState = kPlayResult;
-					break;
+					playSoundBlock(_holes[hole].sound);
+					_holes[hole].litUntil = now + kHoleLitMs;
+					Machine *climber = _holes[hole].climber;
+					climber->climbSteps += climber->moverSpeed;
+					continue;
 				}
 
-				// A ball that leaves the board is discarded.
-				if (ball.y > _drawSurface.h + 64 || ball.x < -64 || ball.x > _drawSurface.w + 64) {
+				// A ball that reaches the floor or leaves the panel is discarded (a miss).
+				if (ball.y > _panelBounds.bottom || ball.x < _panelBounds.left - 32 ||
+						ball.x > _panelBounds.right + 64 || ball.y > _drawSurface.h + 32) {
 					ball.active = false;
 				}
 			}
 
+			// The first climber to reach the pot ends the game (Gold Digger = win, Yeti = lose).
+			if (_winMachine.climbSteps >= kClimbGoal) {
+				_solved = true;
+				_activeMachine = &_winMachine;
+				_pzState = kPlayResult;
+			} else if (_loseMachine.climbSteps >= kClimbGoal) {
+				_solved = false;
+				_activeMachine = &_loseMachine;
+				_pzState = kPlayResult;
+			}
+
 			redraw();
 			break;
 		}
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.h b/engines/nancy/action/puzzle/pachinkopuzzle.h
index 77f6afc876c..6b19c89e6e9 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.h
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.h
@@ -64,28 +64,26 @@ public:
 protected:
 	Common::String getRecordTypeName() const override { return "PachinkoPuzzle"; }
 
-	// One of the two "machine" sub-objects (Miner = win, Yeti = lose). Each is an
-	// animated sprite whose frames slide from a start point to an end (catch) point.
+	// One of the two mountain climbers (Miner/Gold Digger = win, Yeti = lose). Each is an
+	// animated sprite that climbs from its start anchor (bottom of the mountain) up to its
+	// end anchor (the pot at the top) as balls fall into its holes.
 	struct Machine {
 		Common::Path imageName;					// the ANIM_OVL sprite strip
 		int32 animRate = 0;						// frames per second
 		Common::Array<Common::Rect> frames;		// sprite-strip source rects
-		Common::Rect moverStart;				// slide path start rect (its centre is the anchor)
-		Common::Rect moverEnd;					// slide path end / catch rect
-		int32 moverSpeed = 0;
+		Common::Rect moverStart;				// climb-path bottom anchor (2x2 point rect)
+		Common::Rect moverEnd;					// climb-path top anchor (the pot)
+		int32 moverSpeed = 0;					// climb steps gained per ball caught
 		RandomSoundBlock winchSound;			// [snd1] the winch-up cue
 		RandomSoundBlock resultSound;			// [snd2] the win/lose voice cue (MinerWin*/PachinkoLose*)
 		RandomSoundBlock fastSound;				// [snd3] the fast-winch cue
 		Common::Path movieName;					// result animation (blob[0], "" == none)
+		Common::Rect movieDest;					// where the result animation is drawn
 
 		Graphics::ManagedSurface image;
 		uint frame = 0;							// current animation frame
 		uint32 nextFrameTime = 0;
-
-		Common::Point catchPoint() const {
-			return Common::Point((moverEnd.left + moverEnd.right) / 2,
-				(moverEnd.top + moverEnd.bottom) / 2);
-		}
+		int climbSteps = 0;						// accumulated climb (moverSpeed per catch)
 	};
 
 	// A single launched ball. Physics run in viewport space; the heading is stored as a
@@ -99,14 +97,27 @@ protected:
 		bool active = false;
 	};
 
+	// One of the four holes on the panel. A ball that drops in advances its climber and
+	// briefly lights the hole (a sprite from the "lit" overlay).
+	struct Hole {
+		Common::Rect rect;
+		Machine *climber = nullptr;
+		RandomSoundBlock sound;			// the bell cue (PinballBell_*)
+		Common::Rect litSrc;			// lit-hole sprite source (in _litImage)
+		Common::Rect litDest;			// where it is drawn
+		uint32 litUntil = 0;			// keep it lit until this time
+	};
+
 	void readMachine(Common::SeekableReadStream &stream, Machine &m);
 	void loadMachineImage(Machine &m);
+	void buildHoles();
+	Common::Point climberAnchor(const Machine &m) const;
 
 	void redraw();
 	void spawnBall();
 	void stepBall(Ball &ball, double dt);
-	bool collideBall(Ball &ball, double nx, double ny) const;
-	bool ballSettled(const Ball &ball, const Machine &m) const;
+	bool collidePins(Ball &ball) const;
+	int catchInHole(const Ball &ball) const;	// hole index the ball fell into, or -1
 	void advanceMachine(Machine &m, uint32 now);
 	SoundDescription playSoundBlock(const RandomSoundBlock &block);
 	void setDataCursor(uint16 cursorType) const;
@@ -115,11 +126,13 @@ protected:
 	Common::Path _imageName;				// 0x00 - board overlay (MUS_PachinkoPUZ02_OVL)
 
 	Common::Rect _ballSrc;					// ball sprite source in the overlay
+	Common::Rect _ballEntry;				// top-right entry chute (where balls appear)
 	int32 _velMin = 0;						// launch-speed floor
 	int32 _velMax = 0;						// launch-speed ceiling
 	int32 _spawnYMin = 0;					// launch-heading (deg) range
 	int32 _spawnYMax = 0;
 	int32 _launchVecLen = 0;				// decorative launch nub length
+	Common::Rect _panelBounds;				// pin-panel bounds (side walls + floor)
 	int16 _eventFlag = 0;					// "in progress" flag id
 	Common::Path _ballImageName;			// the ball sprite sheet
 	Common::Rect _launcherBallSrc;			// ball-in-launcher sprite src
@@ -135,6 +148,7 @@ protected:
 
 	Common::Array<Common::Rect> _pins;		// static pin collision rects
 	Common::Array<ActionZone> _zones;		// bumpers / walls / overlays (Nancy13 layout)
+	Common::Array<Hole> _holes;				// the four catch holes (built from _zones)
 
 	// The give-up / exit hotspot (the base trailer's 23-byte record).
 	Common::Rect _exitHotspot;
@@ -161,8 +175,10 @@ protected:
 	uint32 _resultTime = 0;
 	SoundDescription _resultSoundDesc;
 
+	Common::Path _litImageName;				// "lit" board overlay (hole highlights)
 	Graphics::ManagedSurface _image;		// board overlay
 	Graphics::ManagedSurface _ballImage;	// ball sprite sheet
+	Graphics::ManagedSurface _litImage;		// lit-hole sprites
 	MoviePlayer _resultMovie;
 };
 




More information about the Scummvm-git-logs mailing list