Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions VisualPinball.Engine.PinMAME.Test/PinMameOutputModelTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@
<Compile Include="..\VisualPinball.Engine.PinMAME.Unity\Editor\PinMameGameDefinitionConverter.cs" Link="PinMameGameDefinitionConverter.cs" />
<Compile Include="..\VisualPinball.Engine.PinMAME.Unity\Editor\PinMameSpatialMatcher.cs" Link="PinMameSpatialMatcher.cs" />
<Compile Include="..\VisualPinball.Engine.PinMAME.Unity\Runtime\PinMameLegacyAliasResolver.cs" Link="PinMameLegacyAliasResolver.cs" />
<Compile Include="..\VisualPinball.Engine.PinMAME.Unity\Runtime\PinMameOutputModel.cs" Link="PinMameOutputModel.cs" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"references": [
"VisualPinball.Engine",
"VisualPinball.Unity",
"VisualPinball.Plugins",
"VisualPinball.Unity.Editor",
"VisualPinball.Engine.PinMAME.Unity",
"Newtonsoft.Json"
Expand Down
2 changes: 2 additions & 0 deletions VisualPinball.Engine.PinMAME.Unity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 139 additions & 8 deletions VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -401,7 +407,25 @@ public void ApplySharedState(in SimulationState.Snapshot snapshot)
private readonly Dictionary<int, float> _lastAppliedLampStates = new();
private readonly Dictionary<int, float> _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<string, (string layout, string hardware)> _segmentLayouts = new Dictionary<string, (string, string)>();
private readonly Queue<float[]> _audioQueue = new();
private float _outputGain = 1f, _queuedGain = 1f;
/// <summary>User output gain, applied after raw PCM publication without changing ROM or device settings.</summary>
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<byte>();
private long _pluginAudioSequence;
private readonly HashSet<string> _loggedUnresolvedMappingIds = new(StringComparer.Ordinal);

private int _audioFilterChannels;
Expand Down Expand Up @@ -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) {
Expand All @@ -513,6 +538,8 @@ private void Update()
}
}

DispatchNativeOutputs();

// if (_keyboard != null && _keyboard.cKey.wasPressedThisFrame)
// {
// OnCoilChanged.Invoke(this, new CoilEventArgs("28", true));
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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))));
Expand All @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -1390,21 +1432,23 @@ 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
frame = new float[frameSize * 2];
unsafe {
var src = (float*)framePtr;
for (var i = 0; i < frameSize; i++) {
frame[i * 2] = src[i];
frame[i * 2] = targetGain == 1f && previousGain == 1f ? src[i] : src[i] * Gain(i);
frame[i * 2 + 1] = frame[i * 2];
}
}
Expand All @@ -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();
Expand All @@ -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<float>();
_lastAudioFrameOffset = 0;
return;
}
_audioNumSamplesOutput += data.Length / channels;
if (_audioNumSamplesOutput > 100000) {
//var delta = AudioSettings.dspTime - _audioOutputStart;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 _)) { }
}
}

/// <summary>Forwards this poll's changes by PinMAME number, including outputs the table does not map.</summary>
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;
}

/// <summary>Maps PinMAME's display types onto the plugin segment layouts.</summary>
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)
Expand All @@ -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));

Expand Down
Loading
Loading