From 75ff95d756c0345ba8931d89552c76baa4a6e063 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 13 Oct 2026 18:04:38 +0200 Subject: [PATCH 1/7] dmd: add player adapter and colorization selection --- Tests/Player/ColorizationCatalogTests.cs | 49 ++++++++ ...sualPinball.Engine.DMD.Player.Tests.csproj | 15 +++ VisualPinball.Engine.DMD.Player/LICENSE | 21 ++++ VisualPinball.Engine.DMD.Player/LICENSE.meta | 7 ++ VisualPinball.Engine.DMD.Player/README.md | 11 ++ .../README.md.meta | 7 ++ VisualPinball.Engine.DMD.Player/Runtime.meta | 8 ++ .../Runtime/ColorizationCatalog.cs | 69 +++++++++++ .../Runtime/ColorizationCatalog.cs.meta | 2 + .../Runtime/DmdPlugin.cs | 95 ++++++++++++++++ .../Runtime/DmdPlugin.cs.meta | 2 + .../Runtime/DmdPluginFactory.cs | 105 +++++++++++++++++ .../Runtime/DmdPluginFactory.cs.meta | 2 + .../Runtime/DmdPresentationDestination.cs | 64 +++++++++++ .../DmdPresentationDestination.cs.meta | 2 + .../VisualPinball.Engine.DMD.Player.asmdef | 6 + ...isualPinball.Engine.DMD.Player.asmdef.meta | 7 ++ VisualPinball.Engine.DMD.Player/package.json | 17 +++ .../package.json.meta | 7 ++ .../Runtime/DmdBridgeBootstrapper.cs | 2 + .../Runtime/DmdBridgePlayer.cs | 83 ++++++++++++-- .../Runtime/DmdPipeline.cs | 107 +++++++++++------- .../Runtime/InSceneDmdDestination.cs | 5 +- .../VisualPinball.Engine.DMD.Unity.asmdef | 3 +- VisualPinball.Engine.DMD.Unity/package.json | 4 +- 25 files changed, 648 insertions(+), 52 deletions(-) create mode 100644 Tests/Player/ColorizationCatalogTests.cs create mode 100644 Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj create mode 100644 VisualPinball.Engine.DMD.Player/LICENSE create mode 100644 VisualPinball.Engine.DMD.Player/LICENSE.meta create mode 100644 VisualPinball.Engine.DMD.Player/README.md create mode 100644 VisualPinball.Engine.DMD.Player/README.md.meta create mode 100644 VisualPinball.Engine.DMD.Player/Runtime.meta create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs.meta create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs.meta create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs.meta create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs.meta create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef create mode 100644 VisualPinball.Engine.DMD.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef.meta create mode 100644 VisualPinball.Engine.DMD.Player/package.json create mode 100644 VisualPinball.Engine.DMD.Player/package.json.meta diff --git a/Tests/Player/ColorizationCatalogTests.cs b/Tests/Player/ColorizationCatalogTests.cs new file mode 100644 index 0000000..29780f3 --- /dev/null +++ b/Tests/Player/ColorizationCatalogTests.cs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: 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() + { + Asset("game/one.crz"); Asset("game/two.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 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/VisualPinball.Engine.DMD.Player.Tests.csproj b/Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj new file mode 100644 index 0000000..45c07b5 --- /dev/null +++ b/Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj @@ -0,0 +1,15 @@ + + + net10.0 + true + false + ../../../VisualPinball.Engine + + + + + + + + + diff --git a/VisualPinball.Engine.DMD.Player/LICENSE b/VisualPinball.Engine.DMD.Player/LICENSE new file mode 100644 index 0000000..d0efc58 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/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.Player/LICENSE.meta b/VisualPinball.Engine.DMD.Player/LICENSE.meta new file mode 100644 index 0000000..7bf7f95 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/LICENSE.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: de0beacf6535fbc468e244a3f5992cfc +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Player/README.md b/VisualPinball.Engine.DMD.Player/README.md new file mode 100644 index 0000000..d4d6047 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/README.md @@ -0,0 +1,11 @@ +# DMD player plugin + +Add this optional package to the player application together with `org.visualpinball.engine.dmd`. The adapter owns configuration and resource subscriptions; legacy automatic bridges stay inactive in a host-managed player. + +Settings → Plugins → DMD provides a global altcolor library, cabinet device setup, native-window placement and appearance. Selected game can choose a colorization version and override supported display options. The library contains a folder per ROM; variant subfolders can hold Serum or PAC files, palettes, and paired VNI/FSQ animations. The selector offers only discovered assets, retains missing explicit selections, and blocks ambiguous Automatic selection until a version is chosen. PAC assets require the configured key. + +LibDmd receives an owned cache containing exactly the chosen files under its canonical filenames. Original colorization files remain unchanged. The cache and physical output reservations are released after the worker has stopped; a blocked worker keeps its reservation until deferred cleanup finishes. INI import is an explicit draft action: review the imported settings and Apply. The original INI is never rewritten. + +The adapter binds original display frames from the selected game's controller, negotiates raw identification when colorization requires it, and publishes colorization plus scaling as one derived presentation resource. PinUp and other identification consumers keep the original stream. Controller-relative source selections remain usable when configuring a stopped game. + +Native backends and cabinet devices depend on the base DMD package and its platform libraries. The portable catalog tests validate discovery, ambiguity and staging independently of Unity or hardware; real destinations require target-platform testing. diff --git a/VisualPinball.Engine.DMD.Player/README.md.meta b/VisualPinball.Engine.DMD.Player/README.md.meta new file mode 100644 index 0000000..54fd6c0 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 74b9e4497a4a1e94c96981ba44a17647 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Player/Runtime.meta b/VisualPinball.Engine.DMD.Player/Runtime.meta new file mode 100644 index 0000000..5312831 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fe64f821f5bb31d409218292c8441f7a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs b/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs new file mode 100644 index 0000000..61cadc9 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: 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)) { + 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 string Relative(string root, string file) => Path.GetRelativePath(root, file).Replace('\\', '/'); + internal static ColorizationAsset Select(string root, string game, string selection) + { + if (selection == "none") return null; + var assets = Read(root, game); + if (selection == "auto") { + if (assets.Count > 1) throw new InvalidOperationException("Several colorizations are available. Select one for this game."); + return assets.FirstOrDefault(); + } + 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.Player/Runtime/ColorizationCatalog.cs.meta b/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs.meta new file mode 100644 index 0000000..13373a1 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: df441dce64efa7c4da0ecbe1257c6bcc \ No newline at end of file diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs new file mode 100644 index 0000000..6b3e906 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: 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(game.Settings.String("AltColorRoot"), game.Identity.Key, game.Settings.String("Colorization", "auto")); + 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); + 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.Player/Runtime/DmdPlugin.cs.meta b/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs.meta new file mode 100644 index 0000000..70408eb --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8af5269d23cb35c43a4d962dda113027 \ No newline at end of file diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs new file mode 100644 index 0000000..82e9235 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: 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 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 = "", Description = "The altcolor folder containing one folder per ROM." }, + Field("Colorization", "Colorization", SettingKind.Choice, "auto", "colorizations"), + Field("ColorizationScalerMode", "Scaler", SettingKind.Choice, "None", "scalers"), + new PluginField("VniKey", "PAC key", SettingKind.Text) { Default = "" }), + 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), + Field("NativeWindowLeft", "Left", SettingKind.Integer, 100), Field("NativeWindowTop", "Top", SettingKind.Integer, 100), + Field("NativeWindowWidth", "Width", SettingKind.Integer, 512, minimum: 1, maximum: 16384), Field("NativeWindowHeight", "Height", SettingKind.Integer, 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 = "" }, 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; multiple versions require a selection.")); choices.Add(new PluginChoice("none", "No colorization")); + foreach (var asset in ColorizationCatalog.Read(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); + } + 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(request.Settings.String("AltColorRoot"), request.Game.Value.Key, request.Settings.String("Colorization", "auto")); + 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 settings = DmdBridgeConfig.Load(path, Settings(Schema.Defaults()), true); + var values = JObject.FromObject(settings); values["UnlitDot"] = "#" + ColorUtility.ToHtmlStringRGB(settings.UnlitDot); values["GlassColor"] = "#" + ColorUtility.ToHtmlStringRGB(settings.GlassColor); + 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.Player/Runtime/DmdPluginFactory.cs.meta b/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs.meta new file mode 100644 index 0000000..dde8ddf --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b1b18202c8e71b4d98b6c2657d67995 \ No newline at end of file diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs new file mode 100644 index 0000000..c5ddf91 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: 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.Player/Runtime/DmdPresentationDestination.cs.meta b/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs.meta new file mode 100644 index 0000000..8394e99 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e9897d2871bb94045b12c8927a0c2171 \ No newline at end of file diff --git a/VisualPinball.Engine.DMD.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef b/VisualPinball.Engine.DMD.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef new file mode 100644 index 0000000..5d9e263 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/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.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef.meta b/VisualPinball.Engine.DMD.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef.meta new file mode 100644 index 0000000..2440246 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/Runtime/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.Player/package.json b/VisualPinball.Engine.DMD.Player/package.json new file mode 100644 index 0000000..ab13628 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/package.json @@ -0,0 +1,17 @@ +{ + "name": "org.visualpinball.engine.dmd.player", + "version": "0.1.0", + "displayName": "Visual Pinball Engine (DMD Player Plugin)", + "description": "Optional player configuration and resource routing for DMD Extensions.", + "unity": "6000.5", + "unityRelease": "0f1", + "author": "freezy ", + "keywords": ["Unity", "Pinball", "DMD", "Player", "Plugin"], + "documentationUrl": "https://docs.visualpinball.org/", + "changelogUrl": "https://docs.visualpinball.org/CHANGELOG.html", + "licensesUrl": "https://github.com/VisualPinball/VisualPinball.Engine.DMD/blob/master/VisualPinball.Engine.DMD.Player/LICENSE", + "repository": { "url": "https://github.com/VisualPinball/VisualPinball.Engine.DMD.git", "type": "git" }, + "publishConfig": { "registry": "https://registry.visualpinball.org" }, + "license": "MIT", + "dependencies": { "org.visualpinball.engine.unity": "0.0.1-preview.212", "org.visualpinball.engine.dmd": "0.0.1-preview.2" } +} diff --git a/VisualPinball.Engine.DMD.Player/package.json.meta b/VisualPinball.Engine.DMD.Player/package.json.meta new file mode 100644 index 0000000..dd11922 --- /dev/null +++ b/VisualPinball.Engine.DMD.Player/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2f83517456d00aa438f1edbd4a3b4b1a +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs index 792117c..54765df 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs @@ -11,6 +11,7 @@ internal static class DmdBridgeBootstrapper [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void StartWatcher() { + if (PlayerIntegrationMode.HostManaged) return; if (_watcher != null) { return; } @@ -53,6 +54,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..d695e47 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs @@ -3,6 +3,8 @@ 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 +22,7 @@ using UnityEngine; using UnityEngine.Serialization; using VisualPinball.Unity; +using VisualPinball.Plugins; using Logger = NLog.Logger; namespace VisualPinball.Engine.DMD.Unity @@ -92,6 +95,14 @@ 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 Task _managedRebuild; + private long _sourceInstance; private DmdPipeline _pipeline; private DisplayConfig _currentDisplay; private DmdBridgeSettings _settings; @@ -114,6 +125,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 +149,63 @@ 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) { - RequestSourceFrameFormat(DisplayFrameFormat.Dmd8); + 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; + } + public void FeedFrame(in PluginDisplayFrame frame, string displayId) + { + if (!_managed || _shutdown) return; + if (_managedRebuild != null && !_managedRebuild.IsCompleted) return; + if (_pipeline != null && (_sourceInstance != frame.Instance || !_pipeline.Matches(new DisplayConfig(displayId, frame.Width, frame.Height)))) { + _managedRebuild = ReleaseManagedPipelineAsync(); + return; // Borrowed pixels cannot outlive this call. The next frame starts the new graph. + } + EnsurePipeline(new DisplayConfig(displayId, frame.Width, frame.Height), _sourceInstance != 0 && _sourceInstance != frame.Instance); + _sourceInstance = frame.Instance; + _pipeline?.Push(frame); + } + private async Task ReleaseManagedPipelineAsync() + { + var pipeline = _pipeline; + await pipeline.StopWorkerAsync().ConfigureAwait(false); + await _ownerScheduler.Switch(); + pipeline.Dispose(); + if (_pipeline == pipeline) _pipeline = null; + } + 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() + { + _shutdown = true; enabled = false; + if (_managedRebuild != null) await _managedRebuild.ConfigureAwait(false); + 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(); + _pipeline?.Dispose(); + return; + } + if (_shutdown) { _pipeline?.Dispose(); return; } + _shutdown = true; enabled = false; + if (!_managed) RequestSourceFrameFormat(DisplayFrameFormat.Dmd8); if (_gamelogicEngine != null) { _gamelogicEngine.OnDisplaysRequested -= HandleDisplaysRequested; @@ -156,6 +222,7 @@ 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(); + 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 +382,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 +401,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 +502,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 +792,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 +907,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..dbe9673 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs @@ -1,12 +1,14 @@ 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 @@ -47,6 +49,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(); @@ -105,6 +110,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 +171,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(); + + 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; + } } - } - - if (!_running) { - return; - } - 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..148a8dc 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using LibDmd; using LibDmd.Frame; using LibDmd.Output; @@ -44,9 +45,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/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..2b66b45 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/", From e1a90baeb3b749f854add628d73de4c1c377f892 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 13 Oct 2026 21:59:25 +0200 Subject: [PATCH 2/7] dmd: align player integration source headers --- Tests/Player/ColorizationCatalogTests.cs | 7 ++++++- .../Runtime/ColorizationCatalog.cs | 7 ++++++- VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs | 7 ++++++- .../Runtime/DmdPluginFactory.cs | 7 ++++++- .../Runtime/DmdPresentationDestination.cs | 7 ++++++- .../Runtime/DmdBridgeBootstrapper.cs | 6 ++++++ VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs | 6 ++++++ VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs | 6 ++++++ .../Runtime/InSceneDmdDestination.cs | 6 ++++++ 9 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Tests/Player/ColorizationCatalogTests.cs b/Tests/Player/ColorizationCatalogTests.cs index 29780f3..fd4a223 100644 --- a/Tests/Player/ColorizationCatalogTests.cs +++ b/Tests/Player/ColorizationCatalogTests.cs @@ -1,4 +1,9 @@ -// SPDX-License-Identifier: MIT +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// See LICENSE for the terms of use and distribution. + using System; using System.IO; using System.Linq; diff --git a/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs b/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs index 61cadc9..c07b979 100644 --- a/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs +++ b/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs @@ -1,4 +1,9 @@ -// SPDX-License-Identifier: MIT +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// See LICENSE for the terms of use and distribution. + using System; using System.Collections.Generic; using System.IO; diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs index 6b3e906..7cece21 100644 --- a/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs @@ -1,4 +1,9 @@ -// SPDX-License-Identifier: MIT +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// See LICENSE for the terms of use and distribution. + using System; using System.Collections.Generic; using System.IO; diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs index 82e9235..d8cc22a 100644 --- a/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs @@ -1,4 +1,9 @@ -// SPDX-License-Identifier: MIT +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// See LICENSE for the terms of use and distribution. + using System; using System.Collections.Generic; using System.IO; diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs index c5ddf91..ebdf815 100644 --- a/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs @@ -1,4 +1,9 @@ -// SPDX-License-Identifier: MIT +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// See LICENSE for the terms of use and distribution. + using System; using System.Threading; using System.Diagnostics; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs index 54765df..986df49 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 +// See LICENSE for the terms of use and distribution. + using UnityEngine; using UnityEngine.SceneManagement; using VisualPinball.Unity; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs index d695e47..8115756 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs @@ -1,3 +1,9 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// See LICENSE for the terms of use and distribution. + using System; using System.Collections; using System.Collections.Generic; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs index dbe9673..d7c8a74 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs @@ -1,3 +1,9 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// See LICENSE for the terms of use and distribution. + using System; using System.Collections.Generic; using System.Threading; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs b/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs index 148a8dc..b147832 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs @@ -1,3 +1,9 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// See LICENSE for the terms of use and distribution. + using System; using System.Collections.Generic; using System.Linq; From 84842ed2f281ce79c9ed92840f5f66273193eff6 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 13 Oct 2026 22:10:24 +0200 Subject: [PATCH 3/7] dmd: normalize source header formatting --- Tests/Player/ColorizationCatalogTests.cs | 12 +++++----- .../Runtime/ColorizationCatalog.cs | 12 +++++----- .../Runtime/DmdPlugin.cs | 12 +++++----- .../Runtime/DmdPluginFactory.cs | 24 +++++++++---------- .../Runtime/DmdPresentationDestination.cs | 12 +++++----- .../Runtime/DmdBridgeBootstrapper.cs | 12 +++++----- .../Runtime/DmdBridgePlayer.cs | 12 +++++----- .../Runtime/DmdPipeline.cs | 12 +++++----- .../Runtime/InSceneDmdDestination.cs | 12 +++++----- 9 files changed, 60 insertions(+), 60 deletions(-) diff --git a/Tests/Player/ColorizationCatalogTests.cs b/Tests/Player/ColorizationCatalogTests.cs index fd4a223..90e1d01 100644 --- a/Tests/Player/ColorizationCatalogTests.cs +++ b/Tests/Player/ColorizationCatalogTests.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; diff --git a/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs b/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs index c07b979..2cad68b 100644 --- a/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs +++ b/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs index 7cece21..91fa7b2 100644 --- a/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs index d8cc22a..c1c631c 100644 --- a/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; @@ -32,10 +32,10 @@ internal sealed class DmdPluginFactory : IPlayerPluginFactory, IPluginSettingsPr 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 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 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 = "", Description = "The altcolor folder containing one folder per ROM." }, @@ -69,8 +69,8 @@ internal static DmdBridgeSettings Settings(PluginSettingsSnapshot snapshot) 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); + 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(); diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs b/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs index ebdf815..63a633b 100644 --- a/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs +++ b/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs index 986df49..e9341ca 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgeBootstrapper.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs index 8115756..0c29bdc 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs index d7c8a74..80b182f 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs b/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs index b147832..2034733 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/InSceneDmdDestination.cs @@ -1,9 +1,9 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// SPDX-License-Identifier: MIT -// See LICENSE for the terms of use and distribution. - +// 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; From 1c4a841d55d1fa3f23afcabd42da146865e65fef Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 13 Oct 2026 23:57:42 +0200 Subject: [PATCH 4/7] dmd: include declarative Player integration in the Unity package --- .gitattributes | 9 +++++++++ VisualPinball.Engine.DMD.Player/LICENSE.meta | 7 ------- VisualPinball.Engine.DMD.Player/README.md | 11 ----------- VisualPinball.Engine.DMD.Player/README.md.meta | 7 ------- VisualPinball.Engine.DMD.Player/Runtime.meta | 8 -------- VisualPinball.Engine.DMD.Player/package.json | 17 ----------------- .../package.json.meta | 7 ------- VisualPinball.Engine.DMD.Unity/README.md | 4 ++++ .../Runtime/Plugins.meta | 8 ++++++++ .../Runtime/Plugins}/ColorizationCatalog.cs | 0 .../Plugins}/ColorizationCatalog.cs.meta | 0 .../Runtime/Plugins}/DmdPlugin.cs | 0 .../Runtime/Plugins}/DmdPlugin.cs.meta | 0 .../Runtime/Plugins}/DmdPluginFactory.cs | 0 .../Runtime/Plugins}/DmdPluginFactory.cs.meta | 0 .../Plugins}/DmdPresentationDestination.cs | 0 .../Plugins}/DmdPresentationDestination.cs.meta | 0 .../Runtime/Plugins}/LICENSE | 0 .../Runtime/Plugins/LICENSE.meta | 7 +++++++ .../VisualPinball.Engine.DMD.Player.asmdef | 0 .../VisualPinball.Engine.DMD.Player.asmdef.meta | 0 VisualPinball.Engine.DMD.Unity/package.json | 3 ++- 22 files changed, 30 insertions(+), 58 deletions(-) create mode 100644 .gitattributes delete mode 100644 VisualPinball.Engine.DMD.Player/LICENSE.meta delete mode 100644 VisualPinball.Engine.DMD.Player/README.md delete mode 100644 VisualPinball.Engine.DMD.Player/README.md.meta delete mode 100644 VisualPinball.Engine.DMD.Player/Runtime.meta delete mode 100644 VisualPinball.Engine.DMD.Player/package.json delete mode 100644 VisualPinball.Engine.DMD.Player/package.json.meta create mode 100644 VisualPinball.Engine.DMD.Unity/Runtime/Plugins.meta rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/ColorizationCatalog.cs (100%) rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/ColorizationCatalog.cs.meta (100%) rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/DmdPlugin.cs (100%) rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/DmdPlugin.cs.meta (100%) rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/DmdPluginFactory.cs (100%) rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/DmdPluginFactory.cs.meta (100%) rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/DmdPresentationDestination.cs (100%) rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/DmdPresentationDestination.cs.meta (100%) rename {VisualPinball.Engine.DMD.Player => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/LICENSE (100%) create mode 100644 VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE.meta rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/VisualPinball.Engine.DMD.Player.asmdef (100%) rename {VisualPinball.Engine.DMD.Player/Runtime => VisualPinball.Engine.DMD.Unity/Runtime/Plugins}/VisualPinball.Engine.DMD.Player.asmdef.meta (100%) 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/VisualPinball.Engine.DMD.Player/LICENSE.meta b/VisualPinball.Engine.DMD.Player/LICENSE.meta deleted file mode 100644 index 7bf7f95..0000000 --- a/VisualPinball.Engine.DMD.Player/LICENSE.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: de0beacf6535fbc468e244a3f5992cfc -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Player/README.md b/VisualPinball.Engine.DMD.Player/README.md deleted file mode 100644 index d4d6047..0000000 --- a/VisualPinball.Engine.DMD.Player/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# DMD player plugin - -Add this optional package to the player application together with `org.visualpinball.engine.dmd`. The adapter owns configuration and resource subscriptions; legacy automatic bridges stay inactive in a host-managed player. - -Settings → Plugins → DMD provides a global altcolor library, cabinet device setup, native-window placement and appearance. Selected game can choose a colorization version and override supported display options. The library contains a folder per ROM; variant subfolders can hold Serum or PAC files, palettes, and paired VNI/FSQ animations. The selector offers only discovered assets, retains missing explicit selections, and blocks ambiguous Automatic selection until a version is chosen. PAC assets require the configured key. - -LibDmd receives an owned cache containing exactly the chosen files under its canonical filenames. Original colorization files remain unchanged. The cache and physical output reservations are released after the worker has stopped; a blocked worker keeps its reservation until deferred cleanup finishes. INI import is an explicit draft action: review the imported settings and Apply. The original INI is never rewritten. - -The adapter binds original display frames from the selected game's controller, negotiates raw identification when colorization requires it, and publishes colorization plus scaling as one derived presentation resource. PinUp and other identification consumers keep the original stream. Controller-relative source selections remain usable when configuring a stopped game. - -Native backends and cabinet devices depend on the base DMD package and its platform libraries. The portable catalog tests validate discovery, ambiguity and staging independently of Unity or hardware; real destinations require target-platform testing. diff --git a/VisualPinball.Engine.DMD.Player/README.md.meta b/VisualPinball.Engine.DMD.Player/README.md.meta deleted file mode 100644 index 54fd6c0..0000000 --- a/VisualPinball.Engine.DMD.Player/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 74b9e4497a4a1e94c96981ba44a17647 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Player/Runtime.meta b/VisualPinball.Engine.DMD.Player/Runtime.meta deleted file mode 100644 index 5312831..0000000 --- a/VisualPinball.Engine.DMD.Player/Runtime.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: fe64f821f5bb31d409218292c8441f7a -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Player/package.json b/VisualPinball.Engine.DMD.Player/package.json deleted file mode 100644 index ab13628..0000000 --- a/VisualPinball.Engine.DMD.Player/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "org.visualpinball.engine.dmd.player", - "version": "0.1.0", - "displayName": "Visual Pinball Engine (DMD Player Plugin)", - "description": "Optional player configuration and resource routing for DMD Extensions.", - "unity": "6000.5", - "unityRelease": "0f1", - "author": "freezy ", - "keywords": ["Unity", "Pinball", "DMD", "Player", "Plugin"], - "documentationUrl": "https://docs.visualpinball.org/", - "changelogUrl": "https://docs.visualpinball.org/CHANGELOG.html", - "licensesUrl": "https://github.com/VisualPinball/VisualPinball.Engine.DMD/blob/master/VisualPinball.Engine.DMD.Player/LICENSE", - "repository": { "url": "https://github.com/VisualPinball/VisualPinball.Engine.DMD.git", "type": "git" }, - "publishConfig": { "registry": "https://registry.visualpinball.org" }, - "license": "MIT", - "dependencies": { "org.visualpinball.engine.unity": "0.0.1-preview.212", "org.visualpinball.engine.dmd": "0.0.1-preview.2" } -} diff --git a/VisualPinball.Engine.DMD.Player/package.json.meta b/VisualPinball.Engine.DMD.Player/package.json.meta deleted file mode 100644 index dd11922..0000000 --- a/VisualPinball.Engine.DMD.Player/package.json.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 2f83517456d00aa438f1edbd4a3b4b1a -PackageManifestImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: 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/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.Player/Runtime/ColorizationCatalog.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs diff --git a/VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs.meta similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/ColorizationCatalog.cs.meta rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs.meta diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs.meta similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/DmdPlugin.cs.meta rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs.meta diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs.meta similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/DmdPluginFactory.cs.meta rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs.meta diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs diff --git a/VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs.meta similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/DmdPresentationDestination.cs.meta rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPresentationDestination.cs.meta diff --git a/VisualPinball.Engine.DMD.Player/LICENSE b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE similarity index 100% rename from VisualPinball.Engine.DMD.Player/LICENSE rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/LICENSE 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.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef diff --git a/VisualPinball.Engine.DMD.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef.meta b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef.meta similarity index 100% rename from VisualPinball.Engine.DMD.Player/Runtime/VisualPinball.Engine.DMD.Player.asmdef.meta rename to VisualPinball.Engine.DMD.Unity/Runtime/Plugins/VisualPinball.Engine.DMD.Player.asmdef.meta diff --git a/VisualPinball.Engine.DMD.Unity/package.json b/VisualPinball.Engine.DMD.Unity/package.json index 2b66b45..5893c59 100644 --- a/VisualPinball.Engine.DMD.Unity/package.json +++ b/VisualPinball.Engine.DMD.Unity/package.json @@ -24,5 +24,6 @@ }, "publishConfig": { "registry": "https://registry.visualpinball.org" - } + }, + "license": "(GPL-2.0-only AND MIT)" } From 6d1fb0768bf18e2990aeed485fb39c5041bd7bc0 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 01:08:33 +0200 Subject: [PATCH 5/7] dmd: declare wide fields and paired display coordinates --- .../Runtime/Plugins/DmdPluginFactory.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs index c1c631c..6c4e76c 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs @@ -36,18 +36,22 @@ private static PluginField Hardware(string id, string label, SettingKind kind, J { 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 = "", Description = "The altcolor folder containing one folder per ROM." }, + new PluginField("AltColorRoot", "Colorization library", SettingKind.Folder) { Default = "", FullWidth = true, Description = "The altcolor folder containing one folder per ROM." }, Field("Colorization", "Colorization", SettingKind.Choice, "auto", "colorizations"), Field("ColorizationScalerMode", "Scaler", SettingKind.Choice, "None", "scalers"), - new PluginField("VniKey", "PAC key", SettingKind.Text) { Default = "" }), + 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), - Field("NativeWindowLeft", "Left", SettingKind.Integer, 100), Field("NativeWindowTop", "Top", SettingKind.Integer, 100), - Field("NativeWindowWidth", "Width", SettingKind.Integer, 512, minimum: 1, maximum: 16384), Field("NativeWindowHeight", "Height", SettingKind.Integer, 128, minimum: 1, maximum: 16384), + 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), @@ -60,7 +64,7 @@ private static PluginField Hardware(string id, string label, SettingKind kind, J 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 = "" }, new PluginField("Import", "Import INI", SettingKind.Action) { Provider = "import-ini" })); + 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"); From 9c50b332bc731e59ff3d7c984457f5d73ef3cf7c Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 13:26:18 +0200 Subject: [PATCH 6/7] dmd: retain changed display frames and import game settings --- Tests/Player/ManagedDmdInputTests.cs | 228 ++++++++++++++++++ ...sualPinball.Engine.DMD.Player.Tests.csproj | 3 +- .../Runtime/DmdBridgePlayer.cs | 36 ++- .../Runtime/DmdPipeline.cs | 8 +- .../Runtime/ManagedDmdInput.cs | 125 ++++++++++ .../Runtime/ManagedDmdInput.cs.meta | 2 + .../Runtime/Plugins/DmdPlugin.cs | 2 +- .../Runtime/Plugins/DmdPluginFactory.cs | 9 +- 8 files changed, 385 insertions(+), 28 deletions(-) create mode 100644 Tests/Player/ManagedDmdInputTests.cs create mode 100644 VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs create mode 100644 VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs.meta 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 index 45c07b5..ff8b561 100644 --- a/Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj +++ b/Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj @@ -7,7 +7,8 @@ - + + diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs index 0c29bdc..0d59c02 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs @@ -107,8 +107,7 @@ public class DmdBridgePlayer : MonoBehaviour private Func _presentationDestination; private OwnerScheduler _ownerScheduler; private Task _shutdownTask; - private Task _managedRebuild; - private long _sourceInstance; + private ManagedDmdInput _managedInput; private DmdPipeline _pipeline; private DisplayConfig _currentDisplay; private DmdBridgeSettings _settings; @@ -156,30 +155,19 @@ private IEnumerator Start() } /// Configures direct resource input; automatic GLE subscriptions and INI polling stay inactive. - public void ConfigureManaged(DmdBridgeSettings settings, GameObject scene, Func presentationDestination, OwnerScheduler ownerScheduler) + 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; - if (_managedRebuild != null && !_managedRebuild.IsCompleted) return; - if (_pipeline != null && (_sourceInstance != frame.Instance || !_pipeline.Matches(new DisplayConfig(displayId, frame.Width, frame.Height)))) { - _managedRebuild = ReleaseManagedPipelineAsync(); - return; // Borrowed pixels cannot outlive this call. The next frame starts the new graph. - } - EnsurePipeline(new DisplayConfig(displayId, frame.Width, frame.Height), _sourceInstance != 0 && _sourceInstance != frame.Instance); - _sourceInstance = frame.Instance; - _pipeline?.Push(frame); - } - private async Task ReleaseManagedPipelineAsync() - { - var pipeline = _pipeline; - await pipeline.StopWorkerAsync().ConfigureAwait(false); - await _ownerScheduler.Switch(); - pipeline.Dispose(); - if (_pipeline == pipeline) _pipeline = null; + _managedInput.Feed(frame, displayId); } private void OnDestroy() => Shutdown(); public Task ShutdownCompletion => _shutdownTask ?? Task.CompletedTask; @@ -195,7 +183,12 @@ public async Task ShutdownAsync(CancellationToken cancellation = default) private async Task ShutdownManagedAsync() { _shutdown = true; enabled = false; - if (_managedRebuild != null) await _managedRebuild.ConfigureAwait(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); @@ -206,7 +199,6 @@ public void Shutdown() { if (_managed) { _shutdownTask ??= ShutdownManagedAsync(); - _pipeline?.Dispose(); return; } if (_shutdown) { _pipeline?.Dispose(); return; } @@ -227,7 +219,7 @@ 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 diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs index 80b182f..5f92e27 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs @@ -32,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; @@ -97,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) { 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/DmdPlugin.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs index 91fa7b2..b3cf8cb 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs @@ -54,7 +54,7 @@ public override Task PrepareGameAsync(PluginGameContext game, CancellationToken _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); + }, _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)); })); diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs index 6c4e76c..d797795 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs @@ -42,7 +42,7 @@ private static PluginField Coordinate(string id, string label, string row, int v } 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 = "", FullWidth = true, Description = "The altcolor folder containing one folder per ROM." }, + new PluginField("AltColorRoot", "Colorization library", SettingKind.Folder) { Default = "", Scope = ConfigurationScope.GlobalAndGame, FullWidth = true, Description = "The altcolor folder containing one folder per ROM. 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 }), @@ -105,9 +105,12 @@ public Task RunActionAsync(string action, PluginChoiceReques 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 settings = DmdBridgeConfig.Load(path, Settings(Schema.Defaults()), true); + 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); - values["AltColorRoot"] = settings.AltColorPath ?? ""; values["Colorization"] = settings.EnableColorization ? "auto" : "none"; + 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))); } } From 3e72ac08f5cabe8c53fe701909ed5087eff4f2a2 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 16:30:22 +0200 Subject: [PATCH 7/7] dmd: find the default altcolor folder and pick what DmdDevice loads Without a configured library the plugin looks next to DmdDevice.ini, in VPM_HOME and next to the ROM folders, like the standalone bridge. Automatic selection prefers the newest Serum file in the game folder, then pin2dmd.pac with a key, then pin2dmd.pal, and skips macOS resource forks. --- Tests/Player/ColorizationCatalogTests.cs | 18 ++++++++- .../Runtime/Plugins/ColorizationCatalog.cs | 19 ++++++++-- .../Runtime/Plugins/DmdPlugin.cs | 2 +- .../Runtime/Plugins/DmdPluginFactory.cs | 38 +++++++++++++++++-- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/Tests/Player/ColorizationCatalogTests.cs b/Tests/Player/ColorizationCatalogTests.cs index 90e1d01..55c5a49 100644 --- a/Tests/Player/ColorizationCatalogTests.cs +++ b/Tests/Player/ColorizationCatalogTests.cs @@ -34,12 +34,28 @@ public void VariantsHaveStableIdsAndKeepPaletteAnimationsTogether() [Test] public void AmbiguousAutomaticAndMissingManualChoicesNeverFallBack() { - Asset("game/one.crz"); Asset("game/two.crz"); + // 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"); diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs index 2cad68b..2674b11 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs @@ -33,6 +33,7 @@ internal static IReadOnlyList Read(string root, string game) 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; @@ -42,14 +43,26 @@ internal static IReadOnlyList Read(string root, string game) } 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('\\', '/'); - internal static ColorizationAsset Select(string root, string game, string selection) + /// 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) throw new InvalidOperationException("Several colorizations are available. Select one for this game."); - return assets.FirstOrDefault(); + 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); } diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs index b3cf8cb..c937408 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs @@ -34,7 +34,7 @@ public override Task PrepareGameAsync(PluginGameContext game, CancellationToken var scene = game.Capabilities.Require(UnityPluginCapability.GameScene); var settings = DmdPluginFactory.Settings(game.Settings); settings.RomName = game.Identity.Key; - var asset = ColorizationCatalog.Select(game.Settings.String("AltColorRoot"), game.Identity.Key, game.Settings.String("Colorization", "auto")); + 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)); diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs index d797795..28551e7 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs @@ -42,7 +42,7 @@ private static PluginField Coordinate(string id, string label, string row, int v } 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. Games can override the global library." }, + 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 }), @@ -82,20 +82,50 @@ private PluginChoiceCatalog Discover(string provider, PluginChoiceRequest reques var choices = new List(); switch (provider) { case "colorizations": - choices.Add(new PluginChoice("auto", "Automatic", "Uses the sole available colorization; multiple versions require a selection.")); choices.Add(new PluginChoice("none", "No colorization")); - foreach (var asset in ColorizationCatalog.Read(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.")); + 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(request.Settings.String("AltColorRoot"), request.Game.Value.Key, request.Settings.String("Colorization", "auto")); + 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;