From 1b2bcd763298b51056f90c73387e7dec854c8cfb Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 15 Jul 2026 01:24:20 +0200 Subject: [PATCH 1/3] dmd: bypass unsupported colorization formats --- .../Runtime/AssemblyInfo.cs | 3 + .../Runtime/AssemblyInfo.cs.meta | 2 + .../Runtime/DmdBridgePlayer.cs | 34 +++-- .../Runtime/DmdColorizationPolicy.cs | 63 +++++++++ .../Runtime/DmdColorizationPolicy.cs.meta | 2 + .../Runtime/DmdPipeline.cs | 2 + .../Runtime/VpeGleSource.cs | 10 ++ VisualPinball.Engine.DMD.Unity/Tests.meta | 8 ++ .../Tests/Runtime.meta | 8 ++ .../Runtime/DmdColorizationPolicyTests.cs | 126 ++++++++++++++++++ .../DmdColorizationPolicyTests.cs.meta | 2 + ...VisualPinball.Engine.DMD.Unity.Test.asmdef | 24 ++++ ...lPinball.Engine.DMD.Unity.Test.asmdef.meta | 7 + 13 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 VisualPinball.Engine.DMD.Unity/Runtime/AssemblyInfo.cs create mode 100644 VisualPinball.Engine.DMD.Unity/Runtime/AssemblyInfo.cs.meta create mode 100644 VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs create mode 100644 VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs.meta create mode 100644 VisualPinball.Engine.DMD.Unity/Tests.meta create mode 100644 VisualPinball.Engine.DMD.Unity/Tests/Runtime.meta create mode 100644 VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs create mode 100644 VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs.meta create mode 100644 VisualPinball.Engine.DMD.Unity/Tests/Runtime/VisualPinball.Engine.DMD.Unity.Test.asmdef create mode 100644 VisualPinball.Engine.DMD.Unity/Tests/Runtime/VisualPinball.Engine.DMD.Unity.Test.asmdef.meta diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/AssemblyInfo.cs b/VisualPinball.Engine.DMD.Unity/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..9d1ff13 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("VisualPinball.Engine.DMD.Unity.Test")] diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/AssemblyInfo.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..96cdb92 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a5f12fcd519342c0b5e97983ccb41169 diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs index 5e5a2b4..84e8ccc 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs @@ -97,6 +97,7 @@ public class DmdBridgePlayer : MonoBehaviour private DmdBridgeSettings _settings; private DmdBridgeSettings _appliedSettings; private DmdBridgeSettings _lastFallbackSettings; + private readonly DmdColorizationPolicy _colorizationPolicy = new DmdColorizationPolicy(); private DateTime _appliedConfigWriteTimeUtc; private string _appliedConfigPath; private bool _missingDisplayWarningLogged; @@ -196,8 +197,9 @@ private void HandleDisplaysRequested(object sender, RequestedDisplays requestedD continue; } - EnsurePipeline(display); - _pipeline?.Clear(); + if (EnsurePipeline(display)) { + _pipeline?.Clear(); + } return; } } @@ -231,7 +233,17 @@ private void HandleDisplayUpdateFrame(object sender, DisplayFrameData frame) } } - _pipeline.Push(frame); + // This handler runs on Unity's main thread. Rebuilding here is intentional: the pipeline + // worker cannot dispose itself because disposal waits for that worker to finish. + if (_pipeline != null && _pipeline.UsesColorization && + _colorizationPolicy.ObserveColorizedFrame(frame.Format, out var shouldWarn)) { + if (shouldWarn) { + Logger.Warn($"[DMD] Unsupported colorization source format {frame.Format} — bypassing colorization for display \"{frame.Id}\"."); + } + EnsurePipeline(_currentDisplay, force: true); + } + + _pipeline?.Push(frame); } private bool IsTargetDisplay(string id) @@ -242,7 +254,7 @@ private bool IsTargetDisplay(string id) : string.Equals(id, targetDisplayId, StringComparison.OrdinalIgnoreCase); } - private static bool TryInferDisplayConfig(DisplayFrameData frame, out DisplayConfig display) + internal static bool TryInferDisplayConfig(DisplayFrameData frame, out DisplayConfig display) { display = null; if (frame?.Data == null) { @@ -290,11 +302,12 @@ private static bool TryInferDimensions(int pixelCount, out int width, out int he } } - private void EnsurePipeline(DisplayConfig display, bool force = false) + private bool EnsurePipeline(DisplayConfig display, bool force = false) { + _colorizationPolicy.SelectDisplay(display); _currentDisplay = display; if (!force && _pipeline != null && _pipeline.Matches(display)) { - return; + return false; } _pipeline?.Dispose(); @@ -303,14 +316,17 @@ private void EnsurePipeline(DisplayConfig display, bool force = false) if (destinations.Count == 0) { Logger.Warn("[DMD] No DMD destinations are available; bridge will ignore frames."); _pipeline = null; - return; + return true; } - var converter = CreateConverter(); - _resolvedConverter = converter?.Name ?? "none"; + var converter = _colorizationPolicy.BypassColorization ? null : CreateConverter(); + _resolvedConverter = _colorizationPolicy.BypassColorization + ? "bypassed (unsupported source format)" + : converter?.Name ?? "none"; RequestSourceFrameFormat(converter != null ? DisplayFrameFormat.Dmd4 : DisplayFrameFormat.Dmd8); _pipeline = new DmdPipeline(display, destinations, converter, _settings.FlipHorizontally); Logger.Info($"[DMD] Pipeline for \"{display.Id}\" created with {destinations.Count} destination(s)."); + return true; } private void RequestSourceFrameFormat(DisplayFrameFormat format) diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs new file mode 100644 index 0000000..1548a2c --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs @@ -0,0 +1,63 @@ +using System; +using VisualPinball.Unity; + +namespace VisualPinball.Engine.DMD.Unity +{ + /// + /// Tracks whether the selected display must bypass a colorization source that cannot consume + /// the format emitted by its gamelogic engine. + /// + internal sealed class DmdColorizationPolicy + { + private DisplayConfig _display; + private bool _warningIssued; + + public bool BypassColorization { get; private set; } + + /// + /// Selects a display topology. An identical re-announcement deliberately preserves the + /// bypass latch; selecting a different id, geometry, or horizontal orientation resets it. + /// + public void SelectDisplay(DisplayConfig display) + { + if (SameTopology(_display, display)) { + return; + } + + _display = display; + BypassColorization = false; + _warningIssued = false; + } + + /// + /// Returns whether the active colorizing pipeline must be rebuilt as passthrough. + /// + public bool ObserveColorizedFrame(DisplayFrameFormat format, out bool shouldWarn) + { + if (SupportsColorization(format)) { + shouldWarn = false; + return false; + } + + BypassColorization = true; + shouldWarn = !_warningIssued; + _warningIssued = true; + return true; + } + + public static bool SupportsColorization(DisplayFrameFormat format) + { + return format == DisplayFrameFormat.Dmd2 || format == DisplayFrameFormat.Dmd4; + } + + private static bool SameTopology(DisplayConfig first, DisplayConfig second) + { + if (ReferenceEquals(first, second)) { + return true; + } + return first != null && second != null && + string.Equals(first.Id, second.Id, StringComparison.OrdinalIgnoreCase) && + first.Width == second.Width && first.Height == second.Height && first.FlipX == second.FlipX; + } + } +} diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs.meta b/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs.meta new file mode 100644 index 0000000..fc88f5e --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c86cffc0c9004283899b93931ae35da3 diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs index bb20b51..897e9a6 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdPipeline.cs @@ -49,11 +49,13 @@ internal sealed class DmdPipeline : IDisposable private volatile bool _running = true; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + public bool UsesColorization { get; } public DmdPipeline(DisplayConfig display, List destinations, AbstractConverter converter, bool flipHorizontally) { _display = display; _destinations = destinations; + UsesColorization = converter != null; _source = VpeGleSource.Create(display, converter != null); _renderGraph = new RenderGraph(new UndisposedReferences(), runOnMainThread: true) { Name = $"VPE DMD ({display.Id})", diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/VpeGleSource.cs b/VisualPinball.Engine.DMD.Unity/Runtime/VpeGleSource.cs index df4dd46..f17cf15 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/VpeGleSource.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/VpeGleSource.cs @@ -3,7 +3,9 @@ using System.Reactive.Subjects; using LibDmd.Frame; using LibDmd.Input; +using NLog; using VisualPinball.Unity; +using Logger = NLog.Logger; namespace VisualPinball.Engine.DMD.Unity { @@ -17,6 +19,7 @@ internal abstract class VpeGleSource : AbstractSource, ISource private readonly Subject _onResume = new Subject(); private readonly Subject _onPause = new Subject(); + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); protected VpeGleSource(string displayId, int width, int height) { @@ -104,6 +107,7 @@ private sealed class ColorizableVpeGleSource : VpeGleSource, IGray2Source, IGray { private readonly Subject _gray2Frames = new Subject(); private readonly Subject _gray4Frames = new Subject(); + private bool _unsupportedFormatWarningLogged; public ColorizableVpeGleSource(string displayId, int width, int height) : base(displayId, width, height) { @@ -118,6 +122,12 @@ public override void Push(DisplayFrameFormat format, byte[] data, int length) case DisplayFrameFormat.Dmd4: _gray4Frames.OnNext(CreateFrame(data, length, 4)); break; + default: + if (!_unsupportedFormatWarningLogged) { + Logger.Warn($"[DMD] Colorization source cannot consume {format}; frame ignored while the main-thread bridge switches to passthrough."); + _unsupportedFormatWarningLogged = true; + } + break; } } diff --git a/VisualPinball.Engine.DMD.Unity/Tests.meta b/VisualPinball.Engine.DMD.Unity/Tests.meta new file mode 100644 index 0000000..6791542 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 18d02616676d4a629a14db0997a07cd3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Unity/Tests/Runtime.meta b/VisualPinball.Engine.DMD.Unity/Tests/Runtime.meta new file mode 100644 index 0000000..9b16b4a --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Tests/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 008aec85a48c44f0ad1953f715bdfc54 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs new file mode 100644 index 0000000..b9f8b13 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs @@ -0,0 +1,126 @@ +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using VisualPinball.Unity; + +namespace VisualPinball.Engine.DMD.Unity.Test +{ + public class DmdColorizationPolicyTests + { + [Test] + public void ColorizingAndPassthroughSourcesAdvertiseMatchingCapabilities() + { + var display = new DisplayConfig("dmd0", 128, 32); + + var colorizing = CreateSource(display, true); + Assert.That(Advertises(colorizing, "IGray2Source"), Is.True); + Assert.That(Advertises(colorizing, "IGray4Source"), Is.True); + Assert.That(Advertises(colorizing, "IGray8Source"), Is.False); + Assert.That(Advertises(colorizing, "IRgb24Source"), Is.False); + + var passthrough = CreateSource(display, false); + Assert.That(Advertises(passthrough, "IGray2Source"), Is.True); + Assert.That(Advertises(passthrough, "IGray4Source"), Is.True); + Assert.That(Advertises(passthrough, "IGray8Source"), Is.True); + Assert.That(Advertises(passthrough, "IRgb24Source"), Is.True); + } + + [TestCase(DisplayFrameFormat.Dmd8, 128 * 16, 128, 16)] + [TestCase(DisplayFrameFormat.Dmd8, 128 * 32, 128, 32)] + [TestCase(DisplayFrameFormat.Dmd8, 192 * 64, 192, 64)] + [TestCase(DisplayFrameFormat.Dmd8, 256 * 64, 256, 64)] + [TestCase(DisplayFrameFormat.Dmd24, 128 * 32 * 3, 128, 32)] + public void BridgeInfersSupportedStandardFrameSizes(DisplayFrameFormat format, int length, + int width, int height) + { + var frame = new DisplayFrameData("dmd0", format, new byte[length]); + + Assert.That(DmdBridgePlayer.TryInferDisplayConfig(frame, out var display), Is.True); + Assert.That(display.Id, Is.EqualTo(frame.Id)); + Assert.That(display.Width, Is.EqualTo(width)); + Assert.That(display.Height, Is.EqualTo(height)); + } + + [Test] + public void BridgeNeedsAnnouncementForNonStandardOrMalformedFrames() + { + Assert.That(DmdBridgePlayer.TryInferDisplayConfig( + new DisplayFrameData("dmd0", DisplayFrameFormat.Dmd8, new byte[140 * 36]), out _), Is.False); + Assert.That(DmdBridgePlayer.TryInferDisplayConfig( + new DisplayFrameData("dmd0", DisplayFrameFormat.Dmd24, new byte[128 * 32 * 3 + 1]), out _), + Is.False); + } + + [TestCase(DisplayFrameFormat.Dmd2, true)] + [TestCase(DisplayFrameFormat.Dmd4, true)] + [TestCase(DisplayFrameFormat.Dmd8, false)] + [TestCase(DisplayFrameFormat.Dmd24, false)] + [TestCase(DisplayFrameFormat.Segment, false)] + public void ReportsFormatsSupportedByTheColorizationSource(DisplayFrameFormat format, bool supported) + { + Assert.That(DmdColorizationPolicy.SupportsColorization(format), Is.EqualTo(supported)); + } + + [Test] + public void UnsupportedFormatLatchesBypassAndWarnsOnce() + { + var policy = new DmdColorizationPolicy(); + policy.SelectDisplay(new DisplayConfig("dmd0", 128, 32)); + + Assert.That(policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd24, out var firstWarning), Is.True); + Assert.That(policy.BypassColorization, Is.True); + Assert.That(firstWarning, Is.True); + Assert.That(policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd8, out var secondWarning), Is.True); + Assert.That(secondWarning, Is.False); + } + + [Test] + public void IdenticalTopologyReannouncementPreservesBypass() + { + var policy = new DmdColorizationPolicy(); + policy.SelectDisplay(new DisplayConfig("DMD0", 140, 36, true, Color.red, Color.black)); + policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd24, out _); + + policy.SelectDisplay(new DisplayConfig("dmd0", 140, 36, true, Color.green, Color.white)); + + Assert.That(policy.BypassColorization, Is.True); + policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd8, out var shouldWarn); + Assert.That(shouldWarn, Is.False); + } + + [Test] + public void ChangedDisplayTopologyResetsBypassAndWarning() + { + AssertTopologyChangeResets(new DisplayConfig("dmd1", 140, 36, true)); + AssertTopologyChangeResets(new DisplayConfig("dmd0", 141, 36, true)); + AssertTopologyChangeResets(new DisplayConfig("dmd0", 140, 37, true)); + AssertTopologyChangeResets(new DisplayConfig("dmd0", 140, 36, false)); + } + + private static void AssertTopologyChangeResets(DisplayConfig changed) + { + var policy = new DmdColorizationPolicy(); + policy.SelectDisplay(new DisplayConfig("dmd0", 140, 36, true)); + policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd24, out _); + + policy.SelectDisplay(changed); + + Assert.That(policy.BypassColorization, Is.False); + policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd24, out var shouldWarn); + Assert.That(shouldWarn, Is.True); + } + + private static bool Advertises(object source, string interfaceName) + { + return source.GetType().GetInterfaces().Any(type => type.Name == interfaceName); + } + + private static object CreateSource(DisplayConfig display, bool colorize) + { + var create = typeof(VpeGleSource).GetMethod("Create", BindingFlags.Static | BindingFlags.Public); + Assert.That(create, Is.Not.Null); + return create.Invoke(null, new object[] { display, colorize }); + } + } +} diff --git a/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs.meta b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs.meta new file mode 100644 index 0000000..fbf146f --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4723db222d7846b895226f764b9c0a9f diff --git a/VisualPinball.Engine.DMD.Unity/Tests/Runtime/VisualPinball.Engine.DMD.Unity.Test.asmdef b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/VisualPinball.Engine.DMD.Unity.Test.asmdef new file mode 100644 index 0000000..9d0f07f --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/VisualPinball.Engine.DMD.Unity.Test.asmdef @@ -0,0 +1,24 @@ +{ + "name": "VisualPinball.Engine.DMD.Unity.Test", + "rootNamespace": "VisualPinball.Engine.DMD.Unity.Test", + "references": [ + "VisualPinball.Engine.DMD.Unity", + "VisualPinball.Unity" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/VisualPinball.Engine.DMD.Unity/Tests/Runtime/VisualPinball.Engine.DMD.Unity.Test.asmdef.meta b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/VisualPinball.Engine.DMD.Unity.Test.asmdef.meta new file mode 100644 index 0000000..2c98301 --- /dev/null +++ b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/VisualPinball.Engine.DMD.Unity.Test.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 05fcbbf3b96142d4bf24404b82df32b5 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From a3c2bfe1f7ab8b6a64322a2f84838dc72af34a78 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 15 Jul 2026 12:44:41 +0200 Subject: [PATCH 2/3] dmd: recover transient colorization bypass --- .../Runtime/DmdBridgePlayer.cs | 23 ++++++-- .../Runtime/DmdColorizationPolicy.cs | 32 +++++++++-- .../Runtime/DmdColorizationPolicyTests.cs | 53 ++++++++++++++++--- 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs index 84e8ccc..f6967b9 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs @@ -235,12 +235,19 @@ private void HandleDisplayUpdateFrame(object sender, DisplayFrameData frame) // This handler runs on Unity's main thread. Rebuilding here is intentional: the pipeline // worker cannot dispose itself because disposal waits for that worker to finish. - if (_pipeline != null && _pipeline.UsesColorization && - _colorizationPolicy.ObserveColorizedFrame(frame.Format, out var shouldWarn)) { + var colorizationAction = _colorizationPolicy.ObserveFrame(frame.Format, + _pipeline != null && _pipeline.UsesColorization, out var shouldWarn); + if (colorizationAction == DmdColorizationPipelineAction.Bypass) { if (shouldWarn) { - Logger.Warn($"[DMD] Unsupported colorization source format {frame.Format} — bypassing colorization for display \"{frame.Id}\"."); + var recovery = _colorizationPolicy.AwaitingSupportedColorizedFrame + ? " temporarily; colorization will resume when Dmd2/Dmd4 arrives" + : string.Empty; + Logger.Warn($"[DMD] Unsupported colorization source format {frame.Format} — bypassing colorization{recovery} for display \"{frame.Id}\"."); } EnsurePipeline(_currentDisplay, force: true); + } else if (colorizationAction == DmdColorizationPipelineAction.Restore) { + Logger.Info($"[DMD] Supported colorization format {frame.Format} arrived — restoring colorization for display \"{frame.Id}\"."); + EnsurePipeline(_currentDisplay, force: true); } _pipeline?.Push(frame); @@ -323,12 +330,20 @@ private bool EnsurePipeline(DisplayConfig display, bool force = false) _resolvedConverter = _colorizationPolicy.BypassColorization ? "bypassed (unsupported source format)" : converter?.Name ?? "none"; - RequestSourceFrameFormat(converter != null ? DisplayFrameFormat.Dmd4 : DisplayFrameFormat.Dmd8); + RequestSourceFrameFormat(PreferredSourceFormat(converter != null, _colorizationPolicy)); _pipeline = new DmdPipeline(display, destinations, converter, _settings.FlipHorizontally); Logger.Info($"[DMD] Pipeline for \"{display.Id}\" created with {destinations.Count} destination(s)."); return true; } + internal static DisplayFrameFormat PreferredSourceFormat(bool converterAvailable, + DmdColorizationPolicy policy) + { + return converterAvailable || policy != null && policy.AwaitingSupportedColorizedFrame + ? DisplayFrameFormat.Dmd4 + : DisplayFrameFormat.Dmd8; + } + private void RequestSourceFrameFormat(DisplayFrameFormat format) { if (_gamelogicEngine is IDisplayFrameFormatPreference frameFormatPreference) { diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs index 1548a2c..5b730e8 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs @@ -3,6 +3,13 @@ namespace VisualPinball.Engine.DMD.Unity { + internal enum DmdColorizationPipelineAction + { + None, + Bypass, + Restore, + } + /// /// Tracks whether the selected display must bypass a colorization source that cannot consume /// the format emitted by its gamelogic engine. @@ -13,6 +20,7 @@ internal sealed class DmdColorizationPolicy private bool _warningIssued; public bool BypassColorization { get; private set; } + public bool AwaitingSupportedColorizedFrame { get; private set; } /// /// Selects a display topology. An identical re-announcement deliberately preserves the @@ -26,23 +34,39 @@ public void SelectDisplay(DisplayConfig display) _display = display; BypassColorization = false; + AwaitingSupportedColorizedFrame = false; _warningIssued = false; } /// - /// Returns whether the active colorizing pipeline must be rebuilt as passthrough. + /// Decides whether the bridge must rebuild its pipeline. A Dmd8 frame may be the transient + /// pre-preference frame from a missed display announcement, so its bypass recovers when the + /// requested Dmd2/Dmd4 stream arrives. Dmd24 is RGB-authored and remains a sticky bypass. /// - public bool ObserveColorizedFrame(DisplayFrameFormat format, out bool shouldWarn) + public DmdColorizationPipelineAction ObserveFrame(DisplayFrameFormat format, + bool pipelineUsesColorization, out bool shouldWarn) { + if (!pipelineUsesColorization) { + if (BypassColorization && AwaitingSupportedColorizedFrame && SupportsColorization(format)) { + BypassColorization = false; + AwaitingSupportedColorizedFrame = false; + shouldWarn = false; + return DmdColorizationPipelineAction.Restore; + } + shouldWarn = false; + return DmdColorizationPipelineAction.None; + } + if (SupportsColorization(format)) { shouldWarn = false; - return false; + return DmdColorizationPipelineAction.None; } BypassColorization = true; + AwaitingSupportedColorizedFrame = format == DisplayFrameFormat.Dmd8; shouldWarn = !_warningIssued; _warningIssued = true; - return true; + return DmdColorizationPipelineAction.Bypass; } public static bool SupportsColorization(DisplayFrameFormat format) diff --git a/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs index b9f8b13..49125c9 100644 --- a/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs +++ b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs @@ -63,29 +63,68 @@ public void ReportsFormatsSupportedByTheColorizationSource(DisplayFrameFormat fo } [Test] - public void UnsupportedFormatLatchesBypassAndWarnsOnce() + public void Rgb24LatchesStickyBypassAndWarnsOnce() { var policy = new DmdColorizationPolicy(); policy.SelectDisplay(new DisplayConfig("dmd0", 128, 32)); - Assert.That(policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd24, out var firstWarning), Is.True); + Assert.That(policy.ObserveFrame(DisplayFrameFormat.Dmd24, true, out var firstWarning), + Is.EqualTo(DmdColorizationPipelineAction.Bypass)); Assert.That(policy.BypassColorization, Is.True); + Assert.That(policy.AwaitingSupportedColorizedFrame, Is.False); Assert.That(firstWarning, Is.True); - Assert.That(policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd8, out var secondWarning), Is.True); + Assert.That(policy.ObserveFrame(DisplayFrameFormat.Dmd4, false, out var secondWarning), + Is.EqualTo(DmdColorizationPipelineAction.None)); + Assert.That(policy.BypassColorization, Is.True); Assert.That(secondWarning, Is.False); } + [Test] + public void TransientDmd8BypassKeepsPreferenceAndRestoresOnSupportedFrame() + { + var policy = new DmdColorizationPolicy(); + policy.SelectDisplay(new DisplayConfig("dmd0", 128, 32)); + + Assert.That(policy.ObserveFrame(DisplayFrameFormat.Dmd8, true, out var warning), + Is.EqualTo(DmdColorizationPipelineAction.Bypass)); + Assert.That(warning, Is.True); + Assert.That(policy.BypassColorization, Is.True); + Assert.That(policy.AwaitingSupportedColorizedFrame, Is.True); + + Assert.That(policy.ObserveFrame(DisplayFrameFormat.Dmd4, false, out warning), + Is.EqualTo(DmdColorizationPipelineAction.Restore)); + Assert.That(warning, Is.False); + Assert.That(policy.BypassColorization, Is.False); + Assert.That(policy.AwaitingSupportedColorizedFrame, Is.False); + } + + [Test] + public void BridgeKeepsRequestingColorizableFramesDuringTransientBypass() + { + var policy = new DmdColorizationPolicy(); + policy.SelectDisplay(new DisplayConfig("dmd0", 128, 32)); + policy.ObserveFrame(DisplayFrameFormat.Dmd8, true, out _); + + Assert.That(DmdBridgePlayer.PreferredSourceFormat(false, policy), + Is.EqualTo(DisplayFrameFormat.Dmd4)); + + policy.SelectDisplay(new DisplayConfig("dmd1", 128, 32)); + policy.ObserveFrame(DisplayFrameFormat.Dmd24, true, out _); + Assert.That(DmdBridgePlayer.PreferredSourceFormat(false, policy), + Is.EqualTo(DisplayFrameFormat.Dmd8)); + } + [Test] public void IdenticalTopologyReannouncementPreservesBypass() { var policy = new DmdColorizationPolicy(); policy.SelectDisplay(new DisplayConfig("DMD0", 140, 36, true, Color.red, Color.black)); - policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd24, out _); + policy.ObserveFrame(DisplayFrameFormat.Dmd24, true, out _); policy.SelectDisplay(new DisplayConfig("dmd0", 140, 36, true, Color.green, Color.white)); Assert.That(policy.BypassColorization, Is.True); - policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd8, out var shouldWarn); + policy.ObserveFrame(DisplayFrameFormat.Dmd8, false, out var shouldWarn); Assert.That(shouldWarn, Is.False); } @@ -102,12 +141,12 @@ private static void AssertTopologyChangeResets(DisplayConfig changed) { var policy = new DmdColorizationPolicy(); policy.SelectDisplay(new DisplayConfig("dmd0", 140, 36, true)); - policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd24, out _); + policy.ObserveFrame(DisplayFrameFormat.Dmd24, true, out _); policy.SelectDisplay(changed); Assert.That(policy.BypassColorization, Is.False); - policy.ObserveColorizedFrame(DisplayFrameFormat.Dmd24, out var shouldWarn); + policy.ObserveFrame(DisplayFrameFormat.Dmd24, true, out var shouldWarn); Assert.That(shouldWarn, Is.True); } From 3fd07969caa4feb1b9f3ba04e08b3fb46a345324 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 15 Jul 2026 13:51:47 +0200 Subject: [PATCH 3/3] dmd: rearm transient bypass diagnostics --- .../Runtime/DmdColorizationPolicy.cs | 1 + .../Tests/Runtime/DmdColorizationPolicyTests.cs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs index 5b730e8..0edf30b 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdColorizationPolicy.cs @@ -50,6 +50,7 @@ public DmdColorizationPipelineAction ObserveFrame(DisplayFrameFormat format, if (BypassColorization && AwaitingSupportedColorizedFrame && SupportsColorization(format)) { BypassColorization = false; AwaitingSupportedColorizedFrame = false; + _warningIssued = false; shouldWarn = false; return DmdColorizationPipelineAction.Restore; } diff --git a/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs index 49125c9..b84c962 100644 --- a/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs +++ b/VisualPinball.Engine.DMD.Unity/Tests/Runtime/DmdColorizationPolicyTests.cs @@ -98,6 +98,20 @@ public void TransientDmd8BypassKeepsPreferenceAndRestoresOnSupportedFrame() Assert.That(policy.AwaitingSupportedColorizedFrame, Is.False); } + [Test] + public void TransientDmd8BypassWarnsAgainAfterRestoringColorization() + { + var policy = new DmdColorizationPolicy(); + policy.SelectDisplay(new DisplayConfig("dmd0", 128, 32)); + + policy.ObserveFrame(DisplayFrameFormat.Dmd8, true, out var firstWarning); + policy.ObserveFrame(DisplayFrameFormat.Dmd4, false, out _); + policy.ObserveFrame(DisplayFrameFormat.Dmd8, true, out var secondWarning); + + Assert.That(firstWarning, Is.True); + Assert.That(secondWarning, Is.True); + } + [Test] public void BridgeKeepsRequestingColorizableFramesDuringTransientBypass() {