diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMamePlayModeLifecycle.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMamePlayModeLifecycle.cs
new file mode 100644
index 0000000..006db23
--- /dev/null
+++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMamePlayModeLifecycle.cs
@@ -0,0 +1,143 @@
+// Visual Pinball Engine
+// Copyright (C) 2021 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+#if UNITY_EDITOR
+
+using System;
+using System.Diagnostics;
+using NLog;
+using UnityEditor;
+using UnityEngine;
+using Logger = NLog.Logger;
+
+
+// ReSharper disable CheckNamespace
+
+namespace VisualPinball.Engine.PinMAME.Editor
+{
+ [InitializeOnLoad]
+ public static class PinMamePlayModeLifecycle
+ {
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+ private static bool _stopping;
+ private static Stopwatch _stopwatch;
+ private const int StopTimeoutMs = 2000;
+ private static bool _warnedTimeout;
+ private static System.Threading.Tasks.Task _stopTask;
+
+ // (removed) Domain reload branching; stop is synchronous for determinism.
+
+ static PinMamePlayModeLifecycle()
+ {
+ EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
+ EditorApplication.quitting += OnQuitting;
+ }
+
+ private static void OnQuitting()
+ {
+ RequestStop("Editor quitting");
+ }
+
+ private static void OnPlayModeStateChanged(PlayModeStateChange state)
+ {
+ if (state == PlayModeStateChange.ExitingPlayMode) {
+ RequestStop("Exiting play mode");
+ }
+ }
+
+ private static void RequestStop(string reason)
+ {
+ int runState;
+ try {
+ runState = PinMame.PinMame.RunState;
+ if (runState == 0) {
+ return;
+ }
+ } catch {
+ return;
+ }
+
+ if (_stopping) {
+ return;
+ }
+
+ _stopping = true;
+ _warnedTimeout = false;
+ _stopwatch = Stopwatch.StartNew();
+ Logger.Info($"[PinMAME] Stop requested ({reason}), RunState={runState}");
+
+ // Stop sim thread(s) first. This reduces the chance of other high-frequency code paths
+ // continuing to call into PinMAME while we are stopping it.
+ try {
+ var sims = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None);
+ for (var i = 0; i < sims.Length; i++) {
+ sims[i].StopSimulation();
+ }
+ } catch { }
+
+ // Prefer stopping via the active component(s) so they can unsubscribe callbacks first.
+ try {
+ var engines = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None);
+ for (var i = 0; i < engines.Length; i++) {
+ engines[i].StopForPlayModeExit();
+ }
+ } catch { }
+
+ EditorApplication.update -= Update;
+ EditorApplication.update += Update;
+
+ // Stop PinMAME now.
+ // With Domain Reload disabled, background stop can overlap the next play session.
+ // Now that other hot loops are tamed, prefer a synchronous stop for determinism.
+ try {
+ var sw = Stopwatch.StartNew();
+ PinMame.PinMame.StopRunningGame();
+ Logger.Info($"[PinMAME] Stop call returned after {sw.ElapsedMilliseconds}ms");
+ } catch (Exception e) {
+ Logger.Warn(e, "[PinMAME] StopGame failed while exiting play mode.");
+ }
+ _stopTask = System.Threading.Tasks.Task.CompletedTask;
+ }
+
+ private static void Update()
+ {
+ int runState;
+ try {
+ runState = PinMame.PinMame.RunState;
+ } catch {
+ runState = 0;
+ }
+ var running = runState != 0;
+
+ var stopDone = _stopTask == null || _stopTask.IsCompleted;
+ if (!running && stopDone) {
+ EditorApplication.update -= Update;
+ _stopping = false;
+ _warnedTimeout = false;
+ _stopTask = null;
+ Logger.Info("[PinMAME] Stopped (editor)");
+ return;
+ }
+
+ if (!_warnedTimeout && _stopwatch != null && _stopwatch.ElapsedMilliseconds > StopTimeoutMs) {
+ _warnedTimeout = true;
+ Logger.Warn($"[PinMAME] Still running after stop timeout (editor), RunState={runState}");
+ }
+ }
+ }
+}
+
+#endif
diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMamePlayModeLifecycle.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMamePlayModeLifecycle.cs.meta
new file mode 100644
index 0000000..fae26a5
--- /dev/null
+++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMamePlayModeLifecycle.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e233609de2ea2164b89f4688b5597e55
\ No newline at end of file
diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs
index 6d57986..96d1ba0 100644
--- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs
+++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs
@@ -18,19 +18,22 @@
// ReSharper disable InconsistentNaming
// ReSharper disable PossibleNullReferenceException
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Runtime.InteropServices;
-using System.Threading;
-using System.Threading.Tasks;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
using NLog;
using PinMame;
-using UnityEngine;
-using UnityEngine.InputSystem;
-using VisualPinball.Engine.Game.Engines;
-using VisualPinball.Unity;
-using Logger = NLog.Logger;
+using UnityEngine;
+using UnityEngine.InputSystem;
+using VisualPinball.Engine.Game.Engines;
+using VisualPinball.Unity;
+using VisualPinball.Unity.Simulation;
+using Logger = NLog.Logger;
namespace VisualPinball.Engine.PinMAME
{
@@ -38,9 +41,10 @@ namespace VisualPinball.Engine.PinMAME
[DisallowMultipleComponent]
[RequireComponent(typeof(AudioSource))]
[AddComponentMenu("Pinball/Gamelogic Engine/PinMAME")]
- public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine
- {
- public string Name { get; } = "PinMAME Gamelogic Engine";
+ public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats
+ {
+ public string Name { get; } = "PinMAME Gamelogic Engine";
+ public GamelogicInputDispatchMode SwitchDispatchMode => GamelogicInputDispatchMode.SimulationThread;
public const string DmdPrefix = "dmd";
public const string SegDispPrefix = "display";
@@ -95,10 +99,91 @@ public GamelogicEngineLamp[] RequestedLamps {
public event EventHandler OnLampsChanged;
public event EventHandler OnDisplaysRequested;
public event EventHandler OnDisplayClear;
- public event EventHandler OnDisplayUpdateFrame;
- public event EventHandler OnStarted;
-
- #endregion
+ public event EventHandler OnDisplayUpdateFrame;
+ public event EventHandler OnStarted;
+
+ public void SetTimeFence(double timeInSeconds)
+ {
+ if (_pinMame == null) {
+ return;
+ }
+
+ try {
+ PinMame.PinMame.SetTimeFence(timeInSeconds);
+ }
+ catch (Exception e) {
+ Logger.Warn(e, "[PinMAME] SetTimeFence call failed.");
+ }
+ }
+
+ public bool TryDequeueCoilEvent(out CoilEventArgs coilEvent)
+ {
+ if (_simulationCoilDispatchQueue.TryDequeue(out coilEvent)) {
+ if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) {
+ Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0);
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool TryGetPerformanceStats(out GamelogicPerformanceStats stats)
+ {
+ if (_pinMame == null) {
+ stats = default;
+ return false;
+ }
+
+ stats = new GamelogicPerformanceStats(_isRunning, Volatile.Read(ref _pinMameCallbackRateHz), PinMame.PinMame.RunState);
+ return true;
+ }
+
+ public void WriteSharedState(ref SimulationState.Snapshot snapshot)
+ {
+ lock (_outputStateLock) {
+ snapshot.CoilCount = CopyCoilStates(ref snapshot);
+ snapshot.LampCount = CopyLampStates(ref snapshot);
+ snapshot.GICount = CopyGiStates(ref snapshot);
+ }
+ }
+
+ public void ApplySharedState(in SimulationState.Snapshot snapshot)
+ {
+ if (_player == null) {
+ return;
+ }
+
+ _sharedLampPlaybackActive = true;
+
+ for (var i = 0; i < snapshot.LampCount; i++) {
+ var lamp = snapshot.LampStates[i];
+ if (!_pinMameIdToLampIdMapping.TryGetValue(lamp.Id, out var lampId)) {
+ continue;
+ }
+ if (_lastAppliedLampStates.TryGetValue(lamp.Id, out var currentValue) && currentValue == lamp.Value) {
+ continue;
+ }
+
+ _lastAppliedLampStates[lamp.Id] = lamp.Value;
+ _player.SetLamp(lampId, lamp.Value, LampSource.Lamp);
+ }
+
+ for (var i = 0; i < snapshot.GICount; i++) {
+ var gi = snapshot.GIStates[i];
+ if (!_pinMameIdToLampIdMapping.TryGetValue(gi.Id, out var giId)) {
+ continue;
+ }
+ if (_lastAppliedGiStates.TryGetValue(gi.Id, out var currentValue) && currentValue == gi.Value) {
+ continue;
+ }
+
+ _lastAppliedGiStates[gi.Id] = gi.Value;
+ _player.SetLamp(giId, gi.Value, LampSource.GI);
+ }
+ }
+
+ #endregion
#region Internals
@@ -123,18 +208,39 @@ public GamelogicEngineLamp[] RequestedLamps {
private Dictionary _lamps = new();
private Dictionary _pinMameIdToLampIdMapping = new();
- private bool _isRunning;
+ private volatile bool _isRunning;
private int _numMechs;
- private Dictionary _frameBuffer = new();
- private Dictionary> _dmdLevels = new();
-
- private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
- private static readonly Color Tint = new(1, 0.18f, 0);
-
- private readonly Queue _dispatchQueue = new();
- private readonly Queue _audioQueue = new();
-
- private int _audioFilterChannels;
+ private Dictionary _frameBuffer = new();
+
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+ private static readonly Color Tint = new(1, 0.18f, 0);
+ private static readonly Color UnlitTint = new(Tint.r * 0.18f, Tint.g * 0.18f, Tint.b * 0.18f, 1f);
+
+ private readonly Queue _dispatchQueue = new();
+ private readonly List _pendingDispatchCallbacks = new();
+ private readonly object _displayStateLock = new();
+ private readonly object _outputStateLock = new();
+ private readonly ConcurrentQueue _simulationCoilDispatchQueue = new();
+ private int _simulationCoilDispatchQueueSize;
+ private bool _dispatchQueueWarningIssued;
+ private bool _simulationCoilQueueWarningIssued;
+ private bool _simulationCoilQueueDropWarningIssued;
+ private long _solenoidDelayStartTimestampUsec;
+ private const int MaxSimulationCoilDispatchQueueSize = 8192;
+ private const int DispatchQueueWarningThreshold = 512;
+ private const double PerfSampleWindowSeconds = 0.25;
+ private long _pinMamePerfWindowStartTicks = Stopwatch.GetTimestamp();
+ private int _pinMameCallbacksInWindow;
+ private float _pinMameCallbackRateHz;
+ private readonly Dictionary _sharedCoilStates = new();
+ private readonly Dictionary _sharedLampStates = new();
+ private readonly Dictionary _sharedGiStates = new();
+ private readonly Dictionary _lastAppliedLampStates = new();
+ private readonly Dictionary _lastAppliedGiStates = new();
+ private bool _sharedLampPlaybackActive;
+ private readonly Queue _audioQueue = new();
+
+ private int _audioFilterChannels;
private PinMameAudioInfo _audioInfo;
private float[] _lastAudioFrame = {};
private int _lastAudioFrameOffset;
@@ -147,21 +253,31 @@ public GamelogicEngineLamp[] RequestedLamps {
public bool _solenoidsEnabled;
public long _solenoidDelayStart;
- private Dictionary _registeredMechs = new();
- private HashSet _mechSwitches = new();
+ private Dictionary _registeredMechs = new();
+ private Dictionary _registeredMechNames = new();
+ private HashSet _mechSwitches = new();
- private bool _toggleSpeed = false;
- private Keyboard _keyboard;
-
- #endregion
+ private bool _toggleSpeed = false;
+ private Keyboard _keyboard;
+
+ private static long TimestampUsec => (Stopwatch.GetTimestamp() * 1_000_000) / Stopwatch.Frequency;
+
+ private static readonly SemaphoreSlim PinMameStartStopGate = new SemaphoreSlim(1, 1);
+ private int _onInitCalled;
+ private int _stopRequested;
+ private int _playModeExitHandled;
+ // (removed) lamp delta debug tracking
+ private const int StopTimeoutMs = 10000;
+
+ #endregion
#region Lifecycle
- private void Awake()
- {
- Logger.Info("Project audio sample rate: " + AudioSettings.outputSampleRate);
- _keyboard = Keyboard.current;
- }
+ private void Awake()
+ {
+ Logger.Info("Project audio sample rate: " + AudioSettings.outputSampleRate);
+ _keyboard = Keyboard.current;
+ }
private void Start()
{
@@ -171,53 +287,136 @@ private void Start()
_lastAudioFrameOffset = 0;
}
- private void Update()
- {
- if (_pinMame == null || !_isRunning) {
- return;
- }
-
- lock (_dispatchQueue) {
- while (_dispatchQueue.Count > 0) {
- _dispatchQueue.Dequeue().Invoke();
- }
- }
-
- // lamps
- _pinMame.GetChangedLamps(_changedLamps);
- foreach (var changedLamp in _changedLamps) {
- if (_pinMameIdToLampIdMapping.ContainsKey(changedLamp.Id)) {
- //Logger.Info($"[PinMAME] <= lamp {changedLamp.Id}: {changedLamp.Value}");
- OnLampChanged?.Invoke(this, new LampEventArgs(_lamps[_pinMameIdToLampIdMapping[changedLamp.Id]].Id, changedLamp.Value));
- }
- }
+ private void Update()
+ {
+ if (_pinMame == null || !_isRunning) {
+ return;
+ }
+
+ _pendingDispatchCallbacks.Clear();
+
+ lock (_dispatchQueue) {
+ while (_dispatchQueue.Count > 0) {
+ _pendingDispatchCallbacks.Add(_dispatchQueue.Dequeue());
+ }
+ }
+
+ foreach (var callback in _pendingDispatchCallbacks) {
+ try {
+ callback.Invoke();
+ }
+ catch (Exception e) {
+ Logger.Error(e, "[PinMAME] Exception while processing main-thread dispatch callback.");
+ }
+ }
+
+ // lamps
+ _pinMame.GetChangedLamps(_changedLamps);
+ foreach (var changedLamp in _changedLamps) {
+ lock (_outputStateLock) {
+ _sharedLampStates[changedLamp.Id] = changedLamp.Value;
+ }
+ if (!_sharedLampPlaybackActive && _pinMameIdToLampIdMapping.ContainsKey(changedLamp.Id)) {
+ //Logger.Info($"[PinMAME] <= lamp {changedLamp.Id}: {changedLamp.Value}");
+ OnLampChanged?.Invoke(this, new LampEventArgs(_lamps[_pinMameIdToLampIdMapping[changedLamp.Id]].Id, changedLamp.Value));
+ }
+ }
// gi
- _pinMame.GetChangedGIs(_changedGIs);
- foreach (var changedGi in _changedGIs) {
- if (_pinMameIdToLampIdMapping.ContainsKey(changedGi.Id)) {
- //Logger.Info($"[PinMAME] <= gi {changedGi.Id}: {changedGi.Value}");
- OnLampChanged?.Invoke(this, new LampEventArgs(_lamps[_pinMameIdToLampIdMapping[changedGi.Id]].Id, changedGi.Value, LampSource.GI));
- } /*else {
+ _pinMame.GetChangedGIs(_changedGIs);
+ foreach (var changedGi in _changedGIs) {
+ lock (_outputStateLock) {
+ _sharedGiStates[changedGi.Id] = changedGi.Value;
+ }
+ if (!_sharedLampPlaybackActive && _pinMameIdToLampIdMapping.ContainsKey(changedGi.Id)) {
+ //Logger.Info($"[PinMAME] <= gi {changedGi.Id}: {changedGi.Value}");
+ OnLampChanged?.Invoke(this, new LampEventArgs(_lamps[_pinMameIdToLampIdMapping[changedGi.Id]].Id, changedGi.Value, LampSource.GI));
+ } /*else {
Logger.Info($"No GI {changedGi.Id} found.");
}*/
}
- // if (_keyboard != null && _keyboard.cKey.wasPressedThisFrame)
+ // if (_keyboard != null && _keyboard.cKey.wasPressedThisFrame)
// {
// OnCoilChanged.Invoke(this, new CoilEventArgs("28", true));
// OnCoilChanged.Invoke(this, new CoilEventArgs("28", false));
- // }
- }
-
- private void OnDestroy()
- {
- if (_pinMame != null) {
- _pinMame.StopGame();
- _pinMame.OnGameStarted -= OnGameStarted;
- _pinMame.OnGameEnded -= OnGameEnded;
- _pinMame.OnDisplayAvailable -= OnDisplayRequested;
- _pinMame.OnDisplayUpdated -= OnDisplayUpdated;
+ // }
+ }
+
+ private void EnqueueMainThreadDispatch(Action callback)
+ {
+ lock (_dispatchQueue) {
+ _dispatchQueue.Enqueue(callback);
+ if (!_dispatchQueueWarningIssued && _dispatchQueue.Count >= DispatchQueueWarningThreshold) {
+ _dispatchQueueWarningIssued = true;
+ Logger.Warn($"[PinMAME] Main-thread dispatch queue backlog reached {_dispatchQueue.Count} items.");
+ }
+ }
+ }
+
+ private int CopyCoilStates(ref SimulationState.Snapshot snapshot)
+ {
+ var index = 0;
+ foreach (var entry in _sharedCoilStates) {
+ if (index >= snapshot.CoilStates.Length) {
+ break;
+ }
+
+ snapshot.CoilStates[index++] = new SimulationState.CoilState {
+ Id = entry.Key,
+ IsActive = entry.Value ? (byte)1 : (byte)0,
+ };
+ }
+
+ return index;
+ }
+
+ private int CopyLampStates(ref SimulationState.Snapshot snapshot)
+ {
+ var index = 0;
+ foreach (var entry in _sharedLampStates) {
+ if (index >= snapshot.LampStates.Length) {
+ break;
+ }
+
+ snapshot.LampStates[index++] = new SimulationState.LampState {
+ Id = entry.Key,
+ Value = entry.Value,
+ };
+ }
+
+ return index;
+ }
+
+ private int CopyGiStates(ref SimulationState.Snapshot snapshot)
+ {
+ var index = 0;
+ foreach (var entry in _sharedGiStates) {
+ if (index >= snapshot.GIStates.Length) {
+ break;
+ }
+
+ snapshot.GIStates[index++] = new SimulationState.GIState {
+ Id = entry.Key,
+ Value = entry.Value,
+ };
+ }
+
+ return index;
+ }
+
+ private void OnDestroy()
+ {
+ #if UNITY_EDITOR
+ StopForPlayModeExit();
+ #else
+ RequestStopGame("OnDestroy");
+ #endif
+ if (_pinMame != null) {
+ _pinMame.OnGameStarted -= OnGameStarted;
+ _pinMame.OnGameEnded -= OnGameEnded;
+ _pinMame.OnDisplayAvailable -= OnDisplayRequested;
+ _pinMame.OnDisplayUpdated -= OnDisplayUpdated;
if (!DisableAudio)
{
@@ -229,16 +428,150 @@ private void OnDestroy()
_pinMame.OnMechUpdated -= OnMechUpdated;
_pinMame.OnSolenoidUpdated -= OnSolenoidUpdated;
_pinMame.IsKeyPressed -= IsKeyPressed;
- }
- _frameBuffer.Clear();
- _dmdLevels.Clear();
- }
+ }
+ _frameBuffer.Clear();
+ lock (_outputStateLock) {
+ _sharedCoilStates.Clear();
+ _sharedLampStates.Clear();
+ _sharedGiStates.Clear();
+ }
+ _lastAppliedLampStates.Clear();
+ _lastAppliedGiStates.Clear();
+ _sharedLampPlaybackActive = false;
+ }
+
+ private void OnDisable()
+ {
+ // In some Unity playmode/domain-reload configurations, OnDestroy isn't reliably called.
+ // Best-effort shutdown to avoid leaving a game running across sessions.
+ #if UNITY_EDITOR
+ StopForPlayModeExit();
+ #else
+ RequestStopGame("OnDisable");
+ #endif
+ }
+
+ private void OnApplicationQuit()
+ {
+ #if UNITY_EDITOR
+ StopForPlayModeExit();
+ #else
+ RequestStopGame("OnApplicationQuit");
+ #endif
+ }
+
+ public void StopForPlayModeExit()
+ {
+ if (Interlocked.Exchange(ref _playModeExitHandled, 1) != 0) {
+ return;
+ }
+
+ // Editor-only: called from playmode lifecycle hook.
+ // 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);
+ // 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;
+ Logger.Info($"[PinMAME] StopForPlayModeExit: unsubscribing callbacks ({name}#{GetInstanceID()})");
+ try {
+ if (_pinMame == null) {
+ return;
+ }
+
+ _pinMame.OnGameStarted -= OnGameStarted;
+ _pinMame.OnGameEnded -= OnGameEnded;
+ _pinMame.OnDisplayAvailable -= OnDisplayRequested;
+ _pinMame.OnDisplayUpdated -= OnDisplayUpdated;
+ _pinMame.OnMechAvailable -= OnMechAvailable;
+ _pinMame.OnMechUpdated -= OnMechUpdated;
+ _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated;
+ _pinMame.IsKeyPressed -= IsKeyPressed;
+ _pinMame.OnAudioAvailable -= OnAudioAvailable;
+ _pinMame.OnAudioUpdated -= OnAudioUpdated;
+ }
+ catch { }
+ }
+
+ private void RequestStopGame(string reason)
+ {
+ #if UNITY_EDITOR
+ // In the editor, playmode shutdown is handled by StopForPlayModeExit + editor lifecycle hook.
+ // Calling native Stop() from here can race and destabilize the editor.
+ StopForPlayModeExit();
+ return;
+ #endif
+
+ try {
+ SetTimeFence(0.0);
+
+ if (Interlocked.Exchange(ref _stopRequested, 1) != 0) {
+ return;
+ }
+ _isRunning = false;
+
+ if (!PinMame.PinMame.IsRunning) {
+ return;
+ }
+
+ } catch (Exception e) {
+ Logger.Warn(e, $"[PinMAME] StopGame ({reason}) failed.");
+ return;
+ }
+
+ // Avoid racing StartGame on re-entering play mode.
+ if (!PinMameStartStopGate.Wait(0)) {
+ Logger.Warn($"[PinMAME] StopGame ({reason}) skipped: start/stop already in progress.");
+ return;
+ }
+
+ // Never block the Unity main thread during playmode exit.
+ // PinMAME shutdown is best-effort here; OnInit() and the editor lifecycle hook
+ // will recover/ensure shutdown before starting a new ROM.
+ Logger.Warn($"[PinMAME] StopGame ({reason}) requested. RunningGame={_pinMame?.RunningGame}");
+
+ try {
+ if (_pinMame != null) {
+ // Unsubscribe first to keep the native thread from doing work in managed callbacks during shutdown.
+ _pinMame.OnGameStarted -= OnGameStarted;
+ _pinMame.OnGameEnded -= OnGameEnded;
+ _pinMame.OnDisplayAvailable -= OnDisplayRequested;
+ _pinMame.OnDisplayUpdated -= OnDisplayUpdated;
+ _pinMame.OnMechAvailable -= OnMechAvailable;
+ _pinMame.OnMechUpdated -= OnMechUpdated;
+ _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated;
+ _pinMame.IsKeyPressed -= IsKeyPressed;
+ _pinMame.OnAudioAvailable -= OnAudioAvailable;
+ _pinMame.OnAudioUpdated -= OnAudioUpdated;
+ }
+ }
+ catch (Exception e) {
+ Logger.Warn(e, $"[PinMAME] Unsubscribe during shutdown ({reason}) failed.");
+ }
+
+ // PinmameStop may block (joins the emulation thread). Never do that on the Unity main thread.
+ System.Threading.Tasks.Task.Run(() => {
+ try {
+ _pinMame?.StopGame();
+ } catch (Exception e) {
+ Logger.Warn(e, $"[PinMAME] StopGame ({reason}) failed.");
+ } finally {
+ PinMameStartStopGate.Release();
+ }
+ });
+ }
- public Task OnInit(Player player, TableApi tableApi, BallManager ballManager, CancellationToken ct)
- {
- string vpmPath = null;
- _ballManager = ballManager;
- _playfieldComponent = GetComponentInChildren();
+ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManager, CancellationToken ct)
+ {
+ if (Interlocked.Exchange(ref _onInitCalled, 1) != 0) {
+ Logger.Warn($"[PinMAME] OnInit called more than once on {name}, ignoring.");
+ return;
+ }
+
+ string vpmPath = null;
+ _ballManager = ballManager;
+ _playfieldComponent = GetComponentInChildren();
#if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR
vpmPath = Path.Combine(Application.persistentDataPath, "pinmame");
@@ -262,57 +595,148 @@ public Task OnInit(Player player, TableApi tableApi, BallManager ballManager, Ca
File.WriteAllBytes(Path.Combine(vpmPath, "roms", $"{romId}.zip"), data);
#endif
- Logger.Info($"New PinMAME instance at {(double)AudioSettings.outputSampleRate / 1000}kHz");
-
- // ReSharper disable once ExpressionIsAlwaysNull
- _pinMame = PinMame.PinMame.Instance(PinMameAudioFormat.AudioFormatFloat, AudioSettings.outputSampleRate, vpmPath);
-
- _pinMame.SetHandleKeyboard(false);
- _pinMame.SetHandleMechanics(DisableMechs ? 0 : 0xFF);
-
- _pinMame.OnGameStarted += OnGameStarted;
- _pinMame.OnGameEnded += OnGameEnded;
- _pinMame.OnDisplayAvailable += OnDisplayRequested;
- _pinMame.OnDisplayUpdated += OnDisplayUpdated;
-
- if (!DisableAudio) {
- _pinMame.OnAudioAvailable += OnAudioAvailable;
- _pinMame.OnAudioUpdated += OnAudioUpdated;
- }
-
- _pinMame.OnMechAvailable += OnMechAvailable;
- _pinMame.OnMechUpdated += OnMechUpdated;
- _pinMame.OnSolenoidUpdated += OnSolenoidUpdated;
- _pinMame.IsKeyPressed += IsKeyPressed;
-
- _player = player;
-
- _solenoidsEnabled = SolenoidDelay == 0;
-
- try {
- _pinMame.StartGame(romId);
-
- } catch (Exception e) {
- Logger.Error(e);
- }
-
- return Task.CompletedTask;
- }
+ await PinMameStartStopGate.WaitAsync(ct);
+ try {
+ Logger.Info($"[PinMAME] OnInit {name}: romId={romId}, sampleRate={AudioSettings.outputSampleRate}");
+
+ #if UNITY_EDITOR
+ // With Domain Reload disabled, PinMAME (native + managed singleton) persists across play sessions.
+ // Even when RunState reports 0, the native game thread might still exist and need joining.
+ // Calling Stop here is a cheap no-op if nothing is running, and a critical cleanup if something is.
+ var preStopState = PinMame.PinMame.RunState;
+ var preStopSw = Stopwatch.StartNew();
+ try {
+ PinMame.PinMame.StopRunningGame();
+ } catch (Exception e) {
+ Logger.Warn(e, "[PinMAME] Pre-start StopRunningGame failed.");
+ }
+ // Wait for a clean stopped state before starting a new ROM.
+ var preStopWaitSw = Stopwatch.StartNew();
+ while (PinMame.PinMame.RunState != 0 && preStopWaitSw.ElapsedMilliseconds < StopTimeoutMs) {
+ await Task.Delay(10, ct);
+ }
+ Logger.Debug($"[PinMAME] Editor pre-stop: initialRunState={preStopState}, stopCallMs={preStopSw.ElapsedMilliseconds}, waitMs={preStopWaitSw.ElapsedMilliseconds}, finalRunState={PinMame.PinMame.RunState}");
+ if (PinMame.PinMame.RunState != 0) {
+ Logger.Error($"[PinMAME] Cannot start ROM; PinMAME did not reach stopped state (RunState={PinMame.PinMame.RunState}).");
+ return;
+ }
+
+ // Force a fresh managed wrapper each init. This avoids cross-session managed state
+ // (callbacks, buffers) bleeding into the next play run when the host keeps assemblies loaded.
+ PinMame.PinMame.ResetInstance();
+ #endif
+
+ // If we exited play mode recently, a background stop may still be running.
+ // Starting a new ROM while the previous stop is cleaning up can lead to a "started"
+ // state with no progress/output on the next run.
+ var wasRunning = PinMame.PinMame.IsRunning;
+ var wasStopping = PinMame.PinMame.IsStopInProgress;
+ var swStop = Stopwatch.StartNew();
+ while ((PinMame.PinMame.IsStopInProgress || PinMame.PinMame.IsRunning) && swStop.ElapsedMilliseconds < StopTimeoutMs) {
+ await Task.Delay(10, ct);
+ }
+ Logger.Debug($"[PinMAME] Pre-start wait: {swStop.ElapsedMilliseconds}ms (WasRunning={wasRunning}, WasStopping={wasStopping}, IsRunning={PinMame.PinMame.IsRunning}, RunState={PinMame.PinMame.RunState}, StopInProgress={PinMame.PinMame.IsStopInProgress})");
+
+ // ReSharper disable once ExpressionIsAlwaysNull
+ _pinMame = PinMame.PinMame.Instance(PinMameAudioFormat.AudioFormatFloat, AudioSettings.outputSampleRate, vpmPath);
+ // Re-apply config each init (important when Domain Reload is disabled).
+ _pinMame.ApplyConfig();
+
+ // Prevent duplicate subscriptions if OnInit is invoked more than once.
+ _pinMame.OnGameStarted -= OnGameStarted;
+ _pinMame.OnGameEnded -= OnGameEnded;
+ _pinMame.OnDisplayAvailable -= OnDisplayRequested;
+ _pinMame.OnDisplayUpdated -= OnDisplayUpdated;
+ _pinMame.OnMechAvailable -= OnMechAvailable;
+ _pinMame.OnMechUpdated -= OnMechUpdated;
+ _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated;
+ _pinMame.IsKeyPressed -= IsKeyPressed;
+ _pinMame.OnAudioAvailable -= OnAudioAvailable;
+ _pinMame.OnAudioUpdated -= OnAudioUpdated;
+
+ // If domain reload is disabled or a previous table didn't shut down cleanly,
+ // PinMAME can still be running here.
+ if (PinMame.PinMame.IsRunning) {
+ Logger.Warn($"[PinMAME] A game is already running (RunningGame={_pinMame.RunningGame}); stopping it before starting a new one.");
+ try {
+ _pinMame.StopGame();
+ }
+ catch (Exception e) {
+ Logger.Warn(e, "[PinMAME] StopGame failed while recovering from already running state.");
+ }
+
+ var sw = Stopwatch.StartNew();
+ while (PinMame.PinMame.IsRunning && sw.ElapsedMilliseconds < StopTimeoutMs) {
+ await Task.Delay(10, ct);
+ }
+ if (PinMame.PinMame.IsRunning) {
+ Logger.Warn("[PinMAME] Failed to stop running game within timeout; attempting StartGame anyway.");
+ }
+ }
+
+ _pinMame.SetHandleKeyboard(false);
+ _pinMame.SetHandleMechanics(DisableMechs ? 0 : 0xFF);
+ SetTimeFence(0.01);
+
+ _pinMame.OnGameStarted += OnGameStarted;
+ _pinMame.OnGameEnded += OnGameEnded;
+ _pinMame.OnDisplayAvailable += OnDisplayRequested;
+ _pinMame.OnDisplayUpdated += OnDisplayUpdated;
+
+ if (!DisableAudio) {
+ _pinMame.OnAudioAvailable += OnAudioAvailable;
+ _pinMame.OnAudioUpdated += OnAudioUpdated;
+ }
+
+ _pinMame.OnMechAvailable += OnMechAvailable;
+ _pinMame.OnMechUpdated += OnMechUpdated;
+ _pinMame.OnSolenoidUpdated += OnSolenoidUpdated;
+ _pinMame.IsKeyPressed += IsKeyPressed;
+
+ _player = player;
+ _solenoidsEnabled = SolenoidDelay == 0;
+
+ try {
+ _pinMame.StartGame(romId);
+ Logger.Debug($"[PinMAME] StartGame returned. RunState={PinMame.PinMame.RunState}");
+ }
+ catch (InvalidOperationException e) when (e.Message != null && e.Message.Contains("status=GAME_ALREADY_RUNNING"))
+ {
+ Logger.Warn(e, "[PinMAME] StartGame reported already running; stopping and retrying once.");
+ try {
+ _pinMame.StopGame();
+ await Task.Delay(100, ct);
+ _pinMame.StartGame(romId);
+ Logger.Debug($"[PinMAME] StartGame retry returned. RunState={PinMame.PinMame.RunState}");
+ }
+ catch (Exception retryEx) {
+ Logger.Error(retryEx);
+ }
+ }
+ catch (Exception e) {
+ Logger.Error(e);
+ }
+
+ } finally {
+ PinMameStartStopGate.Release();
+ }
+ }
public void ToggleSpeed()
{
- Logger.Info($"[PinMAME] Toggle speed.");
+ Logger.Info("[PinMAME] Toggle speed.");
_pinMame.SetHandleKeyboard(true);
_toggleSpeed = true;
}
- private void OnGameStarted()
- {
- Logger.Info($"[PinMAME] Game started.");
- _isRunning = true;
-
- _solenoidDelayStart = DateTimeOffset.Now.ToUnixTimeMilliseconds();
+ private void OnGameStarted()
+ {
+ Logger.Info("[PinMAME] Game started.");
+ _isRunning = true;
+
+ _solenoidDelayStartTimestampUsec = TimestampUsec;
+ _solenoidDelayStart = _solenoidDelayStartTimestampUsec / 1000;
try {
SendInitialSwitches();
@@ -320,19 +744,33 @@ private void OnGameStarted()
}
catch(Exception e) {
- Logger.Error($"[PinMAME] OnGameStarted: {e.Message}");
+ Logger.Error($"[PinMAME] OnGameStarted: {e.Message}");
}
- lock (_dispatchQueue) {
- _dispatchQueue.Enqueue(() => OnStarted?.Invoke(this, EventArgs.Empty));
- }
+ EnqueueMainThreadDispatch(() => OnStarted?.Invoke(this, EventArgs.Empty));
}
- private void OnGameEnded()
- {
- Logger.Info($"[PinMAME] Game ended.");
- _isRunning = false;
- }
+ private void OnGameEnded()
+ {
+ // Native can report ended more than once during teardown; keep it idempotent.
+ if (_isRunning) {
+ Logger.Info("[PinMAME] Game ended.");
+ }
+ _isRunning = false;
+ while (_simulationCoilDispatchQueue.TryDequeue(out _)) {
+ if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) {
+ Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0);
+ }
+ }
+ lock (_outputStateLock) {
+ _sharedCoilStates.Clear();
+ _sharedLampStates.Clear();
+ _sharedGiStates.Clear();
+ }
+ _lastAppliedLampStates.Clear();
+ _lastAppliedGiStates.Clear();
+ _sharedLampPlaybackActive = false;
+ }
private void UpdateCaches()
{
@@ -417,78 +855,79 @@ private void UpdateCaches()
#region Displays
- private void OnDisplayRequested(int index, int displayCount, PinMameDisplayLayout displayLayout)
- {
- if (displayLayout.IsDmd) {
- lock (_dispatchQueue) {
- _dispatchQueue.Enqueue(() =>
- OnDisplaysRequested?.Invoke(this, new RequestedDisplays(
- new DisplayConfig($"{DmdPrefix}{index}", displayLayout.Width, displayLayout.Height))));
- }
-
- _frameBuffer[index] = new byte[displayLayout.Width * displayLayout.Height];
- _dmdLevels[index] = displayLayout.Levels;
-
- } else {
- lock (_dispatchQueue) {
- _dispatchQueue.Enqueue(() =>
- OnDisplaysRequested?.Invoke(this, new RequestedDisplays(
- new DisplayConfig($"{SegDispPrefix}{index}", displayLayout.Length, 1))));
- }
-
- _frameBuffer[index] = new byte[displayLayout.Length * 2];
- Logger.Info($"[PinMAME] Display {SegDispPrefix}{index} is of type {displayLayout.Type} at {displayLayout.Length} wide.");
- }
- }
-
- private void OnDisplayUpdated(int index, IntPtr framePtr, PinMameDisplayLayout displayLayout)
- {
- if (displayLayout.IsDmd) {
- UpdateDmd(index, displayLayout, framePtr);
+ private void OnDisplayRequested(int index, int displayCount, PinMameDisplayLayout displayLayout)
+ {
+ if (displayLayout.IsDmd) {
+ EnqueueMainThreadDispatch(() =>
+ OnDisplaysRequested?.Invoke(this, new RequestedDisplays(
+ new DisplayConfig($"{DmdPrefix}{index}", displayLayout.Width, displayLayout.Height, false, Tint, UnlitTint))));
+
+ lock (_displayStateLock) {
+ _frameBuffer[index] = new byte[displayLayout.Width * displayLayout.Height];
+ }
+
+ } else {
+ EnqueueMainThreadDispatch(() =>
+ OnDisplaysRequested?.Invoke(this, new RequestedDisplays(
+ new DisplayConfig($"{SegDispPrefix}{index}", displayLayout.Length, 1))));
+
+ lock (_displayStateLock) {
+ _frameBuffer[index] = new byte[displayLayout.Length * 2];
+ }
+ Logger.Info($"[PinMAME] Display {SegDispPrefix}{index} is of type {displayLayout.Type} at {displayLayout.Length} wide.");
+ }
+ }
- } else {
+ private void OnDisplayUpdated(int index, IntPtr framePtr, PinMameDisplayLayout displayLayout)
+ {
+ MarkPinMameCallbackActivity();
+
+ if (displayLayout.IsDmd) {
+ UpdateDmd(index, displayLayout, framePtr);
+
+ } else {
UpdateSegDisp(index, displayLayout, framePtr);
}
}
- private void UpdateDmd(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr)
- {
- unsafe {
- var ptr = (byte*) framePtr;
- for (var y = 0; y < displayLayout.Height; y++) {
- for (var x = 0; x < displayLayout.Width; x++) {
- var pos = y * displayLayout.Width + x;
- if (!_dmdLevels[index].ContainsKey(ptr[pos])) {
- Logger.Error($"Display {index}: Provided levels ({BitConverter.ToString(_dmdLevels[index].Keys.ToArray())}) don't contain level {BitConverter.ToString(new[] {ptr[pos]})}.");
- _dmdLevels[index][ptr[pos]] = 0x4;
- }
- _frameBuffer[index][pos] = _dmdLevels[index][ptr[pos]];
- }
- }
- }
-
- lock (_dispatchQueue) {
- _dispatchQueue.Enqueue(() => OnDisplayUpdateFrame?.Invoke(this,
- new DisplayFrameData($"{DmdPrefix}{index}", GetDisplayFrameFormat(displayLayout), _frameBuffer[index])));
- }
- }
-
- private void UpdateSegDisp(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr)
- {
- Marshal.Copy(framePtr, _frameBuffer[index], 0, displayLayout.Length * 2);
-
- lock (_dispatchQueue) {
- //Logger.Info($"[PinMAME] Seg data ({index}): {BitConverter.ToString(_frameBuffer[index])}" );
- _dispatchQueue.Enqueue(() => OnDisplayUpdateFrame?.Invoke(this,
- new DisplayFrameData($"{SegDispPrefix}{index}", GetDisplayFrameFormat(displayLayout), _frameBuffer[index])));
- }
- }
+ private void UpdateDmd(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr)
+ {
+ byte[] frameBuffer;
+ lock (_displayStateLock) {
+ if (!_frameBuffer.TryGetValue(index, out frameBuffer)) {
+ Logger.Warn($"[PinMAME] Dropping DMD frame for unknown index {index} (layout: {displayLayout}).");
+ return;
+ }
+ }
+
+ Marshal.Copy(framePtr, frameBuffer, 0, frameBuffer.Length);
+
+ EnqueueMainThreadDispatch(() => OnDisplayUpdateFrame?.Invoke(this,
+ new DisplayFrameData($"{DmdPrefix}{index}", GetDisplayFrameFormat(displayLayout), frameBuffer)));
+ }
+
+ private void UpdateSegDisp(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr)
+ {
+ byte[] frameBuffer;
+ lock (_displayStateLock) {
+ if (!_frameBuffer.TryGetValue(index, out frameBuffer)) {
+ Logger.Warn($"[PinMAME] Dropping segmented display frame for unknown index {index} (layout: {displayLayout}).");
+ return;
+ }
+ }
+
+ Marshal.Copy(framePtr, frameBuffer, 0, displayLayout.Length * 2);
+
+ //Logger.Info($"[PinMAME] Seg data ({index}): {BitConverter.ToString(_frameBuffer[index])}" );
+ EnqueueMainThreadDispatch(() => OnDisplayUpdateFrame?.Invoke(this,
+ new DisplayFrameData($"{SegDispPrefix}{index}", GetDisplayFrameFormat(displayLayout), frameBuffer)));
+ }
- public static DisplayFrameFormat GetDisplayFrameFormat(PinMameDisplayLayout layout)
- {
- if (layout.IsDmd) {
- return layout.Depth == 4 ? DisplayFrameFormat.Dmd4 : DisplayFrameFormat.Dmd2;
- }
+ public static DisplayFrameFormat GetDisplayFrameFormat(PinMameDisplayLayout layout)
+ {
+ if (layout.IsDmd) {
+ return DisplayFrameFormat.Dmd8;
+ }
switch (layout.Type) {
case PinMameDisplayType.Seg8: // 7 segments and comma
@@ -551,11 +990,11 @@ private int OnAudioAvailable(PinMameAudioInfo audioInfo)
return _audioInfo.SamplesPerFrame;
}
- private int OnAudioUpdated(IntPtr framePtr, int frameSize)
- {
- if (_audioFilterChannels == 0) {
- // don't know how many channels yet
- return _audioInfo.SamplesPerFrame;
+ private int OnAudioUpdated(IntPtr framePtr, int frameSize)
+ {
+ if (_audioFilterChannels == 0) {
+ // don't know how many channels yet
+ return _audioInfo.SamplesPerFrame;
}
_audioNumSamplesInput += frameSize;
@@ -667,39 +1106,71 @@ private void OnAudioFilterRead(float[] data, int channels)
#region Coils
- public void SetCoil(string n, bool value)
- {
- OnCoilChanged?.Invoke(this, new CoilEventArgs(n, value));
- }
+ public void SetCoil(string n, bool value)
+ {
+ if (int.TryParse(n, out var coilId)) {
+ lock (_outputStateLock) {
+ _sharedCoilStates[coilId] = value;
+ }
+ }
+ OnCoilChanged?.Invoke(this, new CoilEventArgs(n, value));
+ }
public bool GetCoil(string id)
{
return _player != null && _player.CoilStatuses.ContainsKey(id) && _player.CoilStatuses[id];
}
- private void OnSolenoidUpdated(int id, bool isActive)
- {
- if (_pinMameIdToCoilIdMapping.ContainsKey(id)) {
- if (!_solenoidsEnabled) {
- _solenoidsEnabled = DateTimeOffset.Now.ToUnixTimeMilliseconds() - _solenoidDelayStart >= SolenoidDelay;
-
- if (_solenoidsEnabled) {
- Logger.Info($"Solenoids enabled, {SolenoidDelay}ms passed");
- }
- }
-
- var coil = _coils[_pinMameIdToCoilIdMapping[id]];
-
- if (_solenoidsEnabled) {
- Logger.Info($"[PinMAME] <= coil {coil.Id} : {isActive} | {coil.Description}");
+ private void OnSolenoidUpdated(int id, bool isActive)
+ {
+ MarkPinMameCallbackActivity();
+
+ if (_pinMameIdToCoilIdMapping.ContainsKey(id)) {
+ if (!_solenoidsEnabled) {
+ _solenoidsEnabled = TimestampUsec - _solenoidDelayStartTimestampUsec >= (long)(SolenoidDelay * 1000f);
+
+ if (_solenoidsEnabled) {
+ Logger.Info($"Solenoids enabled, {SolenoidDelay}ms passed");
+ }
+ }
- lock (_dispatchQueue) {
- _dispatchQueue.Enqueue(() => OnCoilChanged?.Invoke(this, new CoilEventArgs(coil.Id, isActive)));
- }
- }
- else {
- Logger.Info($"[PinMAME] <= solenoids disabled, coil {coil.Id} : {isActive} | {coil.Description}");
- }
+ var coil = _coils[_pinMameIdToCoilIdMapping[id]];
+
+ if (_solenoidsEnabled) {
+ lock (_outputStateLock) {
+ _sharedCoilStates[id] = isActive;
+ }
+
+ if (Logger.IsDebugEnabled) {
+ Logger.Debug($"[PinMAME] <= coil {coil.Id} : {isActive} | {coil.Description}");
+ }
+ if (ShouldDispatchSimulationCoil(coil.Id)) {
+ _simulationCoilDispatchQueue.Enqueue(new CoilEventArgs(coil.Id, isActive));
+ var simulationCoilQueueSize = Interlocked.Increment(ref _simulationCoilDispatchQueueSize);
+ if (!_simulationCoilQueueWarningIssued && simulationCoilQueueSize >= MaxSimulationCoilDispatchQueueSize / 2) {
+ _simulationCoilQueueWarningIssued = true;
+ Logger.Warn($"[PinMAME] Simulation coil dispatch backlog reached {simulationCoilQueueSize} items.");
+ }
+ if (simulationCoilQueueSize > MaxSimulationCoilDispatchQueueSize) {
+ if (_simulationCoilDispatchQueue.TryDequeue(out _)) {
+ if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) {
+ Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0);
+ }
+ if (!_simulationCoilQueueDropWarningIssued) {
+ _simulationCoilQueueDropWarningIssued = true;
+ Logger.Warn($"[PinMAME] Simulation coil dispatch queue exceeded {MaxSimulationCoilDispatchQueueSize} items. Dropping oldest coil callbacks.");
+ }
+ }
+ }
+ }
+
+ EnqueueMainThreadDispatch(() => OnCoilChanged?.Invoke(this, new CoilEventArgs(coil.Id, isActive)));
+ }
+ else {
+ if (Logger.IsDebugEnabled) {
+ Logger.Debug($"[PinMAME] <= solenoids disabled, coil {coil.Id} : {isActive} | {coil.Description}");
+ }
+ }
}
else {
Logger.Warn($"[PinMAME] <= coil UNMAPPED {id}: {isActive}");
@@ -710,10 +1181,19 @@ private void OnSolenoidUpdated(int id, bool isActive)
#region Lamps
- public void SetLamp(string id, float value, bool isCoil = false, LampSource source = LampSource.Lamp)
- {
- OnLampChanged?.Invoke(this, new LampEventArgs(id, value, isCoil, source));
- }
+ public void SetLamp(string id, float value, bool isCoil = false, LampSource source = LampSource.Lamp)
+ {
+ if (int.TryParse(id, out var lampId)) {
+ lock (_outputStateLock) {
+ if (source == LampSource.GI) {
+ _sharedGiStates[lampId] = value;
+ } else {
+ _sharedLampStates[lampId] = value;
+ }
+ }
+ }
+ OnLampChanged?.Invoke(this, new LampEventArgs(id, value, isCoil, source));
+ }
public LampState GetLamp(string id)
{
@@ -727,7 +1207,7 @@ public LampState GetLamp(string id)
public void SendInitialSwitches()
{
var switches = _player.SwitchStatuses;
- Logger.Info("[PinMAME] Sending initial switch statuses...");
+ Logger.Info("[PinMAME] Sending initial switch statuses...");
foreach (var id in switches.Keys) {
var isClosed = switches[id].IsSwitchClosed;
// skip open switches
@@ -779,11 +1259,13 @@ public bool GetSwitch(string id)
#region Mechs
- public void RegisterMech(PinMameMechComponent mechComponent)
- {
- var id = _numMechs++;
- _registeredMechs[id] = mechComponent;
- }
+ public void RegisterMech(PinMameMechComponent mechComponent)
+ {
+ // PinMAME mech numbers are 1-based.
+ var id = ++_numMechs;
+ _registeredMechs[id] = mechComponent;
+ _registeredMechNames[id] = mechComponent ? mechComponent.name : "";
+ }
private void OnMechAvailable(int mechNo, PinMameMechInfo mechInfo)
{
@@ -792,47 +1274,74 @@ private void OnMechAvailable(int mechNo, PinMameMechInfo mechInfo)
private void OnMechUpdated(int mechNo, PinMameMechInfo mechInfo)
{
- if (_registeredMechs.ContainsKey(mechNo)) {
- lock (_dispatchQueue) {
- _dispatchQueue.Enqueue(() => _registeredMechs[mechNo].UpdateMech(mechInfo));
- }
+ if (_registeredMechs.ContainsKey(mechNo)) {
+ EnqueueMainThreadDispatch(() => _registeredMechs[mechNo].UpdateMech(mechInfo));
} else {
Logger.Info($"[PinMAME] <= mech updated: mechNo={mechNo}, mechInfo={mechInfo}");
}
}
- private void SendMechs()
- {
- var max = _pinMame.GetMaxMechs();
- foreach (var (id, mech) in _registeredMechs) {
- if (id > max) {
- Logger.Error($"PinMAME only supports up to {max} custom mechs, ignoring {mech.name}.");
- return;
- }
- var mechConfig = mech.Config(_player.SwitchMapping, _player.CoilMapping, _switchIdToPinMameIdMappings, _coilIdToPinMameIdMapping);
- _pinMame.SetMech(id, mechConfig);
- foreach (var c in mechConfig.SwitchList) {
- _mechSwitches.Add(c.SwNo);
- }
- }
- }
-
- #endregion
-
- private int IsKeyPressed(PinMameKeycode keycode)
- {
- if (keycode == PinMameKeycode.F10) {
- if (_toggleSpeed) {
- _toggleSpeed = false;
-
- _pinMame.SetHandleKeyboard(false);
-
- return 1;
- }
- }
+ private void SendMechs()
+ {
+ var max = _pinMame.GetMaxMechs();
+ foreach (var (id, mech) in _registeredMechs) {
+ var mechName = _registeredMechNames.TryGetValue(id, out var name) ? name : $"mech#{id}";
+ // GetMaxMechs() returns the count of available slots and valid mech numbers are 1..max.
+ if (id > max) {
+ Logger.Error($"PinMAME supports {max} custom mech slot(s). Cannot register mech id={id} ({mechName}).");
+ continue;
+ }
+ var mechConfig = mech.Config(_player.SwitchMapping, _player.CoilMapping, _switchIdToPinMameIdMappings, _coilIdToPinMameIdMapping);
+ _pinMame.SetMech(id, mechConfig);
+ foreach (var c in mechConfig.SwitchList) {
+ _mechSwitches.Add(c.SwNo);
+ }
+ }
+ }
- return 0;
- }
- }
-}
+ #endregion
+
+ private int IsKeyPressed(PinMameKeycode keycode)
+ {
+ if (keycode == PinMameKeycode.F10) {
+ if (_toggleSpeed) {
+ _toggleSpeed = false;
+
+ _pinMame.SetHandleKeyboard(false);
+
+ return 1;
+ }
+ }
+
+ return 0;
+ }
+
+ private bool ShouldDispatchSimulationCoil(string coilId)
+ {
+ return !string.IsNullOrEmpty(coilId)
+ && _player != null
+ && _player.SupportsSimulationThreadCoilDispatch(coilId);
+ }
+
+ private void MarkPinMameCallbackActivity()
+ {
+ Interlocked.Increment(ref _pinMameCallbacksInWindow);
+
+ var nowTicks = Stopwatch.GetTimestamp();
+ var startTicks = Volatile.Read(ref _pinMamePerfWindowStartTicks);
+ var elapsedSeconds = (double)(nowTicks - startTicks) / Stopwatch.Frequency;
+ if (elapsedSeconds < PerfSampleWindowSeconds) {
+ return;
+ }
+
+ if (Interlocked.CompareExchange(ref _pinMamePerfWindowStartTicks, nowTicks, startTicks) != startTicks) {
+ return;
+ }
+
+ var callbacks = Interlocked.Exchange(ref _pinMameCallbacksInWindow, 0);
+ var rate = elapsedSeconds > 0.0 ? callbacks / elapsedSeconds : 0.0;
+ Volatile.Write(ref _pinMameCallbackRateHz, (float)rate);
+ }
+ }
+}
diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameMechComponent.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameMechComponent.cs
index 5f5ef77..0dff722 100644
--- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameMechComponent.cs
+++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameMechComponent.cs
@@ -134,10 +134,10 @@ public PinMameMechConfig Config(List switchMappings, List ReferenceEquals(sm.Device, this) && sm.DeviceItem == mark.SwitchId);
- if (switchMapping == null) {
- Logger.Error($"Switch \"{mark.Name}\" for mech {name} is not mapped in the switch manager, ignoring.");
- continue;
- }
+ if (switchMapping == null) {
+ Logger.Error($"Switch \"{mark.Name}\" for mech {_cachedName} is not mapped in the switch manager, ignoring.");
+ continue;
+ }
switch (mark.Type) {
case MechMarkSwitchType.EnableBetween:
@@ -159,7 +159,8 @@ public PinMameMechConfig Config(List switchMappings, List AvailableCoils {
private PinMameGamelogicEngine _gle;
- private void Awake()
- {
- _gle = GetComponentInParent();
- if (_gle && enabled) {
- _gle.RegisterMech(this);
+ private void Awake()
+ {
+ _cachedName = gameObject.name;
+ _gle = GetComponentInParent();
+ if (_gle && enabled) {
+ _gle.RegisterMech(this);
}
}
diff --git a/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj b/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj
index a4da2fd..041653a 100644
--- a/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj
+++ b/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj
@@ -5,7 +5,7 @@
false
true
9.0
- 1.0.0
+ 1.0.2
3.7.0-beta1
@@ -23,35 +23,35 @@
..\..\VisualPinball.Engine\VisualPinball.Engine\.bin\Release\netstandard2.1\VisualPinball.Engine.dll
-->
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
- ..\VisualPinball.Engine.PinMAME.Unity\Plugins\$(RuntimeIdentifier)\pinmame.dll
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ ..\VisualPinball.Engine.PinMAME.Unity\Plugins\$(RuntimeIdentifier)\pinmame.dll
+
+
+
+
+
+
+
+
+
+
+
+