[Scummvm-git-logs] scummvm master -> 7dec9a56ab6f657ccb014399a7a08b62ce86521b

bluegr noreply at scummvm.org
Sun Aug 30 10:25:37 UTC 2026


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

Summary:
d3f59d446c NANCY: NANCY12: Fix the position of Nancy's cash indicator
34fe503989 NANCY: NANCY13: Fix setting the solve scene flag
0cd1152025 NANCY: NANCY13: Map more mouse cursors
a3eaf5c558 NANCY: NANCY13: Implement more cellphone functionality
f2b884e47f NANCY: NANCY9: Implement screen saving
7dec9a56ab NANCY: NANCY14: Implement new sound related ARs


Commit: d3f59d446cd267832030ddc9eb7d0fa97c635ae2
    https://github.com/scummvm/scummvm/commit/d3f59d446cd267832030ddc9eb7d0fa97c635ae2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-30T13:25:20+03:00

Commit Message:
NANCY: NANCY12: Fix the position of Nancy's cash indicator

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


diff --git a/engines/nancy/ui/taskbar.cpp b/engines/nancy/ui/taskbar.cpp
index c3129ecea9d..3fcd0a7a585 100644
--- a/engines/nancy/ui/taskbar.cpp
+++ b/engines/nancy/ui/taskbar.cpp
@@ -189,10 +189,12 @@ void Taskbar::drawMoney() {
 	Common::Rect dst = taskData->buttons[kTaskButtonCoinPurse].button.destRect;
 	dst.translate(-_screenPosition.left, -_screenPosition.top);
 
-	// Position matches the original: a small inset from the left, and a little
-	// below the button's vertical centre.
+	// A small inset from the left, and a little below the button's vertical
+	// center. That vertical coordinate is the bottom row the glyphs are aligned
+	// on, while drawString() takes the top of the line, so shift it up by the
+	// height of a line.
 	const int x = dst.left + 12;
-	const int y = dst.top + dst.height() / 2 + 10;
+	const int y = dst.top + dst.height() / 2 + 10 - font->getFontHeight() + 1;
 	font->drawString(&_drawSurface, text, x, y, dst.right - x, 0, Graphics::kTextAlignLeft);
 	_needsRedraw = true;
 }


Commit: 34fe5039891d14c9648d3bef0dbccbc78be1254f
    https://github.com/scummvm/scummvm/commit/34fe5039891d14c9648d3bef0dbccbc78be1254f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-30T13:25:21+03:00

Commit Message:
NANCY: NANCY13: Fix setting the solve scene flag

Now, the game progresses correctly to the win scene in the Pagoda
Pillar puzzle, inside the Caboose carriage

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


diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
index 850287b67a5..863d1e9fcd7 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
@@ -130,7 +130,6 @@ void RotatingLockPuzzle::readData(Common::SeekableReadStream &stream) {
 		_exitScene._flag.flag  = stream.readByte();
 
 		readRect(stream, _exitHotspot);
-		// 16 trailing bytes (cursor type + unused) at offset 0x317 are ignored.
 	} else {
 		_solveExitScene.readData(stream);
 		_solveSoundDelay = stream.readUint16LE();
@@ -174,7 +173,6 @@ void RotatingLockPuzzle::execute() {
 				}
 			}
 
-			NancySceneState.setEventFlag(_solveExitScene._flag);
 			_solveSoundPlayTime = g_nancy->getTotalPlayTime() + _solveSoundDelay * 1000;
 			_solveState = kPlaySound;
 			// fall through
@@ -198,10 +196,13 @@ void RotatingLockPuzzle::execute() {
 		g_nancy->_sound->stopSound(_clickSound);
 		g_nancy->_sound->stopSound(_solveSound);
 
+		// The solve event flag is set here, together with the scene change, and
+		// not when the solution is first detected: a record whose own dependency
+		// tests that flag would stop being evaluated before it could trigger.
 		if (_solveState == kNotSolved)
 			_exitScene.execute();
 		else
-			NancySceneState.changeScene(_solveExitScene._sceneChange);
+			_solveExitScene.execute();
 
 		finishExecution();
 	}


Commit: 0cd11520252a5d9b620b06cdfaafff31549b4a92
    https://github.com/scummvm/scummvm/commit/0cd11520252a5d9b620b06cdfaafff31549b4a92
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-30T13:25:21+03:00

Commit Message:
NANCY: NANCY13: Map more mouse cursors

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


diff --git a/engines/nancy/cursor.cpp b/engines/nancy/cursor.cpp
index d15bf7cfd31..b60aebca272 100644
--- a/engines/nancy/cursor.cpp
+++ b/engines/nancy/cursor.cpp
@@ -238,18 +238,19 @@ uint CursorManager::resolveNancy13CursorID(CursorType type, int16 itemID, bool s
 	case kMoveDown:             sysType = kNancy13MoveDown; break;
 	case kMoveLeft:             sysType = kNancy13MoveLeft; break;
 	case kMoveRight:            sysType = kNancy13MoveRight; break;
+	case kRotateRight:          sysType = kNancy13RotateRight; break;
+	case kRotateLeft:           sysType = kNancy13RotateLeft; break;
+	case kInvertedRotateRight:  sysType = kNancy13InvertedRotateRight; break;
+	case kInvertedRotateLeft:   sysType = kNancy13InvertedRotateLeft; break;
+	case kHotspotTalk:          sysType = kNancy13Talk; hotspot = true; break;
+	// Only puzzles use the types below, so they take the blue variants.
 	case kRotateCW:
-	case kRotateRight:
-	case kDialCW:
-	case kInvertedRotateRight:  sysType = kNancy13RotateCW; break;
+	case kDialCW:               sysType = kNancy13PuzzleRotateCW; break;
 	case kRotateCCW:
-	case kRotateLeft:
-	case kDialCCW:
-	case kInvertedRotateLeft:   sysType = kNancy13RotateCCW; break;
-	case kDragHand:
-	case kDropHand:             sysType = kNancy13DropHand; break;
+	case kDialCCW:              sysType = kNancy13PuzzleRotateCCW; break;
+	case kDragHand:             sysType = kNancy13PuzzleDragHand; break;
+	case kDropHand:             sysType = kNancy13PuzzleDropHand; break;
 	case kPuzzleArrow:          sysType = kNancy13PuzzleArrow; hotspot = true; break;
-	case kHotspotTalk:          sysType = kNancy13Normal; hotspot = true; break;	// TODO: talk sprite not yet identified in the sheet
 	default:                    sysType = kNancy13Normal; break;
 	}
 
diff --git a/engines/nancy/cursor.h b/engines/nancy/cursor.h
index 1cb44f99d30..e1aa240f8fd 100644
--- a/engines/nancy/cursor.h
+++ b/engines/nancy/cursor.h
@@ -110,19 +110,29 @@ public:
 		// pair at slots (type * 2, type * 2 + 1). These constants are Nancy13
 		// system TYPE indices (not slot indices); resolveNancy13CursorID turns a
 		// type into the idle or hotspot slot.
-		kNancy13Normal			= 0,	// Eyeglass (kHotspot is the same type's hotspot slot)
-		kNancy13MoveUp			= 6,	// Scene-change: look/move up
-		kNancy13MoveDown		= 7,	// Scene-change: look/move down
-		kNancy13MoveLeft		= 8,	// Scene-change: move left
-		kNancy13MoveRight		= 9,	// Scene-change: move right
-		kNancy13MoveBackward	= 10,	// Scene-change: move back / exit puzzle
-		kNancy13MoveForward		= 12,	// Scene-change: move forward
-		kNancy13Arrow			= 14,	// Frame / taskbar arrow
-		kNancy13Exit			= 19,	// Exit
-		kNancy13DropHand		= 24,	// Hand shown while carrying a puzzle piece
-		kNancy13RotateCCW		= 25,	// Dial / turn cursor
-		kNancy13RotateCW		= 26,	// Dial / turn cursor
-		kNancy13PuzzleArrow		= 29	// Generic clickable puzzle hotspot
+		// Types 25-34 are the blue in-puzzle variants of the plain ones.
+		kNancy13Normal				= 0,	// Eyeglass
+		kNancy13Talk				= 5,
+		kNancy13MoveUp				= 6,
+		kNancy13MoveDown			= 7,
+		kNancy13MoveLeft			= 8,
+		kNancy13MoveRight			= 9,
+		kNancy13MoveBackward		= 10,
+		kNancy13MoveForward			= 12,
+		kNancy13Arrow				= 14,
+		kNancy13RotateRight			= 17,
+		kNancy13RotateLeft			= 18,
+		kNancy13Exit				= 19,
+		kNancy13InvertedRotateRight	= 20,
+		kNancy13InvertedRotateLeft	= 21,
+		// Empty. Selected while the cell phone's camera viewfinder is up, so
+		// only the viewfinder is seen.
+		kNancy13Blank				= 24,
+		kNancy13PuzzleRotateCW		= 25,
+		kNancy13PuzzleRotateCCW		= 26,
+		kNancy13PuzzleDragHand		= 28,
+		kNancy13PuzzleDropHand		= 29,
+		kNancy13PuzzleArrow			= 34
 	};
 
 	CursorManager();


Commit: a3eaf5c558c896841b9ae6fbb66e4fe85eee9fe7
    https://github.com/scummvm/scummvm/commit/a3eaf5c558c896841b9ae6fbb66e4fe85eee9fe7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-30T13:25:22+03:00

Commit Message:
NANCY: NANCY13: Implement more cellphone functionality

- Split the cellphone contracts fields
- Added enums for the dial pad keys
- Set flags when capturing or sending photos from the camera
- Use the correct camera viewfinder image
- Fix image gallery up/down button placements and functionality
- Save phone photos correctly - this invalidates Nancy13 saves that
  contained photos, but this feature didn't work correctly up to now,
  so that's not an issue

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 036e130bb69..43ab948197a 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -359,14 +359,7 @@ void SetCellPhoneBatteryAndSignal::execute() {
 }
 
 void ChangeCellPhoneInfo::readData(Common::SeekableReadStream &stream) {
-	stream.read(_contact.unknownPrefix, sizeof(_contact.unknownPrefix));
-
-	char nameBuf[21];
-	stream.read(nameBuf, 20);
-	nameBuf[20] = '\0';
-	_contact.name = nameBuf;
-
-	stream.read(_contact.unknownSuffix, sizeof(_contact.unknownSuffix));
+	readContact(stream, _contact);
 }
 
 void ChangeCellPhoneInfo::execute() {
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 6fd618fd24b..d8175c40c1c 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -964,6 +964,22 @@ UIBW::UIBW(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	}
 }
 
+// Shared by the UICL chunk and ChangeCellPhoneInfo (AR 130).
+void readContact(Common::SeekableReadStream &stream, UICL::Contact &c) {
+	c.visibility = stream.readUint16LE();
+	stream.read(c.dialPattern, sizeof(c.dialPattern));
+
+	char nameBuf[21];
+	stream.read(nameBuf, 20);
+	nameBuf[20] = 0;
+	c.name = nameBuf;
+
+	c.sceneID = stream.readUint16LE();
+	c.frameID = stream.readUint16LE();
+	c.flag.label = stream.readSint16LE();
+	c.flag.flag = (byte)stream.readUint16LE();
+}
+
 UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	readUIPopupHeader(*chunkStream, header);
 
@@ -1179,30 +1195,20 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	const uint16 entries = MIN<uint16>(contactCount, (uint16)maxEntries);
 	contacts.resize(entries);
 	for (uint i = 0; i < entries; ++i) {
-		Contact &c = contacts[i];
-
-		chunkStream->read(c.unknownPrefix, sizeof(c.unknownPrefix));
-
-		char nameBuf[21];
-		chunkStream->read(nameBuf, 20);
-		nameBuf[20] = '\0';
-		c.name = nameBuf;
-
-		chunkStream->read(c.unknownSuffix, sizeof(c.unknownSuffix));
+		readContact(*chunkStream, contacts[i]);
 	}
 
 	if (isNancy13) {
-		// 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) {
-			PictureRecord &p = pictures[i];
-			p.id = chunkStream->readUint16LE();
-			readRect(*chunkStream, p.rect);
-			chunkStream->read(p.unknown, sizeof(p.unknown));
+		// Trailing table of photographable subjects, added in Nancy 13.
+		const uint16 subjectCount = chunkStream->readUint16LE();
+		cameraSubjects.resize(subjectCount);
+		for (uint i = 0; i < subjectCount; ++i) {
+			CameraSubject &s = cameraSubjects[i];
+			s.sceneID = chunkStream->readSint16LE();
+			readRect(*chunkStream, s.coords);
+			s.captureFlag = chunkStream->readSint16LE();
+			s.sendFlag = chunkStream->readSint16LE();
+			s.recipientIndex = chunkStream->readSint16LE();
 		}
 	}
 }
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 5dfd55342bd..1eec70b1adf 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -598,18 +598,25 @@ struct UICL : public EngineData {
 		Common::Rect destRect;
 	};
 
+	// One phonebook entry, 41 bytes. A person can own several, one per number
+	// they are reachable on; `visibility` picks which is listed.
 	struct Contact {
-		// Prefix layout:
-		//   [0..1]   visibility flag (10 = always, 11 = never, else =
-		//            scene event-flag index; contact hidden until set).
-		//   [2..8]   7-digit dial pattern (slot indices 0..9).
-		//   [9]      '\n' terminator.
-		//   [10..12] unused.
-		byte unknownPrefix[13];
-		Common::String name;      // 20-byte null-terminated
-		// Suffix layout: [0..1] sceneID, [2..3] frameID,
-		// [4..5] event-flag label, [6] event-flag value, [7] unused.
-		byte unknownSuffix[8];
+		static const uint kDialPatternLength = 11;
+
+		enum {
+			kAlwaysListed = 10,
+			kNeverListed = 11
+		};
+
+		// kAlwaysListed, kNeverListed, or the event flag that reveals the entry.
+		uint16 visibility = kNeverListed;
+		// Dial-pad slot indices (0..9), newline-terminated unless all 11 are used.
+		byte dialPattern[kDialPatternLength] = {};
+		Common::String name;                     // 20-byte field
+		// Calling the contact changes to this scene and fires the flag.
+		uint16 sceneID = kNoScene;
+		uint16 frameID = 0;
+		FlagDescription flag;
 	};
 
 	struct SrcDestRectPair {
@@ -618,6 +625,17 @@ struct UICL : public EngineData {
 	};
 
 	static const uint kNumDialPadSlots = 15;
+
+	// The last three are soft keys. Nancy 13 relabels them per screen (Cam or
+	// Dial, Del or Yes, Send or No); the ribbon label says which.
+	enum DialPadKey {
+		kDialKeyStar = 10,
+		kDialKeyHash = 11,
+		kDialKeyTalk = 12,
+		kDialKeyMenu = 13,
+		kDialKeyDirectory = 14
+	};
+
 	// 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;
@@ -708,13 +726,20 @@ struct UICL : public EngineData {
 	// Nancy 13 added a camera / pictures sub-UI to the cell phone, which
 	// reorganized the chunk body. The fields below are only populated for
 	// Nancy 13 and later.
-	struct PictureRecord {
-		uint16 id = 0;
-		Common::Rect rect;
-		byte unknown[6] = {};
+
+	// A photographable subject. Framing coords in sceneID sets captureFlag;
+	// sending that snapshot to contact recipientIndex sets sendFlag.
+	struct CameraSubject {
+		int16 sceneID = -1;
+		Common::Rect coords;
+		int16 captureFlag = -1;
+		int16 sendFlag = -1;
+		int16 recipientIndex = -1;
 	};
 
-	Common::Rect cameraViewSrcRect;           // camera viewfinder SRC on the overlay
+	// The LCD screen area: cleared through, and bounds the directory list.
+	// Earlier games keep it in screenOutSrcRect.
+	Common::Rect cameraViewSrcRect;
 	int32 cameraTextX = 0;
 	int32 cameraTextY = 0;
 	Common::Path cameraViewImageName;         // "UI_CellCamView_OVL"
@@ -723,9 +748,12 @@ struct UICL : public EngineData {
 	Common::Rect noPictureScreenRect;         // "no pictures" placeholder
 	Common::Path helpTextKey2;                // second CVTX key (phone-use help)
 	byte screenColors[9] = {};                // 3 RGB colors for the phone screen
-	Common::Array<PictureRecord> pictures;    // captured-picture slots (up to 50)
+	Common::Array<CameraSubject> cameraSubjects;  // up to 50
 };
 
+// Shared by the UICL chunk and ChangeCellPhoneInfo (AR 130).
+void readContact(Common::SeekableReadStream &stream, UICL::Contact &c);
+
 // Camera UI, added in Nancy 14. This is a standalone camera. While it is active,
 // the cursor becomes a large viewfinder rectangle that the player aims at the
 // scene; clicking photographs every subject whose region falls inside the framed
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 747dbf39f5d..43f9006b479 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -339,7 +339,8 @@ void CellPhoneData::synchronize(Common::Serializer &ser) {
 	char nameBuf[21];
 	for (uint16 i = 0; i < numContacts; ++i) {
 		UICL::Contact &c = contacts[i];
-		ser.syncBytes(c.unknownPrefix, sizeof(c.unknownPrefix));
+		ser.syncAsUint16LE(c.visibility);
+		ser.syncBytes(c.dialPattern, sizeof(c.dialPattern));
 
 		if (ser.isSaving()) {
 			memset(nameBuf, 0, sizeof(nameBuf));
@@ -351,7 +352,15 @@ void CellPhoneData::synchronize(Common::Serializer &ser) {
 			c.name = nameBuf;
 		}
 
-		ser.syncBytes(c.unknownSuffix, sizeof(c.unknownSuffix));
+		ser.syncAsUint16LE(c.sceneID);
+		ser.syncAsUint16LE(c.frameID);
+		ser.syncAsSint16LE(c.flag.label);
+
+		uint16 flagValue = c.flag.flag;
+		ser.syncAsUint16LE(flagValue);
+		if (ser.isLoading()) {
+			c.flag.flag = (byte)flagValue;
+		}
 	}
 
 	syncLinkArray(ser, emailMessages);
@@ -394,6 +403,15 @@ void CellPhonePictureData::synchronize(Common::Serializer &ser) {
 		if (numBytes) {
 			ser.syncBytes(p.pixels.data(), numBytes);
 		}
+
+		uint16 numSubjects = (uint16)p.subjects.size();
+		ser.syncAsUint16LE(numSubjects);
+		if (ser.isLoading()) {
+			p.subjects.resize(numSubjects);
+		}
+		for (uint16 j = 0; j < numSubjects; ++j) {
+			ser.syncAsSint16LE(p.subjects[j]);
+		}
 	}
 }
 
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 22de11e9043..b3cb0a899e7 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -263,6 +263,9 @@ struct CapturedPicture {
 	uint16 height = 0;
 	Common::Array<byte> pixels;   // width * height * 4, BGRA32
 	bool sent = false;            // true once the player has "sent" it
+
+	// Indices into UICL::cameraSubjects that were inside the viewfinder.
+	Common::Array<int16> subjects;
 };
 
 // Nancy 13 camera snapshots. Kept in its own lazily-created PuzzleData chunk so
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 57bfe8101f6..eed69c43cf1 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -74,7 +74,7 @@ enum {
 	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
+	kN13SubListUp   = 6,   // zoomed list scroll up
 	kN13SubListDown = 7,
 	kN13SubBackFull = 8    // Back at the bottom of a zoomed (full-screen) list
 };
@@ -155,6 +155,12 @@ void CellPhonePopup::init() {
 										_spritesImage);
 	}
 
+	// The viewfinder is blitted over the scene while aiming; the pointer is
+	// blanked meanwhile.
+	if (!_uiclData->cameraViewImageName.empty()) {
+		g_nancy->_resource->loadImage(_uiclData->cameraViewImageName, _cameraViewImage);
+	}
+
 	Common::Rect popupRect = _uiclData->header.normalDestRect;
 	if (_uiclData->header.overlayInGameFrame) {
 		const VIEW *view = GetEngineData(VIEW);
@@ -269,12 +275,11 @@ void CellPhonePopup::addSearchLink(int16 mode, const SearchLink &link) {
 }
 
 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.
+	// An entry with this dial pattern is overwritten; otherwise append.
 	bool replaced = false;
 	for (uint i = 0; i < _contacts.size(); ++i) {
-		if (memcmp(_contacts[i].unknownPrefix + 2,
-					c.unknownPrefix + 2, 11) == 0) {
+		if (memcmp(_contacts[i].dialPattern, c.dialPattern,
+					sizeof(c.dialPattern)) == 0) {
 			_contacts[i] = c;
 			replaced = true;
 			break;
@@ -665,11 +670,12 @@ void CellPhonePopup::drawScreenContent() {
 		drawHeading(_uiclData->dialHilite);
 		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);
+			// Cam becomes Dial here; while picking a photo recipient the third
+			// button also becomes Send, and dialling stays available.
+			drawRibbonLabelAt(_uiclData->dialingLabel.srcRect, _uiclData->dialLabel.destRect);
+			if (_sendingPicture) {
+				drawRibbonLabel(_uiclData->sendLabel);
+			}
 		}
 		break;
 
@@ -754,11 +760,11 @@ void CellPhonePopup::drawScreenContent() {
 		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) {
+		// Each paging arrow only appears while there is a photo that way.
+		if (canPageToPreviousPicture()) {
 			drawScrollArrow(scrollUpButton(), _scrollUpHovered);
+		}
+		if (canPageToNextPicture()) {
 			drawScrollArrow(scrollDownButton(), _scrollDownHovered);
 		}
 		break;
@@ -1265,14 +1271,14 @@ void CellPhonePopup::drawContentView() {
 }
 
 void CellPhonePopup::drawDirectoryList() {
-	// Contacts have one record per dial-pattern variant; collapse by name.
+	// Filtered by the visibility flag alone, in the list's own (alphabetical)
+	// order. Camera subjects address contacts by row, so nothing may collapse.
 	const Font *font = g_nancy->_graphics->getFont(_uiclData->fontId2);
 	if (!font) {
 		return;
 	}
 
 	const uint maxRows = maxDirectoryRows();
-	Common::Array<Common::String> seenNames;
 	uint visibleRow = 0;
 	uint visited = 0;
 
@@ -1280,21 +1286,9 @@ void CellPhonePopup::drawDirectoryList() {
 			contactIdx < _contacts.size() && visibleRow < maxRows;
 			++contactIdx) {
 		const UICL::Contact &c = _contacts[contactIdx];
-		if (c.name.empty() || !isContactVisible(c)) {
-			continue;
-		}
-
-		bool duplicate = false;
-		for (uint s = 0; s < seenNames.size(); ++s) {
-			if (seenNames[s].equalsIgnoreCase(c.name)) {
-				duplicate = true;
-				break;
-			}
-		}
-		if (duplicate) {
+		if (!isContactVisible(c)) {
 			continue;
 		}
-		seenNames.push_back(c.name);
 
 		if (visited < _directoryScroll) {
 			++visited;
@@ -1424,14 +1418,65 @@ void CellPhonePopup::captureViewport(const Common::Rect &screenRegion) {
 	conv->free();
 	delete conv;
 
+	// Every subject wholly inside the framed area is captured.
+	const uint16 sceneID = NancySceneState.getSceneInfo().sceneID;
+	for (uint i = 0; i < _uiclData->cameraSubjects.size(); ++i) {
+		const UICL::CameraSubject &subject = _uiclData->cameraSubjects[i];
+		if (subject.sceneID != (int16)sceneID || !grab.contains(subject.coords)) {
+			continue;
+		}
+
+		pic.subjects.push_back((int16)i);
+		if (subject.captureFlag != kEvNoEvent) {
+			NancySceneState.setEventFlag(subject.captureFlag, g_nancy->_true);
+		}
+	}
+
 	pd->pictures.push_back(pic);
 	_pictureIndex = (int)pd->pictures.size() - 1;
 }
 
+void CellPhonePopup::sendCurrentPicture(uint listRow) {
+	_sendingPicture = false;
+
+	CellPhonePictureData *pd = pictureData();
+	if (pd && _pictureIndex >= 0 && _pictureIndex < (int)pd->pictures.size()) {
+		CapturedPicture &pic = pd->pictures[_pictureIndex];
+		pic.sent = true;
+
+		// A subject's recipient is a row in the directory as displayed, not an
+		// index into the full contact table. Wrong recipient, nothing happens.
+		for (uint i = 0; i < pic.subjects.size(); ++i) {
+			const int16 subjectID = pic.subjects[i];
+			if (subjectID < 0 || subjectID >= (int16)_uiclData->cameraSubjects.size()) {
+				continue;
+			}
+
+			const UICL::CameraSubject &subject = _uiclData->cameraSubjects[subjectID];
+			if (subject.recipientIndex == (int16)listRow && subject.sendFlag != kEvNoEvent) {
+				NancySceneState.setEventFlag(subject.sendFlag, g_nancy->_true);
+			}
+		}
+	}
+
+	showMessageScreen(kN13MsgPictureSent, kPictureView);
+}
+
+bool CellPhonePopup::canPageToPreviousPicture() const {
+	return _screenState == kPictureView && _pictureIndex > 0;
+}
+
+bool CellPhonePopup::canPageToNextPicture() const {
+	const CellPhonePictureData *pd = pictureData();
+	return _screenState == kPictureView && pd &&
+			_pictureIndex + 1 < (int)pd->pictures.size();
+}
+
 Common::Rect CellPhonePopup::framingScreenRect() const {
+	// Sized like a stored picture, centred on the cursor, clamped to the viewport.
 	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 w = MIN<int>(_uiclData->pictureDisplayRect.width(), vp.width());
+	const int h = MIN<int>(_uiclData->pictureDisplayRect.height(), 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);
@@ -1444,8 +1489,7 @@ void CellPhonePopup::enterCameraFraming() {
 	_inCameraFraming = true;
 	_savedPhoneRect = _screenPosition;
 
-	// Grow the popup to cover the viewport so the framing box can be drawn
-	// anywhere over the live scene.
+	// Cover the viewport so the popup keeps input while the player aims.
 	const Common::Rect vp = NancySceneState.getViewport().getScreenPosition();
 	moveTo(vp);
 	_drawSurface.create(vp.width(), vp.height(), g_nancy->_graphics->getScreenPixelFormat());
@@ -1466,22 +1510,15 @@ void CellPhonePopup::exitCameraFraming() {
 }
 
 void CellPhonePopup::drawCameraFraming() {
-	const uint32 trans = g_nancy->_graphics->getTransColor();
-	_drawSurface.clear(trans);
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
 
-	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);
+	if (_cameraViewImage.w && _cameraViewImage.h) {
+		Common::Rect box = framingScreenRect();
+		box.translate(-_screenPosition.left, -_screenPosition.top);
+		_drawSurface.blitFrom(_cameraViewImage,
+								Common::Rect(_cameraViewImage.w, _cameraViewImage.h), box);
 	}
+
 	_needsRedraw = true;
 }
 
@@ -1594,11 +1631,10 @@ bool CellPhonePopup::isBrowserArticle() const {
 }
 
 const UICL::ThreeRectWidget &CellPhonePopup::scrollUpButton() const {
-	// 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();
+	// Directory, help and the picture view use the small-LCD pair
+	// (subButtons[1]/[2]); zoomed lists use [6]/[7], or [5]/[6] before Nancy 13.
+	const bool smallLcd = _screenState == kDirectory || _screenState == kPictureView ||
+							isHelpContentView();
 	if (g_nancy->getGameType() >= kGameTypeNancy13) {
 		return smallLcd ? _uiclData->subButtons[kN13SubDirUp]
 						: _uiclData->subButtons[kN13SubListUp];
@@ -1607,7 +1643,8 @@ const UICL::ThreeRectWidget &CellPhonePopup::scrollUpButton() const {
 }
 
 const UICL::ThreeRectWidget &CellPhonePopup::scrollDownButton() const {
-	const bool smallLcd = _screenState == kDirectory || isHelpContentView();
+	const bool smallLcd = _screenState == kDirectory || _screenState == kPictureView ||
+							isHelpContentView();
 	if (g_nancy->getGameType() >= kGameTypeNancy13) {
 		return smallLcd ? _uiclData->subButtons[kN13SubDirDown]
 						: _uiclData->subButtons[kN13SubListDown];
@@ -1652,10 +1689,13 @@ void CellPhonePopup::drawDirectoryArrows() {
 	if (selRow >= maxDirectoryRows()) {
 		return;
 	}
-	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));
+
+	// dirArrowSrc is the sprite, dirCursorSrc the box it scales into, stepped
+	// down one row pitch per selected row.
+	Common::Rect dest = cursor;
+	dest.translate(-_screenPosition.left,
+					-_screenPosition.top + (int)selRow * rowPitch());
+	_drawSurface.blitFrom(_spritesImage, arrowSrc, dest);
 }
 
 // --------------------------------------------------------------------
@@ -1699,7 +1739,7 @@ void CellPhonePopup::enterScreenState(ScreenState newState) {
 bool CellPhonePopup::isDialKeyActive(uint slot) const {
 	if (_noSignal && (_screenState == kWelcome || _screenState == kDialing ||
 			_screenState == kOnlineHub)) {
-		return slot == 13;
+		return slot == UICL::kDialKeyMenu;
 	}
 	return true;
 }
@@ -1793,7 +1833,6 @@ int CellPhonePopup::findContactByDialBuffer() const {
 
 	// Dial pattern lives in prefix[2..], terminated by '\n'.
 	const uint dialLen = _dialedNumber.size();
-	const uint kDialOffset = 2;
 	for (uint i = 0; i < _contacts.size(); ++i) {
 		const UICL::Contact &c = _contacts[i];
 		if (!isContactVisible(c)) {
@@ -1802,14 +1841,13 @@ int CellPhonePopup::findContactByDialBuffer() const {
 		bool match = true;
 		for (uint b = 0; b < dialLen; ++b) {
 			const byte slotIdx = (byte)(_dialedNumber[b] - '0');
-			if (kDialOffset + b >= sizeof(c.unknownPrefix) ||
-					slotIdx != c.unknownPrefix[kDialOffset + b]) {
+			if (b >= sizeof(c.dialPattern) || slotIdx != c.dialPattern[b]) {
 				match = false;
 				break;
 			}
 		}
-		if (match && kDialOffset + dialLen < sizeof(c.unknownPrefix) &&
-				c.unknownPrefix[kDialOffset + dialLen] == '\n') {
+		if (match && dialLen < sizeof(c.dialPattern) &&
+				c.dialPattern[dialLen] == '\n') {
 			return (int)i;
 		}
 	}
@@ -1823,25 +1861,20 @@ void CellPhonePopup::triggerContactCallSceneChange(uint contactIndex) {
 
 	const UICL::Contact &c = _contacts[contactIndex];
 
-	const uint16 sceneID = (uint16)c.unknownSuffix[0] | ((uint16)c.unknownSuffix[1] << 8);
-	if (sceneID == kNoScene) {
+	if (c.sceneID == kNoScene) {
 		return;
 	}
-	const uint16 frameID = (uint16)c.unknownSuffix[2] | ((uint16)c.unknownSuffix[3] << 8);
-	const int16 eventFlagLabel = (int16)((uint16)c.unknownSuffix[4] |
-											((uint16)c.unknownSuffix[5] << 8));
-	const byte eventFlagValue = c.unknownSuffix[6];
 
 	SceneChangeDescription scene;
-	scene.sceneID = sceneID;
-	scene.frameID = frameID;
+	scene.sceneID = c.sceneID;
+	scene.frameID = c.frameID;
 	scene.verticalOffset = 0;
 	// The destination scene's sound carries the conversation audio.
 	scene.continueSceneSound = kLoadSceneSound;
 
-	if (eventFlagLabel != -1) {
-		NancySceneState.setEventFlag(eventFlagLabel,
-										eventFlagValue ? g_nancy->_true : g_nancy->_false);
+	if (c.flag.label != kEvNoEvent) {
+		NancySceneState.setEventFlag(c.flag.label,
+										c.flag.flag ? g_nancy->_true : g_nancy->_false);
 	}
 
 	// Save the pre-call scene on the popup so AR 128 can return there
@@ -1897,6 +1930,13 @@ int CellPhonePopup::rowTopScreen() const {
 	return _uiclData->welcomeScreen.destRect.top + 22;
 }
 
+const Common::Rect &CellPhonePopup::lcdListBounds() const {
+	// The LCD screen area; Nancy 13 moved it into the camera block.
+	return _uiclData->cameraViewSrcRect.isEmpty()
+		? _uiclData->screenOutSrcRect
+		: _uiclData->cameraViewSrcRect;
+}
+
 uint CellPhonePopup::maxDirectoryRows() const {
 	const int pitch = rowPitch();
 	if (pitch <= 0) {
@@ -1904,7 +1944,7 @@ uint CellPhonePopup::maxDirectoryRows() const {
 	}
 	const int yLimit = (isLinkListMode() && !_uiclData->emailListContainer.isEmpty())
 		? _uiclData->emailListContainer.bottom
-		: _uiclData->welcomeScreen.destRect.bottom;
+		: lcdListBounds().bottom;
 	int y = rowTopScreen();
 	uint count = 0;
 	while (y + pitch < yLimit) {
@@ -1916,32 +1956,24 @@ uint CellPhonePopup::maxDirectoryRows() const {
 
 Common::Rect CellPhonePopup::directoryRowRect(uint visibleIndex) const {
 	const Common::Rect &cursor = _uiclData->dirCursorSrc;
-	const Common::Rect &ws = _uiclData->welcomeScreen.destRect;
 	const int pitch = rowPitch();
 
-	// The web / email lists render under the zoomed (keypad-hidden) chrome,
-	// where the LCD extends into the wider emailListContainer. Use that as
-	// the right bound so long entries aren't clipped to the narrow
-	// keypad-mode screen; the directory list keeps the small LCD.
-	const Common::Rect &lcd =
-		(isZoomedChromeState() && !_uiclData->emailListContainer.isEmpty())
-			? _uiclData->emailListContainer
-			: ws;
-
-	// Row text spans from just right of the arrow cursor to a margin
-	// inside the LCD's right edge.
 	int xLeftScreen, xRightScreen;
-	if (_screenState == kWebList) {
-		// Search list: a plain left-aligned list — no arrow/icon column.
-		xLeftScreen  = lcd.left + 8;
-		xRightScreen = lcd.right - 8;
+	if (isLinkListMode() && !_uiclData->emailListContainer.isEmpty()) {
+		// Under the zoomed chrome the LCD widens into emailListContainer; both
+		// lists span it, indented past the envelope-icon column.
+		const Common::Rect &container = _uiclData->emailListContainer;
+		xLeftScreen  = container.left + 5;
+		xRightScreen = container.right;
 	} else if (!cursor.isEmpty()) {
+		// Indented past the selection arrow, short of the LCD's right edge.
 		xLeftScreen  = cursor.right + 5;
-		xRightScreen = lcd.right - 30;
+		xRightScreen = lcdListBounds().right - 30;
 	} else {
+		const Common::Rect &ws = _uiclData->welcomeScreen.destRect;
 		const Common::Rect &arrow = _uiclData->dirArrowSrc;
 		xLeftScreen  = ws.left + arrow.width() + 4;
-		xRightScreen = lcd.right - 2;
+		xRightScreen = ws.right - 2;
 	}
 
 	const int yTopScreen = rowTopScreen() + (int)visibleIndex * pitch;
@@ -1953,14 +1985,13 @@ Common::Rect CellPhonePopup::directoryRowRect(uint visibleIndex) const {
 }
 
 bool CellPhonePopup::isContactVisible(const UICL::Contact &c) const {
-	const uint16 flag = (uint16)c.unknownPrefix[0] | ((uint16)c.unknownPrefix[1] << 8);
-	if (flag == 10) {
+	if (c.visibility == UICL::Contact::kAlwaysListed) {
 		return true;
 	}
-	if (flag == 11) {
+	if (c.visibility == UICL::Contact::kNeverListed) {
 		return false;
 	}
-	return NancySceneState.getEventFlag((int16)flag, g_nancy->_true);
+	return NancySceneState.getEventFlag((int16)c.visibility, g_nancy->_true);
 }
 
 Common::Rect CellPhonePopup::hubEmailRect() const {
@@ -2003,25 +2034,12 @@ Common::Rect CellPhonePopup::backLabelHitRect() const {
 }
 
 int CellPhonePopup::contactIndexForVisibleRow(uint visibleRow) const {
-	Common::Array<Common::String> seenNames;
 	uint visited = 0;
 	uint visibleSoFar = 0;
 	for (uint i = 0; i < _contacts.size(); ++i) {
-		const UICL::Contact &c = _contacts[i];
-		if (c.name.empty() || !isContactVisible(c)) {
-			continue;
-		}
-		bool duplicate = false;
-		for (uint s = 0; s < seenNames.size(); ++s) {
-			if (seenNames[s].equalsIgnoreCase(c.name)) {
-				duplicate = true;
-				break;
-			}
-		}
-		if (duplicate) {
+		if (!isContactVisible(_contacts[i])) {
 			continue;
 		}
-		seenNames.push_back(c.name);
 		if (visited < _directoryScroll) {
 			++visited;
 			continue;
@@ -2038,7 +2056,7 @@ int CellPhonePopup::contactIndexForVisibleRow(uint visibleRow) const {
 uint CellPhonePopup::currentListEntryCount() const {
 	switch (_screenState) {
 	case kDirectory:
-		return deduplicatedContactCount();
+		return visibleContactCount();
 	case kWebList:
 	case kEmailList:
 		return listVisibleIndices().size();
@@ -2105,8 +2123,8 @@ void CellPhonePopup::startCallToContact(uint contactIndex) {
 
 	// Rebuild _dialedNumber so the call flow's lookup matches.
 	_dialedNumber.clear();
-	for (uint b = 2; b < sizeof(c.unknownPrefix); ++b) {
-		const byte v = c.unknownPrefix[b];
+	for (uint b = 0; b < sizeof(c.dialPattern); ++b) {
+		const byte v = c.dialPattern[b];
 		if (v == '\n') {
 			break;
 		}
@@ -2122,25 +2140,14 @@ void CellPhonePopup::startCallToContact(uint contactIndex) {
 	enterScreenState(kPlaceCall);
 }
 
-uint CellPhonePopup::deduplicatedContactCount() const {
-	Common::Array<Common::String> seen;
+uint CellPhonePopup::visibleContactCount() const {
+	uint count = 0;
 	for (uint i = 0; i < _contacts.size(); ++i) {
-		const UICL::Contact &c = _contacts[i];
-		if (c.name.empty() || !isContactVisible(c)) {
-			continue;
-		}
-		bool dup = false;
-		for (uint s = 0; s < seen.size(); ++s) {
-			if (seen[s].equalsIgnoreCase(c.name)) {
-				dup = true;
-				break;
-			}
-		}
-		if (!dup) {
-			seen.push_back(c.name);
+		if (isContactVisible(_contacts[i])) {
+			++count;
 		}
 	}
-	return seen.size();
+	return count;
 }
 
 // --------------------------------------------------------------------
@@ -2159,6 +2166,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 	// 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) {
+		// The pointer itself is blanked while the viewfinder is up.
+		g_nancy->_cursor->setCursorType(CursorManager::kNancy13Blank, true, false);
 		if (input.mousePos != _framingMouse) {
 			_framingMouse = input.mousePos;
 			drawScreenContent();
@@ -2251,11 +2260,10 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 	// 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 == kPictureView;
-	const bool overUp = arrowsActive &&
+								_screenState == kContentView;
+	const bool overUp = (arrowsActive || canPageToPreviousPicture()) &&
 			scrollUpButton().destRect.contains(chunkMouse);
-	const bool overDown = arrowsActive && !overUp &&
+	const bool overDown = (arrowsActive || canPageToNextPicture()) && !overUp &&
 			scrollDownButton().destRect.contains(chunkMouse);
 	if (overUp != _scrollUpHovered || overDown != _scrollDownHovered) {
 		_scrollUpHovered = overUp;
@@ -2308,11 +2316,10 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		drawScreenContent();
 	}
 
-	// Help "?" button: opens the help page in the content view. Hidden
-	// (and unclickable) on sub-screens that already show their own heading.
-	if (!isSubScreenState() && !_noSignal &&
+	// Only on the welcome / dialing screen, which also keeps it off the picture
+	// view's paging arrows.
+	if (helpVisible &&
 			!_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) {
@@ -2368,6 +2375,7 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 				_directoryScroll = 0;
 				_directorySelection = 0;
 				_dialedNumber.clear();
+				_sendingPicture = false;
 				enterScreenState(kWelcome);
 				input.eatMouseInput();
 				return;
@@ -2389,8 +2397,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 				}
 			}
 		}
-		// Fall through so slot 14 can toggle the mode off and slots 0..9
-		// can override the directory by starting a fresh dial.
+		// Fall through: the Directory key toggles the mode off, digits start a
+		// fresh dial.
 	}
 
 	// Online hub: two labels — Email and Web — plus the Back hotspot.
@@ -2458,11 +2466,11 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		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;
+		Common::Rect camR = _uiclData->dialPadSlots[UICL::kDialKeyTalk].destRect;
+		Common::Rect delR = _uiclData->dialPadSlots[UICL::kDialKeyMenu].destRect;
+		Common::Rect sendR = _uiclData->dialPadSlots[UICL::kDialKeyDirectory].destRect;
+		Common::Rect upR = _uiclData->subButtons[kN13SubDirUp].destRect;
+		Common::Rect downR = _uiclData->subButtons[kN13SubDirDown].destRect;
 		const Common::Point origin(_screenPosition.left, _screenPosition.top);
 		camR.translate(-origin.x, -origin.y);
 		delR.translate(-origin.x, -origin.y);
@@ -2471,17 +2479,14 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		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) {
+		// Back's pressed sprite and the arrows' hover state are handled above.
+		const bool overBackButton = !backHit.isEmpty() && backHit.contains(popupMouse);
+		if (overBackButton || camR.contains(popupMouse) ||
+				(numPics > 0 && (delR.contains(popupMouse) || sendR.contains(popupMouse))) ||
+				(canPageToPreviousPicture() && upR.contains(popupMouse)) ||
+				(canPageToNextPicture() && downR.contains(popupMouse))) {
 			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 		}
-		if (overButton != _backButtonHovered) {
-			_backButtonHovered = overButton;
-			drawScreenContent();
-		}
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			if (camR.contains(popupMouse)) {
@@ -2497,13 +2502,13 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 				_directoryScroll = 0;
 				_directorySelection = 0;
 				enterScreenState(kDirectory);
-			} else if (numPics > 1 && upR.contains(popupMouse)) {
-				_pictureIndex = (_pictureIndex + numPics - 1) % numPics;
+			} else if (canPageToPreviousPicture() && upR.contains(popupMouse)) {
+				--_pictureIndex;
 				drawScreenContent();
-			} else if (numPics > 1 && downR.contains(popupMouse)) {
-				_pictureIndex = (_pictureIndex + 1) % numPics;
+			} else if (canPageToNextPicture() && downR.contains(popupMouse)) {
+				++_pictureIndex;
 				drawScreenContent();
-			} else if (!backHit.isEmpty() && backHit.contains(popupMouse)) {
+			} else if (overBackButton) {
 				enterScreenState(kOnlineHub);
 			}
 			input.eatMouseInput();
@@ -2513,15 +2518,13 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		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.
+	// Delete-confirm: Yes and No are the Menu and Directory soft keys
+	// relabelled, so those keys are the hitboxes.
 	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;
+		Common::Rect yesR = _uiclData->dialPadSlots[UICL::kDialKeyMenu].destRect;
+		Common::Rect noR = _uiclData->dialPadSlots[UICL::kDialKeyDirectory].destRect;
 		yesR.translate(-_screenPosition.left, -_screenPosition.top);
 		noR.translate(-_screenPosition.left, -_screenPosition.top);
 		if (yesR.contains(popupMouse) || noR.contains(popupMouse)) {
@@ -2797,40 +2800,39 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		}
 	}
 
+	// The Directory soft key becomes Send while picking a recipient. Checked
+	// before the dial-pad loop so its normal action can't eat the click.
+	if (_sendingPicture && _screenState == kDirectory &&
+			_uiclData->dialPadSlots[UICL::kDialKeyDirectory].destRect.contains(chunkMouse)) {
+		g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			playDialPadSound(_uiclData->dialPadSlots[UICL::kDialKeyDirectory].soundName);
+			sendCurrentPicture(_directoryScroll + _directorySelection);
+			input.eatMouseInput();
+			return;
+		}
+	}
+
 	// Call/talk button. Checked before the dial-pad loop so an overlapping
-	// slot can't eat it. The Talk key is dial-pad slot 12. Only live while the
-	// keypad is on screen (skipped in the zoomed web / email / browser views).
-	if (keypadVisible && isDialKeyActive(12) &&
-			_uiclData->dialPadSlots[12].destRect.contains(chunkMouse)) {
+	// slot can't eat it. Only live while the keypad is on screen (skipped in
+	// the zoomed web / email / browser views).
+	if (keypadVisible && isDialKeyActive(UICL::kDialKeyTalk) &&
+			_uiclData->dialPadSlots[UICL::kDialKeyTalk].destRect.contains(chunkMouse)) {
 		g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
-			playDialPadSound(_uiclData->dialPadSlots[12].soundName);
+			playDialPadSound(_uiclData->dialPadSlots[UICL::kDialKeyTalk].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.
+				// The Talk key doubles as the camera button on the welcome /
+				// dialing screen; in the directory it dials, so fall through.
 				_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 =
@@ -2852,12 +2854,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 		}
 	}
 
-	// Dial-pad slot behaviour:
-	//   0..9   - digit input
-	//   10, 11 - *, # (no-op)
-	//   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
+	// Keys below kDialKeyStar enter digits; Star / Hash do nothing; Talk is
+	// handled above; Menu opens the online hub and Directory toggles the list.
 	int newHovered = -1;
 	if (keypadVisible) {
 		for (uint i = 0; i < UICL::kNumDialPadSlots; ++i) {
@@ -2878,19 +2876,19 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 
 			playDialPadSound(slot.soundName);
 
-			if (newHovered < 10) {
+			if (newHovered < UICL::kDialKeyStar) {
 				if (_screenState == kDirectory || isLinkListMode()) {
 					_dialedNumber.clear();
 				}
 				appendDigit((byte)newHovered);
-			} else if (newHovered == 13) {
+			} else if (newHovered == UICL::kDialKeyMenu) {
 				// Opens the Email/Web hub. Re-pressing does not toggle back to
 				// the welcome screen — the on-screen Back button does that.
 				_dialedNumber.clear();
 				_directoryScroll = 0;
 				_directorySelection = 0;
 				enterScreenState(kOnlineHub);
-			} else if (newHovered == 14) {
+			} else if (newHovered == UICL::kDialKeyDirectory) {
 				_dialedNumber.clear();
 				_directoryScroll = 0;
 				_directorySelection = 0;
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/cellphonepopup.h
index d71a955c265..6f818634779 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/cellphonepopup.h
@@ -158,7 +158,13 @@ private:
 	void drawCameraFraming();
 	// Screen-space rect of the framing box, centred on the mouse and clamped to
 	// the viewport.
+	// Each paging arrow is only drawn and live while its side has a photo.
+	bool canPageToPreviousPicture() const;
+	bool canPageToNextPicture() const;
 	Common::Rect framingScreenRect() const;
+	// Marks the shown snapshot sent and fires the flags of every subject in it
+	// addressed to the contact on the given directory row.
+	void sendCurrentPicture(uint listRow);
 	// 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).
@@ -225,7 +231,7 @@ private:
 	void enterScreenState(ScreenState newState);
 	// With no signal the phone locks to "Old Email Only": on the welcome,
 	// dialing and online-hub screens every keypad key is dead except Menu
-	// (slot 13), which still reaches the e-mail list. Digits, *, #, Talk, Dir
+	// (the Menu key), which still reaches the e-mail list. Digits, *, #, Talk, Dir
 	// and the Help "?" go inert. The directory keeps its keys so the reachable
 	// e-mail path still works.
 	bool isDialKeyActive(uint slot) const;
@@ -253,6 +259,8 @@ private:
 	void triggerContactCallSceneChange(uint contactIndex);
 	int findContactByDialBuffer() const;
 
+	// LCD content bounds the directory list is laid out against.
+	const Common::Rect &lcdListBounds() const;
 	uint maxDirectoryRows() const;
 	uint directoryRowAt(const Common::Point &chunkMouse) const;
 	Common::Rect directoryRowRect(uint visibleIndex) const;
@@ -271,9 +279,9 @@ private:
 	Common::Rect hubEmailRect() const;
 	Common::Rect hubWebRect() const;
 	void startCallToContact(uint contactIndex);
-	// Visible (deduplicated) row -> raw contact index, or -1.
+	// Directory row -> raw contact index, or -1.
 	int contactIndexForVisibleRow(uint visibleRow) const;
-	uint deduplicatedContactCount() const;
+	uint visibleContactCount() 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
@@ -307,6 +315,8 @@ private:
 	// Chrome (header.imageName) and sprite atlas (overlayImageName).
 	Graphics::ManagedSurface _overlayImage;
 	Graphics::ManagedSurface _spritesImage;
+	// Nancy 13 camera viewfinder, drawn over the scene while aiming.
+	Graphics::ManagedSurface _cameraViewImage;
 
 	bool _closeButtonHovered = false;
 	bool _scrollUpHovered = false;
@@ -351,8 +361,6 @@ private:
 	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;


Commit: f2b884e47f3a6dc09f7214812b9d5483faf1cde6
    https://github.com/scummvm/scummvm/commit/f2b884e47f3a6dc09f7214812b9d5483faf1cde6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-30T13:25:23+03:00

Commit Message:
NANCY: NANCY9: Implement screen saving

This is used as an easter egg in the Nancy9 sandcastle puzzle, as well
as saving a snapshot of the final award screen, shown after completing
Nancy12

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index ab2a338fa10..dc850f5307e 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -403,11 +403,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new SetPlayerClock();	// Moved from 170 in Nancy12
 		else
 			return new SetVolume();			// Legacy SetVolume slot (used up to Nancy8)
-	case 141:
-		// MakeScreenFile, moved here from 148 in Nancy12.
-		// Saves a cropped image of the screen to a bitmap/TGA file.
-		// TODO: debug-only feature, not implemented
-		return nullptr;
+	case 141:	// Nancy12
+		return new MakeScreenFile();	// Moved from 148 in Nancy12
 	case 143:	// Nancy14 - ConcatSound
 	case 144:	// Nancy14 - MultiSound (dropped from the Nancy15 dispatch)
 		// Sibling sound ARs. ConcatSound plays a list of named sounds back-to-back;
@@ -426,8 +423,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new StopSound();	// Nancy13: StopSound moved here (was 154)
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
 			return new SetVolume();	// Moved from 149 in Nancy12
-		// MakeScreenFile - seems to save a cropped image of the screen in a bitmap file?
-		// TODO: Used in Nancy 9, sand castle puzzle. Moved to 141 in Nancy12.
+		if (g_nancy->getGameType() >= kGameTypeNancy9)
+			return new MakeScreenFile();	// Moved to 141 in Nancy12
 		return nullptr;
 	case 149:
 		if (g_nancy->getGameType() >= kGameTypeNancy13)
@@ -452,9 +449,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 153:
 		return new PlaySoundMultiHS();
 	case 154:
+	case 155: // StopAndUnloadSound, but we always unload
 		return new StopSound();
-	case 155:
-		return new StopSound(); // StopAndUnloadSound, but we always unload
 	case 156:	// Nancy11
 		return new Update3DSound();
 	case 157:
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 43ab948197a..4ecd1a880dc 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -35,6 +35,10 @@
 #include "common/events.h"
 #include "common/config-manager.h"
 #include "common/random.h"
+#include "common/file.h"
+
+#include "image/png.h"
+
 #include "nancy/ui/taskbar.h"
 
 namespace Nancy {
@@ -419,6 +423,44 @@ void SaveContinueGame::execute() {
 	_isDone = true;
 }
 
+void MakeScreenFile::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _filename);
+
+	readRect(stream, _cropRect);
+
+	// Image format selector, unused
+	stream.skip(1);
+}
+
+void MakeScreenFile::execute() {
+	Graphics::ManagedSurface screenshot;
+	g_nancy->_graphics->screenshotScreen(screenshot);
+
+	Common::Rect cropRect = _cropRect;
+
+	if (cropRect.isValidRect()) {
+		cropRect.clip(Common::Rect(screenshot.w, screenshot.h));
+
+		if (!cropRect.isEmpty()) {
+			Common::Path outName(_filename + ".png");
+			Common::DumpFile outFile;
+
+			if (outFile.open(outName)) {
+				if (!Image::writePNG(outFile, screenshot.getSubArea(cropRect))) {
+					warning("Could not write screen file %s", outName.toString().c_str());
+				}
+
+				outFile.finalize();
+				outFile.close();
+			} else {
+				warning("Could not create screen file %s", outName.toString().c_str());
+			}
+		}
+	}
+
+	finishExecution();
+}
+
 void TurnOffMainRendering::readData(Common::SeekableReadStream &stream) {
 	stream.skip(1);
 }
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index e383914f898..6f387b8e88a 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -264,6 +264,26 @@ protected:
 	Common::String getRecordTypeName() const override { return "SaveContinueGame"; }
 };
 
+// Nancy9 AR 148, moved to AR 141 in Nancy12. Saves a cropped picture of the
+// current frame to a PNG file. Used as an easter egg, e.g. on the sandcastle
+// screen in Danger on Deception Island.
+class MakeScreenFile : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	Common::String _filename;
+	Common::Rect _cropRect;
+
+	Common::String getRecordExtraInfo() const override {
+		return Common::String::format("Filename: %s.png, crop rect: (%d, %d, %d, %d)",
+			_filename.c_str(), _cropRect.left, _cropRect.top, _cropRect.right, _cropRect.bottom);
+	}
+
+protected:
+	Common::String getRecordTypeName() const override { return "MakeScreenFile"; }
+};
+
 // Stops the screen from rendering. Our rendering system is different from the original engine's,
 // so we have no use for this.
 class TurnOffMainRendering : public Unimplemented {


Commit: 7dec9a56ab6f657ccb014399a7a08b62ce86521b
    https://github.com/scummvm/scummvm/commit/7dec9a56ab6f657ccb014399a7a08b62ce86521b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-30T13:25:25+03:00

Commit Message:
NANCY: NANCY14: Implement new sound related ARs

New ARs:
- ConcatSound and MultiSound - play a list of named sounds consecutively
- SetMovieVolume - changes movie volume by its filename

Changed paths:
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/secondarymovie.cpp
    engines/nancy/action/secondarymovie.h
    engines/nancy/action/soundrecords.cpp
    engines/nancy/action/soundrecords.h
    engines/nancy/movieplayer.cpp
    engines/nancy/movieplayer.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index dc850f5307e..5e43bffb63d 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -405,11 +405,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return new SetVolume();			// Legacy SetVolume slot (used up to Nancy8)
 	case 141:	// Nancy12
 		return new MakeScreenFile();	// Moved from 148 in Nancy12
-	case 143:	// Nancy14 - ConcatSound
-	case 144:	// Nancy14 - MultiSound (dropped from the Nancy15 dispatch)
-		// Sibling sound ARs. ConcatSound plays a list of named sounds back-to-back;
-		// TODO: not yet implemented
-		return nullptr;
+	case 143:	// Nancy14
+		return new ConcatSound();
+	case 144:	// Nancy14
+		return new MultiSound();
 	case 145:	// Nancy13
 		return new PlaySound(); // Moved from 150 in Nancy13
 	case 146:	// Nancy13
@@ -437,7 +436,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 			return nullptr;
 	case 150:
 		if (g_nancy->getGameType() >= kGameTypeNancy14)
-			return nullptr;	// Nancy14: SetMovieVolume, TODO. PlaySound moved to 145 in Nancy13.
+			return new SetMovieVolume();	// PlaySound moved to 145 in Nancy13
 		return new PlaySound();
 	case 151:
 		if (g_nancy->getGameType() <= kGameTypeNancy6)
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 7016df86ed0..c93386d14e2 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -196,7 +196,9 @@ void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Com
 	ser.skip(4);	// Two u16s (object offsets 0x8c / 0xe7); purpose not yet mapped
 	ser.syncAsSint16LE(_sceneChange.sceneID);
 	ser.syncAsUint16LE(_sceneChange.frameID);
-	ser.skip(1);	// Per-movie volume byte (movie sound off since Nancy6)
+
+	ser.syncAsByte(_movieVolume);
+	_movieVolume = MIN<byte>(_movieVolume, 100);
 
 	uint16 sequenceCount = 0;
 	ser.syncAsUint16LE(sequenceCount);
@@ -278,6 +280,8 @@ bool PlaySecondaryMovie::activateRandomSequence(int index) {
 		return false;
 	}
 
+	_decoder.setVolume(_movieVolume);
+
 	resolveSentinelFrames();
 
 	_isFinished = false;
@@ -295,6 +299,8 @@ bool PlaySecondaryMovie::activateSecondaryMovie() {
 		return false;
 	}
 
+	_decoder.setVolume(_movieVolume);
+
 	resolveSentinelFrames();
 
 	_isFinished = false;
@@ -488,12 +494,12 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 
 	_videoSceneChange = _sceneChange.sceneID != kNoScene ? kMovieSceneChange : kMovieNoSceneChange;
 
-	// Per-movie volume; consumed but unused (movie sound is off since Nancy6).
-	// AR 44 always carries it. AR 47 does too, but only from Nancy15 - the
-	// retail flipped the "mode" convention, and Nancy14's AR 47 omits the byte.
+	// Per-movie volume. AR 44 always carries it. AR 47 does too, but only from
+	// Nancy15 - the retail flipped the "mode" convention, and Nancy14's AR 47
+	// omits the byte.
 	if (_type == 44 || (_type == 47 && isNancy15)) {
-		byte movieVolume = 0;
-		ser.syncAsByte(movieVolume);
+		ser.syncAsByte(_movieVolume);
+		_movieVolume = MIN<byte>(_movieVolume, 100);
 	}
 
 	uint16 numFrameFlags = 0;
@@ -632,6 +638,8 @@ void PlaySecondaryMovie::init() {
 			error("Couldn't load video file %s", _videoName.toString().c_str());
 		}
 
+		_decoder.setVolume(_movieVolume);
+
 		if (!_paletteName.empty()) {
 			GraphicsManager::loadSurfacePalette(_fullFrame, _paletteName);
 			GraphicsManager::loadSurfacePalette(_drawSurface, _paletteName);
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 49fd839635a..a711b717f27 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -115,6 +115,11 @@ public:
 	// unused by playback.
 	uint16 _playStyle = 1;
 
+	// Volume of the movie's audio track, as a percentage. Carried by Nancy14+
+	// AR 44/47 only; every other record plays at full volume. SetMovieVolume
+	// (AR 150) can change it later.
+	byte _movieVolume = 100;
+
 	// AR 47 "InteractiveVideo" (a PlaySecondaryMovie subclass): after the
 	// normal AR-44-style movie data it carries a name, a flag byte, and a
 	// list of named {value, flag} entries. Read but not yet acted on.
diff --git a/engines/nancy/action/soundrecords.cpp b/engines/nancy/action/soundrecords.cpp
index b4143548b55..290f06b22e7 100644
--- a/engines/nancy/action/soundrecords.cpp
+++ b/engines/nancy/action/soundrecords.cpp
@@ -24,6 +24,7 @@
 #include "common/system.h"
 
 #include "engines/nancy/nancy.h"
+#include "engines/nancy/movieplayer.h"
 #include "engines/nancy/sound.h"
 #include "engines/nancy/util.h"
 
@@ -83,6 +84,20 @@ void SetVolume::execute() {
 	_isDone = true;
 }
 
+void SetMovieVolume::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, movieName);
+	volume = MIN<byte>(stream.readByte(), 100);
+}
+
+void SetMovieVolume::execute() {
+	MoviePlayer *movie = MoviePlayer::findLoadedMovie(movieName);
+	if (movie) {
+		movie->setVolume(volume);
+	}
+
+	_isDone = true;
+}
+
 void FadeSoundToSilence::readData(Common::SeekableReadStream &stream) {
 	channel = stream.readUint16LE();
 	stream.skip(2); // pad / flag
@@ -488,5 +503,132 @@ void TableIndexPlaySound::execute() {
 	PlaySoundCC::execute();
 }
 
+void ConcatMultiSound::readData(Common::SeekableReadStream &stream) {
+	// Sound records split into groups; each group declares its size.
+	int16 remaining = stream.readSint16LE();
+	while (remaining > 0) {
+		int16 groupSize = stream.readSint16LE();
+		if (groupSize <= 0) {
+			break;
+		}
+
+		_groups.push_back(SoundGroup());
+		SoundGroup &group = _groups.back();
+		for (int16 i = 0; i < groupSize; ++i) {
+			group.sounds.push_back(SequencedSound());
+			SequencedSound &sound = group.sounds.back();
+			readFilename(stream, sound.name);	// 33-byte field
+			sound.flag = stream.readByte();
+			sound.delay = stream.readSint16LE();
+		}
+
+		// ConcatSound: flag pairs per group.
+		if (perGroupFlags()) {
+			int16 numFlags = stream.readSint16LE();
+			for (int16 i = 0; i < numFlags; ++i) {
+				group.flags.push_back(FlagDescription());
+				group.flags.back().label = stream.readSint16LE();
+				group.flags.back().flag = (byte)stream.readSint16LE();
+			}
+		}
+
+		remaining -= groupSize;
+	}
+
+	// Shared sound descriptor.
+	_sound.channelID = stream.readUint16LE();
+	_sound.numLoops = (uint16)stream.readSint32LE();	// stored as an int32 on disk
+	_sound.volume = stream.readUint16LE();
+	_exitSceneID = stream.readSint16LE();
+	_field35 = stream.readByte();
+
+	// MultiSound: one shared set of flag pairs.
+	if (!perGroupFlags()) {
+		int16 numFlags = stream.readSint16LE();
+		for (int16 i = 0; i < numFlags; ++i) {
+			_sharedFlags.push_back(FlagDescription());
+			_sharedFlags.back().label = stream.readSint16LE();
+			_sharedFlags.back().flag = (byte)stream.readSint16LE();
+		}
+	}
+
+	_sound.name = "NO SOUND";
+}
+
+void ConcatMultiSound::startCurrentSound() {
+	SoundGroup &group = _groups[_currentGroup];
+	SequencedSound &sound = group.sounds[_currentSound];
+
+	// ConcatSound: apply the group's flags on its first sound.
+	if (perGroupFlags() && _currentSound == 0) {
+		for (const FlagDescription &flag : group.flags) {
+			NancySceneState.setEventFlag(flag);
+		}
+	}
+
+	_sound.name = sound.name;
+	if (!_sound.name.empty() && _sound.name != "NO SOUND") {
+		g_nancy->_sound->loadSound(_sound);
+		g_nancy->_sound->playSound(_sound);
+	}
+
+	_delayEnd = g_nancy->getTotalPlayTime() + (sound.delay > 0 ? (uint32)sound.delay * 1000 : 0);
+}
+
+void ConcatMultiSound::execute() {
+	switch (_state) {
+	case kBegin:
+		_currentGroup = 0;
+		_currentSound = 0;
+		_soundStarted = false;
+
+		// MultiSound: apply the shared flags up front.
+		if (!perGroupFlags()) {
+			for (const FlagDescription &flag : _sharedFlags) {
+				NancySceneState.setEventFlag(flag);
+			}
+		}
+
+		_state = kRun;
+		break;
+	case kRun:
+		if (_currentGroup >= _groups.size()) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_currentSound >= _groups[_currentGroup].sounds.size()) {
+			++_currentGroup;
+			_currentSound = 0;
+			_soundStarted = false;
+			break;
+		}
+
+		if (!_soundStarted) {
+			startCurrentSound();
+			_soundStarted = true;
+		} else {
+			// Advance once the sound finishes and its delay elapses.
+			bool soundDone = !g_nancy->_sound->isSoundPlaying(_sound);
+			bool delayDone = g_nancy->getTotalPlayTime() >= _delayEnd;
+			if (soundDone && delayDone) {
+				++_currentSound;
+				_soundStarted = false;
+			}
+		}
+
+		break;
+	case kActionTrigger:
+		if (_exitSceneID != kNoScene) {
+			SceneChangeDescription desc;
+			desc.sceneID = _exitSceneID;
+			NancySceneState.changeScene(desc);
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
 } // End of namespace Action
 } // End of namespace Nancy
diff --git a/engines/nancy/action/soundrecords.h b/engines/nancy/action/soundrecords.h
index bf69d95bd39..7b571714efe 100644
--- a/engines/nancy/action/soundrecords.h
+++ b/engines/nancy/action/soundrecords.h
@@ -40,6 +40,20 @@ protected:
 	Common::String getRecordTypeName() const override { return "SetVolume"; }
 };
 
+// Nancy14 AR 150. Changes the volume of a movie that is already loaded,
+// addressed by its filename.
+class SetMovieVolume : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	Common::Path movieName;
+	byte volume = 0;
+
+protected:
+	Common::String getRecordTypeName() const override { return "SetMovieVolume"; }
+};
+
 // Nancy 11+ AR 147. Linearly ramps a channel's volume down to 0 over
 // the given time, then stops execution.
 class FadeSoundToSilence : public ActionRecord {
@@ -251,6 +265,57 @@ protected:
 	int16 _lastIndexVal = -1;
 };
 
+// Nancy14 sequenced sound player (AR 143 ConcatSound / 144 MultiSound): plays a
+// list of grouped sounds one after another on a shared channel, then optionally
+// changes scene. ConcatSound keeps a set of event flags per group, MultiSound one
+// shared set.
+class ConcatMultiSound : public ActionRecord {
+public:
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+protected:
+	struct SequencedSound {
+		Common::String name;
+		byte flag = 0;
+		int16 delay = 0;	// seconds to hold after the sound starts
+	};
+
+	struct SoundGroup {
+		Common::Array<SequencedSound> sounds;
+		Common::Array<FlagDescription> flags;	// ConcatSound only
+	};
+
+	// Flags stored per group (ConcatSound) or as one shared set (MultiSound).
+	virtual bool perGroupFlags() const = 0;
+
+	void startCurrentSound();
+
+	Common::Array<SoundGroup> _groups;
+	Common::Array<FlagDescription> _sharedFlags;	// MultiSound only
+	SoundDescription _sound;
+	int16 _exitSceneID = kNoScene;
+	byte _field35 = 0;
+
+	// Runtime state
+	uint _currentGroup = 0;
+	uint _currentSound = 0;
+	bool _soundStarted = false;
+	uint32 _delayEnd = 0;
+};
+
+class ConcatSound : public ConcatMultiSound {
+protected:
+	bool perGroupFlags() const override { return true; }
+	Common::String getRecordTypeName() const override { return "ConcatSound"; }
+};
+
+class MultiSound : public ConcatMultiSound {
+protected:
+	bool perGroupFlags() const override { return false; }
+	Common::String getRecordTypeName() const override { return "MultiSound"; }
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 
diff --git a/engines/nancy/movieplayer.cpp b/engines/nancy/movieplayer.cpp
index 77ac2d48f80..5fbb850ea0e 100644
--- a/engines/nancy/movieplayer.cpp
+++ b/engines/nancy/movieplayer.cpp
@@ -30,6 +30,8 @@
 #include "engines/nancy/util.h"
 #include "engines/nancy/commontypes.h"
 
+#include "audio/mixer.h"
+
 #include "engines/nancy/movieplayer.h"
 
 namespace Nancy {
@@ -45,9 +47,34 @@ private:
 	MoviePlayer &_owner;
 };
 
+Common::Array<MoviePlayer *> MoviePlayer::_loadedMovies;
+
 MoviePlayer::MoviePlayer() {}
 
-MoviePlayer::~MoviePlayer() {}
+MoviePlayer::~MoviePlayer() {
+	unregisterMovie();
+}
+
+void MoviePlayer::unregisterMovie() {
+	for (uint i = 0; i < _loadedMovies.size(); ++i) {
+		if (_loadedMovies[i] == this) {
+			_loadedMovies.remove_at(i);
+			break;
+		}
+	}
+
+	_loadedName.clear();
+}
+
+MoviePlayer *MoviePlayer::findLoadedMovie(const Common::Path &name) {
+	for (MoviePlayer *movie : _loadedMovies) {
+		if (movie->_loadedName.equalsIgnoreCase(name)) {
+			return movie;
+		}
+	}
+
+	return nullptr;
+}
 
 byte MoviePlayer::resolvePlaytype(byte videoPlaytype) {
 	if (videoPlaytype != kVideoPlaytypeAuto) {
@@ -60,6 +87,7 @@ byte MoviePlayer::resolvePlaytype(byte videoPlaytype) {
 
 bool MoviePlayer::loadFile(const Common::Path &name, byte videoPlaytype, bool bidirectionalCache) {
 	freeFrameCache();
+	unregisterMovie();
 
 	const Common::Path avfPath = name.append(".avf");
 	const Common::Path bikPath = name.append(".bik");
@@ -114,6 +142,9 @@ bool MoviePlayer::loadFile(const Common::Path &name, byte videoPlaytype, bool bi
 		g_nancy->addDeferredLoader(_cacheLoader);
 	}
 
+	_loadedName = name;
+	_loadedMovies.push_back(this);
+
 	return true;
 }
 
@@ -124,6 +155,7 @@ bool MoviePlayer::isVideoLoaded() const {
 void MoviePlayer::close() {
 	_currentSurface = nullptr;
 	freeFrameCache();
+	unregisterMovie();
 	if (_decoder) {
 		_decoder->close();
 	}
@@ -193,6 +225,12 @@ Audio::Timestamp MoviePlayer::getDuration() const	{ return _decoder->getDuration
 uint16 MoviePlayer::getWidth() const		{ return _decoder->getWidth(); }
 uint16 MoviePlayer::getHeight() const		{ return _decoder->getHeight(); }
 
+void MoviePlayer::setVolume(byte percent) {
+	if (_decoder) {
+		_decoder->setVolume(MIN<byte>(percent, 100) * Audio::Mixer::kMaxChannelVolume / 100);
+	}
+}
+
 void MoviePlayer::addFrameTime(uint16 timeToAdd) {
 	if (_decoder && _videoType == kVideoPlaytypeAVF) {
 		((AVFDecoder *)_decoder.get())->addFrameTime(timeToAdd);
diff --git a/engines/nancy/movieplayer.h b/engines/nancy/movieplayer.h
index ed2a5c7a578..36e4a734d38 100644
--- a/engines/nancy/movieplayer.h
+++ b/engines/nancy/movieplayer.h
@@ -89,6 +89,13 @@ public:
 	uint16 getHeight() const;
 	void addFrameTime(uint16 timeToAdd);	// AVF only, no-op otherwise
 
+	// Sets the volume of the movie's audio track, as a percentage (0 - 100).
+	void setVolume(byte percent);
+
+	// Loaded movies are registered under their filename, so SetMovieVolume
+	// (Nancy14 AR 150) can address one that is already playing by name.
+	static MoviePlayer *findLoadedMovie(const Common::Path &name);
+
 	// Decode a frame: frameNr < 0 returns the next frame; otherwise that
 	// specific frame via the format-appropriate cached path (AVF decodeFrame;
 	// Bink decodes forward when possible and caches frames, see _frameCache).
@@ -112,11 +119,17 @@ private:
 
 	void storeCurrentFrame();
 	void freeFrameCache();
+	void unregisterMovie();
 	bool fillNextCacheFrame();	// decode one uncached frame; true when the cache is full
 
 	Common::ScopedPtr<Video::VideoDecoder> _decoder;
 	byte _videoType = kVideoPlaytypeAVF;
 
+	// Name this movie was loaded with, and the list of all currently loaded
+	// movies; both only serve findLoadedMovie().
+	Common::Path _loadedName;
+	static Common::Array<MoviePlayer *> _loadedMovies;
+
 	// Decoded-frame cache for the Bink path (AVF caches internally). Bink seeking
 	// re-decodes from the previous keyframe, so caching keeps panorama scrubbing
 	// fast. Enabled only when loadFile() is asked for a bidirectional cache; the




More information about the Scummvm-git-logs mailing list