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/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/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 75949f7..4188c43 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, IGamelogicNativeOutputs, ISegmentDisplayMetadata, 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;
@@ -401,7 +407,25 @@ 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();
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;
private readonly HashSet _loggedUnresolvedMappingIds = new(StringComparer.Ordinal);
private int _audioFilterChannels;
@@ -497,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) {
@@ -513,6 +538,8 @@ private void Update()
}
}
+ DispatchNativeOutputs();
+
// if (_keyboard != null && _keyboard.cKey.wasPressedThisFrame)
// {
// OnCoilChanged.Invoke(this, new CoilEventArgs("28", true));
@@ -896,7 +923,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;
}
@@ -1197,6 +1224,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 = new PinMameOutputModel((ulong)PinMame.PinMame.CurrentHardwareGen).UprightSegments ? "gottlieb" : string.Empty;
+ _segmentLayouts[$"{SegDispPrefix}{index}"] = (SegmentLayout(displayLayout.Type), hardware);
+ }
EnqueueMainThreadDispatch(() =>
OnDisplaysRequested?.Invoke(this, new RequestedDisplays(
new DisplayConfig($"{SegDispPrefix}{index}", displayLayout.Length, 1))));
@@ -1222,6 +1254,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 +1274,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)
@@ -1371,11 +1405,19 @@ 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;
}
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;
@@ -1390,13 +1432,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
@@ -1404,7 +1448,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];
}
}
@@ -1413,11 +1457,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();
@@ -1431,6 +1476,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;
@@ -1508,6 +1562,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) {
@@ -1652,6 +1709,77 @@ 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.
+ var model = new PinMameOutputModel((ulong)PinMame.PinMame.CurrentHardwareGen);
+ foreach (var lamp in _changedLamps) {
+ 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, model.GiMaximum);
+ }
+ while (_nativeSolenoids.TryDequeue(out var solenoid)) {
+ 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);
+ }
+ 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)
@@ -1669,6 +1797,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));
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
diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs
new file mode 100644
index 0000000..1fe1496
--- /dev/null
+++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePackageIdentityReader.cs
@@ -0,0 +1,28 @@
+// 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;
+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/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
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": [