diff --git a/Runtime/MpfGamelogicEngine.cs b/Runtime/MpfGamelogicEngine.cs
index 8c92c6e..7ae4d65 100644
--- a/Runtime/MpfGamelogicEngine.cs
+++ b/Runtime/MpfGamelogicEngine.cs
@@ -20,6 +20,7 @@
using UnityEngine;
using VisualPinball.Engine.Game.Engines;
using VisualPinball.Engine.Mpf.Unity.MediaController;
+using VisualPinball.Plugins;
using VisualPinball.Unity;
using Logger = NLog.Logger;
@@ -30,7 +31,11 @@ namespace VisualPinball.Engine.Mpf.Unity
/// and applying changes to coils, lights and hardware rules requested by MPF.
///
[AddComponentMenu("Pinball/Gamelogic Engine/MPF Game Logic")]
- public class MpfGamelogicEngine : MonoBehaviour, IGamelogicEngine, IGamelogicInputThreading
+ public class MpfGamelogicEngine
+ : MonoBehaviour,
+ IGamelogicEngine,
+ IGamelogicInputThreading,
+ IGamelogicNativeOutputs
{
[SerializeField]
private MpfWranglerOptions _wranglerOptions;
@@ -107,6 +112,10 @@ private MpfWrangler MpfWrangler
private Player _player;
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+ private readonly MpfOutputModel _nativeOutputModel = new(MonotonicMs);
+ private NativeOutputSink _nativeOutputSink;
+ private INativeOutputListener _nativeOutputs;
+
public string Name => "Mission Pinball Framework";
public GamelogicInputDispatchMode SwitchDispatchMode => GamelogicInputDispatchMode.MainThread;
public GamelogicEngineSwitch[] RequestedSwitches => _requestedSwitches;
@@ -366,6 +375,10 @@ public async void Switch(string id, bool isClosed)
{
OnSwitchChanged?.Invoke(this, new SwitchEventArgs2(id, isClosed));
+ // VPE runs MPF's hardware rules as wires, so their coils only show up here.
+ if (_mpfSwitchNumbers.TryGetNumberByName(id, out var ruleSwitchNumber))
+ _nativeOutputModel.EnqueueSwitch(ruleSwitchNumber, isClosed);
+
if (_mpfSwitchNumbers.ContainsName(id))
{
if (MpfWrangler.MpfState == MpfState.Connected)
@@ -394,6 +407,96 @@ public async void Switch(string id, bool isClosed)
}
}
+ private void Update()
+ {
+ DispatchNativeOutputs();
+ }
+
+ ///
+ /// Reports every light and coil MPF drives by its machine config number, including
+ /// outputs the table does not map. See for the numbering.
+ ///
+ ///
+ /// Called on the main thread, like . The model only queues MPF
+ /// commands while attached and checks that under its queue lock, so a command that races
+ /// with the detach cannot reach the next listener.
+ ///
+ public void SetNativeOutputListener(INativeOutputListener listener)
+ {
+ if (listener != null)
+ {
+ _nativeOutputModel.Attach();
+ _nativeOutputs = listener;
+ }
+ else
+ {
+ _nativeOutputs = null;
+ // Release the last listener too, so a detached plugin is not kept alive by the engine.
+ if (_nativeOutputSink != null)
+ _nativeOutputSink.Listener = null;
+ _nativeOutputModel.Detach();
+ }
+ }
+
+ private void DispatchNativeOutputs()
+ {
+ var listener = _nativeOutputs;
+ if (listener == null)
+ return;
+ _nativeOutputSink ??= new NativeOutputSink();
+ _nativeOutputSink.Listener = listener;
+ _nativeOutputModel.Poll(_nativeOutputSink);
+ listener.NativeOutputPollEnd();
+ }
+
+ private static double MonotonicMs() =>
+ System.Diagnostics.Stopwatch.GetTimestamp()
+ * 1000d
+ / System.Diagnostics.Stopwatch.Frequency;
+
+ ///
+ /// MPF lights are dimmable channels (brightness 0..1, faded by );
+ /// MPF coils are switched on and off by the VPE platform.
+ ///
+ private sealed class NativeOutputSink : IMpfOutputSink
+ {
+ public INativeOutputListener Listener;
+
+ public void OutputChanged(MpfOutputKind kind, int number, double value)
+ {
+ switch (kind)
+ {
+ case MpfOutputKind.Coil:
+ Listener.NativeOutputChanged(
+ ControllerEventKind.Coil,
+ number,
+ value,
+ ControllerValueSemantics.Binary,
+ 1d
+ );
+ break;
+ case MpfOutputKind.GeneralIllumination:
+ Listener.NativeOutputChanged(
+ ControllerEventKind.GeneralIllumination,
+ number,
+ value,
+ ControllerValueSemantics.Physical,
+ 1d
+ );
+ break;
+ default:
+ Listener.NativeOutputChanged(
+ ControllerEventKind.Lamp,
+ number,
+ value,
+ ControllerValueSemantics.Physical,
+ 1d
+ );
+ break;
+ }
+ }
+ }
+
private void OnMpfStateChanged(object sender, StateChangedEventArgs args)
{
MpfStateChanged?.Invoke(this, args);
@@ -444,6 +547,13 @@ private MachineState CompileMachineState(Player player)
private void ExecuteMpfFadeLightRequest(object sender, FadeLightRequest request)
{
+ foreach (var fade in request.Fades)
+ _nativeOutputModel.EnqueueFade(
+ fade.LightNumber,
+ fade.TargetBrightness,
+ request.CommonFadeMs
+ );
+
var args = new List();
foreach (var fade in request.Fades)
{
@@ -460,6 +570,8 @@ private void ExecuteMpfFadeLightRequest(object sender, FadeLightRequest request)
private void ExecuteMpfPulseCoilRequest(object sender, PulseCoilRequest request)
{
+ _nativeOutputModel.EnqueuePulse(request.CoilNumber, request.PulseMs);
+
if (_mpfCoilNumbers.TryGetNameByNumber(request.CoilNumber, out var coilName))
{
SetCoil(coilName, true);
@@ -474,6 +586,8 @@ private void ExecuteMpfPulseCoilRequest(object sender, PulseCoilRequest request)
private void ExecuteMpfEnableCoilRequest(object sender, EnableCoilRequest request)
{
+ _nativeOutputModel.EnqueueEnable(request.CoilNumber);
+
if (_mpfCoilNumbers.TryGetNameByNumber(request.CoilNumber, out var coilName))
SetCoil(coilName, true);
else
@@ -485,6 +599,8 @@ private void ExecuteMpfEnableCoilRequest(object sender, EnableCoilRequest reques
private void ExecuteMpfCommandDisableCoilRequest(object sender, DisableCoilRequest request)
{
+ _nativeOutputModel.EnqueueDisable(request.CoilNumber);
+
if (_mpfCoilNumbers.TryGetNameByNumber(request.CoilNumber, out var coilName))
SetCoil(coilName, false);
else
@@ -501,6 +617,12 @@ ConfigureHardwareRuleRequest request
{
var switchNumber = request.SwitchNumber;
var coilNumber = request.CoilNumber;
+ _nativeOutputModel.ConfigureRule(
+ switchNumber,
+ coilNumber,
+ request.PulseMs,
+ request.HoldPower
+ );
if (
_mpfSwitchNumbers.TryGetNameByNumber(switchNumber, out var switchName)
&& _mpfCoilNumbers.TryGetNameByNumber(coilNumber, out var coilName)
@@ -521,6 +643,7 @@ RemoveHardwareRuleRequest request
{
var switchNumber = request.SwitchNumber;
var coilNumber = request.CoilNumber;
+ _nativeOutputModel.RemoveRule(switchNumber, coilNumber);
if (
_mpfSwitchNumbers.TryGetNameByNumber(switchNumber, out var switchName)
&& _mpfCoilNumbers.TryGetNameByNumber(coilNumber, out var coilName)
diff --git a/Runtime/MpfOutputModel.cs b/Runtime/MpfOutputModel.cs
new file mode 100644
index 0000000..bc59751
--- /dev/null
+++ b/Runtime/MpfOutputModel.cs
@@ -0,0 +1,692 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+
+namespace VisualPinball.Engine.Mpf.Unity
+{
+ /// The kind of an MPF output, as reported to native output listeners.
+ public enum MpfOutputKind
+ {
+ Lamp,
+ GeneralIllumination,
+ Coil,
+ }
+
+ /// Receives the output changes of one .
+ public interface IMpfOutputSink
+ {
+ /// The output kind.
+ ///
+ /// The output's number from the machine config, or -1 when it is not a plain integer.
+ ///
+ ///
+ /// Lamps and GI: brightness 0..1, already faded. Coils: 0 or 1.
+ ///
+ void OutputChanged(MpfOutputKind kind, int number, double value);
+ }
+
+ ///
+ /// Tracks the state of every light and coil MPF drives over the VPE platform, independent of
+ /// the table's mapping, and reports their changes by number once per poll.
+ ///
+ ///
+ ///
+ /// MPF's visual_pinball_engine platform passes the number: of the machine config
+ /// through as an opaque string (see mpf/platforms/visual_pinball_engine). Coils arrive
+ /// as that string. Lights arrive as <subtype>-<number>, where the subtype
+ /// is light unless the config sets one, and each channel of a multi-channel light is
+ /// its own number. This model reports:
+ ///
+ /// - coils by their config number;
+ /// - lights of subtype light as lamps and of subtype gi as GI strings, by
+ /// their config number;
+ /// - number -1 for anything else: numbers that are not plain non-negative integers (such as
+ /// FAST 0-3 or P-ROC A0-B0-1) and lights of other subtypes, whose numbers
+ /// would overlap with those of subtype light.
+ ///
+ ///
+ ///
+ /// MPF delegates fades to the platform (the VPE light implements set_fade and sends the
+ /// target brightness with a fade time), so this model interpolates them linearly, as MPF's
+ /// software fades do, and reports the faded brightness. Coils are reported on or off: on
+ /// from an enable or pulse until a disable or the pulse's end. Pulse and hold power are not
+ /// modelled.
+ ///
+ ///
+ /// MPF fires flipper, slingshot and pop bumper coils through hardware rules, which VPE runs as
+ /// wires without telling the engine. The model keeps MPF's rules and applies them to the
+ /// switch changes the engine receives: a rule turns its coil on when the switch closes and
+ /// off when it opens, or after the pulse time when the rule has no hold power. Removing a rule
+ /// releases what it drives. A coil stays on while anything drives it: MPF's own enable or
+ /// pulse, or any rule; MPF's disable turns it off regardless.
+ ///
+ ///
+ /// Every command is stamped with the clock when it is queued, and applies
+ /// commands and pulse ends in that order, so a late poll does not stretch fades or pulses.
+ /// Commands are only queued while attached (, ); the
+ /// check happens under the queue lock, so nothing queued before a detach survives it.
+ ///
+ ///
+ /// The queue and rule methods may be called from any thread; and
+ /// belong to one thread (Unity's main thread). Once every output has been
+ /// seen and the queues have grown, polling does not allocate.
+ ///
+ ///
+ public sealed class MpfOutputModel
+ {
+ /// The subtype MPF's VPE platform gives lights that have none in the config.
+ public const string LampSubtype = "light";
+
+ /// The light subtype reported as a GI string.
+ public const string GiSubtype = "gi";
+
+ private enum CommandType
+ {
+ Fade,
+ Pulse,
+ Enable,
+ Disable,
+ RuleOn,
+ RuleOff,
+ }
+
+ private struct Command
+ {
+ public CommandType Type;
+ public string Number;
+ public string Switch;
+ public float Brightness;
+ public uint Milliseconds;
+ public bool Hold;
+ public double TimeMs;
+ }
+
+ private struct Rule
+ {
+ public string Coil;
+ public uint PulseMs;
+ public bool Hold;
+ }
+
+ /// How MPF's own commands drive a coil.
+ private enum Drive
+ {
+ None,
+ Held,
+ Pulse,
+ }
+
+ /// A hardware rule currently driving a coil, keyed by its switch.
+ private struct RuleDrive
+ {
+ public string Switch;
+ public bool Hold;
+ public double EndMs;
+ }
+
+ private sealed class Output
+ {
+ public MpfOutputKind Kind;
+ public int Number;
+
+ // lights
+ public double Start;
+ public double Target;
+ public double FadeStartMs;
+ public double FadeEndMs;
+ public bool Fading;
+ public bool InFadeList;
+ public bool Dirty;
+ public double Reported;
+
+ // coils
+ public bool On;
+ public Drive Explicit;
+ public double ExplicitEndMs;
+ public List Rules;
+ public double PulseEndMs; // earliest end of any pulse driving the coil
+ public bool Pulsing; // in _pulsing
+ }
+
+ private readonly Func _clockMs;
+
+ // guarded by _queueLock
+ private readonly object _queueLock = new();
+ private List _incoming = new();
+ private bool _attached;
+ private readonly Dictionary> _rules = new(StringComparer.Ordinal);
+
+ // owned by the polling thread
+ private List _processing = new();
+ private readonly Dictionary _lights = new(StringComparer.Ordinal);
+ private readonly Dictionary _coils = new(StringComparer.Ordinal);
+ private readonly List