From 26804c88db427415713072a937ba49812e5dc3cb Mon Sep 17 00:00:00 2001 From: dcbenji Date: Sun, 11 Oct 2026 18:42:02 -0700 Subject: [PATCH] gle: add physical outputs option (PWM-integrated lamps, GI, solenoids) PinMAME can run lamps, GI and solenoids through the game driver's device models (#44 lamp bulbs, #89 flasher bulbs, LEDs, two-state solenoids) and report 0-255 levels, which VPX tables get with UseVPMModSol = 2. VPE never turned it on, so lamps were 0/1, GI 0-8 and flashers on/off. Add a "Physical Outputs" flags field (Lamps, GI, Solenoids; default none) to the PinMAME gamelogic engine: - Applied right after ApplyConfig (which resets PinMAME's options) with PinmameSetSolenoidMask(2, mask) and read back. pinmame-dotnet 1.1.0 has no wrapper, so a guarded internal DllImport with the binding's library names is used until it ships one (TODO in the code). - Physical lamp/GI levels are scaled into the range of SingleFading mappings (value * FadingSteps / 255), so the default GI mapping (8 steps) gets the full 0-255 resolution without remapping the table. - The fades of lights driven by physical outputs are turned off for the session, since PinMAME's bulb model already fades them. - Physical solenoids: libpinmame integrates physical outputs on request only (core.c VIDEO_UPDATE(core_gen), "LibPinMAME: on request"). Lamps and GI are integrated by the per-frame GetChangedLamps/GIs poll, but solenoids only arrive through the callback, which compares the last integrated levels, so no coil ever fired. Call PinmameGetChangedSolenoids once per simulation tick (in SetTimeFence) to integrate them, and treat every solenoid value as 0-255 in physical mode. Also clear the GI list before GetChangedGIs: PinMame 1.1.0 appends without clearing, so every GI change since the game started was dispatched again on every frame. Co-Authored-By: Claude Opus 5.5 --- .../Editor/PinMameGamelogicEngineInspector.cs | 7 + .../Runtime/PinMameEnums.cs | 26 +++ .../Runtime/PinMameGamelogicEngine.cs | 176 +++++++++++++++++- 3 files changed, 202 insertions(+), 7 deletions(-) diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGamelogicEngineInspector.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGamelogicEngineInspector.cs index d993b79..9654d19 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGamelogicEngineInspector.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGamelogicEngineInspector.cs @@ -51,6 +51,7 @@ public class PinMameGamelogicEngineInspector : UnityEditor.Editor private bool _toggleDebug = true; private int _debugSwitchId; private SerializedProperty _disableAudioProperty; + private SerializedProperty _physicalOutputsProperty; private CancellationTokenSource _loadCancellation; private bool _isLoading; private string _loadError; @@ -69,6 +70,7 @@ private void OnEnable() _solenoidDelayProperty = serializedObject.FindProperty(nameof(_gle.SolenoidDelay)); _disableMechsProperty = serializedObject.FindProperty(nameof(_gle.DisableMechs)); _disableAudioProperty = serializedObject.FindProperty(nameof(_gle.DisableAudio)); + _physicalOutputsProperty = serializedObject.FindProperty(nameof(_gle.PhysicalOutputs)); Undo.undoRedoPerformed += OnUndoRedo; LoadCatalog(false); } @@ -341,6 +343,11 @@ public override void OnInspectorGUI() if (EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); } + EditorGUI.BeginChangeCheck(); + EditorGUILayout.PropertyField(_physicalOutputsProperty, new GUIContent("Physical Outputs", _physicalOutputsProperty.tooltip)); + if (EditorGUI.EndChangeCheck()) { + serializedObject.ApplyModifiedProperties(); + } EditorGUI.indentLevel--; } EditorGUILayout.EndFoldoutHeaderGroup(); diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameEnums.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameEnums.cs index 2942e4f..364b5cf 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameEnums.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameEnums.cs @@ -72,4 +72,30 @@ public enum PinMameRepeat /// StopAtEnd, } + + /// + /// Outputs PinMAME reports as physical, PWM-integrated levels. The values are PinMAME's + /// CORE_MODOUT_ENABLE_PHYSOUT_* bits, i.e. the mask of PinmameSetSolenoidMask(2, mask). + /// + [System.Flags] + public enum PinMamePhysicalOutputFlags + { + None = 0, + + /// + /// Lamp matrix: 0-255 through the driver's bulb model (e.g. #44 on WPC) instead of 0/1. + /// + Lamps = 0x04, + + /// + /// GI strings: 0-255 through the driver's bulb model instead of 0-8. + /// + GI = 0x08, + + /// + /// Solenoids: 0-255. Flashers get their bulb model (e.g. #89), plain coils PinMAME's + /// two-state model, which releases 60 ms after the last pulse. + /// + Solenoids = 0x10, + } } diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index 75949f7..b4e973b 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -94,6 +94,9 @@ public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayF [Tooltip("Disable audio")] public bool DisableAudio; + [Tooltip("Outputs PinMAME reports as physical, PWM-integrated levels (incandescent bulb, LED and solenoid models), like UseVPMModSol = 2 in VPX. Off by default.")] + public PinMamePhysicalOutputFlags PhysicalOutputs; + #endregion #region Packaging @@ -106,7 +109,8 @@ public byte[] Pack() RomId = romId, DisableMechs = DisableMechs, SolenoidDelay = SolenoidDelay, - DisableAudio = DisableAudio + DisableAudio = DisableAudio, + PhysicalOutputs = PhysicalOutputs, }; return PackageApi.Packer.Pack(data); } @@ -122,6 +126,7 @@ public void Unpack(byte[] bytes) DisableMechs = data.DisableMechs; SolenoidDelay = data.SolenoidDelay; DisableAudio = data.DisableAudio; + PhysicalOutputs = data.PhysicalOutputs; #if UNITY_EDITOR ClearAvailableHardware(); UpdateCaches(false); @@ -264,6 +269,38 @@ public void SetTimeFence(double timeInSeconds) catch (Exception e) { Logger.Warn(e, "[PinMAME] SetTimeFence call failed."); } + + UpdatePhysicalSolenoids(); + } + + /// + /// libpinmame integrates physical outputs on request only (core.c, VIDEO_UPDATE(core_gen): + /// "LibPinMAME: on request"). Lamps and GI get integrated by the per-frame GetChangedLamps/GetChangedGIs + /// poll, but solenoids reach us through the callback only, which compares the last integrated levels. + /// Without this call physical solenoids never change and no coil fires at all. + /// + /// The simulation thread moves the time fence once per tick, so this keeps the added latency below + /// one tick. The changes it returns are dropped: the callback reports the same ones. + /// + private void UpdatePhysicalSolenoids() + { + if (!_isRunning || (_physicalOutputs & PinMamePhysicalOutputFlags.Solenoids) == 0 || _physicalSolenoidStates == null) { + return; + } + // the stop paths also move the fence (from the main thread), possibly during a tick + if (Interlocked.Exchange(ref _updatingPhysicalSolenoids, 1) != 0) { + return; + } + try { + PinMameNative.GetChangedSolenoids(_physicalSolenoidStates); + } + catch (Exception e) { + Logger.Error(e, "[PinMAME] Updating physical solenoids failed, turning them off."); + _physicalOutputs &= ~PinMamePhysicalOutputFlags.Solenoids; + } + finally { + Volatile.Write(ref _updatingPhysicalSolenoids, 0); + } } public bool TryDequeueCoilEvent(out CoilEventArgs coilEvent) @@ -317,7 +354,7 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) _lastAppliedLampStates[lamp.Id] = lamp.Value; foreach (var lampId in lampIds) { - _player.SetLamp(lampId, lamp.Value, LampSource.Lamp); + _player.SetLamp(lampId, PhysicalLampValue(lampId, LampSource.Lamp, lamp.Value), LampSource.Lamp); } } @@ -332,7 +369,7 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) _lastAppliedGiStates[gi.Id] = gi.Value; foreach (var giId in giIds) { - _player.SetLamp(giId, gi.Value, LampSource.GI); + _player.SetLamp(giId, PhysicalLampValue(giId, LampSource.GI, gi.Value), LampSource.GI); } } } @@ -396,6 +433,13 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) // "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; + // physical outputs applied to the running game, see ApplyPhysicalOutputs() + private volatile PinMamePhysicalOutputFlags _physicalOutputs; + private int[] _physicalSolenoidStates; + private int _updatingPhysicalSolenoids; + // FadingSteps of SingleFading lamp/GI mappings, to scale physical 0-255 levels into their range + private readonly Dictionary _physicalLampSteps = new(StringComparer.Ordinal); + private readonly Dictionary _physicalGiSteps = new(StringComparer.Ordinal); private readonly Dictionary _sharedLampStates = new(); private readonly Dictionary _sharedGiStates = new(); private readonly Dictionary _lastAppliedLampStates = new(); @@ -490,7 +534,7 @@ private void Update() if (!_sharedLampPlaybackActive && _pinMameIdToLampIdMapping.TryGetValue(changedLamp.Id, out var vpeLampIds)) { foreach (var vpeLampId in vpeLampIds) { LogChangedLampSample("lamp", changedLamp.Id, vpeLampId, changedLamp.Value, LampSource.Lamp); - OnLampChanged?.Invoke(this, new LampEventArgs(vpeLampId, changedLamp.Value)); + OnLampChanged?.Invoke(this, new LampEventArgs(vpeLampId, PhysicalLampValue(vpeLampId, LampSource.Lamp, changedLamp.Value))); } } else { LogUnmappedLampSample("lamp", changedLamp.Id, changedLamp.Value, LampSource.Lamp); @@ -498,6 +542,9 @@ private void Update() } // gi + // PinMame 1.1.0's GetChangedGIs(List) appends without clearing (GetChangedLamps clears), so every + // GI change since the game started was dispatched again on every frame. + _changedGIs.Clear(); _pinMame.GetChangedGIs(_changedGIs); foreach (var changedGi in _changedGIs) { lock (_outputStateLock) { @@ -506,7 +553,7 @@ private void Update() if (!_sharedLampPlaybackActive && _pinMameIdToGiIdMapping.TryGetValue(changedGi.Id, out var vpeGiIds)) { foreach (var vpeLampId in vpeGiIds) { LogChangedLampSample("gi", changedGi.Id, vpeLampId, changedGi.Value, LampSource.GI); - OnLampChanged?.Invoke(this, new LampEventArgs(vpeLampId, changedGi.Value, LampSource.GI)); + OnLampChanged?.Invoke(this, new LampEventArgs(vpeLampId, PhysicalLampValue(vpeLampId, LampSource.GI, changedGi.Value), LampSource.GI)); } } else { LogUnmappedLampSample("gi", changedGi.Id, changedGi.Value, LampSource.GI); @@ -849,6 +896,8 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag _pinMame = PinMame.PinMame.Instance(PinMameAudioFormat.AudioFormatFloat, AudioSettings.outputSampleRate, vpmPath); // Re-apply config each init (important when Domain Reload is disabled). _pinMame.ApplyConfig(); + // after ApplyConfig, which resets PinMAME's options + ApplyPhysicalOutputs(); // Prevent duplicate subscriptions if OnInit is invoked more than once. _pinMame.OnGameStarted -= OnGameStarted; @@ -935,6 +984,112 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag } } + /// + /// Switches the selected outputs to PinMAME's physical mode (PWM integrated through the game driver's + /// device models: #44 lamps, #89 flashers, LEDs, ...), like UseVPMModSol = 2 in VPX. + /// + private void ApplyPhysicalOutputs() + { + // the inspector's "Everything" is -1, which would also set PinMAME's legacy and internal bits + var requested = PhysicalOutputs & (PinMamePhysicalOutputFlags.Lamps | PinMamePhysicalOutputFlags.GI | PinMamePhysicalOutputFlags.Solenoids); + _physicalOutputs = requested; + if (requested == PinMamePhysicalOutputFlags.None) { + return; + } + try { + PinMameNative.SetSolenoidMask(2, (uint)requested); + _physicalOutputs = (PinMamePhysicalOutputFlags)PinMameNative.GetSolenoidMask(2) & requested; + _physicalSolenoidStates = new int[PinMameNative.GetMaxSolenoids() * 2]; + } + catch (Exception e) when (e is DllNotFoundException or EntryPointNotFoundException) { + Logger.Error(e, "[PinMAME] This PinMAME can't switch to physical outputs, keeping binary outputs."); + _physicalOutputs = PinMamePhysicalOutputFlags.None; + return; + } + Logger.Info($"[PinMAME] Physical outputs: {_physicalOutputs}."); + DisableLightFades(); + } + + /// + /// Physical lamps and GI report 0-255, where 255 is the bulb's rated brightness. Fading mappings divide by + /// their FadingSteps (PinMAME GI defaults to 8 for its native 0-8), so scale into that range: the light + /// gets the bulb's level at full resolution without remapping the table. Other mapping types get 0-255. + /// + private float PhysicalLampValue(string vpeLampId, LampSource source, float value) + { + var physical = source == LampSource.GI ? PinMamePhysicalOutputFlags.GI : PinMamePhysicalOutputFlags.Lamps; + if ((_physicalOutputs & physical) == 0) { + return value; + } + var steps = source == LampSource.GI ? _physicalGiSteps : _physicalLampSteps; + return steps.TryGetValue(vpeLampId, out var fadingSteps) ? value * fadingSteps / 255f : value; + } + + /// + /// PinMAME's bulb model already fades physical outputs. A light's own linear fade on top of it would + /// fade twice, so it's turned off for this session (not saved) on the lights driven by physical outputs. + /// + private void DisableLightFades() + { + var mappingConfig = GetComponentInParent()?.MappingConfig; + if (mappingConfig == null) { + return; + } + var lights = new HashSet(); + foreach (var mapping in mappingConfig.Lamps) { + var physical = mapping.IsCoil + ? PinMamePhysicalOutputFlags.Solenoids + : mapping.Source == LampSource.GI ? PinMamePhysicalOutputFlags.GI : PinMamePhysicalOutputFlags.Lamps; + if ((_physicalOutputs & physical) == 0 || mapping.Device is not Component device || !device) { + continue; + } + foreach (var light in device.GetComponentsInChildren(true)) { + if (light.FadeSpeedUp > 0f || light.FadeSpeedDown > 0f) { + light.FadeSpeedUp = 0f; + light.FadeSpeedDown = 0f; + lights.Add(light); + } + } + } + if (lights.Count > 0) { + Logger.Info($"[PinMAME] Physical outputs: turned off the fades of {lights.Count} lights for this session, PinMAME's bulb model fades them."); + } + } + + /// + /// libpinmame calls that PinMame (pinmame-dotnet) 1.1.0 doesn't expose. + /// TODO: replace with PinMame.SetPhysicalOutputs / GetPhysicalOutputs / GetChangedSolenoids once a + /// pinmame-dotnet release has them (vpinball/pinmame-dotnet, feat/physical-outputs-api). + /// The library names match pinmame-dotnet's, so these bind to the same loaded library. + /// + private static class PinMameNative + { + #if (UNITY_IOS || UNITY_TVOS) && !UNITY_EDITOR + private const string Lib = "__Internal"; + #elif UNITY_EDITOR_WIN || (UNITY_STANDALONE_WIN && !UNITY_EDITOR) + private const string Lib = "pinmame"; + #elif UNITY_EDITOR_OSX || (UNITY_STANDALONE_OSX && !UNITY_EDITOR) + private const string Lib = "libpinmame.dylib"; + #else + private const string Lib = "libpinmame.so"; + #endif + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "PinmameSetSolenoidMask")] + internal static extern void SetSolenoidMask(int low, uint mask); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "PinmameGetSolenoidMask")] + internal static extern uint GetSolenoidMask(int low); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "PinmameGetMaxSolenoids")] + internal static extern int GetMaxSolenoids(); + + /// + /// Fills id/state pairs; with physical solenoids it also integrates them up to now. + /// + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "PinmameGetChangedSolenoids")] + internal static extern int GetChangedSolenoids(int[] changedStates); + } + /// /// Points PinMAME at the ROM folder and brings over Visual PinMAME's state for the game. /// @@ -1017,6 +1172,8 @@ private void UpdateCaches(bool runtime) _requestedLamps = Array.Empty(); _pinMameIdToLampIdMapping.Clear(); _pinMameIdToGiIdMapping.Clear(); + _physicalLampSteps.Clear(); + _physicalGiSteps.Clear(); _coils.Clear(); _requestedCoils = Array.Empty(); @@ -1086,6 +1243,9 @@ private void UpdateCaches(bool runtime) } foreach (var mapping in mappingConfig.Lamps.Where(mapping => !mapping.IsCoil)) { + if (mapping.Type == LampType.SingleFading && mapping.FadingSteps > 0) { + (mapping.Source == LampSource.GI ? _physicalGiSteps : _physicalLampSteps)[mapping.Id] = mapping.FadingSteps; + } var lamp = new GamelogicEngineLamp(mapping.Id) { Description = mapping.Description, Channel = mapping.Channel, @@ -1509,8 +1669,9 @@ 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) { + // value is 0/1 for binary coils, 0-255 for PWM-integrated (modulated) coils and for all coils + // with physical solenoids, where a single 1 is a faint level, not "on" + if (value > 1 || (_physicalOutputs & PinMamePhysicalOutputFlags.Solenoids) != 0) { _solenoidsModulated = true; } var isActive = value > 0; @@ -1751,6 +1912,7 @@ public struct PinMameGamelogicEnginePackable public bool DisableMechs; public float SolenoidDelay; public bool DisableAudio; + public PinMamePhysicalOutputFlags PhysicalOutputs; } #if UNITY_EDITOR