diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index 9ac44ca..8b8a090 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -1,23 +1,23 @@ -// 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 . - -// ReSharper disable CheckNamespace -// ReSharper disable InconsistentNaming -// ReSharper disable PossibleNullReferenceException - +// 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 . + +// ReSharper disable CheckNamespace +// ReSharper disable InconsistentNaming +// ReSharper disable PossibleNullReferenceException + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -27,44 +27,46 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using NLog; -using PinMame; +using NLog; +using PinMame; 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 -{ + +namespace VisualPinball.Engine.PinMAME +{ [Serializable] [DisallowMultipleComponent] [RequireComponent(typeof(AudioSource))] [PackAs("PinMameGamelogicEngine")] [AddComponentMenu("Pinball/Gamelogic Engine/PinMAME")] - public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats, IPackable + public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayFrameFormatPreference, IRomNameProvider, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats, IPackable { public string Name { get; } = "PinMAME Gamelogic Engine"; public GamelogicInputDispatchMode SwitchDispatchMode => GamelogicInputDispatchMode.SimulationThread; - - public const string DmdPrefix = "dmd"; - public const string SegDispPrefix = "display"; - - public PinMameGame Game { get => _game; set => _game = value; } - - #region Configuration - - [HideInInspector] - public string romId = string.Empty; - - [Tooltip("Disable built-in mechs")] - public bool DisableMechs = true; - - [Min(0f)] - [Tooltip("Delay after startup to listen for solenoid events.")] - public float SolenoidDelay; - + + public const string DmdPrefix = "dmd"; + public const string SegDispPrefix = "display"; + + public PinMameGame Game { get => _game; set => _game = value; } + + #region Configuration + + [HideInInspector] + public string romId = string.Empty; + + string IRomNameProvider.RomName => romId; + + [Tooltip("Disable built-in mechs")] + public bool DisableMechs = true; + + [Min(0f)] + [Tooltip("Delay after startup to listen for solenoid events.")] + public float SolenoidDelay; + [Tooltip("Disable audio")] public bool DisableAudio; @@ -158,34 +160,34 @@ private static PinMameGame CreateByTypeName(string gameType) #endregion #region IGamelogicEngine - - public GamelogicEngineSwitch[] RequestedSwitches { - get { - UpdateCaches(); - return _game?.AvailableSwitches ?? Array.Empty(); - } - } - - public GamelogicEngineCoil[] RequestedCoils { - get { - UpdateCaches(); - return _coils.Values.ToArray(); - } - } - - public GamelogicEngineLamp[] RequestedLamps { - get { - UpdateCaches(); - return _lamps.Values.ToArray(); - } - } - - public GamelogicEngineWire[] AvailableWires => _game?.AvailableWires ?? Array.Empty(); - - public event EventHandler OnCoilChanged; - public event EventHandler OnSwitchChanged; - public event EventHandler OnLampChanged; - public event EventHandler OnLampsChanged; + + public GamelogicEngineSwitch[] RequestedSwitches { + get { + UpdateCaches(); + return _game?.AvailableSwitches ?? Array.Empty(); + } + } + + public GamelogicEngineCoil[] RequestedCoils { + get { + UpdateCaches(); + return _coils.Values.ToArray(); + } + } + + public GamelogicEngineLamp[] RequestedLamps { + get { + UpdateCaches(); + return _lamps.Values.ToArray(); + } + } + + public GamelogicEngineWire[] AvailableWires => _game?.AvailableWires ?? Array.Empty(); + + public event EventHandler OnCoilChanged; + public event EventHandler OnSwitchChanged; + public event EventHandler OnLampChanged; + public event EventHandler OnLampsChanged; public event EventHandler OnDisplaysRequested; public event EventHandler OnDisplayClear; public event EventHandler OnDisplayUpdateFrame; @@ -277,33 +279,34 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) } #endregion - - #region Internals - - [NonSerialized] private Player _player; - [NonSerialized] private PinMame.PinMame _pinMame; - [NonSerialized] private BallManager _ballManager; - [NonSerialized] private PlayfieldComponent _playfieldComponent; - - [NonSerialized] private readonly List _changedLamps = new(); - [NonSerialized] private readonly List _changedGIs = new(); - - [SerializeReference] private PinMameGame _game; - - private Dictionary _switches = new(); - private Dictionary _pinMameIdToSwitchIdMappings = new(); - private Dictionary _switchIdToPinMameIdMappings = new(); - - private Dictionary _coils = new(); - private Dictionary _pinMameIdToCoilIdMapping = new(); - private Dictionary _coilIdToPinMameIdMapping = new(); - - private Dictionary _lamps = new(); - private Dictionary _pinMameIdToLampIdMapping = new(); - + + #region Internals + + [NonSerialized] private Player _player; + [NonSerialized] private PinMame.PinMame _pinMame; + [NonSerialized] private BallManager _ballManager; + [NonSerialized] private PlayfieldComponent _playfieldComponent; + + [NonSerialized] private readonly List _changedLamps = new(); + [NonSerialized] private readonly List _changedGIs = new(); + + [SerializeReference] private PinMameGame _game; + + private Dictionary _switches = new(); + private Dictionary _pinMameIdToSwitchIdMappings = new(); + private Dictionary _switchIdToPinMameIdMappings = new(); + + private Dictionary _coils = new(); + private Dictionary _pinMameIdToCoilIdMapping = new(); + private Dictionary _coilIdToPinMameIdMapping = new(); + + private Dictionary _lamps = new(); + private Dictionary _pinMameIdToLampIdMapping = new(); + private volatile bool _isRunning; - private int _numMechs; + private int _numMechs; private Dictionary _frameBuffer = new(); + private volatile DisplayFrameFormat _requestedDmdFrameFormat = DisplayFrameFormat.Dmd8; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly Color Tint = new(1, 0.18f, 0); @@ -326,30 +329,35 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) private int _pinMameCallbacksInWindow; private float _pinMameCallbackRateHz; private readonly Dictionary _sharedCoilStates = new(); + // Solenoid callbacks report 0/1 for binary coils, but 0-255 for PWM-integrated + // (modulated) coils on WPC/SAM ROMs. We can't tell from a single value whether a + // "1" is "binary on" or "modulated 0.4% duty"; once any coil reports a value above + // 1 the game is modulating, and every coil value is then normalized by 255. + private volatile bool _solenoidsModulated; 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; - private const int _maximalQueueSize = 10; - - private double _audioInputStart; - private double _audioOutputStart; - private int _audioNumSamplesInput; - private int _audioNumSamplesOutput; - - public bool _solenoidsEnabled; - public long _solenoidDelayStart; + private PinMameAudioInfo _audioInfo; + private float[] _lastAudioFrame = {}; + private int _lastAudioFrameOffset; + private const int _maximalQueueSize = 10; + + private double _audioInputStart; + private double _audioOutputStart; + private int _audioNumSamplesInput; + private int _audioNumSamplesOutput; + + public bool _solenoidsEnabled; + public long _solenoidDelayStart; private Dictionary _registeredMechs = new(); private Dictionary _registeredMechNames = new(); private HashSet _mechSwitches = new(); - + private bool _toggleSpeed = false; private Keyboard _keyboard; @@ -362,23 +370,23 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) private const int StopTimeoutMs = 10000; #endregion - - #region Lifecycle - + + #region Lifecycle + private void Awake() { Logger.Info("Project audio sample rate: " + AudioSettings.outputSampleRate); _keyboard = Keyboard.current; } - - private void Start() - { - UpdateCaches(); - - _lastAudioFrame = Array.Empty(); - _lastAudioFrameOffset = 0; - } - + + private void Start() + { + UpdateCaches(); + + _lastAudioFrame = Array.Empty(); + _lastAudioFrameOffset = 0; + } + private void Update() { if (_pinMame == null || !_isRunning) { @@ -416,8 +424,8 @@ private void Update() LogUnmappedLampSample("lamp", changedLamp.Id, changedLamp.Value, LampSource.Lamp); } } - - // gi + + // gi _pinMame.GetChangedGIs(_changedGIs); foreach (var changedGi in _changedGIs) { lock (_outputStateLock) { @@ -431,11 +439,11 @@ private void Update() LogUnmappedLampSample("gi", changedGi.Id, changedGi.Value, LampSource.GI); } } - + // if (_keyboard != null && _keyboard.cKey.wasPressedThisFrame) - // { - // OnCoilChanged.Invoke(this, new CoilEventArgs("28", true)); - // OnCoilChanged.Invoke(this, new CoilEventArgs("28", false)); + // { + // OnCoilChanged.Invoke(this, new CoilEventArgs("28", true)); + // OnCoilChanged.Invoke(this, new CoilEventArgs("28", false)); // } } @@ -537,21 +545,22 @@ private void OnDestroy() _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; + + if (!DisableAudio) + { + _pinMame.OnAudioAvailable -= OnAudioAvailable; + _pinMame.OnAudioUpdated -= OnAudioUpdated; + } + + _pinMame.OnMechAvailable -= OnMechAvailable; + _pinMame.OnMechUpdated -= OnMechUpdated; + _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; + _pinMame.IsKeyPressed -= IsKeyPressed; } _frameBuffer.Clear(); lock (_outputStateLock) { _sharedCoilStates.Clear(); + _solenoidsModulated = false; _sharedLampStates.Clear(); _sharedGiStates.Clear(); } @@ -681,7 +690,7 @@ private void RequestStopGame(string reason) } }); } - + public async Task OnInit(Player player, TableApi tableApi, BallManager ballManager, CancellationToken ct) { if (Interlocked.Exchange(ref _onInitCalled, 1) != 0) { @@ -692,29 +701,29 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag string vpmPath = null; _ballManager = ballManager; _playfieldComponent = GetComponentInChildren(); - - #if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR - vpmPath = Path.Combine(Application.persistentDataPath, "pinmame"); - Directory.CreateDirectory(Path.Combine(vpmPath, "roms")); - - byte[] data = null; - - #if UNITY_IOS - data = File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, $"{romId}.zip")); - #else - UnityWebRequest webRequest = new UnityWebRequest(Path.Combine(Application.streamingAssetsPath, $"{romId}.zip")); - - webRequest.downloadHandler = new DownloadHandlerBuffer(); - webRequest.SendWebRequest(); - - while (!webRequest.isDone) { }; - - data = webRequest.downloadHandler.data; - #endif - - File.WriteAllBytes(Path.Combine(vpmPath, "roms", $"{romId}.zip"), data); - #endif - + + #if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR + vpmPath = Path.Combine(Application.persistentDataPath, "pinmame"); + Directory.CreateDirectory(Path.Combine(vpmPath, "roms")); + + byte[] data = null; + + #if UNITY_IOS + data = File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, $"{romId}.zip")); + #else + UnityWebRequest webRequest = new UnityWebRequest(Path.Combine(Application.streamingAssetsPath, $"{romId}.zip")); + + webRequest.downloadHandler = new DownloadHandlerBuffer(); + webRequest.SendWebRequest(); + + while (!webRequest.isDone) { }; + + data = webRequest.downloadHandler.data; + #endif + + File.WriteAllBytes(Path.Combine(vpmPath, "roms", $"{romId}.zip"), data); + #endif + await PinMameStartStopGate.WaitAsync(ct); try { Logger.Info($"[PinMAME] OnInit {name}: romId={romId}, sampleRate={AudioSettings.outputSampleRate}"); @@ -796,6 +805,7 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag _pinMame.SetHandleKeyboard(false); _pinMame.SetHandleMechanics(DisableMechs ? 0 : 0xFF); + ApplyDmdModePreference(); SetTimeFence(0.01); _pinMame.OnGameStarted += OnGameStarted; @@ -841,15 +851,15 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag PinMameStartStopGate.Release(); } } - - public void ToggleSpeed() - { + + public void ToggleSpeed() + { Logger.Info("[PinMAME] Toggle speed."); - - _pinMame.SetHandleKeyboard(true); - _toggleSpeed = true; - } - + + _pinMame.SetHandleKeyboard(true); + _toggleSpeed = true; + } + private void OnGameStarted() { Logger.Info("[PinMAME] Game started."); @@ -857,19 +867,19 @@ private void OnGameStarted() _solenoidDelayStartTimestampUsec = TimestampUsec; _solenoidDelayStart = _solenoidDelayStartTimestampUsec / 1000; - - try { - SendInitialSwitches(); - SendMechs(); - } - - catch(Exception e) { + + try { + SendInitialSwitches(); + SendMechs(); + } + + catch(Exception e) { Logger.Error($"[PinMAME] OnGameStarted: {e.Message}"); - } - + } + EnqueueMainThreadDispatch(() => OnStarted?.Invoke(this, EventArgs.Empty)); - } - + } + private void OnGameEnded() { // Native can report ended more than once during teardown; keep it idempotent. @@ -884,6 +894,7 @@ private void OnGameEnded() } lock (_outputStateLock) { _sharedCoilStates.Clear(); + _solenoidsModulated = false; _sharedLampStates.Clear(); _sharedGiStates.Clear(); } @@ -891,90 +902,108 @@ private void OnGameEnded() _lastAppliedGiStates.Clear(); _sharedLampPlaybackActive = false; } - - private void UpdateCaches() - { - if (_game == null) { - return; - } - - _lamps.Clear(); - _pinMameIdToLampIdMapping.Clear(); - - _coils.Clear(); - _pinMameIdToCoilIdMapping.Clear(); - - _switches.Clear(); - _pinMameIdToSwitchIdMappings.Clear(); - _switchIdToPinMameIdMappings.Clear(); - - // check aliases first (the switches/coils that aren't an integer) - foreach (var alias in _game.AvailableAliases) { - switch (alias.AliasType) { - case AliasType.Switch: - _pinMameIdToSwitchIdMappings[alias.Id] = alias.Name; - _switchIdToPinMameIdMappings[alias.Name] = alias.Id; - break; - - - case AliasType.Coil: - _pinMameIdToCoilIdMapping[alias.Id] = alias.Name; - _coilIdToPinMameIdMapping[alias.Name] = alias.Id; - break; - - case AliasType.Lamp: - _pinMameIdToLampIdMapping[alias.Id] = alias.Name; - break; - } - } - - // retrieve the game's switches - foreach (var @switch in _game.AvailableSwitches) { - _switches[@switch.Id] = @switch; - - if (int.TryParse(@switch.Id, out var pinMameId)) { - _pinMameIdToSwitchIdMappings[pinMameId] = @switch.Id; - _switchIdToPinMameIdMappings[@switch.Id] = pinMameId; - - // add mappings with prefixed 0. - if (pinMameId < 10) { - _switchIdToPinMameIdMappings["0" + @switch.Id] = pinMameId; - _switchIdToPinMameIdMappings["00" + @switch.Id] = pinMameId; - - _switches["0" + @switch.Id] = @switch; - _switches["00" + @switch.Id] = @switch; - } - if (pinMameId < 100) { - _switchIdToPinMameIdMappings["0" + @switch.Id] = pinMameId; - _switches["0" + @switch.Id] = @switch; - } - } - } - - // retrieve the game's coils - foreach (var coil in _game.AvailableCoils) { - _coils[coil.Id] = coil; - - if (int.TryParse(coil.Id, out int pinMameId)) { - _pinMameIdToCoilIdMapping[pinMameId] = coil.Id; - _coilIdToPinMameIdMapping[coil.Id] = pinMameId; - } - } - - // retrieve the game's lamps - foreach (var lamp in _game.AvailableLamps) { - _lamps[lamp.Id] = lamp; - - if (int.TryParse(lamp.Id, out int pinMameId)) { - _pinMameIdToLampIdMapping[pinMameId] = lamp.Id; - } - } - } - - #endregion - - #region Displays - + + private void UpdateCaches() + { + if (_game == null) { + return; + } + + _lamps.Clear(); + _pinMameIdToLampIdMapping.Clear(); + + _coils.Clear(); + _pinMameIdToCoilIdMapping.Clear(); + + _switches.Clear(); + _pinMameIdToSwitchIdMappings.Clear(); + _switchIdToPinMameIdMappings.Clear(); + + // check aliases first (the switches/coils that aren't an integer) + foreach (var alias in _game.AvailableAliases) { + switch (alias.AliasType) { + case AliasType.Switch: + _pinMameIdToSwitchIdMappings[alias.Id] = alias.Name; + _switchIdToPinMameIdMappings[alias.Name] = alias.Id; + break; + + + case AliasType.Coil: + _pinMameIdToCoilIdMapping[alias.Id] = alias.Name; + _coilIdToPinMameIdMapping[alias.Name] = alias.Id; + break; + + case AliasType.Lamp: + _pinMameIdToLampIdMapping[alias.Id] = alias.Name; + break; + } + } + + // retrieve the game's switches + foreach (var @switch in _game.AvailableSwitches) { + _switches[@switch.Id] = @switch; + + if (int.TryParse(@switch.Id, out var pinMameId)) { + _pinMameIdToSwitchIdMappings[pinMameId] = @switch.Id; + _switchIdToPinMameIdMappings[@switch.Id] = pinMameId; + + // add mappings with prefixed 0. + if (pinMameId < 10) { + _switchIdToPinMameIdMappings["0" + @switch.Id] = pinMameId; + _switchIdToPinMameIdMappings["00" + @switch.Id] = pinMameId; + + _switches["0" + @switch.Id] = @switch; + _switches["00" + @switch.Id] = @switch; + } + if (pinMameId < 100) { + _switchIdToPinMameIdMappings["0" + @switch.Id] = pinMameId; + _switches["0" + @switch.Id] = @switch; + } + } + } + + // retrieve the game's coils + foreach (var coil in _game.AvailableCoils) { + _coils[coil.Id] = coil; + + if (int.TryParse(coil.Id, out int pinMameId)) { + _pinMameIdToCoilIdMapping[pinMameId] = coil.Id; + _coilIdToPinMameIdMapping[coil.Id] = pinMameId; + } + } + + // retrieve the game's lamps + foreach (var lamp in _game.AvailableLamps) { + _lamps[lamp.Id] = lamp; + + if (int.TryParse(lamp.Id, out int pinMameId)) { + _pinMameIdToLampIdMapping[pinMameId] = lamp.Id; + } + } + } + + #endregion + + #region Displays + + public void RequestDisplayFrameFormat(string displayId, DisplayFrameFormat format) + { + if (!string.IsNullOrWhiteSpace(displayId) && !displayId.StartsWith(DmdPrefix, StringComparison.OrdinalIgnoreCase)) { + return; + } + + if (format != DisplayFrameFormat.Dmd2 && format != DisplayFrameFormat.Dmd4 && format != DisplayFrameFormat.Dmd8) { + return; + } + + if (_requestedDmdFrameFormat == format) { + return; + } + + _requestedDmdFrameFormat = format; + ApplyDmdModePreference(); + } + private void OnDisplayRequested(int index, int displayCount, PinMameDisplayLayout displayLayout) { if (displayLayout.IsDmd) { @@ -997,7 +1026,7 @@ private void OnDisplayRequested(int index, int displayCount, PinMameDisplayLayou Logger.Info($"[PinMAME] Display {SegDispPrefix}{index} is of type {displayLayout.Type} at {displayLayout.Length} wide."); } } - + private void OnDisplayUpdated(int index, IntPtr framePtr, PinMameDisplayLayout displayLayout) { MarkPinMameCallbackActivity(); @@ -1006,12 +1035,17 @@ private void OnDisplayUpdated(int index, IntPtr framePtr, PinMameDisplayLayout d UpdateDmd(index, displayLayout, framePtr); } else { - UpdateSegDisp(index, displayLayout, framePtr); - } - } - + UpdateSegDisp(index, displayLayout, framePtr); + } + } + private void UpdateDmd(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr) { + if (framePtr == IntPtr.Zero) { + Logger.Warn($"[PinMAME] Dropping DMD frame for index {index} because frame pointer is null (layout: {displayLayout})."); + return; + } + byte[] frameBuffer; lock (_displayStateLock) { if (!_frameBuffer.TryGetValue(index, out frameBuffer)) { @@ -1020,14 +1054,23 @@ private void UpdateDmd(int index, PinMameDisplayLayout displayLayout, IntPtr fra } } - Marshal.Copy(framePtr, frameBuffer, 0, frameBuffer.Length); + // Snapshot into a fresh array: the native PinMAME thread can fire UpdateDmd again before + // the queued dispatch runs on the main thread, so the frame handed to subscribers must not + // be the reused _frameBuffer (otherwise it can be overwritten / torn mid-read). + var frame = new byte[frameBuffer.Length]; + Marshal.Copy(framePtr, frame, 0, frame.Length); EnqueueMainThreadDispatch(() => OnDisplayUpdateFrame?.Invoke(this, - new DisplayFrameData($"{DmdPrefix}{index}", GetDisplayFrameFormat(displayLayout), frameBuffer))); + new DisplayFrameData($"{DmdPrefix}{index}", GetRequestedDmdDisplayFrameFormat(displayLayout), frame))); } private void UpdateSegDisp(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr) { + if (framePtr == IntPtr.Zero) { + Logger.Warn($"[PinMAME] Dropping segmented display frame for index {index} because frame pointer is null (layout: {displayLayout})."); + return; + } + byte[] frameBuffer; lock (_displayStateLock) { if (!_frameBuffer.TryGetValue(index, out frameBuffer)) { @@ -1036,196 +1079,236 @@ private void UpdateSegDisp(int index, PinMameDisplayLayout displayLayout, IntPtr } } - Marshal.Copy(framePtr, frameBuffer, 0, displayLayout.Length * 2); + // Snapshot into a fresh array (see UpdateDmd) so a follow-up native callback can't + // overwrite a frame already queued for the main thread. + var frame = new byte[frameBuffer.Length]; + Marshal.Copy(framePtr, frame, 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))); + new DisplayFrameData($"{SegDispPrefix}{index}", GetDisplayFrameFormat(displayLayout), frame))); } - + public static DisplayFrameFormat GetDisplayFrameFormat(PinMameDisplayLayout layout) { if (layout.IsDmd) { return DisplayFrameFormat.Dmd8; } - - switch (layout.Type) { - case PinMameDisplayType.Seg8: // 7 segments and comma - case PinMameDisplayType.Seg7SC: // 7 segments, small, with comma - case PinMameDisplayType.Seg8D: // 7 segments and period - case PinMameDisplayType.Seg7: // 7 segments - case PinMameDisplayType.Seg7S: // 7 segments, small - case PinMameDisplayType.Seg87: // 7 segments, comma every three - case PinMameDisplayType.Seg87F: // 7 segments, forced comma every three - case PinMameDisplayType.Seg10: // 9 segments and comma - case PinMameDisplayType.Seg9: // 9 segments - case PinMameDisplayType.Seg98: // 9 segments, comma every three - case PinMameDisplayType.Seg98F: // 9 segments, forced comma every three - case PinMameDisplayType.Seg16: // 16 segments - case PinMameDisplayType.Seg16R: // 16 segments with comma and period reversed - case PinMameDisplayType.Seg16N: // 16 segments without commas - case PinMameDisplayType.Seg16D: // 16 segments with periods only - case PinMameDisplayType.Seg16S: // 16 segments with split top and bottom line - case PinMameDisplayType.Seg8H: - case PinMameDisplayType.Seg7H: - case PinMameDisplayType.Seg87H: - case PinMameDisplayType.Seg87FH: - case PinMameDisplayType.Seg7SH: - case PinMameDisplayType.Seg7SCH: - case PinMameDisplayType.Seg7 | PinMameDisplayType.NoDisp: - return DisplayFrameFormat.Segment; - - case PinMameDisplayType.Video: - break; - - case PinMameDisplayType.SegAll: - case PinMameDisplayType.Import: - case PinMameDisplayType.SegMask: - case PinMameDisplayType.SegHiBit: - case PinMameDisplayType.SegRev: - case PinMameDisplayType.DmdNoAA: - case PinMameDisplayType.NoDisp: - throw new ArgumentOutOfRangeException(nameof(layout), layout, null); - - default: - throw new ArgumentOutOfRangeException(nameof(layout), layout, null); - } - - throw new NotImplementedException($"Still unsupported segmented display format: {layout}."); - } - - public void DisplayChanged(DisplayFrameData displayFrameData) - { - } - - #endregion - - #region Audio - - private int OnAudioAvailable(PinMameAudioInfo audioInfo) - { - Logger.Info("Game audio available: " + audioInfo); - - _audioInfo = audioInfo; - return _audioInfo.SamplesPerFrame; - } - + + switch (layout.Type) { + case PinMameDisplayType.Seg8: // 7 segments and comma + case PinMameDisplayType.Seg7SC: // 7 segments, small, with comma + case PinMameDisplayType.Seg8D: // 7 segments and period + case PinMameDisplayType.Seg7: // 7 segments + case PinMameDisplayType.Seg7S: // 7 segments, small + case PinMameDisplayType.Seg87: // 7 segments, comma every three + case PinMameDisplayType.Seg87F: // 7 segments, forced comma every three + case PinMameDisplayType.Seg10: // 9 segments and comma + case PinMameDisplayType.Seg9: // 9 segments + case PinMameDisplayType.Seg98: // 9 segments, comma every three + case PinMameDisplayType.Seg98F: // 9 segments, forced comma every three + case PinMameDisplayType.Seg16: // 16 segments + case PinMameDisplayType.Seg16R: // 16 segments with comma and period reversed + case PinMameDisplayType.Seg16N: // 16 segments without commas + case PinMameDisplayType.Seg16D: // 16 segments with periods only + case PinMameDisplayType.Seg16S: // 16 segments with split top and bottom line + case PinMameDisplayType.Seg8H: + case PinMameDisplayType.Seg7H: + case PinMameDisplayType.Seg87H: + case PinMameDisplayType.Seg87FH: + case PinMameDisplayType.Seg7SH: + case PinMameDisplayType.Seg7SCH: + case PinMameDisplayType.Seg7 | PinMameDisplayType.NoDisp: + return DisplayFrameFormat.Segment; + + case PinMameDisplayType.Video: + break; + + case PinMameDisplayType.SegAll: + case PinMameDisplayType.Import: + case PinMameDisplayType.SegMask: + case PinMameDisplayType.SegHiBit: + case PinMameDisplayType.SegRev: + case PinMameDisplayType.DmdNoAA: + case PinMameDisplayType.NoDisp: + throw new ArgumentOutOfRangeException(nameof(layout), layout, null); + + default: + throw new ArgumentOutOfRangeException(nameof(layout), layout, null); + } + + throw new NotImplementedException($"Still unsupported segmented display format: {layout}."); + } + + private DisplayFrameFormat GetRequestedDmdDisplayFrameFormat(PinMameDisplayLayout layout) + { + if (_requestedDmdFrameFormat == DisplayFrameFormat.Dmd8) { + return DisplayFrameFormat.Dmd8; + } + + return GetRawDmdDisplayFrameFormat(layout); + } + + private static DisplayFrameFormat GetRawDmdDisplayFrameFormat(PinMameDisplayLayout layout) + { + var levels = layout.Levels?.Count ?? 0; + if ((levels > 0 && levels <= 4) || (layout.Depth > 0 && layout.Depth <= 2)) { + return DisplayFrameFormat.Dmd2; + } + + if ((levels > 0 && levels <= 16) || (layout.Depth > 0 && layout.Depth <= 4)) { + return DisplayFrameFormat.Dmd4; + } + + return DisplayFrameFormat.Dmd8; + } + + private void ApplyDmdModePreference() + { + if (_pinMame == null) { + return; + } + + var rawMode = _requestedDmdFrameFormat == DisplayFrameFormat.Dmd2 || _requestedDmdFrameFormat == DisplayFrameFormat.Dmd4; + try { + _pinMame.SetDmdMode(rawMode ? PinMameDmdMode.Raw : PinMameDmdMode.Brightness); + Logger.Info($"[PinMAME] DMD frame mode set to {(rawMode ? "raw" : "brightness")} for {_requestedDmdFrameFormat} output."); + } catch (Exception exception) { + Logger.Warn(exception, "[PinMAME] Could not set DMD frame mode."); + } + } + + public void DisplayChanged(DisplayFrameData displayFrameData) + { + } + + #endregion + + #region Audio + + private int OnAudioAvailable(PinMameAudioInfo audioInfo) + { + Logger.Info("Game audio available: " + audioInfo); + + _audioInfo = audioInfo; + 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; - if (_audioNumSamplesInput > 100000) { - // var delta = AudioSettings.dspTime - _audioInputStart; - // var queueMs = System.Math.Round(_audioQueue.Count * (double)_audioInfo.SamplesPerFrame / _audioInfo.SampleRate * 1000); - // Logger.Info($"INPUT: {System.Math.Round(_audioNumSamplesInput / delta)} - {_audioQueue.Count} in queue ({queueMs}ms)"); - _audioInputStart = AudioSettings.dspTime; - _audioNumSamplesInput = 0; - } - - float[] frame; - if (_audioFilterChannels == _audioInfo.Channels) { // n channels -> n channels - frame = new float[frameSize * 2]; - unsafe { - var src = (float*)framePtr; - for (var i = 0; i < frameSize * 2; i++) { - frame[i] = src[i]; - } - } - } else if (_audioFilterChannels > _audioInfo.Channels) { // 1 channel -> 2 channels - frame = new float[frameSize * 2]; - unsafe { - var src = (float*)framePtr; - for (var i = 0; i < frameSize; i++) { - frame[i * 2] = src[i]; - frame[i * 2 + 1] = frame[i * 2]; - } - } - } else { // 2 channels -> 1 channel - frame = new float[frameSize / 2]; - unsafe { - var src = (float*)framePtr; - for (var i = 0; i < frameSize; i += 2) { - frame[i] = src[i]; - } - } - } - - lock (_audioQueue) { - if (_audioQueue.Count >= _maximalQueueSize) { - _audioQueue.Clear(); - Logger.Error("Clearing full audio frame queue."); - } - _audioQueue.Enqueue(frame); - } - - return _audioInfo.SamplesPerFrame; - } - - private void OnAudioFilterRead(float[] data, int channels) - { - _audioNumSamplesOutput += data.Length / channels; - if (_audioNumSamplesOutput > 100000) { - //var delta = AudioSettings.dspTime - _audioOutputStart; - //Logger.Info($"OUTPUT: {System.Math.Round(_audioNumSamplesOutput / delta)}"); - _audioOutputStart = AudioSettings.dspTime; - _audioNumSamplesOutput = 0; - } - - if (_audioFilterChannels == 0) { - _audioFilterChannels = channels; - Logger.Info($"Creating audio on {channels} channels."); - return; - } - - if (_audioQueue.Count == 0) { - if (!DisableAudio) { - Logger.Info("Filtering audio but nothing to de-queue."); - } - return; - } - - - const int size = sizeof(float); - var dataOffset = 0; - var lastFrameSize = _lastAudioFrame.Length - _lastAudioFrameOffset; - if (data.Length >= lastFrameSize) { - if (lastFrameSize > 0) { - Buffer.BlockCopy(_lastAudioFrame, _lastAudioFrameOffset * size, data, 0, lastFrameSize * size); - dataOffset += lastFrameSize; - } - _lastAudioFrame = Array.Empty(); - _lastAudioFrameOffset = 0; - - lock (_audioQueue) { - while (dataOffset < data.Length && _audioQueue.Count > 0) { - var frame = _audioQueue.Dequeue(); - if (frame.Length <= data.Length - dataOffset) { - Buffer.BlockCopy(frame, 0, data, dataOffset * size, frame.Length * size); - dataOffset += frame.Length; - - } else { - Buffer.BlockCopy(frame, 0, data, dataOffset * size, (data.Length - dataOffset) * size); - _lastAudioFrame = frame; - _lastAudioFrameOffset = data.Length - dataOffset; - dataOffset = data.Length; - } - } - } - - } else { - Buffer.BlockCopy(_lastAudioFrame, _lastAudioFrameOffset * size, data, 0, data.Length * size); - _lastAudioFrameOffset += data.Length; - } - } - - #endregion - - #region Coils - + } + + _audioNumSamplesInput += frameSize; + if (_audioNumSamplesInput > 100000) { + // var delta = AudioSettings.dspTime - _audioInputStart; + // var queueMs = System.Math.Round(_audioQueue.Count * (double)_audioInfo.SamplesPerFrame / _audioInfo.SampleRate * 1000); + // Logger.Info($"INPUT: {System.Math.Round(_audioNumSamplesInput / delta)} - {_audioQueue.Count} in queue ({queueMs}ms)"); + _audioInputStart = AudioSettings.dspTime; + _audioNumSamplesInput = 0; + } + + float[] frame; + if (_audioFilterChannels == _audioInfo.Channels) { // n channels -> n channels + frame = new float[frameSize * 2]; + unsafe { + var src = (float*)framePtr; + for (var i = 0; i < frameSize * 2; i++) { + frame[i] = src[i]; + } + } + } else if (_audioFilterChannels > _audioInfo.Channels) { // 1 channel -> 2 channels + frame = new float[frameSize * 2]; + unsafe { + var src = (float*)framePtr; + for (var i = 0; i < frameSize; i++) { + frame[i * 2] = src[i]; + frame[i * 2 + 1] = frame[i * 2]; + } + } + } else { // 2 channels -> 1 channel + frame = new float[frameSize / 2]; + unsafe { + var src = (float*)framePtr; + for (var i = 0; i < frameSize; i += 2) { + frame[i] = src[i]; + } + } + } + + lock (_audioQueue) { + if (_audioQueue.Count >= _maximalQueueSize) { + _audioQueue.Clear(); + Logger.Error("Clearing full audio frame queue."); + } + _audioQueue.Enqueue(frame); + } + + return _audioInfo.SamplesPerFrame; + } + + private void OnAudioFilterRead(float[] data, int channels) + { + _audioNumSamplesOutput += data.Length / channels; + if (_audioNumSamplesOutput > 100000) { + //var delta = AudioSettings.dspTime - _audioOutputStart; + //Logger.Info($"OUTPUT: {System.Math.Round(_audioNumSamplesOutput / delta)}"); + _audioOutputStart = AudioSettings.dspTime; + _audioNumSamplesOutput = 0; + } + + if (_audioFilterChannels == 0) { + _audioFilterChannels = channels; + Logger.Info($"Creating audio on {channels} channels."); + return; + } + + if (_audioQueue.Count == 0) { + if (!DisableAudio) { + Logger.Info("Filtering audio but nothing to de-queue."); + } + return; + } + + + const int size = sizeof(float); + var dataOffset = 0; + var lastFrameSize = _lastAudioFrame.Length - _lastAudioFrameOffset; + if (data.Length >= lastFrameSize) { + if (lastFrameSize > 0) { + Buffer.BlockCopy(_lastAudioFrame, _lastAudioFrameOffset * size, data, 0, lastFrameSize * size); + dataOffset += lastFrameSize; + } + _lastAudioFrame = Array.Empty(); + _lastAudioFrameOffset = 0; + + lock (_audioQueue) { + while (dataOffset < data.Length && _audioQueue.Count > 0) { + var frame = _audioQueue.Dequeue(); + if (frame.Length <= data.Length - dataOffset) { + Buffer.BlockCopy(frame, 0, data, dataOffset * size, frame.Length * size); + dataOffset += frame.Length; + + } else { + Buffer.BlockCopy(frame, 0, data, dataOffset * size, (data.Length - dataOffset) * size); + _lastAudioFrame = frame; + _lastAudioFrameOffset = data.Length - dataOffset; + dataOffset = data.Length; + } + } + } + + } else { + Buffer.BlockCopy(_lastAudioFrame, _lastAudioFrameOffset * size, data, 0, data.Length * size); + _lastAudioFrameOffset += data.Length; + } + } + + #endregion + + #region Coils + public void SetCoil(string n, bool value) { if (int.TryParse(n, out var coilId)) { @@ -1235,16 +1318,25 @@ public void SetCoil(string n, bool 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) + + public bool GetCoil(string id) + { + return _player != null && _player.CoilStatuses.ContainsKey(id) && _player.CoilStatuses[id]; + } + + private void OnSolenoidUpdated(int id, int value) { MarkPinMameCallbackActivity(); + // value is 0/1 for binary coils, 0-255 for PWM-integrated (modulated) coils + if (value > 1) { + _solenoidsModulated = true; + } + var isActive = value > 0; + // normalized strength for the (main-thread) coil pipeline; the sim-thread path + // and the shared snapshot stay boolean on/off + var intensity = _solenoidsModulated ? System.Math.Min(value, 255) / 255f : (isActive ? 1f : 0f); + if (_pinMameIdToCoilIdMapping.ContainsKey(id)) { if (!_solenoidsEnabled) { _solenoidsEnabled = TimestampUsec - _solenoidDelayStartTimestampUsec >= (long)(SolenoidDelay * 1000f); @@ -1253,7 +1345,7 @@ private void OnSolenoidUpdated(int id, bool isActive) Logger.Info($"Solenoids enabled, {SolenoidDelay}ms passed"); } } - + var coil = _coils[_pinMameIdToCoilIdMapping[id]]; if (_solenoidsEnabled) { @@ -1262,7 +1354,7 @@ private void OnSolenoidUpdated(int id, bool isActive) } if (Logger.IsDebugEnabled) { - Logger.Debug($"[PinMAME] <= coil {coil.Id} : {isActive} | {coil.Description}"); + Logger.Debug($"[PinMAME] <= coil {coil.Id} : {value} | {coil.Description}"); } if (ShouldDispatchSimulationCoil(coil.Id)) { _simulationCoilDispatchQueue.Enqueue(new CoilEventArgs(coil.Id, isActive)); @@ -1284,23 +1376,23 @@ private void OnSolenoidUpdated(int id, bool isActive) } } - EnqueueMainThreadDispatch(() => OnCoilChanged?.Invoke(this, new CoilEventArgs(coil.Id, isActive))); + EnqueueMainThreadDispatch(() => OnCoilChanged?.Invoke(this, new CoilEventArgs(coil.Id, intensity))); } else { if (Logger.IsDebugEnabled) { - Logger.Debug($"[PinMAME] <= solenoids disabled, coil {coil.Id} : {isActive} | {coil.Description}"); + Logger.Debug($"[PinMAME] <= solenoids disabled, coil {coil.Id} : {value} | {coil.Description}"); } } - } - else { - Logger.Warn($"[PinMAME] <= coil UNMAPPED {id}: {isActive}"); - } - } - - #endregion - - #region Lamps - + } + else { + Logger.Warn($"[PinMAME] <= coil UNMAPPED {id}: {isActive}"); + } + } + + #endregion + + #region Lamps + public void SetLamp(string id, float value, bool isCoil = false, LampSource source = LampSource.Lamp) { if (int.TryParse(id, out var lampId)) { @@ -1314,34 +1406,34 @@ public void SetLamp(string id, float value, bool isCoil = false, LampSource sour } OnLampChanged?.Invoke(this, new LampEventArgs(id, value, isCoil, source)); } - - public LampState GetLamp(string id) - { - return _player != null && _player.LampStatuses.ContainsKey(id) ? _player.LampStatuses[id] : LampState.Default; - } - - #endregion - - #region Switches - - public void SendInitialSwitches() - { - var switches = _player.SwitchStatuses; + + public LampState GetLamp(string id) + { + return _player != null && _player.LampStatuses.ContainsKey(id) ? _player.LampStatuses[id] : LampState.Default; + } + + #endregion + + #region Switches + + public void SendInitialSwitches() + { + var switches = _player.SwitchStatuses; Logger.Info("[PinMAME] Sending initial switch statuses..."); - foreach (var id in switches.Keys) { - var isClosed = switches[id].IsSwitchClosed; - // skip open switches - if (!isClosed) { - continue; - } - if (_switches.ContainsKey(id) && !_mechSwitches.Contains(_switchIdToPinMameIdMappings[_switches[id].Id])) { - Logger.Info($"[PinMAME] => sw {id} ({_switches[id].Id}): {true} | {_switches[id].Description}"); - - _pinMame.SetSwitch(_switchIdToPinMameIdMappings[_switches[id].Id], true); - } - } - } - + foreach (var id in switches.Keys) { + var isClosed = switches[id].IsSwitchClosed; + // skip open switches + if (!isClosed) { + continue; + } + if (_switches.ContainsKey(id) && !_mechSwitches.Contains(_switchIdToPinMameIdMappings[_switches[id].Id])) { + Logger.Info($"[PinMAME] => sw {id} ({_switches[id].Id}): {true} | {_switches[id].Description}"); + + _pinMame.SetSwitch(_switchIdToPinMameIdMappings[_switches[id].Id], true); + } + } + } + public void Switch(string id, bool isClosed) { if (_switches.TryGetValue(id, out var sw)) { @@ -1369,16 +1461,16 @@ public void Switch(string id, bool isClosed) OnSwitchChanged?.Invoke(this, new SwitchEventArgs2(id, isClosed)); } - - public bool GetSwitch(string id) - { - return _player != null && _player.SwitchStatuses.ContainsKey(id) && _player.SwitchStatuses[id].IsSwitchEnabled; - } - - #endregion - - #region Mechs - + + public bool GetSwitch(string id) + { + return _player != null && _player.SwitchStatuses.ContainsKey(id) && _player.SwitchStatuses[id].IsSwitchEnabled; + } + + #endregion + + #region Mechs + public void RegisterMech(PinMameMechComponent mechComponent) { // PinMAME mech numbers are 1-based. @@ -1386,22 +1478,22 @@ public void RegisterMech(PinMameMechComponent mechComponent) _registeredMechs[id] = mechComponent; _registeredMechNames[id] = mechComponent ? mechComponent.name : ""; } - - private void OnMechAvailable(int mechNo, PinMameMechInfo mechInfo) - { - Logger.Info($"[PinMAME] <= mech available: mechNo={mechNo}, mechInfo={mechInfo}"); - } - - private void OnMechUpdated(int mechNo, PinMameMechInfo mechInfo) - { + + private void OnMechAvailable(int mechNo, PinMameMechInfo mechInfo) + { + Logger.Info($"[PinMAME] <= mech available: mechNo={mechNo}, mechInfo={mechInfo}"); + } + + private void OnMechUpdated(int mechNo, PinMameMechInfo mechInfo) + { if (_registeredMechs.ContainsKey(mechNo)) { EnqueueMainThreadDispatch(() => _registeredMechs[mechNo].UpdateMech(mechInfo)); - - } else { - Logger.Info($"[PinMAME] <= mech updated: mechNo={mechNo}, mechInfo={mechInfo}"); - } - } - + + } else { + Logger.Info($"[PinMAME] <= mech updated: mechNo={mechNo}, mechInfo={mechInfo}"); + } + } + private void SendMechs() { var max = _pinMame.GetMaxMechs(); @@ -1419,7 +1511,7 @@ private void SendMechs() } } } - + #endregion private int IsKeyPressed(PinMameKeycode keycode) diff --git a/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj b/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj index 041653a..c1c22b9 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.2 + 1.0.4 3.7.0-beta1