From f67fd5a9a07ba1a515d6f309f9737b0279b21099 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 21:42:53 +0200 Subject: [PATCH 1/8] dmd: record studio readiness decisions --- .../developer-guide/dmd-studio-readiness.md | 85 +++++++++++++++++++ .../Documentation~/developer-guide/toc.yml | 2 + 2 files changed, 87 insertions(+) create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md diff --git a/VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md b/VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md new file mode 100644 index 000000000..a669d6340 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md @@ -0,0 +1,85 @@ +--- +uid: developer-guide-dmd-studio-readiness +title: DMD Studio Readiness Decisions +description: Frozen contracts and prototype results for the first DMD Studio implementation. +--- + +# DMD Studio readiness decisions + +This page records the Phase 0A decisions from the DMD Studio implementation plan. They are the implementation contract for Phases 0 through 5. The prototype code used to reach these decisions was discarded; only the documented outcomes and test evidence remain. + +## Asset and color model + +The v1 asset and color model in section 3 of the plan is frozen with these amendments: + +- `DmdFontAsset` includes a `Notes` string for source, license, and attribution details required by the starter font pack. +- `DmdKerningPair` is a serializable value with `LeftCodepoint`, `RightCodepoint`, and `Adjustment` integer fields. +- `DmdCueParameter` is a serializable value with `Name`, `Type`, and the same tagged default-value union as `DmdParamValue`: `IntValue`, `FloatValue`, `StringValue`, and `BoolValue`. +- Direct ScriptableObject references, ScriptableObject inventory lists, and the polymorphic cue-layer list remain Unity-serialized fields. They receive Newtonsoft `JsonIgnore` attributes so the generic package serializer cannot recursively inline object graphs or attempt to deserialize abstract layers. The future DMD packer owns explicit reference IDs and layer DTOs. +- Packaged reference identity uses `UnityObjectId.Get`, matching the existing package system, rather than raw `GetInstanceID` values. +- Mono assets store I8 intensity and RGB assets store literal RGB24 values exactly as specified. Indexed color remains out of scope. + +V1 font import is BMFont text plus PNG only. A TTF/OTF baker is deferred because Unity 6000.5 does not expose the required `FontEngine` glyph-to-texture rasterization calls as public API. Private reflection is not an acceptable production dependency. + +## Scheduler scenario sign-off + +The admission table needs no row changes after walking these scenarios: + +1. An attract/base cue is configured before `Start`; game-start status or mode cues enter according to priority, and natural completion drains back to held work or the base cue. +2. Repeated multiball jackpots use a non-empty coalesce key. Replays merge parameters into the active, held, or queued instance and return its existing handle; higher-priority mode work still follows the queue/preemption table. +3. A tilt cue that must bypass a non-interruptible mode intro must use `System` priority. A `Critical` tilt intentionally queues behind a non-interruptible active cue. + +`DmdCuePlayer` stays main-thread-only, with caller marshaling as the boundary. An internal command queue was rejected because `Play` must return a synchronous handle and admission/coalescing order is part of the API contract. In particular, an `IGamelogicInputThreading` implementation using `GamelogicInputDispatchMode.SimulationThread` must marshal DMD calls to Unity's main thread. + +## Display bridge behavior + +The current colorization source accepts Dmd2 and Dmd4 frames and silently ignores other formats, including Dmd8 and Dmd24. Phase 3 will inspect `DisplayFrameData.Format` on the main thread in `DmdBridgePlayer.HandleDisplayUpdateFrame`, before `_pipeline.Push`. When an unsupported format reaches a colorizing pipeline, the bridge latches passthrough mode for that display, logs one warning, and force-rebuilds the pipeline. Detection or rebuilding must not occur in `ColorizableVpeGleSource.Push`: that method runs on the pipeline worker, and disposing the pipeline there would join the current thread. `DmdPipeline.Matches` currently ignores converter identity, so this rebuild must either use the existing force path or extend matching to include converter identity. + +The passthrough latch survives settings-driven `EnsurePipeline(force: true)` rebuilds and format-preference requests; it resets only when the selected display topology changes. Mono input continues through colorization unchanged. `ColorizableVpeGleSource` still logs one warning for any independently ignored unsupported format as a defensive diagnostic. + +A delayed display re-announcement can rebuild a bridge pipeline that subscribed after the initial announcement. Avoiding in-scene flicker requires both of these safeguards: + +- `DisplayPlayer` suppresses a duplicate `DisplayConfig` before calling `Clear` or resizing the component. +- `DotMatrixDisplayComponent.UpdateDimensions` returns early for unchanged dimensions and flip state, and releases replaced texture/mesh resources when a real resize occurs. + +Arbitrary dimensions work in-scene only when a matching `DisplayComponent` existed when `DisplayPlayer.Awake` discovered displays. The delayed re-announcement supplies arbitrary dimensions to a late bridge subscriber; it does not dynamically create an in-scene display component. + +The native DMD bridge binds exactly one display. With an explicit target it selects that ID; without one it selects the first announced ID beginning with `dmd`, so multi-project tables must configure the intended hardware target instead of relying on announcement order. Additional displays still work through the in-scene `DisplayPlayer` path. + +The bridge checks are pinned to `VisualPinball.Engine.DMD` `origin/master` and `VisualPinball.Engine.Player.Hdrp` local `master`, both on the feature branch. Unit and EditMode tests use the Unity 6000.5 package test project under `VisualPinball.Unity.Test/TestProject~`. The authoring project's `master` is still on Unity 6000.2, so the Phase 3 sample-scene host must be upgraded or replaced by a clean Unity 6000.5 host before that scene is committed. Before running the Player host, its local package manifest must be repointed from the main engine checkout to the feature worktree (or the main checkout must be switched after its unrelated changes are resolved). + +Phase 0A validated the bridge behavior by tracing the complete main-thread and worker-thread source paths rather than committing or retaining a throwaway scene. Runtime proof remains mandatory in Phase 3 E2E-2 and E2E-3. + +## Packaging design + +A Unity 6000.5 EditMode spike round-tripped project to cue to sprite through editor and runtime unpack paths and produced the same FNV render hash after unpack. The result was one passing test in 0.976 seconds. + +The selected full-delivery design is: + +- Replace type-erased use of `IPacker` with a non-generic ScriptableObject packer adapter/base. The existing `PackerFactory.GetPacker(Type)` cast is not variance-safe; the same defect is reproducible with `SoundAssetPacker`. +- Add a non-generic post-load fix-up contract and invoke it only after all assets have been instantiated and registered in both editor and runtime unpack paths. Package folder visitation order is not a reference-ordering contract. +- Store DMD graph references as `MetaPackable.InstanceId` values obtained through `UnityObjectId`. Discovery starts at the game-logic root and recursively adds referenced assets with existing ID-based deduplication. +- Serialize cue layers through explicit DTOs with a versioned type discriminator. Do not ask the generic JSON packer to reconstruct the abstract `SerializeReference` list. +- Use the Visual Scripting envelope `{ magic: "VPE.DMD.VS", version: 1, graphPayload: byte[], dmdProjectRef: int }`. The magic is checked before envelope deserialization. Missing or unknown magic means the entire payload is a legacy graph payload; an explicit discriminator is necessary because permissive JSON deserialization may otherwise produce default values instead of failing. + +Full packaging and the Visual Scripting envelope remain Phase 6 work. The spike only freezes their compatible field shapes and loading strategy. + +## Import prototypes + +A 2x2 PNG with distinct top and bottom rows proved that Unity's first decoded row is the bottom row. Importers must explicitly flip rows when converting top-origin PNG or BMFont coordinates into DMD bitmap data. + +The public `UnityEngine.TextCore.LowLevel.FontEngine` surface compiles for face loading and glyph lookup, but its glyph-to-texture rasterization entry points are inaccessible in Unity 6000.5. Phase 5 therefore keeps glyph touch-up and the licensed starter font pack but removes the in-editor TTF/OTF bake feature. + +## Public API contract + +All public `DmdCuePlayer` calls are main-thread-only. Editor and development builds assert the constructing thread; production code remains deliberately lock-free. + +- Null constructor dependencies throw `ArgumentNullException`. Constructor-time project validation records diagnostics for later one-time publication rather than throwing for malformed authored content. +- `DmdParams.Set` rejects null, empty, or invalid names and more than 256 distinct bound parameters with `ArgumentException`. `Play` and `UpdateCue` apply the same bound-parameter cap. +- Unknown cue IDs and invalid/excluded assets are authored-content failures: `Play` returns an invalid handle, boolean operations return `false`, and `SetBase` leaves the current base unchanged. Each distinct failure emits one validation diagnostic rather than throwing. +- `Start` is idempotent. Calls before `Start` may configure the base, admission state, parameters, and preferred format, but no sink method is called until `Start`. `Tick` before `Start` is a no-op. +- `Tick` rejects NaN, infinity, and negative time with `ArgumentOutOfRangeException`. A time value earlier than the previous tick resets the accumulator origin without rewinding logical cue state or emitting duplicate lifecycle events. +- `Dispose` is idempotent, clears the announced display at most once, drops buffers and subscriptions, and does not raise `OnCueFinished` during teardown. Every method other than `Dispose` throws `ObjectDisposedException` after disposal. +- Stale handles and unmatched string targets return `false`. Stop/update never throw merely because an instance ended. Existing `OnCueFinished` timing remains as specified in section 6.4 of the plan. + +Utility APIs validate null inputs, dimensions, formats, and destination buffer sizes at their public boundary with standard argument exceptions. Authored asset-shape failures continue through `DmdValidation` diagnostics so malformed content cannot escape from the renderer as an indexing exception. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml index ed89a281b..622e14c6e 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml @@ -6,6 +6,8 @@ href: threading-model.md - name: Nudge System href: nudge-system.md +- name: DMD Studio Readiness Decisions + href: dmd-studio-readiness.md - name: Packaging items: - name: Overview From 9e388538dc83a2ed9d54a1ceec14ce4990dfbc42 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 22:32:48 +0200 Subject: [PATCH 2/8] dmd: add studio asset model --- .../VisualPinball.Unity.Test/DmdStudio.meta | 8 + .../DmdStudio/DmdAssetTests.cs | 195 +++++ .../DmdStudio/DmdAssetTests.cs.meta | 2 + .../DmdStudio/DmdDisplayPlayerTests.cs | 78 ++ .../DmdStudio/DmdDisplayPlayerTests.cs.meta | 2 + .../DmdStudio/DmdParamsTests.cs | 81 +++ .../DmdStudio/DmdParamsTests.cs.meta | 2 + .../DmdStudio/DmdTestDisplay.shader | 12 + .../DmdStudio/DmdTestDisplay.shader.meta | 9 + .../DmdStudio/DmdValidationTests.cs | 335 +++++++++ .../DmdStudio/DmdValidationTests.cs.meta | 2 + .../Display/DotMatrixDisplayComponent.cs | 30 +- .../VisualPinball.Unity/DmdStudio.meta | 8 + .../VisualPinball.Unity/DmdStudio/Assets.meta | 8 + .../DmdStudio/Assets/DmdBitmapData.cs | 42 ++ .../DmdStudio/Assets/DmdBitmapData.cs.meta | 2 + .../DmdStudio/Assets/DmdCueAsset.cs | 113 +++ .../DmdStudio/Assets/DmdCueAsset.cs.meta | 2 + .../DmdStudio/Assets/DmdFontAsset.cs | 50 ++ .../DmdStudio/Assets/DmdFontAsset.cs.meta | 2 + .../DmdStudio/Assets/DmdPaletteAsset.cs | 20 + .../DmdStudio/Assets/DmdPaletteAsset.cs.meta | 2 + .../DmdStudio/Assets/DmdProjectAsset.cs | 50 ++ .../DmdStudio/Assets/DmdProjectAsset.cs.meta | 2 + .../DmdStudio/Assets/DmdShade.cs | 33 + .../DmdStudio/Assets/DmdShade.cs.meta | 2 + .../DmdStudio/Assets/DmdSpriteAsset.cs | 23 + .../DmdStudio/Assets/DmdSpriteAsset.cs.meta | 2 + .../DmdStudio/Binding.meta | 8 + .../DmdStudio/Binding/DmdParamValue.cs | 94 +++ .../DmdStudio/Binding/DmdParamValue.cs.meta | 2 + .../DmdStudio/Binding/DmdParams.cs | 82 +++ .../DmdStudio/Binding/DmdParams.cs.meta | 2 + .../DmdStudio/Compositing.meta | 8 + .../DmdStudio/Compositing/DmdValidation.cs | 680 ++++++++++++++++++ .../Compositing/DmdValidation.cs.meta | 2 + .../VisualPinball.Unity/DmdStudio/Layers.meta | 8 + .../DmdStudio/Layers/BitmapLayer.cs | 31 + .../DmdStudio/Layers/BitmapLayer.cs.meta | 2 + .../DmdStudio/Layers/DmdLayer.cs | 35 + .../DmdStudio/Layers/DmdLayer.cs.meta | 2 + .../DmdStudio/Layers/DmdPropertyTrack.cs | 42 ++ .../DmdStudio/Layers/DmdPropertyTrack.cs.meta | 2 + .../DmdStudio/Layers/MaskLayer.cs | 21 + .../DmdStudio/Layers/MaskLayer.cs.meta | 2 + .../DmdStudio/Layers/NumberLayer.cs | 20 + .../DmdStudio/Layers/NumberLayer.cs.meta | 2 + .../DmdStudio/Layers/ShapeLayer.cs | 30 + .../DmdStudio/Layers/ShapeLayer.cs.meta | 2 + .../DmdStudio/Layers/TextLayer.cs | 56 ++ .../DmdStudio/Layers/TextLayer.cs.meta | 2 + .../VisualPinball.Unity/Game/DisplayPlayer.cs | 14 + .../Game/Engine/IGamelogicEngine.cs | 2 +- 53 files changed, 2266 insertions(+), 2 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdAssetTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdAssetTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdParamsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdParamsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdBitmapData.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdBitmapData.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdCueAsset.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdCueAsset.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdFontAsset.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdFontAsset.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdPaletteAsset.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdPaletteAsset.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdProjectAsset.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdProjectAsset.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdShade.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdShade.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdSpriteAsset.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdSpriteAsset.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParamValue.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParamValue.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParams.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParams.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/BitmapLayer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/BitmapLayer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdLayer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdLayer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdPropertyTrack.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdPropertyTrack.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/MaskLayer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/MaskLayer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/NumberLayer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/NumberLayer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/ShapeLayer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/ShapeLayer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/TextLayer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/TextLayer.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio.meta new file mode 100644 index 000000000..2b00076ea --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: de98a9d9f7f2196469aa178f58cee11c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdAssetTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdAssetTests.cs new file mode 100644 index 000000000..9d978d0cf --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdAssetTests.cs @@ -0,0 +1,195 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Reflection; +using System.Linq; +using System.Text; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Test +{ + public class DmdAssetTests + { + private const string TestFolder = "Assets/__DmdStudioPhase0Tests"; + + [Test] + public void CuePreservesPolymorphicLayersThroughUnityAssetSerialization() + { + AssetDatabase.DeleteAsset(TestFolder); + AssetDatabase.CreateFolder("Assets", "__DmdStudioPhase0Tests"); + try { + var cue = ScriptableObject.CreateInstance(); + cue.CueId = "serialization"; + cue.Layers.Add(new ShapeLayer { + Name = "box", + Shape = DmdShapeType.Rect, + Width = 12, + Height = 6, + Filled = true + }); + cue.Layers.Add(new NumberLayer { + Name = "score", + ParamName = "player.score", + Format = "N0", + CountUpSeconds = 0.5f + }); + + var path = $"{TestFolder}/Cue.asset"; + AssetDatabase.CreateAsset(cue, path); + AssetDatabase.SaveAssets(); + Resources.UnloadAsset(cue); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); + + var loaded = AssetDatabase.LoadAssetAtPath(path); + Assert.That(loaded, Is.Not.SameAs(cue)); + Assert.That(loaded.Layers, Has.Count.EqualTo(2)); + Assert.That(loaded.Layers[0], Is.TypeOf()); + Assert.That(((ShapeLayer)loaded.Layers[0]).Width, Is.EqualTo(12)); + Assert.That(loaded.Layers[1], Is.TypeOf()); + Assert.That(((NumberLayer)loaded.Layers[1]).ParamName, Is.EqualTo("player.score")); + } finally { + AssetDatabase.DeleteAsset(TestFolder); + } + } + + [Test] + public void PackageJsonIgnoresUnityGraphReferences() + { + AssertJsonIgnored(typeof(DmdProjectAsset), nameof(DmdProjectAsset.Cues)); + AssertJsonIgnored(typeof(DmdProjectAsset), nameof(DmdProjectAsset.Sprites)); + AssertJsonIgnored(typeof(DmdProjectAsset), nameof(DmdProjectAsset.Fonts)); + AssertJsonIgnored(typeof(DmdProjectAsset), nameof(DmdProjectAsset.Palettes)); + AssertJsonIgnored(typeof(DmdProjectAsset), nameof(DmdProjectAsset.PreviewTint)); + AssertJsonIgnored(typeof(DmdCueAsset), nameof(DmdCueAsset.Layers)); + AssertJsonIgnored(typeof(BitmapLayer), nameof(BitmapLayer.Sprite)); + AssertJsonIgnored(typeof(TextLayer), nameof(TextLayer.Font)); + AssertJsonIgnored(typeof(MaskLayer), nameof(MaskLayer.Mask)); + Assert.That(typeof(DmdCueAsset).GetField(nameof(DmdCueAsset.Layers)) + .GetCustomAttribute(), Is.Not.Null); + + var project = ScriptableObject.CreateInstance(); + var cue = ScriptableObject.CreateInstance(); + var sprite = ScriptableObject.CreateInstance(); + try { + project.Cues.Add(cue); + project.Sprites.Add(sprite); + cue.Layers.Add(new BitmapLayer { Sprite = sprite }); + + var projectJson = Encoding.UTF8.GetString(PackageApi.Packer.Pack(project)); + var cueJson = Encoding.UTF8.GetString(PackageApi.Packer.Pack(cue)); + var layerJson = Encoding.UTF8.GetString(PackageApi.Packer.Pack((BitmapLayer)cue.Layers[0])); + + Assert.That(projectJson, Does.Not.Contain("\"Cues\"")); + Assert.That(projectJson, Does.Not.Contain("\"Sprites\"")); + Assert.That(projectJson, Does.Not.Contain("\"PreviewTint\"")); + Assert.That(cueJson, Does.Not.Contain("\"Layers\"")); + Assert.That(layerJson, Does.Not.Contain("\"Sprite\"")); + } finally { + Object.DestroyImmediate(sprite); + Object.DestroyImmediate(cue); + Object.DestroyImmediate(project); + } + } + + [Test] + public void DuplicateDisplayConfigsCompareAllVisibleSettings() + { + var color = new Color(1f, 0.25f, 0f); + var original = new DisplayConfig("dmd0", 128, 32, false, color, Color.black); + + Assert.That(DisplayPlayer.SameConfig(original, + new DisplayConfig("dmd0", 128, 32, false, color, Color.black)), Is.True); + Assert.That(DisplayPlayer.SameConfig(original, + new DisplayConfig("dmd1", 128, 32, false, color, Color.black)), Is.False); + Assert.That(DisplayPlayer.SameConfig(original, + new DisplayConfig("dmd0", 64, 32, false, color, Color.black)), Is.False); + Assert.That(DisplayPlayer.SameConfig(original, + new DisplayConfig("dmd0", 128, 16, false, color, Color.black)), Is.False); + Assert.That(DisplayPlayer.SameConfig(original, + new DisplayConfig("dmd0", 128, 32, true, color, Color.black)), Is.False); + Assert.That(DisplayPlayer.SameConfig(original, + new DisplayConfig("dmd0", 128, 32, false, Color.white, Color.black)), Is.False); + Assert.That(DisplayPlayer.SameConfig(original, + new DisplayConfig("dmd0", 128, 32, false, color, Color.white)), Is.False); + Assert.That(DisplayPlayer.SameConfig(original, + new DisplayConfig("dmd0", 128, 32, false, null, Color.black)), Is.False); + } + + [Test] + public void DotMatrixDisplaySkipsAnUnchangedAllocatedSize() + { + var gameObject = new GameObject("DMD"); + var texture = new Texture2D(128, 32); + try { + var display = gameObject.AddComponent(); + var textureField = typeof(DisplayComponent).GetField("_texture", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(textureField, Is.Not.Null); + textureField.SetValue(display, texture); + + display.UpdateDimensions(128, 32, false); + + Assert.That(textureField.GetValue(display), Is.SameAs(texture)); + } finally { + Object.DestroyImmediate(gameObject); + Object.DestroyImmediate(texture); + } + } + + [Test] + public void DotMatrixDisplayReleasesOwnedResourcesOnResize() + { + var gameObject = new GameObject("DMD"); + Texture2D currentTexture = null; + Mesh currentMesh = null; + Material currentMaterial = null; + try { + var shader = AssetDatabase.LoadAssetAtPath( + "Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader"); + Assert.That(shader, Is.Not.Null); + currentMaterial = new Material(shader); + gameObject.AddComponent().sharedMaterial = currentMaterial; + var display = gameObject.AddComponent(); + var textureField = typeof(DisplayComponent).GetField("_texture", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(textureField, Is.Not.Null); + + display.UpdateDimensions(128, 32, false); + var previousTexture = (Texture2D)textureField.GetValue(display); + var previousMesh = gameObject.GetComponent().sharedMesh; + + display.UpdateDimensions(64, 16, true); + + currentTexture = (Texture2D)textureField.GetValue(display); + currentMesh = gameObject.GetComponent().sharedMesh; + Assert.That(previousTexture == null, Is.True); + Assert.That(previousMesh == null, Is.True); + Assert.That(currentTexture, Is.Not.Null); + Assert.That(currentMesh, Is.Not.Null); + Assert.That(display.Width, Is.EqualTo(64)); + Assert.That(display.Height, Is.EqualTo(16)); + } finally { + Object.DestroyImmediate(gameObject); + Object.DestroyImmediate(currentMaterial); + Object.DestroyImmediate(currentMesh); + Object.DestroyImmediate(currentTexture); + } + } + + private static void AssertJsonIgnored(System.Type type, string fieldName) + { + var field = type.GetField(fieldName); + Assert.That(field, Is.Not.Null); + Assert.That(field.GetCustomAttributes(false).Any(attribute => + attribute.GetType().FullName == "Newtonsoft.Json.JsonIgnoreAttribute"), Is.True, + $"{type.Name}.{fieldName} must be ignored by package JSON serialization."); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdAssetTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdAssetTests.cs.meta new file mode 100644 index 000000000..8540b4b1a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdAssetTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a26fbbc51ae1cf548b6c08aa18c1edff \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs new file mode 100644 index 000000000..15016c985 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs @@ -0,0 +1,78 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace VisualPinball.Unity.Test +{ + public class DmdDisplayPlayerTests + { + [Test] + public void ReannouncingAnIdenticalConfigDoesNotResizeOrClear() + { + var gameObject = new GameObject("Display"); + try { + var display = gameObject.AddComponent(); + display.Id = "dmd0"; + var player = new DisplayPlayer(); + var displaysField = typeof(DisplayPlayer).GetField("_displayGameObjects", + BindingFlags.Instance | BindingFlags.NonPublic); + var handler = typeof(DisplayPlayer).GetMethod("HandleDisplaysRequested", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(displaysField, Is.Not.Null); + Assert.That(handler, Is.Not.Null); + var displays = (Dictionary)displaysField.GetValue(player); + displays.Add(display.Id, display); + var config = new DisplayConfig("dmd0", 140, 36, true, Color.red, Color.black); + + handler.Invoke(player, new object[] { null, new RequestedDisplays(config) }); + handler.Invoke(player, new object[] { null, new RequestedDisplays( + new DisplayConfig("dmd0", 140, 36, true, Color.red, Color.black)) }); + + Assert.That(display.ResizeCount, Is.EqualTo(1)); + Assert.That(display.ClearCount, Is.EqualTo(1)); + } finally { + Object.DestroyImmediate(gameObject); + } + } + } + + public class CountingDisplayComponent : DisplayComponent + { + public int ResizeCount { get; private set; } + public int ClearCount { get; private set; } + + public override string Id { get; set; } + public override Color LitColor { get; set; } + public override Color UnlitColor { get; set; } + public override float MeshHeight => 1f; + public override float AspectRatio { get; set; } + protected override float MeshWidth => 1f; + protected override float MeshDepth => 0.01f; + + public override void UpdateDimensions(int width, int height, bool flipX = false) + { + ResizeCount++; + } + + public override void Clear() + { + ClearCount++; + } + + public override void UpdateFrame(DisplayFrameFormat format, byte[] data) + { + } + + protected override Material CreateMaterial() => null; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs.meta new file mode 100644 index 000000000..c396f1b5d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b5be51aae2d52c84193b896019ba3dfb \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdParamsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdParamsTests.cs new file mode 100644 index 000000000..e098a2cba --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdParamsTests.cs @@ -0,0 +1,81 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Globalization; +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + public class DmdParamsTests + { + [Test] + public void StoresEverySupportedValueTypeByOrdinalName() + { + var values = new DmdParams() + .Set("score", 42) + .Set("player.total", 1234567890123L) + .Set("timer", 1.25) + .Set("label", "BALL 1") + .Set("active", true); + + Assert.That(values.TryGet("score", out var score), Is.True); + Assert.That(score.Type, Is.EqualTo(DmdParamType.Integer)); + Assert.That(score.IntValue, Is.EqualTo(42)); + Assert.That(values.TryGet("player.total", out var total), Is.True); + Assert.That(total.IntValue, Is.EqualTo(1234567890123L)); + Assert.That(values.TryGet("timer", out var timer), Is.True); + Assert.That(timer.ToInvariantString(), Is.EqualTo(1.25.ToString(CultureInfo.InvariantCulture))); + Assert.That(values.TryGet("label", out var label), Is.True); + Assert.That(label.StringValue, Is.EqualTo("BALL 1")); + Assert.That(values.TryGet("active", out var active), Is.True); + Assert.That(active.BoolValue, Is.True); + Assert.That(values.TryGet("Score", out _), Is.False); + } + + [TestCase("")] + [TestCase("player..score")] + [TestCase("9score")] + [TestCase("player score")] + [TestCase("score:value")] + public void RejectsInvalidParameterNames(string name) + { + Assert.Throws(() => new DmdParams().Set(name, 1)); + } + + [Test] + public void RejectsNullNamesAndNormalizesNullStrings() + { + Assert.Throws(() => new DmdParams().Set(null, 1)); + var values = new DmdParams().Set("label", (string)null); + Assert.That(values.TryGet("label", out var label), Is.True); + Assert.That(label.StringValue, Is.EqualTo(string.Empty)); + } + + [Test] + public void CapsDistinctParametersButAllowsUpdates() + { + var values = new DmdParams(); + for (var index = 0; index < DmdValidation.MaxBoundParams; index++) { + values.Set($"p{index}", index); + } + + Assert.That(values.Count, Is.EqualTo(DmdValidation.MaxBoundParams)); + Assert.DoesNotThrow(() => values.Set("p0", 100)); + Assert.Throws(() => values.Set("overflow", 1)); + } + + [Test] + public void ParameterNameLengthCapIsInclusive() + { + var maximum = new string('p', DmdValidation.MaxParameterNameLength); + Assert.DoesNotThrow(() => new DmdParams().Set(maximum, 1)); + Assert.Throws(() => new DmdParams().Set(maximum + "p", 1)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdParamsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdParamsTests.cs.meta new file mode 100644 index 000000000..09ac3becc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdParamsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a89ca380cfba9f0488d1734f9c3ca1f7 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader new file mode 100644 index 000000000..3a63f4458 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader @@ -0,0 +1,12 @@ +Shader "Hidden/VisualPinball/DmdTestDisplay" +{ + Properties + { + __Data ("Data", 2D) = "white" {} + __Dimensions ("Dimensions", Vector) = (128, 32, 0, 0) + } + SubShader + { + Pass {} + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader.meta new file mode 100644 index 000000000..31cc95912 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestDisplay.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: c7a8c2c957c4c394cbbfa0a769d7f366 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs new file mode 100644 index 000000000..a98a10c31 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs @@ -0,0 +1,335 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Linq; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace VisualPinball.Unity.Test +{ + public class DmdValidationTests + { + [Test] + public void BitmapRejectsWrongPixelAndAlphaLengths() + { + var bitmap = new DmdBitmapData { + Width = 4, + Height = 2, + Format = DmdPixelFormat.Rgb24, + Pixels = new byte[8], + Alpha = new byte[7] + }; + + var exception = Assert.Throws(() => bitmap.Validate()); + Assert.That(exception.Diagnostics.Select(diagnostic => diagnostic.Code), + Is.EquivalentTo(new[] { "bitmap.pixels", "bitmap.alpha" })); + } + + [TestCase(0, 32)] + [TestCase(1025, 32)] + [TestCase(128, 0)] + [TestCase(128, 513)] + public void BitmapRejectsDimensionsOutsideCaps(int width, int height) + { + var bitmap = new DmdBitmapData { + Width = width, + Height = height, + Pixels = Array.Empty() + }; + Assert.Throws(() => bitmap.Validate()); + } + + [Test] + public void SpriteValidationIsPureAndNormalizationIsExplicit() + { + var sprite = ScriptableObject.CreateInstance(); + try { + sprite.Frames.Add(ValidBitmap(2, 2)); + sprite.Frames.Add(ValidBitmap(2, 2)); + sprite.FrameDurations.Add(0); + + var result = sprite.Validate(); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.Diagnostics.Any(diagnostic => diagnostic.Code == "sprite.durations.count"), Is.True); + Assert.That(sprite.FrameDurations, Is.EqualTo(new[] { 0 })); + + DmdValidation.Normalize(sprite); + + Assert.That(sprite.FrameDurations, Is.EqualTo(new[] { 1, 1 })); + } finally { + Object.DestroyImmediate(sprite); + } + } + + [Test] + public void CueRejectsTransitionsLongerThanItsLifetime() + { + var cue = ScriptableObject.CreateInstance(); + try { + cue.CueId = "award"; + cue.DurationFrames = 10; + cue.EnterTransition = new DmdTransitionSpec { DurationFrames = 6 }; + cue.ExitTransition = new DmdTransitionSpec { DurationFrames = 5 }; + cue.Layers.Add(new ShapeLayer { Shape = DmdShapeType.Dot }); + + var result = cue.Validate(); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.Diagnostics.Any(diagnostic => diagnostic.Code == "cue.transition.total"), Is.True); + } finally { + Object.DestroyImmediate(cue); + } + } + + [Test] + public void ProjectClampsFrameRateAndRejectsDuplicateCueIds() + { + var project = ScriptableObject.CreateInstance(); + var first = ScriptableObject.CreateInstance(); + var second = ScriptableObject.CreateInstance(); + try { + project.FrameRate = 500; + first.CueId = "same"; + second.CueId = "same"; + first.Layers.Add(new ShapeLayer { Shape = DmdShapeType.Dot }); + second.Layers.Add(new ShapeLayer { Shape = DmdShapeType.Dot }); + project.Cues.Add(first); + project.Cues.Add(second); + + var result = project.Validate(); + + Assert.That(project.FrameRate, Is.EqualTo(500)); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Diagnostics.Any(diagnostic => diagnostic.Code == "project.frameRate"), Is.True); + Assert.That(result.Diagnostics.Any(diagnostic => diagnostic.Code == "project.cue.duplicate"), Is.True); + + DmdValidation.Normalize(project); + Assert.That(project.FrameRate, Is.EqualTo(DmdValidation.MaxFrameRate)); + } finally { + Object.DestroyImmediate(first); + Object.DestroyImmediate(second); + Object.DestroyImmediate(project); + } + } + + [Test] + public void ProjectReportsASharedInvalidSpriteOnlyOnceWithoutMutatingIt() + { + var project = ScriptableObject.CreateInstance(); + var cue = ScriptableObject.CreateInstance(); + var sprite = ScriptableObject.CreateInstance(); + try { + cue.CueId = "shared"; + sprite.Frames.Add(new DmdBitmapData { + Width = 2, + Height = 2, + Format = DmdPixelFormat.I8, + Pixels = new byte[3] + }); + sprite.FrameDurations.Add(0); + cue.Layers.Add(new BitmapLayer { Sprite = sprite }); + cue.Layers.Add(new BitmapLayer { Sprite = sprite }); + project.Cues.Add(cue); + project.Sprites.Add(sprite); + + var result = project.Validate(); + + Assert.That(result.Diagnostics.Count(diagnostic => diagnostic.Code == "bitmap.pixels"), Is.EqualTo(1)); + Assert.That(result.Diagnostics.Count(diagnostic => diagnostic.Code == "sprite.duration.value"), Is.EqualTo(1)); + Assert.That(sprite.FrameDurations[0], Is.EqualTo(0)); + } finally { + Object.DestroyImmediate(sprite); + Object.DestroyImmediate(cue); + Object.DestroyImmediate(project); + } + } + + [Test] + public void ProjectReportsDistinctSameNamedInvalidSpritesSeparately() + { + var project = ScriptableObject.CreateInstance(); + var first = ScriptableObject.CreateInstance(); + var second = ScriptableObject.CreateInstance(); + try { + first.name = "logo"; + second.name = "logo"; + first.Frames.Add(new DmdBitmapData { Width = 2, Height = 2, Pixels = new byte[3] }); + second.Frames.Add(new DmdBitmapData { Width = 2, Height = 2, Pixels = new byte[3] }); + project.Sprites.Add(first); + project.Sprites.Add(second); + + var result = project.Validate(); + + Assert.That(result.Diagnostics.Count(diagnostic => diagnostic.Code == "bitmap.pixels"), + Is.EqualTo(2)); + } finally { + Object.DestroyImmediate(second); + Object.DestroyImmediate(first); + Object.DestroyImmediate(project); + } + } + + [Test] + public void DimensionCapsAcceptTheirInclusiveBoundaries() + { + var minimum = ValidBitmap(1, 1); + var maximum = ValidBitmap(DmdValidation.MaxWidth, DmdValidation.MaxHeight); + + Assert.That(DmdValidation.Validate(minimum).IsValid, Is.True); + Assert.That(DmdValidation.Validate(maximum).IsValid, Is.True); + } + + [Test] + public void SpriteFrameCapAndPaletteSizeAreValidated() + { + var sprite = ScriptableObject.CreateInstance(); + var palette = ScriptableObject.CreateInstance(); + try { + var frame = ValidBitmap(1, 1); + for (var index = 0; index <= DmdValidation.MaxSpriteFrames; index++) { + sprite.Frames.Add(frame); + } + palette.Colors = new Color32[15]; + + Assert.That(sprite.Validate().Diagnostics.Any(diagnostic => diagnostic.Code == "sprite.frames.cap"), Is.True); + Assert.That(palette.Validate().Diagnostics.Any(diagnostic => diagnostic.Code == "palette.colors"), Is.True); + } finally { + Object.DestroyImmediate(palette); + Object.DestroyImmediate(sprite); + } + } + + [Test] + public void TransitionCapAndLoopExemptionUseInclusiveLimits() + { + var cue = ScriptableObject.CreateInstance(); + try { + cue.CueId = "loop"; + cue.DurationFrames = 1; + cue.Loop = true; + cue.EnterTransition = new DmdTransitionSpec { DurationFrames = DmdValidation.MaxTransitionFrames }; + cue.ExitTransition = new DmdTransitionSpec { DurationFrames = DmdValidation.MaxTransitionFrames }; + cue.Layers.Add(new ShapeLayer { Shape = DmdShapeType.Dot }); + + Assert.That(cue.Validate().IsValid, Is.True); + + cue.ExitTransition = new DmdTransitionSpec { DurationFrames = DmdValidation.MaxTransitionFrames + 1 }; + Assert.That(cue.Validate().Diagnostics.Any(diagnostic => diagnostic.Code == "cue.transition.exit"), Is.True); + } finally { + Object.DestroyImmediate(cue); + } + } + + [Test] + public void CueParameterCapAndLayerTracksAreValidated() + { + var cue = ScriptableObject.CreateInstance(); + try { + cue.CueId = "invalid"; + for (var index = 0; index <= DmdValidation.MaxBoundParams; index++) { + cue.Parameters.Add(new DmdCueParameter { Name = $"p{index}" }); + } + var layer = new ShapeLayer { + Shape = DmdShapeType.Rect, + Opacity = 2f, + Width = 0, + Height = 0 + }; + layer.Tracks.Add(new DmdPropertyTrack { + Property = DmdAnimatableProperty.Opacity, + Keys = { + new DmdKeyframe { Frame = 2, Value = 1.5f }, + new DmdKeyframe { Frame = 1, Value = 0.5f } + } + }); + layer.Tracks.Add(new DmdPropertyTrack { + Property = DmdAnimatableProperty.Opacity, + Keys = { new DmdKeyframe { Frame = 0, Value = 1f } } + }); + cue.Layers.Add(layer); + + var codes = cue.Validate().Diagnostics.Select(diagnostic => diagnostic.Code).ToArray(); + + Assert.That(codes, Does.Contain("cue.parameters.cap")); + Assert.That(codes, Does.Contain("layer.opacity")); + Assert.That(codes, Does.Contain("layer.shape.size")); + Assert.That(codes, Does.Contain("layer.track.order")); + Assert.That(codes, Does.Contain("layer.track.opacity")); + Assert.That(codes, Does.Contain("layer.track.duplicate")); + } finally { + Object.DestroyImmediate(cue); + } + } + + [Test] + public void ProjectValidatesBoundsSamplesAndOrphanReferences() + { + var project = ScriptableObject.CreateInstance(); + var cue = ScriptableObject.CreateInstance(); + var sprite = ScriptableObject.CreateInstance(); + try { + project.Width = DmdValidation.MaxWidth + 1; + project.FrameRate = 0; + cue.CueId = "orphan"; + sprite.Frames.Add(ValidBitmap(1, 1)); + cue.Layers.Add(new BitmapLayer { Sprite = sprite }); + project.Cues.Add(cue); + project.SampleStates.Add(new DmdSampleState { + Name = "Bad", + Values = { DmdParamValue.From("bad name", 1L) } + }); + + var codes = project.Validate().Diagnostics.Select(diagnostic => diagnostic.Code).ToArray(); + + Assert.That(project.FrameRate, Is.EqualTo(0)); + Assert.That(codes, Does.Contain("project.width")); + Assert.That(codes, Does.Contain("project.frameRate")); + Assert.That(codes, Does.Contain("project.sprite.orphan")); + Assert.That(codes, Does.Contain("project.sample.name")); + DmdValidation.Normalize(project); + Assert.That(project.FrameRate, Is.EqualTo(DmdValidation.MinFrameRate)); + } finally { + Object.DestroyImmediate(sprite); + Object.DestroyImmediate(cue); + Object.DestroyImmediate(project); + } + } + + [Test] + public void FontRejectsGlyphRectOutsideAtlas() + { + var font = ScriptableObject.CreateInstance(); + try { + font.Atlas = ValidBitmap(4, 4); + font.LineHeight = 5; + font.Baseline = 4; + font.Glyphs.Add(new DmdGlyph { Codepoint = 'A', X = 3, Y = 0, W = 2, H = 4, Advance = 3 }); + + var result = font.Validate(); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.Diagnostics.Any(diagnostic => diagnostic.Code == "font.glyph.rect"), Is.True); + } finally { + Object.DestroyImmediate(font); + } + } + + private static DmdBitmapData ValidBitmap(int width, int height) + { + return new DmdBitmapData { + Width = width, + Height = height, + Format = DmdPixelFormat.I8, + Pixels = new byte[width * height] + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs.meta new file mode 100644 index 000000000..bbb6af1f2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6f9cd73a24f753d4f819c3591b3539aa \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Display/DotMatrixDisplayComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Display/DotMatrixDisplayComponent.cs index 6b3944f3e..21ce48a90 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Display/DotMatrixDisplayComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Display/DotMatrixDisplayComponent.cs @@ -52,6 +52,9 @@ public override float AspectRatio { [NonSerialized] private DisplayFrameFormat _frameFormat = DisplayFrameFormat.Dmd4; [NonSerialized] private Color32[] _colorBuffer; + [NonSerialized] private bool _flipX; + // Runtime-only ownership tracking avoids ever destroying a serialized mesh asset. + [NonSerialized] private Mesh _generatedMesh; private readonly Dictionary> _map = new Dictionary>(); @@ -145,18 +148,30 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public override void UpdateDimensions(int width, int height, bool flipX = false) { + if (_texture != null && _width == width && _height == height && _flipX == flipX) { + return; + } + Logger.Info($"Updating dimensions for DMD \"{_id}\" to {width}x{height}."); + var previousTexture = _texture; + var previousMesh = _generatedMesh; + _width = width; _height = height; + _flipX = flipX; _colorBuffer = new Color32[width * height]; _texture = new Texture2D(width, height, TextureFormat.RGB24, false); _texture.SetPixels32(_colorBuffer); _texture.Apply(); RegenerateMesh(flipX); + _generatedMesh = gameObject.GetComponent().sharedMesh; var mr = gameObject.GetComponent(); mr.sharedMaterial.SetVector(DimensionsProp, new Vector4(_width, _height)); + + DestroyDisplayResource(previousTexture); + DestroyDisplayResource(previousMesh); } public override void UpdateColor(Color color) @@ -183,8 +198,8 @@ public override void UpdateFrame(DisplayFrameFormat format, byte[] frame) case DisplayFrameFormat.Dmd8: if (!_map.ContainsKey(format)) { UpdatePalette(format); - _frameFormat = format; } + _frameFormat = format; var map = _map[format]; if (frame.Length == _width * _height) { for (var y = 0; y < _height; y++) { @@ -202,6 +217,7 @@ public override void UpdateFrame(DisplayFrameFormat format, byte[] frame) break; case DisplayFrameFormat.Dmd24: + _frameFormat = format; if (frame.Length == _width * _height * 3) { CopyRgb24FrameToTexture(frame, _texture.GetRawTextureData()); _texture.Apply(); @@ -220,6 +236,18 @@ public override void UpdateFrame(DisplayFrameFormat format, byte[] frame) } } + private static void DestroyDisplayResource(UnityEngine.Object resource) + { + if (resource == null) { + return; + } + if (Application.isPlaying) { + Destroy(resource); + } else { + DestroyImmediate(resource); + } + } + private void CopyRgb24FrameToTexture(byte[] frame, NativeArray textureData) { var rowLength = _width * 3; diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio.meta new file mode 100644 index 000000000..a5197c9cb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 26fbbab75f56aea469ed6278e0cbf86a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets.meta new file mode 100644 index 000000000..ca47539d2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d2afe92a145fb0a4d9feb66bd3083146 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdBitmapData.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdBitmapData.cs new file mode 100644 index 000000000..4f705bc06 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdBitmapData.cs @@ -0,0 +1,42 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; + +namespace VisualPinball.Unity +{ + public enum DmdPixelFormat + { + I8, + Rgb24, + } + + /// + /// Top-origin bitmap data used by DMD sprites and font atlases. + /// + [Serializable] + public class DmdBitmapData + { + public int Width; + public int Height; + public DmdPixelFormat Format; + public byte[] Pixels = Array.Empty(); + public byte[] Alpha = Array.Empty(); + + /// + /// Throws when the dimensions or buffers are inconsistent. + /// + public void Validate() + { + var result = DmdValidation.Validate(this); + if (!result.IsValid) { + throw new DmdValidationException(result.Diagnostics); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdBitmapData.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdBitmapData.cs.meta new file mode 100644 index 000000000..6041cd9d7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdBitmapData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7deeaf9502e38af408da9dec903acf78 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdCueAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdCueAsset.cs new file mode 100644 index 000000000..d22874b7f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdCueAsset.cs @@ -0,0 +1,113 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public enum CuePriority + { + Base = 0, + Status = 1, + Award = 2, + Mode = 3, + Critical = 4, + System = 5, + } + + public enum CueInterruptPolicy + { + Replace, + Queue, + NonInterruptible, + } + + public enum CueReturnPolicy + { + Resume, + Restart, + Discard, + } + + public enum DmdDirection + { + Left, + Right, + Up, + Down, + } + + public enum DmdTransitionType + { + Cut, + Push, + Cover, + Uncover, + WipeOn, + SplitIn, + SplitOut, + Dissolve, + FadeThroughBlack, + ScrollOff, + } + + [Serializable] + public struct DmdTransitionSpec + { + public DmdTransitionType Type; + public DmdDirection Direction; + public int DurationFrames; + } + + [Serializable] + public struct DmdCueParameter + { + public string Name; + public DmdParamType Type; + public long IntValue; + public double FloatValue; + public string StringValue; + public bool BoolValue; + + public DmdParamValue DefaultValue => new DmdParamValue { + Name = Name, + Type = Type, + IntValue = IntValue, + FloatValue = FloatValue, + StringValue = StringValue, + BoolValue = BoolValue + }; + } + + [CreateAssetMenu(fileName = "DmdCue", menuName = "Pinball/DMD/Cue", order = 311)] + public class DmdCueAsset : ScriptableObject + { + public string CueId; + public CuePriority Priority = CuePriority.Award; + public CueInterruptPolicy Interrupt = CueInterruptPolicy.Replace; + public CueReturnPolicy Return = CueReturnPolicy.Resume; + public string CoalesceKey; + public int DurationFrames; + public bool Loop; + public DmdTransitionSpec EnterTransition; + public DmdTransitionSpec ExitTransition; + + [JsonIgnore] + [SerializeReference] + public List Layers = new List(); + + public List Parameters = new List(); + + public string EffectiveId => string.IsNullOrWhiteSpace(CueId) ? name : CueId; + + public DmdValidationResult Validate() => DmdValidation.Validate(this); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdCueAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdCueAsset.cs.meta new file mode 100644 index 000000000..22acb4ee0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdCueAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bff6dcc9d72128941ae88e59515545ca \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdFontAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdFontAsset.cs new file mode 100644 index 000000000..46b96f635 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdFontAsset.cs @@ -0,0 +1,50 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace VisualPinball.Unity +{ + [Serializable] + public struct DmdGlyph + { + public int Codepoint; + public int X; + public int Y; + public int W; + public int H; + public int OffsetX; + public int OffsetY; + public int Advance; + } + + [Serializable] + public struct DmdKerningPair + { + public int LeftCodepoint; + public int RightCodepoint; + public int Adjustment; + } + + [CreateAssetMenu(fileName = "DmdFont", menuName = "Pinball/DMD/Font", order = 313)] + public class DmdFontAsset : ScriptableObject + { + public string Notes; + public DmdBitmapData Atlas; + public List Glyphs = new List(); + public int LineHeight; + public int Baseline; + public int Tracking; + public List Kerning = new List(); + public int DigitWidth; + + public DmdValidationResult Validate() => DmdValidation.Validate(this); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdFontAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdFontAsset.cs.meta new file mode 100644 index 000000000..6346de3f5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdFontAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 264a07a49902a9145a284bc941dfea6e \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdPaletteAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdPaletteAsset.cs new file mode 100644 index 000000000..e8f75d52d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdPaletteAsset.cs @@ -0,0 +1,20 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using UnityEngine; + +namespace VisualPinball.Unity +{ + [CreateAssetMenu(fileName = "DmdPalette", menuName = "Pinball/DMD/Palette", order = 314)] + public class DmdPaletteAsset : ScriptableObject + { + public Color32[] Colors = new Color32[16]; + + public DmdValidationResult Validate() => DmdValidation.Validate(this); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdPaletteAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdPaletteAsset.cs.meta new file mode 100644 index 000000000..21d323732 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdPaletteAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42c5b17ff24417846a31665a887a68ea \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdProjectAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdProjectAsset.cs new file mode 100644 index 000000000..1b3877861 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdProjectAsset.cs @@ -0,0 +1,50 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public enum DmdColorMode + { + Mono4, + Mono16, + Rgb24, + } + + [Serializable] + public class DmdSampleState + { + public string Name; + public List Values = new List(); + } + + [CreateAssetMenu(fileName = "DmdProject", menuName = "Pinball/DMD/Project", order = 310)] + public class DmdProjectAsset : ScriptableObject + { + public string DisplayId = "dmd0"; + public int Width = 128; + public int Height = 32; + public DmdColorMode ColorMode = DmdColorMode.Mono16; + public int FrameRate = 30; + [JsonIgnore] public Color PreviewTint = new Color(1f, 0.18f, 0f); + + [JsonIgnore] public List Cues = new List(); + [JsonIgnore] public List Sprites = new List(); + [JsonIgnore] public List Fonts = new List(); + [JsonIgnore] public List Palettes = new List(); + + public List SampleStates = new List(); + + public DmdValidationResult Validate() => DmdValidation.Validate(this); + public DmdValidationResult Normalize() => DmdValidation.Normalize(this); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdProjectAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdProjectAsset.cs.meta new file mode 100644 index 000000000..4935fc035 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdProjectAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ecb58590387e6142ae6a8c1852efbe4 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdShade.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdShade.cs new file mode 100644 index 000000000..8a9b56f10 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdShade.cs @@ -0,0 +1,33 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// A layer color carrying both monochrome intensity and RGB color. + /// + [Serializable] + public struct DmdShade + { + public byte Intensity; + public Color32 Color; + + public static readonly DmdShade White = new DmdShade { + Intensity = byte.MaxValue, + Color = new Color32(byte.MaxValue, byte.MaxValue, byte.MaxValue, byte.MaxValue) + }; + + public static readonly DmdShade Black = new DmdShade { + Intensity = 0, + Color = new Color32(0, 0, 0, byte.MaxValue) + }; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdShade.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdShade.cs.meta new file mode 100644 index 000000000..60e38cda8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdShade.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a5fae76eb091ab429b1f7da2af93248 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdSpriteAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdSpriteAsset.cs new file mode 100644 index 000000000..873d2db61 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdSpriteAsset.cs @@ -0,0 +1,23 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Collections.Generic; +using UnityEngine; + +namespace VisualPinball.Unity +{ + [CreateAssetMenu(fileName = "DmdSprite", menuName = "Pinball/DMD/Sprite", order = 312)] + public class DmdSpriteAsset : ScriptableObject + { + public List Frames = new List(); + public List FrameDurations = new List(); + + public DmdValidationResult Validate() => DmdValidation.Validate(this); + public DmdValidationResult Normalize() => DmdValidation.Normalize(this); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdSpriteAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdSpriteAsset.cs.meta new file mode 100644 index 000000000..bcab66c20 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Assets/DmdSpriteAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8648a5a625b99fc4789b58cc7a50abc6 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding.meta new file mode 100644 index 000000000..23bd0ddd6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ba26b3b86dca92542bd82bd0b8357d92 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParamValue.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParamValue.cs new file mode 100644 index 000000000..8e5997579 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParamValue.cs @@ -0,0 +1,94 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Globalization; + +namespace VisualPinball.Unity +{ + public enum DmdParamType + { + Integer, + Float, + String, + Boolean, + } + + [Serializable] + public struct DmdParamValue : IEquatable + { + public string Name; + public DmdParamType Type; + public long IntValue; + public double FloatValue; + public string StringValue; + public bool BoolValue; + + public static DmdParamValue From(string name, long value) => new DmdParamValue { + Name = name, + Type = DmdParamType.Integer, + IntValue = value + }; + + public static DmdParamValue From(string name, double value) => new DmdParamValue { + Name = name, + Type = DmdParamType.Float, + FloatValue = value + }; + + public static DmdParamValue From(string name, string value) => new DmdParamValue { + Name = name, + Type = DmdParamType.String, + StringValue = value + }; + + public static DmdParamValue From(string name, bool value) => new DmdParamValue { + Name = name, + Type = DmdParamType.Boolean, + BoolValue = value + }; + + public string ToInvariantString() + { + switch (Type) { + case DmdParamType.Integer: + return IntValue.ToString(CultureInfo.InvariantCulture); + case DmdParamType.Float: + return FloatValue.ToString(CultureInfo.InvariantCulture); + case DmdParamType.String: + return StringValue ?? string.Empty; + case DmdParamType.Boolean: + return BoolValue.ToString(CultureInfo.InvariantCulture); + default: + throw new ArgumentOutOfRangeException(); + } + } + + public bool Equals(DmdParamValue other) + { + return Name == other.Name && Type == other.Type && IntValue == other.IntValue && + FloatValue.Equals(other.FloatValue) && StringValue == other.StringValue && + BoolValue == other.BoolValue; + } + + public override bool Equals(object obj) => obj is DmdParamValue other && Equals(other); + + public override int GetHashCode() + { + unchecked { + var hashCode = Name != null ? Name.GetHashCode() : 0; + hashCode = (hashCode * 397) ^ (int)Type; + hashCode = (hashCode * 397) ^ IntValue.GetHashCode(); + hashCode = (hashCode * 397) ^ FloatValue.GetHashCode(); + hashCode = (hashCode * 397) ^ (StringValue != null ? StringValue.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ BoolValue.GetHashCode(); + return hashCode; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParamValue.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParamValue.cs.meta new file mode 100644 index 000000000..fee83a8c3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParamValue.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 710e5101fae0df743b01f0e93434ac6f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParams.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParams.cs new file mode 100644 index 000000000..ad2c518cc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParams.cs @@ -0,0 +1,82 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Unity +{ + /// + /// Mutable, ordinal-keyed values supplied to a cue instance. + /// + public sealed class DmdParams + { + private readonly Dictionary _values = + new Dictionary(StringComparer.Ordinal); + + public int Count => _values.Count; + internal int Version { get; private set; } + + public DmdParams Set(string name, int value) => Set(name, (long)value); + + public DmdParams Set(string name, long value) => SetValue(name, DmdParamValue.From(name, value)); + + public DmdParams Set(string name, double value) => SetValue(name, DmdParamValue.From(name, value)); + + public DmdParams Set(string name, string value) + { + return SetValue(name, DmdParamValue.From(name, value ?? string.Empty)); + } + + public DmdParams Set(string name, bool value) => SetValue(name, DmdParamValue.From(name, value)); + + public bool TryGet(string name, out DmdParamValue value) + { + if (name == null) { + throw new ArgumentNullException(nameof(name)); + } + return _values.TryGetValue(name, out value); + } + + internal IEnumerable> Values => _values; + + internal DmdParams Clone() + { + var clone = new DmdParams(); + clone.MergeFrom(this); + return clone; + } + + internal void MergeFrom(DmdParams other) + { + if (other == null) { + return; + } + + foreach (var pair in other._values) { + SetValue(pair.Key, pair.Value); + } + } + + private DmdParams SetValue(string name, DmdParamValue value) + { + DmdValidation.ValidateParameterName(name, nameof(name)); + if (!_values.ContainsKey(name) && _values.Count >= DmdValidation.MaxBoundParams) { + throw new ArgumentException($"A cue cannot bind more than {DmdValidation.MaxBoundParams} parameters.", nameof(name)); + } + + if (!_values.TryGetValue(name, out var previous) || !previous.Equals(value)) { + _values[name] = value; + unchecked { + Version++; + } + } + return this; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParams.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParams.cs.meta new file mode 100644 index 000000000..689243b84 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/DmdParams.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8bf25e622c5e27d4a8ba956280af73a3 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing.meta new file mode 100644 index 000000000..baf40c25e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 49fda3a1ddab9fb43b0c1194f9dc3602 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs new file mode 100644 index 000000000..d101ee4d8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs @@ -0,0 +1,680 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using System.Linq; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public enum DmdValidationSeverity + { + Warning, + Error, + } + + public readonly struct DmdValidationDiagnostic + { + public DmdValidationSeverity Severity { get; } + public string Code { get; } + public string Message { get; } + + public DmdValidationDiagnostic(DmdValidationSeverity severity, string code, string message) + { + Severity = severity; + Code = code; + Message = message; + } + + public override string ToString() => $"{Severity} {Code}: {Message}"; + } + + public sealed class DmdValidationResult + { + private readonly List _diagnostics = new List(); + + public IReadOnlyList Diagnostics => _diagnostics; + public bool IsValid => _diagnostics.All(diagnostic => diagnostic.Severity != DmdValidationSeverity.Error); + + internal void Error(string code, string message) + { + Add(DmdValidationSeverity.Error, code, message); + } + + internal void Warning(string code, string message) + { + Add(DmdValidationSeverity.Warning, code, message); + } + + internal void Include(DmdValidationResult other, string path) + { + foreach (var diagnostic in other.Diagnostics) { + Add(diagnostic.Severity, diagnostic.Code, + string.IsNullOrEmpty(path) ? diagnostic.Message : $"{path}: {diagnostic.Message}"); + } + } + + private void Add(DmdValidationSeverity severity, string code, string message) + { + _diagnostics.Add(new DmdValidationDiagnostic(severity, code, message)); + } + } + + public sealed class DmdValidationException : Exception + { + public IReadOnlyList Diagnostics { get; } + + public DmdValidationException(IReadOnlyList diagnostics) + : base(BuildMessage(diagnostics)) + { + Diagnostics = diagnostics ?? Array.Empty(); + } + + private static string BuildMessage(IReadOnlyList diagnostics) + { + if (diagnostics == null || diagnostics.Count == 0) { + return "Invalid DMD data."; + } + return string.Join(Environment.NewLine, diagnostics.Select(diagnostic => diagnostic.ToString())); + } + } + + /// + /// Shared validation and normalization rules for DMD assets. + /// + public static class DmdValidation + { + public const int MaxWidth = 1024; + public const int MaxHeight = 512; + public const int MaxSpriteFrames = 4096; + public const int MaxResolvedTextLength = 1024; + public const int MaxBoundParams = 256; + public const int MinFrameRate = 1; + public const int MaxFrameRate = 120; + public const int MaxTransitionFrames = 600; + public const int MaxParameterNameLength = 128; + + public static DmdValidationResult Validate(DmdBitmapData bitmap) + { + if (bitmap == null) { + throw new ArgumentNullException(nameof(bitmap)); + } + + var result = new DmdValidationResult(); + if (!ValidateDimensions(bitmap.Width, bitmap.Height, result, "bitmap")) { + return result; + } + + long channelCount; + switch (bitmap.Format) { + case DmdPixelFormat.I8: + channelCount = 1; + break; + case DmdPixelFormat.Rgb24: + channelCount = 3; + break; + default: + result.Error("bitmap.format", $"Unsupported pixel format value {(int)bitmap.Format}."); + return result; + } + + var pixelCount = (long)bitmap.Width * bitmap.Height; + var expectedPixels = pixelCount * channelCount; + if (bitmap.Pixels == null || bitmap.Pixels.LongLength != expectedPixels) { + result.Error("bitmap.pixels", + $"Pixel buffer has {bitmap.Pixels?.LongLength ?? 0} bytes; expected {expectedPixels}."); + } + if (bitmap.Alpha != null && bitmap.Alpha.Length != 0 && bitmap.Alpha.LongLength != pixelCount) { + result.Error("bitmap.alpha", + $"Alpha buffer has {bitmap.Alpha.LongLength} bytes; expected 0 or {pixelCount}."); + } + return result; + } + + public static DmdValidationResult Validate(DmdSpriteAsset sprite) + { + if (sprite == null) { + throw new ArgumentNullException(nameof(sprite)); + } + + var result = new DmdValidationResult(); + if (sprite.Frames == null || sprite.Frames.Count == 0) { + result.Error("sprite.frames.empty", "A sprite must contain at least one frame."); + return result; + } + if (sprite.Frames.Count > MaxSpriteFrames) { + result.Error("sprite.frames.cap", $"A sprite cannot exceed {MaxSpriteFrames} frames."); + } + + DmdBitmapData first = null; + for (var index = 0; index < sprite.Frames.Count; index++) { + var frame = sprite.Frames[index]; + if (frame == null) { + result.Error("sprite.frame.null", $"Frame {index} is null."); + continue; + } + result.Include(Validate(frame), $"Frame {index}"); + if (first == null) { + first = frame; + } else if (frame.Width != first.Width || frame.Height != first.Height || frame.Format != first.Format) { + result.Error("sprite.frame.shape", $"Frame {index} does not match the first frame's size and format."); + } + } + + ValidateFrameDurations(sprite, result); + return result; + } + + public static DmdValidationResult Validate(DmdFontAsset font) + { + if (font == null) { + throw new ArgumentNullException(nameof(font)); + } + + var result = new DmdValidationResult(); + if (font.Atlas == null) { + result.Error("font.atlas.null", "Font atlas is missing."); + return result; + } + result.Include(Validate(font.Atlas), "Atlas"); + if (font.Atlas.Format != DmdPixelFormat.I8) { + result.Error("font.atlas.format", "A font atlas must use the I8 format."); + } + if (font.LineHeight <= 0) { + result.Error("font.lineHeight", "LineHeight must be greater than zero."); + } + if (font.Baseline < 0 || font.Baseline > font.LineHeight) { + result.Error("font.baseline", "Baseline must be between zero and LineHeight."); + } + if (font.DigitWidth < 0) { + result.Error("font.digitWidth", "DigitWidth cannot be negative."); + } + + if (font.Glyphs == null) { + result.Error("font.glyphs.null", "Glyph collection is null."); + return result; + } + var codepoints = new HashSet(); + for (var index = 0; index < font.Glyphs.Count; index++) { + var glyph = font.Glyphs[index]; + if (glyph.Codepoint < 0 || !codepoints.Add(glyph.Codepoint)) { + result.Error("font.glyph.codepoint", $"Glyph {index} has an invalid or duplicate codepoint."); + } + if (glyph.X < 0 || glyph.Y < 0 || glyph.W < 0 || glyph.H < 0 || + (long)glyph.X + glyph.W > font.Atlas.Width || (long)glyph.Y + glyph.H > font.Atlas.Height) { + result.Error("font.glyph.rect", $"Glyph {index} lies outside the atlas."); + } + if (glyph.Advance < 0) { + result.Error("font.glyph.advance", $"Glyph {index} has a negative advance."); + } + } + if (font.Kerning == null) { + result.Error("font.kerning.null", "Kerning collection is null."); + } else { + for (var index = 0; index < font.Kerning.Count; index++) { + var pair = font.Kerning[index]; + if (pair.LeftCodepoint < 0 || pair.RightCodepoint < 0) { + result.Error("font.kerning.codepoint", $"Kerning pair {index} has a negative codepoint."); + } + } + } + return result; + } + + public static DmdValidationResult Validate(DmdPaletteAsset palette) + { + if (palette == null) { + throw new ArgumentNullException(nameof(palette)); + } + var result = new DmdValidationResult(); + if (palette.Colors == null || palette.Colors.Length != 16) { + result.Error("palette.colors", "A v1 DMD palette must contain exactly 16 colors."); + } + return result; + } + + public static DmdValidationResult Validate(DmdCueAsset cue) + { + if (cue == null) { + throw new ArgumentNullException(nameof(cue)); + } + + var result = new DmdValidationResult(); + ValidateCue(cue, result, new HashSet(), new HashSet()); + return result; + } + + public static DmdValidationResult Validate(DmdProjectAsset project) + { + if (project == null) { + throw new ArgumentNullException(nameof(project)); + } + + var result = new DmdValidationResult(); + ValidateDimensions(project.Width, project.Height, result, "project"); + if (string.IsNullOrWhiteSpace(project.DisplayId)) { + result.Error("project.displayId", "DisplayId cannot be empty."); + } + var clampedFrameRate = math.clamp(project.FrameRate, MinFrameRate, MaxFrameRate); + if (clampedFrameRate != project.FrameRate) { + result.Warning("project.frameRate", + $"FrameRate {project.FrameRate} will normalize to {clampedFrameRate}."); + } + + if (project.Cues == null || project.Sprites == null || project.Fonts == null || project.Palettes == null) { + result.Error("project.inventory.null", "Project inventory collections cannot be null."); + return result; + } + + var cueIds = new HashSet(StringComparer.Ordinal); + var validatedCues = new HashSet(); + var validatedSprites = new HashSet(); + var validatedFonts = new HashSet(); + var validatedPalettes = new HashSet(); + for (var index = 0; index < project.Cues.Count; index++) { + var cue = project.Cues[index]; + if (cue == null) { + result.Error("project.cue.null", $"Cue inventory entry {index} is null."); + continue; + } + if (validatedCues.Add(cue)) { + var cueResult = new DmdValidationResult(); + ValidateCue(cue, cueResult, validatedSprites, validatedFonts); + result.Include(cueResult, $"Cue {cue.name}"); + } + if (!string.IsNullOrWhiteSpace(cue.EffectiveId) && !cueIds.Add(cue.EffectiveId)) { + result.Error("project.cue.duplicate", $"Cue id '{cue.EffectiveId}' is not unique."); + } + ValidateLayerInventoryReferences(project, cue, result); + } + + for (var index = 0; index < project.Sprites.Count; index++) { + var sprite = project.Sprites[index]; + if (sprite == null) { + result.Error("project.sprite.null", $"Sprite inventory entry {index} is null."); + } else if (validatedSprites.Add(sprite)) { + result.Include(Validate(sprite), $"Sprite {sprite.name}"); + } + } + for (var index = 0; index < project.Fonts.Count; index++) { + var font = project.Fonts[index]; + if (font == null) { + result.Error("project.font.null", $"Font inventory entry {index} is null."); + } else if (validatedFonts.Add(font)) { + result.Include(Validate(font), $"Font {font.name}"); + } + } + for (var index = 0; index < project.Palettes.Count; index++) { + var palette = project.Palettes[index]; + if (palette == null) { + result.Error("project.palette.null", $"Palette inventory entry {index} is null."); + } else if (validatedPalettes.Add(palette)) { + result.Include(Validate(palette), $"Palette {palette.name}"); + } + } + + ValidateSampleStates(project, result); + return result; + } + + /// + /// Returns the pre-normalization diagnostics, then applies the load-time project defaults explicitly. + /// + public static DmdValidationResult Normalize(DmdProjectAsset project) + { + if (project == null) { + throw new ArgumentNullException(nameof(project)); + } + + var result = Validate(project); + project.FrameRate = math.clamp(project.FrameRate, MinFrameRate, MaxFrameRate); + var normalizedSprites = new HashSet(); + if (project.Sprites != null) { + foreach (var sprite in project.Sprites) { + NormalizeSprite(sprite, normalizedSprites); + } + } + if (project.Cues != null) { + foreach (var cue in project.Cues) { + if (cue?.Layers == null) { + continue; + } + foreach (var layer in cue.Layers) { + switch (layer) { + case BitmapLayer bitmap: + NormalizeSprite(bitmap.Sprite, normalizedSprites); + break; + case MaskLayer mask: + NormalizeSprite(mask.Mask, normalizedSprites); + break; + } + } + } + } + return result; + } + + /// + /// Returns the pre-normalization diagnostics, then applies load-time frame-duration defaults explicitly. + /// + public static DmdValidationResult Normalize(DmdSpriteAsset sprite) + { + if (sprite == null) { + throw new ArgumentNullException(nameof(sprite)); + } + var result = Validate(sprite); + NormalizeFrameDurations(sprite); + return result; + } + + internal static void ValidateParameterName(string name, string argumentName) + { + if (name == null) { + throw new ArgumentNullException(argumentName); + } + if (!IsValidParameterName(name)) { + throw new ArgumentException( + $"Parameter names must be 1-{MaxParameterNameLength} characters, use identifier segments separated by dots, and contain only letters, digits, or underscores.", + argumentName); + } + } + + public static bool IsValidParameterName(string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > MaxParameterNameLength) { + return false; + } + var segmentStart = true; + foreach (var character in name) { + if (character == '.') { + if (segmentStart) { + return false; + } + segmentStart = true; + continue; + } + if (segmentStart) { + if (character != '_' && !char.IsLetter(character)) { + return false; + } + segmentStart = false; + } else if (character != '_' && !char.IsLetterOrDigit(character)) { + return false; + } + } + return !segmentStart; + } + + private static bool ValidateDimensions(int width, int height, DmdValidationResult result, string kind) + { + var valid = true; + if (width < 1 || width > MaxWidth) { + result.Error($"{kind}.width", $"Width must be between 1 and {MaxWidth}; got {width}."); + valid = false; + } + if (height < 1 || height > MaxHeight) { + result.Error($"{kind}.height", $"Height must be between 1 and {MaxHeight}; got {height}."); + valid = false; + } + return valid; + } + + private static void ValidateFrameDurations(DmdSpriteAsset sprite, DmdValidationResult result) + { + if (sprite.FrameDurations == null) { + result.Warning("sprite.durations.null", "Null frame durations will normalize to the default duration."); + return; + } + if (sprite.FrameDurations.Count == 0) { + return; + } + if (sprite.FrameDurations.Count != sprite.Frames.Count) { + result.Warning("sprite.durations.count", "Frame durations will normalize because their count does not match the frames."); + return; + } + for (var index = 0; index < sprite.FrameDurations.Count; index++) { + if (sprite.FrameDurations[index] < 1) { + result.Warning("sprite.duration.value", $"Frame duration {index} will normalize to one tick."); + } + } + } + + private static void NormalizeFrameDurations(DmdSpriteAsset sprite) + { + if (sprite.FrameDurations == null) { + sprite.FrameDurations = new List(); + return; + } + if (sprite.FrameDurations.Count == 0 || sprite.Frames == null) { + return; + } + + var normalized = new List(sprite.Frames.Count); + for (var index = 0; index < sprite.Frames.Count; index++) { + normalized.Add(index < sprite.FrameDurations.Count ? math.max(1, sprite.FrameDurations[index]) : 1); + } + sprite.FrameDurations = normalized; + } + + private static void NormalizeSprite(DmdSpriteAsset sprite, HashSet normalizedSprites) + { + if (sprite != null && normalizedSprites.Add(sprite)) { + NormalizeFrameDurations(sprite); + } + } + + private static void ValidateCue(DmdCueAsset cue, DmdValidationResult result, + HashSet validatedSprites, HashSet validatedFonts) + { + if (string.IsNullOrWhiteSpace(cue.EffectiveId)) { + result.Error("cue.id", "CueId is empty and the asset has no name."); + } + if (cue.DurationFrames < 0) { + result.Error("cue.duration", "DurationFrames cannot be negative."); + } + ValidateTransition(cue.EnterTransition, "enter", result); + ValidateTransition(cue.ExitTransition, "exit", result); + if (cue.DurationFrames > 0 && !cue.Loop && + (long)cue.EnterTransition.DurationFrames + cue.ExitTransition.DurationFrames > cue.DurationFrames) { + result.Error("cue.transition.total", "Enter and exit transitions exceed the finite cue duration."); + } + + ValidateCueParameters(cue, result); + if (cue.Layers == null) { + result.Error("cue.layers.null", "Layer collection is null."); + return; + } + for (var index = 0; index < cue.Layers.Count; index++) { + var layer = cue.Layers[index]; + if (layer == null) { + result.Error("cue.layer.null", $"Layer {index} is null."); + continue; + } + ValidateLayer(layer, index, result, validatedSprites, validatedFonts); + } + } + + private static void ValidateTransition(DmdTransitionSpec transition, string label, DmdValidationResult result) + { + if (transition.DurationFrames < 0 || transition.DurationFrames > MaxTransitionFrames) { + result.Error($"cue.transition.{label}", + $"The {label} transition duration must be between 0 and {MaxTransitionFrames}."); + } + } + + private static void ValidateCueParameters(DmdCueAsset cue, DmdValidationResult result) + { + if (cue.Parameters == null) { + result.Error("cue.parameters.null", "Parameter declarations are null."); + return; + } + if (cue.Parameters.Count > MaxBoundParams) { + result.Error("cue.parameters.cap", $"A cue cannot declare more than {MaxBoundParams} parameters."); + } + var names = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < cue.Parameters.Count; index++) { + var parameter = cue.Parameters[index]; + if (!Enum.IsDefined(typeof(DmdParamType), parameter.Type)) { + result.Error("cue.parameter.type", $"Parameter {index} has an invalid type."); + } + if (!IsValidParameterName(parameter.Name)) { + result.Error("cue.parameter.name", $"Parameter {index} has an invalid name."); + } else if (!names.Add(parameter.Name)) { + result.Error("cue.parameter.duplicate", $"Parameter '{parameter.Name}' is declared more than once."); + } + } + } + + private static void ValidateLayer(DmdLayer layer, int index, DmdValidationResult result, + HashSet validatedSprites, HashSet validatedFonts) + { + var path = $"Layer {index}"; + if (layer.Opacity < 0f || layer.Opacity > 1f) { + result.Error("layer.opacity", $"{path} opacity must be between zero and one."); + } + if (layer.StartFrame < 0 || layer.EndFrame < 0 || layer.EndFrame != 0 && layer.EndFrame < layer.StartFrame) { + result.Error("layer.span", $"{path} has an invalid frame span."); + } + ValidateTracks(layer, path, result); + + switch (layer) { + case NumberLayer number: + if (!IsValidParameterName(number.ParamName)) { + result.Error("layer.number.parameter", $"{path} has an invalid parameter name."); + } + if (number.CountUpSeconds < 0f) { + result.Error("layer.number.countUp", $"{path} count-up time cannot be negative."); + } + ValidateTextLayer(number, path, result, validatedFonts); + break; + case TextLayer text: + ValidateTextLayer(text, path, result, validatedFonts); + break; + case BitmapLayer bitmap: + if (bitmap.Sprite == null) { + result.Error("layer.bitmap.sprite", $"{path} has no sprite."); + } else if (validatedSprites.Add(bitmap.Sprite)) { + result.Include(Validate(bitmap.Sprite), $"{path} sprite"); + } + if (bitmap.SpriteStartFrame < 0) { + result.Error("layer.bitmap.start", $"{path} SpriteStartFrame cannot be negative."); + } + break; + case MaskLayer mask: + if (mask.Mask == null) { + result.Error("layer.mask.sprite", $"{path} has no mask sprite."); + } else if (validatedSprites.Add(mask.Mask)) { + result.Include(Validate(mask.Mask), $"{path} mask"); + } + if (mask.SpriteStartFrame < 0) { + result.Error("layer.mask.start", $"{path} SpriteStartFrame cannot be negative."); + } + break; + case ShapeLayer shape: + if (shape.Shape != DmdShapeType.Dot && (shape.Width < 1 || shape.Height < 1)) { + result.Error("layer.shape.size", $"{path} shape dimensions must be positive."); + } + break; + default: + result.Error("layer.type", $"{path} has unsupported type {layer.GetType().FullName}."); + break; + } + } + + private static void ValidateTextLayer(TextLayer text, string path, DmdValidationResult result, + HashSet validatedFonts) + { + if (text.Font == null) { + result.Error("layer.text.font", $"{path} has no font."); + } else if (validatedFonts.Add(text.Font)) { + result.Include(Validate(text.Font), $"{path} font"); + } + if (text.MarqueeSpeed < 0) { + result.Error("layer.text.marquee", $"{path} marquee speed cannot be negative."); + } + } + + private static void ValidateTracks(DmdLayer layer, string path, DmdValidationResult result) + { + if (layer.Tracks == null) { + result.Error("layer.tracks.null", $"{path} track collection is null."); + return; + } + var properties = new HashSet(); + for (var trackIndex = 0; trackIndex < layer.Tracks.Count; trackIndex++) { + var track = layer.Tracks[trackIndex]; + if (track == null || track.Keys == null || track.Keys.Count == 0) { + result.Error("layer.track.empty", $"{path} track {trackIndex} has no keys."); + continue; + } + if (!properties.Add(track.Property)) { + result.Warning("layer.track.duplicate", $"{path} contains duplicate {track.Property} tracks; the last wins."); + } + var previousFrame = -1; + for (var keyIndex = 0; keyIndex < track.Keys.Count; keyIndex++) { + var key = track.Keys[keyIndex]; + if (key.Frame < 0 || key.Frame < previousFrame) { + result.Error("layer.track.order", $"{path} track {trackIndex} keys must be ordered at non-negative frames."); + } + if (track.Property == DmdAnimatableProperty.Opacity && (key.Value < 0f || key.Value > 1f)) { + result.Error("layer.track.opacity", $"{path} opacity track values must be between zero and one."); + } + previousFrame = key.Frame; + } + } + } + + private static void ValidateLayerInventoryReferences(DmdProjectAsset project, DmdCueAsset cue, + DmdValidationResult result) + { + if (cue.Layers == null) { + return; + } + foreach (var layer in cue.Layers) { + switch (layer) { + case BitmapLayer bitmap when bitmap.Sprite != null && !project.Sprites.Contains(bitmap.Sprite): + result.Warning("project.sprite.orphan", $"Cue '{cue.EffectiveId}' references sprite '{bitmap.Sprite.name}' outside the project inventory."); + break; + case MaskLayer mask when mask.Mask != null && !project.Sprites.Contains(mask.Mask): + result.Warning("project.mask.orphan", $"Cue '{cue.EffectiveId}' references mask '{mask.Mask.name}' outside the project inventory."); + break; + } + if (layer is TextLayer text && text.Font != null && !project.Fonts.Contains(text.Font)) { + result.Warning("project.font.orphan", $"Cue '{cue.EffectiveId}' references font '{text.Font.name}' outside the project inventory."); + } + } + } + + private static void ValidateSampleStates(DmdProjectAsset project, DmdValidationResult result) + { + if (project.SampleStates == null) { + result.Error("project.samples.null", "Sample state collection cannot be null."); + return; + } + for (var stateIndex = 0; stateIndex < project.SampleStates.Count; stateIndex++) { + var state = project.SampleStates[stateIndex]; + if (state == null || state.Values == null) { + result.Error("project.sample.null", $"Sample state {stateIndex} is null or has no values."); + continue; + } + if (state.Values.Count > MaxBoundParams) { + result.Error("project.sample.cap", $"Sample state {stateIndex} exceeds {MaxBoundParams} values."); + } + foreach (var value in state.Values) { + if (!Enum.IsDefined(typeof(DmdParamType), value.Type)) { + result.Error("project.sample.type", $"Sample state {stateIndex} contains an invalid parameter type."); + } + if (!IsValidParameterName(value.Name)) { + result.Error("project.sample.name", $"Sample state {stateIndex} contains an invalid parameter name."); + } + } + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs.meta new file mode 100644 index 000000000..98ad68cb3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de9e251d3aa2eb1478215503bc462fb4 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers.meta new file mode 100644 index 000000000..5af459875 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4084e7107c8e56244b2ad17abdc503e5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/BitmapLayer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/BitmapLayer.cs new file mode 100644 index 000000000..39e43815c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/BitmapLayer.cs @@ -0,0 +1,31 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using Newtonsoft.Json; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public enum DmdLoopMode + { + Once, + Loop, + PingPong, + HoldLast, + } + + [Serializable] + public class BitmapLayer : DmdLayer + { + [JsonIgnore] public DmdSpriteAsset Sprite; + public DmdLoopMode Loop; + public int SpriteStartFrame; + public Color32 Tint = new Color32(byte.MaxValue, byte.MaxValue, byte.MaxValue, byte.MaxValue); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/BitmapLayer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/BitmapLayer.cs.meta new file mode 100644 index 000000000..06b6c9dbf --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/BitmapLayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 303a3298f79c5a14089c759b7f6e988a \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdLayer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdLayer.cs new file mode 100644 index 000000000..c030bdc14 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdLayer.cs @@ -0,0 +1,35 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Unity +{ + public enum DmdBlendMode + { + Opaque, + Alpha, + Add, + Invert, + } + + [Serializable] + public abstract class DmdLayer + { + public string Name; + public bool Visible = true; + public int X; + public int Y; + public DmdBlendMode Blend = DmdBlendMode.Alpha; + public float Opacity = 1f; + public int StartFrame; + public int EndFrame; + public List Tracks = new List(); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdLayer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdLayer.cs.meta new file mode 100644 index 000000000..1ee121696 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdLayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e7ea696ea30c7d479d54289e91730bb \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdPropertyTrack.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdPropertyTrack.cs new file mode 100644 index 000000000..02fc85827 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdPropertyTrack.cs @@ -0,0 +1,42 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Unity +{ + public enum DmdAnimatableProperty + { + X, + Y, + Opacity, + SpriteFrame, + } + + public enum DmdInterpolation + { + Step, + Linear, + } + + [Serializable] + public struct DmdKeyframe + { + public int Frame; + public float Value; + public DmdInterpolation Interp; + } + + [Serializable] + public class DmdPropertyTrack + { + public DmdAnimatableProperty Property; + public List Keys = new List(); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdPropertyTrack.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdPropertyTrack.cs.meta new file mode 100644 index 000000000..7cf0af8dc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/DmdPropertyTrack.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a11e9ca5ad9eb944b9e7323f9cd712b5 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/MaskLayer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/MaskLayer.cs new file mode 100644 index 000000000..741710250 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/MaskLayer.cs @@ -0,0 +1,21 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using Newtonsoft.Json; + +namespace VisualPinball.Unity +{ + [Serializable] + public class MaskLayer : DmdLayer + { + [JsonIgnore] public DmdSpriteAsset Mask; + public DmdLoopMode Loop; + public int SpriteStartFrame; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/MaskLayer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/MaskLayer.cs.meta new file mode 100644 index 000000000..5a43dbdb0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/MaskLayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7192c318126af5e48abfc7eec736ae0c \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/NumberLayer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/NumberLayer.cs new file mode 100644 index 000000000..c17cf7919 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/NumberLayer.cs @@ -0,0 +1,20 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; + +namespace VisualPinball.Unity +{ + [Serializable] + public class NumberLayer : TextLayer + { + public string ParamName; + public string Format = "N0"; + public float CountUpSeconds; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/NumberLayer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/NumberLayer.cs.meta new file mode 100644 index 000000000..d56511490 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/NumberLayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fcb3a34957b77524ebc01a9811359c5c \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/ShapeLayer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/ShapeLayer.cs new file mode 100644 index 000000000..8bcb740d0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/ShapeLayer.cs @@ -0,0 +1,30 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; + +namespace VisualPinball.Unity +{ + public enum DmdShapeType + { + Rect, + HLine, + VLine, + Dot, + } + + [Serializable] + public class ShapeLayer : DmdLayer + { + public DmdShapeType Shape; + public int Width; + public int Height; + public DmdShade Shade = DmdShade.White; + public bool Filled; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/ShapeLayer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/ShapeLayer.cs.meta new file mode 100644 index 000000000..1f44d43d5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/ShapeLayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 822fde6dbe0a2d74389f903c8366debd \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/TextLayer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/TextLayer.cs new file mode 100644 index 000000000..7fc902896 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/TextLayer.cs @@ -0,0 +1,56 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using Newtonsoft.Json; + +namespace VisualPinball.Unity +{ + public enum DmdAnchor + { + TopLeft, + TopCenter, + TopRight, + MiddleLeft, + MiddleCenter, + MiddleRight, + BottomLeft, + BottomCenter, + BottomRight, + BaselineLeft, + BaselineCenter, + BaselineRight, + } + + public enum DmdTextEffect + { + None, + Outline, + Shadow, + Inverse, + } + + public enum DmdOverflow + { + Clip, + Marquee, + } + + [Serializable] + public class TextLayer : DmdLayer + { + [JsonIgnore] public DmdFontAsset Font; + public string Text; + public DmdAnchor Anchor; + public DmdTextEffect Effect; + public DmdShade Shade = DmdShade.White; + public DmdShade OutlineShade = DmdShade.Black; + public DmdOverflow Overflow; + public int MarqueeSpeed; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/TextLayer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/TextLayer.cs.meta new file mode 100644 index 000000000..ec5328795 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/TextLayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5088b56de42bf8f448c1f2f0933f73c5 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DisplayPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DisplayPlayer.cs index ffe0c4864..b3beec069 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DisplayPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DisplayPlayer.cs @@ -27,6 +27,7 @@ public class DisplayPlayer { private IGamelogicEngine _gamelogicEngine; private readonly Dictionary _displayGameObjects = new Dictionary(); + private readonly Dictionary _displayConfigs = new Dictionary(); private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -57,6 +58,10 @@ private void HandleDisplaysRequested(object sender, RequestedDisplays requestedD if (!displayGameObject.ReceiveGamelogicFrames) { continue; } + if (_displayConfigs.TryGetValue(display.Id, out var previous) && SameConfig(previous, display)) { + Logger.Debug($"Ignoring unchanged configuration for display \"{display.Id}\"."); + continue; + } Logger.Info($"Updating display \"{display.Id}\" to {display.Width}x{display.Height}"); displayGameObject.UpdateDimensions(display.Width, display.Height, display.FlipX); if (display.LitColor.HasValue) { @@ -66,12 +71,20 @@ private void HandleDisplaysRequested(object sender, RequestedDisplays requestedD displayGameObject.UnlitColor = display.UnlitColor.Value; } displayGameObject.Clear(); + _displayConfigs[display.Id] = display; } else { Logger.Warn($"Cannot find game object for display \"{display.Id}\""); } } } + internal static bool SameConfig(DisplayConfig first, DisplayConfig second) + { + return first.Id == second.Id && first.Width == second.Width && first.Height == second.Height && + first.FlipX == second.FlipX && first.LitColor.Equals(second.LitColor) && + first.UnlitColor.Equals(second.UnlitColor); + } + private void HandleDisplayClear(object sender, string id) { if (_displayGameObjects.TryGetValue(id, out var display) && display.ReceiveGamelogicFrames) { @@ -100,6 +113,7 @@ public void OnDestroy() foreach (var id in _displayGameObjects.Keys) { _displayGameObjects[id].OnDisplayChanged -= HandleDisplayChanged; } + _displayConfigs.Clear(); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index ab4a74f3f..ae3782ff2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -331,7 +331,7 @@ public DisplayConfig(string id, uint width, uint height, bool flipX) public enum DisplayFrameFormat { - Dmd2, // 2-bit (0-4) + Dmd2, // 2-bit (0-3) Dmd4, // 4-bit (0-15) Dmd8, // 8-bit (0-255) Dmd24, // rgb (3x 0-255) From d1ffb5d1831f433547ec6cc33fb739984291b6b1 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 23:02:26 +0200 Subject: [PATCH 3/8] dmd: add studio compositor core --- .../DmdStudio/BoundTextTests.cs | 85 +++++ .../DmdStudio/BoundTextTests.cs.meta | 2 + .../DmdStudio/DmdBlitterTests.cs | 132 +++++++ .../DmdStudio/DmdBlitterTests.cs.meta | 2 + .../DmdStudio/DmdCompositorSmokeTests.cs | 52 +++ .../DmdStudio/DmdCompositorSmokeTests.cs.meta | 2 + .../DmdStudio/DmdQuantizerTests.cs | 31 ++ .../DmdStudio/DmdQuantizerTests.cs.meta | 2 + .../DmdStudio/DmdSurfaceAssert.cs | 47 +++ .../DmdStudio/DmdSurfaceAssert.cs.meta | 2 + .../DmdStudio/DmdSurfaceTests.cs | 33 ++ .../DmdStudio/DmdSurfaceTests.cs.meta | 2 + .../DmdStudio/DmdTestFont.cs | 63 ++++ .../DmdStudio/DmdTestFont.cs.meta | 2 + .../DmdStudio/DmdTextRendererTests.cs | 136 +++++++ .../DmdStudio/DmdTextRendererTests.cs.meta | 2 + .../DmdStudio/DmdTransitionTests.cs | 160 ++++++++ .../DmdStudio/DmdTransitionTests.cs.meta | 2 + .../DmdStudio/DmdValidationTests.cs | 8 + .../DmdStudio/Binding/BoundText.cs | 207 ++++++++++ .../DmdStudio/Binding/BoundText.cs.meta | 2 + .../DmdStudio/Compositing/CueDiagnostics.cs | 134 +++++++ .../Compositing/CueDiagnostics.cs.meta | 2 + .../DmdStudio/Compositing/DmdBlitter.cs | 186 +++++++++ .../DmdStudio/Compositing/DmdBlitter.cs.meta | 2 + .../DmdStudio/Compositing/DmdQuantizer.cs | 44 +++ .../Compositing/DmdQuantizer.cs.meta | 2 + .../DmdStudio/Compositing/DmdSurface.cs | 72 ++++ .../DmdStudio/Compositing/DmdSurface.cs.meta | 2 + .../DmdStudio/Compositing/DmdTextRenderer.cs | 357 ++++++++++++++++++ .../Compositing/DmdTextRenderer.cs.meta | 2 + .../DmdStudio/Compositing/DmdTransitions.cs | 282 ++++++++++++++ .../Compositing/DmdTransitions.cs.meta | 2 + .../DmdStudio/Compositing/DmdValidation.cs | 6 + 34 files changed, 2067 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/BoundTextTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/BoundTextTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCompositorSmokeTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCompositorSmokeTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdQuantizerTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdQuantizerTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceAssert.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceAssert.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestFont.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestFont.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTextRendererTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTextRendererTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/BoundText.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/BoundText.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdQuantizer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdQuantizer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdSurface.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdSurface.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/BoundTextTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/BoundTextTests.cs new file mode 100644 index 000000000..6136e896e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/BoundTextTests.cs @@ -0,0 +1,85 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Globalization; +using System.Linq; +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + public class BoundTextTests + { + [Test] + public void ResolvesEscapesDottedNamesAndFormats() + { + var bound = BoundText.Parse("{{{player.score:N0}}} {timer:0.0}"); + var values = new DmdParams().Set("player.score", 12345).Set("timer", 1.25); + + var resolved = bound.Resolve(values, new CueDiagnostics()); + + Assert.That(bound.IsDynamic, Is.True); + Assert.That(resolved, Is.EqualTo("{12,345} 1.3")); + } + + [Test] + public void UsesInvariantCultureRegardlessOfCurrentCulture() + { + var previous = CultureInfo.CurrentCulture; + try { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + var resolved = BoundText.Parse("{score:N1}") + .Resolve(new DmdParams().Set("score", 1234), new CueDiagnostics()); + + Assert.That(resolved, Is.EqualTo("1,234.0")); + } finally { + CultureInfo.CurrentCulture = previous; + } + } + + [Test] + public void MissingAndInvalidFormatsFallBackAndDiagnoseOnce() + { + var diagnostics = new CueDiagnostics(); + var bound = BoundText.Parse("{missing}-{score:Q9}"); + var values = new DmdParams().Set("score", 42); + + Assert.That(bound.Resolve(values, diagnostics), Is.EqualTo("-42")); + Assert.That(bound.Resolve(values, diagnostics), Is.EqualTo("-42")); + Assert.That(diagnostics.Diagnostics.Count(item => item.Code == "binding.missing"), Is.EqualTo(1)); + Assert.That(diagnostics.Diagnostics.Count(item => item.Code == "binding.format"), Is.EqualTo(1)); + } + + [Test] + public void TruncatesResolvedTextAtTheCapOnce() + { + var diagnostics = new CueDiagnostics(); + var value = new string('x', DmdValidation.MaxResolvedTextLength + 20); + var bound = BoundText.Parse("{value}"); + + var result = bound.Resolve(new DmdParams().Set("value", value), diagnostics); + + Assert.That(result, Has.Length.EqualTo(DmdValidation.MaxResolvedTextLength)); + Assert.That(diagnostics.Diagnostics.Count(item => item.Code == "binding.truncated"), Is.EqualTo(1)); + } + + [Test] + public void VersionTracksReferencedValuesOnly() + { + var bound = BoundText.Parse("{score}"); + var values = new DmdParams().Set("score", 1); + var first = bound.Version(values); + values.Set("unused", 10); + var unrelated = bound.Version(values); + values.Set("score", 2); + + Assert.That(unrelated, Is.EqualTo(first)); + Assert.That(bound.Version(values), Is.Not.EqualTo(first)); + Assert.That(BoundText.Parse("literal").Version(values), Is.Zero); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/BoundTextTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/BoundTextTests.cs.meta new file mode 100644 index 000000000..4c5fa7f08 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/BoundTextTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a030edcb0287be34daf4532c3e3b6ae9 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs new file mode 100644 index 000000000..2f57212bd --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs @@ -0,0 +1,132 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using NUnit.Framework; +using UnityEngine; + +namespace VisualPinball.Unity.Test +{ + public class DmdBlitterTests + { + [Test] + public void BlendModesUseTheSpecifiedIntegerEquations() + { + var source = Bitmap(1, 1, 200, 128); + + Assert.That(Blend(100, source, DmdBlendMode.Opaque, 0), Is.EqualTo(200)); + Assert.That(Blend(100, source, DmdBlendMode.Alpha, 255), Is.EqualTo(150)); + Assert.That(Blend(100, source, DmdBlendMode.Add, 255), Is.EqualTo(200)); + Assert.That(Blend(100, Bitmap(1, 1, 0, 255), DmdBlendMode.Invert, 128), Is.EqualTo(155)); + Assert.That(Blend(100, Bitmap(1, 1, 255, 127), DmdBlendMode.Invert, 255), Is.EqualTo(100)); + } + + [Test] + public void ClipsNegativeCoordinatesAndTintsI8IntoRgb() + { + var surface = new DmdSurface(3, 1, DmdPixelFormat.Rgb24); + var source = new DmdBitmapData { + Width = 2, + Height = 1, + Pixels = new byte[] { 255, 128 } + }; + + DmdBlitter.Blit(surface, source, -1, 0, DmdBlendMode.Opaque, 0, + new Color32(255, 128, 0, 255)); + + Assert.That(surface.Data, Is.EqualTo(new byte[] { 128, 64, 0, 0, 0, 0, 0, 0, 0 })); + } + + [Test] + public void AlphaBlendsRgbPerChannelAndHandlesExtremeClipping() + { + var surface = new DmdSurface(1, 1, DmdPixelFormat.Rgb24); + surface.Data[0] = 110; + surface.Data[1] = 120; + surface.Data[2] = 130; + var source = new DmdBitmapData { + Width = 1, + Height = 1, + Format = DmdPixelFormat.Rgb24, + Pixels = new byte[] { 10, 20, 30 }, + Alpha = new byte[] { 128 } + }; + + DmdBlitter.Blit(surface, source, 0, 0, DmdBlendMode.Alpha, byte.MaxValue, + new Color32(1, 2, 3, 4)); + Assert.That(surface.Data, Is.EqualTo(new byte[] { 60, 70, 80 })); + Assert.DoesNotThrow(() => DmdBlitter.Blit(surface, source, int.MinValue, int.MaxValue, + DmdBlendMode.Alpha, byte.MaxValue, new Color32())); + Assert.DoesNotThrow(() => DmdBlitter.FillRect(surface, int.MaxValue, int.MinValue, + int.MaxValue, int.MaxValue, DmdShade.White, byte.MaxValue)); + } + + [Test] + public void FillAndMaskClipAtSurfaceBounds() + { + var surface = new DmdSurface(4, 2, DmdPixelFormat.I8); + var shade = new DmdShade { Intensity = 200 }; + DmdBlitter.FillRect(surface, -1, 0, 4, 2, shade, 128); + var mask = new DmdBitmapData { + Width = 2, + Height = 1, + Pixels = new byte[] { 255, 128 } + }; + + DmdBlitter.ApplyAlphaMask(surface, mask, 1, 1); + + Assert.That(surface.Data, Is.EqualTo(new byte[] { 0, 0, 0, 0, 0, 100, 50, 0 })); + } + + [Test] + public void ScriptedBitmapCompositionHasStableGoldenHash() + { + var surface = new DmdSurface(8, 4, DmdPixelFormat.I8); + surface.Clear(17); + var source = new DmdBitmapData { + Width = 5, + Height = 3, + Pixels = new byte[] { + 0, 64, 128, 192, 255, + 255, 192, 128, 64, 0, + 32, 96, 160, 224, 128, + }, + Alpha = new byte[] { + 255, 192, 128, 64, 0, + 0, 64, 128, 192, 255, + 255, 255, 255, 255, 255, + } + }; + DmdBlitter.Blit(surface, source, 2, 1, DmdBlendMode.Alpha, 192, (Color32)Color.white); + + var hash = DmdSurfaceAssert.Hash(surface.Data); + if (DmdSurfaceAssert.RegenerateGoldenHashes) { + Assert.Fail($"Replace the bitmap golden hash with {hash}u."); + } + Assert.That(hash, Is.EqualTo(2409974179u), + DmdSurfaceAssert.ToAscii(surface)); + } + + private static byte Blend(byte initial, DmdBitmapData source, DmdBlendMode mode, byte opacity) + { + var surface = new DmdSurface(1, 1, DmdPixelFormat.I8); + surface.Data[0] = initial; + DmdBlitter.Blit(surface, source, 0, 0, mode, opacity, (Color32)Color.white); + return surface.Data[0]; + } + + private static DmdBitmapData Bitmap(int width, int height, byte value, byte alpha) + { + return new DmdBitmapData { + Width = width, + Height = height, + Pixels = new[] { value }, + Alpha = new[] { alpha } + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs.meta new file mode 100644 index 000000000..88295f75e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 79d7be20fccbde44294ea52a30814b6f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCompositorSmokeTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCompositorSmokeTests.cs new file mode 100644 index 000000000..f6bdfa243 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCompositorSmokeTests.cs @@ -0,0 +1,52 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace VisualPinball.Unity.Test +{ + public class DmdCompositorSmokeTests + { + [Test] + public void BitmapAndBoundTextCompositionMatchesGoldenFrame() + { + var font = DmdTestFont.Create(); + try { + var surface = new DmdSurface(16, 8, DmdPixelFormat.I8); + var bitmap = new DmdBitmapData { + Width = 8, + Height = 4, + Pixels = new byte[] { + 20, 40, 60, 80, 100, 120, 140, 160, + 40, 60, 80, 100, 120, 140, 160, 180, + 60, 80, 100, 120, 140, 160, 180, 200, + 80, 100, 120, 140, 160, 180, 200, 220, + } + }; + DmdBlitter.Blit(surface, bitmap, 4, 2, DmdBlendMode.Opaque, byte.MaxValue, + (Color32)Color.white); + var text = BoundText.Parse("A{score}").Resolve(new DmdParams().Set("score", 0), + new CueDiagnostics()); + DmdTextRenderer.Draw(surface, font, text, 8, 4, DmdAnchor.MiddleCenter, + DmdTextEffect.Shadow, DmdShade.White, new DmdShade { Intensity = 48 }, + DmdBlendMode.Alpha, new CueDiagnostics()); + + var hash = DmdSurfaceAssert.Hash(surface.Data); + if (DmdSurfaceAssert.RegenerateGoldenHashes) { + Assert.Fail($"Replace the compositor smoke golden hash with {hash}u."); + } + Assert.That(hash, Is.EqualTo(455540081u), + DmdSurfaceAssert.ToAscii(surface)); + } finally { + Object.DestroyImmediate(font); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCompositorSmokeTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCompositorSmokeTests.cs.meta new file mode 100644 index 000000000..88fa85f2b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCompositorSmokeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f71a8214b1b114740b9ced66d7c19620 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdQuantizerTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdQuantizerTests.cs new file mode 100644 index 000000000..2fdfa84d4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdQuantizerTests.cs @@ -0,0 +1,31 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + public class DmdQuantizerTests + { + [Test] + public void ProducesExactTwoAndFourBitShadeRamps() + { + var luminance = new byte[] { 0, 15, 16, 63, 64, 127, 128, 191, 192, 239, 240, 255 }; + var dmd2 = new byte[luminance.Length]; + var dmd4 = new byte[luminance.Length]; + + DmdQuantizer.I8ToDmd2(luminance, dmd2); + DmdQuantizer.I8ToDmd4(luminance, dmd4); + + Assert.That(dmd2, Is.EqualTo(new byte[] { 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3 })); + Assert.That(dmd4, Is.EqualTo(new byte[] { 0, 0, 1, 3, 4, 7, 8, 11, 12, 14, 15, 15 })); + Assert.Throws(() => DmdQuantizer.I8ToDmd4(luminance, new byte[1])); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdQuantizerTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdQuantizerTests.cs.meta new file mode 100644 index 000000000..bd51dcf81 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdQuantizerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38be731afa6fa76439610a0a369f4d4c \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceAssert.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceAssert.cs new file mode 100644 index 000000000..c33caaa66 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceAssert.cs @@ -0,0 +1,47 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Text; + +namespace VisualPinball.Unity.Test +{ + internal static class DmdSurfaceAssert + { + public static readonly bool RegenerateGoldenHashes = false; + private const string Shades = " .:-=+*#%@"; + + public static uint Hash(byte[] data) + { + var hash = 2166136261u; + foreach (var value in data) { + hash ^= value; + hash *= 16777619u; + } + return hash; + } + + public static string ToAscii(DmdSurface surface) + { + var output = new StringBuilder(); + for (var y = 0; y < surface.Height; y++) { + if (y > 0) { + output.Append('\n'); + } + for (var x = 0; x < surface.Width; x++) { + var offset = (y * surface.Width + x) * (surface.Format == DmdPixelFormat.I8 ? 1 : 3); + var value = surface.Format == DmdPixelFormat.I8 + ? surface.Data[offset] + : (byte)((77 * surface.Data[offset] + 150 * surface.Data[offset + 1] + + 29 * surface.Data[offset + 2] + 128) >> 8); + output.Append(Shades[value * (Shades.Length - 1) / 255]); + } + } + return output.ToString(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceAssert.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceAssert.cs.meta new file mode 100644 index 000000000..9532c2280 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceAssert.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0aa1e3f5b010881418a562f076a97135 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceTests.cs new file mode 100644 index 000000000..f2e3c7006 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceTests.cs @@ -0,0 +1,33 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + public class DmdSurfaceTests + { + [Test] + public void AllocatesClearsAndCopiesCheckedFormats() + { + var source = new DmdSurface(4, 2, DmdPixelFormat.Rgb24); + var copy = new DmdSurface(4, 2, DmdPixelFormat.Rgb24); + source.Clear(37); + + copy.CopyFrom(source); + + Assert.That(source.Data, Has.Length.EqualTo(24)); + Assert.That(copy.Data, Is.EqualTo(source.Data)); + Assert.Throws(() => copy.CopyFrom(new DmdSurface(4, 2, DmdPixelFormat.I8))); + Assert.Throws(() => new DmdSurface(0, 1, DmdPixelFormat.I8)); + Assert.Throws(() => + new DmdSurface(DmdValidation.MaxWidth + 1, 1, DmdPixelFormat.I8)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceTests.cs.meta new file mode 100644 index 000000000..f6f51783f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdSurfaceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a52cf980f5d311c438ba7a33927b6964 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestFont.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestFont.cs new file mode 100644 index 000000000..759927978 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestFont.cs @@ -0,0 +1,63 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using UnityEngine; + +namespace VisualPinball.Unity.Test +{ + internal static class DmdTestFont + { + private static readonly string[] Patterns = { + "111101101101111", // .notdef + "010101111101101", // A + "110101110101110", // B + "111101101101111", // 0 + }; + + public static DmdFontAsset Create() + { + var font = ScriptableObject.CreateInstance(); + font.LineHeight = 5; + font.Baseline = 4; + font.Tracking = 1; + font.DigitWidth = 5; + font.Atlas = new DmdBitmapData { + Width = 12, + Height = 5, + Format = DmdPixelFormat.I8, + Pixels = new byte[60] + }; + for (var glyphIndex = 0; glyphIndex < Patterns.Length; glyphIndex++) { + for (var y = 0; y < 5; y++) { + for (var x = 0; x < 3; x++) { + font.Atlas.Pixels[y * font.Atlas.Width + glyphIndex * 3 + x] = + Patterns[glyphIndex][y * 3 + x] == '1' ? byte.MaxValue : (byte)0; + } + } + } + AddGlyph(font, 0, 0); + AddGlyph(font, 'A', 3); + AddGlyph(font, 'B', 6); + AddGlyph(font, '0', 9); + font.Kerning.Add(new DmdKerningPair { LeftCodepoint = 'A', RightCodepoint = 'B', Adjustment = -1 }); + return font; + } + + private static void AddGlyph(DmdFontAsset font, int codepoint, int x) + { + font.Glyphs.Add(new DmdGlyph { + Codepoint = codepoint, + X = x, + Y = 0, + W = 3, + H = 5, + Advance = 4 + }); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestFont.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestFont.cs.meta new file mode 100644 index 000000000..e7c180457 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTestFont.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0d7841a1defc5e542af6b3324813fafc \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTextRendererTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTextRendererTests.cs new file mode 100644 index 000000000..4248168a7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTextRendererTests.cs @@ -0,0 +1,136 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Linq; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace VisualPinball.Unity.Test +{ + public class DmdTextRendererTests + { + [Test] + public void MeasuresKerningTrackingAndTabularDigits() + { + var font = DmdTestFont.Create(); + try { + Assert.That(DmdTextRenderer.Measure(font, "AB"), Is.EqualTo(8)); + Assert.That(DmdTextRenderer.Measure(font, "00"), Is.EqualTo(11)); + } finally { + Object.DestroyImmediate(font); + } + } + + [Test] + public void DrawsTopOriginGlyphAtCenterAnchor() + { + var font = DmdTestFont.Create(); + try { + var surface = new DmdSurface(8, 7, DmdPixelFormat.I8); + DmdTextRenderer.Draw(surface, font, "A", 4, 1, DmdAnchor.TopCenter, DmdTextEffect.None, + DmdShade.White, DmdShade.Black, DmdBlendMode.Alpha, new CueDiagnostics()); + + Assert.That(DmdSurfaceAssert.ToAscii(surface), Is.EqualTo( + " \n" + + " @ \n" + + " @ @ \n" + + " @@@ \n" + + " @ @ \n" + + " @ @ \n" + + " ")); + } finally { + Object.DestroyImmediate(font); + } + } + + [TestCase(DmdTextEffect.None, 194383655u)] + [TestCase(DmdTextEffect.Outline, 1190555245u)] + [TestCase(DmdTextEffect.Shadow, 3866497374u)] + [TestCase(DmdTextEffect.Inverse, 1978379769u)] + public void TextEffectsHaveStableGoldenHashes(DmdTextEffect effect, uint expected) + { + var font = DmdTestFont.Create(); + try { + var surface = new DmdSurface(16, 8, DmdPixelFormat.I8); + surface.Clear(21); + DmdTextRenderer.Draw(surface, font, "AB", 8, 4, DmdAnchor.MiddleCenter, effect, + new DmdShade { Intensity = 230 }, new DmdShade { Intensity = 64 }, + DmdBlendMode.Alpha, new CueDiagnostics()); + + var hash = DmdSurfaceAssert.Hash(surface.Data); + if (DmdSurfaceAssert.RegenerateGoldenHashes) { + Assert.Fail($"Replace the {effect} text golden hash with {hash}u."); + } + Assert.That(hash, Is.EqualTo(expected), + $"{effect}\n{DmdSurfaceAssert.ToAscii(surface)}"); + } finally { + Object.DestroyImmediate(font); + } + } + + [Test] + public void MissingGlyphUsesNotdefAndDiagnosesOnce() + { + var font = DmdTestFont.Create(); + try { + var expected = new DmdSurface(4, 5, DmdPixelFormat.I8); + var actual = new DmdSurface(4, 5, DmdPixelFormat.I8); + var diagnostics = new CueDiagnostics(); + DmdTextRenderer.Draw(expected, font, "\0", 0, 0, DmdAnchor.TopLeft, DmdTextEffect.None, + DmdShade.White, DmdShade.Black, DmdBlendMode.Alpha, new CueDiagnostics()); + DmdTextRenderer.Draw(actual, font, "X", 0, 0, DmdAnchor.TopLeft, DmdTextEffect.None, + DmdShade.White, DmdShade.Black, DmdBlendMode.Alpha, diagnostics); + DmdTextRenderer.Draw(actual, font, "X", 0, 0, DmdAnchor.TopLeft, DmdTextEffect.None, + DmdShade.White, DmdShade.Black, DmdBlendMode.Alpha, diagnostics); + + Assert.That(actual.Data, Is.EqualTo(expected.Data)); + Assert.That(diagnostics.Diagnostics.Count(item => item.Code == "font.glyph.missing"), Is.EqualTo(1)); + } finally { + Object.DestroyImmediate(font); + } + } + + [Test] + public void RgbTextUsesShadeColorAndAlpha() + { + var font = DmdTestFont.Create(); + try { + var surface = new DmdSurface(3, 5, DmdPixelFormat.Rgb24); + var shade = new DmdShade { Color = new Color32(100, 50, 25, 128) }; + DmdTextRenderer.Draw(surface, font, "A", 0, 0, DmdAnchor.TopLeft, DmdTextEffect.None, + shade, DmdShade.Black, DmdBlendMode.Alpha, new CueDiagnostics()); + + Assert.That(surface.Data[3], Is.EqualTo(50)); + Assert.That(surface.Data[4], Is.EqualTo(25)); + Assert.That(surface.Data[5], Is.EqualTo(13)); + Assert.That(surface.Data[0], Is.Zero); + } finally { + Object.DestroyImmediate(font); + } + } + + [Test] + public void MalformedAtlasNeverThrowsAndDiagnosesOnce() + { + var font = DmdTestFont.Create(); + try { + font.Atlas.Pixels = new byte[1]; + var diagnostics = new CueDiagnostics(); + var surface = new DmdSurface(8, 5, DmdPixelFormat.I8); + + Assert.DoesNotThrow(() => DmdTextRenderer.Draw(surface, font, "A", 0, 0, + DmdAnchor.TopLeft, DmdTextEffect.None, DmdShade.White, DmdShade.Black, + DmdBlendMode.Alpha, diagnostics)); + Assert.That(diagnostics.Diagnostics.Count(item => item.Code == "font.malformed"), Is.EqualTo(1)); + } finally { + Object.DestroyImmediate(font); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTextRendererTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTextRendererTests.cs.meta new file mode 100644 index 000000000..a5a7b4ff5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTextRendererTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b64ad5ab1bafd3a4aae2e07ec4ca6b5f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs new file mode 100644 index 000000000..36454a02d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs @@ -0,0 +1,160 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + public class DmdTransitionTests + { + [TestCase(DmdTransitionType.Cut)] + [TestCase(DmdTransitionType.Push)] + [TestCase(DmdTransitionType.Cover)] + [TestCase(DmdTransitionType.Uncover)] + [TestCase(DmdTransitionType.WipeOn)] + [TestCase(DmdTransitionType.SplitIn)] + [TestCase(DmdTransitionType.SplitOut)] + [TestCase(DmdTransitionType.Dissolve)] + [TestCase(DmdTransitionType.FadeThroughBlack)] + [TestCase(DmdTransitionType.ScrollOff)] + public void EveryTransitionHasExactEndpoints(DmdTransitionType type) + { + var from = Pattern(8, 4, 17, 3); + var to = Pattern(8, 4, 29, 11); + var destination = new DmdSurface(8, 4, DmdPixelFormat.I8); + + DmdTransitions.Compose(destination, from, to, type, DmdDirection.Left, 0f); + if (type == DmdTransitionType.Cut) { + Assert.That(destination.Data, Is.EqualTo(to.Data)); + } else { + Assert.That(destination.Data, Is.EqualTo(from.Data)); + } + DmdTransitions.Compose(destination, from, to, type, DmdDirection.Left, 1f); + Assert.That(destination.Data, Is.EqualTo(to.Data)); + } + + [TestCase(DmdTransitionType.Push, new byte[] { 3, 4, 5, 6 })] + [TestCase(DmdTransitionType.Cover, new byte[] { 1, 2, 5, 6 })] + [TestCase(DmdTransitionType.Uncover, new byte[] { 3, 4, 7, 8 })] + [TestCase(DmdTransitionType.WipeOn, new byte[] { 1, 2, 7, 8 })] + [TestCase(DmdTransitionType.ScrollOff, new byte[] { 3, 4, 7, 8 })] + public void HorizontalMotionUsesRoundedIntegerOffsets(DmdTransitionType type, byte[] expected) + { + var from = Surface(4, 1, 1, 2, 3, 4); + var to = Surface(4, 1, 5, 6, 7, 8); + var destination = new DmdSurface(4, 1, DmdPixelFormat.I8); + + DmdTransitions.Compose(destination, from, to, type, DmdDirection.Left, 0.5f); + + Assert.That(destination.Data, Is.EqualTo(expected)); + } + + [Test] + public void DissolveUsesTheBayerFourByFourThreshold() + { + var from = new DmdSurface(4, 4, DmdPixelFormat.I8); + var to = new DmdSurface(4, 4, DmdPixelFormat.I8); + var destination = new DmdSurface(4, 4, DmdPixelFormat.I8); + to.Clear(255); + + DmdTransitions.Compose(destination, from, to, DmdTransitionType.Dissolve, + DmdDirection.Left, 0.5f); + + Assert.That(destination.Data, Is.EqualTo(new byte[] { + 255, 0, 255, 0, + 0, 255, 0, 255, + 255, 0, 255, 0, + 0, 255, 0, 255, + })); + } + + [Test] + public void FadeThroughBlackIsBlackAtItsMidpoint() + { + var from = Surface(2, 1, 100, 200); + var to = Surface(2, 1, 50, 250); + var destination = new DmdSurface(2, 1, DmdPixelFormat.I8); + + DmdTransitions.Compose(destination, from, to, DmdTransitionType.FadeThroughBlack, + DmdDirection.Left, 0.5f); + + Assert.That(destination.Data, Is.EqualTo(new byte[] { 0, 0 })); + } + + [TestCase(DmdTransitionType.Cut, 646444229u)] + [TestCase(DmdTransitionType.Push, 686640837u)] + [TestCase(DmdTransitionType.Cover, 1602420165u)] + [TestCase(DmdTransitionType.Uncover, 872900293u)] + [TestCase(DmdTransitionType.WipeOn, 1788679621u)] + [TestCase(DmdTransitionType.SplitIn, 2720066757u)] + [TestCase(DmdTransitionType.SplitOut, 1911098821u)] + [TestCase(DmdTransitionType.Dissolve, 661240773u)] + [TestCase(DmdTransitionType.FadeThroughBlack, 1924397413u)] + [TestCase(DmdTransitionType.ScrollOff, 872900293u)] + public void FullSizeTransitionHasStableGoldenHash(DmdTransitionType type, uint expected) + { + var from = Pattern(128, 32, 17, 3); + var to = Pattern(128, 32, 29, 11); + var destination = new DmdSurface(128, 32, DmdPixelFormat.I8); + + DmdTransitions.Compose(destination, from, to, type, DmdDirection.Up, 0.37f); + + var hash = DmdSurfaceAssert.Hash(destination.Data); + if (DmdSurfaceAssert.RegenerateGoldenHashes) { + Assert.Fail($"Replace the {type} transition golden hash with {hash}u."); + } + Assert.That(hash, Is.EqualTo(expected), type.ToString()); + } + + [Test] + public void SameTimelineProducesIdenticalHashes() + { + var first = RenderTimeline(); + var second = RenderTimeline(); + + Assert.That(second, Is.EqualTo(first)); + } + + private static uint[] RenderTimeline() + { + var from = Pattern(16, 8, 11, 5); + var to = Pattern(16, 8, 23, 7); + var destination = new DmdSurface(16, 8, DmdPixelFormat.I8); + var hashes = new uint[21]; + for (var frame = 0; frame < hashes.Length; frame++) { + DmdTransitions.Compose(destination, from, to, DmdTransitionType.Dissolve, + DmdDirection.Down, frame / 20f); + hashes[frame] = DmdSurfaceAssert.Hash(destination.Data); + } + return hashes; + } + + private static DmdSurface Pattern(int width, int height, int multiplier, int addend) + { + var surface = new DmdSurface(width, height, DmdPixelFormat.I8); + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + surface.Data[y * width + x] = (byte)(x * multiplier + y * (multiplier + 14) + + x * y * 7 + addend); + } + } + return surface; + } + + private static DmdSurface Surface(int width, int height, params byte[] values) + { + var surface = new DmdSurface(width, height, DmdPixelFormat.I8); + if (values.Length != surface.Data.Length) { + throw new ArgumentException(); + } + values.CopyTo(surface.Data, 0); + return surface; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs.meta new file mode 100644 index 000000000..9a9597a53 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b836c158ac2c7c4499031b05ad16773 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs index a98a10c31..b0aca4a56 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdValidationTests.cs @@ -223,6 +223,14 @@ public void TransitionCapAndLoopExemptionUseInclusiveLimits() cue.ExitTransition = new DmdTransitionSpec { DurationFrames = DmdValidation.MaxTransitionFrames + 1 }; Assert.That(cue.Validate().Diagnostics.Any(diagnostic => diagnostic.Code == "cue.transition.exit"), Is.True); + + cue.ExitTransition = new DmdTransitionSpec { + Type = (DmdTransitionType)999, + Direction = (DmdDirection)999, + }; + var codes = cue.Validate().Diagnostics.Select(diagnostic => diagnostic.Code).ToArray(); + Assert.That(codes, Does.Contain("cue.transition.exit.type")); + Assert.That(codes, Does.Contain("cue.transition.exit.direction")); } finally { Object.DestroyImmediate(cue); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/BoundText.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/BoundText.cs new file mode 100644 index 000000000..a895ef16f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/BoundText.cs @@ -0,0 +1,207 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace VisualPinball.Unity +{ + internal readonly struct BoundText + { + private readonly Segment[] _segments; + + private BoundText(Segment[] segments, bool isDynamic) + { + _segments = segments; + IsDynamic = isDynamic; + } + + public bool IsDynamic { get; } + + public static BoundText Parse(string template) + { + template = template ?? string.Empty; + var segments = new List(); + var literal = new StringBuilder(); + var isDynamic = false; + for (var index = 0; index < template.Length;) { + var character = template[index]; + if (character == '{' && index + 1 < template.Length && template[index + 1] == '{') { + literal.Append('{'); + index += 2; + continue; + } + if (character == '}' && index + 1 < template.Length && template[index + 1] == '}') { + literal.Append('}'); + index += 2; + continue; + } + if (character != '{') { + literal.Append(character); + index++; + continue; + } + + var end = template.IndexOf('}', index + 1); + if (end < 0) { + literal.Append(character); + index++; + continue; + } + var token = template.Substring(index + 1, end - index - 1); + var separator = token.IndexOf(':'); + var name = separator < 0 ? token : token.Substring(0, separator); + if (!DmdValidation.IsValidParameterName(name)) { + literal.Append(template, index, end - index + 1); + index = end + 1; + continue; + } + + FlushLiteral(segments, literal); + var format = separator < 0 ? null : token.Substring(separator + 1); + segments.Add(Segment.Parameter(name, format)); + isDynamic = true; + index = end + 1; + } + FlushLiteral(segments, literal); + return new BoundText(segments.ToArray(), isDynamic); + } + + public string Resolve(DmdParams parameters, CueDiagnostics diagnostics) + { + if (_segments == null || _segments.Length == 0) { + return string.Empty; + } + if (!IsDynamic && _segments.Length == 1) { + return Truncate(_segments[0].Literal, diagnostics); + } + + var output = new StringBuilder(); + var truncated = false; + foreach (var segment in _segments) { + if (!segment.IsParameter) { + truncated = !AppendCapped(output, segment.Literal); + } else if (parameters == null || !parameters.TryGet(segment.Name, out var value)) { + diagnostics?.MissingParameter(segment.Name); + } else { + truncated = !AppendCapped(output, Format(value, segment, diagnostics)); + } + if (truncated) { + break; + } + } + if (truncated) { + diagnostics?.TextTruncated(); + } + return output.ToString(); + } + + public int Version(DmdParams parameters) + { + if (!IsDynamic) { + return 0; + } + unchecked { + var version = 17; + foreach (var segment in _segments) { + if (!segment.IsParameter) { + continue; + } + version = version * 31 + segment.Name.GetHashCode(); + version = version * 31 + (parameters != null && parameters.TryGet(segment.Name, out var value) + ? value.GetHashCode() + : 0); + } + return version; + } + } + + private static string Format(DmdParamValue value, Segment segment, CueDiagnostics diagnostics) + { + if (segment.Format == null) { + return value.ToInvariantString(); + } + try { + object raw; + switch (value.Type) { + case DmdParamType.Integer: + raw = value.IntValue; + break; + case DmdParamType.Float: + raw = value.FloatValue; + break; + case DmdParamType.String: + raw = value.StringValue ?? string.Empty; + break; + case DmdParamType.Boolean: + raw = value.BoolValue; + break; + default: + throw new ArgumentOutOfRangeException(); + } + return string.Format(CultureInfo.InvariantCulture, segment.CompositeFormat, raw); + } catch (FormatException) { + diagnostics?.InvalidFormat(segment.Name, segment.Format); + return value.ToInvariantString(); + } + } + + private static string Truncate(string value, CueDiagnostics diagnostics) + { + if (value.Length <= DmdValidation.MaxResolvedTextLength) { + return value; + } + diagnostics?.TextTruncated(); + return value.Substring(0, DmdValidation.MaxResolvedTextLength); + } + + private static bool AppendCapped(StringBuilder output, string value) + { + var remaining = DmdValidation.MaxResolvedTextLength - output.Length; + if (value.Length <= remaining) { + output.Append(value); + return true; + } + if (remaining > 0) { + output.Append(value, 0, remaining); + } + return false; + } + + private static void FlushLiteral(List segments, StringBuilder literal) + { + if (literal.Length == 0) { + return; + } + segments.Add(Segment.Text(literal.ToString())); + literal.Clear(); + } + + private readonly struct Segment + { + public readonly string Literal; + public readonly string Name; + public readonly string Format; + public readonly string CompositeFormat; + public bool IsParameter => Name != null; + + private Segment(string literal, string name, string format) + { + Literal = literal; + Name = name; + Format = format; + CompositeFormat = format == null ? null : $"{{0:{format}}}"; + } + + public static Segment Text(string value) => new Segment(value, null, null); + public static Segment Parameter(string name, string format) => new Segment(null, name, format); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/BoundText.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/BoundText.cs.meta new file mode 100644 index 000000000..721ce9891 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Binding/BoundText.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f0f46bd16eca574f8b29a7b7c9ea032 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs new file mode 100644 index 000000000..74ff7007d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs @@ -0,0 +1,134 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Unity +{ + public readonly struct CueDiagnostic + { + public string Code { get; } + public string Message { get; } + + public CueDiagnostic(string code, string message) + { + Code = code; + Message = message; + } + } + + /// + /// Per-cue-instance diagnostics. Identical logical faults are reported once. + /// + public sealed class CueDiagnostics + { + private readonly List _diagnostics = new List(); + private readonly HashSet _keys = new HashSet(); + + public IReadOnlyList Diagnostics => _diagnostics; + public int Count => _diagnostics.Count; + + public void Clear() + { + _diagnostics.Clear(); + _keys.Clear(); + } + + internal void MissingParameter(string name) + { + if (_keys.Add(new DiagnosticKey(DiagnosticKind.MissingParameter, name))) { + _diagnostics.Add(new CueDiagnostic("binding.missing", $"Parameter '{name}' is not bound.")); + } + } + + internal void InvalidFormat(string name, string format) + { + if (_keys.Add(new DiagnosticKey(DiagnosticKind.InvalidFormat, name, format))) { + _diagnostics.Add(new CueDiagnostic("binding.format", + $"Format '{format}' is invalid for parameter '{name}'.")); + } + } + + internal void TextTruncated() + { + if (_keys.Add(new DiagnosticKey(DiagnosticKind.TextTruncated))) { + _diagnostics.Add(new CueDiagnostic("binding.truncated", + $"Resolved text exceeded {DmdValidation.MaxResolvedTextLength} characters and was truncated.")); + } + } + + internal void MissingGlyph(int codepoint) + { + if (_keys.Add(new DiagnosticKey(DiagnosticKind.MissingGlyph, number: codepoint))) { + _diagnostics.Add(new CueDiagnostic("font.glyph.missing", $"Font has no glyph for U+{codepoint:X4}.")); + } + } + + internal void MalformedFont(string detail) + { + detail = detail ?? "Font data is malformed."; + if (_keys.Add(new DiagnosticKey(DiagnosticKind.MalformedFont, detail))) { + _diagnostics.Add(new CueDiagnostic("font.malformed", detail)); + } + } + + internal void MalformedGlyph(int codepoint) + { + if (_keys.Add(new DiagnosticKey(DiagnosticKind.MalformedGlyph, number: codepoint))) { + _diagnostics.Add(new CueDiagnostic("font.malformed", + $"Glyph U+{codepoint:X4} lies outside the font atlas.")); + } + } + + private enum DiagnosticKind + { + MissingParameter, + InvalidFormat, + TextTruncated, + MissingGlyph, + MalformedFont, + MalformedGlyph, + } + + private readonly struct DiagnosticKey : IEquatable + { + private readonly DiagnosticKind _kind; + private readonly string _first; + private readonly string _second; + private readonly int _number; + + public DiagnosticKey(DiagnosticKind kind, string first = null, string second = null, int number = 0) + { + _kind = kind; + _first = first; + _second = second; + _number = number; + } + + public bool Equals(DiagnosticKey other) + { + return _kind == other._kind && _number == other._number && + string.Equals(_first, other._first, StringComparison.Ordinal) && + string.Equals(_second, other._second, StringComparison.Ordinal); + } + + public override bool Equals(object obj) => obj is DiagnosticKey other && Equals(other); + + public override int GetHashCode() + { + unchecked { + var hash = ((int)_kind * 397) ^ _number; + hash = (hash * 397) ^ (_first != null ? StringComparer.Ordinal.GetHashCode(_first) : 0); + hash = (hash * 397) ^ (_second != null ? StringComparer.Ordinal.GetHashCode(_second) : 0); + return hash; + } + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs.meta new file mode 100644 index 000000000..bb9d1f354 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b8d0b32a52151c14d80c215d71afb151 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs new file mode 100644 index 000000000..1aed029c8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs @@ -0,0 +1,186 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public static class DmdBlitter + { + public static void Blit(DmdSurface dst, DmdBitmapData src, int x, int y, + DmdBlendMode mode, byte opacity, Color32 tint) + { + if (dst == null) { + throw new ArgumentNullException(nameof(dst)); + } + ValidateSource(src); + if (dst.Format == DmdPixelFormat.I8 && src.Format != DmdPixelFormat.I8) { + throw new ArgumentException("RGB24 sources cannot be blitted to an I8 surface.", nameof(src)); + } + + var sourceStartX = (int)System.Math.Min(src.Width, System.Math.Max(0L, -(long)x)); + var sourceStartY = (int)System.Math.Min(src.Height, System.Math.Max(0L, -(long)y)); + var sourceEndX = (int)System.Math.Max(0L, System.Math.Min(src.Width, (long)dst.Width - x)); + var sourceEndY = (int)System.Math.Max(0L, System.Math.Min(src.Height, (long)dst.Height - y)); + if (sourceStartX >= sourceEndX || sourceStartY >= sourceEndY) { + return; + } + + var hasAlpha = src.Alpha != null && src.Alpha.Length != 0; + for (var sourceY = sourceStartY; sourceY < sourceEndY; sourceY++) { + for (var sourceX = sourceStartX; sourceX < sourceEndX; sourceX++) { + var sourcePixel = sourceY * src.Width + sourceX; + var destinationPixel = (sourceY + y) * dst.Width + sourceX + x; + var alpha = hasAlpha ? src.Alpha[sourcePixel] : byte.MaxValue; + + if (dst.Format == DmdPixelFormat.I8) { + dst.Data[destinationPixel] = Blend(dst.Data[destinationPixel], src.Pixels[sourcePixel], + alpha, opacity, mode); + } else { + var destinationOffset = destinationPixel * 3; + if (src.Format == DmdPixelFormat.I8) { + var intensity = src.Pixels[sourcePixel]; + BlendRgb(dst.Data, destinationOffset, + Multiply(intensity, tint.r), Multiply(intensity, tint.g), Multiply(intensity, tint.b), + alpha, opacity, mode); + } else { + var sourceOffset = sourcePixel * 3; + BlendRgb(dst.Data, destinationOffset, src.Pixels[sourceOffset], src.Pixels[sourceOffset + 1], + src.Pixels[sourceOffset + 2], alpha, opacity, mode); + } + } + } + } + } + + public static void FillRect(DmdSurface dst, int x, int y, int width, int height, + in DmdShade shade, byte opacity) + { + FillRect(dst, x, y, width, height, shade, DmdBlendMode.Alpha, opacity); + } + + internal static void FillRect(DmdSurface dst, int x, int y, int width, int height, + in DmdShade shade, DmdBlendMode mode, byte opacity) + { + if (dst == null) { + throw new ArgumentNullException(nameof(dst)); + } + if (width <= 0 || height <= 0) { + return; + } + + var startX = math.max(0, x); + var startY = math.max(0, y); + var endX = (int)System.Math.Max(0L, System.Math.Min(dst.Width, (long)x + width)); + var endY = (int)System.Math.Max(0L, System.Math.Min(dst.Height, (long)y + height)); + var sourceAlpha = dst.Format == DmdPixelFormat.Rgb24 ? shade.Color.a : byte.MaxValue; + for (var destinationY = startY; destinationY < endY; destinationY++) { + for (var destinationX = startX; destinationX < endX; destinationX++) { + var destinationPixel = destinationY * dst.Width + destinationX; + if (dst.Format == DmdPixelFormat.I8) { + dst.Data[destinationPixel] = Blend(dst.Data[destinationPixel], shade.Intensity, + sourceAlpha, opacity, mode); + } else { + BlendRgb(dst.Data, destinationPixel * 3, shade.Color.r, shade.Color.g, shade.Color.b, + sourceAlpha, opacity, mode); + } + } + } + } + + public static void ApplyAlphaMask(DmdSurface dst, DmdBitmapData mask, + int maskFrameOffsetX, int maskFrameOffsetY) + { + if (dst == null) { + throw new ArgumentNullException(nameof(dst)); + } + ValidateSource(mask); + var hasAlpha = mask.Alpha != null && mask.Alpha.Length != 0; + for (var y = 0; y < dst.Height; y++) { + for (var x = 0; x < dst.Width; x++) { + var maskXValue = (long)x - maskFrameOffsetX; + var maskYValue = (long)y - maskFrameOffsetY; + byte alpha = 0; + if (maskXValue >= 0 && maskXValue < mask.Width && maskYValue >= 0 && maskYValue < mask.Height) { + var maskX = (int)maskXValue; + var maskY = (int)maskYValue; + var maskPixel = maskY * mask.Width + maskX; + if (hasAlpha) { + alpha = mask.Alpha[maskPixel]; + } else if (mask.Format == DmdPixelFormat.I8) { + alpha = mask.Pixels[maskPixel]; + } else { + var offset = maskPixel * 3; + alpha = (byte)((77 * mask.Pixels[offset] + 150 * mask.Pixels[offset + 1] + + 29 * mask.Pixels[offset + 2] + 128) >> 8); + } + } + + var destinationOffset = (y * dst.Width + x) * (dst.Format == DmdPixelFormat.I8 ? 1 : 3); + var channelCount = dst.Format == DmdPixelFormat.I8 ? 1 : 3; + for (var channel = 0; channel < channelCount; channel++) { + dst.Data[destinationOffset + channel] = Multiply(dst.Data[destinationOffset + channel], alpha); + } + } + } + } + + internal static byte Blend(byte destination, byte source, byte sourceAlpha, byte opacity, + DmdBlendMode mode) + { + if (mode == DmdBlendMode.Opaque) { + return source; + } + var alpha = Multiply(sourceAlpha, opacity); + switch (mode) { + case DmdBlendMode.Alpha: + return (byte)((source * alpha + destination * (255 - alpha) + 127) / 255); + case DmdBlendMode.Add: + return (byte)math.min(255, destination + (source * alpha + 127) / 255); + case DmdBlendMode.Invert: + return alpha > 127 ? (byte)(255 - destination) : destination; + default: + throw new ArgumentOutOfRangeException(nameof(mode)); + } + } + + internal static byte Multiply(byte value, byte multiplier) + { + return (byte)((value * multiplier + 127) / 255); + } + + private static void BlendRgb(byte[] data, int offset, byte red, byte green, byte blue, + byte sourceAlpha, byte opacity, DmdBlendMode mode) + { + data[offset] = Blend(data[offset], red, sourceAlpha, opacity, mode); + data[offset + 1] = Blend(data[offset + 1], green, sourceAlpha, opacity, mode); + data[offset + 2] = Blend(data[offset + 2], blue, sourceAlpha, opacity, mode); + } + + private static void ValidateSource(DmdBitmapData source) + { + if (source == null) { + throw new ArgumentNullException(nameof(source)); + } + if (source.Width < 1 || source.Height < 1) { + throw new ArgumentException("Source dimensions must be positive.", nameof(source)); + } + var channelCount = source.Format == DmdPixelFormat.I8 ? 1 : source.Format == DmdPixelFormat.Rgb24 ? 3 : 0; + var expectedPixels = (long)source.Width * source.Height * channelCount; + if (channelCount == 0 || source.Pixels == null || source.Pixels.LongLength != expectedPixels) { + throw new ArgumentException("Source pixel data does not match its dimensions and format.", nameof(source)); + } + var expectedAlpha = (long)source.Width * source.Height; + if (source.Alpha != null && source.Alpha.Length != 0 && source.Alpha.LongLength != expectedAlpha) { + throw new ArgumentException("Source alpha data does not match its dimensions.", nameof(source)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs.meta new file mode 100644 index 000000000..dbbce2973 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b9d31a51737f4ba41a13e17c2baf2872 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdQuantizer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdQuantizer.cs new file mode 100644 index 000000000..aa9b7d722 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdQuantizer.cs @@ -0,0 +1,44 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; + +namespace VisualPinball.Unity +{ + public static class DmdQuantizer + { + public static void I8ToDmd2(byte[] luminance, byte[] destination) + { + ValidateBuffers(luminance, destination); + for (var index = 0; index < luminance.Length; index++) { + destination[index] = (byte)(luminance[index] >> 6); + } + } + + public static void I8ToDmd4(byte[] luminance, byte[] destination) + { + ValidateBuffers(luminance, destination); + for (var index = 0; index < luminance.Length; index++) { + destination[index] = (byte)(luminance[index] >> 4); + } + } + + private static void ValidateBuffers(byte[] source, byte[] destination) + { + if (source == null) { + throw new ArgumentNullException(nameof(source)); + } + if (destination == null) { + throw new ArgumentNullException(nameof(destination)); + } + if (source.Length != destination.Length) { + throw new ArgumentException("Quantizer buffers must have matching lengths.", nameof(destination)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdQuantizer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdQuantizer.cs.meta new file mode 100644 index 000000000..baa0ff31b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdQuantizer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 95be3d4b805f64f4bb3e80b0aacfc730 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdSurface.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdSurface.cs new file mode 100644 index 000000000..f8b8e79ec --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdSurface.cs @@ -0,0 +1,72 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; + +namespace VisualPinball.Unity +{ + /// + /// A top-origin compositor surface. + /// + public sealed class DmdSurface + { + public readonly int Width; + public readonly int Height; + public readonly DmdPixelFormat Format; + public readonly byte[] Data; + + public DmdSurface(int width, int height, DmdPixelFormat format) + { + if (width < 1 || width > DmdValidation.MaxWidth) { + throw new ArgumentOutOfRangeException(nameof(width)); + } + if (height < 1 || height > DmdValidation.MaxHeight) { + throw new ArgumentOutOfRangeException(nameof(height)); + } + + int bytesPerPixel; + switch (format) { + case DmdPixelFormat.I8: + bytesPerPixel = 1; + break; + case DmdPixelFormat.Rgb24: + bytesPerPixel = 3; + break; + default: + throw new ArgumentOutOfRangeException(nameof(format)); + } + + Width = width; + Height = height; + Format = format; + Data = new byte[checked(width * height * bytesPerPixel)]; + } + + public void Clear(byte value = 0) + { + if (value == 0) { + Array.Clear(Data, 0, Data.Length); + return; + } + for (var index = 0; index < Data.Length; index++) { + Data[index] = value; + } + } + + public void CopyFrom(DmdSurface other) + { + if (other == null) { + throw new ArgumentNullException(nameof(other)); + } + if (Width != other.Width || Height != other.Height || Format != other.Format) { + throw new ArgumentException("DMD surfaces must have matching dimensions and formats.", nameof(other)); + } + Buffer.BlockCopy(other.Data, 0, Data, 0, Data.Length); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdSurface.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdSurface.cs.meta new file mode 100644 index 000000000..1e21063c0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdSurface.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ba494b6a3074ccc4195cb46fc990825c \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs new file mode 100644 index 000000000..4f9df9a25 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs @@ -0,0 +1,357 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public static class DmdTextRenderer + { + private const int ReplacementCodepoint = 0xfffd; + + public static int Measure(DmdFontAsset font, string text) + { + if (font == null) { + throw new ArgumentNullException(nameof(font)); + } + if (string.IsNullOrEmpty(text)) { + return 0; + } + + var width = 0; + var previous = -1; + var index = 0; + var glyphCount = 0; + while (TryReadCodepoint(text, ref index, out var codepoint)) { + if (glyphCount > 0) { + width += font.Tracking + FindKerning(font, previous, codepoint); + } + if (TryResolveGlyph(font, codepoint, out var glyph)) { + width += Advance(font, codepoint, glyph); + } else { + width += MissingGlyphWidth(font); + } + previous = codepoint; + glyphCount++; + } + return math.max(0, width); + } + + public static void Draw(DmdSurface dst, DmdFontAsset font, string text, + int x, int y, DmdAnchor anchor, DmdTextEffect effect, + in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode, CueDiagnostics diagnostics) + { + if (dst == null) { + throw new ArgumentNullException(nameof(dst)); + } + if (font == null) { + diagnostics?.MalformedFont("Font asset is missing."); + return; + } + if (string.IsNullOrEmpty(text)) { + return; + } + if (!HasReadableAtlas(font, diagnostics)) { + return; + } + + var lineHeight = math.max(1, font.LineHeight); + var originX = ResolveHorizontalOrigin(x, Measure(font, text), anchor); + var originY = ResolveVerticalOrigin(y, lineHeight, math.clamp(font.Baseline, 0, lineHeight), anchor); + var pen = 0; + var previous = -1; + var glyphCount = 0; + var index = 0; + while (TryReadCodepoint(text, ref index, out var codepoint)) { + if (glyphCount > 0) { + pen += font.Tracking + FindKerning(font, previous, codepoint); + } + + if (!TryFindGlyph(font, codepoint, out var glyph)) { + diagnostics?.MissingGlyph(codepoint); + if (!TryResolveGlyph(font, codepoint, out glyph)) { + DrawMissingBox(dst, originX + pen, originY, MissingGlyphWidth(font), lineHeight, + shade, outlineShade, effect, mode); + pen += MissingGlyphWidth(font); + previous = codepoint; + glyphCount++; + continue; + } + } + + if (!GlyphIsReadable(font.Atlas, glyph)) { + diagnostics?.MalformedGlyph(glyph.Codepoint); + pen += Advance(font, codepoint, glyph); + previous = codepoint; + glyphCount++; + continue; + } + + var digitOffset = IsDigit(codepoint) && font.DigitWidth > 0 + ? (font.DigitWidth - glyph.Advance) / 2 + : 0; + var glyphX = originX + pen + digitOffset + glyph.OffsetX; + var glyphY = originY + glyph.OffsetY; + DrawGlyphWithEffect(dst, font.Atlas, glyph, glyphX, glyphY, effect, shade, outlineShade, mode); + pen += Advance(font, codepoint, glyph); + previous = codepoint; + glyphCount++; + } + } + + private static void DrawGlyphWithEffect(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, + int x, int y, DmdTextEffect effect, in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode) + { + switch (effect) { + case DmdTextEffect.None: + DrawGlyph(dst, atlas, glyph, x, y, shade, mode); + break; + case DmdTextEffect.Outline: + DrawOutline(dst, atlas, glyph, x, y, outlineShade, mode); + DrawGlyph(dst, atlas, glyph, x, y, shade, mode); + break; + case DmdTextEffect.Shadow: + DrawGlyph(dst, atlas, glyph, x + 1, y + 1, outlineShade, mode); + DrawGlyph(dst, atlas, glyph, x, y, shade, mode); + break; + case DmdTextEffect.Inverse: + DrawInverseGlyph(dst, atlas, glyph, x, y, shade, outlineShade, mode); + break; + default: + throw new ArgumentOutOfRangeException(nameof(effect)); + } + } + + private static void DrawGlyph(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, + int x, int y, in DmdShade shade, DmdBlendMode mode) + { + for (var glyphY = 0; glyphY < glyph.H; glyphY++) { + var destinationY = y + glyphY; + if (destinationY < 0 || destinationY >= dst.Height) { + continue; + } + for (var glyphX = 0; glyphX < glyph.W; glyphX++) { + var destinationX = x + glyphX; + if (destinationX < 0 || destinationX >= dst.Width) { + continue; + } + var mask = GlyphMask(atlas, glyph, glyphX, glyphY); + DrawShadedPixel(dst, destinationX, destinationY, shade, mask, mode); + } + } + } + + private static void DrawOutline(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, + int x, int y, in DmdShade shade, DmdBlendMode mode) + { + for (var glyphY = -1; glyphY <= glyph.H; glyphY++) { + for (var glyphX = -1; glyphX <= glyph.W; glyphX++) { + if (glyphX >= 0 && glyphX < glyph.W && glyphY >= 0 && glyphY < glyph.H && + GlyphMask(atlas, glyph, glyphX, glyphY) != 0) { + continue; + } + byte mask = 0; + for (var neighborY = -1; neighborY <= 1; neighborY++) { + for (var neighborX = -1; neighborX <= 1; neighborX++) { + var sampleX = glyphX + neighborX; + var sampleY = glyphY + neighborY; + if (sampleX >= 0 && sampleX < glyph.W && sampleY >= 0 && sampleY < glyph.H) { + var sample = GlyphMask(atlas, glyph, sampleX, sampleY); + mask = sample > mask ? sample : mask; + } + } + } + if (mask != 0) { + DrawShadedPixel(dst, x + glyphX, y + glyphY, shade, mask, mode); + } + } + } + } + + private static void DrawInverseGlyph(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, + int x, int y, in DmdShade background, in DmdShade foreground, DmdBlendMode mode) + { + for (var glyphY = 0; glyphY < glyph.H; glyphY++) { + for (var glyphX = 0; glyphX < glyph.W; glyphX++) { + var mask = GlyphMask(atlas, glyph, glyphX, glyphY); + var inverseShade = Interpolate(background, foreground, mask); + DrawShadedPixel(dst, x + glyphX, y + glyphY, inverseShade, byte.MaxValue, mode); + } + } + } + + private static void DrawMissingBox(DmdSurface dst, int x, int y, int width, int height, + in DmdShade shade, in DmdShade outlineShade, DmdTextEffect effect, DmdBlendMode mode) + { + var boxShade = effect == DmdTextEffect.Inverse ? outlineShade : shade; + for (var boxY = 0; boxY < height; boxY++) { + for (var boxX = 0; boxX < width; boxX++) { + var border = boxX == 0 || boxX == width - 1 || boxY == 0 || boxY == height - 1; + DrawShadedPixel(dst, x + boxX, y + boxY, boxShade, border ? byte.MaxValue : (byte)0, mode); + } + } + } + + private static void DrawShadedPixel(DmdSurface dst, int x, int y, in DmdShade shade, + byte mask, DmdBlendMode mode) + { + if (x < 0 || x >= dst.Width || y < 0 || y >= dst.Height) { + return; + } + var pixel = y * dst.Width + x; + if (dst.Format == DmdPixelFormat.I8) { + var source = mode == DmdBlendMode.Opaque ? DmdBlitter.Multiply(shade.Intensity, mask) : shade.Intensity; + dst.Data[pixel] = DmdBlitter.Blend(dst.Data[pixel], source, mask, byte.MaxValue, mode); + } else { + var alpha = DmdBlitter.Multiply(mask, shade.Color.a); + var offset = pixel * 3; + var red = mode == DmdBlendMode.Opaque ? DmdBlitter.Multiply(shade.Color.r, mask) : shade.Color.r; + var green = mode == DmdBlendMode.Opaque ? DmdBlitter.Multiply(shade.Color.g, mask) : shade.Color.g; + var blue = mode == DmdBlendMode.Opaque ? DmdBlitter.Multiply(shade.Color.b, mask) : shade.Color.b; + dst.Data[offset] = DmdBlitter.Blend(dst.Data[offset], red, alpha, byte.MaxValue, mode); + dst.Data[offset + 1] = DmdBlitter.Blend(dst.Data[offset + 1], green, alpha, byte.MaxValue, mode); + dst.Data[offset + 2] = DmdBlitter.Blend(dst.Data[offset + 2], blue, alpha, byte.MaxValue, mode); + } + } + + private static DmdShade Interpolate(in DmdShade from, in DmdShade to, byte amount) + { + return new DmdShade { + Intensity = (byte)((from.Intensity * (255 - amount) + to.Intensity * amount + 127) / 255), + Color = new UnityEngine.Color32( + (byte)((from.Color.r * (255 - amount) + to.Color.r * amount + 127) / 255), + (byte)((from.Color.g * (255 - amount) + to.Color.g * amount + 127) / 255), + (byte)((from.Color.b * (255 - amount) + to.Color.b * amount + 127) / 255), + (byte)((from.Color.a * (255 - amount) + to.Color.a * amount + 127) / 255)) + }; + } + + private static bool HasReadableAtlas(DmdFontAsset font, CueDiagnostics diagnostics) + { + var atlas = font.Atlas; + if (atlas == null || atlas.Width < 1 || atlas.Height < 1 || atlas.Format != DmdPixelFormat.I8 || + atlas.Pixels == null || atlas.Pixels.LongLength != (long)atlas.Width * atlas.Height || + atlas.Alpha != null && atlas.Alpha.Length != 0 && atlas.Alpha.LongLength != (long)atlas.Width * atlas.Height) { + diagnostics?.MalformedFont("Font atlas dimensions or buffers are invalid."); + return false; + } + return true; + } + + private static bool GlyphIsReadable(DmdBitmapData atlas, DmdGlyph glyph) + { + return glyph.X >= 0 && glyph.Y >= 0 && glyph.W >= 0 && glyph.H >= 0 && + (long)glyph.X + glyph.W <= atlas.Width && (long)glyph.Y + glyph.H <= atlas.Height; + } + + private static byte GlyphMask(DmdBitmapData atlas, DmdGlyph glyph, int x, int y) + { + var pixel = (glyph.Y + y) * atlas.Width + glyph.X + x; + return atlas.Alpha != null && atlas.Alpha.Length != 0 ? atlas.Alpha[pixel] : atlas.Pixels[pixel]; + } + + private static bool TryResolveGlyph(DmdFontAsset font, int codepoint, out DmdGlyph glyph) + { + return TryFindGlyph(font, codepoint, out glyph) || TryFindGlyph(font, 0, out glyph) || + TryFindGlyph(font, ReplacementCodepoint, out glyph); + } + + private static bool TryFindGlyph(DmdFontAsset font, int codepoint, out DmdGlyph glyph) + { + if (font.Glyphs != null) { + for (var index = 0; index < font.Glyphs.Count; index++) { + if (font.Glyphs[index].Codepoint == codepoint) { + glyph = font.Glyphs[index]; + return true; + } + } + } + glyph = default; + return false; + } + + private static int FindKerning(DmdFontAsset font, int left, int right) + { + if (font.Kerning != null) { + for (var index = 0; index < font.Kerning.Count; index++) { + var pair = font.Kerning[index]; + if (pair.LeftCodepoint == left && pair.RightCodepoint == right) { + return pair.Adjustment; + } + } + } + return 0; + } + + private static int Advance(DmdFontAsset font, int codepoint, DmdGlyph glyph) + { + return IsDigit(codepoint) && font.DigitWidth > 0 ? font.DigitWidth : glyph.Advance; + } + + private static bool IsDigit(int codepoint) => codepoint >= '0' && codepoint <= '9'; + + private static int MissingGlyphWidth(DmdFontAsset font) + { + return math.max(3, math.max(1, font.LineHeight) / 2); + } + + private static int ResolveHorizontalOrigin(int x, int width, DmdAnchor anchor) + { + switch (anchor) { + case DmdAnchor.TopCenter: + case DmdAnchor.MiddleCenter: + case DmdAnchor.BottomCenter: + case DmdAnchor.BaselineCenter: + return x - width / 2; + case DmdAnchor.TopRight: + case DmdAnchor.MiddleRight: + case DmdAnchor.BottomRight: + case DmdAnchor.BaselineRight: + return x - width; + default: + return x; + } + } + + private static int ResolveVerticalOrigin(int y, int lineHeight, int baseline, DmdAnchor anchor) + { + switch (anchor) { + case DmdAnchor.MiddleLeft: + case DmdAnchor.MiddleCenter: + case DmdAnchor.MiddleRight: + return y - lineHeight / 2; + case DmdAnchor.BottomLeft: + case DmdAnchor.BottomCenter: + case DmdAnchor.BottomRight: + return y - lineHeight; + case DmdAnchor.BaselineLeft: + case DmdAnchor.BaselineCenter: + case DmdAnchor.BaselineRight: + return y - baseline; + default: + return y; + } + } + + private static bool TryReadCodepoint(string text, ref int index, out int codepoint) + { + if (index >= text.Length) { + codepoint = 0; + return false; + } + var first = text[index++]; + if (char.IsHighSurrogate(first) && index < text.Length && char.IsLowSurrogate(text[index])) { + codepoint = char.ConvertToUtf32(first, text[index++]); + } else { + codepoint = first; + } + return true; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs.meta new file mode 100644 index 000000000..bc50a66f0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 73cfaed139fb364448c10c12b73813de \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs new file mode 100644 index 000000000..fec7dc052 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs @@ -0,0 +1,282 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public static class DmdTransitions + { + private static readonly byte[] Bayer4x4 = { + 0, 8, 2, 10, + 12, 4, 14, 6, + 3, 11, 1, 9, + 15, 7, 13, 5, + }; + + public static void Compose(DmdSurface dst, DmdSurface from, DmdSurface to, + DmdTransitionType type, DmdDirection direction, float progress) + { + ValidateSurfaces(dst, from, to); + if (type < DmdTransitionType.Cut || type > DmdTransitionType.ScrollOff) { + throw new ArgumentOutOfRangeException(nameof(type)); + } + if (direction < DmdDirection.Left || direction > DmdDirection.Down) { + throw new ArgumentOutOfRangeException(nameof(direction)); + } + if (float.IsNaN(progress) || float.IsInfinity(progress)) { + throw new ArgumentOutOfRangeException(nameof(progress)); + } + progress = math.clamp(progress, 0f, 1f); + if (type == DmdTransitionType.Cut || progress >= 1f) { + dst.CopyFrom(to); + return; + } + if (progress <= 0f) { + dst.CopyFrom(from); + return; + } + + switch (type) { + case DmdTransitionType.Push: + ComposePush(dst, from, to, direction, progress); + break; + case DmdTransitionType.Cover: + ComposeCover(dst, from, to, direction, progress); + break; + case DmdTransitionType.Uncover: + case DmdTransitionType.ScrollOff: + ComposeUncover(dst, from, to, direction, progress); + break; + case DmdTransitionType.WipeOn: + ComposeWipe(dst, from, to, direction, progress); + break; + case DmdTransitionType.SplitIn: + ComposeSplit(dst, from, to, direction, progress, true); + break; + case DmdTransitionType.SplitOut: + ComposeSplit(dst, from, to, direction, progress, false); + break; + case DmdTransitionType.Dissolve: + ComposeDissolve(dst, from, to, progress); + break; + case DmdTransitionType.FadeThroughBlack: + ComposeFadeThroughBlack(dst, from, to, progress); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type)); + } + } + + private static void ComposePush(DmdSurface dst, DmdSurface from, DmdSurface to, + DmdDirection direction, float progress) + { + Motion(direction, progress, dst.Width, dst.Height, out var fromX, out var fromY); + StartOffset(direction, dst.Width, dst.Height, out var startX, out var startY); + var toX = startX + fromX; + var toY = startY + fromY; + for (var y = 0; y < dst.Height; y++) { + for (var x = 0; x < dst.Width; x++) { + if (!TryCopyTranslatedPixel(dst, to, x, y, toX, toY)) { + TryCopyTranslatedPixel(dst, from, x, y, fromX, fromY); + } + } + } + } + + private static void ComposeCover(DmdSurface dst, DmdSurface from, DmdSurface to, + DmdDirection direction, float progress) + { + Motion(direction, progress, dst.Width, dst.Height, out var motionX, out var motionY); + StartOffset(direction, dst.Width, dst.Height, out var startX, out var startY); + var toX = startX + motionX; + var toY = startY + motionY; + dst.CopyFrom(from); + for (var y = 0; y < dst.Height; y++) { + for (var x = 0; x < dst.Width; x++) { + TryCopyTranslatedPixel(dst, to, x, y, toX, toY); + } + } + } + + private static void ComposeUncover(DmdSurface dst, DmdSurface from, DmdSurface to, + DmdDirection direction, float progress) + { + Motion(direction, progress, dst.Width, dst.Height, out var fromX, out var fromY); + dst.CopyFrom(to); + for (var y = 0; y < dst.Height; y++) { + for (var x = 0; x < dst.Width; x++) { + TryCopyTranslatedPixel(dst, from, x, y, fromX, fromY); + } + } + } + + private static void ComposeWipe(DmdSurface dst, DmdSurface from, DmdSurface to, + DmdDirection direction, float progress) + { + var extent = direction == DmdDirection.Left || direction == DmdDirection.Right ? dst.Width : dst.Height; + var reveal = (int)math.round(progress * extent); + for (var y = 0; y < dst.Height; y++) { + for (var x = 0; x < dst.Width; x++) { + bool showTo; + switch (direction) { + case DmdDirection.Left: + showTo = x >= dst.Width - reveal; + break; + case DmdDirection.Right: + showTo = x < reveal; + break; + case DmdDirection.Up: + showTo = y >= dst.Height - reveal; + break; + case DmdDirection.Down: + showTo = y < reveal; + break; + default: + throw new ArgumentOutOfRangeException(nameof(direction)); + } + CopyPixel(showTo ? to : from, dst, x, y); + } + } + } + + private static void ComposeSplit(DmdSurface dst, DmdSurface from, DmdSurface to, + DmdDirection direction, float progress, bool inward) + { + var horizontal = direction == DmdDirection.Left || direction == DmdDirection.Right; + var extent = horizontal ? dst.Width : dst.Height; + var reveal = (int)math.round(progress * extent); + for (var y = 0; y < dst.Height; y++) { + for (var x = 0; x < dst.Width; x++) { + var coordinate = horizontal ? x : y; + var doubledDistanceFromCenter = math.abs(2 * coordinate + 1 - extent); + var showTo = inward + ? doubledDistanceFromCenter >= extent - reveal + : doubledDistanceFromCenter < reveal; + CopyPixel(showTo ? to : from, dst, x, y); + } + } + } + + private static void ComposeDissolve(DmdSurface dst, DmdSurface from, DmdSurface to, float progress) + { + var threshold = progress * 16f; + for (var y = 0; y < dst.Height; y++) { + for (var x = 0; x < dst.Width; x++) { + var showTo = Bayer4x4[(y & 3) * 4 + (x & 3)] < threshold; + CopyPixel(showTo ? to : from, dst, x, y); + } + } + } + + private static void ComposeFadeThroughBlack(DmdSurface dst, DmdSurface from, DmdSurface to, + float progress) + { + var source = progress < 0.5f ? from : to; + var scale = progress < 0.5f ? 1f - progress * 2f : progress * 2f - 1f; + for (var index = 0; index < dst.Data.Length; index++) { + dst.Data[index] = (byte)math.clamp((int)math.round(source.Data[index] * scale), 0, 255); + } + } + + private static bool TryCopyTranslatedPixel(DmdSurface dst, DmdSurface source, + int destinationX, int destinationY, int translationX, int translationY) + { + var sourceX = destinationX - translationX; + var sourceY = destinationY - translationY; + if (sourceX < 0 || sourceX >= source.Width || sourceY < 0 || sourceY >= source.Height) { + return false; + } + CopyPixel(source, dst, sourceX, sourceY, destinationX, destinationY); + return true; + } + + private static void CopyPixel(DmdSurface source, DmdSurface destination, int x, int y) + { + CopyPixel(source, destination, x, y, x, y); + } + + private static void CopyPixel(DmdSurface source, DmdSurface destination, + int sourceX, int sourceY, int destinationX, int destinationY) + { + var channels = source.Format == DmdPixelFormat.I8 ? 1 : 3; + var sourceOffset = (sourceY * source.Width + sourceX) * channels; + var destinationOffset = (destinationY * destination.Width + destinationX) * channels; + for (var channel = 0; channel < channels; channel++) { + destination.Data[destinationOffset + channel] = source.Data[sourceOffset + channel]; + } + } + + private static void Motion(DmdDirection direction, float progress, int width, int height, + out int x, out int y) + { + x = 0; + y = 0; + switch (direction) { + case DmdDirection.Left: + x = -(int)math.round(progress * width); + break; + case DmdDirection.Right: + x = (int)math.round(progress * width); + break; + case DmdDirection.Up: + y = -(int)math.round(progress * height); + break; + case DmdDirection.Down: + y = (int)math.round(progress * height); + break; + default: + throw new ArgumentOutOfRangeException(nameof(direction)); + } + } + + private static void StartOffset(DmdDirection direction, int width, int height, out int x, out int y) + { + x = 0; + y = 0; + switch (direction) { + case DmdDirection.Left: + x = width; + break; + case DmdDirection.Right: + x = -width; + break; + case DmdDirection.Up: + y = height; + break; + case DmdDirection.Down: + y = -height; + break; + default: + throw new ArgumentOutOfRangeException(nameof(direction)); + } + } + + private static void ValidateSurfaces(DmdSurface destination, DmdSurface from, DmdSurface to) + { + if (destination == null) { + throw new ArgumentNullException(nameof(destination)); + } + if (from == null) { + throw new ArgumentNullException(nameof(from)); + } + if (to == null) { + throw new ArgumentNullException(nameof(to)); + } + if (ReferenceEquals(destination, from) || ReferenceEquals(destination, to)) { + throw new ArgumentException("Transition output must use a distinct destination surface."); + } + if (destination.Width != from.Width || destination.Height != from.Height || + destination.Format != from.Format || destination.Width != to.Width || + destination.Height != to.Height || destination.Format != to.Format) { + throw new ArgumentException("Transition surfaces must have matching dimensions and formats."); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs.meta new file mode 100644 index 000000000..2920c1805 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4b37e4046db14a245b23ddf93637d975 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs index d101ee4d8..23a2c2b25 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs @@ -502,6 +502,12 @@ private static void ValidateCue(DmdCueAsset cue, DmdValidationResult result, private static void ValidateTransition(DmdTransitionSpec transition, string label, DmdValidationResult result) { + if (!Enum.IsDefined(typeof(DmdTransitionType), transition.Type)) { + result.Error($"cue.transition.{label}.type", $"The {label} transition type is invalid."); + } + if (!Enum.IsDefined(typeof(DmdDirection), transition.Direction)) { + result.Error($"cue.transition.{label}.direction", $"The {label} transition direction is invalid."); + } if (transition.DurationFrames < 0 || transition.DurationFrames > MaxTransitionFrames) { result.Error($"cue.transition.{label}", $"The {label} transition duration must be between 0 and {MaxTransitionFrames}."); From d5099e0241ebbab7a3015628b45d3f790b04c8fb Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 15 Jul 2026 00:50:13 +0200 Subject: [PATCH 4/8] dmd: add studio cue runtime --- .../developer-guide/dmd-studio-readiness.md | 8 + .../DmdStudio/CueRendererTests.cs | 224 ++++++ .../DmdStudio/CueRendererTests.cs.meta | 2 + .../DmdStudio/CueSchedulerTests.cs | 214 ++++++ .../DmdStudio/CueSchedulerTests.cs.meta | 2 + .../DmdStudio/DmdBlitterTests.cs | 5 +- .../DmdStudio/DmdCuePlayerTests.cs | 530 +++++++++++++ .../DmdStudio/DmdCuePlayerTests.cs.meta | 2 + .../DmdStudio/DmdTransitionTests.cs | 4 +- .../DmdStudio/SpriteFrameClockTests.cs | 69 ++ .../DmdStudio/SpriteFrameClockTests.cs.meta | 2 + .../DmdStudio/Compositing/CueDiagnostics.cs | 16 +- .../DmdStudio/Compositing/CueRenderer.cs | 459 +++++++++++ .../DmdStudio/Compositing/CueRenderer.cs.meta | 2 + .../DmdStudio/Compositing/DmdBlitter.cs | 30 +- .../DmdStudio/Compositing/DmdTextRenderer.cs | 131 ++-- .../DmdStudio/Compositing/DmdTransitions.cs | 26 +- .../DmdStudio/Compositing/DmdValidation.cs | 77 +- .../DmdStudio/Layers/SpriteFrameClock.cs | 105 +++ .../DmdStudio/Layers/SpriteFrameClock.cs.meta | 2 + .../DmdStudio/Runtime.meta | 8 + .../DmdStudio/Runtime/CueHandle.cs | 33 + .../DmdStudio/Runtime/CueHandle.cs.meta | 2 + .../DmdStudio/Runtime/CueInstance.cs | 54 ++ .../DmdStudio/Runtime/CueInstance.cs.meta | 2 + .../DmdStudio/Runtime/CueInstanceState.cs | 46 ++ .../Runtime/CueInstanceState.cs.meta | 2 + .../DmdStudio/Runtime/CueScheduler.cs | 234 ++++++ .../DmdStudio/Runtime/CueScheduler.cs.meta | 2 + .../DmdStudio/Runtime/DmdCuePlayer.cs | 719 ++++++++++++++++++ .../DmdStudio/Runtime/DmdCuePlayer.cs.meta | 2 + .../DmdStudio/Runtime/IDmdFrameSink.cs | 17 + .../DmdStudio/Runtime/IDmdFrameSink.cs.meta | 2 + 33 files changed, 2950 insertions(+), 83 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueRendererTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueRendererTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueSchedulerTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueSchedulerTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/SpriteFrameClockTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/SpriteFrameClockTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueRenderer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueRenderer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/SpriteFrameClock.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/SpriteFrameClock.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueHandle.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueHandle.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstance.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstance.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstanceState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstanceState.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueScheduler.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueScheduler.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/IDmdFrameSink.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/IDmdFrameSink.cs.meta diff --git a/VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md b/VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md index a669d6340..b1627fc6b 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md @@ -83,3 +83,11 @@ All public `DmdCuePlayer` calls are main-thread-only. Editor and development bui - Stale handles and unmatched string targets return `false`. Stop/update never throw merely because an instance ended. Existing `OnCueFinished` timing remains as specified in section 6.4 of the plan. Utility APIs validate null inputs, dimensions, formats, and destination buffer sizes at their public boundary with standard argument exceptions. Authored asset-shape failures continue through `DmdValidation` diagnostics so malformed content cannot escape from the renderer as an indexing exception. + +## Phase 2 compositor clarifications + +The Phase 1 review exposed three edge cases that must be deterministic before cue-layer wiring: + +- `ScrollOff` moves the outgoing surface over black and reveals the incoming surface only at completion. `Uncover` continues to move the outgoing surface while revealing the live incoming surface beneath it; the two transitions are intentionally distinct. +- `Opaque` ignores source per-pixel alpha, but the layer's global opacity still fades the complete overwrite. Full layer opacity remains an exact `dst = src` copy, including black or transparent source texels. +- A mask covers the canvas with transparent black outside its bitmap rectangle. Applying an offset or undersized mask therefore clears pixels outside the mask bounds instead of leaving them unchanged. diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueRendererTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueRendererTests.cs new file mode 100644 index 000000000..ee54b4ca7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueRendererTests.cs @@ -0,0 +1,224 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace VisualPinball.Unity.Test +{ + public class CueRendererTests + { + private DmdProjectAsset _project; + private DmdCueAsset _cue; + private DmdFontAsset _font; + + [SetUp] + public void SetUp() + { + _project = ScriptableObject.CreateInstance(); + _project.Width = 6; + _project.Height = 5; + _project.FrameRate = 10; + _cue = ScriptableObject.CreateInstance(); + _cue.CueId = "render"; + _font = DmdTestFont.Create(); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(_font); + Object.DestroyImmediate(_cue); + Object.DestroyImmediate(_project); + } + + [Test] + public void LayersRenderBottomToTopAndTracksOverrideStaticValues() + { + _cue.Layers.Add(new ShapeLayer { + Shape = DmdShapeType.Rect, Width = 6, Height = 1, Filled = true, + Shade = new DmdShade { Intensity = 100 } + }); + var sprite = Sprite(new byte[] { 200, 220 }, 2, 1); + _cue.Layers.Add(new BitmapLayer { + Sprite = sprite, X = 5, Blend = DmdBlendMode.Opaque, Loop = DmdLoopMode.HoldLast, + Tracks = { new DmdPropertyTrack { + Property = DmdAnimatableProperty.X, + Keys = { new DmdKeyframe { Frame = 0, Value = 0, Interp = DmdInterpolation.Linear }, + new DmdKeyframe { Frame = 2, Value = 2 } } + } } + }); + + var surface = Surface(); + new CueRenderer(_project).Render(surface, _cue, 1, null, new CueInstanceState(), new CueDiagnostics()); + + Assert.That(surface.Data, Is.EqualTo(new byte[] { + 100, 200, 220, 100, 100, 100, + 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + })); + Object.DestroyImmediate(sprite); + } + + [Test] + public void MaskUsesTheSharedClockAndClearsOutsideItsBounds() + { + _cue.Layers.Add(new ShapeLayer { + Shape = DmdShapeType.Rect, Width = 6, Height = 5, Filled = true, + Shade = DmdShade.White + }); + var sprite = ScriptableObject.CreateInstance(); + sprite.Frames.Add(new DmdBitmapData { Width = 2, Height = 1, Pixels = new byte[] { 255, 0 } }); + sprite.Frames.Add(new DmdBitmapData { Width = 2, Height = 1, Pixels = new byte[] { 0, 255 } }); + _cue.Layers.Add(new MaskLayer { Mask = sprite, X = 2, Y = 1, Loop = DmdLoopMode.Loop }); + + var surface = Surface(); + new CueRenderer(_project).Render(surface, _cue, 1, null, new CueInstanceState(), new CueDiagnostics()); + + Assert.That(surface.Data[1 * 6 + 2], Is.Zero); + Assert.That(surface.Data[1 * 6 + 3], Is.EqualTo(255)); + Assert.That(surface.Data[0], Is.Zero); + Object.DestroyImmediate(sprite); + } + + [Test] + public void TextBindingsAreCachedAndRefreshWhenAParameterChanges() + { + _cue.Layers.Add(new TextLayer { Font = _font, Text = "{letter}", Blend = DmdBlendMode.Alpha }); + var state = new CueInstanceState(); + var parameters = new DmdParams().Set("letter", "A"); + var renderer = new CueRenderer(_project); + var surface = Surface(); + + renderer.Render(surface, _cue, 0, parameters, state, new CueDiagnostics()); + var first = DmdSurfaceAssert.Hash(surface.Data); + var resolved = state.ResolvedTexts[0]; + renderer.Render(surface, _cue, 1, parameters, state, new CueDiagnostics()); + Assert.That(state.ResolvedTexts[0], Is.SameAs(resolved)); + parameters.Set("letter", "B"); + renderer.Render(surface, _cue, 2, parameters, state, new CueDiagnostics()); + Assert.That(DmdSurfaceAssert.Hash(surface.Data), Is.Not.EqualTo(first)); + } + + [Test] + public void NumberTweenRetargetsFromTheDisplayedValueDeterministically() + { + _cue.Layers.Add(new NumberLayer { + Font = _font, ParamName = "value", Format = "0", CountUpSeconds = 1f + }); + var state = new CueInstanceState(); + var parameters = new DmdParams().Set("value", 100d); + var renderer = new CueRenderer(_project); + + renderer.Render(Surface(), _cue, 0, parameters, state, new CueDiagnostics()); + renderer.Render(Surface(), _cue, 5, parameters, state, new CueDiagnostics()); + parameters.Set("value", 200d); + renderer.Render(Surface(), _cue, 5, parameters, state, new CueDiagnostics()); + + Assert.That(state.NumberTweens[0].StartValue, Is.EqualTo(50d)); + Assert.That(state.NumberTweens[0].TargetValue, Is.EqualTo(200d)); + Assert.That(state.NumberTweens[0].StartTick, Is.EqualTo(5)); + } + + [Test] + public void MalformedAuthoredAssetsNeverEscapeRenderAndReportOnce() + { + var sprite = Sprite(new byte[] { 1 }, 2, 1); + _cue.Layers.Add(new BitmapLayer { Sprite = sprite }); + var diagnostics = new CueDiagnostics(); + var renderer = new CueRenderer(_project); + + Assert.DoesNotThrow(() => renderer.Render(Surface(), _cue, 0, null, new CueInstanceState(), diagnostics)); + Assert.DoesNotThrow(() => renderer.Render(Surface(), _cue, 0, null, new CueInstanceState(), diagnostics)); + Assert.That(diagnostics.Count, Is.EqualTo(1)); + Object.DestroyImmediate(sprite); + } + + [Test] + public void LayerSpanAndLastDuplicateTrackAreDeterministic() + { + var layer = new ShapeLayer { + Shape = DmdShapeType.Dot, StartFrame = 2, EndFrame = 4, X = 0, Shade = DmdShade.White + }; + layer.Tracks.Add(new DmdPropertyTrack { + Property = DmdAnimatableProperty.X, + Keys = { new DmdKeyframe { Frame = 0, Value = 1 } } + }); + layer.Tracks.Add(new DmdPropertyTrack { + Property = DmdAnimatableProperty.X, + Keys = { new DmdKeyframe { Frame = 0, Value = 3 } } + }); + _cue.Layers.Add(layer); + var renderer = new CueRenderer(_project); + var diagnostics = new CueDiagnostics(); + + var before = Surface(); + renderer.Render(before, _cue, 1, null, new CueInstanceState(), diagnostics); + var during = Surface(); + renderer.Render(during, _cue, 2, null, new CueInstanceState(), diagnostics); + var after = Surface(); + renderer.Render(after, _cue, 4, null, new CueInstanceState(), diagnostics); + + Assert.That(before.Data, Is.All.EqualTo(0)); + Assert.That(during.Data[3], Is.EqualTo(255)); + Assert.That(after.Data, Is.All.EqualTo(0)); + Assert.That(diagnostics.Count, Is.EqualTo(1)); + Assert.That(diagnostics.Diagnostics[0].Code, Is.EqualTo("layer.track.duplicate")); + } + + [Test] + public void ShapeLayerSupportsFilledOutlineLinesAndDot() + { + _cue.Layers.Add(new ShapeLayer { Shape = DmdShapeType.Rect, Width = 4, Height = 4, + X = 1, Filled = false, Shade = DmdShade.White }); + _cue.Layers.Add(new ShapeLayer { Shape = DmdShapeType.Dot, X = 2, Y = 2, + Shade = new DmdShade { Intensity = 128 } }); + var surface = Surface(); + new CueRenderer(_project).Render(surface, _cue, 0, null, new CueInstanceState(), new CueDiagnostics()); + + Assert.That(surface.Data[0 * 6 + 1], Is.EqualTo(255)); + Assert.That(surface.Data[1 * 6 + 2], Is.Zero); + Assert.That(surface.Data[2 * 6 + 2], Is.EqualTo(128)); + Assert.That(surface.Data[3 * 6 + 4], Is.EqualTo(255)); + } + + [Test] + public void StaticRendererHasNoSteadyStateManagedAllocations() + { + _cue.Layers.Add(new ShapeLayer { + Shape = DmdShapeType.Rect, Width = 6, Height = 5, Filled = true, Shade = DmdShade.White + }); + var surface = Surface(); + var renderer = new CueRenderer(_project); + var state = new CueInstanceState(); + var diagnostics = new CueDiagnostics(); + renderer.Render(surface, _cue, 0, null, state, diagnostics); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var frame = 1; frame <= 100; frame++) { + renderer.Render(surface, _cue, frame, null, state, diagnostics); + } + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.That(allocated, Is.Zero); + } + + private DmdSurface Surface() => new DmdSurface(_project.Width, _project.Height, DmdPixelFormat.I8); + + private static DmdSpriteAsset Sprite(byte[] pixels, int width, int height) + { + var sprite = ScriptableObject.CreateInstance(); + sprite.Frames.Add(new DmdBitmapData { Width = width, Height = height, Pixels = pixels }); + return sprite; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueRendererTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueRendererTests.cs.meta new file mode 100644 index 000000000..5f475e22b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueRendererTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6ea227ee684cb047bb167967f818572 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueSchedulerTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueSchedulerTests.cs new file mode 100644 index 000000000..08ea25d4e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueSchedulerTests.cs @@ -0,0 +1,214 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace VisualPinball.Unity.Test +{ + public class CueSchedulerTests + { + private readonly List _assets = new List(); + private CueScheduler _scheduler; + + [SetUp] + public void SetUp() => _scheduler = new CueScheduler(); + + [TearDown] + public void TearDown() + { + foreach (var asset in _assets) { + Object.DestroyImmediate(asset); + } + _assets.Clear(); + } + + [Test] + public void Row1CoalesceMergesBeforeAdmissionAndReturnsExistingInstance() + { + var cue = Cue("award", CuePriority.Award, key: "jackpot"); + var active = Create(cue, new DmdParams().Set("score", 10)); + _scheduler.Admit(active); + + var result = _scheduler.Admit(Create(cue, new DmdParams().Set("score", 20))); + + Assert.That(result.Kind, Is.EqualTo(CueAdmissionKind.Coalesced)); + Assert.That(result.Instance, Is.SameAs(active)); + Assert.That(active.Params.TryGet("score", out var value) && value.IntValue == 20, Is.True); + } + + [Test] + public void Row2QueuePolicyNeverInterruptsEvenAtHigherPriority() + { + var active = Create(Cue("status", CuePriority.Status)); + _scheduler.Admit(active); + var incoming = Create(Cue("system", CuePriority.System, CueInterruptPolicy.Queue)); + + var result = _scheduler.Admit(incoming); + + Assert.That(result.Kind, Is.EqualTo(CueAdmissionKind.Queued)); + Assert.That(_scheduler.Active, Is.SameAs(active)); + } + + [Test] + public void Row3HigherPriorityPreemptsInterruptibleActive() + { + var active = Create(Cue("status", CuePriority.Status)); + _scheduler.Admit(active); + var incoming = Create(Cue("mode", CuePriority.Mode)); + + var result = _scheduler.Admit(incoming); + + Assert.That(result.Kind, Is.EqualTo(CueAdmissionKind.Preempted)); + Assert.That(_scheduler.Active, Is.SameAs(incoming)); + Assert.That(_scheduler.HoldStack, Does.Contain(active)); + } + + [Test] + public void Row3SystemPreemptsNonInterruptibleActive() + { + var active = Create(Cue("mode", CuePriority.Mode, CueInterruptPolicy.NonInterruptible)); + _scheduler.Admit(active); + var incoming = Create(Cue("tilt", CuePriority.System)); + + Assert.That(_scheduler.Admit(incoming).Kind, Is.EqualTo(CueAdmissionKind.Preempted)); + } + + [Test] + public void Row4NonInterruptibleActiveQueuesHigherNonSystemCue() + { + var active = Create(Cue("mode", CuePriority.Mode, CueInterruptPolicy.NonInterruptible)); + _scheduler.Admit(active); + var incoming = Create(Cue("critical", CuePriority.Critical)); + + Assert.That(_scheduler.Admit(incoming).Kind, Is.EqualTo(CueAdmissionKind.Queued)); + Assert.That(_scheduler.Active, Is.SameAs(active)); + } + + [Test] + public void Row5EqualReplaceDiscardsInterruptibleActive() + { + var active = Create(Cue("one", CuePriority.Award)); + _scheduler.Admit(active); + var incoming = Create(Cue("two", CuePriority.Award)); + + var result = _scheduler.Admit(incoming); + + Assert.That(result.Kind, Is.EqualTo(CueAdmissionKind.Replaced)); + Assert.That(result.Displaced, Is.SameAs(active)); + Assert.That(_scheduler.TryFind(active.Handle, out _), Is.False); + } + + [TestCase(CueInterruptPolicy.NonInterruptible, CueInterruptPolicy.Replace)] + [TestCase(CueInterruptPolicy.Replace, CueInterruptPolicy.NonInterruptible)] + public void Row6EqualNonInterruptibleCombinationQueues(CueInterruptPolicy activePolicy, + CueInterruptPolicy incomingPolicy) + { + var active = Create(Cue("one", CuePriority.Award, activePolicy)); + _scheduler.Admit(active); + var incoming = Create(Cue("two", CuePriority.Award, incomingPolicy)); + + Assert.That(_scheduler.Admit(incoming).Kind, Is.EqualTo(CueAdmissionKind.Queued)); + } + + [Test] + public void Row7LowerPriorityQueues() + { + var active = Create(Cue("mode", CuePriority.Mode)); + _scheduler.Admit(active); + + Assert.That(_scheduler.Admit(Create(Cue("status", CuePriority.Status))).Kind, + Is.EqualTo(CueAdmissionKind.Queued)); + } + + [Test] + public void DuplicateCueIdsRemainPreciselyAddressableByHandle() + { + var cue = Cue("award", CuePriority.Award, CueInterruptPolicy.Queue); + var first = Create(cue); + var second = Create(cue); + _scheduler.Admit(first); + _scheduler.Admit(second); + + Assert.That(first.Handle, Is.Not.EqualTo(second.Handle)); + Assert.That(_scheduler.TryFind(first.Handle, out var foundFirst) && foundFirst == first, Is.True); + Assert.That(_scheduler.TryFind(second.Handle, out var foundSecond) && foundSecond == second, Is.True); + } + + [Test] + public void StringResolutionChecksCoalesceKeyBeforeCueId() + { + var active = Create(Cue("shared", CuePriority.Status, key: "active-key")); + _scheduler.Admit(active); + var queuedByKey = Create(Cue("other", CuePriority.Status, CueInterruptPolicy.Queue, "shared")); + _scheduler.Admit(queuedByKey); + + Assert.That(_scheduler.Resolve("shared"), Is.SameAs(queuedByKey)); + } + + [Test] + public void DrainChoosesHigherPriorityBetweenHoldTopAndQueueHeadThenHoldOnTie() + { + var held = Create(Cue("held", CuePriority.Status)); + _scheduler.Admit(held); + var system = Create(Cue("system", CuePriority.System)); + _scheduler.Admit(system); + var award = Create(Cue("award", CuePriority.Award, CueInterruptPolicy.Queue)); + _scheduler.Admit(award); + + Assert.That(_scheduler.EndActive(), Is.SameAs(award)); + Assert.That(_scheduler.EndActive(), Is.SameAs(held)); + } + + [Test] + public void RestartReturnPolicyResetsTimelineAndInstanceState() + { + var held = Create(Cue("held", CuePriority.Status, returnPolicy: CueReturnPolicy.Restart)); + held.ElapsedFrames = 42; + held.State.EnsureLayerCount(1); + held.State.NumberTweens[0].Initialized = true; + _scheduler.Admit(held); + _scheduler.Admit(Create(Cue("system", CuePriority.System))); + + Assert.That(_scheduler.EndActive(), Is.SameAs(held)); + Assert.That(held.ElapsedFrames, Is.Zero); + Assert.That(held.State.NumberTweens, Is.Empty); + } + + [Test] + public void DiscardReturnPolicyIsDroppedDuringDrain() + { + var held = Create(Cue("held", CuePriority.Status, returnPolicy: CueReturnPolicy.Discard)); + _scheduler.Admit(held); + _scheduler.Admit(Create(Cue("system", CuePriority.System))); + + Assert.That(_scheduler.EndActive(), Is.Null); + Assert.That(_scheduler.TryFind(held.Handle, out _), Is.False); + } + + private CueInstance Create(DmdCueAsset cue, DmdParams parameters = null) + { + return _scheduler.Create(cue, parameters ?? new DmdParams()); + } + + private DmdCueAsset Cue(string id, CuePriority priority, + CueInterruptPolicy interrupt = CueInterruptPolicy.Replace, string key = null, + CueReturnPolicy returnPolicy = CueReturnPolicy.Resume) + { + var cue = ScriptableObject.CreateInstance(); + cue.CueId = id; + cue.Priority = priority; + cue.Interrupt = interrupt; + cue.CoalesceKey = key; + cue.Return = returnPolicy; + _assets.Add(cue); + return cue; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueSchedulerTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueSchedulerTests.cs.meta new file mode 100644 index 000000000..2a3c835ea --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/CueSchedulerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6850874fcc5585e4f924e27fb992ec67 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs index 2f57212bd..dc08e3b3e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdBlitterTests.cs @@ -18,7 +18,8 @@ public void BlendModesUseTheSpecifiedIntegerEquations() { var source = Bitmap(1, 1, 200, 128); - Assert.That(Blend(100, source, DmdBlendMode.Opaque, 0), Is.EqualTo(200)); + Assert.That(Blend(100, source, DmdBlendMode.Opaque, 255), Is.EqualTo(200)); + Assert.That(Blend(100, source, DmdBlendMode.Opaque, 128), Is.EqualTo(150)); Assert.That(Blend(100, source, DmdBlendMode.Alpha, 255), Is.EqualTo(150)); Assert.That(Blend(100, source, DmdBlendMode.Add, 255), Is.EqualTo(200)); Assert.That(Blend(100, Bitmap(1, 1, 0, 255), DmdBlendMode.Invert, 128), Is.EqualTo(155)); @@ -35,7 +36,7 @@ public void ClipsNegativeCoordinatesAndTintsI8IntoRgb() Pixels = new byte[] { 255, 128 } }; - DmdBlitter.Blit(surface, source, -1, 0, DmdBlendMode.Opaque, 0, + DmdBlitter.Blit(surface, source, -1, 0, DmdBlendMode.Opaque, byte.MaxValue, new Color32(255, 128, 0, 255)); Assert.That(surface.Data, Is.EqualTo(new byte[] { 128, 64, 0, 0, 0, 0, 0, 0, 0 })); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs new file mode 100644 index 000000000..8164fe161 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs @@ -0,0 +1,530 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace VisualPinball.Unity.Test +{ + public class DmdCuePlayerTests + { + private readonly List _cues = new List(); + private DmdProjectAsset _project; + private RecordingSink _sink; + + [SetUp] + public void SetUp() + { + _project = ScriptableObject.CreateInstance(); + _project.DisplayId = "studio"; + _project.Width = 4; + _project.Height = 2; + _project.FrameRate = 10; + _project.ColorMode = DmdColorMode.Mono16; + _sink = new RecordingSink(); + } + + [TearDown] + public void TearDown() + { + foreach (var cue in _cues) { + Object.DestroyImmediate(cue); + } + Object.DestroyImmediate(_project); + } + + [Test] + public void StartIsIdempotentAndAnnouncesThenEmitsConfiguredBase() + { + var cue = Cue("base", CuePriority.Base, 0, 255); + using (var player = new DmdCuePlayer(_project, _sink)) { + player.SetBase(cue.EffectiveId); + player.Start(); + player.Start(); + + Assert.That(_sink.Requests, Has.Count.EqualTo(1)); + Assert.That(_sink.Frames, Has.Count.EqualTo(1)); + Assert.That(_sink.Frames[0].Format, Is.EqualTo(DisplayFrameFormat.Dmd4)); + Assert.That(_sink.Snapshots[0][0], Is.EqualTo(15)); + } + Assert.That(_sink.Clears, Is.EqualTo(new[] { "studio" })); + } + + [Test] + public void StaticContentEmitsOnlyOnceWhileItsLifecycleStillAdvances() + { + var cue = Cue("hold", CuePriority.Award, 0, 200); + using (var player = new DmdCuePlayer(_project, _sink)) { + player.Play(cue.EffectiveId); + player.Start(); + player.Tick(0d); + player.Tick(1d); + + Assert.That(_sink.Frames, Has.Count.EqualTo(1)); + } + } + + [Test] + public void HitchAdvancesEveryLifecycleEventButRendersOnlyNewestFrame() + { + var first = Cue("first", CuePriority.Award, 1, 100); + var second = Cue("second", CuePriority.Award, 1, 200, CueInterruptPolicy.Queue); + using (var player = new DmdCuePlayer(_project, _sink)) { + var finished = new List(); + player.OnCueFinished += (_, handle) => finished.Add(handle); + var firstHandle = player.Play(first.EffectiveId); + var secondHandle = player.Play(second.EffectiveId); + player.Start(); + player.Tick(0d); + player.Tick(1d); + + Assert.That(finished, Is.EqualTo(new[] { firstHandle, secondHandle })); + Assert.That(_sink.Frames, Has.Count.EqualTo(2)); + } + } + + [Test] + public void ActiveStopFiresFinishedOnlyAfterExitTransitionCompletes() + { + var cue = Cue("exit", CuePriority.Award, 0, 255); + cue.ExitTransition = new DmdTransitionSpec { + Type = DmdTransitionType.FadeThroughBlack, DurationFrames = 2 + }; + using (var player = new DmdCuePlayer(_project, _sink)) { + var finished = 0; + player.OnCueFinished += (_, __) => finished++; + var handle = player.Play(cue.EffectiveId); + player.Start(); + player.Tick(0d); + Assert.That(player.StopCue(handle), Is.True); + player.Tick(0.1d); + Assert.That(finished, Is.Zero); + Assert.That(player.UpdateCue(handle, new DmdParams().Set("duringExit", 1)), Is.True); + player.Tick(0.2d); + Assert.That(finished, Is.EqualTo(1)); + Assert.That(player.UpdateCue(handle, new DmdParams().Set("afterExit", 1)), Is.False); + } + } + + [Test] + public void StoppingQueuedCueFinishesImmediatelyAndMakesItsHandleStale() + { + var active = Cue("active", CuePriority.Mode, 0, 100); + var queued = Cue("queued", CuePriority.Status, 0, 200); + using (var player = new DmdCuePlayer(_project, _sink)) { + player.Play(active.EffectiveId); + var handle = player.Play(queued.EffectiveId); + CueHandle finished = default; + player.OnCueFinished += (_, value) => finished = value; + + Assert.That(player.StopCue(handle), Is.True); + Assert.That(finished, Is.EqualTo(handle)); + Assert.That(player.StopCue(handle), Is.False); + } + } + + [Test] + public void CoalescedPlayMergesParametersWithoutRestartingOrChangingHandle() + { + var cue = Cue("award", CuePriority.Award, 0, 255); + cue.CoalesceKey = "jackpot"; + using (var player = new DmdCuePlayer(_project, _sink)) { + var first = player.Play(cue.EffectiveId, new DmdParams().Set("value", 1)); + var second = player.Play(cue.EffectiveId, new DmdParams().Set("value", 2)); + + Assert.That(second, Is.EqualTo(first)); + Assert.That(player.UpdateCue(first, new DmdParams().Set("other", 3)), Is.True); + } + } + + [Test] + public void CoalescedPlayDoesNotAbandonAnEnterTransition() + { + var cue = Cue("coalesced-transition", CuePriority.Award, 0, 255); + cue.CoalesceKey = "jackpot"; + cue.EnterTransition = new DmdTransitionSpec { + Type = DmdTransitionType.WipeOn, + Direction = DmdDirection.Right, + DurationFrames = 4 + }; + using (var player = new DmdCuePlayer(_project, _sink)) { + var handle = player.Play(cue.EffectiveId, new DmdParams().Set("value", 1)); + player.Start(); + player.Tick(0d); + player.Tick(0.1d); + + Assert.That(player.Play(cue.EffectiveId, new DmdParams().Set("value", 2)), Is.EqualTo(handle)); + player.Tick(0.2d); + + Assert.That(_sink.Snapshots[_sink.Snapshots.Count - 1], + Is.EqualTo(new byte[] { 15, 15, 0, 0, 15, 15, 0, 0 })); + } + } + + [Test] + public void QueuedPlayDoesNotAbandonExitOrFinishOutgoingEarly() + { + var outgoing = Cue("outgoing", CuePriority.Mode, 0, 255); + outgoing.ExitTransition = new DmdTransitionSpec { + Type = DmdTransitionType.FadeThroughBlack, + DurationFrames = 4 + }; + var successor = Cue("successor", CuePriority.Status, 0, 128); + var queued = Cue("queued-during-exit", CuePriority.Base, 0, 64, CueInterruptPolicy.Queue); + using (var player = new DmdCuePlayer(_project, _sink)) { + var outgoingHandle = player.Play(outgoing.EffectiveId); + player.Play(successor.EffectiveId); + player.Start(); + player.Tick(0d); + var finished = new List(); + player.OnCueFinished += (_, handle) => finished.Add(handle); + + Assert.That(player.StopCue(outgoingHandle), Is.True); + player.Play(queued.EffectiveId); + Assert.That(finished, Is.Empty); + player.Tick(0.1d); + Assert.That(finished, Is.Empty); + player.Tick(0.4d); + + Assert.That(finished, Is.EqualTo(new[] { outgoingHandle })); + } + } + + [Test] + public void ShortSuccessorStillFinishesOnTimeBehindALongerOutgoingExit() + { + var outgoing = Cue("long-exit", CuePriority.Mode, 0, 255); + outgoing.ExitTransition = new DmdTransitionSpec { + Type = DmdTransitionType.FadeThroughBlack, + DurationFrames = 4 + }; + var shortSuccessor = Cue("short-successor", CuePriority.Status, 2, 128); + var final = Cue("final", CuePriority.Base, 0, 64); + using (var player = new DmdCuePlayer(_project, _sink)) { + var outgoingHandle = player.Play(outgoing.EffectiveId); + var shortHandle = player.Play(shortSuccessor.EffectiveId); + player.Play(final.EffectiveId); + player.Start(); + player.Tick(0d); + var finished = new List(); + player.OnCueFinished += (_, handle) => finished.Add(handle); + + player.StopCue(outgoingHandle); + player.Tick(0.1d); + Assert.That(finished, Is.Empty); + player.Tick(0.2d); + Assert.That(finished, Is.EqualTo(new[] { shortHandle })); + player.Tick(0.4d); + Assert.That(finished, Is.EqualTo(new[] { shortHandle, outgoingHandle })); + } + } + + [Test] + public void ExitThatConsumesTheWholeFiniteLifetimeStartsAtFrameZero() + { + var cue = Cue("whole-life-exit", CuePriority.Award, 4, 255); + cue.ExitTransition = new DmdTransitionSpec { + Type = DmdTransitionType.FadeThroughBlack, + DurationFrames = 4 + }; + using (var player = new DmdCuePlayer(_project, _sink)) { + var finished = 0; + player.OnCueFinished += (_, __) => finished++; + player.Play(cue.EffectiveId); + player.Start(); + player.Tick(0d); + player.Tick(0.3d); + Assert.That(finished, Is.Zero); + player.Tick(0.4d); + Assert.That(finished, Is.EqualTo(1)); + } + } + + [Test] + public void SuppressedExitUsesSuccessorEnterAfterOwningTransitionHasFinished() + { + var outgoing = Cue("owning-exit", CuePriority.Mode, 0, 255); + outgoing.ExitTransition = new DmdTransitionSpec { + Type = DmdTransitionType.FadeThroughBlack, + DurationFrames = 2 + }; + var suppressed = Cue("suppressed-exit", CuePriority.Status, 3, 128); + suppressed.ExitTransition = new DmdTransitionSpec { + Type = DmdTransitionType.FadeThroughBlack, + DurationFrames = 2 + }; + var final = Cue("enter-after-suppressed", CuePriority.Base, 0, 64); + final.EnterTransition = new DmdTransitionSpec { + Type = DmdTransitionType.WipeOn, + Direction = DmdDirection.Right, + DurationFrames = 2 + }; + using (var player = new DmdCuePlayer(_project, _sink)) { + var outgoingHandle = player.Play(outgoing.EffectiveId); + player.Play(suppressed.EffectiveId); + player.Play(final.EffectiveId); + player.Start(); + player.Tick(0d); + player.StopCue(outgoingHandle); + player.Tick(0.2d); + + player.Tick(0.3d); + + Assert.That(_sink.Snapshots[_sink.Snapshots.Count - 1], + Is.EqualTo(new byte[] { 8, 8, 8, 8, 8, 8, 8, 8 })); + player.Tick(0.5d); + Assert.That(_sink.Snapshots[_sink.Snapshots.Count - 1], + Is.EqualTo(new byte[] { 4, 4, 4, 4, 4, 4, 4, 4 })); + } + } + + [Test] + public void AnimatedTicksReuseDisplayFrameAndDataIdentities() + { + var cue = Cue("animated", CuePriority.Award, 0, 255); + cue.Layers[0].Tracks.Add(new DmdPropertyTrack { + Property = DmdAnimatableProperty.X, + Keys = { + new DmdKeyframe { Frame = 0, Value = 0 }, + new DmdKeyframe { Frame = 10, Value = 1, Interp = DmdInterpolation.Linear } + } + }); + using (var player = new DmdCuePlayer(_project, _sink)) { + player.Play(cue.EffectiveId); + player.Start(); + player.Tick(0d); + player.Tick(0.1d); + player.Tick(0.2d); + + Assert.That(_sink.Frames, Has.Count.EqualTo(3)); + Assert.That(_sink.Frames[1], Is.SameAs(_sink.Frames[0])); + Assert.That(_sink.Frames[2].Data, Is.SameAs(_sink.Frames[0].Data)); + } + } + + [Test] + public void FormatRequestsRespectColorModeAndRebuildOnlyWhenAccepted() + { + Cue("base", CuePriority.Base, 0, 255); + using (var player = new DmdCuePlayer(_project, _sink)) { + player.RequestFormat(DisplayFrameFormat.Dmd2); + player.RequestFormat(DisplayFrameFormat.Dmd8); + player.Start(); + Assert.That(_sink.Frames[0].Format, Is.EqualTo(DisplayFrameFormat.Dmd8)); + } + } + + [Test] + public void TickReannouncesOnceAfterDelay() + { + using (var player = new DmdCuePlayer(_project, _sink)) { + player.Start(); + player.Tick(10d); + player.Tick(11.99d); + player.Tick(12d); + player.Tick(20d); + + Assert.That(_sink.Requests, Has.Count.EqualTo(2)); + } + } + + [TestCase(true, 1)] + [TestCase(false, 0)] + public void LoopAndZeroDurationCuesRemainActiveUntilExplicitlyStopped(bool loop, int duration) + { + var cue = Cue("indefinite", CuePriority.Award, duration, 255); + cue.Loop = loop; + using (var player = new DmdCuePlayer(_project, _sink)) { + var finished = 0; + player.OnCueFinished += (_, __) => finished++; + var handle = player.Play(cue.EffectiveId); + player.Start(); + player.Tick(0d); + player.Tick(10d); + Assert.That(finished, Is.Zero); + Assert.That(player.StopCue(handle), Is.True); + Assert.That(finished, Is.EqualTo(1)); + } + } + + [Test] + public void StopAllFinishesWaitingWorkThenActiveAndReturnsToBase() + { + var baseCue = Cue("base", CuePriority.Base, 0, 32); + var held = Cue("held", CuePriority.Status, 0, 64); + var active = Cue("active", CuePriority.Mode, 0, 128); + var queued = Cue("queued", CuePriority.Status, 0, 255, CueInterruptPolicy.Queue); + using (var player = new DmdCuePlayer(_project, _sink)) { + player.SetBase(baseCue.EffectiveId); + player.Play(held.EffectiveId); + player.Play(active.EffectiveId); + player.Play(queued.EffectiveId); + var finished = new List(); + player.OnCueFinished += (_, handle) => finished.Add(handle); + player.Start(); + + player.StopAll(); + player.Tick(0d); + player.Tick(0.1d); + + Assert.That(finished, Has.Count.EqualTo(3)); + Assert.That(_sink.Snapshots[_sink.Snapshots.Count - 1][0], Is.EqualTo(2)); + } + } + + [Test] + public void EarlierTickTimeResetsOriginWithoutRepeatingFinishedEvents() + { + var cue = Cue("finite", CuePriority.Award, 1, 255); + using (var player = new DmdCuePlayer(_project, _sink)) { + var finished = 0; + player.OnCueFinished += (_, __) => finished++; + player.Play(cue.EffectiveId); + player.Start(); + player.Tick(5d); + player.Tick(5.1d); + player.Tick(1d); + player.Tick(1.1d); + + Assert.That(finished, Is.EqualTo(1)); + } + } + + [Test] + public void AnimatedTickAndEmissionHaveNoSteadyStateManagedAllocations() + { + var cue = Cue("allocation", CuePriority.Award, 2, 255); + cue.Loop = true; + cue.Layers[0].Tracks.Add(new DmdPropertyTrack { + Property = DmdAnimatableProperty.X, + Keys = { + new DmdKeyframe { Frame = 0, Value = 0, Interp = DmdInterpolation.Linear }, + new DmdKeyframe { Frame = 1, Value = 1 } + } + }); + var sink = new AllocationSink(); + using (var player = new DmdCuePlayer(_project, sink)) { + player.Play(cue.EffectiveId); + player.Start(); + player.Tick(0d); + player.Tick(0.1d); + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var tick = 2; tick <= 101; tick++) { + player.Tick(tick / 10d); + } + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.That(allocated, Is.Zero); + } + } + + [Test] + public void InvalidTimeAndDisposedCallsFollowThePublicContract() + { + var player = new DmdCuePlayer(_project, _sink); + Assert.Throws(() => player.Tick(double.NaN)); + player.Dispose(); + player.Dispose(); + Assert.Throws(() => player.Start()); + } + + [Test] + public void FinishedHandlerMayDisposeWithoutBreakingTheCompletingTick() + { + var cue = Cue("dispose-handler", CuePriority.Award, 1, 255); + var player = new DmdCuePlayer(_project, _sink); + player.OnCueFinished += (_, __) => player.Dispose(); + player.Play(cue.EffectiveId); + player.Start(); + player.Tick(0d); + + Assert.DoesNotThrow(() => player.Tick(0.1d)); + Assert.That(_sink.Clears, Is.EqualTo(new[] { "studio" })); + } + + [Test] + public void ValidationHandlerMayDisposeAfterTheDiagnosticFrameIsEmitted() + { + var font = DmdTestFont.Create(); + try { + _project.Fonts.Add(font); + var cue = Cue("validation-handler", CuePriority.Award, 0, 0); + cue.Layers.Add(new TextLayer { Font = font, Text = "{missing}" }); + var player = new DmdCuePlayer(_project, _sink); + player.OnValidationError += (_, __) => player.Dispose(); + player.Play(cue.EffectiveId); + + Assert.DoesNotThrow(() => player.Start()); + Assert.That(_sink.Frames, Has.Count.EqualTo(1)); + Assert.That(_sink.Clears, Is.EqualTo(new[] { "studio" })); + } finally { + Object.DestroyImmediate(font); + } + } + + [Test] + public void UnknownCueReportsOnceAndLeavesBaseUnchanged() + { + using (var player = new DmdCuePlayer(_project, _sink)) { + var errors = new List(); + player.OnValidationError += (_, error) => errors.Add(error); + player.SetBase("missing"); + Assert.That(player.Play("missing").IsValid, Is.False); + Assert.That(errors, Has.Count.EqualTo(1)); + } + } + + private DmdCueAsset Cue(string id, CuePriority priority, int duration, byte intensity, + CueInterruptPolicy interrupt = CueInterruptPolicy.Replace) + { + var cue = ScriptableObject.CreateInstance(); + cue.CueId = id; + cue.Priority = priority; + cue.Interrupt = interrupt; + cue.DurationFrames = duration; + cue.Layers.Add(new ShapeLayer { + Shape = DmdShapeType.Rect, + Width = _project.Width, + Height = _project.Height, + Filled = true, + Shade = new DmdShade { Intensity = intensity } + }); + _project.Cues.Add(cue); + _cues.Add(cue); + return cue; + } + + private sealed class RecordingSink : IDmdFrameSink + { + public readonly List Requests = new List(); + public readonly List Frames = new List(); + public readonly List Snapshots = new List(); + public readonly List Clears = new List(); + + public void RequestDisplays(RequestedDisplays displays) => Requests.Add(displays); + + public void UpdateFrame(DisplayFrameData frame) + { + Frames.Add(frame); + Snapshots.Add((byte[])frame.Data.Clone()); + } + + public void Clear(string displayId) => Clears.Add(displayId); + } + + private sealed class AllocationSink : IDmdFrameSink + { + public void RequestDisplays(RequestedDisplays displays) { } + public void UpdateFrame(DisplayFrameData frame) { } + public void Clear(string displayId) { } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs.meta new file mode 100644 index 000000000..a96e39580 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 099e826a7c7562646afedfb4cb17f371 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs index 36454a02d..4078d2c41 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdTransitionTests.cs @@ -43,7 +43,7 @@ public void EveryTransitionHasExactEndpoints(DmdTransitionType type) [TestCase(DmdTransitionType.Cover, new byte[] { 1, 2, 5, 6 })] [TestCase(DmdTransitionType.Uncover, new byte[] { 3, 4, 7, 8 })] [TestCase(DmdTransitionType.WipeOn, new byte[] { 1, 2, 7, 8 })] - [TestCase(DmdTransitionType.ScrollOff, new byte[] { 3, 4, 7, 8 })] + [TestCase(DmdTransitionType.ScrollOff, new byte[] { 3, 4, 0, 0 })] public void HorizontalMotionUsesRoundedIntegerOffsets(DmdTransitionType type, byte[] expected) { var from = Surface(4, 1, 1, 2, 3, 4); @@ -96,7 +96,7 @@ public void FadeThroughBlackIsBlackAtItsMidpoint() [TestCase(DmdTransitionType.SplitOut, 1911098821u)] [TestCase(DmdTransitionType.Dissolve, 661240773u)] [TestCase(DmdTransitionType.FadeThroughBlack, 1924397413u)] - [TestCase(DmdTransitionType.ScrollOff, 872900293u)] + [TestCase(DmdTransitionType.ScrollOff, 1717662533u)] public void FullSizeTransitionHasStableGoldenHash(DmdTransitionType type, uint expected) { var from = Pattern(128, 32, 17, 3); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/SpriteFrameClockTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/SpriteFrameClockTests.cs new file mode 100644 index 000000000..76c14ecf7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/SpriteFrameClockTests.cs @@ -0,0 +1,69 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using NUnit.Framework; +using UnityEngine; + +namespace VisualPinball.Unity.Test +{ + public class SpriteFrameClockTests + { + private DmdSpriteAsset _sprite; + + [SetUp] + public void SetUp() + { + _sprite = ScriptableObject.CreateInstance(); + for (var index = 0; index < 3; index++) { + _sprite.Frames.Add(new DmdBitmapData { Width = 1, Height = 1, Pixels = new[] { (byte)index } }); + } + _sprite.FrameDurations.AddRange(new[] { 1, 2, 1 }); + } + + [TearDown] + public void TearDown() => Object.DestroyImmediate(_sprite); + + [Test] + public void OnceBecomesInvisibleAfterTheLastFrame() + { + AssertFrame(DmdLoopMode.Once, 0, 0); + AssertFrame(DmdLoopMode.Once, 1, 1); + AssertFrame(DmdLoopMode.Once, 2, 1); + AssertFrame(DmdLoopMode.Once, 3, 2); + Assert.That(SpriteFrameClock.TryGetFrame(_sprite, 4, DmdLoopMode.Once, 0, out _), Is.False); + } + + [Test] + public void HoldLastStaysOnTheLastFrame() + { + AssertFrame(DmdLoopMode.HoldLast, 100, 2); + } + + [Test] + public void LoopRepeatsDurations() + { + AssertFrame(DmdLoopMode.Loop, 4, 0); + AssertFrame(DmdLoopMode.Loop, 6, 1); + } + + [Test] + public void PingPongDoesNotDuplicateEndpoints() + { + var expected = new[] { 0, 1, 1, 2, 1, 1 }; + for (var tick = 0; tick < expected.Length; tick++) { + AssertFrame(DmdLoopMode.PingPong, tick, expected[tick]); + } + } + + private void AssertFrame(DmdLoopMode mode, int tick, int expected) + { + Assert.That(SpriteFrameClock.TryGetFrame(_sprite, tick, mode, 0, out var actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/SpriteFrameClockTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/SpriteFrameClockTests.cs.meta new file mode 100644 index 000000000..9fc508e3b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/SpriteFrameClockTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 500accd7f02ff584e972110f29b08d37 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs index 74ff7007d..896cf57af 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueDiagnostics.cs @@ -78,13 +78,22 @@ internal void MalformedFont(string detail) } } - internal void MalformedGlyph(int codepoint) + internal void MalformedGlyph(int codepoint) { if (_keys.Add(new DiagnosticKey(DiagnosticKind.MalformedGlyph, number: codepoint))) { _diagnostics.Add(new CueDiagnostic("font.malformed", $"Glyph U+{codepoint:X4} lies outside the font atlas.")); } - } + } + + internal void AuthoredContent(string code, string detail) + { + code = string.IsNullOrEmpty(code) ? "content.invalid" : code; + detail = detail ?? "Authored DMD content is invalid."; + if (_keys.Add(new DiagnosticKey(DiagnosticKind.AuthoredContent, code, detail))) { + _diagnostics.Add(new CueDiagnostic(code, detail)); + } + } private enum DiagnosticKind { @@ -93,7 +102,8 @@ private enum DiagnosticKind TextTruncated, MissingGlyph, MalformedFont, - MalformedGlyph, + MalformedGlyph, + AuthoredContent, } private readonly struct DiagnosticKey : IEquatable diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueRenderer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueRenderer.cs new file mode 100644 index 000000000..72a7f5d48 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueRenderer.cs @@ -0,0 +1,459 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Globalization; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public sealed class CueRenderer + { + private readonly DmdProjectAsset _project; + + public CueRenderer(DmdProjectAsset project) + { + _project = project ?? throw new ArgumentNullException(nameof(project)); + } + + public void Render(DmdSurface destination, DmdCueAsset cue, int frame, DmdParams parameters, + CueInstanceState state, CueDiagnostics diagnostics) + { + if (destination == null) { + throw new ArgumentNullException(nameof(destination)); + } + if (cue == null) { + throw new ArgumentNullException(nameof(cue)); + } + if (state == null) { + throw new ArgumentNullException(nameof(state)); + } + if (frame < 0) { + throw new ArgumentOutOfRangeException(nameof(frame)); + } + var expectedFormat = _project.ColorMode == DmdColorMode.Rgb24 + ? DmdPixelFormat.Rgb24 + : DmdPixelFormat.I8; + if (destination.Width != _project.Width || destination.Height != _project.Height || + destination.Format != expectedFormat) { + throw new ArgumentException("The destination surface does not match the DMD project.", nameof(destination)); + } + + destination.Clear(); + if (cue.Layers == null) { + diagnostics?.AuthoredContent("cue.layers.null", "Cue layer collection is missing."); + return; + } + state.EnsureLayerCount(cue.Layers.Count); + for (var layerIndex = 0; layerIndex < cue.Layers.Count; layerIndex++) { + var layer = cue.Layers[layerIndex]; + if (layer == null) { + diagnostics?.AuthoredContent("cue.layer.null", $"Layer {layerIndex} is missing."); + continue; + } + try { + RenderLayer(destination, layer, layerIndex, frame, parameters, state, diagnostics); + } catch (ArgumentException exception) { + diagnostics?.AuthoredContent("layer.malformed", $"Layer {layerIndex}: {exception.Message}"); + } catch (IndexOutOfRangeException exception) { + diagnostics?.AuthoredContent("layer.malformed", $"Layer {layerIndex}: {exception.Message}"); + } catch (OverflowException exception) { + diagnostics?.AuthoredContent("layer.malformed", $"Layer {layerIndex}: {exception.Message}"); + } + } + } + + internal bool IsAnimated(DmdCueAsset cue, int frame, CueInstanceState state) + { + if (cue?.Layers == null) { + return false; + } + for (var layerIndex = 0; layerIndex < cue.Layers.Count; layerIndex++) { + var layer = cue.Layers[layerIndex]; + if (layer == null || !layer.Visible) { + continue; + } + if (cue.Loop && cue.DurationFrames > 0 && LayerChangesOverTime(layer)) { + return true; + } + if (frame == layer.StartFrame || layer.EndFrame > 0 && frame == layer.EndFrame) { + return true; + } + if (layer.Tracks != null) { + foreach (var track in layer.Tracks) { + if (track?.Keys != null && track.Keys.Count > 1 && + frame <= track.Keys[track.Keys.Count - 1].Frame) { + return true; + } + } + } + if (layer is NumberLayer number && NumberIsAnimating(number, layerIndex, frame, state) || + layer is TextLayer text && text.Overflow == DmdOverflow.Marquee && text.MarqueeSpeed > 0 || + layer is BitmapLayer bitmap && SpriteIsAnimating(bitmap.Sprite, bitmap.Loop, + frame - layer.StartFrame) || + layer is MaskLayer mask && SpriteIsAnimating(mask.Mask, mask.Loop, + frame - layer.StartFrame)) { + return true; + } + } + return false; + } + + private void RenderLayer(DmdSurface destination, DmdLayer layer, int layerIndex, int frame, + DmdParams parameters, CueInstanceState state, CueDiagnostics diagnostics) + { + if (!layer.Visible || frame < layer.StartFrame || layer.EndFrame != 0 && frame >= layer.EndFrame) { + return; + } + + var x = EvaluateInt(layer, DmdAnimatableProperty.X, layer.X, frame, diagnostics); + var y = EvaluateInt(layer, DmdAnimatableProperty.Y, layer.Y, frame, diagnostics); + var opacity = ToOpacity(Evaluate(layer, DmdAnimatableProperty.Opacity, layer.Opacity, frame, diagnostics)); + if (opacity == 0) { + return; + } + + switch (layer) { + case NumberLayer number: + RenderNumber(destination, number, layerIndex, x, y, opacity, frame, parameters, state, diagnostics); + break; + case TextLayer text: + RenderText(destination, text, layerIndex, x, y, opacity, frame, parameters, state, diagnostics); + break; + case BitmapLayer bitmap: + RenderBitmap(destination, bitmap, x, y, opacity, frame, diagnostics); + break; + case MaskLayer mask: + RenderMask(destination, mask, x, y, opacity, frame, diagnostics); + break; + case ShapeLayer shape: + RenderShape(destination, shape, x, y, opacity); + break; + default: + diagnostics?.AuthoredContent("layer.type", $"Unsupported layer type {layer.GetType().FullName}."); + break; + } + } + + private static void RenderBitmap(DmdSurface destination, BitmapLayer layer, int x, int y, + byte opacity, int frame, CueDiagnostics diagnostics) + { + if (!TryGetSpriteFrame(layer.Sprite, layer, frame, layer.Loop, layer.SpriteStartFrame, + out var bitmap, diagnostics)) { + return; + } + DmdBlitter.Blit(destination, bitmap, x, y, layer.Blend, opacity, layer.Tint); + } + + private static void RenderMask(DmdSurface destination, MaskLayer layer, int x, int y, + byte opacity, int frame, CueDiagnostics diagnostics) + { + if (!TryGetSpriteFrame(layer.Mask, layer, frame, layer.Loop, layer.SpriteStartFrame, + out var bitmap, diagnostics)) { + return; + } + DmdBlitter.ApplyAlphaMask(destination, bitmap, x, y, opacity); + } + + private static bool TryGetSpriteFrame(DmdSpriteAsset sprite, DmdLayer layer, int frame, + DmdLoopMode loop, int startFrame, out DmdBitmapData bitmap, CueDiagnostics diagnostics) + { + bitmap = null; + if (sprite?.Frames == null || sprite.Frames.Count == 0) { + diagnostics?.AuthoredContent("sprite.missing", "Layer sprite is missing or has no frames."); + return false; + } + int frameIndex; + if (TryEvaluate(layer, DmdAnimatableProperty.SpriteFrame, frame, diagnostics, out var trackedFrame)) { + frameIndex = math.clamp((int)math.round(trackedFrame), 0, sprite.Frames.Count - 1); + } else if (!SpriteFrameClock.TryGetFrame(sprite, frame - layer.StartFrame, loop, startFrame, + out frameIndex)) { + return false; + } + bitmap = sprite.Frames[frameIndex]; + if (!IsReadable(bitmap)) { + diagnostics?.AuthoredContent("sprite.frame.malformed", $"Sprite frame {frameIndex} is malformed."); + bitmap = null; + return false; + } + return true; + } + + private void RenderText(DmdSurface destination, TextLayer layer, int layerIndex, int x, int y, + byte opacity, int frame, DmdParams parameters, CueInstanceState state, CueDiagnostics diagnostics) + { + if (!ReferenceEquals(state.TextTemplates[layerIndex], layer.Text) && + !string.Equals(state.TextTemplates[layerIndex], layer.Text, StringComparison.Ordinal)) { + state.TextTemplates[layerIndex] = layer.Text; + state.BoundTexts[layerIndex] = BoundText.Parse(layer.Text); + state.TextInitialized[layerIndex] = false; + } + var bound = state.BoundTexts[layerIndex]; + var version = bound.Version(parameters); + if (!state.TextInitialized[layerIndex] || state.TextVersions[layerIndex] != version) { + state.ResolvedTexts[layerIndex] = bound.Resolve(parameters, diagnostics); + state.TextVersions[layerIndex] = version; + state.TextInitialized[layerIndex] = true; + } + DrawText(destination, layer, state.ResolvedTexts[layerIndex], x, y, opacity, frame, diagnostics); + } + + private void RenderNumber(DmdSurface destination, NumberLayer layer, int layerIndex, int x, int y, + byte opacity, int frame, DmdParams parameters, CueInstanceState state, CueDiagnostics diagnostics) + { + if (parameters == null || string.IsNullOrEmpty(layer.ParamName) || + !parameters.TryGet(layer.ParamName, out var parameter) || + parameter.Type != DmdParamType.Integer && parameter.Type != DmdParamType.Float) { + diagnostics?.MissingParameter(layer.ParamName ?? string.Empty); + return; + } + var target = parameter.Type == DmdParamType.Integer ? parameter.IntValue : parameter.FloatValue; + var tweenTicks = math.max(0, (int)math.round(layer.CountUpSeconds * _project.FrameRate)); + ref var tween = ref state.NumberTweens[layerIndex]; + if (!tween.Initialized) { + tween.StartValue = tweenTicks == 0 ? target : 0d; + tween.TargetValue = target; + tween.StartTick = frame; + tween.Initialized = true; + } else if (!target.Equals(tween.TargetValue)) { + tween.StartValue = CurrentValue(tween, tweenTicks, frame); + tween.TargetValue = target; + tween.StartTick = frame; + } + var value = CurrentValue(tween, tweenTicks, frame); + var buffer = state.NumberBuffers[layerIndex] ?? + (state.NumberBuffers[layerIndex] = new char[DmdValidation.MaxResolvedTextLength]); + int length; + try { + if (!value.TryFormat(buffer.AsSpan(), out length, layer.Format, CultureInfo.InvariantCulture)) { + diagnostics?.TextTruncated(); + if (!value.TryFormat(buffer.AsSpan(), out length, default, CultureInfo.InvariantCulture)) { + length = 0; + } + } + } catch (FormatException) { + diagnostics?.InvalidFormat(layer.ParamName, layer.Format); + value.TryFormat(buffer.AsSpan(), out length, default, CultureInfo.InvariantCulture); + } + DmdTextRenderer.Draw(destination, layer.Font, buffer, length, x, y, layer.Anchor, layer.Effect, + layer.Shade, layer.OutlineShade, layer.Blend, opacity, diagnostics); + } + + private void DrawText(DmdSurface destination, TextLayer layer, string text, int x, int y, + byte opacity, int frame, CueDiagnostics diagnostics) + { + if (layer.Overflow != DmdOverflow.Marquee || layer.MarqueeSpeed <= 0 || layer.Font == null || + DmdTextRenderer.Measure(layer.Font, text) <= destination.Width) { + DmdTextRenderer.Draw(destination, layer.Font, text, x, y, layer.Anchor, layer.Effect, + layer.Shade, layer.OutlineShade, layer.Blend, opacity, diagnostics); + return; + } + var textWidth = DmdTextRenderer.Measure(layer.Font, text); + var period = math.max(1, textWidth + destination.Width / 4); + var offset = (int)((long)frame * layer.MarqueeSpeed / math.max(1, _project.FrameRate) % period); + var originX = ResolveTextLeft(x, textWidth, layer.Anchor); + var leftAnchor = ToLeftAnchor(layer.Anchor); + DmdTextRenderer.Draw(destination, layer.Font, text, originX - offset, y, leftAnchor, layer.Effect, + layer.Shade, layer.OutlineShade, layer.Blend, opacity, diagnostics); + DmdTextRenderer.Draw(destination, layer.Font, text, originX - offset + period, y, leftAnchor, + layer.Effect, layer.Shade, layer.OutlineShade, layer.Blend, opacity, diagnostics); + } + + private static int ResolveTextLeft(int x, int width, DmdAnchor anchor) + { + switch (anchor) { + case DmdAnchor.TopCenter: + case DmdAnchor.MiddleCenter: + case DmdAnchor.BottomCenter: + case DmdAnchor.BaselineCenter: + return x - width / 2; + case DmdAnchor.TopRight: + case DmdAnchor.MiddleRight: + case DmdAnchor.BottomRight: + case DmdAnchor.BaselineRight: + return x - width; + default: + return x; + } + } + + private static DmdAnchor ToLeftAnchor(DmdAnchor anchor) + { + switch (anchor) { + case DmdAnchor.MiddleLeft: + case DmdAnchor.MiddleCenter: + case DmdAnchor.MiddleRight: + return DmdAnchor.MiddleLeft; + case DmdAnchor.BottomLeft: + case DmdAnchor.BottomCenter: + case DmdAnchor.BottomRight: + return DmdAnchor.BottomLeft; + case DmdAnchor.BaselineLeft: + case DmdAnchor.BaselineCenter: + case DmdAnchor.BaselineRight: + return DmdAnchor.BaselineLeft; + default: + return DmdAnchor.TopLeft; + } + } + + private static void RenderShape(DmdSurface destination, ShapeLayer layer, int x, int y, byte opacity) + { + switch (layer.Shape) { + case DmdShapeType.Dot: + DmdBlitter.FillRect(destination, x, y, 1, 1, layer.Shade, layer.Blend, opacity); + break; + case DmdShapeType.HLine: + DmdBlitter.FillRect(destination, x, y, layer.Width, math.max(1, layer.Height), layer.Shade, + layer.Blend, opacity); + break; + case DmdShapeType.VLine: + DmdBlitter.FillRect(destination, x, y, math.max(1, layer.Width), layer.Height, layer.Shade, + layer.Blend, opacity); + break; + case DmdShapeType.Rect: + if (layer.Filled) { + DmdBlitter.FillRect(destination, x, y, layer.Width, layer.Height, layer.Shade, layer.Blend, + opacity); + } else { + DmdBlitter.FillRect(destination, x, y, layer.Width, 1, layer.Shade, layer.Blend, opacity); + DmdBlitter.FillRect(destination, x, y + layer.Height - 1, layer.Width, 1, layer.Shade, + layer.Blend, opacity); + DmdBlitter.FillRect(destination, x, y + 1, 1, layer.Height - 2, layer.Shade, layer.Blend, + opacity); + DmdBlitter.FillRect(destination, x + layer.Width - 1, y + 1, 1, layer.Height - 2, + layer.Shade, layer.Blend, opacity); + } + break; + default: + throw new ArgumentOutOfRangeException(nameof(layer.Shape)); + } + } + + private static double CurrentValue(CueInstanceState.NumberTweenState tween, int tweenTicks, int frame) + { + if (tweenTicks <= 0) { + return tween.TargetValue; + } + var progress = math.saturate((double)(frame - tween.StartTick) / tweenTicks); + return tween.StartValue + (tween.TargetValue - tween.StartValue) * progress; + } + + private bool NumberIsAnimating(NumberLayer layer, int layerIndex, int frame, CueInstanceState state) + { + var ticks = math.max(0, (int)math.round(layer.CountUpSeconds * _project.FrameRate)); + if (ticks <= 0 || state == null || layerIndex >= state.NumberTweens.Length) { + return false; + } + var tween = state.NumberTweens[layerIndex]; + return !tween.Initialized || !tween.StartValue.Equals(tween.TargetValue) && + frame - tween.StartTick <= ticks; + } + + private static bool SpriteIsAnimating(DmdSpriteAsset sprite, DmdLoopMode loop, int localFrame) + { + if (sprite?.Frames == null || sprite.Frames.Count <= 1) { + return false; + } + if (loop == DmdLoopMode.Loop || loop == DmdLoopMode.PingPong) { + return true; + } + var duration = SpriteFrameClock.TotalDuration(sprite); + return loop == DmdLoopMode.Once ? localFrame <= duration : localFrame < duration; + } + + private static bool LayerChangesOverTime(DmdLayer layer) + { + if (layer.StartFrame > 0 || layer.EndFrame > 0 || layer.Tracks != null && layer.Tracks.Count > 0) { + return true; + } + if (layer is NumberLayer number && number.CountUpSeconds > 0f || + layer is TextLayer text && text.Overflow == DmdOverflow.Marquee && text.MarqueeSpeed > 0) { + return true; + } + return layer is BitmapLayer bitmap && bitmap.Sprite?.Frames != null && bitmap.Sprite.Frames.Count > 1 || + layer is MaskLayer mask && mask.Mask?.Frames != null && mask.Mask.Frames.Count > 1; + } + + private static bool IsReadable(DmdBitmapData bitmap) + { + if (bitmap == null || bitmap.Width < 1 || bitmap.Height < 1) { + return false; + } + var channels = bitmap.Format == DmdPixelFormat.I8 ? 1 : bitmap.Format == DmdPixelFormat.Rgb24 ? 3 : 0; + var pixels = (long)bitmap.Width * bitmap.Height; + return channels != 0 && bitmap.Pixels != null && bitmap.Pixels.LongLength == pixels * channels && + (bitmap.Alpha == null || bitmap.Alpha.Length == 0 || bitmap.Alpha.LongLength == pixels); + } + + private static int EvaluateInt(DmdLayer layer, DmdAnimatableProperty property, int fallback, int frame, + CueDiagnostics diagnostics) + { + var value = Evaluate(layer, property, fallback, frame, diagnostics); + return float.IsNaN(value) || float.IsInfinity(value) ? fallback : (int)math.round(value); + } + + private static float Evaluate(DmdLayer layer, DmdAnimatableProperty property, float fallback, int frame, + CueDiagnostics diagnostics) + { + return TryEvaluate(layer, property, frame, diagnostics, out var value) ? value : fallback; + } + + private static bool TryEvaluate(DmdLayer layer, DmdAnimatableProperty property, int frame, + CueDiagnostics diagnostics, out float value) + { + value = 0f; + DmdPropertyTrack selected = null; + var count = 0; + if (layer.Tracks != null) { + for (var index = 0; index < layer.Tracks.Count; index++) { + var candidate = layer.Tracks[index]; + if (candidate != null && candidate.Property == property) { + selected = candidate; + count++; + } + } + } + if (count > 1) { + diagnostics?.AuthoredContent("layer.track.duplicate", $"Duplicate {property} tracks found; the last wins."); + } + if (selected?.Keys == null || selected.Keys.Count == 0) { + return false; + } + var keys = selected.Keys; + if (frame <= keys[0].Frame) { + value = keys[0].Value; + return true; + } + for (var index = 1; index < keys.Count; index++) { + var next = keys[index]; + if (frame > next.Frame) { + continue; + } + var previous = keys[index - 1]; + if (previous.Interp == DmdInterpolation.Step || next.Frame <= previous.Frame) { + value = previous.Value; + } else { + var progress = (float)(frame - previous.Frame) / (next.Frame - previous.Frame); + value = math.lerp(previous.Value, next.Value, progress); + } + return true; + } + value = keys[keys.Count - 1].Value; + return true; + } + + private static byte ToOpacity(float opacity) + { + return float.IsNaN(opacity) || float.IsInfinity(opacity) + ? (byte)0 + : (byte)math.round(math.saturate(opacity) * byte.MaxValue); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueRenderer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueRenderer.cs.meta new file mode 100644 index 000000000..f37a54e9e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/CueRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2ac3d106fbaf664b868af9e3a43b927 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs index 1aed029c8..cad78b456 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdBlitter.cs @@ -95,9 +95,15 @@ internal static void FillRect(DmdSurface dst, int x, int y, int width, int heigh } } - public static void ApplyAlphaMask(DmdSurface dst, DmdBitmapData mask, - int maskFrameOffsetX, int maskFrameOffsetY) - { + public static void ApplyAlphaMask(DmdSurface dst, DmdBitmapData mask, + int maskFrameOffsetX, int maskFrameOffsetY) + { + ApplyAlphaMask(dst, mask, maskFrameOffsetX, maskFrameOffsetY, byte.MaxValue); + } + + internal static void ApplyAlphaMask(DmdSurface dst, DmdBitmapData mask, + int maskFrameOffsetX, int maskFrameOffsetY, byte opacity) + { if (dst == null) { throw new ArgumentNullException(nameof(dst)); } @@ -123,7 +129,9 @@ public static void ApplyAlphaMask(DmdSurface dst, DmdBitmapData mask, } } - var destinationOffset = (y * dst.Width + x) * (dst.Format == DmdPixelFormat.I8 ? 1 : 3); + // Opacity blends between an identity mask (0) and the authored mask (255). + alpha = (byte)(255 - Multiply((byte)(255 - alpha), opacity)); + var destinationOffset = (y * dst.Width + x) * (dst.Format == DmdPixelFormat.I8 ? 1 : 3); var channelCount = dst.Format == DmdPixelFormat.I8 ? 1 : 3; for (var channel = 0; channel < channelCount; channel++) { dst.Data[destinationOffset + channel] = Multiply(dst.Data[destinationOffset + channel], alpha); @@ -132,12 +140,14 @@ public static void ApplyAlphaMask(DmdSurface dst, DmdBitmapData mask, } } - internal static byte Blend(byte destination, byte source, byte sourceAlpha, byte opacity, - DmdBlendMode mode) - { - if (mode == DmdBlendMode.Opaque) { - return source; - } + internal static byte Blend(byte destination, byte source, byte sourceAlpha, byte opacity, + DmdBlendMode mode) + { + if (mode == DmdBlendMode.Opaque) { + // Opaque ignores the source bitmap's per-pixel alpha, but layer opacity still + // fades the overwrite as a whole. At full opacity this remains an exact copy. + return (byte)((source * opacity + destination * (255 - opacity) + 127) / 255); + } var alpha = Multiply(sourceAlpha, opacity); switch (mode) { case DmdBlendMode.Alpha: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs index 4f9df9a25..324e99b14 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTextRenderer.cs @@ -15,16 +15,20 @@ public static class DmdTextRenderer { private const int ReplacementCodepoint = 0xfffd; - public static int Measure(DmdFontAsset font, string text) - { + public static int Measure(DmdFontAsset font, string text) + { if (font == null) { throw new ArgumentNullException(nameof(font)); } - if (string.IsNullOrEmpty(text)) { - return 0; - } - - var width = 0; + return Measure(font, text.AsSpan()); + } + + private static int Measure(DmdFontAsset font, ReadOnlySpan text) + { + if (text.Length == 0) { + return 0; + } + var width = 0; var previous = -1; var index = 0; var glyphCount = 0; @@ -43,10 +47,41 @@ public static int Measure(DmdFontAsset font, string text) return math.max(0, width); } - public static void Draw(DmdSurface dst, DmdFontAsset font, string text, - int x, int y, DmdAnchor anchor, DmdTextEffect effect, - in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode, CueDiagnostics diagnostics) - { + public static void Draw(DmdSurface dst, DmdFontAsset font, string text, + int x, int y, DmdAnchor anchor, DmdTextEffect effect, + in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode, CueDiagnostics diagnostics) + { + Draw(dst, font, text, x, y, anchor, effect, shade, outlineShade, mode, byte.MaxValue, diagnostics); + } + + internal static void Draw(DmdSurface dst, DmdFontAsset font, string text, + int x, int y, DmdAnchor anchor, DmdTextEffect effect, + in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode, byte opacity, + CueDiagnostics diagnostics) + { + Draw(dst, font, text.AsSpan(), x, y, anchor, effect, shade, outlineShade, mode, opacity, diagnostics); + } + + internal static void Draw(DmdSurface dst, DmdFontAsset font, char[] text, int textLength, + int x, int y, DmdAnchor anchor, DmdTextEffect effect, + in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode, byte opacity, + CueDiagnostics diagnostics) + { + if (text == null) { + throw new ArgumentNullException(nameof(text)); + } + if (textLength < 0 || textLength > text.Length) { + throw new ArgumentOutOfRangeException(nameof(textLength)); + } + Draw(dst, font, new ReadOnlySpan(text, 0, textLength), x, y, anchor, effect, shade, + outlineShade, mode, opacity, diagnostics); + } + + private static void Draw(DmdSurface dst, DmdFontAsset font, ReadOnlySpan text, + int x, int y, DmdAnchor anchor, DmdTextEffect effect, + in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode, byte opacity, + CueDiagnostics diagnostics) + { if (dst == null) { throw new ArgumentNullException(nameof(dst)); } @@ -54,7 +89,7 @@ public static void Draw(DmdSurface dst, DmdFontAsset font, string text, diagnostics?.MalformedFont("Font asset is missing."); return; } - if (string.IsNullOrEmpty(text)) { + if (text.Length == 0) { return; } if (!HasReadableAtlas(font, diagnostics)) { @@ -62,7 +97,7 @@ public static void Draw(DmdSurface dst, DmdFontAsset font, string text, } var lineHeight = math.max(1, font.LineHeight); - var originX = ResolveHorizontalOrigin(x, Measure(font, text), anchor); + var originX = IsLeftAnchor(anchor) ? x : ResolveHorizontalOrigin(x, Measure(font, text), anchor); var originY = ResolveVerticalOrigin(y, lineHeight, math.clamp(font.Baseline, 0, lineHeight), anchor); var pen = 0; var previous = -1; @@ -77,7 +112,7 @@ public static void Draw(DmdSurface dst, DmdFontAsset font, string text, diagnostics?.MissingGlyph(codepoint); if (!TryResolveGlyph(font, codepoint, out glyph)) { DrawMissingBox(dst, originX + pen, originY, MissingGlyphWidth(font), lineHeight, - shade, outlineShade, effect, mode); + shade, outlineShade, effect, mode, opacity); pen += MissingGlyphWidth(font); previous = codepoint; glyphCount++; @@ -98,38 +133,39 @@ public static void Draw(DmdSurface dst, DmdFontAsset font, string text, : 0; var glyphX = originX + pen + digitOffset + glyph.OffsetX; var glyphY = originY + glyph.OffsetY; - DrawGlyphWithEffect(dst, font.Atlas, glyph, glyphX, glyphY, effect, shade, outlineShade, mode); + DrawGlyphWithEffect(dst, font.Atlas, glyph, glyphX, glyphY, effect, shade, outlineShade, mode, opacity); pen += Advance(font, codepoint, glyph); previous = codepoint; glyphCount++; } } - private static void DrawGlyphWithEffect(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, - int x, int y, DmdTextEffect effect, in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode) + private static void DrawGlyphWithEffect(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, + int x, int y, DmdTextEffect effect, in DmdShade shade, in DmdShade outlineShade, DmdBlendMode mode, + byte opacity) { switch (effect) { case DmdTextEffect.None: - DrawGlyph(dst, atlas, glyph, x, y, shade, mode); + DrawGlyph(dst, atlas, glyph, x, y, shade, mode, opacity); break; case DmdTextEffect.Outline: - DrawOutline(dst, atlas, glyph, x, y, outlineShade, mode); - DrawGlyph(dst, atlas, glyph, x, y, shade, mode); + DrawOutline(dst, atlas, glyph, x, y, outlineShade, mode, opacity); + DrawGlyph(dst, atlas, glyph, x, y, shade, mode, opacity); break; case DmdTextEffect.Shadow: - DrawGlyph(dst, atlas, glyph, x + 1, y + 1, outlineShade, mode); - DrawGlyph(dst, atlas, glyph, x, y, shade, mode); + DrawGlyph(dst, atlas, glyph, x + 1, y + 1, outlineShade, mode, opacity); + DrawGlyph(dst, atlas, glyph, x, y, shade, mode, opacity); break; case DmdTextEffect.Inverse: - DrawInverseGlyph(dst, atlas, glyph, x, y, shade, outlineShade, mode); + DrawInverseGlyph(dst, atlas, glyph, x, y, shade, outlineShade, mode, opacity); break; default: throw new ArgumentOutOfRangeException(nameof(effect)); } } - private static void DrawGlyph(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, - int x, int y, in DmdShade shade, DmdBlendMode mode) + private static void DrawGlyph(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, + int x, int y, in DmdShade shade, DmdBlendMode mode, byte opacity) { for (var glyphY = 0; glyphY < glyph.H; glyphY++) { var destinationY = y + glyphY; @@ -142,13 +178,13 @@ private static void DrawGlyph(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyp continue; } var mask = GlyphMask(atlas, glyph, glyphX, glyphY); - DrawShadedPixel(dst, destinationX, destinationY, shade, mask, mode); + DrawShadedPixel(dst, destinationX, destinationY, shade, mask, mode, opacity); } } } - private static void DrawOutline(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, - int x, int y, in DmdShade shade, DmdBlendMode mode) + private static void DrawOutline(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, + int x, int y, in DmdShade shade, DmdBlendMode mode, byte opacity) { for (var glyphY = -1; glyphY <= glyph.H; glyphY++) { for (var glyphX = -1; glyphX <= glyph.W; glyphX++) { @@ -168,38 +204,39 @@ private static void DrawOutline(DmdSurface dst, DmdBitmapData atlas, DmdGlyph gl } } if (mask != 0) { - DrawShadedPixel(dst, x + glyphX, y + glyphY, shade, mask, mode); + DrawShadedPixel(dst, x + glyphX, y + glyphY, shade, mask, mode, opacity); } } } } - private static void DrawInverseGlyph(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, - int x, int y, in DmdShade background, in DmdShade foreground, DmdBlendMode mode) + private static void DrawInverseGlyph(DmdSurface dst, DmdBitmapData atlas, DmdGlyph glyph, + int x, int y, in DmdShade background, in DmdShade foreground, DmdBlendMode mode, byte opacity) { for (var glyphY = 0; glyphY < glyph.H; glyphY++) { for (var glyphX = 0; glyphX < glyph.W; glyphX++) { var mask = GlyphMask(atlas, glyph, glyphX, glyphY); var inverseShade = Interpolate(background, foreground, mask); - DrawShadedPixel(dst, x + glyphX, y + glyphY, inverseShade, byte.MaxValue, mode); + DrawShadedPixel(dst, x + glyphX, y + glyphY, inverseShade, byte.MaxValue, mode, opacity); } } } - private static void DrawMissingBox(DmdSurface dst, int x, int y, int width, int height, - in DmdShade shade, in DmdShade outlineShade, DmdTextEffect effect, DmdBlendMode mode) + private static void DrawMissingBox(DmdSurface dst, int x, int y, int width, int height, + in DmdShade shade, in DmdShade outlineShade, DmdTextEffect effect, DmdBlendMode mode, byte opacity) { var boxShade = effect == DmdTextEffect.Inverse ? outlineShade : shade; for (var boxY = 0; boxY < height; boxY++) { for (var boxX = 0; boxX < width; boxX++) { var border = boxX == 0 || boxX == width - 1 || boxY == 0 || boxY == height - 1; - DrawShadedPixel(dst, x + boxX, y + boxY, boxShade, border ? byte.MaxValue : (byte)0, mode); + DrawShadedPixel(dst, x + boxX, y + boxY, boxShade, border ? byte.MaxValue : (byte)0, mode, + opacity); } } } - private static void DrawShadedPixel(DmdSurface dst, int x, int y, in DmdShade shade, - byte mask, DmdBlendMode mode) + private static void DrawShadedPixel(DmdSurface dst, int x, int y, in DmdShade shade, + byte mask, DmdBlendMode mode, byte opacity) { if (x < 0 || x >= dst.Width || y < 0 || y >= dst.Height) { return; @@ -207,16 +244,16 @@ private static void DrawShadedPixel(DmdSurface dst, int x, int y, in DmdShade sh var pixel = y * dst.Width + x; if (dst.Format == DmdPixelFormat.I8) { var source = mode == DmdBlendMode.Opaque ? DmdBlitter.Multiply(shade.Intensity, mask) : shade.Intensity; - dst.Data[pixel] = DmdBlitter.Blend(dst.Data[pixel], source, mask, byte.MaxValue, mode); + dst.Data[pixel] = DmdBlitter.Blend(dst.Data[pixel], source, mask, opacity, mode); } else { var alpha = DmdBlitter.Multiply(mask, shade.Color.a); var offset = pixel * 3; var red = mode == DmdBlendMode.Opaque ? DmdBlitter.Multiply(shade.Color.r, mask) : shade.Color.r; var green = mode == DmdBlendMode.Opaque ? DmdBlitter.Multiply(shade.Color.g, mask) : shade.Color.g; var blue = mode == DmdBlendMode.Opaque ? DmdBlitter.Multiply(shade.Color.b, mask) : shade.Color.b; - dst.Data[offset] = DmdBlitter.Blend(dst.Data[offset], red, alpha, byte.MaxValue, mode); - dst.Data[offset + 1] = DmdBlitter.Blend(dst.Data[offset + 1], green, alpha, byte.MaxValue, mode); - dst.Data[offset + 2] = DmdBlitter.Blend(dst.Data[offset + 2], blue, alpha, byte.MaxValue, mode); + dst.Data[offset] = DmdBlitter.Blend(dst.Data[offset], red, alpha, opacity, mode); + dst.Data[offset + 1] = DmdBlitter.Blend(dst.Data[offset + 1], green, alpha, opacity, mode); + dst.Data[offset + 2] = DmdBlitter.Blend(dst.Data[offset + 2], blue, alpha, opacity, mode); } } @@ -301,7 +338,7 @@ private static int MissingGlyphWidth(DmdFontAsset font) return math.max(3, math.max(1, font.LineHeight) / 2); } - private static int ResolveHorizontalOrigin(int x, int width, DmdAnchor anchor) + private static int ResolveHorizontalOrigin(int x, int width, DmdAnchor anchor) { switch (anchor) { case DmdAnchor.TopCenter: @@ -317,7 +354,13 @@ private static int ResolveHorizontalOrigin(int x, int width, DmdAnchor anchor) default: return x; } - } + } + + private static bool IsLeftAnchor(DmdAnchor anchor) + { + return anchor == DmdAnchor.TopLeft || anchor == DmdAnchor.MiddleLeft || + anchor == DmdAnchor.BottomLeft || anchor == DmdAnchor.BaselineLeft; + } private static int ResolveVerticalOrigin(int y, int lineHeight, int baseline, DmdAnchor anchor) { @@ -339,7 +382,7 @@ private static int ResolveVerticalOrigin(int y, int lineHeight, int baseline, Dm } } - private static bool TryReadCodepoint(string text, ref int index, out int codepoint) + private static bool TryReadCodepoint(ReadOnlySpan text, ref int index, out int codepoint) { if (index >= text.Length) { codepoint = 0; diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs index fec7dc052..73d598e7e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdTransitions.cs @@ -50,10 +50,12 @@ public static void Compose(DmdSurface dst, DmdSurface from, DmdSurface to, case DmdTransitionType.Cover: ComposeCover(dst, from, to, direction, progress); break; - case DmdTransitionType.Uncover: - case DmdTransitionType.ScrollOff: - ComposeUncover(dst, from, to, direction, progress); - break; + case DmdTransitionType.Uncover: + ComposeUncover(dst, from, to, direction, progress); + break; + case DmdTransitionType.ScrollOff: + ComposeScrollOff(dst, from, direction, progress); + break; case DmdTransitionType.WipeOn: ComposeWipe(dst, from, to, direction, progress); break; @@ -105,7 +107,7 @@ private static void ComposeCover(DmdSurface dst, DmdSurface from, DmdSurface to, } } - private static void ComposeUncover(DmdSurface dst, DmdSurface from, DmdSurface to, + private static void ComposeUncover(DmdSurface dst, DmdSurface from, DmdSurface to, DmdDirection direction, float progress) { Motion(direction, progress, dst.Width, dst.Height, out var fromX, out var fromY); @@ -115,7 +117,19 @@ private static void ComposeUncover(DmdSurface dst, DmdSurface from, DmdSurface t TryCopyTranslatedPixel(dst, from, x, y, fromX, fromY); } } - } + } + + private static void ComposeScrollOff(DmdSurface dst, DmdSurface from, + DmdDirection direction, float progress) + { + Motion(direction, progress, dst.Width, dst.Height, out var fromX, out var fromY); + dst.Clear(); + for (var y = 0; y < dst.Height; y++) { + for (var x = 0; x < dst.Width; x++) { + TryCopyTranslatedPixel(dst, from, x, y, fromX, fromY); + } + } + } private static void ComposeWipe(DmdSurface dst, DmdSurface from, DmdSurface to, DmdDirection direction, float progress) diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs index 23a2c2b25..c02a07750 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Compositing/DmdValidation.cs @@ -257,7 +257,10 @@ public static DmdValidationResult Validate(DmdProjectAsset project) } var result = new DmdValidationResult(); - ValidateDimensions(project.Width, project.Height, result, "project"); + ValidateDimensions(project.Width, project.Height, result, "project"); + if (!Enum.IsDefined(typeof(DmdColorMode), project.ColorMode)) { + result.Error("project.colorMode", "ColorMode is invalid."); + } if (string.IsNullOrWhiteSpace(project.DisplayId)) { result.Error("project.displayId", "DisplayId cannot be empty."); } @@ -472,9 +475,18 @@ private static void NormalizeSprite(DmdSpriteAsset sprite, HashSet validatedSprites, HashSet validatedFonts) { - if (string.IsNullOrWhiteSpace(cue.EffectiveId)) { + if (string.IsNullOrWhiteSpace(cue.EffectiveId)) { result.Error("cue.id", "CueId is empty and the asset has no name."); - } + } + if (!Enum.IsDefined(typeof(CuePriority), cue.Priority)) { + result.Error("cue.priority", "Cue priority is invalid."); + } + if (!Enum.IsDefined(typeof(CueInterruptPolicy), cue.Interrupt)) { + result.Error("cue.interrupt", "Cue interrupt policy is invalid."); + } + if (!Enum.IsDefined(typeof(CueReturnPolicy), cue.Return)) { + result.Error("cue.return", "Cue return policy is invalid."); + } if (cue.DurationFrames < 0) { result.Error("cue.duration", "DurationFrames cannot be negative."); } @@ -541,20 +553,25 @@ private static void ValidateLayer(DmdLayer layer, int index, DmdValidationResult HashSet validatedSprites, HashSet validatedFonts) { var path = $"Layer {index}"; - if (layer.Opacity < 0f || layer.Opacity > 1f) { - result.Error("layer.opacity", $"{path} opacity must be between zero and one."); - } + if (float.IsNaN(layer.Opacity) || float.IsInfinity(layer.Opacity) || + layer.Opacity < 0f || layer.Opacity > 1f) { + result.Error("layer.opacity", $"{path} opacity must be between zero and one."); + } + if (!Enum.IsDefined(typeof(DmdBlendMode), layer.Blend)) { + result.Error("layer.blend", $"{path} blend mode is invalid."); + } if (layer.StartFrame < 0 || layer.EndFrame < 0 || layer.EndFrame != 0 && layer.EndFrame < layer.StartFrame) { result.Error("layer.span", $"{path} has an invalid frame span."); } ValidateTracks(layer, path, result); switch (layer) { - case NumberLayer number: + case NumberLayer number: if (!IsValidParameterName(number.ParamName)) { result.Error("layer.number.parameter", $"{path} has an invalid parameter name."); } - if (number.CountUpSeconds < 0f) { + if (float.IsNaN(number.CountUpSeconds) || float.IsInfinity(number.CountUpSeconds) || + number.CountUpSeconds < 0f) { result.Error("layer.number.countUp", $"{path} count-up time cannot be negative."); } ValidateTextLayer(number, path, result, validatedFonts); @@ -562,7 +579,10 @@ private static void ValidateLayer(DmdLayer layer, int index, DmdValidationResult case TextLayer text: ValidateTextLayer(text, path, result, validatedFonts); break; - case BitmapLayer bitmap: + case BitmapLayer bitmap: + if (!Enum.IsDefined(typeof(DmdLoopMode), bitmap.Loop)) { + result.Error("layer.bitmap.loop", $"{path} has an invalid loop mode."); + } if (bitmap.Sprite == null) { result.Error("layer.bitmap.sprite", $"{path} has no sprite."); } else if (validatedSprites.Add(bitmap.Sprite)) { @@ -572,7 +592,10 @@ private static void ValidateLayer(DmdLayer layer, int index, DmdValidationResult result.Error("layer.bitmap.start", $"{path} SpriteStartFrame cannot be negative."); } break; - case MaskLayer mask: + case MaskLayer mask: + if (!Enum.IsDefined(typeof(DmdLoopMode), mask.Loop)) { + result.Error("layer.mask.loop", $"{path} has an invalid loop mode."); + } if (mask.Mask == null) { result.Error("layer.mask.sprite", $"{path} has no mask sprite."); } else if (validatedSprites.Add(mask.Mask)) { @@ -582,7 +605,10 @@ private static void ValidateLayer(DmdLayer layer, int index, DmdValidationResult result.Error("layer.mask.start", $"{path} SpriteStartFrame cannot be negative."); } break; - case ShapeLayer shape: + case ShapeLayer shape: + if (!Enum.IsDefined(typeof(DmdShapeType), shape.Shape)) { + result.Error("layer.shape.type", $"{path} has an invalid shape type."); + } if (shape.Shape != DmdShapeType.Dot && (shape.Width < 1 || shape.Height < 1)) { result.Error("layer.shape.size", $"{path} shape dimensions must be positive."); } @@ -593,9 +619,18 @@ private static void ValidateLayer(DmdLayer layer, int index, DmdValidationResult } } - private static void ValidateTextLayer(TextLayer text, string path, DmdValidationResult result, + private static void ValidateTextLayer(TextLayer text, string path, DmdValidationResult result, HashSet validatedFonts) - { + { + if (!Enum.IsDefined(typeof(DmdAnchor), text.Anchor)) { + result.Error("layer.text.anchor", $"{path} has an invalid anchor."); + } + if (!Enum.IsDefined(typeof(DmdTextEffect), text.Effect)) { + result.Error("layer.text.effect", $"{path} has an invalid text effect."); + } + if (!Enum.IsDefined(typeof(DmdOverflow), text.Overflow)) { + result.Error("layer.text.overflow", $"{path} has an invalid overflow mode."); + } if (text.Font == null) { result.Error("layer.text.font", $"{path} has no font."); } else if (validatedFonts.Add(text.Font)) { @@ -615,16 +650,26 @@ private static void ValidateTracks(DmdLayer layer, string path, DmdValidationRes var properties = new HashSet(); for (var trackIndex = 0; trackIndex < layer.Tracks.Count; trackIndex++) { var track = layer.Tracks[trackIndex]; - if (track == null || track.Keys == null || track.Keys.Count == 0) { + if (track == null || track.Keys == null || track.Keys.Count == 0) { result.Error("layer.track.empty", $"{path} track {trackIndex} has no keys."); continue; - } + } + if (!Enum.IsDefined(typeof(DmdAnimatableProperty), track.Property)) { + result.Error("layer.track.property", $"{path} track {trackIndex} has an invalid property."); + } if (!properties.Add(track.Property)) { result.Warning("layer.track.duplicate", $"{path} contains duplicate {track.Property} tracks; the last wins."); } var previousFrame = -1; for (var keyIndex = 0; keyIndex < track.Keys.Count; keyIndex++) { - var key = track.Keys[keyIndex]; + var key = track.Keys[keyIndex]; + if (float.IsNaN(key.Value) || float.IsInfinity(key.Value)) { + result.Error("layer.track.value", $"{path} track {trackIndex} has a non-finite value."); + } + if (!Enum.IsDefined(typeof(DmdInterpolation), key.Interp)) { + result.Error("layer.track.interpolation", + $"{path} track {trackIndex} key {keyIndex} has an invalid interpolation mode."); + } if (key.Frame < 0 || key.Frame < previousFrame) { result.Error("layer.track.order", $"{path} track {trackIndex} keys must be ordered at non-negative frames."); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/SpriteFrameClock.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/SpriteFrameClock.cs new file mode 100644 index 000000000..194d14e9d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/SpriteFrameClock.cs @@ -0,0 +1,105 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal static class SpriteFrameClock + { + public static int TotalDuration(DmdSpriteAsset sprite) + { + if (sprite?.Frames == null) { + return 0; + } + var duration = 0; + for (var index = 0; index < sprite.Frames.Count; index++) { + duration += Duration(sprite, index); + } + return duration; + } + + public static bool TryGetFrame(DmdSpriteAsset sprite, int elapsedFrames, DmdLoopMode loop, + int startFrame, out int frameIndex) + { + frameIndex = 0; + if (sprite?.Frames == null || sprite.Frames.Count == 0 || elapsedFrames < 0) { + return false; + } + + var count = sprite.Frames.Count; + startFrame = math.clamp(startFrame, 0, count - 1); + if (count == 1) { + if (loop == DmdLoopMode.Once && elapsedFrames >= Duration(sprite, 0)) { + return false; + } + frameIndex = 0; + return true; + } + + switch (loop) { + case DmdLoopMode.Once: + case DmdLoopMode.HoldLast: + for (var index = startFrame; index < count; index++) { + var duration = Duration(sprite, index); + if (elapsedFrames < duration) { + frameIndex = index; + return true; + } + elapsedFrames -= duration; + } + frameIndex = count - 1; + return loop == DmdLoopMode.HoldLast; + case DmdLoopMode.Loop: + return TryResolveCycle(sprite, elapsedFrames, startFrame, count, false, out frameIndex); + case DmdLoopMode.PingPong: + return TryResolveCycle(sprite, elapsedFrames, startFrame, count, true, out frameIndex); + default: + return false; + } + } + + private static bool TryResolveCycle(DmdSpriteAsset sprite, int elapsedFrames, int startFrame, + int count, bool pingPong, out int frameIndex) + { + var sequenceCount = pingPong ? count * 2 - 2 : count; + var cycleDuration = 0; + for (var sequence = 0; sequence < sequenceCount; sequence++) { + cycleDuration += Duration(sprite, SequenceFrame(sequence, startFrame, count, pingPong)); + } + if (cycleDuration <= 0) { + frameIndex = startFrame; + return true; + } + elapsedFrames %= cycleDuration; + for (var sequence = 0; sequence < sequenceCount; sequence++) { + frameIndex = SequenceFrame(sequence, startFrame, count, pingPong); + var duration = Duration(sprite, frameIndex); + if (elapsedFrames < duration) { + return true; + } + elapsedFrames -= duration; + } + frameIndex = startFrame; + return true; + } + + private static int SequenceFrame(int sequence, int startFrame, int count, bool pingPong) + { + var logical = pingPong && sequence >= count ? count * 2 - 2 - sequence : sequence; + return (startFrame + logical) % count; + } + + private static int Duration(DmdSpriteAsset sprite, int index) + { + return sprite.FrameDurations != null && sprite.FrameDurations.Count == sprite.Frames.Count + ? math.max(1, sprite.FrameDurations[index]) + : 1; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/SpriteFrameClock.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/SpriteFrameClock.cs.meta new file mode 100644 index 000000000..f619bc775 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Layers/SpriteFrameClock.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4047d61b5418ad04bb9ceb4512d5ec4f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime.meta new file mode 100644 index 000000000..1f8663ef4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8cf90b393bb39c74784f81899a35a840 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueHandle.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueHandle.cs new file mode 100644 index 000000000..ba4d9afb3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueHandle.cs @@ -0,0 +1,33 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; + +namespace VisualPinball.Unity +{ + public readonly struct CueHandle : IEquatable + { + internal readonly uint Id; + internal readonly uint Generation; + + internal CueHandle(uint id, uint generation) + { + Id = id; + Generation = generation; + } + + public bool IsValid => Id != 0 && Generation != 0; + + public bool Equals(CueHandle other) => Id == other.Id && Generation == other.Generation; + public override bool Equals(object obj) => obj is CueHandle other && Equals(other); + public override int GetHashCode() => unchecked(((int)Id * 397) ^ (int)Generation); + public static bool operator ==(CueHandle left, CueHandle right) => left.Equals(right); + public static bool operator !=(CueHandle left, CueHandle right) => !left.Equals(right); + public override string ToString() => IsValid ? $"{Generation}:{Id}" : "Invalid"; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueHandle.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueHandle.cs.meta new file mode 100644 index 000000000..4c81591e7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueHandle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9b69e67855f623243bb99478db08e866 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstance.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstance.cs new file mode 100644 index 000000000..1e6a4c6f0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstance.cs @@ -0,0 +1,54 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +namespace VisualPinball.Unity +{ + internal enum CuePhase + { + Entering, + Running, + Exiting, + } + + internal sealed class CueInstance + { + public readonly CueHandle Handle; + public readonly DmdCueAsset Cue; + public readonly DmdParams Params; + public CueInstanceState State = new CueInstanceState(); + public readonly CueDiagnostics Diagnostics = new CueDiagnostics(); + public readonly long Sequence; + public int ElapsedFrames; + public CuePhase Phase; + // Set when another cue owns the transition across this cue's exit window. It remains + // set across Resume preemption because replaying the full exit would exceed the cue's + // already-advanced DurationFrames lifetime. + public bool ExitSuppressed; + public int PublishedDiagnostics; + + public CueInstance(CueHandle handle, DmdCueAsset cue, DmdParams parameters, long sequence) + { + Handle = handle; + Cue = cue; + Params = parameters; + Sequence = sequence; + Phase = CuePhase.Running; + ExitSuppressed = false; + } + + public void Restart() + { + ElapsedFrames = 0; + Phase = CuePhase.Running; + ExitSuppressed = false; + State = new CueInstanceState(); + Diagnostics.Clear(); + PublishedDiagnostics = 0; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstance.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstance.cs.meta new file mode 100644 index 000000000..9d73a19ba --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstance.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bda68fa2a73f73e4db9b6a6d13447d3f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstanceState.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstanceState.cs new file mode 100644 index 000000000..f4fca76c0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstanceState.cs @@ -0,0 +1,46 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; + +namespace VisualPinball.Unity +{ + public sealed class CueInstanceState + { + public NumberTweenState[] NumberTweens = Array.Empty(); + + internal BoundText[] BoundTexts = Array.Empty(); + internal string[] TextTemplates = Array.Empty(); + internal string[] ResolvedTexts = Array.Empty(); + internal int[] TextVersions = Array.Empty(); + internal bool[] TextInitialized = Array.Empty(); + internal char[][] NumberBuffers = Array.Empty(); + + internal void EnsureLayerCount(int count) + { + if (NumberTweens.Length == count) { + return; + } + NumberTweens = new NumberTweenState[count]; + BoundTexts = new BoundText[count]; + TextTemplates = new string[count]; + ResolvedTexts = new string[count]; + TextVersions = new int[count]; + TextInitialized = new bool[count]; + NumberBuffers = new char[count][]; + } + + public struct NumberTweenState + { + public double StartValue; + public double TargetValue; + public int StartTick; + public bool Initialized; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstanceState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstanceState.cs.meta new file mode 100644 index 000000000..8c7078ab8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueInstanceState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5cbe24eb175f9549807185ef19ec457 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueScheduler.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueScheduler.cs new file mode 100644 index 000000000..088adcba3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueScheduler.cs @@ -0,0 +1,234 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Unity +{ + internal enum CueAdmissionKind + { + Activated, + Queued, + Preempted, + Replaced, + Coalesced, + } + + internal readonly struct CueAdmission + { + public readonly CueAdmissionKind Kind; + public readonly CueInstance Instance; + public readonly CueInstance Displaced; + + public CueAdmission(CueAdmissionKind kind, CueInstance instance, CueInstance displaced = null) + { + Kind = kind; + Instance = instance; + Displaced = displaced; + } + } + + internal sealed class CueScheduler + { + private readonly List _holdStack = new List(); + private readonly List _queue = new List(); + private uint _nextId = 1; + private uint _generation = 1; + private long _sequence; + + public CueInstance Base { get; private set; } + public CueInstance Active { get; private set; } + internal IReadOnlyList HoldStack => _holdStack; + internal IReadOnlyList Queue => _queue; + + public CueInstance Create(DmdCueAsset cue, DmdParams parameters) + { + if (_nextId == 0) { + _nextId = 1; + unchecked { _generation++; } + if (_generation == 0) { + _generation = 1; + } + } + return new CueInstance(new CueHandle(_nextId++, _generation), cue, parameters, _sequence++); + } + + public void SetBase(CueInstance instance) + { + Base = instance; + } + + public CueAdmission Admit(CueInstance incoming) + { + if (incoming == null) { + throw new ArgumentNullException(nameof(incoming)); + } + if (!string.IsNullOrEmpty(incoming.Cue.CoalesceKey)) { + var existing = Resolve(incoming.Cue.CoalesceKey, true); + if (existing != null) { + existing.Params.MergeFrom(incoming.Params); + return new CueAdmission(CueAdmissionKind.Coalesced, existing); + } + } + if (Active == null) { + Active = incoming; + return new CueAdmission(CueAdmissionKind.Activated, incoming); + } + + var active = Active; + if (incoming.Cue.Interrupt == CueInterruptPolicy.Queue) { + Enqueue(incoming); + return new CueAdmission(CueAdmissionKind.Queued, incoming); + } + if (incoming.Cue.Priority > active.Cue.Priority) { + if (active.Cue.Interrupt == CueInterruptPolicy.NonInterruptible && + incoming.Cue.Priority != CuePriority.System) { + Enqueue(incoming); + return new CueAdmission(CueAdmissionKind.Queued, incoming); + } + _holdStack.Add(active); + Active = incoming; + return new CueAdmission(CueAdmissionKind.Preempted, incoming, active); + } + if (incoming.Cue.Priority == active.Cue.Priority && + incoming.Cue.Interrupt == CueInterruptPolicy.Replace && + active.Cue.Interrupt != CueInterruptPolicy.NonInterruptible) { + Active = incoming; + return new CueAdmission(CueAdmissionKind.Replaced, incoming, active); + } + + Enqueue(incoming); + return new CueAdmission(CueAdmissionKind.Queued, incoming); + } + + public CueInstance EndActive() + { + Active = SelectNext(); + return Active; + } + + public bool TryFind(CueHandle handle, out CueInstance instance) + { + instance = null; + if (!handle.IsValid) { + return false; + } + if (Active?.Handle == handle) { + instance = Active; + return true; + } + for (var index = _holdStack.Count - 1; index >= 0; index--) { + if (_holdStack[index].Handle == handle) { + instance = _holdStack[index]; + return true; + } + } + for (var index = 0; index < _queue.Count; index++) { + if (_queue[index].Handle == handle) { + instance = _queue[index]; + return true; + } + } + return false; + } + + public CueInstance Resolve(string cueIdOrKey) + { + return Resolve(cueIdOrKey, true) ?? Resolve(cueIdOrKey, false); + } + + public bool RemoveWaiting(CueInstance instance) + { + for (var index = _holdStack.Count - 1; index >= 0; index--) { + if (ReferenceEquals(_holdStack[index], instance)) { + _holdStack.RemoveAt(index); + return true; + } + } + return _queue.Remove(instance); + } + + public void ClearWaiting(Action removed) + { + for (var index = _holdStack.Count - 1; index >= 0; index--) { + removed?.Invoke(_holdStack[index]); + } + _holdStack.Clear(); + for (var index = 0; index < _queue.Count; index++) { + removed?.Invoke(_queue[index]); + } + _queue.Clear(); + } + + private CueInstance Resolve(string value, bool coalesceKey) + { + if (string.IsNullOrEmpty(value)) { + return null; + } + if (Matches(Active, value, coalesceKey)) { + return Active; + } + for (var index = _holdStack.Count - 1; index >= 0; index--) { + if (Matches(_holdStack[index], value, coalesceKey)) { + return _holdStack[index]; + } + } + for (var index = 0; index < _queue.Count; index++) { + if (Matches(_queue[index], value, coalesceKey)) { + return _queue[index]; + } + } + return null; + } + + private static bool Matches(CueInstance instance, string value, bool coalesceKey) + { + return instance != null && string.Equals(coalesceKey ? instance.Cue.CoalesceKey : instance.Cue.EffectiveId, + value, StringComparison.Ordinal); + } + + private void Enqueue(CueInstance incoming) + { + var index = 0; + while (index < _queue.Count && (_queue[index].Cue.Priority > incoming.Cue.Priority || + _queue[index].Cue.Priority == incoming.Cue.Priority && + _queue[index].Sequence < incoming.Sequence)) { + index++; + } + _queue.Insert(index, incoming); + } + + private CueInstance SelectNext() + { + CueInstance held = null; + while (_holdStack.Count > 0) { + held = _holdStack[_holdStack.Count - 1]; + _holdStack.RemoveAt(_holdStack.Count - 1); + if (held.Cue.Return != CueReturnPolicy.Discard) { + break; + } + held = null; + } + var queued = _queue.Count > 0 ? _queue[0] : null; + if (held != null && (queued == null || held.Cue.Priority >= queued.Cue.Priority)) { + if (held.Cue.Return == CueReturnPolicy.Restart) { + held.Restart(); + } + return held; + } + if (held != null) { + _holdStack.Add(held); + } + if (queued != null) { + _queue.RemoveAt(0); + } + return queued; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueScheduler.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueScheduler.cs.meta new file mode 100644 index 000000000..8cca0ac10 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/CueScheduler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 60c2313fa9d14bc44a51ca7b0b868b82 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs new file mode 100644 index 000000000..34f794fbd --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs @@ -0,0 +1,719 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using System.Threading; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Main-thread cue scheduler, compositor, and GLE display emitter for one DMD project. + /// + public sealed class DmdCuePlayer : IDisposable + { + public const double ReannounceDelaySeconds = 2.0; + + private readonly DmdProjectAsset _project; + private readonly IDmdFrameSink _sink; + private readonly CueScheduler _scheduler = new CueScheduler(); + private readonly Dictionary _cues = + new Dictionary(StringComparer.Ordinal); + private readonly List _constructorDiagnostics = new List(); + private readonly HashSet _publishedDiagnostics = new HashSet(StringComparer.Ordinal); + private readonly Queue _finishedEvents = new Queue(); + private readonly int _threadId; + private readonly int _frameRate; + private readonly int _width; + private readonly int _height; + private readonly bool _canRender; + private readonly CueRenderer _renderer; + private readonly DmdSurface _output; + private readonly DmdSurface _from; + private readonly DmdSurface _to; + private readonly RequestedDisplays _requestedDisplays; + + private DisplayFrameFormat _format; + private byte[] _emitBuffer; + private DisplayFrameData _frameData; + private Transition _transition; + private bool _started; + private bool _disposed; + private bool _cleared; + private bool _dirty = true; + private bool _hasLastTime; + private double _lastTime; + private double _accumulator; + private bool _hasFirstTickTime; + private double _firstTickTime; + private bool _reannounced; + + public event EventHandler OnCueFinished; + public event EventHandler OnValidationError; + + public DmdCuePlayer(DmdProjectAsset project, IDmdFrameSink sink) + { + _project = project ?? throw new ArgumentNullException(nameof(project)); + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + _threadId = Thread.CurrentThread.ManagedThreadId; + _frameRate = math.clamp(project.FrameRate, DmdValidation.MinFrameRate, DmdValidation.MaxFrameRate); + _width = math.clamp(project.Width, 1, DmdValidation.MaxWidth); + _height = math.clamp(project.Height, 1, DmdValidation.MaxHeight); + _canRender = project.Width == _width && project.Height == _height && + Enum.IsDefined(typeof(DmdColorMode), project.ColorMode); + var pixelFormat = project.ColorMode == DmdColorMode.Rgb24 + ? DmdPixelFormat.Rgb24 + : DmdPixelFormat.I8; + _renderer = new CueRenderer(project); + _output = new DmdSurface(_width, _height, pixelFormat); + _from = new DmdSurface(_width, _height, pixelFormat); + _to = new DmdSurface(_width, _height, pixelFormat); + _requestedDisplays = new RequestedDisplays(new DisplayConfig( + string.IsNullOrWhiteSpace(project.DisplayId) ? "dmd0" : project.DisplayId, _width, _height)); + _format = DefaultFormat(project.ColorMode); + CreateEmitBuffer(); + IndexProject(); + } + + public void Start() + { + EnsureUsable(); + if (_started) { + return; + } + _started = true; + _sink.RequestDisplays(_requestedDisplays); + PublishConstructorDiagnostics(); + _output.Clear(); + if (_scheduler.Active != null) { + RenderInstance(_to, _scheduler.Base); + _output.CopyFrom(_to); + BeginEnter(_scheduler.Active); + if (!_transition.Active) { + TryStartNaturalExit(); + } + } + RenderAndEmit(); + DrainFinishedEvents(); + } + + public void SetBase(string cueId, DmdParams parameters = null) + { + EnsureUsable(); + if (!TryGetCue(cueId, out var cue)) { + return; + } + if (_scheduler.Base != null && string.Equals(_scheduler.Base.Cue.EffectiveId, cue.EffectiveId, + StringComparison.Ordinal)) { + _scheduler.Base.Params.MergeFrom(parameters); + } else { + _scheduler.SetBase(_scheduler.Create(cue, CreateParameters(cue, parameters))); + } + _dirty = true; + } + + public CueHandle Play(string cueId, DmdParams parameters = null) + { + EnsureUsable(); + if (!TryGetCue(cueId, out var cue)) { + return default; + } + var transitioningInstance = _started && _transition.Active ? _scheduler.Active : null; + var incoming = _scheduler.Create(cue, CreateParameters(cue, parameters)); + var admission = _scheduler.Admit(incoming); + if (admission.Kind == CueAdmissionKind.Coalesced) { + _dirty = true; + return admission.Instance.Handle; + } + if (_started && (admission.Kind == CueAdmissionKind.Activated || + admission.Kind == CueAdmissionKind.Preempted || + admission.Kind == CueAdmissionKind.Replaced)) { + AbandonTransition(transitioningInstance); + BeginEnter(admission.Instance); + if (!_transition.Active) { + TryStartNaturalExit(); + } + } + _dirty = true; + DrainFinishedEvents(); + return admission.Instance.Handle; + } + + public bool UpdateCue(CueHandle handle, DmdParams parameters) + { + EnsureUsable(); + if (!TryFindInstance(handle, out var instance)) { + return false; + } + instance.Params.MergeFrom(parameters); + _dirty = true; + return true; + } + + public bool UpdateCue(string cueIdOrKey, DmdParams parameters) + { + EnsureUsable(); + var instance = _scheduler.Resolve(cueIdOrKey); + if (instance == null) { + return false; + } + instance.Params.MergeFrom(parameters); + _dirty = true; + return true; + } + + public bool StopCue(CueHandle handle) + { + EnsureUsable(); + if (!TryFindInstance(handle, out var instance)) { + return false; + } + var stopped = ReferenceEquals(instance, _transition.Outgoing) || StopInstance(instance); + DrainFinishedEvents(); + return stopped; + } + + public bool StopCue(string cueIdOrKey) + { + EnsureUsable(); + var instance = _scheduler.Resolve(cueIdOrKey); + var stopped = instance != null && StopInstance(instance); + DrainFinishedEvents(); + return stopped; + } + + public void StopAll() + { + EnsureUsable(); + AbandonTransition(); + _scheduler.ClearWaiting(Finish); + if (_scheduler.Active != null) { + BeginExitActive(); + if (!_transition.Active) { + TryStartNaturalExit(); + } + } + _dirty = true; + DrainFinishedEvents(); + } + + public void Tick(double timeSeconds) + { + EnsureUsable(); + if (double.IsNaN(timeSeconds) || double.IsInfinity(timeSeconds) || timeSeconds < 0d) { + throw new ArgumentOutOfRangeException(nameof(timeSeconds)); + } + if (!_started) { + return; + } + if (!_hasFirstTickTime) { + _hasFirstTickTime = true; + _firstTickTime = timeSeconds; + } + if (!_reannounced && timeSeconds - _firstTickTime >= ReannounceDelaySeconds) { + _sink.RequestDisplays(_requestedDisplays); + _reannounced = true; + } + if (!_hasLastTime) { + _hasLastTime = true; + _lastTime = timeSeconds; + return; + } + if (timeSeconds < _lastTime) { + _lastTime = timeSeconds; + _accumulator = 0d; + return; + } + + _accumulator += timeSeconds - _lastTime; + _lastTime = timeSeconds; + var frameDuration = 1d / _frameRate; + var advanced = false; + while (_accumulator + 1e-12 >= frameDuration) { + _accumulator -= frameDuration; + AdvanceOneFrame(); + advanced = true; + } + if (advanced && _dirty) { + RenderAndEmit(); + } + if (advanced) { + DrainFinishedEvents(); + } + } + + public void RequestFormat(DisplayFrameFormat format) + { + EnsureUsable(); + if (!AcceptsFormat(format) || format == _format) { + return; + } + _format = format; + CreateEmitBuffer(); + _dirty = true; + } + + public void Dispose() + { + EnsureThread(); + if (_disposed) { + return; + } + if (_started && !_cleared) { + _sink.Clear(_requestedDisplays.Displays[0].Id); + _cleared = true; + } + _disposed = true; + _transition = default; + _finishedEvents.Clear(); + _emitBuffer = null; + _frameData = null; + } + + private bool StopInstance(CueInstance instance) + { + if (!ReferenceEquals(_scheduler.Active, instance)) { + if (!_scheduler.RemoveWaiting(instance)) { + return false; + } + Finish(instance); + return true; + } + AbandonTransition(); + BeginExitActive(); + if (!_transition.Active) { + TryStartNaturalExit(); + } + _dirty = true; + return true; + } + + private bool TryFindInstance(CueHandle handle, out CueInstance instance) + { + if (_scheduler.TryFind(handle, out instance)) { + return true; + } + instance = _transition.Outgoing; + return instance != null && instance.Handle == handle; + } + + private void AdvanceOneFrame() + { + if (_scheduler.Base != null) { + _scheduler.Base.ElapsedFrames++; + } + if (_transition.Active) { + if (_scheduler.Active != null) { + _scheduler.Active.ElapsedFrames++; + } + if (_transition.OutgoingLive && _transition.Outgoing != null) { + _transition.Outgoing.ElapsedFrames++; + } + _transition.ElapsedFrames++; + if (_transition.OutgoingLive) { + AdvanceTransitionTargets(); + } + _dirty = true; + if (_transition.ElapsedFrames >= _transition.Spec.DurationFrames) { + var outgoing = _transition.Outgoing; + _transition = default; + if (outgoing != null) { + Finish(outgoing); + } + } + } else if (_scheduler.Active != null) { + _scheduler.Active.ElapsedFrames++; + } + + if (!_transition.Active) { + TryStartNaturalExit(); + } + var visible = _scheduler.Active ?? _scheduler.Base; + if (visible != null && _renderer.IsAnimated(visible.Cue, RenderFrame(visible), visible.State)) { + _dirty = true; + } + } + + private void TryStartNaturalExit() + { + while (true) { + var active = _scheduler.Active; + if (active == null || active.Cue.DurationFrames <= 0 || active.Cue.Loop) { + return; + } + if (active.ExitSuppressed) { + if (active.ElapsedFrames < active.Cue.DurationFrames) { + return; + } + CompleteSuppressedActive(active); + continue; + } + var exitFrames = EffectiveExitFrames(active.Cue); + if (exitFrames > 0) { + if (active.Phase != CuePhase.Exiting && + active.ElapsedFrames >= active.Cue.DurationFrames - exitFrames) { + BeginExitActive(); + if (!_transition.Active) { + continue; + } + } + } else if (active.ElapsedFrames >= active.Cue.DurationFrames) { + BeginExitActive(); + if (!_transition.Active) { + continue; + } + } + return; + } + } + + private void AdvanceTransitionTargets() + { + while (true) { + var active = _scheduler.Active; + if (active == null || active.Cue.DurationFrames <= 0 || active.Cue.Loop) { + return; + } + var exitFrames = EffectiveExitFrames(active.Cue); + if (exitFrames > 0 && + active.ElapsedFrames >= active.Cue.DurationFrames - exitFrames) { + active.ExitSuppressed = true; + } + if (active.ElapsedFrames < active.Cue.DurationFrames) { + return; + } + CompleteSuppressedActive(active); + } + } + + private void CompleteSuppressedActive(CueInstance active) + { + var incoming = _scheduler.EndActive(); + if (!_transition.Active && incoming != null && + incoming.Cue.EnterTransition.Type != DmdTransitionType.Cut && + incoming.Cue.EnterTransition.DurationFrames > 0) { + BeginEnter(incoming); + } + Finish(active); + _dirty = true; + } + + private static int EffectiveExitFrames(DmdCueAsset cue) + { + return cue.ExitTransition.Type == DmdTransitionType.Cut + ? 0 + : cue.ExitTransition.DurationFrames; + } + + private void BeginExitActive() + { + var outgoing = _scheduler.Active; + if (outgoing == null) { + return; + } + outgoing.Phase = CuePhase.Exiting; + var spec = outgoing.Cue.ExitTransition; + var incoming = _scheduler.EndActive(); + if (spec.Type != DmdTransitionType.Cut && spec.DurationFrames > 0) { + _transition = new Transition(spec, outgoing, true); + _dirty = true; + return; + } + + if (incoming != null && incoming.Cue.EnterTransition.Type != DmdTransitionType.Cut && + incoming.Cue.EnterTransition.DurationFrames > 0) { + BeginEnter(incoming); + } + Finish(outgoing); + _dirty = true; + } + + private void BeginEnter(CueInstance incoming) + { + var spec = incoming.Cue.EnterTransition; + if (spec.Type == DmdTransitionType.Cut || spec.DurationFrames <= 0) { + incoming.Phase = CuePhase.Running; + _transition = default; + return; + } + _from.CopyFrom(_output); + incoming.Phase = CuePhase.Entering; + _transition = new Transition(spec, null, false); + } + + private void AbandonTransition(CueInstance transitioningInstance = null) + { + if (!_transition.Active) { + return; + } + var outgoing = _transition.Outgoing; + _transition = default; + if (outgoing != null) { + Finish(outgoing); + } + transitioningInstance = transitioningInstance ?? _scheduler.Active; + if (transitioningInstance != null) { + transitioningInstance.Phase = CuePhase.Running; + transitioningInstance.ExitSuppressed = false; + } + _dirty = true; + } + + private void RenderAndEmit() + { + if (_transition.Active) { + if (_transition.OutgoingLive) { + RenderInstance(_from, _transition.Outgoing); + } + RenderVisible(_to); + var progress = _transition.Spec.DurationFrames <= 0 + ? 1f + : (float)_transition.ElapsedFrames / _transition.Spec.DurationFrames; + DmdTransitions.Compose(_output, _from, _to, _transition.Spec.Type, + _transition.Spec.Direction, progress); + } else { + RenderVisible(_output); + } + Emit(); + _dirty = false; + // Publish only after the frame is self-consistent. Handlers may dispose the player + // or admit new work; emitting first avoids invalidating buffers mid-frame, and + // clearing dirty first preserves dirtiness set by a reentrant admission. + PublishVisibleDiagnostics(); + } + + private void RenderVisible(DmdSurface destination) + { + RenderInstance(destination, _scheduler.Active ?? _scheduler.Base); + } + + private void RenderInstance(DmdSurface destination, CueInstance instance) + { + destination.Clear(); + if (!_canRender || instance == null) { + return; + } + var frame = RenderFrame(instance); + _renderer.Render(destination, instance.Cue, math.max(0, frame), instance.Params, instance.State, + instance.Diagnostics); + } + + private static int RenderFrame(CueInstance instance) + { + var frame = instance.ElapsedFrames; + return instance.Cue.DurationFrames > 0 && instance.Cue.Loop + ? frame % instance.Cue.DurationFrames + : frame; + } + + private void Emit() + { + switch (_format) { + case DisplayFrameFormat.Dmd2: + DmdQuantizer.I8ToDmd2(_output.Data, _emitBuffer); + break; + case DisplayFrameFormat.Dmd4: + DmdQuantizer.I8ToDmd4(_output.Data, _emitBuffer); + break; + case DisplayFrameFormat.Dmd8: + case DisplayFrameFormat.Dmd24: + Buffer.BlockCopy(_output.Data, 0, _emitBuffer, 0, _emitBuffer.Length); + break; + } + _sink.UpdateFrame(_frameData); + } + + private void CreateEmitBuffer() + { + var length = checked(_width * _height * (_format == DisplayFrameFormat.Dmd24 ? 3 : 1)); + _emitBuffer = new byte[length]; + _frameData = new DisplayFrameData( + string.IsNullOrWhiteSpace(_project.DisplayId) ? "dmd0" : _project.DisplayId, _format, _emitBuffer); + } + + private bool AcceptsFormat(DisplayFrameFormat format) + { + switch (_project.ColorMode) { + case DmdColorMode.Mono4: + return format == DisplayFrameFormat.Dmd2 || format == DisplayFrameFormat.Dmd4 || + format == DisplayFrameFormat.Dmd8; + case DmdColorMode.Mono16: + return format == DisplayFrameFormat.Dmd4 || format == DisplayFrameFormat.Dmd8; + case DmdColorMode.Rgb24: + return format == DisplayFrameFormat.Dmd24; + default: + return false; + } + } + + private static DisplayFrameFormat DefaultFormat(DmdColorMode mode) + { + return mode == DmdColorMode.Mono4 ? DisplayFrameFormat.Dmd2 : + mode == DmdColorMode.Rgb24 ? DisplayFrameFormat.Dmd24 : DisplayFrameFormat.Dmd4; + } + + private DmdParams CreateParameters(DmdCueAsset cue, DmdParams supplied) + { + var result = new DmdParams(); + if (cue.Parameters != null) { + foreach (var parameter in cue.Parameters) { + var value = parameter.DefaultValue; + switch (value.Type) { + case DmdParamType.Integer: + result.Set(value.Name, value.IntValue); + break; + case DmdParamType.Float: + result.Set(value.Name, value.FloatValue); + break; + case DmdParamType.String: + result.Set(value.Name, value.StringValue); + break; + case DmdParamType.Boolean: + result.Set(value.Name, value.BoolValue); + break; + } + } + } + result.MergeFrom(supplied); + return result; + } + + private void IndexProject() + { + DmdValidationResult projectValidation = null; + try { + projectValidation = _project.Validate(); + } catch (Exception exception) when (!(exception is OutOfMemoryException)) { + _constructorDiagnostics.Add($"Project validation failed: {exception.Message}"); + } + if (projectValidation != null) { + foreach (var diagnostic in projectValidation.Diagnostics) { + _constructorDiagnostics.Add(diagnostic.ToString()); + } + } + if (_project.Cues == null) { + return; + } + var seen = new HashSet(StringComparer.Ordinal); + var duplicates = new HashSet(StringComparer.Ordinal); + foreach (var cue in _project.Cues) { + if (cue == null || string.IsNullOrWhiteSpace(cue.EffectiveId)) { + continue; + } + if (!seen.Add(cue.EffectiveId)) { + duplicates.Add(cue.EffectiveId); + continue; + } + if (cue.Validate().IsValid) { + _cues.Add(cue.EffectiveId, cue); + } + } + foreach (var duplicate in duplicates) { + _cues.Remove(duplicate); + } + } + + private bool TryGetCue(string cueId, out DmdCueAsset cue) + { + cue = null; + if (string.IsNullOrEmpty(cueId) || !_cues.TryGetValue(cueId, out cue)) { + ReportValidation($"Cue '{cueId ?? ""}' is unknown or invalid."); + return false; + } + return true; + } + + private void PublishConstructorDiagnostics() + { + foreach (var diagnostic in _constructorDiagnostics) { + ReportValidation(diagnostic); + } + _constructorDiagnostics.Clear(); + } + + private void PublishVisibleDiagnostics() + { + PublishDiagnostics(_scheduler.Active); + PublishDiagnostics(_scheduler.Base); + if (_transition.Outgoing != null) { + PublishDiagnostics(_transition.Outgoing); + } + } + + private void PublishDiagnostics(CueInstance instance) + { + if (instance == null) { + return; + } + while (instance.PublishedDiagnostics < instance.Diagnostics.Count) { + var diagnostic = instance.Diagnostics.Diagnostics[instance.PublishedDiagnostics++]; + var message = $"Cue '{instance.Cue.EffectiveId}': {diagnostic.Message}"; + Debug.LogWarning($"DMD Studio: {message}"); + OnValidationError?.Invoke(this, message); + } + } + + private void ReportValidation(string message) + { + if (!_publishedDiagnostics.Add(message)) { + return; + } + Debug.LogWarning($"DMD Studio: {message}"); + OnValidationError?.Invoke(this, message); + } + + private void Finish(CueInstance instance) + { + _finishedEvents.Enqueue(instance.Handle); + } + + private void DrainFinishedEvents() + { + while (_finishedEvents.Count > 0) { + var handle = _finishedEvents.Dequeue(); + OnCueFinished?.Invoke(this, handle); + } + } + + private void EnsureUsable() + { + EnsureThread(); + if (_disposed) { + throw new ObjectDisposedException(nameof(DmdCuePlayer)); + } + } + + private void EnsureThread() + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + if (Thread.CurrentThread.ManagedThreadId != _threadId) { + throw new InvalidOperationException("DmdCuePlayer is main-thread-only."); + } +#endif + } + + private struct Transition + { + public readonly DmdTransitionSpec Spec; + public readonly CueInstance Outgoing; + public readonly bool OutgoingLive; + public int ElapsedFrames; + public bool Active => Spec.DurationFrames > 0; + + public Transition(DmdTransitionSpec spec, CueInstance outgoing, bool outgoingLive) + { + Spec = spec; + Outgoing = outgoing; + OutgoingLive = outgoingLive; + ElapsedFrames = 0; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs.meta new file mode 100644 index 000000000..c573418a6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5781c845642a75a4faeea0d041580808 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/IDmdFrameSink.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/IDmdFrameSink.cs new file mode 100644 index 000000000..2fb998e87 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/IDmdFrameSink.cs @@ -0,0 +1,17 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +namespace VisualPinball.Unity +{ + public interface IDmdFrameSink + { + void RequestDisplays(RequestedDisplays displays); + void UpdateFrame(DisplayFrameData frame); + void Clear(string displayId); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/IDmdFrameSink.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/IDmdFrameSink.cs.meta new file mode 100644 index 000000000..0556383d9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/IDmdFrameSink.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f152e7b7c04b69944a878d5c911451c6 \ No newline at end of file From c491fa325e453ce06a584b8ca34ab7daf0ed9541 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 15 Jul 2026 01:24:20 +0200 Subject: [PATCH 5/8] dmd: add studio GLE integration --- Samples~/DmdStudioGamelogicEngine/README.md | 15 +++ .../DmdStudioGamelogicEngine/README.md.meta | 7 ++ .../DmdStudioGamelogicEngine/Scripts.meta | 8 ++ .../Scripts/DmdStudioSampleGamelogicEngine.cs | 118 ++++++++++++++++++ .../DmdStudioSampleGamelogicEngine.cs.meta | 2 + .../DmdStudio/DmdCuePlayerTests.cs | 1 + .../DmdStudio/DmdDisplayPlayerTests.cs | 54 ++++++++ .../DmdStudio/GleDisplayEmitterTests.cs | 61 +++++++++ .../DmdStudio/GleDisplayEmitterTests.cs.meta | 2 + .../DmdStudio/Runtime/DmdCuePlayer.cs | 3 + .../DmdStudio/Runtime/GleDisplayEmitter.cs | 54 ++++++++ .../Runtime/GleDisplayEmitter.cs.meta | 2 + package.json | 7 ++ 13 files changed, 334 insertions(+) create mode 100644 Samples~/DmdStudioGamelogicEngine/README.md create mode 100644 Samples~/DmdStudioGamelogicEngine/README.md.meta create mode 100644 Samples~/DmdStudioGamelogicEngine/Scripts.meta create mode 100644 Samples~/DmdStudioGamelogicEngine/Scripts/DmdStudioSampleGamelogicEngine.cs create mode 100644 Samples~/DmdStudioGamelogicEngine/Scripts/DmdStudioSampleGamelogicEngine.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/GleDisplayEmitterTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/GleDisplayEmitterTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/GleDisplayEmitter.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/GleDisplayEmitter.cs.meta diff --git a/Samples~/DmdStudioGamelogicEngine/README.md b/Samples~/DmdStudioGamelogicEngine/README.md new file mode 100644 index 000000000..221b0edea --- /dev/null +++ b/Samples~/DmdStudioGamelogicEngine/README.md @@ -0,0 +1,15 @@ +# DMD Studio Gamelogic Engine + +`DmdStudioSampleGamelogicEngine` is a minimal custom VPE gamelogic engine that connects a +`DmdProjectAsset` to both in-scene displays and the optional DMD bridge. + +1. Import this sample from Package Manager. +2. Add `DmdStudioSampleGamelogicEngine` to the same GameObject as `Player` and select it as the + table's gamelogic engine. +3. Assign a DMD Studio project. The optional base cue defaults to `score`. +4. Call `PlayCue`, `UpdateCue`, or `SetBaseCue` from table logic. All calls must remain on Unity's + main thread. + +The sample uses the real `IGamelogicEngine.OnInit(Player, TableApi, BallManager, CancellationToken)` +signature, forwards format preferences from the DMD bridge, and disposes its `DmdCuePlayer` during +scene teardown. diff --git a/Samples~/DmdStudioGamelogicEngine/README.md.meta b/Samples~/DmdStudioGamelogicEngine/README.md.meta new file mode 100644 index 000000000..52ab9c4f9 --- /dev/null +++ b/Samples~/DmdStudioGamelogicEngine/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f11ad35358d04bd6bf5d89e4006136c2 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/DmdStudioGamelogicEngine/Scripts.meta b/Samples~/DmdStudioGamelogicEngine/Scripts.meta new file mode 100644 index 000000000..7c24bc452 --- /dev/null +++ b/Samples~/DmdStudioGamelogicEngine/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c81ef323fdff4491ab479f7b750153dd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/DmdStudioGamelogicEngine/Scripts/DmdStudioSampleGamelogicEngine.cs b/Samples~/DmdStudioGamelogicEngine/Scripts/DmdStudioSampleGamelogicEngine.cs new file mode 100644 index 000000000..a7c28e7ff --- /dev/null +++ b/Samples~/DmdStudioGamelogicEngine/Scripts/DmdStudioSampleGamelogicEngine.cs @@ -0,0 +1,118 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; + +namespace VisualPinball.Unity.Samples.DmdStudio +{ + /// + /// Minimal custom gamelogic engine showing the complete DMD Studio runtime lifecycle. + /// + [DisallowMultipleComponent] + [AddComponentMenu("Pinball/Gamelogic Engine/DMD Studio Sample")] + public sealed class DmdStudioSampleGamelogicEngine : MonoBehaviour, IGamelogicEngine, + IDisplayFrameFormatPreference + { + [SerializeField] private DmdProjectAsset _dmdProject; + [SerializeField] private string _baseCueId = "score"; + + private DmdCuePlayer _dmd; + + public string Name => "DMD Studio Sample"; + public GamelogicEngineSwitch[] RequestedSwitches { get; } = Array.Empty(); + public GamelogicEngineLamp[] RequestedLamps { get; } = Array.Empty(); + public GamelogicEngineCoil[] RequestedCoils { get; } = Array.Empty(); + public GamelogicEngineWire[] AvailableWires { get; } = Array.Empty(); + +#pragma warning disable CS0067 + public event EventHandler OnCoilChanged; + public event EventHandler OnLampChanged; + public event EventHandler OnLampsChanged; + public event EventHandler OnSwitchChanged; +#pragma warning restore CS0067 + public event EventHandler OnDisplaysRequested; + public event EventHandler OnDisplayClear; + public event EventHandler OnDisplayUpdateFrame; + public event EventHandler OnStarted; + + public Task OnInit(Player player, TableApi tableApi, BallManager ballManager, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (_dmdProject == null) { + Debug.LogError("DMD Studio Sample requires a DmdProjectAsset.", this); + return Task.CompletedTask; + } + + DisposePlayer(); + _dmd = new DmdCuePlayer(_dmdProject, new GleDisplayEmitter( + displays => OnDisplaysRequested?.Invoke(this, displays), + frame => OnDisplayUpdateFrame?.Invoke(this, frame), + id => OnDisplayClear?.Invoke(this, id))); + if (!string.IsNullOrWhiteSpace(_baseCueId)) { + _dmd.SetBase(_baseCueId); + } + _dmd.Start(); + OnStarted?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } + + private void Update() + { + _dmd?.Tick(Time.timeAsDouble); + } + + private void OnDestroy() + { + DisposePlayer(); + } + + public CueHandle PlayCue(string cueId, DmdParams parameters = null) + { + return _dmd != null ? _dmd.Play(cueId, parameters) : default; + } + + public bool UpdateCue(string cueIdOrKey, DmdParams parameters) + { + return _dmd != null && _dmd.UpdateCue(cueIdOrKey, parameters); + } + + public void SetBaseCue(string cueId, DmdParams parameters = null) + { + _dmd?.SetBase(cueId, parameters); + } + + public void RequestDisplayFrameFormat(string displayId, DisplayFrameFormat format) + { + var projectDisplayId = string.IsNullOrWhiteSpace(_dmdProject?.DisplayId) + ? "dmd0" + : _dmdProject.DisplayId; + if (_dmd != null && string.Equals(displayId, projectDisplayId, + StringComparison.OrdinalIgnoreCase)) { + _dmd.RequestFormat(format); + } + } + + private void DisposePlayer() + { + _dmd?.Dispose(); + _dmd = null; + } + + public void DisplayChanged(DisplayFrameData displayFrameData) { } + public void Switch(string id, bool isClosed) { } + public void SetCoil(string id, bool isEnabled) { } + public void SetLamp(string id, float value, bool isCoil = false, LampSource source = LampSource.Lamp) { } + public bool GetSwitch(string id) => false; + public bool GetCoil(string id) => false; + public LampState GetLamp(string id) => default; + } +} diff --git a/Samples~/DmdStudioGamelogicEngine/Scripts/DmdStudioSampleGamelogicEngine.cs.meta b/Samples~/DmdStudioGamelogicEngine/Scripts/DmdStudioSampleGamelogicEngine.cs.meta new file mode 100644 index 000000000..b628f2080 --- /dev/null +++ b/Samples~/DmdStudioGamelogicEngine/Scripts/DmdStudioSampleGamelogicEngine.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 75dfec9bc8e84efdb7ff55e0d48aa450 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs index 8164fe161..920ec9f09 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdCuePlayerTests.cs @@ -333,6 +333,7 @@ public void TickReannouncesOnceAfterDelay() player.Tick(20d); Assert.That(_sink.Requests, Has.Count.EqualTo(2)); + Assert.That(_sink.Frames, Has.Count.EqualTo(2)); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs index 15016c985..82c334953 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdDisplayPlayerTests.cs @@ -16,6 +16,50 @@ namespace VisualPinball.Unity.Test { public class DmdDisplayPlayerTests { + [Test] + public void CuePlayerEventsReachSceneDisplayAndTeardownUnsubscribes() + { + var displayObject = new GameObject("Display"); + var gleObject = new GameObject("GLE"); + var project = ScriptableObject.CreateInstance(); + var displayPlayer = new DisplayPlayer(); + try { + var display = displayObject.AddComponent(); + display.Id = "studio"; + var gle = gleObject.AddComponent(); + displayPlayer.Awake(gle); + project.DisplayId = display.Id; + project.Width = 4; + project.Height = 2; + project.FrameRate = 10; + project.ColorMode = DmdColorMode.Mono16; + var emitter = new GleDisplayEmitter( + displays => Raise(gle, "OnDisplaysRequested", displays), + frame => Raise(gle, "OnDisplayUpdateFrame", frame), + id => Raise(gle, "OnDisplayClear", id)); + + using (var cuePlayer = new DmdCuePlayer(project, emitter)) { + cuePlayer.Start(); + cuePlayer.Tick(0d); + cuePlayer.Tick(DmdCuePlayer.ReannounceDelaySeconds); + + Assert.That(display.ResizeCount, Is.EqualTo(1)); + Assert.That(display.ClearCount, Is.EqualTo(1)); + Assert.That(display.FrameCount, Is.EqualTo(2)); + } + + Assert.That(display.ClearCount, Is.EqualTo(2)); + displayPlayer.OnDestroy(); + Raise(gle, "OnDisplayUpdateFrame", + new DisplayFrameData(display.Id, DisplayFrameFormat.Dmd8, new byte[8])); + Assert.That(display.FrameCount, Is.EqualTo(2)); + } finally { + Object.DestroyImmediate(project); + Object.DestroyImmediate(gleObject); + Object.DestroyImmediate(displayObject); + } + } + [Test] public void ReannouncingAnIdenticalConfigDoesNotResizeOrClear() { @@ -44,12 +88,21 @@ public void ReannouncingAnIdenticalConfigDoesNotResizeOrClear() Object.DestroyImmediate(gameObject); } } + + private static void Raise(DefaultGamelogicEngine gle, string eventName, T args) + { + var field = typeof(DefaultGamelogicEngine).GetField(eventName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, $"Could not find backing field for {eventName}."); + ((System.EventHandler)field.GetValue(gle))?.Invoke(gle, args); + } } public class CountingDisplayComponent : DisplayComponent { public int ResizeCount { get; private set; } public int ClearCount { get; private set; } + public int FrameCount { get; private set; } public override string Id { get; set; } public override Color LitColor { get; set; } @@ -71,6 +124,7 @@ public override void Clear() public override void UpdateFrame(DisplayFrameFormat format, byte[] data) { + FrameCount++; } protected override Material CreateMaterial() => null; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/GleDisplayEmitterTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/GleDisplayEmitterTests.cs new file mode 100644 index 000000000..ded5c9d4b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/GleDisplayEmitterTests.cs @@ -0,0 +1,61 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + public class GleDisplayEmitterTests + { + [Test] + public void ForwardsEachCallWithoutReplacingPayloads() + { + var calls = new List(); + RequestedDisplays receivedDisplays = null; + DisplayFrameData receivedFrame = null; + string receivedClear = null; + var emitter = new GleDisplayEmitter( + displays => { + calls.Add("request"); + receivedDisplays = displays; + }, + frame => { + calls.Add("frame"); + receivedFrame = frame; + }, + id => { + calls.Add("clear"); + receivedClear = id; + }); + var displays = new RequestedDisplays(new DisplayConfig("studio", 140, 36)); + var frame = new DisplayFrameData("studio", DisplayFrameFormat.Dmd8, new byte[140 * 36]); + + emitter.RequestDisplays(displays); + emitter.UpdateFrame(frame); + emitter.Clear("studio"); + + Assert.That(calls, Is.EqualTo(new[] { "request", "frame", "clear" })); + Assert.That(receivedDisplays, Is.SameAs(displays)); + Assert.That(receivedFrame, Is.SameAs(frame)); + Assert.That(receivedClear, Is.EqualTo("studio")); + } + + [Test] + public void RequiresAllRaiseDelegates() + { + Assert.That(() => new GleDisplayEmitter(null, _ => { }, _ => { }), + Throws.ArgumentNullException.With.Property("ParamName").EqualTo("raiseDisplaysRequested")); + Assert.That(() => new GleDisplayEmitter(_ => { }, null, _ => { }), + Throws.ArgumentNullException.With.Property("ParamName").EqualTo("raiseDisplayUpdateFrame")); + Assert.That(() => new GleDisplayEmitter(_ => { }, _ => { }, null), + Throws.ArgumentNullException.With.Property("ParamName").EqualTo("raiseDisplayClear")); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/GleDisplayEmitterTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/GleDisplayEmitterTests.cs.meta new file mode 100644 index 000000000..991e1e571 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/GleDisplayEmitterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8c7a0d11c9a747c4bd04e8b927e05c7c diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs index 34f794fbd..b078d891d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/DmdCuePlayer.cs @@ -220,6 +220,9 @@ public void Tick(double timeSeconds) if (!_reannounced && timeSeconds - _firstTickTime >= ReannounceDelaySeconds) { _sink.RequestDisplays(_requestedDisplays); _reannounced = true; + // A late subscriber also missed the initial frame. Re-emit the current surface once + // so a newly created display pipeline does not remain blank for static content. + _dirty = true; } if (!_hasLastTime) { _hasLastTime = true; diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/GleDisplayEmitter.cs b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/GleDisplayEmitter.cs new file mode 100644 index 000000000..d28b0b7dc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/GleDisplayEmitter.cs @@ -0,0 +1,54 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; + +namespace VisualPinball.Unity +{ + /// + /// Adapts a to the display events exposed by a gamelogic engine. + /// + /// + /// The emitter deliberately holds no display state. Announcement timing, frame reuse, and + /// disposal are owned by ; this type only forwards each sink call. + /// + public sealed class GleDisplayEmitter : IDmdFrameSink + { + private readonly Action _raiseDisplaysRequested; + private readonly Action _raiseDisplayUpdateFrame; + private readonly Action _raiseDisplayClear; + + public GleDisplayEmitter( + Action raiseDisplaysRequested, + Action raiseDisplayUpdateFrame, + Action raiseDisplayClear) + { + _raiseDisplaysRequested = raiseDisplaysRequested ?? + throw new ArgumentNullException(nameof(raiseDisplaysRequested)); + _raiseDisplayUpdateFrame = raiseDisplayUpdateFrame ?? + throw new ArgumentNullException(nameof(raiseDisplayUpdateFrame)); + _raiseDisplayClear = raiseDisplayClear ?? + throw new ArgumentNullException(nameof(raiseDisplayClear)); + } + + public void RequestDisplays(RequestedDisplays displays) + { + _raiseDisplaysRequested(displays); + } + + public void UpdateFrame(DisplayFrameData frame) + { + _raiseDisplayUpdateFrame(frame); + } + + public void Clear(string displayId) + { + _raiseDisplayClear(displayId); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/GleDisplayEmitter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/GleDisplayEmitter.cs.meta new file mode 100644 index 000000000..29dfae7de --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/Runtime/GleDisplayEmitter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 66e9fcf7303e4c7aae282622e842b9f9 diff --git a/package.json b/package.json index a6e127e5d..31e5e268f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,13 @@ "com.unity.ugui": "2.5.0", "com.unity.test-framework": "1.7.0" }, + "samples": [ + { + "displayName": "DMD Studio Gamelogic Engine", + "description": "A custom gamelogic engine that drives DMD Studio projects through the VPE display bus.", + "path": "Samples~/DmdStudioGamelogicEngine" + } + ], "keywords": [ "Pinball", "Unity" From 860e63e2ca4b0f43f1cb07341eb1d55e2785d35e Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 15 Jul 2026 02:08:09 +0200 Subject: [PATCH 6/8] dmd: add studio editor MVP --- .../VisualPinball.Unity.Editor/DmdStudio.meta | 8 + .../DmdStudio/DmdBmFontImporter.cs | 162 +++++ .../DmdStudio/DmdBmFontImporter.cs.meta | 2 + .../DmdStudio/DmdCanvasView.cs | 349 ++++++++++ .../DmdStudio/DmdCanvasView.cs.meta | 2 + .../DmdStudio/DmdImageDecoder.cs | 138 ++++ .../DmdStudio/DmdImageDecoder.cs.meta | 2 + .../DmdStudio/DmdSpriteImporter.cs | 167 +++++ .../DmdStudio/DmdSpriteImporter.cs.meta | 2 + .../DmdStudio/DmdStudioDefaults.cs | 87 +++ .../DmdStudio/DmdStudioDefaults.cs.meta | 2 + .../DmdStudio/DmdStudioWindow.cs | 612 ++++++++++++++++++ .../DmdStudio/DmdStudioWindow.cs.meta | 2 + .../DmdStudio/DmdStudioWindow.uss | 62 ++ .../DmdStudio/DmdStudioWindow.uss.meta | 11 + .../DmdStudio/DmdStudioWindow.uxml | 52 ++ .../DmdStudio/DmdStudioWindow.uxml.meta | 10 + .../DmdStudio/DmdTimelineView.cs | 279 ++++++++ .../DmdStudio/DmdTimelineView.cs.meta | 2 + .../DmdStudio/ProjectTreeView.cs | 179 +++++ .../DmdStudio/ProjectTreeView.cs.meta | 2 + .../DmdStudio/DmdStudioEditorTests.cs | 311 +++++++++ .../DmdStudio/DmdStudioEditorTests.cs.meta | 2 + 23 files changed, 2445 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdImageDecoder.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdImageDecoder.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdSpriteImporter.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdSpriteImporter.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioDefaults.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioDefaults.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.uss create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.uss.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.uxml create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.uxml.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdTimelineView.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdTimelineView.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/ProjectTreeView.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/ProjectTreeView.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdStudioEditorTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DmdStudio/DmdStudioEditorTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio.meta new file mode 100644 index 000000000..ab8902811 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0e46c828d8cb47e49fb9532dd244cff4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs new file mode 100644 index 000000000..97acbfec0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs @@ -0,0 +1,162 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + public static class DmdBmFontImporter + { + private static readonly Regex AttributePattern = new Regex( + @"(?[A-Za-z][A-Za-z0-9]*)=(?""[^""]*""|[^\s]+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static DmdFontAsset Import(DmdProjectAsset project, string descriptorPath, string assetPath) + { + if (project == null) { + throw new ArgumentNullException(nameof(project)); + } + if (string.IsNullOrWhiteSpace(descriptorPath) || !File.Exists(descriptorPath)) { + throw new FileNotFoundException("BMFont text descriptor not found.", descriptorPath); + } + if (string.IsNullOrWhiteSpace(assetPath) || + !assetPath.Replace('\\', '/').StartsWith("Assets/", StringComparison.Ordinal)) { + throw new ArgumentException("Imported assets must be saved below Assets/.", nameof(assetPath)); + } + + var font = ScriptableObject.CreateInstance(); + font.name = Path.GetFileNameWithoutExtension(assetPath); + font.Notes = $"Imported from BMFont descriptor {Path.GetFileName(descriptorPath)}."; + string pageFile = null; + var declaredWidth = 0; + var declaredHeight = 0; + var declaredPages = 0; + try { + foreach (var rawLine in File.ReadLines(descriptorPath)) { + var line = rawLine.Trim(); + if (line.Length == 0) { + continue; + } + var space = line.IndexOf(' '); + var kind = space < 0 ? line : line.Substring(0, space); + var values = ParseAttributes(line); + switch (kind) { + case "common": + font.LineHeight = RequiredInt(values, "lineHeight", kind); + font.Baseline = RequiredInt(values, "base", kind); + declaredWidth = RequiredInt(values, "scaleW", kind); + declaredHeight = RequiredInt(values, "scaleH", kind); + declaredPages = RequiredInt(values, "pages", kind); + break; + case "page": + if (RequiredInt(values, "id", kind) == 0) { + pageFile = Required(values, "file", kind); + } + break; + case "char": + font.Glyphs.Add(new DmdGlyph { + Codepoint = RequiredInt(values, "id", kind), + X = RequiredInt(values, "x", kind), + Y = RequiredInt(values, "y", kind), + W = RequiredInt(values, "width", kind), + H = RequiredInt(values, "height", kind), + OffsetX = RequiredInt(values, "xoffset", kind), + OffsetY = RequiredInt(values, "yoffset", kind), + Advance = RequiredInt(values, "xadvance", kind) + }); + break; + case "kerning": + font.Kerning.Add(new DmdKerningPair { + LeftCodepoint = RequiredInt(values, "first", kind), + RightCodepoint = RequiredInt(values, "second", kind), + Adjustment = RequiredInt(values, "amount", kind) + }); + break; + } + } + + if (declaredPages != 1) { + throw new NotSupportedException("DMD Studio v1 supports single-page BMFont text descriptors only."); + } + if (string.IsNullOrWhiteSpace(pageFile)) { + throw new InvalidDataException("BMFont descriptor does not declare page id 0."); + } + var pagePath = Path.Combine(Path.GetDirectoryName(descriptorPath) ?? string.Empty, pageFile); + var decoded = DmdImageDecoder.DecodePng(pagePath, DmdColorMode.Mono16); + if (decoded.Count != 1) { + throw new InvalidDataException("BMFont atlas must decode to one image."); + } + font.Atlas = decoded[0].Bitmap; + if (font.Atlas.Width != declaredWidth || font.Atlas.Height != declaredHeight) { + throw new InvalidDataException( + $"BMFont atlas is {font.Atlas.Width}x{font.Atlas.Height}, descriptor declares {declaredWidth}x{declaredHeight}."); + } + + var validation = font.Validate(); + if (!validation.IsValid) { + throw new DmdValidationException(validation.Diagnostics); + } + + var createdPath = AssetDatabase.GenerateUniqueAssetPath(assetPath); + AssetDatabase.CreateAsset(font, createdPath); + Undo.RegisterCreatedObjectUndo(font, "Import DMD font"); + Undo.RecordObject(project, "Add DMD font"); + project.Fonts ??= new List(); + project.Fonts.Add(font); + EditorUtility.SetDirty(project); + AssetDatabase.SaveAssets(); + return font; + } catch { + project.Fonts?.Remove(font); + EditorUtility.SetDirty(project); + if (AssetDatabase.Contains(font)) { + AssetDatabase.DeleteAsset(AssetDatabase.GetAssetPath(font)); + } else { + UnityEngine.Object.DestroyImmediate(font); + } + throw; + } + } + + private static Dictionary ParseAttributes(string line) + { + var values = new Dictionary(StringComparer.Ordinal); + foreach (Match match in AttributePattern.Matches(line)) { + var value = match.Groups["value"].Value; + if (value.Length >= 2 && value[0] == '"' && value[value.Length - 1] == '"') { + value = value.Substring(1, value.Length - 2); + } + values[match.Groups["name"].Value] = value; + } + return values; + } + + private static int RequiredInt(IReadOnlyDictionary values, string name, string line) + { + var value = Required(values, name, line); + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { + throw new InvalidDataException($"BMFont {line} has invalid integer {name}=\"{value}\"."); + } + return parsed; + } + + private static string Required(IReadOnlyDictionary values, string name, string line) + { + if (!values.TryGetValue(name, out var value)) { + throw new InvalidDataException($"BMFont {line} is missing {name}."); + } + return value; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs.meta new file mode 100644 index 000000000..ec82d8e51 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 79f61cc41400447d8bcc7fe86b2ac63f diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs new file mode 100644 index 000000000..02bebd13b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs @@ -0,0 +1,349 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace VisualPinball.Unity.Editor +{ + public enum DmdCanvasMode + { + Raw, + Dots, + } + + public sealed class DmdCanvasView : VisualElement, IDisposable + { + private readonly Image _image; + private Texture2D _texture; + private DmdProjectAsset _project; + private DmdCueAsset _selectedCue; + private DmdLayer _selectedLayer; + private DmdCanvasMode _mode; + private int _sourceWidth; + private int _sourceHeight; + private Vector2 _dragStart; + private int _dragX; + private int _dragY; + private bool _dragging; + private float _zoom = 1f; + private Vector2 _pan; + private bool _panning; + private Vector2 _panStart; + private Vector2 _panOrigin; + + public Texture2D PreviewTexture => _texture; + public event Action LayerPositionChanged; + + public DmdCanvasView() + { + name = "dmd-canvas"; + focusable = true; + style.flexGrow = 1; + style.minHeight = 120; + style.backgroundColor = new Color(0.035f, 0.035f, 0.035f); + style.overflow = Overflow.Hidden; + _image = new Image { + scaleMode = ScaleMode.ScaleToFit, + pickingMode = PickingMode.Ignore + }; + _image.style.position = Position.Absolute; + Add(_image); + generateVisualContent += DrawOverlay; + RegisterCallback(OnPointerDown); + RegisterCallback(OnPointerMove); + RegisterCallback(OnPointerUp); + RegisterCallback(OnWheel); + RegisterCallback(_ => UpdateImageLayout()); + RegisterCallback(_ => ReleaseTexture()); + } + + public void SetSelection(DmdCueAsset cue, DmdLayer layer) + { + _selectedCue = cue; + _selectedLayer = layer; + MarkDirtyRepaint(); + } + + public void Dispose() + { + ReleaseTexture(); + } + + public void SetFrame(DmdSurface surface, DmdProjectAsset project, DmdCanvasMode mode, bool tint) + { + if (surface == null) { + throw new ArgumentNullException(nameof(surface)); + } + _project = project ?? throw new ArgumentNullException(nameof(project)); + _mode = mode; + _sourceWidth = surface.Width; + _sourceHeight = surface.Height; + var scale = mode == DmdCanvasMode.Dots ? 8 : 1; + var width = checked(surface.Width * scale); + var height = checked(surface.Height * scale); + EnsureTexture(width, height); + var colors = new Color32[checked(width * height)]; + if (mode == DmdCanvasMode.Dots) { + for (var index = 0; index < colors.Length; index++) { + colors[index] = new Color32(0, 0, 0, 255); + } + } + for (var sourceY = 0; sourceY < surface.Height; sourceY++) { + for (var sourceX = 0; sourceX < surface.Width; sourceX++) { + var color = ReadColor(surface, sourceX, sourceY, project, tint); + for (var localY = 0; localY < scale; localY++) { + for (var localX = 0; localX < scale; localX++) { + if (mode == DmdCanvasMode.Dots && !InsideDot(localX, localY)) { + continue; + } + var topY = sourceY * scale + localY; + var textureY = height - 1 - topY; + colors[textureY * width + sourceX * scale + localX] = color; + } + } + } + } + _texture.SetPixels32(colors); + _texture.Apply(false, false); + _image.image = _texture; + UpdateImageLayout(); + MarkDirtyRepaint(); + } + + private static Color32 ReadColor(DmdSurface surface, int x, int y, DmdProjectAsset project, bool tint) + { + var index = y * surface.Width + x; + if (surface.Format == DmdPixelFormat.Rgb24) { + var offset = index * 3; + return new Color32(surface.Data[offset], surface.Data[offset + 1], surface.Data[offset + 2], 255); + } + var value = surface.Data[index]; + if (!tint) { + return new Color32(value, value, value, 255); + } + var color = Color.Lerp(Color.black, project.PreviewTint, value / 255f); + color.a = 1f; + return color; + } + + private static bool InsideDot(int x, int y) + { + var dx = x - 3.5f; + var dy = y - 3.5f; + return dx * dx + dy * dy <= 10.6f; + } + + private void EnsureTexture(int width, int height) + { + if (_texture != null && _texture.width == width && _texture.height == height) { + return; + } + ReleaseTexture(); + _texture = new Texture2D(width, height, TextureFormat.RGBA32, false, true) { + name = "DMD Studio Preview", + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + hideFlags = HideFlags.HideAndDontSave + }; + } + + private void ReleaseTexture() + { + if (_texture == null) { + return; + } + UnityEngine.Object.DestroyImmediate(_texture); + _texture = null; + _image.image = null; + } + + private void DrawOverlay(MeshGenerationContext context) + { + if (_texture == null || _sourceWidth == 0 || _sourceHeight == 0) { + return; + } + var rect = PreviewRect(); + var painter = context.painter2D; + painter.strokeColor = new Color(1f, 1f, 1f, 0.12f); + painter.lineWidth = 1f; + if (_mode == DmdCanvasMode.Raw && rect.width / _sourceWidth >= 4f) { + for (var x = 1; x < _sourceWidth; x++) { + var px = rect.x + rect.width * x / _sourceWidth; + painter.BeginPath(); + painter.MoveTo(new Vector2(px, rect.y)); + painter.LineTo(new Vector2(px, rect.yMax)); + painter.Stroke(); + } + for (var y = 1; y < _sourceHeight; y++) { + var py = rect.y + rect.height * y / _sourceHeight; + painter.BeginPath(); + painter.MoveTo(new Vector2(rect.x, py)); + painter.LineTo(new Vector2(rect.xMax, py)); + painter.Stroke(); + } + } + if (_selectedLayer != null) { + var bounds = LayerBounds(_selectedLayer); + var left = rect.x + rect.width * bounds.x / _sourceWidth; + var top = rect.y + rect.height * bounds.y / _sourceHeight; + var right = rect.x + rect.width * (bounds.x + bounds.width) / _sourceWidth; + var bottom = rect.y + rect.height * (bounds.y + bounds.height) / _sourceHeight; + painter.strokeColor = new Color(0.15f, 0.75f, 1f, 0.95f); + painter.lineWidth = 2f; + painter.BeginPath(); + painter.MoveTo(new Vector2(left, top)); + painter.LineTo(new Vector2(right, top)); + painter.LineTo(new Vector2(right, bottom)); + painter.LineTo(new Vector2(left, bottom)); + painter.ClosePath(); + painter.Stroke(); + } + } + + private Rect PreviewRect() + { + var available = contentRect; + available.xMin += 8; + available.xMax -= 8; + available.yMin += 8; + available.yMax -= 8; + var aspect = (float)_sourceWidth / _sourceHeight; + var width = System.Math.Max(1f, available.width); + var height = width / aspect; + if (height > available.height) { + height = System.Math.Max(1f, available.height); + width = height * aspect; + } + width *= _zoom; + height *= _zoom; + return new Rect(available.center.x - width * 0.5f + _pan.x, + available.center.y - height * 0.5f + _pan.y, width, height); + } + + private void UpdateImageLayout() + { + if (_sourceWidth == 0 || _sourceHeight == 0) { + return; + } + var rect = PreviewRect(); + _image.style.left = rect.x; + _image.style.top = rect.y; + _image.style.width = rect.width; + _image.style.height = rect.height; + } + + private RectInt LayerBounds(DmdLayer layer) + { + var width = 1; + var height = 1; + if (layer is BitmapLayer bitmap && bitmap.Sprite?.Frames?.Count > 0 && bitmap.Sprite.Frames[0] != null) { + width = bitmap.Sprite.Frames[0].Width; + height = bitmap.Sprite.Frames[0].Height; + } else if (layer is ShapeLayer shape) { + width = System.Math.Max(1, shape.Width); + height = System.Math.Max(1, shape.Height); + } else if (layer is TextLayer text && text.Font != null) { + width = System.Math.Max(1, DmdTextRenderer.Measure(text.Font, text.Text ?? string.Empty)); + height = System.Math.Max(1, text.Font.LineHeight); + } + return new RectInt(layer.X, layer.Y, width, height); + } + + private void OnPointerDown(PointerDownEvent evt) + { + if (evt.button == 2 || evt.button == 0 && evt.altKey) { + _panStart = new Vector2(evt.localPosition.x, evt.localPosition.y); + _panOrigin = _pan; + _panning = true; + this.CapturePointer(evt.pointerId); + evt.StopPropagation(); + return; + } + if (evt.button != 0 || _selectedLayer == null || _selectedCue == null || !PreviewRect().Contains(evt.localPosition)) { + return; + } + _dragStart = evt.localPosition; + _dragX = _selectedLayer.X; + _dragY = _selectedLayer.Y; + Undo.RecordObject(_selectedCue, "Move DMD layer"); + _dragging = true; + this.CapturePointer(evt.pointerId); + evt.StopPropagation(); + } + + private void OnPointerMove(PointerMoveEvent evt) + { + if (_panning && this.HasPointerCapture(evt.pointerId)) { + var position = new Vector2(evt.localPosition.x, evt.localPosition.y); + _pan = _panOrigin + position - _panStart; + UpdateImageLayout(); + MarkDirtyRepaint(); + return; + } + if (!_dragging || !this.HasPointerCapture(evt.pointerId)) { + return; + } + var rect = PreviewRect(); + var dx = Mathf.RoundToInt((evt.localPosition.x - _dragStart.x) * _sourceWidth / rect.width); + var dy = Mathf.RoundToInt((evt.localPosition.y - _dragStart.y) * _sourceHeight / rect.height); + if (_selectedLayer.X == _dragX + dx && _selectedLayer.Y == _dragY + dy) { + return; + } + _selectedLayer.X = _dragX + dx; + _selectedLayer.Y = _dragY + dy; + EditorUtility.SetDirty(_selectedCue); + LayerPositionChanged?.Invoke(); + MarkDirtyRepaint(); + } + + private void OnPointerUp(PointerUpEvent evt) + { + if (_panning && (evt.button == 2 || evt.button == 0)) { + _panning = false; + if (this.HasPointerCapture(evt.pointerId)) { + this.ReleasePointer(evt.pointerId); + } + return; + } + if (!_dragging || evt.button != 0) { + return; + } + _dragging = false; + if (this.HasPointerCapture(evt.pointerId)) { + this.ReleasePointer(evt.pointerId); + } + } + + private void OnWheel(WheelEvent evt) + { + if (_sourceWidth == 0 || _sourceHeight == 0) { + return; + } + var oldRect = PreviewRect(); + var pointer = new Vector2(evt.localMousePosition.x, evt.localMousePosition.y); + var normalized = new Vector2((pointer.x - oldRect.x) / oldRect.width, + (pointer.y - oldRect.y) / oldRect.height); + var factor = evt.delta.y > 0f ? 0.8f : 1.25f; + var next = Mathf.Clamp(_zoom * factor, 0.5f, 16f); + if (Mathf.Approximately(next, _zoom)) { + return; + } + _zoom = next; + var nextRect = PreviewRect(); + var nextPoint = new Vector2(nextRect.x + normalized.x * nextRect.width, + nextRect.y + normalized.y * nextRect.height); + _pan += pointer - nextPoint; + UpdateImageLayout(); + MarkDirtyRepaint(); + evt.StopPropagation(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs.meta new file mode 100644 index 000000000..00bb136b6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3660adca091c473cb9c6adca730657eb diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdImageDecoder.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdImageDecoder.cs new file mode 100644 index 000000000..4630bdbb7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdImageDecoder.cs @@ -0,0 +1,138 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + internal sealed class DmdDecodedImage + { + public DmdBitmapData Bitmap; + public bool ContainsColor; + public int DistinctIntensities; + public int[] IntensityHistogram; + } + + internal static class DmdImageDecoder + { + public static List DecodePng(string path, DmdColorMode colorMode, + int cellWidth = 0, int cellHeight = 0, byte alphaThreshold = 0) + { + if (string.IsNullOrWhiteSpace(path)) { + throw new ArgumentException("A PNG path is required.", nameof(path)); + } + if (!File.Exists(path)) { + throw new FileNotFoundException("PNG file not found.", path); + } + + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, true); + try { + if (!ImageConversion.LoadImage(texture, File.ReadAllBytes(path), false)) { + throw new InvalidDataException($"Could not decode PNG \"{path}\"."); + } + return Decode(texture.GetPixels32(), texture.width, texture.height, colorMode, + cellWidth, cellHeight, alphaThreshold); + } finally { + UnityEngine.Object.DestroyImmediate(texture); + } + } + + private static List Decode(Color32[] source, int sourceWidth, int sourceHeight, + DmdColorMode colorMode, int cellWidth, int cellHeight, byte alphaThreshold) + { + if (cellWidth == 0 && cellHeight == 0) { + cellWidth = sourceWidth; + cellHeight = sourceHeight; + } else if (cellWidth <= 0 || cellHeight <= 0) { + throw new ArgumentException("Sprite-sheet cell width and height must both be positive."); + } + if (cellWidth > DmdValidation.MaxWidth || cellHeight > DmdValidation.MaxHeight) { + throw new ArgumentOutOfRangeException(nameof(cellWidth), + $"DMD images cannot exceed {DmdValidation.MaxWidth}x{DmdValidation.MaxHeight} pixels."); + } + if (sourceWidth % cellWidth != 0 || sourceHeight % cellHeight != 0) { + throw new ArgumentException( + $"PNG dimensions {sourceWidth}x{sourceHeight} are not divisible by cell size {cellWidth}x{cellHeight}."); + } + + var columns = sourceWidth / cellWidth; + var rows = sourceHeight / cellHeight; + var frameCount = checked(columns * rows); + if (frameCount > DmdValidation.MaxSpriteFrames) { + throw new ArgumentException($"A sprite cannot exceed {DmdValidation.MaxSpriteFrames} frames."); + } + + var decoded = new List(frameCount); + for (var row = 0; row < rows; row++) { + for (var column = 0; column < columns; column++) { + decoded.Add(DecodeCell(source, sourceWidth, sourceHeight, column * cellWidth, + row * cellHeight, cellWidth, cellHeight, colorMode, alphaThreshold)); + } + } + return decoded; + } + + private static DmdDecodedImage DecodeCell(Color32[] source, int sourceWidth, int sourceHeight, + int cellX, int cellYFromTop, int width, int height, DmdColorMode colorMode, byte alphaThreshold) + { + var rgb = colorMode == DmdColorMode.Rgb24; + var bitmap = new DmdBitmapData { + Width = width, + Height = height, + Format = rgb ? DmdPixelFormat.Rgb24 : DmdPixelFormat.I8, + Pixels = new byte[checked(width * height * (rgb ? 3 : 1))], + Alpha = new byte[checked(width * height)] + }; + var histogram = new int[256]; + var containsColor = false; + for (var y = 0; y < height; y++) { + var sourceY = sourceHeight - 1 - (cellYFromTop + y); + for (var x = 0; x < width; x++) { + var color = source[sourceY * sourceWidth + cellX + x]; + var target = y * width + x; + containsColor |= color.r != color.g || color.g != color.b; + var luma = Rec601(color); + histogram[luma]++; + if (rgb) { + var offset = target * 3; + bitmap.Pixels[offset] = color.r; + bitmap.Pixels[offset + 1] = color.g; + bitmap.Pixels[offset + 2] = color.b; + } else { + bitmap.Pixels[target] = luma; + } + bitmap.Alpha[target] = alphaThreshold == 0 + ? color.a + : color.a >= alphaThreshold ? byte.MaxValue : byte.MinValue; + } + } + + var distinct = 0; + for (var intensity = 0; intensity < histogram.Length; intensity++) { + if (histogram[intensity] > 0) { + distinct++; + } + } + return new DmdDecodedImage { + Bitmap = bitmap, + ContainsColor = containsColor, + DistinctIntensities = distinct, + IntensityHistogram = histogram + }; + } + + private static byte Rec601(Color32 color) + { + return (byte)System.Math.Round(color.r * 0.299d + color.g * 0.587d + color.b * 0.114d, + MidpointRounding.AwayFromZero); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdImageDecoder.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdImageDecoder.cs.meta new file mode 100644 index 000000000..a5bbf59c4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdImageDecoder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e079706b984d457292dbde006ba95b13 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdSpriteImporter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdSpriteImporter.cs new file mode 100644 index 000000000..305d5f9a3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdSpriteImporter.cs @@ -0,0 +1,167 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + public struct DmdSpriteImportOptions + { + public int CellWidth; + public int CellHeight; + public int DefaultFrameDuration; + public byte AlphaThreshold; + + public static DmdSpriteImportOptions Default => new DmdSpriteImportOptions { + DefaultFrameDuration = 1 + }; + } + + public sealed class DmdSpriteImportResult + { + public DmdSpriteAsset Sprite { get; internal set; } + public List Warnings { get; } = new List(); + public int MaxDistinctIntensities { get; internal set; } + public int[] ShadeHistogram { get; } = new int[256]; + } + + public static class DmdSpriteImporter + { + [MenuItem("Assets/Create/Pinball/DMD/Sprite from Image…", false, 314)] + private static void ImportSelectedProject() + { + var project = Selection.activeObject as DmdProjectAsset; + if (project == null) { + EditorUtility.DisplayDialog("DMD Sprite Import", + "Select a DmdProjectAsset before importing a sprite.", "OK"); + return; + } + var source = EditorUtility.OpenFilePanel("Import DMD Sprite", string.Empty, "png"); + if (string.IsNullOrEmpty(source)) { + return; + } + var destination = DefaultAssetPath(project, Path.GetFileNameWithoutExtension(source)); + var result = Import(project, new[] { source }, destination, DmdSpriteImportOptions.Default); + Selection.activeObject = result.Sprite; + ReportWarnings(result.Warnings); + } + + public static DmdSpriteImportResult Import(DmdProjectAsset project, IReadOnlyList sourcePaths, + string assetPath, DmdSpriteImportOptions options) + { + if (project == null) { + throw new ArgumentNullException(nameof(project)); + } + if (sourcePaths == null || sourcePaths.Count == 0) { + throw new ArgumentException("At least one PNG is required.", nameof(sourcePaths)); + } + ValidateAssetPath(assetPath); + if (options.DefaultFrameDuration <= 0) { + options.DefaultFrameDuration = 1; + } + + var result = new DmdSpriteImportResult(); + var frames = new List(); + var convertedColor = false; + for (var sourceIndex = 0; sourceIndex < sourcePaths.Count; sourceIndex++) { + var decoded = DmdImageDecoder.DecodePng(sourcePaths[sourceIndex], project.ColorMode, + options.CellWidth, options.CellHeight, options.AlphaThreshold); + if (frames.Count + decoded.Count > DmdValidation.MaxSpriteFrames) { + throw new ArgumentException($"A sprite cannot exceed {DmdValidation.MaxSpriteFrames} frames."); + } + foreach (var image in decoded) { + frames.Add(image.Bitmap); + convertedColor |= project.ColorMode != DmdColorMode.Rgb24 && image.ContainsColor; + result.MaxDistinctIntensities = System.Math.Max(result.MaxDistinctIntensities, + image.DistinctIntensities); + for (var shade = 0; shade < result.ShadeHistogram.Length; shade++) { + result.ShadeHistogram[shade] += image.IntensityHistogram[shade]; + } + if (image.Bitmap.Width != project.Width || image.Bitmap.Height != project.Height) { + AddWarning(result, $"Frame size {image.Bitmap.Width}x{image.Bitmap.Height} differs from the project canvas {project.Width}x{project.Height}."); + } + } + } + + if (convertedColor) { + AddWarning(result, "RGB source pixels were converted to Rec.601 luminance for this mono project."); + } + var shadeCount = project.ColorMode == DmdColorMode.Mono4 ? 4 : 16; + if (project.ColorMode != DmdColorMode.Rgb24 && result.MaxDistinctIntensities > shadeCount) { + AddWarning(result, + $"Source contains {result.MaxDistinctIntensities} intensity levels; the project supports {shadeCount} shades."); + } + + var sprite = ScriptableObject.CreateInstance(); + sprite.name = Path.GetFileNameWithoutExtension(assetPath); + sprite.Frames.AddRange(frames); + for (var frame = 0; frame < frames.Count; frame++) { + sprite.FrameDurations.Add(options.DefaultFrameDuration); + } + var validation = sprite.Validate(); + if (!validation.IsValid) { + UnityEngine.Object.DestroyImmediate(sprite); + throw new DmdValidationException(validation.Diagnostics); + } + + var createdPath = AssetDatabase.GenerateUniqueAssetPath(assetPath); + try { + AssetDatabase.CreateAsset(sprite, createdPath); + Undo.RegisterCreatedObjectUndo(sprite, "Import DMD sprite"); + Undo.RecordObject(project, "Add DMD sprite"); + project.Sprites ??= new List(); + project.Sprites.Add(sprite); + EditorUtility.SetDirty(project); + AssetDatabase.SaveAssets(); + result.Sprite = sprite; + return result; + } catch { + project.Sprites?.Remove(sprite); + EditorUtility.SetDirty(project); + if (!AssetDatabase.DeleteAsset(createdPath) && sprite != null) { + UnityEngine.Object.DestroyImmediate(sprite); + } + throw; + } + } + + internal static string DefaultAssetPath(DmdProjectAsset project, string name) + { + var projectPath = AssetDatabase.GetAssetPath(project); + var folder = string.IsNullOrEmpty(projectPath) ? "Assets" : Path.GetDirectoryName(projectPath); + return $"{folder?.Replace('\\', '/')}/{name}.asset"; + } + + internal static void ReportWarnings(IReadOnlyList warnings) + { + if (warnings == null || warnings.Count == 0) { + return; + } + Debug.LogWarning("DMD import warnings:\n" + string.Join("\n", warnings)); + } + + private static void AddWarning(DmdSpriteImportResult result, string warning) + { + if (!result.Warnings.Contains(warning)) { + result.Warnings.Add(warning); + } + } + + private static void ValidateAssetPath(string assetPath) + { + if (string.IsNullOrWhiteSpace(assetPath) || + !assetPath.Replace('\\', '/').StartsWith("Assets/", StringComparison.Ordinal)) { + throw new ArgumentException("Imported assets must be saved below Assets/.", nameof(assetPath)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdSpriteImporter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdSpriteImporter.cs.meta new file mode 100644 index 000000000..39240b57b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdSpriteImporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1f45417c3d004a23913f402c0ef45a1a diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioDefaults.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioDefaults.cs new file mode 100644 index 000000000..755cec5c4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioDefaults.cs @@ -0,0 +1,87 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using UnityEditor; + +namespace VisualPinball.Unity.Editor +{ + public static class DmdStudioDefaults + { + public static bool EnsureSampleStates(DmdProjectAsset project) + { + if (project == null) { + throw new ArgumentNullException(nameof(project)); + } + project.SampleStates ??= new List(); + var changed = false; + for (var players = 1; players <= 4; players++) { + changed |= AddIfMissing(project, State($"{players} Player{(players == 1 ? string.Empty : "s")}", + DmdParamValue.From("player", 1L), DmdParamValue.From("players", (long)players), + DmdParamValue.From("score", 1234560L))); + } + changed |= AddIfMissing(project, State("Huge Score", + DmdParamValue.From("score", 9_999_999_990L))); + changed |= AddIfMissing(project, State("Expired Timer", DmdParamValue.From("timer", 0L))); + changed |= AddIfMissing(project, State("Missing Text")); + changed |= AddIfMissing(project, State("Empty Text", DmdParamValue.From("text", string.Empty))); + if (changed) { + EditorUtility.SetDirty(project); + } + return changed; + } + + public static DmdParams ToParams(DmdSampleState state) + { + var parameters = new DmdParams(); + if (state?.Values == null) { + return parameters; + } + foreach (var value in state.Values) { + if (string.IsNullOrWhiteSpace(value.Name)) { + continue; + } + switch (value.Type) { + case DmdParamType.Integer: + parameters.Set(value.Name, value.IntValue); + break; + case DmdParamType.Float: + parameters.Set(value.Name, value.FloatValue); + break; + case DmdParamType.String: + parameters.Set(value.Name, value.StringValue); + break; + case DmdParamType.Boolean: + parameters.Set(value.Name, value.BoolValue); + break; + } + } + return parameters; + } + + private static bool AddIfMissing(DmdProjectAsset project, DmdSampleState state) + { + foreach (var existing in project.SampleStates) { + if (existing != null && string.Equals(existing.Name, state.Name, StringComparison.Ordinal)) { + return false; + } + } + project.SampleStates.Add(state); + return true; + } + + private static DmdSampleState State(string name, params DmdParamValue[] values) + { + return new DmdSampleState { + Name = name, + Values = new List(values) + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioDefaults.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioDefaults.cs.meta new file mode 100644 index 000000000..aa2238943 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioDefaults.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e110ed35e67b43a4bf464205495a866d diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.cs new file mode 100644 index 000000000..b1342cee9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.cs @@ -0,0 +1,612 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace VisualPinball.Unity.Editor +{ + public sealed class DmdStudioWindow : EditorWindow + { + private const string PackageRoot = + "Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/"; + + [SerializeField] private DmdProjectAsset _project; + [SerializeField] private DmdCueAsset _selectedCue; + [SerializeField] private UnityEngine.Object _selectedAsset; + [SerializeField] private int _selectedLayerIndex = -1; + [SerializeField] private int _sampleStateIndex; + [SerializeField] private int _frame; + [SerializeField] private DmdCanvasMode _canvasMode; + [SerializeField] private bool _tint = true; + [SerializeField] private bool _mirrorToScene; + + private ObjectField _projectField; + private DmdProjectTreeView _projectTree; + private VisualElement _inspectorHost; + private Label _inspectorTitle; + private VisualElement _sampleStateHost; + private PopupField _sampleStatePopup; + private Label _frameLabel; + private Label _status; + private DropdownField _canvasModeField; + private Toggle _tintToggle; + private Toggle _mirrorToggle; + private IntegerField _cellWidth; + private IntegerField _cellHeight; + private ToolbarButton _playButton; + private DmdCanvasView _canvas; + private DmdTimelineView _timeline; + private SerializedObject _inspectedObject; + private CueRenderer _renderer; + private CueInstanceState _previewState = new CueInstanceState(); + private CueDiagnostics _diagnostics = new CueDiagnostics(); + private bool _playing; + private double _lastUpdateTime; + private double _frameAccumulator; + private int _lastRenderedFrame = -1; + + [MenuItem("Pinball/DMD Studio", false, 410)] + public static void ShowWindow() + { + var window = GetWindow(); + window.titleContent = new GUIContent("DMD Studio"); + window.minSize = new Vector2(900, 480); + } + + private void OnEnable() + { + EditorApplication.update += OnEditorUpdate; + Undo.undoRedoPerformed += OnUndoRedo; + _lastUpdateTime = EditorApplication.timeSinceStartup; + } + + private void OnDisable() + { + EditorApplication.update -= OnEditorUpdate; + Undo.undoRedoPerformed -= OnUndoRedo; + _canvas?.Dispose(); + } + + public void CreateGUI() + { + var tree = AssetDatabase.LoadAssetAtPath(PackageRoot + "DmdStudioWindow.uxml"); + if (tree == null) { + throw new InvalidOperationException("Could not load DMD Studio UXML."); + } + tree.CloneTree(rootVisualElement); + var style = AssetDatabase.LoadAssetAtPath(PackageRoot + "DmdStudioWindow.uss"); + if (style != null) { + rootVisualElement.styleSheets.Add(style); + } + + _projectField = rootVisualElement.Q("project-field"); + _projectField.objectType = typeof(DmdProjectAsset); + _projectField.SetValueWithoutNotify(_project); + _projectField.RegisterValueChangedCallback(evt => SetProject(evt.newValue as DmdProjectAsset)); + _projectTree = new DmdProjectTreeView(); + _projectTree.ItemSelected += OnTreeSelectionChanged; + rootVisualElement.Q("project-tree-host").Add(_projectTree); + + _inspectorHost = rootVisualElement.Q("inspector-host"); + _inspectorTitle = rootVisualElement.Q