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

bluegr noreply at scummvm.org
Sun Aug 2 23:09:22 UTC 2026


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

Summary:
b0d53868bc NANCY: NANCY12: Document and clean up ActionZone
f15f03ac26 NANCY: NANCY12: Implement the rest of the DrivingPuzzle functionality
c9df87accb NANCY: Filter out more invalid file name chars in actionrecord_export


Commit: b0d53868bcc714479e400fd82fd3ce93202e7606
    https://github.com/scummvm/scummvm/commit/b0d53868bcc714479e400fd82fd3ce93202e7606
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-03T02:09:11+03:00

Commit Message:
NANCY: NANCY12: Document and clean up ActionZone

Changed paths:
    engines/nancy/action/actionzone.cpp
    engines/nancy/action/actionzone.h
    engines/nancy/action/puzzle/minigolfpuzzle.cpp
    engines/nancy/action/puzzle/pachinkopuzzle.cpp
    engines/nancy/action/puzzle/sewingmachinepuzzle.cpp


diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
index 1002b7f9aad..bf16c6bf8bd 100644
--- a/engines/nancy/action/actionzone.cpp
+++ b/engines/nancy/action/actionzone.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "common/stream.h"
+#include "common/util.h"
 
 #include "engines/nancy/util.h"
 #include "engines/nancy/action/actionzone.h"
@@ -27,10 +28,13 @@
 namespace Nancy {
 namespace Action {
 
+// Terminator in place of a special effect's type byte, meaning "no effect follows"
+static const byte kNoSpecialEffect = 0xff;
+
 void ActionZone::readData(Common::SeekableReadStream &stream, bool isNancy13) {
 	// Base ActionZone fields, shared by every subtype.
 	typeField = stream.readSint32LE();
-	type = typeField & 0xFF;
+	type = (ActionZoneType)(typeField & 0xFF);
 
 	readRect(stream, rect);
 	readFilename(stream, ovlName);
@@ -55,53 +59,54 @@ void ActionZone::readData(Common::SeekableReadStream &stream, bool isNancy13) {
 
 // Subtype-specific trailing data. Fields not yet needed are skipped to keep the stream
 // aligned. The Nancy12 and Nancy13 layouts are identical apart from three subtypes
-// (0x0d / 0x15 / 0x16), which branch on isNancy13.
+// (overlay, unknown 0x15 and bumper), which branch on isNancy13.
 void ActionZone::readSubtype(Common::SeekableReadStream &stream, bool isNancy13) {
 	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:
+	case kZoneSpecialEffect:
+	case kZoneDestination:
+	case kZoneUnknown12:
+	case kZoneUnknown13:
 		readSpecialEffect(stream);
 		break;
-	case 0x0b:				// collision zone: event-flag id + on/off
-		tailId = stream.readSint16LE();
-		tailFlag = stream.readByte();
+	case kZoneBaseOnly:
+	case kZoneUnknown05:
+	case kZoneUnknown10:
+	case kZoneBoundary:
+		// No trailing data.
 		break;
-	case 0x0e:
-		stream.skip(2);		// int16
-		break;
-	case 0x0f:
-		stream.skip(4);		// int16 + int16
+	case kZoneTeleport:
+		readRect(stream, exitRect);
+		teleportDelay = stream.readSint32LE();
+		exitAngle = stream.readSint16LE();
+		exitSpeed = stream.readSint16LE();
 		break;
-	case 3:					// terrain (sand): extra deceleration while the ball is inside
+	case kZoneTerrain:
 		terrainDecel = stream.readDoubleLE();
 		break;
-	case 0x17:				// Flat Tire (min/max) - Nancy12 only
-		stream.skip(8);		// int32 + int32
-		break;
-	case 4:					// slope: a velocity kick of slopeForce along slopeAngle
+	case kZoneSlope:
 		slopeForce = stream.readDoubleLE();
 		slopeAngle = stream.readSint16LE();
 		break;
-	case 2:					// teleport / pipe: exit rect + hold time + exit angle/speed
-		readRect(stream, exitRect);
-		teleportDelay = stream.readSint32LE();
-		exitAngle = stream.readSint16LE();
-		exitSpeed = stream.readSint16LE();
+	case kZoneEventFlag:
+		tailId = stream.readSint16LE();
+		tailFlag = stream.readByte();
 		break;
-	case 0x0c:				// trigger zone: special effect + target scene id + flag
+	case kZoneSceneChange:
 		readSpecialEffect(stream);
 		tailId = stream.readSint16LE();
 		tailFlag = stream.readByte();
 		break;
-	case 0x15:
-		// Nancy12: special effect + a trailing int32. Nancy13: the flat-tire zone
+	case kZoneOverlay:
+		readOverlayZone(stream, isNancy13);
+		break;
+	case kZoneUnknown0E:
+		stream.skip(2);		// int16
+		break;
+	case kZoneUnknown0F:
+		stream.skip(4);		// int16 + int16
+		break;
+	case kZoneUnknown15:
+		// Nancy12: special effect + a trailing int32. Nancy13: a damage range
 		// (min/max int32), with no special effect.
 		if (isNancy13) {
 			stream.skip(8);
@@ -110,11 +115,8 @@ void ActionZone::readSubtype(Common::SeekableReadStream &stream, bool isNancy13)
 			stream.skip(4);
 		}
 		break;
-	case 0x0d:				// OverlayZone (Nancy13 carries one extra int32)
-		readOverlayZone(stream, isNancy13);
-		break;
-	case 0x16:
-		// Nancy12: OverlayZone + int32. Nancy13: a short bumper record (two bytes + int16).
+	case kZoneBumper:
+		// Nancy12: an OverlayZone + int32. Nancy13: a short record (two bytes + int16).
 		if (isNancy13) {
 			stream.skip(4);
 		} else {
@@ -122,6 +124,13 @@ void ActionZone::readSubtype(Common::SeekableReadStream &stream, bool isNancy13)
 			stream.skip(4);
 		}
 		break;
+	case kZoneFlatTire:		// Nancy12 only
+		flatTireMin = stream.readSint32LE();
+		flatTireMax = stream.readSint32LE();
+		if (flatTireMax < flatTireMin) {
+			SWAP(flatTireMin, flatTireMax);
+		}
+		break;
 	default:
 		warning("ActionZone: unknown type %d - chunk may desync", type);
 		break;
@@ -129,13 +138,13 @@ void ActionZone::readSubtype(Common::SeekableReadStream &stream, bool isNancy13)
 }
 
 // Special Effect block: an int16 id (a target scene on transition zones), then the
-// effect type byte. If the type is the 0xff terminator the effect is absent;
-// otherwise a 21-byte SpecialEffect record follows (type + totalTime +
-// fadeToBlackTime + Rect), matching the standalone SpecialEffect action record.
+// effect type byte. If the type is the terminator the effect is absent; otherwise a
+// 21-byte SpecialEffect record follows (type + totalTime + fadeToBlackTime + Rect),
+// matching the standalone SpecialEffect action record.
 void ActionZone::readSpecialEffect(Common::SeekableReadStream &stream) {
 	specialEffectId = stream.readUint16LE();
 	seType = stream.readByte();
-	if (seType == 0xff) {
+	if (seType == kNoSpecialEffect) {
 		seType = 0;
 		return;
 	}
diff --git a/engines/nancy/action/actionzone.h b/engines/nancy/action/actionzone.h
index ae3edc77cf4..96d255e6a85 100644
--- a/engines/nancy/action/actionzone.h
+++ b/engines/nancy/action/actionzone.h
@@ -35,13 +35,38 @@ class SeekableReadStream;
 namespace Nancy {
 namespace Action {
 
+// The zone's subtype, stored in the low byte of its leading int32. It selects both
+// what the zone does at runtime and how much trailing data follows the shared base.
+// Subtypes whose meaning has not been worked out yet are named after their value;
+// those still read (or skip) the right number of bytes.
+enum ActionZoneType : byte {
+	kZoneSpecialEffect	= 0x00,	// carries a scene id plus an optional fade
+	kZoneBaseOnly		= 0x01,	// base fields only, no behavior of its own
+	kZoneTeleport		= 0x02,	// pipe: swallows the ball and re-emits it elsewhere
+	kZoneTerrain		= 0x03,	// sand trap / mud puddle: slows whatever is inside
+	kZoneSlope			= 0x04,	// hill: a velocity kick while crossing the zone
+	kZoneUnknown05		= 0x05,	// base fields only
+	kZoneEventFlag		= 0x0b,	// checkpoint / sewing seam: sets an event flag
+	kZoneSceneChange	= 0x0c,	// finish line / golf cup: fade + target scene + flag
+	kZoneOverlay		= 0x0d,	// cosmetic overlay sprite
+	kZoneUnknown0E		= 0x0e,
+	kZoneUnknown0F		= 0x0f,
+	kZoneUnknown10		= 0x10,	// base fields only
+	kZoneDestination	= 0x11,	// driving: a location entrance the car can park in
+	kZoneUnknown12		= 0x12,	// another special-effect variant
+	kZoneUnknown13		= 0x13,	// another special-effect variant
+	kZoneBoundary		= 0x14,	// play-area wall
+	kZoneUnknown15		= 0x15,	// special effect + int32; a damage range in Nancy13
+	kZoneBumper			= 0x16,	// Nancy12: an overlay variant; Nancy13: a pachinko hole
+	kZoneFlatTire		= 0x17	// pothole: damages the car driving over it
+};
+
 // 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.
+// Chase). Each subtype reads a different amount of trailing data; readData()
+// consumes exactly the right number of bytes so the surrounding chunk stays in sync.
 struct ActionZone {
-	byte type = 0;
+	ActionZoneType type = kZoneSpecialEffect;
 	int32 typeField = 0;	// full leading int32; low byte == type
 
 	Common::Rect rect;
@@ -57,7 +82,7 @@ struct ActionZone {
 	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).
+	// special-effect subtypes when the effect byte is not the terminator).
 	uint16 specialEffectId = 0;
 	bool hasSpecialEffect = false;
 	byte seType = 0;				// 1 = blackout, 2 = cross-dissolve, 3 = through-black
@@ -65,37 +90,41 @@ struct ActionZone {
 	uint16 seFadeToBlackTime = 0;
 	Common::Rect seRect;
 
-	// int16 + byte trailer carried by the collision (0x0b) and trigger (0x0c)
-	// subtypes. For 0x0b it is an event-flag id + on/off; for 0x0c it is a target
-	// scene id + a flag. Left at their defaults for the other subtypes.
+	// int16 + byte trailer carried by kZoneEventFlag and kZoneSceneChange. For the
+	// former it is an event-flag id + on/off; for the latter a target scene id + a
+	// flag. Left at their defaults for the other subtypes.
 	int16 tailId = 0;
 	byte tailFlag = 0;
 
-	// Teleport / pipe subtype (0x02): the ball entering this zone is held for
-	// teleportDelay ms, then re-emerges at exitRect travelling at exitSpeed along
-	// exitAngle (degrees).
+	// kZoneTeleport: the ball entering this zone is held for teleportDelay ms, then
+	// re-emerges at exitRect travelling at exitSpeed along exitAngle (degrees).
 	Common::Rect exitRect;
 	int32 teleportDelay = 0;
 	int16 exitAngle = 0;
 	int16 exitSpeed = 0;
 
-	// Terrain subtype (0x03, e.g. a sand trap): extra deceleration added to the
-	// ball's friction while it is inside this zone.
+	// kZoneTerrain: extra deceleration added to the moving object's friction while
+	// it is inside this zone.
 	double terrainDecel = 0.0;
 
-	// Slope subtype (0x04, e.g. a sloped green): a velocity kick of slopeForce along
-	// slopeAngle (degrees) applied on entering the zone and removed on leaving.
+	// kZoneFlatTire: entering the zone adds a random amount of damage in
+	// [flatTireMin, flatTireMax].
+	int32 flatTireMin = 0;
+	int32 flatTireMax = 0;
+
+	// kZoneSlope: a velocity kick of slopeForce along slopeAngle (degrees), applied
+	// on entering the zone and removed on leaving.
 	double slopeForce = 0.0;
 	int16 slopeAngle = 0;
 
-	// OverlayZone subtypes (0x0d / 0x16)
+	// kZoneOverlay (and kZoneBumper in Nancy12)
 	Common::String overlayName;
 	Common::Array<Common::Rect> overlaySrcRects;
 	Common::Rect overlayDestRect;
 
 	// The Nancy13 pinball layout (AR 175) differs from the Nancy12 one: the base carries an
-	// extra int32 before the sound block, and subtypes 0x0d/0x15/0x16 have different trailers.
-	// Pass isNancy13 = true to parse it; the default keeps the Nancy12 behaviour.
+	// extra int32 before the sound block, and the overlay/unknown-0x15/bumper subtypes have
+	// different trailers. Pass isNancy13 = true to parse it; the default keeps Nancy12.
 	void readData(Common::SeekableReadStream &stream, bool isNancy13 = false);
 
 private:
diff --git a/engines/nancy/action/puzzle/minigolfpuzzle.cpp b/engines/nancy/action/puzzle/minigolfpuzzle.cpp
index b514b832a56..8eac66e4088 100644
--- a/engines/nancy/action/puzzle/minigolfpuzzle.cpp
+++ b/engines/nancy/action/puzzle/minigolfpuzzle.cpp
@@ -53,10 +53,6 @@ static const double kRestSpeed = 0.5;		// stop the ball below this per-step spee
 static const double kSinkSpeed = 50.0;		// ball sinks only if it reaches the cup at or below this speed; faster rolls over
 static const double kRestitution = 0.8;		// wall-bounce energy retained
 static const double kDefaultAimDrag = 24.0;	// default aim-cursor distance from the ball (mask px)
-static const byte kZoneTeleport = 2;		// ActionZone subtype for a pipe (enter one hole, exit another)
-static const byte kZoneTerrain = 3;			// ActionZone subtype for terrain (a sand trap that adds friction)
-static const byte kZoneSlope = 4;			// ActionZone subtype for a slope (a velocity kick toward its angle)
-static const byte kZoneOverlay = 0xd;		// ActionZone subtype for a cosmetic overlay sprite
 
 // Isometric projection (mask space -> screen): rotate 45 degrees, foreshorten Y by
 // half. cos45 == sin45; the Y component is additionally scaled by kIsoYScale.
@@ -627,7 +623,7 @@ void MinigolfPuzzle::updateBall() {
 			playSoundBlock(_sinkSound);
 
 			_winScene.sceneID = cup.specialEffectId;
-			if (cup.type == 0x0c && cup.tailId != -1) {
+			if (cup.type == kZoneSceneChange && cup.tailId != -1) {
 				NancySceneState.setEventFlag(cup.tailId, cup.tailFlag ? g_nancy->_true : g_nancy->_false);
 			}
 			if (cup.hasSpecialEffect) {
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
index 09e0ffb1042..c6ec92b8616 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.cpp
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -180,13 +180,13 @@ void PachinkoPuzzle::loadMachineImage(Machine &m) {
 	}
 }
 
-// The four holes are the bumper zones (type 0x16) that carry a bell sound. The sound name
-// maps each hole to its climber: Miner/Explosion feed the Gold Digger (win), Yeti/Ouch feed
-// the Yeti (lose).
+// The four holes are the bumper zones that carry a bell sound. The sound name maps each
+// hole to its climber: Miner/Explosion feed the Gold Digger (win), Yeti/Ouch feed the
+// Yeti (lose).
 void PachinkoPuzzle::buildHoles() {
 	_holes.clear();
 	for (const ActionZone &z : _zones) {
-		if (z.type != 0x16 || z._sound.names.empty()) {
+		if (z.type != kZoneBumper || z._sound.names.empty()) {
 			continue;
 		}
 
@@ -212,10 +212,10 @@ void PachinkoPuzzle::buildHoles() {
 		_holes.push_back(hole);
 	}
 
-	// Attach each hole's "lit" sprite from the matching overlay (0x0d) zone, which shares
-	// the hole's rect and names the lit board overlay.
+	// Attach each hole's "lit" sprite from the matching overlay zone, which shares the
+	// hole's rect and names the lit board overlay.
 	for (const ActionZone &z : _zones) {
-		if (z.type != 0x0d || z.overlaySrcRects.empty()) {
+		if (z.type != kZoneOverlay || z.overlaySrcRects.empty()) {
 			continue;
 		}
 		for (Hole &hole : _holes) {
diff --git a/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp b/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
index 03a0d54e298..82b0e26f389 100644
--- a/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
+++ b/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
@@ -66,13 +66,13 @@ void SewingMachinePuzzle::classifyZones() {
 	for (uint i = 0; i < _zones.size(); ++i) {
 		const ActionZone &z = _zones[i];
 		switch (z.type) {
-		case 0x0b:	// seam mask + mistake lines
+		case kZoneEventFlag:	// seam mask + mistake lines
 			_collisionZone = i;
 			break;
-		case 0x0c:	// bottom completion trigger
+		case kZoneSceneChange:	// bottom completion trigger
 			_triggerZones.push_back(i);
 			break;
-		case 0x14:	// play-area boundary
+		case kZoneBoundary:	// play-area boundary
 			_boundaryZone = i;
 			break;
 		default:


Commit: f15f03ac26f24a98eb47b28981c991bfa539af25
    https://github.com/scummvm/scummvm/commit/f15f03ac26f24a98eb47b28981c991bfa539af25
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-03T02:09:12+03:00

Commit Message:
NANCY: NANCY12: Implement the rest of the DrivingPuzzle functionality

The driving and car chase scenes are playable now

Key changes:
- Persist driving state
- Add handling for pressing space to enter buildings
- Fix physics (car movement and speed)
- Use the scene collision mask
- Fix car controls
- Fix fuel handling
- Wire up flat tire incidents
- Add scene conditional decorations
- Implement mud puddles and potholes
- Update gas/tire gauge overlays correctly

Changed paths:
    engines/nancy/action/overlay.cpp
    engines/nancy/action/puzzle/drivingpuzzle.cpp
    engines/nancy/action/puzzle/drivingpuzzle.h
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h


diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index 41828e3db1e..ddb0ff76a28 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -76,6 +76,14 @@ void Overlay::handleInput(NancyInput &input) {
 }
 
 void Overlay::updateGraphics() {
+	// A static overlay gated by a dynamic dependency - e.g. a Nancy12 gas/tire gauge,
+	// which is one of a set of sprites each shown for a different resource range - must be
+	// visible only while its dependency currently holds. (With the usual set-once event
+	// flags _isActive never drops back to false, so this is a no-op there.)
+	if (g_nancy->getGameType() >= kGameTypeNancy12 && _state == kRun && _overlayType == kPlayOverlayStatic) {
+		setVisible(_isActive);
+	}
+
 	// Update inactive animated overlays
 	if (!_isActive && _state == kRun && !_blitDescriptions.empty() && _overlayType == kPlayOverlayAnimated) {
 		uint16 newFrame = NancySceneState.getSceneInfo().frameID;
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.cpp b/engines/nancy/action/puzzle/drivingpuzzle.cpp
index 7d99c38a672..7a1b0ce963e 100644
--- a/engines/nancy/action/puzzle/drivingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/drivingpuzzle.cpp
@@ -28,6 +28,7 @@
 #include "engines/nancy/sound.h"
 #include "engines/nancy/input.h"
 #include "engines/nancy/util.h"
+#include "engines/nancy/puzzledata.h"
 
 #include "engines/nancy/state/scene.h"
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
@@ -78,7 +79,7 @@ void DrivingPuzzle::readBlob(Common::SeekableReadStream &stream) {
 	stream.skip(2);
 	_distanceDivisor = stream.readSint32LE();	// 0x7b
 	_retainState = stream.readByte() != 0;		// 0x7f
-	stream.skip(2);
+	_finishScene = stream.readUint16LE();		// 0x80: the scene to enter when a tire goes flat
 }
 
 void DrivingPuzzle::readData(Common::SeekableReadStream &stream) {
@@ -115,16 +116,17 @@ void DrivingPuzzle::readData(Common::SeekableReadStream &stream) {
 }
 
 void DrivingPuzzle::classifyZones(const Common::Array<ActionZone> &zones) {
-	// The car often spawns already sitting inside its starting location's zone, so
-	// zones are edge-triggered: a zone only fires once the car has left it and driven
-	// back in. Seed each zone's "inside" state from the spawn point.
-	Common::Point spawn(_startX, _startY);
+	// The car often starts already sitting inside a location's zone (its start position,
+	// or a restored parking spot), so zones are edge-triggered: a zone only fires once the
+	// car has left it and driven back in. Seed each zone's "inside" state from where the
+	// car currently is (init() has already restored any saved position).
+	Common::Point spawn((int)(_carX + 0.5), (int)(_carY + 0.5));
 
 	for (uint i = 0; i < zones.size(); ++i) {
 		const ActionZone &z = zones[i];
 		switch (z.type) {
-		case 0x11:		// location entrance
-		case 0x0c: {	// chase finish line
+		case kZoneDestination:		// location entrance
+		case kZoneSceneChange: {	// drive-in scene trigger (chase)
 			DestinationZone dest;
 			dest.rect = z.rect;
 			dest.scene.sceneID = z.specialEffectId;
@@ -135,15 +137,16 @@ void DrivingPuzzle::classifyZones(const Common::Array<ActionZone> &zones) {
 				dest.fadeToBlackTime = z.seFadeToBlackTime;
 				dest.fadeRect = z.seRect;
 			}
-			if (z.type == 0x0c) {
+			if (z.type == kZoneSceneChange) {
 				dest.eventFlag = z.tailId;
 				dest.eventFlagValue = z.tailFlag;
+				dest.autoTrigger = true;	// the chase finish line fires on drive-in, no spacebar
 			}
 			dest.carInside = dest.rect.contains(spawn);
 			_destinations.push_back(dest);
 			break;
 		}
-		case 0x0b: {	// checkpoint: sets an event flag once driven over
+		case kZoneEventFlag: {	// checkpoint: sets an event flag once driven over
 			Checkpoint cp;
 			cp.rect = z.rect;
 			cp.flagId = z.tailId;
@@ -152,12 +155,39 @@ void DrivingPuzzle::classifyZones(const Common::Array<ActionZone> &zones) {
 			_checkpoints.push_back(cp);
 			break;
 		}
-		case 0x14:	// play-area boundary
-			_boundaries.push_back(z.rect);
+		case kZoneTerrain: {	// mud puddle: slows the car while inside
+			MudZone mud;
+			mud.rect = z.rect;
+			mud.decel = z.terrainDecel;
+			_mudZones.push_back(mud);
+			break;
+		}
+		case kZoneFlatTire: {	// pothole: damages the tires on entry
+			Pothole hole;
+			hole.rect = z.rect;
+			hole.minDamage = z.flatTireMin;
+			hole.maxDamage = z.flatTireMax;
+			hole.carInside = hole.rect.contains(spawn);
+			_potholes.push_back(hole);
+			break;
+		}
+		case kZoneOverlay: {	// cosmetic map decoration (buildings, cars, potholes, animations)
+			if (z.overlayName.empty() || z.overlaySrcRects.empty() || z.overlayDestRect.isEmpty()) {
+				break;
+			}
+			Overlay ov;
+			ov.imageIndex = overlayImageIndex(z.overlayName);
+			ov.srcRects = z.overlaySrcRects;
+			ov.destRect = z.overlayDestRect;
+			ov.condFlag = z.val49;
+			ov.condValue = z.val4b;
+			if (ov.imageIndex >= 0) {
+				_overlays.push_back(ov);
+			}
 			break;
+		}
 		default:
-			// The remaining subtypes (overlays, driving hazards, terrain markers) are
-			// not simulated yet.
+			// The remaining subtypes (terrain markers) are not simulated yet.
 			break;
 		}
 	}
@@ -184,6 +214,27 @@ void DrivingPuzzle::playSoundBlock(const RandomSoundBlock &block) {
 	g_nancy->_sound->playSound(desc);
 }
 
+void DrivingPuzzle::armExit(const DestinationZone &dest) {
+	_exitScene = dest.scene;
+	_exitHasFade = dest.hasFade;
+	_exitFadeType = dest.fadeType;
+	_exitFadeTotalTime = dest.fadeTotalTime;
+	_exitFadeToBlackTime = dest.fadeToBlackTime;
+	_exitFadeRect = dest.fadeRect;
+	_exitFlag = dest.eventFlag;
+	_exitFlagValue = dest.eventFlagValue;
+	_state = kActionTrigger;
+}
+
+void DrivingPuzzle::armExitScene(uint16 sceneID, int16 flag, byte flagValue) {
+	_exitScene = SceneChangeDescription();
+	_exitScene.sceneID = sceneID;
+	_exitHasFade = false;
+	_exitFlag = flag;
+	_exitFlagValue = flagValue;
+	_state = kActionTrigger;
+}
+
 void DrivingPuzzle::init() {
 	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
 	_drawSurface.create(vpBounds.width(), vpBounds.height(),
@@ -203,12 +254,29 @@ void DrivingPuzzle::init() {
 		_chaseCarImage.setTransparentColor(_drawSurface.getTransparentColor());
 	}
 
+	// The collision mask marks the drivable road in white; everything else is off-road.
+	g_nancy->_resource->loadImage(_collisionName, _collisionMask);
+
 	// Seed the car from the physics parameters decoded from the header blob.
 	_carX = _startX;
 	_carY = _startY;
 	_carHeading = (double)_startAngle * (M_PI / 180.0);
 	_carVelocity = 0.0;
 	_speedCap = _forwardSpeed;
+	_lastPhysicsMs = 0;
+
+	// When the map keeps its state (retainState), resume from where the car was left the
+	// last time this map was driven (i.e. before entering a building), and keep the tire
+	// damage. The first ever visit starts from the header position.
+	if (_retainState) {
+		DrivingData *data = (DrivingData *)NancySceneState.getPuzzleData(DrivingData::getTag());
+		if (data && data->valid) {
+			_carX = data->carX;
+			_carY = data->carY;
+			_carHeading = data->heading;
+			_tireDamage = data->tireDamage;
+		}
+	}
 
 	// Seed the chaser at the start of its recorded path.
 	if (!_chaserPathA.empty()) {
@@ -242,6 +310,82 @@ Common::Point DrivingPuzzle::cameraOffset() const {
 	return Common::Point(camX, camY);
 }
 
+int DrivingPuzzle::overlayImageIndex(const Common::String &name) {
+	for (uint i = 0; i < _overlayImageNames.size(); ++i) {
+		if (_overlayImageNames[i] == name) {
+			return (int)i;
+		}
+	}
+
+	Graphics::ManagedSurface surf;
+	g_nancy->_resource->loadImage(Common::Path(name), surf);
+	if (surf.empty()) {
+		return -1;
+	}
+	surf.setTransparentColor(_drawSurface.getTransparentColor());
+
+	_overlayImages.push_back(surf);
+	_overlayImageNames.push_back(name);
+	return (int)_overlayImages.size() - 1;
+}
+
+void DrivingPuzzle::drawOverlays(const Common::Point &cam) {
+	if (_overlays.empty()) {
+		return;
+	}
+
+	const int frameMs = 66;		// decoration animation speed (0x42 ms/frame)
+	uint32 nowMs = g_system->getMillis();
+	Common::Rect view(cam.x, cam.y, cam.x + _drawSurface.w, cam.y + _drawSurface.h);
+
+	for (uint i = 0; i < _overlays.size(); ++i) {
+		const Overlay &ov = _overlays[i];
+		if (ov.imageIndex < 0 || !view.intersects(ov.destRect)) {
+			continue;
+		}
+
+		// Only draw the decoration while its event-flag condition holds (a car appears
+		// once its story flag is set, etc.).
+		if (ov.condFlag != -1 && !NancySceneState.getEventFlag(ov.condFlag, ov.condValue)) {
+			continue;
+		}
+
+		uint frame = ov.srcRects.size() == 1 ? 0 : (nowMs / frameMs) % ov.srcRects.size();
+		_drawSurface.blitFrom(_overlayImages[ov.imageIndex], ov.srcRects[frame],
+			Common::Point(ov.destRect.left - cam.x, ov.destRect.top - cam.y));
+	}
+}
+
+void DrivingPuzzle::saveState() const {
+	if (!_retainState) {
+		return;
+	}
+
+	DrivingData *data = (DrivingData *)NancySceneState.getPuzzleData(DrivingData::getTag());
+	if (data) {
+		data->valid = true;
+		data->carX = (int32)(_carX + 0.5);
+		data->carY = (int32)(_carY + 0.5);
+		data->heading = _carHeading;
+		data->tireDamage = _tireDamage;
+	}
+}
+
+bool DrivingPuzzle::isWall(int px, int py) const {
+	if (px < 0 || py < 0 || px >= _collisionMask.w || py >= _collisionMask.h) {
+		return true;	// off the map
+	}
+
+	// The road is white; anything darker is off-road.
+	byte r, g, b;
+	_collisionMask.format.colorToRGB(_collisionMask.getPixel(px, py), r, g, b);
+	return r < 128 || g < 128 || b < 128;
+}
+
+bool DrivingPuzzle::isBlocked(const Common::Point &p) const {
+	return isWall(p.x, p.y);
+}
+
 void DrivingPuzzle::drawScene() {
 	Common::Point cam = cameraOffset();
 	int camX = cam.x;
@@ -249,6 +393,10 @@ void DrivingPuzzle::drawScene() {
 
 	_drawSurface.blitFrom(_image, Common::Rect(camX, camY, camX + _drawSurface.w, camY + _drawSurface.h), Common::Point(0, 0));
 
+	// Map decorations (buildings, cars, potholes, animated cows/flags) sit on the map,
+	// under the cars.
+	drawOverlays(cam);
+
 	// The chaser car (kChase), drawn under the player car.
 	if (_variant == kChase && !_frameRects2.empty() && _chaseCarImage.w > 0) {
 		const Common::Rect &src = _frameRects2[frameIndexForHeading(_chaserHeading, _frameRects2.size())];
@@ -269,7 +417,8 @@ void DrivingPuzzle::drawScene() {
 }
 
 void DrivingPuzzle::updateChaser() {
-	if (_chaserPathA.empty()) {
+	const Common::Array<Waypoint> &path = _chaserOnPathB ? _chaserPathB : _chaserPathA;
+	if (path.empty()) {
 		return;
 	}
 
@@ -282,11 +431,11 @@ void DrivingPuzzle::updateChaser() {
 	// Play the recorded path back in real time: advance to the last waypoint whose
 	// timestamp the elapsed chase time has passed.
 	uint32 elapsed = g_system->getMillis() - _chaseStartTime;
-	while (_chaserWaypoint + 1 < _chaserPathA.size() && _chaserPathA[_chaserWaypoint].timeMs < elapsed) {
+	while (_chaserWaypoint + 1 < path.size() && path[_chaserWaypoint].timeMs < elapsed) {
 		++_chaserWaypoint;
 	}
 
-	const Waypoint &wp = _chaserPathA[_chaserWaypoint];
+	const Waypoint &wp = path[_chaserWaypoint];
 	_chaserX = wp.x;
 	_chaserY = wp.y;
 	_chaserHeading = wp.heading;
@@ -300,50 +449,170 @@ void DrivingPuzzle::updateChaser() {
 	const double slowSlope = 5.0;
 	_speedCap = dist >= slowRadius ? (double)_forwardSpeed : (double)_forwardSpeed - (slowRadius - dist) * slowSlope;
 
-	// TODO: switch onto the second path (_chaserPathB), the event-flag outcomes
-	// (_chaseParams) and the "chaser left the viewport" loss branch are not simulated.
+	// While Nancy is still pursuing Jane (states 0 and 1, before Jane is caught and the
+	// crash sequence on the second path begins), letting the chaser leave the visible map
+	// means the player has lost her. The original only tests this in state 0 because its
+	// state stays there through the whole pursuit; here state advances to 1 as soon as its
+	// gate flag is clear, so the check has to cover both.
+	if (_chaseState < 2) {
+		Common::Point cam = cameraOffset();
+		Common::Rect viewport(0, 0, _drawSurface.w, _drawSurface.h);
+		Common::Rect chaserRect(1, 1);
+		if (!_frameRects2.empty()) {
+			chaserRect = _frameRects2[0];
+		}
+		chaserRect.moveTo((int)(_chaserX + 0.5) - cam.x - chaserRect.width() / 2,
+			(int)(_chaserY + 0.5) - cam.y - chaserRect.height() / 2);
+		if (!chaserRect.intersects(viewport)) {
+			armExitScene(_chaseParams[kChaseOffViewScene], _chaseParams[kChaseOffViewFlag], 1);
+			return;
+		}
+	}
+
+	// Chase state machine (mirrors the original): flags gating it are set by the chase's
+	// own checkpoints (zones2 type 0x0b) and by the scene scripts.
+	switch (_chaseState) {
+	case 0:
+		if (_chaseParams[kChaseGate01Flag] != -1 &&
+				NancySceneState.getEventFlag(_chaseParams[kChaseGate01Flag], g_nancy->_false)) {
+			_chaseState = 1;
+		}
+		break;
+	case 1:
+		// Switch onto the second path once Jane is caught.
+		if (_chaseParams[kChaseGate12Flag] != -1 &&
+				NancySceneState.getEventFlag(_chaseParams[kChaseGate12Flag], g_nancy->_true)) {
+			_chaserOnPathB = true;
+			_chaserWaypoint = 0;
+			_chaseStarted = false;
+			_chaseState = 2;
+		}
+		break;
+	case 2:
+		// The chaser has completed its route (Jane crashes) - the win.
+		if (_chaserWaypoint + 1 >= path.size()) {
+			armExitScene(_chaseParams[kChasePathEndScene], -1, 0);
+			return;
+		}
+		break;
+	default:
+		break;
+	}
 }
 
 // Per-frame car physics. Throttle is +1 (forward), -1 (reverse) or 0 (coast); the car
-// already faces the cursor (steering happens in handleInput). The acceleration divisors
-// (1.0/0.4) and 0.02 timestep are exact; the velocity decay is a playability stand-in.
-void DrivingPuzzle::updatePhysics(int throttle) {
-	const double timeStep = 0.02;
-	const double decay = 0.98;
-	const double forwardCap = MAX(0.0, _speedCap);
+// already faces the cursor (steering happens in handleInput). cursorDist is how far the
+// cursor is from the car, which sets the forward speed. Velocity is in pixels per second
+// and integrated against the real elapsed time, so the car covers the same distance per
+// second at any frame rate - the chaser plays back in real time, so a frame-rate-dependent
+// car speed made the chase unwinnable.
+void DrivingPuzzle::updatePhysics(int throttle, double cursorDist) {
+	const double refStep = 0.02;			// the original's nominal per-frame step
+	const double decay = 0.98;				// coast decay per refStep
+	const int kTireFlatThreshold = 100;		// accumulated pothole damage that blows a tire
+
+	// Cursor distance at which the car reaches top speed. The chase needs Nancy to sustain
+	// full speed to keep pace with Jane's recorded run, so full throttle comes at a much
+	// shorter cursor distance there than in the fuel-economy-minded free-driving map.
+	const double kFullSpeedDist = (_variant == kChase) ? 120.0 : 250.0;
+
+	uint32 nowMs = g_system->getMillis();
+	double dt = (_lastPhysicsMs == 0) ? refStep : CLIP<double>((nowMs - _lastPhysicsMs) / 1000.0, 0.0, 0.1);
+	_lastPhysicsMs = nowMs;
+
+	// The further the cursor, the faster the car; the chase slowdown (_speedCap) caps it.
+	double distanceCap = CLIP<double>(cursorDist / kFullSpeedDist * _forwardSpeed, 0.0, (double)_forwardSpeed);
+	double forwardCap = MIN(distanceCap, MAX(0.0, _speedCap));
+
+	// Mud slows the car (it does not stop it): while sitting in a puddle its top speed is
+	// cut. Jane's car is a recorded playback that ignores the terrain, so the penalty is
+	// kept mild - enough to matter in the free-driving map without making the chase, where
+	// she never slows, unwinnable.
+	Common::Point cur((int)(_carX + 0.5), (int)(_carY + 0.5));
+	for (uint i = 0; i < _mudZones.size(); ++i) {
+		if (_mudZones[i].decel > 0.0 && _mudZones[i].rect.contains(cur)) {
+			forwardCap = MIN(forwardCap, (double)_forwardSpeed * 0.7);
+			break;
+		}
+	}
 
 	if (throttle > 0) {
-		_carVelocity += (forwardCap / 1.0) * timeStep;
+		_carVelocity += forwardCap * dt;
 	} else if (throttle < 0) {
-		_carVelocity -= ((double)_forwardSpeed / 0.4) * timeStep;
+		_carVelocity -= ((double)_forwardSpeed / 0.4) * dt;
 	} else {
-		_carVelocity *= decay;
+		_carVelocity *= pow(decay, dt / refStep);
 	}
 	_carVelocity = CLIP<double>(_carVelocity, -(double)_reverseSpeed, forwardCap);
 
-	double newX = _carX + cos(_carHeading) * _carVelocity * timeStep;
-	double newY = _carY - sin(_carHeading) * _carVelocity * timeStep;
-
-	// Keep the car on the map and out of the boundary zones (coarse rect test, no mask).
-	newX = CLIP<double>(newX, 0.0, MAX(0, _image.w - 1));
-	newY = CLIP<double>(newY, 0.0, MAX(0, _image.h - 1));
-
-	Common::Point next((int)(newX + 0.5), (int)(newY + 0.5));
-	for (uint i = 0; i < _boundaries.size(); ++i) {
-		if (_boundaries[i].contains(next)) {
+	// Move against the collision mask, sub-stepped ~1px at a time so a fast car can't
+	// tunnel through a thin road edge. On hitting off-road, slide along it (keep whichever
+	// single axis stays on the road) instead of stopping dead. If the car somehow starts
+	// the frame off-road, let it move out freely so it can never get wedged.
+	double preX = _carX;
+	double preY = _carY;
+	double moveX = cos(_carHeading) * _carVelocity * dt;
+	double moveY = -sin(_carHeading) * _carVelocity * dt;
+	int steps = MAX(1, (int)(MAX(ABS(moveX), ABS(moveY)) + 0.5));
+	double stepX = moveX / steps;
+	double stepY = moveY / steps;
+	bool escaping = isBlocked(cur);
+
+	for (int s = 0; s < steps; ++s) {
+		double tryX = CLIP<double>(_carX + stepX, 0.0, MAX(0, _image.w - 1));
+		double tryY = CLIP<double>(_carY + stepY, 0.0, MAX(0, _image.h - 1));
+
+		if (escaping || !isBlocked(Common::Point((int)(tryX + 0.5), (int)(tryY + 0.5)))) {
+			_carX = tryX;
+			_carY = tryY;
+		} else if (!isBlocked(Common::Point((int)(tryX + 0.5), (int)(_carY + 0.5)))) {
+			_carX = tryX;
+		} else if (!isBlocked(Common::Point((int)(_carX + 0.5), (int)(tryY + 0.5)))) {
+			_carY = tryY;
+		} else {
 			_carVelocity = 0.0;
-			return;
+			break;
 		}
 	}
 
-	_carX = newX;
-	_carY = newY;
+	Common::Point next((int)(_carX + 0.5), (int)(_carY + 0.5));
+
+	// The gas tank empties by the distance the car actually travels this frame divided by
+	// the header's distance divisor. The DT_RESOURCE scene dependency reads the same
+	// resource to warn Nancy when it runs low.
+	if (_distanceDivisor > 0) {
+		double moved = sqrt((_carX - preX) * (_carX - preX) + (_carY - preY) * (_carY - preY));
+		_fuelBurnAccum += moved / (double)_distanceDivisor;
+		if (_fuelBurnAccum >= 1.0) {
+			int burn = (int)_fuelBurnAccum;
+			_fuelBurnAccum -= burn;
+			int fuel = NancySceneState.getUIResource(_frictionIndex);
+			NancySceneState.setUIResource(_frictionIndex, MAX(0, fuel - burn));
+		}
+	}
 
-	// TODO: burn fuel here - decrement the gas-gauge UI resource (index _frictionIndex)
-	// by distance/_distanceDivisor and stop the car at 0. Deferred: the resource is an
-	// integer, so the rate needs runtime tuning to avoid corrupting the saved value.
+	// Driving into a pothole (on entry) damages the tires by a random amount; at 100 the
+	// tire blows: the damage resets (a fresh spare goes on) and the car leaves for the
+	// flat-tire scene once the blowout sound has played.
+	for (uint i = 0; i < _potholes.size(); ++i) {
+		Pothole &hole = _potholes[i];
+		bool nowInside = hole.rect.contains(next);
+		if (nowInside && !hole.carInside && !_flatTirePending) {
+			int dmg = hole.minDamage;
+			if (hole.maxDamage > hole.minDamage) {
+				dmg += g_nancy->_randomSource->getRandomNumber(hole.maxDamage - hole.minDamage);
+			}
+			_tireDamage += dmg;
+			if (_tireDamage >= kTireFlatThreshold) {
+				_tireDamage = 0;
+				_flatTirePending = true;
+				playSoundBlock(_soundBlocks[0]);	// tire blowout
+			}
+		}
+		hole.carInside = nowInside;
+	}
 
-	// Driving into a checkpoint (on entry) sets its event flag once.
+	// Driving over a checkpoint (on entry) sets its event flag once.
 	for (uint i = 0; i < _checkpoints.size(); ++i) {
 		Checkpoint &cp = _checkpoints[i];
 		bool nowInside = cp.rect.contains(next);
@@ -354,17 +623,24 @@ void DrivingPuzzle::updatePhysics(int throttle) {
 		cp.carInside = nowInside;
 	}
 
-	// Driving into a location entrance / finish line (on entry) transitions there. The
-	// zone the car spawned inside does not fire until the car leaves and re-enters.
+	// A drive-in destination (the chase finish line) fires on entry; a parking
+	// destination (a location) is only noted here and entered with space in handleInput.
+	_parkedDest = -1;
 	for (uint i = 0; i < _destinations.size(); ++i) {
 		DestinationZone &dest = _destinations[i];
-		bool nowInside = dest.rect.contains(next);
-		if (nowInside && !dest.carInside && _triggeredDest < 0 && dest.scene.sceneID != kNoScene) {
-			_triggeredDest = (int)i;
-			_state = kActionTrigger;
+		bool nowInside = dest.scene.sceneID != kNoScene && dest.rect.contains(next);
+		if (dest.autoTrigger) {
+			if (nowInside && !dest.carInside) {
+				armExit(dest);
+			}
+			dest.carInside = nowInside;
+		} else if (nowInside && _parkedDest < 0) {
+			_parkedDest = (int)i;
 		}
-		dest.carInside = nowInside;
 	}
+
+	// Remember where the car is so it resumes here after a building visit or a save/load.
+	saveState();
 }
 
 void DrivingPuzzle::execute() {
@@ -384,15 +660,14 @@ void DrivingPuzzle::execute() {
 		break;
 	case kActionTrigger:
 		g_nancy->_sound->stopSound(_soundBlocks[2].channel);	// stop the engine ambience
-		if (_triggeredDest >= 0 && _triggeredDest < (int)_destinations.size()) {
-			const DestinationZone &dest = _destinations[_triggeredDest];
-			if (dest.eventFlag != -1) {
-				NancySceneState.setEventFlag(dest.eventFlag, dest.eventFlagValue ? g_nancy->_true : g_nancy->_false);
-			}
-			if (dest.hasFade) {
-				NancySceneState.specialEffect(dest.fadeType, dest.fadeTotalTime, dest.fadeToBlackTime, dest.fadeRect);
-			}
-			NancySceneState.changeScene(dest.scene);
+		if (_exitFlag != -1) {
+			NancySceneState.setEventFlag(_exitFlag, _exitFlagValue ? g_nancy->_true : g_nancy->_false);
+		}
+		if (_exitHasFade) {
+			NancySceneState.specialEffect(_exitFadeType, _exitFadeTotalTime, _exitFadeToBlackTime, _exitFadeRect);
+		}
+		if (_exitScene.sceneID != kNoScene) {
+			NancySceneState.changeScene(_exitScene);
 		}
 		finishExecution();
 		break;
@@ -404,6 +679,31 @@ void DrivingPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// A tire has blown: hold the car still until the blowout sound finishes, then leave
+	// for the flat-tire scene (where Nancy fits the spare).
+	if (_flatTirePending) {
+		if (!g_nancy->_sound->isSoundPlaying(_soundBlocks[0].channel)) {
+			saveState();
+			if (_finishScene != kNoScene) {
+				SceneChangeDescription scene;
+				scene.sceneID = _finishScene;
+				NancySceneState.changeScene(scene);
+			}
+			finishExecution();
+		}
+		return;
+	}
+
+	// Parked in a location: pressing space gets Nancy out of the car and into it.
+	if (_parkedDest >= 0 && _parkedDest < (int)_destinations.size()) {
+		for (uint i = 0; i < input.otherKbdInput.size(); ++i) {
+			if (input.otherKbdInput[i].keycode == Common::KEYCODE_SPACE) {
+				armExit(_destinations[_parkedDest]);
+				return;
+			}
+		}
+	}
+
 	// Throttle with the mouse buttons: left drives forward, right reverses.
 	int throttle = 0;
 	if (input.input & NancyInput::kLeftMouseButtonHeld) {
@@ -412,13 +712,15 @@ void DrivingPuzzle::handleInput(NancyInput &input) {
 		throttle = -1;
 	}
 
-	// Steer the car to face the cursor while driving (its distance is irrelevant).
+	// Steer the car to face the cursor; its distance sets the driving speed.
+	double cursorDist = 0.0;
 	if (throttle != 0) {
 		Common::Point cam = cameraOffset();
 		Common::Rect mouseVp = NancySceneState.getViewport().convertScreenToViewport(
 			Common::Rect(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1));
 		double dx = (double)mouseVp.left - (_carX - cam.x);
 		double dy = (double)mouseVp.top - (_carY - cam.y);
+		cursorDist = sqrt(dx * dx + dy * dy);
 		if (dx != 0.0 || dy != 0.0) {
 			_carHeading = atan2(-dy, dx);
 			if (_carHeading < 0.0) {
@@ -430,9 +732,12 @@ void DrivingPuzzle::handleInput(NancyInput &input) {
 	// Drive continuously so momentum and the chaser animate every frame.
 	if (_variant == kChase) {
 		updateChaser();
+		if (_state != kRun) {
+			return;	// a chase outcome fired
+		}
 	}
 
-	updatePhysics(throttle);
+	updatePhysics(throttle, cursorDist);
 
 	if (_state == kRun) {
 		drawScene();
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.h b/engines/nancy/action/puzzle/drivingpuzzle.h
index 7571d82592d..5e9e8fb9bd1 100644
--- a/engines/nancy/action/puzzle/drivingpuzzle.h
+++ b/engines/nancy/action/puzzle/drivingpuzzle.h
@@ -34,27 +34,36 @@ namespace Action {
 // action records share the same engine:
 //   160 - kDriving  (drive Nancy's car around the Titusville town map, entering
 //                    locations by driving into them)
-//   167 - kChase    (kDriving plus a second, chaser car, a second zone array and
-//                    two path-point arrays that steer the chaser)
+//   167 - kChase    (kDriving plus a chaser car - Jane - that plays back a recorded path
+//                    in real time; Nancy has to keep her in view. An event-flag-gated
+//                    state machine drives the outcome: the win is the chaser completing
+//                    its second path once Jane is caught; letting her drive off-view, or
+//                    driving into a trigger zone, ends it otherwise)
 //
 // The map scrolls under a car-centered camera; the car is drawn as a rotation-atlas
 // sprite whose frame is chosen from its heading. The map is populated with an
 // ActionZone array: type 0x11 zones are location entrances (each carries the
-// destination scene id and the transition effect), type 0x14 zones are boundaries,
-// and the remaining subtypes are decorations and driving hazards.
+// destination scene id and the transition effect), type 0x0d zones are cosmetic
+// decorations (buildings, parked cars, potholes and animated cows/flags/fountains),
+// and the rest are the driving hazards.
 //
-// Controls: the car steers to face the cursor (distance is irrelevant); the left mouse
-// button drives forward (accelerating while held) and the right button reverses.
+// Controls: the car steers to face the cursor; the left mouse button drives forward
+// (the further from the car the cursor is, the faster) and the right button reverses.
+// Fuel is a UI resource (index _frictionIndex) drained with the distance driven; potholes
+// damage the tires; at 100 damage a tire blows and the car leaves for the flat-tire scene
+// (blob+0x80). Mud slows the car; a location is entered by parking in its zone and pressing
+// space. The car position, heading and accumulated tire damage persist across visits
+// (DrivingData), like the original's retainState. The dashboard gas/tire gauges are not
+// part of this record: they are the scene's own OverlayStaticTerse records gated by
+// DT_RESOURCE dependencies on the fuel and tire UI resources.
 //
-// TODO (need runtime tuning):
-//  - Fuel: burn the gas-gauge UI resource (index _frictionIndex) while driving; it
-//    currently stays at its seeded value. The DT_RESOURCE dependency that reads it is
-//    handled in ActionManager::processDependency.
-//  - Hazards: potholes (type 0x17) should damage the car (more the faster it is hit,
-//    building to a flat tire) and mud puddles should be penalised; both are ignored.
-//  - Collision: only the type 0x14 boundary rects block the car (no per-pixel mask).
-//  - kChase: no second-path switch or "chaser left the viewport" loss branch.
-//  - The decorative overlay zone subtypes (0x0d and friends) are ignored.
+// Collision uses the "...Collision" mask, whose white streets are drivable and dark
+// areas are off-road; the type 0x14 boundary rects are only a fallback if it fails to load.
+//
+// TODO:
+//  - kChase: the "caught Jane" transition (state 1 -> 2, the win) is gated on an event
+//    flag the chase scene is expected to set (nothing in this record sets it); confirm
+//    what triggers it so a missed catch can't still win.
 class DrivingPuzzle : public RenderActionRecord {
 public:
 	enum Variant { kDriving = 0, kChase };
@@ -75,9 +84,11 @@ protected:
 		return _variant == kChase ? "ChasePuzzle" : "DrivingPuzzle";
 	}
 
-	// A destination the car can drive into: a location entrance (type 0x11) or the
-	// chase's finish line (type 0x0c). Entering its map-space rect optionally sets an
-	// event flag and transitions to the destination scene through a fade.
+	// A destination the car can drive into: a location entrance (type 0x11) or a drive-in
+	// scene trigger (type 0x0c, used in the chase). Entering its map-space rect optionally
+	// sets an event flag and transitions to the scene through a fade. Location entrances
+	// need a spacebar press to enter (you park first); the drive-in trigger (autoTrigger)
+	// fires the moment the car drives into it.
 	struct DestinationZone {
 		Common::Rect rect;
 		SceneChangeDescription scene;
@@ -88,9 +99,20 @@ protected:
 		Common::Rect fadeRect;
 		int16 eventFlag = -1;
 		byte eventFlagValue = 0;
+		bool autoTrigger = false;	// true for the chase finish (drive-in), false for parking
 		bool carInside = false;		// the car was inside this zone last frame
 	};
 
+	// Index into _chaseParams (167). The five values are the chase's outcome scenes and
+	// the event flags that gate its state machine.
+	enum ChaseParam {
+		kChaseGate01Flag = 0,		// state 0 -> 1 once this flag is clear
+		kChaseOffViewScene = 1,		// scene entered when the chaser leaves the viewport
+		kChaseOffViewFlag = 2,		// flag set (to 1) on the off-viewport outcome
+		kChaseGate12Flag = 3,		// state 1 -> 2 (switch to the second path) once this flag is set
+		kChasePathEndScene = 4		// scene entered when the chaser finishes its route
+	};
+
 	// A checkpoint (type 0x0b): driving over it sets an event flag once.
 	struct Checkpoint {
 		Common::Rect rect;
@@ -100,6 +122,32 @@ protected:
 		bool carInside = false;		// the car was inside this zone last frame
 	};
 
+	// A mud puddle (type 0x03): slows the car (adds to its velocity decay) while inside.
+	struct MudZone {
+		Common::Rect rect;
+		double decel = 0.0;
+	};
+
+	// A pothole (type 0x17): driving into it damages the tires by a random amount in
+	// [minDamage, maxDamage].
+	struct Pothole {
+		Common::Rect rect;
+		int32 minDamage = 0;
+		int32 maxDamage = 0;
+		bool carInside = false;		// the car was inside this zone last frame
+	};
+
+	// A cosmetic map decoration (type 0x0d): a sprite drawn onto the map at destRect.
+	// A single source rect is static; several are animation frames cycled over time.
+	// It is only visible while its event-flag condition holds (condFlag == -1 = always).
+	struct Overlay {
+		int imageIndex = -1;
+		Common::Array<Common::Rect> srcRects;
+		Common::Rect destRect;			// map space
+		int16 condFlag = -1;			// base zone val49: event flag gating visibility
+		byte condValue = 0;				// base zone val4b: the flag value that shows it
+	};
+
 	// A recorded chaser-path waypoint (kChase): the pursuer plays these back in real
 	// time, jumping to the entry whose timestamp the elapsed chase time has passed.
 	struct Waypoint {
@@ -126,13 +174,30 @@ protected:
 	// Plays one (randomly chosen) entry of a random-sound block.
 	void playSoundBlock(const RandomSoundBlock &block);
 
+	// Arms a pending exit (applied in kActionTrigger): from a destination zone (keeping
+	// its fade), or from a raw scene id plus an optional event flag to set.
+	void armExit(const DestinationZone &dest);
+	void armExitScene(uint16 sceneID, int16 flag, byte flagValue);
+
 	// Advances the car's velocity/position for one frame. Throttle is +1 forward, -1
-	// reverse, 0 coast; the heading is set separately (steering toward the cursor).
-	void updatePhysics(int throttle);
+	// reverse, 0 coast; cursorDist (how far the cursor is from the car) sets the forward
+	// speed. The heading is set separately (steering toward the cursor).
+	void updatePhysics(int throttle, double cursorDist);
 
 	// Top-left of the car-centered camera window into the map, clamped to its bounds.
 	Common::Point cameraOffset() const;
 
+	// Persists the car's position/heading and tire state so it survives leaving the map
+	// (and saving). Only does anything when the header's retainState flag is set.
+	void saveState() const;
+
+	// Whether a map-space point is off the road: off the map, or a non-white (dark)
+	// pixel in the collision mask (its white marks the drivable streets).
+	bool isWall(int px, int py) const;
+
+	// Whether the car may not drive at this map-space point (off the road).
+	bool isBlocked(const Common::Point &p) const;
+
 	// Advances the chaser along its recorded path (kChase) and slows the player's
 	// speed cap the closer the chaser gets.
 	void updateChaser();
@@ -140,6 +205,14 @@ protected:
 	// Chooses a rotation-atlas frame from a heading.
 	uint frameIndexForHeading(double heading, uint frameCount) const;
 
+	// Loads (and caches) a decoration sprite by name, returning its index into
+	// _overlayImages, or -1 on failure.
+	int overlayImageIndex(const Common::String &name);
+
+	// Draws the map's cosmetic decorations (animated frames cycled over time), offset by
+	// the camera and clipped to the visible window.
+	void drawOverlays(const Common::Point &cam);
+
 	// Redraws the scrolling map (car-centered camera) and the car sprite(s) on top.
 	void drawScene();
 
@@ -159,6 +232,7 @@ protected:
 	int16 _frictionIndex = 0;	// blob+0x77: index into the shared friction table
 	int32 _distanceDivisor = 0;	// blob+0x7b
 	bool _retainState = false;	// blob+0x7f: resume from the saved position
+	uint16 _finishScene = kNoScene;	// blob+0x80: the scene entered when a tire goes flat
 
 	// Three random-sound blocks (tire blowout, horn, engine) and a rotation-frame
 	// rect table precede the ActionZone array.
@@ -177,17 +251,46 @@ protected:
 	Common::Array<Waypoint> _chaserPathB;
 
 	// ActionZone gameplay roles.
-	Common::Array<DestinationZone> _destinations;	// types 0x11 / 0x0c
+	Common::Array<DestinationZone> _destinations;	// types 0x11 / 0x0c (parking spaces)
 	Common::Array<Checkpoint> _checkpoints;			// type 0x0b
-	Common::Array<Common::Rect> _boundaries;		// type 0x14
+	Common::Array<MudZone> _mudZones;				// type 0x03
+	Common::Array<Pothole> _potholes;				// type 0x17
+	Common::Array<Overlay> _overlays;				// type 0x0d (map decorations)
+	Common::Array<Graphics::ManagedSurface> _overlayImages;
+	Common::Array<Common::String> _overlayImageNames;
 
 	// Runtime state
 	double _carX = 0.0;			// current car position (map space)
 	double _carY = 0.0;
 	double _carHeading = 0.0;	// radians
-	double _carVelocity = 0.0;
+	double _carVelocity = 0.0;	// pixels per second
 	double _speedCap = 0.0;		// current forward speed cap (lowered as the chaser closes in)
-	int _triggeredDest = -1;	// destination zone the car has entered (-1 == none)
+	uint32 _lastPhysicsMs = 0;	// real time of the last physics step (frame-rate independence)
+	int _parkedDest = -1;		// destination zone the car is currently parked in (-1 == none)
+
+	// A pending exit to another scene (a location, the chase finish, or a chase outcome).
+	// Armed via armExit()/armExitScene(); applied and finished in the kActionTrigger state.
+	SceneChangeDescription _exitScene;
+	bool _exitHasFade = false;
+	byte _exitFadeType = 0;
+	uint16 _exitFadeTotalTime = 0;
+	uint16 _exitFadeToBlackTime = 0;
+	Common::Rect _exitFadeRect;
+	int16 _exitFlag = -1;
+	byte _exitFlagValue = 0;
+
+	// Chase (167) state machine: 0 = following the first path, 1 = waiting to switch,
+	// 2 = following the second path.
+	int _chaseState = 0;
+	bool _chaserOnPathB = false;
+
+	// Fuel + tire hazards. Fuel is the gas-gauge UI resource (index _frictionIndex),
+	// drained as the car drives; the fractional part is accumulated here. Tire damage
+	// builds up from potholes; once it blows a tire the car leaves for the flat-tire
+	// scene while _flatTirePending waits for the blowout sound to finish.
+	double _fuelBurnAccum = 0.0;
+	int _tireDamage = 0;
+	bool _flatTirePending = false;
 
 	// Chaser (kChase) runtime state.
 	bool _chaseStarted = false;
@@ -200,6 +303,7 @@ protected:
 	Graphics::ManagedSurface _image;			// the town map
 	Graphics::ManagedSurface _carImage;			// the player car rotation atlas
 	Graphics::ManagedSurface _chaseCarImage;	// the chaser car rotation atlas
+	Graphics::ManagedSurface _collisionMask;	// road/off-road mask ("MAP_TitusvilleCollision")
 };
 
 } // End of namespace Action
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 0f0613fe9ac..d6e966c039e 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -478,8 +478,19 @@ void WordFindPuzzleData::synchronize(Common::Serializer &ser) {
 	ser.syncAsSint16LE(currentWord);
 }
 
+void DrivingData::synchronize(Common::Serializer &ser) {
+	ser.syncAsByte(valid);
+	ser.syncAsSint32LE(carX);
+	ser.syncAsSint32LE(carY);
+	ser.syncAsDoubleLE(heading);
+	ser.syncAsSint32LE(tireDamage);
+	ser.syncAsByte(flatTire);
+}
+
 PuzzleData *makePuzzleData(const uint32 tag) {
 	switch(tag) {
+	case DrivingData::getTag():
+		return new DrivingData();
 	case WordFindPuzzleData::getTag():
 		return new WordFindPuzzleData();
 	case SliderPuzzleData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 91f34c81ca0..bd0b4e3b755 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -379,6 +379,26 @@ struct WordFindPuzzleData : public PuzzleData {
 	int16 currentWord = 0;
 };
 
+// Nancy12 DrivingPuzzle (AR 160). The car's position, heading and tire state persist
+// across visits to the driving map (driving into a location, then coming back), matching
+// the original's retainState mechanism, which saves the car to globals every frame and
+// restores it on setup. `valid` is false until the car has been driven, so the first
+// visit starts from the header's start position.
+struct DrivingData : public PuzzleData {
+	DrivingData() {}
+	virtual ~DrivingData() {}
+
+	static constexpr uint32 getTag() { return MKTAG('D', 'R', 'V', 'G'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	bool valid = false;
+	int32 carX = 0;
+	int32 carY = 0;
+	double heading = 0.0;
+	int32 tireDamage = 0;
+	bool flatTire = false;
+};
+
 PuzzleData *makePuzzleData(const uint32 tag);
 
 } // End of namespace Nancy


Commit: c9df87accbb3c8da4b647a521f45123c63543d7b
    https://github.com/scummvm/scummvm/commit/c9df87accbb3c8da4b647a521f45123c63543d7b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-03T02:09:13+03:00

Commit Message:
NANCY: Filter out more invalid file name chars in actionrecord_export

Changed paths:
    engines/nancy/console.cpp


diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index a082a464be2..656d533a3b7 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -712,6 +712,8 @@ bool NancyConsole::Cmd_actionRecordExport(int argc, const char **argv) {
 		Common::String desc(descBuf);
 		desc.replace('/', '-');
 		desc.replace('\\', '-');
+		desc.replace('>', '_');
+		desc.replace('<', '_');
 		byte ARType = chunk->readByte();
 		chunk->skip(1); // execType
 




More information about the Scummvm-git-logs mailing list