diff --git a/VisualPinball.Engine.PinMAME.Test/PinMameTimeFenceTests.cs b/VisualPinball.Engine.PinMAME.Test/PinMameTimeFenceTests.cs
new file mode 100644
index 0000000..f8b32dc
--- /dev/null
+++ b/VisualPinball.Engine.PinMAME.Test/PinMameTimeFenceTests.cs
@@ -0,0 +1,36 @@
+using NUnit.Framework;
+
+namespace VisualPinball.Engine.PinMAME.Test
+{
+ public class PinMameTimeFenceTests
+ {
+ [TestCase(0)]
+ [TestCase(1800)]
+ public void MenuTimeDoesNotAdvanceRom(double applicationTime)
+ {
+ var clock = new PinMameTimeFence();
+ Assert.That(clock.ToEmulationTime(applicationTime), Is.EqualTo(0.01).Within(1e-9));
+ Assert.That(clock.ToEmulationTime(applicationTime + 1), Is.EqualTo(1.01).Within(1e-9));
+ }
+
+ [Test]
+ public void BackwardsClockNeverDisablesFence()
+ {
+ var clock = new PinMameTimeFence();
+ clock.ToEmulationTime(100);
+ Assert.That(clock.ToEmulationTime(99), Is.EqualTo(0.01));
+ }
+
+ [Test]
+ public void ReloadResetsRomClockAndPauseKeepsFenceEnabled()
+ {
+ var clock = new PinMameTimeFence();
+ clock.ToEmulationTime(100);
+ Assert.That(clock.ToEmulationTime(105), Is.EqualTo(5.01).Within(1e-9));
+ Assert.That(clock.ToEmulationTime(105), Is.EqualTo(5.01).Within(1e-9));
+ clock.Reset();
+ Assert.That(clock.ToEmulationTime(300), Is.EqualTo(0.01).Within(1e-9));
+ Assert.That(clock.ToEmulationTime(300.001), Is.EqualTo(0.011).Within(1e-9));
+ }
+ }
+}
diff --git a/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj b/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj
index 49011ff..58a6434 100644
--- a/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj
+++ b/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj
@@ -18,5 +18,6 @@
+
diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs
index b813623..8cc7328 100644
--- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs
+++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs
@@ -258,7 +258,39 @@ public void SetAvailableHardware(IEnumerable switches, IE
private const int MaxLoggedChangedLampSamples = 24;
private const int MaxLoggedUnmappedLampSamples = 24;
+ private readonly PinMameTimeFence _timeFenceClock = new();
+ private readonly object _timeFenceLock = new();
+ private bool _timeFenceActive;
+
public void SetTimeFence(double timeInSeconds)
+ {
+ lock (_timeFenceLock) {
+ if (!_timeFenceActive || Volatile.Read(ref _stopRequested) != 0) {
+ return;
+ }
+ SetNativeTimeFence(_timeFenceClock.ToEmulationTime(timeInSeconds));
+ }
+ }
+
+ // Serialize reset, tick and shutdown so no in-flight tick can re-enable a stopped fence.
+ private void ResetTimeFence()
+ {
+ lock (_timeFenceLock) {
+ _timeFenceClock.Reset();
+ _timeFenceActive = true;
+ SetNativeTimeFence(PinMameTimeFence.InitialFenceSeconds);
+ }
+ }
+
+ private void DisableTimeFence()
+ {
+ lock (_timeFenceLock) {
+ _timeFenceActive = false;
+ SetNativeTimeFence(0.0);
+ }
+ }
+
+ private void SetNativeTimeFence(double timeInSeconds)
{
if (_pinMame == null) {
return;
@@ -760,7 +792,7 @@ public void StopForPlayModeExit()
// Do not call into native Stop() here (it blocks); the editor hook will stop PinMAME
// after we unsubscribe managed callbacks.
Interlocked.Exchange(ref _stopRequested, 1);
- SetTimeFence(0.0);
+ DisableTimeFence();
// Stop polling outputs immediately to avoid concurrent calls into pinmame.dll
// while a stop is in progress (pinmame APIs are not guaranteed thread-safe).
_isRunning = false;
@@ -798,7 +830,7 @@ private void RequestStopGame(string reason)
#endif
try {
- SetTimeFence(0.0);
+ DisableTimeFence();
if (Interlocked.Exchange(ref _stopRequested, 1) != 0) {
return;
@@ -985,7 +1017,7 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag
_pinMame.SetHandleKeyboard(false);
_pinMame.SetHandleMechanics(DisableMechs ? 0 : 0xFF);
ApplyDmdModePreference();
- SetTimeFence(0.01);
+ ResetTimeFence();
_pinMame.OnGameStarted += OnGameStarted;
_pinMame.OnGameEnded += OnGameEnded;
@@ -1017,8 +1049,10 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag
try {
// the game stopped here isn't ours, so its end isn't a failed start
_awaitingGameStart = false;
+ DisableTimeFence();
_pinMame.StopGame();
await Task.Delay(100, ct);
+ ResetTimeFence();
_awaitingGameStart = true;
_pinMame.StartGame(romId);
Logger.Debug($"[PinMAME] StartGame retry returned. RunState={PinMame.PinMame.RunState}");
diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameTimeFence.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameTimeFence.cs
new file mode 100644
index 0000000..d74c9cb
--- /dev/null
+++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameTimeFence.cs
@@ -0,0 +1,18 @@
+namespace VisualPinball.Engine.PinMAME
+{
+ /// Maps the application's simulation clock to the current ROM's elapsed time.
+ internal sealed class PinMameTimeFence
+ {
+ // Zero disables PinMAME's fence; retain the startup fence on the first tick.
+ internal const double InitialFenceSeconds = 0.01;
+ private double? _origin;
+
+ internal void Reset() => _origin = null;
+
+ internal double ToEmulationTime(double simulationSeconds)
+ {
+ _origin ??= simulationSeconds;
+ return System.Math.Max(InitialFenceSeconds, simulationSeconds - _origin.Value + InitialFenceSeconds);
+ }
+ }
+}
diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameTimeFence.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameTimeFence.cs.meta
new file mode 100644
index 0000000..b24e999
--- /dev/null
+++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameTimeFence.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3cf03705fb0106166b67c78a2366b961
\ No newline at end of file