[Scummvm-git-logs] scummvm master -> 8a854b3f18a13a2999242a336508a67ab12b04a2

bluegr noreply at scummvm.org
Tue Jul 21 00:56:07 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:
579f26a0a3 NANCY: NANCY13: Implement the MMIX (MusicMix) functionality
dcb90ead28 NANCY: NANCY14: Implement chunk loader for the UICM (camera) chunk
5d68a280fa NANCY: NANCY15: Add names to Nancy14 ARs, and map ARs used in Nancy15
5507fb29d8 NANCY: NANCY13: Initial implementation of PachinkoPuzzle
8541ea72ec NANCY: NANCY14: Implement new SecondaryMovie functionality for N14/N15
8a854b3f18 NANCY: NANCY15: Initial handling for the per-character UI functionality


Commit: 579f26a0a3ec51584cd039e4f9cd038f387475cb
    https://github.com/scummvm/scummvm/commit/579f26a0a3ec51584cd039e4f9cd038f387475cb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:49+03:00

Commit Message:
NANCY: NANCY13: Implement the MMIX (MusicMix) functionality

Now, the overriden random scene music, specified in the new MMIX chunk,
is working

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


diff --git a/engines/nancy/sound.cpp b/engines/nancy/sound.cpp
index f7ac6110013..e58d16b1b95 100644
--- a/engines/nancy/sound.cpp
+++ b/engines/nancy/sound.cpp
@@ -262,6 +262,27 @@ SoundManager::~SoundManager() {
 	stopAllSounds();
 }
 
+Common::String SoundManager::resolveMusicMix(const Common::String &name) const {
+	auto *mmix = GetEngineData(MMIX);
+	if (!mmix) {
+		// Pre-Nancy13, or no mix table present
+		return name;
+	}
+
+	for (const MMIX::Record &record : mmix->records) {
+		if (record.name.equalsIgnoreCase(name)) {
+			if (record.musicNames.empty()) {
+				return "NO SOUND";
+			}
+
+			uint pick = g_nancy->_randomSource->getRandomNumber(record.musicNames.size() - 1);
+			return record.musicNames[pick];
+		}
+	}
+
+	return name;
+}
+
 void SoundManager::loadSound(const SoundDescription &description, SoundEffectDescription **effectData, bool forceReload) {
 	if (description.name == "NO SOUND") {
 		return;
@@ -307,7 +328,15 @@ void SoundManager::loadSound(const SoundDescription &description, SoundEffectDes
 		*effectData = nullptr;
 	}
 
-	Common::Path path(description.name + (g_nancy->getGameType() == kGameTypeVampire ? ".dwd" : ".his"));
+	// Resolve the sound name through the music mix table (Nancy 13+). Location
+	// codes get swapped for a randomly-picked track; unmapped names pass through.
+	Common::String soundName = resolveMusicMix(description.name);
+	if (soundName.empty() || soundName == "NO SOUND") {
+		// The mix maps this location to silence
+		return;
+	}
+
+	Common::Path path(soundName + (g_nancy->getGameType() == kGameTypeVampire ? ".dwd" : ".his"));
 	Common::SeekableReadStream *file = SearchMan.createReadStreamForMember(path);
 	if (file) {
 		uint numLoops = chan.numLoops;
diff --git a/engines/nancy/sound.h b/engines/nancy/sound.h
index f195e10c42a..01594f92e11 100644
--- a/engines/nancy/sound.h
+++ b/engines/nancy/sound.h
@@ -160,6 +160,13 @@ protected:
 
 	void soundEffectMaintenance(uint16 channelID, bool force = false);
 
+	// Nancy 13 introduced the MMIX ("music mix") boot chunk, a table mapping a
+	// short location code (e.g. "CAM", "BRI") to a set of interchangeable music /
+	// ambience tracks. When a sound is loaded by such a code, the engine picks one
+	// of the mapped tracks at random and plays that instead. Returns the resolved
+	// filename, or the input name unchanged when it doesn't match any mix record.
+	Common::String resolveMusicMix(const Common::String &name) const;
+
 	// Returns the listener position override if one is active, otherwise
 	// the scene summary's listener position
 	Math::Vector3d getListenerPosition() const;


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

Commit Message:
NANCY: NANCY14: Implement chunk loader for the UICM (camera) chunk

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index bbcbfb30482..0c2c0f20a45 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1167,6 +1167,28 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	}
 }
 
+UICM::UICM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	readFilename(*chunkStream, overlayImageName);
+
+	readRect(*chunkStream, viewRect);
+	maxPictures = chunkStream->readUint16LE();
+	pictureCount = chunkStream->readByte();
+	chunkStream->skip(2);
+
+	shutterSound.readData(*chunkStream);
+
+	const uint16 count = chunkStream->readUint16LE();
+	subjects.resize(count);
+	for (uint i = 0; i < count; ++i) {
+		CameraSubject &s = subjects[i];
+		s.hotspot.readData(*chunkStream);
+		s.subjectID = chunkStream->readSint16LE();
+		s.flag.label = chunkStream->readSint16LE();
+		s.flag.flag = chunkStream->readByte();
+		chunkStream->skip(1);
+	}
+}
+
 UICO::UICO(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	readUIPopupHeader(*chunkStream, header);
 	readRect(*chunkStream, textRect);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index f3edb1fca3f..c5320e45ac8 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -711,6 +711,32 @@ struct UICL : public EngineData {
 	Common::Array<PictureRecord> pictures;    // captured-picture slots (up to 50)
 };
 
+// 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
+// area.
+struct UICM : public EngineData {
+	UICM(Common::SeekableReadStream *chunkStream);
+
+	// One photographable region. When a picture is taken, every subject whose
+	// frameID matches the current scene view and whose coords lie within the
+	// viewfinder rectangle is captured: its subjectID is recorded in the picture
+	// and its flag (if any) is set.
+	struct CameraSubject {
+		HotspotDescription hotspot;   // frameID + region that can be photographed
+		int16 subjectID = -1;         // identifies what was photographed
+		FlagDescription flag;         // event flag set on capture (often unset)
+	};
+
+	Common::Path overlayImageName;            // "PHO_CameraView"
+	Common::Rect viewRect;                    // captured-picture bounds
+	uint16 maxPictures = 0;                   // most pictures the camera can hold
+	byte pictureCount = 0;                    // current picture count (0xff = none)
+	RandomSoundBlock shutterSound;            // picture-capture cue
+
+	Common::Array<CameraSubject> subjects;
+};
+
 // New conversation popup UI (the text strip that appears above the taskbar
 // when a character is speaking). Introduced in Nancy 10.
 // Note: response hotspots are NOT in this chunk — they live in a separate
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 01630698704..8361b859b8b 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -574,7 +574,7 @@ void NancyEngine::bootGameEngine() {
 	LOAD_BOOT(MMIX)	// Music mix table (location code -> music tracks)
 
 	// Nancy 14
-	// LOAD_BOOT(UICM)
+	LOAD_BOOT(UICM)	// Camera UI
 
 	// Nancy 15+
 	// TASK, UIRC, UIIV, UICO, UICM, UICL,


Commit: 5d68a280faf7e789110e48e3cd5f6a06ed21d186
    https://github.com/scummvm/scummvm/commit/5d68a280faf7e789110e48e3cd5f6a06ed21d186
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:51+03:00

Commit Message:
NANCY: NANCY15: Add names to Nancy14 ARs, and map ARs used in Nancy15

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 3f9cb7e0b23..24987c0afa9 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -381,9 +381,15 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new AddSearchLink();
 	case 132:	// Nancy12
 		return new ResourceUse();
-	case 133:	// Nancy14
+	case 133:	// Nancy14 - CameraAction
+		// Cell-phone camera action (introduced alongside the UICM camera UI).
 		// TODO: not yet implemented
 		return nullptr;
+	case 134:	// Nancy15 - PlayCharAR
+		// Switches the active player character (Nancy / Frank / Joe), the
+		// dual-protagonist mechanic new to The Creature of Kapu Cave.
+		// TODO: not yet implemented (depends on the PCUI/LDSN player-char UI)
+		return nullptr;
 	case 140:
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
 			return new SetPlayerClock();	// Moved from 170 in Nancy12
@@ -394,9 +400,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// Saves a cropped image of the screen to a bitmap/TGA file.
 		// TODO: debug-only feature, not implemented
 		return nullptr;
-	case 143:	// Nancy14
-	case 144:	// Nancy14
-		// TODO: new paired AR, not yet implemented
+	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 145:	// Nancy13
 		return new PlaySound(); // Moved from 150 in Nancy13
@@ -448,7 +455,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new PlayRandomSound();
 	case 159:
 		if (g_nancy->getGameType() >= kGameTypeNancy14)
-			return nullptr;	// Nancy14: new AR here, not PlaySoundTerse. TODO.
+			return new GridMapPuzzle();	// moved from 244
 		return new PlaySoundTerse();
 	case 160:
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
@@ -498,19 +505,29 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 177:	// HangmanPuzzle
 		// TODO: not yet implemented
 		return nullptr;
-	case 178:	// logic-grid / crossword-like puzzle, unconfirmed
+	case 178:	// AdjustPuzzle
+		// TODO: not yet implemented
+		return nullptr;
+	case 179:	// MeterPuzzle
+		// TODO: not yet implemented
+		return nullptr;
+	case 180:	// BlockingPuzzle
+		// TODO: not yet implemented
+		return nullptr;
+	case 181:	// PaintPuzzle
 		// TODO: not yet implemented
 		return nullptr;
-	case 179:	// small utility AR, unconfirmed
+	case 182:	// DecoderPuzzle
 		// TODO: not yet implemented
 		return nullptr;
-	case 180:	// movie-driven puzzle, unconfirmed
+	// -- Nancy15 new puzzles (types 183-185) --
+	case 183:	// MagicBoxPuzzle
 		// TODO: not yet implemented
 		return nullptr;
-	case 181:	// image/hotspot puzzle, unconfirmed
+	case 184:	// EscapeGridPuzzle
 		// TODO: not yet implemented
 		return nullptr;
-	case 182:	// word puzzle, unconfirmed
+	case 185:	// WeightSortPuzzle
 		// TODO: not yet implemented
 		return nullptr;
 	case 200:


Commit: 5507fb29d8693eb73e15000453db00e5e8795098
    https://github.com/scummvm/scummvm/commit/5507fb29d8693eb73e15000453db00e5e8795098
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:54+03:00

Commit Message:
NANCY: NANCY13: Initial implementation of PachinkoPuzzle

Still needs fine tuning.

Pachinko / pinball ball-drop puzzle. The player clicks a spring
launcher (a fixed hotspot on the right of the board) to fire a ball
leftward across a pin field. The ball falls under gravity and bounces
off pins and bumper zones (restitution ~0.85) until it settles into one
of two catch "machines": the Miner (a win) or the Yeti (a loss). Each
machine then plays its own result animation before the puzzle finishes.

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 24987c0afa9..5902def9e5d 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -63,6 +63,7 @@
 #include "engines/nancy/action/puzzle/onebuildpuzzle.h"
 #include "engines/nancy/action/puzzle/orderingpuzzle.h"
 #include "engines/nancy/action/puzzle/overridelockpuzzle.h"
+#include "engines/nancy/action/puzzle/pachinkopuzzle.h"
 #include "engines/nancy/action/puzzle/passwordpuzzle.h"
 #include "engines/nancy/action/puzzle/peepholepuzzle.h"
 #include "engines/nancy/action/puzzle/pegspuzzle.h"
@@ -496,9 +497,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 174:
 		return new ScalePuzzle();	// balance scale
 	case 175:
-		// PachinkoPuzzle (ball drop)
-		// TODO: not yet implemented
-		return nullptr;
+		return new PachinkoPuzzle();	// ball drop / pinball
 	case 176:
 		return new DropSortPuzzle();	// conveyor-belt candy sorting
 	// -- Nancy14 new puzzles (types 177-182) --
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
new file mode 100644
index 00000000000..40f3392cbd0
--- /dev/null
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -0,0 +1,532 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/pachinkopuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// -- Physics constants --
+static const double kGravity = 50.0;			// downward accel (px/s^2)
+static const double kRestitution = 0.85;		// bounce energy retained
+static const double kTwoPi = 6.283185307179586;
+static const double kDeg2Rad = 0.017453292519943295;
+// Launch-param jitter: progress*0.95 + (rand%100)*0.002; launch-Y jitter: (rand%100)*0.01.
+static const double kSpawnParamMax = 0.95;
+static const double kSpawnParamJitter = 0.002;
+static const double kSpawnYJitter = 0.01;
+// Per-ball deceleration is not in the chunk (it lives in a runtime-built physics body), so
+// a small constant drag is used here as an approximation.
+static const double kDrag = 12.0;				// px/s per second
+
+static double wrapAngle(double a) {
+	while (a < 0.0) {
+		a += kTwoPi;
+	}
+	while (a >= kTwoPi) {
+		a -= kTwoPi;
+	}
+	return a;
+}
+
+void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);			// 0x00 - board overlay
+
+	// 167-byte header blob.
+	readRect(stream, _ballSrc);					// ball sprite source
+	stream.skip(16);							// four unidentified int32
+	_velMin = stream.readSint32LE();			// launch-speed floor
+	_velMax = stream.readSint32LE();			// launch-speed ceiling
+	_spawnYMin = stream.readSint32LE();			// launch-heading (deg) range
+	_spawnYMax = stream.readSint32LE();
+	stream.skip(4);
+	_launchVecLen = stream.readSint32LE();
+	stream.skip(4);
+	stream.skip(16);							// four unidentified int32
+	_eventFlag = stream.readSint16LE();			// "in progress" flag
+	readFilename(stream, _ballImageName);		// ball sprite sheet
+	readRect(stream, _launcherBallSrc);
+	readRect(stream, _launcherBallDest);
+	readRect(stream, _launcherHotspot);			// the clickable launcher
+	_spawnWindowMin = stream.readSint32LE();
+	_spawnWindowMax = stream.readSint32LE();	// spawn window (ms)
+
+	// The random "plink" cues played each time a ball is launched (LeverPull*).
+	_plinkSounds.readData(stream);
+
+	// The two catch machines: Miner (win) then Yeti (lose).
+	readMachine(stream, _winMachine);
+	readMachine(stream, _loseMachine);
+
+	// Static pin collision rects.
+	int16 numPins = stream.readSint16LE();
+	if (numPins > 0) {
+		_pins.resize(numPins);
+		for (int16 i = 0; i < numPins; ++i) {
+			readRect(stream, _pins[i]);
+		}
+	}
+
+	// The polymorphic Nancy13 ActionZone array (bumpers / walls / overlays). The shared
+	// ActionZone reader handles the Nancy13 layout when told to.
+	readActionZoneArray(stream, _zones, true);
+
+	// The base trailer: a count-prefixed array of 23-byte hotspot records. The sample
+	// carries the single "give up / exit" hotspot.
+	int16 numExit = stream.readSint16LE();
+	for (int16 i = 0; i < numExit; ++i) {
+		Common::Rect r;
+		readRect(stream, r);
+		uint16 cursorType = stream.readUint16LE();
+		uint16 sceneID = stream.readUint16LE();
+		int16 exitFlagLabel = stream.readSint16LE();
+		byte exitFlagValue = stream.readByte();
+
+		if (i == 0) {
+			_exitHotspot = r;
+			_exitCursorType = cursorType;
+			_exitScene.sceneID = sceneID;
+			// The field after the scene id is a flag label (set on give-up), not a frame.
+			_exitScene.frameID = 0;
+			_exitFlag.label = exitFlagLabel;
+			_exitFlag.flag = exitFlagValue;
+		}
+	}
+}
+
+void PachinkoPuzzle::readMachine(Common::SeekableReadStream &stream, Machine &m) {
+	readFilename(stream, m.imageName);			// the ANIM_OVL sprite strip
+	m.animRate = stream.readSint32LE();			// frames per second
+
+	// Sprite-strip source rects (an embedded "sprite container": int16 count + rects).
+	int16 numFrames = stream.readSint16LE();
+	if (numFrames > 0) {
+		m.frames.resize(numFrames);
+		for (int16 i = 0; i < numFrames; ++i) {
+			readRect(stream, m.frames[i]);
+		}
+	}
+
+	// The slide "mover": a start rect, an end/catch rect, and a speed.
+	readRect(stream, m.moverStart);
+	readRect(stream, m.moverEnd);
+	m.moverSpeed = stream.readSint32LE();
+
+	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);
+
+	m.resultSound.readData(stream);				// snd2 - the win/lose voice cue
+
+	stream.skip(1);								// byte (unused here)
+	stream.skip(4);								// int32 (unused here)
+
+	m.fastSound.readData(stream);				// snd3 - the fast-winch cue
+}
+
+void PachinkoPuzzle::loadMachineImage(Machine &m) {
+	if (!m.imageName.empty()) {
+		g_nancy->_resource->loadImage(m.imageName, m.image);
+		m.image.setTransparentColor(_drawSurface.getTransparentColor());
+	}
+}
+
+void PachinkoPuzzle::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(vpBounds.width(), vpBounds.height(), g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+	_image.setTransparentColor(_drawSurface.getTransparentColor());
+	if (!_ballImageName.empty()) {
+		g_nancy->_resource->loadImage(_ballImageName, _ballImage);
+		_ballImage.setTransparentColor(_drawSurface.getTransparentColor());
+	}
+
+	loadMachineImage(_winMachine);
+	loadMachineImage(_loseMachine);
+
+	NancySceneState.setNoHeldItem();
+
+	// In-progress marker: set on entry, cleared once a ball is caught.
+	if (_eventFlag > 0) {
+		NancySceneState.setEventFlag(_eventFlag, g_nancy->_true);
+	}
+
+	_pzState = kRunning;
+	_lastUpdate = g_nancy->getTotalPlayTime();
+
+	redraw();
+	registerGraphics();
+}
+
+SoundDescription PachinkoPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	SoundDescription desc;
+	if (block.names.empty()) {
+		return desc;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return desc;
+	}
+
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+	return desc;
+}
+
+void PachinkoPuzzle::setDataCursor(uint16 cursorType) const {
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true);
+}
+
+void PachinkoPuzzle::spawnBall() {
+	uint32 now = g_nancy->getTotalPlayTime();
+	uint32 elapsed = now - _spawnClickTime;
+
+	// The launch parameter grows across the spawn window, plus a small random jitter.
+	double launchParam = kSpawnParamMax;
+	int span = _spawnWindowMax - _spawnWindowMin;
+	if ((int)elapsed <= _spawnWindowMax && span > 0) {
+		int rel = (int)elapsed - _spawnWindowMin;
+		if (rel < 0) {
+			rel = 0;
+		}
+		launchParam = ((double)rel / (double)span) * kSpawnParamMax;
+	}
+	launchParam += (double)(g_nancy->_randomSource->getRandomNumber(99)) * kSpawnParamJitter;
+
+	// The launch heading (degrees) comes from the [_spawnYMin,_spawnYMax] range.
+	double headingDeg = (double)_spawnYMin +
+		(double)(_spawnYMax - _spawnYMin) * (double)(g_nancy->_randomSource->getRandomNumber(99)) * kSpawnYJitter;
+
+	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;
+	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;
+			}
+		}
+	}
+
+	if (!hit) {
+		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;
+	}
+
+	ball.speed = sqrt(vx * vx + vy * vy) * kRestitution;
+	ball.angle = wrapAngle(atan2(-vy, vx));
+	return true;
+}
+
+void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
+	// Gravity: convert the polar velocity to cartesian, add gravity, convert back.
+	double vx = cos(ball.angle) * ball.speed;
+	double vy = -sin(ball.angle) * ball.speed;
+	vy += kGravity * dt;
+	ball.speed = sqrt(vx * vx + vy * vy);
+	ball.angle = wrapAngle(atan2(-vy, vx));
+
+	// Deceleration (drag).
+	ball.speed -= kDrag * dt;
+	if (ball.speed < 0.0) {
+		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;
+	}
+}
+
+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);
+}
+
+void PachinkoPuzzle::advanceMachine(Machine &m, uint32 now) {
+	if (m.frames.empty() || m.animRate <= 0) {
+		return;
+	}
+	uint32 period = 1000 / (uint32)m.animRate;
+	if (now >= m.nextFrameTime) {
+		m.frame = (m.frame + 1) % m.frames.size();
+		m.nextFrameTime = now + period;
+	}
+}
+
+void PachinkoPuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	// The two machines (their current animation frame), drawn at their mover start.
+	const Machine *machines[2] = { &_winMachine, &_loseMachine };
+	for (int i = 0; i < 2; ++i) {
+		const Machine &m = *machines[i];
+		if (m.frames.empty() || m.image.empty()) {
+			continue;
+		}
+		const Common::Rect &src = m.frames[m.frame % m.frames.size()];
+		Common::Point pos(m.moverStart.left, m.moverStart.top);
+		_drawSurface.blitFrom(m.image, src, pos);
+	}
+
+	// The balls in flight.
+	if (!_ballImage.empty() && !_ballSrc.isEmpty()) {
+		int w = _ballSrc.width();
+		int h = _ballSrc.height();
+		for (const Ball &ball : _balls) {
+			if (!ball.active) {
+				continue;
+			}
+			Common::Point pos((int16)ball.x - w / 2, (int16)ball.y - h / 2);
+			_drawSurface.blitFrom(_ballImage, _ballSrc, pos);
+		}
+	}
+
+	// The result animation, when playing.
+	if (_pzState == kWaitResult && _resultMovie.isVideoLoaded()) {
+		_resultMovie.drawFrame(_drawSurface, Common::Point(0, 0));
+	}
+
+	_needsRedraw = true;
+}
+
+void PachinkoPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		uint32 now = g_nancy->getTotalPlayTime();
+		double dt = (now - _lastUpdate) / 1000.0;
+		_lastUpdate = now;
+		if (dt > 0.1) {
+			dt = 0.1;	// clamp long stalls
+		}
+
+		if (_exitRequested) {
+			_pzState = kComplete;
+		}
+
+		switch (_pzState) {
+		case kRunning: {
+			if (_spawnPending) {
+				spawnBall();
+				_spawnPending = false;
+			}
+
+			advanceMachine(_winMachine, now);
+			advanceMachine(_loseMachine, now);
+
+			for (uint i = 0; i < _balls.size(); ++i) {
+				Ball &ball = _balls[i];
+				if (!ball.active) {
+					continue;
+				}
+				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)) {
+					ball.active = false;
+					_solved = false;
+					_activeMachine = &_loseMachine;
+					_pzState = kPlayResult;
+					break;
+				}
+
+				// A ball that leaves the board is discarded.
+				if (ball.y > _drawSurface.h + 64 || ball.x < -64 || ball.x > _drawSurface.w + 64) {
+					ball.active = false;
+				}
+			}
+
+			redraw();
+			break;
+		}
+		case kPlayResult: {
+			if (_eventFlag > 0) {
+				NancySceneState.setEventFlag(_eventFlag, g_nancy->_false);
+			}
+			if (_activeMachine) {
+				_resultSoundDesc = playSoundBlock(_activeMachine->resultSound);
+				if (!_activeMachine->movieName.empty() &&
+						_resultMovie.loadFile(_activeMachine->movieName) &&
+						_resultMovie.getFrameCount() > 0) {
+					_resultMovie.playRange(0, _resultMovie.getFrameCount() - 1);
+				}
+			}
+			_resultTime = now;
+			_pzState = kWaitResult;
+			redraw();
+			break;
+		}
+		case kWaitResult: {
+			bool movieDone = !_resultMovie.isVideoLoaded() ||
+				(!_resultMovie.isRangePlaying());
+			if (_resultMovie.isVideoLoaded() && _resultMovie.update()) {
+				redraw();
+			}
+			bool soundDone = _resultSoundDesc.name.empty() ||
+				!g_nancy->_sound->isSoundPlaying(_resultSoundDesc);
+			if (movieDone && soundDone && now - _resultTime > 500) {
+				if (_resultMovie.isVideoLoaded()) {
+					_resultMovie.close();
+				}
+				_pzState = kComplete;
+			}
+			break;
+		}
+		case kComplete:
+			_state = kActionTrigger;
+			break;
+		}
+
+		break;
+	}
+	case kActionTrigger:
+		// The give-up hotspot and the completion path both route to the exit scene; the
+		// win/lose branch is driven downstream by the solved flag and the puzzle event flag.
+		NancySceneState.setEventFlag(_exitFlag);
+		if (_exitScene.sceneID != kNoScene) {
+			NancySceneState.changeScene(_exitScene);
+		}
+		finishExecution();
+		break;
+	}
+}
+
+void PachinkoPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _pzState != kRunning) {
+		return;
+	}
+
+	const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+	if (!_launcherHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_launcherHotspot).contains(input.mousePos)) {
+		setDataCursor(_exitCursorType);
+		if (click) {
+			// One launcher click queues one ball.
+			_spawnPending = true;
+			_spawnClickTime = g_nancy->getTotalPlayTime();
+		}
+		input.eatMouseInput();
+		return;
+	}
+
+	if (!_exitHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		setDataCursor(_exitCursorType);
+		if (click) {
+			_exitRequested = true;
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.h b/engines/nancy/action/puzzle/pachinkopuzzle.h
new file mode 100644
index 00000000000..77f6afc876c
--- /dev/null
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.h
@@ -0,0 +1,172 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_PACHINKOPUZZLE_H
+#define NANCY_ACTION_PACHINKOPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/action/actionzone.h"
+
+namespace Nancy {
+namespace Action {
+
+// Pachinko / pinball ball-drop puzzle, new in Nancy13 (AR 175.
+//
+// The player clicks a spring launcher (a fixed hotspot on the right of the board) to
+// fire a ball leftward across a pin field. The ball falls under gravity and bounces off
+// pins and bumper zones (restitution ~0.85) until it settles into one of two catch
+// "machines": the Miner (a win) or the Yeti (a loss). Each machine then plays its own
+// result animation (MUS_PachinkoWinANIM for the Miner) before the puzzle finishes.
+//
+// The chunk is a 167-byte header, a random "plink" sound block, two machine sub-objects
+// (each: an ANIM sprite strip + a slide "mover" + three sound blocks + a 55-byte blob
+// whose leading filename is the result movie), a pin-rect array, a polymorphic Nancy13
+// ActionZone array (the bumpers/walls/overlays), and the give-up exit hotspot. The parse
+// is byte-exact (verified to consume the whole chunk).
+//
+// The physics uses a polar-coordinate integrator: velocity as speed + heading, gravity
+// added in cartesian, per-frame heading recomputed with atan2, sub-stepped rectangle
+// collision with restitution. Per-ball deceleration is not in the chunk, so it is
+// approximated (see kDrag).
+class PachinkoPuzzle : public RenderActionRecord {
+public:
+	PachinkoPuzzle() : RenderActionRecord(7) {}
+	virtual ~PachinkoPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "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.
+	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;
+		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)
+
+		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);
+		}
+	};
+
+	// A single launched ball. Physics run in viewport space; the heading is stored as a
+	// scalar speed plus a heading angle in radians.
+	struct Ball {
+		double x = 0.0;
+		double y = 0.0;
+		double speed = 0.0;			// px / second
+		double angle = 0.0;			// radians; velocity = (cos a, -sin a) * speed
+		uint frame = 0;
+		bool active = false;
+	};
+
+	void readMachine(Common::SeekableReadStream &stream, Machine &m);
+	void loadMachineImage(Machine &m);
+
+	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;
+	void advanceMachine(Machine &m, uint32 now);
+	SoundDescription playSoundBlock(const RandomSoundBlock &block);
+	void setDataCursor(uint16 cursorType) const;
+
+	// -- File data (167-byte header, in stream order) --
+	Common::Path _imageName;				// 0x00 - board overlay (MUS_PachinkoPUZ02_OVL)
+
+	Common::Rect _ballSrc;					// ball sprite source in the overlay
+	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
+	int16 _eventFlag = 0;					// "in progress" flag id
+	Common::Path _ballImageName;			// the ball sprite sheet
+	Common::Rect _launcherBallSrc;			// ball-in-launcher sprite src
+	Common::Rect _launcherBallDest;			// ball-in-launcher dest
+	Common::Rect _launcherHotspot;			// the clickable launcher
+	int32 _spawnWindowMin = 0;
+	int32 _spawnWindowMax = 0;				// spawn window (ms)
+
+	RandomSoundBlock _plinkSounds;			// random ball-launch cues (LeverPull*)
+
+	Machine _winMachine;					// the Miner
+	Machine _loseMachine;					// the Yeti
+
+	Common::Array<Common::Rect> _pins;		// static pin collision rects
+	Common::Array<ActionZone> _zones;		// bumpers / walls / overlays (Nancy13 layout)
+
+	// The give-up / exit hotspot (the base trailer's 23-byte record).
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+	FlagDescription _exitFlag;			// set on give-up
+
+	// -- Runtime state --
+	enum State {
+		kRunning,		// launcher live, balls in flight
+		kPlayResult,	// a ball was caught; start the machine's result movie
+		kWaitResult,	// wait for the result movie / cue
+		kComplete		// finish -> scene change
+	};
+	State _pzState = kRunning;
+
+	Common::Array<Ball> _balls;
+	bool _spawnPending = false;				// a launcher click awaiting a spawn
+	uint32 _spawnClickTime = 0;
+	Machine *_activeMachine = nullptr;		// the machine that caught the ball
+	bool _solved = false;
+	bool _exitRequested = false;
+	uint32 _lastUpdate = 0;
+	uint32 _resultTime = 0;
+	SoundDescription _resultSoundDesc;
+
+	Graphics::ManagedSurface _image;		// board overlay
+	Graphics::ManagedSurface _ballImage;	// ball sprite sheet
+	MoviePlayer _resultMovie;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_PACHINKOPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 424915e92c4..0a2c3644ef5 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -47,6 +47,7 @@ MODULE_OBJS = \
   action/puzzle/onebuildpuzzle.o \
   action/puzzle/orderingpuzzle.o \
   action/puzzle/overridelockpuzzle.o \
+  action/puzzle/pachinkopuzzle.o \
   action/puzzle/passwordpuzzle.o \
   action/puzzle/peepholepuzzle.o \
   action/puzzle/pegspuzzle.o \


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

Commit Message:
NANCY: NANCY14: Implement new SecondaryMovie functionality for N14/N15

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


diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
index c7eb6066ccd..6a2d59f0c78 100644
--- a/engines/nancy/action/actionzone.cpp
+++ b/engines/nancy/action/actionzone.cpp
@@ -28,7 +28,7 @@ namespace Nancy {
 namespace Action {
 
 void ActionZone::readData(Common::SeekableReadStream &stream, bool isNancy13) {
-	// Base ActionZone (matches the original "Action Zone Boundary OVL" reader).
+	// Base ActionZone fields, shared by every subtype.
 	typeField = stream.readSint32LE();
 	type = typeField & 0xFF;
 
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 5902def9e5d..437943d42aa 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -216,9 +216,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new PlaySecondaryMovie(true);
 	case 46:	// Nancy11
 		return new PlayRandomMovieControl();
-	case 47:	// Nancy14 - PlaySecondaryMovie subclass with a per-frame flag list
-		// TODO: not yet implemented
-		return nullptr;
+	case 47:	// Nancy14
+		// A PlaySecondaryMovie subclass that appends a named {value, flag} list.
+		// Handled inside PlaySecondaryMovie via _type == 47.
+		return new PlaySecondaryMovie();
 	case 50:
 		return new ConversationVideo(); // PlayPrimaryVideoChan0
 	case 51:
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index bbf06540bab..a8779baeaf1 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -174,6 +174,58 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
 		_videoDescs[i].readData(stream);
 	}
 
+	applyStartingRandomSequence();
+}
+
+// Nancy14+ random-movie layout (verified byte-identical in Nancy14 and Nancy15).
+// The header grew to mirror the non-random AR (videoFormat / visibility / cursor
+// / sceneID / frameID, plus two currently unmapped u16s and a per-movie volume
+// byte). The sequence records are unchanged. The tail is a blt-descriptor list
+// for the main movie, then the recognition ("secondary") movie's name and its
+// own blt-descriptor list, in place of Nancy13's secondaryMovie record + hotspot
+// list.
+void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream) {
+	readFilename(ser, _startingSequenceName);
+
+	ser.syncAsUint16LE(_videoFormat);
+	_videoFormat = kLargeVideoFormat;
+	ser.skip(2);	// Visibility frame ID; ScummVM drives visibility from the videoDescs
+	ser.syncAsUint16LE(_randomPlayerCursorAllowed);
+	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)
+
+	uint16 sequenceCount = 0;
+	ser.syncAsUint16LE(sequenceCount);
+	_sequences.resize(sequenceCount);
+	for (uint i = 0; i < sequenceCount; ++i) {
+		readRandomSequence(ser, _sequences[i]);
+	}
+
+	// Main movie blt/hotspot descriptors.
+	uint16 numVideoDescs = 0;
+	ser.syncAsUint16LE(numVideoDescs);
+	_videoDescs.resize(numVideoDescs);
+	for (uint i = 0; i < numVideoDescs; ++i) {
+		_videoDescs[i].readData(stream);
+	}
+
+	// Recognition ("secondary") movie: its name followed by its own blt
+	// descriptors. Stored for future playback; the descriptors are consumed to
+	// keep the stream aligned (no home in the struct yet).
+	readFilename(ser, _secondaryMovie.name);
+	uint16 numSecondaryDescs = 0;
+	ser.syncAsUint16LE(numSecondaryDescs);
+	for (uint i = 0; i < numSecondaryDescs; ++i) {
+		SecondaryVideoDescription unused;
+		unused.readData(stream);
+	}
+
+	applyStartingRandomSequence();
+}
+
+void PlaySecondaryMovie::applyStartingRandomSequence() {
 	// "RandomMovie" picks any sequence; otherwise look up by name.
 	// Only the first sequence is played; chained playback is TODO.
 	if (!_sequences.empty()) {
@@ -366,7 +418,14 @@ int PlaySecondaryMovie::rollNextSequence() {
 // gone (a scene change is now requested via the sceneID sentinel), playDirection
 // moved after lastFrame, and a "hide on finish" flag was added. AR 44 matches
 // AR 41 plus a trailing movie-volume byte.
+//
+// Nancy15 adds two things on top: AR 44 gained a "play style" u16 (1/3) after
+// the hide-on-finish flag, and the firstFrame field can be -1 (LOOP_RANDOM),
+// in which case a min/max loop-count pair follows and a random value in that
+// range is chosen.
 void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream) {
+	const bool isNancy15 = g_nancy->getGameType() >= kGameTypeNancy15;
+
 	readFilename(ser, _videoName);
 
 	ser.syncAsUint16LE(_videoFormat);
@@ -375,7 +434,25 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 	ser.skip(2);	// Visibility frame ID; ScummVM drives visibility from the videoDescs instead
 	ser.syncAsUint16LE(_playerCursorAllowed);
 	ser.syncAsUint16LE(_hideOnFinish);
+
+	// AR 44 and its subclass AR 47 read the play-style field (retail "mode 0");
+	// AR 41 ("mode 1") does not.
+	if (isNancy15 && (_type == 44 || _type == 47)) {
+		ser.syncAsUint16LE(_playStyle);
+	}
+
 	ser.syncAsUint16LE(_firstFrame);
+
+	if (isNancy15 && (int16)_firstFrame == -1) {
+		// LOOP_RANDOM: firstFrame -1 is followed by a min/max loop count; the
+		// game picks a random value in [min, max) (min must be < max).
+		uint16 minLoops = 0, maxLoops = 0;
+		ser.syncAsUint16LE(minLoops);
+		ser.syncAsUint16LE(maxLoops);
+		_firstFrame = maxLoops > minLoops ?
+			(uint16)(minLoops + g_nancy->_randomSource->getRandomNumber(maxLoops - minLoops - 1)) : minLoops;
+	}
+
 	ser.syncAsUint16LE(_lastFrame);
 	ser.syncAsUint16LE(_playDirection);
 	ser.syncAsSint16LE(_sceneChange.sceneID);
@@ -383,8 +460,10 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 
 	_videoSceneChange = _sceneChange.sceneID != kNoScene ? kMovieSceneChange : kMovieNoSceneChange;
 
-	if (_type == 44) {
-		// Per-movie volume; consumed but unused (movie sound is off since Nancy6).
+	// 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.
+	if (_type == 44 || (_type == 47 && isNancy15)) {
 		byte movieVolume = 0;
 		ser.syncAsByte(movieVolume);
 	}
@@ -406,6 +485,24 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
 	}
 
 	_sound.name = "NO SOUND";
+
+	// AR 47 ("InteractiveVideo") appends a name, a flag byte, and a list of
+	// named {value, flag} entries on top of the AR-44 movie data.
+	if (_type == 47) {
+		readFilename(ser, _interactiveName);
+		byte flag = 0;
+		ser.syncAsByte(flag);
+		_interactiveFlag = flag != 0;
+
+		uint16 numEntries = 0;
+		ser.syncAsUint16LE(numEntries);
+		_interactiveEntries.resize(numEntries);
+		for (uint i = 0; i < numEntries; ++i) {
+			readFilename(ser, _interactiveEntries[i].name);
+			ser.syncAsUint32LE(_interactiveEntries[i].value);
+			ser.syncAsByte(_interactiveEntries[i].flag);
+		}
+	}
 }
 
 void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
@@ -413,7 +510,13 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
 	ser.setVersion(g_nancy->getGameType());
 
 	if (_isRandom) {
-		readRandomMovieData(ser, stream);
+		// Nancy14 reworked the random-movie layout (Nancy13 and earlier use the
+		// older secondaryMovie-record + hotspot-list form).
+		if (g_nancy->getGameType() >= kGameTypeNancy14) {
+			readRandomMovieDataNancy14(ser, stream);
+		} else {
+			readRandomMovieData(ser, stream);
+		}
 		return;
 	}
 
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 3073c61f35a..29eccf57c52 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -103,6 +103,21 @@ public:
 	uint16 _lastFrame = 0;
 	// Nancy14-only: when non-zero, hide the movie once it reaches its last frame.
 	uint16 _hideOnFinish = 0;
+	// Nancy15 AR 44: a "play style" selector (1 or 3). Read but currently
+	// unused by playback.
+	uint16 _playStyle = 1;
+
+	// 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.
+	struct InteractiveEntry {
+		Common::Path name;
+		uint32 value = 0;
+		byte flag = 0;
+	};
+	Common::Path _interactiveName;
+	bool _interactiveFlag = false;
+	Common::Array<InteractiveEntry> _interactiveEntries;
 	Common::Array<FlagAtFrame> _frameFlags;
 	MultiEventFlagDescription _triggerFlags;
 	FlagDescription _videoStartFlag;
@@ -173,9 +188,18 @@ protected:
 	// `ser` and `stream` must wrap the same input; `stream` is only
 	// needed for SecondaryVideoDescription::readData.
 	void readRandomMovieData(Common::Serializer &ser, Common::SeekableReadStream &stream);
+	// Nancy14 reworked the random-movie layout (confirmed identical in Nancy15):
+	// a larger header (shared with the non-random AR) and a tail of two
+	// blt-descriptor lists separated by the recognition movie's name, in place
+	// of Nancy13's secondaryMovie record + hotspot list.
+	void readRandomMovieDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream);
 	void readRandomSequence(Common::Serializer &ser, RandomSequence &seq);
 	void readSecondaryRandomMovie(Common::Serializer &ser, RandomSequence &seq);
 
+	// Shared tail of the random-movie readers: pick the starting sequence
+	// (random or by name) and seed the flat playback fields from it.
+	void applyStartingRandomSequence();
+
 	void readDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream);
 
 	// Apply a RandomSequence's playback config to the PSM flat fields


Commit: 8a854b3f18a13a2999242a336508a67ab12b04a2
    https://github.com/scummvm/scummvm/commit/8a854b3f18a13a2999242a336508a67ab12b04a2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:56+03:00

Commit Message:
NANCY: NANCY15: Initial handling for the per-character UI functionality

- Per-character CIF trees and UI chunks are now loaded for the default
  character (Nancy). These will need to be reloaded when the character
  changes
- Implement loaders for the LVLN, PCUI, LDSN, PUIH, PUIV data chunks

Now, Nancy15 starts, but crashes after the initial new menu video
finishes

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


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 0c2c0f20a45..8f0434683cb 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1304,4 +1304,71 @@ MMIX::MMIX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	}
 }
 
+LVLN::LVLN(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	// The count is the total number of strings, which always comes in
+	// (code, name) pairs - hence it must be even.
+	const uint16 count = chunkStream->readUint16LE();
+	const uint16 numPairs = count / 2;
+	levelCodes.resize(numPairs);
+	levelNames.resize(numPairs);
+
+	for (uint16 i = 0; i < numPairs; ++i) {
+		readFilename(*chunkStream, levelCodes[i]);
+		readFilename(*chunkStream, levelNames[i]);
+	}
+}
+
+PCUI::PCUI(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	flag = chunkStream->readByte();
+	const uint16 count = chunkStream->readUint16LE();
+	characters.resize(count);
+
+	for (uint16 i = 0; i < count; ++i) {
+		// Each entry begins with the slot index it populates.
+		const byte slot = chunkStream->readByte();
+		Character &chr = (slot < characters.size()) ? characters[slot] : characters[i];
+		readFilename(*chunkStream, chr.imageName);
+		readFilename(*chunkStream, chr.defaultImageName);
+		chr.id = chunkStream->readUint16LE();
+	}
+}
+
+LDSN::LDSN(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	readFilename(*chunkStream, backgroundImageName);
+	readFilename(*chunkStream, overlayImageName);
+
+	// The remainder is a run of button/selection rects (16 bytes each),
+	// followed by a short trailer whose fields aren't fully understood yet.
+	while (chunkStream->pos() + 16 <= chunkStream->size()) {
+		Common::Rect rect;
+		readRect(*chunkStream, rect);
+		rects.push_back(rect);
+	}
+}
+
+PUIH::PUIH(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	flag = chunkStream->readByte();
+	readFilename(*chunkStream, themeName);
+	readFilename(*chunkStream, swatchImageName);
+}
+
+PUIV::PUIV(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	readFilename(*chunkStream, name);
+	channelID = chunkStream->readUint16LE();
+	unknown   = chunkStream->readUint32LE();
+	volume    = chunkStream->readUint16LE();
+
+	const uint16 count = chunkStream->readUint16LE();
+	soundGroups.resize(count);
+	for (uint16 i = 0; i < count; ++i) {
+		SoundGroup &group = soundGroups[i];
+		group.tag = chunkStream->readByte();
+		const uint16 numVariants = chunkStream->readUint16LE();
+		group.variants.resize(numVariants);
+		for (uint16 j = 0; j < numVariants; ++j) {
+			readFilename(*chunkStream, group.variants[j]);
+		}
+	}
+}
+
 } // End of namespace Nancy
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index c5320e45ac8..94c8d725c04 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -838,6 +838,71 @@ struct MMIX : public EngineData {
 
 	Common::Array<Record> records;
 };
+
+// Level name table. Introduced in Nancy 15. Maps a short scene-prefix
+// code (e.g. "KAP") to its human-readable level name (e.g. "Kapu Cave").
+struct LVLN : public EngineData {
+	LVLN(Common::SeekableReadStream *chunkStream);
+
+	Common::Array<Common::String> levelCodes;	// e.g. "KAP"
+	Common::Array<Common::String> levelNames;	// e.g. "Kapu Cave"
+};
+
+// Player-character selector UI. Introduced in Nancy 15, where the player
+// alternates between Nancy and the Hardy Boys.
+// Each entry supplies the image names for one selectable character.
+struct PCUI : public EngineData {
+	struct Character {
+		Common::String imageName;			// e.g. "PUI_CRE_Nancy"
+		Common::String defaultImageName;	// e.g. "PUI_CRE_Nancy_Default"
+		uint16 id = 0;
+	};
+
+	PCUI(Common::SeekableReadStream *chunkStream);
+
+	byte flag = 0;
+	Common::Array<Character> characters;	// indexed by the on-disk slot byte
+};
+
+// Fixed layout/graphics block for the Nancy 15 player-character ("Design
+// Select") switcher screen. Companion to PCUI. Supplies the background and
+// overlay image names plus the on-screen button/selection rects.
+struct LDSN : public EngineData {
+	LDSN(Common::SeekableReadStream *chunkStream);
+
+	Common::String backgroundImageName;	// "UI_DesignSelectBG"
+	Common::String overlayImageName;	// "UI_DesignSelect_OVL"
+	Common::Array<Common::Rect> rects;	// button + per-character selection rects
+};
+
+// Player-UI header. Introduced in Nancy 15, first chunk of each character's
+// PUI_CRE_<char>_DEFAULT_BOOT file. Names the UI theme and its swatch image.
+struct PUIH : public EngineData {
+	PUIH(Common::SeekableReadStream *chunkStream);
+
+	byte flag = 0;
+	Common::String themeName;	// e.g. "Nancy Classic Look (Default)"
+	Common::String swatchImageName;	// e.g. "UI_Swatch_ND"
+};
+
+// Player-UI random-sound bank. Introduced in Nancy 15 (per-character boot).
+// Holds a base name plus a channel/volume and a list of sound groups, each
+// group being a set of interchangeable (randomly-picked) sound variants -
+// e.g. the character's "can't do that" response cues.
+struct PUIV : public EngineData {
+	struct SoundGroup {
+		byte tag = 0;
+		Common::Array<Common::String> variants;
+	};
+
+	PUIV(Common::SeekableReadStream *chunkStream);
+
+	Common::String name;		// e.g. "DEF_ND_CANT"
+	uint16 channelID = 0;		// shared playback channel (inferred)
+	uint32 unknown = 0;
+	uint16 volume = 0;			// shared volume (inferred)
+	Common::Array<SoundGroup> soundGroups;
+};
 } // End of namespace Nancy
 
 #endif // NANCY_ENGINEDATA_H
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 8361b859b8b..e2579c6b2cd 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -484,6 +484,18 @@ void NancyEngine::bootGameEngine() {
 	_resource->readCifTree("ciftree", "dat", 1);
 	_resource->readCifTree("promotree", "dat", 1);
 
+	if (getGameType() >= kGameTypeNancy15) {
+		_resource->readCifTree("PUI_CRE_Nancy_Default", "dat", 1);
+		// Other player character CIF trees are loaded on demand,
+		// based on the PCUI chunk:
+		// - PUI_CRE_Nancy_Jungle
+		// - PUI_CRE_Nancy_Pink_Hibiscus
+		// - PUI_CRE_Nancy_Teal_Hibiscus
+		// - PUI_CRE_Frank_Default
+		// - PUI_CRE_HB_Default
+		// - PUI_CRE_Joe_Default
+	}
+
 	// Read the static data. Up to Nancy11 it lives in nancy.dat; from Nancy12
 	// onwards the game ships it in its own data files, so the engine only needs
 	// to provide the few remaining hardcoded values itself.
@@ -577,12 +589,13 @@ void NancyEngine::bootGameEngine() {
 	LOAD_BOOT(UICM)	// Camera UI
 
 	// Nancy 15+
-	// TASK, UIRC, UIIV, UICO, UICM, UICL,
-	// UIBW, UINB, SCTB, CURT, TMOD chunks have
-	// been removed
-	// LOAD_BOOT(LVLN)
-	// LOAD_BOOT(PCUI)
-	// LOAD_BOOT(LDSN)
+	// The EVNT, UICO, SCTB, TASK, UIRC, UIIV, UICM, UICL, UIBW, UINB chunks, plus two new ones
+	// (PUIH, PUIV) have been moved from the BOOT chunk to different per-character chunks,
+	// based on names specified in the PCUI chunk.
+	// For now, we load Nancy's chunk at boot below, from PUI_CRE_NANCY_DEFAULT_BOOT
+	LOAD_BOOT(LVLN)	// Level name table (scene-prefix code -> display name)
+	LOAD_BOOT(PCUI)	// Player-character selector (Nancy / Frank / Joe)
+	LOAD_BOOT(LDSN)	// Player-character "Design Select" screen layout
 
 	_cursor->init(iff->getChunkStream("CURS"));
 
@@ -605,6 +618,25 @@ void NancyEngine::bootGameEngine() {
 
 	delete iff;
 
+	if (getGameType() >= kGameTypeNancy15) {
+		const PCUI *pcui = GetEngineData(PCUI);
+		// Note: the default character is Nancy, so we load her boot chunks here. Her CIF name is
+		// PUI_CRE_NANCY_DEFAULT_BOOT.
+		iff = _resource->loadIFF(Common::Path(pcui->characters[0].defaultImageName + "_boot"));
+		LOAD_BOOT(TASK)
+		LOAD_BOOT(UIIV)
+		LOAD_BOOT(UICO)
+		LOAD_BOOT(UICL)
+		LOAD_BOOT(UIBW)
+		LOAD_BOOT(UINB)
+		LOAD_BOOT(SCTB)
+		LOAD_BOOT(UIRC)
+		LOAD_BOOT(UICM)
+		LOAD_BOOT(PUIH)	// Player-UI header (theme name + swatch image)
+		LOAD_BOOT(PUIV)	// Player-UI random-sound bank ("can't" responses)
+		delete iff;
+	}
+
 	if (getGameType() >= kGameTypeNancy12) {
 		LOAD_CHUNK("FLAGS", EVNT, "EVNT", "EVNT")
 




More information about the Scummvm-git-logs mailing list