[Scummvm-git-logs] scummvm master -> 3b875efc0e7b3d06a3846f8a73fd07cd895262f5

bluegr noreply at scummvm.org
Thu Jul 2 06:23:23 UTC 2026


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

Summary:
296f7391e7 NANCY: NANCY12: Add enums for OneBuildPuzzle
1d80f75fe7 NANCY: NANCY13: Add handling of new boot chunk data
a047513400 NANCY: NANCY10: Simplify the FrameTextBox AR
827cdcee96 NANCY: NANCY13: Map Action Records and add relevant TODOs
eb11becdbf NANCY: NANCY6: Fix subtitle not shown in HamRadioPuzzle
5d9736aacb NANCY: NANCY6: Fix answer checking in PasswordPuzzle
8909a5ddd9 NANCY: Refactor movie playing code to be inside a common class
c3fd298f75 NANCY: NANCY12: Implement BoardGamePuzzle
3b875efc0e NANCY: NANCY12: Initial work on MirrorLightPuzzle and ActionZone


Commit: 296f7391e7f4abb73bec6a451091d8415e289b5c
    https://github.com/scummvm/scummvm/commit/296f7391e7f4abb73bec6a451091d8415e289b5c
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:02+03:00

Commit Message:
NANCY: NANCY12: Add enums for OneBuildPuzzle

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


diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.cpp b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
index 17a795ab9f4..618c3fe777a 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -153,14 +153,14 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	_solveScene.readData(stream);           // 0x1cf
 	_cancelScene.readData(stream);          // 0x1e8 (ends the 513-byte blob)
 
-	// --- Six random-sound blocks: pickup, rotate, drop, good, bad, completion ---
-	RandomSoundBlock blocks[6];
-	for (uint i = 0; i < 6; ++i)
+	// --- Random-sound blocks: pickup, rotate, drop, good, bad, completion ---
+	RandomSoundBlock blocks[kNumSounds];
+	for (uint i = 0; i < kNumSounds; ++i)
 		blocks[i].readData(stream);
 
-	SoundDescription *sounds[6] = { &_pickupSound, &_rotateSound, &_dropSound,
-									&_goodPlacementSound, &_badPlacementSound, &_completionSound };
-	for (uint i = 0; i < 6; ++i) {
+	SoundDescription *sounds[kNumSounds] = { &_pickupSound, &_rotateSound, &_dropSound,
+											 &_goodPlacementSound, &_badPlacementSound, &_completionSound };
+	for (uint i = 0; i < kNumSounds; ++i) {
 		SoundDescription &s = *sounds[i];
 		s.name = blocks[i].names.empty() ? "NO SOUND" : blocks[i].names[0];
 		s.channelID = blocks[i].channel;
@@ -174,7 +174,7 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	Common::String *goodAlts[2] = { &_goodAlt1Filename, &_goodAlt2Filename };
 	Common::String *badAlts[2]  = { &_badAlt1Filename,  &_badAlt2Filename };
 	Common::String **altSets[3] = { dropAlts, goodAlts, badAlts };
-	const uint altBlocks[3] = { 2, 3, 4 };
+	const uint altBlocks[3] = { kDropSound, kGoodSound, kBadSound };
 	for (uint i = 0; i < 3; ++i) {
 		for (uint a = 0; a < 2; ++a) {
 			const RandomSoundBlock &b = blocks[altBlocks[i]];
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.h b/engines/nancy/action/puzzle/onebuildpuzzle.h
index 753299539ac..6eb05f42df3 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.h
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.h
@@ -111,6 +111,16 @@ protected:
 
 	Common::Array<Piece> _pieces;
 
+	// Nancy12 stores the puzzle's sounds as this many random-sound blocks, in
+	// this fixed on-disk order.
+	static const uint kNumSounds = 6;
+	static const uint kPickupSound = 0;
+	static const uint kRotateSound = 1;
+	static const uint kDropSound = 2;
+	static const uint kGoodSound = 3;
+	static const uint kBadSound = 4;
+	static const uint kCompletionSound = 5;
+
 	SoundDescription _pickupSound;
 	SoundDescription _rotateSound;
 	SoundDescription _dropSound;


Commit: 1d80f75fe71d34db61f22188be284f930f907712
    https://github.com/scummvm/scummvm/commit/1d80f75fe71d34db61f22188be284f930f907712
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:04+03:00

Commit Message:
NANCY: NANCY13: Add handling of new boot chunk data

- Implemented differences in the UIIV and INV chunks
- Implemented a parser for the MMIX (Music Mix Table) chunk
- Corrected the number of item and regular cursors

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 88e9128045f..732982644c8 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -159,7 +159,10 @@ INV::INV(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	s.syncAsUint16LE(captionAutoClearTime, kGameTypeNancy3);
 
 	readFilename(s, inventoryBoxIconsImageName);
-	readFilename(s, inventoryCursorsImageName);
+	// Nancy13 split the cursor sheet into two images and moved both cursor
+	// image names into the CURS chunk, so the inventory cursors name is no
+	// longer stored here.
+	readFilename(s, inventoryCursorsImageName, kGameTypeVampire, kGameTypeNancy12);
 
 	s.skip(0x4, kGameTypeVampire, kGameTypeNancy1); // inventory box icons surface w/h
 	s.skip(0x4, kGameTypeVampire, kGameTypeNancy1); // inventory cursors surface w/h
@@ -1081,6 +1084,12 @@ UIIV::UIIV(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	readRectArray(*chunkStream, slotSrcRects, 16);
 	readRectArray(*chunkStream, slotDestRects, 16);
 
+	// Nancy13 inserted a 16-byte Rect here (before the original 2-byte field):
+	// the clickable bounding region of the inventory item slots, used as a
+	// PtInRect gate before hit-testing the individual slots.
+	if (g_nancy->getGameType() >= kGameTypeNancy13)
+		readRect(*chunkStream, slotsHotspot);
+
 	chunkStream->skip(2);
 
 	for (uint i = 0; i < kNumFilters; ++i) {
@@ -1150,4 +1159,19 @@ UIRC::UIRC(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	}
 }
 
+MMIX::MMIX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+	const uint16 count = chunkStream->readUint16LE();
+	records.resize(count);
+
+	for (uint16 i = 0; i < count; ++i) {
+		Record &rec = records[i];
+		readFilename(*chunkStream, rec.name);
+		const uint16 numTracks = chunkStream->readUint16LE();
+		rec.musicNames.resize(numTracks);
+		for (uint16 j = 0; j < numTracks; ++j) {
+			readFilename(*chunkStream, rec.musicNames[j]);
+		}
+	}
+}
+
 } // End of namespace Nancy
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 80311273362..8658e75a767 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -715,6 +715,7 @@ struct UIIV : public EngineData {
 	UIPopupHeader header;
 	Common::Array<Common::Rect> slotSrcRects;       // 16 entries (image coords)
 	Common::Array<Common::Rect> slotDestRects;      // 16 entries (screen coords)
+	Common::Rect slotsHotspot;                      // Nancy13+: clickable region of the item slots
 	UIButtonSlot filters[kNumFilters];              // 6 entries
 	Common::Array<Common::Rect> tabCaptionSrcRects; // 6 entries
 	Common::Rect tabCaptionDestRect;                // on-screen target
@@ -774,6 +775,20 @@ struct UIRC : public EngineData {
 
 	Common::Array<ItemRecord> items;
 };
+
+// Music mix table. Introduced in Nancy 13. Each record maps a short location
+// code (e.g. "BRI", "CAM", "TUT") to the set of music / ambience tracks that
+// may play there ("TacitA"/"TacitB" are the silence variants).
+struct MMIX : public EngineData {
+	struct Record {
+		Common::String name;
+		Common::Array<Common::String> musicNames;
+	};
+
+	MMIX(Common::SeekableReadStream *chunkStream);
+
+	Common::Array<Record> records;
+};
 } // End of namespace Nancy
 
 #endif // NANCY_ENGINEDATA_H
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 68778d92ce1..b4ee347a02c 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -567,8 +567,8 @@ void NancyEngine::bootGameEngine() {
 	LOAD_BOOT(UIRC)	// UI overlay element table
 
 	// Nancy 13+
-	// RCPR and RCLB chunks have been removed
-	// LOAD_BOOT(MMIX)
+	// RCPR and RCLB chunks have been removed (used in the RayCast puzzle, which has been dropped)
+	LOAD_BOOT(MMIX)	// Music mix table (location code -> music tracks)
 
 	// Nancy 14
 	// LOAD_BOOT(UICM)
@@ -768,8 +768,8 @@ void NancyEngine::populateStaticData() {
 		_staticData.numCursorTypes = 37;
 		break;
 	case kGameTypeNancy13:
-		_staticData.numItems = 50;
-		_staticData.numCursorTypes = 37;
+		_staticData.numItems = 42;
+		_staticData.numCursorTypes = 45;
 		break;
 	case kGameTypeNancy14:
 	case kGameTypeNancy15:


Commit: a0475134000b6b94e428afc6419bb2911e2eb3eb
    https://github.com/scummvm/scummvm/commit/a0475134000b6b94e428afc6419bb2911e2eb3eb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:05+03:00

Commit Message:
NANCY: NANCY10: Simplify the FrameTextBox AR

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 8d508efe0bc..e6ece524fbf 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -269,13 +269,12 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new ModifyListEntry(ModifyListEntry::kDelete);
 	case 73:
 		return new ModifyListEntry(ModifyListEntry::kMark);
-	case 74:	// Added in Nancy 10
-	case 75:	// Changed in Nancy 10
-		if (g_nancy->getGameType() <= kGameTypeNancy9 && type == 75) {
+	case 74:	// Nancy 10
+		return new FrameTextBox(true);
+	case 75:
+		if (g_nancy->getGameType() <= kGameTypeNancy9)
 			return new TextBoxWrite();
-		} else {
-			return new FrameTextBox(static_cast<FrameTextBox::Variant>(type));
-		}
+		return new FrameTextBox(false);
 	case 76:
 		return new TextboxClear();
 	case 77:
@@ -366,20 +365,19 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 126:
 		return new GoInvViewScene();
 	case 128:
-		// Nancy 10+
+		// Nancy10+
 		return new CellPhonePopCellSceneFromStack();
 	case 129:
-		// Nancy 10+
+		// Nancy10+
 		return new SetCellPhoneBatteryAndSignal();
 	case 130:
-		// Nancy 10+
+		// Nancy10+
 		return new ChangeCellPhoneInfo();
 	case 131:
-		// Nancy 10+
+		// Nancy10+
 		return new AddSearchLink();
 	case 132:
-		// ResourceUse ("UIResource Adjust") - adjusts a UI overlay resource (see the UIRC
-		// boot chunk) at runtime. Added in Nancy12.
+		// Nancy12+
 		return new ResourceUse();
 	case 140:
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 544e4a56517..eceec99fa7f 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -221,19 +221,10 @@ void FrameTextBox::readData(Common::SeekableReadStream &stream) {
 void FrameTextBox::execute() {
 	auto &tb = NancySceneState.getTextbox();
 	tb.clear();
-	if (!_text.empty()) {
+	if (!_text.empty())
 		tb.addTextLine(_text);
-	}
-
-	// Variant 74 opens the full-width textbox overlay that covers the taskbar
-	// buttons; the original arms a 15-second timer that drops it back to the
-	// closed strip. Variant 75 is the legacy closed/strip path.
-	if (_variant == kVariant74) {
-		tb.setFullMode(true);
-	} else {
-		tb.setFullMode(false);
-	}
 
+	tb.setFullMode(_fullMode);
 	finishExecution();
 }
 
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 13ba5ce3443..740e8eecb8e 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -130,17 +130,12 @@ protected:
 // text into the new (UICO-driven) textbox
 class FrameTextBox : public ActionRecord {
 public:
-	enum Variant {
-		kVariant74 = 74,
-		kVariant75 = 75
-	};
-
-	FrameTextBox(Variant variant) : _variant(variant), _flags(0), _slot(0) {}
+	FrameTextBox(bool fullMode) : _fullMode(fullMode), _flags(0), _slot(0) {}
 
 	void readData(Common::SeekableReadStream &stream) override;
 	void execute() override;
 
-	Variant _variant;
+	bool _fullMode;
 	Common::String _text;
 	int16 _flags;
 	int16 _slot;


Commit: 827cdcee968b3337c9f1014f2120da51048c8a1b
    https://github.com/scummvm/scummvm/commit/827cdcee968b3337c9f1014f2120da51048c8a1b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:06+03:00

Commit Message:
NANCY: NANCY13: Map Action Records and add relevant TODOs

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index e6ece524fbf..834bd09e225 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -389,15 +389,33 @@ 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 147:	// Nancy11
-		return new FadeSoundToSilence();
+	case 145:
+		// Nancy13 renumbered the sound-playing AR block downwards; PlaySound
+		// moved here (it was 150 up to Nancy12).
+		// TODO: verify the Nancy13 PlaySound on-disk layout matches PlaySound::readData.
+		if (g_nancy->getGameType() >= kGameTypeNancy13)
+			return new PlaySound();
+		return nullptr;
+	case 146:
+		// Nancy13: FadeSoundToSilence moved here (was 147).
+		if (g_nancy->getGameType() >= kGameTypeNancy13)
+			return new FadeSoundToSilence();
+		return nullptr;
+	case 147:
+		if (g_nancy->getGameType() >= kGameTypeNancy13)
+			return new SetVolume();			// Nancy13: SetVolume moved here (was 148)
+		return new FadeSoundToSilence();	// Nancy11
 	case 148:
+		if (g_nancy->getGameType() >= kGameTypeNancy13)
+			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.
 		return nullptr;
 	case 149:
+		if (g_nancy->getGameType() >= kGameTypeNancy13)
+			return new StopSound();	// Nancy13: StopAndUnloadSound moved here (was 155)
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
 			return new PlaySoundEventFlagTerse();	// Moved from 161 in Nancy12
 		else if (g_nancy->getGameType() >= kGameTypeNancy9)
@@ -454,17 +472,52 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 165:
 		return new MindPuzzle();
 	case 166:
-		// OneBuildPuzzle, moved here from 234 in Nancy12.
-		// TODO: verify the Nancy12 data layout against OneBuildPuzzle::readData
+		// OneBuildPuzzle, moved here from 234 in Nancy12
 		return new OneBuildPuzzle();
 	case 167:
 		// TODO: Nancy12 ChasePuzzle (new), not implemented
 		return nullptr;
 	case 168:
 		return new Set3DSoundListenerPosition();
+	// -- Nancy 13 new/relocated puzzles (types 169-176) --
+	case 169:
+		// StepObjectsPuzzle, new in Nancy13
+		// TODO: not yet implemented
+		return nullptr;
 	case 170:
-		// Moved to 140 in Nancy12
+		if (g_nancy->getGameType() >= kGameTypeNancy13) {
+			// WordFindPuzzle, new in Nancy13. This reuses the slot that used
+			// to hold SetPlayerClock (which itself moved to 140 in Nancy12).
+			// TODO: not yet implemented
+			return nullptr;
+		}
+		// SetPlayerClock lived here up to Nancy11; moved to 140 in Nancy12
 		return new SetPlayerClock();
+	case 171:
+		// TurningPuzzle, moved here from 209 in Nancy13.
+		if (g_nancy->getGameType() >= kGameTypeNancy13)
+			return new TurningPuzzle();
+		return nullptr;
+	case 172:
+		// BlocksPuzzle, new in Nancy13
+		// TODO: not yet implemented
+		return nullptr;
+	case 173:
+		// PegsPuzzle, new in Nancy13
+		// TODO: not yet implemented
+		return nullptr;
+	case 174:
+		// Unknown Puzzle, new in Nancy13
+		// TODO: not yet implemented
+		return nullptr;
+	case 175:
+		// Unknown Puzzle, new in Nancy13
+		// TODO: not yet implemented
+		return nullptr;
+	case 176:
+		// Unknown Puzzle, new in Nancy13
+		// TODO: not yet implemented
+		return nullptr;
 	case 200:
 		return new SoundEqualizerPuzzle();
 	case 201:


Commit: eb11becdbf796bf741cd2b0a0a3660fa15341d4d
    https://github.com/scummvm/scummvm/commit/eb11becdbf796bf741cd2b0a0a3660fa15341d4d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:07+03:00

Commit Message:
NANCY: NANCY6: Fix subtitle not shown in HamRadioPuzzle

Fix #16876

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


diff --git a/engines/nancy/action/puzzle/hamradiopuzzle.cpp b/engines/nancy/action/puzzle/hamradiopuzzle.cpp
index 5ea096f246a..42343fed118 100644
--- a/engines/nancy/action/puzzle/hamradiopuzzle.cpp
+++ b/engines/nancy/action/puzzle/hamradiopuzzle.cpp
@@ -311,9 +311,14 @@ void HamRadioPuzzle::execute() {
 					break;
 				}
 
-				// Morse code is incorrect
+				// Morse code is invalid (unrecognized, or Send pressed without any
+				// dots/dashes). Play the "bad letter" sound, whose caption reads
+				// "Invalid Character", and stop here: falling through to the textbox
+				// refresh below would immediately overwrite that caption.
 				if (!foundCorrect) {
+					_curMorseString.clear();
 					_badLetterSound.loadAndPlay();
+					break;
 				}
 			}
 


Commit: 5d9736aacbe507531d46ab2fe6e8ac015be63994
    https://github.com/scummvm/scummvm/commit/5d9736aacbe507531d46ab2fe6e8ac015be63994
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:08+03:00

Commit Message:
NANCY: NANCY6: Fix answer checking in PasswordPuzzle

Fix #16886

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


diff --git a/engines/nancy/action/puzzle/passwordpuzzle.cpp b/engines/nancy/action/puzzle/passwordpuzzle.cpp
index fe0f5aa5e50..66fc2170237 100644
--- a/engines/nancy/action/puzzle/passwordpuzzle.cpp
+++ b/engines/nancy/action/puzzle/passwordpuzzle.cpp
@@ -118,8 +118,15 @@ void PasswordPuzzle::execute() {
 
 				bool solvedCurrentInput = false;
 				if (correctAnswers.size()) {
+					// The original accepts the input if a correct answer appears
+					// as a case-insensitive substring of what the player typed
+					// (e.g. answer "Xoc" is matched by typing "Lady Xoc").
+					Common::String inputLower = activeField;
+					inputLower.toLowercase();
 					for (uint i = 0; i < correctAnswers.size(); ++i) {
-						if (activeField.equalsIgnoreCase(correctAnswers[i])) {
+						Common::String answerLower = correctAnswers[i];
+						answerLower.toLowercase();
+						if (inputLower.contains(answerLower)) {
 							solvedCurrentInput = true;
 							break;
 						}


Commit: 8909a5ddd9924c0304155058e925bfb9e056e720
    https://github.com/scummvm/scummvm/commit/8909a5ddd9924c0304155058e925bfb9e056e720
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:09+03:00

Commit Message:
NANCY: Refactor movie playing code to be inside a common class

This helps with the following:
- avoid code duplication
- helps create a common video interface
- helps with separation of concerns
- allow us to reuse that code in some puzzles in the future
- add caching at a lower layer, to help with display of Bink video
  panoramas
- hides all the code that checks for the video type, which is now done
  by checking for file existence

Changed paths:
  A engines/nancy/movieplayer.cpp
  A engines/nancy/movieplayer.h
    engines/nancy/action/actionmanager.cpp
    engines/nancy/action/conversation.cpp
    engines/nancy/action/conversation.h
    engines/nancy/action/interactivevideo.cpp
    engines/nancy/action/secondarymovie.cpp
    engines/nancy/action/secondarymovie.h
    engines/nancy/action/secondaryvideo.cpp
    engines/nancy/action/secondaryvideo.h
    engines/nancy/console.cpp
    engines/nancy/module.mk
    engines/nancy/state/map.cpp
    engines/nancy/state/map.h
    engines/nancy/ui/viewport.cpp
    engines/nancy/ui/viewport.h


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 625aff0beaf..324571c365b 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -656,7 +656,7 @@ void ActionManager::synchronizeMovieWithSound() {
 
 			if (length.msecs() != 0) {
 				// ..and set the movie's playback speed to match
-				movie->_decoder->setRate(Common::Rational(movie->_decoder->getDuration().msecs(), length.msecs()));
+				movie->_decoder.setRate(Common::Rational(movie->_decoder.getDuration().msecs(), length.msecs()));
 			}
 		}
 	}
diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index f4370ebc9c5..d49e175d45d 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -673,7 +673,7 @@ bool ConversationSound::ConversationFlags::isSatisfied() const {
 }
 
 void ConversationVideo::init() {
-	if (!_decoder.loadFile(Common::Path(_videoName + ".avf"))) {
+	if (!_decoder.loadFile(Common::Path(_videoName))) {
 		error("Couldn't load video file %s", _videoName.c_str());
 	}
 
diff --git a/engines/nancy/action/conversation.h b/engines/nancy/action/conversation.h
index 558b69ad2e9..5c6e7d180f9 100644
--- a/engines/nancy/action/conversation.h
+++ b/engines/nancy/action/conversation.h
@@ -23,7 +23,7 @@
 #define NANCY_ACTION_CONVERSATION_H
 
 #include "engines/nancy/action/actionrecord.h"
-#include "engines/nancy/video.h"
+#include "engines/nancy/movieplayer.h"
 
 namespace Nancy {
 namespace Action {
@@ -153,7 +153,7 @@ protected:
 	uint _videoFormat = kLargeVideoFormat;
 	uint16 _firstFrame = 0;
 	int16 _lastFrame = 0;
-	AVFDecoder _decoder;
+	MoviePlayer _decoder;
 };
 
 class ConversationCelLoader;
diff --git a/engines/nancy/action/interactivevideo.cpp b/engines/nancy/action/interactivevideo.cpp
index 21ea17c58b6..e9a4c882a7b 100644
--- a/engines/nancy/action/interactivevideo.cpp
+++ b/engines/nancy/action/interactivevideo.cpp
@@ -116,7 +116,7 @@ void InteractiveVideo::handleInput(NancyInput &input) {
 		return;
 	}
 
-	int curFrame = _movieAR->_decoder->getCurFrame();
+	int curFrame = _movieAR->_decoder.getCurFrame();
 	if (curFrame < 0) {
 		return;
 	}
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 1b1e5d2152d..c504e75271e 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -44,13 +44,6 @@ PlaySecondaryMovie::PlaySecondaryMovie(bool isRandom)
 	}
 }
 
-void PlaySecondaryMovie::resetDecoder() {
-	if (_videoType == kVideoPlaytypeAVF) {
-		_decoder.reset(new AVFDecoder());
-	} else {
-		_decoder.reset(new Video::BinkDecoder());
-	}
-}
 
 PlaySecondaryMovie::~PlaySecondaryMovie() {
 	if (NancySceneState.getActiveMovie() == this) {
@@ -123,7 +116,6 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
 			_videoName = src.name;
 			_firstFrame = src.startFrame;
 			_lastFrame = src.lastFrame;
-			_videoType = kVideoPlaytypeBink;
 			_videoFormat = kLargeVideoFormat;
 			_videoSceneChange = kMovieNoSceneChange;
 			_playerCursorAllowed = (byte)_randomPlayerCursorAllowed;
@@ -145,20 +137,9 @@ bool PlaySecondaryMovie::activateRandomSequence(int index) {
 	_firstFrame = src.startFrame;
 	_lastFrame = src.lastFrame;
 
-	// Reload the decoder with the new movie. The original engine
-	// auto-detects AVF vs Bink from disk; we honour the existing
-	// _videoType but fall back to the other if needed.
-	resetDecoder();
-
-	Common::Path withExt = _videoName.append(_videoType == kVideoPlaytypeAVF ? ".avf" : ".bik");
-	if (!_decoder->loadFile(withExt)) {
-		_videoType = _videoType == kVideoPlaytypeAVF ? kVideoPlaytypeBink : kVideoPlaytypeAVF;
-		resetDecoder();
-		withExt = _videoName.append(_videoType == kVideoPlaytypeAVF ? ".avf" : ".bik");
-		if (!_decoder->loadFile(withExt)) {
-			warning("PlayRandomMovie: couldn't load %s", _videoName.toString().c_str());
-			return false;
-		}
+	if (!_decoder.loadFile(_videoName)) {
+		warning("PlayRandomMovie: couldn't load %s", _videoName.toString().c_str());
+		return false;
 	}
 
 	_isFinished = false;
@@ -205,9 +186,7 @@ int PlaySecondaryMovie::rollNextSequence() {
 		_randomPauseEndTime = g_system->getMillis() + (uint32)MAX<int32>(0, pauseMs);
 		_randomChainState = kRandomPaused;
 		setVisible(false);
-		if (_decoder) {
-			_decoder->pauseVideo(true);
-		}
+		_decoder.pauseVideo(true);
 		return -1;
 	}
 
@@ -243,7 +222,7 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
 	readFilename(ser, _paletteName, kGameTypeVampire, kGameTypeVampire);
 	readFilename(ser, _bitmapOverlayName, kGameTypeVampire, kGameTypeNancy9);
 
-	ser.syncAsUint16LE(_videoType, kGameTypeNancy7);
+	ser.skip(2, kGameTypeNancy7);	// videoType
 	ser.skip(2, kGameTypeVampire, kGameTypeNancy9); // videoPlaySource
 	ser.syncAsUint16LE(_videoFormat);
 	if (g_nancy->getGameType() >= kGameTypeNancy10)
@@ -300,12 +279,8 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
 }
 
 void PlaySecondaryMovie::init() {
-	if (!_decoder) {
-		resetDecoder();
-	}
-
-	if (!_decoder->isVideoLoaded()) {
-		if (!_decoder->loadFile(_videoName.append(_videoType == kVideoPlaytypeAVF ? ".avf" : ".bik"))) {
+	if (!_decoder.isVideoLoaded()) {
+		if (!_decoder.loadFile(_videoName)) {
 			error("Couldn't load video file %s", _videoName.toString().c_str());
 		}
 
@@ -330,11 +305,7 @@ void PlaySecondaryMovie::init() {
 }
 
 void PlaySecondaryMovie::onPause(bool pause) {
-	if (!_decoder) {
-		resetDecoder();
-	}
-
-	_decoder->pauseVideo(pause);
+	_decoder.pauseVideo(pause);
 	RenderActionRecord::onPause(pause);
 }
 
@@ -361,7 +332,7 @@ void PlaySecondaryMovie::execute() {
 			// Sync audio and video. This is mostly relevant for some nancy2 scenes, as the
 			// devs stopped using the built-in movie sound around nancy4. The 12 ms
 			// difference is roughly how long it takes for a single execution of the main game loop
-			((AVFDecoder *)_decoder.get())->addFrameTime(12);
+			_decoder.addFrameTime(12);
 		}
 
 		if (_playerCursorAllowed == kNoPlayerCursorAllowed) {
@@ -375,7 +346,7 @@ void PlaySecondaryMovie::execute() {
 
 		_state = kRun;
 
-		if (Common::Rect(_decoder->getWidth(), _decoder->getHeight()) == NancySceneState.getViewport().getBounds()) {
+		if (Common::Rect(_decoder.getWidth(), _decoder.getHeight()) == NancySceneState.getViewport().getBounds()) {
 			g_nancy->_graphics->suppressNextDraw();
 			break;
 		}
@@ -432,18 +403,18 @@ void PlaySecondaryMovie::execute() {
 		// another action record, but doesn't do so, because updateGraphics() gets called after all
 		// action record execution. Instead, the movie's own scene change (which is inexplicably enabled)
 		// gets triggered, and teleports the player to the wrong place instead of making them lose the game
-		if (!_decoder->isPlaying() && _isVisible && !_isFinished) {
-			_decoder->start();
+		if (!_decoder.isPlaying() && _isVisible && !_isFinished) {
+			_decoder.start();
 
 			if (_playDirection == kPlayMovieReverse) {
-				_decoder->setRate(-_decoder->getRate());
-				_decoder->seekToFrame(_lastFrame);
+				_decoder.setRate(-_decoder.getRate());
+				_decoder.seekToFrame(_lastFrame);
 			} else {
-				_decoder->seekToFrame(_firstFrame);
+				_decoder.seekToFrame(_firstFrame);
 			}
 		}
 
-		if (_decoder->needsUpdate()) {
+		if (_decoder.needsUpdate()) {
 			uint descID = 0;
 
 			for (uint i = 0; i < _videoDescs.size(); ++i) {
@@ -452,24 +423,24 @@ void PlaySecondaryMovie::execute() {
 				}
 			}
 
-			GraphicsManager::copyToManaged(*_decoder->decodeNextFrame(), _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
+			GraphicsManager::copyToManaged(*_decoder.decodeNextFrame(), _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
 			_drawSurface.create(_fullFrame, _videoDescs[descID].srcRect);
 			moveTo(_videoDescs[descID].destRect);
 
 			_needsRedraw = true;
 
 			for (auto &f : _frameFlags) {
-				if (_decoder->getCurFrame() == f.frameID) {
+				if (_decoder.getCurFrame() == f.frameID) {
 					NancySceneState.setEventFlag(f.flagDesc);
 				}
 			}
 		}
 
-		if ((_decoder->getCurFrame() == _lastFrame && _playDirection == kPlayMovieForward) ||
-			(_decoder->getCurFrame() == _firstFrame && _playDirection == kPlayMovieReverse) ||
-			_decoder->endOfVideo()) {
+		if ((_decoder.getCurFrame() == _lastFrame && _playDirection == kPlayMovieForward) ||
+			(_decoder.getCurFrame() == _firstFrame && _playDirection == kPlayMovieReverse) ||
+			_decoder.endOfVideo()) {
 
-			_decoder->pauseVideo(true);
+			_decoder.pauseVideo(true);
 			_isFinished = true;
 
 			if (_isRandom) {
@@ -507,8 +478,8 @@ void PlaySecondaryMovie::execute() {
 		// Allow looping
 		if (!_isDone) {
 			_isFinished = false;
-			_decoder->seek(0);
-			_decoder->pauseVideo(false);
+			_decoder.seek(0);
+			_decoder.pauseVideo(false);
 		} else if (_playerCursorAllowed == kNoPlayerCursorAllowed) {
 			// The movie finished and isn't looping, so restore the cursor now.
 			// WORKAROUND: Don't restore the cursor for Nancy 8, scenes 5420 - 5422
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index a251bde1060..e272dee2722 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -25,10 +25,7 @@
 #include "common/ptr.h"
 
 #include "engines/nancy/action/actionrecord.h"
-
-namespace Video {
-class VideoDecoder;
-}
+#include "engines/nancy/movieplayer.h"
 
 namespace Nancy {
 namespace Action {
@@ -98,7 +95,6 @@ public:
 	Common::Path _paletteName;
 	Common::Path _bitmapOverlayName;
 
-	uint16 _videoType = kVideoPlaytypeAVF;
 	uint16 _videoFormat = kLargeVideoFormat;
 	uint16 _videoSceneChange = kMovieNoSceneChange;
 	byte _playerCursorAllowed = kPlayerCursorAllowed;
@@ -114,7 +110,7 @@ public:
 	SceneChangeDescription _sceneChange;
 	Common::Array<SecondaryVideoDescription> _videoDescs;
 
-	Common::ScopedPtr<Video::VideoDecoder> _decoder;
+	MoviePlayer _decoder;
 
 	// Random-movie state (only populated when _isRandom).
 	bool _isRandom = false;
@@ -154,9 +150,6 @@ protected:
 	void readRandomMovieData(Common::Serializer &ser, Common::SeekableReadStream &stream);
 	void readRandomSequence(Common::Serializer &ser, RandomSequence &seq);
 
-	// (Re)create _decoder as an AVFDecoder or BinkDecoder matching _videoType.
-	void resetDecoder();
-
 	// Apply a RandomSequence's playback config to the PSM flat fields
 	// and reload the decoder. Returns true on success.
 	bool activateRandomSequence(int index);
diff --git a/engines/nancy/action/secondaryvideo.cpp b/engines/nancy/action/secondaryvideo.cpp
index c1547957c57..25ad754dfd2 100644
--- a/engines/nancy/action/secondaryvideo.cpp
+++ b/engines/nancy/action/secondaryvideo.cpp
@@ -38,7 +38,7 @@ void PlaySecondaryVideo::init() {
 		_decoder.close();
 	}
 
-	if (!_decoder.loadFile(_filename.append(".avf"))) {
+	if (!_decoder.loadFile(_filename)) {
 		error("Couldn't load video file %s", _filename.toString().c_str());
 	}
 
@@ -63,7 +63,7 @@ void PlaySecondaryVideo::updateGraphics() {
 		return;
 	}
 
-	if (_isInFrame && _decoder.isPlaying() ? _decoder.needsUpdate() || _decoder.atEnd() : true) {
+	if (_isInFrame && _decoder.isPlaying() ? _decoder.needsUpdate() || _decoder.endOfVideo() : true) {
 		int lastAnimationFrame = -1;
 		switch (_hoverState) {
 		case kNoHover:
@@ -115,7 +115,7 @@ void PlaySecondaryVideo::updateGraphics() {
 			}
 
 			if (lastAnimationFrame > -1 &&
-					(_decoder.atEnd() ||
+					(_decoder.endOfVideo() ||
 					 _decoder.getCurFrame() == lastAnimationFrame)) {
 				if (_hoverState == kNoHover) {
 					_decoder.seekToFrame(_loopFirstFrame);
diff --git a/engines/nancy/action/secondaryvideo.h b/engines/nancy/action/secondaryvideo.h
index d6cde625e98..3167c2677ee 100644
--- a/engines/nancy/action/secondaryvideo.h
+++ b/engines/nancy/action/secondaryvideo.h
@@ -24,7 +24,7 @@
 
 #include "engines/nancy/cursor.h"
 #include "engines/nancy/nancy.h"
-#include "engines/nancy/video.h"
+#include "engines/nancy/movieplayer.h"
 #include "engines/nancy/action/actionrecord.h"
 
 namespace Nancy {
@@ -92,7 +92,7 @@ protected:
 
 	Graphics::ManagedSurface _fullFrame;
 	HoverState _hoverState = kNoHover;
-	AVFDecoder _decoder;
+	MoviePlayer _decoder;
 	int _currentViewportFrame = -1;
 	int _currentViewportScroll = -1;
 	bool _isInFrame = false;
diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index a8fc38dbac5..b7d5068ae2e 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -25,8 +25,8 @@
 
 #include "audio/audiostream.h"
 #include "image/bmp.h"
-#include "video/bink_decoder.h"
 
+#include "engines/nancy/movieplayer.h"
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/console.h"
 #include "engines/nancy/resource.h"
@@ -76,31 +76,20 @@ NancyConsole::~NancyConsole() {}
 void NancyConsole::postEnter() {
 	GUI::Debugger::postEnter();
 	if (!_videoFile.empty()) {
-		Common::Path withExt = _videoFile;
-		Video::VideoDecoder *dec = new AVFDecoder();
-
-		if (!dec->loadFile(withExt.append(".avf"))) {
-			// No AVF found, try Bink
-			delete dec;
-			dec = new Video::BinkDecoder();
-
-			if (!dec->loadFile(withExt.append(".bik"))) {
-				debugPrintf("Failed to load video '%s'\n", _videoFile.toString(Common::Path::kNativeSeparator).c_str());
-				delete dec;
-				dec = nullptr;
-			}
-		}
+		MoviePlayer player;
 
-		if (dec) {
+		if (!player.loadFile(_videoFile)) {
+			debugPrintf("Failed to load video '%s'\n", _videoFile.toString(Common::Path::kNativeSeparator).c_str());
+		} else {
 			Graphics::ManagedSurface surf;
 
 			if (!_paletteFile.empty()) {
 				GraphicsManager::loadSurfacePalette(surf, _paletteFile);
 			}
 
-			dec->start();
+			player.start();
 			Common::EventManager *ev = g_system->getEventManager();
-			while (!g_nancy->shouldQuit() && !dec->endOfVideo()) {
+			while (!g_nancy->shouldQuit() && !player.endOfVideo()) {
 				Common::Event event;
 				if (ev->pollEvent(event)) {
 					if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_END && event.customType == InputManager::kNancyActionLeftClick) {
@@ -108,8 +97,8 @@ void NancyConsole::postEnter() {
 					}
 				}
 
-				if (dec->needsUpdate()) {
-					const Graphics::Surface *frame = dec->decodeNextFrame();
+				if (player.needsUpdate()) {
+					const Graphics::Surface *frame = player.decodeNextFrame();
 					if (frame) {
 						GraphicsManager::copyToManaged(*frame, surf, !_paletteFile.empty());
 						g_nancy->_graphics->debugDrawToScreen(surf);
@@ -124,7 +113,6 @@ void NancyConsole::postEnter() {
 
 		_videoFile.clear();
 		_paletteFile.clear();
-		delete dec;
 	}
 
 	if (!_imageFile.empty()) {
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 1fb0010dfd5..79b5aeeaf7a 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -98,6 +98,7 @@ MODULE_OBJS = \
   font.o \
   graphics.o \
   iff.o \
+  movieplayer.o \
   input.o \
   metaengine.o \
   nancy.o \
diff --git a/engines/nancy/movieplayer.cpp b/engines/nancy/movieplayer.cpp
new file mode 100644
index 00000000000..bb9727d5e65
--- /dev/null
+++ b/engines/nancy/movieplayer.cpp
@@ -0,0 +1,198 @@
+/* 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/file.h"
+#include "common/system.h"
+
+#include "video/bink_decoder.h"
+
+#include "engines/nancy/video.h"
+#include "engines/nancy/commontypes.h"
+
+#include "engines/nancy/movieplayer.h"
+
+namespace Nancy {
+
+MoviePlayer::MoviePlayer() {}
+
+MoviePlayer::~MoviePlayer() {}
+
+bool MoviePlayer::loadFile(const Common::Path &name, bool bidirectionalCache) {
+	const Common::Path avfPath = name.append(".avf");
+	const Common::Path bikPath = name.append(".bik");
+
+	// Detect the format from which file exists (AVF wins if both do).
+	if (Common::File::exists(avfPath)) {
+		_videoType = kVideoPlaytypeAVF;
+		_decoder.reset(new AVFDecoder(bidirectionalCache ? AVFDecoder::kLoadBidirectional : AVFDecoder::kLoadForward));
+	} else if (Common::File::exists(bikPath)) {
+		_videoType = kVideoPlaytypeBink;
+		_decoder.reset(new Video::BinkDecoder());
+	} else {
+		_decoder.reset();
+		return false;
+	}
+
+	_currentSurface = nullptr;
+
+	if (!_decoder->loadFile(_videoType == kVideoPlaytypeAVF ? avfPath : bikPath)) {
+		_decoder.reset();
+		return false;
+	}
+	return true;
+}
+
+bool MoviePlayer::isVideoLoaded() const {
+	return _decoder && _decoder->isVideoLoaded();
+}
+
+void MoviePlayer::close() {
+	_currentSurface = nullptr;
+	if (_decoder) {
+		_decoder->close();
+	}
+}
+
+void MoviePlayer::start()					{ if (_decoder) _decoder->start(); }
+void MoviePlayer::stop()					{ if (_decoder) _decoder->stop(); }
+void MoviePlayer::pauseVideo(bool pause)	{ if (_decoder) _decoder->pauseVideo(pause); }
+bool MoviePlayer::isPlaying() const			{ return _decoder && _decoder->isPlaying(); }
+bool MoviePlayer::needsUpdate() const		{ return _decoder && _decoder->needsUpdate(); }
+
+bool MoviePlayer::endOfVideo() const {
+	if (!_decoder) {
+		return true;
+	}
+	// AVF has its own stricter end check (accounts for playback time / reversal).
+	if (_videoType == kVideoPlaytypeAVF) {
+		return ((AVFDecoder *)_decoder.get())->atEnd();
+	}
+	return _decoder->endOfVideo();
+}
+
+void MoviePlayer::seekToFrame(uint frame)				{ if (_decoder) _decoder->seekToFrame(frame); }
+void MoviePlayer::seek(const Audio::Timestamp &time)	{ if (_decoder) _decoder->seek(time); }
+void MoviePlayer::rewind()								{ if (_decoder) _decoder->rewind(); }
+void MoviePlayer::setRate(const Common::Rational &rate)	{ _decoder->setRate(rate); }
+Common::Rational MoviePlayer::getRate() const			{ return _decoder->getRate(); }
+void MoviePlayer::setReverse(bool reverse)				{ _decoder->setReverse(reverse); }
+
+int MoviePlayer::getCurFrame() const		{ return _decoder ? _decoder->getCurFrame() : -1; }
+int MoviePlayer::getFrameCount() const		{ return _decoder ? _decoder->getFrameCount() : 0; }
+Audio::Timestamp MoviePlayer::getDuration() const	{ return _decoder->getDuration(); }
+uint16 MoviePlayer::getWidth() const		{ return _decoder->getWidth(); }
+uint16 MoviePlayer::getHeight() const		{ return _decoder->getHeight(); }
+
+void MoviePlayer::addFrameTime(uint16 timeToAdd) {
+	if (_decoder && _videoType == kVideoPlaytypeAVF) {
+		((AVFDecoder *)_decoder.get())->addFrameTime(timeToAdd);
+	}
+}
+
+const Graphics::Surface *MoviePlayer::decodeNextFrame(int frameNr) {
+	if (frameNr < 0) {
+		return _decoder->decodeNextFrame();
+	}
+
+	if (_videoType == kVideoPlaytypeAVF) {
+		AVFDecoder *decoder = (AVFDecoder *)_decoder.get();
+		const Graphics::Surface *frame = decoder->decodeFrame(frameNr);
+		decoder->seek(frameNr);	// seek to take advantage of caching
+		return frame;
+	}
+
+	_decoder->seekToFrame(frameNr);
+	return _decoder->decodeNextFrame();
+}
+
+// --- Simple frame-range player ------------------------------------------
+
+void MoviePlayer::storeCurrentFrame() {
+	_currentSurface = _decoder->decodeNextFrame();
+}
+
+void MoviePlayer::goToFrame(int frameNr) {
+	if (!_decoder) {
+		return;
+	}
+
+	if (!_decoder->isPlaying()) {
+		_decoder->start();
+	}
+	_rangePlaying = false;
+	_decoder->setReverse(false);
+	_decoder->seekToFrame(frameNr);
+	storeCurrentFrame();
+
+	int frameCount = _decoder->getFrameCount();
+	uint32 durationMs = _decoder->getDuration().msecs();
+	_frameDelayMs = (frameCount > 0 && durationMs > 0) ? (durationMs / frameCount) : 66;
+}
+
+void MoviePlayer::playRange(int first, int last) {
+	if (!_decoder) {
+		return;
+	}
+
+	if (!_decoder->isPlaying()) {
+		_decoder->start();
+	}
+	_step = (last >= first) ? 1 : -1;
+	_rangeLast = last;
+	_decoder->setReverse(_step < 0);
+	_decoder->seekToFrame(first);
+	storeCurrentFrame();
+	_rangePlaying = (first != last);
+	_lastFrameTime = g_system->getMillis();
+}
+
+bool MoviePlayer::update() {
+	if (!_rangePlaying || !_decoder) {
+		return false;
+	}
+
+	// Own the frame timing (one frame per delay, never skipping), so playback
+	// starts immediately and stays smooth even when the decoder's own timing
+	// would stall after a seek.
+	uint32 now = g_system->getMillis();
+	if (now - _lastFrameTime < _frameDelayMs) {
+		return false;
+	}
+	_lastFrameTime = now;
+
+	if (_decoder->endOfVideo() ||
+			(_step > 0 && _decoder->getCurFrame() >= _rangeLast) ||
+			(_step < 0 && _decoder->getCurFrame() <= _rangeLast)) {
+		_rangePlaying = false;
+		return false;
+	}
+
+	storeCurrentFrame();
+	return true;
+}
+
+void MoviePlayer::drawFrame(Graphics::ManagedSurface &dst, const Common::Point &pos) const {
+	if (_currentSurface) {
+		dst.blitFrom(*_currentSurface, pos);
+	}
+}
+
+} // End of namespace Nancy
diff --git a/engines/nancy/movieplayer.h b/engines/nancy/movieplayer.h
new file mode 100644
index 00000000000..6b32b5b923d
--- /dev/null
+++ b/engines/nancy/movieplayer.h
@@ -0,0 +1,122 @@
+/* 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_MOVIEPLAYER_H
+#define NANCY_MOVIEPLAYER_H
+
+#include "common/ptr.h"
+#include "common/path.h"
+#include "common/rational.h"
+#include "common/rect.h"
+
+#include "audio/timestamp.h"
+
+#include "graphics/managed_surface.h"
+
+#include "engines/nancy/commontypes.h"
+
+namespace Graphics {
+struct Surface;
+}
+
+namespace Video {
+class VideoDecoder;
+}
+
+namespace Nancy {
+
+// The single low-level interface to the AVF/Bink video decoders. Every consumer
+// (PlaySecondaryMovie, PlaySecondaryVideo, Conversation, Viewport, Map,
+// BoardGamePuzzle, ...) owns one of these instead of a raw Video::VideoDecoder,
+// so the AVF-vs-Bink specifics (decoder creation, .avf/.bik file selection, and
+// the AVF-only behaviour of decodeFrame/addFrameTime/endOfVideo) live in one
+// place. All other methods forward 1:1 to the underlying decoder, so playback
+// behaviour is unchanged; higher-level playback/timing logic stays per consumer.
+class MoviePlayer {
+public:
+	MoviePlayer();
+	~MoviePlayer();
+
+	// Load <name> + ".avf"/".bik", auto-detecting the format from which file
+	// exists (AVF preferred if both do) and creating the matching decoder.
+	// bidirectionalCache selects the AVF frame-cache hint (for scrubbing).
+	bool loadFile(const Common::Path &name, bool bidirectionalCache = false);
+	bool isVideoLoaded() const;
+	void close();
+	Video::VideoDecoder *getDecoder() { return _decoder.get(); }
+
+	// Playback control - forwarded to the decoder.
+	void start();
+	void stop();
+	void pauseVideo(bool pause);
+	bool isPlaying() const;
+	bool needsUpdate() const;
+	bool endOfVideo() const;	// AVF uses AVFDecoder::atEnd()
+
+	void seekToFrame(uint frame);
+	void seek(const Audio::Timestamp &time);
+	void rewind();
+	void setRate(const Common::Rational &rate);
+	Common::Rational getRate() const;
+	void setReverse(bool reverse);
+
+	int getCurFrame() const;
+	int getFrameCount() const;
+	Audio::Timestamp getDuration() const;
+	uint16 getWidth() const;
+	uint16 getHeight() const;
+	void addFrameTime(uint16 timeToAdd);	// AVF only, no-op otherwise
+
+	// Decode a frame: frameNr < 0 returns the next frame; otherwise that
+	// specific frame via the format-appropriate cached path (AVF decodeFrame +
+	// seek; Bink seekToFrame + decodeNextFrame).
+	const Graphics::Surface *decodeNextFrame(int frameNr = -1);
+
+	// --- Optional simple frame-range player, built on the above. Handy for
+	// consumers (e.g. BoardGamePuzzle) that just want to show a still frame or
+	// play [first, last] over time. Uses its own, no-skip timing so it starts
+	// immediately and stays smooth, and decodes into an internal surface.
+	void goToFrame(int frameNr);				// show a single still frame
+	void playRange(int first, int last);		// begin timed playback (reverse if last < first)
+	bool update();								// advance; true if the shown frame changed
+	bool isRangePlaying() const { return _rangePlaying; }
+	int getCurrentFrame() const { return getCurFrame(); }
+	void drawFrame(Graphics::ManagedSurface &dst, const Common::Point &pos) const;
+
+private:
+	void storeCurrentFrame();
+
+	Common::ScopedPtr<Video::VideoDecoder> _decoder;
+	byte _videoType = kVideoPlaytypeAVF;
+
+	// Simple frame-range player state. _currentSurface points at the decoder's
+	// last-decoded frame (owned by it, valid until the next decode).
+	const Graphics::Surface *_currentSurface = nullptr;
+	int _rangeLast = 0;
+	int _step = 1;
+	bool _rangePlaying = false;
+	uint32 _lastFrameTime = 0;
+	uint32 _frameDelayMs = 66;
+};
+
+} // End of namespace Nancy
+
+#endif // NANCY_MOVIEPLAYER_H
diff --git a/engines/nancy/state/map.cpp b/engines/nancy/state/map.cpp
index 230cd5f219e..73ea2c47ba5 100644
--- a/engines/nancy/state/map.cpp
+++ b/engines/nancy/state/map.cpp
@@ -173,7 +173,7 @@ void Map::MapViewport::loadVideo(const Common::Path &filename, const Common::Pat
 		_decoder.close();
 	}
 
-	if (!_decoder.loadFile(filename.append(".avf"))) {
+	if (!_decoder.loadFile(filename)) {
 		error("Couldn't load video file %s", filename.toString().c_str());
 	}
 
diff --git a/engines/nancy/state/map.h b/engines/nancy/state/map.h
index 319e60a5876..0f51eb2a7fa 100644
--- a/engines/nancy/state/map.h
+++ b/engines/nancy/state/map.h
@@ -26,7 +26,7 @@
 #include "common/singleton.h"
 
 #include "engines/nancy/sound.h"
-#include "engines/nancy/video.h"
+#include "engines/nancy/movieplayer.h"
 
 #include "engines/nancy/state/state.h"
 
@@ -68,7 +68,7 @@ protected:
 		void playVideo() { _decoder.start(); }
 		void unloadVideo() { _decoder.close(); }
 
-		AVFDecoder _decoder;
+		MoviePlayer _decoder;
 	private:
 	};
 
diff --git a/engines/nancy/ui/viewport.cpp b/engines/nancy/ui/viewport.cpp
index fc7e1b116d1..1a4be6fc8c0 100644
--- a/engines/nancy/ui/viewport.cpp
+++ b/engines/nancy/ui/viewport.cpp
@@ -32,7 +32,6 @@
 #include "engines/nancy/ui/viewport.h"
 
 #include "common/config-manager.h"
-#include "video/bink_decoder.h"
 
 namespace Nancy {
 namespace UI {
@@ -219,39 +218,14 @@ void Viewport::handleInput(NancyInput &input) {
 }
 
 void Viewport::loadVideo(const Common::Path &filename, uint frameNr, uint verticalScroll, byte panningType, uint16 format, const Common::Path &palette) {
-	if (_decoder->isVideoLoaded()) {
-		_decoder->close();
+	if (_decoder.isVideoLoaded()) {
+		_decoder.close();
 	}
 
-	Common::String suffix;
-
-	if (_videoType == kVideoPlaytypeAVF) {
-		suffix = ".avf";
-
-		if (!Common::File::exists(filename.append(".avf"))) {
-			if (Common::File::exists(filename.append(".bik"))) {
-				suffix = ".bik";
-				_videoType = kVideoPlaytypeBink;
-				_decoder.reset(new Video::BinkDecoder());
-			} else {
-				error("Couldn't load video file %s.avf or %s.bik", filename.toString().c_str(), filename.toString().c_str());
-			}
-		}
-	} else {
-		suffix = ".bik";
-		if (!Common::File::exists(filename.append(".bik"))) {
-			if (Common::File::exists(filename.append(".avf"))) {
-				suffix = ".avf";
-				_videoType = kVideoPlaytypeAVF;
-				_decoder.reset(new AVFDecoder());
-			} else {
-				error("Couldn't load video file %s.avf or %s.bik", filename.toString().c_str(), filename.toString().c_str());
-			}
-		}
-	}
-
-	if (!_decoder->loadFile(filename.append(suffix))) {
-		error("Couldn't load video file %s", filename.toString().c_str());
+	// The scene video uses the bidirectional AVF frame cache so scrubbing in
+	// both directions is fast; the player selects AVF/Bink by which file exists.
+	if (!_decoder.loadFile(filename, true)) {
+		error("Couldn't load video file %s.avf or %s.bik", filename.toString().c_str(), filename.toString().c_str());
 	}
 
 	_videoFormat = format;
@@ -273,23 +247,10 @@ void Viewport::loadVideo(const Common::Path &filename, uint frameNr, uint vertic
 }
 
 void Viewport::setFrame(uint frameNr) {
-	assert(frameNr < _decoder->getFrameCount());
+	assert(frameNr < (uint)_decoder.getFrameCount());
 
-	const Graphics::Surface *newFrame;
-
-	if (_videoType == kVideoPlaytypeAVF) {
-		AVFDecoder *decoder = dynamic_cast<AVFDecoder *>(_decoder.get());
-		if (!decoder)
-			error("Viewport::setFrame(): Decoder is not an AVFDecoder");
-		newFrame = decoder->decodeFrame(frameNr);
-		decoder->seek(frameNr); // Seek to take advantage of caching
-	} else {
-		Video::BinkDecoder *decoder = dynamic_cast<Video::BinkDecoder *>(_decoder.get());
-		if (!decoder)
-			error("Viewport::setFrame(): Decoder is not a BinkDecoder");
-		decoder->seekToFrame(frameNr); // Seek to take advantage of caching
-		newFrame = decoder->decodeNextFrame();
-	}
+	// The player returns the frame using the format-appropriate cached path.
+	const Graphics::Surface *newFrame = _decoder.decodeNextFrame(frameNr);
 
 	// Format 1 uses quarter-size images, while format 2 uses full-size ones
 	// Videos in TVD are always upside-down
diff --git a/engines/nancy/ui/viewport.h b/engines/nancy/ui/viewport.h
index 6b2c5cb9367..7d3a8adb0e8 100644
--- a/engines/nancy/ui/viewport.h
+++ b/engines/nancy/ui/viewport.h
@@ -23,7 +23,8 @@
 #define NANCY_UI_VIEWPORT_H
 
 #include "engines/nancy/time.h"
-#include "engines/nancy/video.h"
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
 
 #include "engines/nancy/renderobject.h"
 
@@ -48,10 +49,9 @@ public:
 		_videoFormat(kLargeVideoFormat),
 		_stickyCursorPos(-1, -1),
 		_panningType(kPanNone),
-		_decoder(new AVFDecoder(AVFDecoder::kLoadBidirectional)),
 		_autoMove(false) {}
 
-	virtual ~Viewport() { _decoder->close(); _fullFrame.free(); }
+	virtual ~Viewport() { _decoder.close(); _fullFrame.free(); }
 
 	void init() override;
 	void handleInput(NancyInput &input);
@@ -66,7 +66,7 @@ public:
 	void scrollUp(uint delta);
 	void scrollDown(uint delta);
 
-	uint16 getFrameCount() const { return _decoder->isVideoLoaded() ? _decoder->getFrameCount() : 0; }
+	uint16 getFrameCount() const { return _decoder.getFrameCount(); }
 	uint16 getCurFrame() const { return _currentFrame; }
 	uint16 getCurVerticalScroll() const { return _drawSurface.getOffsetFromOwner().y; }
 	uint16 getMaxScroll() const;
@@ -88,8 +88,7 @@ protected:
 
 	byte _panningType;
 
-	Common::ScopedPtr<Video::VideoDecoder> _decoder;
-	uint16 _videoType = kVideoPlaytypeAVF;
+	MoviePlayer _decoder;
 	uint16 _currentFrame;
 	uint16 _videoFormat;
 	Graphics::ManagedSurface _fullFrame;


Commit: c3fd298f7557785a2ca5f0257f01926bae8842ac
    https://github.com/scummvm/scummvm/commit/c3fd298f7557785a2ca5f0257f01926bae8842ac
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:10+03:00

Commit Message:
NANCY: NANCY12: Implement BoardGamePuzzle

Movie-driven board game introduced in Nancy12 (AR 164) - the "Mother Clock"
puzzle. The board states are pre-rendered frames of a Bink movie the game
navigates between as moves are made.

Changed paths:
  A engines/nancy/action/puzzle/boardgamepuzzle.cpp
  A engines/nancy/action/puzzle/boardgamepuzzle.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 834bd09e225..67dceb62758 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -37,6 +37,7 @@
 #include "engines/nancy/action/puzzle/assemblypuzzle.h"
 #include "engines/nancy/action/puzzle/bballpuzzle.h"
 #include "engines/nancy/action/puzzle/beadpuzzle.h"
+#include "engines/nancy/action/puzzle/boardgamepuzzle.h"
 #include "engines/nancy/action/puzzle/bulpuzzle.h"
 #include "engines/nancy/action/puzzle/bombpuzzle.h"
 #include "engines/nancy/action/puzzle/cardgamepuzzle.h"
@@ -467,8 +468,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// TODO: Nancy12 MirrorLightPuzzle (new), not implemented
 		return nullptr;
 	case 164:
-		// TODO: Nancy12 BoardGamePuzzle (new), not implemented
-		return nullptr;
+		return new BoardGamePuzzle();
 	case 165:
 		return new MindPuzzle();
 	case 166:
diff --git a/engines/nancy/action/puzzle/boardgamepuzzle.cpp b/engines/nancy/action/puzzle/boardgamepuzzle.cpp
new file mode 100644
index 00000000000..485b317ea91
--- /dev/null
+++ b/engines/nancy/action/puzzle/boardgamepuzzle.cpp
@@ -0,0 +1,293 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/boardgamepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void BoardGamePuzzle::readData(Common::SeekableReadStream &stream) {
+	readFilename(stream, _imageName);		// 0x00
+	stream.skip(2);							// int16 card count (10)
+	readRectArray(stream, _cardBlueSrcs, kNumButtons);	// 0x23, available-card sprites
+	readRectArray(stream, _cardWhiteSrcs, kNumButtons);	// 0xc3, active-card sprites
+	readRectArray(stream, _buttonRects, kNumButtons);	// 0x163, on-screen card positions
+	readRect(stream, _resetPressedSrc);		// 0x203, pressed reset sprite
+	readRect(stream, _resetButtonRect);		// 0x213, on-screen reset position
+	readFilename(stream, _movieName);		// 0x223, the board-game movie
+	readRect(stream, _movieRect);			// 0x244
+	readRect(stream, _boardRect);			// 0x254
+
+	_framesPerPosition = stream.readSint16LE();	// 0x264
+	_winTarget = stream.readSint16LE();			// 0x266
+	stream.skip(2);								// 0x268
+
+	_moves.resize(kNumMoves);
+	for (uint i = 0; i < kNumMoves; ++i) {		// 0x26a, 12 x 10-byte records
+		_moves[i].amount = stream.readSint16LE();
+		_moves[i].jumpFrom = stream.readSint16LE();
+		_moves[i].jumpTo = stream.readSint16LE();
+		_moves[i].jumpFrameStart = stream.readSint16LE();
+		_moves[i].jumpFrameEnd = stream.readSint16LE();
+	}
+
+	_winScene.readData(stream);				// 0x2e2, SceneChangeWithFlag
+	_loseScene.readData(stream);			// 0x2fb, SceneChangeWithFlag
+
+	// Six random-sound blocks (button/click/clank/slide/key/beep).
+	for (uint i = 0; i < kNumSounds; ++i) {
+		_sounds[i].readData(stream);
+	}
+}
+
+void BoardGamePuzzle::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());
+
+	_moviePlayer.loadFile(_movieName);
+	_buttonUsed.clear();
+	_buttonUsed.resize(kNumButtons, false);
+	_position = 0;
+	_solved = false;
+	_lost = false;
+	_boardState = kBoardWaiting;
+	_moviePlayer.goToFrame(framePosition(_position));
+
+	redraw();
+}
+
+void BoardGamePuzzle::drawCard(uint index) {
+	if (index >= _buttonRects.size() || _buttonRects[index].isEmpty()) {
+		return;
+	}
+
+	const Common::Rect *src;
+	if ((int)index == _activeCard) {
+		src = &_cardWhiteSrcs[index];	// currently being played
+	} else if (!_buttonUsed[index]) {
+		src = &_cardBlueSrcs[index];	// available
+	} else {
+		return;							// used -> dark card from the background shows
+	}
+
+	const Common::Rect &dst = _buttonRects[index];
+	_drawSurface.blitFrom(_image, *src, Common::Point(dst.left, dst.top));
+}
+
+void BoardGamePuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	// The board movie over the board region. Everything else (board frame, the
+	// reset button, and the dark "used" cards) lives on the scene background and
+	// shows through the transparent surface. _image is a sprite sheet holding
+	// only the blue/white card variants + the pressed reset button.
+	_moviePlayer.drawFrame(_drawSurface, Common::Point(_boardRect.left, _boardRect.top));
+
+	// The move cards: blue when available, white while being played.
+	for (uint i = 0; i < _buttonRects.size(); ++i) {
+		drawCard(i);
+	}
+
+	// The reset button only appears (its pressed sprite) while it is held.
+	if (_resetPressed && !_resetButtonRect.isEmpty()) {
+		_drawSurface.blitFrom(_image, _resetPressedSrc,
+			Common::Point(_resetButtonRect.left, _resetButtonRect.top));
+	}
+
+	_needsRedraw = true;
+}
+
+void BoardGamePuzzle::playSoundBlock(uint index) {
+	if (index >= kNumSounds) {
+		return;
+	}
+
+	const RandomSoundBlock &block = _sounds[index];
+	if (block.names.empty() || block.names[0].empty() || block.names[0] == "NO SOUND") {
+		return;
+	}
+
+	SoundDescription desc;
+	desc.name = block.names[0];
+	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);
+}
+
+void BoardGamePuzzle::resolveMove(int button) {
+	// Mirrors the original move-resolution rules: advance the token along the
+	// track, warp on a jump square, win by landing on the target exactly, lose
+	// by overshooting it. Positions map to movie frames via framePosition().
+	const MoveRecord &m = _moves[button];
+	int oldPos = _position;
+	int startFrame, endFrame;
+
+	if (oldPos == 0) {
+		startFrame = 0;
+		_position = m.amount;
+		endFrame = framePosition(_position);
+	} else if (oldPos == m.jumpFrom) {
+		startFrame = m.jumpFrameStart;
+		endFrame = m.jumpFrameEnd;
+		_position = m.jumpTo;
+	} else if (m.amount + oldPos > _winTarget) {
+		_lost = true;
+		startFrame = framePosition(oldPos);
+		endFrame = startFrame;
+	} else {
+		startFrame = framePosition(oldPos);
+		_position += m.amount;
+		endFrame = framePosition(_position);
+		if (_position == _winTarget) {
+			_solved = true;
+		}
+	}
+
+	_buttonUsed[button] = true;
+	_activeCard = button;			// shows the white sprite while the move plays
+	_boardState = kBoardAnimating;
+	playSoundBlock(kSlideSound);
+	_moviePlayer.playRange(startFrame, endFrame);
+
+	// Full redraw once at the start so the played card turns white and the first
+	// frame is shown; the animation then only re-blits the movie region.
+	redraw();
+}
+
+void BoardGamePuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_resetPressed && g_nancy->getTotalPlayTime() - _resetPressedTime > 200) {
+			_resetPressed = false;
+			redraw();
+		}
+
+		if (_boardState == kBoardAnimating) {
+			if (_moviePlayer.update()) {
+				// Only the board (movie) region changes each frame; re-blitting
+				// the whole overlay + re-darkening buttons here would be far too
+				// slow and make playback drop frames. The static chrome stays.
+				_moviePlayer.drawFrame(_drawSurface, Common::Point(_boardRect.left, _boardRect.top));
+				_needsRedraw = true;
+			}
+			if (!_moviePlayer.isRangePlaying()) {
+				// The played card is now used: a full redraw turns it dark.
+				_activeCard = -1;
+				redraw();
+
+				if (_solved) {
+					playSoundBlock(kWinSound);
+					_resultTime = g_nancy->getTotalPlayTime();
+					_boardState = kBoardResult;
+				} else if (_lost) {
+					playSoundBlock(kLoseSound);
+					_resultTime = g_nancy->getTotalPlayTime();
+					_boardState = kBoardResult;
+				} else {
+					playSoundBlock(kLandSound);
+					_boardState = kBoardWaiting;
+				}
+			}
+		} else if (_boardState == kBoardResult) {
+			// Hold on the finished board briefly before the scene change.
+			if (g_nancy->getTotalPlayTime() - _resultTime > 1500) {
+				_state = kActionTrigger;
+			}
+		}
+		break;
+	case kActionTrigger:
+		if (_solved) {
+			_winScene.execute();
+		} else if (_lost) {
+			_loseScene.execute();
+		}
+		finishExecution();
+		break;
+	}
+}
+
+void BoardGamePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _boardState != kBoardWaiting) {
+		return;
+	}
+
+	// Reset returns the token to the start and re-enables every move button.
+	if (!_resetButtonRect.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_resetButtonRect).contains(input.mousePos)) {
+		g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_position = 0;
+			for (uint i = 0; i < _buttonUsed.size(); ++i) {
+				_buttonUsed[i] = false;
+			}
+			_resetPressed = true;
+			_resetPressedTime = g_nancy->getTotalPlayTime();
+			playSoundBlock(kResetSound);
+			_moviePlayer.goToFrame(framePosition(_position));
+			redraw();
+		}
+		return;
+	}
+
+	// A move button advances the token; each may be used once.
+	for (uint i = 0; i < _buttonRects.size(); ++i) {
+		if (_buttonUsed[i] || _buttonRects[i].isEmpty()) {
+			continue;
+		}
+		if (!NancySceneState.getViewport().convertViewportToScreen(_buttonRects[i]).contains(input.mousePos)) {
+			continue;
+		}
+		g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			playSoundBlock(kButtonSound);
+			resolveMove(i);
+		}
+		return;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/boardgamepuzzle.h b/engines/nancy/action/puzzle/boardgamepuzzle.h
new file mode 100644
index 00000000000..c2a586829c8
--- /dev/null
+++ b/engines/nancy/action/puzzle/boardgamepuzzle.h
@@ -0,0 +1,122 @@
+/* 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_BOARDGAMEPUZZLE_H
+#define NANCY_ACTION_BOARDGAMEPUZZLE_H
+
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+
+namespace Nancy {
+namespace Action {
+
+// Movie-driven board game introduced in Nancy12 (AR 164) - the "Mother Clock"
+// puzzle. The board states are pre-rendered frames of a Bink movie the game
+// navigates between as moves are made.
+class BoardGamePuzzle : public RenderActionRecord {
+public:
+	BoardGamePuzzle() : RenderActionRecord(7) {}
+	virtual ~BoardGamePuzzle() {}
+
+	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 "BoardGamePuzzle"; }
+
+	static const uint kNumSounds = 6;
+	static const uint kNumButtons = 10;
+	static const uint kNumMoves = 12;
+
+	// One turn's scripted move: advance by _amount; landing on _jumpFrom warps
+	// to _jumpTo playing movie frames [_jumpFrameStart, _jumpFrameEnd].
+	struct MoveRecord {
+		int16 amount = 0;
+		int16 jumpFrom = 0;
+		int16 jumpTo = 0;
+		int16 jumpFrameStart = 0;
+		int16 jumpFrameEnd = 0;
+	};
+
+	// File data
+	Common::Path _imageName;
+	Common::Path _movieName;		// the board-game Bink movie ("..._ANIM")
+	Common::Rect _movieRect;		// movie source/params rect (buf+0x244)
+	Common::Rect _boardRect;		// board area within the viewport (buf+0x254)
+
+	Common::Array<Common::Rect> _cardBlueSrcs;	// available-card sprites in _image (buf+0x23)
+	Common::Array<Common::Rect> _cardWhiteSrcs;	// active-card sprites in _image (buf+0xc3)
+	Common::Array<Common::Rect> _buttonRects;	// 10 on-screen card positions (buf+0x163)
+	Common::Rect _resetPressedSrc;				// pressed reset sprite in _image (buf+0x203)
+	Common::Rect _resetButtonRect;				// on-screen reset position (buf+0x213)
+
+	int16 _framesPerPosition = 0;	// track position N shows at movie frame N*this+1
+	int16 _winTarget = 0;			// land here exactly to win; overshoot loses
+	Common::Array<MoveRecord> _moves;	// 12 scripted turn records (buf+0x26a)
+
+	SceneChangeWithFlag _winScene;	// buf+0x2e2
+	SceneChangeWithFlag _loseScene;	// buf+0x2fb (also the exit/quit target)
+
+	RandomSoundBlock _sounds[kNumSounds];	// button / click / clank / slide / key / beep
+
+	// Named indices into _sounds (best-guess mapping from the sound names).
+	enum SoundIndex {
+		kButtonSound = 0,	// ButtonDownUp02 - a move button is pressed
+		kResetSound = 1,	// Click_MetalButton02 - the reset button
+		kLandSound = 2,		// Clank_Metal01 - token settles on a square
+		kSlideSound = 3,	// MetalSlide09 - token slides during a move
+		kWinSound = 4,		// Key_Turn_Double - reached the target
+		kLoseSound = 5		// Beep01 - overshot the target
+	};
+
+	// Runtime state
+	enum BoardState { kBoardWaiting, kBoardAnimating, kBoardResult };
+
+	MoviePlayer _moviePlayer;
+	int16 _position = 0;				// current track position (0.._winTarget)
+	Common::Array<bool> _buttonUsed;	// a move button may be pressed once
+	int16 _activeCard = -1;				// card currently being played (white sprite)
+	bool _resetPressed = false;			// reset button showing its pressed sprite
+	uint32 _resetPressedTime = 0;
+	BoardState _boardState = kBoardWaiting;
+	bool _solved = false;				// reached the target exactly
+	bool _lost = false;					// overshot the target
+	uint32 _resultTime = 0;				// ms timestamp when the game ended, for the result hold
+
+	Graphics::ManagedSurface _image;
+
+	void redraw();
+	void drawCard(uint index);
+	void resolveMove(int button);
+	void playSoundBlock(uint index);
+	int framePosition(int position) const { return _framesPerPosition * position + 1; }
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_BOARDGAMEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 79b5aeeaf7a..09fa6b98a4f 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -20,6 +20,7 @@ MODULE_OBJS = \
   action/puzzle/assemblypuzzle.o \
   action/puzzle/bballpuzzle.o \
   action/puzzle/beadpuzzle.o \
+  action/puzzle/boardgamepuzzle.o \
   action/puzzle/bulpuzzle.o \
   action/puzzle/bombpuzzle.o \
   action/puzzle/cardgamepuzzle.o \


Commit: 3b875efc0e7b3d06a3846f8a73fd07cd895262f5
    https://github.com/scummvm/scummvm/commit/3b875efc0e7b3d06a3846f8a73fd07cd895262f5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-02T09:23:11+03:00

Commit Message:
NANCY: NANCY12: Initial work on MirrorLightPuzzle and ActionZone

In this puzzle, the player rotates a set of mirrors within their angle
limits to route a beam of light from a source to a target.

Still missing functionality, described in relevant TODOs.

Also, introduce the reusable ActionZone code, used in some puzzles
in Nancy12.

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


diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
new file mode 100644
index 00000000000..dc8f730137e
--- /dev/null
+++ b/engines/nancy/action/actionzone.cpp
@@ -0,0 +1,152 @@
+/* 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/stream.h"
+
+#include "engines/nancy/util.h"
+#include "engines/nancy/action/actionzone.h"
+
+namespace Nancy {
+namespace Action {
+
+void ActionZone::readData(Common::SeekableReadStream &stream) {
+	// Base ActionZone (matches the original "Action Zone Boundary OVL" reader).
+	typeField = stream.readSint32LE();
+	type = typeField & 0xFF;
+
+	readRect(stream, rect);
+	readFilename(stream, ovlName);
+
+	pointA = stream.readSint32LE();
+	pointB = stream.readSint32LE();
+	valC = stream.readSint32LE();
+	valD = stream.readSint32LE();
+	val49 = stream.readSint16LE();
+	val4b = stream.readByte();
+
+	// Random-sound block: count, then that many 33-byte names + 8 bytes of params.
+	_sound.readData(stream);
+
+	// Subtype-specific trailing data. Fields not yet needed are skipped to keep
+	// the stream aligned.
+	switch (type) {
+	case 1:		// base only
+	case 5:
+	case 0x10:
+	case 0x14:	// Boundary
+		break;
+	case 0:		// Special Effect (and movement variants) - peeked terminator
+	case 0x11:
+	case 0x12:
+	case 0x13:
+		readSpecialEffect(stream);
+		break;
+	case 0x0b:
+		stream.skip(3);		// int16 + byte (plain, no peek)
+		break;
+	case 0x0e:
+		stream.skip(2);		// int16
+		break;
+	case 0x0f:
+		stream.skip(4);		// int16 + int16
+		break;
+	case 3:
+		stream.skip(8);		// double
+		break;
+	case 0x17:				// Flat Tire (min/max)
+		stream.skip(8);		// int32 + int32
+		break;
+	case 4:
+		stream.skip(10);	// double + int16
+		break;
+	case 2:
+		stream.skip(24);	// Rect + int32 + int16 + int16
+		break;
+	case 0x0c:
+		readSpecialEffect(stream);
+		stream.skip(3);		// int16 + byte
+		break;
+	case 0x15:
+		readSpecialEffect(stream);
+		stream.skip(4);		// int32
+		break;
+	case 0x0d:				// OverlayZone
+		readOverlayZone(stream);
+		break;
+	case 0x16:				// OverlayZone + int32
+		readOverlayZone(stream);
+		stream.skip(4);
+		break;
+	default:
+		warning("Nancy12 ActionZone: unknown type %d - chunk may desync", type);
+		break;
+	}
+}
+
+// Special Effect block: an int16 id, then a byte. If the byte is the 0xff
+// terminator the effect is absent; otherwise it is the first byte of an embedded
+// 21-byte SpecialEffect record (5 int32 + 1 byte).
+void ActionZone::readSpecialEffect(Common::SeekableReadStream &stream) {
+	specialEffectId = stream.readUint16LE();
+	byte b = stream.readByte();
+	if (b == 0xff) {
+		return;
+	}
+
+	stream.seek(-1, SEEK_CUR);
+	hasSpecialEffect = true;
+	for (int i = 0; i < 5; ++i) {
+		specialEffect[i] = stream.readSint32LE();
+	}
+	specialEffectFlag = stream.readByte();
+}
+
+void ActionZone::readOverlayZone(Common::SeekableReadStream &stream) {
+	readFilename(stream, overlayName);
+
+	int16 numSrcRects = stream.readSint16LE();
+	if (numSrcRects > 0) {
+		overlaySrcRects.resize(numSrcRects);
+		for (int i = 0; i < numSrcRects; ++i) {
+			readRect(stream, overlaySrcRects[i]);
+		}
+	}
+
+	readRect(stream, overlayDestRect);
+	stream.skip(4);		// int32
+	stream.skip(1);		// byte (loop/play mode)
+	stream.skip(4);		// int32
+}
+
+void readActionZoneArray(Common::SeekableReadStream &stream, Common::Array<ActionZone> &out) {
+	int16 count = stream.readSint16LE();
+	if (count <= 0) {
+		return;
+	}
+
+	out.resize(count);
+	for (int i = 0; i < count; ++i) {
+		out[i].readData(stream);
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/actionzone.h b/engines/nancy/action/actionzone.h
new file mode 100644
index 00000000000..9d359e15647
--- /dev/null
+++ b/engines/nancy/action/actionzone.h
@@ -0,0 +1,84 @@
+/* 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_ACTIONZONE_H
+#define NANCY_ACTION_ACTIONZONE_H
+
+#include "common/array.h"
+#include "common/rect.h"
+#include "common/str.h"
+
+#include "engines/nancy/commontypes.h"
+
+namespace Common {
+class SeekableReadStream;
+}
+
+namespace Nancy {
+namespace Action {
+
+// A polymorphic "ActionZone" record, embedded as a count-prefixed array inside
+// every Nancy12 PuzzleBase puzzle (Driving/Minigolf/MirrorLight/BoardGame/Mind/
+// Chase). The zone's type is the low byte of its leading int32; each subtype
+// reads a different amount of trailing data. readData() consumes exactly the
+// right number of bytes for the type so the surrounding chunk stays in sync.
+struct ActionZone {
+	byte type = 0;
+	int32 typeField = 0;	// full leading int32; low byte == type
+
+	Common::Rect rect;
+	Common::String ovlName;	// OVL sprite name ("" / sentinel == none)
+
+	int32 pointA = 0;		// 8 bytes (a point) at base+0x39
+	int32 pointB = 0;
+	int32 valC = 0;			// 8 bytes at base+0x41
+	int32 valD = 0;
+	int16 val49 = 0;
+	byte val4b = 0;
+
+	RandomSoundBlock _sound;
+
+	// Special Effect (an embedded 21-byte SpecialEffect record, present on the
+	// special-effect subtypes when the effect byte is not the 0xff terminator).
+	uint16 specialEffectId = 0;
+	bool hasSpecialEffect = false;
+	int32 specialEffect[5] = {};
+	byte specialEffectFlag = 0;
+
+	// OverlayZone subtypes (0x0d / 0x16)
+	Common::String overlayName;
+	Common::Array<Common::Rect> overlaySrcRects;
+	Common::Rect overlayDestRect;
+
+	void readData(Common::SeekableReadStream &stream);
+
+private:
+	void readSpecialEffect(Common::SeekableReadStream &stream);
+	void readOverlayZone(Common::SeekableReadStream &stream);
+};
+
+// Reads an int16 count, then that many ActionZones.
+void readActionZoneArray(Common::SeekableReadStream &stream, Common::Array<ActionZone> &out);
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_ACTIONZONE_H
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 67dceb62758..26bdc2e47d2 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -53,6 +53,7 @@
 #include "engines/nancy/action/puzzle/mazechasepuzzle.h"
 #include "engines/nancy/action/puzzle/memorypuzzle.h"
 #include "engines/nancy/action/puzzle/mindpuzzle.h"
+#include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
 #include "engines/nancy/action/puzzle/mouselightpuzzle.h"
 #include "engines/nancy/action/puzzle/multibuildpuzzle.h"
 #include "engines/nancy/action/puzzle/onebuildpuzzle.h"
@@ -465,8 +466,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// TODO: Nancy12 - new puzzle (reuses the AT_DRIVING_PUZZLE debug string), not implemented
 		return nullptr;
 	case 163:
-		// TODO: Nancy12 MirrorLightPuzzle (new), not implemented
-		return nullptr;
+		return new MirrorLightPuzzle();
 	case 164:
 		return new BoardGamePuzzle();
 	case 165:
diff --git a/engines/nancy/action/puzzle/mirrorlightpuzzle.cpp b/engines/nancy/action/puzzle/mirrorlightpuzzle.cpp
new file mode 100644
index 00000000000..25b563b095b
--- /dev/null
+++ b/engines/nancy/action/puzzle/mirrorlightpuzzle.cpp
@@ -0,0 +1,331 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// TODO - open items for this puzzle:
+//  - Win/exit scene progression: the "solved" event flag is set outside the
+//    puzzle data (not embedded here) and is still unidentified, so the scene
+//    may not advance after a win.
+//  - No exit/cancel path: how the player quits an unsolved puzzle is unknown.
+//  - Win presentation: only a static lit-bulb overlay is drawn; the original
+//    plays a bulb-lights-up animation with a sound cue - neither is wired up.
+//  - Detector zone is chosen heuristically (any non-boundary zone away from the
+//    source); the true detector zone is unconfirmed.
+//  - Mirror rotation step (2 deg/click) and the glowing light-ray rendering are
+//    approximations, not the original's exact behaviour.
+
+void MirrorLightPuzzle::readData(Common::SeekableReadStream &stream) {
+	// 65-byte base header.
+	readFilename(stream, _imageName);
+	_beamAngle = stream.readSint16LE();
+	_beamOriginX = stream.readSint32LE();
+	_beamOriginY = stream.readSint32LE();
+	stream.skip(4);
+	_glowRadius = stream.readSint16LE();
+	stream.skip(8);							// step size (double)
+	stream.skip(8);
+
+	// Mirror sprite frames - one per evenly-spaced angle around a full turn.
+	uint16 numFrames = stream.readUint16LE();
+	_frameSrcRects.resize(numFrames);
+	for (uint i = 0; i < numFrames; ++i) {
+		readRect(stream, _frameSrcRects[i]);
+	}
+
+	// Mirrors. The original reads every record then ignores any past kMaxMirrors.
+	uint16 numMirrors = stream.readUint16LE();
+	_mirrors.resize(numMirrors);
+	for (uint i = 0; i < numMirrors; ++i) {
+		Mirror &m = _mirrors[i];
+		readRect(stream, m.destRect);
+		m.angle = (double)stream.readSint16LE() * (M_PI / 180.0);
+		stream.skip(16);	// secondary rect (unused; empty in the data)
+		m.minAngle = (double)stream.readSint16LE() * (M_PI / 180.0);
+		m.maxAngle = (double)stream.readSint16LE() * (M_PI / 180.0);
+	}
+	if (_mirrors.size() > kMaxMirrors) {
+		_mirrors.resize(kMaxMirrors);
+	}
+
+	readActionZoneArray(stream, _zones);
+}
+
+uint MirrorLightPuzzle::frameForAngle(double angle) const {
+	if (_frameSrcRects.empty()) {
+		return 0;
+	}
+
+	const double twoPi = 2.0 * M_PI;
+	double a = fmod(angle, twoPi);
+	if (a < 0.0) {
+		a += twoPi;
+	}
+
+	uint frame = (uint)((a / twoPi) * _frameSrcRects.size() + 0.5);
+	return frame % _frameSrcRects.size();
+}
+
+void MirrorLightPuzzle::drawMirror(uint index) {
+	const Mirror &m = _mirrors[index];
+	if (m.destRect.isEmpty()) {
+		return;
+	}
+
+	const Common::Rect &src = _frameSrcRects[frameForAngle(m.angle)];
+	_drawSurface.blitFrom(_image, src, m.destRect);
+}
+
+void MirrorLightPuzzle::traceBeam() {
+	_beamPath.clear();
+	_solved = false;
+
+	// The original marches the beam a fixed step at a time, reflecting off each
+	// mirror it enters. It works in a y-up frame (it negates dy when measuring
+	// angles), so in screen space (y-down) a beam at angle a advances along
+	// (cos a, -sin a): the level's 270deg source points straight down. Each
+	// mirror's stored angle is its surface NORMAL, so reflection is the standard
+	// r = d - 2(d.n)n (verified against the original: the 270deg beam off the
+	// fixed 75deg mirror heads to the top-right mirror).
+	const double kStep = 1.0;
+	const int kMaxSteps = 20000;
+	const byte kZoneBoundary = 0x14;
+
+	double a = (double)_beamAngle * (M_PI / 180.0);
+	double dx = cos(a);
+	double dy = -sin(a);
+
+	double px = _beamOriginX;
+	double py = _beamOriginY;
+	Common::Point origin((int16)px, (int16)py);
+	_beamPath.push_back(origin);
+
+	Common::Rect vp = NancySceneState.getViewport().getBounds();
+	int lastMirror = -1;
+
+	for (int steps = 0; steps < kMaxSteps; ++steps) {
+		px += dx * kStep;
+		py += dy * kStep;
+		Common::Point p((int16)(px + 0.5), (int16)(py + 0.5));
+
+		if (p.x < 0 || p.y < 0 || p.x >= vp.width() || p.y >= vp.height()) {
+			break;	// beam left the play area without solving
+		}
+
+		// Stepping clear of the last mirror re-arms it for another bounce.
+		if (lastMirror != -1 && !_mirrors[lastMirror].destRect.contains(p)) {
+			lastMirror = -1;
+		}
+
+		int hit = -1;
+		for (uint i = 0; i < _mirrors.size(); ++i) {
+			if ((int)i != lastMirror && !_mirrors[i].destRect.isEmpty() &&
+					_mirrors[i].destRect.contains(p)) {
+				hit = (int)i;
+				break;
+			}
+		}
+
+		if (hit != -1) {
+			_beamPath.push_back(p);
+			// Reflect about the mirror's surface normal (its stored angle).
+			double nx = cos(_mirrors[hit].angle);
+			double ny = -sin(_mirrors[hit].angle);
+			double dot = dx * nx + dy * ny;
+			dx -= 2.0 * dot * nx;
+			dy -= 2.0 * dot * ny;
+			lastMirror = hit;
+			continue;
+		}
+
+		// Reaching a detector zone (any non-boundary zone away from the source)
+		// solves the puzzle. TODO: confirm which zone is the true detector.
+		for (uint i = 0; i < _zones.size(); ++i) {
+			const ActionZone &z = _zones[i];
+			if (z.type != kZoneBoundary && !z.rect.isEmpty() &&
+					!z.rect.contains(origin) && z.rect.contains(p)) {
+				_solved = true;
+				break;
+			}
+		}
+		if (_solved) {
+			_beamPath.push_back(p);
+			return;
+		}
+	}
+
+	_beamPath.push_back(Common::Point((int16)(px + 0.5), (int16)(py + 0.5)));
+}
+
+void MirrorLightPuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	// Mirror sprites first, then the beam on top of them.
+	for (uint i = 0; i < _mirrors.size(); ++i) {
+		drawMirror(i);
+	}
+
+	// The beam as a round radial falloff (dim green-white outer -> bright white
+	// core) to read as glowing light. TODO: the original composites actual
+	// glowing light-ray sprites; a soft/additive glow is a follow-up.
+	if (_beamPath.size() >= 2) {
+		int t = MAX<int>(2, _glowRadius);
+		uint32 colors[3] = {
+			_drawSurface.format.RGBToColor(90, 120, 90),	// outer haze
+			_drawSurface.format.RGBToColor(180, 210, 180),	// mid glow
+			_drawSurface.format.RGBToColor(255, 255, 255)	// core
+		};
+		int bands[3] = { t, t / 2, MAX<int>(1, t / 4) };
+		// Outer band first so brighter bands land on top.
+		for (int band = 0; band < 3; ++band) {
+			int r = bands[band];
+			for (uint i = 1; i < _beamPath.size(); ++i) {
+				const Common::Point &p0 = _beamPath[i - 1];
+				const Common::Point &p1 = _beamPath[i];
+				for (int oy = -r; oy <= r; ++oy) {
+					for (int ox = -r; ox <= r; ++ox) {
+						if (ox * ox + oy * oy > r * r) {
+							continue;
+						}
+						_drawSurface.drawLine(p0.x + ox, p0.y + oy, p1.x + ox, p1.y + oy, colors[band]);
+					}
+				}
+			}
+		}
+	}
+
+	// Once solved, light up the detector overlay (the bulb graphic).
+	if (_solved) {
+		for (uint i = 0; i < _zones.size(); ++i) {
+			const ActionZone &z = _zones[i];
+			if (!z.overlaySrcRects.empty() && !z.overlayDestRect.isEmpty()) {
+				_drawSurface.blitFrom(_image, z.overlaySrcRects[0],
+					Common::Point(z.overlayDestRect.left, z.overlayDestRect.top));
+			}
+		}
+	}
+
+	_needsRedraw = true;
+}
+
+void MirrorLightPuzzle::rotateMirror(uint index, bool clockwise) {
+	Mirror &m = _mirrors[index];
+	if (m.minAngle == m.maxAngle) {
+		return;	// fixed mirror
+	}
+
+	// Fine step so the beam can be aimed precisely; clamped to the mirror's
+	// rotation range. The exact per-click amount in the original is unconfirmed.
+	const double step = 2.0 * (M_PI / 180.0);
+	m.angle += clockwise ? step : -step;
+	if (m.angle < m.minAngle) {
+		m.angle = m.minAngle;
+	} else if (m.angle > m.maxAngle) {
+		m.angle = m.maxAngle;
+	}
+
+	traceBeam();
+	redraw();
+}
+
+void MirrorLightPuzzle::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());
+
+	traceBeam();
+	redraw();
+}
+
+void MirrorLightPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_solved) {
+			// Hold on the lit-bulb win state briefly before releasing.
+			if (_solvedTime == 0) {
+				_solvedTime = g_nancy->getTotalPlayTime();
+			} else if (g_nancy->getTotalPlayTime() - _solvedTime > 1500) {
+				_state = kActionTrigger;
+			}
+		}
+		break;
+	case kActionTrigger:
+		// TODO: the win/exit scene transitions are driven externally (an event
+		// flag the puzzle sets on solve + separate scene ARs), not embedded here.
+		// The solved event flag is still unidentified, so the scene may not
+		// advance yet; for now just release the record.
+		finishExecution();
+		break;
+	}
+}
+
+void MirrorLightPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _solved) {
+		return;
+	}
+
+	for (uint i = 0; i < _mirrors.size(); ++i) {
+		Mirror &m = _mirrors[i];
+		if (m.minAngle == m.maxAngle) {
+			continue;	// fixed mirror
+		}
+
+		Common::Rect screenRect = NancySceneState.getViewport().convertViewportToScreen(m.destRect);
+		if (!screenRect.contains(input.mousePos)) {
+			continue;
+		}
+
+		// Clicking the right half rotates one way, the left half the other.
+		bool clockwise = input.mousePos.x >= (screenRect.left + screenRect.right) / 2;
+		g_nancy->_cursor->setCursorType(clockwise ? CursorManager::kRotateRight : CursorManager::kRotateLeft);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			rotateMirror(i, clockwise);
+		}
+		return;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/mirrorlightpuzzle.h b/engines/nancy/action/puzzle/mirrorlightpuzzle.h
new file mode 100644
index 00000000000..9c935e09dbe
--- /dev/null
+++ b/engines/nancy/action/puzzle/mirrorlightpuzzle.h
@@ -0,0 +1,94 @@
+/* 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_MIRRORLIGHTPUZZLE_H
+#define NANCY_ACTION_MIRRORLIGHTPUZZLE_H
+
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionzone.h"
+
+namespace Nancy {
+namespace Action {
+
+// Light-reflection puzzle introduced in Nancy12 (AR 163). The player rotates a
+// set of mirrors within their angle limits to route a beam of light from a
+// source to a target.
+class MirrorLightPuzzle : public RenderActionRecord {
+public:
+	MirrorLightPuzzle() : RenderActionRecord(7) {}
+	virtual ~MirrorLightPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+	static const uint16 kMaxMirrors = 20;
+
+protected:
+	Common::String getRecordTypeName() const override { return "MirrorLightPuzzle"; }
+
+	struct Mirror {
+		Common::Rect destRect;	// where the mirror is drawn / clicked
+		double angle = 0.0;		// current angle (radians)
+		double minAngle = 0.0;	// rotation limits (min == max == fixed mirror)
+		double maxAngle = 0.0;
+	};
+
+	// File data
+	Common::Path _imageName;
+
+	int16 _beamAngle = 0;		// initial beam angle (degrees)
+	int32 _beamOriginX = 0;
+	int32 _beamOriginY = 0;
+	int16 _glowRadius = 0;		// beam-glow half-width in pixels
+
+	// Mirror sprite frames - the mirror appearance at each of kNumFrames angles
+	// (full turn split evenly), indexed by angle.
+	Common::Array<Common::Rect> _frameSrcRects;
+
+	Common::Array<Mirror> _mirrors;
+
+	Common::Array<ActionZone> _zones;
+
+	// Runtime state
+	int16 _pickedUpMirror = -1;
+	bool _solved = false;
+	uint32 _solvedTime = 0;					// ms timestamp when solved, for the win hold
+	Common::Array<Common::Point> _beamPath;	// traced beam polyline, in viewport coords
+
+	Graphics::ManagedSurface _image;
+
+	uint frameForAngle(double angle) const;
+	void drawMirror(uint index);
+	void rotateMirror(uint index, bool clockwise);
+	void traceBeam();
+	void redraw();
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_MIRRORLIGHTPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 09fa6b98a4f..f2cdce3da5c 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -3,6 +3,7 @@ MODULE := engines/nancy
 MODULE_OBJS = \
   action/actionmanager.o \
   action/actionrecord.o \
+  action/actionzone.o \
   action/arfactory.o \
   action/autotext.o \
   action/datarecords.o \
@@ -36,6 +37,7 @@ MODULE_OBJS = \
   action/puzzle/matchpuzzle.o \
   action/puzzle/memorypuzzle.o \
   action/puzzle/mindpuzzle.o \
+  action/puzzle/mirrorlightpuzzle.o \
   action/puzzle/mouselightpuzzle.o \
   action/puzzle/multibuildpuzzle.o \
   action/puzzle/onebuildpuzzle.o \




More information about the Scummvm-git-logs mailing list