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