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": [