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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions Tests/Player/ColorizationCatalogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Visual Pinball Engine
// Copyright (C) 2026 freezy and VPE Team
//
// SPDX-License-Identifier: MIT
// Licensed under MIT. See https://opensource.org/license/mit.

using System;
using System.IO;
using System.Linq;
using NUnit.Framework;
using VisualPinball.Engine.DMD.Player;

namespace VisualPinball.Engine.DMD.Tests
{
[TestFixture]
public sealed class ColorizationCatalogTests
{
private string _root;
[SetUp] public void SetUp() { _root = Path.Combine(Path.GetTempPath(), "vpe-colorization-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); }
[TearDown] public void TearDown() { if (Directory.Exists(_root)) Directory.Delete(_root, true); }
private string Asset(string path)
{
string file = Path.Combine(_root, path); Directory.CreateDirectory(Path.GetDirectoryName(file)); File.WriteAllText(file, path); return file;
}
[Test]
public void VariantsHaveStableIdsAndKeepPaletteAnimationsTogether()
{
Asset("game/first/pin2dmd.pal"); Asset("game/first/pin2dmd.vni"); Asset("game/second/game.crz"); Asset("game/third/pin2dmd.pac");
var assets = ColorizationCatalog.Read(_root, "game");
Assert.That(assets.Count, Is.EqualTo(3));
Assert.That(assets.Single(asset => asset.Id == "game/first/pin2dmd.pal").Files.Length, Is.EqualTo(2));
Assert.That(assets.Single(asset => asset.Id == "game/third/pin2dmd.pac").RequiresKey, Is.True);
}
[Test]
public void AmbiguousAutomaticAndMissingManualChoicesNeverFallBack()
{
// Variants in subfolders aren't what DmdDevice loads, so automatic selection refuses to guess.
Asset("game/one/game.crz"); Asset("game/two/game.crz");
Assert.Throws<InvalidOperationException>(() => ColorizationCatalog.Select(_root, "game", "auto"));
Assert.Throws<FileNotFoundException>(() => ColorizationCatalog.Select(_root, "game", "game/missing.crz"));
Assert.That(ColorizationCatalog.Select(_root, "game", "none"), Is.Null);
}
[Test]
public void AmbiguousAutomaticPrefersTheFilesDmdDeviceLoads()
{
Asset("game/pin2dmd.pal"); Asset("game/pin2dmd.vni"); Asset("game/old/pin2dmd.pal"); Asset("game/old/pin2dmd.vni"); Asset("game/update.pac"); Asset("game/pin2dmd - Copy.pal"); Asset("game/old/._pin2dmd.pal");
Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal"));
Assert.That(ColorizationCatalog.Read(_root, "game").Any(asset => asset.Id.Contains("._")), Is.False, "macOS resource forks are not colorizations");
Asset("game/pin2dmd.pac");
Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal"), "a PAC without key can't be decrypted");
Assert.That(ColorizationCatalog.Select(_root, "game", "auto", hasPacKey: true).Id, Is.EqualTo("game/pin2dmd.pac"));
Asset("game/old/other.cRZ");
Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal"), "Serum only counts in the game folder itself");
Asset("game/older.cROM"); File.SetCreationTimeUtc(Path.Combine(_root, "game/older.cROM"), new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Asset("game/newer.cROMc");
Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/newer.cROMc"), "the newest Serum file wins, like the bridge");
}
[Test]
public void StagingContainsOnlySelectedVariantAndReleasesOwnedCopies()
{
string original = Asset("game/one/game.crz"); Asset("game/two/game.crz");
var selected = ColorizationCatalog.Select(_root, "game", "game/one/game.crz");
var lease = ColorizationCatalog.Stage(selected, Path.Combine(_root, "cache"), "game", out string staged);
Assert.That(File.ReadAllText(Path.Combine(staged, "game", "game.cRZ")), Is.EqualTo("game/one/game.crz"));
Assert.That(Directory.GetFiles(staged, "*", SearchOption.AllDirectories).Length, Is.EqualTo(1));
lease.Dispose(); lease.Dispose();
Assert.That(Directory.Exists(staged), Is.False); Assert.That(File.Exists(original), Is.True);
}
}
}
228 changes: 228 additions & 0 deletions Tests/Player/ManagedDmdInputTests.cs
Original file line number Diff line number Diff line change
@@ -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<Pipeline> _created;
private bool _sourceIsCurrent;
private bool _failCreation;
private List<Exception> _errors;
[SetUp]
public void SetUp()
{
_owner = new OwnerScheduler();
_created = new List<Pipeline>();
_sourceIsCurrent = true;
_failCreation = false;
_errors = new List<Exception>();
_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<bool> 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<PluginDisplayFrame> Frames = new List<PluginDisplayFrame>();
private readonly TaskCompletionSource<bool> _stopped = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
internal bool Disposed;
internal bool Capture = true;
internal bool FailNextStop;
internal int StopRequests;
internal ReadOnlyMemory<byte> 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; }
}
}
}
16 changes: 16 additions & 0 deletions Tests/Player/VisualPinball.Engine.DMD.Player.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
<VpeSourceRoot Condition="'$(VpeSourceRoot)' == ''">../../../VisualPinball.Engine</VpeSourceRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(VpeSourceRoot)/VisualPinball.Plugins/VisualPinball.Plugins.csproj" />
<Compile Include="../../VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs" Link="ColorizationCatalog.cs" />
<Compile Include="../../VisualPinball.Engine.DMD.Unity/Runtime/ManagedDmdInput.cs" Link="ManagedDmdInput.cs" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
</ItemGroup>
</Project>
4 changes: 4 additions & 0 deletions VisualPinball.Engine.DMD.Unity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<rid>` 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.
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
// Visual Pinball Engine
// Copyright (C) 2026 freezy and VPE Team
//
// SPDX-License-Identifier: MIT
// Licensed under MIT. See https://opensource.org/license/mit.

using UnityEngine;
using UnityEngine.SceneManagement;
using VisualPinball.Unity;
Expand All @@ -11,6 +17,7 @@ internal static class DmdBridgeBootstrapper
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void StartWatcher()
{
if (PlayerIntegrationMode.HostManaged) return;
if (_watcher != null) {
return;
}
Expand Down Expand Up @@ -53,6 +60,7 @@ private void OnSceneLoaded(Scene scene, LoadSceneMode mode)

private static void Install()
{
if (PlayerIntegrationMode.HostManaged) return;
foreach (var player in Object.FindObjectsByType<Player>(FindObjectsInactive.Include)) {
if (player.GetComponent<DmdBridgePlayer>() == null) {
player.gameObject.AddComponent<DmdBridgePlayer>();
Expand Down
Loading
Loading