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 + + + + + + + + + + + +