diff --git a/VisualPinball.Plugins/Api/PluginStatusSource.cs b/VisualPinball.Plugins/Api/PluginStatusSource.cs
new file mode 100644
index 0000000000..b6641dcd4d
--- /dev/null
+++ b/VisualPinball.Plugins/Api/PluginStatusSource.cs
@@ -0,0 +1,65 @@
+// 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.Collections.Generic;
+
+namespace VisualPinball.Plugins
+{
+ ///
+ /// A running plugin that tells the player what it does in the current game, in a few short lines the
+ /// Player shows in its pause menu: the DMD plugin which colorization it loaded, PinUP which pack, B2S
+ /// which backglass.
+ ///
+ ///
+ /// Unlike , which is for developers, this is for the player at the table:
+ /// plain words, file names rather than paths, nothing about internals. A plugin that writes nothing has
+ /// nothing to do in this game (PinMAME on a table without a ROM), and the pause menu leaves it out; a
+ /// running plugin that doesn't implement this is listed with its name only.
+ ///
+ public interface IPluginStatusSource
+ {
+ ///
+ /// Writes the lines, such as Colorization → Serum, t2_l8.cRZ. Called on the owner
+ /// thread when the pause menu opens, so keep it cheap: no file reads.
+ ///
+ void DescribeStatus(PluginStatusWriter writer);
+ }
+
+ /// Collects the label/value lines of a plugin's status, in order.
+ public sealed class PluginStatusWriter
+ {
+ private readonly List> _lines = new();
+
+ /// The lines written so far.
+ public IReadOnlyList> Lines => _lines;
+
+ public PluginStatusWriter Add(string label, string value)
+ {
+ _lines.Add(new KeyValuePair(label ?? "", value ?? ""));
+ return this;
+ }
+
+ public PluginStatusWriter Add(string label, bool on) => Add(label, on ? "on" : "off");
+ }
+
+ /// One plugin as the pause menu shows it: what it is, its state, and its status lines.
+ public sealed class PluginStatusReport
+ {
+ public PluginDescriptor Descriptor { get; }
+ public PluginState State { get; }
+ /// Why the plugin isn't running, for a blocked or faulted one; empty otherwise.
+ public string Message { get; }
+ public IReadOnlyList> Lines { get; }
+
+ public PluginStatusReport(PluginDescriptor descriptor, PluginState state, string message, IReadOnlyList> lines)
+ {
+ Descriptor = descriptor;
+ State = state;
+ Message = message ?? "";
+ Lines = lines;
+ }
+ }
+}
diff --git a/VisualPinball.Plugins/Api/PluginStatusSource.cs.meta b/VisualPinball.Plugins/Api/PluginStatusSource.cs.meta
new file mode 100644
index 0000000000..8a171a502f
--- /dev/null
+++ b/VisualPinball.Plugins/Api/PluginStatusSource.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4bc4abe8152b4f07a8838fe2fb1fab89
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Plugins/Runtime/PluginHost.cs b/VisualPinball.Plugins/Runtime/PluginHost.cs
index e55cc57d5a..bdeeb03ee0 100644
--- a/VisualPinball.Plugins/Runtime/PluginHost.cs
+++ b/VisualPinball.Plugins/Runtime/PluginHost.cs
@@ -151,6 +151,47 @@ internal void Track(Task hook)
///
public IReadOnlyCollection Factories => _factories.Values;
///
+ /// The plugins of the current game as the Player's pause menu shows them: the running ones with what they
+ /// write as , and the ones that should run but don't (starting, blocked,
+ /// faulted, unresponsive) with why. Disabled and unsupported plugins are left out, and so is a running one
+ /// whose status says nothing, since it has nothing to do in this game.
+ ///
+ /// Call on the owner thread. A plugin that throws while describing itself is logged, not faulted.
+ public IReadOnlyList DescribeStatus()
+ {
+ Scheduler.VerifyOwner();
+ var reports = new List();
+ foreach (var factory in _factories.Values) {
+ var status = _statuses[factory.Descriptor.Id];
+ switch (status.State) {
+ case PluginState.Running:
+ // an emergency stop disposes the plugins and leaves their state alone: they have nothing to say
+ var instance = _startOrder.FirstOrDefault(entry => entry.Factory == factory && !entry.Cleaned);
+ if (instance == null) break;
+ if (instance.Plugin is not IPluginStatusSource source) {
+ reports.Add(new PluginStatusReport(factory.Descriptor, status.State, "", Array.Empty>()));
+ break;
+ }
+ var writer = new PluginStatusWriter();
+ try {
+ source.DescribeStatus(writer);
+ } catch (Exception error) {
+ _error(factory.Descriptor.Id + " could not describe its status", error);
+ writer.Add("Status", "unavailable");
+ }
+ if (writer.Lines.Count > 0) reports.Add(new PluginStatusReport(factory.Descriptor, status.State, "", writer.Lines));
+ break;
+ case PluginState.Starting:
+ case PluginState.Blocked:
+ case PluginState.Faulted:
+ case PluginState.Unresponsive:
+ reports.Add(new PluginStatusReport(factory.Descriptor, status.State, status.Message, Array.Empty>()));
+ break;
+ }
+ }
+ return reports;
+ }
+ ///
/// True from the end of until the game is stopped by ,
/// or the next start. It is also true when some or all plugins failed to start; check
/// for individual plugins.
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs
new file mode 100644
index 0000000000..9c57d417d2
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs
@@ -0,0 +1,76 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+// https://github.com/freezy/VisualPinball.Engine
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// The vertical field of view a camera shows on screen, for plain and physical cameras alike. A physical
+ /// camera's comes from the sensor's height only, while its gate fit can
+ /// fit the sensor's width to the screen instead; this reads and sets what the picture really spans.
+ ///
+ ///
+ ///
+ /// Works on the logical picture: for an output turned by , its aspect is the
+ /// turned one, and the camera is the unturned one the scripts see between frames.
+ ///
+ public static class CameraLens
+ {
+ public const float MinVerticalFov = 3f;
+ public const float MaxVerticalFov = 100f;
+
+ /// Width over height of the picture the camera renders, turned when the output is.
+ public static float LogicalAspect(Camera camera)
+ {
+ var size = DisplayRotation.LogicalSize(camera.pixelWidth, camera.pixelHeight);
+ return size.y > 0 ? size.x / (float)size.y : 1f;
+ }
+
+ /// The vertical field of view in degrees.
+ public static float GetVerticalFov(Camera camera)
+ {
+ if (!camera.usePhysicalProperties) {
+ return camera.fieldOfView;
+ }
+ var sensor = camera.sensorSize;
+ var focal = math.max(camera.focalLength, 0.01f);
+ var tanHalf = FitsWidth(camera, sensor)
+ ? sensor.x * 0.5f / focal / LogicalAspect(camera)
+ : sensor.y * 0.5f / focal;
+ return 2f * math.degrees(math.atan(tanHalf));
+ }
+
+ /// Sets the vertical field of view in degrees; a physical camera changes its focal length.
+ public static void SetVerticalFov(Camera camera, float degrees)
+ {
+ degrees = math.clamp(degrees, MinVerticalFov, MaxVerticalFov);
+ if (!camera.usePhysicalProperties) {
+ camera.fieldOfView = degrees;
+ return;
+ }
+ var sensor = camera.sensorSize;
+ var tanHalf = math.tan(math.radians(degrees) * 0.5f);
+ camera.focalLength = FitsWidth(camera, sensor)
+ ? sensor.x * 0.5f / (tanHalf * LogicalAspect(camera))
+ : sensor.y * 0.5f / tanHalf;
+ }
+
+ // Whether the sensor's width spans the picture's width (else its height spans the picture's height).
+ private static bool FitsWidth(Camera camera, Vector2 sensor)
+ {
+ var sensorAspect = sensor.y > 0f ? sensor.x / sensor.y : 1f;
+ var aspect = LogicalAspect(camera);
+ return camera.gateFit switch {
+ Camera.GateFitMode.Horizontal => true,
+ Camera.GateFitMode.Fill => aspect > sensorAspect,
+ Camera.GateFitMode.Overscan => aspect < sensorAspect,
+ _ => false,
+ };
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs.meta
new file mode 100644
index 0000000000..7bd688f423
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bc376ac052894de0ab6d6a5fcf9f92b9
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs
index 5b138f69c7..9dde5d66c6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs
@@ -70,11 +70,20 @@ public class CameraTranslateAndOrbit : MonoBehaviour
private Keyboard _keyboard;
private Mouse _mouse;
private PhysicsEngine _physicsEngine;
+ private Camera _camera;
/* -------------------------------------------------------- */
private void Start()
{
+ EnsureInitialized();
+ }
+
+ private void EnsureInitialized()
+ {
+ if (_transformCache != null) {
+ return;
+ }
// same logic you had before to center on renderer bounds, etc.
if (initialOrbit != null) {
var pfr = initialOrbit.GetComponent();
@@ -99,6 +108,29 @@ private void Start()
}
_keyboard = Keyboard.current;
_mouse = Mouse.current;
+ _camera = GetComponent();
+ }
+
+ ///
+ /// Puts the camera at a pose right away; from then on, it orbits around the point
+ /// ahead of it. Lets go of a locked ball.
+ ///
+ public void SetPose(Vector3 position, Quaternion rotation, float pivotDistance)
+ {
+ EnsureInitialized();
+ lockTarget = null;
+ _isTrackingBall = false;
+ _isTrackingBallTarget = false;
+ _isTrackingMouse0 = false;
+ _isTrackingMouse1 = false;
+
+ _radius = _radiusCurrent = Mathf.Clamp(pivotDistance, RadiusMin, RadiusMax);
+ _radiusMax = Mathf.Max(RadiusMax, _radius);
+ positionOffset = _positionOffsetCurrent = position + rotation * Vector3.forward * _radius;
+ _rot2 = rotation;
+ _focusPoint = rotation * Vector3.forward * -_radius;
+ _transformCache.SetPositionAndRotation(position, rotation);
+ _dummyTransform.SetPositionAndRotation(position, rotation);
}
private void Update()
@@ -287,11 +319,16 @@ private void UpdateMouse()
}
}
- /* ========== ZOOM (scroll) ========== */
+ /* ========== ZOOM (scroll), FIELD OF VIEW (ctrl + scroll) ========== */
if (!isAnimating && !hasHitRestrictedHitArea) {
float deltaScroll = _mouse.scroll.y.ReadValue() / 300f * zoomSpeed;
+ if (_keyboard != null && _keyboard.ctrlKey.isPressed) {
+ if (deltaScroll != 0f && _camera != null) {
+ CameraLens.SetVerticalFov(_camera, CameraLens.GetVerticalFov(_camera) * (1f - deltaScroll));
+ }
+ }
/* --------------- UpdateMouse() – ZOOM in lock --------------- */
- if (lockTarget != null && _isTrackingBall) { // ★ only follow-lock zooms
+ else if (lockTarget != null && _isTrackingBall) { // ★ only follow-lock zooms
float r = Mathf.Clamp(_ballFollowOffsetTarget.magnitude * (1f - deltaScroll), RadiusMin, _radiusMax);
_ballFollowOffsetTarget = _ballFollowOffsetTarget.normalized * r;
} else if (lockTarget == null) {
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs
index c3774dc8a4..9543e935b8 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs
@@ -25,6 +25,23 @@ public enum VpeAntiAliasing
Taa = 3,
}
+ /// Temporal upscaler the camera renders through, at a lower internal resolution.
+ public enum VpeUpscaler
+ {
+ Off = 0,
+ Dlss = 1, // NVIDIA Deep Learning Super Sampling
+ Fsr2 = 2, // AMD FidelityFX Super Resolution 2
+ }
+
+ /// How far the upscaler lowers the internal resolution (each upscaler picks its own scale per mode).
+ public enum VpeUpscalerQuality
+ {
+ Quality = 0,
+ Balanced = 1,
+ Performance = 2,
+ UltraPerformance = 3,
+ }
+
///
/// Plain-data render-quality settings that are tunable at runtime via the active render pipeline's
/// . Carries only the SRP-specific knobs (camera AA + Volume effects);
@@ -40,6 +57,10 @@ public struct VpeGraphicsSettings
public bool Bloom;
public bool RayTracedGlobalIllumination;
public bool RayTracedReflections;
+ // Experimental: clear refractive materials (ramps, transparent plastics) are ray traced recursively.
+ public bool RayTracedRecursiveRendering;
+ public int Upscaler; // VpeUpscaler
+ public int UpscalerQuality; // VpeUpscalerQuality
}
/// Implemented by the active render pipeline (e.g. HDRP) to apply .
@@ -62,5 +83,19 @@ public static class VpeGraphics
public static void Register(IVpeGraphicsApplier applier) => _applier = applier;
public static void Apply(VpeGraphicsSettings settings) => _applier?.Apply(settings);
+
+ ///
+ /// Whether an upscaler runs on this machine, as the active pipeline reports it (it registers the check
+ /// next to its applier). Off is always supported.
+ ///
+ public static bool IsUpscalerSupported(VpeUpscaler upscaler)
+ => upscaler == VpeUpscaler.Off || (UpscalerSupport != null && UpscalerSupport(upscaler));
+
+ public static System.Func UpscalerSupport;
+
+ /// The share of the output resolution the camera renders at (1 without upscaling), as the pipeline reports it.
+ public static float RenderScale => RenderScaleSource?.Invoke() ?? 1f;
+
+ public static System.Func RenderScaleSource;
}
}