diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4941d00 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto +*.cs text eol=lf +*.json text eol=lf +*.md text eol=lf +*.meta text eol=lf whitespace=-blank-at-eol +LICENSE text eol=lf +*.dll binary +*.so binary +*.dylib binary diff --git a/Tests/Player/ColorizationCatalogTests.cs b/Tests/Player/ColorizationCatalogTests.cs new file mode 100644 index 0000000..55c5a49 --- /dev/null +++ b/Tests/Player/ColorizationCatalogTests.cs @@ -0,0 +1,70 @@ +// 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.IO; +using System.Linq; +using NUnit.Framework; +using VisualPinball.Engine.DMD.Player; + +namespace VisualPinball.Engine.DMD.Tests +{ + [TestFixture] + public sealed class ColorizationCatalogTests + { + private string _root; + [SetUp] public void SetUp() { _root = Path.Combine(Path.GetTempPath(), "vpe-colorization-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); } + [TearDown] public void TearDown() { if (Directory.Exists(_root)) Directory.Delete(_root, true); } + private string Asset(string path) + { + string file = Path.Combine(_root, path); Directory.CreateDirectory(Path.GetDirectoryName(file)); File.WriteAllText(file, path); return file; + } + [Test] + public void VariantsHaveStableIdsAndKeepPaletteAnimationsTogether() + { + Asset("game/first/pin2dmd.pal"); Asset("game/first/pin2dmd.vni"); Asset("game/second/game.crz"); Asset("game/third/pin2dmd.pac"); + var assets = ColorizationCatalog.Read(_root, "game"); + Assert.That(assets.Count, Is.EqualTo(3)); + Assert.That(assets.Single(asset => asset.Id == "game/first/pin2dmd.pal").Files.Length, Is.EqualTo(2)); + Assert.That(assets.Single(asset => asset.Id == "game/third/pin2dmd.pac").RequiresKey, Is.True); + } + [Test] + public void AmbiguousAutomaticAndMissingManualChoicesNeverFallBack() + { + // Variants in subfolders aren't what DmdDevice loads, so automatic selection refuses to guess. + Asset("game/one/game.crz"); Asset("game/two/game.crz"); + Assert.Throws(() => ColorizationCatalog.Select(_root, "game", "auto")); + Assert.Throws(() => ColorizationCatalog.Select(_root, "game", "game/missing.crz")); + Assert.That(ColorizationCatalog.Select(_root, "game", "none"), Is.Null); + } + [Test] + public void AmbiguousAutomaticPrefersTheFilesDmdDeviceLoads() + { + Asset("game/pin2dmd.pal"); Asset("game/pin2dmd.vni"); Asset("game/old/pin2dmd.pal"); Asset("game/old/pin2dmd.vni"); Asset("game/update.pac"); Asset("game/pin2dmd - Copy.pal"); Asset("game/old/._pin2dmd.pal"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal")); + Assert.That(ColorizationCatalog.Read(_root, "game").Any(asset => asset.Id.Contains("._")), Is.False, "macOS resource forks are not colorizations"); + Asset("game/pin2dmd.pac"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal"), "a PAC without key can't be decrypted"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto", hasPacKey: true).Id, Is.EqualTo("game/pin2dmd.pac")); + Asset("game/old/other.cRZ"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal"), "Serum only counts in the game folder itself"); + Asset("game/older.cROM"); File.SetCreationTimeUtc(Path.Combine(_root, "game/older.cROM"), new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Asset("game/newer.cROMc"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/newer.cROMc"), "the newest Serum file wins, like the bridge"); + } + [Test] + public void StagingContainsOnlySelectedVariantAndReleasesOwnedCopies() + { + string original = Asset("game/one/game.crz"); Asset("game/two/game.crz"); + var selected = ColorizationCatalog.Select(_root, "game", "game/one/game.crz"); + var lease = ColorizationCatalog.Stage(selected, Path.Combine(_root, "cache"), "game", out string staged); + Assert.That(File.ReadAllText(Path.Combine(staged, "game", "game.cRZ")), Is.EqualTo("game/one/game.crz")); + Assert.That(Directory.GetFiles(staged, "*", SearchOption.AllDirectories).Length, Is.EqualTo(1)); + lease.Dispose(); lease.Dispose(); + Assert.That(Directory.Exists(staged), Is.False); Assert.That(File.Exists(original), Is.True); + } + } +} diff --git a/Tests/Player/ManagedDmdInputTests.cs b/Tests/Player/ManagedDmdInputTests.cs new file mode 100644 index 0000000..57b7727 --- /dev/null +++ b/Tests/Player/ManagedDmdInputTests.cs @@ -0,0 +1,228 @@ +// 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using VisualPinball.Engine.DMD.Unity; +using VisualPinball.Plugins; + +namespace VisualPinball.Engine.DMD.Tests +{ + [TestFixture] + public sealed class ManagedDmdInputTests + { + private OwnerScheduler _owner; + private ManagedDmdInput _input; + private List _created; + private bool _sourceIsCurrent; + private bool _failCreation; + private List _errors; + [SetUp] + public void SetUp() + { + _owner = new OwnerScheduler(); + _created = new List(); + _sourceIsCurrent = true; + _failCreation = false; + _errors = new List(); + _input = new ManagedDmdInput(_owner, (id, width, height) => { + _owner.VerifyOwner(); + if (_failCreation) throw new InvalidOperationException("Graph initialization failed."); + var pipeline = new Pipeline(id, width, height); + _created.Add(pipeline); + return pipeline; + }, instance => _sourceIsCurrent, _errors.Add); + } + [TearDown] + public void TearDown() + { + foreach (var pipeline in _created) pipeline.FinishStop(); + var shutdown = _input.ShutdownAsync(); + PumpUntil(() => shutdown.IsCompleted); + shutdown.GetAwaiter().GetResult(); + } + private void PumpUntil(Func complete) + { + Assert.That(SpinWait.SpinUntil(() => { _owner.Pump(); return complete(); }, TimeSpan.FromSeconds(5)), Is.True, "Owner-thread work did not finish."); + } + private static PluginDisplayFrame Frame(byte[] pixels, int width = 2, int height = 1, int stride = 2, long instance = 1, long sequence = 1) + => new PluginDisplayFrame(pixels, width, height, stride, DisplayEncoding.Gray4, sequence, sequence * 1000, instance); + + [TestCase(false)] + [TestCase(true)] + public void SingleChangedFrameSurvivesWithoutAnyFurtherSourceEvents(bool sourceChange) + { + _input.Feed(Frame(new byte[] { 1, 2 }), "dmd0"); + var previous = _created[0]; + var pixels = new byte[] { 3, 4, 5, 6 }; + var changed = sourceChange ? Frame(pixels, instance: 2) : Frame(pixels, width: 4, stride: 4); + _input.Feed(changed, "dmd0"); + Array.Fill(pixels, (byte)0); // The producer reuses its borrowed memory as soon as Feed returns. + Assert.That(_created.Count, Is.EqualTo(1)); + Assert.That(previous.Disposed, Is.False); + previous.FinishStop(); + PumpUntil(() => _created.Count == 2); + Assert.That(previous.Disposed, Is.True); + Assert.That(_created[1].Frames[0].Pixels.ToArray(), Is.EqualTo(sourceChange ? new byte[] { 3, 4 } : new byte[] { 3, 4, 5, 6 })); + Assert.That(_created[1].Frames[0].Instance, Is.EqualTo(changed.Instance)); + } + + [Test] + public void RebuildUsesLatestFrameAndItsShapeEncodingAndMetadata() + { + _input.Feed(Frame(new byte[] { 1, 2 }), "dmd0"); + _input.Feed(Frame(new byte[] { 3, 4, 5, 6 }, width: 4, stride: 4), "dmd0"); + var pixels = new byte[] { 10, 11, 12, 0, 20, 21, 22, 0 }; + var latest = new PluginDisplayFrame(pixels, 1, 2, 4, DisplayEncoding.Rgb24, 17, 23000, 5, FrameColorSpace.Linear); + _input.Feed(latest, "dmd1"); + Array.Fill(pixels, (byte)0); + _created[0].FinishStop(); + PumpUntil(() => _created.Count == 2); + var current = _created[1]; + Assert.That(current.Id, Is.EqualTo("dmd1")); + Assert.That(current.Width, Is.EqualTo(1)); + Assert.That(current.Height, Is.EqualTo(2)); + Assert.That(current.Frames.Count, Is.EqualTo(1)); + var received = current.Frames[0]; + Assert.That(received.Pixels.ToArray(), Is.EqualTo(new byte[] { 10, 11, 12, 0, 20, 21, 22, 0 })); + Assert.That(received.Stride, Is.EqualTo(4)); + Assert.That(received.Encoding, Is.EqualTo(DisplayEncoding.Rgb24)); + Assert.That(received.Instance, Is.EqualTo(5)); + Assert.That(received.Sequence, Is.EqualTo(17)); + Assert.That(received.TimestampUs, Is.EqualTo(23000)); + Assert.That(received.ColorSpace, Is.EqualTo(FrameColorSpace.Linear)); + } + + [Test] + public void ShutdownDuringRebuildWaitsForWorkerAndDoesNotCreateAnOutput() + { + _input.Feed(Frame(new byte[] { 1, 2 }), "dmd0"); + _input.Feed(Frame(new byte[] { 3, 4, 5, 6 }, width: 4, stride: 4), "dmd0"); + var shutdown = _input.ShutdownAsync(); + Assert.That(_input.ShutdownAsync(), Is.SameAs(shutdown)); + Assert.That(shutdown.IsCompleted, Is.False); + Assert.That(_created[0].Disposed, Is.False); + _input.Feed(Frame(new byte[] { 7, 8 }, instance: 3), "dmd0"); + _created[0].FinishStop(); + PumpUntil(() => shutdown.IsCompleted); + shutdown.GetAwaiter().GetResult(); + Assert.That(_created.Count, Is.EqualTo(1)); + Assert.That(_created[0].Disposed, Is.True); + Assert.That(_input.Pipeline, Is.Null); + } + + [Test] + public void FailedReplacementCanRetryOnTheNextFrameAndStillShutDown() + { + _input.Feed(Frame(new byte[] { 1, 2 }), "dmd0"); + _failCreation = true; + _input.Feed(Frame(new byte[] { 3, 4 }, instance: 2), "dmd0"); + _created[0].FinishStop(); + PumpUntil(() => _errors.Count == 1); + Assert.That(_input.Pipeline, Is.Null); + Assert.That(_created[0].Disposed, Is.True); + _failCreation = false; + _input.Feed(Frame(new byte[] { 5, 6 }, instance: 2), "dmd0"); + Assert.That(_created.Count, Is.EqualTo(2)); + Assert.That(_created[1].Frames[0].Pixels.ToArray(), Is.EqualTo(new byte[] { 5, 6 })); + var shutdown = _input.ShutdownAsync(); + Assert.That(shutdown.IsCompleted, Is.False); + _created[1].FinishStop(); + PumpUntil(() => shutdown.IsCompleted); + Assert.That(shutdown.IsCompletedSuccessfully, Is.True); + } + + [Test] + public void FailedWorkerStopDoesNotReleaseItsGraphAndIsRetriedEvenForOriginalShape() + { + _input.Feed(Frame(new byte[] { 1, 2 }), "dmd0"); + var previous = _created[0]; + previous.FailNextStop = true; + _input.Feed(Frame(new byte[] { 3, 4, 5, 6 }, width: 4, stride: 4), "dmd0"); + Assert.That(_errors.Count, Is.EqualTo(1)); + Assert.That(previous.Disposed, Is.False); + _input.Feed(Frame(new byte[] { 5, 6 }), "dmd0"); + Assert.That(previous.StopRequests, Is.EqualTo(2)); + Assert.That(previous.Disposed, Is.False); + previous.FinishStop(); + PumpUntil(() => _created.Count == 2); + Assert.That(previous.Disposed, Is.True); + Assert.That(_created[1].Frames[0].Pixels.ToArray(), Is.EqualTo(new byte[] { 5, 6 })); + } + + [Test] + public void RemovedSourceDoesNotRecreateAnOutputFromAPendingFrame() + { + _input.Feed(Frame(new byte[] { 1, 2 }), "dmd0"); + _input.Feed(Frame(new byte[] { 3, 4, 5, 6 }, width: 4, stride: 4), "dmd0"); + _sourceIsCurrent = false; + _created[0].FinishStop(); + PumpUntil(() => _input.Pipeline == null); + Assert.That(_created.Count, Is.EqualTo(1)); + Assert.That(_created[0].Disposed, Is.True); + } + + [Test] + public void AlreadyStoppedWorkerCanReplaceSynchronously() + { + _input.Feed(Frame(new byte[] { 1, 2 }), "dmd0"); + _created[0].FinishStop(); + _input.Feed(Frame(new byte[] { 3, 4 }, instance: 2), "dmd0"); + Assert.That(_created.Count, Is.EqualTo(2)); + Assert.That(_created[1].Frames[0].Pixels.ToArray(), Is.EqualTo(new byte[] { 3, 4 })); + _input.Feed(Frame(new byte[] { 5, 6 }, instance: 2), "dmd0"); + Assert.That(_created.Count, Is.EqualTo(2)); + Assert.That(_created[1].Frames.Count, Is.EqualTo(2)); + } + + [Test] + public void ExistingPipelineReceivesBorrowedMemoryDirectlyWithoutAllocating() + { + var frame = Frame(new byte[] { 1, 2 }); + _input.Feed(frame, "dmd0"); + _created[0].Capture = false; + for (int i = 0; i < 1000; i++) _input.Feed(frame, "dmd0"); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1000; i++) _input.Feed(frame, "dmd0"); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.That(allocated, Is.Zero); + Assert.That(_created.Count, Is.EqualTo(1)); + Assert.That(_created[0].LastBorrowedPixels, Is.EqualTo(frame.Pixels)); + } + + private sealed class Pipeline : IManagedDmdPipeline + { + internal readonly string Id; + internal readonly int Width, Height; + internal readonly List Frames = new List(); + private readonly TaskCompletionSource _stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + internal bool Disposed; + internal bool Capture = true; + internal bool FailNextStop; + internal int StopRequests; + internal ReadOnlyMemory LastBorrowedPixels; + internal Pipeline(string id, int width, int height) { Id = id; Width = width; Height = height; } + public bool Matches(string id, int width, int height) => Id == id && Width == width && Height == height; + public void Push(in PluginDisplayFrame frame) + { + if (Disposed) throw new ObjectDisposedException(nameof(Pipeline)); + LastBorrowedPixels = frame.Pixels; + if (Capture) Frames.Add(new PluginDisplayFrame(frame.Pixels.ToArray(), frame.Width, frame.Height, frame.Stride, frame.Encoding, frame.Sequence, frame.TimestampUs, frame.Instance, frame.ColorSpace, frame.Alpha)); + } + public Task StopWorkerAsync() + { + StopRequests++; + if (FailNextStop) { FailNextStop = false; return Task.FromException(new InvalidOperationException("Worker stop failed.")); } + return _stopped.Task; + } + internal void FinishStop() => _stopped.TrySetResult(true); + public void Dispose() { Assert.That(_stopped.Task.IsCompleted, Is.True); Disposed = true; } + } + } +} diff --git a/Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj b/Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj new file mode 100644 index 0000000..ff8b561 --- /dev/null +++ b/Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + true + false + ../../../VisualPinball.Engine + + + + + + + + + + diff --git a/VisualPinball.Engine.DMD.Unity/README.md b/VisualPinball.Engine.DMD.Unity/README.md index b03773d..e90d192 100644 --- a/VisualPinball.Engine.DMD.Unity/README.md +++ b/VisualPinball.Engine.DMD.Unity/README.md @@ -4,3 +4,7 @@ Unity package for DMD Extensions / LibDmd integration in VPE. Build the sibling `VisualPinball.Engine.DMD` proxy project before importing this package locally so the `Plugins/` folders contain the required binaries. + +This package includes its VPE Player integration. Configuration controls are rendered by the shared Player from the plugin's field schema and discovery providers; no extra Player package is needed. + +The DMD runtime retains its GPL-2.0 license. The Player integration under `Runtime/Plugins` is separately [MIT licensed](Runtime/Plugins/LICENSE); its source headers and that license apply to those files. Native DMD dependencies retain their upstream licenses. diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs index 792117c..e9341ca 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs @@ -1,3 +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. + using UnityEngine; using UnityEngine.SceneManagement; using VisualPinball.Unity; @@ -11,6 +17,7 @@ internal static class DmdBridgeBootstrapper [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void StartWatcher() { + if (PlayerIntegrationMode.HostManaged) return; if (_watcher != null) { return; } @@ -53,6 +60,7 @@ private void OnSceneLoaded(Scene scene, LoadSceneMode mode) private static void Install() { + if (PlayerIntegrationMode.HostManaged) return; foreach (var player in Object.FindObjectsByType(FindObjectsInactive.Include)) { if (player.GetComponent() == null) { player.gameObject.AddComponent(); diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs index 5e5a2b4..0d59c02 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs @@ -1,8 +1,16 @@ +// 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.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Threading.Tasks; +using System.Threading; using LibDmd; using LibDmd.Common; using LibDmd.Converter; @@ -20,6 +28,7 @@ using UnityEngine; using UnityEngine.Serialization; using VisualPinball.Unity; +using VisualPinball.Plugins; using Logger = NLog.Logger; namespace VisualPinball.Engine.DMD.Unity @@ -92,6 +101,13 @@ public class DmdBridgePlayer : MonoBehaviour private Player _player; private IGamelogicEngine _gamelogicEngine; + private bool _managed; + private bool _shutdown; + private GameObject _managedScene; + private Func _presentationDestination; + private OwnerScheduler _ownerScheduler; + private Task _shutdownTask; + private ManagedDmdInput _managedInput; private DmdPipeline _pipeline; private DisplayConfig _currentDisplay; private DmdBridgeSettings _settings; @@ -114,6 +130,7 @@ public class DmdBridgePlayer : MonoBehaviour private IEnumerator Start() { + if (_managed || PlayerIntegrationMode.HostManaged) yield break; _player = GetComponent(); if (_player == null) { Logger.Warn("[DMD] DmdBridgePlayer must be attached to the same GameObject as Player."); @@ -137,9 +154,56 @@ private IEnumerator Start() Logger.Info("[DMD] Bridge connected to gamelogic display events."); } - private void OnDestroy() + /// Configures direct resource input; automatic GLE subscriptions and INI polling stay inactive. + public void ConfigureManaged(DmdBridgeSettings settings, GameObject scene, Func presentationDestination, OwnerScheduler ownerScheduler, Func sourceIsCurrent = null) + { + if (_pipeline != null) throw new InvalidOperationException("Configure the DMD bridge before starting its pipeline."); + _managed = true; _settings = settings.Clone(); _managedScene = scene; _presentationDestination = presentationDestination; _ownerScheduler = ownerScheduler; + _managedInput = new ManagedDmdInput(ownerScheduler, (displayId, width, height) => { + EnsurePipeline(new DisplayConfig(displayId, width, height), force: true); + return _pipeline; + }, sourceIsCurrent, error => Logger.Warn(error, "[DMD] Could not replace the display graph; the next frame will retry.")); + } + public void FeedFrame(in PluginDisplayFrame frame, string displayId) + { + if (!_managed || _shutdown) return; + _managedInput.Feed(frame, displayId); + } + private void OnDestroy() => Shutdown(); + public Task ShutdownCompletion => _shutdownTask ?? Task.CompletedTask; + public async Task ShutdownAsync(CancellationToken cancellation = default) + { + _shutdownTask ??= ShutdownManagedAsync(); + if (await Task.WhenAny(_shutdownTask, Task.Delay(Timeout.Infinite, cancellation)) != _shutdownTask) { + Shutdown(); // Deferred graph disposal never races the worker. + throw new OperationCanceledException(cancellation); + } + await _shutdownTask; + } + private async Task ShutdownManagedAsync() { - RequestSourceFrameFormat(DisplayFrameFormat.Dmd8); + _shutdown = true; enabled = false; + if (_managedInput != null) { + await _managedInput.ShutdownAsync().ConfigureAwait(false); + await _ownerScheduler.Switch(); + _pipeline = null; + return; + } + if (_ownerScheduler != null) await _ownerScheduler.Switch(); + var pipeline = _pipeline; + if (pipeline != null) await pipeline.StopWorkerAsync().ConfigureAwait(false); + if (_ownerScheduler != null) await _ownerScheduler.Switch(); + pipeline?.Dispose(); _pipeline = null; + } + public void Shutdown() + { + if (_managed) { + _shutdownTask ??= ShutdownManagedAsync(); + return; + } + if (_shutdown) { _pipeline?.Dispose(); return; } + _shutdown = true; enabled = false; + if (!_managed) RequestSourceFrameFormat(DisplayFrameFormat.Dmd8); if (_gamelogicEngine != null) { _gamelogicEngine.OnDisplaysRequested -= HandleDisplaysRequested; @@ -155,7 +219,8 @@ private void Update() { // Drives a host-pumped native window (macOS/SDL) on the main thread; no-op on the // self-driven Windows backend. Must run every frame. - _pipeline?.PumpMainThread(); + (_managed ? _managedInput?.Pipeline as DmdPipeline : _pipeline)?.PumpMainThread(); + if (_managed || PlayerIntegrationMode.HostManaged) return; // Settings (component fields + DmdDevice.ini) change rarely; checking ~4x/sec keeps the // per-frame INI File.Exists/GetLastWriteTime stat off the hot path. @@ -315,6 +380,7 @@ private void EnsurePipeline(DisplayConfig display, bool force = false) private void RequestSourceFrameFormat(DisplayFrameFormat format) { + if (_managed) return; if (_gamelogicEngine is IDisplayFrameFormatPreference frameFormatPreference) { frameFormatPreference.RequestDisplayFrameFormat(_settings?.TargetDisplayId ?? _targetDisplayId, format); } @@ -333,6 +399,7 @@ private void CaptureAppliedSettings() private List CreateDestinations(DisplayConfig display) { var destinations = new List(); + if (_presentationDestination != null) destinations.Add(_presentationDestination()); if (_settings.EnableZeDmd) { try { @@ -433,7 +500,7 @@ private List CreateDestinations(DisplayConfig display) } if (_settings.EnableInSceneDisplay) { - var inSceneDisplay = InSceneDmdDestination.TryCreate(display); + var inSceneDisplay = InSceneDmdDestination.TryCreate(display, _managedScene); if (inSceneDisplay != null) { destinations.Add(inSceneDisplay); } @@ -723,7 +790,7 @@ private static void CopyPresentationSettings(DmdBridgeSettings source, DmdBridge } } - internal sealed class DmdBridgeSettings : IEquatable + public sealed class DmdBridgeSettings : IEquatable { public string TargetDisplayId; public bool EnableZeDmd; @@ -838,14 +905,14 @@ public bool TopologyEquals(DmdBridgeSettings other) } } - internal static class DmdBridgeConfig + public static class DmdBridgeConfig { private const string EnvConfig = "DMDDEVICE_CONFIG"; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - public static DmdBridgeSettings Load(string path, DmdBridgeSettings fallback) + public static DmdBridgeSettings Load(string path, DmdBridgeSettings fallback, bool exactPath = false) { - var resolvedPath = ResolveConfigPath(path); + var resolvedPath = exactPath ? Path.GetFullPath(path) : ResolveConfigPath(path); if (string.IsNullOrWhiteSpace(resolvedPath) || !File.Exists(resolvedPath)) { Logger.Warn($"[DMD] No DmdDevice.ini found at \"{resolvedPath}\"; using component settings."); return fallback.Clone(); diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs index bb20b51..5f92e27 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs @@ -1,12 +1,20 @@ +// 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.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using LibDmd; using LibDmd.Converter; using LibDmd.Output; using LibDmd.Output.NativeWindow; using NLog; using VisualPinball.Unity; +using VisualPinball.Plugins; using Logger = NLog.Logger; namespace VisualPinball.Engine.DMD.Unity @@ -24,7 +32,7 @@ namespace VisualPinball.Engine.DMD.Unity /// thread (a host-pumped native window) re-marshal back to the main thread themselves: /// the in-scene destination via its own frame pump, the host-pumped window via . /// - internal sealed class DmdPipeline : IDisposable + internal sealed class DmdPipeline : IManagedDmdPipeline { private readonly DisplayConfig _display; private readonly VpeGleSource _source; @@ -47,6 +55,9 @@ internal sealed class DmdPipeline : IDisposable private bool _hasPending; private bool _clearRequested; private volatile bool _running = true; + private readonly TaskCompletionSource _stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly SynchronizationContext _owner = SynchronizationContext.Current; + private bool _disposed; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -86,6 +97,12 @@ public bool Matches(DisplayConfig display) && _display.FlipX == display.FlipX; } + public bool Matches(string displayId, int width, int height) + { + return string.Equals(_display.Id, displayId, StringComparison.OrdinalIgnoreCase) + && _display.Width == width && _display.Height == height; + } + /// Called on the Unity main thread. Copies the frame and signals the worker. public void Push(DisplayFrameData frame) { @@ -105,6 +122,18 @@ public void Push(DisplayFrameData frame) _signal.Set(); } + public void Push(in PluginDisplayFrame frame) + { + int rowBytes = frame.Width * (frame.Encoding == DisplayEncoding.Rgb24 ? 3 : 1); + int length = checked(rowBytes * frame.Height); + lock (_gate) { + if (!_running) return; + if (_pending == null || _pending.Length < length) _pending = new byte[length]; + for (int row = 0; row < frame.Height; row++) frame.Pixels.Span.Slice(row * frame.Stride, rowBytes).CopyTo(_pending.AsSpan(row * rowBytes, rowBytes)); + _pendingLength = length; _pendingFormat = GamelogicPluginResources.ToLegacy(frame.Encoding); _hasPending = true; + } + _signal.Set(); + } public void Clear() { lock (_gate) { @@ -154,62 +183,72 @@ public bool TryReadNativeWindowLayout(out int left, out int top, out int width, private void WorkerLoop() { - while (_running) { - _signal.WaitOne(); - + try { while (_running) { - bool doClear; - bool doFrame; - DisplayFrameFormat format = default; - int length = 0; - - lock (_gate) { - doClear = _clearRequested; - _clearRequested = false; - - doFrame = _hasPending; - _hasPending = false; - if (doFrame) { - // Swap the just-written buffer in; the old work buffer becomes the next - // pending slot. No allocation, no copy held under the lock beyond the swap. - var swap = _work; - _work = _pending; - _pending = swap; - format = _pendingFormat; - length = _pendingLength; - } - } + _signal.WaitOne(); - if (!_running) { - return; - } + while (_running) { + bool doClear; + bool doFrame; + DisplayFrameFormat format = default; + int length = 0; + + lock (_gate) { + doClear = _clearRequested; + _clearRequested = false; + + doFrame = _hasPending; + _hasPending = false; + if (doFrame) { + // Swap the just-written buffer in; the old work buffer becomes the next + // pending slot. No allocation, no copy held under the lock beyond the swap. + var swap = _work; + _work = _pending; + _pending = swap; + format = _pendingFormat; + length = _pendingLength; + } + } - try { - if (doClear) { - _renderGraph.ClearDisplay(); + if (!_running) { + return; } - if (doFrame) { - _source.Push(format, _work, length); + + try { + if (doClear) { + _renderGraph.ClearDisplay(); + } + if (doFrame) { + _source.Push(format, _work, length); + } + } catch (Exception exception) { + Logger.Warn(exception, "[DMD] DMD worker failed to render a frame."); } - } catch (Exception exception) { - Logger.Warn(exception, "[DMD] DMD worker failed to render a frame."); - } - if (!doClear && !doFrame) { - break; + if (!doClear && !doFrame) { + break; + } } } - } + } finally { _stopped.TrySetResult(true); } } + public Task StopWorkerAsync() + { + if (_disposed) return Task.CompletedTask; + _running = false; _signal.Set(); return _stopped.Task; + } public void Dispose() { + if (_disposed) return; _running = false; _signal.Set(); if (_worker != null && _worker.IsAlive && !_worker.Join(TimeSpan.FromSeconds(1))) { - Logger.Warn("[DMD] DMD worker thread did not stop within 1s."); + Logger.Warn("[DMD] Waiting for the native worker before releasing its graph."); + if (_owner != null) _stopped.Task.ContinueWith(_ => _owner.Post(__ => Dispose(), null), TaskScheduler.Default); + return; } - + _disposed = true; _renderer?.Dispose(); _renderGraph?.Dispose(); _signal.Dispose(); diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs b/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs index 933b3ec..2034733 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs @@ -1,5 +1,12 @@ +// 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.Collections.Generic; +using System.Linq; using LibDmd; using LibDmd.Frame; using LibDmd.Output; @@ -44,9 +51,9 @@ private InSceneDmdDestination(DisplayComponent display, DisplayConfig config) public Dimensions FixedSize => _size; public bool DmdAllowHdScaling => false; - public static InSceneDmdDestination TryCreate(DisplayConfig config) + public static InSceneDmdDestination TryCreate(DisplayConfig config, GameObject scene = null) { - var display = FindDisplay(config.Id); + var display = scene != null ? scene.GetComponentsInChildren(true).FirstOrDefault(candidate => candidate.Id == config.Id) : FindDisplay(config.Id); if (display == null) { Logger.Info($"[DMD] No in-scene display found for \"{config.Id}\"."); return null; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs b/VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs new file mode 100644 index 0000000..b17be70 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs @@ -0,0 +1,125 @@ +// 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.Tasks; +using VisualPinball.Plugins; + +namespace VisualPinball.Engine.DMD.Unity +{ + internal interface IManagedDmdPipeline : IDisposable + { + bool Matches(string displayId, int width, int height); + void Push(in PluginDisplayFrame frame); + Task StopWorkerAsync(); + } + + /// Replaces a display graph after its worker stops, retaining the latest frame during the handoff. + internal sealed class ManagedDmdInput + { + private readonly OwnerScheduler _owner; + private readonly Func _create; + private readonly Func _sourceIsCurrent; + private readonly Action _reportError; + private long _instance; + private Task _replacement; + private Task _shutdownTask; + private bool _shutdown; + private bool _replacementRequired; + private byte[] _pendingPixels; + private PluginDisplayFrame _pendingFrame; + private string _pendingDisplayId; + internal IManagedDmdPipeline Pipeline { get; private set; } + + internal ManagedDmdInput(OwnerScheduler owner, Func create, Func sourceIsCurrent = null, Action reportError = null) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + _create = create ?? throw new ArgumentNullException(nameof(create)); + _sourceIsCurrent = sourceIsCurrent; + _reportError = reportError; + } + + internal void Feed(in PluginDisplayFrame frame, string displayId) + { + _owner.VerifyOwner(); + if (_shutdown) return; + if (_replacement != null && !_replacement.IsCompleted) { + Retain(frame, displayId); + return; + } + _replacement = null; + if (Pipeline != null && (_replacementRequired || _instance != frame.Instance || !Pipeline.Matches(displayId, frame.Width, frame.Height))) { + Retain(frame, displayId); + _replacement = ReplaceAsync(Pipeline); + return; + } + StartOrPush(frame, displayId); + } + + private void Retain(in PluginDisplayFrame frame, string displayId) + { + int length = checked(frame.Stride * frame.Height); + if (_pendingPixels == null || _pendingPixels.Length < length) _pendingPixels = new byte[length]; + frame.Pixels.Span.Slice(0, length).CopyTo(_pendingPixels); + _pendingFrame = new PluginDisplayFrame(_pendingPixels.AsMemory(0, length), frame.Width, frame.Height, frame.Stride, + frame.Encoding, frame.Sequence, frame.TimestampUs, frame.Instance, frame.ColorSpace, frame.Alpha); + _pendingDisplayId = displayId; + } + + private void StartOrPush(in PluginDisplayFrame frame, string displayId) + { + Pipeline ??= _create(displayId, frame.Width, frame.Height); + _instance = frame.Instance; + Pipeline?.Push(frame); + } + + private async Task ReplaceAsync(IManagedDmdPipeline previous) + { + try { + await previous.StopWorkerAsync().ConfigureAwait(false); + await _owner.Switch(); + previous.Dispose(); + Pipeline = null; + _replacementRequired = false; + if (_shutdown) return; + // Push copies synchronously into the replacement's worker buffer. No further source event is needed. + if (_sourceIsCurrent == null || _sourceIsCurrent(_pendingFrame.Instance)) StartOrPush(_pendingFrame, _pendingDisplayId); + } catch (Exception error) { + await _owner.Switch(); + // A failed stop is not proof of worker completion. Keep its graph until a later stop succeeds. + _replacementRequired = Pipeline != null; + _reportError?.Invoke(error); + } finally { + await _owner.Switch(); + _pendingFrame = default; + _pendingDisplayId = null; + } + } + + internal Task ShutdownAsync() + { + _owner.VerifyOwner(); + if (_shutdownTask != null) return _shutdownTask; + _shutdown = true; + _pendingFrame = default; + _pendingDisplayId = null; + _pendingPixels = null; + return _shutdownTask = StopAsync(); + } + + private async Task StopAsync() + { + if (_replacement != null) await _replacement.ConfigureAwait(false); + await _owner.Switch(); + var pipeline = Pipeline; + if (pipeline == null) return; + await pipeline.StopWorkerAsync().ConfigureAwait(false); + await _owner.Switch(); + pipeline.Dispose(); + Pipeline = null; + } + } +} diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs.meta new file mode 100644 index 0000000..d04c219 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41c4e33b364b4798a867e33c9ce0492f diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins.meta new file mode 100644 index 0000000..a7aca9e --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fcfd55b68ac6ade488514c17d10c5b57 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs new file mode 100644 index 0000000..2674b11 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs @@ -0,0 +1,87 @@ +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using VisualPinball.Plugins; + +namespace VisualPinball.Engine.DMD.Player +{ + internal sealed class ColorizationAsset + { + internal string Id { get; } + internal string Label { get; } + internal string[] Files { get; } + internal bool RequiresKey => Files.Any(file => Path.GetExtension(file).Equals(".pac", StringComparison.OrdinalIgnoreCase)); + internal ColorizationAsset(string id, string label, params string[] files) { Id = id; Label = label; Files = files; } + } + internal static class ColorizationCatalog + { + internal static IReadOnlyList Read(string root, string game) + { + if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(game)) return Array.Empty(); + if (game != Path.GetFileName(game) || game == "." || game == "..") throw new ArgumentException("Invalid game key."); + root = Path.GetFullPath(root); + string directory = Path.Combine(root, game); + if (!Directory.Exists(directory)) return Array.Empty(); + var files = Directory.EnumerateFiles(directory, "*", new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true, AttributesToSkip = FileAttributes.ReparsePoint }).Take(5001).ToArray(); + if (files.Length > 5000) throw new InvalidOperationException("The game's colorization folder contains too many files."); + var assets = new List(); + foreach (string file in files.OrderBy(file => file, StringComparer.OrdinalIgnoreCase)) { + if (Path.GetFileName(file).StartsWith("._", StringComparison.Ordinal)) continue; // macOS resource forks + string extension = Path.GetExtension(file).ToLowerInvariant(); + if (extension == ".crz" || extension == ".crom" || extension == ".cromc" || extension == ".pac") assets.Add(new ColorizationAsset(Relative(root, file), Path.GetFileNameWithoutExtension(file) + " · " + (extension == ".pac" ? "PAC" : "Serum"), file)); + if (extension != ".pal") continue; + string stem = Path.Combine(Path.GetDirectoryName(file), Path.GetFileNameWithoutExtension(file)); + var animation = files.FirstOrDefault(candidate => string.Equals(Path.Combine(Path.GetDirectoryName(candidate), Path.GetFileNameWithoutExtension(candidate)), stem, StringComparison.OrdinalIgnoreCase) && (Path.GetExtension(candidate).Equals(".vni", StringComparison.OrdinalIgnoreCase) || Path.GetExtension(candidate).Equals(".fsq", StringComparison.OrdinalIgnoreCase))); + assets.Add(new ColorizationAsset(Relative(root, file), Path.GetFileNameWithoutExtension(file) + " · " + (animation == null ? "Palette" : "VNI/PAL"), animation == null ? new[] { file } : new[] { file, animation })); + } + return assets; + } + private static bool InGameFolder(ColorizationAsset asset, string game) => asset.Id.StartsWith(game + "/", StringComparison.OrdinalIgnoreCase) && asset.Id.IndexOf('/', game.Length + 1) < 0; + private static bool IsSerum(string file) => Path.GetExtension(file).ToLowerInvariant() is ".crz" or ".crom" or ".cromc"; + private static string Relative(string root, string file) => Path.GetRelativePath(root, file).Replace('\\', '/'); + /// Whether a PAC key is configured; PAC files only win automatic selection with one. + internal static ColorizationAsset Select(string root, string game, string selection, bool hasPacKey = false) + { + if (selection == "none") return null; + var assets = Read(root, game); + if (selection == "auto") { + if (assets.Count <= 1) return assets.FirstOrDefault(); + // Several: what DmdDevice itself loads from the game folder, like the standalone bridge did. Serum first (the newest + // .cRZ, .cROM or .cROMc), then VNI, which prefers pin2dmd.pac over pin2dmd.pal when it can decrypt it. + var serum = assets.Where(asset => InGameFolder(asset, game) && IsSerum(asset.Files[0])) + .OrderByDescending(asset => File.GetCreationTimeUtc(asset.Files[0])).FirstOrDefault(); + if (serum != null) return serum; + foreach (string standard in hasPacKey ? new[] { "pin2dmd.pac", "pin2dmd.pal" } : new[] { "pin2dmd.pal", "pin2dmd.pac" }) { + var match = assets.FirstOrDefault(asset => string.Equals(asset.Id, game + "/" + standard, StringComparison.OrdinalIgnoreCase)); + if (match != null) return match; + } + throw new InvalidOperationException("Several colorizations are available. Select one for this game."); + } + return assets.FirstOrDefault(asset => asset.Id == selection) ?? throw new FileNotFoundException("Selected colorization is missing: " + selection); + } + /// LibDmd accepts a game folder. An owned cache presents exactly the selected files, leaving the library unchanged. + internal static IDisposable Stage(ColorizationAsset asset, string cache, string game, out string root) + { + if (game != Path.GetFileName(game) || game == "." || game == "..") throw new ArgumentException("Invalid game key."); + root = Path.GetFullPath(Path.Combine(cache, Guid.NewGuid().ToString("N"))); + string owned = root; + if (!owned.StartsWith(Path.GetFullPath(cache) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Invalid cache location."); + Directory.CreateDirectory(Path.Combine(owned, game)); + try { + foreach (string source in asset.Files) { + string extension = Path.GetExtension(source).ToLowerInvariant(); + string filename = extension == ".crz" ? game + ".cRZ" : extension == ".crom" ? game + ".cROM" : extension == ".cromc" ? game + ".cROMc" : "pin2dmd" + extension; + File.Copy(source, Path.Combine(owned, game, filename)); + } + } catch { Directory.Delete(owned, true); throw; } + return new Subscription(() => { if (Directory.Exists(owned)) Directory.Delete(owned, true); }); + } + } +} diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs.meta new file mode 100644 index 0000000..13373a1 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: df441dce64efa7c4da0ecbe1257c6bcc \ No newline at end of file diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs new file mode 100644 index 0000000..c937408 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs @@ -0,0 +1,100 @@ +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using VisualPinball.Engine.DMD.Unity; +using VisualPinball.Plugins; +using VisualPinball.Unity; + +namespace VisualPinball.Engine.DMD.Player +{ + internal sealed class DmdPlugin : PlayerPlugin, IPluginTick, IPluginEmergencyStop, IPluginDeferredCleanup + { + private readonly PluginApplicationContext _application; + private readonly List _presentations = new List(); + private readonly ResourceScope _nativeResources = new ResourceScope(); + private bool _releasePending; + private DmdPresentationDestination[] _dispatch = Array.Empty(); + public Task Quiescence { get; private set; } = Task.CompletedTask; + private DmdBridgePlayer _bridge; + private GameObject _root; + private PluginResourceBinding _binding; + internal DmdPlugin(PluginApplicationContext application) { _application = application; } + public override Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation) + { + var scene = game.Capabilities.Require(UnityPluginCapability.GameScene); + var settings = DmdPluginFactory.Settings(game.Settings); + settings.RomName = game.Identity.Key; + var asset = ColorizationCatalog.Select(DmdPluginFactory.ColorizationLibrary(game.Settings.String("AltColorRoot")), game.Identity.Key, game.Settings.String("Colorization", "auto"), game.Settings.String("VniKey").Length > 0); + settings.EnableColorization = asset != null; + if (asset != null) { + _nativeResources.Own(ColorizationCatalog.Stage(asset, Path.Combine(_application.Configuration.DirectoryFor(_application.Plugin), "cache", "colorizations"), game.Identity.Key, out string staged)); + settings.AltColorPath = staged; + } + var outputs = game.Capabilities.Require(UnityPluginCapability.Outputs); + foreach (var output in OutputIds(settings)) _nativeResources.Own(outputs.Acquire(output, _application.Plugin.Id)); + _root = new GameObject("DMD plugin"); _root.SetActive(false); _root.transform.SetParent(scene.Table.transform, false); + game.Lifetime.Own(new Subscription(() => { if (_root != null) UnityEngine.Object.Destroy(_root); })); + _bridge = _root.AddComponent(); + var selection = new PluginResourceSelection(ResourceKind.Display) { Controller = game.ControllerId, Session = game.Session, Role = "dmd", + Intent = asset == null ? ResourceView.Original : ResourceView.Identification, + Accepts = asset == null ? DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8 | DisplayEncoding.Rgb24 : DisplayEncoding.Gray2 | DisplayEncoding.Gray4 }; + string source = game.Settings.String("Source", "auto"); if (source != "auto") selection.ResourceId = PluginResourceChoices.Resolve(game.ControllerId, source); + _binding = game.Lifetime.Own(game.Resources.Bind(selection)); + _bridge.ConfigureManaged(settings, scene.Table, () => { + _application.Scheduler.VerifyOwner(); + var destination = new DmdPresentationDestination(_application, _binding.Descriptor); + _presentations.RemoveAll(presentation => !presentation.IsAvailable); _presentations.Add(destination); _dispatch = _presentations.ToArray(); return destination; + }, _application.Scheduler, instance => _binding.State == ResourceBindingState.Bound && _binding.Descriptor?.Instance == instance); + game.Lifetime.Own(_binding.SubscribeFrames(frame => { + if (_bridge != null) _bridge.FeedFrame(frame, _binding.Descriptor.Id.Substring(_binding.Descriptor.Id.LastIndexOf('/') + 1)); + })); + _binding.Changed += ReportBinding; + game.Lifetime.Own(new Subscription(() => _binding.Changed -= ReportBinding)); + _root.SetActive(true); + return Task.CompletedTask; + } + private static IEnumerable OutputIds(DmdBridgeSettings settings) + { + if (settings.EnableZeDmd) yield return string.IsNullOrWhiteSpace(settings.ZeDmdPort) ? "usb:zedmd:auto" : "serial:" + settings.ZeDmdPort.ToUpperInvariant(); + if (settings.EnablePinDmd3) yield return string.IsNullOrWhiteSpace(settings.PinDmd3Port) ? "usb:pindmd3:auto" : "serial:" + settings.PinDmd3Port.ToUpperInvariant(); + if (settings.EnablePixelcade) yield return string.IsNullOrWhiteSpace(settings.PixelcadePort) ? "usb:pixelcade:auto" : "serial:" + settings.PixelcadePort.ToUpperInvariant(); + if (settings.EnablePin2Dmd) yield return "usb:pin2dmd:auto"; + if (settings.EnablePinDmd1) yield return "usb:pindmd1:auto"; + if (settings.EnablePinDmd2) yield return "usb:pindmd2:auto"; + } + private void ReportBinding() { if (_binding.State != ResourceBindingState.Bound) _application.Log(_binding.Diagnostic); } + public void Tick(double unscaledSeconds) { foreach (var presentation in _dispatch) presentation.Pump(); } + public override async Task StopGameAsync(CancellationToken cancellation) + { + try { if (_bridge != null) await _bridge.ShutdownAsync(cancellation); } + finally { + await _application.Scheduler.Switch(); + if (_bridge != null) Quiescence = _bridge.ShutdownCompletion; + ReleaseWhenStopped(); _presentations.Clear(); _dispatch = Array.Empty(); + } + } + public void EmergencyStop() { if (_bridge != null) { _bridge.Shutdown(); Quiescence = _bridge.ShutdownCompletion; } ReleaseWhenStopped(); } + public override void Dispose() => EmergencyStop(); + private void ReleaseWhenStopped() + { + if (_releasePending) return; + var stopped = _bridge != null ? _bridge.ShutdownCompletion : Task.CompletedTask; + _releasePending = true; + if (stopped.IsCompleted) _nativeResources.Dispose(); + else _ = stopped.ContinueWith(task => { + var ignored = task.Exception; + _application.Scheduler.Post(_nativeResources.Dispose); + }, TaskScheduler.Default); + } + } +} diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs.meta new file mode 100644 index 0000000..70408eb --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8af5269d23cb35c43a4d962dda113027 \ No newline at end of file diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs new file mode 100644 index 0000000..28551e7 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs @@ -0,0 +1,147 @@ +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LibDmd.Converter; +using LibDmd.Common; +using LibDmd.Output.Pixelcade; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; +using UnityEngine; +using VisualPinball.Engine.DMD.Unity; +using VisualPinball.Plugins; + +namespace VisualPinball.Engine.DMD.Player +{ + internal sealed class DmdPluginFactory : IPlayerPluginFactory, IPluginSettingsProvider + { + internal const string Id = "org.visualpinball.dmd"; + public PluginDescriptor Descriptor { get; } = new PluginDescriptor(Id, "DMD", "Colorizations, your in-scene DMD and native displays, with optional cabinet hardware.") { EnabledByDefault = true, Category = "Displays", Order = 20 }; + public PluginSettingsSchema Schema { get; } = BuildSchema(); + public IPluginSettingsProvider SettingsProvider => this; + public IPlayerPlugin Create(PluginApplicationContext context) => new DmdPlugin(context); + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void Register() => PlayerPluginRegistry.Register(new DmdPluginFactory()); + private static PluginField Field(string id, string label, SettingKind kind, JToken value, string provider = "", double? minimum = null, double? maximum = null) + => new PluginField(id, label, kind) { Default = value, Provider = provider, Scope = ConfigurationScope.GlobalAndGame, Minimum = minimum, Maximum = maximum }; + private static PluginField Hardware(string id, string label, SettingKind kind, JToken value, string provider = "", double? minimum = null, double? maximum = null) + { + var field = Field(id, label, kind, value, provider, minimum, maximum); field.Scope = ConfigurationScope.GlobalOnly; return field; + } + private static PluginField Coordinate(string id, string label, string row, int value, double? minimum = null, double? maximum = null) + { + var field = Field(id, label, SettingKind.Integer, value, minimum: minimum, maximum: maximum); field.Row = row; return field; + } + private static PluginSettingsSchema BuildSchema() => new PluginSettingsSchema(1, + new PluginSettingsGroup("Colorizations", "Choose an available version for each game. The original library stays unchanged.", + new PluginField("AltColorRoot", "Colorization library", SettingKind.Folder) { Default = "", Scope = ConfigurationScope.GlobalAndGame, FullWidth = true, Description = "The altcolor folder containing one folder per ROM. Leave empty to use the altcolor folder next to DmdDevice.ini or VPinMAME's. Games can override the global library." }, + Field("Colorization", "Colorization", SettingKind.Choice, "auto", "colorizations"), + Field("ColorizationScalerMode", "Scaler", SettingKind.Choice, "None", "scalers"), + new PluginField("VniKey", "PAC key", SettingKind.Text) { Default = "", FullWidth = true }), + new PluginSettingsGroup("Display outputs", "Native placement uses desktop pixels and allows negative coordinates on any monitor.", + new PluginField("Source", "DMD source", SettingKind.Choice) { Default = "auto", Scope = ConfigurationScope.GameOnly, Provider = "sources" }, + Field("EnableInSceneDisplay", "Use the table's DMD", SettingKind.Boolean, true), + Field("EnableNativeWindow", "Native borderless window", SettingKind.Boolean, false), + Coordinate("NativeWindowLeft", "Left", "native-position", 100), Coordinate("NativeWindowTop", "Top", "native-position", 100), + Coordinate("NativeWindowWidth", "Width", "native-size", 512, minimum: 1, maximum: 16384), Coordinate("NativeWindowHeight", "Height", "native-size", 128, minimum: 1, maximum: 16384), + Field("NativeWindowStayOnTop", "Keep on top", SettingKind.Boolean, false), Field("FlipHorizontally", "Mirror horizontally", SettingKind.Boolean, false)), + new PluginSettingsGroup("Cabinet hardware", "Outputs are acquired exclusively. A disabled plugin opens no device.", + Hardware("EnableZeDmd", "ZeDMD", SettingKind.Boolean, false), Hardware("ZeDmdPort", "ZeDMD port", SettingKind.Text, ""), Hardware("ZeDmdBrightness", "ZeDMD brightness", SettingKind.Integer, 8, minimum: 0, maximum: 15), Hardware("ZeDmdDebug", "ZeDMD diagnostics", SettingKind.Boolean, false), + Hardware("EnablePinDmd3", "PinDMD 3", SettingKind.Boolean, false), Hardware("PinDmd3Port", "PinDMD 3 port", SettingKind.Text, ""), + Hardware("EnablePixelcade", "Pixelcade", SettingKind.Boolean, false), Hardware("PixelcadePort", "Pixelcade port", SettingKind.Text, ""), Hardware("PixelcadeColorMatrix", "Pixelcade color order", SettingKind.Choice, "Rgb", "matrices"), + Hardware("EnablePin2Dmd", "PIN2DMD", SettingKind.Boolean, false), Hardware("Pin2DmdOutputDelay", "PIN2DMD delay (ms)", SettingKind.Integer, 25, minimum: 0, maximum: 1000), + Hardware("EnablePinDmd1", "PinDMD 1", SettingKind.Boolean, false), Hardware("EnablePinDmd2", "PinDMD 2", SettingKind.Boolean, false)), + new PluginSettingsGroup("Appearance", "Presentation settings apply to native DMD windows.", + Field("DotSize", "Dot size", SettingKind.Number, 0.92, minimum: 0.05, maximum: 1.5), Field("DotRounding", "Rounding", SettingKind.Number, 1, minimum: 0, maximum: 1), Field("DotSharpness", "Sharpness", SettingKind.Number, 0.8, minimum: 0, maximum: 1), + Field("Brightness", "Brightness", SettingKind.Number, 0.95, minimum: 0, maximum: 4), Field("DotGlow", "Dot glow", SettingKind.Number, 0, minimum: 0, maximum: 4), Field("BackGlow", "Background glow", SettingKind.Number, 0, minimum: 0, maximum: 4), Field("Gamma", "Gamma", SettingKind.Number, 1, minimum: 0.1, maximum: 4), + Field("UnlitDot", "Unlit dot color", SettingKind.Color, "#000000"), Field("GlassColor", "Glass color", SettingKind.Color, "#000000"), Field("GlassLighting", "Glass lighting", SettingKind.Number, 0, minimum: 0, maximum: 4)), + new PluginSettingsGroup("Import existing settings", "Import once into the draft, review it, then Apply. The INI is never rewritten.", + new PluginField("ImportPath", "DmdDevice.ini", SettingKind.File) { Default = "", FullWidth = true }, new PluginField("Import", "Import INI", SettingKind.Action) { Provider = "import-ini" })); + internal static DmdBridgeSettings Settings(PluginSettingsSnapshot snapshot) + { + var values = (JObject)snapshot.Values.DeepClone(); values.Remove("UnlitDot"); values.Remove("GlassColor"); + var settings = values.ToObject(JsonSerializer.Create(new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.None })); + ColorUtility.TryParseHtmlString(snapshot.String("UnlitDot", "#000000"), out settings.UnlitDot); + ColorUtility.TryParseHtmlString(snapshot.String("GlassColor", "#000000"), out settings.GlassColor); + return settings; + } + public Task DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation) + => Task.Run(() => Discover(provider, request, cancellation), cancellation); + private PluginChoiceCatalog Discover(string provider, PluginChoiceRequest request, CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + if (provider == "sources") return PluginResourceChoices.Displays(request, DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8 | DisplayEncoding.Rgb24); + var choices = new List(); + switch (provider) { + case "colorizations": + choices.Add(new PluginChoice("auto", "Automatic", "Uses the sole available colorization, or what DmdDevice loads from the game folder: Serum first, then pin2dmd.pac with a PAC key, then pin2dmd.pal and .vni. Otherwise select one.")); choices.Add(new PluginChoice("none", "No colorization")); + foreach (var asset in ColorizationCatalog.Read(DmdPluginFactory.ColorizationLibrary(request.Settings.String("AltColorRoot")), request.Game?.Key ?? "")) choices.Add(new PluginChoice(asset.Id, asset.Label, available: !asset.RequiresKey || request.Settings.String("VniKey").Length > 0, unavailableReason: "A PAC key is required.")); + break; + case "scalers": foreach (string name in Enum.GetNames(typeof(ScalerMode))) choices.Add(new PluginChoice(name, name)); break; + case "matrices": foreach (string name in Enum.GetNames(typeof(ColorMatrix))) choices.Add(new PluginChoice(name, name)); break; + } + return new PluginChoiceCatalog(string.Join("|", choices.Select(choice => choice.Id)), choices); + } + /// The colorization library to use: the configured folder, or the first default altcolor folder that exists. + internal static string ColorizationLibrary(string configured) + { + if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim(); + foreach (string candidate in DefaultColorizationLibraries()) { + try { if (!string.IsNullOrWhiteSpace(candidate) && Directory.Exists(candidate)) return candidate; } catch (Exception) { } + } + return ""; + } + /// + /// Where DmdDevice and the standalone DMD bridge look: next to DmdDevice.ini, in VPinMAME's folder (VPM_HOME, or next to a ROM + /// folder, since VPinMAME keeps roms and altcolor side by side), then where LibDmd finds it. + /// + private static IEnumerable DefaultColorizationLibraries() + { + string config = null; + try { config = DmdBridgeConfig.ResolveEnvConfigPath(); } catch (Exception) { } + if (!string.IsNullOrWhiteSpace(config)) yield return Path.Combine(Path.GetDirectoryName(config) ?? "", "altcolor"); + string home = Environment.GetEnvironmentVariable("VPM_HOME"); + if (!string.IsNullOrWhiteSpace(home)) yield return Path.Combine(home.Trim().Trim('"'), "altcolor"); + foreach (string roms in (VisualPinball.Unity.GamelogicPaths.RomFolder ?? "").Split(';')) { + if (string.IsNullOrWhiteSpace(roms)) continue; + string parent = null; + try { parent = Path.GetDirectoryName(Path.GetFullPath(roms.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); } catch (Exception) { } + if (parent != null) yield return Path.Combine(parent, "altcolor"); + } + string libDmd = null; + try { libDmd = PathUtil.GetVpmFolder("altcolor", "[DMD]"); } catch (Exception) { } + yield return libDmd; + } + public IReadOnlyList Validate(PluginChoiceRequest request) + { + var issues = new List(); + foreach (string field in new[] { "UnlitDot", "GlassColor" }) if (!ColorUtility.TryParseHtmlString(request.Settings.String(field), out _)) issues.Add(new PluginValidationIssue(field, "Enter a color such as #FFAA00.")); + if (request.Game.HasValue) try { + var asset = ColorizationCatalog.Select(DmdPluginFactory.ColorizationLibrary(request.Settings.String("AltColorRoot")), request.Game.Value.Key, request.Settings.String("Colorization", "auto"), request.Settings.String("VniKey").Length > 0); + if (asset?.RequiresKey == true && request.Settings.String("VniKey").Length == 0) issues.Add(new PluginValidationIssue("VniKey", "A PAC key is required.")); + } catch (Exception error) { issues.Add(new PluginValidationIssue("Colorization", error.Message)); } + return issues; + } + public Task RunActionAsync(string action, PluginChoiceRequest request, CancellationToken cancellation) + { + if (action != "import-ini") return Task.FromResult(new PluginActionResult(false, "Unknown DMD action.")); + string path = request.Settings.String("ImportPath"); + if (!File.Exists(path)) return Task.FromResult(new PluginActionResult(false, "Choose an existing DmdDevice.ini.")); + var fallback = Settings(Schema.Defaults()); + fallback.RomName = request.Game?.Key; + var settings = DmdBridgeConfig.Load(path, fallback, true); + var values = JObject.FromObject(settings); values["UnlitDot"] = "#" + ColorUtility.ToHtmlStringRGB(settings.UnlitDot); values["GlassColor"] = "#" + ColorUtility.ToHtmlStringRGB(settings.GlassColor); + if (!string.IsNullOrWhiteSpace(settings.AltColorPath)) values["AltColorRoot"] = settings.AltColorPath; + values["Colorization"] = settings.EnableColorization ? "auto" : "none"; + return Task.FromResult(new PluginActionResult(true, "Imported into the draft. Review the values and Apply to save.", new PluginSettingsSnapshot(1, values))); + } + } +} diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs.meta new file mode 100644 index 0000000..dde8ddf --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b1b18202c8e71b4d98b6c2657d67995 \ No newline at end of file diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs new file mode 100644 index 0000000..63a633b --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs @@ -0,0 +1,69 @@ +// 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.Diagnostics; +using LibDmd; +using LibDmd.Frame; +using LibDmd.Output; +using VisualPinball.Plugins; + +namespace VisualPinball.Engine.DMD.Player +{ + /// LibDmd writes on its worker; the owner publishes the latest complete presentation frame. + internal sealed class DmdPresentationDestination : IRgb24Destination + { + private static long _instances; + private readonly DisplayResourceSource _source; + private readonly IDisposable _publication; + private readonly long _instance; + private readonly object _gate = new object(); + private byte[] _pending = Array.Empty(); + private byte[] _displayed = Array.Empty(); + private int _width, _height, _length; + private long _sequence; + private bool _dirty; + private bool _disposed; + internal DmdPresentationDestination(PluginApplicationContext application, PluginResourceDescriptor original) + { + _instance = Interlocked.Increment(ref _instances); + _source = new DisplayResourceSource(fault: error => application.Log(error.Message)); + _publication = application.Resources.Publish(new PluginResourceDescriptor(original.Id + "/presentation/" + application.Plugin.Id, application.Plugin.Id, + original.Controller, original.Label + " · DMD presentation", original.Role, ResourceKind.Display, original.Session, _instance, ResourceView.Presentation, DisplayEncoding.Rgb24, original.Id), _source); + } + public string Name => "VPE Presentation Resource"; + public bool IsAvailable => !_disposed; + public bool NeedsDuplicateFrames => false; + public bool NeedsIdentificationFrames => false; + // RGB24 frames already contain the graph's color and palette decisions. + public void SetColor(DmdColor color) { } + public void ClearColor() { } + public void SetPalette(DmdColor[] colors) { } + public void ClearPalette() { } + public void RenderRgb24(DmdFrame frame) + { + lock (_gate) { + if (_disposed) return; + int length = checked(frame.Dimensions.Width * frame.Dimensions.Height * 3); + if (_pending.Length < length) _pending = new byte[length]; + Buffer.BlockCopy(frame.Data, 0, _pending, 0, length); _length = length; _width = frame.Dimensions.Width; _height = frame.Dimensions.Height; _dirty = true; + } + } + internal void Pump() + { + int width, height, length; + lock (_gate) { + if (_disposed || !_dirty) return; + var swap = _displayed; _displayed = _pending; _pending = swap; + width = _width; height = _height; length = _length; _dirty = false; + } + _source.Publish(new PluginDisplayFrame(_displayed.AsMemory(0, length), width, height, width * 3, DisplayEncoding.Rgb24, ++_sequence, (long)(Stopwatch.GetTimestamp() * (1000000d / Stopwatch.Frequency)), _instance)); + } + public void ClearDisplay() { lock (_gate) { if (_pending.Length < _length) _pending = new byte[_length]; Array.Clear(_pending, 0, _pending.Length); _dirty = _length > 0; } } + public void Dispose() { lock (_gate) { if (_disposed) return; _disposed = true; } _publication.Dispose(); _source.Dispose(); } + } +} diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs.meta new file mode 100644 index 0000000..8394e99 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e9897d2871bb94045b12c8927a0c2171 \ No newline at end of file diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE new file mode 100644 index 0000000..d0efc58 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Visual Pinball Engine contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE.meta new file mode 100644 index 0000000..1f7933a --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c3f5b894384e9ae4f809b88cd4e5bc10 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef new file mode 100644 index 0000000..5d9e263 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef @@ -0,0 +1,6 @@ +{ + "name": "VisualPinball.Engine.DMD.Player", + "rootNamespace": "VisualPinball.Engine.DMD.Player", + "references": ["VisualPinball.Plugins", "VisualPinball.Unity", "VisualPinball.Engine.DMD.Unity", "Newtonsoft.Json"], + "autoReferenced": true +} diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef.meta new file mode 100644 index 0000000..2440246 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5006ffa8258ad7546aa8ad83f1d566d4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/VisualPinball.Engine.DMD.Unity.asmdef b/VisualPinball.Engine.DMD.Unity/Runtime/VisualPinball.Engine.DMD.Unity.asmdef index 53878a8..491afcb 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/VisualPinball.Engine.DMD.Unity.asmdef +++ b/VisualPinball.Engine.DMD.Unity/Runtime/VisualPinball.Engine.DMD.Unity.asmdef @@ -3,7 +3,8 @@ "rootNamespace": "VisualPinball.Engine.DMD.Unity", "references": [ "VisualPinball.Engine", - "VisualPinball.Unity" + "VisualPinball.Unity", + "VisualPinball.Plugins" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/VisualPinball.Engine.DMD.Unity/package.json b/VisualPinball.Engine.DMD.Unity/package.json index 09ffd73..5893c59 100644 --- a/VisualPinball.Engine.DMD.Unity/package.json +++ b/VisualPinball.Engine.DMD.Unity/package.json @@ -1,6 +1,6 @@ { "name": "org.visualpinball.engine.dmd", - "version": "0.0.1-preview.1", + "version": "0.0.1-preview.2", "displayName": "Visual Pinball Engine (DMD)", "description": "DMD Extensions / LibDmd support for VPE.", "keywords": [ @@ -12,7 +12,7 @@ "unity": "6000.5", "unityRelease": "0f1", "dependencies": { - "org.visualpinball.engine.unity": "0.0.1-preview.119" + "org.visualpinball.engine.unity": "0.0.1-preview.212" }, "author": "freezy ", "documentationUrl": "https://docs.visualpinball.org/", @@ -24,5 +24,6 @@ }, "publishConfig": { "registry": "https://registry.visualpinball.org" - } + }, + "license": "(GPL-2.0-only AND MIT)" }