Skip to content
Merged
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Built with Unity 6.5

### Added

- Closed shared cabinet output roles with independent LCD/HD game display routing and preserved custom scene bindings.

- Cabinet output content rotation, mirroring, and explicit-load inset composition with asymmetric GPU validation.

- Shared cabinet display configuration contracts, deterministic surface ranking, and an owned SDR output-copy target with GPU and native-window validation probes, preparing centralized Player display routing.

- Extensible shared plugin settings sections for paths and multiple mixer channels, with canonical plugin-owned values and provider validation for global edits.

- Smooth 100 ms master fades when opening and closing the Player pause menu, with rapid-toggle cancellation and independent sound previews.
Expand Down Expand Up @@ -207,6 +213,8 @@ Built with Unity 6.5

### Fixed

- Preserve custom playfield collider meshes without an enabled renderer when exporting tables, and load hidden collision meshes without UVs, so authored openings remain functional in the Player.

- The flipper collider gizmo follows the bat's rotation in edit mode so its shape can be compared with the visible mesh.
- A ball held by a moving kicker keeps its height above the kicker, instead of being pulled down to the kicker's base, and balls only passing over it are left alone.
- Devices assigned in the coil, lamp, switch and wire managers survive a recompile or entering play mode, instead of being dropped until the scene is reloaded.
Expand Down
103 changes: 103 additions & 0 deletions VisualPinball.Plugins.Test/CabinetDisplayTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Visual Pinball Engine
// Copyright (C) 2026 freezy and VPE Team
//
// SPDX-License-Identifier: MIT
// Licensed under MIT. See https://opensource.org/license/mit.

using System;
using NUnit.Framework;

namespace VisualPinball.Plugins.Test
{
public sealed class CabinetDisplayTests
{
[Test]
public void SnapshotCopiesInputAndKeepsAdoptedMissingOutputsManaged()
{
var assignments = new[] { new CabinetOutputAssignment("b2s", CabinetOutputRole.Backglass, CabinetOutputMode.Player) };
var targets = new[] { new CabinetDisplayTarget("screen", CabinetOutputRole.Backglass, CabinetEndpointState.Unavailable, reason: "Disconnected") };
var snapshot = new CabinetDisplaySnapshot(4, 7, 9, targets, assignments);
assignments[0] = new CabinetOutputAssignment("b2s", CabinetOutputRole.Backglass, CabinetOutputMode.Off);
targets[0] = new CabinetDisplayTarget("screen", CabinetOutputRole.Backglass, CabinetEndpointState.Available, -1920, 0, 1920, 1080);
Assert.That(snapshot.ModeFor("b2s", CabinetOutputRole.Backglass), Is.EqualTo(CabinetOutputMode.Player));
Assert.That(snapshot.ModeFor("pup", CabinetOutputRole.Backglass), Is.EqualTo(CabinetOutputMode.Custom));
Assert.That(snapshot.Targets[0].State, Is.EqualTo(CabinetEndpointState.Unavailable));
}

[Test]
public void DuplicateAssignmentsAndInvalidAvailableDestinationsAreRejected()
{
var adoption = new CabinetOutputAssignment("pup", CabinetOutputRole.Topper, CabinetOutputMode.Player);
Assert.Throws<ArgumentException>(() => new CabinetDisplaySnapshot(1, 1, 1, Array.Empty<CabinetDisplayTarget>(), new[] { adoption, adoption }));
Assert.Throws<ArgumentOutOfRangeException>(() => new CabinetDisplayTarget("screen", CabinetOutputRole.Backglass, CabinetEndpointState.Available));
var target = new CabinetDisplayTarget("screen", CabinetOutputRole.Dmd, CabinetEndpointState.Available, width: 128, height: 32);
Assert.Throws<ArgumentException>(() => new CabinetDisplaySnapshot(1, 1, 1, new[] { target, target }, Array.Empty<CabinetOutputAssignment>()));
var second = new CabinetDisplayTarget("second", CabinetOutputRole.Dmd, CabinetEndpointState.Disabled);
Assert.That(new CabinetDisplaySnapshot(1, 1, 1, new[] { target, second }, Array.Empty<CabinetOutputAssignment>()).TargetsFor(CabinetOutputRole.Dmd).Count, Is.EqualTo(2));
}

[Test]
public void RankingFallsBackOnlyWhenIneligibleAndIsIndependentOfPublicationOrder()
{
var b2s = Descriptor("b2s/1", "b2s");
var pup = Descriptor("pup/1", "pup");
var other = Descriptor("other/1", "other");
var order = new[] { "pup", "b2s" };
Assert.That(CabinetSurfaceSelector.Choose(new[] { other, b2s, pup }, order, _ => true), Is.SameAs(pup));
Assert.That(CabinetSurfaceSelector.Choose(new[] { pup, other, b2s }, order, item => item != pup), Is.SameAs(b2s));
Assert.That(CabinetSurfaceSelector.Choose(new[] { pup, other, b2s }, order, _ => false), Is.Null);
Assert.That(CabinetSurfaceSelector.Choose(new[] { Descriptor("z", "unknown"), Descriptor("a", "unknown") }, order, _ => true)!.Id, Is.EqualTo("a"));
}

[Test]
public void DesktopDemandDoesNotEnableAnAuthoredSceneScreen()
{
var source = new Surface { Consumers = CabinetSurfaceConsumers.ManagedDesktop, IsReady = true };
Assert.That(CabinetSurfaceSelector.Eligible(source, CabinetSurfaceConsumers.Scene), Is.False);
Assert.That(CabinetSurfaceSelector.Eligible(source, CabinetSurfaceConsumers.ManagedDesktop), Is.True);
source.IsReady = false;
Assert.That(CabinetSurfaceSelector.Eligible(source, CabinetSurfaceConsumers.ManagedDesktop), Is.False);
}

[Test]
public void RegistryProbeRejectsStaleInstancesAndReadinessChangesRecomputeSelection()
{
var registry = new PluginResourceRegistry();
var descriptor = Descriptor("surface", "pup");
var source = new Surface { Consumers = CabinetSurfaceConsumers.ManagedDesktop, IsReady = true };
using var publication = registry.Publish(descriptor, source);
PluginResourceDescriptor? selected = null;
using var watch = registry.Observe(_ => selected = CabinetSurfaceSelector.Choose(registry.Snapshot(), new[] { "pup" }, d => registry.TryGetSource<IPluginSurface>(d, out var current) && CabinetSurfaceSelector.Eligible(current!, CabinetSurfaceConsumers.Scene)));
source.Consumers |= CabinetSurfaceConsumers.Scene;
registry.NotifyChanged(descriptor.Id);
Assert.That(selected, Is.SameAs(descriptor));
source.IsReady = false;
registry.NotifyChanged(descriptor.Id);
Assert.That(selected, Is.Null);
var stale = new PluginResourceDescriptor(descriptor.Id, "pup", "controller", "surface", "backglass", ResourceKind.Surface, 1, 2);
Assert.That(registry.TryGetSource<IPluginSurface>(stale, out _), Is.False);
}

[Test]
public void SharedRolesAreClosedAndKeepHdDisplaySeparateFromBackglass()
{
Assert.That(CabinetOutputRoles.TryParse("pup-screen-8", out _), Is.False);
Assert.That(CabinetOutputRoles.TryParse("playfield", out _), Is.False, "Playfield placement belongs to Video.");
Assert.That(CabinetOutputRoles.Parse("game-display"), Is.EqualTo(CabinetOutputRole.GameDisplay));
Assert.That(CabinetOutputRoles.TryParse("full-dmd", out _), Is.False, "Full DMD is a screen PinUP places itself.");
Assert.That(CabinetOutputRole.GameDisplay.Id(), Is.Not.EqualTo(CabinetOutputRole.Backglass.Id()));
Assert.Throws<ArgumentException>(() => new CabinetOutputAssignment("plugin", (CabinetOutputRole)0, CabinetOutputMode.Player));
}

private static PluginResourceDescriptor Descriptor(string id, string provider) => new PluginResourceDescriptor(id, provider, "controller", id, "backglass", ResourceKind.Surface, 1, 1);
private sealed class Surface : IPluginSurface, ICabinetSurface
{
public ResourceKind Kind => ResourceKind.Surface;
public int Width => 1;
public int Height => 1;
public string Backend => "test";
public CabinetSurfaceConsumers Consumers { get; set; }
public bool IsReady { get; set; }
}
}
}
2 changes: 2 additions & 0 deletions VisualPinball.Plugins.Test/CabinetDisplayTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

154 changes: 154 additions & 0 deletions VisualPinball.Plugins/Api/CabinetDisplays.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Visual Pinball Engine
// Copyright (C) 2026 freezy and VPE Team
//
// SPDX-License-Identifier: MIT
// Licensed under MIT. See https://opensource.org/license/mit.

using System;
using System.Collections.Generic;
using System.Linq;

namespace VisualPinball.Plugins
{
/// <summary>Optional host capabilities for cabinet output configuration.</summary>
public static class CabinetDisplayCapability
{
/// <summary>An immutable <see cref="CabinetDisplaySnapshot"/>, published for the game before preparation.</summary>
public const string Configuration = "vpe.cabinet-displays.v1";
}

/// <summary>Shared cabinet destinations with Player-defined semantics. Plug-in-private screens are not cabinet roles.</summary>
public enum CabinetOutputRole
{
Backglass = 1,
Dmd = 2,
/// <summary>The game’s LCD/HD display, independent of backglass artwork and cabinet video panels.</summary>
GameDisplay = 3,
Topper = 4,
ScorePanel = 5,
}

/// <summary>Conversion at the generic resource/persistence boundary. The cabinet API itself uses the enum.</summary>
public static class CabinetOutputRoles
{
public static bool IsDefined(CabinetOutputRole role) => Enum.IsDefined(typeof(CabinetOutputRole), role);
public static string Id(this CabinetOutputRole role) => role switch {
CabinetOutputRole.Backglass => SurfaceRoles.Backglass,
CabinetOutputRole.Dmd => SurfaceRoles.Dmd,
CabinetOutputRole.GameDisplay => SurfaceRoles.GameDisplay,
CabinetOutputRole.Topper => SurfaceRoles.Topper,
CabinetOutputRole.ScorePanel => SurfaceRoles.ScorePanel,
_ => throw new ArgumentOutOfRangeException(nameof(role)),
};
public static bool TryParse(string id, out CabinetOutputRole role)
{
switch (id) {
case SurfaceRoles.Backglass: role = CabinetOutputRole.Backglass; return true;
case SurfaceRoles.Dmd: role = CabinetOutputRole.Dmd; return true;
case SurfaceRoles.GameDisplay: role = CabinetOutputRole.GameDisplay; return true;
case SurfaceRoles.Topper: role = CabinetOutputRole.Topper; return true;
case SurfaceRoles.ScorePanel: role = CabinetOutputRole.ScorePanel; return true;
default: role = default; return false;
}
}
public static CabinetOutputRole Parse(string id)
=> TryParse(id, out var role) ? role : throw new ArgumentException("Unknown cabinet output role: " + id, nameof(id));
}

/// <summary>Who places one plugin's physical output. Does not control its in-scene output.</summary>
public enum CabinetOutputMode { Custom = 0, Player = 1, Off = 2 }
/// <summary>Availability of a configured destination, independent of its content source.</summary>
public enum CabinetEndpointState { Unassigned = 0, Available = 1, Disabled = 2, Unavailable = 3 }

/// <summary>Immutable resolved destination: a region of the desktop, in physical pixels, including negative origins.</summary>
/// <remarks>Physical DMD panels are not player destinations. Their plug-ins open them from their own settings.</remarks>
public sealed class CabinetDisplayTarget
{
public string EndpointId { get; }
public CabinetOutputRole Role { get; }
public CabinetEndpointState State { get; }
public string Reason { get; }
public int X { get; }
public int Y { get; }
public int Width { get; }
public int Height { get; }

public CabinetDisplayTarget(string endpointId, CabinetOutputRole role, CabinetEndpointState state,
int x = 0, int y = 0, int width = 0, int height = 0, string reason = "")
{
if (string.IsNullOrWhiteSpace(endpointId)) throw new ArgumentException("An endpoint needs an ID.", nameof(endpointId));
if (!CabinetOutputRoles.IsDefined(role)) throw new ArgumentException("An output needs a role.", nameof(role));
if (!Enum.IsDefined(typeof(CabinetEndpointState), state)) throw new ArgumentOutOfRangeException(nameof(state));
if (state == CabinetEndpointState.Available && (width < 1 || height < 1)) throw new ArgumentOutOfRangeException(nameof(width));
EndpointId = endpointId; Role = role; State = state;
X = x; Y = y; Width = width; Height = height; Reason = reason ?? "";
}
}

/// <summary>Explicit adoption of one plugin output; absence always means Custom.</summary>
public sealed class CabinetOutputAssignment
{
public string PluginId { get; }
public CabinetOutputRole Role { get; }
public CabinetOutputMode Mode { get; }
public CabinetOutputAssignment(string pluginId, CabinetOutputRole role, CabinetOutputMode mode)
{
if (string.IsNullOrWhiteSpace(pluginId) || !CabinetOutputRoles.IsDefined(role)) throw new ArgumentException("An assignment needs a plugin and role.");
if (!Enum.IsDefined(typeof(CabinetOutputMode), mode)) throw new ArgumentOutOfRangeException(nameof(mode));
PluginId = pluginId; Role = role; Mode = mode;
}
}

/// <summary>
/// Game-scoped resolved configuration. Player/Off never imply falling back to a plugin-owned window when a monitor
/// disappears. Source production and in-scene visibility are independent of destination availability.
/// </summary>
public sealed class CabinetDisplaySnapshot
{
public long Revision { get; }
public long TopologyGeneration { get; }
public long Session { get; }
public IReadOnlyList<CabinetDisplayTarget> Targets { get; }
public IReadOnlyList<CabinetOutputAssignment> Assignments { get; }
public CabinetDisplaySnapshot(long revision, long topologyGeneration, long session,
IEnumerable<CabinetDisplayTarget> targets, IEnumerable<CabinetOutputAssignment> assignments)
{
Revision = revision; TopologyGeneration = topologyGeneration; Session = session;
var targetArray = (targets ?? throw new ArgumentNullException(nameof(targets))).ToArray();
var assignmentArray = (assignments ?? throw new ArgumentNullException(nameof(assignments))).ToArray();
if (targetArray.Any(value => value == null) || assignmentArray.Any(value => value == null)) throw new ArgumentException("Snapshot entries cannot be null.");
if (targetArray.GroupBy(value => (value.EndpointId, value.Role)).Any(group => group.Count() > 1)) throw new ArgumentException("Duplicate endpoint/role target.", nameof(targets));
if (assignmentArray.GroupBy(value => (value.PluginId, value.Role)).Any(group => group.Count() > 1)) throw new ArgumentException("Duplicate plugin/role assignment.", nameof(assignments));
Targets = Array.AsReadOnly(targetArray); Assignments = Array.AsReadOnly(assignmentArray);
}

public CabinetOutputMode ModeFor(string pluginId, CabinetOutputRole role)
{
foreach (var assignment in Assignments) if (assignment.PluginId == pluginId && assignment.Role == role) return assignment.Mode;
return CabinetOutputMode.Custom;
}

/// <summary>All destinations for this role, including unavailable ones.</summary>
public IReadOnlyList<CabinetDisplayTarget> TargetsFor(CabinetOutputRole role)
=> Array.AsReadOnly(Targets.Where(target => target.Role == role).ToArray());
}

/// <summary>Optional factory declaration. Inspecting roles must not create a gameplay instance or open a device.</summary>
public interface ICabinetDisplayPlugin
{
IReadOnlyList<CabinetOutputRole> CabinetRoles { get; }
}

/// <summary>Where an updated surface is eligible to be shown.</summary>
[Flags]
public enum CabinetSurfaceConsumers { None = 0, Scene = 1, ManagedDesktop = 2 }

/// <summary>Optional metadata on an existing resource, not a second transport. Legacy surfaces remain scene-eligible.</summary>
/// <remarks>Call PluginResourceRegistry.NotifyChanged for this publication whenever readiness or consumers changes.</remarks>
public interface ICabinetSurface
{
CabinetSurfaceConsumers Consumers { get; }
/// <summary>False before the first composition or after withdrawal; a black image can still be ready.</summary>
bool IsReady { get; }
}
}
2 changes: 2 additions & 0 deletions VisualPinball.Plugins/Api/CabinetDisplays.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 81 additions & 0 deletions VisualPinball.Plugins/Api/PluginSystemDefaults.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Visual Pinball Engine
// Copyright (C) 2026 freezy and VPE Team
//
// SPDX-License-Identifier: MIT
// Licensed under MIT. See https://opensource.org/license/mit.

using System;
using System.Collections.Generic;
using System.Threading;

namespace VisualPinball.Plugins
{
/// <summary>
/// Optional on an <see cref="IPlayerPluginFactory"/> (or its <see cref="IPlayerPluginFactory.SettingsProvider"/>): what the
/// plugin finds on this computer and uses while its own settings are empty, such as another application's folders or
/// configuration files. The Player shows the findings when it starts for the first time, so the user sees where their
/// defaults come from and can replace them with the player's own values.
/// </summary>
public interface IPluginSystemDefaults
{
/// <summary>
/// Looks for the plugin's defaults on this computer. Called on a worker thread, also while the plugin is switched off.
/// Only reads: never writes another application's files or settings.
/// </summary>
/// <param name="settings">The plugin's effective global settings.</param>
/// <param name="cancellation">Cancelled when the Player no longer needs the result.</param>
IReadOnlyList<PluginSystemDefault> FindSystemDefaults(PluginSettingsSnapshot settings, CancellationToken cancellation);
}

/// <summary>One thing a plugin found on this computer, or looked for and didn't find.</summary>
public sealed class PluginSystemDefault
{
/// <param name="title">What it is, such as <c>AltSound</c>.</param>
/// <param name="source">Where it comes from, such as the application that set it up: <c>VPinMAME</c>.</param>
public PluginSystemDefault(string title, string source)
{
Title = title ?? throw new ArgumentNullException(nameof(title));
Source = source ?? "";
}

/// <summary>What it is, such as <c>AltSound</c>.</summary>
public string Title { get; }
/// <summary>Where it comes from, such as the application that set it up: <c>VPinMAME</c>.</summary>
public string Source { get; }
/// <summary>The folder or file found, or empty when nothing was found.</summary>
public string Path { get; set; } = "";
/// <summary>Whether something was found. Without it, <see cref="Description"/> says what was looked for.</summary>
public bool Found => Path.Length > 0;
/// <summary>One line on what was found and what it is for, such as <c>12 replacement sound packs</c>.</summary>
public string Description { get; set; } = "";
/// <summary>
/// The plugin's global setting that replaces this default when set (a <see cref="SettingKind.Folder"/> or
/// <see cref="SettingKind.File"/> field), or null when the default can only be shown.
/// </summary>
public string? FieldId { get; set; }
/// <summary>
/// Whether the plugin uses this default by itself while <see cref="FieldId"/> is empty (the default). When false, the Player
/// saves the found path into <see cref="FieldId"/> once the user accepts the findings.
/// </summary>
public bool UsedWhenEmpty { get; set; } = true;
/// <summary>Where on the desktop the found configuration puts the plugin's cabinet outputs.</summary>
public IReadOnlyList<PluginSuggestedOutput> Outputs { get; set; } = Array.Empty<PluginSuggestedOutput>();
}

/// <summary>A cabinet output's placement in another application's configuration, in desktop pixels.</summary>
public readonly struct PluginSuggestedOutput
{
public PluginSuggestedOutput(CabinetOutputRole role, int x, int y, int width, int height)
{
Role = role; X = x; Y = y; Width = width; Height = height;
}

public CabinetOutputRole Role { get; }
/// <summary>Left edge, in virtual desktop pixels (negative left of the primary monitor).</summary>
public int X { get; }
/// <summary>Top edge, in virtual desktop pixels.</summary>
public int Y { get; }
public int Width { get; }
public int Height { get; }
}
}
2 changes: 2 additions & 0 deletions VisualPinball.Plugins/Api/PluginSystemDefaults.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading