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

bluegr noreply at scummvm.org
Wed Aug 19 11:24:57 UTC 2026


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

Summary:
94652a9d8e NANCY: NANCY9: Fix issues with MemoryPuzzle
42c9a39c2e NANCY: NANCY10: Revert per-frame conversion
447d50b289 NANCY: NANCY7: Properly handle playing preference in Bink/AVF videos
a88a37dae5 NANCY: NANCY13: Implement returning to idle animation gracefully


Commit: 94652a9d8e513da83896ffa7ce88437a278a74eb
    https://github.com/scummvm/scummvm/commit/94652a9d8e513da83896ffa7ce88437a278a74eb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-19T14:24:29+03:00

Commit Message:
NANCY: NANCY9: Fix issues with MemoryPuzzle

Verify changes across Nancy9 to Nancy13. This puzzle is used in Nancy9,
Nancy11 and Nancy12. Changes:

- Read the correct number of pairs and required pairs
- Read the flip/match/no match sounds correctly
- Remove exit hotspot: it's provided by the surrounding scene, not by
  the puzzle itself
- Handle global shuffling and page tabs properly, for Nancy9 hard mode

Fix #17071

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


diff --git a/engines/nancy/action/puzzle/memorypuzzle.cpp b/engines/nancy/action/puzzle/memorypuzzle.cpp
index c67a21973d7..26f376f6cde 100644
--- a/engines/nancy/action/puzzle/memorypuzzle.cpp
+++ b/engines/nancy/action/puzzle/memorypuzzle.cpp
@@ -63,10 +63,14 @@ void MemoryPuzzle::readData(Common::SeekableReadStream &stream) {
 		for (int slot = 0; slot < 3; ++slot)
 			readRect(stream, _tabHotspots[tab][slot]);
 
-	// 0x4b1: flipDelay (uint32), numPairs (uint32), requiredPairs (uint32)
-	_flipDelay     = stream.readUint32LE();
-	_numPairs      = stream.readUint32LE();
-	_requiredPairs = stream.readUint32LE();
+	// 0x4b1: flipDelay, then two percentages - the share of the faces to deal out, and
+	// the share of the board's pairs needed to win
+	_flipDelay = stream.readUint32LE();
+	int32 pairsPercent   = stream.readSint32LE(); // 0x4b5
+	int32 requirePercent = stream.readSint32LE(); // 0x4b9
+
+	_numPairs      = (uint32)(pairsPercent * _numTypes / 100);
+	_requiredPairs = (uint32)(requirePercent * (_numTabs * _cardsPerTab / 2) / 100);
 
 	// 0x4bd: cursor flag (ignored in ScummVM)
 	stream.skip(1);
@@ -74,9 +78,9 @@ void MemoryPuzzle::readData(Common::SeekableReadStream &stream) {
 	// 0x4be: shuffle flag
 	_shuffleGlobal = (stream.readByte() != 0);
 
-	// 0x4bf: match sound; 0x4f0: card flip sound
+	// 0x4bf: match sound; 0x4f0: no-match sound
 	_matchSound.readNormal(stream);
-	_cardFlipSound.readNormal(stream);
+	_noMatchSound.readNormal(stream);
 
 	// 0x521: win scene + flag
 	_winScene.readData(stream);
@@ -85,9 +89,6 @@ void MemoryPuzzle::readData(Common::SeekableReadStream &stream) {
 
 	// 0x53b: win sound
 	_winSound.readNormal(stream);
-
-	// 0x56c: exit hotspot
-	readRect(stream, _exitHotspot);
 }
 
 // Nancy 11 reworked the layout: fewer (12) face rects, a configurable grid/page count,
@@ -109,32 +110,43 @@ void MemoryPuzzle::readDataNancy11(Common::SeekableReadStream &stream) {
 			readRect(stream, _tabHotspots[tab][slot]);
 
 	_flipDelay = stream.readUint32LE();     // 0x331
-	stream.skip(4);                         // 0x335 (second timing value, unused)
+	int32 pairsPercent   = stream.readSint32LE(); // 0x335 (percentage of the faces to deal out)
 	int32 requirePercent = stream.readSint32LE(); // 0x339 (-1 = use the fixed count below)
 	int32 requireCount   = stream.readSint32LE(); // 0x33d
 	stream.skip(4);                         // 0x341 (unused)
 
-	_shuffleGlobal = (stream.readByte() != 0); // 0x345
-	stream.skip(1);                         // 0x346 (flag, unused)
+	stream.skip(1);                         // 0x345 (cursor flag, ignored in ScummVM)
+	_shuffleGlobal = (stream.readByte() != 0); // 0x346
 
 	int32 pages      = stream.readSint32LE(); // 0x347
 	int32 gridsWide  = stream.readSint32LE(); // 0x34b
 	int32 gridsTall  = stream.readSint32LE(); // 0x34f
 	int32 srcWide    = stream.readSint32LE(); // 0x353
 	int32 srcTall    = stream.readSint32LE(); // 0x357
-	stream.skip(1);                         // 0x35b (tabs flag)
+	_hasPageTabs = (stream.readByte() != 0); // 0x35b
 
 	_numTabs     = CLIP<int>(pages, 1, kNumTabs);
 	_cardsPerTab = CLIP<int>(gridsWide * gridsTall, 1, kCardsPerTab);
 	_numTypes    = CLIP<int>(srcWide * srcTall, 1, kMaxTypes);
-	_numPairs    = _numTypes;
-	_requiredPairs = (requirePercent < 0) ? (uint32)requireCount
-	                                       : (uint32)(_numPairs * requirePercent / 100);
-
-	// 27 fixed 0xb6-byte voice-clip blocks: [0] is the card-flip sound, [17] starts the
-	// per-card match sounds (used here as a single match sound; per-type audio is a TODO).
-	_cardFlipSound.readNormal(stream);             // block 0 @ 0x35c
-	stream.skip(17 * 0xb6 - 0x31);                 // advance to block 17 @ 0xf72
+
+	// A single page can only ever pair within itself
+	if (_numTabs == 1)
+		_shuffleGlobal = false;
+
+	// Both counts are percentages: the faces to deal out are a share of the available
+	// faces, the pairs needed to win a share of every pair on the board.
+	int halfBoard = _numTabs * _cardsPerTab / 2;
+	_numPairs = (uint32)(pairsPercent * _numTypes / 100);
+	_requiredPairs = (requirePercent == -1) ? (uint32)requireCount
+	                                        : (uint32)(requirePercent * halfBoard / 100);
+
+	// 27 fixed 0xb6-byte voice-clip blocks: [0] and [1] are the sounds for flipping the
+	// first and second card of a pair, [17] starts the per-card match sounds (used here
+	// as a single match sound; per-type audio is a TODO).
+	_firstFlipSound.readNormal(stream);            // block 0 @ 0x35c
+	stream.skip(0xb6 - 0x31);                      // advance to block 1 @ 0x412
+	_secondFlipSound.readNormal(stream);           // block 1
+	stream.skip(16 * 0xb6 - 0x31);                 // advance to block 17 @ 0xf72
 	_matchSound.readNormal(stream);                // block 17
 	stream.skip((27 - 17) * 0xb6 - 0x31);          // advance to the scenes @ 0x168e
 	// Nancy 11 has no win sound; _winSound keeps its default "NO SOUND".
@@ -148,9 +160,32 @@ void MemoryPuzzle::readDataNancy11(Common::SeekableReadStream &stream) {
 	stream.skip(g_nancy->getGameType() >= kGameTypeNancy12 ? 24 : 26);	// alternate scene
 }
 
-// Shuffles type IDs (0..numPairs-1) into the 72-card array so that every type
-// appears exactly twice. numPairs is clamped to [4, 36]; cards beyond numPairs
-// remain typeId -1 (unassigned, unselectable). requiredPairs is clamped to [2, totalCards/2].
+// Picks the type ID for the next pair. Types that haven't been handed out yet are
+// preferred; once all of them are used, a random type is reused, so the same face
+// can end up on more than one pair.
+int MemoryPuzzle::pickCardType() {
+	bool allUsed = true;
+	for (uint32 i = 0; i < _numPairs; ++i) {
+		if (!_typeUsed[i]) {
+			allUsed = false;
+			break;
+		}
+	}
+
+	if (allUsed)
+		return g_nancy->_randomSource->getRandomNumber(_numPairs - 1);
+
+	int type;
+	do {
+		type = g_nancy->_randomSource->getRandomNumber(_numPairs - 1);
+	} while (_typeUsed[type]);
+
+	_typeUsed[type] = true;
+	return type;
+}
+
+// Shuffles type IDs into the card array, two cards at a time, until every card slot
+// is filled. numPairs is clamped to [4, 36] and requiredPairs to [2, totalCards/2].
 void MemoryPuzzle::initCards() {
 	_numPairs = CLIP<uint32>(_numPairs, 4, (uint32)_numTypes);
 
@@ -169,7 +204,8 @@ void MemoryPuzzle::initCards() {
 	_secondFlip      = -1;
 	_flipTimerActive = false;
 
-	int nextType = 0;
+	for (int i = 0; i < kMaxTypes; ++i)
+		_typeUsed[i] = false;
 
 	if (!_shuffleGlobal) {
 		// By-tab: pairs are always within the same tab.
@@ -178,19 +214,16 @@ void MemoryPuzzle::initCards() {
 			for (int i = 0; i < _cardsPerTab; ++i) {
 				if (_cards[base + i].typeId != -1)
 					continue;
-				if (static_cast<uint32>(nextType) >= _numPairs)
-					break; // all types used up for this tab
 
-				_cards[base + i].typeId = nextType;
+				int type = pickCardType();
+				_cards[base + i].typeId = type;
 
 				// Find a random unassigned slot in the same tab for the pair
 				int partner;
 				do {
 					partner = g_nancy->_randomSource->getRandomNumber(_cardsPerTab - 1);
 				} while (_cards[base + partner].typeId != -1);
-				_cards[base + partner].typeId = nextType;
-
-				++nextType;
+				_cards[base + partner].typeId = type;
 			}
 		}
 	} else {
@@ -198,19 +231,16 @@ void MemoryPuzzle::initCards() {
 		for (int i = 0; i < totalCards; ++i) {
 			if (_cards[i].typeId != -1)
 				continue;
-			if (static_cast<uint32>(nextType) >= _numPairs)
-				break;
 
-			_cards[i].typeId = nextType;
+			int type = pickCardType();
+			_cards[i].typeId = type;
 
 			// Find a random unassigned slot anywhere
 			int partner;
 			do {
 				partner = g_nancy->_randomSource->getRandomNumber(totalCards - 1);
 			} while (_cards[partner].typeId != -1);
-			_cards[partner].typeId = nextType;
-
-			++nextType;
+			_cards[partner].typeId = type;
 		}
 	}
 }
@@ -237,10 +267,14 @@ void MemoryPuzzle::execute() {
 	case kBegin:
 		init();
 		registerGraphics();
-		if (_cardFlipSound.name != "NO SOUND")
-			g_nancy->_sound->loadSound(_cardFlipSound);
+		if (_firstFlipSound.name != "NO SOUND")
+			g_nancy->_sound->loadSound(_firstFlipSound);
+		if (_secondFlipSound.name != "NO SOUND")
+			g_nancy->_sound->loadSound(_secondFlipSound);
 		if (_matchSound.name != "NO SOUND")
 			g_nancy->_sound->loadSound(_matchSound);
+		if (_noMatchSound.name != "NO SOUND")
+			g_nancy->_sound->loadSound(_noMatchSound);
 		_state = kRun;
 		// fall through
 
@@ -275,8 +309,10 @@ void MemoryPuzzle::execute() {
 		break;
 
 	case kActionTrigger:
-		g_nancy->_sound->stopSound(_cardFlipSound);
+		g_nancy->_sound->stopSound(_firstFlipSound);
+		g_nancy->_sound->stopSound(_secondFlipSound);
 		g_nancy->_sound->stopSound(_matchSound);
+		g_nancy->_sound->stopSound(_noMatchSound);
 		g_nancy->_sound->stopSound(_winSound);
 		_winScene.execute();
 		finishExecution();
@@ -291,30 +327,15 @@ void MemoryPuzzle::handleInput(NancyInput &input) {
 	Common::Rect vpScreen = NancySceneState.getViewport().getScreenPosition();
 	Common::Point mouseVP = input.mousePos - Common::Point(vpScreen.left, vpScreen.top);
 
-	// Exit hotspot
-	if (!_exitHotspot.isEmpty() && _exitHotspot.contains(mouseVP)) {
-		g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
-		if (input.input & NancyInput::kLeftMouseButtonUp)
-			_state = kActionTrigger;
-		return;
-	}
-
 	// Tab switching: _tabHotspots[currentTab][slot] where slot is the target tab
-	for (int slot = 0; slot < _numTabs; ++slot) {
+	for (int slot = 0; _hasPageTabs && slot < _numTabs; ++slot) {
 		if (_tabHotspots[_currentTab][slot].contains(mouseVP)) {
 			g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
 			if ((input.input & NancyInput::kLeftMouseButtonUp) && slot != _currentTab) {
-				// Cancel any pending flip-back timer and flip both cards back
-				if (_flipTimerActive)
-					flipBackCards();
-				// Also flip back any lone first-flip card
-				if (_firstFlip != -1) {
-					_cards[_firstFlip].flipState = 0;
-					_firstFlip = -1;
-				}
+				// Changing pages keeps any pending flip state, so a pair can be
+				// matched across two different pages
 				_currentTab = slot;
-				if (_cardFlipSound.name != "NO SOUND")
-					g_nancy->_sound->playSound(_cardFlipSound);
+				g_nancy->_sound->playSound("BUOK");
 				redrawCards();
 			}
 			return;
@@ -344,15 +365,24 @@ void MemoryPuzzle::handleInput(NancyInput &input) {
 
 		// Flip this card face-up
 		card.flipState = 1;
-		if (_cardFlipSound.name != "NO SOUND")
-			g_nancy->_sound->playSound(_cardFlipSound);
 		redrawCards();
 
 		if (_firstFlip == -1) {
 			// First card of a potential pair
 			_firstFlip = idx;
+
+			if (g_nancy->getGameType() >= kGameTypeNancy11) {
+				if (_firstFlipSound.name != "NO SOUND")
+					g_nancy->_sound->playSound(_firstFlipSound);
+			} else {
+				g_nancy->_sound->playSound("BUOK");
+			}
 		} else {
-			// Second card flipped: check for match
+			// Second card. Up to Nancy 10 this one is silent, and the match or no-match
+			// sound answers it instead.
+			if (_secondFlipSound.name != "NO SOUND")
+				g_nancy->_sound->playSound(_secondFlipSound);
+
 			CardState &first = _cards[_firstFlip];
 			if (first.typeId == card.typeId && first.typeId != -1) {
 				// Match! Mark both as matched
@@ -370,6 +400,8 @@ void MemoryPuzzle::handleInput(NancyInput &input) {
 				_secondFlip      = idx;
 				_flipTimerActive = true;
 				_flipTimerEnd    = g_system->getMillis() + _flipDelay;
+				if (_noMatchSound.name != "NO SOUND")
+					g_nancy->_sound->playSound(_noMatchSound);
 			}
 		}
 		return;
diff --git a/engines/nancy/action/puzzle/memorypuzzle.h b/engines/nancy/action/puzzle/memorypuzzle.h
index cd26e170b32..f90efb7d393 100644
--- a/engines/nancy/action/puzzle/memorypuzzle.h
+++ b/engines/nancy/action/puzzle/memorypuzzle.h
@@ -63,9 +63,8 @@ protected:
 	Common::Rect _cardRects[kCardsPerTab];   // viewport-relative screen positions (shared across tabs)
 	Common::Rect _tabRect;                   // screen rect where the active tab indicator is drawn
 	Common::Rect _tabHotspots[kNumTabs][3];  // [currentTab][targetTab]: hit-test rects for tab switching
-	Common::Rect _exitHotspot;
 
-	uint32 _numPairs      = 12;    // pairs in the shuffled layout (clamped [4..36])
+	uint32 _numPairs      = 12;    // distinct faces dealt out (clamped [4..36])
 	uint32 _requiredPairs = 12;    // pairs needed to win (clamped [2..36])
 	uint32 _flipDelay     = 1500;  // ms before non-matching cards flip back
 
@@ -75,9 +74,14 @@ protected:
 	int _numTypes     = kMaxTypes;
 
 	bool _shuffleGlobal = false;  // false = pairs stay within same tab; true = can cross tabs
+	bool _hasPageTabs   = true;   // Nancy 11 onwards can lay the puzzle out without page tabs
 
-	SoundDescription _cardFlipSound; // played when a card is flipped face-up
+	// Nancy 11 onwards has a sound for each of the two card flips; before that the first
+	// flip uses the generic button click and the second one is silent
+	SoundDescription _firstFlipSound;
+	SoundDescription _secondFlipSound;
 	SoundDescription _matchSound;    // played when a matching pair is found
+	SoundDescription _noMatchSound;  // pre-Nancy 11: played when a pair doesn't match
 	SceneChangeWithFlag _winScene;
 	SoundDescription _winSound;
 
@@ -91,6 +95,7 @@ protected:
 
 	// _cards[tab * kCardsPerTab + i] = state of card i on tab `tab`
 	CardState _cards[kNumTabs * kCardsPerTab];
+	bool _typeUsed[kMaxTypes];  // faces already handed out while filling _cards
 
 	Graphics::ManagedSurface _image;
 
@@ -111,6 +116,7 @@ protected:
 
 	// Internal methods
 
+	int  pickCardType();  // next face to place, preferring unused ones
 	void initCards();     // shuffle types into all 72 card slots
 	void checkIfSolved();
 	void flipBackCards(); // unflip non-matching pair after timer expires


Commit: 42c9a39c2eeccec061474c61117699f7321d2a1e
    https://github.com/scummvm/scummvm/commit/42c9a39c2eeccec061474c61117699f7321d2a1e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-19T14:24:30+03:00

Commit Message:
NANCY: NANCY10: Revert per-frame conversion

This reverts the changes done in 3fe1886. A better implementation was
done in c6e3566 (PR 7823)

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


diff --git a/engines/nancy/ui/viewport.cpp b/engines/nancy/ui/viewport.cpp
index 16a76a1819e..e4c7197e654 100644
--- a/engines/nancy/ui/viewport.cpp
+++ b/engines/nancy/ui/viewport.cpp
@@ -19,20 +19,15 @@
  *
  */
 
+#include "common/config-manager.h"
 #include "common/system.h"
 
 #include "engines/nancy/nancy.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/cursor.h"
 #include "engines/nancy/input.h"
-#include "engines/nancy/util.h"
-
 #include "engines/nancy/state/scene.h"
 
-#include "engines/nancy/ui/viewport.h"
-
-#include "common/config-manager.h"
-
 namespace Nancy {
 namespace UI {
 
@@ -255,16 +250,7 @@ void Viewport::setFrame(uint frameNr) {
 
 	// Format 1 uses quarter-size images, while format 2 uses full-size ones
 	// Videos in TVD are always upside-down
-	if (newFrame->format != _fullFrame.format && newFrame->format.bytesPerPixel == _fullFrame.format.bytesPerPixel) {
-		// Character closeups are in a different format than the main viewport
-		// in Nancy10+, so convert them before copying to the main surface.
-		Graphics::Surface *converted = newFrame->convertTo(_fullFrame.format);
-		GraphicsManager::copyToManaged(*converted, _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
-		converted->free();
-		delete converted;
-	} else {
-		GraphicsManager::copyToManaged(*newFrame, _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
-	}
+	GraphicsManager::copyToManaged(*newFrame, _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
 
 	_needsRedraw = true;
 	_currentFrame = frameNr;


Commit: 447d50b289f3f662150f936ae76b457c44cd3b9c
    https://github.com/scummvm/scummvm/commit/447d50b289f3f662150f936ae76b457c44cd3b9c
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-19T14:24:31+03:00

Commit Message:
NANCY: NANCY7: Properly handle playing preference in Bink/AVF videos

Now, each place in the engine where movies are played loads the correct
type (Bink or AVF). There is a fallback when an expected movie type
isn't found, to check for the other one (e.g. when the engine doesn't
find an expected Bink video, it falls back to AVF and vice versa).

Changed paths:
    engines/nancy/action/secondarymovie.cpp
    engines/nancy/action/secondarymovie.h
    engines/nancy/action/secondaryvideo.cpp
    engines/nancy/commontypes.h
    engines/nancy/movieplayer.cpp
    engines/nancy/movieplayer.h
    engines/nancy/ui/viewport.cpp


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 7924a27ddfe..e6d7cb39860 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -531,7 +531,12 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
 	readFilename(ser, _paletteName, kGameTypeVampire, kGameTypeVampire);
 	readFilename(ser, _bitmapOverlayName, kGameTypeVampire, kGameTypeNancy9);
 
-	ser.skip(2, kGameTypeNancy7);	// videoType
+	if (g_nancy->getGameType() >= kGameTypeNancy7) {
+		uint16 videoType = 0;
+		ser.syncAsUint16LE(videoType);
+		_videoPlaytype = videoType == kVideoPlaytypeBink ? kVideoPlaytypeBink : kVideoPlaytypeAVF;
+	}
+
 	ser.skip(2, kGameTypeVampire, kGameTypeNancy9); // videoPlaySource
 	ser.syncAsUint16LE(_videoFormat);
 	if (g_nancy->getGameType() >= kGameTypeNancy10)
@@ -593,7 +598,7 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
 
 void PlaySecondaryMovie::init() {
 	if (!_decoder.isVideoLoaded()) {
-		if (!_decoder.loadFile(_videoName)) {
+		if (!_decoder.loadFile(_videoName, _videoPlaytype)) {
 			error("Couldn't load video file %s", _videoName.toString().c_str());
 		}
 
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 0ff4af69121..c0f0cbfc0d5 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -95,6 +95,9 @@ public:
 	Common::Path _paletteName;
 	Common::Path _bitmapOverlayName;
 
+	// Container the record asks for; only present from Nancy7 to Nancy13.
+	byte _videoPlaytype = kVideoPlaytypeAuto;
+
 	uint16 _videoFormat = kLargeVideoFormat;
 	uint16 _videoSceneChange = kMovieNoSceneChange;
 	byte _playerCursorAllowed = kPlayerCursorAllowed;
diff --git a/engines/nancy/action/secondaryvideo.cpp b/engines/nancy/action/secondaryvideo.cpp
index 1c4491cad30..99b0d9a9d6e 100644
--- a/engines/nancy/action/secondaryvideo.cpp
+++ b/engines/nancy/action/secondaryvideo.cpp
@@ -38,7 +38,8 @@ void PlaySecondaryVideo::init() {
 		_decoder.close();
 	}
 
-	if (!_decoder.loadFile(_filename)) {
+	// The secondary video channels are AVF-only, even when a .bik exists.
+	if (!_decoder.loadFile(_filename, kVideoPlaytypeAVF)) {
 		error("Couldn't load video file %s", _filename.toString().c_str());
 	}
 
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index c409cfd203c..bfc381d606c 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -101,8 +101,10 @@ static const byte kPlayerDuskDawn					= 2;
 static const byte kSmallVideoFormat					= 1;
 static const byte kLargeVideoFormat					= 2;
 
+// Video container. Auto picks per game type.
 static const byte kVideoPlaytypeAVF					= 0;
 static const byte kVideoPlaytypeBink				= 1;
+static const byte kVideoPlaytypeAuto				= 2;
 
 // Overlay transparency mode. 1 = opaque, anything >= kPlayOverlayTransparent is
 // drawn transparent (the original engine is 16bpp color-key, with no alpha/blend
diff --git a/engines/nancy/movieplayer.cpp b/engines/nancy/movieplayer.cpp
index 1c3f2916efa..77ac2d48f80 100644
--- a/engines/nancy/movieplayer.cpp
+++ b/engines/nancy/movieplayer.cpp
@@ -49,24 +49,40 @@ MoviePlayer::MoviePlayer() {}
 
 MoviePlayer::~MoviePlayer() {}
 
-bool MoviePlayer::loadFile(const Common::Path &name, bool bidirectionalCache) {
+byte MoviePlayer::resolvePlaytype(byte videoPlaytype) {
+	if (videoPlaytype != kVideoPlaytypeAuto) {
+		return videoPlaytype;
+	}
+
+	// Scene backgrounds only look for .bik from Nancy10 on.
+	return g_nancy->getGameType() >= kGameTypeNancy10 ? kVideoPlaytypeBink : kVideoPlaytypeAVF;
+}
+
+bool MoviePlayer::loadFile(const Common::Path &name, byte videoPlaytype, bool bidirectionalCache) {
 	freeFrameCache();
 
 	const Common::Path avfPath = name.append(".avf");
 	const Common::Path bikPath = name.append(".bik");
 
-	// Detect the format from which file exists. Bink wins if both do.
-	if (Common::File::exists(bikPath)) {
-		_videoType = kVideoPlaytypeBink;
-		_decoder.reset(new Video::BinkDecoder());
-	} else if (Common::File::exists(avfPath)) {
-		_videoType = kVideoPlaytypeAVF;
-		_decoder.reset(new AVFDecoder(bidirectionalCache ? AVFDecoder::kLoadBidirectional : AVFDecoder::kLoadForward));
+	// Use the requested container, the other one only if its file is missing.
+	const byte preferred = resolvePlaytype(videoPlaytype);
+	const byte fallback = preferred == kVideoPlaytypeBink ? kVideoPlaytypeAVF : kVideoPlaytypeBink;
+
+	if (Common::File::exists(preferred == kVideoPlaytypeBink ? bikPath : avfPath)) {
+		_videoType = preferred;
+	} else if (Common::File::exists(fallback == kVideoPlaytypeBink ? bikPath : avfPath)) {
+		_videoType = fallback;
 	} else {
 		_decoder.reset();
 		return false;
 	}
 
+	if (_videoType == kVideoPlaytypeBink) {
+		_decoder.reset(new Video::BinkDecoder());
+	} else {
+		_decoder.reset(new AVFDecoder(bidirectionalCache ? AVFDecoder::kLoadBidirectional : AVFDecoder::kLoadForward));
+	}
+
 	_currentSurface = nullptr;
 
 	if (!_decoder->loadFile(_videoType == kVideoPlaytypeAVF ? avfPath : bikPath)) {
diff --git a/engines/nancy/movieplayer.h b/engines/nancy/movieplayer.h
index 07d9d1259ab..ed2a5c7a578 100644
--- a/engines/nancy/movieplayer.h
+++ b/engines/nancy/movieplayer.h
@@ -58,11 +58,11 @@ public:
 	MoviePlayer();
 	~MoviePlayer();
 
-	// Load <name> + ".avf"/".bik", auto-detecting the format from which file
-	// exists (Bink preferred if both do) and creating the matching decoder.
+	// Load <name> + ".avf"/".bik" and create the matching decoder. videoPlaytype
+	// picks the container, falling back to the other one if its file is missing.
 	// bidirectionalCache enables fast bidirectional scrubbing; pass it only for
 	// scrubbed panorama scenes.
-	bool loadFile(const Common::Path &name, bool bidirectionalCache = false);
+	bool loadFile(const Common::Path &name, byte videoPlaytype = kVideoPlaytypeAuto, bool bidirectionalCache = false);
 	bool isVideoLoaded() const;
 	void close();
 	Video::VideoDecoder *getDecoder() { return _decoder.get(); }
@@ -108,6 +108,8 @@ public:
 private:
 	friend class BinkCacheLoader;
 
+	static byte resolvePlaytype(byte videoPlaytype);	// resolves kVideoPlaytypeAuto
+
 	void storeCurrentFrame();
 	void freeFrameCache();
 	bool fillNextCacheFrame();	// decode one uncached frame; true when the cache is full
diff --git a/engines/nancy/ui/viewport.cpp b/engines/nancy/ui/viewport.cpp
index e4c7197e654..3cf55feb41c 100644
--- a/engines/nancy/ui/viewport.cpp
+++ b/engines/nancy/ui/viewport.cpp
@@ -220,7 +220,7 @@ void Viewport::loadVideo(const Common::Path &filename, uint frameNr, uint vertic
 	// Only panorama scenes step through frames, so only they need the frame cache
 	// for fast bidirectional scrubbing; other scenes would just waste memory.
 	const bool isPanorama = panningType == kPan360 || panningType == kPanLeftRight;
-	if (!_decoder.loadFile(filename, isPanorama)) {
+	if (!_decoder.loadFile(filename, kVideoPlaytypeAuto, isPanorama)) {
 		error("Couldn't load video file %s.avf or %s.bik", filename.toString().c_str(), filename.toString().c_str());
 	}
 


Commit: a88a37dae57eb22a79af645558e1abb60416d368
    https://github.com/scummvm/scummvm/commit/a88a37dae57eb22a79af645558e1abb60416d368
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-19T14:24:31+03:00

Commit Message:
NANCY: NANCY13: Implement returning to idle animation gracefully

This is the same functionality as in Nancy12 and older games: when
hovering the mouse over a character, he/she turns to the player.
When moving the mouse out of the character, he/she gracefully goes
back to the idle animation.

The difference in Nancy13 is that these animations are now in Bink
format, instead of AVF, which needs special handling for rewinding
these videos.

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


diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index e6d7cb39860..81b15f545f5 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -299,10 +299,37 @@ bool PlaySecondaryMovie::activateSecondaryMovie() {
 	resolveSentinelFrames();
 
 	_isFinished = false;
+	_secondaryRewinding = false;
 	_curViewportFrame = -1;
 	return true;
 }
 
+void PlaySecondaryMovie::beginSecondaryRewind() {
+	_secondaryRewinding = true;
+	_rewindFrame = _decoder.getCurFrame();
+	_rewindLastFrameTime = g_system->getMillis();
+
+	const int frameCount = _decoder.getFrameCount();
+	const uint32 durationMs = _decoder.getDuration().msecs();
+	_rewindFrameDelay = (frameCount > 0 && durationMs > 0) ? (durationMs / frameCount) : 66;
+	_isFinished = false;
+}
+
+const Graphics::Surface *PlaySecondaryMovie::updateSecondaryRewind() {
+	uint32 now = g_system->getMillis();
+	if (now - _rewindLastFrameTime < _rewindFrameDelay) {
+		return nullptr;
+	}
+
+	_rewindLastFrameTime = now;
+
+	if (_rewindFrame > (int)_firstFrame) {
+		--_rewindFrame;
+	}
+
+	return _decoder.decodeNextFrame(_rewindFrame);
+}
+
 void PlaySecondaryMovie::resolveSentinelFrames() {
 	// Random sequences use -1/-2 for the start/last frame to mean "play the
 	// movie's own first/last frame". Resolve them now that the decoder (and
@@ -684,16 +711,24 @@ void PlaySecondaryMovie::execute() {
 			break;
 		}
 
-		// Talkable character: swap immediately between the idle loop and the
-		// recognition ("turn around") movie as the mouse enters/leaves the
-		// character, without waiting for the current cycle to finish.
+		// Talkable character: swap immediately from the idle loop to the
+		// recognition ("turn around") movie as the mouse enters the character,
+		// without waiting for the current cycle to finish. On the way out the
+		// recognition movie is played backwards instead, so the character turns
+		// away again before the idle loop resumes.
 		if (isTalkable()) {
-			if (_isHovered && !_playingSecondary) {
-				_playingSecondary = true;
-				activateSecondaryMovie();
-			} else if (!_isHovered && _playingSecondary) {
-				_playingSecondary = false;
-				activateRandomSequence(_activeSequenceIndex);
+			if (_isHovered) {
+				if (!_playingSecondary) {
+					_playingSecondary = true;
+					activateSecondaryMovie();
+				} else if (_secondaryRewinding) {
+					// The mouse came back mid-turn: play forward again from here.
+					_secondaryRewinding = false;
+					_isFinished = false;
+					_decoder.pauseVideo(false);
+				}
+			} else if (_playingSecondary && !_secondaryRewinding) {
+				beginSecondaryRewind();
 			}
 		}
 
@@ -736,7 +771,7 @@ 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) {
+		if (!_decoder.isPlaying() && _isVisible && !_isFinished && !_secondaryRewinding) {
 			_decoder.start();
 			resolveSentinelFrames();
 
@@ -748,7 +783,10 @@ void PlaySecondaryMovie::execute() {
 			}
 		}
 
-		if (_decoder.needsUpdate()) {
+		const Graphics::Surface *decodedFrame = _secondaryRewinding ? updateSecondaryRewind() :
+			(_decoder.needsUpdate() ? _decoder.decodeNextFrame() : nullptr);
+
+		if (decodedFrame) {
 			uint descID = 0;
 
 			for (uint i = 0; i < _videoDescs.size(); ++i) {
@@ -757,7 +795,7 @@ void PlaySecondaryMovie::execute() {
 				}
 			}
 
-			GraphicsManager::copyToManaged(*_decoder.decodeNextFrame(), _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
+			GraphicsManager::copyToManaged(*decodedFrame, _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
 
 			// Nancy14 stores an all -1 srcRect to mean "use the whole frame".
 			Common::Rect srcRect = _videoDescs[descID].srcRect;
@@ -793,12 +831,25 @@ void PlaySecondaryMovie::execute() {
 			}
 		}
 
-		if ((_decoder.getCurFrame() == _lastFrame && _playDirection == kPlayMovieForward) ||
+		if (_secondaryRewinding) {
+			// The character has turned away again: hand the screen back to the
+			// idle loop it was playing before the mouse arrived.
+			if (_rewindFrame <= (int)_firstFrame) {
+				_playingSecondary = false;
+				_secondaryRewinding = false;
+				activateRandomSequence(_activeSequenceIndex);
+			}
+		} else if ((_decoder.getCurFrame() == _lastFrame && _playDirection == kPlayMovieForward) ||
 			(_decoder.getCurFrame() == _firstFrame && _playDirection == kPlayMovieReverse) ||
 			_decoder.endOfVideo()) {
 
-			_decoder.pauseVideo(true);
-			_isFinished = true;
+			// A movie held on its last frame keeps matching the check above every
+			// tick; only pause it the once, so the pause level stays balanced for
+			// whoever resumes playback.
+			if (!_isFinished) {
+				_decoder.pauseVideo(true);
+				_isFinished = true;
+			}
 
 			if (_isRandom) {
 				// Sequence finished: roll for next. If stop was requested
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index c0f0cbfc0d5..fd366c78cd1 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -164,6 +164,16 @@ public:
 	bool _isHovered = false;
 	bool _playingSecondary = false;
 
+	// Rewind state for the recognition movie. When the mouse leaves the
+	// character the movie is played backwards to its first frame so the
+	// character turns away again instead of snapping back to its idle pose.
+	// Bink can't be played in reverse by the decoder, so the frames are
+	// stepped through by hand.
+	bool _secondaryRewinding = false;
+	int _rewindFrame = 0;
+	uint32 _rewindLastFrameTime = 0;
+	uint32 _rewindFrameDelay = 66;
+
 	// Called by PlayRandomMovieControl::execute() to wind down the AR.
 	void stopRandom() { _randomStopRequested = true; }
 
@@ -212,6 +222,12 @@ protected:
 	// Load & start the recognition (secondary) movie in place of the idle loop.
 	bool activateSecondaryMovie();
 
+	// Start playing the recognition movie backwards from wherever it is now.
+	void beginSecondaryRewind();
+	// Advance the manual rewind; returns the frame to draw, or nullptr when
+	// the next frame isn't due yet.
+	const Graphics::Surface *updateSecondaryRewind();
+
 	// A Nancy13 talkable character: has a conversation scene and a recognition
 	// movie to swap to on hover.
 	bool isTalkable() const { return _isRandom && _talkSceneID != kNoScene && !_secondaryMovie.name.empty(); }




More information about the Scummvm-git-logs mailing list