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/Documentation~/creators-guide/editor/dmd-studio.md b/VisualPinball.Unity/Documentation~/creators-guide/editor/dmd-studio.md
new file mode 100644
index 000000000..1bf962bac
--- /dev/null
+++ b/VisualPinball.Unity/Documentation~/creators-guide/editor/dmd-studio.md
@@ -0,0 +1,38 @@
+# DMD Studio
+
+DMD Studio is available from **Pinball > DMD Studio**. Create or select a `DmdProjectAsset`, add cues and layers,
+then use the sample-state selector to preview live bindings without entering play mode.
+
+## Authoring
+
+- Drag layer bodies and end handles in the timeline to move or resize their spans. Drag the shaded enter and exit
+ boundaries in the ruler to set transition durations.
+- Select an animatable property and use **Add Key** to add a key at the playhead. Existing key diamonds can be
+ dragged. Sprite-frame durations are editable in the Pixel / Glyph Editor.
+- `NumberLayer.CountUpSeconds` enables deterministic count-up animation. Set a text layer's overflow to `Marquee`
+ and give it a positive speed to scroll clipped text.
+- The Pixel / Glyph Editor supports pencil, eraser, fill, rectangle, ramp or palette shades, and transparent pixels.
+ Select a font to touch up glyph pixels and metrics. All bitmap edits participate in Unity undo.
+- Validation is continuously refreshed after authoring operations. The Cue Simulator accepts lines such as
+ `t=0 Play(jackpot, value=100000)` and plots the base, active, held, and queued scheduler lanes.
+
+## Fonts
+
+V1 imports single-page BMFont text descriptors and their PNG atlas. DMD Studio explicitly converts PNG rows to its
+top-origin coordinate convention. TTF/OTF baking is intentionally external because Unity 6000.5 does not expose the
+required glyph rasterization API publicly.
+
+**Add Starter Fonts** adds four package assets with tabular digits and ASCII `0x20`–`0x7E` plus `©®™•×`:
+
+| Asset | Approximate pixel size | Source |
+|---|---:|---|
+| VpeMicro5 | 5 px | Tiny5 |
+| VpeMicro7 | 7 px | Tiny5 |
+| VpeArcade9 | 9 px | Press Start 2P |
+| VpeArcade15 | 15 px | Press Start 2P |
+
+The atlases were baked with Pillow 11.3.0 from Google Fonts commit
+`03781cf7a714af8431d14b6f337f923c774429d7`, using `ofl/tiny5/Tiny5-Regular.ttf` and
+`ofl/pressstart2p/PressStart2P-Regular.ttf`. Glyph masks use a 128/255 threshold and are stored in
+top-origin atlases. Both sources are SIL Open Font License 1.1. The exact copyright and license text is shipped beside
+the generated assets in `DmdStudio/StarterFonts`; every `DmdFontAsset.Notes` field repeats its source and attribution.
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml
index 2384a7fd8..f0a6b9df7 100644
--- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml
+++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml
@@ -30,6 +30,8 @@
href: editor/lamp-manager.md
- name: Wire Manager
href: editor/wire-manager.md
+ - name: DMD Studio
+ href: editor/dmd-studio.md
- name: Multiple Tables
href: editor/multiple-tables.md
- name: Advanced Topics
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..b1627fc6b
--- /dev/null
+++ b/VisualPinball.Unity/Documentation~/developer-guide/dmd-studio-readiness.md
@@ -0,0 +1,93 @@
+---
+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.
+
+## 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/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
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..94e7c8bdb
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdBmFontImporter.cs
@@ -0,0 +1,178 @@
+// 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;
+ UseLuminanceMaskWhenAtlasIsOpaque(font.Atlas);
+ 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 void UseLuminanceMaskWhenAtlasIsOpaque(DmdBitmapData atlas)
+ {
+ if (atlas?.Alpha == null || atlas.Alpha.Length == 0) {
+ return;
+ }
+ for (var index = 0; index < atlas.Alpha.Length; index++) {
+ if (atlas.Alpha[index] != byte.MaxValue) {
+ return;
+ }
+ }
+ // Classic BMFont pages are commonly opaque white glyphs on black. In that case alpha
+ // contains no mask information, so DmdTextRenderer must use atlas luminance instead.
+ atlas.Alpha = Array.Empty();
+ }
+
+ 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..d64659e8c
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCanvasView.cs
@@ -0,0 +1,370 @@
+// 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;
+ private Color32[] _colors = Array.Empty();
+
+ 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 colorCount = checked(width * height);
+ if (_colors.Length != colorCount) {
+ _colors = new Color32[colorCount];
+ }
+ 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;
+ var x = layer.X;
+ var y = layer.Y;
+ 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);
+ var center = text.Anchor == DmdAnchor.TopCenter || text.Anchor == DmdAnchor.MiddleCenter ||
+ text.Anchor == DmdAnchor.BottomCenter || text.Anchor == DmdAnchor.BaselineCenter;
+ var right = text.Anchor == DmdAnchor.TopRight || text.Anchor == DmdAnchor.MiddleRight ||
+ text.Anchor == DmdAnchor.BottomRight || text.Anchor == DmdAnchor.BaselineRight;
+ if (center) x -= width / 2;
+ else if (right) x -= width;
+ var middle = text.Anchor == DmdAnchor.MiddleLeft || text.Anchor == DmdAnchor.MiddleCenter ||
+ text.Anchor == DmdAnchor.MiddleRight;
+ var bottom = text.Anchor == DmdAnchor.BottomLeft || text.Anchor == DmdAnchor.BottomCenter ||
+ text.Anchor == DmdAnchor.BottomRight;
+ var baseline = text.Anchor == DmdAnchor.BaselineLeft || text.Anchor == DmdAnchor.BaselineCenter ||
+ text.Anchor == DmdAnchor.BaselineRight;
+ if (middle) y -= height / 2;
+ else if (bottom) y -= height;
+ else if (baseline) y -= Mathf.Clamp(text.Font.Baseline, 0, height);
+ }
+ return new RectInt(x, 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/DmdCueSimulator.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCueSimulator.cs
new file mode 100644
index 000000000..2387f63a6
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCueSimulator.cs
@@ -0,0 +1,348 @@
+// 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.Linq;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ public readonly struct DmdCueSimulationSample
+ {
+ public double Time { get; }
+ public DmdCuePlayerSnapshot Snapshot { get; }
+
+ public DmdCueSimulationSample(double time, DmdCuePlayerSnapshot snapshot)
+ {
+ Time = time;
+ Snapshot = snapshot;
+ }
+ }
+
+ public sealed class DmdCueSimulationResult
+ {
+ public IReadOnlyList Samples { get; }
+ public IReadOnlyList Errors { get; }
+ public int EmittedFrames { get; }
+
+ internal DmdCueSimulationResult(IReadOnlyList samples,
+ IReadOnlyList errors, int emittedFrames)
+ {
+ Samples = samples;
+ Errors = errors;
+ EmittedFrames = emittedFrames;
+ }
+ }
+
+ ///
+ /// Runs editor-authored events through the production cue player and scheduler.
+ ///
+ public static class DmdCueSimulator
+ {
+ public static DmdCueSimulationResult Run(DmdProjectAsset project, string script, double durationSeconds)
+ {
+ if (project == null) throw new ArgumentNullException(nameof(project));
+ if (double.IsNaN(durationSeconds) || double.IsInfinity(durationSeconds) || durationSeconds <= 0d) {
+ throw new ArgumentOutOfRangeException(nameof(durationSeconds));
+ }
+ var errors = new List();
+ var events = Parse(script, errors);
+ var samples = new List();
+ var sink = new SimulatorSink();
+ using (var player = new DmdCuePlayer(project, sink)) {
+ player.OnValidationError += (_, message) => errors.Add(message);
+ var eventIndex = 0;
+ while (eventIndex < events.Count && events[eventIndex].Time <= 0d) {
+ Dispatch(player, events[eventIndex++], errors);
+ }
+ player.Start();
+ var frameRate = System.Math.Max(1, System.Math.Min(DmdValidation.MaxFrameRate, project.FrameRate));
+ var frames = System.Math.Max(1, (int)System.Math.Ceiling(durationSeconds * frameRate));
+ for (var frame = 0; frame <= frames; frame++) {
+ var time = System.Math.Min(durationSeconds, frame / (double)frameRate);
+ while (eventIndex < events.Count && events[eventIndex].Time <= time + 0.0000001d) {
+ Dispatch(player, events[eventIndex++], errors);
+ }
+ player.Tick(time);
+ samples.Add(new DmdCueSimulationSample(time, player.GetSnapshot()));
+ }
+ }
+ return new DmdCueSimulationResult(samples, errors.Distinct().ToArray(), sink.FrameCount);
+ }
+
+ private static List Parse(string script, List errors)
+ {
+ var events = new List();
+ var lines = (script ?? string.Empty).Replace("\r", string.Empty).Split('\n');
+ for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++) {
+ var line = lines[lineIndex].Trim();
+ if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
+ try {
+ var separator = line.IndexOf(' ');
+ if (!line.StartsWith("t=", StringComparison.OrdinalIgnoreCase) || separator < 3 ||
+ !double.TryParse(line.Substring(2, separator - 2), NumberStyles.Float,
+ CultureInfo.InvariantCulture, out var time) || time < 0d) {
+ throw new FormatException("Expected a non-negative 't=' prefix.");
+ }
+ var call = line.Substring(separator + 1).Trim();
+ var open = call.IndexOf('(');
+ var close = call.LastIndexOf(')');
+ if (open <= 0 || close != call.Length - 1) throw new FormatException("Expected Action(...). ");
+ var action = call.Substring(0, open).Trim();
+ var arguments = SplitArguments(call.Substring(open + 1, close - open - 1));
+ var parsedAction = ParseAction(action);
+ var cueId = parsedAction == SimulatorAction.StopAll
+ ? null
+ : arguments.FirstOrDefault()?.Trim();
+ if (parsedAction != SimulatorAction.StopAll && string.IsNullOrEmpty(cueId)) {
+ throw new FormatException($"{parsedAction} requires a cue id.");
+ }
+ var values = new List();
+ for (var index = cueId == null ? 0 : 1; index < arguments.Count; index++) {
+ values.Add(ParseParameter(arguments[index]));
+ }
+ events.Add(new SimulatorEvent(time, parsedAction, cueId, values, lineIndex + 1));
+ } catch (Exception exception) when (exception is FormatException || exception is ArgumentException) {
+ errors.Add($"Line {lineIndex + 1}: {exception.Message}");
+ }
+ }
+ events.Sort((left, right) => left.Time != right.Time
+ ? left.Time.CompareTo(right.Time)
+ : left.Line.CompareTo(right.Line));
+ return events;
+ }
+
+ private static List SplitArguments(string input)
+ {
+ var result = new List();
+ var start = 0;
+ var quoted = false;
+ for (var index = 0; index < input.Length; index++) {
+ if (input[index] == '"' && (index == 0 || input[index - 1] != '\\')) quoted = !quoted;
+ if (input[index] == ',' && !quoted) {
+ result.Add(input.Substring(start, index - start).Trim());
+ start = index + 1;
+ }
+ }
+ if (quoted) throw new FormatException("Unterminated quoted string.");
+ if (start < input.Length) result.Add(input.Substring(start).Trim());
+ return result;
+ }
+
+ private static DmdParamValue ParseParameter(string argument)
+ {
+ var separator = argument.IndexOf('=');
+ if (separator <= 0) throw new FormatException($"Parameter '{argument}' must be name=value.");
+ var name = argument.Substring(0, separator).Trim();
+ var value = argument.Substring(separator + 1).Trim();
+ if (!DmdValidation.IsValidParameterName(name)) throw new FormatException($"Invalid parameter name '{name}'.");
+ if (value.Length >= 2 && value[0] == '"' && value[value.Length - 1] == '"') {
+ return DmdParamValue.From(name, value.Substring(1, value.Length - 2).Replace("\\\"", "\""));
+ }
+ if (bool.TryParse(value, out var boolean)) return DmdParamValue.From(name, boolean);
+ if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer)) {
+ return DmdParamValue.From(name, integer);
+ }
+ if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) {
+ return DmdParamValue.From(name, number);
+ }
+ return DmdParamValue.From(name, value);
+ }
+
+ private static SimulatorAction ParseAction(string value)
+ {
+ if (Enum.TryParse(value, true, out SimulatorAction action)) return action;
+ throw new FormatException($"Unknown simulator action '{value}'.");
+ }
+
+ private static void Dispatch(DmdCuePlayer player, SimulatorEvent evt, List errors)
+ {
+ try {
+ var parameters = ToParams(evt.Values);
+ switch (evt.Action) {
+ case SimulatorAction.SetBase: player.SetBase(evt.CueId, parameters); break;
+ case SimulatorAction.Play: player.Play(evt.CueId, parameters); break;
+ case SimulatorAction.Update:
+ if (!player.UpdateCue(evt.CueId, parameters)) errors.Add($"Line {evt.Line}: cue '{evt.CueId}' is not live.");
+ break;
+ case SimulatorAction.Stop:
+ if (!player.StopCue(evt.CueId)) errors.Add($"Line {evt.Line}: cue '{evt.CueId}' is not live.");
+ break;
+ case SimulatorAction.StopAll: player.StopAll(); break;
+ }
+ } catch (Exception exception) when (!(exception is OutOfMemoryException)) {
+ errors.Add($"Line {evt.Line}: {exception.Message}");
+ }
+ }
+
+ private static DmdParams ToParams(IEnumerable values)
+ {
+ var result = new DmdParams();
+ foreach (var value in values) {
+ 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;
+ }
+ }
+ return result;
+ }
+
+ private enum SimulatorAction { SetBase, Play, Update, Stop, StopAll }
+
+ private readonly struct SimulatorEvent
+ {
+ public readonly double Time;
+ public readonly SimulatorAction Action;
+ public readonly string CueId;
+ public readonly IReadOnlyList Values;
+ public readonly int Line;
+
+ public SimulatorEvent(double time, SimulatorAction action, string cueId,
+ IReadOnlyList values, int line)
+ {
+ Time = time; Action = action; CueId = cueId; Values = values; Line = line;
+ }
+ }
+
+ private sealed class SimulatorSink : IDmdFrameSink
+ {
+ public int FrameCount { get; private set; }
+ public void RequestDisplays(RequestedDisplays displays) { }
+ public void UpdateFrame(DisplayFrameData frame) => FrameCount++;
+ public void Clear(string displayId) { }
+ }
+ }
+
+ public sealed class DmdCueSimulatorView : VisualElement
+ {
+ private const float LabelWidth = 58f;
+ private readonly TextField _script;
+ private readonly FloatField _duration;
+ private readonly Label _status;
+ private readonly VisualElement _lanes;
+ private DmdProjectAsset _project;
+ private DmdCueSimulationResult _result;
+
+ public DmdCueSimulationResult Result => _result;
+
+ public DmdCueSimulatorView()
+ {
+ _script = new TextField("Events") { multiline = true, value =
+ "t=0 SetBase(attract)\nt=0 Play(multiball)\nt=0.4 Play(jackpot, value=100000)" };
+ _script.style.minHeight = 62;
+ Add(_script);
+ var controls = new VisualElement { style = { flexDirection = FlexDirection.Row } };
+ _duration = new FloatField("Seconds") { value = 4f };
+ _duration.style.width = 150;
+ controls.Add(_duration);
+ var run = new Button(Run) { text = "Run Simulation" };
+ controls.Add(run);
+ _status = new Label();
+ _status.style.flexGrow = 1;
+ _status.style.unityTextAlign = TextAnchor.MiddleLeft;
+ controls.Add(_status);
+ Add(controls);
+ _lanes = new VisualElement { name = "dmd-simulator-lanes" };
+ _lanes.style.height = 98;
+ _lanes.generateVisualContent += DrawLanes;
+ var laneLabels = new[] { "Base", "Active", "Held", "Queue" };
+ for (var index = 0; index < laneLabels.Length; index++) {
+ var label = new Label(laneLabels[index]) { pickingMode = PickingMode.Ignore };
+ label.style.position = Position.Absolute;
+ label.style.left = 4f;
+ label.style.top = index * 24.5f;
+ label.style.width = LabelWidth - 8f;
+ label.style.height = 24.5f;
+ label.style.unityTextAlign = TextAnchor.MiddleLeft;
+ _lanes.Add(label);
+ }
+ Add(_lanes);
+ }
+
+ public void SetProject(DmdProjectAsset project)
+ {
+ _project = project;
+ _result = null;
+ _status.text = project == null ? "Select a project." : string.Empty;
+ _lanes.MarkDirtyRepaint();
+ }
+
+ public void SetScript(string script)
+ {
+ _script.SetValueWithoutNotify(script ?? string.Empty);
+ }
+
+ public void Run()
+ {
+ if (_project == null) {
+ _status.text = "Select a project.";
+ return;
+ }
+ try {
+ _result = DmdCueSimulator.Run(_project, _script.value, System.Math.Max(0.1f, _duration.value));
+ _status.text = _result.Errors.Count == 0
+ ? $"{_result.EmittedFrames} frames"
+ : $"{_result.Errors.Count} issue(s): {_result.Errors[0]}";
+ } catch (Exception exception) {
+ _result = null;
+ _status.text = exception.Message;
+ }
+ _lanes.MarkDirtyRepaint();
+ }
+
+ private void DrawLanes(MeshGenerationContext context)
+ {
+ var painter = context.painter2D;
+ var rowHeight = System.Math.Max(20f, _lanes.contentRect.height / 4f);
+ for (var row = 0; row < 4; row++) {
+ FillRect(painter, new Rect(0f, row * rowHeight, LabelWidth - 2f, rowHeight - 1f),
+ new Color(0.16f, 0.16f, 0.16f));
+ }
+ if (_result?.Samples == null || _result.Samples.Count == 0) return;
+ var width = System.Math.Max(1f, _lanes.contentRect.width - LabelWidth);
+ for (var index = 0; index < _result.Samples.Count; index++) {
+ var sample = _result.Samples[index];
+ var next = index + 1 < _result.Samples.Count ? _result.Samples[index + 1].Time : sample.Time;
+ var x = LabelWidth + width * (float)(sample.Time / System.Math.Max(0.1f, _duration.value));
+ var right = LabelWidth + width * (float)(next / System.Math.Max(0.1f, _duration.value));
+ var snapshot = sample.Snapshot;
+ DrawLane(painter, 0, snapshot.BaseCueId, x, right, rowHeight);
+ DrawLane(painter, 1, snapshot.ActiveCueId, x, right, rowHeight);
+ DrawLane(painter, 2, string.Join(" + ", snapshot.HoldStackCueIds), x, right, rowHeight);
+ DrawLane(painter, 3, string.Join(" + ", snapshot.QueuedCueIds), x, right, rowHeight);
+ }
+ }
+
+ private static void DrawLane(Painter2D painter, int row, string cueId, float left, float right,
+ float rowHeight)
+ {
+ if (string.IsNullOrEmpty(cueId)) return;
+ var hash = cueId.Aggregate(17, (current, character) => current * 31 + character);
+ var hue = (hash & 1023) / 1023f;
+ FillRect(painter, new Rect(left, row * rowHeight + 2f, System.Math.Max(1f, right - left + 0.5f),
+ rowHeight - 4f), Color.HSVToRGB(hue, 0.58f, 0.78f));
+ }
+
+ private static void FillRect(Painter2D painter, Rect rect, Color color)
+ {
+ painter.fillColor = color;
+ painter.BeginPath();
+ painter.MoveTo(new Vector2(rect.xMin, rect.yMin));
+ painter.LineTo(new Vector2(rect.xMax, rect.yMin));
+ painter.LineTo(new Vector2(rect.xMax, rect.yMax));
+ painter.LineTo(new Vector2(rect.xMin, rect.yMax));
+ painter.ClosePath();
+ painter.Fill();
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCueSimulator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCueSimulator.cs.meta
new file mode 100644
index 000000000..374576b60
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdCueSimulator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 62f12b80f922475d8c3878583c392cae
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/DmdPixelEditorView.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdPixelEditorView.cs
new file mode 100644
index 000000000..39f4e45be
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdPixelEditorView.cs
@@ -0,0 +1,504 @@
+// 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 UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ public enum DmdPixelTool
+ {
+ Pencil,
+ Eraser,
+ Fill,
+ Rectangle,
+ }
+
+ ///
+ /// Pixel-accurate editor shared by sprite frames and BMFont glyph regions.
+ ///
+ public sealed class DmdPixelEditorView : VisualElement
+ {
+ private readonly VisualElement _canvas;
+ private readonly Label _targetLabel;
+ private readonly SliderInt _shade;
+ private readonly Toggle _transparent;
+ private readonly DropdownField _palette;
+ private readonly Dictionary _toolButtons =
+ new Dictionary();
+
+ private UnityEngine.Object _owner;
+ private DmdBitmapData _bitmap;
+ private DmdProjectAsset _project;
+ private RectInt _region;
+ private DmdPixelTool _tool;
+ private bool _drawing;
+ private Vector2Int _rectangleStart;
+ private Vector2Int _rectangleEnd;
+ private bool _recordedUndo;
+
+ public event Action Changed;
+ public bool HasTarget => _owner != null && _bitmap != null && _region.width > 0 && _region.height > 0;
+
+ public DmdPixelEditorView()
+ {
+ name = "dmd-pixel-editor";
+ style.minHeight = 180;
+ style.flexGrow = 1;
+
+ var tools = new Toolbar();
+ foreach (DmdPixelTool tool in Enum.GetValues(typeof(DmdPixelTool))) {
+ var button = new ToolbarToggle { text = tool.ToString() };
+ button.RegisterValueChangedCallback(evt => {
+ if (evt.newValue) {
+ SelectTool(tool);
+ } else if (_tool == tool) {
+ button.SetValueWithoutNotify(true);
+ }
+ });
+ _toolButtons.Add(tool, button);
+ tools.Add(button);
+ }
+ _toolButtons[DmdPixelTool.Pencil].SetValueWithoutNotify(true);
+ _shade = new SliderInt("Shade", 0, 15) { value = 15 };
+ _shade.style.width = 150;
+ tools.Add(_shade);
+ _transparent = new Toggle("Transparent");
+ tools.Add(_transparent);
+ _palette = new DropdownField(new List { "Project ramp" }, 0);
+ _palette.style.width = 130;
+ tools.Add(_palette);
+ Add(tools);
+
+ _targetLabel = new Label("Select a sprite frame or font glyph.");
+ _targetLabel.style.unityTextAlign = TextAnchor.MiddleLeft;
+ _targetLabel.style.height = 20;
+ Add(_targetLabel);
+
+ _canvas = new VisualElement {
+ name = "dmd-pixel-canvas",
+ focusable = true
+ };
+ _canvas.style.flexGrow = 1;
+ _canvas.style.minHeight = 140;
+ _canvas.style.backgroundColor = new Color(0.045f, 0.045f, 0.045f);
+ _canvas.generateVisualContent += Draw;
+ _canvas.RegisterCallback(OnPointerDown);
+ _canvas.RegisterCallback(OnPointerMove);
+ _canvas.RegisterCallback(OnPointerUp);
+ Add(_canvas);
+ }
+
+ public void SetTarget(UnityEngine.Object owner, DmdBitmapData bitmap, RectInt region,
+ DmdProjectAsset project, string label)
+ {
+ _owner = owner;
+ _bitmap = bitmap;
+ _project = project;
+ _region = ClampRegion(bitmap, region);
+ _targetLabel.text = HasTarget ? label : "Select a sprite frame or font glyph.";
+ var choices = new List { "Project ramp" };
+ if (project?.Palettes != null) {
+ choices.AddRange(project.Palettes.Where(candidate => candidate != null)
+ .Select(candidate => candidate.name));
+ }
+ _palette.choices = choices;
+ _palette.index = Mathf.Clamp(_palette.index, 0, choices.Count - 1);
+ var shadeCount = project?.ColorMode == DmdColorMode.Mono4 ? 4 : 16;
+ _shade.highValue = shadeCount - 1;
+ _shade.SetValueWithoutNotify(Mathf.Clamp(_shade.value, 0, shadeCount - 1));
+ _canvas.MarkDirtyRepaint();
+ }
+
+ public void ClearTarget()
+ {
+ SetTarget(null, null, default, null, null);
+ }
+
+ public void SetBrush(int shade, bool transparent)
+ {
+ _shade.SetValueWithoutNotify(Mathf.Clamp(shade, 0, _shade.highValue));
+ _transparent.SetValueWithoutNotify(transparent);
+ }
+
+ ///
+ /// Applies a tool in region-local pixel coordinates. Used by keyboard actions and editor tests.
+ ///
+ public bool ApplyTool(DmdPixelTool tool, Vector2Int start, Vector2Int end)
+ {
+ if (!HasTarget || start.x < 0 || start.y < 0 || start.x >= _region.width ||
+ start.y >= _region.height) {
+ return false;
+ }
+ end.x = Mathf.Clamp(end.x, 0, _region.width - 1);
+ end.y = Mathf.Clamp(end.y, 0, _region.height - 1);
+ _tool = tool;
+ _recordedUndo = false;
+ _rectangleStart = start;
+ _rectangleEnd = end;
+ BeginMutation(tool switch {
+ DmdPixelTool.Eraser => "Erase DMD pixels",
+ DmdPixelTool.Fill => "Fill DMD pixels",
+ DmdPixelTool.Rectangle => "Draw DMD rectangle",
+ _ => "Draw DMD pixels"
+ });
+ if (tool == DmdPixelTool.Fill) {
+ FloodFill(start);
+ } else if (tool == DmdPixelTool.Rectangle) {
+ DrawRectangle();
+ } else {
+ Paint(start);
+ }
+ EditorUtility.SetDirty(_owner);
+ Changed?.Invoke();
+ _canvas.MarkDirtyRepaint();
+ return true;
+ }
+
+ private void SelectTool(DmdPixelTool tool)
+ {
+ _tool = tool;
+ foreach (var pair in _toolButtons) {
+ pair.Value.SetValueWithoutNotify(pair.Key == tool);
+ }
+ }
+
+ private void Draw(MeshGenerationContext context)
+ {
+ if (!HasTarget) {
+ return;
+ }
+ var layout = PixelLayout();
+ var painter = context.painter2D;
+ for (var y = 0; y < _region.height; y++) {
+ for (var x = 0; x < _region.width; x++) {
+ var rect = new Rect(layout.x + x * layout.width, layout.y + y * layout.height,
+ layout.width, layout.height);
+ var globalX = _region.x + x;
+ var globalY = _region.y + y;
+ var alpha = ReadAlpha(globalX, globalY);
+ if (alpha < 255) {
+ FillRect(painter, rect, (x + y & 1) == 0
+ ? new Color(0.22f, 0.22f, 0.22f)
+ : new Color(0.13f, 0.13f, 0.13f));
+ }
+ var color = ReadColor(globalX, globalY);
+ color.a = alpha / 255f;
+ FillRect(painter, rect, color);
+ }
+ }
+ if (layout.width >= 5f && layout.height >= 5f) {
+ painter.strokeColor = new Color(1f, 1f, 1f, 0.13f);
+ painter.lineWidth = 1f;
+ for (var x = 0; x <= _region.width; x++) {
+ StrokeLine(painter, new Vector2(layout.x + x * layout.width, layout.y),
+ new Vector2(layout.x + x * layout.width, layout.y + _region.height * layout.height));
+ }
+ for (var y = 0; y <= _region.height; y++) {
+ StrokeLine(painter, new Vector2(layout.x, layout.y + y * layout.height),
+ new Vector2(layout.x + _region.width * layout.width, layout.y + y * layout.height));
+ }
+ }
+ if (_drawing && _tool == DmdPixelTool.Rectangle) {
+ var min = Vector2Int.Min(_rectangleStart, _rectangleEnd);
+ var max = Vector2Int.Max(_rectangleStart, _rectangleEnd);
+ painter.strokeColor = Color.white;
+ painter.lineWidth = 1.5f;
+ var rect = new Rect(layout.x + min.x * layout.width, layout.y + min.y * layout.height,
+ (max.x - min.x + 1) * layout.width, (max.y - min.y + 1) * layout.height);
+ StrokeRect(painter, rect);
+ }
+ }
+
+ private void OnPointerDown(PointerDownEvent evt)
+ {
+ if (!HasTarget || evt.button != 0 || !TryPixel(evt.localPosition, out var pixel)) {
+ return;
+ }
+ _drawing = true;
+ _recordedUndo = false;
+ _rectangleStart = _rectangleEnd = pixel;
+ _canvas.CapturePointer(evt.pointerId);
+ if (_tool == DmdPixelTool.Fill) {
+ BeginMutation("Fill DMD pixels");
+ FloodFill(pixel);
+ EndGesture(evt.pointerId);
+ } else if (_tool != DmdPixelTool.Rectangle) {
+ BeginMutation(_tool == DmdPixelTool.Eraser ? "Erase DMD pixels" : "Draw DMD pixels");
+ Paint(pixel);
+ }
+ evt.StopPropagation();
+ }
+
+ private void OnPointerMove(PointerMoveEvent evt)
+ {
+ if (!_drawing || !_canvas.HasPointerCapture(evt.pointerId) ||
+ !TryPixel(evt.localPosition, out var pixel)) {
+ return;
+ }
+ if (_tool == DmdPixelTool.Rectangle) {
+ _rectangleEnd = pixel;
+ _canvas.MarkDirtyRepaint();
+ } else if (_tool != DmdPixelTool.Fill) {
+ Paint(pixel);
+ }
+ }
+
+ private void OnPointerUp(PointerUpEvent evt)
+ {
+ if (!_drawing || evt.button != 0) {
+ return;
+ }
+ if (_tool == DmdPixelTool.Rectangle) {
+ if (TryPixel(evt.localPosition, out var pixel)) {
+ _rectangleEnd = pixel;
+ }
+ BeginMutation("Draw DMD rectangle");
+ DrawRectangle();
+ }
+ EndGesture(evt.pointerId);
+ }
+
+ private void EndGesture(int pointerId)
+ {
+ _drawing = false;
+ if (_canvas.HasPointerCapture(pointerId)) {
+ _canvas.ReleasePointer(pointerId);
+ }
+ if (_recordedUndo) {
+ EditorUtility.SetDirty(_owner);
+ Changed?.Invoke();
+ }
+ _canvas.MarkDirtyRepaint();
+ }
+
+ private void BeginMutation(string undoName)
+ {
+ if (_recordedUndo) {
+ return;
+ }
+ Undo.RecordObject(_owner, undoName);
+ _bitmap.Pixels = _bitmap.Pixels == null ? Array.Empty() : (byte[])_bitmap.Pixels.Clone();
+ _bitmap.Alpha = _bitmap.Alpha == null ? Array.Empty() : (byte[])_bitmap.Alpha.Clone();
+ EnsureBuffers();
+ _recordedUndo = true;
+ }
+
+ private void Paint(Vector2Int pixel)
+ {
+ var global = new Vector2Int(_region.x + pixel.x, _region.y + pixel.y);
+ Write(global.x, global.y, _tool == DmdPixelTool.Eraser || _transparent.value);
+ _canvas.MarkDirtyRepaint();
+ }
+
+ private void DrawRectangle()
+ {
+ var min = Vector2Int.Min(_rectangleStart, _rectangleEnd);
+ var max = Vector2Int.Max(_rectangleStart, _rectangleEnd);
+ for (var x = min.x; x <= max.x; x++) {
+ Write(_region.x + x, _region.y + min.y, _transparent.value);
+ Write(_region.x + x, _region.y + max.y, _transparent.value);
+ }
+ for (var y = min.y + 1; y < max.y; y++) {
+ Write(_region.x + min.x, _region.y + y, _transparent.value);
+ Write(_region.x + max.x, _region.y + y, _transparent.value);
+ }
+ }
+
+ private void FloodFill(Vector2Int start)
+ {
+ var startX = _region.x + start.x;
+ var startY = _region.y + start.y;
+ var old = PixelKey(startX, startY);
+ var transparent = _transparent.value;
+ var replacement = SelectedPixelKey(transparent);
+ if (old == replacement) {
+ return;
+ }
+ var pending = new Stack();
+ pending.Push(start);
+ while (pending.Count > 0) {
+ var pixel = pending.Pop();
+ var x = _region.x + pixel.x;
+ var y = _region.y + pixel.y;
+ if (PixelKey(x, y) != old) {
+ continue;
+ }
+ Write(x, y, transparent);
+ if (pixel.x > 0) pending.Push(new Vector2Int(pixel.x - 1, pixel.y));
+ if (pixel.x + 1 < _region.width) pending.Push(new Vector2Int(pixel.x + 1, pixel.y));
+ if (pixel.y > 0) pending.Push(new Vector2Int(pixel.x, pixel.y - 1));
+ if (pixel.y + 1 < _region.height) pending.Push(new Vector2Int(pixel.x, pixel.y + 1));
+ }
+ }
+
+ private void Write(int x, int y, bool transparent)
+ {
+ var index = y * _bitmap.Width + x;
+ if (_bitmap.Alpha.Length > 0) {
+ _bitmap.Alpha[index] = transparent ? (byte)0 : (byte)255;
+ }
+ if (transparent) {
+ return;
+ }
+ var color = SelectedColor();
+ if (_bitmap.Format == DmdPixelFormat.Rgb24) {
+ var offset = index * 3;
+ _bitmap.Pixels[offset] = color.r;
+ _bitmap.Pixels[offset + 1] = color.g;
+ _bitmap.Pixels[offset + 2] = color.b;
+ } else {
+ _bitmap.Pixels[index] = (byte)((color.r * 77 + color.g * 150 + color.b * 29 + 128) >> 8);
+ }
+ }
+
+ private Color32 SelectedColor()
+ {
+ var paletteIndex = _palette.index - 1;
+ var palette = _project?.Palettes?.Where(candidate => candidate != null).ElementAtOrDefault(paletteIndex);
+ if (palette?.Colors != null && _shade.value < palette.Colors.Length) {
+ return palette.Colors[_shade.value];
+ }
+ var shadeCount = System.Math.Max(2, _shade.highValue + 1);
+ var value = (byte)Mathf.RoundToInt(_shade.value * 255f / (shadeCount - 1));
+ return new Color32(value, value, value, 255);
+ }
+
+ private ulong SelectedPixelKey(bool transparent)
+ {
+ if (transparent) {
+ return 0;
+ }
+ var color = SelectedColor();
+ if (_bitmap.Format == DmdPixelFormat.I8) {
+ var value = (byte)((color.r * 77 + color.g * 150 + color.b * 29 + 128) >> 8);
+ return 0xff000000UL | (ulong)value << 16 | (ulong)value << 8 | value;
+ }
+ return 0xff000000UL | (ulong)color.r << 16 | (ulong)color.g << 8 | color.b;
+ }
+
+ private ulong PixelKey(int x, int y)
+ {
+ var alpha = ReadAlpha(x, y);
+ if (alpha == 0) {
+ return 0;
+ }
+ var color = (Color32)ReadColor(x, y);
+ return (ulong)alpha << 24 | (ulong)color.r << 16 | (ulong)color.g << 8 | color.b;
+ }
+
+ private Color ReadColor(int x, int y)
+ {
+ if (_bitmap?.Pixels == null) {
+ return Color.clear;
+ }
+ var index = y * _bitmap.Width + x;
+ if (_bitmap.Format == DmdPixelFormat.Rgb24) {
+ var offset = index * 3;
+ if (offset + 2 >= _bitmap.Pixels.Length) return Color.clear;
+ return new Color32(_bitmap.Pixels[offset], _bitmap.Pixels[offset + 1],
+ _bitmap.Pixels[offset + 2], 255);
+ }
+ if (index >= _bitmap.Pixels.Length) return Color.clear;
+ var value = _bitmap.Pixels[index];
+ return new Color32(value, value, value, 255);
+ }
+
+ private byte ReadAlpha(int x, int y)
+ {
+ var index = y * _bitmap.Width + x;
+ return _bitmap.Alpha != null && _bitmap.Alpha.Length == _bitmap.Width * _bitmap.Height
+ ? _bitmap.Alpha[index]
+ : (byte)255;
+ }
+
+ private void EnsureBuffers()
+ {
+ var pixels = checked(_bitmap.Width * _bitmap.Height);
+ var expected = _bitmap.Format == DmdPixelFormat.Rgb24 ? checked(pixels * 3) : pixels;
+ if (_bitmap.Pixels.Length != expected) {
+ Array.Resize(ref _bitmap.Pixels, expected);
+ }
+ if (_bitmap.Alpha.Length != pixels) {
+ var hadAlpha = _bitmap.Alpha.Length > 0;
+ Array.Resize(ref _bitmap.Alpha, pixels);
+ if (!hadAlpha) {
+ for (var index = 0; index < pixels; index++) _bitmap.Alpha[index] = 255;
+ }
+ }
+ }
+
+ private bool TryPixel(Vector2 position, out Vector2Int pixel)
+ {
+ pixel = default;
+ if (!HasTarget) return false;
+ var layout = PixelLayout();
+ var x = Mathf.FloorToInt((position.x - layout.x) / layout.width);
+ var y = Mathf.FloorToInt((position.y - layout.y) / layout.height);
+ if (x < 0 || y < 0 || x >= _region.width || y >= _region.height) return false;
+ pixel = new Vector2Int(x, y);
+ return true;
+ }
+
+ private Rect PixelLayout()
+ {
+ var scale = Mathf.Max(1f, Mathf.Min(_canvas.contentRect.width / System.Math.Max(1, _region.width),
+ _canvas.contentRect.height / System.Math.Max(1, _region.height)));
+ var width = _region.width * scale;
+ var height = _region.height * scale;
+ return new Rect((_canvas.contentRect.width - width) * 0.5f,
+ (_canvas.contentRect.height - height) * 0.5f, scale, scale);
+ }
+
+ private static RectInt ClampRegion(DmdBitmapData bitmap, RectInt region)
+ {
+ if (bitmap == null || bitmap.Width <= 0 || bitmap.Height <= 0) return default;
+ if (region.width <= 0 || region.height <= 0) return new RectInt(0, 0, bitmap.Width, bitmap.Height);
+ var x = Mathf.Clamp(region.x, 0, bitmap.Width);
+ var y = Mathf.Clamp(region.y, 0, bitmap.Height);
+ return new RectInt(x, y, Mathf.Clamp(region.width, 0, bitmap.Width - x),
+ Mathf.Clamp(region.height, 0, bitmap.Height - y));
+ }
+
+ private static void FillRect(Painter2D painter, Rect rect, Color color)
+ {
+ painter.fillColor = color;
+ painter.BeginPath();
+ painter.MoveTo(new Vector2(rect.xMin, rect.yMin));
+ painter.LineTo(new Vector2(rect.xMax, rect.yMin));
+ painter.LineTo(new Vector2(rect.xMax, rect.yMax));
+ painter.LineTo(new Vector2(rect.xMin, rect.yMax));
+ painter.ClosePath();
+ painter.Fill();
+ }
+
+ private static void StrokeRect(Painter2D painter, Rect rect)
+ {
+ painter.BeginPath();
+ painter.MoveTo(new Vector2(rect.xMin, rect.yMin));
+ painter.LineTo(new Vector2(rect.xMax, rect.yMin));
+ painter.LineTo(new Vector2(rect.xMax, rect.yMax));
+ painter.LineTo(new Vector2(rect.xMin, rect.yMax));
+ painter.ClosePath();
+ painter.Stroke();
+ }
+
+ private static void StrokeLine(Painter2D painter, Vector2 from, Vector2 to)
+ {
+ painter.BeginPath();
+ painter.MoveTo(from);
+ painter.LineTo(to);
+ painter.Stroke();
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdPixelEditorView.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdPixelEditorView.cs.meta
new file mode 100644
index 000000000..0fc7889be
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdPixelEditorView.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a33102cf08124487b1c4583eb463d632
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/DmdStarterFontLibrary.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStarterFontLibrary.cs
new file mode 100644
index 000000000..17e8cafc9
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStarterFontLibrary.cs
@@ -0,0 +1,53 @@
+// 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 DmdStarterFontLibrary
+ {
+ private const string Root =
+ "Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity/DmdStudio/StarterFonts/";
+
+ private static readonly string[] AssetNames = {
+ "VpeMicro5", "VpeMicro7", "VpeArcade9", "VpeArcade15"
+ };
+
+ public static IReadOnlyList LoadAll()
+ {
+ var fonts = new List(AssetNames.Length);
+ foreach (var assetName in AssetNames) {
+ var font = AssetDatabase.LoadAssetAtPath($"{Root}{assetName}.asset");
+ if (font != null) fonts.Add(font);
+ }
+ return fonts;
+ }
+
+ public static int AddToProject(DmdProjectAsset project)
+ {
+ if (project == null) throw new ArgumentNullException(nameof(project));
+ var fonts = LoadAll();
+ if (fonts.Count != AssetNames.Length) {
+ throw new InvalidOperationException("The DMD starter font package is incomplete.");
+ }
+ project.Fonts ??= new List();
+ var added = 0;
+ foreach (var font in fonts) {
+ if (project.Fonts.Contains(font)) continue;
+ if (added == 0) Undo.RecordObject(project, "Add DMD starter fonts");
+ project.Fonts.Add(font);
+ added++;
+ }
+ if (added > 0) EditorUtility.SetDirty(project);
+ return added;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStarterFontLibrary.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStarterFontLibrary.cs.meta
new file mode 100644
index 000000000..6adb6f088
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStarterFontLibrary.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6506263cc0714258a2302f1b9b292d53
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/DmdStudioPreviewFrame.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioPreviewFrame.cs
new file mode 100644
index 000000000..efdae70b7
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioPreviewFrame.cs
@@ -0,0 +1,77 @@
+// 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.Editor
+{
+ ///
+ /// Converts the compositor surface into the exact default wire format used by a project and
+ /// expands mono levels back to I8 solely for the tinted editor canvas.
+ ///
+ public sealed class DmdStudioPreviewFrame
+ {
+ private DmdSurface _canvasSurface;
+ private byte[] _displayData = Array.Empty();
+
+ public DmdSurface CanvasSurface { get; private set; }
+ public DisplayFrameFormat Format { get; private set; }
+ public byte[] DisplayData { get; private set; }
+
+ public void Prepare(DmdSurface rendered, DmdColorMode colorMode)
+ {
+ if (rendered == null) {
+ throw new ArgumentNullException(nameof(rendered));
+ }
+ if (colorMode == DmdColorMode.Rgb24) {
+ if (rendered.Format != DmdPixelFormat.Rgb24) {
+ throw new ArgumentException("An RGB preview requires an RGB24 compositor surface.", nameof(rendered));
+ }
+ CanvasSurface = rendered;
+ Format = DisplayFrameFormat.Dmd24;
+ DisplayData = rendered.Data;
+ return;
+ }
+ if (rendered.Format != DmdPixelFormat.I8) {
+ throw new ArgumentException("A mono preview requires an I8 compositor surface.", nameof(rendered));
+ }
+
+ EnsureMonoBuffers(rendered.Width, rendered.Height);
+ var multiplier = 17;
+ switch (colorMode) {
+ case DmdColorMode.Mono4:
+ Format = DisplayFrameFormat.Dmd2;
+ multiplier = 85;
+ DmdQuantizer.I8ToDmd2(rendered.Data, _displayData);
+ break;
+ case DmdColorMode.Mono16:
+ Format = DisplayFrameFormat.Dmd4;
+ DmdQuantizer.I8ToDmd4(rendered.Data, _displayData);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(colorMode));
+ }
+ for (var index = 0; index < _displayData.Length; index++) {
+ _canvasSurface.Data[index] = (byte)(_displayData[index] * multiplier);
+ }
+ CanvasSurface = _canvasSurface;
+ DisplayData = _displayData;
+ }
+
+ private void EnsureMonoBuffers(int width, int height)
+ {
+ var length = checked(width * height);
+ if (_canvasSurface == null || _canvasSurface.Width != width || _canvasSurface.Height != height) {
+ _canvasSurface = new DmdSurface(width, height, DmdPixelFormat.I8);
+ }
+ if (_displayData.Length != length) {
+ _displayData = new byte[length];
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioPreviewFrame.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioPreviewFrame.cs.meta
new file mode 100644
index 000000000..864fb2aa4
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioPreviewFrame.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 295e84d6ccdb440ab0bb72e9d8028512
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioValidation.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioValidation.cs
new file mode 100644
index 000000000..e5d11f237
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioValidation.cs
@@ -0,0 +1,295 @@
+// 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.Linq;
+
+namespace VisualPinball.Unity.Editor
+{
+ public readonly struct DmdStudioValidationDiagnostic
+ {
+ public DmdValidationSeverity Severity { get; }
+ public string Code { get; }
+ public string Message { get; }
+
+ public DmdStudioValidationDiagnostic(DmdValidationSeverity severity, string code, string message)
+ {
+ Severity = severity;
+ Code = code;
+ Message = message;
+ }
+ }
+
+ ///
+ /// Authoring-only checks which need project inventories, samples, and rendered text.
+ ///
+ public static class DmdStudioValidation
+ {
+ public static IReadOnlyList Validate(DmdProjectAsset project)
+ {
+ var result = new List();
+ if (project == null) {
+ return result;
+ }
+
+ try {
+ foreach (var diagnostic in project.Validate().Diagnostics) {
+ result.Add(new DmdStudioValidationDiagnostic(diagnostic.Severity, diagnostic.Code,
+ diagnostic.Message));
+ }
+ } catch (Exception exception) when (!(exception is OutOfMemoryException)) {
+ Add(result, DmdValidationSeverity.Error, "project.validation.exception", exception.Message);
+ }
+
+ if (project.ColorMode == DmdColorMode.Rgb24) {
+ Add(result, DmdValidationSeverity.Warning, "project.rgb.colorization",
+ "RGB24 output bypasses external mono DMD colorization formats.");
+ }
+ ValidateSprites(project, result);
+ if (project.Cues != null) {
+ foreach (var cue in project.Cues.Where(cue => cue != null)) {
+ ValidateCue(project, cue, result);
+ }
+ }
+ return result;
+ }
+
+ private static void ValidateSprites(DmdProjectAsset project, List result)
+ {
+ var sprites = new HashSet();
+ if (project.Sprites != null) {
+ foreach (var sprite in project.Sprites.Where(sprite => sprite != null)) sprites.Add(sprite);
+ }
+ if (project.Cues != null) {
+ foreach (var layer in project.Cues.Where(cue => cue?.Layers != null).SelectMany(cue => cue.Layers)) {
+ if (layer is BitmapLayer bitmap && bitmap.Sprite != null) sprites.Add(bitmap.Sprite);
+ if (layer is MaskLayer mask && mask.Mask != null) sprites.Add(mask.Mask);
+ }
+ }
+ var shadeLimit = project.ColorMode == DmdColorMode.Mono4 ? 4 : 16;
+ foreach (var sprite in sprites) {
+ if (sprite.Frames == null) {
+ continue;
+ }
+ for (var frameIndex = 0; frameIndex < sprite.Frames.Count; frameIndex++) {
+ var frame = sprite.Frames[frameIndex];
+ if (frame == null) {
+ continue;
+ }
+ var path = $"Sprite '{sprite.name}' frame {frameIndex}";
+ if (frame.Width != project.Width || frame.Height != project.Height) {
+ Add(result, DmdValidationSeverity.Warning, "sprite.canvas.size",
+ $"{path} is {frame.Width}x{frame.Height}, not the {project.Width}x{project.Height} canvas.");
+ }
+ if (project.ColorMode != DmdColorMode.Rgb24 && frame.Format == DmdPixelFormat.Rgb24) {
+ Add(result, DmdValidationSeverity.Warning, "sprite.rgb.mono",
+ $"{path} is RGB and will be converted for a mono project.");
+ }
+ if (project.ColorMode != DmdColorMode.Rgb24 && frame.Format == DmdPixelFormat.I8 &&
+ CountShades(frame.Pixels, shadeLimit + 1) > shadeLimit) {
+ Add(result, DmdValidationSeverity.Warning, "sprite.shades.overflow",
+ $"{path} uses more than {shadeLimit} shades and will be quantized.");
+ }
+ }
+ }
+ }
+
+ private static void ValidateCue(DmdProjectAsset project, DmdCueAsset cue,
+ List result)
+ {
+ var declarations = (cue.Parameters ?? new List())
+ .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Name))
+ .GroupBy(parameter => parameter.Name, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
+ if (cue.Layers == null) {
+ return;
+ }
+ for (var layerIndex = 0; layerIndex < cue.Layers.Count; layerIndex++) {
+ if (!(cue.Layers[layerIndex] is TextLayer text)) {
+ continue;
+ }
+ var bindings = text is NumberLayer number
+ ? new[] { new TextBinding(number.ParamName, number.Format) }
+ : ParseBindings(text.Text);
+ foreach (var binding in bindings) {
+ if (string.IsNullOrWhiteSpace(binding.Name)) {
+ continue;
+ }
+ if (!declarations.TryGetValue(binding.Name, out var declaration)) {
+ Add(result, DmdValidationSeverity.Warning, "binding.undeclared",
+ $"Cue '{cue.EffectiveId}' layer {layerIndex} uses undeclared parameter '{binding.Name}'.");
+ } else if (!IsValidFormat(declaration.DefaultValue, binding.Format)) {
+ Add(result, DmdValidationSeverity.Error, "binding.format",
+ $"Cue '{cue.EffectiveId}' layer {layerIndex} has invalid format '{binding.Format}' for '{binding.Name}'.");
+ }
+ ValidateSampleBinding(project, cue, layerIndex, binding.Name, result);
+ }
+ }
+ ValidateRenderedSamples(project, cue, declarations.Values, result);
+ }
+
+ private static void ValidateSampleBinding(DmdProjectAsset project, DmdCueAsset cue, int layerIndex,
+ string name, List result)
+ {
+ if (project.SampleStates == null || project.SampleStates.Count == 0) {
+ Add(result, DmdValidationSeverity.Warning, "binding.sample.none",
+ $"Cue '{cue.EffectiveId}' layer {layerIndex} has no sample state for '{name}'.");
+ return;
+ }
+ foreach (var sample in project.SampleStates.Where(sample => sample != null)) {
+ if (sample.Values == null || sample.Values.All(value => !string.Equals(value.Name, name,
+ StringComparison.Ordinal))) {
+ Add(result, DmdValidationSeverity.Warning, "binding.sample.unbound",
+ $"Sample '{sample.Name}' does not bind '{name}' used by cue '{cue.EffectiveId}'.");
+ }
+ }
+ }
+
+ private static void ValidateRenderedSamples(DmdProjectAsset project, DmdCueAsset cue,
+ IEnumerable declarations, List result)
+ {
+ if (project.Width < 1 || project.Height < 1 || project.Width > DmdValidation.MaxWidth ||
+ project.Height > DmdValidation.MaxHeight) {
+ return;
+ }
+ var renderer = new CueRenderer(project);
+ var format = project.ColorMode == DmdColorMode.Rgb24 ? DmdPixelFormat.Rgb24 : DmdPixelFormat.I8;
+ var surface = new DmdSurface(project.Width, project.Height, format);
+ var samples = project.SampleStates != null && project.SampleStates.Count > 0
+ ? project.SampleStates.Where(sample => sample != null).ToArray()
+ : new[] { new DmdSampleState { Name = "Defaults" } };
+ var frames = cue.Layers.Where(layer => layer is TextLayer)
+ .Select(layer => System.Math.Max(0, layer.StartFrame)).Distinct().ToArray();
+ if (frames.Length == 0) {
+ return;
+ }
+ foreach (var sample in samples) {
+ var parameters = new DmdParams();
+ foreach (var declaration in declarations) {
+ Set(parameters, declaration.DefaultValue);
+ }
+ if (sample.Values != null) {
+ foreach (var value in sample.Values) {
+ Set(parameters, value);
+ }
+ }
+ var diagnostics = new CueDiagnostics();
+ var state = new CueInstanceState();
+ foreach (var frame in frames) {
+ renderer.Render(surface, cue, frame, parameters, state, diagnostics);
+ }
+ foreach (var diagnostic in diagnostics.Diagnostics) {
+ Add(result, DmdValidationSeverity.Warning, diagnostic.Code,
+ $"Cue '{cue.EffectiveId}', sample '{sample.Name}': {diagnostic.Message}");
+ }
+ }
+ }
+
+ private static TextBinding[] ParseBindings(string template)
+ {
+ var bindings = new List();
+ template = template ?? string.Empty;
+ for (var index = 0; index < template.Length; index++) {
+ if (template[index] != '{') {
+ continue;
+ }
+ if (index + 1 < template.Length && template[index + 1] == '{') {
+ index++;
+ continue;
+ }
+ var end = template.IndexOf('}', index + 1);
+ if (end < 0) {
+ break;
+ }
+ 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)) {
+ bindings.Add(new TextBinding(name, separator < 0 ? null : token.Substring(separator + 1)));
+ }
+ index = end;
+ }
+ return bindings.ToArray();
+ }
+
+ private static bool IsValidFormat(DmdParamValue value, string format)
+ {
+ if (string.IsNullOrEmpty(format)) {
+ return true;
+ }
+ try {
+ object raw = value.Type switch {
+ DmdParamType.Integer => value.IntValue,
+ DmdParamType.Float => value.FloatValue,
+ DmdParamType.String => value.StringValue ?? string.Empty,
+ DmdParamType.Boolean => value.BoolValue,
+ _ => value.ToInvariantString()
+ };
+ string.Format(CultureInfo.InvariantCulture, $"{{0:{format}}}", raw);
+ return true;
+ } catch (FormatException) {
+ return false;
+ }
+ }
+
+ private static void Set(DmdParams parameters, DmdParamValue value)
+ {
+ if (!DmdValidation.IsValidParameterName(value.Name)) {
+ return;
+ }
+ 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;
+ }
+ }
+
+ private static int CountShades(byte[] pixels, int stopAt)
+ {
+ if (pixels == null) {
+ return 0;
+ }
+ var shades = new bool[256];
+ var count = 0;
+ foreach (var pixel in pixels) {
+ if (!shades[pixel]) {
+ shades[pixel] = true;
+ if (++count >= stopAt) {
+ break;
+ }
+ }
+ }
+ return count;
+ }
+
+ private static void Add(List result, DmdValidationSeverity severity,
+ string code, string message)
+ {
+ if (result.Any(existing => existing.Severity == severity && existing.Code == code &&
+ existing.Message == message)) {
+ return;
+ }
+ result.Add(new DmdStudioValidationDiagnostic(severity, code, message));
+ }
+
+ private readonly struct TextBinding
+ {
+ public readonly string Name;
+ public readonly string Format;
+
+ public TextBinding(string name, string format)
+ {
+ Name = name;
+ Format = format;
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioValidation.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioValidation.cs.meta
new file mode 100644
index 000000000..b5621d3c6
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioValidation.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 28036ea9ac70490798ac02dd384baaba
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..19a0cf291
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DmdStudio/DmdStudioWindow.cs
@@ -0,0 +1,873 @@
+// 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;
+ [SerializeField] private int _bitmapTargetIndex;
+
+ 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 DmdPixelEditorView _pixelEditor;
+ private DmdCueSimulatorView _simulator;
+ private VisualElement _pixelTargetHost;
+ private VisualElement _validationList;
+ private DropdownField _keyProperty;
+ private FloatField _keyValue;
+ private SerializedObject _inspectedObject;
+ private CueRenderer _renderer;
+ private DmdSurface _previewRenderSurface;
+ private readonly DmdStudioPreviewFrame _previewFrame = new DmdStudioPreviewFrame();
+ private DotMatrixDisplayComponent _mirrorDisplay;
+ private CueInstanceState _previewState = new CueInstanceState();
+ private CueDiagnostics _diagnostics = new CueDiagnostics();
+ private DmdParams _previewParameters;
+ 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