From f9c925fb58c6721bd0f6d96d232db6391d58be85 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 13 Oct 2026 18:04:35 +0200 Subject: [PATCH 1/7] pinmame: expose game identity and original display and audio resources --- ...Pinball.Engine.PinMAME.Unity.Editor.asmdef | 1 + .../Runtime/PinMameGamelogicEngine.cs | 35 ++++++++++++++++--- .../Runtime/PinMamePackageIdentityReader.cs | 23 ++++++++++++ .../PinMamePackageIdentityReader.cs.meta | 2 ++ .../VisualPinball.Engine.PinMAME.Unity.asmdef | 2 ++ .../package.json | 4 +-- 6 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs create mode 100644 VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs.meta diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/VisualPinball.Engine.PinMAME.Unity.Editor.asmdef b/VisualPinball.Engine.PinMAME.Unity/Editor/VisualPinball.Engine.PinMAME.Unity.Editor.asmdef index b54b019..cedb9bd 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Editor/VisualPinball.Engine.PinMAME.Unity.Editor.asmdef +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/VisualPinball.Engine.PinMAME.Unity.Editor.asmdef @@ -4,6 +4,7 @@ "references": [ "VisualPinball.Engine", "VisualPinball.Unity", + "VisualPinball.Plugins", "VisualPinball.Unity.Editor", "VisualPinball.Engine.PinMAME.Unity", "Newtonsoft.Json" diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index 75949f7..d60dc1d 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -1,4 +1,4 @@ -// Visual Pinball Engine +// Visual Pinball Engine // Copyright (C) 2021 freezy and VPE Team // // This program is free software: you can redistribute it and/or modify @@ -34,6 +34,7 @@ using VisualPinball.Engine.Game.Engines; using VisualPinball.Unity; using VisualPinball.Unity.Simulation; +using VisualPinball.Plugins; using ColorChannel = VisualPinball.Engine.Math.ColorChannel; using Logger = NLog.Logger; @@ -44,7 +45,7 @@ namespace VisualPinball.Engine.PinMAME [RequireComponent(typeof(AudioSource))] [PackAs("PinMameGamelogicEngine")] [AddComponentMenu("Pinball/Gamelogic Engine/PinMAME")] - public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayFrameFormatPreference, IRomNameProvider, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats, IPackable + public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayFrameFormatPreference, IDisplayResourceMetadata, IGamelogicAudioResourceProvider, IGameIdentityProvider, IRomNameProvider, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats, IPackable { public string Name { get; } = "PinMAME Gamelogic Engine"; public GamelogicInputDispatchMode SwitchDispatchMode => GamelogicInputDispatchMode.SimulationThread; @@ -83,6 +84,11 @@ public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayF private string _legacyGameId; string IRomNameProvider.RomName => romId; + GameIdentity? IGameIdentityProvider.GameIdentity => string.IsNullOrWhiteSpace(romId) ? null : new GameIdentity("pinmame", romId); + DisplayEncoding IDisplayResourceMetadata.DisplayFormats => DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8; + DisplayEncoding IDisplayResourceMetadata.DefaultDisplayFormat => DisplayEncoding.Luminance8; + bool IDisplayResourceMetadata.FormatAppliesToAllDisplays => true; + IPluginAudioSource IGamelogicAudioResourceProvider.AudioSource => _pluginAudio; [Tooltip("Disable built-in mechs")] public bool DisableMechs = true; @@ -402,6 +408,9 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) private readonly Dictionary _lastAppliedGiStates = new(); private bool _sharedLampPlaybackActive; private readonly Queue _audioQueue = new(); + private readonly AudioResourceSource _pluginAudio = new(error => Logger.Warn(error, "[PinMAME] Plugin audio subscriber failed.")); + private byte[] _pluginAudioBuffer = Array.Empty(); + private long _pluginAudioSequence; private readonly HashSet _loggedUnresolvedMappingIds = new(StringComparer.Ordinal); private int _audioFilterChannels; @@ -896,7 +905,7 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag _pinMame.OnDisplayAvailable += OnDisplayRequested; _pinMame.OnDisplayUpdated += OnDisplayUpdated; - if (!DisableAudio) { + if (!DisableAudio || _pluginAudio.HasSubscribers) { _pinMame.OnAudioAvailable += OnAudioAvailable; _pinMame.OnAudioUpdated += OnAudioUpdated; } @@ -1222,6 +1231,7 @@ private void OnDisplayUpdated(int index, IntPtr framePtr, PinMameDisplayLayout d private void UpdateDmd(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr) { + var format = GetRequestedDmdDisplayFrameFormat(displayLayout); if (framePtr == IntPtr.Zero) { Logger.Warn($"[PinMAME] Dropping DMD frame for index {index} because frame pointer is null (layout: {displayLayout})."); return; @@ -1241,8 +1251,9 @@ private void UpdateDmd(int index, PinMameDisplayLayout displayLayout, IntPtr fra var frame = new byte[frameBuffer.Length]; Marshal.Copy(framePtr, frame, 0, frame.Length); + // Preserve the encoding at capture time: another consumer can change the provider mode before dispatch. EnqueueMainThreadDispatch(() => OnDisplayUpdateFrame?.Invoke(this, - new DisplayFrameData($"{DmdPrefix}{index}", GetRequestedDmdDisplayFrameFormat(displayLayout), frame))); + new DisplayFrameData($"{DmdPrefix}{index}", format, frame))); } private void UpdateSegDisp(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr) @@ -1376,6 +1387,13 @@ private int OnAudioAvailable(PinMameAudioInfo audioInfo) private int OnAudioUpdated(IntPtr framePtr, int frameSize) { + if (_pluginAudio.HasSubscribers && framePtr != IntPtr.Zero && frameSize > 0) { + int length = checked(frameSize * _audioInfo.Channels * sizeof(float)); + if (_pluginAudioBuffer.Length < length) _pluginAudioBuffer = new byte[length]; + Marshal.Copy(framePtr, _pluginAudioBuffer, 0, length); + _pluginAudio.Publish(new PluginPcmFrame(_pluginAudioBuffer.AsMemory(0, length), PcmEncoding.Float32, (int)_audioInfo.SampleRate, _audioInfo.Channels, frameSize, ++_pluginAudioSequence)); + } + if (_pluginAudio.PlaybackReplaced || DisableAudio) return _audioInfo.SamplesPerFrame; if (_audioFilterChannels == 0) { // don't know how many channels yet return _audioInfo.SamplesPerFrame; @@ -1431,6 +1449,15 @@ private int OnAudioUpdated(IntPtr framePtr, int frameSize) private void OnAudioFilterRead(float[] data, int channels) { + if (_pluginAudio.PlaybackReplaced) { + Array.Clear(data, 0, data.Length); + lock (_audioQueue) { + _audioQueue.Clear(); + } + _lastAudioFrame = Array.Empty(); + _lastAudioFrameOffset = 0; + return; + } _audioNumSamplesOutput += data.Length / channels; if (_audioNumSamplesOutput > 100000) { //var delta = AudioSettings.dspTime - _audioOutputStart; diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs new file mode 100644 index 0000000..7334c88 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +using System; +using System.Text; +using Newtonsoft.Json.Linq; +using UnityEngine; +using VisualPinball.Plugins; + +namespace VisualPinball.Engine.PinMAME +{ + internal sealed class PinMamePackageIdentityReader : IPackagedGameIdentityReader + { + public string ComponentType => "PinMameGamelogicEngine"; + public GameIdentity? Read(ReadOnlyMemory payload) + { + var values = JObject.Parse(Encoding.UTF8.GetString(payload.Span)); + string rom = values.Value("RomId"); + if (string.IsNullOrWhiteSpace(rom)) rom = values.Value("GameId"); + return string.IsNullOrWhiteSpace(rom) ? null : new GameIdentity("pinmame", rom); + } + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void Register() => PackagedGameIdentityReaders.Register(new PinMamePackageIdentityReader()); + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs.meta new file mode 100644 index 0000000..7e38328 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5fc4d8883279ed04188381dc199954c3 \ No newline at end of file diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/VisualPinball.Engine.PinMAME.Unity.asmdef b/VisualPinball.Engine.PinMAME.Unity/Runtime/VisualPinball.Engine.PinMAME.Unity.asmdef index 2d20350..b71836e 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/VisualPinball.Engine.PinMAME.Unity.asmdef +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/VisualPinball.Engine.PinMAME.Unity.asmdef @@ -4,6 +4,8 @@ "references": [ "VisualPinball.Engine", "VisualPinball.Unity", + "VisualPinball.Plugins", + "Newtonsoft.Json", "Unity.InputSystem" ], "includePlatforms": [], diff --git a/VisualPinball.Engine.PinMAME.Unity/package.json b/VisualPinball.Engine.PinMAME.Unity/package.json index e284891..078300d 100644 --- a/VisualPinball.Engine.PinMAME.Unity/package.json +++ b/VisualPinball.Engine.PinMAME.Unity/package.json @@ -1,6 +1,6 @@ { "name": "org.visualpinball.engine.pinmame", - "version": "0.0.1-preview.48", + "version": "0.0.1-preview.49", "displayName": "Visual Pinball Engine (PinMAME)", "description": "PinMAME support", "keywords": [ @@ -11,7 +11,7 @@ "unityRelease": "0f1", "dependencies": { "com.unity.nuget.newtonsoft-json": "3.2.1", - "org.visualpinball.engine.unity": "0.0.1-preview.119" + "org.visualpinball.engine.unity": "0.0.1-preview.212" }, "author": "freezy ", "contributors": [ From f1840705f9fae8c23575fdab392c1ddce2684be7 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 13 Oct 2026 22:10:25 +0200 Subject: [PATCH 2/7] pinmame: align player identity source header --- .../Runtime/PinMamePackageIdentityReader.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs index 7334c88..f72222e 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs @@ -1,4 +1,9 @@ -// SPDX-License-Identifier: MIT +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + using System; using System.Text; using Newtonsoft.Json.Linq; From 122310c00f00f2c7fec65776561ebe020e9df1ad Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 13 Oct 2026 22:15:10 +0200 Subject: [PATCH 3/7] pinmame: normalize source header line endings --- .../Runtime/PinMamePackageIdentityReader.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs index f72222e..1fe1496 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// Licensed under MIT. See https://opensource.org/license/mit. - +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + using System; using System.Text; using Newtonsoft.Json.Linq; From e35f593404ae466173df30c508fa23559c0e12ce Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 02:35:31 +0200 Subject: [PATCH 4/7] pinmame: expose global playback volume settings --- VisualPinball.Engine.PinMAME.Unity/README.md | 2 + .../Runtime/PinMameGamelogicEngine.cs | 19 +++++-- .../Runtime/PinMamePluginFactory.cs | 53 +++++++++++++++++++ .../Runtime/PinMamePluginFactory.cs.meta | 2 + 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs create mode 100644 VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs.meta diff --git a/VisualPinball.Engine.PinMAME.Unity/README.md b/VisualPinball.Engine.PinMAME.Unity/README.md index 5cb046f..49ecc01 100644 --- a/VisualPinball.Engine.PinMAME.Unity/README.md +++ b/VisualPinball.Engine.PinMAME.Unity/README.md @@ -12,6 +12,8 @@ This package adds a new gamelogic engine called *PinMAME*. Add the component to The catalog and cache are editor-only. A player build receives the serialized hardware mappings and machine/ROM identifiers; it does not fetch or contain the source metadata. Display layouts continue to be queried directly from the selected ROM through PinMAME. +In the Player, this package contributes a **PinMAME** settings section with a global **Volume** slider, also exposed in the **Sound** mixer. The default is 100%; zero mutes playback while the ROM continues running. Both surfaces edit the same plugin-owned value. Gain is applied before the first ROM audio frame and can change during playback, independently of ROM volume and NVRAM. Raw PCM published to other integrations is unaffected; a plugin replacing audio playback owns its output gain. The settings adapter applies only to tables using PinMAME, and cannot be disabled independently of the existing gamelogic engine. + Existing scenes and packages that used the retired class-based definitions remain compatible through a small runtime alias resolver. New and repopulated tables serialize numeric PinMAME IDs and populated flipper-wire mappings on the table. Candidate hardware snapshots stay in the editor scene and are omitted from packaged gamelogic data, so no machine-definition classes or catalog records are shipped with the player. ## Developer Setup diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index d60dc1d..06e9adc 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -408,6 +408,15 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) private readonly Dictionary _lastAppliedGiStates = new(); private bool _sharedLampPlaybackActive; private readonly Queue _audioQueue = new(); + private float _outputGain = 1f, _queuedGain = 1f; + /// User output gain, applied after raw PCM publication without changing ROM or device settings. + public float OutputGain { + get => Volatile.Read(ref _outputGain); + set { + if (float.IsNaN(value) || float.IsInfinity(value) || value < 0 || value > 1) throw new ArgumentOutOfRangeException(nameof(value)); + Volatile.Write(ref _outputGain, value); + } + } private readonly AudioResourceSource _pluginAudio = new(error => Logger.Warn(error, "[PinMAME] Plugin audio subscriber failed.")); private byte[] _pluginAudioBuffer = Array.Empty(); private long _pluginAudioSequence; @@ -1382,6 +1391,7 @@ private int OnAudioAvailable(PinMameAudioInfo audioInfo) Logger.Info("Game audio available: " + audioInfo); _audioInfo = audioInfo; + _queuedGain = OutputGain; // A saved mute applies to the first produced frame, including boot audio. return _audioInfo.SamplesPerFrame; } @@ -1408,13 +1418,15 @@ private int OnAudioUpdated(IntPtr framePtr, int frameSize) _audioNumSamplesInput = 0; } + float targetGain = OutputGain, previousGain = _queuedGain; + float Gain(int sample) => previousGain == targetGain ? targetGain : previousGain + (targetGain - previousGain) * ((sample + 1f) / frameSize); 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]; + frame[i] = targetGain == 1f && previousGain == 1f ? src[i] : src[i] * Gain(i / _audioInfo.Channels); } } } else if (_audioFilterChannels > _audioInfo.Channels) { // 1 channel -> 2 channels @@ -1422,7 +1434,7 @@ private int OnAudioUpdated(IntPtr framePtr, int frameSize) unsafe { var src = (float*)framePtr; for (var i = 0; i < frameSize; i++) { - frame[i * 2] = src[i]; + frame[i * 2] = targetGain == 1f && previousGain == 1f ? src[i] : src[i] * Gain(i); frame[i * 2 + 1] = frame[i * 2]; } } @@ -1431,11 +1443,12 @@ private int OnAudioUpdated(IntPtr framePtr, int frameSize) unsafe { var src = (float*)framePtr; for (var i = 0; i < frameSize; i += 2) { - frame[i] = src[i]; + frame[i] = targetGain == 1f && previousGain == 1f ? src[i] : src[i] * Gain(i / _audioInfo.Channels); } } } + _queuedGain = targetGain; lock (_audioQueue) { if (_audioQueue.Count >= _maximalQueueSize) { _audioQueue.Clear(); diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs new file mode 100644 index 0000000..cbfc0b4 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs @@ -0,0 +1,53 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using VisualPinball.Plugins; +using VisualPinball.Unity; + +namespace VisualPinball.Engine.PinMAME +{ + /// Configures the selected engine's audio; emulation remains owned by the table. + internal sealed class PinMamePluginFactory : IPlayerPluginFactory + { + public PluginDescriptor Descriptor { get; } = new PluginDescriptor("org.visualpinball.pinmame", "PinMAME", "ROM audio for tables using the PinMAME engine.") { EnabledByDefault = true, CanDisable = false, Category = "Gamelogic", Order = 5 }; + public PluginSettingsSchema Schema { get; } = new PluginSettingsSchema(1, new PluginSettingsGroup("ROM audio", "Controls PinMAME playback without changing ROM settings.", + new PluginField("Volume", "Volume", SettingKind.Integer) { Default = 100, Minimum = 0, Maximum = 100, Change = ConfigurationChange.Live, + Presentation = PluginFieldPresentation.Slider, Unit = "%", GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.VolumeMixer) { Order = 20 } })); + public IPluginSettingsProvider SettingsProvider => null; + public IPlayerPlugin Create(PluginApplicationContext context) => new SettingsAdapter(context); + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void Register() => PlayerPluginRegistry.Register(new PinMamePluginFactory()); + private sealed class SettingsAdapter : PlayerPlugin, IPluginSynchronousReconfiguration, IPluginReconfiguration, IPluginEmergencyStop + { + private readonly PluginApplicationContext _application; + private PinMameGamelogicEngine _engine; + private float _original; + internal SettingsAdapter(PluginApplicationContext application) => _application = application; + public override Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + var scene = game.Capabilities.Require(UnityPluginCapability.GameScene); + _engine = scene.Table.GetComponentInChildren(true); + if (_engine != null) { _original = _engine.OutputGain; Apply(game.Settings); } + return Task.CompletedTask; + } + public PluginActionResult Apply(PluginSettingsSnapshot settings) + { + _application.Scheduler.VerifyOwner(); + if (_engine != null) _engine.OutputGain = Mathf.Clamp(settings.Integer("Volume", 100), 0, 100) / 100f; + return new PluginActionResult(true, ""); + } + public Task ApplyAsync(PluginSettingsSnapshot settings, CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); return Task.FromResult(Apply(settings)); } + public override Task StopGameAsync(CancellationToken cancellation) { EmergencyStop(); return Task.CompletedTask; } + public void EmergencyStop() { if (_engine != null) _engine.OutputGain = _original; _engine = null; } + public override void Dispose() => EmergencyStop(); + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs.meta new file mode 100644 index 0000000..d8c28bc --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c28ef407acb876d4baac772c788d20dd \ No newline at end of file From 98091a8757b6472e55c3f3671779d8927943a968 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 02:52:28 +0200 Subject: [PATCH 5/7] pinmame: report native outputs and segment layouts Implements IGamelogicNativeOutputs: every lamp, GI string, solenoid and mech by PinMAME number, mapped or not, once per poll. Value semantics are fixed per hardware generation, following libpinmame: physical lamps on Pinball 2000 and SAM LEDs above 80, physical solenoids where PWM integration is forced (SAM, Capcom, PinHeck, Pinball 2000), GI levels 0..8 on WPC and 0/9 elsewhere. Implements ISegmentDisplayMetadata with the segment layout per display and an upright hint for Gottlieb System 80. --- .../Runtime/PinMameGamelogicEngine.cs | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index 06e9adc..f1f12e0 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -45,7 +45,7 @@ namespace VisualPinball.Engine.PinMAME [RequireComponent(typeof(AudioSource))] [PackAs("PinMameGamelogicEngine")] [AddComponentMenu("Pinball/Gamelogic Engine/PinMAME")] - public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayFrameFormatPreference, IDisplayResourceMetadata, IGamelogicAudioResourceProvider, IGameIdentityProvider, IRomNameProvider, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats, IPackable + public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayFrameFormatPreference, IDisplayResourceMetadata, IGamelogicAudioResourceProvider, IGameIdentityProvider, IRomNameProvider, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats, IGamelogicNativeOutputs, ISegmentDisplayMetadata, IPackable { public string Name { get; } = "PinMAME Gamelogic Engine"; public GamelogicInputDispatchMode SwitchDispatchMode => GamelogicInputDispatchMode.SimulationThread; @@ -407,6 +407,14 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) private readonly Dictionary _lastAppliedLampStates = new(); private readonly Dictionary _lastAppliedGiStates = new(); private bool _sharedLampPlaybackActive; + + // Native outputs for plugins: every lamp, GI string, solenoid and mech by PinMAME number, mapped or not. + private INativeOutputListener _nativeOutputs; + private readonly ConcurrentQueue<(int id, int value)> _nativeSolenoids = new ConcurrentQueue<(int, int)>(); + private readonly ConcurrentQueue<(int id, int position)> _nativeMechs = new ConcurrentQueue<(int, int)>(); + private readonly Dictionary _segmentLayouts = new Dictionary(); + // libpinmame's output encoding per hardware generation (pinmame/src/wpc/core.c and vpintf.c). + private const ulong GenAllWpc = 0xff, GenAllS80 = 0x0000600000000, GenSam = 0x0100000000000, GenP2K = 0x8000000000000, GenPinHeck = 0x10000000000000, GenCapcom = 0x20000000000000; private readonly Queue _audioQueue = new(); private float _outputGain = 1f, _queuedGain = 1f; /// User output gain, applied after raw PCM publication without changing ROM or device settings. @@ -531,6 +539,8 @@ private void Update() } } + DispatchNativeOutputs(); + // if (_keyboard != null && _keyboard.cKey.wasPressedThisFrame) // { // OnCoilChanged.Invoke(this, new CoilEventArgs("28", true)); @@ -1215,6 +1225,11 @@ private void OnDisplayRequested(int index, int displayCount, PinMameDisplayLayou } } else { + lock (_segmentLayouts) { + // Gottlieb System 80 glass displays are upright; the technology is left to the consumer. + var hardware = ((ulong)PinMame.PinMame.CurrentHardwareGen & GenAllS80) != 0 ? "gottlieb" : string.Empty; + _segmentLayouts[$"{SegDispPrefix}{index}"] = (SegmentLayout(displayLayout.Type), hardware); + } EnqueueMainThreadDispatch(() => OnDisplaysRequested?.Invoke(this, new RequestedDisplays( new DisplayConfig($"{SegDispPrefix}{index}", displayLayout.Length, 1)))); @@ -1548,6 +1563,9 @@ public bool GetCoil(string id) private void OnSolenoidUpdated(int id, int value) { MarkPinMameCallbackActivity(); + if (_nativeOutputs != null) { + _nativeSolenoids.Enqueue((id, value)); + } // value is 0/1 for binary coils, 0-255 for PWM-integrated (modulated) coils if (value > 1) { @@ -1692,6 +1710,82 @@ public bool GetSwitch(string id) #endregion + #region Native outputs + + public void SetNativeOutputListener(INativeOutputListener listener) + { + _nativeOutputs = listener; + if (listener == null) { + while (_nativeSolenoids.TryDequeue(out _)) { } + while (_nativeMechs.TryDequeue(out _)) { } + } + } + + /// Forwards this poll's changes by PinMAME number, including outputs the table does not map. + private void DispatchNativeOutputs() + { + var listener = _nativeOutputs; + if (listener == null) { + return; + } + // The encoding is fixed by the hardware generation, so every address keeps one meaning from its first value: + // lamps are 0/1 except on Pinball 2000 and SAM's modulated LEDs above 80 (0..255); solenoids are 0..255 where + // PWM integration is forced (SAM, Capcom, PinHeck, Pinball 2000); GI is 0..8 on WPC and 0 or 9 elsewhere. + var gen = (ulong)PinMame.PinMame.CurrentHardwareGen; + bool p2k = (gen & GenP2K) != 0, sam = (gen & GenSam) != 0; + bool physicalSolenoids = (gen & (GenSam | GenCapcom | GenPinHeck | GenP2K)) != 0; + int giMaximum = (gen & GenAllWpc) != 0 ? 8 : 9; + foreach (var lamp in _changedLamps) { + var physical = p2k || sam && lamp.Id > 80; + listener.NativeOutputChanged(ControllerEventKind.Lamp, lamp.Id, lamp.Value, physical ? ControllerValueSemantics.Physical : ControllerValueSemantics.Binary, physical ? 255 : 1); + } + foreach (var gi in _changedGIs) { + listener.NativeOutputChanged(ControllerEventKind.GeneralIllumination, gi.Id, gi.Value, ControllerValueSemantics.Levels, giMaximum); + } + while (_nativeSolenoids.TryDequeue(out var solenoid)) { + listener.NativeOutputChanged(ControllerEventKind.Coil, solenoid.id, solenoid.value, physicalSolenoids ? ControllerValueSemantics.Physical : ControllerValueSemantics.Binary, physicalSolenoids ? 255 : 1); + } + while (_nativeMechs.TryDequeue(out var mech)) { + listener.NativeOutputChanged(ControllerEventKind.Mech, mech.id, mech.position, ControllerValueSemantics.Position, 1); + } + listener.NativeOutputPollEnd(); + } + + public bool TryGetSegmentLayout(string displayId, out string layout, out string hardware) + { + lock (_segmentLayouts) { + if (_segmentLayouts.TryGetValue(displayId, out var known)) { + (layout, hardware) = known; + return true; + } + } + layout = hardware = string.Empty; + return false; + } + + /// Maps PinMAME's display types onto the plugin segment layouts. + private static string SegmentLayout(PinMameDisplayType type) + { + switch (type) { + case PinMameDisplayType.Seg9: + case PinMameDisplayType.Seg10: + case PinMameDisplayType.Seg98: + case PinMameDisplayType.Seg98F: + return "seg9"; + case PinMameDisplayType.Seg16: + case PinMameDisplayType.Seg16R: + case PinMameDisplayType.Seg16N: + case PinMameDisplayType.Seg16D: + return "seg14"; + case PinMameDisplayType.Seg16S: + return "seg16"; + default: + return "seg7"; + } + } + + #endregion + #region Mechs public void RegisterMech(PinMameMechComponent mechComponent) @@ -1709,6 +1803,9 @@ private void OnMechAvailable(int mechNo, PinMameMechInfo mechInfo) private void OnMechUpdated(int mechNo, PinMameMechInfo mechInfo) { + if (_nativeOutputs != null) { + _nativeMechs.Enqueue((mechNo, mechInfo.Pos)); + } if (_registeredMechs.ContainsKey(mechNo)) { EnqueueMainThreadDispatch(() => _registeredMechs[mechNo].UpdateMech(mechInfo)); From 0adfc6235459f2feb7e0c0aa79efa43ca6de8769 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 10:12:17 +0200 Subject: [PATCH 6/7] pinmame: extract and test the native output model The hardware-generation rules for native output values (physical SAM LEDs above lamp 80 and all Pinball 2000 lamps, physical solenoids where PWM integration is forced, the GI maximum, upright Gottlieb System 80 segments) move into PinMameOutputModel, a Unity-free struct. The test project links it and covers the boundaries and generations. --- .../PinMameOutputModelTests.cs | 79 +++++++++++++++++++ .../VisualPinball.Engine.PinMAME.Test.csproj | 1 + .../Runtime/PinMameGamelogicEngine.cs | 21 ++--- .../Runtime/PinMameOutputModel.cs | 53 +++++++++++++ .../Runtime/PinMameOutputModel.cs.meta | 2 + 5 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 VisualPinball.Engine.PinMAME.Test/PinMameOutputModelTests.cs create mode 100644 VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameOutputModel.cs create mode 100644 VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameOutputModel.cs.meta diff --git a/VisualPinball.Engine.PinMAME.Test/PinMameOutputModelTests.cs b/VisualPinball.Engine.PinMAME.Test/PinMameOutputModelTests.cs new file mode 100644 index 0000000..e19cddc --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Test/PinMameOutputModelTests.cs @@ -0,0 +1,79 @@ +// Visual Pinball Engine +// Copyright (C) 2026 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. + +using NUnit.Framework; + +namespace VisualPinball.Engine.PinMAME +{ + [TestFixture] + public class PinMameOutputModelTests + { + // Single generations from pinmame/src/wpc/gen.h. + private const ulong WpcDmd = 0x04, Wpc95 = 0x80, Whitestar = 0x0004000000000, Gts80 = 0x0000200000000, Gts80B = 0x0000400000000; + + [Test] + public void SamLampsAreBinaryUpToEightyAndPhysicalAbove() + { + var model = new PinMameOutputModel(PinMameOutputModel.GenSam); + Assert.That(model.IsPhysicalLamp(1), Is.False); + Assert.That(model.IsPhysicalLamp(80), Is.False); + Assert.That(model.IsPhysicalLamp(81), Is.True); + Assert.That(model.IsPhysicalLamp(128), Is.True); + } + + [Test] + public void Pinball2000LampsAreAllPhysical() + { + var model = new PinMameOutputModel(PinMameOutputModel.GenP2K); + Assert.That(model.IsPhysicalLamp(1), Is.True); + Assert.That(model.IsPhysicalLamp(80), Is.True); + } + + [TestCase(WpcDmd)] + [TestCase(Wpc95)] + [TestCase(Whitestar)] + [TestCase(Gts80)] + public void OtherLampsAreBinary(ulong generation) + { + var model = new PinMameOutputModel(generation); + Assert.That(model.IsPhysicalLamp(1), Is.False); + Assert.That(model.IsPhysicalLamp(81), Is.False); + } + + [TestCase(PinMameOutputModel.GenSam, true)] + [TestCase(PinMameOutputModel.GenCapcom, true)] + [TestCase(PinMameOutputModel.GenPinHeck, true)] + [TestCase(PinMameOutputModel.GenP2K, true)] + [TestCase(WpcDmd, false)] + [TestCase(Wpc95, false)] + [TestCase(Whitestar, false)] + [TestCase(Gts80, false)] + public void SolenoidsArePhysicalWherePwmIntegrationIsForced(ulong generation, bool physical) + { + Assert.That(new PinMameOutputModel(generation).PhysicalSolenoids, Is.EqualTo(physical)); + } + + [TestCase(WpcDmd, 8)] + [TestCase(Wpc95, 8)] + [TestCase(Whitestar, 9)] + [TestCase(PinMameOutputModel.GenSam, 9)] + public void GiMaximumFollowsTheGeneration(ulong generation, int maximum) + { + Assert.That(new PinMameOutputModel(generation).GiMaximum, Is.EqualTo(maximum)); + } + + [TestCase(Gts80, true)] + [TestCase(Gts80B, true)] + [TestCase(WpcDmd, false)] + [TestCase(Whitestar, false)] + public void GottliebSystem80SegmentsAreUpright(ulong generation, bool upright) + { + Assert.That(new PinMameOutputModel(generation).UprightSegments, Is.EqualTo(upright)); + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj b/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj index 024f3aa..49011ff 100644 --- a/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj +++ b/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj @@ -17,5 +17,6 @@ + diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index f1f12e0..beaf604 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -413,8 +413,6 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot) private readonly ConcurrentQueue<(int id, int value)> _nativeSolenoids = new ConcurrentQueue<(int, int)>(); private readonly ConcurrentQueue<(int id, int position)> _nativeMechs = new ConcurrentQueue<(int, int)>(); private readonly Dictionary _segmentLayouts = new Dictionary(); - // libpinmame's output encoding per hardware generation (pinmame/src/wpc/core.c and vpintf.c). - private const ulong GenAllWpc = 0xff, GenAllS80 = 0x0000600000000, GenSam = 0x0100000000000, GenP2K = 0x8000000000000, GenPinHeck = 0x10000000000000, GenCapcom = 0x20000000000000; private readonly Queue _audioQueue = new(); private float _outputGain = 1f, _queuedGain = 1f; /// User output gain, applied after raw PCM publication without changing ROM or device settings. @@ -1227,7 +1225,7 @@ private void OnDisplayRequested(int index, int displayCount, PinMameDisplayLayou } else { lock (_segmentLayouts) { // Gottlieb System 80 glass displays are upright; the technology is left to the consumer. - var hardware = ((ulong)PinMame.PinMame.CurrentHardwareGen & GenAllS80) != 0 ? "gottlieb" : string.Empty; + var hardware = new PinMameOutputModel((ulong)PinMame.PinMame.CurrentHardwareGen).UprightSegments ? "gottlieb" : string.Empty; _segmentLayouts[$"{SegDispPrefix}{index}"] = (SegmentLayout(displayLayout.Type), hardware); } EnqueueMainThreadDispatch(() => @@ -1728,22 +1726,17 @@ private void DispatchNativeOutputs() if (listener == null) { return; } - // The encoding is fixed by the hardware generation, so every address keeps one meaning from its first value: - // lamps are 0/1 except on Pinball 2000 and SAM's modulated LEDs above 80 (0..255); solenoids are 0..255 where - // PWM integration is forced (SAM, Capcom, PinHeck, Pinball 2000); GI is 0..8 on WPC and 0 or 9 elsewhere. - var gen = (ulong)PinMame.PinMame.CurrentHardwareGen; - bool p2k = (gen & GenP2K) != 0, sam = (gen & GenSam) != 0; - bool physicalSolenoids = (gen & (GenSam | GenCapcom | GenPinHeck | GenP2K)) != 0; - int giMaximum = (gen & GenAllWpc) != 0 ? 8 : 9; + // The encoding is fixed by the hardware generation, so every address keeps one meaning from its first value. + var model = new PinMameOutputModel((ulong)PinMame.PinMame.CurrentHardwareGen); foreach (var lamp in _changedLamps) { - var physical = p2k || sam && lamp.Id > 80; - listener.NativeOutputChanged(ControllerEventKind.Lamp, lamp.Id, lamp.Value, physical ? ControllerValueSemantics.Physical : ControllerValueSemantics.Binary, physical ? 255 : 1); + var physical = model.IsPhysicalLamp(lamp.Id); + listener.NativeOutputChanged(ControllerEventKind.Lamp, lamp.Id, lamp.Value, physical ? ControllerValueSemantics.Physical : ControllerValueSemantics.Binary, physical ? PinMameOutputModel.PhysicalMaximum : 1); } foreach (var gi in _changedGIs) { - listener.NativeOutputChanged(ControllerEventKind.GeneralIllumination, gi.Id, gi.Value, ControllerValueSemantics.Levels, giMaximum); + listener.NativeOutputChanged(ControllerEventKind.GeneralIllumination, gi.Id, gi.Value, ControllerValueSemantics.Levels, model.GiMaximum); } while (_nativeSolenoids.TryDequeue(out var solenoid)) { - listener.NativeOutputChanged(ControllerEventKind.Coil, solenoid.id, solenoid.value, physicalSolenoids ? ControllerValueSemantics.Physical : ControllerValueSemantics.Binary, physicalSolenoids ? 255 : 1); + listener.NativeOutputChanged(ControllerEventKind.Coil, solenoid.id, solenoid.value, model.PhysicalSolenoids ? ControllerValueSemantics.Physical : ControllerValueSemantics.Binary, model.PhysicalSolenoids ? PinMameOutputModel.PhysicalMaximum : 1); } while (_nativeMechs.TryDequeue(out var mech)) { listener.NativeOutputChanged(ControllerEventKind.Mech, mech.id, mech.position, ControllerValueSemantics.Position, 1); diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameOutputModel.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameOutputModel.cs new file mode 100644 index 0000000..54294c0 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameOutputModel.cs @@ -0,0 +1,53 @@ +// Visual Pinball Engine +// Copyright (C) 2026 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. + +// ReSharper disable CheckNamespace + +namespace VisualPinball.Engine.PinMAME +{ + /// + /// How libpinmame encodes output values for one hardware generation. The encoding is fixed per generation, so + /// every output keeps one meaning from its first value (see pinmame/src/wpc/core.c and vpintf.c): + /// + /// Lamps are 0/1, except on Pinball 2000 and SAM's modulated LEDs above lamp 80, which report 0..255. + /// Solenoids report 0..255 where PinMAME forces PWM integration (SAM, Capcom, PinHeck, Pinball 2000), 0/1 elsewhere. + /// GI strings report levels 0..8 on WPC and 0 or 9 elsewhere. + /// + /// + public readonly struct PinMameOutputModel + { + /// Hardware generation bits, as in pinmame/src/wpc/gen.h. + public const ulong GenAllWpc = 0xff, GenAllS80 = 0x0000600000000, GenSam = 0x0100000000000, GenP2K = 0x8000000000000, + GenPinHeck = 0x10000000000000, GenCapcom = 0x20000000000000; + + /// Raw value of a fully on physical output. + public const int PhysicalMaximum = 255; + + /// Highest SAM lamp number that is a plain matrix lamp; the modulated LEDs follow. + public const int SamLastMatrixLamp = 80; + + private readonly ulong _generation; + + public PinMameOutputModel(ulong generation) + { + _generation = generation; + } + + /// Whether the lamp reports physical brightness 0..255 rather than 0/1. + public bool IsPhysicalLamp(int lampNo) => (_generation & GenP2K) != 0 || (_generation & GenSam) != 0 && lampNo > SamLastMatrixLamp; + + /// Whether solenoids report physical strength 0..255 rather than 0/1. + public bool PhysicalSolenoids => (_generation & (GenSam | GenCapcom | GenPinHeck | GenP2K)) != 0; + + /// The GI level of a fully lit string. + public int GiMaximum => (_generation & GenAllWpc) != 0 ? 8 : 9; + + /// Whether segment displays are upright (Gottlieb System 80) rather than slanted. + public bool UprightSegments => (_generation & GenAllS80) != 0; + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameOutputModel.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameOutputModel.cs.meta new file mode 100644 index 0000000..331ff76 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameOutputModel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a08e3c6d8e4f40c4a89bee093a8f863c From 32c3a734085c41ddb133ff4a5ea906b3808466c7 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 16:31:45 +0200 Subject: [PATCH 7/7] engine: stop replaying every GI change on each poll pinmame-dotnet's GetChangedGIs(List) appends without clearing, unlike GetChangedLamps, so each poll dispatched all GI changes since the game started to lights, shared state and native output listeners. --- .../Runtime/PinMameGamelogicEngine.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index beaf604..4188c43 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -521,7 +521,8 @@ private void Update() } } - // gi + // gi (pinmame-dotnet's list overload appends without clearing, unlike the lamp one, which would replay every change so far) + _changedGIs.Clear(); _pinMame.GetChangedGIs(_changedGIs); foreach (var changedGi in _changedGIs) { lock (_outputStateLock) {