From 47a2029c7803ceef5a96cbccdd78db940ec1ecca Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 13:00:51 +0200 Subject: [PATCH 1/2] plugins: report native outputs to player plugins Implements IGamelogicNativeOutputs, so player plugins such as the B2S backglass receive every light and coil MPF drives, mapped or not. - Numbering: coils use their MPF config number when it is a plain integer. Lights of subtype `light` are lamps and `gi` lights are GI strings, both with their config number. Hardware addresses that aren't plain integers (FAST, P-ROC, OPP styles) and other light subtypes are reported with number -1 rather than a guessed number. - Values: lights are physical brightness 0..1, following MPF's fades. Coils are on/off, from enable or pulse until disable or the pulse's end. - Hardware rules (flippers, slings, pops) are reported from the switch changes that fire them. Removing a rule, as on a tilt, releases what it held. - Commands from the gRPC threads are timestamped and applied in order once per frame on the main thread, with no allocation in steady state. Detaching drops anything still queued. --- Runtime/MpfGamelogicEngine.cs | 122 ++- Runtime/MpfOutputModel.cs | 692 ++++++++++++++++++ Runtime/MpfOutputModel.cs.meta | 11 + Runtime/VisualPinball.Engine.Mpf.Unity.asmdef | 1 + 4 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 Runtime/MpfOutputModel.cs create mode 100644 Runtime/MpfOutputModel.cs.meta diff --git a/Runtime/MpfGamelogicEngine.cs b/Runtime/MpfGamelogicEngine.cs index 8c92c6eb..7c5fccac 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,93 @@ 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; + _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 +544,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 +567,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 +583,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 +596,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 +614,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 +640,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 00000000..bc597518 --- /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 _fading = new(); + private readonly List _dirty = new(); + private readonly List _pulsing = new(); + + /// A monotonic clock in milliseconds, safe to call from any thread. + public MpfOutputModel(Func clockMs) + { + _clockMs = clockMs ?? throw new ArgumentNullException(nameof(clockMs)); + } + + /// Starts accepting commands. + public void Attach() + { + lock (_queueLock) + { + _attached = true; + } + } + + /// + /// Stops accepting commands, drops the queued ones and forgets all output states. Hardware + /// rules are kept, since MPF configured them independently of any listener. + /// + public void Detach() + { + lock (_queueLock) + { + _attached = false; + _incoming.Clear(); + } + _processing.Clear(); + _lights.Clear(); + _coils.Clear(); + _fading.Clear(); + _dirty.Clear(); + _pulsing.Clear(); + } + + /// Queues MPF's FadeLightRequest for one channel. + /// The channel's number as MPF sends it, such as light-12. + /// The brightness to reach, 0..1. + /// The fade duration; 0 sets the brightness at once. + public void EnqueueFade(string lightNumber, float targetBrightness, uint fadeMs) => + Enqueue(CommandType.Fade, lightNumber, targetBrightness, fadeMs); + + /// Queues MPF's PulseCoilRequest. + public void EnqueuePulse(string coilNumber, uint pulseMs) => + Enqueue(CommandType.Pulse, coilNumber, 0, pulseMs); + + /// Queues MPF's EnableCoilRequest. + public void EnqueueEnable(string coilNumber) => + Enqueue(CommandType.Enable, coilNumber, 0, 0); + + /// Queues MPF's DisableCoilRequest. + public void EnqueueDisable(string coilNumber) => + Enqueue(CommandType.Disable, coilNumber, 0, 0); + + /// + /// Records MPF's ConfigureHardwareRuleRequest, replacing an earlier rule between the + /// same switch and coil. Accepted while detached. + /// + /// MPF's switch number. + /// MPF's coil number. + /// How long the coil fires when the rule has no hold power. + /// + /// MPF's hold power; above zero, the coil stays on while the switch is closed. + /// + public void ConfigureRule( + string switchNumber, + string coilNumber, + uint pulseMs, + float holdPower + ) + { + if (string.IsNullOrEmpty(switchNumber) || string.IsNullOrEmpty(coilNumber)) + return; + var rule = new Rule + { + Coil = coilNumber, + PulseMs = pulseMs, + Hold = holdPower > 0, + }; + lock (_queueLock) + { + if (!_rules.TryGetValue(switchNumber, out var rules)) + { + rules = new List(); + _rules[switchNumber] = rules; + } + for (var i = 0; i < rules.Count; i++) + { + if (rules[i].Coil == coilNumber) + { + rules[i] = rule; + return; + } + } + rules.Add(rule); + } + } + + /// + /// Removes the rule between a switch and a coil (MPF's RemoveHardwareRuleRequest) + /// and queues the release of what it drives. The coil stays on if something else drives it. + /// + public void RemoveRule(string switchNumber, string coilNumber) + { + if (string.IsNullOrEmpty(switchNumber) || string.IsNullOrEmpty(coilNumber)) + return; + lock (_queueLock) + { + if (!_rules.TryGetValue(switchNumber, out var rules)) + return; + var removed = false; + for (var i = rules.Count - 1; i >= 0; i--) + { + if (rules[i].Coil == coilNumber) + { + rules.RemoveAt(i); + removed = true; + } + } + // release whatever the rule drives now (MPF clears rules on tilt without disabling) + if (removed && _attached) + { + _incoming.Add( + new Command + { + Type = CommandType.RuleOff, + Number = coilNumber, + Switch = switchNumber, + TimeMs = _clockMs(), + } + ); + } + } + } + + /// + /// Applies the hardware rules of a switch that changed, queuing what their coils do. + /// + /// MPF's switch number. + /// Whether the switch is now closed. + public void EnqueueSwitch(string switchNumber, bool isClosed) + { + if (string.IsNullOrEmpty(switchNumber)) + return; + lock (_queueLock) + { + if ( + !_attached + || !_rules.TryGetValue(switchNumber, out var rules) + || rules.Count == 0 + ) + return; + var now = _clockMs(); + foreach (var rule in rules) + { + _incoming.Add( + new Command + { + Type = isClosed ? CommandType.RuleOn : CommandType.RuleOff, + Number = rule.Coil, + Switch = switchNumber, + Milliseconds = rule.PulseMs, + Hold = rule.Hold, + TimeMs = now, + } + ); + } + } + } + + /// + /// Applies the queued commands in the order and at the time they arrived, ends pulses in + /// between, and reports every output whose value changed since the last poll. + /// + /// + /// Coil transitions are reported in order, so a coil enabled and disabled within one poll + /// reports both. Lights report their brightness at the time of the poll. + /// + /// Receives the changes. + public void Poll(IMpfOutputSink sink) + { + double nowMs; + lock (_queueLock) + { + (_incoming, _processing) = (_processing, _incoming); + // read under the lock, so no queued command is stamped later than the poll + nowMs = _clockMs(); + } + + foreach (var command in _processing) + { + var timeMs = System.Math.Min(command.TimeMs, nowMs); + EndPulses(timeMs, sink); + if (string.IsNullOrEmpty(command.Number)) + continue; + switch (command.Type) + { + case CommandType.Fade: + ApplyFade(command, timeMs); + break; + case CommandType.Pulse: + { + // a pulse on an enabled coil disables it afterwards + var coil = GetCoil(command.Number); + coil.Explicit = Drive.Pulse; + coil.ExplicitEndMs = timeMs + command.Milliseconds; + UpdateCoil(coil, sink); + break; + } + case CommandType.Enable: + { + var coil = GetCoil(command.Number); + coil.Explicit = Drive.Held; + UpdateCoil(coil, sink); + break; + } + case CommandType.Disable: + { + var coil = GetCoil(command.Number); + coil.Explicit = Drive.None; + coil.Rules?.Clear(); + UpdateCoil(coil, sink); + break; + } + case CommandType.RuleOn: + { + var coil = GetCoil(command.Number); + coil.Rules ??= new List(); + var drive = new RuleDrive + { + Switch = command.Switch, + Hold = command.Hold, + EndMs = command.Hold + ? double.PositiveInfinity + : timeMs + command.Milliseconds, + }; + var index = FindRule(coil, command.Switch); + if (index >= 0) + coil.Rules[index] = drive; + else + coil.Rules.Add(drive); + UpdateCoil(coil, sink); + break; + } + case CommandType.RuleOff: + { + // the switch opened or the rule was removed + var coil = GetCoil(command.Number); + var index = FindRule(coil, command.Switch); + if (index >= 0) + { + coil.Rules.RemoveAt(index); + UpdateCoil(coil, sink); + } + break; + } + } + } + _processing.Clear(); + EndPulses(nowMs, sink); + + foreach (var light in _dirty) + { + light.Dirty = false; + Report(light, nowMs, sink); + } + _dirty.Clear(); + + for (var i = _fading.Count - 1; i >= 0; i--) + { + var light = _fading[i]; + Report(light, nowMs, sink); + if (!light.Fading || nowMs >= light.FadeEndMs) + { + light.Fading = false; + light.InFadeList = false; + RemoveAt(_fading, i); + } + } + } + + /// + /// Parses a coil number from the machine config: a plain non-negative decimal integer, or -1. + /// + public static int CoilNumber(string number) => ParseNumber(number, 0); + + /// + /// Classifies a light channel number as MPF's VPE platform sends it + /// (<subtype>-<number>). + /// + /// The channel number, such as light-12 or gi-1. + /// GI for subtype gi, lamp otherwise. + /// + /// The config number for subtypes light and gi when it is a plain + /// non-negative decimal integer, or -1. + /// + public static int LightNumber(string lightNumber, out MpfOutputKind kind) + { + kind = MpfOutputKind.Lamp; + var dash = lightNumber?.IndexOf('-') ?? -1; + if (dash <= 0) + return -1; + if (IsSubtype(lightNumber, dash, GiSubtype)) + { + kind = MpfOutputKind.GeneralIllumination; + return ParseNumber(lightNumber, dash + 1); + } + return IsSubtype(lightNumber, dash, LampSubtype) + ? ParseNumber(lightNumber, dash + 1) + : -1; + } + + private void Enqueue(CommandType type, string number, float brightness, uint ms) + { + lock (_queueLock) + { + if (!_attached) + return; + _incoming.Add( + new Command + { + Type = type, + Number = number, + Brightness = brightness, + Milliseconds = ms, + TimeMs = _clockMs(), + } + ); + } + } + + private void ApplyFade(Command command, double timeMs) + { + if (!_lights.TryGetValue(command.Number, out var light)) + { + var number = LightNumber(command.Number, out var kind); + light = new Output { Kind = kind, Number = number }; + _lights[command.Number] = light; + } + + var target = System.Math.Clamp((double)command.Brightness, 0d, 1d); + if (command.Milliseconds == 0) + { + light.Target = target; + light.Fading = false; // leaves _fading on its next pass + } + else + { + light.Start = Evaluate(light, timeMs); + light.Target = target; + light.FadeStartMs = timeMs; + light.FadeEndMs = timeMs + command.Milliseconds; + light.Fading = true; + if (!light.InFadeList) + { + light.InFadeList = true; + _fading.Add(light); + } + } + if (!light.Dirty) + { + light.Dirty = true; + _dirty.Add(light); + } + } + + private Output GetCoil(string number) + { + if (!_coils.TryGetValue(number, out var coil)) + { + coil = new Output { Kind = MpfOutputKind.Coil, Number = CoilNumber(number) }; + _coils[number] = coil; + } + return coil; + } + + private static int FindRule(Output coil, string switchNumber) + { + if (coil.Rules == null) + return -1; + for (var i = 0; i < coil.Rules.Count; i++) + { + if (string.Equals(coil.Rules[i].Switch, switchNumber, StringComparison.Ordinal)) + return i; + } + return -1; + } + + /// + /// Recomputes whether anything drives the coil and when its next pulse ends, and reports + /// the change. + /// + private void UpdateCoil(Output coil, IMpfOutputSink sink) + { + var on = coil.Explicit != Drive.None; + var endMs = coil.Explicit == Drive.Pulse ? coil.ExplicitEndMs : double.PositiveInfinity; + if (coil.Rules != null) + { + foreach (var rule in coil.Rules) + { + on = true; + if (!rule.Hold && rule.EndMs < endMs) + endMs = rule.EndMs; + } + } + coil.PulseEndMs = endMs; + var pulsing = !double.IsPositiveInfinity(endMs); + if (pulsing && !coil.Pulsing) + { + coil.Pulsing = true; + _pulsing.Add(coil); + } + else if (!pulsing && coil.Pulsing) + { + coil.Pulsing = false; + var index = _pulsing.IndexOf(coil); + if (index >= 0) + RemoveAt(_pulsing, index); + } + SetCoil(coil, on, sink); + } + + /// + /// Ends the pulses that are over by , earliest first. + /// + private void EndPulses(double timeMs, IMpfOutputSink sink) + { + while (true) + { + var earliest = -1; + for (var i = 0; i < _pulsing.Count; i++) + { + var end = _pulsing[i].PulseEndMs; + if (end <= timeMs && (earliest < 0 || end < _pulsing[earliest].PulseEndMs)) + earliest = i; + } + if (earliest < 0) + return; + var coil = _pulsing[earliest]; + var endMs = coil.PulseEndMs; + if (coil.Explicit == Drive.Pulse && coil.ExplicitEndMs <= endMs) + coil.Explicit = Drive.None; + if (coil.Rules != null) + { + for (var i = coil.Rules.Count - 1; i >= 0; i--) + { + if (!coil.Rules[i].Hold && coil.Rules[i].EndMs <= endMs) + coil.Rules.RemoveAt(i); + } + } + UpdateCoil(coil, sink); + } + } + + private static void SetCoil(Output coil, bool on, IMpfOutputSink sink) + { + if (coil.On == on) + return; + coil.On = on; + sink.OutputChanged(MpfOutputKind.Coil, coil.Number, on ? 1d : 0d); + } + + private static void Report(Output light, double nowMs, IMpfOutputSink sink) + { + var value = Evaluate(light, nowMs); + if (value == light.Reported) + return; + light.Reported = value; + sink.OutputChanged(light.Kind, light.Number, value); + } + + private static double Evaluate(Output light, double nowMs) + { + if (!light.Fading || nowMs >= light.FadeEndMs) + return light.Target; + if (nowMs <= light.FadeStartMs) + return light.Start; + var t = (nowMs - light.FadeStartMs) / (light.FadeEndMs - light.FadeStartMs); + return light.Start + (light.Target - light.Start) * t; + } + + private static bool IsSubtype(string lightNumber, int dash, string subtype) => + dash == subtype.Length + && string.Compare( + lightNumber, + 0, + subtype, + 0, + dash, + StringComparison.OrdinalIgnoreCase + ) == 0; + + private static int ParseNumber(string text, int start) + { + if (text == null || start >= text.Length) + return -1; + long value = 0; + for (var i = start; i < text.Length; i++) + { + var c = text[i]; + if (c < '0' || c > '9') + return -1; + value = value * 10 + (c - '0'); + if (value > int.MaxValue) + return -1; + } + return (int)value; + } + + private static void RemoveAt(List list, int index) + { + var last = list.Count - 1; + list[index] = list[last]; + list.RemoveAt(last); + } + } +} diff --git a/Runtime/MpfOutputModel.cs.meta b/Runtime/MpfOutputModel.cs.meta new file mode 100644 index 00000000..feafe7cc --- /dev/null +++ b/Runtime/MpfOutputModel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e09ff32cfd5244d5897ce0e08b3e9c25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/VisualPinball.Engine.Mpf.Unity.asmdef b/Runtime/VisualPinball.Engine.Mpf.Unity.asmdef index 60965d03..baeb8dd5 100644 --- a/Runtime/VisualPinball.Engine.Mpf.Unity.asmdef +++ b/Runtime/VisualPinball.Engine.Mpf.Unity.asmdef @@ -4,6 +4,7 @@ "references": [ "VisualPinball.Engine", "VisualPinball.Unity", + "VisualPinball.Plugins", "Cysharp.Net.Http.YetAnotherHttpHandler", "Unity.TextMeshPro" ], From 27a404706a6e3c87777ff9d6ebf6620a449afbc2 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 14 Oct 2026 13:05:49 +0200 Subject: [PATCH 2/2] plugins: release the native output listener on detach The dispatch sink kept a reference to the last listener after SetNativeOutputListener(null), keeping an unloaded plugin alive until the engine was destroyed. --- Runtime/MpfGamelogicEngine.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Runtime/MpfGamelogicEngine.cs b/Runtime/MpfGamelogicEngine.cs index 7c5fccac..7ae4d659 100644 --- a/Runtime/MpfGamelogicEngine.cs +++ b/Runtime/MpfGamelogicEngine.cs @@ -431,6 +431,9 @@ public void SetNativeOutputListener(INativeOutputListener 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(); } }