[Scummvm-git-logs] scummvm master -> 99be9e6211e4211cadd7b30c99d526acdf75c7f7

bluegr noreply at scummvm.org
Wed Jun 24 01:26:25 UTC 2026


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

Summary:
99be9e6211 NANCY: Implement the Nancy11 timer functionality


Commit: 99be9e6211e4211cadd7b30c99d526acdf75c7f7
    https://github.com/scummvm/scummvm/commit/99be9e6211e4211cadd7b30c99d526acdf75c7f7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-24T04:24:14+03:00

Commit Message:
NANCY: Implement the Nancy11 timer functionality

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


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 13f009f0b88..efa0523850b 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -259,8 +259,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 68:
 		return new TextScroll(false);
 	case 69:	// Nancy11
-		warning("TimerControl");	// TODO
-		return nullptr;
+		return new TimerControl();
 	case 70:
 		return new TextScroll(true); // AutotextEntryList
 	case 71:
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index e05029c5429..3f0d9f77eb4 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -435,21 +435,150 @@ void TurnOnMainRendering::readData(Common::SeekableReadStream &stream) {
 	stream.skip(1);
 }
 
+// Returns the Nancy 11+ software-timer slot at the given index, lazily creating
+// the TimerData puzzle-data chunk. nullptr if the index is out of range.
+static TimerData::Timer *getSoftwareTimer(int16 index) {
+	if (index < 0 || (uint)index >= TimerData::kNumTimers) {
+		return nullptr;
+	}
+
+	TimerData *timerData = (TimerData *)NancySceneState.getPuzzleData(TimerData::getTag());
+	return timerData ? &timerData->timers[index] : nullptr;
+}
+
 void ResetAndStartTimer::readData(Common::SeekableReadStream &stream) {
-	stream.skip(1);
+	_timerIndex = stream.readByte();
 }
 
 void ResetAndStartTimer::execute() {
 	NancySceneState.resetAndStartTimer();
+
+	// Nancy 11 also resets and starts one of the software-timer slots
+	if (g_nancy->getGameType() >= kGameTypeNancy11) {
+		TimerData::Timer *timer = getSoftwareTimer(_timerIndex);
+		if (timer) {
+			timer->reset();
+			timer->state = TimerData::Timer::kRunning;
+		}
+	}
+
 	_isDone = true;
 }
 
 void StopTimer::readData(Common::SeekableReadStream &stream) {
-	stream.skip(1);
+	_timerIndex = stream.readByte();
 }
 
 void StopTimer::execute() {
 	NancySceneState.stopTimer();
+
+	if (g_nancy->getGameType() >= kGameTypeNancy11) {
+		TimerData::Timer *timer = getSoftwareTimer(_timerIndex);
+		if (timer) {
+			timer->reset();
+		}
+	}
+
+	_isDone = true;
+}
+
+// Reads exactly size bytes from the stream, returning the text up to the first
+// null byte. Used for the fixed-size string fields inside a TimerControl chunk.
+static Common::String readFixedSizeString(Common::SeekableReadStream &stream, uint size) {
+	Common::String result;
+	bool ended = false;
+	for (uint i = 0; i < size; ++i) {
+		byte b = stream.readByte();
+		if (b == 0) {
+			ended = true;
+		}
+		if (!ended) {
+			result += (char)b;
+		}
+	}
+
+	return result;
+}
+
+void TimerControl::readData(Common::SeekableReadStream &stream) {
+	const int64 startPos = stream.pos();
+
+	_timerIndex = stream.readSint16LE();    // 0x00
+	_command = stream.readSint16LE();       // 0x02
+	_hours = stream.readSint16LE();         // 0x04
+	_minutes = stream.readSint16LE();       // 0x06
+	_seconds = stream.readSint16LE();       // 0x08
+	stream.skip(2);                         // 0x0a, unused
+
+	// Sound to play on expiry (0x0c)
+	_sound.readDIGI(stream);
+
+	// Autotext key for the caption (0x3d, 33 bytes)
+	stream.skip((startPos + 0x3d) - stream.pos());
+	_autotextKey = readFixedSizeString(stream, 0x21);
+
+	// Inline caption text (0x5e, 100 bytes)
+	_caption = readFixedSizeString(stream, 0x64);
+
+	// Event flags to fire on expiry (count at 0xc2, then count*4 bytes)
+	uint16 numFlags = stream.readUint16LE();
+	_flags.resize(numFlags);
+	for (uint i = 0; i < numFlags; ++i) {
+		_flags[i].label = stream.readSint16LE();
+		_flags[i].flag = (byte)stream.readSint16LE();
+	}
+}
+
+void TimerControl::execute() {
+	TimerData::Timer *timer = getSoftwareTimer(_timerIndex);
+	if (!timer) {
+		_isDone = true;
+		return;
+	}
+
+	const uint32 durationMs = ((uint32)_hours * 3600 + (uint32)_minutes * 60 + (uint32)_seconds) * 1000;
+
+	switch (_command) {
+	case kReset:
+		timer->reset();
+		break;
+	case kStart:
+		timer->state = TimerData::Timer::kRunning;
+		break;
+	case kPause:
+		timer->state = TimerData::Timer::kPaused;
+		break;
+	case kAddTime:
+		if (timer->state != TimerData::Timer::kIdle) {
+			timer->currentTimeMs += durationMs;
+		}
+		break;
+	case kSubtractTime:
+		if (timer->state != TimerData::Timer::kIdle) {
+			timer->currentTimeMs = timer->currentTimeMs > durationMs ? timer->currentTimeMs - durationMs : 0;
+		}
+		break;
+	case kConfigOneShot:
+	case kConfigRepeating:
+		// A timer can only be configured once it has been started
+		if (timer->state == TimerData::Timer::kRunning) {
+			timer->durationMs = durationMs;
+			timer->hasFired = false;
+			timer->sound = _sound;
+			timer->autotextKey = _autotextKey;
+			timer->caption = _caption;
+
+			for (uint i = 0; i < ARRAYSIZE(timer->flags); ++i) {
+				timer->flags[i] = i < _flags.size() ? _flags[i] : FlagDescription();
+			}
+
+			timer->state = _command;
+		}
+		break;
+	default:
+		break;
+	}
+
 	_isDone = true;
 }
 
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index d133518b151..54716cb49f1 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -288,12 +288,15 @@ protected:
 };
 
 // Starts the timer. Used in combination with Dependency types that check for
-// how much time has passed since the timer was started.
+// how much time has passed since the timer was started. From Nancy 11 onwards
+// the record also carries a software-timer slot index (see TimerControl).
 class ResetAndStartTimer : public ActionRecord {
 public:
 	void readData(Common::SeekableReadStream &stream) override;
 	void execute() override;
 
+	byte _timerIndex = 0; // Nancy 11+ software-timer slot
+
 protected:
 	Common::String getRecordTypeName() const override { return "ResetAndStartTimer"; }
 };
@@ -304,10 +307,47 @@ public:
 	void readData(Common::SeekableReadStream &stream) override;
 	void execute() override;
 
+	byte _timerIndex = 0; // Nancy 11+ software-timer slot
+
 protected:
 	Common::String getRecordTypeName() const override { return "StopTimer"; }
 };
 
+// Nancy 11+ AR 69 (AT_TIMER_CONTROL). Issues a command to one of the 10
+// software-timer slots (see TimerData::Timer). The fixed-size chunk
+// (0xc4 header + count*4 flag entries) carries a slot index, a command, a
+// target duration, an optional sound + caption, and the event flags to fire
+// when the timer expires.
+class TimerControl : public ActionRecord {
+public:
+	enum Command {
+		kReset       = 0, // Clear the slot back to idle
+		kStart       = 1, // Begin counting, with no target
+		kPause       = 2, // Suspend counting
+		kAddTime     = 3, // Add the duration to the elapsed time
+		kSubtractTime = 4, // Subtract the duration from the elapsed time
+		kConfigOneShot   = 5, // Set target/payload; fire once, then reset
+		kConfigRepeating = 6  // Set target/payload; fire once, then keep running
+	};
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	int16 _timerIndex = 0;
+	int16 _command = 0;
+	int16 _hours = 0;
+	int16 _minutes = 0;
+	int16 _seconds = 0;
+
+	SoundDescription _sound;
+	Common::String _autotextKey;
+	Common::String _caption;
+	Common::Array<FlagDescription> _flags;
+
+protected:
+	Common::String getRecordTypeName() const override { return "TimerControl"; }
+};
+
 // Nancy 11+ AR 30. Disables the player's ability to scroll/pan the viewport
 // (both mouse-edge and keyboard movement). State persists across scene changes.
 class StopPlayerScrolling : public ActionRecord {
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index a11dbc379cd..938d31bc253 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -352,6 +352,30 @@ void CellPhoneData::syncLinkArray(Common::Serializer &ser, Common::Array<LinkEnt
 	}
 }
 
+void TimerData::synchronize(Common::Serializer &ser) {
+	for (uint i = 0; i < kNumTimers; ++i) {
+		Timer &t = timers[i];
+		ser.syncAsSint32LE(t.state);
+		ser.syncAsUint32LE(t.currentTimeMs);
+		ser.syncAsUint32LE(t.durationMs);
+		ser.syncAsByte(t.hasFired);
+
+		ser.syncString(t.sound.name);
+		ser.syncAsUint16LE(t.sound.channelID);
+		ser.syncAsUint16LE(t.sound.playCommands);
+		ser.syncAsUint16LE(t.sound.numLoops);
+		ser.syncAsUint16LE(t.sound.volume);
+
+		ser.syncString(t.autotextKey);
+		ser.syncString(t.caption);
+
+		for (uint j = 0; j < ARRAYSIZE(t.flags); ++j) {
+			ser.syncAsSint16LE(t.flags[j].label);
+			ser.syncAsByte(t.flags[j].flag);
+		}
+	}
+}
+
 PuzzleData *makePuzzleData(const uint32 tag) {
 	switch(tag) {
 	case SliderPuzzleData::getTag():
@@ -382,6 +406,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new TableData();
 	case CellPhoneData::getTag():
 		return new CellPhoneData();
+	case TimerData::getTag():
+		return new TimerData();
 	default:
 		return nullptr;
 	}
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 5932ec92c0e..fafedab7de4 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -254,6 +254,38 @@ private:
 	void syncLinkArray(Common::Serializer &ser, Common::Array<LinkEntry> &arr);
 };
 
+// Nancy 11+ AR 69 (TimerControl). 10 software timers, each counting up from
+// zero. A "configured" timer (state 5/6) fires a set of event flags, plays an
+// optional sound and shows an optional caption once its target duration
+// elapses. Started/stopped via ResetAndStartTimer (104) and StopTimer (105),
+// which in Nancy 11 carry a timer-slot index.
+struct TimerData : public PuzzleData {
+	struct Timer {
+		enum State { kIdle = 0, kRunning = 1, kPaused = 2, kOneShot = 5, kRepeating = 6 };
+
+		int32 state = kIdle;
+		uint32 currentTimeMs = 0;
+		uint32 durationMs = 0;
+		bool hasFired = false;
+		SoundDescription sound;
+		Common::String autotextKey;
+		Common::String caption;
+		FlagDescription flags[10];
+
+		void reset() { *this = Timer(); }
+	};
+
+	static const uint kNumTimers = 10;
+
+	TimerData() {}
+	virtual ~TimerData() {}
+
+	static constexpr uint32 getTag() { return MKTAG('T', 'M', 'R', 'S'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Timer timers[kNumTimers];
+};
+
 PuzzleData *makePuzzleData(const uint32 tag);
 
 } // End of namespace Nancy
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 18571f13cdd..66c2405d07f 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1119,6 +1119,10 @@ void Scene::run() {
 
 	_timers.sceneTime += deltaTime;
 
+	// Advance the Nancy 11+ software timers before processing action records,
+	// so any flags they fire this frame are visible to record dependencies
+	tickSoftwareTimers((uint32)deltaTime);
+
 	// Calculate the in-game time (playerTime)
 	if (currentPlayTime > _timers.playerTimeNextMinute) {
 		auto *bootSummary = GetEngineData(BSUM);
@@ -1160,6 +1164,76 @@ void Scene::run() {
 	}
 }
 
+void Scene::tickSoftwareTimers(uint32 deltaMs) {
+	if (g_nancy->getGameType() < kGameTypeNancy11 || deltaMs == 0) {
+		return;
+	}
+
+	// getPuzzleData() below lazily creates (and thereafter persists) the TimerData
+	// chunk. This runs every frame, so without this guard every Nancy 11 save
+	// would carry an empty TimerData chunk even if no timer is ever used. The
+	// chunk only exists once a timer AR has configured a slot.
+	if (!_puzzleData.contains(TimerData::getTag())) {
+		return;
+	}
+
+	TimerData *timerData = (TimerData *)getPuzzleData(TimerData::getTag());
+
+	for (uint i = 0; i < TimerData::kNumTimers; ++i) {
+		TimerData::Timer &timer = timerData->timers[i];
+
+		if (timer.state != TimerData::Timer::kRunning &&
+			timer.state != TimerData::Timer::kOneShot &&
+			timer.state != TimerData::Timer::kRepeating) {
+			continue;
+		}
+
+		timer.currentTimeMs += deltaMs;
+
+		if ((timer.state == TimerData::Timer::kOneShot || timer.state == TimerData::Timer::kRepeating) &&
+			timer.durationMs > 0 && !timer.hasFired && timer.currentTimeMs >= timer.durationMs) {
+			fireSoftwareTimer(timer);
+
+			if (timer.state == TimerData::Timer::kOneShot) {
+				// One-shot timers clear themselves once they fire
+				timer.reset();
+			} else {
+				// Repeating timers keep counting up but will not fire again
+				timer.state = TimerData::Timer::kRunning;
+			}
+		}
+	}
+}
+
+void Scene::fireSoftwareTimer(TimerData::Timer &timer) {
+	timer.hasFired = true;
+
+	// Set the configured event flags
+	for (uint i = 0; i < ARRAYSIZE(timer.flags); ++i) {
+		if (timer.flags[i].label != kFlagNoLabel) {
+			setEventFlag(timer.flags[i]);
+		}
+	}
+
+	// Play the optional expiry sound
+	if (timer.sound.name != "NO SOUND") {
+		g_nancy->_sound->loadSound(timer.sound);
+		g_nancy->_sound->playSound(timer.sound);
+	}
+
+	// Show the optional caption, if captions are enabled
+	if (ConfMan.getBool("subtitles", ConfMan.getActiveDomainName())) {
+		if (!timer.autotextKey.empty()) {
+			const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+			if (autotext && autotext->texts.contains(timer.autotextKey)) {
+				_textbox.addTextLine(autotext->texts[timer.autotextKey]);
+			}
+		} else if (!timer.caption.empty()) {
+			_textbox.addTextLine(timer.caption);
+		}
+	}
+}
+
 Common::Rect Scene::activePopupConfinement() const {
 	// Pick the first visible Nancy 10+ popup; if more than one is open
 	// (shouldn't normally happen) the priority order matches the input
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 407971b6b9c..68c16600899 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -247,6 +247,11 @@ private:
 	void run();
 	void handleInput();
 
+	// Nancy 11+ AR 69. Advances all running software timers (stored as TimerData
+	// puzzle data) and fires any whose configured duration has just elapsed.
+	void tickSoftwareTimers(uint32 deltaMs);
+	void fireSoftwareTimer(TimerData::Timer &timer);
+
 	// Rect of the open Nancy 10+ taskbar popup, or empty if none.
 	Common::Rect activePopupConfinement() const;
 




More information about the Scummvm-git-logs mailing list