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