From 290162d428b807e7452097b036b6bb1b13d761be Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 15:31:14 +0200 Subject: [PATCH 01/25] physics: add cabinet oscillator primitives --- VisualPinball.Engine/Common/Constants.cs | 14 ++-- .../VisualPinball.Unity.Test/Physics.meta | 8 +++ .../Physics/CabinetPhysicsTests.cs | 70 +++++++++++++++++++ .../Physics/CabinetPhysicsTests.cs.meta | 11 +++ .../VisualPinball.Unity/Physics/Cabinet.meta | 8 +++ .../Physics/Cabinet/CabinetPhysicsState.cs | 56 +++++++++++++++ .../Cabinet/CabinetPhysicsState.cs.meta | 3 + .../Cabinet/DampedHarmonicOscillator.cs | 61 ++++++++++++++++ .../Cabinet/DampedHarmonicOscillator.cs.meta | 3 + 9 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs.meta diff --git a/VisualPinball.Engine/Common/Constants.cs b/VisualPinball.Engine/Common/Constants.cs index c003e00d0..f60f03608 100644 --- a/VisualPinball.Engine/Common/Constants.cs +++ b/VisualPinball.Engine/Common/Constants.cs @@ -86,11 +86,15 @@ public static class PhysicsConstants public const float DefaultTableMaxSlope = 6.0f; // DEFAULT_TABLE_MAX_SLOPE public const float DefaultTableGravity = 0.97f; // DEFAULT_TABLE_GRAVITY - public const float GravityConst = 1.81751f; // GRAVITYCONST - - /// - /// trigger/kicker boundary crossing hysterisis - /// + public const float GravityConst = 1.81751f; // GRAVITYCONST + + public const float MToVpu = 50f / (0.0254f * 1.0625f); // MTOVPU + public const float VpuToM = 1f / MToVpu; // VPUTOM + public const float Ms2ToVpuVpt2 = MToVpu * 1e-4f; // MS2TOVPUVPT2 + + /// + /// trigger/kicker boundary crossing hysterisis + /// public const float StaticTime = 0.02f; // STATICTIME public const float StaticCnts = 10f; // STATICCNTS diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics.meta new file mode 100644 index 000000000..9ebe585bb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 708e2e5ed6414d60aa87fd0eeab4083a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs new file mode 100644 index 000000000..fec3c2ab0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs @@ -0,0 +1,70 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class CabinetPhysicsTests + { + [Test] + public void OscillatorTracksAnalyticStepResponse() + { + const float mass = 113f; + const float frequencyHz = 9.3f; + const float dampingRatio = 0.052f; + const float targetAcceleration = 12f; + var oscillator = new DampedHarmonicOscillator(mass, frequencyHz, dampingRatio); + var force = mass * targetAcceleration; + + for (var i = 0; i < 50; i++) { + oscillator.StepOneMillisecond(force); + } + + var expected = AnalyticStepResponse(mass, frequencyHz, dampingRatio, force, 0.050f); + Assert.That(oscillator.Displacement, Is.EqualTo(expected.Displacement).Within(0.0008f)); + Assert.That(oscillator.Velocity, Is.EqualTo(expected.Velocity).Within(0.03f)); + Assert.That(oscillator.Acceleration, Is.EqualTo(expected.Acceleration).Within(0.7f)); + } + + [Test] + public void CabinetPhysicsUsesCalibratedAxisModels() + { + var cabinet = CabinetPhysicsState.Default; + cabinet.StepOneMillisecond(new float2(cabinet.Mass * 12f, cabinet.Mass * -10f)); + + Assert.That(cabinet.CabinetAcceleration.x, Is.EqualTo(12f).Within(1e-5f)); + Assert.That(cabinet.CabinetAcceleration.y, Is.EqualTo(-10f).Within(1e-5f)); + Assert.That(cabinet.CabinetOffset.x, Is.GreaterThan(0f)); + Assert.That(cabinet.CabinetOffset.y, Is.LessThan(0f)); + } + + private static (float Displacement, float Velocity, float Acceleration) AnalyticStepResponse(float mass, float frequencyHz, float dampingRatio, float force, float time) + { + var omega0 = 2f * math.PI * frequencyHz; + var omegaD = omega0 * math.sqrt(1f - dampingRatio * dampingRatio); + var alpha = dampingRatio * omega0; + var steadyState = force / (mass * omega0 * omega0); + var decay = math.exp(-alpha * time); + var displacement = steadyState * (1f - decay * (math.cos(omegaD * time) + alpha / omegaD * math.sin(omegaD * time))); + var velocity = steadyState * decay * (omega0 * omega0 / omegaD) * math.sin(omegaD * time); + var acceleration = (force - 2f * dampingRatio * mass * omega0 * velocity - mass * omega0 * omega0 * displacement) / mass; + + return (displacement, velocity, acceleration); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs.meta new file mode 100644 index 000000000..290ca419f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a825506318714722902efe0b72b52afe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet.meta new file mode 100644 index 000000000..d8f2a3881 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9fadb2b2fd334fe89fae688c7d57c910 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs new file mode 100644 index 000000000..29ffa0a9f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs @@ -0,0 +1,56 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public struct CabinetPhysicsState + { + public float Mass; + public DampedHarmonicOscillator X; + public DampedHarmonicOscillator Y; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + + public CabinetPhysicsState(float mass) + { + Mass = mass; + X = new DampedHarmonicOscillator(mass, 9.3f, 0.052f); + Y = new DampedHarmonicOscillator(mass, 5.8f, 0.055f); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + } + + public static CabinetPhysicsState Default => new(113f); + + public void StepOneMillisecond(float2 force) + { + X.StepOneMillisecond(force.x); + Y.StepOneMillisecond(force.y); + CabinetAcceleration = new float2(X.Acceleration, Y.Acceleration); + CabinetOffset = new float2(X.Displacement, Y.Displacement); + } + + public void Reset() + { + X.Reset(); + Y.Reset(); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs.meta new file mode 100644 index 000000000..c0a66edcc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 96b1b5faeaf24a9f81a0959261fbb4f4 +timeCreated: 1783190001 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs new file mode 100644 index 000000000..0a704512c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs @@ -0,0 +1,61 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public struct DampedHarmonicOscillator + { + public float Mass; + public float Omega0; + public float SpringConstant; + public float DampingCoefficient; + public float Displacement; + public float Velocity; + public float Acceleration; + + public DampedHarmonicOscillator(float mass, float frequencyHz, float dampingRatio) + { + Mass = mass; + Omega0 = 2f * math.PI * frequencyHz; + SpringConstant = mass * Omega0 * Omega0; + DampingCoefficient = 2f * dampingRatio * mass * Omega0; + Displacement = 0f; + Velocity = 0f; + Acceleration = 0f; + } + + public void StepOneMillisecond(float force) + { + Step(force, 0.001f); + } + + public void Step(float force, float deltaTime) + { + Acceleration = (force - DampingCoefficient * Velocity - SpringConstant * Displacement) / Mass; + Velocity += Acceleration * deltaTime; + Displacement += Velocity * deltaTime; + } + + public void Reset() + { + Displacement = 0f; + Velocity = 0f; + Acceleration = 0f; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs.meta new file mode 100644 index 000000000..a4596d538 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 815f1ed2b4fd4ad8a92e66324115caca +timeCreated: 1783190000 From b9808c6403edea01e4a3ce8225f898d52f8b9ef3 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 15:45:26 +0200 Subject: [PATCH 02/25] physics: wire keyboard nudge --- VisualPinball.Engine/Common/Constants.cs | 12 +- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 51 +++- .../Game/PhysicsEngineContext.cs | 7 + .../Game/PhysicsEnginePackable.cs | 40 +-- .../Game/PhysicsEngineThreading.cs | 20 ++ .../VisualPinball.Unity/Game/PhysicsEnv.cs | 32 ++- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 21 +- .../VisualPinball.Unity/Game/Player.cs | 104 +++++-- .../VisualPinball.Unity/Input/InputManager.cs | 4 + .../Physics/Cabinet/KeyboardNudgeMode.cs | 25 ++ .../Physics/Cabinet/KeyboardNudgeMode.cs.meta | 3 + .../Physics/Cabinet/KeyboardNudgeState.cs | 255 ++++++++++++++++++ .../Cabinet/KeyboardNudgeState.cs.meta | 3 + .../Physics/Cabinet/NudgeState.cs | 83 ++++++ .../Physics/Cabinet/NudgeState.cs.meta | 3 + .../Simulation/NativeInputApi.cs | 2 + .../Simulation/NativeInputManager.cs | 10 +- .../Simulation/SimulationThread.cs | 43 +++ .../VisualPinball.Unity/VPT/Ball/BallState.cs | 2 +- .../VPT/Ball/BallVelocityPhysics.cs | 29 +- .../VPT/Table/TableComponent.cs | 23 +- 21 files changed, 676 insertions(+), 96 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs.meta diff --git a/VisualPinball.Engine/Common/Constants.cs b/VisualPinball.Engine/Common/Constants.cs index f60f03608..19eee6985 100644 --- a/VisualPinball.Engine/Common/Constants.cs +++ b/VisualPinball.Engine/Common/Constants.cs @@ -171,7 +171,11 @@ public static class InputConstants public const string ActionCoinDoorUpDown = "Coin Door Up/Down"; public const string ActionSlamTilt = "Slam Tilt"; public const string ActionSelfTest = "Self Test"; - public const string ActionLeftAdvance = "Left Advance"; - public const string ActionRightAdvance = "Right Advance"; - } -} + public const string ActionLeftAdvance = "Left Advance"; + public const string ActionRightAdvance = "Right Advance"; + public const string ActionLeftNudge = "Left Nudge"; + public const string ActionRightNudge = "Right Nudge"; + public const string ActionCenterNudge = "Center Nudge"; + public const string ActionTilt = "Tilt"; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 8c53867d2..4eed6f04b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -116,6 +116,13 @@ public class PhysicsEngine : MonoBehaviour, IPackable [Tooltip("Gravity constant, in VPX units.")] public float GravityStrength = PhysicsConstants.GravityConst * PhysicsConstants.DefaultTableGravity; + [Tooltip("Keyboard nudge model.")] + public KeyboardNudgeMode KeyboardNudgeMode = KeyboardNudgeMode.CabModel; + + [Tooltip("Keyboard nudge strength scale.")] + [Range(0f, 2f)] + public float KeyboardNudgeStrength = 1f; + #endregion #region Packaging @@ -157,6 +164,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [NonSerialized] private float4x4 _worldToPlayfield; [NonSerialized] private int _mainThreadManagedThreadId; [NonSerialized] private int _simulationThreadManagedThreadId = -1; + [NonSerialized] private int _keyboardNudgeIndex; [NonSerialized] private readonly HashSet _unsafeLiveStateAccessWarnings = new HashSet(); [NonSerialized] private bool _inputActionsQueueWarningIssued; [NonSerialized] private bool _scheduledActionsQueueWarningIssued; @@ -266,6 +274,45 @@ internal void MutateState(InputAction action) action(ref state); } + public int KeyboardNudgeIndex => Volatile.Read(ref _keyboardNudgeIndex); + + public void Nudge(float angleDeg, float force) + { + Interlocked.Increment(ref _keyboardNudgeIndex); + lock (_ctx.PendingKeyboardNudgesLock) { + _ctx.PendingKeyboardNudges.Enqueue(new KeyboardNudgeCommand(angleDeg, force)); + } + } + + public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength) + { + KeyboardNudgeMode = mode; + KeyboardNudgeStrength = math.clamp(strength, 0f, 2f); + + if (_ctx == null || !_ctx.IsInitialized) { + return; + } + + var table = GetComponentInParent(); + var nudgeTime = table != null ? table.NudgeTime : 5f; + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + nudge.Keyboard.Configure(KeyboardNudgeMode, KeyboardNudgeStrength, nudgeTime); + _ctx.PhysicsEnv.Nudge = nudge; + } + } + + public void NudgeSensorStatus(out float x, out float y) + { + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + var acceleration = nudge.ReadAndResetMaxCabinetAcceleration(); + _ctx.PhysicsEnv.Nudge = nudge; + x = acceleration.x; + y = acceleration.y; + } + } + internal void MarkCurrentThreadAsSimulationThread() { Interlocked.Exchange(ref _simulationThreadManagedThreadId, Thread.CurrentThread.ManagedThreadId); @@ -590,7 +637,9 @@ private void Awake() _mainThreadManagedThreadId = Thread.CurrentThread.ManagedThreadId; _player = GetComponentInParent(); _ctx.InsideOfs = new InsideOfs(Allocator.Persistent); - _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength); + var table = GetComponentInParent(); + _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength, + KeyboardNudgeMode, KeyboardNudgeStrength, table != null ? table.NudgeTime : 5f); Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime); _ctx.ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); _ctx.FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index a621d6618..d74dc4b9a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -193,6 +193,13 @@ internal class PhysicsEngineContext : IDisposable public readonly Queue InputActions = new(); public readonly object InputActionsLock = new(); + /// + /// Keyboard/script nudge impulses waiting to be applied to the persistent + /// cabinet state. Drained inside PhysicsLock before the physics loop. + /// + public readonly Queue PendingKeyboardNudges = new(); + public readonly object PendingKeyboardNudgesLock = new(); + /// /// Scheduled managed callbacks stored in a min-heap by due time. /// Protected by locking on . diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs index b418aefee..75d6fc661 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs @@ -16,21 +16,31 @@ namespace VisualPinball.Unity { - public struct PhysicsEnginePackable - { - public float GravityStrength; - - public static byte[] Pack(PhysicsEngine comp) - { - return PackageApi.Packer.Pack(new PhysicsEnginePackable { - GravityStrength = comp.GravityStrength, - }); - } + public struct PhysicsEnginePackable + { + public float GravityStrength; + public bool HasKeyboardNudgeSettings; + public KeyboardNudgeMode KeyboardNudgeMode; + public float KeyboardNudgeStrength; + + public static byte[] Pack(PhysicsEngine comp) + { + return PackageApi.Packer.Pack(new PhysicsEnginePackable { + GravityStrength = comp.GravityStrength, + HasKeyboardNudgeSettings = true, + KeyboardNudgeMode = comp.KeyboardNudgeMode, + KeyboardNudgeStrength = comp.KeyboardNudgeStrength, + }); + } public static void Unpack(byte[] bytes, PhysicsEngine comp) { - var data = PackageApi.Packer.Unpack(bytes); - comp.GravityStrength = data.GravityStrength; - } - } -} + var data = PackageApi.Packer.Unpack(bytes); + comp.GravityStrength = data.GravityStrength; + comp.ConfigureKeyboardNudge( + data.HasKeyboardNudgeSettings ? data.KeyboardNudgeMode : KeyboardNudgeMode.CabModel, + data.HasKeyboardNudgeSettings ? data.KeyboardNudgeStrength : 1f + ); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index ae9e4e994..24ec6299f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -57,6 +57,7 @@ internal class PhysicsEngineThreading private readonly List _deferredMainThreadScheduledActions = new(); private readonly List _dueSingleThreadScheduledActions = new(); private readonly List _pendingInputActions = new(); + private readonly List _pendingKeyboardNudges = new(); private readonly List> _pendingKinematicUpdates = new(); private readonly List _pendingKinematicStopUpdates = new(); @@ -195,6 +196,7 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) // process input ProcessInputActions(ref state); + ProcessPendingKeyboardNudges(); // run physics loop (Burst-compiled, thread-safe) PhysicsUpdate.Execute( @@ -233,6 +235,23 @@ private void ProcessInputActions(ref PhysicsState state) } } + private void ProcessPendingKeyboardNudges() + { + _pendingKeyboardNudges.Clear(); + + lock (_ctx.PendingKeyboardNudgesLock) { + while (_ctx.PendingKeyboardNudges.Count > 0) { + _pendingKeyboardNudges.Add(_ctx.PendingKeyboardNudges.Dequeue()); + } + } + + foreach (var nudge in _pendingKeyboardNudges) { + var nudgeState = _ctx.PhysicsEnv.Nudge; + nudgeState.ApplyKeyboardImpulse(nudge.AngleDeg, nudge.Force); + _ctx.PhysicsEnv.Nudge = nudgeState; + } + } + /// /// Apply kinematic transforms staged by the main thread into the /// physics state maps, deriving each item's velocity from the @@ -783,6 +802,7 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) // process input ProcessInputActions(ref state); + ProcessPendingKeyboardNudges(); // run physics loop PhysicsUpdate.Execute( diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs index edca08c04..836a4c242 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs @@ -27,17 +27,21 @@ public struct PhysicsEnv public readonly ulong StartTimeUsec; public ulong CurPhysicsFrameTime; public ulong NextPhysicsFrameTime; - public uint TimeMsec; - - public Random Random; - - public PhysicsEnv(ulong startTimeUsec, PlayfieldComponent playfield, float gravityStrength) : this() - { - StartTimeUsec = startTimeUsec; - CurPhysicsFrameTime = StartTimeUsec; - NextPhysicsFrameTime = StartTimeUsec + PhysicsConstants.PhysicsStepTime; - Random = new Random((uint)UnityEngine.Random.Range(1, 100000)); - Gravity = playfield.PlayfieldGravity(gravityStrength); - } - } -} + public uint TimeMsec; + + public Random Random; + public NudgeState Nudge; + + public PhysicsEnv(ulong startTimeUsec, PlayfieldComponent playfield, float gravityStrength, + KeyboardNudgeMode keyboardNudgeMode = KeyboardNudgeMode.CabModel, float keyboardNudgeStrength = 1f, + float nudgeTime = 5f) : this() + { + StartTimeUsec = startTimeUsec; + CurPhysicsFrameTime = StartTimeUsec; + NextPhysicsFrameTime = StartTimeUsec + PhysicsConstants.PhysicsStepTime; + Random = new Random((uint)UnityEngine.Random.Range(1, 100000)); + Gravity = playfield.PlayfieldGravity(gravityStrength); + Nudge = new NudgeState(keyboardNudgeMode, keyboardNudgeStrength, nudgeTime); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index 05464f515..ccc3eb0c4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -92,15 +92,18 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ env.TimeMsec = (uint)((env.CurPhysicsFrameTime - env.StartTimeUsec) / 1000); var physicsDiffTime = (float)((env.NextPhysicsFrameTime - env.CurPhysicsFrameTime) * (1.0 / PhysicsConstants.DefaultStepTime)); - // update velocities - always on integral physics frame boundary (spinner, gate, flipper, plunger, ball) - #region Update Velocities - - // balls - using (var enumerator = state.Balls.GetEnumerator()) { - while (enumerator.MoveNext()) { - BallVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value, env.Gravity); - } - } + // update velocities - always on integral physics frame boundary (spinner, gate, flipper, plunger, ball) + #region Update Velocities + + env.Nudge.StepOneMillisecond(); + var cabinetAcceleration = env.Nudge.CabinetAcceleration; + + // balls + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + BallVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value, env.Gravity, cabinetAcceleration); + } + } // flippers using (var enumerator = state.FlipperStates.GetEnumerator()) { while (enumerator.MoveNext()) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 9a79e8759..e5b368931 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -94,9 +94,11 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac internal IEnumerable ColliderGenerators => _colliderGenerators; - // input related - [NonSerialized] private InputManager _inputManager; - [NonSerialized] private readonly List<(InputAction, Action)> _actions = new(); + // input related + [NonSerialized] private InputManager _inputManager; + [NonSerialized] private readonly List<(InputAction, Action)> _actions = new(); + [NonSerialized] private readonly System.Random _nudgeRandom = new System.Random(); + [NonSerialized] private int _lastObservedKeyboardNudgeIndex; // players [NonSerialized] private readonly LampPlayer _lampPlayer = new(); @@ -192,11 +194,10 @@ private void Awake() _apis.Add(TableApi); - BallManager = new BallManager(GetComponentInChildren(), this, Playfield.transform); - _inputManager = new InputManager(); - _inputManager.Enable(HandleInput); - - if (engineComponent != null) { + BallManager = new BallManager(GetComponentInChildren(), this, Playfield.transform); + _inputManager = new InputManager(); + + if (engineComponent != null) { GamelogicEngine = engineComponent; _lampPlayer.Awake(this, _tableComponent, GamelogicEngine); _coilPlayer.Awake(this, _tableComponent, GamelogicEngine, _lampPlayer, _wirePlayer); @@ -218,9 +219,10 @@ private async void Start() } _coilPlayer.OnStart(); - _switchPlayer.OnStart(); - _lampPlayer.OnStart(); - _wirePlayer.OnStart(); + _switchPlayer.OnStart(); + _lampPlayer.OnStart(); + _wirePlayer.OnStart(); + _inputManager.Enable(HandleInput); _gamelogicEngineInitCts = new CancellationTokenSource(); try { @@ -346,8 +348,16 @@ private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) #region Events - public void ScheduleAction(int timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); - public void ScheduleAction(uint timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); + public void ScheduleAction(int timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); + public void ScheduleAction(uint timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); + public void Nudge(float angleDeg, float force) => PhysicsEngine.Nudge(angleDeg, force); + public void NudgeSensorStatus(out float x, out float y) => PhysicsEngine.NudgeSensorStatus(out x, out y); + public void NudgeGetCalibration(out float x, out float y) + { + x = 0f; + y = 0f; + } + public void NudgeSetCalibration(float x, float y) { } public void OnEvent(in EventData eventData) { @@ -456,13 +466,21 @@ public void RemoveHardwareRule(string switchId, string coilId) #endregion - private static void HandleInput(object obj, InputActionChange change) - { - if (obj is InputAction action && action.actionMap != null && action.actionMap.name == InputConstants.MapDebug) { - var value = action.ReadValue(); - switch (action.name) { - case InputConstants.ActionSlowMotion: { - switch (change) { + private void HandleInput(object obj, InputActionChange change) + { + if (obj is not InputAction action || action.actionMap == null) { + return; + } + + if (action.actionMap.name == InputConstants.MapCabinetSwitches) { + HandleNudgeInput(action, change); + } + + if (action.actionMap.name == InputConstants.MapDebug) { + var value = action.ReadValue(); + switch (action.name) { + case InputConstants.ActionSlowMotion: { + switch (change) { case InputActionChange.ActionPerformed when value > 0.1: Time.timeScale = math.lerp(1f, SlowMotionMax, value); break; @@ -490,10 +508,48 @@ private static void HandleInput(object obj, InputActionChange change) Logger.Info("Timescale = " + Time.timeScale); break; } - } - } - } - } + } + } + } + + private void HandleNudgeInput(InputAction action, InputActionChange change) + { + if (NativeInputManager.TryGetExistingInstance()?.IsPolling == true || change != InputActionChange.ActionStarted) { + return; + } + + var actionName = InputManager.GetCanonicalActionName(action.name); + if (actionName != InputConstants.ActionLeftNudge + && actionName != InputConstants.ActionRightNudge + && actionName != InputConstants.ActionCenterNudge) { + return; + } + + var currentNudgeIndex = PhysicsEngine.KeyboardNudgeIndex; + if (currentNudgeIndex == _lastObservedKeyboardNudgeIndex) { + ApplyDefaultKeyboardNudge(actionName); + } + _lastObservedKeyboardNudgeIndex = PhysicsEngine.KeyboardNudgeIndex; + } + + private void ApplyDefaultKeyboardNudge(string actionName) + { + const float baseForce = 2f; + var angle = ((float)_nudgeRandom.NextDouble() - 0.5f) * 15f * baseForce; + var force = (0.6f + (float)_nudgeRandom.NextDouble() * 0.8f) * baseForce; + switch (actionName) { + case InputConstants.ActionLeftNudge: + Nudge(75f + angle, force); + break; + case InputConstants.ActionRightNudge: + Nudge(285f + angle, force); + break; + case InputConstants.ActionCenterNudge: + Nudge(angle, force); + break; + } + } + } public readonly struct BallEvent { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs index 45bbc68de..26c3c6f76 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs @@ -189,6 +189,10 @@ public static InputActionAsset GetDefaultInputActionAsset() map.AddAction(InputConstants.ActionCoinDoorPlus, InputActionType.Button); map.AddAction(InputConstants.ActionCoinDoorSelect, InputActionType.Button); map.AddAction(InputConstants.ActionSlamTilt, InputActionType.Button, "/home"); + map.AddAction(InputConstants.ActionLeftNudge, InputActionType.Button, "/z"); + map.AddAction(InputConstants.ActionRightNudge, InputActionType.Button, "/slash"); + map.AddAction(InputConstants.ActionCenterNudge, InputActionType.Button, "/space"); + map.AddAction(InputConstants.ActionTilt, InputActionType.Button, "/t"); map.AddAction(InputConstants.ActionSelfTest, InputActionType.Button); map.AddAction(InputConstants.ActionLeftAdvance, InputActionType.Button); map.AddAction(InputConstants.ActionRightAdvance, InputActionType.Button); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs new file mode 100644 index 000000000..73281d0ad --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs @@ -0,0 +1,25 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + public enum KeyboardNudgeMode + { + PushRetract = 0, + BoxModel = 1, + CabModel = 2 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs.meta new file mode 100644 index 000000000..eaa836da3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3bd56abde1f04167a4ebf4b612044bf4 +timeCreated: 1783190002 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs new file mode 100644 index 000000000..50f177401 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs @@ -0,0 +1,255 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity +{ + public struct KeyboardNudgeState + { + private const int DeactivationDelayMs = 10000; + private const int CabImpulseLengthMs = 25; + + public KeyboardNudgeMode Mode; + public float Strength; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + + private float2 _pushImpulse; + private int _pushNudgeTime; + private int _pushDeactivationDelay; + + private float2 _boxVelocity; + private float2 _boxPrevVelocity; + private float2 _boxPositionVpu; + private float _boxSpring; + private float _boxDamping; + private int _boxDeactivationDelay; + + private CabinetPhysicsState _cabinet; + private FixedList512Bytes _cabImpulses; + private int _cabDeactivationDelay; + + private struct CabinetImpulse + { + public int Length; + public int Elapsed; + public float2 Acceleration; + + public CabinetImpulse(int length, float2 acceleration) + { + Length = length; + Elapsed = 0; + Acceleration = acceleration; + } + + public bool IsInProgress => Elapsed <= Length; + + public float2 CurrentAcceleration + { + get { + if (!IsInProgress) { + return float2.zero; + } + var t = (float)Elapsed / Length; + return Acceleration * 0.5f * (1f - math.cos(2f * math.PI * t)); + } + } + } + + public KeyboardNudgeState(KeyboardNudgeMode mode, float strength, float nudgeTime) + { + Mode = mode; + Strength = strength; + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + _pushImpulse = float2.zero; + _pushNudgeTime = 0; + _pushDeactivationDelay = 0; + _boxVelocity = float2.zero; + _boxPrevVelocity = float2.zero; + _boxPositionVpu = float2.zero; + _boxSpring = 0f; + _boxDamping = 0f; + _boxDeactivationDelay = 0; + _cabinet = CabinetPhysicsState.Default; + _cabImpulses = default; + _cabDeactivationDelay = 0; + + ConfigureBoxModel(nudgeTime); + } + + public bool IsActive => Mode switch { + KeyboardNudgeMode.PushRetract => _pushDeactivationDelay > 0, + KeyboardNudgeMode.BoxModel => _boxDeactivationDelay > 0, + _ => _cabDeactivationDelay > 0, + }; + + public void Configure(KeyboardNudgeMode mode, float strength, float nudgeTime) + { + this = new KeyboardNudgeState(mode, strength, nudgeTime); + } + + public void SetStrength(float strength) + { + Strength = strength; + } + + public void Nudge(float angleDeg, float force) + { + switch (Mode) { + case KeyboardNudgeMode.PushRetract: + PushRetractNudge(angleDeg, force); + break; + case KeyboardNudgeMode.BoxModel: + BoxModelNudge(angleDeg, force); + break; + default: + CabModelNudge(angleDeg, force); + break; + } + } + + public void StepOneMillisecond() + { + switch (Mode) { + case KeyboardNudgeMode.PushRetract: + StepPushRetract(); + break; + case KeyboardNudgeMode.BoxModel: + StepBoxModel(); + break; + default: + StepCabModel(); + break; + } + } + + private void ConfigureBoxModel(float nudgeTime) + { + nudgeTime = math.max(0.001f, nudgeTime); + const float dampingRatio = 0.5f; + _boxSpring = math.PI * math.PI / (nudgeTime * nudgeTime * (1f - dampingRatio * dampingRatio)); + _boxDamping = dampingRatio * 2f * math.sqrt(_boxSpring); + } + + private void PushRetractNudge(float angleDeg, float force) + { + _pushDeactivationDelay = DeactivationDelayMs; + if (_pushNudgeTime != 0) { + return; + } + + var angle = math.radians(angleDeg); + _pushImpulse = new float2(math.sin(angle), -math.cos(angle)) * (Strength * force); + _pushNudgeTime = 100; + } + + private void StepPushRetract() + { + if (_pushDeactivationDelay > 0) { + _pushDeactivationDelay--; + } + + if (_pushNudgeTime != 0) { + _pushNudgeTime--; + if (_pushNudgeTime == 95) { + CabinetAcceleration = new float2(-_pushImpulse.x * 2f, _pushImpulse.y * 2f) + * (1f / PhysicsConstants.PhysFactor) + * (1f / PhysicsConstants.Ms2ToVpuVpt2); + } else if (_pushNudgeTime == 90) { + CabinetAcceleration = new float2(_pushImpulse.x, -_pushImpulse.y) + * (1f / PhysicsConstants.PhysFactor) + * (1f / PhysicsConstants.Ms2ToVpuVpt2); + } else { + CabinetAcceleration = float2.zero; + } + } else { + CabinetAcceleration = float2.zero; + } + + var attenuation = math.pow(_pushNudgeTime * 0.01f, 2f); + CabinetOffset = new float2(_pushImpulse.x, -_pushImpulse.y) * attenuation * PhysicsConstants.VpuToM; + } + + private void BoxModelNudge(float angleDeg, float force) + { + _boxDeactivationDelay = DeactivationDelayMs; + var angle = math.radians(angleDeg); + _boxVelocity += new float2(math.sin(angle), -math.cos(angle)) * (Strength * force); + } + + private void StepBoxModel() + { + if (_boxDeactivationDelay > 0) { + _boxDeactivationDelay--; + } + + var force = -_boxSpring * _boxPositionVpu - _boxDamping * _boxVelocity; + _boxVelocity += PhysicsConstants.PhysFactor * force; + _boxPositionVpu += PhysicsConstants.PhysFactor * _boxVelocity; + CabinetOffset = _boxPositionVpu * PhysicsConstants.VpuToM; + CabinetAcceleration = (_boxVelocity - _boxPrevVelocity) + * (1f / PhysicsConstants.PhysFactor) + * (1f / PhysicsConstants.Ms2ToVpuVpt2); + _boxPrevVelocity = _boxVelocity; + } + + private void CabModelNudge(float angleDeg, float force) + { + _cabDeactivationDelay = DeactivationDelayMs; + var angle = math.radians(angleDeg); + var acceleration = new float2(math.sin(angle), -math.cos(angle)) * (force * Strength * 6f); + if (_cabImpulses.Length < _cabImpulses.Capacity) { + _cabImpulses.Add(new CabinetImpulse(CabImpulseLengthMs, acceleration)); + } + } + + private void StepCabModel() + { + if (_cabDeactivationDelay > 0) { + _cabDeactivationDelay--; + } + + var impulse = float2.zero; + for (var i = 0; i < _cabImpulses.Length;) { + var cabImpulse = _cabImpulses[i]; + cabImpulse.Elapsed++; + if (cabImpulse.IsInProgress) { + impulse += cabImpulse.CurrentAcceleration; + _cabImpulses[i] = cabImpulse; + i++; + } else { + RemoveCabImpulseAt(i); + } + } + + _cabinet.StepOneMillisecond(_cabinet.Mass * impulse); + CabinetAcceleration = _cabinet.CabinetAcceleration; + CabinetOffset = _cabinet.CabinetOffset; + } + + private void RemoveCabImpulseAt(int index) + { + for (var i = index + 1; i < _cabImpulses.Length; i++) { + _cabImpulses[i - 1] = _cabImpulses[i]; + } + _cabImpulses.Length--; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs.meta new file mode 100644 index 000000000..5914741ac --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ab7a79c1d27c46cc964e5251a0ae99a5 +timeCreated: 1783190003 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs new file mode 100644 index 000000000..ff46da826 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs @@ -0,0 +1,83 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal readonly struct KeyboardNudgeCommand + { + public readonly float AngleDeg; + public readonly float Force; + + public KeyboardNudgeCommand(float angleDeg, float force) + { + AngleDeg = angleDeg; + Force = force; + } + } + + public struct NudgeState + { + public KeyboardNudgeState Keyboard; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + public float2 MaxCabinetAcceleration; + public int KeyboardNudgeIndex; + + public NudgeState(KeyboardNudgeMode keyboardMode, float keyboardStrength, float nudgeTime) + { + Keyboard = new KeyboardNudgeState(keyboardMode, keyboardStrength, nudgeTime); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + MaxCabinetAcceleration = float2.zero; + KeyboardNudgeIndex = 0; + } + + public void ApplyKeyboardImpulse(float angleDeg, float force) + { + KeyboardNudgeIndex++; + Keyboard.Nudge(angleDeg, force); + } + + public void StepOneMillisecond() + { + Keyboard.StepOneMillisecond(); + + if (Keyboard.IsActive) { + CabinetAcceleration = Keyboard.CabinetAcceleration; + CabinetOffset = Keyboard.CabinetOffset; + } else { + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + } + + if (math.abs(CabinetAcceleration.x) > math.abs(MaxCabinetAcceleration.x)) { + MaxCabinetAcceleration.x = CabinetAcceleration.x; + } + if (math.abs(CabinetAcceleration.y) > math.abs(MaxCabinetAcceleration.y)) { + MaxCabinetAcceleration.y = CabinetAcceleration.y; + } + } + + public float2 ReadAndResetMaxCabinetAcceleration() + { + var value = MaxCabinetAcceleration; + MaxCabinetAcceleration = float2.zero; + return value; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs.meta new file mode 100644 index 000000000..a7edd1296 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 593f37a235c3419a899ef0b4645ede9c +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index 4f774b634..3fc1ca9d5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -120,6 +120,7 @@ public enum KeyCode P = 0x50, T = 0x54, Y = 0x59, + Z = 0x5A, Numpad1 = 0x61, A = 0x41, @@ -129,6 +130,7 @@ public enum KeyCode W = 0x57, Minus = 0xBD, // VK_OEM_MINUS + Slash = 0xBF, // VK_OEM_2 Quote = 0xDE, // VK_OEM_7 Oem3 = 0xC0, // VK_OEM_3 (layout dependent) } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index 7262b3c07..6804b9b85 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -298,11 +298,11 @@ void Add(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) Add(NativeInputApi.InputAction.Service6, NativeInputApi.KeyCode.PageUp); Add(NativeInputApi.InputAction.Service7, NativeInputApi.KeyCode.PageDown); - // Nudging - Add(NativeInputApi.InputAction.LeftNudge, NativeInputApi.KeyCode.Y); - Add(NativeInputApi.InputAction.RightNudge, NativeInputApi.KeyCode.Minus); - Add(NativeInputApi.InputAction.CenterNudge, NativeInputApi.KeyCode.Space); - Add(NativeInputApi.InputAction.Tilt, NativeInputApi.KeyCode.T); + // Nudging + Add(NativeInputApi.InputAction.LeftNudge, NativeInputApi.KeyCode.Z); + Add(NativeInputApi.InputAction.RightNudge, NativeInputApi.KeyCode.Slash); + Add(NativeInputApi.InputAction.CenterNudge, NativeInputApi.KeyCode.Space); + Add(NativeInputApi.InputAction.Tilt, NativeInputApi.KeyCode.T); // Plunger Add(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Return); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 353da6b89..fc2ea7786 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -53,6 +53,7 @@ public class SimulationThread : IDisposable private readonly Action _simulationCoilDispatcher; private readonly InputEventBuffer _inputBuffer; private readonly SimulationState _sharedState; + private readonly System.Random _nudgeRandom = new System.Random(); private Thread _thread; private volatile bool _running = false; @@ -480,10 +481,15 @@ private void ProcessInputEvents() InputLatencyTracker.RecordInputPolled((NativeInputApi.InputAction)actionIndex, isPressed, evt.TimestampUsec); + var nudgeIndexBeforeSwitchDispatch = _physicsEngine.KeyboardNudgeIndex; + // Only forward to GLE once it's ready (or at least has started) if (_gamelogicEngine != null && _gamelogicStarted) { SendMappedSwitch(actionIndex, isPressed); } + if (isPressed && IsNudgeAction(actionIndex) && nudgeIndexBeforeSwitchDispatch == _physicsEngine.KeyboardNudgeIndex) { + ApplyDefaultKeyboardNudge(actionIndex); + } _inputEventsProcessed++; } @@ -575,6 +581,27 @@ private static bool IsFlipperAction(int actionIndex) || actionIndex == (int)NativeInputApi.InputAction.UpperRightFlipper; } + private static bool IsNudgeAction(int actionIndex) + { + return actionIndex == (int)NativeInputApi.InputAction.LeftNudge + || actionIndex == (int)NativeInputApi.InputAction.RightNudge + || actionIndex == (int)NativeInputApi.InputAction.CenterNudge; + } + + private void ApplyDefaultKeyboardNudge(int actionIndex) + { + const float baseForce = 2f; + var angle = ((float)_nudgeRandom.NextDouble() - 0.5f) * 15f * baseForce; + var force = (0.6f + (float)_nudgeRandom.NextDouble() * 0.8f) * baseForce; + if (actionIndex == (int)NativeInputApi.InputAction.LeftNudge) { + _physicsEngine.Nudge(75f + angle, force); + } else if (actionIndex == (int)NativeInputApi.InputAction.RightNudge) { + _physicsEngine.Nudge(285f + angle, force); + } else if (actionIndex == (int)NativeInputApi.InputAction.CenterNudge) { + _physicsEngine.Nudge(angle, force); + } + } + private void SyncAllMappedSwitches() { for (var i = 0; i < _actionToSwitchId.Length; i++) @@ -749,6 +776,22 @@ private static bool TryMapInputActionHint(string inputActionHint, out NativeInpu action = NativeInputApi.InputAction.SlamTilt; return true; } + if (inputActionHint == InputConstants.ActionLeftNudge) { + action = NativeInputApi.InputAction.LeftNudge; + return true; + } + if (inputActionHint == InputConstants.ActionRightNudge) { + action = NativeInputApi.InputAction.RightNudge; + return true; + } + if (inputActionHint == InputConstants.ActionCenterNudge) { + action = NativeInputApi.InputAction.CenterNudge; + return true; + } + if (inputActionHint == InputConstants.ActionTilt) { + action = NativeInputApi.InputAction.Tilt; + return true; + } action = default; return false; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs index 937b182b2..ae9d9d2fd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs @@ -145,7 +145,7 @@ the angular velocity used here is not the angular velocity that the ball has (wh */ } - public void UpdateVelocities(float3 gravity) => BallVelocityPhysics.UpdateVelocities(ref this, gravity); + public void UpdateVelocities(float3 gravity) => BallVelocityPhysics.UpdateVelocities(ref this, gravity, float2.zero); public override string ToString() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs index 2a72c0acb..c47d0a1cc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs @@ -21,13 +21,13 @@ namespace VisualPinball.Unity { - internal static class BallVelocityPhysics - { - public static void UpdateVelocities(ref BallState ball, float3 gravity) - { - if (ball.IsFrozen) { - return; - } + internal static class BallVelocityPhysics + { + public static void UpdateVelocities(ref BallState ball, float3 gravity, float2 cabinetAcceleration) + { + if (ball.IsFrozen) { + return; + } // A resting ball must stop rotating. The point-contact model has no // drilling friction, and the per-contact friction impulses that hold the @@ -54,9 +54,12 @@ public static void UpdateVelocities(ref BallState ball, float3 gravity) math.max(-10.0f, math.min(10.0f, (ball.ManualPosition.y - ball.Position.y) * (float)(1.0/10.0))), -2.0f ); - } else { - ball.Velocity += PhysicsConstants.PhysFactor * gravity; - } - } - } -} + } else { + ball.Velocity += PhysicsConstants.PhysFactor * gravity; + var nudgeVelocity = PhysicsConstants.PhysFactor * PhysicsConstants.Ms2ToVpuVpt2 * cabinetAcceleration; + ball.Velocity.x -= nudgeVelocity.x; + ball.Velocity.y -= nudgeVelocity.y; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs index 752c457ee..63d3e3df9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs @@ -44,8 +44,9 @@ public class TableComponent : MainRenderableComponent, IPackable #region Data - public float GlobalDifficulty = 0.2f; - public int OverridePhysics; + public float GlobalDifficulty = 0.2f; + public int OverridePhysics; + public float NudgeTime = 5f; #endregion @@ -109,9 +110,10 @@ public void RestoreCollections(List collections) public override IEnumerable SetData(TableData data) { - GlobalDifficulty = data.GlobalDifficulty; - OverridePhysics = data.OverridePhysics; - return new List { this }; + GlobalDifficulty = data.GlobalDifficulty; + OverridePhysics = data.OverridePhysics; + NudgeTime = data.NudgeTime; + return new List { this }; } public override IEnumerable SetReferencedData(TableData data, Table table, IMaterialProvider materialProvider, ITextureProvider textureProvider, Dictionary components) @@ -122,11 +124,12 @@ public override IEnumerable SetReferencedData(TableData data, Tab public override TableData CopyDataTo(TableData data, string[] materialNames, string[] textureNames, bool forExport) { data.TableHeight = 0; - data.GlobalDifficulty = GlobalDifficulty; - data.OverridePhysics = OverridePhysics; - - return data; - } + data.GlobalDifficulty = GlobalDifficulty; + data.OverridePhysics = OverridePhysics; + data.NudgeTime = NudgeTime; + + return data; + } #endregion From e964ae22a7272505be816226995cdfb01d57b669 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 15:53:31 +0200 Subject: [PATCH 03/25] physics: add plumb tilt simulation --- .../Physics/CabinetPhysicsTests.cs | 27 +++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 66 ++++++- .../Game/PhysicsEnginePackable.cs | 13 ++ .../Game/PhysicsEngineThreading.cs | 7 + .../VisualPinball.Unity/Game/PhysicsEnv.cs | 4 +- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 1 + .../VisualPinball.Unity/Game/Player.cs | 23 ++- .../VisualPinball.Unity/Game/SwitchPlayer.cs | 43 +++-- .../VisualPinball.Unity/Game/WirePlayer.cs | 83 +++++---- .../Physics/Cabinet/PlumbState.cs | 170 ++++++++++++++++++ .../Physics/Cabinet/PlumbState.cs.meta | 11 ++ .../Simulation/SimulationState.cs | 13 ++ .../Simulation/SimulationThread.cs | 20 +++ 13 files changed, 414 insertions(+), 67 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs index fec3c2ab0..6a618d352 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs @@ -53,6 +53,33 @@ public void CabinetPhysicsUsesCalibratedAxisModels() Assert.That(cabinet.CabinetOffset.y, Is.LessThan(0f)); } + [Test] + public void PlumbTiltLatchesAndQueuesTiltEvent() + { + var plumb = new PlumbState(true, 1f, 2f); + + for (var i = 0; i < 1000 && !plumb.TiltHigh; i++) { + plumb.StepOneMillisecond(new float2(80f, 0f)); + } + + Assert.That(plumb.TiltHigh, Is.True); + Assert.That(plumb.TiltIndex, Is.EqualTo(1)); + Assert.That(plumb.PendingTiltStates.Length, Is.EqualTo(1)); + Assert.That(plumb.PendingTiltStates[0], Is.EqualTo(1)); + + var status = plumb.ReadAndResetTiltStatus(); + Assert.That(math.abs(status.x), Is.GreaterThan(0f)); + Assert.That(status.z, Is.GreaterThan(100f)); + + var resetStatus = plumb.ReadAndResetTiltStatus(); + Assert.That(resetStatus.x, Is.EqualTo(0f)); + Assert.That(resetStatus.y, Is.EqualTo(0f)); + Assert.That(resetStatus.z, Is.EqualTo(0f)); + + plumb.ClearPendingTiltEvents(); + Assert.That(plumb.PendingTiltStates.Length, Is.EqualTo(0)); + } + private static (float Displacement, float Velocity, float Acceleration) AnalyticStepResponse(float mass, float frequencyHz, float dampingRatio, float force, float time) { var omega0 = 2f * math.PI * frequencyHz; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 4eed6f04b..89c94167a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -123,6 +123,17 @@ public class PhysicsEngine : MonoBehaviour, IPackable [Range(0f, 2f)] public float KeyboardNudgeStrength = 1f; + [Tooltip("Simulate a mechanical plumb-bob tilt switch from cabinet nudge.")] + public bool SimulatedPlumb = true; + + [Tooltip("Mechanical plumb-bob damping scale.")] + [Range(0f, 2f)] + public float PlumbDamping = 1f; + + [Tooltip("Mechanical plumb-bob tilt threshold angle in degrees.")] + [Range(0.5f, 4f)] + public float PlumbThresholdAngle = 2f; + #endregion #region Packaging @@ -165,6 +176,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [NonSerialized] private int _mainThreadManagedThreadId; [NonSerialized] private int _simulationThreadManagedThreadId = -1; [NonSerialized] private int _keyboardNudgeIndex; + [NonSerialized] private readonly List _pendingPlumbTiltEvents = new(8); [NonSerialized] private readonly HashSet _unsafeLiveStateAccessWarnings = new HashSet(); [NonSerialized] private bool _inputActionsQueueWarningIssued; [NonSerialized] private bool _scheduledActionsQueueWarningIssued; @@ -302,6 +314,23 @@ public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength) } } + public void ConfigurePlumb(bool simulatedPlumb, float damping, float thresholdAngle) + { + SimulatedPlumb = simulatedPlumb; + PlumbDamping = math.clamp(damping, 0f, 2f); + PlumbThresholdAngle = math.clamp(thresholdAngle, 0.5f, 4f); + + if (_ctx == null || !_ctx.IsInitialized) { + return; + } + + lock (_ctx.PhysicsLock) { + var plumb = _ctx.PhysicsEnv.Plumb; + plumb.Configure(SimulatedPlumb, PlumbDamping, PlumbThresholdAngle); + _ctx.PhysicsEnv.Plumb = plumb; + } + } + public void NudgeSensorStatus(out float x, out float y) { lock (_ctx.PhysicsLock) { @@ -313,6 +342,39 @@ public void NudgeSensorStatus(out float x, out float y) } } + public void NudgeTiltStatus(out float plumbX, out float plumbY, out float tiltPercent) + { + lock (_ctx.PhysicsLock) { + var plumb = _ctx.PhysicsEnv.Plumb; + var status = plumb.ReadAndResetTiltStatus(); + _ctx.PhysicsEnv.Plumb = plumb; + plumbX = status.x; + plumbY = status.y; + tiltPercent = status.z; + } + } + + internal void DrainPlumbTiltEvents(List destination) + { + lock (_ctx.PhysicsLock) { + var plumb = _ctx.PhysicsEnv.Plumb; + for (var i = 0; i < plumb.PendingTiltStates.Length; i++) { + destination.Add(plumb.PendingTiltStates[i] != 0); + } + plumb.ClearPendingTiltEvents(); + _ctx.PhysicsEnv.Plumb = plumb; + } + } + + private void DispatchPendingPlumbTiltEvents() + { + _pendingPlumbTiltEvents.Clear(); + DrainPlumbTiltEvents(_pendingPlumbTiltEvents); + foreach (var high in _pendingPlumbTiltEvents) { + _player?.DispatchInputAction(InputConstants.ActionTilt, high); + } + } + internal void MarkCurrentThreadAsSimulationThread() { Interlocked.Exchange(ref _simulationThreadManagedThreadId, Thread.CurrentThread.ManagedThreadId); @@ -639,7 +701,8 @@ private void Awake() _ctx.InsideOfs = new InsideOfs(Allocator.Persistent); var table = GetComponentInParent(); _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength, - KeyboardNudgeMode, KeyboardNudgeStrength, table != null ? table.NudgeTime : 5f); + KeyboardNudgeMode, KeyboardNudgeStrength, table != null ? table.NudgeTime : 5f, + SimulatedPlumb, PlumbDamping, PlumbThresholdAngle); Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime); _ctx.ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); _ctx.FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); @@ -799,6 +862,7 @@ private void Update() } else { // Normal mode: Execute full physics update _threading.ExecutePhysicsUpdate(NowUsec); + DispatchPendingPlumbTiltEvents(); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs index 75d6fc661..81c255d10 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs @@ -22,6 +22,10 @@ public struct PhysicsEnginePackable public bool HasKeyboardNudgeSettings; public KeyboardNudgeMode KeyboardNudgeMode; public float KeyboardNudgeStrength; + public bool HasPlumbSettings; + public bool SimulatedPlumb; + public float PlumbDamping; + public float PlumbThresholdAngle; public static byte[] Pack(PhysicsEngine comp) { @@ -30,6 +34,10 @@ public static byte[] Pack(PhysicsEngine comp) HasKeyboardNudgeSettings = true, KeyboardNudgeMode = comp.KeyboardNudgeMode, KeyboardNudgeStrength = comp.KeyboardNudgeStrength, + HasPlumbSettings = true, + SimulatedPlumb = comp.SimulatedPlumb, + PlumbDamping = comp.PlumbDamping, + PlumbThresholdAngle = comp.PlumbThresholdAngle, }); } @@ -41,6 +49,11 @@ public static void Unpack(byte[] bytes, PhysicsEngine comp) data.HasKeyboardNudgeSettings ? data.KeyboardNudgeMode : KeyboardNudgeMode.CabModel, data.HasKeyboardNudgeSettings ? data.KeyboardNudgeStrength : 1f ); + comp.ConfigurePlumb( + data.HasPlumbSettings ? data.SimulatedPlumb : true, + data.HasPlumbSettings ? data.PlumbDamping : 1f, + data.HasPlumbSettings ? data.PlumbThresholdAngle : 2f + ); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 24ec6299f..5535e853d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -572,6 +572,13 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) _float2SnapshotOverflowWarningIssued = true; Logger.Warn($"[PhysicsEngine] Float2 animation snapshot capacity exceeded: {snapshot.Float2AnimationSourceCount} channels for max {SimulationState.MaxFloat2Animations}. Snapshot output is truncated."); } + + snapshot.NudgeCabinetAcceleration = _ctx.PhysicsEnv.Nudge.CabinetAcceleration; + snapshot.NudgeCabinetOffset = _ctx.PhysicsEnv.Nudge.CabinetOffset; + snapshot.PlumbPosition = _ctx.PhysicsEnv.Plumb.Position; + snapshot.PlumbTiltPercent = _ctx.PhysicsEnv.Plumb.MaxTiltPercent; + snapshot.PlumbTiltIndex = _ctx.PhysicsEnv.Plumb.TiltIndex; + snapshot.PlumbTiltHigh = _ctx.PhysicsEnv.Plumb.TiltHigh ? (byte)1 : (byte)0; } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs index 836a4c242..d3c774a67 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs @@ -31,10 +31,11 @@ public struct PhysicsEnv public Random Random; public NudgeState Nudge; + public PlumbState Plumb; public PhysicsEnv(ulong startTimeUsec, PlayfieldComponent playfield, float gravityStrength, KeyboardNudgeMode keyboardNudgeMode = KeyboardNudgeMode.CabModel, float keyboardNudgeStrength = 1f, - float nudgeTime = 5f) : this() + float nudgeTime = 5f, bool simulatedPlumb = true, float plumbDamping = 1f, float plumbThresholdAngle = 2f) : this() { StartTimeUsec = startTimeUsec; CurPhysicsFrameTime = StartTimeUsec; @@ -42,6 +43,7 @@ public PhysicsEnv(ulong startTimeUsec, PlayfieldComponent playfield, float gravi Random = new Random((uint)UnityEngine.Random.Range(1, 100000)); Gravity = playfield.PlayfieldGravity(gravityStrength); Nudge = new NudgeState(keyboardNudgeMode, keyboardNudgeStrength, nudgeTime); + Plumb = new PlumbState(simulatedPlumb, plumbDamping, plumbThresholdAngle); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index ccc3eb0c4..a7c62b821 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -97,6 +97,7 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ env.Nudge.StepOneMillisecond(); var cabinetAcceleration = env.Nudge.CabinetAcceleration; + env.Plumb.StepOneMillisecond(cabinetAcceleration); // balls using (var enumerator = state.Balls.GetEnumerator()) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index e5b368931..b75bb6252 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -152,14 +152,20 @@ private SimulationThreadComponent SimulationThreadComponent { public IApiWireDeviceDest WireDevice(IWireableComponent c) => _wirePlayer.WireDevice(c); internal void HandleWireSwitchChange(WireDestConfig wireConfig, bool isEnabled) => _wirePlayer.HandleSwitchChange(wireConfig, isEnabled); - internal void DispatchSwitch(string switchId, bool isClosed) - { - if (SimulationThreadComponent != null && SimulationThreadComponent.EnqueueSwitchFromMainThread(switchId, isClosed)) { - return; - } - GamelogicEngine?.Switch(switchId, isClosed); - } - + internal void DispatchSwitch(string switchId, bool isClosed) + { + if (SimulationThreadComponent != null && SimulationThreadComponent.EnqueueSwitchFromMainThread(switchId, isClosed)) { + return; + } + GamelogicEngine?.Switch(switchId, isClosed); + } + + internal void DispatchInputAction(string actionName, bool isPressed) + { + _switchPlayer.DispatchInputAction(actionName, isPressed); + _wirePlayer.DispatchInputAction(actionName, isPressed); + } + internal bool DispatchCoilSimulationThread(string coilId, bool isEnabled) { return _coilPlayer.HandleCoilEventSimulationThread(coilId, isEnabled); @@ -352,6 +358,7 @@ private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) public void ScheduleAction(uint timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); public void Nudge(float angleDeg, float force) => PhysicsEngine.Nudge(angleDeg, force); public void NudgeSensorStatus(out float x, out float y) => PhysicsEngine.NudgeSensorStatus(out x, out y); + public void NudgeTiltStatus(out float plumbX, out float plumbY, out float tiltPercent) => PhysicsEngine.NudgeTiltStatus(out plumbX, out plumbY, out tiltPercent); public void NudgeGetCalibration(out float x, out float y) { x = 0f; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs index c3cbfe501..72889f42c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs @@ -139,23 +139,30 @@ private void HandleKeyInput(object obj, InputActionChange change) switch (change) { case InputActionChange.ActionStarted: case InputActionChange.ActionCanceled: - var action = (InputAction)obj; - var actionName = InputManager.GetCanonicalActionName(action.name); - if (_keySwitchAssignments.TryGetValue(actionName, out var assignment)) { - if (_player != null) { - foreach (var sw in assignment) { - sw.IsSwitchEnabled = change == InputActionChange.ActionStarted; - _player.DispatchSwitch(sw.SwitchId, sw.IsSwitchClosed); - } - } - } else { - //Logger.Info($"Unmapped input command \"{action.name}\"."); - } - break; - } - } - - public void OnDestroy() + var action = (InputAction)obj; + var actionName = InputManager.GetCanonicalActionName(action.name); + if (!DispatchInputAction(actionName, change == InputActionChange.ActionStarted)) { + //Logger.Info($"Unmapped input command \"{action.name}\"."); + } + break; + } + } + + internal bool DispatchInputAction(string actionName, bool isPressed) + { + if (!_keySwitchAssignments.TryGetValue(actionName, out var assignment)) { + return false; + } + if (_player != null) { + foreach (var sw in assignment) { + sw.IsSwitchEnabled = isPressed; + _player.DispatchSwitch(sw.SwitchId, sw.IsSwitchClosed); + } + } + return true; + } + + public void OnDestroy() { if (_keySwitchAssignments.Count > 0) { _inputManager.Disable(HandleKeyInput); @@ -195,4 +202,4 @@ public ConstantSwitch(bool isSwitchClosed) IsSwitchEnabled = isSwitchClosed; } } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs index 0d1e61e06..302f87c36 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs @@ -260,45 +260,50 @@ private void HandleKeyInput(object obj, InputActionChange change) case InputActionChange.ActionCanceled: var action = (InputAction)obj; var actionName = InputManager.GetCanonicalActionName(action.name); - if (_keyWireAssignments != null && _keyWireAssignments.ContainsKey(actionName)) { - foreach (var wireConfig in _keyWireAssignments[actionName]) { - if (wireConfig.Device == null || !_wireDevices.ContainsKey(wireConfig.Device)) { - continue; - } - - var device = _wireDevices[wireConfig.Device]; - var wire = device.Wire(wireConfig.DeviceItem); - if (wire != null) { - var isEnabled = change == InputActionChange.ActionStarted; - if (!wireConfig.IsDynamic) { - wire.OnChange(isEnabled); - WireStatuses[wireConfig.Id] = (isEnabled, 0); - - } else { - _gleSignals[wireConfig][isEnabled].Enqueue(Time.realtimeSinceStartup); - if (wireConfig.IsActive) { - // the dynamic wire is active, so trigger directly. - wire.OnChange(isEnabled); - WireStatuses[wireConfig.Id] = (isEnabled, -2); - - } else { - WireStatuses[wireConfig.Id] = (WireStatuses[wireConfig.Id].Item1, -1); - } - } -#if UNITY_EDITOR - RefreshUI(); -#endif - } - else { - Logger.Warn($"Unknown wire \"{wireConfig.DeviceItem}\" in wire device \"{wireConfig.Device}\"."); - } - } - } - break; - } - } - - public void HandleSwitchChange(WireDestConfig wireConfig, bool isEnabled) + DispatchInputAction(actionName, change == InputActionChange.ActionStarted); + break; + } + } + + internal void DispatchInputAction(string actionName, bool isEnabled) + { + if (_keyWireAssignments == null || !_keyWireAssignments.ContainsKey(actionName)) { + return; + } + foreach (var wireConfig in _keyWireAssignments[actionName]) { + if (wireConfig.Device == null || !_wireDevices.ContainsKey(wireConfig.Device)) { + continue; + } + + var device = _wireDevices[wireConfig.Device]; + var wire = device.Wire(wireConfig.DeviceItem); + if (wire != null) { + if (!wireConfig.IsDynamic) { + wire.OnChange(isEnabled); + WireStatuses[wireConfig.Id] = (isEnabled, 0); + + } else { + _gleSignals[wireConfig][isEnabled].Enqueue(Time.realtimeSinceStartup); + if (wireConfig.IsActive) { + // the dynamic wire is active, so trigger directly. + wire.OnChange(isEnabled); + WireStatuses[wireConfig.Id] = (isEnabled, -2); + + } else { + WireStatuses[wireConfig.Id] = (WireStatuses[wireConfig.Id].Item1, -1); + } + } +#if UNITY_EDITOR + RefreshUI(); +#endif + } + else { + Logger.Warn($"Unknown wire \"{wireConfig.DeviceItem}\" in wire device \"{wireConfig.Device}\"."); + } + } + } + + public void HandleSwitchChange(WireDestConfig wireConfig, bool isEnabled) { var device = _player.WireDevice(wireConfig.Device); if (device != null) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs new file mode 100644 index 000000000..d755932b3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs @@ -0,0 +1,170 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public struct PlumbState + { + private const float Gravity = 9.80665f; + private const float DefaultPoleLength = 0.10f; + private const float DampingCoef0 = 1.25f; + private const float DampingCoef1 = 0.75f; + + public float PoleLength; + public float TiltThresholdRad; + public float3 Position; + public float3 AngularVelocity; + public int TiltIndex; + public float MaxTiltPercent; + public float2 MaxPlumbPosition; + public FixedList32Bytes PendingTiltStates; + + private byte _enabled; + private byte _tiltHigh; + private float _angularDamping0; + private float _angularDamping1; + private float _cabinetAccelerationScale; + + public bool Enabled + { + get => _enabled != 0; + set => _enabled = value ? (byte)1 : (byte)0; + } + + public bool TiltHigh + { + get => _tiltHigh != 0; + private set => _tiltHigh = value ? (byte)1 : (byte)0; + } + + public PlumbState(bool enabled, float damping, float tiltThresholdDeg) + { + PoleLength = DefaultPoleLength; + TiltThresholdRad = math.radians(math.clamp(tiltThresholdDeg, 0.5f, 4f)); + Position = new float3(0f, 0f, -DefaultPoleLength); + AngularVelocity = float3.zero; + TiltIndex = 0; + MaxTiltPercent = 0f; + MaxPlumbPosition = float2.zero; + PendingTiltStates = default; + _enabled = enabled ? (byte)1 : (byte)0; + _tiltHigh = 0; + _angularDamping0 = DampingCoef0 * math.clamp(damping, 0f, 2f); + _angularDamping1 = DampingCoef1 * math.clamp(damping, 0f, 2f); + _cabinetAccelerationScale = 1f; + } + + public void Configure(bool enabled, float damping, float tiltThresholdDeg) + { + this = new PlumbState(enabled, damping, tiltThresholdDeg); + } + + public void StepOneMillisecond(float2 cabinetAcceleration) + { + if (!Enabled || TiltThresholdRad <= 0f) { + return; + } + + const float dt = 0.001f; + var poleAxis = Position / PoleLength; + var plumbAcceleration = new float3( + -cabinetAcceleration.x * _cabinetAccelerationScale, + -cabinetAcceleration.y * _cabinetAccelerationScale, + -Gravity + ); + + var torque = math.cross(Position, plumbAcceleration); + var alpha = torque / (PoleLength * PoleLength); + var damping = _angularDamping0 + _angularDamping1 * math.length(AngularVelocity); + alpha -= AngularVelocity * damping; + + AngularVelocity += alpha * dt; + AngularVelocity -= poleAxis * math.dot(AngularVelocity, poleAxis); + + Position += math.cross(AngularVelocity, Position) * dt; + var positionLength = math.length(Position); + if (positionLength > 1.0e-8f) { + Position *= PoleLength / positionLength; + } else { + Position = new float3(0f, 0f, -PoleLength); + } + + poleAxis = Position / PoleLength; + AngularVelocity -= poleAxis * math.dot(AngularVelocity, poleAxis); + + var psi = math.atan2(math.sqrt(Position.x * Position.x + Position.y * Position.y), -Position.z); + var tiltPercent = 100f * psi / TiltThresholdRad; + var tilted = false; + if (tiltPercent > 100f) { + tilted = true; + ClampToTiltRingAndBounce(TiltThresholdRad); + } + + if (TiltHigh != tilted) { + TiltHigh = tilted; + if (tilted) { + TiltIndex++; + } + if (PendingTiltStates.Length < PendingTiltStates.Capacity) { + PendingTiltStates.Add(tilted ? (byte)1 : (byte)0); + } + } + + if (tiltPercent > MaxTiltPercent) { + MaxTiltPercent = tiltPercent; + } + if (math.abs(Position.x) > math.abs(MaxPlumbPosition.x)) { + MaxPlumbPosition.x = Position.x; + } + if (math.abs(Position.y) > math.abs(MaxPlumbPosition.y)) { + MaxPlumbPosition.y = Position.y; + } + } + + public float3 ReadAndResetTiltStatus() + { + var value = new float3(MaxPlumbPosition.x, MaxPlumbPosition.y, MaxTiltPercent); + MaxPlumbPosition = float2.zero; + MaxTiltPercent = 0f; + return value; + } + + public void ClearPendingTiltEvents() + { + PendingTiltStates.Length = 0; + } + + private void ClampToTiltRingAndBounce(float tiltAngle) + { + var limitAngle = tiltAngle - 1e-3f; + Position.z = -PoleLength * math.cos(limitAngle); + var xy = PoleLength * math.sin(limitAngle); + var theta = math.atan2(Position.x, Position.y); + var axis = new float3(math.sin(theta), math.cos(theta), 0f); + Position.x = xy * axis.x; + Position.y = xy * axis.y; + + var poleAxis = Position / PoleLength; + var velocity = math.cross(AngularVelocity, Position); + var reflectedVelocity = velocity - 2f * math.dot(velocity, poleAxis) * poleAxis; + AngularVelocity = math.cross(Position, reflectedVelocity) / (PoleLength * PoleLength); + AngularVelocity *= 0.8f; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs.meta new file mode 100644 index 000000000..f43e176e6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b17d6c587f6e4d4e9174c5a62f2b2a83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index f184e0c34..60c1a9c98 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -178,6 +178,13 @@ public struct Snapshot public int Float2AnimationSourceCount; public byte Float2AnimationsTruncated; + public float2 NudgeCabinetAcceleration; + public float2 NudgeCabinetOffset; + public float3 PlumbPosition; + public float PlumbTiltPercent; + public int PlumbTiltIndex; + public byte PlumbTiltHigh; + public void Allocate() { CoilStates = new NativeArray(MaxCoils, Allocator.Persistent); @@ -213,6 +220,12 @@ public void Allocate() Float2AnimationCount = 0; Float2AnimationSourceCount = 0; Float2AnimationsTruncated = 0; + NudgeCabinetAcceleration = float2.zero; + NudgeCabinetOffset = float2.zero; + PlumbPosition = float3.zero; + PlumbTiltPercent = 0f; + PlumbTiltIndex = 0; + PlumbTiltHigh = 0; } public void Dispose() diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index fc2ea7786..36373bfcd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -54,6 +54,7 @@ public class SimulationThread : IDisposable private readonly InputEventBuffer _inputBuffer; private readonly SimulationState _sharedState; private readonly System.Random _nudgeRandom = new System.Random(); + private readonly List _pendingPlumbTiltEvents = new List(8); private Thread _thread; private volatile bool _running = false; @@ -809,6 +810,25 @@ private void UpdatePhysics() // This works now because we changed Allocator.Temp to Allocator.TempJob // in the physics hot path, allowing custom threads to execute physics. _physicsEngine.ExecuteTick((ulong)_simulationTimeUsec); + ProcessPlumbTiltEvents(); + } + } + + private void ProcessPlumbTiltEvents() + { + _pendingPlumbTiltEvents.Clear(); + _physicsEngine.DrainPlumbTiltEvents(_pendingPlumbTiltEvents); + if (_pendingPlumbTiltEvents.Count == 0) { + return; + } + + var tiltActionIndex = (int)NativeInputApi.InputAction.Tilt; + for (var i = 0; i < _pendingPlumbTiltEvents.Count; i++) { + var isPressed = _pendingPlumbTiltEvents[i]; + _actionStates[tiltActionIndex] = isPressed; + if (_gamelogicEngine != null && _gamelogicStarted) { + SendMappedSwitch(tiltActionIndex, isPressed); + } } } From 3745ac81208324ef453376382c7c0826aadf54c8 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 16:07:36 +0200 Subject: [PATCH 04/25] input: expose native axis mappings --- .../VisualPinball.Unity.Test/Input.meta | 8 + .../Input/SensorMappingTests.cs | 61 ++++++ .../Input/SensorMappingTests.cs.meta | 11 ++ .../Input/SensorMapping.cs | 146 ++++++++++++++ .../Input/SensorMapping.cs.meta | 11 ++ .../Simulation/NativeInputApi.cs | 111 ++++++++--- .../Simulation/NativeInputManager.cs | 183 ++++++++++++++---- .../Simulation/SimulationThread.cs | 4 + 8 files changed, 475 insertions(+), 60 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Input.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Input.meta new file mode 100644 index 000000000..b0a643b43 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5b58f9e76ac64d0c90f4c18399bf1f14 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs new file mode 100644 index 000000000..fa133770a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs @@ -0,0 +1,61 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + public class SensorMappingTests + { + [Test] + public void MappingRoundTripsUsingVpinballFormat() + { + var mapping = new SensorMapping { + DeviceId = "HID\\VID_1209&PID_EAEA", + AxisId = 3, + Kind = SensorMappingKind.Velocity, + DeadZone = 0.05f, + Scale = -2f, + Limit = 0.75f + }; + + Assert.That(SensorMapping.TryParse(mapping.ToString(), out var parsed), Is.True); + Assert.That(parsed.DeviceId, Is.EqualTo(mapping.DeviceId)); + Assert.That(parsed.AxisId, Is.EqualTo(mapping.AxisId)); + Assert.That(parsed.Kind, Is.EqualTo(mapping.Kind)); + Assert.That(parsed.DeadZone, Is.EqualTo(mapping.DeadZone).Within(0.0001f)); + Assert.That(parsed.Scale, Is.EqualTo(mapping.Scale).Within(0.0001f)); + Assert.That(parsed.Limit, Is.EqualTo(mapping.Limit).Within(0.0001f)); + } + + [Test] + public void ProcessingAppliesDeadZoneLimitAndScale() + { + var mapping = new SensorMapping { + DeviceId = "device", + AxisId = 1, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0.1f, + Scale = 10f, + Limit = 0.5f + }; + + Assert.That(mapping.ProcessRawValue(0.05f, 100), Is.EqualTo(0f)); + Assert.That(mapping.ProcessRawValue(1f, 200), Is.EqualTo(5f)); + Assert.That(mapping.RawTimestampUsec, Is.EqualTo(200)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs.meta new file mode 100644 index 000000000..1c50467de --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f98f3bc7a12449d9a74e470d4b7df4f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs new file mode 100644 index 000000000..dd7d69d53 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs @@ -0,0 +1,146 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Globalization; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public enum SensorMappingKind + { + Position, + Velocity, + Acceleration + } + + public sealed class SensorMapping + { + public string DeviceId { get; set; } = string.Empty; + public int AxisId { get; set; } = -1; + public SensorMappingKind Kind { get; set; } = SensorMappingKind.Position; + public float DeadZone { get; set; } + public float Scale { get; set; } = 1f; + public float Limit { get; set; } = 1f; + public float RawValue { get; private set; } + public float MappedValue { get; private set; } + public long RawTimestampUsec { get; private set; } + + public bool IsMapped => !string.IsNullOrEmpty(DeviceId) && AxisId >= 0; + + public float ProcessRawValue(float rawValue, long timestampUsec) + { + RawValue = math.clamp(rawValue, -1f, 1f); + RawTimestampUsec = timestampUsec; + + var deadZone = math.clamp(DeadZone, 0f, 0.999f); + var value = RawValue; + var absValue = math.abs(value); + if (absValue <= deadZone) { + value = 0f; + } else { + value = math.sign(value) * ((absValue - deadZone) / (1f - deadZone)); + } + + var limit = math.max(0f, Limit); + if (limit > 0f) { + value = math.clamp(value, -limit, limit); + } + MappedValue = value * Scale; + return MappedValue; + } + + public override string ToString() + { + if (!IsMapped) { + return string.Empty; + } + + return string.Join(";", + DeviceId, + AxisId.ToString(CultureInfo.InvariantCulture), + KindToCode(Kind), + DeadZone.ToString(CultureInfo.InvariantCulture), + Scale.ToString(CultureInfo.InvariantCulture), + Limit.ToString(CultureInfo.InvariantCulture) + ); + } + + public static bool TryParse(string value, out SensorMapping mapping) + { + mapping = new SensorMapping(); + if (string.IsNullOrWhiteSpace(value)) { + return false; + } + + var parts = value.Split(';'); + if (parts.Length != 6) { + return false; + } + + if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var axisId)) { + return false; + } + if (!TryCodeToKind(parts[2], out var kind)) { + return false; + } + if (!float.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var deadZone)) { + return false; + } + if (!float.TryParse(parts[4], NumberStyles.Float, CultureInfo.InvariantCulture, out var scale)) { + return false; + } + if (!float.TryParse(parts[5], NumberStyles.Float, CultureInfo.InvariantCulture, out var limit)) { + return false; + } + + mapping.DeviceId = parts[0]; + mapping.AxisId = axisId; + mapping.Kind = kind; + mapping.DeadZone = math.clamp(deadZone, 0f, 0.999f); + mapping.Scale = scale; + mapping.Limit = math.max(0f, limit); + return mapping.IsMapped; + } + + private static string KindToCode(SensorMappingKind kind) + { + return kind switch { + SensorMappingKind.Velocity => "V", + SensorMappingKind.Acceleration => "A", + _ => "P" + }; + } + + private static bool TryCodeToKind(string code, out SensorMappingKind kind) + { + switch (code) { + case "P": + kind = SensorMappingKind.Position; + return true; + case "V": + kind = SensorMappingKind.Velocity; + return true; + case "A": + kind = SensorMappingKind.Acceleration; + return true; + default: + kind = default; + return false; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs.meta new file mode 100644 index 000000000..48e5da42a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e9be1b50c0cb4ce0a8782ec7f610f0e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index 3fc1ca9d5..7468ed83f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -16,8 +16,12 @@ namespace VisualPinball.Unity.Simulation public static class NativeInputApi { private const string DllName = "VisualPinball.NativeInput"; - - #region Enums + public const int ProtocolVersion = 2; + public const int DeviceIdSize = 260; + public const int DeviceNameSize = 128; + public const int AxisNameSize = 32; + + #region Enums /// /// Input action enum (must match native enum) @@ -63,12 +67,25 @@ public enum InputAction /// /// Input binding type /// - public enum BindingType - { - Keyboard = 0, - Gamepad = 1, - Mouse = 2, - } + public enum BindingType + { + Keyboard = 0, + Gamepad = 1, + Mouse = 2, + } + + public enum InputEventType + { + Action = 0, + Axis = 1, + } + + public enum AxisKind + { + Position = 0, + Velocity = 1, + Acceleration = 2, + } /// /// Key codes (Windows virtual key codes) @@ -142,14 +159,17 @@ public enum KeyCode /// /// Input event structure (matches native struct layout) /// - [StructLayout(LayoutKind.Sequential, Pack = 4)] - public struct InputEvent - { - public long TimestampUsec; - public int Action; // InputAction - public float Value; - private int _padding; - } + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct InputEvent + { + public long TimestampUsec; + public int EventType; // InputEventType + public int Action; // InputAction + public int DeviceIndex; + public int AxisId; + public float Value; + private int _padding; + } /// /// Input binding structure @@ -160,10 +180,40 @@ public struct InputBinding public int Action; // InputAction public int BindingType; // BindingType public int KeyCode; // KeyCode or button index - private int _padding; - } - - #endregion + private int _padding; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Ansi)] + public struct InputDeviceInfo + { + public int DeviceIndex; + public int AxisCount; + public int IsConnected; + private int _padding; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = DeviceIdSize)] + public string StableId; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = DeviceNameSize)] + public string DisplayName; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Ansi)] + public struct InputAxisInfo + { + public int AxisId; + public int UsagePage; + public int Usage; + public int Kind; + public float RawValue; + private int _padding; + public long TimestampUsec; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = AxisNameSize)] + public string Name; + } + + #endregion #region Delegates @@ -177,8 +227,11 @@ public struct InputBinding #region Native Functions - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern int VpeInputInit(); + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputInit(); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputGetProtocolVersion(); [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern void VpeInputShutdown(); @@ -189,10 +242,16 @@ public struct InputBinding [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern int VpeInputStartPolling(InputEventCallback callback, IntPtr userData, int pollIntervalUs); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern void VpeInputStopPolling(); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputStopPolling(); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputListDevices([Out] InputDeviceInfo[] devices, int maxDevices); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputListDeviceAxes(int deviceIndex, [Out] InputAxisInfo[] axes, int maxAxes); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern long VpeGetTimestampUsec(); [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index 6804b9b85..85e4f077b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -36,8 +36,9 @@ public class NativeInputManager : IDisposable private const double PerfSampleWindowSeconds = 0.25; private long _inputPerfWindowStartTicks = Stopwatch.GetTimestamp(); private int _inputEventsInWindow; - private float _actualEventRateHz; - + private float _actualEventRateHz; + private readonly List _deviceScratch = new(); + // Input configuration private readonly List _bindings = new(); @@ -82,8 +83,9 @@ public static NativeInputManager TryGetExistingInstance() public bool IsPolling => _polling; - public float TargetPollingHz => _polling && _pollIntervalUs > 0 ? 1000000f / _pollIntervalUs : 0f; - public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; + public float TargetPollingHz => _polling && _pollIntervalUs > 0 ? 1000000f / _pollIntervalUs : 0f; + public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; + public event Action AxisInputReceived; private NativeInputManager() { @@ -101,14 +103,27 @@ public bool Initialize() { if (_initialized) return true; - int result = NativeInputApi.VpeInputInit(); - if (result == 0) - { - Logger.Error($"{LogPrefix} [NativeInputManager] Failed to initialize native input system"); - return false; - } - - _initialized = true; + int result = NativeInputApi.VpeInputInit(); + if (result == 0) + { + Logger.Error($"{LogPrefix} [NativeInputManager] Failed to initialize native input system"); + return false; + } + + try { + var protocolVersion = NativeInputApi.VpeInputGetProtocolVersion(); + if (protocolVersion != NativeInputApi.ProtocolVersion) { + Logger.Error($"{LogPrefix} [NativeInputManager] Native input protocol mismatch: managed={NativeInputApi.ProtocolVersion}, native={protocolVersion}"); + NativeInputApi.VpeInputShutdown(); + return false; + } + } catch (EntryPointNotFoundException) { + Logger.Error($"{LogPrefix} [NativeInputManager] Native input plugin is too old for protocol {NativeInputApi.ProtocolVersion}"); + NativeInputApi.VpeInputShutdown(); + return false; + } + + _initialized = true; Logger.Info($"{LogPrefix} [NativeInputManager] Initialized"); // Setup default bindings @@ -162,11 +177,66 @@ public static List ConfiguredBindings /// Whether the app window currently has focus. The host sets this each frame from /// Application.isFocused; native input events are dropped while it is false. /// - public static bool AppFocused - { - get => _appFocused; - set => _appFocused = value; - } + public static bool AppFocused + { + get => _appFocused; + set => _appFocused = value; + } + + public IReadOnlyList ListDevices() + { + _deviceScratch.Clear(); + if (!_initialized) { + return _deviceScratch; + } + + var count = NativeInputApi.VpeInputListDevices(null, 0); + if (count <= 0) { + return _deviceScratch; + } + + var devices = new NativeInputApi.InputDeviceInfo[count]; + var copied = NativeInputApi.VpeInputListDevices(devices, devices.Length); + for (var i = 0; i < global::System.Math.Min(count, copied); i++) { + var axes = ListDeviceAxes(devices[i].DeviceIndex); + _deviceScratch.Add(new NativeInputDeviceInfo( + devices[i].DeviceIndex, + devices[i].StableId ?? string.Empty, + devices[i].DisplayName ?? string.Empty, + devices[i].IsConnected != 0, + axes + )); + } + return _deviceScratch; + } + + public IReadOnlyList ListDeviceAxes(int deviceIndex) + { + if (!_initialized) { + return Array.Empty(); + } + + var count = NativeInputApi.VpeInputListDeviceAxes(deviceIndex, null, 0); + if (count <= 0) { + return Array.Empty(); + } + + var axes = new NativeInputApi.InputAxisInfo[count]; + var copied = NativeInputApi.VpeInputListDeviceAxes(deviceIndex, axes, axes.Length); + var result = new NativeInputAxisInfo[global::System.Math.Min(count, copied)]; + for (var i = 0; i < result.Length; i++) { + result[i] = new NativeInputAxisInfo( + axes[i].AxisId, + axes[i].Name ?? string.Empty, + axes[i].UsagePage, + axes[i].Usage, + (NativeInputApi.AxisKind)axes[i].Kind, + axes[i].RawValue, + axes[i].TimestampUsec + ); + } + return result; + } /// /// Start input polling @@ -314,25 +384,30 @@ void Add(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) /// Input event callback from native layer (called on input polling thread) /// [MonoPInvokeCallback(typeof(NativeInputApi.InputEventCallback))] - private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userData) - { - if (Interlocked.Exchange(ref _loggedFirstEvent, 1) == 0) { - Logger.Info($"{LogPrefix} [NativeInputManager] First event: Action={evt.Action}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); - } - if (Logger.IsTraceEnabled) { - Logger.Trace($"{LogPrefix} [NativeInputManager] Received from native: Action={evt.Action}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); - } + private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userData) + { + if (Interlocked.Exchange(ref _loggedFirstEvent, 1) == 0) { + Logger.Info($"{LogPrefix} [NativeInputManager] First event: Type={evt.EventType}, Action={evt.Action}, Device={evt.DeviceIndex}, Axis={evt.AxisId}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); + } + if (Logger.IsTraceEnabled) { + Logger.Trace($"{LogPrefix} [NativeInputManager] Received from native: Type={evt.EventType}, Action={evt.Action}, Device={evt.DeviceIndex}, Axis={evt.AxisId}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); + } // Drop input while the app window isn't focused, so background key presses don't reach the game. if (!_appFocused) { return; - } - - // Forward to simulation thread via ring buffer - var instance = Volatile.Read(ref _instance); - instance?.MarkInputEventActivity(); - instance?._simulationThread?.EnqueueInputEvent(evt); - } + } + + var instance = Volatile.Read(ref _instance); + instance?.MarkInputEventActivity(); + if (evt.EventType == (int)NativeInputApi.InputEventType.Axis) { + instance?.AxisInputReceived?.Invoke(evt); + return; + } + + // Forward action events to simulation thread via ring buffer. + instance?._simulationThread?.EnqueueInputEvent(evt); + } private void MarkInputEventActivity() { @@ -372,5 +447,45 @@ public void Dispose() } #endregion - } -} + } + + public sealed class NativeInputDeviceInfo + { + public NativeInputDeviceInfo(int deviceIndex, string id, string name, bool isConnected, IReadOnlyList axes) + { + DeviceIndex = deviceIndex; + Id = id; + Name = name; + IsConnected = isConnected; + Axes = axes; + } + + public int DeviceIndex { get; } + public string Id { get; } + public string Name { get; } + public bool IsConnected { get; } + public IReadOnlyList Axes { get; } + } + + public readonly struct NativeInputAxisInfo + { + public NativeInputAxisInfo(int axisId, string name, int usagePage, int usage, NativeInputApi.AxisKind kind, float rawValue, long timestampUsec) + { + AxisId = axisId; + Name = name; + UsagePage = usagePage; + Usage = usage; + Kind = kind; + RawValue = rawValue; + TimestampUsec = timestampUsec; + } + + public int AxisId { get; } + public string Name { get; } + public int UsagePage { get; } + public int Usage { get; } + public NativeInputApi.AxisKind Kind { get; } + public float RawValue { get; } + public long TimestampUsec { get; } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 36373bfcd..314c0da18 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -467,6 +467,10 @@ private void ProcessInputEvents() while (_inputBuffer.TryDequeue(out var evt)) { + if (evt.EventType != (int)NativeInputApi.InputEventType.Action) { + continue; + } + var actionIndex = evt.Action; if ((uint)actionIndex >= (uint)_actionStates.Length) { continue; From 1fcc38aae9f2e84df7f11a7f96b1b6133b0012cb Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 16:23:46 +0200 Subject: [PATCH 05/25] physics: add nudge sensor models --- .../Physics/NudgeSensorStateTests.cs | 102 ++++ .../Physics/NudgeSensorStateTests.cs.meta | 3 + .../VisualPinball.Unity/Game/NudgeSystem.cs | 201 ++++++++ .../Game/NudgeSystem.cs.meta | 3 + .../VisualPinball.Unity/Game/PhysicsEngine.cs | 45 ++ .../Game/PhysicsEngineContext.cs | 7 + .../Game/PhysicsEngineThreading.cs | 20 + .../Cabinet/MotionGainCalibratorAxis.cs | 372 ++++++++++++++ .../Cabinet/MotionGainCalibratorAxis.cs.meta | 3 + .../Physics/Cabinet/MotionKalmanAxis.cs | 469 ++++++++++++++++++ .../Physics/Cabinet/MotionKalmanAxis.cs.meta | 3 + .../Physics/Cabinet/NudgeIntentState.cs | 130 +++++ .../Physics/Cabinet/NudgeIntentState.cs.meta | 3 + .../Physics/Cabinet/NudgeSensorChannel.cs | 28 ++ .../Cabinet/NudgeSensorChannel.cs.meta | 3 + .../Physics/Cabinet/NudgeSensorState.cs | 430 ++++++++++++++++ .../Physics/Cabinet/NudgeSensorState.cs.meta | 3 + .../Physics/Cabinet/NudgeSensorType.cs | 25 + .../Physics/Cabinet/NudgeSensorType.cs.meta | 3 + .../Physics/Cabinet/NudgeState.cs | 133 +++++ .../Simulation/SimulationThreadComponent.cs | 2 + 21 files changed, 1988 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs new file mode 100644 index 000000000..cafceb72e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs @@ -0,0 +1,102 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class NudgeSensorStateTests + { + [Test] + public void KalmanRestConstraintsDrainConstantBias() + { + var axis = MotionKalmanAxis.Default; + for (ulong t = 1000; t <= 500000; t += 1000) { + axis.UpdateAcceleration(t, 0.3f); + axis.UpdateRestConstraints(t); + } + + Assert.That(math.abs(axis.Acceleration), Is.LessThan(0.02f)); + Assert.That(math.abs(axis.Velocity), Is.LessThan(0.01f)); + } + + [Test] + public void GainCalibratorConvergesAcrossMotionSegments() + { + var calibrator = MotionGainCalibratorAxis.Default; + const float expectedGain = 2.0f; + ulong baseTime = 0; + + for (var segment = 0; segment < 30; segment++) { + calibrator.StartSegment(baseTime); + var velocity = 0f; + var previousAcceleration = 0f; + for (var i = 0; i <= 100; i++) { + var t = i / 100f; + var acceleration = math.sin(2f * math.PI * t); + if (i > 0) { + velocity += 0.5f * (previousAcceleration + acceleration) * 0.001f; + } + calibrator.AddSample(baseTime + (ulong)(i * 1000), expectedGain * velocity, acceleration); + previousAcceleration = acceleration; + } + calibrator.EndSegment(); + baseTime += 150000; + } + + Assert.That(calibrator.Gain, Is.EqualTo(expectedGain).Within(0.05f)); + Assert.That(calibrator.GlobalConfidence, Is.GreaterThanOrEqualTo(0.5f)); + } + + [Test] + public void IntentHandlerFiresSingleImpulseForPeak() + { + var intent = new NudgeIntentState(false); + + intent.StepOneMillisecond(new float2(0f, 0f)); + intent.StepOneMillisecond(new float2(0f, -0.5f)); + intent.StepOneMillisecond(new float2(0f, -1.2f)); + + Assert.That(intent.IsImpulseInProgress, Is.True); + intent.StepOneMillisecond(new float2(0f, -1.4f)); + Assert.That(intent.ImpulseAcceleration.y, Is.LessThan(0f)); + } + + [Test] + public void DirectCabinetSensorCanDriveNudgeState() + { + var nudge = new NudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.ConfigureSensor(0, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + AccelerationYMapped = 1 + }); + + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, 0f, 1000); + nudge.StepOneMillisecond(); + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, -12f, 2000); + for (var i = 0; i < 10; i++) { + nudge.StepOneMillisecond(); + } + + Assert.That(nudge.ActiveSourceIndex, Is.EqualTo(0)); + Assert.That(nudge.CabinetAcceleration.y, Is.LessThan(0f)); + Assert.That(math.abs(nudge.CabinetOffset.y), Is.GreaterThan(0f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs.meta new file mode 100644 index 000000000..6bd225086 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d4a3e5067591475db6d3a09d379f6f86 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs new file mode 100644 index 000000000..20b1bb06e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs @@ -0,0 +1,201 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using Unity.Mathematics; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity +{ + public sealed class NudgeSensorConfig + { + public NudgeSensorType Type = NudgeSensorType.GamepadIntent; + public float Strength = 1f; + public float CabinetMassKg = 113f; + public SensorMapping X = new(); + public SensorMapping Y = new(); + public SensorMapping AccelerationX = new(); + public SensorMapping AccelerationY = new(); + public SensorMapping VelocityX = new(); + public SensorMapping VelocityY = new(); + + public void Normalize() + { + Strength = math.clamp(Strength, 0f, 2f); + CabinetMassKg = math.clamp(CabinetMassKg <= 0f ? 113f : CabinetMassKg, 0f, 200f); + X ??= new SensorMapping(); + Y ??= new SensorMapping(); + AccelerationX ??= new SensorMapping(); + AccelerationY ??= new SensorMapping(); + VelocityX ??= new SensorMapping(); + VelocityY ??= new SensorMapping(); + } + + internal NudgeSensorRuntimeConfig ToRuntimeConfig() + { + Normalize(); + return new NudgeSensorRuntimeConfig { + Type = Type, + Strength = Strength, + CabinetMassKg = CabinetMassKg, + XMapped = X.IsMapped ? (byte)1 : (byte)0, + YMapped = Y.IsMapped ? (byte)1 : (byte)0, + AccelerationXMapped = AccelerationX.IsMapped ? (byte)1 : (byte)0, + AccelerationYMapped = AccelerationY.IsMapped ? (byte)1 : (byte)0, + VelocityXMapped = VelocityX.IsMapped ? (byte)1 : (byte)0, + VelocityYMapped = VelocityY.IsMapped ? (byte)1 : (byte)0 + }; + } + } + + public sealed class NudgeSystem : IDisposable + { + private readonly PhysicsEngine _physicsEngine; + private readonly object _configLock = new(); + private readonly List _sensors = new(NudgeState.MaxSensors); + private readonly Dictionary _deviceIdsByIndex = new(); + private NativeInputManager _inputManager; + + internal NudgeSystem(PhysicsEngine physicsEngine) + { + _physicsEngine = physicsEngine; + } + + public int SensorCount + { + get { + lock (_configLock) { + return _sensors.Count; + } + } + } + + public IReadOnlyList ListDevices() + { + if (_inputManager == null) { + return Array.Empty(); + } + var devices = _inputManager.ListDevices(); + CacheDeviceIds(devices); + return devices; + } + + public void ConfigureSensors(IReadOnlyList sensors) + { + lock (_configLock) { + _sensors.Clear(); + if (sensors != null) { + for (var i = 0; i < sensors.Count && i < NudgeState.MaxSensors; i++) { + var sensor = sensors[i] ?? new NudgeSensorConfig(); + sensor.Normalize(); + _sensors.Add(sensor); + } + } + + _physicsEngine.ConfigureNudgeSensorCount(_sensors.Count); + for (var i = 0; i < _sensors.Count; i++) { + _physicsEngine.ConfigureNudgeSensor(i, _sensors[i].ToRuntimeConfig()); + } + } + RefreshDevices(); + } + + internal void AttachNativeInputManager(NativeInputManager inputManager) + { + if (_inputManager == inputManager) { + return; + } + DetachNativeInputManager(); + _inputManager = inputManager; + if (_inputManager != null) { + _inputManager.AxisInputReceived += OnAxisInputReceived; + RefreshDevices(); + } + } + + internal void DetachNativeInputManager() + { + if (_inputManager != null) { + _inputManager.AxisInputReceived -= OnAxisInputReceived; + _inputManager = null; + } + _deviceIdsByIndex.Clear(); + } + + public void Dispose() + { + DetachNativeInputManager(); + } + + private void RefreshDevices() + { + if (_inputManager == null) { + return; + } + CacheDeviceIds(_inputManager.ListDevices()); + } + + private void CacheDeviceIds(IReadOnlyList devices) + { + _deviceIdsByIndex.Clear(); + if (devices == null) { + return; + } + for (var i = 0; i < devices.Count; i++) { + _deviceIdsByIndex[devices[i].DeviceIndex] = devices[i].Id; + } + } + + private void OnAxisInputReceived(NativeInputApi.InputEvent evt) + { + if (evt.EventType != (int)NativeInputApi.InputEventType.Axis) { + return; + } + if (!_deviceIdsByIndex.TryGetValue(evt.DeviceIndex, out var deviceId)) { + RefreshDevices(); + if (!_deviceIdsByIndex.TryGetValue(evt.DeviceIndex, out deviceId)) { + return; + } + } + + lock (_configLock) { + for (var i = 0; i < _sensors.Count; i++) { + var sensor = _sensors[i]; + if (sensor.Type == NudgeSensorType.GamepadIntent) { + TryQueueSample(i, sensor.X, NudgeSensorChannel.X, deviceId, evt); + TryQueueSample(i, sensor.Y, NudgeSensorChannel.Y, deviceId, evt); + } else { + TryQueueSample(i, sensor.VelocityX, NudgeSensorChannel.VelocityX, deviceId, evt); + TryQueueSample(i, sensor.VelocityY, NudgeSensorChannel.VelocityY, deviceId, evt); + TryQueueSample(i, sensor.AccelerationX, NudgeSensorChannel.AccelerationX, deviceId, evt); + TryQueueSample(i, sensor.AccelerationY, NudgeSensorChannel.AccelerationY, deviceId, evt); + } + } + } + } + + private void TryQueueSample(int sensorIndex, SensorMapping mapping, NudgeSensorChannel channel, string deviceId, + NativeInputApi.InputEvent evt) + { + if (mapping == null || !mapping.IsMapped || mapping.AxisId != evt.AxisId || mapping.DeviceId != deviceId) { + return; + } + var mappedValue = mapping.ProcessRawValue(evt.Value, evt.TimestampUsec); + _physicsEngine.EnqueueNudgeSensorSample(sensorIndex, channel, mappedValue, (ulong)evt.TimestampUsec); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs.meta new file mode 100644 index 000000000..f66158554 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 653357e2bd134c55972fa92984606fa4 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 89c94167a..8245803d9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -170,6 +170,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac // Lifecycle-local references (used during Awake/Start, then passed // to _threading constructor). [NonSerialized] private Player _player; + [NonSerialized] private NudgeSystem _nudgeSystem; [NonSerialized] private ICollidableComponent[] _colliderComponents; [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents; [NonSerialized] private float4x4 _worldToPlayfield; @@ -196,6 +197,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac /// Used by the simulation thread to wait for physics to be ready. /// public bool IsInitialized => _ctx != null && _ctx.IsInitialized; + public NudgeSystem NudgeSystem => _nudgeSystem; #region Ref-Return Properties @@ -331,6 +333,46 @@ public void ConfigurePlumb(bool simulatedPlumb, float damping, float thresholdAn } } + public void ConfigureNudgeSensors(IReadOnlyList sensors) + { + _nudgeSystem?.ConfigureSensors(sensors); + } + + internal void ConfigureNudgeSensorCount(int count) + { + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + nudge.ConfigureSensors(count); + _ctx.PhysicsEnv.Nudge = nudge; + } + } + + internal void ConfigureNudgeSensor(int index, NudgeSensorRuntimeConfig config) + { + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + nudge.ConfigureSensor(index, config); + _ctx.PhysicsEnv.Nudge = nudge; + } + } + + internal void EnqueueNudgeSensorSample(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) + { + lock (_ctx.PendingNudgeSensorSamplesLock) { + _ctx.PendingNudgeSensorSamples.Enqueue(new NudgeSensorSampleCommand(sensorIndex, channel, value, timestampUsec)); + } + } + + internal void AttachNativeInputManager(NativeInputManager inputManager) + { + _nudgeSystem?.AttachNativeInputManager(inputManager); + } + + internal void DetachNativeInputManager(NativeInputManager inputManager) + { + _nudgeSystem?.DetachNativeInputManager(); + } + public void NudgeSensorStatus(out float x, out float y) { lock (_ctx.PhysicsLock) { @@ -698,6 +740,7 @@ private void Awake() { _mainThreadManagedThreadId = Thread.CurrentThread.ManagedThreadId; _player = GetComponentInParent(); + _nudgeSystem = new NudgeSystem(this); _ctx.InsideOfs = new InsideOfs(Allocator.Persistent); var table = GetComponentInParent(); _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength, @@ -876,6 +919,8 @@ private void OnDestroy() _ctx.IsInitialized = false; _ctx.UseExternalTiming = false; + _nudgeSystem?.Dispose(); + _nudgeSystem = null; lock (_ctx.PhysicsLock) { _ctx.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index d74dc4b9a..b96032d1e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -200,6 +200,13 @@ internal class PhysicsEngineContext : IDisposable public readonly Queue PendingKeyboardNudges = new(); public readonly object PendingKeyboardNudgesLock = new(); + /// + /// Analog nudge sensor samples waiting to be applied to the persistent + /// cabinet state. Drained inside PhysicsLock before the physics loop. + /// + public readonly Queue PendingNudgeSensorSamples = new(); + public readonly object PendingNudgeSensorSamplesLock = new(); + /// /// Scheduled managed callbacks stored in a min-heap by due time. /// Protected by locking on . diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 5535e853d..f3867fe2e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -58,6 +58,7 @@ internal class PhysicsEngineThreading private readonly List _dueSingleThreadScheduledActions = new(); private readonly List _pendingInputActions = new(); private readonly List _pendingKeyboardNudges = new(); + private readonly List _pendingNudgeSensorSamples = new(); private readonly List> _pendingKinematicUpdates = new(); private readonly List _pendingKinematicStopUpdates = new(); @@ -197,6 +198,7 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) // process input ProcessInputActions(ref state); ProcessPendingKeyboardNudges(); + ProcessPendingNudgeSensorSamples(); // run physics loop (Burst-compiled, thread-safe) PhysicsUpdate.Execute( @@ -252,6 +254,23 @@ private void ProcessPendingKeyboardNudges() } } + private void ProcessPendingNudgeSensorSamples() + { + _pendingNudgeSensorSamples.Clear(); + + lock (_ctx.PendingNudgeSensorSamplesLock) { + while (_ctx.PendingNudgeSensorSamples.Count > 0) { + _pendingNudgeSensorSamples.Add(_ctx.PendingNudgeSensorSamples.Dequeue()); + } + } + + foreach (var sample in _pendingNudgeSensorSamples) { + var nudgeState = _ctx.PhysicsEnv.Nudge; + nudgeState.ApplySensorSample(sample.SensorIndex, sample.Channel, sample.Value, sample.TimestampUsec); + _ctx.PhysicsEnv.Nudge = nudgeState; + } + } + /// /// Apply kinematic transforms staged by the main thread into the /// physics state maps, deriving each item's velocity from the @@ -810,6 +829,7 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) // process input ProcessInputActions(ref state); ProcessPendingKeyboardNudges(); + ProcessPendingNudgeSensorSamples(); // run physics loop PhysicsUpdate.Execute( diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs new file mode 100644 index 000000000..0b3d666b0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs @@ -0,0 +1,372 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public struct MotionGainCalibratorAxis + { + public struct Config + { + public float InitialGain; + public ulong MinSegmentDurationUs; + public ulong MaxSegmentDurationUs; + public int MinSampleCount; + public float MinVelocityPeakToPeak; + public float MinIntegratedAccelPeakToPeak; + public float MinRegressionDenominator; + public float ForgettingFactor; + public float MinGain; + public float MaxGain; + public byte ExcludeSegmentEndpointsFromFit; + public byte UseTriangularWeights; + public float Epsilon; + + public static Config Default => new() { + InitialGain = 1.0f, + MinSegmentDurationUs = 30000, + MaxSegmentDurationUs = 2000000, + MinSampleCount = 8, + MinVelocityPeakToPeak = 0.02f, + MinIntegratedAccelPeakToPeak = 0.002f, + MinRegressionDenominator = 1.0e-6f, + ForgettingFactor = 0.995f, + MinGain = 0.001f, + MaxGain = 1000.0f, + ExcludeSegmentEndpointsFromFit = 1, + UseTriangularWeights = 1, + Epsilon = 1.0e-9f + }; + } + + public struct SegmentResult + { + public byte Accepted; + public ulong DurationUs; + public int SampleCount; + public float SegmentGain; + public float SegmentQuality; + public float SegmentResidualRms; + public float VelocityPeakToPeak; + public float IntegratedAccelPeakToPeak; + public float SegmentNumerator; + public float SegmentDenominator; + } + + private struct Sample + { + public ulong TimeUs; + public float Velocity; + public float Acceleration; + } + + private Config _config; + private byte _segmentActive; + private ulong _segmentStartUs; + private FixedList4096Bytes _samples; + private float _gain; + private double _accumulatedNumerator; + private double _accumulatedDenominator; + private int _startedSegmentCount; + private int _acceptedSegmentCount; + private int _rejectedSegmentCount; + private ulong _totalAcceptedDurationUs; + private SegmentResult _lastResult; + private float _globalConfidence; + + public MotionGainCalibratorAxis(Config config) + { + _config = config; + _segmentActive = 0; + _segmentStartUs = 0; + _samples = default; + _gain = config.InitialGain; + _accumulatedNumerator = 0.0; + _accumulatedDenominator = 0.0; + _startedSegmentCount = 0; + _acceptedSegmentCount = 0; + _rejectedSegmentCount = 0; + _totalAcceptedDurationUs = 0; + _lastResult = default; + _globalConfidence = 0f; + } + + public static MotionGainCalibratorAxis Default => new(Config.Default); + public bool IsSegmentActive => _segmentActive != 0; + public float Gain => _gain; + public int StartedSegmentCount => _startedSegmentCount; + public int AcceptedSegmentCount => _acceptedSegmentCount; + public int RejectedSegmentCount => _rejectedSegmentCount; + public ulong TotalAcceptedDurationUs => _totalAcceptedDurationUs; + public float GlobalConfidence => _globalConfidence; + public SegmentResult LastResult => _lastResult; + + public void Configure(Config config) + { + _config = config; + _gain = math.clamp(_gain, _config.MinGain, _config.MaxGain); + } + + public void Reset() + { + _segmentActive = 0; + _samples.Clear(); + _gain = _config.InitialGain; + _accumulatedNumerator = 0.0; + _accumulatedDenominator = 0.0; + _startedSegmentCount = 0; + _acceptedSegmentCount = 0; + _rejectedSegmentCount = 0; + _totalAcceptedDurationUs = 0; + _lastResult = default; + _globalConfidence = 0f; + } + + public float ScaleAcceleration(float rawAcceleration) => _gain * rawAcceleration; + + public float ScaleVelocityToAccelerationUnits(float rawVelocity) + { + return math.abs(_gain) <= _config.Epsilon ? 0f : rawVelocity / _gain; + } + + public void StartSegment(ulong timeUs) + { + _segmentActive = 1; + _samples.Clear(); + _startedSegmentCount++; + _segmentStartUs = timeUs; + } + + public bool AddSample(ulong timeUs, float rawVelocity, float rawAcceleration) + { + if (!IsSegmentActive) { + return false; + } + if (_samples.Length > 0 && timeUs <= _samples[_samples.Length - 1].TimeUs) { + return false; + } + if (_samples.Length >= _samples.Capacity) { + EndSegment(); + StartSegment(timeUs); + } + + _samples.Add(new Sample { + TimeUs = timeUs, + Velocity = rawVelocity, + Acceleration = rawAcceleration + }); + return true; + } + + public bool EndSegment() + { + if (!IsSegmentActive) { + _lastResult = default; + return false; + } + + _segmentActive = 0; + _lastResult = EvaluateCurrentSegment(); + + if (_lastResult.Accepted == 0) { + _rejectedSegmentCount++; + _samples.Clear(); + return false; + } + + var lambda = (double)math.clamp(_config.ForgettingFactor, 0f, 1f); + _accumulatedNumerator = lambda * _accumulatedNumerator + _lastResult.SegmentNumerator; + _accumulatedDenominator = lambda * _accumulatedDenominator + _lastResult.SegmentDenominator; + + if (_accumulatedDenominator > _config.Epsilon) { + var gain = _accumulatedNumerator / _accumulatedDenominator; + _gain = math.clamp((float)gain, _config.MinGain, _config.MaxGain); + } + + _acceptedSegmentCount++; + _totalAcceptedDurationUs += _lastResult.DurationUs; + _globalConfidence = ComputeGlobalConfidence(); + _samples.Clear(); + return true; + } + + private float ComputeGlobalConfidence() + { + if (_acceptedSegmentCount == 0) { + return 0f; + } + + var segFactor = 1f - math.exp(-0.25f * _acceptedSegmentCount); + var durationS = _totalAcceptedDurationUs * 1.0e-6f; + var durationFactor = 1f - math.exp(-durationS * 0.5f); + var denomFactor = 1f - math.exp(-(float)_accumulatedDenominator * 0.25f); + return math.clamp(0.40f * segFactor + 0.35f * durationFactor + 0.25f * denomFactor, 0f, 1f); + } + + private SegmentResult EvaluateCurrentSegment() + { + var result = new SegmentResult { + SegmentGain = 1f + }; + var n = _samples.Length; + result.SampleCount = n; + if (n < _config.MinSampleCount) { + return result; + } + + var startUs = _samples[0].TimeUs; + var endUs = _samples[n - 1].TimeUs; + var durationUs = endUs - startUs; + result.DurationUs = durationUs; + if (durationUs < _config.MinSegmentDurationUs || durationUs > _config.MaxSegmentDurationUs) { + return result; + } + + var tNorm = default(FixedList4096Bytes); + var velocity = default(FixedList4096Bytes); + var velocityDetrended = default(FixedList4096Bytes); + var integratedAcceleration = default(FixedList4096Bytes); + var integratedAccelerationDetrended = default(FixedList4096Bytes); + tNorm.Length = n; + velocity.Length = n; + velocityDetrended.Length = n; + integratedAcceleration.Length = n; + integratedAccelerationDetrended.Length = n; + + var totalDurationS = durationUs * 1.0e-6; + if (totalDurationS <= 0.0) { + return result; + } + + for (var i = 0; i < n; i++) { + var relUs = (double)(_samples[i].TimeUs - startUs); + tNorm[i] = (float)(relUs / durationUs); + velocity[i] = _samples[i].Velocity; + } + + integratedAcceleration[0] = 0f; + for (var i = 1; i < n; i++) { + var dt = (_samples[i].TimeUs - _samples[i - 1].TimeUs) * 1.0e-6f; + var a0 = _samples[i - 1].Acceleration; + var a1 = _samples[i].Acceleration; + integratedAcceleration[i] = integratedAcceleration[i - 1] + 0.5f * (a0 + a1) * dt; + } + + var vel0 = velocity[0]; + var vel1 = velocity[n - 1]; + var int0 = integratedAcceleration[0]; + var int1 = integratedAcceleration[n - 1]; + for (var i = 0; i < n; i++) { + velocityDetrended[i] = velocity[i] - math.lerp(vel0, vel1, tNorm[i]); + integratedAccelerationDetrended[i] = integratedAcceleration[i] - math.lerp(int0, int1, tNorm[i]); + } + + result.VelocityPeakToPeak = PeakToPeak(velocityDetrended, n); + result.IntegratedAccelPeakToPeak = PeakToPeak(integratedAccelerationDetrended, n); + if (result.VelocityPeakToPeak < _config.MinVelocityPeakToPeak + || result.IntegratedAccelPeakToPeak < _config.MinIntegratedAccelPeakToPeak) { + return result; + } + + var numerator = 0.0; + var denominator = 0.0; + var fitBegin = _config.ExcludeSegmentEndpointsFromFit != 0 && n >= 3 ? 1 : 0; + var fitEnd = _config.ExcludeSegmentEndpointsFromFit != 0 && n >= 3 ? n - 1 : n; + + for (var i = fitBegin; i < fitEnd; i++) { + var x = integratedAccelerationDetrended[i]; + var y = velocityDetrended[i]; + var w = ComputeSampleWeight(tNorm[i]); + numerator += (double)w * x * y; + denominator += (double)w * x * x; + } + + result.SegmentNumerator = (float)numerator; + result.SegmentDenominator = (float)denominator; + if (denominator < _config.MinRegressionDenominator) { + return result; + } + + var segmentGain = numerator / denominator; + result.SegmentGain = (float)segmentGain; + if (!IsFinite(result.SegmentGain)) { + return result; + } + + var weightedResidual2 = 0.0; + var weightedCount = 0.0; + for (var i = fitBegin; i < fitEnd; i++) { + var x = integratedAccelerationDetrended[i]; + var y = velocityDetrended[i]; + var w = ComputeSampleWeight(tNorm[i]); + var r = y - segmentGain * x; + weightedResidual2 += (double)w * r * r; + weightedCount += w; + } + if (weightedCount <= _config.Epsilon) { + return result; + } + + result.SegmentResidualRms = (float)math.sqrt((float)(weightedResidual2 / weightedCount)); + var signalScale = math.max(result.VelocityPeakToPeak, _config.Epsilon); + var normalizedResidual = result.SegmentResidualRms / signalScale; + result.SegmentQuality = 1f - math.clamp(normalizedResidual * 2f, 0f, 1f); + + if (!IsFinite(result.SegmentQuality)) { + return result; + } + if (result.SegmentGain < _config.MinGain || result.SegmentGain > _config.MaxGain) { + return result; + } + if (result.SegmentQuality <= 0.05f) { + return result; + } + + result.Accepted = 1; + return result; + } + + private float ComputeSampleWeight(float tNorm) + { + if (_config.UseTriangularWeights == 0) { + return 1f; + } + var d = math.abs(2f * tNorm - 1f); + return math.max(0f, 1f - d); + } + + private static float PeakToPeak(FixedList4096Bytes data, int count) + { + if (count == 0) { + return 0f; + } + var min = data[0]; + var max = data[0]; + for (var i = 1; i < count; i++) { + min = math.min(min, data[i]); + max = math.max(max, data[i]); + } + return max - min; + } + + private static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs.meta new file mode 100644 index 000000000..b68890133 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c9e90cf258c24182b3a9562570a629a5 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs new file mode 100644 index 000000000..9623dbf54 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs @@ -0,0 +1,469 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public struct MotionKalmanAxis + { + private const int StatePosition = 0; + private const int StateVelocity = 1; + private const int StateAcceleration = 2; + private const int StateVelocityBias = 3; + private const int StateAccelerationBias = 4; + private const int StateCount = 5; + + public struct Config + { + public float ProcessJerkVariance; + public float VelocityBiasProcessVariance; + public float AccelerationBiasProcessVariance; + public float VelocityMeasurementVariance; + public float AccelerationMeasurementVariance; + public float ZeroPositionMeasurementVariance; + public float ZeroVelocityMeasurementVariance; + public float ZeroAccelerationMeasurementVariance; + public float InitialPositionVariance; + public float InitialVelocityVariance; + public float InitialAccelerationVariance; + public float InitialVelocityBiasVariance; + public float InitialAccelerationBiasVariance; + public float BiasMeanReversionTimeS; + public float MinDt; + public float MaxDt; + + public static Config Default => new() { + ProcessJerkVariance = 800.0f, + VelocityBiasProcessVariance = 0.0001f, + AccelerationBiasProcessVariance = 0.0001f, + VelocityMeasurementVariance = 0.05f * 0.05f, + AccelerationMeasurementVariance = 0.40f * 0.40f, + ZeroPositionMeasurementVariance = 0.0005f * 0.0005f, + ZeroVelocityMeasurementVariance = 0.01f * 0.01f, + ZeroAccelerationMeasurementVariance = 0.10f * 0.10f, + InitialPositionVariance = 1.0e-4f, + InitialVelocityVariance = 1.0f, + InitialAccelerationVariance = 25.0f, + InitialVelocityBiasVariance = 0.01f, + InitialAccelerationBiasVariance = 0.01f, + BiasMeanReversionTimeS = 5.0f, + MinDt = 1.0e-6f, + MaxDt = 0.001f + }; + } + + private Config _config; + private byte _initialized; + private ulong _timeUs; + private Vector5f _state; + private Matrix5f _covariance; + + public MotionKalmanAxis(Config config) + { + _config = config; + _initialized = 0; + _timeUs = 0; + _state = default; + _covariance = default; + _state.SetZero(); + _covariance.SetZero(); + } + + public static MotionKalmanAxis Default => new(Config.Default); + + public bool IsInitialized => _initialized != 0; + public ulong TimeUs => _timeUs; + public float Position => _state[StatePosition]; + public float Velocity => _state[StateVelocity]; + public float Acceleration => _state[StateAcceleration]; + public float VelocityBias => _state[StateVelocityBias]; + public float AccelerationBias => _state[StateAccelerationBias]; + public float BiasedVelocity => Velocity + VelocityBias; + public float BiasedAcceleration => Acceleration + AccelerationBias; + + public void Configure(Config config) + { + _config = config; + } + + public void Reset(ulong timeUs, float position = 0f, float velocity = 0f, float acceleration = 0f, + float velocityBias = 0f, float accelerationBias = 0f) + { + _initialized = 1; + _timeUs = timeUs; + _state.Set(position, velocity, acceleration, velocityBias, accelerationBias); + _covariance.SetZero(); + _covariance[StatePosition, StatePosition] = _config.InitialPositionVariance; + _covariance[StateVelocity, StateVelocity] = _config.InitialVelocityVariance; + _covariance[StateAcceleration, StateAcceleration] = _config.InitialAccelerationVariance; + _covariance[StateVelocityBias, StateVelocityBias] = _config.InitialVelocityBiasVariance; + _covariance[StateAccelerationBias, StateAccelerationBias] = _config.InitialAccelerationBiasVariance; + } + + public void SetState(float position, float velocity, float acceleration, float velocityBias, float accelerationBias) + { + _state.Set(position, velocity, acceleration, velocityBias, accelerationBias); + } + + public void PredictTo(ulong timeUs) + { + if (!IsInitialized || timeUs <= _timeUs) { + return; + } + + var remainingDt = (timeUs - _timeUs) * 1.0e-6f; + while (remainingDt > 0f) { + var dt = math.min(remainingDt, _config.MaxDt); + PredictStep(dt); + remainingDt -= dt; + } + _timeUs = timeUs; + } + + public void UpdateVelocity(ulong timeUs, float velocity) + { + if (!IsInitialized) { + Reset(timeUs, 0f, 0f, 0f, velocity, 0f); + _covariance[StateVelocityBias, StateVelocityBias] = _config.VelocityMeasurementVariance; + return; + } + + PredictTo(timeUs); + var h = Vector5f.Zero; + h[StateVelocity] = 1f; + h[StateVelocityBias] = 1f; + UpdateScalarMeasurement(h, velocity, _config.VelocityMeasurementVariance); + } + + public void UpdateAcceleration(ulong timeUs, float acceleration) + { + if (!IsInitialized) { + Reset(timeUs, 0f, 0f, 0f, 0f, acceleration); + _covariance[StateAccelerationBias, StateAccelerationBias] = _config.AccelerationMeasurementVariance; + return; + } + + PredictTo(timeUs); + var h = Vector5f.Zero; + h[StateAcceleration] = 1f; + h[StateAccelerationBias] = 1f; + UpdateScalarMeasurement(h, acceleration, _config.AccelerationMeasurementVariance); + } + + public void UpdateRestConstraints(ulong timeUs, bool applyPositionConstraint = true, + bool applyVelocityConstraint = true, bool applyAccelerationConstraint = true) + { + if (!IsInitialized) { + return; + } + + PredictTo(timeUs); + + if (applyVelocityConstraint) { + var h = Vector5f.Zero; + h[StateVelocity] = 1f; + UpdateScalarMeasurement(h, 0f, _config.ZeroVelocityMeasurementVariance); + } + if (applyAccelerationConstraint) { + var h = Vector5f.Zero; + h[StateAcceleration] = 1f; + UpdateScalarMeasurement(h, 0f, _config.ZeroAccelerationMeasurementVariance); + } + if (applyPositionConstraint) { + var h = Vector5f.Zero; + h[StatePosition] = 1f; + UpdateScalarMeasurement(h, 0f, _config.ZeroPositionMeasurementVariance); + } + } + + private void PredictStep(float dt) + { + dt = math.max(dt, _config.MinDt); + var f = BuildTransitionMatrix(dt); + var ft = Transposed(f); + var q = BuildProcessNoiseMatrix(dt); + _state = Mul(f, _state); + _covariance = Add(Mul(Mul(f, _covariance), ft), q); + Symmetrize(ref _covariance); + } + + private Matrix5f BuildTransitionMatrix(float dt) + { + var dt2 = dt * dt; + var f = Matrix5f.Identity; + f[StatePosition, StateVelocity] = dt; + f[StatePosition, StateAcceleration] = 0.5f * dt2; + f[StateVelocity, StateAcceleration] = dt; + + var tau = _config.BiasMeanReversionTimeS; + var alpha = tau > 0f ? math.exp(-dt / tau) : 1f; + f[StateVelocityBias, StateVelocityBias] = alpha; + f[StateAccelerationBias, StateAccelerationBias] = alpha; + return f; + } + + private Matrix5f BuildProcessNoiseMatrix(float dt) + { + var qj = _config.ProcessJerkVariance; + var qbv = _config.VelocityBiasProcessVariance; + var qba = _config.AccelerationBiasProcessVariance; + var dt2 = dt * dt; + var dt3 = dt2 * dt; + var dt4 = dt3 * dt; + var dt5 = dt4 * dt; + + var q = Matrix5f.Zero; + q[StatePosition, StatePosition] = qj * (dt5 / 20f); + q[StatePosition, StateVelocity] = qj * (dt4 / 8f); + q[StatePosition, StateAcceleration] = qj * (dt3 / 6f); + q[StateVelocity, StatePosition] = qj * (dt4 / 8f); + q[StateVelocity, StateVelocity] = qj * (dt3 / 3f); + q[StateVelocity, StateAcceleration] = qj * (dt2 / 2f); + q[StateAcceleration, StatePosition] = qj * (dt3 / 6f); + q[StateAcceleration, StateVelocity] = qj * (dt2 / 2f); + q[StateAcceleration, StateAcceleration] = qj * dt; + q[StateVelocityBias, StateVelocityBias] = qbv * dt; + q[StateAccelerationBias, StateAccelerationBias] = qba * dt; + return q; + } + + private void UpdateScalarMeasurement(Vector5f h, float measurement, float measurementVariance) + { + var predictedMeasurement = Dot(h, _state); + var innovation = measurement - predictedMeasurement; + + var pht = Vector5f.Zero; + for (var i = 0; i < StateCount; i++) { + var sum = 0f; + for (var j = 0; j < StateCount; j++) { + sum += _covariance[i, j] * h[j]; + } + pht[i] = sum; + } + + var s = Dot(h, pht) + measurementVariance; + if (s <= 0f) { + return; + } + + var invS = 1f / s; + var k = Vector5f.Zero; + for (var i = 0; i < StateCount; i++) { + k[i] = pht[i] * invS; + _state[i] += k[i] * innovation; + } + + var a = Matrix5f.Identity; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + a[i, j] -= k[i] * h[j]; + } + } + + var at = Transposed(a); + var apAt = Mul(Mul(a, _covariance), at); + var krKt = OuterProduct(k, k, measurementVariance); + _covariance = Add(apAt, krKt); + Symmetrize(ref _covariance); + } + + private struct Vector5f + { + private FixedList32Bytes _values; + + public static Vector5f Zero + { + get { + var value = new Vector5f(); + value.SetZero(); + return value; + } + } + + public float this[int index] + { + get { + Ensure(); + return _values[index]; + } + set { + Ensure(); + _values[index] = value; + } + } + + public void Set(float position, float velocity, float acceleration, float velocityBias, float accelerationBias) + { + Ensure(); + _values[StatePosition] = position; + _values[StateVelocity] = velocity; + _values[StateAcceleration] = acceleration; + _values[StateVelocityBias] = velocityBias; + _values[StateAccelerationBias] = accelerationBias; + } + + public void SetZero() + { + Ensure(); + for (var i = 0; i < StateCount; i++) { + _values[i] = 0f; + } + } + + private void Ensure() + { + if (_values.Length != StateCount) { + _values.Length = StateCount; + } + } + } + + private struct Matrix5f + { + private FixedList128Bytes _values; + + public static Matrix5f Zero + { + get { + var value = new Matrix5f(); + value.SetZero(); + return value; + } + } + + public static Matrix5f Identity + { + get { + var value = Zero; + for (var i = 0; i < StateCount; i++) { + value[i, i] = 1f; + } + return value; + } + } + + public float this[int row, int column] + { + get { + Ensure(); + return _values[row * StateCount + column]; + } + set { + Ensure(); + _values[row * StateCount + column] = value; + } + } + + public void SetZero() + { + Ensure(); + for (var i = 0; i < StateCount * StateCount; i++) { + _values[i] = 0f; + } + } + + private void Ensure() + { + if (_values.Length != StateCount * StateCount) { + _values.Length = StateCount * StateCount; + } + } + } + + private static Matrix5f Transposed(Matrix5f m) + { + var t = Matrix5f.Zero; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + t[i, j] = m[j, i]; + } + } + return t; + } + + private static Matrix5f Add(Matrix5f a, Matrix5f b) + { + var r = Matrix5f.Zero; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + r[i, j] = a[i, j] + b[i, j]; + } + } + return r; + } + + private static Matrix5f Mul(Matrix5f a, Matrix5f b) + { + var r = Matrix5f.Zero; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + var sum = 0f; + for (var k = 0; k < StateCount; k++) { + sum += a[i, k] * b[k, j]; + } + r[i, j] = sum; + } + } + return r; + } + + private static Vector5f Mul(Matrix5f a, Vector5f v) + { + var r = Vector5f.Zero; + for (var i = 0; i < StateCount; i++) { + var sum = 0f; + for (var j = 0; j < StateCount; j++) { + sum += a[i, j] * v[j]; + } + r[i] = sum; + } + return r; + } + + private static Matrix5f OuterProduct(Vector5f a, Vector5f b, float scale) + { + var r = Matrix5f.Zero; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + r[i, j] = scale * a[i] * b[j]; + } + } + return r; + } + + private static float Dot(Vector5f a, Vector5f b) + { + var sum = 0f; + for (var i = 0; i < StateCount; i++) { + sum += a[i] * b[i]; + } + return sum; + } + + private static void Symmetrize(ref Matrix5f m) + { + for (var i = 0; i < StateCount; i++) { + for (var j = i + 1; j < StateCount; j++) { + var value = 0.5f * (m[i, j] + m[j, i]); + m[i, j] = value; + m[j, i] = value; + } + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs.meta new file mode 100644 index 000000000..bbdf90b66 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1911f9cd69a546d59ee28c47f626c787 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs new file mode 100644 index 000000000..e5a64aaf3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs @@ -0,0 +1,130 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public struct NudgeIntentState + { + private const int ImpulseLengthMs = 25; + private const float ImpulseThreshold = 1.0f; + + private byte _isGamepad; + private int _impulseElapsed; + private float2 _impulse; + private ulong _time; + private float _segmentStrength; + private ulong _segmentStart; + private ulong _segmentEnd; + private byte _segmentIsPeak; + private byte _segmentImpulseSent; + private float _lastImpulseStrength; + private ulong _lastImpulseTime; + + public NudgeIntentState(bool isGamepad) + { + _isGamepad = isGamepad ? (byte)1 : (byte)0; + _impulseElapsed = ImpulseLengthMs + 1; + _impulse = float2.zero; + _time = 0; + _segmentStrength = 0f; + _segmentStart = 0; + _segmentEnd = 0; + _segmentIsPeak = 0; + _segmentImpulseSent = 0; + _lastImpulseStrength = 0f; + _lastImpulseTime = 0; + } + + public bool IsImpulseInProgress => _impulseElapsed <= ImpulseLengthMs; + public float2 ImpulseAcceleration + { + get { + if (!IsImpulseInProgress) { + return float2.zero; + } + var t = (float)_impulseElapsed / ImpulseLengthMs; + return _impulse * 0.5f * (1f - math.cos(2f * math.PI * t)); + } + } + + public void StepOneMillisecond(float2 nudgeAcceleration) + { + _impulseElapsed++; + _time++; + + var nudge = new float2(nudgeAcceleration.x, math.min(nudgeAcceleration.y, 0f)); + var strength = math.length(nudge); + + if (_segmentIsPeak != 0) { + if (strength > _segmentStrength) { + _segmentStrength = strength; + _segmentEnd = _time; + if (_segmentImpulseSent == 0) { + EvaluateImpulse(nudge); + } else { + var newImpulse = GetImpulseStrengthFactor() * nudge; + if (math.lengthsq(newImpulse) > math.lengthsq(_impulse)) { + _impulse = newImpulse; + } + } + } else if (strength < _segmentStrength * 0.9f) { + _lastImpulseTime = _segmentEnd; + _lastImpulseStrength = _segmentStrength; + _segmentStrength = strength; + _segmentStart = _time; + _segmentEnd = _time; + _segmentIsPeak = 0; + } + } else { + if (strength < _segmentStrength) { + _segmentStrength = strength; + _segmentEnd = _time; + } else if (strength > math.max(0.1f, _segmentStrength * 1.1f)) { + _segmentStrength = strength; + _segmentStart = _time; + _segmentEnd = _time; + _segmentIsPeak = 1; + _segmentImpulseSent = 0; + EvaluateImpulse(nudge); + } + } + } + + private float GetImpulseStrengthFactor() + { + return 1f; + } + + private void EvaluateImpulse(float2 impulse) + { + var strengthFactor = GetImpulseStrengthFactor(); + var fireImpulse = strengthFactor * _segmentStrength > ImpulseThreshold; + if (_isGamepad == 0) { + fireImpulse &= _segmentStrength > _lastImpulseStrength || _segmentEnd - _lastImpulseTime > 300; + } + + if (!fireImpulse) { + return; + } + + _impulse = strengthFactor * impulse; + _impulseElapsed = 0; + _segmentImpulseSent = 1; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs.meta new file mode 100644 index 000000000..8b54d1627 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b7e00ddc2b114e9baa92364730dbeea4 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs new file mode 100644 index 000000000..537a63c44 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs @@ -0,0 +1,28 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + public enum NudgeSensorChannel + { + X = 0, + Y = 1, + VelocityX = 2, + VelocityY = 3, + AccelerationX = 4, + AccelerationY = 5 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs.meta new file mode 100644 index 000000000..edaf9bb6b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 739419cc7ec34eb484e55a23c46d499a +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs new file mode 100644 index 000000000..b6420f690 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs @@ -0,0 +1,430 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public struct NudgeSensorRuntimeConfig + { + public NudgeSensorType Type; + public float Strength; + public float CabinetMassKg; + public byte XMapped; + public byte YMapped; + public byte VelocityXMapped; + public byte VelocityYMapped; + public byte AccelerationXMapped; + public byte AccelerationYMapped; + } + + public struct NudgePhysicsSensorState + { + public byte Mapped; + public SensorMappingKind Kind; + public float Value; + public ulong TimestampUsec; + + public bool IsMapped => Mapped != 0; + + public void Configure(bool mapped, SensorMappingKind kind) + { + Mapped = mapped ? (byte)1 : (byte)0; + Kind = kind; + Value = 0f; + TimestampUsec = 0; + } + + public void SetValue(float value, ulong timestampUsec) + { + Value = value; + TimestampUsec = timestampUsec; + } + } + + public struct GamepadNudgeState + { + private const int DeactivationDelayMs = 10000; + + public NudgePhysicsSensorState XSensor; + public NudgePhysicsSensorState YSensor; + public NudgeIntentState Intent; + public CabinetPhysicsState Cabinet; + public float Strength; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + + private int _deactivationDelay; + + public GamepadNudgeState(float strength, bool xMapped, bool yMapped) + { + XSensor = default; + YSensor = default; + XSensor.Configure(xMapped, SensorMappingKind.Position); + YSensor.Configure(yMapped, SensorMappingKind.Position); + Intent = new NudgeIntentState(true); + Cabinet = CabinetPhysicsState.Default; + Strength = math.clamp(strength, 0f, 2f); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + _deactivationDelay = 0; + } + + public bool IsActive => _deactivationDelay > 0; + + public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) + { + switch (channel) { + case NudgeSensorChannel.X: + XSensor.SetValue(value, timestampUsec); + break; + case NudgeSensorChannel.Y: + YSensor.SetValue(value, timestampUsec); + break; + } + } + + public void StepOneMillisecond() + { + var x = XSensor.Value * Strength * 16f; + var y = YSensor.Value * Strength * 12f; + Intent.StepOneMillisecond(new float2(x, y)); + + if (Intent.IsImpulseInProgress) { + Cabinet.StepOneMillisecond(Cabinet.Mass * Intent.ImpulseAcceleration); + _deactivationDelay = DeactivationDelayMs; + } else { + Cabinet.StepOneMillisecond(float2.zero); + if (_deactivationDelay > 0) { + _deactivationDelay--; + } + } + + CabinetAcceleration = Cabinet.CabinetAcceleration; + CabinetOffset = Cabinet.CabinetOffset; + } + } + + public struct CabinetSensorState + { + private const int DeactivationDelayMs = 10000; + private const float GainConfidenceThreshold = 0.5f; + private const int CrossRestCountThreshold = 75; + private const int ForceRestCountThreshold = CrossRestCountThreshold + 200; + + private struct SyncedSensor + { + public NudgePhysicsSensorState Sensor; + public ulong LastTimestampUsec; + public long ClockDeltaUsec; + public int RestCount; + public byte ForceRest; + public float LastValue; + + public void Configure(bool mapped, SensorMappingKind kind) + { + Sensor.Configure(mapped, kind); + LastTimestampUsec = 0; + ClockDeltaUsec = 0; + RestCount = 0; + ForceRest = 0; + LastValue = 0f; + } + } + + public float Strength; + public float CabinetMassKg; + public MotionKalmanAxis KalmanX; + public MotionKalmanAxis KalmanY; + public MotionGainCalibratorAxis GainCalibratorX; + public MotionGainCalibratorAxis GainCalibratorY; + public CabinetPhysicsState Cabinet; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + public int DeactivationDelay => _deactivationDelay; + public bool IsIntentSensor => _intentEnabled != 0; + public bool IsActive => _deactivationDelay > 0; + public float2 KalmanAcceleration => new(KalmanX.Acceleration, KalmanY.Acceleration); + public float2 Gain => new(GainCalibratorX.Gain, GainCalibratorY.Gain); + public float2 GainConfidence => new(GainCalibratorX.GlobalConfidence, GainCalibratorY.GlobalConfidence); + + private SyncedSensor _xVelSensor; + private SyncedSensor _yVelSensor; + private SyncedSensor _xAccSensor; + private SyncedSensor _yAccSensor; + private EmaState _emaX; + private EmaState _emaY; + private ulong _timeUs; + private NudgeIntentState _intent; + private byte _intentEnabled; + private int _deactivationDelay; + + public CabinetSensorState(NudgeSensorType type, float strength, float cabinetMassKg, + bool velXMapped, bool velYMapped, bool accXMapped, bool accYMapped) + { + Strength = math.clamp(strength, 0f, 2f); + CabinetMassKg = math.clamp(cabinetMassKg, 0f, 200f); + KalmanX = MotionKalmanAxis.Default; + KalmanY = MotionKalmanAxis.Default; + GainCalibratorX = MotionGainCalibratorAxis.Default; + GainCalibratorY = MotionGainCalibratorAxis.Default; + Cabinet = CabinetPhysicsState.Default; + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + _xVelSensor = default; + _yVelSensor = default; + _xAccSensor = default; + _yAccSensor = default; + _xVelSensor.Configure(velXMapped, SensorMappingKind.Velocity); + _yVelSensor.Configure(velYMapped, SensorMappingKind.Velocity); + _xAccSensor.Configure(accXMapped, SensorMappingKind.Acceleration); + _yAccSensor.Configure(accYMapped, SensorMappingKind.Acceleration); + _emaX = new EmaState(0.004f); + _emaY = new EmaState(0.004f); + _timeUs = 0; + _intent = new NudgeIntentState(false); + _intentEnabled = type == NudgeSensorType.CabinetIntent ? (byte)1 : (byte)0; + _deactivationDelay = 0; + } + + public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) + { + switch (channel) { + case NudgeSensorChannel.VelocityX: + _xVelSensor.Sensor.SetValue(value, timestampUsec); + break; + case NudgeSensorChannel.VelocityY: + _yVelSensor.Sensor.SetValue(value, timestampUsec); + break; + case NudgeSensorChannel.AccelerationX: + _xAccSensor.Sensor.SetValue(value, timestampUsec); + break; + case NudgeSensorChannel.AccelerationY: + _yAccSensor.Sensor.SetValue(value, timestampUsec); + break; + } + } + + public void StepOneMillisecond() + { + if (_deactivationDelay > 0) { + _deactivationDelay--; + } + + _timeUs += 1000; + UpdateAxis(ref _xVelSensor, ref _xAccSensor, ref KalmanX, ref GainCalibratorX); + UpdateAxis(ref _yVelSensor, ref _yAccSensor, ref KalmanY, ref GainCalibratorY); + + if (_intentEnabled != 0) { + _intent.StepOneMillisecond(new float2(KalmanX.Acceleration * (4f / 3f), KalmanY.Acceleration)); + if (_intent.IsImpulseInProgress) { + Cabinet.StepOneMillisecond(Cabinet.Mass * _intent.ImpulseAcceleration); + _deactivationDelay = DeactivationDelayMs; + } else { + Cabinet.StepOneMillisecond(float2.zero); + } + CabinetAcceleration = Cabinet.CabinetAcceleration; + } else { + CabinetAcceleration.x = _emaX.Update(KalmanX.Acceleration, 0.001f) * Strength; + CabinetAcceleration.y = _emaY.Update(KalmanY.Acceleration, 0.001f) * Strength; + CabinetAcceleration *= Strength * CabinetMassKg / Cabinet.Mass; + Cabinet.StepOneMillisecond(Cabinet.Mass * CabinetAcceleration); + } + CabinetOffset = Cabinet.CabinetOffset; + } + + private void UpdateAxis(ref SyncedSensor velSensor, ref SyncedSensor accSensor, ref MotionKalmanAxis kalmanFilter, + ref MotionGainCalibratorAxis gainCalibrator) + { + var isAccAndVelMapped = accSensor.Sensor.IsMapped && velSensor.Sensor.IsMapped; + if (isAccAndVelMapped) { + if (gainCalibrator.GlobalConfidence < GainConfidenceThreshold || gainCalibrator.Gain < 0.01f) { + UpdateAxisSensor(ref accSensor, ref kalmanFilter, 1f); + velSensor.RestCount = accSensor.RestCount; + } else if (accSensor.Sensor.TimestampUsec < velSensor.Sensor.TimestampUsec) { + UpdateAxisSensor(ref accSensor, ref kalmanFilter, 1f); + UpdateAxisSensor(ref velSensor, ref kalmanFilter, 1f / gainCalibrator.Gain); + } else { + UpdateAxisSensor(ref velSensor, ref kalmanFilter, 1f / gainCalibrator.Gain); + UpdateAxisSensor(ref accSensor, ref kalmanFilter, 1f); + } + } else { + UpdateAxisSensor(ref accSensor, ref kalmanFilter, 1f); + UpdateAxisSensor(ref velSensor, ref kalmanFilter, 1f); + } + + accSensor.ForceRest = (byte)(accSensor.ForceRest != 0 + || (accSensor.RestCount > CrossRestCountThreshold && accSensor.LastValue * accSensor.Sensor.Value < 0f) ? 1 : 0); + velSensor.ForceRest = (byte)(velSensor.ForceRest != 0 + || (velSensor.RestCount > CrossRestCountThreshold && velSensor.LastValue * velSensor.Sensor.Value < 0f) ? 1 : 0); + + var isRest = (accSensor.ForceRest != 0 || accSensor.RestCount > ForceRestCountThreshold) + && (velSensor.ForceRest != 0 || velSensor.RestCount > ForceRestCountThreshold); + if (isRest) { + velSensor.ForceRest = 1; + accSensor.ForceRest = 1; + kalmanFilter.UpdateRestConstraints(_timeUs); + } + + accSensor.LastValue = accSensor.Sensor.Value; + velSensor.LastValue = velSensor.Sensor.Value; + + if (isAccAndVelMapped) { + if (!isRest) { + if (!gainCalibrator.IsSegmentActive) { + gainCalibrator.StartSegment(_timeUs); + } + gainCalibrator.AddSample(_timeUs, velSensor.Sensor.Value, accSensor.Sensor.Value); + } else if (gainCalibrator.IsSegmentActive) { + gainCalibrator.EndSegment(); + } + } + + kalmanFilter.PredictTo(_timeUs); + } + + private void UpdateAxisSensor(ref SyncedSensor sensor, ref MotionKalmanAxis axis, float axisGain) + { + if (!sensor.Sensor.IsMapped) { + sensor.RestCount = DeactivationDelayMs; + return; + } + + var restThreshold = sensor.Sensor.Kind == SensorMappingKind.Acceleration ? 0.020f : 0.002f; + if (math.abs(sensor.Sensor.Value) < restThreshold) { + sensor.RestCount++; + if (sensor.ForceRest != 0) { + return; + } + } else { + sensor.RestCount = 0; + sensor.ForceRest = 0; + _deactivationDelay = DeactivationDelayMs; + } + + var timestampUsec = sensor.Sensor.TimestampUsec; + if (timestampUsec == 0 || timestampUsec == sensor.LastTimestampUsec) { + return; + } + + sensor.LastTimestampUsec = timestampUsec; + var alignedTimestampUsec = (long)timestampUsec + sensor.ClockDeltaUsec; + if (alignedTimestampUsec > (long)_timeUs) { + sensor.ClockDeltaUsec = (long)_timeUs - (long)timestampUsec; + alignedTimestampUsec = (long)_timeUs; + } + if (alignedTimestampUsec < 0) { + alignedTimestampUsec = 0; + } + + if (sensor.Sensor.Kind == SensorMappingKind.Velocity) { + axis.UpdateVelocity((ulong)alignedTimestampUsec, axisGain * sensor.Sensor.Value); + } else if (sensor.Sensor.Kind == SensorMappingKind.Acceleration) { + axis.UpdateAcceleration((ulong)alignedTimestampUsec, axisGain * sensor.Sensor.Value); + } + } + + private struct EmaState + { + private float _tau; + private float _value; + private byte _initialized; + + public EmaState(float tau) + { + _tau = math.max(1.0e-6f, tau); + _value = 0f; + _initialized = 0; + } + + public float Update(float sample, float dt) + { + if (_initialized == 0) { + _value = sample; + _initialized = 1; + return _value; + } + + var alpha = 1f - math.exp(-dt / _tau); + _value += alpha * (sample - _value); + return _value; + } + } + } + + public struct NudgeSensorState + { + public NudgeSensorType Type; + public byte Enabled; + public GamepadNudgeState Gamepad; + public CabinetSensorState Cabinet; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + + public bool IsEnabled => Enabled != 0; + public bool IsActive => IsEnabled && (Type == NudgeSensorType.GamepadIntent ? Gamepad.IsActive : Cabinet.IsActive); + + public NudgeSensorState(NudgeSensorRuntimeConfig config) + { + Type = config.Type; + Enabled = 1; + Gamepad = new GamepadNudgeState(config.Strength, config.XMapped != 0, config.YMapped != 0); + Cabinet = new CabinetSensorState(config.Type, config.Strength, config.CabinetMassKg, + config.VelocityXMapped != 0, config.VelocityYMapped != 0, + config.AccelerationXMapped != 0, config.AccelerationYMapped != 0); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + } + + public void Disable() + { + Enabled = 0; + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + } + + public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) + { + if (!IsEnabled) { + return; + } + if (Type == NudgeSensorType.GamepadIntent) { + Gamepad.ApplySample(channel, value, timestampUsec); + } else { + Cabinet.ApplySample(channel, value, timestampUsec); + } + } + + public void StepOneMillisecond() + { + if (!IsEnabled) { + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + return; + } + + if (Type == NudgeSensorType.GamepadIntent) { + Gamepad.StepOneMillisecond(); + CabinetAcceleration = Gamepad.CabinetAcceleration; + CabinetOffset = Gamepad.CabinetOffset; + } else { + Cabinet.StepOneMillisecond(); + CabinetAcceleration = Cabinet.CabinetAcceleration; + CabinetOffset = Cabinet.CabinetOffset; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs.meta new file mode 100644 index 000000000..747eae074 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f2833467f332487f8aabebeeb8e770fe +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs new file mode 100644 index 000000000..e10b6303e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs @@ -0,0 +1,25 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + public enum NudgeSensorType + { + GamepadIntent = 0, + CabinetIntent = 1, + CabinetDirect = 2 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs.meta new file mode 100644 index 000000000..daaee2a24 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: dc101d22416a4391b59fcfde46ad5b65 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs index ff46da826..9ee1747b7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs @@ -30,9 +30,33 @@ public KeyboardNudgeCommand(float angleDeg, float force) } } + internal readonly struct NudgeSensorSampleCommand + { + public readonly int SensorIndex; + public readonly NudgeSensorChannel Channel; + public readonly float Value; + public readonly ulong TimestampUsec; + + public NudgeSensorSampleCommand(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) + { + SensorIndex = sensorIndex; + Channel = channel; + Value = value; + TimestampUsec = timestampUsec; + } + } + public struct NudgeState { + public const int MaxSensors = 4; + public KeyboardNudgeState Keyboard; + public NudgeSensorState Sensor0; + public NudgeSensorState Sensor1; + public NudgeSensorState Sensor2; + public NudgeSensorState Sensor3; + public int SensorCount; + public int ActiveSourceIndex; public float2 CabinetAcceleration; public float2 CabinetOffset; public float2 MaxCabinetAcceleration; @@ -45,6 +69,12 @@ public NudgeState(KeyboardNudgeMode keyboardMode, float keyboardStrength, float CabinetOffset = float2.zero; MaxCabinetAcceleration = float2.zero; KeyboardNudgeIndex = 0; + Sensor0 = default; + Sensor1 = default; + Sensor2 = default; + Sensor3 = default; + SensorCount = 0; + ActiveSourceIndex = -2; } public void ApplyKeyboardImpulse(float angleDeg, float force) @@ -53,16 +83,57 @@ public void ApplyKeyboardImpulse(float angleDeg, float force) Keyboard.Nudge(angleDeg, force); } + public void ConfigureSensors(int count) + { + SensorCount = math.clamp(count, 0, MaxSensors); + for (var i = SensorCount; i < MaxSensors; i++) { + DisableSensor(i); + } + } + + public void ConfigureSensor(int index, NudgeSensorRuntimeConfig config) + { + if ((uint)index >= MaxSensors) { + return; + } + config.Strength = math.clamp(config.Strength, 0f, 2f); + config.CabinetMassKg = math.clamp(config.CabinetMassKg <= 0f ? 113f : config.CabinetMassKg, 0f, 200f); + SetSensor(index, new NudgeSensorState(config)); + if (index >= SensorCount) { + SensorCount = index + 1; + } + } + + public void ApplySensorSample(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) + { + if ((uint)sensorIndex >= SensorCount) { + return; + } + var sensor = GetSensor(sensorIndex); + sensor.ApplySample(channel, value, timestampUsec); + SetSensor(sensorIndex, sensor); + } + public void StepOneMillisecond() { Keyboard.StepOneMillisecond(); + StepSensor(0); + StepSensor(1); + StepSensor(2); + StepSensor(3); if (Keyboard.IsActive) { CabinetAcceleration = Keyboard.CabinetAcceleration; CabinetOffset = Keyboard.CabinetOffset; + ActiveSourceIndex = -1; + } else if (TryGetActiveSensor(out var activeSensorIndex, out var activeSensor)) { + CabinetAcceleration = activeSensor.CabinetAcceleration; + CabinetOffset = activeSensor.CabinetOffset; + ActiveSourceIndex = activeSensorIndex; } else { CabinetAcceleration = float2.zero; CabinetOffset = float2.zero; + ActiveSourceIndex = -2; } if (math.abs(CabinetAcceleration.x) > math.abs(MaxCabinetAcceleration.x)) { @@ -79,5 +150,67 @@ public float2 ReadAndResetMaxCabinetAcceleration() MaxCabinetAcceleration = float2.zero; return value; } + + private void StepSensor(int index) + { + if (index >= SensorCount) { + return; + } + var sensor = GetSensor(index); + sensor.StepOneMillisecond(); + SetSensor(index, sensor); + } + + private bool TryGetActiveSensor(out int index, out NudgeSensorState sensor) + { + for (var i = 0; i < SensorCount; i++) { + var candidate = GetSensor(i); + if (!candidate.IsActive) { + continue; + } + index = i; + sensor = candidate; + return true; + } + index = -2; + sensor = default; + return false; + } + + private void DisableSensor(int index) + { + var sensor = GetSensor(index); + sensor.Disable(); + SetSensor(index, sensor); + } + + private NudgeSensorState GetSensor(int index) + { + return index switch { + 0 => Sensor0, + 1 => Sensor1, + 2 => Sensor2, + 3 => Sensor3, + _ => default + }; + } + + private void SetSensor(int index, NudgeSensorState sensor) + { + switch (index) { + case 0: + Sensor0 = sensor; + break; + case 1: + Sensor1 = sensor; + break; + case 2: + Sensor2 = sensor; + break; + case 3: + Sensor3 = sensor; + break; + } + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index cf186e56c..73a168363 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -197,6 +197,7 @@ public void StartSimulation() if (_inputManager.Initialize()) { _inputManager.SetSimulationThread(_simulationThread); + _physicsEngine.AttachNativeInputManager(_inputManager); _inputManager.StartPolling(InputPollingIntervalUs); } else @@ -226,6 +227,7 @@ public void StopSimulation() { if (!_started) return; + _physicsEngine?.DetachNativeInputManager(_inputManager); _inputManager?.StopPolling(); _inputManager?.Dispose(); _inputManager = null; From 7cd5b290c9aea6dbd8fa92876f0f00dc8904f129 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 16:35:53 +0200 Subject: [PATCH 06/25] physics: expose nudge telemetry --- .../Game/NudgeTelemetry.cs | 43 +++++++++++++++++++ .../Game/NudgeTelemetry.cs.meta | 3 ++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 27 ++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs new file mode 100644 index 000000000..f5054e649 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs @@ -0,0 +1,43 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public readonly struct NudgeTelemetry + { + public readonly float2 CabinetAcceleration; + public readonly float2 CabinetOffset; + public readonly int ActiveSourceIndex; + public readonly float3 PlumbPosition; + public readonly float PlumbTiltPercent; + public readonly int PlumbTiltIndex; + public readonly bool PlumbTiltHigh; + + public NudgeTelemetry(float2 cabinetAcceleration, float2 cabinetOffset, int activeSourceIndex, + float3 plumbPosition, float plumbTiltPercent, int plumbTiltIndex, bool plumbTiltHigh) + { + CabinetAcceleration = cabinetAcceleration; + CabinetOffset = cabinetOffset; + ActiveSourceIndex = activeSourceIndex; + PlumbPosition = plumbPosition; + PlumbTiltPercent = plumbTiltPercent; + PlumbTiltIndex = plumbTiltIndex; + PlumbTiltHigh = plumbTiltHigh; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs.meta new file mode 100644 index 000000000..0dcf27ccb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8f9e01248a024825b7f07b7a6701e5b0 +timeCreated: 1783190006 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 8245803d9..e89d60cc0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -396,6 +396,33 @@ public void NudgeTiltStatus(out float plumbX, out float plumbY, out float tiltPe } } + public NudgeTelemetry GetNudgeTelemetry() + { + if (_ctx == null || !_ctx.IsInitialized) { + return default; + } + + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + var plumb = _ctx.PhysicsEnv.Plumb; + var threshold = plumb.TiltThresholdRad; + var tiltPercent = 0f; + if (threshold > 0f && plumb.PoleLength > 0f) { + var psi = math.atan2(math.sqrt(plumb.Position.x * plumb.Position.x + plumb.Position.y * plumb.Position.y), -plumb.Position.z); + tiltPercent = 100f * psi / threshold; + } + return new NudgeTelemetry( + nudge.CabinetAcceleration, + nudge.CabinetOffset, + nudge.ActiveSourceIndex, + plumb.Position, + tiltPercent, + plumb.TiltIndex, + plumb.TiltHigh + ); + } + } + internal void DrainPlumbTiltEvents(List destination) { lock (_ctx.PhysicsLock) { From 3052bd2464c852bf7ef7b85f40eec4053e8e9b25 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 16:41:42 +0200 Subject: [PATCH 07/25] physics: add visual nudge offset --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 35 +++++ .../Game/PhysicsEnginePackable.cs | 5 + .../Game/PhysicsEngineThreading.cs | 3 + .../Game/VisualNudgeComponent.cs | 127 ++++++++++++++++++ .../Game/VisualNudgeComponent.cs.meta | 3 + 5 files changed, 173 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index e89d60cc0..c47124474 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -134,6 +134,10 @@ public class PhysicsEngine : MonoBehaviour, IPackable [Range(0.5f, 4f)] public float PlumbThresholdAngle = 2f; + [Tooltip("Render-only visual nudge strength scale.")] + [Range(0f, 2f)] + public float VisualNudgeStrength = 1f; + #endregion #region Packaging @@ -171,6 +175,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac // to _threading constructor). [NonSerialized] private Player _player; [NonSerialized] private NudgeSystem _nudgeSystem; + [NonSerialized] private VisualNudgeComponent _visualNudge; [NonSerialized] private ICollidableComponent[] _colliderComponents; [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents; [NonSerialized] private float4x4 _worldToPlayfield; @@ -333,6 +338,27 @@ public void ConfigurePlumb(bool simulatedPlumb, float damping, float thresholdAn } } + public void ConfigureVisualNudge(float strength) + { + VisualNudgeStrength = math.clamp(strength, 0f, 2f); + if (!Application.isPlaying) { + return; + } + EnsureVisualNudge(); + _visualNudge?.Configure(this, VisualNudgeStrength); + } + + private void EnsureVisualNudge() + { + if (_visualNudge != null) { + return; + } + _visualNudge = GetComponent(); + if (_visualNudge == null) { + _visualNudge = gameObject.AddComponent(); + } + } + public void ConfigureNudgeSensors(IReadOnlyList sensors) { _nudgeSystem?.ConfigureSensors(sensors); @@ -423,6 +449,14 @@ public NudgeTelemetry GetNudgeTelemetry() } } + internal void ApplyVisualNudge(float2 cabinetOffset) + { + if (_visualNudge == null) { + return; + } + _visualNudge.SetCabinetOffset(cabinetOffset); + } + internal void DrainPlumbTiltEvents(List destination) { lock (_ctx.PhysicsLock) { @@ -768,6 +802,7 @@ private void Awake() _mainThreadManagedThreadId = Thread.CurrentThread.ManagedThreadId; _player = GetComponentInParent(); _nudgeSystem = new NudgeSystem(this); + ConfigureVisualNudge(VisualNudgeStrength); _ctx.InsideOfs = new InsideOfs(Allocator.Persistent); var table = GetComponentInParent(); _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength, diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs index 81c255d10..5e1e997be 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs @@ -26,6 +26,8 @@ public struct PhysicsEnginePackable public bool SimulatedPlumb; public float PlumbDamping; public float PlumbThresholdAngle; + public bool HasVisualNudgeSettings; + public float VisualNudgeStrength; public static byte[] Pack(PhysicsEngine comp) { @@ -38,6 +40,8 @@ public static byte[] Pack(PhysicsEngine comp) SimulatedPlumb = comp.SimulatedPlumb, PlumbDamping = comp.PlumbDamping, PlumbThresholdAngle = comp.PlumbThresholdAngle, + HasVisualNudgeSettings = true, + VisualNudgeStrength = comp.VisualNudgeStrength, }); } @@ -54,6 +58,7 @@ public static void Unpack(byte[] bytes, PhysicsEngine comp) data.HasPlumbSettings ? data.PlumbDamping : 1f, data.HasPlumbSettings ? data.PlumbThresholdAngle : 2f ); + comp.ConfigureVisualNudge(data.HasVisualNudgeSettings ? data.VisualNudgeStrength : 1f); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index f3867fe2e..abc550ef3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -673,6 +673,8 @@ private void ApplyMovementsFromSnapshot(in SimulationState.Snapshot snapshot) emitter.UpdateAnimationValue(anim.Value); } } + + _physicsEngine.ApplyVisualNudge(snapshot.NudgeCabinetOffset); } /// @@ -881,6 +883,7 @@ private void ApplyAllMovements(ref PhysicsState state) _physicsMovements.ApplyPlungerMovement(ref _ctx.PlungerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplySpinnerMovement(ref _ctx.SpinnerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplyTriggerMovement(ref _ctx.TriggerStates.Ref, _ctx.FloatAnimatedComponents); + _physicsEngine.ApplyVisualNudge(_ctx.PhysicsEnv.Nudge.CabinetOffset); } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs new file mode 100644 index 000000000..261f95f87 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs @@ -0,0 +1,127 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + [DefaultExecutionOrder(10000)] + public sealed class VisualNudgeComponent : MonoBehaviour + { + [NonSerialized] private readonly Dictionary _appliedOffsets = new(); + [NonSerialized] private readonly List _staleCameras = new(); + [NonSerialized] private PhysicsEngine _physicsEngine; + [NonSerialized] private PlayfieldComponent _playfield; + [NonSerialized] private float2 _cabinetOffset; + [NonSerialized] private float _strength = 1f; + + internal void Configure(PhysicsEngine physicsEngine, float strength) + { + _physicsEngine = physicsEngine; + _strength = math.clamp(strength, 0f, 2f); + if (_playfield == null && _physicsEngine != null) { + _playfield = _physicsEngine.GetComponentInParent()?.GetComponentInChildren(); + } + } + + internal void SetCabinetOffset(float2 cabinetOffset) + { + _cabinetOffset = cabinetOffset; + } + + private void LateUpdate() + { + var desiredOffset = -WorldCabinetOffset() * _strength; + ApplyToGameCameras(desiredOffset); + } + + private void OnDisable() => RestoreAll(); + + private void OnDestroy() => RestoreAll(); + + private Vector3 WorldCabinetOffset() + { + var localOffset = new Vector3(_cabinetOffset.x, 0f, -_cabinetOffset.y); + return _playfield != null ? _playfield.transform.TransformVector(localOffset) : localOffset; + } + + private void ApplyToGameCameras(Vector3 desiredOffset) + { + _staleCameras.Clear(); + foreach (var camera in _appliedOffsets.Keys) { + _staleCameras.Add(camera); + } + + foreach (var camera in Camera.allCameras) { + if (!ShouldOffset(camera)) { + continue; + } + _staleCameras.Remove(camera); + ApplyToCamera(camera, desiredOffset); + } + + foreach (var camera in _staleCameras) { + RestoreCamera(camera); + } + _staleCameras.Clear(); + } + + private static bool ShouldOffset(Camera camera) + { + return camera != null + && camera.enabled + && camera.cameraType == CameraType.Game; + } + + private void ApplyToCamera(Camera camera, Vector3 desiredOffset) + { + if (_appliedOffsets.TryGetValue(camera, out var previousOffset)) { + camera.transform.position -= previousOffset; + } + + if (desiredOffset.sqrMagnitude < 1e-12f) { + _appliedOffsets.Remove(camera); + return; + } + + camera.transform.position += desiredOffset; + _appliedOffsets[camera] = desiredOffset; + } + + private void RestoreCamera(Camera camera) + { + if (camera != null && _appliedOffsets.TryGetValue(camera, out var offset)) { + camera.transform.position -= offset; + } + _appliedOffsets.Remove(camera); + } + + private void RestoreAll() + { + _staleCameras.Clear(); + foreach (var camera in _appliedOffsets.Keys) { + _staleCameras.Add(camera); + } + foreach (var camera in _staleCameras) { + RestoreCamera(camera); + } + _staleCameras.Clear(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs.meta new file mode 100644 index 000000000..4a3346cab --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1ac7ec530f7f45daa9a695d7b7c2ff1d +timeCreated: 1783190007 From d8dafffe0be9162c270ae6b14639fed6eea7b5d2 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 19:37:54 +0200 Subject: [PATCH 08/25] physics: fix nudge review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Push keyboard nudge and plumb settings into the physics env at Start: config applied between Awake and Start (the player app does this on table load) previously only reached the component fields and was lost. - Resolve device ids for axis events from an immutable snapshot owned by NativeInputManager, rebuilt when polling starts; the input callback no longer re-enumerates devices or mutates shared dictionaries off the main thread. - Fix default-keyboard-nudge suppression on the Unity input path: the nudge counter is now snapshotted before each input-system update and evaluated after all callbacks ran, instead of comparing against a stale index from the previous key press. - Defer the suppression check behind the queued switch dispatch for main-thread gamelogic engines, so a graph-driven Nudge() suppresses the default nudge instead of doubling it. - Apply sensor strength once in direct cabinet mode (the reference multiplies it twice, scaling with strength squared — upstream bug). - Compact gain-calibrator samples in place when the buffer fills instead of splitting the segment, keeping the rest-to-rest assumption intact for motion longer than 255 ms. - Preserve plumb state on reconfiguration and emit a release edge when disabling while tilted, so the tilt switch cannot stay stuck closed. - Route plumb tilt through the main thread in simulation-thread mode so switch status and key wires update like in single-threaded mode. - Decode native device names as UTF-8, parse sensor mappings with semicolons in the device id, always clamp to the mapping limit, and cap the pending nudge queues with drop warnings. - Cover strength linearity, single-impulse intent, calibrator compaction and mapping round-trips in tests. --- .../Input/SensorMappingTests.cs | 17 +++++ .../Physics/NudgeSensorStateTests.cs | 75 +++++++++++++++++++ .../VisualPinball.Unity/Game/NudgeSystem.cs | 43 ++--------- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 27 +++++++ .../VisualPinball.Unity/Game/Player.cs | 33 ++++++-- .../Input/SensorMapping.cs | 26 ++++--- .../Cabinet/MotionGainCalibratorAxis.cs | 27 ++++++- .../Physics/Cabinet/NudgeSensorState.cs | 8 +- .../Physics/Cabinet/PlumbState.cs | 17 ++++- .../Simulation/GamelogicInputDispatchers.cs | 17 ++++- .../Simulation/NativeInputApi.cs | 36 +++++++-- .../Simulation/NativeInputManager.cs | 41 ++++++++-- .../Simulation/SimulationThread.cs | 66 ++++++++++++++-- .../Simulation/SimulationThreadComponent.cs | 4 + 14 files changed, 358 insertions(+), 79 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs index fa133770a..998f8904a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs @@ -41,6 +41,23 @@ public void MappingRoundTripsUsingVpinballFormat() Assert.That(parsed.Limit, Is.EqualTo(mapping.Limit).Within(0.0001f)); } + [Test] + public void MappingRoundTripsWithSemicolonInDeviceId() + { + var mapping = new SensorMapping { + DeviceId = @"\\?\hid#vid_1209&pid_eaea;col02#8&2f&0&1", + AxisId = 0, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0f, + Scale = 9.81f, + Limit = 1f + }; + + Assert.That(SensorMapping.TryParse(mapping.ToString(), out var parsed), Is.True); + Assert.That(parsed.DeviceId, Is.EqualTo(mapping.DeviceId)); + Assert.That(parsed.AxisId, Is.EqualTo(mapping.AxisId)); + } + [Test] public void ProcessingAppliesDeadZoneLimitAndScale() { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs index cafceb72e..9406629d1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs @@ -74,6 +74,49 @@ public void IntentHandlerFiresSingleImpulseForPeak() Assert.That(intent.IsImpulseInProgress, Is.True); intent.StepOneMillisecond(new float2(0f, -1.4f)); Assert.That(intent.ImpulseAcceleration.y, Is.LessThan(0f)); + + // Ride out the rest of the peak and its decay: no second impulse may + // fire for the same peak (25ms impulse length, then the signal decays). + var impulseTicks = 0; + for (var i = 0; i < 200; i++) { + var signal = -1.4f * math.exp(-i / 30f); + intent.StepOneMillisecond(new float2(0f, signal)); + if (intent.IsImpulseInProgress) { + impulseTicks++; + } + } + Assert.That(impulseTicks, Is.LessThanOrEqualTo(25)); + } + + [Test] + public void GainCalibratorSurvivesLongSegmentsByCompacting() + { + var calibrator = MotionGainCalibratorAxis.Default; + const float expectedGain = 2.0f; + ulong baseTime = 0; + + // 1.5s segments at 1kHz — way past the sample buffer capacity, forcing + // resolution compaction. The segment must stay whole (rest-to-rest), so + // the gain still converges and no segment splitting inflates confidence. + for (var segment = 0; segment < 10; segment++) { + calibrator.StartSegment(baseTime); + var velocity = 0f; + var previousAcceleration = 0f; + for (var i = 0; i <= 1500; i++) { + var t = i / 1500f; + var acceleration = math.sin(2f * math.PI * 9f * t) * math.sin(math.PI * t); + if (i > 0) { + velocity += 0.5f * (previousAcceleration + acceleration) * 0.001f; + } + calibrator.AddSample(baseTime + (ulong)(i * 1000), expectedGain * velocity, acceleration); + previousAcceleration = acceleration; + } + calibrator.EndSegment(); + baseTime += 2000000; + } + + Assert.That(calibrator.AcceptedSegmentCount, Is.LessThanOrEqualTo(10)); + Assert.That(calibrator.Gain, Is.EqualTo(expectedGain).Within(0.15f)); } [Test] @@ -98,5 +141,37 @@ public void DirectCabinetSensorCanDriveNudgeState() Assert.That(nudge.CabinetAcceleration.y, Is.LessThan(0f)); Assert.That(math.abs(nudge.CabinetOffset.y), Is.GreaterThan(0f)); } + + [Test] + public void DirectCabinetSensorStrengthScalesLinearly() + { + // The reference applies the strength scale twice (upstream bug); VPE + // deliberately applies it once, so halving strength halves the output. + float RunWithStrength(float strength) + { + var nudge = new NudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.ConfigureSensor(0, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = strength, + CabinetMassKg = 113f, + AccelerationYMapped = 1 + }); + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, 0f, 1000); + nudge.StepOneMillisecond(); + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, -12f, 2000); + var peak = 0f; + for (var i = 0; i < 20; i++) { + nudge.StepOneMillisecond(); + peak = math.min(peak, nudge.CabinetAcceleration.y); + } + return peak; + } + + var fullStrength = RunWithStrength(1f); + var halfStrength = RunWithStrength(0.5f); + + Assert.That(fullStrength, Is.LessThan(0f)); + Assert.That(halfStrength, Is.EqualTo(fullStrength * 0.5f).Within(math.abs(fullStrength) * 0.02f)); + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs index 20b1bb06e..1d7c6b859 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs @@ -67,8 +67,7 @@ public sealed class NudgeSystem : IDisposable private readonly PhysicsEngine _physicsEngine; private readonly object _configLock = new(); private readonly List _sensors = new(NudgeState.MaxSensors); - private readonly Dictionary _deviceIdsByIndex = new(); - private NativeInputManager _inputManager; + private volatile NativeInputManager _inputManager; internal NudgeSystem(PhysicsEngine physicsEngine) { @@ -86,12 +85,8 @@ public int SensorCount public IReadOnlyList ListDevices() { - if (_inputManager == null) { - return Array.Empty(); - } - var devices = _inputManager.ListDevices(); - CacheDeviceIds(devices); - return devices; + var inputManager = _inputManager; + return inputManager == null ? Array.Empty() : inputManager.ListDevices(); } public void ConfigureSensors(IReadOnlyList sensors) @@ -111,7 +106,6 @@ public void ConfigureSensors(IReadOnlyList sensors) _physicsEngine.ConfigureNudgeSensor(i, _sensors[i].ToRuntimeConfig()); } } - RefreshDevices(); } internal void AttachNativeInputManager(NativeInputManager inputManager) @@ -123,7 +117,6 @@ internal void AttachNativeInputManager(NativeInputManager inputManager) _inputManager = inputManager; if (_inputManager != null) { _inputManager.AxisInputReceived += OnAxisInputReceived; - RefreshDevices(); } } @@ -133,7 +126,6 @@ internal void DetachNativeInputManager() _inputManager.AxisInputReceived -= OnAxisInputReceived; _inputManager = null; } - _deviceIdsByIndex.Clear(); } public void Dispose() @@ -141,35 +133,16 @@ public void Dispose() DetachNativeInputManager(); } - private void RefreshDevices() - { - if (_inputManager == null) { - return; - } - CacheDeviceIds(_inputManager.ListDevices()); - } - - private void CacheDeviceIds(IReadOnlyList devices) - { - _deviceIdsByIndex.Clear(); - if (devices == null) { - return; - } - for (var i = 0; i < devices.Count; i++) { - _deviceIdsByIndex[devices[i].DeviceIndex] = devices[i].Id; - } - } - + // Called on the native input polling thread: only lock-free reads of the + // device-id snapshot here, never device (re-)enumeration. private void OnAxisInputReceived(NativeInputApi.InputEvent evt) { if (evt.EventType != (int)NativeInputApi.InputEventType.Axis) { return; } - if (!_deviceIdsByIndex.TryGetValue(evt.DeviceIndex, out var deviceId)) { - RefreshDevices(); - if (!_deviceIdsByIndex.TryGetValue(evt.DeviceIndex, out deviceId)) { - return; - } + var inputManager = _inputManager; + if (inputManager == null || !inputManager.TryGetDeviceId(evt.DeviceIndex, out var deviceId)) { + return; } lock (_configLock) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index c47124474..d17e5e686 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -186,8 +186,12 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [NonSerialized] private readonly HashSet _unsafeLiveStateAccessWarnings = new HashSet(); [NonSerialized] private bool _inputActionsQueueWarningIssued; [NonSerialized] private bool _scheduledActionsQueueWarningIssued; + [NonSerialized] private bool _pendingNudgeQueueWarningIssued; + [NonSerialized] private bool _pendingSensorSampleQueueWarningIssued; private const int InputActionsQueueWarningThreshold = 256; + private const int PendingNudgeQueueCap = 64; + private const int PendingSensorSampleQueueCap = 8192; // ~5s of a 4-channel sensor at full USB rate private const int ScheduledActionsWarningThreshold = 256; #endregion @@ -299,6 +303,13 @@ public void Nudge(float angleDeg, float force) { Interlocked.Increment(ref _keyboardNudgeIndex); lock (_ctx.PendingKeyboardNudgesLock) { + if (_ctx.PendingKeyboardNudges.Count >= PendingNudgeQueueCap) { + if (!_pendingNudgeQueueWarningIssued) { + _pendingNudgeQueueWarningIssued = true; + LogQueueWarning($"[PhysicsEngine] PendingKeyboardNudges backlog reached {_ctx.PendingKeyboardNudges.Count} items, dropping. Is the drain stalled?"); + } + return; + } _ctx.PendingKeyboardNudges.Enqueue(new KeyboardNudgeCommand(angleDeg, force)); } } @@ -385,6 +396,15 @@ internal void ConfigureNudgeSensor(int index, NudgeSensorRuntimeConfig config) internal void EnqueueNudgeSensorSample(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) { lock (_ctx.PendingNudgeSensorSamplesLock) { + if (_ctx.PendingNudgeSensorSamples.Count >= PendingSensorSampleQueueCap) { + // A stalled drain (pause, breakpoint) must not let a streaming + // accelerometer grow the queue without bound; new samples win. + _ctx.PendingNudgeSensorSamples.Dequeue(); + if (!_pendingSensorSampleQueueWarningIssued) { + _pendingSensorSampleQueueWarningIssued = true; + LogQueueWarning($"[PhysicsEngine] PendingNudgeSensorSamples backlog reached {PendingSensorSampleQueueCap} items, dropping oldest. Is the drain stalled?"); + } + } _ctx.PendingNudgeSensorSamples.Enqueue(new NudgeSensorSampleCommand(sensorIndex, channel, value, timestampUsec)); } } @@ -911,6 +931,13 @@ private void Start() // Mark as initialized for simulation thread _ctx.IsInitialized = true; + + // Awake built PhysicsEnv from the serialized defaults; settings applied + // between Awake and Start (the player app pushes persisted config right + // after table activation) only reached the component fields because the + // Configure methods bail out while uninitialized. Push them now. + ConfigureKeyboardNudge(KeyboardNudgeMode, KeyboardNudgeStrength); + ConfigurePlumb(SimulatedPlumb, PlumbDamping, PlumbThresholdAngle); } internal PhysicsState CreateState() => _ctx.CreateState(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index b75bb6252..3802996ed 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -98,7 +98,16 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [NonSerialized] private InputManager _inputManager; [NonSerialized] private readonly List<(InputAction, Action)> _actions = new(); [NonSerialized] private readonly System.Random _nudgeRandom = new System.Random(); - [NonSerialized] private int _lastObservedKeyboardNudgeIndex; + + // Default-keyboard-nudge suppression (Unity input path only): the nudge counter is + // snapshotted before each input-system update, i.e. before any action callback (and + // therefore any synchronous gamelogic reaction) of that update ran. Nudge key presses + // are recorded with that snapshot and evaluated in Update(), after all callbacks ran: + // if gamelogic called Nudge() in reaction to the key, the counter moved and the + // default nudge is suppressed — mirroring the tight bracket of the reference and of + // the simulation-thread path. + [NonSerialized] private int _nudgeIndexAtInputUpdateStart; + [NonSerialized] private readonly List<(string ActionName, int IndexBefore)> _pendingDefaultNudges = new(); // players [NonSerialized] private readonly LampPlayer _lampPlayer = new(); @@ -229,6 +238,7 @@ private async void Start() _lampPlayer.OnStart(); _wirePlayer.OnStart(); _inputManager.Enable(HandleInput); + InputSystem.onBeforeUpdate += OnBeforeInputUpdate; _gamelogicEngineInitCts = new CancellationTokenSource(); try { @@ -239,6 +249,15 @@ private async void Start() private void Update() { + if (_pendingDefaultNudges.Count > 0) { + foreach (var (actionName, indexBefore) in _pendingDefaultNudges) { + if (PhysicsEngine.KeyboardNudgeIndex == indexBefore) { + ApplyDefaultKeyboardNudge(actionName); + } + } + _pendingDefaultNudges.Clear(); + } + OnUpdate?.Invoke(this, EventArgs.Empty); } @@ -251,6 +270,7 @@ private void OnDestroy() i.OnDestroy(); } + InputSystem.onBeforeUpdate -= OnBeforeInputUpdate; _inputManager.Disable(HandleInput); _coilPlayer.OnDestroy(); _switchPlayer.OnDestroy(); @@ -532,11 +552,14 @@ private void HandleNudgeInput(InputAction action, InputActionChange change) return; } - var currentNudgeIndex = PhysicsEngine.KeyboardNudgeIndex; - if (currentNudgeIndex == _lastObservedKeyboardNudgeIndex) { - ApplyDefaultKeyboardNudge(actionName); + _pendingDefaultNudges.Add((actionName, _nudgeIndexAtInputUpdateStart)); + } + + private void OnBeforeInputUpdate() + { + if (PhysicsEngine != null) { + _nudgeIndexAtInputUpdateStart = PhysicsEngine.KeyboardNudgeIndex; } - _lastObservedKeyboardNudgeIndex = PhysicsEngine.KeyboardNudgeIndex; } private void ApplyDefaultKeyboardNudge(string actionName) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs index dd7d69d53..06ae58e2a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs @@ -55,10 +55,10 @@ public float ProcessRawValue(float rawValue, long timestampUsec) value = math.sign(value) * ((absValue - deadZone) / (1f - deadZone)); } + // Always clamp, like the reference: a limit of 0 zeroes the output rather + // than meaning "unlimited". var limit = math.max(0f, Limit); - if (limit > 0f) { - value = math.clamp(value, -limit, limit); - } + value = math.clamp(value, -limit, limit); MappedValue = value * Scale; return MappedValue; } @@ -87,27 +87,33 @@ public static bool TryParse(string value, out SensorMapping mapping) } var parts = value.Split(';'); - if (parts.Length != 6) { + if (parts.Length < 6) { return false; } - if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var axisId)) { + // The device id is a free-form native path that may itself contain ';'; + // the five trailing fields never do, so parse from the end and re-join + // whatever precedes them. + var p = parts.Length - 5; + var deviceId = parts.Length == 6 ? parts[0] : string.Join(";", parts, 0, p); + + if (!int.TryParse(parts[p], NumberStyles.Integer, CultureInfo.InvariantCulture, out var axisId)) { return false; } - if (!TryCodeToKind(parts[2], out var kind)) { + if (!TryCodeToKind(parts[p + 1], out var kind)) { return false; } - if (!float.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var deadZone)) { + if (!float.TryParse(parts[p + 2], NumberStyles.Float, CultureInfo.InvariantCulture, out var deadZone)) { return false; } - if (!float.TryParse(parts[4], NumberStyles.Float, CultureInfo.InvariantCulture, out var scale)) { + if (!float.TryParse(parts[p + 3], NumberStyles.Float, CultureInfo.InvariantCulture, out var scale)) { return false; } - if (!float.TryParse(parts[5], NumberStyles.Float, CultureInfo.InvariantCulture, out var limit)) { + if (!float.TryParse(parts[p + 4], NumberStyles.Float, CultureInfo.InvariantCulture, out var limit)) { return false; } - mapping.DeviceId = parts[0]; + mapping.DeviceId = deviceId; mapping.AxisId = axisId; mapping.Kind = kind; mapping.DeadZone = math.clamp(deadZone, 0f, 0.999f); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs index 0b3d666b0..aac78d4b2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs @@ -161,8 +161,12 @@ public bool AddSample(ulong timeUs, float rawVelocity, float rawAcceleration) return false; } if (_samples.Length >= _samples.Capacity) { - EndSegment(); - StartSegment(timeUs); + // The regression assumes segments that begin and end at rest (the endpoint + // detrend removes the baseline between them), so the segment must not be + // split here. Halve the sample resolution instead: the buffer covers the + // full 2s max segment duration at progressively coarser (but timestamped, + // hence still correctly integrated) steps. + CompactSamples(); } _samples.Add(new Sample { @@ -173,6 +177,25 @@ public bool AddSample(ulong timeUs, float rawVelocity, float rawAcceleration) return true; } + private void CompactSamples() + { + var compacted = 0; + var i = 0; + for (; i + 1 < _samples.Length; i += 2) { + var a = _samples[i]; + var b = _samples[i + 1]; + _samples[compacted++] = new Sample { + TimeUs = a.TimeUs + (b.TimeUs - a.TimeUs) / 2, + Velocity = 0.5f * (a.Velocity + b.Velocity), + Acceleration = 0.5f * (a.Acceleration + b.Acceleration) + }; + } + if (i < _samples.Length) { + _samples[compacted++] = _samples[i]; + } + _samples.Length = compacted; + } + public bool EndSegment() { if (!IsSegmentActive) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs index b6420f690..c8d3af7b4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs @@ -238,8 +238,12 @@ public void StepOneMillisecond() } CabinetAcceleration = Cabinet.CabinetAcceleration; } else { - CabinetAcceleration.x = _emaX.Update(KalmanX.Acceleration, 0.001f) * Strength; - CabinetAcceleration.y = _emaY.Update(KalmanY.Acceleration, 0.001f) * Strength; + // Note: the reference (CabinetNudgeSensor.cpp:280-285) multiplies by the + // strength scale twice, making direct-mode output scale with strength². + // That's an upstream bug we deliberately don't reproduce: strength is + // applied once, linearly. + CabinetAcceleration.x = _emaX.Update(KalmanX.Acceleration, 0.001f); + CabinetAcceleration.y = _emaY.Update(KalmanY.Acceleration, 0.001f); CabinetAcceleration *= Strength * CabinetMassKg / Cabinet.Mass; Cabinet.StepOneMillisecond(Cabinet.Mass * CabinetAcceleration); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs index d755932b3..7fff3da92 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs @@ -72,7 +72,22 @@ public PlumbState(bool enabled, float damping, float tiltThresholdDeg) public void Configure(bool enabled, float damping, float tiltThresholdDeg) { - this = new PlumbState(enabled, damping, tiltThresholdDeg); + // Runtime reconfiguration must not teleport the pendulum or lose the + // script-visible state (the reference setters preserve it too). + _enabled = enabled ? (byte)1 : (byte)0; + TiltThresholdRad = math.radians(math.clamp(tiltThresholdDeg, 0.5f, 4f)); + _angularDamping0 = DampingCoef0 * math.clamp(damping, 0f, 2f); + _angularDamping1 = DampingCoef1 * math.clamp(damping, 0f, 2f); + + // If the tilt switch is currently closed and the plumb no longer steps + // (disabled) or the threshold moved past the bob, emit a release edge so + // the consumer side doesn't stay stuck on "tilted". + if (TiltHigh && !enabled) { + TiltHigh = false; + if (PendingTiltStates.Length < PendingTiltStates.Capacity) { + PendingTiltStates.Add(0); + } + } } public void StepOneMillisecond(float2 cabinetAcceleration) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs index e07e67977..04800b8fe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs @@ -83,11 +83,13 @@ private readonly struct QueuedSwitchEvent { public readonly string SwitchId; public readonly bool IsClosed; + public readonly Action AfterDispatch; - public QueuedSwitchEvent(string switchId, bool isClosed) + public QueuedSwitchEvent(string switchId, bool isClosed, Action afterDispatch = null) { SwitchId = switchId; IsClosed = isClosed; + AfterDispatch = afterDispatch; } } @@ -105,13 +107,23 @@ public MainThreadQueuedInputDispatcher(IGamelogicEngine gamelogicEngine) } public void DispatchSwitch(string switchId, bool isClosed) + { + DispatchSwitch(switchId, isClosed, null); + } + + /// + /// Queues a switch event and runs on the main thread + /// right after the gamelogic engine processed it — i.e. after any synchronous reaction + /// (such as a scripted nudge) has taken place. + /// + public void DispatchSwitch(string switchId, bool isClosed, Action afterDispatch) { lock (_queueLock) { if (_queue.Count >= MaxQueuedEvents) { _droppedEvents++; return; } - _queue.Enqueue(new QueuedSwitchEvent(switchId, isClosed)); + _queue.Enqueue(new QueuedSwitchEvent(switchId, isClosed, afterDispatch)); } } @@ -139,6 +151,7 @@ public void FlushMainThread() } _gamelogicEngine.Switch(item.SwitchId, item.IsClosed); + item.AfterDispatch?.Invoke(); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index 7468ed83f..09c826735 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -183,7 +183,10 @@ public struct InputBinding private int _padding; } - [StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Ansi)] + // The native side fills the string fields with UTF-8; decode them manually, + // since ByValTStr would decode with the system ANSI codepage and garble + // non-ASCII device names. + [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct InputDeviceInfo { public int DeviceIndex; @@ -191,14 +194,17 @@ public struct InputDeviceInfo public int IsConnected; private int _padding; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = DeviceIdSize)] - public string StableId; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = DeviceIdSize)] + private byte[] _stableId; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = DeviceNameSize)] - public string DisplayName; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = DeviceNameSize)] + private byte[] _displayName; + + public string StableId => DecodeUtf8(_stableId); + public string DisplayName => DecodeUtf8(_displayName); } - [StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Ansi)] + [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct InputAxisInfo { public int AxisId; @@ -209,8 +215,22 @@ public struct InputAxisInfo private int _padding; public long TimestampUsec; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = AxisNameSize)] - public string Name; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = AxisNameSize)] + private byte[] _name; + + public string Name => DecodeUtf8(_name); + } + + private static string DecodeUtf8(byte[] bytes) + { + if (bytes == null) { + return string.Empty; + } + var length = Array.IndexOf(bytes, (byte)0); + if (length < 0) { + length = bytes.Length; + } + return length == 0 ? string.Empty : System.Text.Encoding.UTF8.GetString(bytes, 0, length); } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index 85e4f077b..74513cd51 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -37,7 +37,12 @@ public class NativeInputManager : IDisposable private long _inputPerfWindowStartTicks = Stopwatch.GetTimestamp(); private int _inputEventsInWindow; private float _actualEventRateHz; - private readonly List _deviceScratch = new(); + + // Immutable snapshot mapping native device indices to stable device ids, swapped as a whole + // on the main thread so the input polling thread can read it lock-free. Rebuilt after + // StartPolling, because that's when the native side switches to its polling enumeration, + // which is the only index space consistent with axis events. + private volatile Dictionary _deviceIdsByIndex = new(); // Input configuration private readonly List _bindings = new(); @@ -185,21 +190,22 @@ public static bool AppFocused public IReadOnlyList ListDevices() { - _deviceScratch.Clear(); + var result = new List(); if (!_initialized) { - return _deviceScratch; + return result; } var count = NativeInputApi.VpeInputListDevices(null, 0); if (count <= 0) { - return _deviceScratch; + RebuildDeviceIdCache(result); + return result; } var devices = new NativeInputApi.InputDeviceInfo[count]; var copied = NativeInputApi.VpeInputListDevices(devices, devices.Length); for (var i = 0; i < global::System.Math.Min(count, copied); i++) { var axes = ListDeviceAxes(devices[i].DeviceIndex); - _deviceScratch.Add(new NativeInputDeviceInfo( + result.Add(new NativeInputDeviceInfo( devices[i].DeviceIndex, devices[i].StableId ?? string.Empty, devices[i].DisplayName ?? string.Empty, @@ -207,7 +213,26 @@ public IReadOnlyList ListDevices() axes )); } - return _deviceScratch; + RebuildDeviceIdCache(result); + return result; + } + + /// + /// Resolves a native device index (as carried by axis events) to the device's stable id. + /// Safe to call from the input polling thread. + /// + public bool TryGetDeviceId(int deviceIndex, out string deviceId) + { + return _deviceIdsByIndex.TryGetValue(deviceIndex, out deviceId); + } + + private void RebuildDeviceIdCache(IReadOnlyList devices) + { + var cache = new Dictionary(devices.Count); + for (var i = 0; i < devices.Count; i++) { + cache[devices[i].DeviceIndex] = devices[i].Id; + } + _deviceIdsByIndex = cache; } public IReadOnlyList ListDeviceAxes(int deviceIndex) @@ -284,6 +309,10 @@ public bool StartPolling(int pollIntervalUs = 500) _pollIntervalUs = pollIntervalUs; Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); + // The native side switched to its polling device enumeration; refresh the + // index → device-id cache so axis events resolve against the right snapshot. + ListDevices(); + return true; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 314c0da18..380ba2960 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -257,6 +257,18 @@ public ref readonly SimulationState.Snapshot GetSharedState() /// Thread: Main thread only (used during initialization). public SimulationState SharedState => _sharedState; + /// + /// Dispatches a plumb tilt state change on the main thread, driving switch + /// status, key wires and (via the external switch queue) the gamelogic + /// engine — the same route the single-threaded mode takes. Set by + /// ; when null, tilt falls back to a + /// direct GLE switch dispatch on the simulation thread. + /// + public Action MainThreadTiltDispatcher { get; set; } + + private readonly Queue _mainThreadTiltStates = new(); + private readonly object _mainThreadTiltLock = new(); + /// /// Flush any queued main-thread input dispatches. /// @@ -264,6 +276,17 @@ public ref readonly SimulationState.Snapshot GetSharedState() public void FlushMainThreadInputDispatch() { _inputDispatcher.FlushMainThread(); + + while (true) { + bool tiltState; + lock (_mainThreadTiltLock) { + if (_mainThreadTiltStates.Count == 0) { + break; + } + tiltState = _mainThreadTiltStates.Dequeue(); + } + MainThreadTiltDispatcher?.Invoke(tiltState); + } } /// @@ -486,13 +509,24 @@ private void ProcessInputEvents() InputLatencyTracker.RecordInputPolled((NativeInputApi.InputAction)actionIndex, isPressed, evt.TimestampUsec); - var nudgeIndexBeforeSwitchDispatch = _physicsEngine.KeyboardNudgeIndex; - // Only forward to GLE once it's ready (or at least has started) if (_gamelogicEngine != null && _gamelogicStarted) { - SendMappedSwitch(actionIndex, isPressed); - } - if (isPressed && IsNudgeAction(actionIndex) && nudgeIndexBeforeSwitchDispatch == _physicsEngine.KeyboardNudgeIndex) { + if (isPressed && IsNudgeAction(actionIndex)) { + // The default nudge is suppressed when the gamelogic reacted to the + // key by nudging itself. For main-thread-dispatched GLEs the switch + // is only queued here, so the decision must run after the main + // thread actually delivered it — hence the after-dispatch callback. + var nudgeIndexBeforeSwitchDispatch = _physicsEngine.KeyboardNudgeIndex; + var nudgeActionIndex = actionIndex; + SendMappedSwitch(actionIndex, isPressed, () => { + if (nudgeIndexBeforeSwitchDispatch == _physicsEngine.KeyboardNudgeIndex) { + ApplyDefaultKeyboardNudge(nudgeActionIndex); + } + }); + } else { + SendMappedSwitch(actionIndex, isPressed); + } + } else if (isPressed && IsNudgeAction(actionIndex)) { ApplyDefaultKeyboardNudge(actionIndex); } _inputEventsProcessed++; @@ -536,13 +570,16 @@ private void ProcessExternalSwitchEvents() } } - private void SendMappedSwitch(int actionIndex, bool isPressed) + private void SendMappedSwitch(int actionIndex, bool isPressed, Action afterDispatch = null) { if ((uint)actionIndex >= (uint)_actionToSwitchId.Length) { + afterDispatch?.Invoke(); return; } var switchId = _actionToSwitchId[actionIndex]; if (switchId == null) { + // No switch mapped: the GLE never sees the action, run the follow-up now. + afterDispatch?.Invoke(); return; } @@ -550,6 +587,7 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) if (_actionToggleOnPress[actionIndex]) { // VP-style coin door behavior toggles only on key-down. if (!isPressed) { + afterDispatch?.Invoke(); return; } isClosed = !_actionSwitchStates[actionIndex]; @@ -573,7 +611,12 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) Logger.Info($"{LogPrefix} [SimulationThread] Input RightFlipper -> Switch({switchId}, True)"); } } - _inputDispatcher.DispatchSwitch(switchId, isClosed); + if (afterDispatch != null && _inputDispatcher is MainThreadQueuedInputDispatcher queuedDispatcher) { + queuedDispatcher.DispatchSwitch(switchId, isClosed, afterDispatch); + } else { + _inputDispatcher.DispatchSwitch(switchId, isClosed); + afterDispatch?.Invoke(); + } } private static bool IsFlipperAction(int actionIndex) @@ -830,7 +873,14 @@ private void ProcessPlumbTiltEvents() for (var i = 0; i < _pendingPlumbTiltEvents.Count; i++) { var isPressed = _pendingPlumbTiltEvents[i]; _actionStates[tiltActionIndex] = isPressed; - if (_gamelogicEngine != null && _gamelogicStarted) { + if (MainThreadTiltDispatcher != null) { + // Route through the main thread so switch status and key wires + // update too; the GLE still receives the switch via the external + // switch queue, like in single-threaded mode. + lock (_mainThreadTiltLock) { + _mainThreadTiltStates.Enqueue(isPressed); + } + } else if (_gamelogicEngine != null && _gamelogicStarted) { SendMappedSwitch(tiltActionIndex, isPressed); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 73a168363..4f1ca975f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using NLog; using UnityEngine; +using VisualPinball.Engine.Common; using Logger = NLog.Logger; namespace VisualPinball.Unity.Simulation @@ -184,6 +185,9 @@ public void StartSimulation() player != null ? new Action((coilId, isEnabled) => player.DispatchCoilSimulationThread(coilId, isEnabled)) : null); + if (player != null) { + _simulationThread.MainThreadTiltDispatcher = isTilted => player.DispatchInputAction(InputConstants.ActionTilt, isTilted); + } _simulationThread.SyncClockFromMainThread(_physicsEngine.CurrentSimulationClockUsec, _physicsEngine.CurrentSimulationClockScale); // Provide the triple-buffered SimulationState to PhysicsEngine so From e376b48bfa28c835dffacebc54f24d0e5f457f46 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 21:31:29 +0200 Subject: [PATCH 09/25] input: default tables to simulation thread --- .../Import/VpxSceneConverter.cs | 24 ++++++++++--------- .../Packaging/PackageReader.cs | 2 ++ .../VisualPinball.Unity/Game/Player.cs | 22 ++++++++++------- .../Packaging/RuntimePackageReader.cs | 2 ++ .../Simulation/SimulationThreadComponent.cs | 19 +++++++++++++++ 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs index a215376c2..47cca220f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs @@ -46,12 +46,13 @@ using VisualPinball.Engine.VPT.Surface; using VisualPinball.Engine.VPT.Table; using VisualPinball.Engine.VPT.TextBox; -using VisualPinball.Engine.VPT.Timer; -using VisualPinball.Engine.VPT.Trigger; -using VisualPinball.Engine.VPT.Trough; -using VisualPinball.Engine.VPT.MetalWireGuide; -using VisualPinball.Unity.Playfield; -using Light = VisualPinball.Engine.VPT.Light.Light; +using VisualPinball.Engine.VPT.Timer; +using VisualPinball.Engine.VPT.Trigger; +using VisualPinball.Engine.VPT.Trough; +using VisualPinball.Engine.VPT.MetalWireGuide; +using VisualPinball.Unity.Playfield; +using VisualPinball.Unity.Simulation; +using Light = VisualPinball.Engine.VPT.Light.Light; using Logger = NLog.Logger; using Material = UnityEngine.Material; using Mesh = UnityEngine.Mesh; @@ -735,11 +736,12 @@ private void CreateRootHierarchy(string tableName = null) _playfieldGo.transform.SetParent(_tableGo.transform, false); backglassGo.transform.SetParent(_tableGo.transform, false); - cabinetGo.transform.SetParent(_tableGo.transform, false); - - // 2. add components - _playfieldGo.AddComponent(); - _playfieldComponent = _playfieldGo.AddComponent(); + cabinetGo.transform.SetParent(_tableGo.transform, false); + + // 2. add components + var physicsEngine = _playfieldGo.AddComponent(); + SimulationThreadComponent.EnsureFor(physicsEngine); + _playfieldComponent = _playfieldGo.AddComponent(); _playfieldGo.AddComponent(); _playfieldGo.AddComponent(); _playfieldGo.AddComponent(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs index 244fecd2a..fb6680151 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs @@ -25,6 +25,7 @@ using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; +using VisualPinball.Unity.Simulation; using Logger = NLog.Logger; using Object = UnityEngine.Object; @@ -92,6 +93,7 @@ public async Task ImportIntoScene(string tableName) ReadGlobals(); ReadTableMetadata(); + SimulationThreadComponent.EnsureForTable(_tableRoot.gameObject); RestoreLights(); ImportMaterials(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 3802996ed..f5fffa393 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -144,14 +144,19 @@ private PhysicsEngine PhysicsEngine { } } - private SimulationThreadComponent SimulationThreadComponent { - get { - if (_simulationThreadComponent == null) { - _simulationThreadComponent = GetComponent(); - } - return _simulationThreadComponent; - } - } + private SimulationThreadComponent SimulationThreadComponent { + get { + if (_simulationThreadComponent == null) { + _simulationThreadComponent = GetComponentInChildren(true); + } + return _simulationThreadComponent; + } + } + + private void EnsureSimulationThreadComponent() + { + _simulationThreadComponent = Simulation.SimulationThreadComponent.EnsureFor(PhysicsEngine); + } #region Access @@ -211,6 +216,7 @@ private void Awake() BallManager = new BallManager(GetComponentInChildren(), this, Playfield.transform); _inputManager = new InputManager(); + EnsureSimulationThreadComponent(); if (engineComponent != null) { GamelogicEngine = engineComponent; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs index 92589c3da..cb4a26d84 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs @@ -28,6 +28,7 @@ using Newtonsoft.Json.Linq; using NLog; using UnityEngine; +using VisualPinball.Unity.Simulation; using Logger = NLog.Logger; namespace VisualPinball.Unity @@ -241,6 +242,7 @@ await ReadPackablesAsync( var tableMetadataStopwatch = Stopwatch.StartNew(); ReportProgress(progress, RuntimePackageLoadStage.RestoringTableMetadata, 0f, "Reading table metadata..."); ReadTableMetadata(); + SimulationThreadComponent.EnsureForTable(_table); ReportProgress(progress, RuntimePackageLoadStage.RestoringTableMetadata, 1f, "Table metadata ready."); tableMetadataStopwatch.Stop(); Logger.Info($"RuntimePackageReader: Read table metadata in {tableMetadataStopwatch.ElapsedMilliseconds}ms."); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 4f1ca975f..836c39eac 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -31,6 +31,25 @@ public class SimulationThreadComponent : MonoBehaviour, IPackable private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const string LogPrefix = "[VPE]"; + public static SimulationThreadComponent EnsureFor(PhysicsEngine physicsEngine) + { + if (!physicsEngine) { + return null; + } + + return physicsEngine.GetComponent() + ?? physicsEngine.gameObject.AddComponent(); + } + + public static SimulationThreadComponent EnsureForTable(GameObject tableRoot) + { + if (!tableRoot) { + return null; + } + + return EnsureFor(tableRoot.GetComponentInChildren(true)); + } + #region Inspector Fields [Header("Simulation Settings")] From dbdfe89519ef6808a4df299adacf24dff58ef7dd Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 21:41:57 +0200 Subject: [PATCH 10/25] physics: damp keyboard cabinet nudge --- .../Physics/CabinetPhysicsTests.cs | 33 +++++++++++++++++++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 18 ++++++++-- .../Game/PhysicsEnginePackable.cs | 7 +++- .../VisualPinball.Unity/Game/PhysicsEnv.cs | 5 +-- .../Physics/Cabinet/CabinetPhysicsState.cs | 25 ++++++++++++-- .../Physics/Cabinet/KeyboardNudgeState.cs | 13 +++++--- .../Physics/Cabinet/NudgeState.cs | 5 +-- 7 files changed, 91 insertions(+), 15 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs index 6a618d352..9194b66a5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs @@ -53,6 +53,23 @@ public void CabinetPhysicsUsesCalibratedAxisModels() Assert.That(cabinet.CabinetOffset.y, Is.LessThan(0f)); } + [Test] + public void KeyboardCabinetModelLimitsVisibleRebounds() + { + var nudge = new KeyboardNudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.Nudge(0f, 2f); + + var offsets = new float[1000]; + var peak = 0f; + for (var i = 0; i < offsets.Length; i++) { + nudge.StepOneMillisecond(); + offsets[i] = nudge.CabinetOffset.y; + peak = math.max(peak, math.abs(offsets[i])); + } + + Assert.That(CountVisibleExtrema(offsets, peak * 0.1f), Is.LessThanOrEqualTo(3)); + } + [Test] public void PlumbTiltLatchesAndQueuesTiltEvent() { @@ -93,5 +110,21 @@ private static (float Displacement, float Velocity, float Acceleration) Analytic return (displacement, velocity, acceleration); } + + private static int CountVisibleExtrema(float[] values, float threshold) + { + var count = 0; + for (var i = 2; i < values.Length; i++) { + var previousSlope = values[i - 1] - values[i - 2]; + var slope = values[i] - values[i - 1]; + if (math.abs(values[i - 1]) <= threshold || previousSlope == 0f || slope == 0f) { + continue; + } + if (math.sign(previousSlope) != math.sign(slope)) { + count++; + } + } + return count; + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index d17e5e686..fc2ab58f8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -123,6 +123,10 @@ public class PhysicsEngine : MonoBehaviour, IPackable [Range(0f, 2f)] public float KeyboardNudgeStrength = 1f; + [Tooltip("Keyboard cabinet nudge damping ratio. Higher values make keyboard nudges settle faster.")] + [Range(CabinetPhysicsState.MinKeyboardDampingRatio, CabinetPhysicsState.MaxKeyboardDampingRatio)] + public float KeyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio; + [Tooltip("Simulate a mechanical plumb-bob tilt switch from cabinet nudge.")] public bool SimulatedPlumb = true; @@ -315,9 +319,17 @@ public void Nudge(float angleDeg, float force) } public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength) + { + ConfigureKeyboardNudge(mode, strength, KeyboardCabinetDamping); + } + + public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength, float cabinetDamping) { KeyboardNudgeMode = mode; KeyboardNudgeStrength = math.clamp(strength, 0f, 2f); + KeyboardCabinetDamping = math.clamp(cabinetDamping, + CabinetPhysicsState.MinKeyboardDampingRatio, + CabinetPhysicsState.MaxKeyboardDampingRatio); if (_ctx == null || !_ctx.IsInitialized) { return; @@ -327,7 +339,7 @@ public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength) var nudgeTime = table != null ? table.NudgeTime : 5f; lock (_ctx.PhysicsLock) { var nudge = _ctx.PhysicsEnv.Nudge; - nudge.Keyboard.Configure(KeyboardNudgeMode, KeyboardNudgeStrength, nudgeTime); + nudge.Keyboard.Configure(KeyboardNudgeMode, KeyboardNudgeStrength, nudgeTime, KeyboardCabinetDamping); _ctx.PhysicsEnv.Nudge = nudge; } } @@ -827,7 +839,7 @@ private void Awake() var table = GetComponentInParent(); _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength, KeyboardNudgeMode, KeyboardNudgeStrength, table != null ? table.NudgeTime : 5f, - SimulatedPlumb, PlumbDamping, PlumbThresholdAngle); + SimulatedPlumb, PlumbDamping, PlumbThresholdAngle, KeyboardCabinetDamping); Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime); _ctx.ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); _ctx.FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); @@ -936,7 +948,7 @@ private void Start() // between Awake and Start (the player app pushes persisted config right // after table activation) only reached the component fields because the // Configure methods bail out while uninitialized. Push them now. - ConfigureKeyboardNudge(KeyboardNudgeMode, KeyboardNudgeStrength); + ConfigureKeyboardNudge(KeyboardNudgeMode, KeyboardNudgeStrength, KeyboardCabinetDamping); ConfigurePlumb(SimulatedPlumb, PlumbDamping, PlumbThresholdAngle); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs index 5e1e997be..55af01de3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs @@ -22,6 +22,8 @@ public struct PhysicsEnginePackable public bool HasKeyboardNudgeSettings; public KeyboardNudgeMode KeyboardNudgeMode; public float KeyboardNudgeStrength; + public bool HasKeyboardCabinetDamping; + public float KeyboardCabinetDamping; public bool HasPlumbSettings; public bool SimulatedPlumb; public float PlumbDamping; @@ -36,6 +38,8 @@ public static byte[] Pack(PhysicsEngine comp) HasKeyboardNudgeSettings = true, KeyboardNudgeMode = comp.KeyboardNudgeMode, KeyboardNudgeStrength = comp.KeyboardNudgeStrength, + HasKeyboardCabinetDamping = true, + KeyboardCabinetDamping = comp.KeyboardCabinetDamping, HasPlumbSettings = true, SimulatedPlumb = comp.SimulatedPlumb, PlumbDamping = comp.PlumbDamping, @@ -51,7 +55,8 @@ public static void Unpack(byte[] bytes, PhysicsEngine comp) comp.GravityStrength = data.GravityStrength; comp.ConfigureKeyboardNudge( data.HasKeyboardNudgeSettings ? data.KeyboardNudgeMode : KeyboardNudgeMode.CabModel, - data.HasKeyboardNudgeSettings ? data.KeyboardNudgeStrength : 1f + data.HasKeyboardNudgeSettings ? data.KeyboardNudgeStrength : 1f, + data.HasKeyboardCabinetDamping ? data.KeyboardCabinetDamping : CabinetPhysicsState.DefaultKeyboardDampingRatio ); comp.ConfigurePlumb( data.HasPlumbSettings ? data.SimulatedPlumb : true, diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs index d3c774a67..f33735022 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs @@ -35,14 +35,15 @@ public struct PhysicsEnv public PhysicsEnv(ulong startTimeUsec, PlayfieldComponent playfield, float gravityStrength, KeyboardNudgeMode keyboardNudgeMode = KeyboardNudgeMode.CabModel, float keyboardNudgeStrength = 1f, - float nudgeTime = 5f, bool simulatedPlumb = true, float plumbDamping = 1f, float plumbThresholdAngle = 2f) : this() + float nudgeTime = 5f, bool simulatedPlumb = true, float plumbDamping = 1f, float plumbThresholdAngle = 2f, + float keyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio) : this() { StartTimeUsec = startTimeUsec; CurPhysicsFrameTime = StartTimeUsec; NextPhysicsFrameTime = StartTimeUsec + PhysicsConstants.PhysicsStepTime; Random = new Random((uint)UnityEngine.Random.Range(1, 100000)); Gravity = playfield.PlayfieldGravity(gravityStrength); - Nudge = new NudgeState(keyboardNudgeMode, keyboardNudgeStrength, nudgeTime); + Nudge = new NudgeState(keyboardNudgeMode, keyboardNudgeStrength, nudgeTime, keyboardCabinetDamping); Plumb = new PlumbState(simulatedPlumb, plumbDamping, plumbThresholdAngle); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs index 29ffa0a9f..554ab8125 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs @@ -20,23 +20,42 @@ namespace VisualPinball.Unity { public struct CabinetPhysicsState { + public const float DefaultKeyboardDampingRatio = 0.5f; + public const float MinKeyboardDampingRatio = 0.05f; + public const float MaxKeyboardDampingRatio = 1f; + + private const float XFrequencyHz = 9.3f; + private const float YFrequencyHz = 5.8f; + private const float XCalibratedDampingRatio = 0.052f; + private const float YCalibratedDampingRatio = 0.055f; + public float Mass; public DampedHarmonicOscillator X; public DampedHarmonicOscillator Y; public float2 CabinetAcceleration; public float2 CabinetOffset; - public CabinetPhysicsState(float mass) + public CabinetPhysicsState(float mass) : this(mass, XCalibratedDampingRatio, YCalibratedDampingRatio) + { + } + + private CabinetPhysicsState(float mass, float xDampingRatio, float yDampingRatio) { Mass = mass; - X = new DampedHarmonicOscillator(mass, 9.3f, 0.052f); - Y = new DampedHarmonicOscillator(mass, 5.8f, 0.055f); + X = new DampedHarmonicOscillator(mass, XFrequencyHz, xDampingRatio); + Y = new DampedHarmonicOscillator(mass, YFrequencyHz, yDampingRatio); CabinetAcceleration = float2.zero; CabinetOffset = float2.zero; } public static CabinetPhysicsState Default => new(113f); + public static CabinetPhysicsState Keyboard(float dampingRatio) + { + dampingRatio = math.clamp(dampingRatio, MinKeyboardDampingRatio, MaxKeyboardDampingRatio); + return new CabinetPhysicsState(113f, dampingRatio, dampingRatio); + } + public void StepOneMillisecond(float2 force) { X.StepOneMillisecond(force.x); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs index 50f177401..4b9bd5922 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs @@ -27,6 +27,7 @@ public struct KeyboardNudgeState public KeyboardNudgeMode Mode; public float Strength; + public float CabinetDamping; public float2 CabinetAcceleration; public float2 CabinetOffset; @@ -72,10 +73,14 @@ public float2 CurrentAcceleration } } - public KeyboardNudgeState(KeyboardNudgeMode mode, float strength, float nudgeTime) + public KeyboardNudgeState(KeyboardNudgeMode mode, float strength, float nudgeTime, + float cabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio) { Mode = mode; Strength = strength; + CabinetDamping = math.clamp(cabinetDamping, + CabinetPhysicsState.MinKeyboardDampingRatio, + CabinetPhysicsState.MaxKeyboardDampingRatio); CabinetAcceleration = float2.zero; CabinetOffset = float2.zero; _pushImpulse = float2.zero; @@ -87,7 +92,7 @@ public KeyboardNudgeState(KeyboardNudgeMode mode, float strength, float nudgeTim _boxSpring = 0f; _boxDamping = 0f; _boxDeactivationDelay = 0; - _cabinet = CabinetPhysicsState.Default; + _cabinet = CabinetPhysicsState.Keyboard(CabinetDamping); _cabImpulses = default; _cabDeactivationDelay = 0; @@ -100,9 +105,9 @@ public KeyboardNudgeState(KeyboardNudgeMode mode, float strength, float nudgeTim _ => _cabDeactivationDelay > 0, }; - public void Configure(KeyboardNudgeMode mode, float strength, float nudgeTime) + public void Configure(KeyboardNudgeMode mode, float strength, float nudgeTime, float cabinetDamping) { - this = new KeyboardNudgeState(mode, strength, nudgeTime); + this = new KeyboardNudgeState(mode, strength, nudgeTime, cabinetDamping); } public void SetStrength(float strength) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs index 9ee1747b7..5c7a8962c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs @@ -62,9 +62,10 @@ public struct NudgeState public float2 MaxCabinetAcceleration; public int KeyboardNudgeIndex; - public NudgeState(KeyboardNudgeMode keyboardMode, float keyboardStrength, float nudgeTime) + public NudgeState(KeyboardNudgeMode keyboardMode, float keyboardStrength, float nudgeTime, + float keyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio) { - Keyboard = new KeyboardNudgeState(keyboardMode, keyboardStrength, nudgeTime); + Keyboard = new KeyboardNudgeState(keyboardMode, keyboardStrength, nudgeTime, keyboardCabinetDamping); CabinetAcceleration = float2.zero; CabinetOffset = float2.zero; MaxCabinetAcceleration = float2.zero; From 670cef561b514c97aba387985d44c5034a1f5109 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 22:09:33 +0200 Subject: [PATCH 11/25] simulation: add nudge sensor calibration inspector --- .../Simulation.meta | 8 + .../SimulationThreadComponentInspector.cs | 149 ++++++++++ ...SimulationThreadComponentInspector.cs.meta | 11 + .../Input/SensorMappingTests.cs | 34 +++ .../Input/SensorMapping.cs | 41 ++- .../Simulation/SimulationThreadComponent.cs | 271 ++++++++++++++++++ .../SimulationThreadComponentPackable.cs | 71 +++++ 7 files changed, 578 insertions(+), 7 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation.meta new file mode 100644 index 000000000..fc2d215c8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1d8f15be3a5342f19beabc75272f6372 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs new file mode 100644 index 000000000..c0f0575cd --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs @@ -0,0 +1,149 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using UnityEditor; +using UnityEngine; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(SimulationThreadComponent)), CanEditMultipleObjects] + public sealed class SimulationThreadComponentInspector : UnityEditor.Editor + { + private string _statusMessage; + private MessageType _statusType = MessageType.Info; + + public override void OnInspectorGUI() + { + serializedObject.Update(); + EditorGUI.BeginChangeCheck(); + DrawPropertiesExcluding(serializedObject, "m_Script"); + var changed = EditorGUI.EndChangeCheck(); + serializedObject.ApplyModifiedProperties(); + + if (targets.Length == 1 && target is SimulationThreadComponent component) { + if (changed && Application.isPlaying) { + component.ApplyNudgeSensorSettings(); + } + DrawNudgeCalibration(component); + } + } + + private void DrawNudgeCalibration(SimulationThreadComponent component) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Nudge Calibration", EditorStyles.boldLabel); + + if (!Application.isPlaying) { + EditorGUILayout.HelpBox("Enter Play Mode to read native input devices and calibrate the current sensor centers.", MessageType.Info); + return; + } + + EditorGUILayout.LabelField("Simulation", component.IsRunning ? "Running" : "Stopped"); + EditorGUILayout.LabelField("Input Thread", $"{component.InputThreadActualHz:0.#} Hz / target {component.InputThreadTargetHz:0.#} Hz"); + + var devices = component.ListNudgeInputDevices(); + if (devices.Count == 0) { + EditorGUILayout.HelpBox("No native input devices are visible. Check that native input is enabled and that the device was connected before polling started.", MessageType.Warning); + } else { + DrawDevices(devices); + } + + EditorGUILayout.Space(); + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginDisabledGroup(devices.Count == 0); + if (GUILayout.Button("Auto-map First Device")) { + Undo.RecordObject(component, "Auto-map nudge sensor"); + if (component.TryAutoConfigureFirstCabinetSensor(out var message)) { + SetStatus(message, MessageType.Info); + EditorUtility.SetDirty(component); + } else { + SetStatus(message, MessageType.Warning); + } + } + EditorGUI.EndDisabledGroup(); + + EditorGUI.BeginDisabledGroup(devices.Count == 0 || component.NudgeSensors == null || component.NudgeSensors.Count == 0); + if (GUILayout.Button("Calibrate Centers")) { + Undo.RecordObject(component, "Calibrate nudge sensor centers"); + var calibrated = component.CalibrateNudgeSensorCenters(); + if (calibrated > 0) { + SetStatus($"Calibrated {calibrated} mapped nudge channel(s).", MessageType.Info); + EditorUtility.SetDirty(component); + } else { + SetStatus("No mapped channels matched the currently visible devices.", MessageType.Warning); + } + } + EditorGUI.EndDisabledGroup(); + EditorGUILayout.EndHorizontal(); + + EditorGUI.BeginDisabledGroup(component.NudgeSensors == null || component.NudgeSensors.Count == 0); + if (GUILayout.Button("Reset Centers")) { + Undo.RecordObject(component, "Reset nudge sensor centers"); + var reset = component.ResetNudgeSensorCenters(); + SetStatus(reset > 0 ? "Cleared nudge sensor raw centers." : "No nudge sensors to reset.", MessageType.Info); + EditorUtility.SetDirty(component); + } + EditorGUI.EndDisabledGroup(); + + if (!string.IsNullOrEmpty(_statusMessage)) { + EditorGUILayout.HelpBox(_statusMessage, _statusType); + } + } + + private void DrawDevices(System.Collections.Generic.IReadOnlyList devices) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Native Devices", EditorStyles.boldLabel); + for (var i = 0; i < devices.Count; i++) { + var device = devices[i]; + EditorGUILayout.LabelField(DeviceLabel(device), EditorStyles.miniBoldLabel); + if (device.Axes == null || device.Axes.Count == 0) { + EditorGUILayout.LabelField(" No axes"); + continue; + } + var axisCount = System.Math.Min(device.Axes.Count, 6); + for (var j = 0; j < axisCount; j++) { + var axis = device.Axes[j]; + EditorGUILayout.LabelField($" {AxisLabel(axis)}", EditorStyles.miniLabel); + } + if (device.Axes.Count > axisCount) { + EditorGUILayout.LabelField($" ... {device.Axes.Count - axisCount} more", EditorStyles.miniLabel); + } + } + } + + private static string DeviceLabel(NativeInputDeviceInfo device) + { + var name = string.IsNullOrEmpty(device.Name) ? device.Id : device.Name; + var state = device.IsConnected ? "connected" : "disconnected"; + return $"{name} ({state})"; + } + + private static string AxisLabel(NativeInputAxisInfo axis) + { + var name = string.IsNullOrEmpty(axis.Name) ? $"Axis {axis.AxisId}" : axis.Name; + return $"{name}: {axis.RawValue:+0.000;-0.000;0.000} [{axis.Kind}]"; + } + + private void SetStatus(string message, MessageType type) + { + _statusMessage = message; + _statusType = type; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs.meta new file mode 100644 index 000000000..82ec3d555 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c9ea6811636342178d3736473a562b3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs index 998f8904a..c70d8cd43 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs @@ -41,6 +41,23 @@ public void MappingRoundTripsUsingVpinballFormat() Assert.That(parsed.Limit, Is.EqualTo(mapping.Limit).Within(0.0001f)); } + [Test] + public void MappingRoundTripsOptionalRawCenter() + { + var mapping = new SensorMapping { + DeviceId = "HID\\VID_1209&PID_EAEA", + AxisId = 1, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0.02f, + Scale = 9.81f, + Limit = 1f, + RawCenter = -0.125f + }; + + Assert.That(SensorMapping.TryParse(mapping.ToString(), out var parsed), Is.True); + Assert.That(parsed.RawCenter, Is.EqualTo(mapping.RawCenter).Within(0.0001f)); + } + [Test] public void MappingRoundTripsWithSemicolonInDeviceId() { @@ -74,5 +91,22 @@ public void ProcessingAppliesDeadZoneLimitAndScale() Assert.That(mapping.ProcessRawValue(1f, 200), Is.EqualTo(5f)); Assert.That(mapping.RawTimestampUsec, Is.EqualTo(200)); } + + [Test] + public void ProcessingSubtractsRawCenterBeforeDeadZone() + { + var mapping = new SensorMapping { + DeviceId = "device", + AxisId = 1, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0.05f, + Scale = 10f, + Limit = 1f, + RawCenter = 0.2f + }; + + Assert.That(mapping.ProcessRawValue(0.22f, 100), Is.EqualTo(0f)); + Assert.That(mapping.ProcessRawValue(0.65f, 200), Is.GreaterThan(0f)); + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs index 06ae58e2a..ebad9e0be 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs @@ -35,6 +35,7 @@ public sealed class SensorMapping public float DeadZone { get; set; } public float Scale { get; set; } = 1f; public float Limit { get; set; } = 1f; + public float RawCenter { get; set; } public float RawValue { get; private set; } public float MappedValue { get; private set; } public long RawTimestampUsec { get; private set; } @@ -47,7 +48,7 @@ public float ProcessRawValue(float rawValue, long timestampUsec) RawTimestampUsec = timestampUsec; var deadZone = math.clamp(DeadZone, 0f, 0.999f); - var value = RawValue; + var value = math.clamp(RawValue - RawCenter, -1f, 1f); var absValue = math.abs(value); if (absValue <= deadZone) { value = 0f; @@ -69,7 +70,7 @@ public override string ToString() return string.Empty; } - return string.Join(";", + var value = string.Join(";", DeviceId, AxisId.ToString(CultureInfo.InvariantCulture), KindToCode(Kind), @@ -77,6 +78,10 @@ public override string ToString() Scale.ToString(CultureInfo.InvariantCulture), Limit.ToString(CultureInfo.InvariantCulture) ); + if (math.abs(RawCenter) > 1.0e-6f) { + value += ";" + RawCenter.ToString(CultureInfo.InvariantCulture); + } + return value; } public static bool TryParse(string value, out SensorMapping mapping) @@ -91,11 +96,27 @@ public static bool TryParse(string value, out SensorMapping mapping) return false; } + if (TryParseParts(parts, 6, out mapping)) { + return mapping.IsMapped; + } + if (TryParseParts(parts, 5, out mapping)) { + return mapping.IsMapped; + } + mapping = new SensorMapping(); + return false; + } + + private static bool TryParseParts(string[] parts, int trailingValueCount, out SensorMapping mapping) + { + mapping = new SensorMapping(); + // The device id is a free-form native path that may itself contain ';'; - // the five trailing fields never do, so parse from the end and re-join - // whatever precedes them. - var p = parts.Length - 5; - var deviceId = parts.Length == 6 ? parts[0] : string.Join(";", parts, 0, p); + // the trailing fields never do, so parse from the end and re-join whatever precedes them. + var p = parts.Length - trailingValueCount; + if (p <= 0) { + return false; + } + var deviceId = p == 1 ? parts[0] : string.Join(";", parts, 0, p); if (!int.TryParse(parts[p], NumberStyles.Integer, CultureInfo.InvariantCulture, out var axisId)) { return false; @@ -112,6 +133,11 @@ public static bool TryParse(string value, out SensorMapping mapping) if (!float.TryParse(parts[p + 4], NumberStyles.Float, CultureInfo.InvariantCulture, out var limit)) { return false; } + var rawCenter = 0f; + if (trailingValueCount > 5 && + !float.TryParse(parts[p + 5], NumberStyles.Float, CultureInfo.InvariantCulture, out rawCenter)) { + return false; + } mapping.DeviceId = deviceId; mapping.AxisId = axisId; @@ -119,7 +145,8 @@ public static bool TryParse(string value, out SensorMapping mapping) mapping.DeadZone = math.clamp(deadZone, 0f, 0.999f); mapping.Scale = scale; mapping.Limit = math.max(0f, limit); - return mapping.IsMapped; + mapping.RawCenter = math.clamp(rawCenter, -1f, 1f); + return true; } private static string KindToCode(SensorMappingKind kind) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 836c39eac..1dde7a043 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -5,6 +5,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later using System; +using System.Collections.Generic; using System.Diagnostics; using NLog; using UnityEngine; @@ -13,6 +14,137 @@ namespace VisualPinball.Unity.Simulation { + [Serializable] + public sealed class SimulationThreadNudgeSensorConfig + { + public NudgeSensorType Type = NudgeSensorType.CabinetDirect; + + [Range(0f, 2f)] + public float Strength = 1f; + + [Range(0f, 200f)] + public float CabinetMassKg = 113f; + + public string X = string.Empty; + public string Y = string.Empty; + public string AccelerationX = string.Empty; + public string AccelerationY = string.Empty; + public string VelocityX = string.Empty; + public string VelocityY = string.Empty; + + public void Normalize() + { + Strength = Mathf.Clamp(Strength, 0f, 2f); + CabinetMassKg = Mathf.Clamp(CabinetMassKg <= 0f ? 113f : CabinetMassKg, 0f, 200f); + X ??= string.Empty; + Y ??= string.Empty; + AccelerationX ??= string.Empty; + AccelerationY ??= string.Empty; + VelocityX ??= string.Empty; + VelocityY ??= string.Empty; + } + + public NudgeSensorConfig ToEngineConfig() + { + Normalize(); + return new NudgeSensorConfig { + Type = Type, + Strength = Strength, + CabinetMassKg = CabinetMassKg, + X = ParseMapping(X), + Y = ParseMapping(Y), + AccelerationX = ParseMapping(AccelerationX), + AccelerationY = ParseMapping(AccelerationY), + VelocityX = ParseMapping(VelocityX), + VelocityY = ParseMapping(VelocityY) + }; + } + + public int CalibrateRawCenters(IReadOnlyList devices) + { + var count = 0; + count += CalibrateRawCenter(ref X, devices); + count += CalibrateRawCenter(ref Y, devices); + count += CalibrateRawCenter(ref AccelerationX, devices); + count += CalibrateRawCenter(ref AccelerationY, devices); + count += CalibrateRawCenter(ref VelocityX, devices); + count += CalibrateRawCenter(ref VelocityY, devices); + return count; + } + + public void ResetRawCenters() + { + ResetRawCenter(ref X); + ResetRawCenter(ref Y); + ResetRawCenter(ref AccelerationX); + ResetRawCenter(ref AccelerationY); + ResetRawCenter(ref VelocityX); + ResetRawCenter(ref VelocityY); + } + + internal static string BuildMapping(NativeInputDeviceInfo device, NativeInputAxisInfo axis, + SensorMappingKind kind, float scale, float rawCenter) + { + return new SensorMapping { + DeviceId = device.Id, + AxisId = axis.AxisId, + Kind = kind, + DeadZone = 0.02f, + Scale = scale, + Limit = 1f, + RawCenter = rawCenter + }.ToString(); + } + + private static SensorMapping ParseMapping(string value) + { + return SensorMapping.TryParse(value, out var mapping) ? mapping : new SensorMapping(); + } + + private static int CalibrateRawCenter(ref string value, IReadOnlyList devices) + { + if (!SensorMapping.TryParse(value, out var mapping)) { + return 0; + } + if (!TryFindAxis(devices, mapping.DeviceId, mapping.AxisId, out var axis)) { + return 0; + } + mapping.RawCenter = axis.RawValue; + value = mapping.ToString(); + return 1; + } + + private static void ResetRawCenter(ref string value) + { + if (!SensorMapping.TryParse(value, out var mapping)) { + return; + } + mapping.RawCenter = 0f; + value = mapping.ToString(); + } + + private static bool TryFindAxis(IReadOnlyList devices, string deviceId, int axisId, + out NativeInputAxisInfo axis) + { + if (devices != null) { + for (var i = 0; i < devices.Count; i++) { + var device = devices[i]; + if (device.Id != deviceId || device.Axes == null) { + continue; + } + for (var j = 0; j < device.Axes.Count; j++) { + if (device.Axes[j].AxisId == axisId) { + axis = device.Axes[j]; + return true; + } + } + } + } + axis = default; + return false; + } + } + /// /// Unity component that manages the high-performance simulation thread. /// Add this to your table GameObject to enable sub-millisecond input latency. @@ -63,6 +195,10 @@ public static SimulationThreadComponent EnsureForTable(GameObject tableRoot) [Range(100, 2000)] public int InputPollingIntervalUs = 500; + [Header("Nudge Sensors")] + [Tooltip("Default analog nudge sensor mappings used by editor/direct play mode. The player app can still override these from its user config.")] + public List NudgeSensors = new(); + [Header("Debug")] [Tooltip("Show simulation statistics in console")] public bool ShowStatistics = false; @@ -91,6 +227,7 @@ public static SimulationThreadComponent EnsureForTable(GameObject tableRoot) public float SimulationThreadHz => _simulationThreadHz; public float InputThreadTargetHz => _inputManager?.TargetPollingHz ?? 0f; public float InputThreadActualHz => _inputManager?.ActualEventRateHz ?? 0f; + public bool IsRunning => _started; #endregion @@ -198,6 +335,7 @@ public void StartSimulation() // Enable external timing on PhysicsEngine // This disables Unity's Update() loop and gives control to the simulation thread _physicsEngine.SetExternalTiming(true); + ApplyNudgeSensorSettings(); // Create simulation thread _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine, @@ -305,6 +443,102 @@ public SimulationState.Snapshot GetCurrentSnapshot() /// public float SampleInputLatencyMs() => InputLatencyTracker.SampleFlipperLatencyMs(false); + public IReadOnlyList ListNudgeInputDevices() + { + var inputManager = _inputManager ?? NativeInputManager.TryGetExistingInstance(); + return inputManager == null ? Array.Empty() : inputManager.ListDevices(); + } + + public void ApplyNudgeSensorSettings() + { + if (_physicsEngine == null) { + _physicsEngine = GetComponent(); + } + if (_physicsEngine == null) { + return; + } + + NudgeSensors ??= new List(); + if (NudgeSensors.Count > NudgeState.MaxSensors) { + NudgeSensors.RemoveRange(NudgeState.MaxSensors, NudgeSensors.Count - NudgeState.MaxSensors); + } + + var configs = new List(NudgeSensors.Count); + for (var i = 0; i < NudgeSensors.Count; i++) { + NudgeSensors[i] ??= new SimulationThreadNudgeSensorConfig(); + configs.Add(NudgeSensors[i].ToEngineConfig()); + } + _physicsEngine.ConfigureNudgeSensors(configs); + } + + public int CalibrateNudgeSensorCenters() + { + var devices = ListNudgeInputDevices(); + var calibrated = 0; + if (NudgeSensors != null) { + foreach (var sensor in NudgeSensors) { + calibrated += sensor?.CalibrateRawCenters(devices) ?? 0; + } + } + if (calibrated > 0) { + ApplyNudgeSensorSettings(); + } + return calibrated; + } + + public int ResetNudgeSensorCenters() + { + var reset = 0; + if (NudgeSensors != null) { + foreach (var sensor in NudgeSensors) { + if (sensor == null) { + continue; + } + sensor.ResetRawCenters(); + reset++; + } + } + if (reset > 0) { + ApplyNudgeSensorSettings(); + } + return reset; + } + + public bool TryAutoConfigureFirstCabinetSensor(out string message) + { + var devices = ListNudgeInputDevices(); + for (var i = 0; i < devices.Count; i++) { + var device = devices[i]; + if (!device.IsConnected || device.Axes == null) { + continue; + } + if (TryPickAxisPair(device, out var xAxis, out var yAxis)) { + NudgeSensors ??= new List(); + var sensor = new SimulationThreadNudgeSensorConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + AccelerationX = SimulationThreadNudgeSensorConfig.BuildMapping(device, xAxis, + SensorMappingKind.Acceleration, 9.81f, xAxis.RawValue), + AccelerationY = SimulationThreadNudgeSensorConfig.BuildMapping(device, yAxis, + SensorMappingKind.Acceleration, 9.81f, yAxis.RawValue) + }; + if (NudgeSensors.Count == 0) { + NudgeSensors.Add(sensor); + } else { + NudgeSensors[0] = sensor; + } + ApplyNudgeSensorSettings(); + var deviceName = string.IsNullOrEmpty(device.Name) ? device.Id : device.Name; + message = $"Mapped {deviceName} axes {xAxis.AxisId}/{yAxis.AxisId}."; + return true; + } + } + + message = "No connected input device with two usable axes was found."; + return false; + } + internal bool EnqueueSwitchFromMainThread(string switchId, bool isClosed) { if (!_started || _simulationThread == null) { @@ -361,6 +595,43 @@ private void LogStatistics(in SimulationState.Snapshot state) private static long TimestampUsec => (Stopwatch.GetTimestamp() * 1_000_000L) / Stopwatch.Frequency; + private static bool TryPickAxisPair(NativeInputDeviceInfo device, out NativeInputAxisInfo xAxis, + out NativeInputAxisInfo yAxis) + { + xAxis = default; + yAxis = default; + var xSet = false; + var ySet = false; + for (var i = 0; i < device.Axes.Count; i++) { + var axis = device.Axes[i]; + if (axis.Kind != NativeInputApi.AxisKind.Acceleration) { + continue; + } + if (!xSet) { + xAxis = axis; + xSet = true; + } else { + yAxis = axis; + ySet = true; + return true; + } + } + + for (var i = 0; i < device.Axes.Count; i++) { + var axis = device.Axes[i]; + if (!xSet) { + xAxis = axis; + xSet = true; + } else { + yAxis = axis; + ySet = true; + break; + } + } + + return xSet && ySet; + } + private void UpdateSimulationSpeed(in SimulationState.Snapshot state) { var now = Time.unscaledTime; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs index 9b64f5934..19aac03e9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs @@ -16,11 +16,58 @@ namespace VisualPinball.Unity.Simulation { + public struct SimulationThreadNudgeSensorPackable + { + public NudgeSensorType Type; + public float Strength; + public float CabinetMassKg; + public string X; + public string Y; + public string AccelerationX; + public string AccelerationY; + public string VelocityX; + public string VelocityY; + + public static SimulationThreadNudgeSensorPackable Pack(SimulationThreadNudgeSensorConfig sensor) + { + sensor ??= new SimulationThreadNudgeSensorConfig(); + sensor.Normalize(); + return new SimulationThreadNudgeSensorPackable { + Type = sensor.Type, + Strength = sensor.Strength, + CabinetMassKg = sensor.CabinetMassKg, + X = sensor.X, + Y = sensor.Y, + AccelerationX = sensor.AccelerationX, + AccelerationY = sensor.AccelerationY, + VelocityX = sensor.VelocityX, + VelocityY = sensor.VelocityY + }; + } + + public SimulationThreadNudgeSensorConfig Unpack() + { + return new SimulationThreadNudgeSensorConfig { + Type = Type, + Strength = Strength, + CabinetMassKg = CabinetMassKg, + X = X, + Y = Y, + AccelerationX = AccelerationX, + AccelerationY = AccelerationY, + VelocityX = VelocityX, + VelocityY = VelocityY + }; + } + } + public struct SimulationThreadComponentPackable { public bool EnableSimulationThread; public bool EnableNativeInput; public int InputPollingIntervalUs; + public bool HasNudgeSensors; + public SimulationThreadNudgeSensorPackable[] NudgeSensors; public bool ShowStatistics; public float StatisticsInterval; @@ -30,6 +77,8 @@ public static byte[] Pack(SimulationThreadComponent comp) EnableSimulationThread = comp.EnableSimulationThread, EnableNativeInput = comp.EnableNativeInput, InputPollingIntervalUs = comp.InputPollingIntervalUs, + HasNudgeSensors = true, + NudgeSensors = PackNudgeSensors(comp), ShowStatistics = comp.ShowStatistics, StatisticsInterval = comp.StatisticsInterval, }); @@ -41,8 +90,30 @@ public static void Unpack(byte[] bytes, SimulationThreadComponent comp) comp.EnableSimulationThread = data.EnableSimulationThread; comp.EnableNativeInput = data.EnableNativeInput; comp.InputPollingIntervalUs = data.InputPollingIntervalUs; + if (data.HasNudgeSensors) { + comp.NudgeSensors ??= new System.Collections.Generic.List(); + comp.NudgeSensors.Clear(); + if (data.NudgeSensors != null) { + for (var i = 0; i < data.NudgeSensors.Length && i < NudgeState.MaxSensors; i++) { + comp.NudgeSensors.Add(data.NudgeSensors[i].Unpack()); + } + } + } comp.ShowStatistics = data.ShowStatistics; comp.StatisticsInterval = data.StatisticsInterval; } + + private static SimulationThreadNudgeSensorPackable[] PackNudgeSensors(SimulationThreadComponent comp) + { + if (comp.NudgeSensors == null || comp.NudgeSensors.Count == 0) { + return null; + } + var count = comp.NudgeSensors.Count > NudgeState.MaxSensors ? NudgeState.MaxSensors : comp.NudgeSensors.Count; + var result = new SimulationThreadNudgeSensorPackable[count]; + for (var i = 0; i < count; i++) { + result[i] = SimulationThreadNudgeSensorPackable.Pack(comp.NudgeSensors[i]); + } + return result; + } } } From 24fb5743e41ab603f01420f4848c0d094042ea87 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 22:22:49 +0200 Subject: [PATCH 12/25] game: avoid direct play startup errors --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 5 +++-- .../VisualPinball.Unity/Sound/CoilSoundComponent.cs | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index fc2ab58f8..c6ba4ec89 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -696,8 +696,9 @@ internal void RegisterRuntimeBall(BallComponent ball) if (_ctx.UseExternalTiming) { lock (_ctx.PhysicsLock) { - if (_ctx.BallStates.Ref.IsCreated && !_ctx.BallStates.Ref.ContainsKey(ballId)) { - _ctx.BallStates.Ref[ballId] = ballState; + ref var ballStates = ref _ctx.BallStates.Ref; + if (!ballStates.ContainsKey(ballId)) { + ballStates[ballId] = ballState; } } return; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs index a1c25c9a5..ab9e4d1b4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs @@ -17,6 +17,7 @@ // ReSharper disable InconsistentNaming using System; +using System.Linq; using UnityEngine; using UnityEngine.Serialization; @@ -43,7 +44,15 @@ protected override bool TryFindEventSource(out IApiCoil coil) } foreach (var component in GetComponents()) { - coil = player.Coil(component, CoilName); + var coilName = CoilName; + if (string.IsNullOrEmpty(coilName)) { + var availableCoils = component.AvailableCoils.ToArray(); + if (availableCoils.Length != 1) { + continue; + } + coilName = availableCoils[0].Id; + } + coil = player.Coil(component, coilName); if (coil != null) { return true; } From 293248a86bec12bd464decb6e49dee87f01138f9 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 22:22:57 +0200 Subject: [PATCH 13/25] simulation: plot native input axes --- .../SimulationThreadComponentInspector.cs | 211 +++++++++++++++++- 1 file changed, 206 insertions(+), 5 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs index c0f0575cd..289acb00e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs @@ -15,6 +15,7 @@ // along with this program. If not, see . using System; +using System.Collections.Generic; using UnityEditor; using UnityEngine; using VisualPinball.Unity.Simulation; @@ -24,8 +25,23 @@ namespace VisualPinball.Unity.Editor [CustomEditor(typeof(SimulationThreadComponent)), CanEditMultipleObjects] public sealed class SimulationThreadComponentInspector : UnityEditor.Editor { + private const int GraphSampleCount = 120; + private const int GraphMaxAxes = 6; + + private static readonly Color[] GraphColors = { + new Color(0.95f, 0.32f, 0.28f), + new Color(0.24f, 0.62f, 1f), + new Color(0.34f, 0.82f, 0.42f), + new Color(1f, 0.72f, 0.2f), + new Color(0.74f, 0.46f, 1f), + new Color(0.25f, 0.86f, 0.8f) + }; + + private readonly Dictionary _axisGraphs = new Dictionary(); private string _statusMessage; private MessageType _statusType = MessageType.Info; + private GUIStyle _graphLegendStyle; + private double _lastGraphSampleTime; public override void OnInspectorGUI() { @@ -43,6 +59,11 @@ public override void OnInspectorGUI() } } + public override bool RequiresConstantRepaint() + { + return Application.isPlaying && targets.Length == 1; + } + private void DrawNudgeCalibration(SimulationThreadComponent component) { EditorGUILayout.Space(); @@ -61,6 +82,7 @@ private void DrawNudgeCalibration(SimulationThreadComponent component) EditorGUILayout.HelpBox("No native input devices are visible. Check that native input is enabled and that the device was connected before polling started.", MessageType.Warning); } else { DrawDevices(devices); + DrawInputGraph(devices); } EditorGUILayout.Space(); @@ -105,7 +127,7 @@ private void DrawNudgeCalibration(SimulationThreadComponent component) } } - private void DrawDevices(System.Collections.Generic.IReadOnlyList devices) + private void DrawDevices(IReadOnlyList devices) { EditorGUILayout.Space(); EditorGUILayout.LabelField("Native Devices", EditorStyles.boldLabel); @@ -127,17 +149,173 @@ private void DrawDevices(System.Collections.Generic.IReadOnlyList devices) + { + if (!TryFindGraphDevice(devices, out var device)) { + return; + } + + SampleGraphDevice(device); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField($"Input Graph: {DeviceName(device)}", EditorStyles.boldLabel); + + var rect = GUILayoutUtility.GetRect(1f, 72f, GUILayout.ExpandWidth(true)); + DrawGraphFrame(rect); + + var axisCount = System.Math.Min(device.Axes.Count, GraphMaxAxes); + for (var i = 0; i < axisCount; i++) { + var axis = device.Axes[i]; + if (_axisGraphs.TryGetValue(GraphKey(device, axis), out var graph)) { + DrawGraphLine(rect, graph, GraphColors[i % GraphColors.Length]); + } + } + + DrawGraphLegend(device, axisCount); + } + + private void SampleGraphDevice(NativeInputDeviceInfo device) + { + var now = EditorApplication.timeSinceStartup; + if (now - _lastGraphSampleTime < 1.0 / 60.0) { + return; + } + _lastGraphSampleTime = now; + + var axisCount = System.Math.Min(device.Axes.Count, GraphMaxAxes); + for (var i = 0; i < axisCount; i++) { + var axis = device.Axes[i]; + var key = GraphKey(device, axis); + if (!_axisGraphs.TryGetValue(key, out var graph)) { + graph = new AxisGraphState(); + _axisGraphs[key] = graph; + } + graph.Add(axis.RawValue); + } + } + + private void DrawGraphFrame(Rect rect) + { + EditorGUI.DrawRect(rect, new Color(0.1f, 0.1f, 0.1f)); + + Handles.BeginGUI(); + var previousColor = Handles.color; + Handles.color = new Color(1f, 1f, 1f, 0.18f); + Handles.DrawLine(new Vector3(rect.xMin, rect.center.y), new Vector3(rect.xMax, rect.center.y)); + Handles.color = new Color(1f, 1f, 1f, 0.08f); + Handles.DrawLine(new Vector3(rect.xMin, rect.yMin + rect.height * 0.25f), new Vector3(rect.xMax, rect.yMin + rect.height * 0.25f)); + Handles.DrawLine(new Vector3(rect.xMin, rect.yMin + rect.height * 0.75f), new Vector3(rect.xMax, rect.yMin + rect.height * 0.75f)); + Handles.color = previousColor; + Handles.EndGUI(); + } + + private static void DrawGraphLine(Rect rect, AxisGraphState graph, Color color) + { + if (graph.Count < 2) { + return; + } + + Handles.BeginGUI(); + var previousColor = Handles.color; + Handles.color = color; + var previous = GraphPoint(rect, graph, 0); + for (var i = 1; i < graph.Count; i++) { + var next = GraphPoint(rect, graph, i); + Handles.DrawLine(previous, next); + previous = next; + } + Handles.color = previousColor; + Handles.EndGUI(); + } + + private static Vector3 GraphPoint(Rect rect, AxisGraphState graph, int index) + { + var x = graph.Count <= 1 ? rect.xMin : Mathf.Lerp(rect.xMin, rect.xMax, index / (float)(graph.Count - 1)); + var value = Mathf.Clamp(graph.Get(index), -1f, 1f); + var y = rect.center.y - value * rect.height * 0.45f; + return new Vector3(x, y); + } + + private void DrawGraphLegend(NativeInputDeviceInfo device, int axisCount) + { + if (_graphLegendStyle == null) { + _graphLegendStyle = new GUIStyle(EditorStyles.miniLabel) { + richText = true, + wordWrap = false + }; + } + + EditorGUILayout.BeginHorizontal(); + for (var i = 0; i < axisCount; i++) { + var axis = device.Axes[i]; + var color = GraphColors[i % GraphColors.Length]; + var colorHex = ColorUtility.ToHtmlStringRGB(color); + GUILayout.Label($"{AxisName(axis)} {axis.RawValue:+0.00;-0.00;0.00}", _graphLegendStyle); + } + EditorGUILayout.EndHorizontal(); + } + + private static bool TryFindGraphDevice(IReadOnlyList devices, out NativeInputDeviceInfo device) + { + for (var i = 0; i < devices.Count; i++) { + if (IsGraphableDevice(devices[i]) && IsKl25zDevice(devices[i])) { + device = devices[i]; + return true; + } + } + for (var i = 0; i < devices.Count; i++) { + if (IsGraphableDevice(devices[i])) { + device = devices[i]; + return true; + } + } + + device = default; + return false; + } + + private static bool IsGraphableDevice(NativeInputDeviceInfo device) + { + return device.IsConnected && device.Axes != null && device.Axes.Count > 0; + } + + private static bool IsKl25zDevice(NativeInputDeviceInfo device) + { + return ContainsIgnoreCase(device.Name, "KL25Z") + || ContainsIgnoreCase(device.Id, "KL25Z") + || ContainsIgnoreCase(device.Name, "Pinscape") + || ContainsIgnoreCase(device.Id, "Pinscape"); + } + + private static bool ContainsIgnoreCase(string value, string match) + { + return !string.IsNullOrEmpty(value) && value.IndexOf(match, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static string GraphKey(NativeInputDeviceInfo device, NativeInputAxisInfo axis) + { + return $"{device.Id}:{axis.AxisId}"; + } + private static string DeviceLabel(NativeInputDeviceInfo device) { - var name = string.IsNullOrEmpty(device.Name) ? device.Id : device.Name; var state = device.IsConnected ? "connected" : "disconnected"; - return $"{name} ({state})"; + return $"{DeviceName(device)} ({state})"; + } + + private static string DeviceName(NativeInputDeviceInfo device) + { + return string.IsNullOrEmpty(device.Name) ? device.Id : device.Name; } private static string AxisLabel(NativeInputAxisInfo axis) { - var name = string.IsNullOrEmpty(axis.Name) ? $"Axis {axis.AxisId}" : axis.Name; - return $"{name}: {axis.RawValue:+0.000;-0.000;0.000} [{axis.Kind}]"; + return $"{AxisName(axis)}: {axis.RawValue:+0.000;-0.000;0.000} [{axis.Kind}]"; + } + + private static string AxisName(NativeInputAxisInfo axis) + { + return string.IsNullOrEmpty(axis.Name) ? $"Axis {axis.AxisId}" : axis.Name; } private void SetStatus(string message, MessageType type) @@ -145,5 +323,28 @@ private void SetStatus(string message, MessageType type) _statusMessage = message; _statusType = type; } + + private sealed class AxisGraphState + { + private readonly float[] _samples = new float[GraphSampleCount]; + private int _next; + + public int Count { get; private set; } + + public void Add(float value) + { + _samples[_next] = value; + _next = (_next + 1) % _samples.Length; + if (Count < _samples.Length) { + Count++; + } + } + + public float Get(int index) + { + var start = (_next - Count + _samples.Length) % _samples.Length; + return _samples[(start + index) % _samples.Length]; + } + } } } From 61733855488c278d161db07349b0192954d276c5 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 22:36:21 +0200 Subject: [PATCH 14/25] simulation: graph calibrated nudge axes --- .../SimulationThreadComponentInspector.cs | 145 +++++++++++++++--- .../Simulation/SimulationThreadComponent.cs | 73 +++++++-- 2 files changed, 181 insertions(+), 37 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs index 289acb00e..a0579185f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs @@ -82,7 +82,7 @@ private void DrawNudgeCalibration(SimulationThreadComponent component) EditorGUILayout.HelpBox("No native input devices are visible. Check that native input is enabled and that the device was connected before polling started.", MessageType.Warning); } else { DrawDevices(devices); - DrawInputGraph(devices); + DrawInputGraph(component, devices); } EditorGUILayout.Space(); @@ -149,32 +149,40 @@ private void DrawDevices(IReadOnlyList devices) } } - private void DrawInputGraph(IReadOnlyList devices) + private void DrawInputGraph(SimulationThreadComponent component, IReadOnlyList devices) { - if (!TryFindGraphDevice(devices, out var device)) { + var channels = new List(); + var title = "Nudge Graph: Calibrated Mappings"; + CollectMappedGraphChannels(component, devices, channels); + if (channels.Count == 0) { + if (!TryFindGraphDevice(devices, out var device)) { + return; + } + title = $"Raw Input Graph: {DeviceName(device)}"; + CollectRawGraphChannels(device, channels); + } + if (channels.Count == 0) { return; } - SampleGraphDevice(device); + SampleGraphChannels(channels); EditorGUILayout.Space(); - EditorGUILayout.LabelField($"Input Graph: {DeviceName(device)}", EditorStyles.boldLabel); + EditorGUILayout.LabelField(title, EditorStyles.boldLabel); var rect = GUILayoutUtility.GetRect(1f, 72f, GUILayout.ExpandWidth(true)); DrawGraphFrame(rect); - var axisCount = System.Math.Min(device.Axes.Count, GraphMaxAxes); - for (var i = 0; i < axisCount; i++) { - var axis = device.Axes[i]; - if (_axisGraphs.TryGetValue(GraphKey(device, axis), out var graph)) { + for (var i = 0; i < channels.Count; i++) { + if (_axisGraphs.TryGetValue(channels[i].Key, out var graph)) { DrawGraphLine(rect, graph, GraphColors[i % GraphColors.Length]); } } - DrawGraphLegend(device, axisCount); + DrawGraphLegend(channels); } - private void SampleGraphDevice(NativeInputDeviceInfo device) + private void SampleGraphChannels(IReadOnlyList channels) { var now = EditorApplication.timeSinceStartup; if (now - _lastGraphSampleTime < 1.0 / 60.0) { @@ -182,15 +190,13 @@ private void SampleGraphDevice(NativeInputDeviceInfo device) } _lastGraphSampleTime = now; - var axisCount = System.Math.Min(device.Axes.Count, GraphMaxAxes); - for (var i = 0; i < axisCount; i++) { - var axis = device.Axes[i]; - var key = GraphKey(device, axis); - if (!_axisGraphs.TryGetValue(key, out var graph)) { + for (var i = 0; i < channels.Count; i++) { + var channel = channels[i]; + if (!_axisGraphs.TryGetValue(channel.Key, out var graph)) { graph = new AxisGraphState(); - _axisGraphs[key] = graph; + _axisGraphs[channel.Key] = graph; } - graph.Add(axis.RawValue); + graph.Add(channel.Value); } } @@ -236,7 +242,7 @@ private static Vector3 GraphPoint(Rect rect, AxisGraphState graph, int index) return new Vector3(x, y); } - private void DrawGraphLegend(NativeInputDeviceInfo device, int axisCount) + private void DrawGraphLegend(IReadOnlyList channels) { if (_graphLegendStyle == null) { _graphLegendStyle = new GUIStyle(EditorStyles.miniLabel) { @@ -246,15 +252,96 @@ private void DrawGraphLegend(NativeInputDeviceInfo device, int axisCount) } EditorGUILayout.BeginHorizontal(); - for (var i = 0; i < axisCount; i++) { - var axis = device.Axes[i]; + for (var i = 0; i < channels.Count; i++) { var color = GraphColors[i % GraphColors.Length]; var colorHex = ColorUtility.ToHtmlStringRGB(color); - GUILayout.Label($"{AxisName(axis)} {axis.RawValue:+0.00;-0.00;0.00}", _graphLegendStyle); + GUILayout.Label($"{channels[i].Label} {channels[i].Value:+0.00;-0.00;0.00}", _graphLegendStyle); } EditorGUILayout.EndHorizontal(); } + private static void CollectMappedGraphChannels(SimulationThreadComponent component, + IReadOnlyList devices, List channels) + { + if (component.NudgeSensors == null) { + return; + } + + for (var sensorIndex = 0; sensorIndex < component.NudgeSensors.Count && channels.Count < GraphMaxAxes; sensorIndex++) { + var sensor = component.NudgeSensors[sensorIndex]; + if (sensor == null) { + continue; + } + AddMappedGraphChannel(sensorIndex, "X", sensor.X, devices, channels); + AddMappedGraphChannel(sensorIndex, "Y", sensor.Y, devices, channels); + AddMappedGraphChannel(sensorIndex, "Velocity X", sensor.VelocityX, devices, channels); + AddMappedGraphChannel(sensorIndex, "Velocity Y", sensor.VelocityY, devices, channels); + AddMappedGraphChannel(sensorIndex, "Accel X", sensor.AccelerationX, devices, channels); + AddMappedGraphChannel(sensorIndex, "Accel Y", sensor.AccelerationY, devices, channels); + } + } + + private static void AddMappedGraphChannel(int sensorIndex, string channelName, string mappingValue, + IReadOnlyList devices, List channels) + { + if (channels.Count >= GraphMaxAxes || !SensorMapping.TryParse(mappingValue, out var mapping)) { + return; + } + if (!TryFindAxis(devices, mapping.DeviceId, mapping.AxisId, out var axis)) { + return; + } + + channels.Add(new InputGraphChannel( + $"mapped:{sensorIndex}:{channelName}:{mapping.DeviceId}:{mapping.AxisId}", + $"{channelName} ({AxisName(axis)})", + CalculateMappedGraphValue(mapping, axis.RawValue))); + } + + private static void CollectRawGraphChannels(NativeInputDeviceInfo device, List channels) + { + var axisCount = System.Math.Min(device.Axes.Count, GraphMaxAxes); + for (var i = 0; i < axisCount; i++) { + var axis = device.Axes[i]; + channels.Add(new InputGraphChannel( + GraphKey(device, axis), + AxisName(axis), + axis.RawValue)); + } + } + + private static float CalculateMappedGraphValue(SensorMapping mapping, float rawValue) + { + var value = Mathf.Clamp(Mathf.Clamp(rawValue, -1f, 1f) - mapping.RawCenter, -1f, 1f); + var deadZone = Mathf.Clamp(mapping.DeadZone, 0f, 0.999f); + var absValue = Mathf.Abs(value); + if (absValue <= deadZone) { + return 0f; + } + value = Mathf.Sign(value) * ((absValue - deadZone) / (1f - deadZone)); + var limit = Mathf.Max(0f, mapping.Limit); + return Mathf.Clamp(value, -limit, limit); + } + + private static bool TryFindAxis(IReadOnlyList devices, string deviceId, int axisId, + out NativeInputAxisInfo axis) + { + for (var i = 0; i < devices.Count; i++) { + var device = devices[i]; + if (device.Id != deviceId || device.Axes == null) { + continue; + } + for (var j = 0; j < device.Axes.Count; j++) { + if (device.Axes[j].AxisId == axisId) { + axis = device.Axes[j]; + return true; + } + } + } + + axis = default; + return false; + } + private static bool TryFindGraphDevice(IReadOnlyList devices, out NativeInputDeviceInfo device) { for (var i = 0; i < devices.Count; i++) { @@ -324,6 +411,20 @@ private void SetStatus(string message, MessageType type) _statusType = type; } + private sealed class InputGraphChannel + { + public readonly string Key; + public readonly string Label; + public readonly float Value; + + public InputGraphChannel(string key, string label, float value) + { + Key = key; + Label = label; + Value = value; + } + } + private sealed class AxisGraphState { private readonly float[] _samples = new float[GraphSampleCount]; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 1dde7a043..43272b0b3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -530,7 +530,7 @@ public bool TryAutoConfigureFirstCabinetSensor(out string message) } ApplyNudgeSensorSettings(); var deviceName = string.IsNullOrEmpty(device.Name) ? device.Id : device.Name; - message = $"Mapped {deviceName} axes {xAxis.AxisId}/{yAxis.AxisId}."; + message = $"Mapped {deviceName} axes {AxisName(xAxis)}/{AxisName(yAxis)}."; return true; } } @@ -600,11 +600,24 @@ private static bool TryPickAxisPair(NativeInputDeviceInfo device, out NativeInpu { xAxis = default; yAxis = default; - var xSet = false; - var ySet = false; + if (TryFindAxis(device, "X", 0x30, NativeInputApi.AxisKind.Acceleration, out xAxis) && + TryFindAxis(device, "Y", 0x31, NativeInputApi.AxisKind.Acceleration, out yAxis) && + xAxis.AxisId != yAxis.AxisId) { + return true; + } + + var xSet = TryFindAxis(device, "X", 0x30, null, out xAxis); + var ySet = TryFindAxis(device, "Y", 0x31, null, out yAxis); + if (xSet && ySet && xAxis.AxisId != yAxis.AxisId) { + return true; + } + for (var i = 0; i < device.Axes.Count; i++) { var axis = device.Axes[i]; - if (axis.Kind != NativeInputApi.AxisKind.Acceleration) { + if (axis.Kind != NativeInputApi.AxisKind.Acceleration || IsNamedAxis(axis, "Z", 0x32)) { + continue; + } + if ((xSet && axis.AxisId == xAxis.AxisId) || (ySet && axis.AxisId == yAxis.AxisId)) { continue; } if (!xSet) { @@ -613,23 +626,53 @@ private static bool TryPickAxisPair(NativeInputDeviceInfo device, out NativeInpu } else { yAxis = axis; ySet = true; - return true; + break; } } - for (var i = 0; i < device.Axes.Count; i++) { - var axis = device.Axes[i]; - if (!xSet) { - xAxis = axis; - xSet = true; - } else { - yAxis = axis; - ySet = true; - break; + return xSet && ySet && xAxis.AxisId != yAxis.AxisId; + } + + private static bool TryFindAxis(NativeInputDeviceInfo device, string axisName, int usage, + NativeInputApi.AxisKind? kind, out NativeInputAxisInfo axis) + { + if (device.Axes != null) { + for (var i = 0; i < device.Axes.Count; i++) { + var candidate = device.Axes[i]; + if (kind.HasValue && candidate.Kind != kind.Value) { + continue; + } + if (IsNamedAxis(candidate, axisName, usage)) { + axis = candidate; + return true; + } } } + axis = default; + return false; + } - return xSet && ySet; + private static bool IsNamedAxis(NativeInputAxisInfo axis, string axisName, int usage) + { + if (axis.Usage == usage) { + return true; + } + + var name = axis.Name; + if (string.IsNullOrEmpty(name)) { + return false; + } + if (string.Equals(name, axisName, StringComparison.OrdinalIgnoreCase)) { + return true; + } + return name.EndsWith(" " + axisName, StringComparison.OrdinalIgnoreCase) + || name.EndsWith("-" + axisName, StringComparison.OrdinalIgnoreCase) + || name.EndsWith("_" + axisName, StringComparison.OrdinalIgnoreCase); + } + + private static string AxisName(NativeInputAxisInfo axis) + { + return string.IsNullOrEmpty(axis.Name) ? $"Axis {axis.AxisId}" : axis.Name; } private void UpdateSimulationSpeed(in SimulationState.Snapshot state) From a4ac2899e9e19f38e073dba5f7d520e5f0b295e8 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 22:50:50 +0200 Subject: [PATCH 15/25] simulation: add nudge sensor mount orientation --- .../SimulationThreadComponentInspector.cs | 39 +++-- .../Physics/NudgeSensorStateTests.cs | 54 +++++++ .../VisualPinball.Unity/Game/NudgeSystem.cs | 5 + .../Cabinet/NudgeSensorMountTransform.cs | 139 ++++++++++++++++++ .../Cabinet/NudgeSensorMountTransform.cs.meta | 2 + .../Physics/Cabinet/NudgeSensorState.cs | 22 ++- .../Physics/Cabinet/NudgeState.cs | 1 + .../Simulation/SimulationThreadComponent.cs | 12 ++ .../SimulationThreadComponentPackable.cs | 6 + 9 files changed, 266 insertions(+), 14 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs index a0579185f..44ad5fe57 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs @@ -152,7 +152,7 @@ private void DrawDevices(IReadOnlyList devices) private void DrawInputGraph(SimulationThreadComponent component, IReadOnlyList devices) { var channels = new List(); - var title = "Nudge Graph: Calibrated Mappings"; + var title = "Nudge Graph: Mounted Mappings"; CollectMappedGraphChannels(component, devices, channels); if (channels.Count == 0) { if (!TryFindGraphDevice(devices, out var device)) { @@ -272,16 +272,17 @@ private static void CollectMappedGraphChannels(SimulationThreadComponent compone if (sensor == null) { continue; } - AddMappedGraphChannel(sensorIndex, "X", sensor.X, devices, channels); - AddMappedGraphChannel(sensorIndex, "Y", sensor.Y, devices, channels); - AddMappedGraphChannel(sensorIndex, "Velocity X", sensor.VelocityX, devices, channels); - AddMappedGraphChannel(sensorIndex, "Velocity Y", sensor.VelocityY, devices, channels); - AddMappedGraphChannel(sensorIndex, "Accel X", sensor.AccelerationX, devices, channels); - AddMappedGraphChannel(sensorIndex, "Accel Y", sensor.AccelerationY, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.X, sensor.X, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.Y, sensor.Y, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.VelocityX, sensor.VelocityX, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.VelocityY, sensor.VelocityY, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.AccelerationX, sensor.AccelerationX, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.AccelerationY, sensor.AccelerationY, devices, channels); } } - private static void AddMappedGraphChannel(int sensorIndex, string channelName, string mappingValue, + private static void AddMappedGraphChannel(int sensorIndex, SimulationThreadNudgeSensorConfig sensor, + NudgeSensorChannel sourceChannel, string mappingValue, IReadOnlyList devices, List channels) { if (channels.Count >= GraphMaxAxes || !SensorMapping.TryParse(mappingValue, out var mapping)) { @@ -291,10 +292,14 @@ private static void AddMappedGraphChannel(int sensorIndex, string channelName, s return; } + var channel = sourceChannel; + var value = CalculateMappedGraphValue(mapping, axis.RawValue); + NudgeSensorMountTransform.TransformChannel(ref channel, ref value, sensor.MountRotation, sensor.MountMirror); + channels.Add(new InputGraphChannel( - $"mapped:{sensorIndex}:{channelName}:{mapping.DeviceId}:{mapping.AxisId}", - $"{channelName} ({AxisName(axis)})", - CalculateMappedGraphValue(mapping, axis.RawValue))); + $"mapped:{sensorIndex}:{sourceChannel}:{channel}:{mapping.DeviceId}:{mapping.AxisId}:{sensor.MountRotation}:{sensor.MountMirror}", + $"{ChannelName(channel)} ({AxisName(axis)})", + value)); } private static void CollectRawGraphChannels(NativeInputDeviceInfo device, List channels) @@ -400,6 +405,18 @@ private static string AxisLabel(NativeInputAxisInfo axis) return $"{AxisName(axis)}: {axis.RawValue:+0.000;-0.000;0.000} [{axis.Kind}]"; } + private static string ChannelName(NudgeSensorChannel channel) + { + return channel switch { + NudgeSensorChannel.VelocityX => "Velocity X", + NudgeSensorChannel.VelocityY => "Velocity Y", + NudgeSensorChannel.AccelerationX => "Accel X", + NudgeSensorChannel.AccelerationY => "Accel Y", + NudgeSensorChannel.Y => "Y", + _ => "X" + }; + } + private static string AxisName(NativeInputAxisInfo axis) { return string.IsNullOrEmpty(axis.Name) ? $"Axis {axis.AxisId}" : axis.Name; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs index 9406629d1..34e61228e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs @@ -119,6 +119,36 @@ public void GainCalibratorSurvivesLongSegmentsByCompacting() Assert.That(calibrator.Gain, Is.EqualTo(expectedGain).Within(0.15f)); } + [TestCase(NudgeSensorMountRotation.Rotation0, false, NudgeSensorChannel.AccelerationX, 2f, NudgeSensorChannel.AccelerationX, 2f)] + [TestCase(NudgeSensorMountRotation.Rotation90, false, NudgeSensorChannel.AccelerationX, 2f, NudgeSensorChannel.AccelerationY, 2f)] + [TestCase(NudgeSensorMountRotation.Rotation90, false, NudgeSensorChannel.AccelerationY, 2f, NudgeSensorChannel.AccelerationX, -2f)] + [TestCase(NudgeSensorMountRotation.Rotation180, false, NudgeSensorChannel.VelocityX, 2f, NudgeSensorChannel.VelocityX, -2f)] + [TestCase(NudgeSensorMountRotation.Rotation270, false, NudgeSensorChannel.X, 2f, NudgeSensorChannel.Y, -2f)] + [TestCase(NudgeSensorMountRotation.Rotation90, true, NudgeSensorChannel.AccelerationX, 2f, NudgeSensorChannel.AccelerationY, -2f)] + public void MountTransformRotatesAndMirrorsChannels(NudgeSensorMountRotation rotation, bool mirror, + NudgeSensorChannel sourceChannel, float sourceValue, NudgeSensorChannel expectedChannel, float expectedValue) + { + var channel = sourceChannel; + var value = sourceValue; + + NudgeSensorMountTransform.TransformChannel(ref channel, ref value, rotation, mirror); + + Assert.That(channel, Is.EqualTo(expectedChannel)); + Assert.That(value, Is.EqualTo(expectedValue).Within(1e-6f)); + } + + [Test] + public void MountTransformRotatesMappedFlags() + { + var xMapped = true; + var yMapped = false; + + NudgeSensorMountTransform.TransformMappedAxes(ref xMapped, ref yMapped, NudgeSensorMountRotation.Rotation90); + + Assert.That(xMapped, Is.False); + Assert.That(yMapped, Is.True); + } + [Test] public void DirectCabinetSensorCanDriveNudgeState() { @@ -142,6 +172,30 @@ public void DirectCabinetSensorCanDriveNudgeState() Assert.That(math.abs(nudge.CabinetOffset.y), Is.GreaterThan(0f)); } + [Test] + public void DirectCabinetSensorAppliesMountTransform() + { + var nudge = new NudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.ConfigureSensor(0, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + MountRotation = NudgeSensorMountRotation.Rotation90, + MountMirror = 1, + AccelerationXMapped = 1 + }); + + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationX, 0f, 1000); + nudge.StepOneMillisecond(); + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationX, 12f, 2000); + for (var i = 0; i < 10; i++) { + nudge.StepOneMillisecond(); + } + + Assert.That(nudge.ActiveSourceIndex, Is.EqualTo(0)); + Assert.That(nudge.CabinetAcceleration.y, Is.LessThan(0f)); + } + [Test] public void DirectCabinetSensorStrengthScalesLinearly() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs index 1d7c6b859..a3575c2c6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs @@ -26,6 +26,8 @@ public sealed class NudgeSensorConfig public NudgeSensorType Type = NudgeSensorType.GamepadIntent; public float Strength = 1f; public float CabinetMassKg = 113f; + public NudgeSensorMountRotation MountRotation = NudgeSensorMountRotation.Rotation0; + public bool MountMirror; public SensorMapping X = new(); public SensorMapping Y = new(); public SensorMapping AccelerationX = new(); @@ -37,6 +39,7 @@ public void Normalize() { Strength = math.clamp(Strength, 0f, 2f); CabinetMassKg = math.clamp(CabinetMassKg <= 0f ? 113f : CabinetMassKg, 0f, 200f); + MountRotation = NudgeSensorMountTransform.NormalizeRotation(MountRotation); X ??= new SensorMapping(); Y ??= new SensorMapping(); AccelerationX ??= new SensorMapping(); @@ -52,6 +55,8 @@ internal NudgeSensorRuntimeConfig ToRuntimeConfig() Type = Type, Strength = Strength, CabinetMassKg = CabinetMassKg, + MountRotation = MountRotation, + MountMirror = MountMirror ? (byte)1 : (byte)0, XMapped = X.IsMapped ? (byte)1 : (byte)0, YMapped = Y.IsMapped ? (byte)1 : (byte)0, AccelerationXMapped = AccelerationX.IsMapped ? (byte)1 : (byte)0, diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs new file mode 100644 index 000000000..973f31cb8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs @@ -0,0 +1,139 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public enum NudgeSensorMountRotation + { + [InspectorName("0 deg")] + Rotation0 = 0, + + [InspectorName("90 deg")] + Rotation90 = 1, + + [InspectorName("180 deg")] + Rotation180 = 2, + + [InspectorName("270 deg")] + Rotation270 = 3 + } + + public static class NudgeSensorMountTransform + { + public static NudgeSensorMountRotation NormalizeRotation(NudgeSensorMountRotation rotation) + { + return (uint)rotation <= (uint)NudgeSensorMountRotation.Rotation270 + ? rotation + : NudgeSensorMountRotation.Rotation0; + } + + public static float2 Transform(float2 value, NudgeSensorMountRotation rotation, bool mirrorX) + { + if (mirrorX) { + value.x = -value.x; + } + + switch (NormalizeRotation(rotation)) { + case NudgeSensorMountRotation.Rotation90: + return new float2(-value.y, value.x); + case NudgeSensorMountRotation.Rotation180: + return -value; + case NudgeSensorMountRotation.Rotation270: + return new float2(value.y, -value.x); + default: + return value; + } + } + + public static void TransformChannel(ref NudgeSensorChannel channel, ref float value, + NudgeSensorMountRotation rotation, bool mirrorX) + { + if (!TryGetChannelAxis(channel, out var sourceX, out var group)) { + return; + } + + var basis = sourceX ? new float2(1f, 0f) : new float2(0f, 1f); + var transformed = Transform(basis, rotation, mirrorX); + var targetX = math.abs(transformed.x) > math.abs(transformed.y); + value *= targetX ? transformed.x : transformed.y; + channel = ChannelFor(group, targetX); + } + + public static void TransformMappedAxes(ref bool xMapped, ref bool yMapped, + NudgeSensorMountRotation rotation) + { + switch (NormalizeRotation(rotation)) { + case NudgeSensorMountRotation.Rotation90: + case NudgeSensorMountRotation.Rotation270: + (xMapped, yMapped) = (yMapped, xMapped); + break; + } + } + + private static bool TryGetChannelAxis(NudgeSensorChannel channel, out bool sourceX, out ChannelGroup group) + { + switch (channel) { + case NudgeSensorChannel.X: + sourceX = true; + group = ChannelGroup.Position; + return true; + case NudgeSensorChannel.Y: + sourceX = false; + group = ChannelGroup.Position; + return true; + case NudgeSensorChannel.VelocityX: + sourceX = true; + group = ChannelGroup.Velocity; + return true; + case NudgeSensorChannel.VelocityY: + sourceX = false; + group = ChannelGroup.Velocity; + return true; + case NudgeSensorChannel.AccelerationX: + sourceX = true; + group = ChannelGroup.Acceleration; + return true; + case NudgeSensorChannel.AccelerationY: + sourceX = false; + group = ChannelGroup.Acceleration; + return true; + default: + sourceX = false; + group = default; + return false; + } + } + + private static NudgeSensorChannel ChannelFor(ChannelGroup group, bool x) + { + return group switch { + ChannelGroup.Velocity => x ? NudgeSensorChannel.VelocityX : NudgeSensorChannel.VelocityY, + ChannelGroup.Acceleration => x ? NudgeSensorChannel.AccelerationX : NudgeSensorChannel.AccelerationY, + _ => x ? NudgeSensorChannel.X : NudgeSensorChannel.Y + }; + } + + private enum ChannelGroup + { + Position, + Velocity, + Acceleration + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs.meta new file mode 100644 index 000000000..d0609ad30 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd6fb25eb11e4894e99c6225c8e744f4 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs index c8d3af7b4..a94fb47e6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs @@ -23,6 +23,8 @@ public struct NudgeSensorRuntimeConfig public NudgeSensorType Type; public float Strength; public float CabinetMassKg; + public NudgeSensorMountRotation MountRotation; + public byte MountMirror; public byte XMapped; public byte YMapped; public byte VelocityXMapped; @@ -373,6 +375,8 @@ public struct NudgeSensorState { public NudgeSensorType Type; public byte Enabled; + public NudgeSensorMountRotation MountRotation; + public byte MountMirror; public GamepadNudgeState Gamepad; public CabinetSensorState Cabinet; public float2 CabinetAcceleration; @@ -385,10 +389,21 @@ public NudgeSensorState(NudgeSensorRuntimeConfig config) { Type = config.Type; Enabled = 1; - Gamepad = new GamepadNudgeState(config.Strength, config.XMapped != 0, config.YMapped != 0); + MountRotation = NudgeSensorMountTransform.NormalizeRotation(config.MountRotation); + MountMirror = config.MountMirror; + var xMapped = config.XMapped != 0; + var yMapped = config.YMapped != 0; + var velXMapped = config.VelocityXMapped != 0; + var velYMapped = config.VelocityYMapped != 0; + var accXMapped = config.AccelerationXMapped != 0; + var accYMapped = config.AccelerationYMapped != 0; + NudgeSensorMountTransform.TransformMappedAxes(ref xMapped, ref yMapped, MountRotation); + NudgeSensorMountTransform.TransformMappedAxes(ref velXMapped, ref velYMapped, MountRotation); + NudgeSensorMountTransform.TransformMappedAxes(ref accXMapped, ref accYMapped, MountRotation); + Gamepad = new GamepadNudgeState(config.Strength, xMapped, yMapped); Cabinet = new CabinetSensorState(config.Type, config.Strength, config.CabinetMassKg, - config.VelocityXMapped != 0, config.VelocityYMapped != 0, - config.AccelerationXMapped != 0, config.AccelerationYMapped != 0); + velXMapped, velYMapped, + accXMapped, accYMapped); CabinetAcceleration = float2.zero; CabinetOffset = float2.zero; } @@ -405,6 +420,7 @@ public void ApplySample(NudgeSensorChannel channel, float value, ulong timestamp if (!IsEnabled) { return; } + NudgeSensorMountTransform.TransformChannel(ref channel, ref value, MountRotation, MountMirror != 0); if (Type == NudgeSensorType.GamepadIntent) { Gamepad.ApplySample(channel, value, timestampUsec); } else { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs index 5c7a8962c..5ef6199e5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs @@ -99,6 +99,7 @@ public void ConfigureSensor(int index, NudgeSensorRuntimeConfig config) } config.Strength = math.clamp(config.Strength, 0f, 2f); config.CabinetMassKg = math.clamp(config.CabinetMassKg <= 0f ? 113f : config.CabinetMassKg, 0f, 200f); + config.MountRotation = NudgeSensorMountTransform.NormalizeRotation(config.MountRotation); SetSensor(index, new NudgeSensorState(config)); if (index >= SensorCount) { SensorCount = index + 1; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 43272b0b3..72199207c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -25,6 +25,12 @@ public sealed class SimulationThreadNudgeSensorConfig [Range(0f, 200f)] public float CabinetMassKg = 113f; + [Tooltip("Rotates the sensor board X/Y axes into cabinet coordinates.")] + public NudgeSensorMountRotation MountRotation = NudgeSensorMountRotation.Rotation0; + + [Tooltip("Mirrors the sensor board X axis before applying mount rotation.")] + public bool MountMirror; + public string X = string.Empty; public string Y = string.Empty; public string AccelerationX = string.Empty; @@ -36,6 +42,7 @@ public void Normalize() { Strength = Mathf.Clamp(Strength, 0f, 2f); CabinetMassKg = Mathf.Clamp(CabinetMassKg <= 0f ? 113f : CabinetMassKg, 0f, 200f); + MountRotation = NudgeSensorMountTransform.NormalizeRotation(MountRotation); X ??= string.Empty; Y ??= string.Empty; AccelerationX ??= string.Empty; @@ -51,6 +58,8 @@ public NudgeSensorConfig ToEngineConfig() Type = Type, Strength = Strength, CabinetMassKg = CabinetMassKg, + MountRotation = MountRotation, + MountMirror = MountMirror, X = ParseMapping(X), Y = ParseMapping(Y), AccelerationX = ParseMapping(AccelerationX), @@ -514,10 +523,13 @@ public bool TryAutoConfigureFirstCabinetSensor(out string message) } if (TryPickAxisPair(device, out var xAxis, out var yAxis)) { NudgeSensors ??= new List(); + var existingSensor = NudgeSensors.Count == 0 ? null : NudgeSensors[0]; var sensor = new SimulationThreadNudgeSensorConfig { Type = NudgeSensorType.CabinetDirect, Strength = 1f, CabinetMassKg = 113f, + MountRotation = existingSensor?.MountRotation ?? NudgeSensorMountRotation.Rotation0, + MountMirror = existingSensor?.MountMirror ?? false, AccelerationX = SimulationThreadNudgeSensorConfig.BuildMapping(device, xAxis, SensorMappingKind.Acceleration, 9.81f, xAxis.RawValue), AccelerationY = SimulationThreadNudgeSensorConfig.BuildMapping(device, yAxis, diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs index 19aac03e9..21d52c27c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs @@ -21,6 +21,8 @@ public struct SimulationThreadNudgeSensorPackable public NudgeSensorType Type; public float Strength; public float CabinetMassKg; + public NudgeSensorMountRotation MountRotation; + public bool MountMirror; public string X; public string Y; public string AccelerationX; @@ -36,6 +38,8 @@ public static SimulationThreadNudgeSensorPackable Pack(SimulationThreadNudgeSens Type = sensor.Type, Strength = sensor.Strength, CabinetMassKg = sensor.CabinetMassKg, + MountRotation = sensor.MountRotation, + MountMirror = sensor.MountMirror, X = sensor.X, Y = sensor.Y, AccelerationX = sensor.AccelerationX, @@ -51,6 +55,8 @@ public SimulationThreadNudgeSensorConfig Unpack() Type = Type, Strength = Strength, CabinetMassKg = CabinetMassKg, + MountRotation = MountRotation, + MountMirror = MountMirror, X = X, Y = Y, AccelerationX = AccelerationX, From a084f0410342647834933d39dc786af7b36463af Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 4 Jul 2026 23:48:47 +0200 Subject: [PATCH 16/25] input: refresh device cache on native hotplug events The native input plugin now reports device arrivals/removals with a DevicesChanged event. Handle it before the window-focus gate (the cache must not go stale while unfocused), rebuild the device-id snapshot and expose a DevicesChanged event for UI consumers. --- .../Simulation/NativeInputApi.cs | 1 + .../Simulation/NativeInputManager.cs | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index 09c826735..b48567a80 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -78,6 +78,7 @@ public enum InputEventType { Action = 0, Axis = 1, + DevicesChanged = 2, } public enum AxisKind diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index 74513cd51..26345cd58 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -92,6 +92,12 @@ public static NativeInputManager TryGetExistingInstance() public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; public event Action AxisInputReceived; + /// + /// Raised after a device arrival/removal was detected and the device-id + /// cache has been refreshed. Invoked on the input polling thread. + /// + public event Action DevicesChanged; + private NativeInputManager() { // Private constructor for singleton @@ -422,6 +428,13 @@ private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userD Logger.Trace($"{LogPrefix} [NativeInputManager] Received from native: Type={evt.EventType}, Action={evt.Action}, Device={evt.DeviceIndex}, Axis={evt.AxisId}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); } + // Device hotplug notifications must be handled regardless of window + // focus, or the device-id cache goes stale while unfocused. + if (evt.EventType == (int)NativeInputApi.InputEventType.DevicesChanged) { + Volatile.Read(ref _instance)?.OnNativeDevicesChanged(); + return; + } + // Drop input while the app window isn't focused, so background key presses don't reach the game. if (!_appFocused) { return; @@ -436,6 +449,19 @@ private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userD // Forward action events to simulation thread via ring buffer. instance?._simulationThread?.EnqueueInputEvent(evt); + } + + // Called on the input polling thread when the native side detected a + // device arrival/removal. Rebuilding via ListDevices is safe here: the + // native listing takes its own lock and the id cache is swapped atomically. + private void OnNativeDevicesChanged() + { + try { + ListDevices(); + DevicesChanged?.Invoke(); + } catch (Exception e) { + Logger.Error(e, $"{LogPrefix} [NativeInputManager] Failed to refresh devices after hotplug"); + } } private void MarkInputEventActivity() From 6a69d20b4696237f879c97afc95acfb50d9b6389 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 00:12:37 +0200 Subject: [PATCH 17/25] physics: address nudge review comments --- .../Input/SensorMappingTests.cs | 19 +++++++++++- .../Physics/NudgeSensorStateTests.cs | 30 +++++++++++++++++++ .../VisualPinball.Unity/Game/NudgeSystem.cs | 12 +++++++- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 2 +- .../VisualPinball.Unity/Game/WirePlayer.cs | 23 ++++++++------ .../Input/SensorMapping.cs | 6 ++-- .../Physics/Cabinet/NudgeSensorState.cs | 6 ++++ .../Physics/Cabinet/NudgeState.cs | 27 ++++++++++++++--- 8 files changed, 106 insertions(+), 19 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs index c70d8cd43..d3d74dd83 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs @@ -51,7 +51,7 @@ public void MappingRoundTripsOptionalRawCenter() DeadZone = 0.02f, Scale = 9.81f, Limit = 1f, - RawCenter = -0.125f + RawCenter = 512f }; Assert.That(SensorMapping.TryParse(mapping.ToString(), out var parsed), Is.True); @@ -108,5 +108,22 @@ public void ProcessingSubtractsRawCenterBeforeDeadZone() Assert.That(mapping.ProcessRawValue(0.22f, 100), Is.EqualTo(0f)); Assert.That(mapping.ProcessRawValue(0.65f, 200), Is.GreaterThan(0f)); } + + [Test] + public void ProcessingSubtractsUnclampedRawCenter() + { + var mapping = new SensorMapping { + DeviceId = "device", + AxisId = 1, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0f, + Scale = 1f, + Limit = 10f, + RawCenter = 512f + }; + + Assert.That(mapping.ProcessRawValue(512f, 100), Is.EqualTo(0f)); + Assert.That(mapping.ProcessRawValue(514f, 200), Is.EqualTo(2f)); + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs index 34e61228e..0335cbb44 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs @@ -196,6 +196,36 @@ public void DirectCabinetSensorAppliesMountTransform() Assert.That(nudge.CabinetAcceleration.y, Is.LessThan(0f)); } + [Test] + public void StrongerActiveSensorTakesOverFromStaleSensor() + { + var nudge = new NudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.ConfigureSensor(0, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + AccelerationYMapped = 1 + }); + nudge.ConfigureSensor(1, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + AccelerationYMapped = 1 + }); + + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, -1f, 1000); + for (var i = 0; i < 5; i++) { + nudge.StepOneMillisecond(); + } + + nudge.ApplySensorSample(1, NudgeSensorChannel.AccelerationY, -12f, 2000); + for (var i = 0; i < 5; i++) { + nudge.StepOneMillisecond(); + } + + Assert.That(nudge.ActiveSourceIndex, Is.EqualTo(1)); + } + [Test] public void DirectCabinetSensorStrengthScalesLinearly() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs index a3575c2c6..49b7322ad 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs @@ -125,7 +125,17 @@ internal void AttachNativeInputManager(NativeInputManager inputManager) } } - internal void DetachNativeInputManager() + internal void DetachNativeInputManager(NativeInputManager inputManager) + { + var attachedInputManager = _inputManager; + if (attachedInputManager == null || attachedInputManager != inputManager) { + return; + } + + DetachNativeInputManager(); + } + + private void DetachNativeInputManager() { if (_inputManager != null) { _inputManager.AxisInputReceived -= OnAxisInputReceived; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index c6ba4ec89..f73460116 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -428,7 +428,7 @@ internal void AttachNativeInputManager(NativeInputManager inputManager) internal void DetachNativeInputManager(NativeInputManager inputManager) { - _nudgeSystem?.DetachNativeInputManager(); + _nudgeSystem?.DetachNativeInputManager(inputManager); } public void NudgeSensorStatus(out float x, out float y) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs index 302f87c36..ce4e1479f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs @@ -17,10 +17,11 @@ using System; using System.Collections.Generic; using System.Linq; -using NLog; -using UnityEngine; -using UnityEngine.InputSystem; -using Logger = NLog.Logger; +using NLog; +using UnityEngine; +using UnityEngine.InputSystem; +using VisualPinball.Unity.Simulation; +using Logger = NLog.Logger; namespace VisualPinball.Unity { @@ -253,11 +254,15 @@ internal void RemoveWire(WireMapping wireMapping) #region Runtime - private void HandleKeyInput(object obj, InputActionChange change) - { - switch (change) { - case InputActionChange.ActionStarted: - case InputActionChange.ActionCanceled: + private void HandleKeyInput(object obj, InputActionChange change) + { + if (NativeInputManager.TryGetExistingInstance()?.IsPolling == true) { + return; + } + + switch (change) { + case InputActionChange.ActionStarted: + case InputActionChange.ActionCanceled: var action = (InputAction)obj; var actionName = InputManager.GetCanonicalActionName(action.name); DispatchInputAction(actionName, change == InputActionChange.ActionStarted); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs index ebad9e0be..73cccc1c2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs @@ -44,11 +44,11 @@ public sealed class SensorMapping public float ProcessRawValue(float rawValue, long timestampUsec) { - RawValue = math.clamp(rawValue, -1f, 1f); + RawValue = rawValue; RawTimestampUsec = timestampUsec; var deadZone = math.clamp(DeadZone, 0f, 0.999f); - var value = math.clamp(RawValue - RawCenter, -1f, 1f); + var value = RawValue - RawCenter; var absValue = math.abs(value); if (absValue <= deadZone) { value = 0f; @@ -145,7 +145,7 @@ private static bool TryParseParts(string[] parts, int trailingValueCount, out Se mapping.DeadZone = math.clamp(deadZone, 0f, 0.999f); mapping.Scale = scale; mapping.Limit = math.max(0f, limit); - mapping.RawCenter = math.clamp(rawCenter, -1f, 1f); + mapping.RawCenter = rawCenter; return true; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs index a94fb47e6..936253bdf 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs @@ -381,6 +381,7 @@ public struct NudgeSensorState public CabinetSensorState Cabinet; public float2 CabinetAcceleration; public float2 CabinetOffset; + public ulong LastActivityTimestampUsec; public bool IsEnabled => Enabled != 0; public bool IsActive => IsEnabled && (Type == NudgeSensorType.GamepadIntent ? Gamepad.IsActive : Cabinet.IsActive); @@ -406,6 +407,7 @@ public NudgeSensorState(NudgeSensorRuntimeConfig config) accXMapped, accYMapped); CabinetAcceleration = float2.zero; CabinetOffset = float2.zero; + LastActivityTimestampUsec = 0; } public void Disable() @@ -413,6 +415,7 @@ public void Disable() Enabled = 0; CabinetAcceleration = float2.zero; CabinetOffset = float2.zero; + LastActivityTimestampUsec = 0; } public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) @@ -421,6 +424,9 @@ public void ApplySample(NudgeSensorChannel channel, float value, ulong timestamp return; } NudgeSensorMountTransform.TransformChannel(ref channel, ref value, MountRotation, MountMirror != 0); + if (math.abs(value) > 1.0e-6f && timestampUsec > LastActivityTimestampUsec) { + LastActivityTimestampUsec = timestampUsec; + } if (Type == NudgeSensorType.GamepadIntent) { Gamepad.ApplySample(channel, value, timestampUsec); } else { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs index 5ef6199e5..220ebbc13 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs @@ -165,18 +165,37 @@ private void StepSensor(int index) private bool TryGetActiveSensor(out int index, out NudgeSensorState sensor) { + var bestScore = -1f; + var bestActivityTimestampUsec = 0UL; + index = -2; + sensor = default; + for (var i = 0; i < SensorCount; i++) { var candidate = GetSensor(i); if (!candidate.IsActive) { continue; } + var score = SensorActivityScore(candidate); + if (index >= 0) { + if (candidate.LastActivityTimestampUsec < bestActivityTimestampUsec) { + continue; + } + if (candidate.LastActivityTimestampUsec == bestActivityTimestampUsec && score <= bestScore) { + continue; + } + } + bestScore = score; + bestActivityTimestampUsec = candidate.LastActivityTimestampUsec; index = i; sensor = candidate; - return true; } - index = -2; - sensor = default; - return false; + return index >= 0; + } + + private static float SensorActivityScore(NudgeSensorState sensor) + { + var accelerationScore = math.lengthsq(sensor.CabinetAcceleration); + return accelerationScore > 1.0e-9f ? accelerationScore : math.lengthsq(sensor.CabinetOffset); } private void DisableSensor(int index) From f0369c521c7e9f3957a99545cc42177aed6f6025 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 00:14:30 +0200 Subject: [PATCH 18/25] input: update native input package --- VisualPinball.Engine/VisualPinball.Engine.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VisualPinball.Engine/VisualPinball.Engine.csproj b/VisualPinball.Engine/VisualPinball.Engine.csproj index e83eaa1e2..8caa6df91 100644 --- a/VisualPinball.Engine/VisualPinball.Engine.csproj +++ b/VisualPinball.Engine/VisualPinball.Engine.csproj @@ -15,7 +15,7 @@ https://visualpinball.org icon.png LICENSE - 0.0.4 + 0.0.5 From 01b1a941a04b1480478c9e4c04ee8141f30c7327 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 00:42:12 +0200 Subject: [PATCH 19/25] doc: document nudge implementation --- .../SimulationThreadComponentInspector.cs | 90 ++++++++++ .../Input/SensorMappingTests.cs | 3 + .../Physics/CabinetPhysicsTests.cs | 10 ++ .../Physics/NudgeSensorStateTests.cs | 6 +- .../VisualPinball.Unity/Game/NudgeSystem.cs | 52 ++++++ .../Game/NudgeTelemetry.cs | 12 ++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 95 +++++++++- .../Game/PhysicsEnginePackable.cs | 25 ++- .../Game/PhysicsEngineThreading.cs | 16 ++ .../VisualPinball.Unity/Game/PhysicsEnv.cs | 22 ++- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 38 ++-- .../Game/VisualNudgeComponent.cs | 28 +++ .../Input/SensorMapping.cs | 33 ++++ .../Physics/Cabinet/CabinetPhysicsState.cs | 30 ++++ .../Cabinet/DampedHarmonicOscillator.cs | 24 +++ .../Physics/Cabinet/KeyboardNudgeMode.cs | 15 ++ .../Physics/Cabinet/KeyboardNudgeState.cs | 40 +++++ .../Cabinet/MotionGainCalibratorAxis.cs | 78 +++++++++ .../Physics/Cabinet/MotionKalmanAxis.cs | 81 +++++++++ .../Physics/Cabinet/NudgeIntentState.cs | 36 ++++ .../Physics/Cabinet/NudgeSensorChannel.cs | 10 ++ .../Cabinet/NudgeSensorMountTransform.cs | 30 ++++ .../Physics/Cabinet/NudgeSensorState.cs | 120 ++++++++++++- .../Physics/Cabinet/NudgeSensorType.cs | 14 ++ .../Physics/Cabinet/NudgeState.cs | 68 ++++++++ .../Physics/Cabinet/PlumbState.cs | 36 ++++ .../Simulation/NativeInputApi.cs | 164 ++++++++++++------ .../Simulation/NativeInputManager.cs | 164 ++++++++++++------ .../Simulation/SimulationState.cs | 33 +++- .../Simulation/SimulationThread.cs | 23 ++- .../Simulation/SimulationThreadComponent.cs | 94 +++++++++- .../SimulationThreadComponentPackable.cs | 25 +++ 32 files changed, 1377 insertions(+), 138 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs index 44ad5fe57..59aba581f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs @@ -22,6 +22,15 @@ namespace VisualPinball.Unity.Editor { + /// + /// Custom inspector for simulation-thread settings and native nudge sensor + /// calibration. + /// + /// + /// The inspector reads live native input device snapshots in Play Mode so a + /// cabinet owner can auto-map, center-calibrate, and visually verify KL25Z or + /// Pinscape axes without leaving Unity. + /// [CustomEditor(typeof(SimulationThreadComponent)), CanEditMultipleObjects] public sealed class SimulationThreadComponentInspector : UnityEditor.Editor { @@ -43,6 +52,10 @@ public sealed class SimulationThreadComponentInspector : UnityEditor.Editor private GUIStyle _graphLegendStyle; private double _lastGraphSampleTime; + /// + /// Draws default simulation fields plus live nudge calibration controls for + /// single-object selection. + /// public override void OnInspectorGUI() { serializedObject.Update(); @@ -59,11 +72,18 @@ public override void OnInspectorGUI() } } + /// + /// Requests repaint while Play Mode graphs can receive fresh input samples. + /// public override bool RequiresConstantRepaint() { return Application.isPlaying && targets.Length == 1; } + /// + /// Draws Play Mode controls for device listing, auto-mapping, and center + /// calibration. + /// private void DrawNudgeCalibration(SimulationThreadComponent component) { EditorGUILayout.Space(); @@ -127,6 +147,9 @@ private void DrawNudgeCalibration(SimulationThreadComponent component) } } + /// + /// Lists visible native input devices and their first few axes. + /// private void DrawDevices(IReadOnlyList devices) { EditorGUILayout.Space(); @@ -149,6 +172,10 @@ private void DrawDevices(IReadOnlyList devices) } } + /// + /// Draws a rolling graph of mapped nudge channels, or raw axes when no + /// mappings exist yet. + /// private void DrawInputGraph(SimulationThreadComponent component, IReadOnlyList devices) { var channels = new List(); @@ -182,6 +209,9 @@ private void DrawInputGraph(SimulationThreadComponent component, IReadOnlyList + /// Samples graph channels at editor repaint cadence with a 60 Hz cap. + /// private void SampleGraphChannels(IReadOnlyList channels) { var now = EditorApplication.timeSinceStartup; @@ -200,6 +230,9 @@ private void SampleGraphChannels(IReadOnlyList channels) } } + /// + /// Draws the graph background and reference lines. + /// private void DrawGraphFrame(Rect rect) { EditorGUI.DrawRect(rect, new Color(0.1f, 0.1f, 0.1f)); @@ -215,6 +248,9 @@ private void DrawGraphFrame(Rect rect) Handles.EndGUI(); } + /// + /// Draws one channel as a polyline in the graph rectangle. + /// private static void DrawGraphLine(Rect rect, AxisGraphState graph, Color color) { if (graph.Count < 2) { @@ -234,6 +270,9 @@ private static void DrawGraphLine(Rect rect, AxisGraphState graph, Color color) Handles.EndGUI(); } + /// + /// Converts a sample index/value into graph GUI coordinates. + /// private static Vector3 GraphPoint(Rect rect, AxisGraphState graph, int index) { var x = graph.Count <= 1 ? rect.xMin : Mathf.Lerp(rect.xMin, rect.xMax, index / (float)(graph.Count - 1)); @@ -242,6 +281,9 @@ private static Vector3 GraphPoint(Rect rect, AxisGraphState graph, int index) return new Vector3(x, y); } + /// + /// Draws the current value legend below the graph. + /// private void DrawGraphLegend(IReadOnlyList channels) { if (_graphLegendStyle == null) { @@ -260,6 +302,9 @@ private void DrawGraphLegend(IReadOnlyList channels) EditorGUILayout.EndHorizontal(); } + /// + /// Collects live graph channels from configured nudge mappings. + /// private static void CollectMappedGraphChannels(SimulationThreadComponent component, IReadOnlyList devices, List channels) { @@ -281,6 +326,10 @@ private static void CollectMappedGraphChannels(SimulationThreadComponent compone } } + /// + /// Adds one mapped channel after applying the same mount transform used by + /// physics. + /// private static void AddMappedGraphChannel(int sensorIndex, SimulationThreadNudgeSensorConfig sensor, NudgeSensorChannel sourceChannel, string mappingValue, IReadOnlyList devices, List channels) @@ -302,6 +351,10 @@ private static void AddMappedGraphChannel(int sensorIndex, SimulationThreadNudge value)); } + /// + /// Adds the first few raw axes from a device for graphing before mappings + /// exist. + /// private static void CollectRawGraphChannels(NativeInputDeviceInfo device, List channels) { var axisCount = System.Math.Min(device.Axes.Count, GraphMaxAxes); @@ -314,6 +367,13 @@ private static void CollectRawGraphChannels(NativeInputDeviceInfo device, List + /// Applies mapping center/dead-zone/limit to a raw value for graph display. + /// + /// + /// Scale is intentionally omitted so the graph shows centered normalized + /// input direction and noise, not the physics strength applied later. + /// private static float CalculateMappedGraphValue(SensorMapping mapping, float rawValue) { var value = Mathf.Clamp(Mathf.Clamp(rawValue, -1f, 1f) - mapping.RawCenter, -1f, 1f); @@ -327,6 +387,9 @@ private static float CalculateMappedGraphValue(SensorMapping mapping, float rawV return Mathf.Clamp(value, -limit, limit); } + /// + /// Finds the live axis snapshot for a serialized mapping. + /// private static bool TryFindAxis(IReadOnlyList devices, string deviceId, int axisId, out NativeInputAxisInfo axis) { @@ -347,6 +410,9 @@ private static bool TryFindAxis(IReadOnlyList devices, st return false; } + /// + /// Chooses the device to graph when no channels are mapped yet. + /// private static bool TryFindGraphDevice(IReadOnlyList devices, out NativeInputDeviceInfo device) { for (var i = 0; i < devices.Count; i++) { @@ -366,11 +432,17 @@ private static bool TryFindGraphDevice(IReadOnlyList devi return false; } + /// + /// Returns whether a device has connected axes suitable for graphing. + /// private static bool IsGraphableDevice(NativeInputDeviceInfo device) { return device.IsConnected && device.Axes != null && device.Axes.Count > 0; } + /// + /// Prefers likely KL25Z/Pinscape devices for the raw graph. + /// private static bool IsKl25zDevice(NativeInputDeviceInfo device) { return ContainsIgnoreCase(device.Name, "KL25Z") @@ -384,6 +456,9 @@ private static bool ContainsIgnoreCase(string value, string match) return !string.IsNullOrEmpty(value) && value.IndexOf(match, StringComparison.OrdinalIgnoreCase) >= 0; } + /// + /// Builds a stable key for one raw graph channel. + /// private static string GraphKey(NativeInputDeviceInfo device, NativeInputAxisInfo axis) { return $"{device.Id}:{axis.AxisId}"; @@ -428,12 +503,18 @@ private void SetStatus(string message, MessageType type) _statusType = type; } + /// + /// Stores one graph channel sample and label. + /// private sealed class InputGraphChannel { public readonly string Key; public readonly string Label; public readonly float Value; + /// + /// Creates a graph channel snapshot for the current repaint. + /// public InputGraphChannel(string key, string label, float value) { Key = key; @@ -442,6 +523,9 @@ public InputGraphChannel(string key, string label, float value) } } + /// + /// Fixed-size rolling sample buffer for one graph line. + /// private sealed class AxisGraphState { private readonly float[] _samples = new float[GraphSampleCount]; @@ -449,6 +533,9 @@ private sealed class AxisGraphState public int Count { get; private set; } + /// + /// Appends one sample, overwriting the oldest sample when full. + /// public void Add(float value) { _samples[_next] = value; @@ -458,6 +545,9 @@ public void Add(float value) } } + /// + /// Reads a sample by age, where zero is the oldest retained sample. + /// public float Get(int index) { var start = (_next - Count + _samples.Length) % _samples.Length; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs index d3d74dd83..33620cb15 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs @@ -18,6 +18,9 @@ namespace VisualPinball.Unity.Test { + /// + /// Covers serialized analog-axis mappings used by nudge sensor configuration. + /// public class SensorMappingTests { [Test] diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs index 9194b66a5..baf74aa6b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs @@ -19,6 +19,9 @@ namespace VisualPinball.Unity.Test { + /// + /// Covers cabinet spring, keyboard nudge, and plumb-bob physics primitives. + /// public class CabinetPhysicsTests { [Test] @@ -97,6 +100,10 @@ public void PlumbTiltLatchesAndQueuesTiltEvent() Assert.That(plumb.PendingTiltStates.Length, Is.EqualTo(0)); } + /// + /// Computes the closed-form underdamped oscillator response used to verify + /// the one-millisecond integrator. + /// private static (float Displacement, float Velocity, float Acceleration) AnalyticStepResponse(float mass, float frequencyHz, float dampingRatio, float force, float time) { var omega0 = 2f * math.PI * frequencyHz; @@ -111,6 +118,9 @@ private static (float Displacement, float Velocity, float Acceleration) Analytic return (displacement, velocity, acceleration); } + /// + /// Counts visible rebound peaks after ignoring small tail oscillations. + /// private static int CountVisibleExtrema(float[] values, float threshold) { var count = 0; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs index 0335cbb44..80f724382 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs @@ -19,6 +19,10 @@ namespace VisualPinball.Unity.Test { + /// + /// Covers analog nudge sensor filtering, calibration, mounting transforms, + /// and source selection. + /// public class NudgeSensorStateTests { [Test] @@ -95,7 +99,7 @@ public void GainCalibratorSurvivesLongSegmentsByCompacting() const float expectedGain = 2.0f; ulong baseTime = 0; - // 1.5s segments at 1kHz — way past the sample buffer capacity, forcing + // 1.5s segments at 1kHz: way past the sample buffer capacity, forcing // resolution compaction. The segment must stay whole (rest-to-rest), so // the gain still converges and no segment splitting inflates confidence. for (var segment = 0; segment < 10; segment++) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs index 49b7322ad..0a4019706 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs @@ -21,6 +21,14 @@ namespace VisualPinball.Unity { + /// + /// Main-thread configuration for one logical nudge input source. + /// + /// + /// This class keeps editor/player settings in a convenient managed shape. + /// converts it to the compact struct consumed by + /// the simulation thread. + /// public sealed class NudgeSensorConfig { public NudgeSensorType Type = NudgeSensorType.GamepadIntent; @@ -35,6 +43,9 @@ public sealed class NudgeSensorConfig public SensorMapping VelocityX = new(); public SensorMapping VelocityY = new(); + /// + /// Clamps numeric settings and guarantees mapping objects are non-null. + /// public void Normalize() { Strength = math.clamp(Strength, 0f, 2f); @@ -48,6 +59,9 @@ public void Normalize() VelocityY ??= new SensorMapping(); } + /// + /// Converts this managed config to simulation-thread state. + /// internal NudgeSensorRuntimeConfig ToRuntimeConfig() { Normalize(); @@ -67,6 +81,15 @@ internal NudgeSensorRuntimeConfig ToRuntimeConfig() } } + /// + /// Bridges native analog input events to the physics engine's nudge state. + /// + /// + /// Native input is polled off the simulation thread. This system keeps a small + /// locked snapshot of sensor mappings, maps matching axis events, and queues + /// normalized samples into the physics engine so the actual cabinet model still + /// runs at the deterministic physics cadence. + /// public sealed class NudgeSystem : IDisposable { private readonly PhysicsEngine _physicsEngine; @@ -74,11 +97,17 @@ public sealed class NudgeSystem : IDisposable private readonly List _sensors = new(NudgeState.MaxSensors); private volatile NativeInputManager _inputManager; + /// + /// Creates a nudge system attached to a physics engine. + /// internal NudgeSystem(PhysicsEngine physicsEngine) { _physicsEngine = physicsEngine; } + /// + /// Number of configured sensor slots. + /// public int SensorCount { get { @@ -88,12 +117,19 @@ public int SensorCount } } + /// + /// Returns the current native input device snapshot. + /// public IReadOnlyList ListDevices() { var inputManager = _inputManager; return inputManager == null ? Array.Empty() : inputManager.ListDevices(); } + /// + /// Replaces all configured nudge sensors and pushes their runtime shape to + /// the physics engine. + /// public void ConfigureSensors(IReadOnlyList sensors) { lock (_configLock) { @@ -113,6 +149,9 @@ public void ConfigureSensors(IReadOnlyList sensors) } } + /// + /// Subscribes to native axis events from an input manager. + /// internal void AttachNativeInputManager(NativeInputManager inputManager) { if (_inputManager == inputManager) { @@ -125,6 +164,9 @@ internal void AttachNativeInputManager(NativeInputManager inputManager) } } + /// + /// Detaches the currently subscribed input manager if it matches. + /// internal void DetachNativeInputManager(NativeInputManager inputManager) { var attachedInputManager = _inputManager; @@ -135,6 +177,9 @@ internal void DetachNativeInputManager(NativeInputManager inputManager) DetachNativeInputManager(); } + /// + /// Unsubscribes from native axis events. + /// private void DetachNativeInputManager() { if (_inputManager != null) { @@ -143,6 +188,9 @@ private void DetachNativeInputManager() } } + /// + /// Releases native input event subscriptions. + /// public void Dispose() { DetachNativeInputManager(); @@ -176,6 +224,10 @@ private void OnAxisInputReceived(NativeInputApi.InputEvent evt) } } + /// + /// Matches one native axis event against one mapping and enqueues the + /// normalized sample when it belongs to that channel. + /// private void TryQueueSample(int sensorIndex, SensorMapping mapping, NudgeSensorChannel channel, string deviceId, NativeInputApi.InputEvent evt) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs index f5054e649..cfbfc0de8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs @@ -18,6 +18,15 @@ namespace VisualPinball.Unity { + /// + /// Immutable snapshot of nudge and plumb-bob diagnostics from the physics + /// thread. + /// + /// + /// The player/editor UI reads this instead of peeking into simulation state, + /// which keeps telemetry consumption decoupled from the data layout used by + /// Burst-friendly physics structs. + /// public readonly struct NudgeTelemetry { public readonly float2 CabinetAcceleration; @@ -28,6 +37,9 @@ public readonly struct NudgeTelemetry public readonly int PlumbTiltIndex; public readonly bool PlumbTiltHigh; + /// + /// Creates a complete telemetry snapshot for one read. + /// public NudgeTelemetry(float2 cabinetAcceleration, float2 cabinetOffset, int activeSourceIndex, float3 plumbPosition, float plumbTiltPercent, int plumbTiltIndex, bool plumbTiltHigh) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index f73460116..7048c22f5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -59,13 +59,13 @@ namespace VisualPinball.Unity /// Threading Contract /// Four threads participate at runtime: /// - /// Unity main thread (~60-144 Hz) — rendering, UI, event + /// Unity main thread (~60-144 Hz) - rendering, UI, event /// drain, visual state application. - /// Simulation thread (1000 Hz) — physics ticks, input + /// Simulation thread (1000 Hz) - physics ticks, input /// dispatch, coil output processing, GLE time fence. - /// Native input polling thread (500-2000 Hz) — raw + /// Native input polling thread (500-2000 Hz) - raw /// keyboard/gamepad polling via the VisualPinball.NativeInput native plugin. - /// PinMAME emulation thread (variable, time-fenced) — + /// PinMAME emulation thread (variable, time-fenced) - /// ROM emulation. /// /// @@ -301,8 +301,24 @@ internal void MutateState(InputAction action) action(ref state); } + /// + /// Monotonic counter incremented whenever a keyboard nudge is queued. + /// + /// + /// Gamelogic callbacks can consume a key press and call + /// themselves. The player checks this counter to avoid applying the built-in + /// default nudge twice for the same key press. + /// public int KeyboardNudgeIndex => Volatile.Read(ref _keyboardNudgeIndex); + /// + /// Queues a keyboard/manual nudge impulse for the next physics tick. + /// + /// + /// The request is queued instead of applied immediately so main-thread, + /// gamelogic, and native input callers all mutate nudge state on the physics + /// owner thread. + /// public void Nudge(float angleDeg, float force) { Interlocked.Increment(ref _keyboardNudgeIndex); @@ -318,11 +334,19 @@ public void Nudge(float angleDeg, float force) } } + /// + /// Configures keyboard nudge mode and strength, keeping the current cabinet + /// damping setting. + /// public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength) { ConfigureKeyboardNudge(mode, strength, KeyboardCabinetDamping); } + /// + /// Configures keyboard nudge response and updates live physics state when + /// the engine is already running. + /// public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength, float cabinetDamping) { KeyboardNudgeMode = mode; @@ -344,6 +368,9 @@ public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength, float } } + /// + /// Configures the simulated mechanical plumb-bob tilt switch. + /// public void ConfigurePlumb(bool simulatedPlumb, float damping, float thresholdAngle) { SimulatedPlumb = simulatedPlumb; @@ -361,6 +388,10 @@ public void ConfigurePlumb(bool simulatedPlumb, float damping, float thresholdAn } } + /// + /// Configures render-only visual nudge strength and ensures the runtime + /// camera offset component exists in Play Mode. + /// public void ConfigureVisualNudge(float strength) { VisualNudgeStrength = math.clamp(strength, 0f, 2f); @@ -371,6 +402,9 @@ public void ConfigureVisualNudge(float strength) _visualNudge?.Configure(this, VisualNudgeStrength); } + /// + /// Finds or adds the component that applies visual nudge to game cameras. + /// private void EnsureVisualNudge() { if (_visualNudge != null) { @@ -382,11 +416,17 @@ private void EnsureVisualNudge() } } + /// + /// Replaces the analog nudge sensor configuration. + /// public void ConfigureNudgeSensors(IReadOnlyList sensors) { _nudgeSystem?.ConfigureSensors(sensors); } + /// + /// Updates the number of active analog sensor slots inside physics state. + /// internal void ConfigureNudgeSensorCount(int count) { lock (_ctx.PhysicsLock) { @@ -396,6 +436,9 @@ internal void ConfigureNudgeSensorCount(int count) } } + /// + /// Updates one analog nudge sensor slot inside physics state. + /// internal void ConfigureNudgeSensor(int index, NudgeSensorRuntimeConfig config) { lock (_ctx.PhysicsLock) { @@ -405,6 +448,15 @@ internal void ConfigureNudgeSensor(int index, NudgeSensorRuntimeConfig config) } } + /// + /// Queues one mapped analog sensor sample for physics-thread processing. + /// + /// + /// A streaming accelerometer can generate thousands of samples per second, + /// so the queue is bounded and drops the oldest sample if the drain stalls. + /// Fresh samples are more useful than stale motion history after a pause or + /// debugger break. + /// internal void EnqueueNudgeSensorSample(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) { lock (_ctx.PendingNudgeSensorSamplesLock) { @@ -421,16 +473,29 @@ internal void EnqueueNudgeSensorSample(int sensorIndex, NudgeSensorChannel chann } } + /// + /// Subscribes the nudge system to native input axis events. + /// internal void AttachNativeInputManager(NativeInputManager inputManager) { _nudgeSystem?.AttachNativeInputManager(inputManager); } + /// + /// Unsubscribes the nudge system from a native input manager. + /// internal void DetachNativeInputManager(NativeInputManager inputManager) { _nudgeSystem?.DetachNativeInputManager(inputManager); } + /// + /// VP-script-compatible nudge status readout. + /// + /// + /// Returns the largest signed cabinet acceleration since the last read and + /// then clears the accumulator, matching VP's polling-oriented script API. + /// public void NudgeSensorStatus(out float x, out float y) { lock (_ctx.PhysicsLock) { @@ -442,6 +507,9 @@ public void NudgeSensorStatus(out float x, out float y) } } + /// + /// VP-script-compatible plumb tilt readout. + /// public void NudgeTiltStatus(out float plumbX, out float plumbY, out float tiltPercent) { lock (_ctx.PhysicsLock) { @@ -454,6 +522,10 @@ public void NudgeTiltStatus(out float plumbX, out float plumbY, out float tiltPe } } + /// + /// Returns a read-only nudge/plumb telemetry snapshot for UI and editor + /// diagnostics. + /// public NudgeTelemetry GetNudgeTelemetry() { if (_ctx == null || !_ctx.IsInitialized) { @@ -481,6 +553,9 @@ public NudgeTelemetry GetNudgeTelemetry() } } + /// + /// Applies the latest physics-produced cabinet offset to visual nudge. + /// internal void ApplyVisualNudge(float2 cabinetOffset) { if (_visualNudge == null) { @@ -489,6 +564,9 @@ internal void ApplyVisualNudge(float2 cabinetOffset) _visualNudge.SetCabinetOffset(cabinetOffset); } + /// + /// Moves queued plumb-bob tilt switch edges into the caller's list. + /// internal void DrainPlumbTiltEvents(List destination) { lock (_ctx.PhysicsLock) { @@ -501,6 +579,9 @@ internal void DrainPlumbTiltEvents(List destination) } } + /// + /// Dispatches pending plumb tilt edges through the main-thread player path. + /// private void DispatchPendingPlumbTiltEvents() { _pendingPlumbTiltEvents.Clear(); @@ -551,7 +632,7 @@ private static void LogQueueWarning(string message) Debug.LogWarning(message); } - // ── State accessors ────────────────────────────────────────── + // State accessors // These return refs into native hash maps. In single-threaded // mode they are safe. In threaded mode, callers on the sim // thread (e.g. FlipperApi coil callbacks) access them inside @@ -757,7 +838,7 @@ internal void DisableCollider(int itemId) /// public bool TryGetKinematicVelocity(int itemId, out float3 linearVelocity, out float3 angularVelocity, out float3 pivot) { - // engine time unit (DefaultStepTime, 10 ms) → seconds + // engine time unit (DefaultStepTime, 10 ms) to seconds const float perSecond = (float)(1e6 / PhysicsConstants.DefaultStepTime); lock (_ctx.PhysicsLock) { if (_ctx.KinematicVelocities.Ref.IsCreated && _ctx.KinematicVelocities.Ref.TryGetValue(itemId, out var velocity)) { @@ -775,7 +856,7 @@ public bool TryGetKinematicVelocity(int itemId, out float3 linearVelocity, out f #endregion - #region Forwarding — Simulation Thread + #region Forwarding - Simulation Thread /// /// Execute a single physics tick with external timing. diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs index 55af01de3..57f2dbe6d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs @@ -14,8 +14,17 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -namespace VisualPinball.Unity -{ +namespace VisualPinball.Unity +{ + /// + /// Packaged representation of settings. + /// + /// + /// The boolean "Has..." flags preserve backward compatibility with packages + /// created before keyboard nudge, plumb, damping, or visual nudge settings were + /// serialized. Missing fields are restored with the same defaults used by new + /// components. + /// public struct PhysicsEnginePackable { public float GravityStrength; @@ -31,6 +40,9 @@ public struct PhysicsEnginePackable public bool HasVisualNudgeSettings; public float VisualNudgeStrength; + /// + /// Serializes physics engine configuration for a table package. + /// public static byte[] Pack(PhysicsEngine comp) { return PackageApi.Packer.Pack(new PhysicsEnginePackable { @@ -48,9 +60,12 @@ public static byte[] Pack(PhysicsEngine comp) VisualNudgeStrength = comp.VisualNudgeStrength, }); } - - public static void Unpack(byte[] bytes, PhysicsEngine comp) - { + + /// + /// Restores physics engine configuration from package data. + /// + public static void Unpack(byte[] bytes, PhysicsEngine comp) + { var data = PackageApi.Packer.Unpack(bytes); comp.GravityStrength = data.GravityStrength; comp.ConfigureKeyboardNudge( diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index abc550ef3..2d148ecb1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -237,6 +237,14 @@ private void ProcessInputActions(ref PhysicsState state) } } + /// + /// Drains queued keyboard/manual nudge commands into physics-owned state. + /// + /// + /// Nudge requests can be produced by main-thread input, gamelogic, or native + /// input callbacks, but the cabinet model is mutated only while the physics + /// thread owns . + /// private void ProcessPendingKeyboardNudges() { _pendingKeyboardNudges.Clear(); @@ -254,6 +262,14 @@ private void ProcessPendingKeyboardNudges() } } + /// + /// Drains queued analog nudge sensor samples into physics-owned state. + /// + /// + /// The input thread may deliver several samples between physics ticks. They + /// are replayed in queue order so the sensor filters retain native timing + /// information while still mutating state on the physics thread. + /// private void ProcessPendingNudgeSensorSamples() { _pendingNudgeSensorSamples.Clear(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs index f33735022..63b54e721 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs @@ -19,10 +19,20 @@ using VisualPinball.Engine.Common; using Random = Unity.Mathematics.Random; -namespace VisualPinball.Unity -{ - public struct PhysicsEnv - { +namespace VisualPinball.Unity +{ + /// + /// Per-table physics environment values that are independent of individual + /// component state. + /// + /// + /// This struct travels with the physics state through both main-thread and + /// simulation-thread execution. Nudge and plumb state live here because they + /// affect every ball and must advance once per one-millisecond physics tick, + /// not once per Unity frame. + /// + public struct PhysicsEnv + { public readonly float3 Gravity; public readonly ulong StartTimeUsec; public ulong CurPhysicsFrameTime; @@ -33,6 +43,10 @@ public struct PhysicsEnv public NudgeState Nudge; public PlumbState Plumb; + /// + /// Creates the physics environment for a table, including gravity, cabinet + /// nudge state, and plumb-bob state. + /// public PhysicsEnv(ulong startTimeUsec, PlayfieldComponent playfield, float gravityStrength, KeyboardNudgeMode keyboardNudgeMode = KeyboardNudgeMode.CabModel, float keyboardNudgeStrength = 1f, float nudgeTime = 5f, bool simulatedPlumb = true, float plumbDamping = 1f, float plumbThresholdAngle = 2f, diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index a7c62b821..ad5e5f469 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -22,10 +22,19 @@ // ReSharper disable InconsistentNaming -namespace VisualPinball.Unity -{ - [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)] - internal static class PhysicsUpdate +namespace VisualPinball.Unity +{ + /// + /// Burst-compiled one-millisecond physics update loop. + /// + /// + /// Cabinet nudge is advanced here before velocity integration so balls and the + /// simulated plumb bob see the same cabinet acceleration for the tick. That + /// mirrors VP's cabinet physics flow, where nudge is an inertial acceleration + /// applied to the entire playfield rather than a per-ball impulse. + /// + [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)] + internal static class PhysicsUpdate { // [ReadOnly] // public ulong InitialTimeUsec; @@ -66,8 +75,11 @@ internal static class PhysicsUpdate // // public bool SwapBallCollisionHandling; - [BurstCompile] - public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kinematicOctree, ref NativeOctree ballOctree, ref PhysicsCycle cycle, ulong initialTimeUsec) + /// + /// Advances physics until simulated time catches up to the requested time. + /// + [BurstCompile] + public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kinematicOctree, ref NativeOctree ballOctree, ref PhysicsCycle cycle, ulong initialTimeUsec) { // ref var state = ref UnsafeUtility.AsRef(statePtr.ToPointer()); // ref var env = ref UnsafeUtility.AsRef(envPtr.ToPointer()); @@ -95,6 +107,9 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ // update velocities - always on integral physics frame boundary (spinner, gate, flipper, plunger, ball) #region Update Velocities + // Nudge is evaluated once per millisecond before ball velocity updates + // so every ball receives the same cabinet inertial acceleration for + // this tick, and the plumb bob records the same motion. env.Nudge.StepOneMillisecond(); var cabinetAcceleration = env.Nudge.CabinetAcceleration; env.Plumb.StepOneMillisecond(cabinetAcceleration); @@ -151,10 +166,13 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ if (subSteps > 0) { UpdateAnimations(ref state, env.TimeMsec, subSteps * (PhysicsConstants.PhysicsStepTime / 1000f)); - } - } - - private static void UpdateAnimations(ref PhysicsState state, uint animationTimeMsec, float animationDeltaTimeMs) + } + } + + /// + /// Advances mechanical animations after physics substeps have completed. + /// + private static void UpdateAnimations(ref PhysicsState state, uint animationTimeMsec, float animationDeltaTimeMs) { // bumper using (var enumerator = state.BumperStates.GetEnumerator()) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs index 261f95f87..2bfa5b458 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs @@ -21,6 +21,16 @@ namespace VisualPinball.Unity { + /// + /// Applies a camera-space visual response to simulated cabinet motion. + /// + /// + /// This mirrors VP's visual nudge behavior in + /// vpinball/src/renderer/Renderer.cpp, where cabinet displacement is + /// represented by shifting the rendered view rather than moving every table + /// object. VPE applies the offset to game cameras so authoring/editor cameras + /// are not disturbed. + /// [DefaultExecutionOrder(10000)] public sealed class VisualNudgeComponent : MonoBehaviour { @@ -31,6 +41,9 @@ public sealed class VisualNudgeComponent : MonoBehaviour [NonSerialized] private float2 _cabinetOffset; [NonSerialized] private float _strength = 1f; + /// + /// Connects this component to physics telemetry and sets the visual strength. + /// internal void Configure(PhysicsEngine physicsEngine, float strength) { _physicsEngine = physicsEngine; @@ -40,6 +53,9 @@ internal void Configure(PhysicsEngine physicsEngine, float strength) } } + /// + /// Updates the latest cabinet-space offset produced by the physics thread. + /// internal void SetCabinetOffset(float2 cabinetOffset) { _cabinetOffset = cabinetOffset; @@ -61,6 +77,10 @@ private Vector3 WorldCabinetOffset() return _playfield != null ? _playfield.transform.TransformVector(localOffset) : localOffset; } + /// + /// Applies the desired offset to all game cameras and restores cameras that + /// disappeared from the active list. + /// private void ApplyToGameCameras(Vector3 desiredOffset) { _staleCameras.Clear(); @@ -89,6 +109,14 @@ private static bool ShouldOffset(Camera camera) && camera.cameraType == CameraType.Game; } + /// + /// Replaces this frame's previous offset with the new one. + /// + /// + /// The previous offset is subtracted first so animated camera rigs, head + /// tracking, or other camera scripts can still move the camera normally + /// between VPE visual nudge updates. + /// private void ApplyToCamera(Camera camera, Vector3 desiredOffset) { if (_appliedOffsets.TryGetValue(camera, out var previousOffset)) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs index 73cccc1c2..31392aa80 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs @@ -20,13 +20,28 @@ namespace VisualPinball.Unity { + /// + /// Physical meaning of a native analog axis after it has been mapped. + /// public enum SensorMappingKind { + /// Position or intent input, such as a gamepad stick axis. Position, + /// Measured cabinet velocity. Velocity, + /// Measured cabinet acceleration. Acceleration } + /// + /// Describes how one native input axis maps into a nudge sensor channel. + /// + /// + /// The mapping stores raw device identity and calibration values rather than a + /// normalized Unity input binding. Native devices such as KL25Z/Pinscape can + /// expose motion axes with device-specific centers, so the raw center must be + /// captured and applied before dead-zone, limit, and scale. + /// public sealed class SensorMapping { public string DeviceId { get; set; } = string.Empty; @@ -42,6 +57,10 @@ public sealed class SensorMapping public bool IsMapped => !string.IsNullOrEmpty(DeviceId) && AxisId >= 0; + /// + /// Applies center, dead-zone, limit, and scale to a raw native input value. + /// + /// The value to feed into the configured nudge channel. public float ProcessRawValue(float rawValue, long timestampUsec) { RawValue = rawValue; @@ -64,6 +83,9 @@ public float ProcessRawValue(float rawValue, long timestampUsec) return MappedValue; } + /// + /// Serializes this mapping into the compact package/component format. + /// public override string ToString() { if (!IsMapped) { @@ -84,6 +106,13 @@ public override string ToString() return value; } + /// + /// Parses a mapping written by . + /// + /// + /// The parser accepts both the current six-value payload and the older + /// five-value form without a raw center so existing tables keep loading. + /// public static bool TryParse(string value, out SensorMapping mapping) { mapping = new SensorMapping(); @@ -106,6 +135,10 @@ public static bool TryParse(string value, out SensorMapping mapping) return false; } + /// + /// Parses from the end of the semicolon-delimited payload so semicolons in + /// native device ids are preserved. + /// private static bool TryParseParts(string[] parts, int trailingValueCount, out SensorMapping mapping) { mapping = new SensorMapping(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs index 554ab8125..5c7c3608e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs @@ -18,6 +18,17 @@ namespace VisualPinball.Unity { + /// + /// Models cabinet movement as two independent damped spring axes and exposes + /// the cabinet acceleration/displacement used by ball physics, visual nudge, + /// and the simulated plumb bob. + /// + /// + /// This is the VPE representation of VP's cabinet nudge source concept from + /// vpinball/src/physics/cabinet/NudgeHandler.*. The calibrated X/Y + /// frequencies are intentionally different: real cabinets are stiffer across + /// the cab than front-to-back. + /// public struct CabinetPhysicsState { public const float DefaultKeyboardDampingRatio = 0.5f; @@ -35,6 +46,9 @@ public struct CabinetPhysicsState public float2 CabinetAcceleration; public float2 CabinetOffset; + /// + /// Creates the default cabinet oscillator with calibrated cabinet damping. + /// public CabinetPhysicsState(float mass) : this(mass, XCalibratedDampingRatio, YCalibratedDampingRatio) { } @@ -50,12 +64,24 @@ private CabinetPhysicsState(float mass, float xDampingRatio, float yDampingRatio public static CabinetPhysicsState Default => new(113f); + /// + /// Creates the softer oscillator used for keyboard/button nudges. + /// + /// + /// Keyboard input is an intent, not a measured cabinet acceleration. The + /// damping parameter lets authors make that synthetic impulse feel sturdy + /// rather than like a long spring oscillation. + /// public static CabinetPhysicsState Keyboard(float dampingRatio) { dampingRatio = math.clamp(dampingRatio, MinKeyboardDampingRatio, MaxKeyboardDampingRatio); return new CabinetPhysicsState(113f, dampingRatio, dampingRatio); } + /// + /// Advances both cabinet axes by one physics millisecond using the supplied + /// force in Newtons. + /// public void StepOneMillisecond(float2 force) { X.StepOneMillisecond(force.x); @@ -64,6 +90,10 @@ public void StepOneMillisecond(float2 force) CabinetOffset = new float2(X.Displacement, Y.Displacement); } + /// + /// Clears displacement, velocity, and acceleration without changing the + /// oscillator calibration. + /// public void Reset() { X.Reset(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs index 0a704512c..f7c3a3929 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs @@ -18,6 +18,14 @@ namespace VisualPinball.Unity { + /// + /// One-dimensional mass/spring/damper integrator used for cabinet motion. + /// + /// + /// It stores physical units directly: force in Newtons, displacement in + /// meters, velocity in meters per second, and acceleration in meters per + /// second squared. The physics thread calls it at the fixed VP/VPE 1 ms step. + /// public struct DampedHarmonicOscillator { public float Mass; @@ -28,6 +36,9 @@ public struct DampedHarmonicOscillator public float Velocity; public float Acceleration; + /// + /// Creates an oscillator from mass, natural frequency, and damping ratio. + /// public DampedHarmonicOscillator(float mass, float frequencyHz, float dampingRatio) { Mass = mass; @@ -39,11 +50,21 @@ public DampedHarmonicOscillator(float mass, float frequencyHz, float dampingRati Acceleration = 0f; } + /// + /// Advances the oscillator by VPE's fixed one millisecond physics step. + /// public void StepOneMillisecond(float force) { Step(force, 0.001f); } + /// + /// Applies one explicit Euler integration step. + /// + /// + /// The cabinet nudge model is intentionally simple and cheap; it runs in the + /// same tight loop as ball physics and does not need a general solver. + /// public void Step(float force, float deltaTime) { Acceleration = (force - DampingCoefficient * Velocity - SpringConstant * Displacement) / Mass; @@ -51,6 +72,9 @@ public void Step(float force, float deltaTime) Displacement += Velocity * deltaTime; } + /// + /// Returns the oscillator to rest while preserving its mass and coefficients. + /// public void Reset() { Displacement = 0f; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs index 73281d0ad..83ba766d9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs @@ -16,10 +16,25 @@ namespace VisualPinball.Unity { + /// + /// Selects how a digital keyboard/button nudge is converted into cabinet + /// acceleration. + /// public enum KeyboardNudgeMode { + /// + /// Legacy VP-style instant shove/retract pulse. + /// PushRetract = 0, + + /// + /// Legacy VP box model where a table-space offset springs back to rest. + /// BoxModel = 1, + + /// + /// Cabinet oscillator model introduced for this nudge stack. + /// CabModel = 2 } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs index 4b9bd5922..2d3646201 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs @@ -20,6 +20,19 @@ namespace VisualPinball.Unity { + /// + /// Converts script or keyboard nudge commands into cabinet acceleration and + /// displacement samples. + /// + /// + /// The and + /// paths are direct ports/adaptations + /// of VP keyboard nudge behavior from + /// vpinball/src/physics/cabinet/KeyboardNudge.*. The cabinet model path + /// keeps the same public Nudge(angle, force) shape but feeds the oscillator in + /// so keyboard nudges feel more like a + /// sturdy cabinet impulse. + /// public struct KeyboardNudgeState { private const int DeactivationDelayMs = 10000; @@ -46,6 +59,13 @@ public struct KeyboardNudgeState private FixedList512Bytes _cabImpulses; private int _cabDeactivationDelay; + /// + /// A short raised-cosine cabinet acceleration pulse. + /// + /// + /// The cosine window avoids a square acceleration step, which would make the + /// visual camera offset and plumb bob react with a hard discontinuity. + /// private struct CabinetImpulse { public int Length; @@ -73,6 +93,13 @@ public float2 CurrentAcceleration } } + /// + /// Creates keyboard nudge state for a table. + /// + /// Conversion model used for keyboard/button nudges. + /// Author/user strength multiplier. + /// Table nudge time used by the legacy box model. + /// Damping ratio used by the cabinet oscillator model. public KeyboardNudgeState(KeyboardNudgeMode mode, float strength, float nudgeTime, float cabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio) { @@ -105,16 +132,26 @@ public KeyboardNudgeState(KeyboardNudgeMode mode, float strength, float nudgeTim _ => _cabDeactivationDelay > 0, }; + /// + /// Rebuilds the mode-specific state after runtime configuration changes. + /// public void Configure(KeyboardNudgeMode mode, float strength, float nudgeTime, float cabinetDamping) { this = new KeyboardNudgeState(mode, strength, nudgeTime, cabinetDamping); } + /// + /// Updates only the strength scalar while preserving in-flight state. + /// public void SetStrength(float strength) { Strength = strength; } + /// + /// Starts a new keyboard/button nudge from the VP script-facing angle/force + /// convention. + /// public void Nudge(float angleDeg, float force) { switch (Mode) { @@ -130,6 +167,9 @@ public void Nudge(float angleDeg, float force) } } + /// + /// Advances whichever keyboard nudge model is active by one physics tick. + /// public void StepOneMillisecond() { switch (Mode) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs index aac78d4b2..f30280795 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs @@ -19,8 +19,23 @@ namespace VisualPinball.Unity { + /// + /// Learns the scale that converts a raw cabinet accelerometer channel into + /// the same units as a raw cabinet velocity channel. + /// + /// + /// Adapted from VP's + /// vpinball/src/physics/cabinet/MotionGainCalibratorAxis.h. The + /// calibrator only accepts motion segments that leave and return to rest, + /// because those segments let us compare measured velocity against integrated + /// acceleration without depending on an external ground-truth position. + /// public struct MotionGainCalibratorAxis { + /// + /// Tunables used to decide whether a motion segment is useful and how much + /// accepted segments influence the running gain estimate. + /// public struct Config { public float InitialGain; @@ -54,6 +69,9 @@ public struct Config }; } + /// + /// Diagnostics for the last completed calibration segment. + /// public struct SegmentResult { public byte Accepted; @@ -89,6 +107,10 @@ private struct Sample private SegmentResult _lastResult; private float _globalConfidence; + /// + /// Creates a calibrator with the supplied acceptance thresholds and initial + /// gain. + /// public MotionGainCalibratorAxis(Config config) { _config = config; @@ -106,6 +128,9 @@ public MotionGainCalibratorAxis(Config config) _globalConfidence = 0f; } + /// + /// Calibrator configured with defaults tuned for cabinet nudge sensors. + /// public static MotionGainCalibratorAxis Default => new(Config.Default); public bool IsSegmentActive => _segmentActive != 0; public float Gain => _gain; @@ -116,12 +141,20 @@ public MotionGainCalibratorAxis(Config config) public float GlobalConfidence => _globalConfidence; public SegmentResult LastResult => _lastResult; + /// + /// Applies new acceptance thresholds while preserving the already learned + /// gain. + /// public void Configure(Config config) { _config = config; _gain = math.clamp(_gain, _config.MinGain, _config.MaxGain); } + /// + /// Clears learned gain, confidence, segment counters, and any in-progress + /// segment. + /// public void Reset() { _segmentActive = 0; @@ -137,13 +170,23 @@ public void Reset() _globalConfidence = 0f; } + /// + /// Converts a raw acceleration sample using the current learned gain. + /// public float ScaleAcceleration(float rawAcceleration) => _gain * rawAcceleration; + /// + /// Converts a raw velocity sample into acceleration-equivalent units for + /// Kalman updates. + /// public float ScaleVelocityToAccelerationUnits(float rawVelocity) { return math.abs(_gain) <= _config.Epsilon ? 0f : rawVelocity / _gain; } + /// + /// Starts collecting samples for one candidate motion segment. + /// public void StartSegment(ulong timeUs) { _segmentActive = 1; @@ -152,6 +195,10 @@ public void StartSegment(ulong timeUs) _segmentStartUs = timeUs; } + /// + /// Adds a raw velocity/acceleration sample to the active segment. + /// + /// true when the sample was accepted into the segment. public bool AddSample(ulong timeUs, float rawVelocity, float rawAcceleration) { if (!IsSegmentActive) { @@ -177,6 +224,9 @@ public bool AddSample(ulong timeUs, float rawVelocity, float rawAcceleration) return true; } + /// + /// Reduces sample density when the fixed-size buffer fills. + /// private void CompactSamples() { var compacted = 0; @@ -196,6 +246,11 @@ private void CompactSamples() _samples.Length = compacted; } + /// + /// Evaluates the active segment and folds accepted data into the running + /// gain estimate. + /// + /// true when the segment passed the quality gates. public bool EndSegment() { if (!IsSegmentActive) { @@ -228,6 +283,10 @@ public bool EndSegment() return true; } + /// + /// Estimates how trustworthy the accumulated gain is from accepted segment + /// count, observed duration, and regression strength. + /// private float ComputeGlobalConfidence() { if (_acceptedSegmentCount == 0) { @@ -241,6 +300,16 @@ private float ComputeGlobalConfidence() return math.clamp(0.40f * segFactor + 0.35f * durationFactor + 0.25f * denomFactor, 0f, 1f); } + /// + /// Fits integrated acceleration to velocity for the current rest-to-rest + /// segment. + /// + /// + /// Both traces are detrended between their endpoints so small DC offsets + /// and residual drift do not dominate the gain estimate. This mirrors the VP + /// implementation and is the reason segments must not be split when the + /// sample buffer fills. + /// private SegmentResult EvaluateCurrentSegment() { var result = new SegmentResult { @@ -364,6 +433,15 @@ private SegmentResult EvaluateCurrentSegment() return result; } + /// + /// Returns the optional triangular regression weight for a normalized sample + /// time. + /// + /// + /// Center weighting favors the part of the nudge where the signal is + /// strongest and deemphasizes endpoints that are most affected by rest + /// detection timing. + /// private float ComputeSampleWeight(float tNorm) { if (_config.UseTriangularWeights == 0) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs index 9623dbf54..7ad7d0c0f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs @@ -19,6 +19,17 @@ namespace VisualPinball.Unity { + /// + /// One-dimensional Kalman filter for cabinet position, velocity, acceleration, + /// and slow sensor bias. + /// + /// + /// Adapted from VP's + /// vpinball/src/physics/cabinet/MotionKalmanAxis.h. Cabinet devices can + /// provide velocity, acceleration, or both, often with independent drift. The + /// five-state filter lets VPE fuse those measurements while keeping estimated + /// sensor bias separate from physical cabinet motion. + /// public struct MotionKalmanAxis { private const int StatePosition = 0; @@ -28,6 +39,9 @@ public struct MotionKalmanAxis private const int StateAccelerationBias = 4; private const int StateCount = 5; + /// + /// Filter covariance and timing parameters. + /// public struct Config { public float ProcessJerkVariance; @@ -73,6 +87,9 @@ public struct Config private Vector5f _state; private Matrix5f _covariance; + /// + /// Creates an uninitialized filter using the supplied covariance settings. + /// public MotionKalmanAxis(Config config) { _config = config; @@ -84,6 +101,9 @@ public MotionKalmanAxis(Config config) _covariance.SetZero(); } + /// + /// Filter configured with defaults tuned for nudge sensor fusion. + /// public static MotionKalmanAxis Default => new(Config.Default); public bool IsInitialized => _initialized != 0; @@ -96,11 +116,17 @@ public MotionKalmanAxis(Config config) public float BiasedVelocity => Velocity + VelocityBias; public float BiasedAcceleration => Acceleration + AccelerationBias; + /// + /// Applies new covariance settings without altering the current state. + /// public void Configure(Config config) { _config = config; } + /// + /// Initializes or reinitializes the filter at a known state and timestamp. + /// public void Reset(ulong timeUs, float position = 0f, float velocity = 0f, float acceleration = 0f, float velocityBias = 0f, float accelerationBias = 0f) { @@ -115,11 +141,21 @@ public void Reset(ulong timeUs, float position = 0f, float velocity = 0f, float _covariance[StateAccelerationBias, StateAccelerationBias] = _config.InitialAccelerationBiasVariance; } + /// + /// Replaces the state vector while preserving timestamp and covariance. + /// public void SetState(float position, float velocity, float acceleration, float velocityBias, float accelerationBias) { _state.Set(position, velocity, acceleration, velocityBias, accelerationBias); } + /// + /// Advances the prediction model to the requested timestamp. + /// + /// + /// Prediction is split into small steps so unusually sparse or delayed input + /// samples do not create a single large covariance jump. + /// public void PredictTo(ulong timeUs) { if (!IsInitialized || timeUs <= _timeUs) { @@ -135,6 +171,14 @@ public void PredictTo(ulong timeUs) _timeUs = timeUs; } + /// + /// Fuses a measured velocity sample. + /// + /// + /// The measurement model observes velocity plus velocity bias; keeping the + /// bias term explicit lets the filter learn slow sensor drift instead of + /// feeding it into cabinet displacement. + /// public void UpdateVelocity(ulong timeUs, float velocity) { if (!IsInitialized) { @@ -150,6 +194,14 @@ public void UpdateVelocity(ulong timeUs, float velocity) UpdateScalarMeasurement(h, velocity, _config.VelocityMeasurementVariance); } + /// + /// Fuses a measured acceleration sample. + /// + /// + /// The measurement observes acceleration plus acceleration bias for the same + /// reason as velocity: real cabinet accelerometers tend to have small DC + /// offsets that should not become permanent cabinet force. + /// public void UpdateAcceleration(ulong timeUs, float acceleration) { if (!IsInitialized) { @@ -165,6 +217,14 @@ public void UpdateAcceleration(ulong timeUs, float acceleration) UpdateScalarMeasurement(h, acceleration, _config.AccelerationMeasurementVariance); } + /// + /// Pulls the filter back toward rest when the synchronized sensor channels + /// indicate the cabinet has settled. + /// + /// + /// These pseudo-measurements are what keep long-running tables from slowly + /// drifting across the playfield after many tiny integrated errors. + /// public void UpdateRestConstraints(ulong timeUs, bool applyPositionConstraint = true, bool applyVelocityConstraint = true, bool applyAccelerationConstraint = true) { @@ -191,6 +251,9 @@ public void UpdateRestConstraints(ulong timeUs, bool applyPositionConstraint = t } } + /// + /// Runs one bounded prediction step. + /// private void PredictStep(float dt) { dt = math.max(dt, _config.MinDt); @@ -202,6 +265,13 @@ private void PredictStep(float dt) Symmetrize(ref _covariance); } + /// + /// Builds the constant-acceleration state transition matrix. + /// + /// + /// Bias terms decay toward zero with a configurable mean-reversion time so + /// stale bias estimates fade if the sensor behavior changes. + /// private Matrix5f BuildTransitionMatrix(float dt) { var dt2 = dt * dt; @@ -217,6 +287,9 @@ private Matrix5f BuildTransitionMatrix(float dt) return f; } + /// + /// Builds process noise for jerk-driven motion plus independent bias drift. + /// private Matrix5f BuildProcessNoiseMatrix(float dt) { var qj = _config.ProcessJerkVariance; @@ -242,6 +315,14 @@ private Matrix5f BuildProcessNoiseMatrix(float dt) return q; } + /// + /// Applies one scalar Kalman measurement update. + /// + /// + /// Uses the Joseph covariance form, which costs a little more math but keeps + /// the covariance matrix better behaved after many updates in single + /// precision. + /// private void UpdateScalarMeasurement(Vector5f h, float measurement, float measurementVariance) { var predictedMeasurement = Dot(h, _state); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs index e5a64aaf3..da2f9a109 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs @@ -18,6 +18,17 @@ namespace VisualPinball.Unity { + /// + /// Detects a player nudge intent from an analog acceleration signal and emits + /// a short cabinet impulse. + /// + /// + /// Ported from VP's vpinball/src/physics/cabinet/NudgeIntentHandler.*. + /// The handler watches for rising peaks rather than treating every sensor + /// sample as cabinet force. That distinction matters for gamepad sticks and + /// accelerometers configured in "intent" mode, where the player is expressing + /// a shove and VP/VPE should synthesize the physical impulse. + /// public struct NudgeIntentState { private const int ImpulseLengthMs = 25; @@ -35,6 +46,10 @@ public struct NudgeIntentState private float _lastImpulseStrength; private ulong _lastImpulseTime; + /// + /// Creates a peak detector for either gamepad-style or cabinet-style analog + /// input. + /// public NudgeIntentState(bool isGamepad) { _isGamepad = isGamepad ? (byte)1 : (byte)0; @@ -51,6 +66,11 @@ public NudgeIntentState(bool isGamepad) } public bool IsImpulseInProgress => _impulseElapsed <= ImpulseLengthMs; + + /// + /// Current raised-cosine impulse acceleration, or zero when no impulse is + /// active. + /// public float2 ImpulseAcceleration { get { @@ -62,6 +82,15 @@ public float2 ImpulseAcceleration } } + /// + /// Advances peak tracking by one millisecond and starts/updates an impulse + /// when a valid nudge peak is detected. + /// + /// + /// Forward nudges are clamped to the "push" half of the Y axis, matching the + /// VP source: players can shove the cabinet away from themselves, but not + /// physically pull it toward themselves with the same motion. + /// public void StepOneMillisecond(float2 nudgeAcceleration) { _impulseElapsed++; @@ -110,6 +139,13 @@ private float GetImpulseStrengthFactor() return 1f; } + /// + /// Decides whether the current peak should become an impulse. + /// + /// + /// Non-gamepad sensors suppress repeated smaller peaks for 300 ms so a noisy + /// accelerometer tail does not trigger multiple cabinet shoves from one hit. + /// private void EvaluateImpulse(float2 impulse) { var strengthFactor = GetImpulseStrengthFactor(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs index 537a63c44..b186d32f5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs @@ -16,13 +16,23 @@ namespace VisualPinball.Unity { + /// + /// Logical axis slot used when routing native analog samples into a configured + /// nudge sensor. + /// public enum NudgeSensorChannel { + /// Gamepad/intent position X. X = 0, + /// Gamepad/intent position Y. Y = 1, + /// Cabinet velocity X. VelocityX = 2, + /// Cabinet velocity Y. VelocityY = 3, + /// Cabinet acceleration X. AccelerationX = 4, + /// Cabinet acceleration Y. AccelerationY = 5 } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs index 973f31cb8..06ff6965f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs @@ -19,6 +19,10 @@ namespace VisualPinball.Unity { + /// + /// Quarter-turn mounting options for accelerometer boards installed in a + /// cabinet. + /// public enum NudgeSensorMountRotation { [InspectorName("0 deg")] @@ -34,8 +38,20 @@ public enum NudgeSensorMountRotation Rotation270 = 3 } + /// + /// Converts sensor-space axes to VPE cabinet-space axes. + /// + /// + /// KL25Z/Pinscape-style boards are often mounted in whichever orientation is + /// convenient inside the cabinet. Keeping this as a pure transform lets + /// calibration, graphing, and physics all consume the same canonical X/Y + /// convention. + /// public static class NudgeSensorMountTransform { + /// + /// Normalizes invalid serialized enum values back to no rotation. + /// public static NudgeSensorMountRotation NormalizeRotation(NudgeSensorMountRotation rotation) { return (uint)rotation <= (uint)NudgeSensorMountRotation.Rotation270 @@ -43,6 +59,9 @@ public static NudgeSensorMountRotation NormalizeRotation(NudgeSensorMountRotatio : NudgeSensorMountRotation.Rotation0; } + /// + /// Applies mirror and rotation to a two-axis sensor vector. + /// public static float2 Transform(float2 value, NudgeSensorMountRotation rotation, bool mirrorX) { if (mirrorX) { @@ -61,6 +80,14 @@ public static float2 Transform(float2 value, NudgeSensorMountRotation rotation, } } + /// + /// Applies the mount transform to a single channel and value. + /// + /// + /// Samples arrive one native axis at a time, so this method rotates both the + /// channel identity and its sign instead of requiring callers to assemble a + /// full vector first. + /// public static void TransformChannel(ref NudgeSensorChannel channel, ref float value, NudgeSensorMountRotation rotation, bool mirrorX) { @@ -75,6 +102,9 @@ public static void TransformChannel(ref NudgeSensorChannel channel, ref float va channel = ChannelFor(group, targetX); } + /// + /// Swaps mapped-axis flags when the mount rotation swaps X and Y. + /// public static void TransformMappedAxes(ref bool xMapped, ref bool yMapped, NudgeSensorMountRotation rotation) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs index 936253bdf..05a9e2576 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs @@ -18,6 +18,14 @@ namespace VisualPinball.Unity { + /// + /// Simulation-thread configuration for one analog nudge sensor. + /// + /// + /// This is a blittable runtime form of the Unity component/editor settings. + /// Mapping flags are stored as bytes so the struct stays job-friendly and can + /// be copied into simulation state without managed references. + /// public struct NudgeSensorRuntimeConfig { public NudgeSensorType Type; @@ -33,6 +41,10 @@ public struct NudgeSensorRuntimeConfig public byte AccelerationYMapped; } + /// + /// Stores the latest value for one logical nudge channel and records how that + /// channel should be interpreted. + /// public struct NudgePhysicsSensorState { public byte Mapped; @@ -42,6 +54,9 @@ public struct NudgePhysicsSensorState public bool IsMapped => Mapped != 0; + /// + /// Enables or disables the channel and clears its last sample. + /// public void Configure(bool mapped, SensorMappingKind kind) { Mapped = mapped ? (byte)1 : (byte)0; @@ -50,6 +65,9 @@ public void Configure(bool mapped, SensorMappingKind kind) TimestampUsec = 0; } + /// + /// Stores a mapped native-input value and its native timestamp. + /// public void SetValue(float value, ulong timestampUsec) { Value = value; @@ -57,6 +75,15 @@ public void SetValue(float value, ulong timestampUsec) } } + /// + /// Converts gamepad-style position axes into VP-style nudge impulses. + /// + /// + /// Adapted from VP's vpinball/src/physics/cabinet/GamepadNudge.*. + /// Sticks and controller axes do not measure actual cabinet movement; they + /// express player intent, so this path detects an intent peak and then drives + /// the shared cabinet spring. + /// public struct GamepadNudgeState { private const int DeactivationDelayMs = 10000; @@ -71,6 +98,9 @@ public struct GamepadNudgeState private int _deactivationDelay; + /// + /// Creates a gamepad intent sensor with optional X/Y mappings. + /// public GamepadNudgeState(float strength, bool xMapped, bool yMapped) { XSensor = default; @@ -87,6 +117,9 @@ public GamepadNudgeState(float strength, bool xMapped, bool yMapped) public bool IsActive => _deactivationDelay > 0; + /// + /// Applies the latest mapped gamepad position sample. + /// public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) { switch (channel) { @@ -99,6 +132,9 @@ public void ApplySample(NudgeSensorChannel channel, float value, ulong timestamp } } + /// + /// Advances intent detection and cabinet spring response by one millisecond. + /// public void StepOneMillisecond() { var x = XSensor.Value * Strength * 16f; @@ -120,6 +156,18 @@ public void StepOneMillisecond() } } + /// + /// Interprets real cabinet motion sensors and produces cabinet acceleration + /// for physics. + /// + /// + /// Adapted from VP's + /// vpinball/src/physics/cabinet/CabinetNudgeSensor.* with supporting + /// filter/calibration ports from MotionKalmanAxis.h and + /// MotionGainCalibratorAxis.h. Direct mode uses measured motion; + /// cabinet-intent mode runs the same measured signal through the VP intent + /// detector and applies a synthesized cabinet impulse. + /// public struct CabinetSensorState { private const int DeactivationDelayMs = 10000; @@ -127,6 +175,10 @@ public struct CabinetSensorState private const int CrossRestCountThreshold = 75; private const int ForceRestCountThreshold = CrossRestCountThreshold + 200; + /// + /// Per-channel state used to align native timestamps to the simulation + /// clock and detect rest. + /// private struct SyncedSensor { public NudgePhysicsSensorState Sensor; @@ -136,6 +188,9 @@ private struct SyncedSensor public byte ForceRest; public float LastValue; + /// + /// Enables or disables the channel and clears synchronization state. + /// public void Configure(bool mapped, SensorMappingKind kind) { Sensor.Configure(mapped, kind); @@ -174,6 +229,9 @@ public void Configure(bool mapped, SensorMappingKind kind) private byte _intentEnabled; private int _deactivationDelay; + /// + /// Creates a cabinet sensor from mapped velocity/acceleration channels. + /// public CabinetSensorState(NudgeSensorType type, float strength, float cabinetMassKg, bool velXMapped, bool velYMapped, bool accXMapped, bool accYMapped) { @@ -202,6 +260,10 @@ public CabinetSensorState(NudgeSensorType type, float strength, float cabinetMas _deactivationDelay = 0; } + /// + /// Stores the latest mapped velocity or acceleration sample for the selected + /// channel. + /// public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) { switch (channel) { @@ -220,6 +282,10 @@ public void ApplySample(NudgeSensorChannel channel, float value, ulong timestamp } } + /// + /// Advances sensor fusion, rest handling, and cabinet output by one + /// millisecond. + /// public void StepOneMillisecond() { if (_deactivationDelay > 0) { @@ -241,7 +307,7 @@ public void StepOneMillisecond() CabinetAcceleration = Cabinet.CabinetAcceleration; } else { // Note: the reference (CabinetNudgeSensor.cpp:280-285) multiplies by the - // strength scale twice, making direct-mode output scale with strength². + // strength scale twice, making direct-mode output scale with strength squared. // That's an upstream bug we deliberately don't reproduce: strength is // applied once, linearly. CabinetAcceleration.x = _emaX.Update(KalmanX.Acceleration, 0.001f); @@ -252,6 +318,16 @@ public void StepOneMillisecond() CabinetOffset = Cabinet.CabinetOffset; } + /// + /// Updates one physical axis from its velocity and/or acceleration channels. + /// + /// + /// When both channels are mapped, acceleration is trusted until the gain + /// calibrator has enough confidence to scale velocity into acceleration + /// units. Rest constraints reset accumulated filter drift once both channels + /// have either crossed zero after a quiet period or remained quiet long + /// enough to be considered settled. + /// private void UpdateAxis(ref SyncedSensor velSensor, ref SyncedSensor accSensor, ref MotionKalmanAxis kalmanFilter, ref MotionGainCalibratorAxis gainCalibrator) { @@ -302,6 +378,15 @@ private void UpdateAxis(ref SyncedSensor velSensor, ref SyncedSensor accSensor, kalmanFilter.PredictTo(_timeUs); } + /// + /// Applies one mapped channel to the Kalman filter after aligning its native + /// timestamp to simulation time. + /// + /// + /// Native input timestamps can be ahead of simulation time because sampling + /// happens on a different thread. The clock delta is adjusted instead of + /// feeding future samples into the filter. + /// private void UpdateAxisSensor(ref SyncedSensor sensor, ref MotionKalmanAxis axis, float axisGain) { if (!sensor.Sensor.IsMapped) { @@ -343,12 +428,19 @@ private void UpdateAxisSensor(ref SyncedSensor sensor, ref MotionKalmanAxis axis } } + /// + /// Small one-pole smoother used to remove the last bit of direct-mode sensor + /// chatter before feeding cabinet acceleration to physics. + /// private struct EmaState { private float _tau; private float _value; private byte _initialized; + /// + /// Creates an exponential moving average with the supplied time constant. + /// public EmaState(float tau) { _tau = math.max(1.0e-6f, tau); @@ -356,6 +448,9 @@ public EmaState(float tau) _initialized = 0; } + /// + /// Filters one sample and returns the smoothed value. + /// public float Update(float sample, float dt) { if (_initialized == 0) { @@ -371,6 +466,15 @@ public float Update(float sample, float dt) } } + /// + /// Runtime wrapper for one configured nudge sensor slot. + /// + /// + /// Applies the sensor mount transform before dispatching samples to either the + /// gamepad-intent or cabinet-sensor implementation. Keeping this transform at + /// the slot boundary ensures the graph, calibration, and physics paths all see + /// cabinet-space axes. + /// public struct NudgeSensorState { public NudgeSensorType Type; @@ -386,6 +490,9 @@ public struct NudgeSensorState public bool IsEnabled => Enabled != 0; public bool IsActive => IsEnabled && (Type == NudgeSensorType.GamepadIntent ? Gamepad.IsActive : Cabinet.IsActive); + /// + /// Creates an enabled sensor slot from runtime configuration. + /// public NudgeSensorState(NudgeSensorRuntimeConfig config) { Type = config.Type; @@ -410,6 +517,9 @@ public NudgeSensorState(NudgeSensorRuntimeConfig config) LastActivityTimestampUsec = 0; } + /// + /// Disables this sensor and clears any last produced cabinet motion. + /// public void Disable() { Enabled = 0; @@ -418,6 +528,10 @@ public void Disable() LastActivityTimestampUsec = 0; } + /// + /// Applies mount orientation and forwards a mapped sample to the active + /// sensor implementation. + /// public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) { if (!IsEnabled) { @@ -434,6 +548,10 @@ public void ApplySample(NudgeSensorChannel channel, float value, ulong timestamp } } + /// + /// Advances the active sensor model and publishes cabinet acceleration and + /// visual offset for the current physics tick. + /// public void StepOneMillisecond() { if (!IsEnabled) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs index e10b6303e..ad0399e5b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs @@ -16,10 +16,24 @@ namespace VisualPinball.Unity { + /// + /// Defines how a configured analog nudge sensor should be interpreted. + /// public enum NudgeSensorType { + /// + /// Treats X/Y values as player intent, useful for sticks or controllers. + /// GamepadIntent = 0, + + /// + /// Treats cabinet acceleration as intent and synthesizes a cabinet impulse. + /// CabinetIntent = 1, + + /// + /// Treats cabinet velocity/acceleration as measured physical cabinet motion. + /// CabinetDirect = 2 } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs index 220ebbc13..c9f2b418b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs @@ -18,11 +18,18 @@ namespace VisualPinball.Unity { + /// + /// Command queued by the main thread to apply one keyboard-driven cabinet + /// impulse on the simulation thread. + /// internal readonly struct KeyboardNudgeCommand { public readonly float AngleDeg; public readonly float Force; + /// + /// Creates a keyboard nudge command in playfield degrees. + /// public KeyboardNudgeCommand(float angleDeg, float force) { AngleDeg = angleDeg; @@ -30,6 +37,10 @@ public KeyboardNudgeCommand(float angleDeg, float force) } } + /// + /// Command queued by the input thread to deliver one mapped analog nudge + /// sample to the simulation thread. + /// internal readonly struct NudgeSensorSampleCommand { public readonly int SensorIndex; @@ -37,6 +48,9 @@ internal readonly struct NudgeSensorSampleCommand public readonly float Value; public readonly ulong TimestampUsec; + /// + /// Creates a timestamped sensor sample for the configured sensor slot. + /// public NudgeSensorSampleCommand(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) { SensorIndex = sensorIndex; @@ -46,6 +60,19 @@ public NudgeSensorSampleCommand(int sensorIndex, NudgeSensorChannel channel, flo } } + /// + /// Owns all cabinet nudge sources and exposes the single cabinet motion value + /// consumed by the physics step. + /// + /// + /// This is the VPE-side coordinator for the VP cabinet nudge model, tying + /// together ports/adaptations from + /// vpinball/src/physics/cabinet/NudgeHandler.*, + /// KeyboardNudge.*, GamepadNudge.*, and + /// CabinetNudgeSensor.*. Keyboard input intentionally wins while its + /// spring response is active; otherwise the most recently active analog sensor + /// supplies the cabinet acceleration and visual offset. + /// public struct NudgeState { public const int MaxSensors = 4; @@ -62,6 +89,10 @@ public struct NudgeState public float2 MaxCabinetAcceleration; public int KeyboardNudgeIndex; + /// + /// Creates a nudge coordinator with keyboard nudging enabled and no analog + /// sensors configured. + /// public NudgeState(KeyboardNudgeMode keyboardMode, float keyboardStrength, float nudgeTime, float keyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio) { @@ -78,12 +109,19 @@ public NudgeState(KeyboardNudgeMode keyboardMode, float keyboardStrength, float ActiveSourceIndex = -2; } + /// + /// Applies a manual nudge impulse and increments the keyboard nudge counter + /// used by telemetry/UI. + /// public void ApplyKeyboardImpulse(float angleDeg, float force) { KeyboardNudgeIndex++; Keyboard.Nudge(angleDeg, force); } + /// + /// Sets the number of analog sensor slots that should be considered active. + /// public void ConfigureSensors(int count) { SensorCount = math.clamp(count, 0, MaxSensors); @@ -92,6 +130,9 @@ public void ConfigureSensors(int count) } } + /// + /// Rebuilds one analog sensor slot from serialized/player configuration. + /// public void ConfigureSensor(int index, NudgeSensorRuntimeConfig config) { if ((uint)index >= MaxSensors) { @@ -106,6 +147,9 @@ public void ConfigureSensor(int index, NudgeSensorRuntimeConfig config) } } + /// + /// Delivers one mapped native-input value to the selected sensor slot. + /// public void ApplySensorSample(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) { if ((uint)sensorIndex >= SensorCount) { @@ -116,6 +160,10 @@ public void ApplySensorSample(int sensorIndex, NudgeSensorChannel channel, float SetSensor(sensorIndex, sensor); } + /// + /// Advances keyboard and sensor models one millisecond and selects the + /// cabinet motion source for this physics tick. + /// public void StepOneMillisecond() { Keyboard.StepOneMillisecond(); @@ -146,6 +194,10 @@ public void StepOneMillisecond() } } + /// + /// Returns the largest signed acceleration seen since the last read and + /// clears the telemetry accumulator. + /// public float2 ReadAndResetMaxCabinetAcceleration() { var value = MaxCabinetAcceleration; @@ -153,6 +205,10 @@ public float2 ReadAndResetMaxCabinetAcceleration() return value; } + /// + /// Advances a configured sensor slot and writes the value back into the + /// fixed field storage. + /// private void StepSensor(int index) { if (index >= SensorCount) { @@ -163,6 +219,14 @@ private void StepSensor(int index) SetSensor(index, sensor); } + /// + /// Finds the analog sensor that should drive cabinet motion this tick. + /// + /// + /// Later timestamps win so that a device the user is actively touching + /// supersedes an older device that is still ringing down. Equal timestamps + /// fall back to the stronger cabinet motion. + /// private bool TryGetActiveSensor(out int index, out NudgeSensorState sensor) { var bestScore = -1f; @@ -192,6 +256,10 @@ private bool TryGetActiveSensor(out int index, out NudgeSensorState sensor) return index >= 0; } + /// + /// Scores active sensors by physical output rather than raw input, so intent + /// and direct sensors can be compared fairly. + /// private static float SensorActivityScore(NudgeSensorState sensor) { var accelerationScore = math.lengthsq(sensor.CabinetAcceleration); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs index 7fff3da92..99534bc43 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs @@ -19,6 +19,16 @@ namespace VisualPinball.Unity { + /// + /// Simulates the mechanical tilt plumb bob and queues tilt switch edges. + /// + /// + /// This is a C# port/adaptation of + /// vpinball/src/physics/cabinet/PlumbHandler.*. VP moved this out of + /// VBS nudging plugins so it can run at the stable one millisecond physics + /// cadence; VPE follows that model and feeds the result back to gamelogic as + /// switch transitions. + /// public struct PlumbState { private const float Gravity = 9.80665f; @@ -53,6 +63,9 @@ public bool TiltHigh private set => _tiltHigh = value ? (byte)1 : (byte)0; } + /// + /// Creates a plumb bob at rest below the pivot. + /// public PlumbState(bool enabled, float damping, float tiltThresholdDeg) { PoleLength = DefaultPoleLength; @@ -70,6 +83,9 @@ public PlumbState(bool enabled, float damping, float tiltThresholdDeg) _cabinetAccelerationScale = 1f; } + /// + /// Updates runtime plumb settings without resetting bob position. + /// public void Configure(bool enabled, float damping, float tiltThresholdDeg) { // Runtime reconfiguration must not teleport the pendulum or lose the @@ -90,6 +106,10 @@ public void Configure(bool enabled, float damping, float tiltThresholdDeg) } } + /// + /// Integrates the plumb bob one millisecond in the accelerating cabinet + /// reference frame. + /// public void StepOneMillisecond(float2 cabinetAcceleration) { if (!Enabled || TiltThresholdRad <= 0f) { @@ -152,6 +172,10 @@ public void StepOneMillisecond(float2 cabinetAcceleration) } } + /// + /// Returns the maximum plumb displacement and tilt percentage since the + /// last read, then clears those maxima. + /// public float3 ReadAndResetTiltStatus() { var value = new float3(MaxPlumbPosition.x, MaxPlumbPosition.y, MaxTiltPercent); @@ -160,11 +184,23 @@ public float3 ReadAndResetTiltStatus() return value; } + /// + /// Drops pending tilt switch edges after the simulation thread has consumed + /// them. + /// public void ClearPendingTiltEvents() { PendingTiltStates.Length = 0; } + /// + /// Keeps the bob on the tilt ring and reflects its velocity after hitting + /// the ring. + /// + /// + /// The small angular margin prevents the next integration step from starting + /// beyond the threshold and immediately re-colliding. + /// private void ClampToTiltRingAndBounce(float tiltAngle) { var limitAngle = tiltAngle - 1e-3f; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index b48567a80..ca9b55e24 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -8,24 +8,40 @@ using System.Runtime.InteropServices; using AOT; -namespace VisualPinball.Unity.Simulation -{ - /// - /// P/Invoke wrapper for native input polling library - /// - public static class NativeInputApi - { +namespace VisualPinball.Unity.Simulation +{ + /// + /// P/Invoke wrapper for the VisualPinball.NativeInput polling library. + /// + /// + /// The enum values, packing, string buffer sizes, and protocol version must + /// stay in lockstep with the native package. Managed code checks + /// at startup so stale native plugins fail fast + /// instead of silently misreading axis data. + /// + public static class NativeInputApi + { private const string DllName = "VisualPinball.NativeInput"; + + /// + /// Managed/native ABI version expected by this assembly. + /// public const int ProtocolVersion = 2; + + /// Maximum UTF-8 bytes in a stable native device id. public const int DeviceIdSize = 260; + + /// Maximum UTF-8 bytes in a display device name. public const int DeviceNameSize = 128; + + /// Maximum UTF-8 bytes in an axis display name. public const int AxisNameSize = 32; #region Enums - /// - /// Input action enum (must match native enum) - /// + /// + /// Input action enum. Numeric values must match the native enum. + /// public enum InputAction { LeftFlipper = 0, @@ -64,9 +80,9 @@ public enum InputAction Service8 = 33, } - /// - /// Input binding type - /// + /// + /// Native input binding type. Numeric values must match the native enum. + /// public enum BindingType { Keyboard = 0, @@ -74,6 +90,9 @@ public enum BindingType Mouse = 2, } + /// + /// Native event payload type. Numeric values must match the native enum. + /// public enum InputEventType { Action = 0, @@ -81,6 +100,9 @@ public enum InputEventType DevicesChanged = 2, } + /// + /// Physical meaning reported for an analog axis by native input. + /// public enum AxisKind { Position = 0, @@ -88,9 +110,10 @@ public enum AxisKind Acceleration = 2, } - /// - /// Key codes (Windows virtual key codes) - /// + /// + /// Key codes used by native keyboard bindings. Values are Windows virtual + /// key codes. + /// public enum KeyCode { LShift = 0xA0, @@ -157,9 +180,9 @@ public enum KeyCode #region Structures - /// - /// Input event structure (matches native struct layout) - /// + /// + /// Input event structure. Layout must match the native struct exactly. + /// [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct InputEvent { @@ -172,11 +195,11 @@ public struct InputEvent private int _padding; } - /// - /// Input binding structure - /// - [StructLayout(LayoutKind.Sequential, Pack = 4)] - public struct InputBinding + /// + /// Input binding structure. Layout must match the native struct exactly. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct InputBinding { public int Action; // InputAction public int BindingType; // BindingType @@ -184,10 +207,15 @@ public struct InputBinding private int _padding; } - // The native side fills the string fields with UTF-8; decode them manually, - // since ByValTStr would decode with the system ANSI codepage and garble - // non-ASCII device names. - [StructLayout(LayoutKind.Sequential, Pack = 4)] + /// + /// Native device metadata returned by enumeration. + /// + /// + /// The native side fills the string fields with UTF-8; decode them manually, + /// since ByValTStr would use the system ANSI codepage and garble + /// non-ASCII device names. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct InputDeviceInfo { public int DeviceIndex; @@ -205,7 +233,10 @@ public struct InputDeviceInfo public string DisplayName => DecodeUtf8(_displayName); } - [StructLayout(LayoutKind.Sequential, Pack = 4)] + /// + /// Native axis metadata and latest raw value for one device axis. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct InputAxisInfo { public int AxisId; @@ -222,8 +253,11 @@ public struct InputAxisInfo public string Name => DecodeUtf8(_name); } - private static string DecodeUtf8(byte[] bytes) - { + /// + /// Decodes a native null-terminated UTF-8 byte buffer. + /// + private static string DecodeUtf8(byte[] bytes) + { if (bytes == null) { return string.Empty; } @@ -238,45 +272,75 @@ private static string DecodeUtf8(byte[] bytes) #region Delegates - /// - /// Callback for input events - /// - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate void InputEventCallback(ref InputEvent evt, IntPtr userData); + /// + /// Callback for input events. Invoked on the native input polling thread. + /// + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void InputEventCallback(ref InputEvent evt, IntPtr userData); #endregion #region Native Functions + /// + /// Initializes the native input subsystem. + /// [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern int VpeInputInit(); + /// + /// Returns the native ABI protocol version. + /// [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern int VpeInputGetProtocolVersion(); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern void VpeInputShutdown(); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern void VpeInputSetBindings(InputBinding[] bindings, int count); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern int VpeInputStartPolling(InputEventCallback callback, IntPtr userData, int pollIntervalUs); - + + /// + /// Shuts down native input and releases native resources. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputShutdown(); + + /// + /// Replaces the native action binding table. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputSetBindings(InputBinding[] bindings, int count); + + /// + /// Starts native polling and dispatches events through the supplied callback. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputStartPolling(InputEventCallback callback, IntPtr userData, int pollIntervalUs); + + /// + /// Stops native polling. + /// [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern void VpeInputStopPolling(); + /// + /// Enumerates connected native input devices. + /// [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern int VpeInputListDevices([Out] InputDeviceInfo[] devices, int maxDevices); + /// + /// Enumerates axes for one native input device. + /// [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern int VpeInputListDeviceAxes(int deviceIndex, [Out] InputAxisInfo[] axes, int maxAxes); + /// + /// Returns the native high-resolution timestamp in microseconds. + /// [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern long VpeGetTimestampUsec(); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern void VpeSetThreadPriority(); + public static extern long VpeGetTimestampUsec(); + + /// + /// Lets the native plugin raise priority for the polling thread. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeSetThreadPriority(); #endregion } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index 26345cd58..62f5b8f03 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -14,11 +14,17 @@ namespace VisualPinball.Unity.Simulation { - /// - /// Manages native input polling and forwards events to the simulation thread. - /// Runs input polling on a separate thread at high frequency (500-1000 Hz). - /// - public class NativeInputManager : IDisposable + /// + /// Manages native input polling and forwards events to the simulation thread. + /// Runs input polling on a separate thread at high frequency (500-1000 Hz). + /// + /// + /// Axis events are also exposed to for analog + /// cabinet sensors. Device enumeration is intentionally cached by native index + /// so the polling callback can resolve stable device ids without doing + /// allocation-heavy enumeration work on the hot path. + /// + public class NativeInputManager : IDisposable { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const string LogPrefix = "[VPE]"; @@ -86,10 +92,24 @@ public static NativeInputManager TryGetExistingInstance() return Volatile.Read(ref _instance); } - public bool IsPolling => _polling; - + /// + /// Whether the native polling thread is currently running. + /// + public bool IsPolling => _polling; + + /// + /// Requested native polling rate in hertz. + /// public float TargetPollingHz => _polling && _pollIntervalUs > 0 ? 1000000f / _pollIntervalUs : 0f; + + /// + /// Measured incoming event rate over a short rolling window. + /// public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; + + /// + /// Raised on the input polling thread for raw analog axis events. + /// public event Action AxisInputReceived; /// @@ -108,9 +128,10 @@ private NativeInputManager() #region Public API /// - /// Initialize native input system - /// - public bool Initialize() + /// Initializes the native input system and verifies managed/native protocol + /// compatibility. + /// + public bool Initialize() { if (_initialized) return true; @@ -144,17 +165,17 @@ public bool Initialize() } /// - /// Set the simulation thread to forward input events to - /// - public void SetSimulationThread(SimulationThread simulationThread) + /// Sets the simulation thread that receives action input events. + /// + public void SetSimulationThread(SimulationThread simulationThread) { _simulationThread = simulationThread; } /// - /// Add an input binding - /// - public void AddBinding(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) + /// Adds a keyboard action binding to the built-in binding list. + /// + public void AddBinding(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) { _bindings.Add(new NativeInputApi.InputBinding { @@ -165,9 +186,9 @@ public void AddBinding(NativeInputApi.InputAction action, NativeInputApi.KeyCode } /// - /// Clear all bindings - /// - public void ClearBindings() + /// Clears the built-in binding list. + /// + public void ClearBindings() { _bindings.Clear(); } @@ -194,6 +215,13 @@ public static bool AppFocused set => _appFocused = value; } + /// + /// Enumerates native input devices and refreshes the device-index cache. + /// + /// + /// Calling this has the side effect of swapping the immutable + /// index-to-stable-id snapshot used by the polling thread. + /// public IReadOnlyList ListDevices() { var result = new List(); @@ -229,18 +257,25 @@ public IReadOnlyList ListDevices() /// public bool TryGetDeviceId(int deviceIndex, out string deviceId) { - return _deviceIdsByIndex.TryGetValue(deviceIndex, out deviceId); - } - - private void RebuildDeviceIdCache(IReadOnlyList devices) - { + return _deviceIdsByIndex.TryGetValue(deviceIndex, out deviceId); + } + + /// + /// Replaces the polling-thread device-id snapshot with data from the latest + /// enumeration. + /// + private void RebuildDeviceIdCache(IReadOnlyList devices) + { var cache = new Dictionary(devices.Count); for (var i = 0; i < devices.Count; i++) { cache[devices[i].DeviceIndex] = devices[i].Id; } - _deviceIdsByIndex = cache; + _deviceIdsByIndex = cache; } + /// + /// Enumerates native axes for one device. + /// public IReadOnlyList ListDeviceAxes(int deviceIndex) { if (!_initialized) { @@ -270,10 +305,11 @@ public IReadOnlyList ListDeviceAxes(int deviceIndex) } /// - /// Start input polling - /// - /// Polling interval in microseconds (default 500) - public bool StartPolling(int pollIntervalUs = 500) + /// Starts native input polling and sends the current action bindings to the + /// native layer. + /// + /// Polling interval in microseconds (default 500) + public bool StartPolling(int pollIntervalUs = 500) { #if UNITY_EDITOR // Avoid extremely aggressive polling in the editor; it can delay/derail PinMAME stop/start. @@ -316,16 +352,16 @@ public bool StartPolling(int pollIntervalUs = 500) Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); // The native side switched to its polling device enumeration; refresh the - // index → device-id cache so axis events resolve against the right snapshot. + // index-to-device-id cache so axis events resolve against the right snapshot. ListDevices(); return true; } /// - /// Stop input polling - /// - public void StopPolling() + /// Stops native input polling. + /// + public void StopPolling() { if (!_polling) return; @@ -342,9 +378,9 @@ public void StopPolling() #region Private Methods /// - /// Setup default input bindings - /// - private void SetupDefaultBindings() + /// Configures the built-in default keyboard bindings. + /// + private void SetupDefaultBindings() { ClearBindings(); _bindings.AddRange(BuildDefaultBindings()); @@ -416,9 +452,9 @@ void Add(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) } /// - /// Input event callback from native layer (called on input polling thread) - /// - [MonoPInvokeCallback(typeof(NativeInputApi.InputEventCallback))] + /// Receives native input events on the input polling thread. + /// + [MonoPInvokeCallback(typeof(NativeInputApi.InputEventCallback))] private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userData) { if (Interlocked.Exchange(ref _loggedFirstEvent, 1) == 0) { @@ -451,21 +487,29 @@ private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userD instance?._simulationThread?.EnqueueInputEvent(evt); } - // Called on the input polling thread when the native side detected a - // device arrival/removal. Rebuilding via ListDevices is safe here: the - // native listing takes its own lock and the id cache is swapped atomically. - private void OnNativeDevicesChanged() - { + /// + /// Refreshes native device metadata after a hotplug notification. + /// + /// + /// Called on the input polling thread. Rebuilding via + /// is safe here because the native listing takes its own lock and the id + /// cache is swapped atomically. + /// + private void OnNativeDevicesChanged() + { try { ListDevices(); DevicesChanged?.Invoke(); } catch (Exception e) { Logger.Error(e, $"{LogPrefix} [NativeInputManager] Failed to refresh devices after hotplug"); - } + } } - - private void MarkInputEventActivity() - { + + /// + /// Updates the rolling input event-rate measurement. + /// + private void MarkInputEventActivity() + { Interlocked.Increment(ref _inputEventsInWindow); var nowTicks = Stopwatch.GetTimestamp(); @@ -486,9 +530,13 @@ private void MarkInputEventActivity() #endregion - #region Dispose - - public void Dispose() + #region Dispose + + /// + /// Stops polling, shuts down native input, and releases the singleton + /// instance. + /// + public void Dispose() { StopPolling(); @@ -501,11 +549,17 @@ public void Dispose() _instance = null; } - #endregion + #endregion } + /// + /// Managed snapshot of one native input device and its axes. + /// public sealed class NativeInputDeviceInfo { + /// + /// Creates a native input device snapshot. + /// public NativeInputDeviceInfo(int deviceIndex, string id, string name, bool isConnected, IReadOnlyList axes) { DeviceIndex = deviceIndex; @@ -522,8 +576,14 @@ public NativeInputDeviceInfo(int deviceIndex, string id, string name, bool isCon public IReadOnlyList Axes { get; } } + /// + /// Managed snapshot of one native input axis and its latest raw value. + /// public readonly struct NativeInputAxisInfo { + /// + /// Creates a native input axis snapshot. + /// public NativeInputAxisInfo(int axisId, string name, int usagePage, int usage, NativeInputApi.AxisKind kind, float rawValue, long timestampUsec) { AxisId = axisId; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 60c1a9c98..0d4da20c4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -16,7 +16,7 @@ namespace VisualPinball.Unity.Simulation /// Shared simulation state between simulation thread and Unity main thread. /// Uses triple-buffering for truly lock-free reads: the sim thread always /// writes to its own buffer, publishes via atomic exchange, and the main - /// thread acquires the latest published buffer — neither thread ever + /// thread acquires the latest published buffer; neither thread ever /// touches the other's active buffer. /// public class SimulationState : IDisposable @@ -178,13 +178,39 @@ public struct Snapshot public int Float2AnimationSourceCount; public byte Float2AnimationsTruncated; + /// + /// Latest cabinet acceleration from keyboard or analog nudge sources. + /// public float2 NudgeCabinetAcceleration; + + /// + /// Latest cabinet spring displacement used by visual nudge. + /// public float2 NudgeCabinetOffset; + + /// + /// Simulated plumb-bob position for diagnostics. + /// public float3 PlumbPosition; + + /// + /// Maximum plumb displacement as a percentage of the tilt ring. + /// public float PlumbTiltPercent; + + /// + /// Number of tilt switch transitions produced by the plumb simulation. + /// public int PlumbTiltIndex; + + /// + /// Whether the simulated tilt switch is currently high. + /// public byte PlumbTiltHigh; + /// + /// Allocates persistent native buffers used by this snapshot. + /// public void Allocate() { CoilStates = new NativeArray(MaxCoils, Allocator.Persistent); @@ -228,6 +254,9 @@ public void Allocate() PlumbTiltHigh = 0; } + /// + /// Disposes persistent native buffers owned by this snapshot. + /// public void Dispose() { if (CoilStates.IsCreated) CoilStates.Dispose(); @@ -256,7 +285,7 @@ public void Dispose() /// /// Index of the most recently published buffer. - /// Shared between threads — accessed only via Interlocked.Exchange. + /// Shared between threads; accessed only via Interlocked.Exchange. /// private int _readyIndex; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 380ba2960..18e7a368a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -260,7 +260,7 @@ public ref readonly SimulationState.Snapshot GetSharedState() /// /// Dispatches a plumb tilt state change on the main thread, driving switch /// status, key wires and (via the external switch queue) the gamelogic - /// engine — the same route the single-threaded mode takes. Set by + /// engine; the same route the single-threaded mode takes. Set by /// ; when null, tilt falls back to a /// direct GLE switch dispatch on the simulation thread. /// @@ -515,7 +515,7 @@ private void ProcessInputEvents() // The default nudge is suppressed when the gamelogic reacted to the // key by nudging itself. For main-thread-dispatched GLEs the switch // is only queued here, so the decision must run after the main - // thread actually delivered it — hence the after-dispatch callback. + // thread actually delivered it; hence the after-dispatch callback. var nudgeIndexBeforeSwitchDispatch = _physicsEngine.KeyboardNudgeIndex; var nudgeActionIndex = actionIndex; SendMappedSwitch(actionIndex, isPressed, () => { @@ -636,6 +636,16 @@ private static bool IsNudgeAction(int actionIndex) || actionIndex == (int)NativeInputApi.InputAction.CenterNudge; } + /// + /// Applies the built-in keyboard nudge for tables whose gamelogic did not + /// handle the nudge key itself. + /// + /// + /// VP tables can script their own nudge response. The simulation thread + /// therefore applies this fallback only after switch dispatch has had a + /// chance to call into gamelogic and increment the physics engine's keyboard + /// nudge counter. + /// private void ApplyDefaultKeyboardNudge(int actionIndex) { const float baseForce = 2f; @@ -861,6 +871,15 @@ private void UpdatePhysics() } } + /// + /// Converts plumb-bob tilt edges produced by physics into input switch + /// events. + /// + /// + /// Main-thread gamelogic engines receive these through the same external + /// switch queue as regular main-thread input so switch state, wires, and ROM + /// input stay in sync. + /// private void ProcessPlumbTiltEvents() { _pendingPlumbTiltEvents.Clear(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 72199207c..01650e3d6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -14,6 +14,15 @@ namespace VisualPinball.Unity.Simulation { + /// + /// Serialized nudge sensor settings stored on . + /// + /// + /// The component stores mappings as strings so they can be packed with the + /// table and survive native device ids that contain punctuation. At runtime + /// converts these strings into managed + /// objects used by the physics engine. + /// [Serializable] public sealed class SimulationThreadNudgeSensorConfig { @@ -38,6 +47,9 @@ public sealed class SimulationThreadNudgeSensorConfig public string VelocityX = string.Empty; public string VelocityY = string.Empty; + /// + /// Clamps numeric values and normalizes null mapping strings. + /// public void Normalize() { Strength = Mathf.Clamp(Strength, 0f, 2f); @@ -51,6 +63,9 @@ public void Normalize() VelocityY ??= string.Empty; } + /// + /// Converts serialized component settings to the runtime nudge config. + /// public NudgeSensorConfig ToEngineConfig() { Normalize(); @@ -69,6 +84,11 @@ public NudgeSensorConfig ToEngineConfig() }; } + /// + /// Captures the current raw value of every mapped axis as its neutral + /// center. + /// + /// The number of mappings whose raw center was updated. public int CalibrateRawCenters(IReadOnlyList devices) { var count = 0; @@ -81,6 +101,9 @@ public int CalibrateRawCenters(IReadOnlyList devices) return count; } + /// + /// Clears saved neutral centers from every mapping. + /// public void ResetRawCenters() { ResetRawCenter(ref X); @@ -91,6 +114,9 @@ public void ResetRawCenters() ResetRawCenter(ref VelocityY); } + /// + /// Builds a serialized mapping from a native device/axis pair. + /// internal static string BuildMapping(NativeInputDeviceInfo device, NativeInputAxisInfo axis, SensorMappingKind kind, float scale, float rawCenter) { @@ -105,11 +131,17 @@ internal static string BuildMapping(NativeInputDeviceInfo device, NativeInputAxi }.ToString(); } + /// + /// Parses a mapping string, returning an unmapped object when parsing fails. + /// private static SensorMapping ParseMapping(string value) { return SensorMapping.TryParse(value, out var mapping) ? mapping : new SensorMapping(); } + /// + /// Updates one serialized mapping with the current raw axis center. + /// private static int CalibrateRawCenter(ref string value, IReadOnlyList devices) { if (!SensorMapping.TryParse(value, out var mapping)) { @@ -123,6 +155,10 @@ private static int CalibrateRawCenter(ref string value, IReadOnlyList + /// Clears one serialized mapping's raw center without changing device/axis + /// identity. + /// private static void ResetRawCenter(ref string value) { if (!SensorMapping.TryParse(value, out var mapping)) { @@ -132,6 +168,9 @@ private static void ResetRawCenter(ref string value) value = mapping.ToString(); } + /// + /// Finds the current axis snapshot for a serialized mapping. + /// private static bool TryFindAxis(IReadOnlyList devices, string deviceId, int axisId, out NativeInputAxisInfo axis) { @@ -200,7 +239,7 @@ public static SimulationThreadComponent EnsureForTable(GameObject tableRoot) [Tooltip("Enable native input polling (requires the VisualPinball.NativeInput native plugin for the current platform)")] public bool EnableNativeInput = true; - [Tooltip("Input polling interval in microseconds (default 500μs = 2000 Hz)")] + [Tooltip("Input polling interval in microseconds (default 500 us = 2000 Hz)")] [Range(100, 2000)] public int InputPollingIntervalUs = 500; @@ -319,7 +358,8 @@ private void OnApplicationQuit() #region Public API /// - /// Start the simulation thread + /// Starts the simulation thread, native input polling, and external physics + /// timing. /// public void StartSimulation() { @@ -391,7 +431,8 @@ public void StartSimulation() } /// - /// Stop the simulation thread + /// Stops the simulation and input threads and returns the physics engine to + /// Unity-driven timing. /// public void StopSimulation() { @@ -445,19 +486,25 @@ public SimulationState.Snapshot GetCurrentSnapshot() } /// - /// The measured input→on-screen latency in milliseconds (input poll → flipper visual movement), + /// The measured input-to-on-screen latency in milliseconds (input poll to flipper visual movement), /// averaged over the calls since the last sample. Returns 0 until the first flipper press is observed. /// Works for any table with flippers (independent of the gamelogic engine's coil-latency stats). /// Main-thread only. /// public float SampleInputLatencyMs() => InputLatencyTracker.SampleFlipperLatencyMs(false); + /// + /// Lists native input devices that can be mapped to nudge sensors. + /// public IReadOnlyList ListNudgeInputDevices() { var inputManager = _inputManager ?? NativeInputManager.TryGetExistingInstance(); return inputManager == null ? Array.Empty() : inputManager.ListDevices(); } + /// + /// Pushes serialized nudge sensor settings into the physics engine. + /// public void ApplyNudgeSensorSettings() { if (_physicsEngine == null) { @@ -480,6 +527,16 @@ public void ApplyNudgeSensorSettings() _physicsEngine.ConfigureNudgeSensors(configs); } + /// + /// Calibrates all mapped nudge channels around their current raw input + /// values. + /// + /// + /// This is intentionally a center capture, not a gain calibration pass. It + /// solves the common KL25Z/Pinscape case where a resting accelerometer does + /// not report exactly zero, while the physics-side gain calibrator continues + /// to learn velocity/acceleration scale from motion. + /// public int CalibrateNudgeSensorCenters() { var devices = ListNudgeInputDevices(); @@ -495,6 +552,9 @@ public int CalibrateNudgeSensorCenters() return calibrated; } + /// + /// Clears saved raw centers for all nudge mappings. + /// public int ResetNudgeSensorCenters() { var reset = 0; @@ -513,6 +573,16 @@ public int ResetNudgeSensorCenters() return reset; } + /// + /// Attempts to map the first connected cabinet-style device to acceleration + /// X/Y channels. + /// + /// + /// The heuristic prefers HID X/Y accelerometer usages, then falls back to + /// named X/Y axes, and finally any two non-Z acceleration axes. This keeps + /// one-click setup useful for KL25Z/Pinscape boards without hard-coding a + /// vendor-specific device name. + /// public bool TryAutoConfigureFirstCabinetSensor(out string message) { var devices = ListNudgeInputDevices(); @@ -607,6 +677,9 @@ private void LogStatistics(in SimulationState.Snapshot state) private static long TimestampUsec => (Stopwatch.GetTimestamp() * 1_000_000L) / Stopwatch.Frequency; + /// + /// Picks a likely X/Y axis pair from one native device. + /// private static bool TryPickAxisPair(NativeInputDeviceInfo device, out NativeInputAxisInfo xAxis, out NativeInputAxisInfo yAxis) { @@ -645,6 +718,10 @@ private static bool TryPickAxisPair(NativeInputDeviceInfo device, out NativeInpu return xSet && ySet && xAxis.AxisId != yAxis.AxisId; } + /// + /// Finds an axis by HID usage or display name, optionally constrained by + /// reported axis kind. + /// private static bool TryFindAxis(NativeInputDeviceInfo device, string axisName, int usage, NativeInputApi.AxisKind? kind, out NativeInputAxisInfo axis) { @@ -664,6 +741,9 @@ private static bool TryFindAxis(NativeInputDeviceInfo device, string axisName, i return false; } + /// + /// Checks whether a native axis looks like the requested X/Y/Z axis. + /// private static bool IsNamedAxis(NativeInputAxisInfo axis, string axisName, int usage) { if (axis.Usage == usage) { @@ -682,11 +762,17 @@ private static bool IsNamedAxis(NativeInputAxisInfo axis, string axisName, int u || name.EndsWith("_" + axisName, StringComparison.OrdinalIgnoreCase); } + /// + /// Returns a human-readable axis label for inspector messages. + /// private static string AxisName(NativeInputAxisInfo axis) { return string.IsNullOrEmpty(axis.Name) ? $"Axis {axis.AxisId}" : axis.Name; } + /// + /// Updates smoothed simulation speed diagnostics for the inspector. + /// private void UpdateSimulationSpeed(in SimulationState.Snapshot state) { var now = Time.unscaledTime; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs index 21d52c27c..adcaea247 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs @@ -16,6 +16,13 @@ namespace VisualPinball.Unity.Simulation { + /// + /// Packaged representation of one serialized nudge sensor. + /// + /// + /// Mapping strings are persisted as-is because they already encode device id, + /// axis id, channel kind, center, dead-zone, scale, and limit. + /// public struct SimulationThreadNudgeSensorPackable { public NudgeSensorType Type; @@ -30,6 +37,9 @@ public struct SimulationThreadNudgeSensorPackable public string VelocityX; public string VelocityY; + /// + /// Copies a component sensor config into package data. + /// public static SimulationThreadNudgeSensorPackable Pack(SimulationThreadNudgeSensorConfig sensor) { sensor ??= new SimulationThreadNudgeSensorConfig(); @@ -49,6 +59,9 @@ public static SimulationThreadNudgeSensorPackable Pack(SimulationThreadNudgeSens }; } + /// + /// Rehydrates package data into a component sensor config. + /// public SimulationThreadNudgeSensorConfig Unpack() { return new SimulationThreadNudgeSensorConfig { @@ -67,6 +80,9 @@ public SimulationThreadNudgeSensorConfig Unpack() } } + /// + /// Packaged representation of . + /// public struct SimulationThreadComponentPackable { public bool EnableSimulationThread; @@ -77,6 +93,9 @@ public struct SimulationThreadComponentPackable public bool ShowStatistics; public float StatisticsInterval; + /// + /// Serializes simulation-thread component settings for table packages. + /// public static byte[] Pack(SimulationThreadComponent comp) { return PackageApi.Packer.Pack(new SimulationThreadComponentPackable { @@ -90,6 +109,9 @@ public static byte[] Pack(SimulationThreadComponent comp) }); } + /// + /// Restores simulation-thread component settings from table package data. + /// public static void Unpack(byte[] bytes, SimulationThreadComponent comp) { var data = PackageApi.Packer.Unpack(bytes); @@ -109,6 +131,9 @@ public static void Unpack(byte[] bytes, SimulationThreadComponent comp) comp.StatisticsInterval = data.StatisticsInterval; } + /// + /// Packs up to the maximum supported number of nudge sensors. + /// private static SimulationThreadNudgeSensorPackable[] PackNudgeSensors(SimulationThreadComponent comp) { if (comp.NudgeSensors == null || comp.NudgeSensors.Count == 0) { From 1beea6af38461975e28c831cf8a10a9d3fba9d61 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 13:12:02 +0200 Subject: [PATCH 20/25] editor: Expose nudge settings in physics and table inspectors. Add keyboard nudge, plumb tilt and visual nudge property fields to the PhysicsEngine inspector, reconfiguring the engine live on change. Also surface the table's NudgeTime property in the table inspector. --- .../Inspectors/PhysicsEngineInspector.cs | 43 +++++++++++++++++++ .../VPT/Table/TableInspector.cs | 5 ++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs index e6ed34144..7f1abea98 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs @@ -23,20 +23,63 @@ namespace VisualPinball.Unity.Editor public class PhysicsEngineInspector : UnityEditor.Editor { private SerializedProperty _gravityProperty; + private SerializedProperty _keyboardNudgeModeProperty; + private SerializedProperty _keyboardNudgeStrengthProperty; + private SerializedProperty _keyboardCabinetDampingProperty; + private SerializedProperty _simulatedPlumbProperty; + private SerializedProperty _plumbDampingProperty; + private SerializedProperty _plumbThresholdAngleProperty; + private SerializedProperty _visualNudgeStrengthProperty; private void OnEnable() { _gravityProperty = serializedObject.FindProperty(nameof(PhysicsEngine.GravityStrength)); + _keyboardNudgeModeProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardNudgeMode)); + _keyboardNudgeStrengthProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardNudgeStrength)); + _keyboardCabinetDampingProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardCabinetDamping)); + _simulatedPlumbProperty = serializedObject.FindProperty(nameof(PhysicsEngine.SimulatedPlumb)); + _plumbDampingProperty = serializedObject.FindProperty(nameof(PhysicsEngine.PlumbDamping)); + _plumbThresholdAngleProperty = serializedObject.FindProperty(nameof(PhysicsEngine.PlumbThresholdAngle)); + _visualNudgeStrengthProperty = serializedObject.FindProperty(nameof(PhysicsEngine.VisualNudgeStrength)); } public override void OnInspectorGUI() { + serializedObject.Update(); + EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(_gravityProperty, new GUIContent("Gravity Constant")); + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("Keyboard Nudge", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(_keyboardNudgeModeProperty, new GUIContent("Mode")); + EditorGUILayout.PropertyField(_keyboardNudgeStrengthProperty, new GUIContent("Strength")); + EditorGUILayout.PropertyField(_keyboardCabinetDampingProperty, new GUIContent("Cabinet Damping")); + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("Plumb Tilt", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(_simulatedPlumbProperty, new GUIContent("Simulated Plumb")); + using (new EditorGUI.DisabledScope(!_simulatedPlumbProperty.boolValue)) { + EditorGUILayout.PropertyField(_plumbDampingProperty, new GUIContent("Damping")); + EditorGUILayout.PropertyField(_plumbThresholdAngleProperty, new GUIContent("Threshold Angle")); + } + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("Visual Nudge", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(_visualNudgeStrengthProperty, new GUIContent("Strength")); if (EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); + foreach (var obj in targets) { + if (obj is PhysicsEngine physicsEngine) { + physicsEngine.ConfigureKeyboardNudge(physicsEngine.KeyboardNudgeMode, + physicsEngine.KeyboardNudgeStrength, physicsEngine.KeyboardCabinetDamping); + physicsEngine.ConfigurePlumb(physicsEngine.SimulatedPlumb, + physicsEngine.PlumbDamping, physicsEngine.PlumbThresholdAngle); + physicsEngine.ConfigureVisualNudge(physicsEngine.VisualNudgeStrength); + } + } } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs index f20b324af..4f6b2454c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs @@ -32,6 +32,7 @@ public class TableInspector : ItemInspector protected override MonoBehaviour UndoTarget => target as MonoBehaviour; private SerializedProperty _globalDifficultyProperty; + private SerializedProperty _nudgeTimeProperty; private SerializedProperty _metadataProperty; private bool _packageFoldout; private bool _runtimeCompressSideChannelTextures = true; @@ -54,6 +55,7 @@ protected override void OnEnable() base.OnEnable(); _globalDifficultyProperty = serializedObject.FindProperty(nameof(TableComponent.GlobalDifficulty)); + _nudgeTimeProperty = serializedObject.FindProperty(nameof(TableComponent.NudgeTime)); _metadataProperty = serializedObject.FindProperty(nameof(TableComponent.Metadata)); _runtimeCompressSideChannelTextures = EditorPrefs.GetBool(RuntimeCompressSideChannelTexturesKey, true); _runtimeCompressNormalMaps = EditorPrefs.GetBool(RuntimeCompressNormalMapsKey, true); @@ -73,6 +75,7 @@ public override void OnInspectorGUI() BeginEditing(); PropertyField(_globalDifficultyProperty); + PropertyField(_nudgeTimeProperty); EditorGUILayout.Space(); _packageFoldout = EditorGUILayout.Foldout(_packageFoldout, "Package", true); @@ -178,4 +181,4 @@ public override void OnInspectorGUI() } } } -} \ No newline at end of file +} From fc42eda7a2aaa28a6e7e8a5ab1184ce0d6847f0b Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 16:48:27 +0200 Subject: [PATCH 21/25] physics: Route nudge config through shared settings objects. Introduce GetNudgeSettings/ConfigureNudge on PhysicsEngine and GetCabinetInputSettings/ApplyCabinetInputSettings on SimulationThreadComponent so nudge and native-input configuration flow through the shared CabinetInputSettings/CabinetNudgeSettings objects instead of many individual Configure* calls. Unpacking now builds a CabinetNudgeSettings and applies it in one call, and sensor conversion delegates to CabinetNudgeSensorSettings. Native input polling can now be reconciled while Play Mode is already running. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 34 +++++ .../Game/PhysicsEnginePackable.cs | 24 ++-- .../Simulation/SimulationThreadComponent.cs | 122 ++++++++++++++---- 3 files changed, 147 insertions(+), 33 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 7048c22f5..f97676bc8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -311,6 +311,40 @@ internal void MutateState(InputAction action) /// public int KeyboardNudgeIndex => Volatile.Read(ref _keyboardNudgeIndex); + /// + /// Captures the current nudge settings stored on this component. + /// + public CabinetNudgeSettings GetNudgeSettings() => CabinetNudgeSettings.From(this); + + /// + /// Applies the shared nudge settings object to this physics engine. + /// + public void ConfigureNudge(CabinetNudgeSettings settings) + { + settings ??= new CabinetNudgeSettings(); + settings.Normalize(); + ConfigureKeyboardNudge((KeyboardNudgeMode)settings.keyboardMode, + settings.keyboardStrength, settings.keyboardCabinetDamping); + ConfigurePlumb(settings.plumb.enabled, settings.plumb.damping, settings.plumb.thresholdDeg); + ConfigureVisualNudge(settings.visualStrength); + ConfigureNudgeSensors(settings.ToEngineSensorConfigs()); + } + + /// + /// Applies the cabinet input settings that are relevant to physics. + /// + /// + /// Native input polling belongs to + /// , + /// so this method intentionally applies only the nested nudge settings. + /// + public void ConfigureCabinetInput(CabinetInputSettings settings) + { + settings ??= new CabinetInputSettings(); + settings.Normalize(); + ConfigureNudge(settings.nudge); + } + /// /// Queues a keyboard/manual nudge impulse for the next physics tick. /// diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs index 57f2dbe6d..0a3a1465f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs @@ -68,17 +68,19 @@ public static void Unpack(byte[] bytes, PhysicsEngine comp) { var data = PackageApi.Packer.Unpack(bytes); comp.GravityStrength = data.GravityStrength; - comp.ConfigureKeyboardNudge( - data.HasKeyboardNudgeSettings ? data.KeyboardNudgeMode : KeyboardNudgeMode.CabModel, - data.HasKeyboardNudgeSettings ? data.KeyboardNudgeStrength : 1f, - data.HasKeyboardCabinetDamping ? data.KeyboardCabinetDamping : CabinetPhysicsState.DefaultKeyboardDampingRatio - ); - comp.ConfigurePlumb( - data.HasPlumbSettings ? data.SimulatedPlumb : true, - data.HasPlumbSettings ? data.PlumbDamping : 1f, - data.HasPlumbSettings ? data.PlumbThresholdAngle : 2f - ); - comp.ConfigureVisualNudge(data.HasVisualNudgeSettings ? data.VisualNudgeStrength : 1f); + comp.ConfigureNudge(new CabinetNudgeSettings { + keyboardMode = (int)(data.HasKeyboardNudgeSettings ? data.KeyboardNudgeMode : KeyboardNudgeMode.CabModel), + keyboardStrength = data.HasKeyboardNudgeSettings ? data.KeyboardNudgeStrength : 1f, + keyboardCabinetDamping = data.HasKeyboardCabinetDamping + ? data.KeyboardCabinetDamping + : CabinetPhysicsState.DefaultKeyboardDampingRatio, + visualStrength = data.HasVisualNudgeSettings ? data.VisualNudgeStrength : 1f, + plumb = new CabinetPlumbSettings { + enabled = data.HasPlumbSettings ? data.SimulatedPlumb : true, + damping = data.HasPlumbSettings ? data.PlumbDamping : 1f, + thresholdDeg = data.HasPlumbSettings ? data.PlumbThresholdAngle : 2f + } + }); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 01650e3d6..a8ebb5386 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -68,20 +68,16 @@ public void Normalize() /// public NudgeSensorConfig ToEngineConfig() { - Normalize(); - return new NudgeSensorConfig { - Type = Type, - Strength = Strength, - CabinetMassKg = CabinetMassKg, - MountRotation = MountRotation, - MountMirror = MountMirror, - X = ParseMapping(X), - Y = ParseMapping(Y), - AccelerationX = ParseMapping(AccelerationX), - AccelerationY = ParseMapping(AccelerationY), - VelocityX = ParseMapping(VelocityX), - VelocityY = ParseMapping(VelocityY) - }; + return ToCabinetSettings().ToEngineConfig(); + } + + /// + /// Converts this component-specific sensor shape into the shared cabinet + /// input settings object. + /// + public CabinetNudgeSensorSettings ToCabinetSettings() + { + return CabinetNudgeSensorSettings.From(this); } /// @@ -131,14 +127,6 @@ internal static string BuildMapping(NativeInputDeviceInfo device, NativeInputAxi }.ToString(); } - /// - /// Parses a mapping string, returning an unmapped object when parsing fails. - /// - private static SensorMapping ParseMapping(string value) - { - return SensorMapping.TryParse(value, out var mapping) ? mapping : new SensorMapping(); - } - /// /// Updates one serialized mapping with the current raw axis center. /// @@ -502,6 +490,96 @@ public IReadOnlyList ListNudgeInputDevices() return inputManager == null ? Array.Empty() : inputManager.ListDevices(); } + /// + /// Captures the current native-input settings from this component and the + /// current nudge settings from the sibling physics engine. + /// + public CabinetInputSettings GetCabinetInputSettings() + { + _physicsEngine ??= GetComponent(); + var settings = new CabinetInputSettings { + enableNativeInput = EnableNativeInput, + inputPollingIntervalUs = InputPollingIntervalUs, + nudge = _physicsEngine != null ? _physicsEngine.GetNudgeSettings() : new CabinetNudgeSettings() + }; + settings.nudge.sensors = CabinetNudgeSensorSettings.FromSimulationThreadSensors(NudgeSensors); + settings.Normalize(); + return settings; + } + + /// + /// Applies shared cabinet-input settings to this component and, optionally, + /// to the sibling physics engine. + /// + public void ApplyCabinetInputSettings(CabinetInputSettings settings, bool applyNudgeToPhysics = true) + { + settings ??= new CabinetInputSettings(); + settings.Normalize(); + + EnableNativeInput = settings.enableNativeInput; + InputPollingIntervalUs = settings.inputPollingIntervalUs; + ApplyNudgeSettings(settings.nudge, applyNudgeToPhysics); + ApplyNativeInputSettingsIfRunning(); + } + + /// + /// Applies only the shared nudge settings, keeping native input polling + /// options unchanged. + /// + public void ApplyNudgeSettings(CabinetNudgeSettings settings, bool applyNudgeToPhysics = true) + { + settings ??= new CabinetNudgeSettings(); + settings.Normalize(); + + NudgeSensors = settings.ToSimulationThreadSensorConfigs(); + ApplyNudgeSensorSettings(); + + if (applyNudgeToPhysics) { + _physicsEngine ??= GetComponent(); + _physicsEngine?.ConfigureNudge(settings); + } + } + + /// + /// Reconciles native input polling with the current component fields when + /// settings are changed while Play Mode is already running. + /// + private void ApplyNativeInputSettingsIfRunning() + { + if (!_started || _simulationThread == null) { + return; + } + + if (!EnableNativeInput) { + _physicsEngine?.DetachNativeInputManager(_inputManager); + _inputManager?.StopPolling(); + return; + } + + _inputManager ??= NativeInputManager.Instance; + if (!_inputManager.Initialize()) { + Logger.Warn($"{LogPrefix} [SimulationThreadComponent] Native input not available, falling back to Unity Input System"); + return; + } + + _inputManager.SetSimulationThread(_simulationThread); + _physicsEngine?.AttachNativeInputManager(_inputManager); + var pollingIntervalUs = InputPollingIntervalUs; +#if UNITY_EDITOR + if (pollingIntervalUs < 1000) { + pollingIntervalUs = 1000; + } +#endif + if (_inputManager.IsPolling) { + var targetPollingHz = pollingIntervalUs > 0 ? 1000000f / pollingIntervalUs : 0f; + if (Mathf.Abs(_inputManager.TargetPollingHz - targetPollingHz) < 0.1f) { + return; + } + _inputManager.StopPolling(); + } + _inputManager.StartPolling(InputPollingIntervalUs); + } + /// /// Pushes serialized nudge sensor settings into the physics engine. /// From 22c0bd7040a9c5dfa15a313c56a9f61140b4c377 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 16:49:46 +0200 Subject: [PATCH 22/25] physics: Add table-owned tilt-bob switch device. Introduce a TiltBobComponent that acts as the table author's routing point for the tilt switch, owning switch dispatch. The player's cabinet settings decide whether the signal originates from the physics plumb-bob simulation or a real mapped cabinet switch. Move plumb-tilt event dispatch out of PhysicsEngine and route it through the tilt-bob component via the simulation thread. Plumb settings on PhysicsEngine are now hidden and default off; older packages still deserialize them but new packages route tilt through the component plus player cabinet settings. Add shared CabinetInputSettings/CabinetNudgeSettings objects (plus a CabinetInputSettingsAsset wrapper) so the same serializable shape can drive runtime config, player JSON, and editor presets, replacing the per-component nudge configuration path. Auto-add the tilt-bob component on VPX import and drop the plumb fields from the physics engine inspector. --- .../Import/VpxSceneConverter.cs | 1 + .../Inspectors/PhysicsEngineInspector.cs | 16 - .../Game/CabinetInputSettings.cs | 440 ++++++++++++++++++ .../Game/CabinetInputSettings.cs.meta | 11 + .../Game/CabinetInputSettingsAsset.cs | 49 ++ .../Game/CabinetInputSettingsAsset.cs.meta | 11 + .../VisualPinball.Unity/Game/PhysicsEngine.cs | 25 +- .../Game/PhysicsEnginePackable.cs | 20 +- .../VisualPinball.Unity/Game/Player.cs | 31 +- .../Simulation/SimulationThread.cs | 52 ++- .../Simulation/SimulationThreadComponent.cs | 40 +- .../VisualPinball.Unity/VPT/TiltBob.meta | 8 + .../VPT/TiltBob/TiltBobApi.cs | 64 +++ .../VPT/TiltBob/TiltBobApi.cs.meta | 11 + .../VPT/TiltBob/TiltBobComponent.cs | 207 ++++++++ .../VPT/TiltBob/TiltBobComponent.cs.meta | 11 + .../VPT/TiltBob/TiltBobMode.cs | 36 ++ .../VPT/TiltBob/TiltBobMode.cs.meta | 11 + 18 files changed, 968 insertions(+), 76 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs index 47cca220f..a143fe887 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs @@ -739,6 +739,7 @@ private void CreateRootHierarchy(string tableName = null) cabinetGo.transform.SetParent(_tableGo.transform, false); // 2. add components + _tableGo.AddComponent(); var physicsEngine = _playfieldGo.AddComponent(); SimulationThreadComponent.EnsureFor(physicsEngine); _playfieldComponent = _playfieldGo.AddComponent(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs index 7f1abea98..b0a3893ac 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs @@ -26,9 +26,6 @@ public class PhysicsEngineInspector : UnityEditor.Editor private SerializedProperty _keyboardNudgeModeProperty; private SerializedProperty _keyboardNudgeStrengthProperty; private SerializedProperty _keyboardCabinetDampingProperty; - private SerializedProperty _simulatedPlumbProperty; - private SerializedProperty _plumbDampingProperty; - private SerializedProperty _plumbThresholdAngleProperty; private SerializedProperty _visualNudgeStrengthProperty; private void OnEnable() @@ -37,9 +34,6 @@ private void OnEnable() _keyboardNudgeModeProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardNudgeMode)); _keyboardNudgeStrengthProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardNudgeStrength)); _keyboardCabinetDampingProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardCabinetDamping)); - _simulatedPlumbProperty = serializedObject.FindProperty(nameof(PhysicsEngine.SimulatedPlumb)); - _plumbDampingProperty = serializedObject.FindProperty(nameof(PhysicsEngine.PlumbDamping)); - _plumbThresholdAngleProperty = serializedObject.FindProperty(nameof(PhysicsEngine.PlumbThresholdAngle)); _visualNudgeStrengthProperty = serializedObject.FindProperty(nameof(PhysicsEngine.VisualNudgeStrength)); } @@ -58,14 +52,6 @@ public override void OnInspectorGUI() EditorGUILayout.PropertyField(_keyboardCabinetDampingProperty, new GUIContent("Cabinet Damping")); EditorGUILayout.Space(); - EditorGUILayout.LabelField("Plumb Tilt", EditorStyles.boldLabel); - EditorGUILayout.PropertyField(_simulatedPlumbProperty, new GUIContent("Simulated Plumb")); - using (new EditorGUI.DisabledScope(!_simulatedPlumbProperty.boolValue)) { - EditorGUILayout.PropertyField(_plumbDampingProperty, new GUIContent("Damping")); - EditorGUILayout.PropertyField(_plumbThresholdAngleProperty, new GUIContent("Threshold Angle")); - } - EditorGUILayout.Space(); - EditorGUILayout.LabelField("Visual Nudge", EditorStyles.boldLabel); EditorGUILayout.PropertyField(_visualNudgeStrengthProperty, new GUIContent("Strength")); @@ -75,8 +61,6 @@ public override void OnInspectorGUI() if (obj is PhysicsEngine physicsEngine) { physicsEngine.ConfigureKeyboardNudge(physicsEngine.KeyboardNudgeMode, physicsEngine.KeyboardNudgeStrength, physicsEngine.KeyboardCabinetDamping); - physicsEngine.ConfigurePlumb(physicsEngine.SimulatedPlumb, - physicsEngine.PlumbDamping, physicsEngine.PlumbThresholdAngle); physicsEngine.ConfigureVisualNudge(physicsEngine.VisualNudgeStrength); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs new file mode 100644 index 000000000..ce996b7f4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs @@ -0,0 +1,440 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using UnityEngine; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity +{ + /// + /// Shared cabinet input settings for runtime configuration, player config, + /// and editor presets. + /// + /// + /// This type intentionally keeps player/cabinet hardware choices separate + /// from table-authored physics values such as gravity and table nudge time. + /// The fields are Unity/JsonUtility-serializable so the same object shape can + /// be embedded in player JSON or wrapped by + /// for editor defaults. + /// + [Serializable] + public sealed class CabinetInputSettings + { + [Tooltip("Enable native input polling (requires the VisualPinball.NativeInput native plugin for the current platform).")] + public bool enableNativeInput = true; + + [Tooltip("Input polling interval in microseconds (default 500 us = 2000 Hz).")] + [Range(100, 2000)] + public int inputPollingIntervalUs = 500; + + public CabinetNudgeSettings nudge = new(); + + /// + /// Clamps values and guarantees nested objects/lists are non-null. + /// + public void Normalize() + { + inputPollingIntervalUs = Mathf.Clamp(inputPollingIntervalUs, 100, 2000); + nudge ??= new CabinetNudgeSettings(); + nudge.Normalize(); + } + + /// + /// Applies all settings that are relevant to a loaded table root. + /// + /// + /// The simulation-thread component owns native input polling settings, + /// while the physics engine owns nudge behavior. Looking both up from the + /// root lets the player app apply one settings object after loading a + /// table without knowing which component currently stores each concern. + /// + public void ApplyTo(GameObject tableRoot) + { + if (tableRoot == null) { + return; + } + + Normalize(); + ApplyTo(tableRoot.GetComponentInChildren(true)); + ApplyTo(tableRoot.GetComponentInChildren(true), false); + } + + /// + /// Applies nudge behavior to a physics engine. + /// + public void ApplyTo(PhysicsEngine physicsEngine) + { + Normalize(); + nudge.ApplyTo(physicsEngine); + } + + /// + /// Applies native input and default sensor mappings to a simulation-thread + /// component. + /// + /// + /// When true, also pushes the nested nudge settings to the sibling physics + /// engine. Pass false when the caller already applied those settings. + /// + public void ApplyTo(SimulationThreadComponent simulationThread, bool applyNudgeToPhysics = true) + { + if (simulationThread == null) { + return; + } + + Normalize(); + simulationThread.ApplyCabinetInputSettings(this, applyNudgeToPhysics); + } + + /// + /// Captures the current component fields into the shared settings shape. + /// + public static CabinetInputSettings From(PhysicsEngine physicsEngine, SimulationThreadComponent simulationThread = null) + { + var settings = new CabinetInputSettings(); + if (physicsEngine != null) { + settings.nudge = CabinetNudgeSettings.From(physicsEngine); + } + if (simulationThread != null) { + settings.enableNativeInput = simulationThread.EnableNativeInput; + settings.inputPollingIntervalUs = simulationThread.InputPollingIntervalUs; + settings.nudge ??= new CabinetNudgeSettings(); + settings.nudge.sensors = CabinetNudgeSensorSettings.FromSimulationThreadSensors(simulationThread.NudgeSensors); + } + settings.Normalize(); + return settings; + } + } + + /// + /// Shared nudge behavior settings for keyboard nudges, visual cabinet motion, + /// player plumb tilt source, and analog sensors. + /// + /// + /// Field names match the original player JSON config so existing + /// player-config.json files deserialize directly after moving the class + /// from the player assembly into the runtime assembly. + /// + [Serializable] + public sealed class CabinetNudgeSettings + { + public int keyboardMode = (int)KeyboardNudgeMode.CabModel; + public float keyboardStrength = 1f; + public float keyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio; + public float visualStrength = 1f; + public CabinetPlumbSettings plumb = new(); + public List sensors = new(); + + /// + /// Clamps values and guarantees nested objects/lists are non-null. + /// + public void Normalize() + { + plumb ??= new CabinetPlumbSettings(); + sensors ??= new List(); + keyboardMode = Mathf.Clamp(keyboardMode, (int)KeyboardNudgeMode.PushRetract, (int)KeyboardNudgeMode.CabModel); + keyboardStrength = Mathf.Clamp(keyboardStrength, 0f, 2f); + keyboardCabinetDamping = Mathf.Clamp(keyboardCabinetDamping, + CabinetPhysicsState.MinKeyboardDampingRatio, + CabinetPhysicsState.MaxKeyboardDampingRatio); + visualStrength = Mathf.Clamp(visualStrength, 0f, 2f); + plumb.Normalize(); + if (sensors.Count > NudgeState.MaxSensors) { + sensors.RemoveRange(NudgeState.MaxSensors, sensors.Count - NudgeState.MaxSensors); + } + for (var i = 0; i < sensors.Count; i++) { + sensors[i] ??= new CabinetNudgeSensorSettings(); + sensors[i].Normalize(); + } + } + + /// + /// Applies this settings object to a physics engine, including live state + /// when the engine is already initialized. + /// + public void ApplyTo(PhysicsEngine physicsEngine) + { + if (physicsEngine == null) { + return; + } + + physicsEngine.ConfigureNudge(this); + TiltBobComponent.ApplySettings(physicsEngine, plumb); + } + + /// + /// Converts persisted/editor sensor settings to physics-engine configs. + /// + public List ToEngineSensorConfigs() + { + Normalize(); + var sensorConfigs = new List(sensors.Count); + foreach (var sensor in sensors) { + sensorConfigs.Add(sensor.ToEngineConfig()); + } + return sensorConfigs; + } + + /// + /// Converts sensor settings to the legacy component shape used by the + /// simulation-thread inspector and packer. + /// + public List ToSimulationThreadSensorConfigs() + { + Normalize(); + var sensorConfigs = new List(sensors.Count); + foreach (var sensor in sensors) { + sensorConfigs.Add(sensor.ToSimulationThreadConfig()); + } + return sensorConfigs; + } + + /// + /// Captures the nudge fields currently stored on a physics engine. + /// + public static CabinetNudgeSettings From(PhysicsEngine physicsEngine) + { + var settings = new CabinetNudgeSettings(); + if (physicsEngine == null) { + return settings; + } + + settings.keyboardMode = (int)physicsEngine.KeyboardNudgeMode; + settings.keyboardStrength = physicsEngine.KeyboardNudgeStrength; + settings.keyboardCabinetDamping = physicsEngine.KeyboardCabinetDamping; + settings.visualStrength = physicsEngine.VisualNudgeStrength; + settings.plumb = new CabinetPlumbSettings { + enabled = true, + mode = TiltBobComponent.FindFor(physicsEngine)?.Mode ?? TiltBobMode.Simulated, + damping = physicsEngine.PlumbDamping, + thresholdDeg = physicsEngine.PlumbThresholdAngle + }; + settings.Normalize(); + return settings; + } + } + + /// + /// Player plumb-bob tilt settings. + /// + [Serializable] + public sealed class CabinetPlumbSettings + { + /// + /// Legacy compatibility flag from the pre-component plumb setting. New + /// behavior is controlled by the presence of + /// on the table and by . + /// + [HideInInspector] + public bool enabled = true; + public TiltBobMode mode = TiltBobMode.Simulated; + public float damping = 1f; + public float thresholdDeg = 2f; + + /// + /// Clamps the plumb-bob mode, damping, and tilt threshold to useful ranges. + /// + public void Normalize() + { + enabled = true; + mode = (TiltBobMode)Mathf.Clamp((int)mode, (int)TiltBobMode.Simulated, (int)TiltBobMode.Mapped); + damping = Mathf.Clamp(damping, 0f, 2f); + thresholdDeg = Mathf.Clamp(thresholdDeg, 0.5f, 4f); + } + } + + /// + /// Serializable settings for one logical nudge sensor. + /// + /// + /// Mapping fields are stored as compact strings because native device ids can + /// contain punctuation and because this is the format already used by table + /// packages and player JSON. Conversion methods parse those strings into + /// objects only when runtime input needs them. + /// + [Serializable] + public sealed class CabinetNudgeSensorSettings + { + public const string TypeGamepad = "gamepad"; + public const string TypeCabinetIntent = "cabinetIntent"; + public const string TypeCabinetDirect = "cabinetDirect"; + + public string type = TypeGamepad; + public float strength = 1f; + public float cabinetMassKg = 113f; + public string x = string.Empty; + public string y = string.Empty; + public string accX = string.Empty; + public string accY = string.Empty; + public string velX = string.Empty; + public string velY = string.Empty; + public int mountRotation; + public bool mountMirror; + + /// + /// Clamps values, normalizes type names, and guarantees mapping strings are + /// non-null. + /// + public void Normalize() + { + if (type != TypeCabinetIntent && type != TypeCabinetDirect) { + type = TypeGamepad; + } + strength = Mathf.Clamp(strength, 0f, 2f); + cabinetMassKg = Mathf.Clamp(cabinetMassKg <= 0f ? 113f : cabinetMassKg, 0f, 200f); + mountRotation = Mathf.Clamp(mountRotation, (int)NudgeSensorMountRotation.Rotation0, (int)NudgeSensorMountRotation.Rotation270); + x ??= string.Empty; + y ??= string.Empty; + accX ??= string.Empty; + accY ??= string.Empty; + velX ??= string.Empty; + velY ??= string.Empty; + } + + /// + /// Converts persisted/editor settings into the runtime physics-engine + /// sensor config. + /// + public NudgeSensorConfig ToEngineConfig() + { + Normalize(); + return new NudgeSensorConfig { + Type = type switch { + TypeCabinetIntent => NudgeSensorType.CabinetIntent, + TypeCabinetDirect => NudgeSensorType.CabinetDirect, + _ => NudgeSensorType.GamepadIntent + }, + Strength = strength, + CabinetMassKg = cabinetMassKg, + MountRotation = (NudgeSensorMountRotation)mountRotation, + MountMirror = mountMirror, + X = ParseMapping(x), + Y = ParseMapping(y), + AccelerationX = ParseMapping(accX), + AccelerationY = ParseMapping(accY), + VelocityX = ParseMapping(velX), + VelocityY = ParseMapping(velY) + }; + } + + /// + /// Converts to the component shape used by the simulation-thread inspector. + /// + public SimulationThreadNudgeSensorConfig ToSimulationThreadConfig() + { + Normalize(); + return new SimulationThreadNudgeSensorConfig { + Type = type switch { + TypeCabinetIntent => NudgeSensorType.CabinetIntent, + TypeCabinetDirect => NudgeSensorType.CabinetDirect, + _ => NudgeSensorType.GamepadIntent + }, + Strength = strength, + CabinetMassKg = cabinetMassKg, + MountRotation = (NudgeSensorMountRotation)mountRotation, + MountMirror = mountMirror, + X = x, + Y = y, + AccelerationX = accX, + AccelerationY = accY, + VelocityX = velX, + VelocityY = velY + }; + } + + /// + /// Captures a runtime physics-engine sensor config back into a serializable + /// settings object. + /// + public static CabinetNudgeSensorSettings From(NudgeSensorConfig sensor) + { + sensor ??= new NudgeSensorConfig(); + sensor.Normalize(); + var settings = new CabinetNudgeSensorSettings { + type = sensor.Type switch { + NudgeSensorType.CabinetIntent => TypeCabinetIntent, + NudgeSensorType.CabinetDirect => TypeCabinetDirect, + _ => TypeGamepad + }, + strength = sensor.Strength, + cabinetMassKg = sensor.CabinetMassKg, + mountRotation = (int)sensor.MountRotation, + mountMirror = sensor.MountMirror, + x = sensor.X?.ToString() ?? string.Empty, + y = sensor.Y?.ToString() ?? string.Empty, + accX = sensor.AccelerationX?.ToString() ?? string.Empty, + accY = sensor.AccelerationY?.ToString() ?? string.Empty, + velX = sensor.VelocityX?.ToString() ?? string.Empty, + velY = sensor.VelocityY?.ToString() ?? string.Empty + }; + settings.Normalize(); + return settings; + } + + /// + /// Captures a simulation-thread component sensor config back into the + /// shared settings object. + /// + public static CabinetNudgeSensorSettings From(SimulationThreadNudgeSensorConfig sensor) + { + sensor ??= new SimulationThreadNudgeSensorConfig(); + sensor.Normalize(); + var settings = new CabinetNudgeSensorSettings { + type = sensor.Type switch { + NudgeSensorType.CabinetIntent => TypeCabinetIntent, + NudgeSensorType.CabinetDirect => TypeCabinetDirect, + _ => TypeGamepad + }, + strength = sensor.Strength, + cabinetMassKg = sensor.CabinetMassKg, + mountRotation = (int)sensor.MountRotation, + mountMirror = sensor.MountMirror, + x = sensor.X, + y = sensor.Y, + accX = sensor.AccelerationX, + accY = sensor.AccelerationY, + velX = sensor.VelocityX, + velY = sensor.VelocityY + }; + settings.Normalize(); + return settings; + } + + /// + /// Converts a simulation-thread sensor list to shared settings objects. + /// + public static List FromSimulationThreadSensors( + IReadOnlyList sensors) + { + var result = new List(); + if (sensors == null) { + return result; + } + + for (var i = 0; i < sensors.Count && i < NudgeState.MaxSensors; i++) { + result.Add(From(sensors[i])); + } + return result; + } + + private static SensorMapping ParseMapping(string value) + { + return SensorMapping.TryParse(value, out var mapping) ? mapping : new SensorMapping(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs.meta new file mode 100644 index 000000000..e017dad7d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d74627ae0fd5440eaefd6b6f5fd795b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs new file mode 100644 index 000000000..aaf9a76be --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs @@ -0,0 +1,49 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Unity asset wrapper for cabinet input defaults or named cabinet profiles. + /// + /// + /// Built players should still persist user edits to JSON. The asset exists as + /// an editor-friendly default/preset container and can be written during Play + /// Mode in the editor when a human intentionally wants to update the preset. + /// + [CreateAssetMenu(menuName = "Visual Pinball/Cabinet Input Settings", fileName = "CabinetInputSettings")] + public sealed class CabinetInputSettingsAsset : ScriptableObject + { + public CabinetInputSettings settings = new(); + + /// + /// Applies this asset's settings to a loaded table root. + /// + public void ApplyTo(GameObject tableRoot) + { + settings ??= new CabinetInputSettings(); + settings.ApplyTo(tableRoot); + } + + private void OnValidate() + { + settings ??= new CabinetInputSettings(); + settings.Normalize(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs.meta new file mode 100644 index 000000000..5ded13cce --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 566371fe86c34561850ee36ea9994990 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index f97676bc8..a078384bd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -127,13 +127,16 @@ public class PhysicsEngine : MonoBehaviour, IPackable [Range(CabinetPhysicsState.MinKeyboardDampingRatio, CabinetPhysicsState.MaxKeyboardDampingRatio)] public float KeyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio; + [HideInInspector] [Tooltip("Simulate a mechanical plumb-bob tilt switch from cabinet nudge.")] - public bool SimulatedPlumb = true; + public bool SimulatedPlumb = false; + [HideInInspector] [Tooltip("Mechanical plumb-bob damping scale.")] [Range(0f, 2f)] public float PlumbDamping = 1f; + [HideInInspector] [Tooltip("Mechanical plumb-bob tilt threshold angle in degrees.")] [Range(0.5f, 4f)] public float PlumbThresholdAngle = 2f; @@ -186,7 +189,6 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [NonSerialized] private int _mainThreadManagedThreadId; [NonSerialized] private int _simulationThreadManagedThreadId = -1; [NonSerialized] private int _keyboardNudgeIndex; - [NonSerialized] private readonly List _pendingPlumbTiltEvents = new(8); [NonSerialized] private readonly HashSet _unsafeLiveStateAccessWarnings = new HashSet(); [NonSerialized] private bool _inputActionsQueueWarningIssued; [NonSerialized] private bool _scheduledActionsQueueWarningIssued; @@ -325,7 +327,6 @@ public void ConfigureNudge(CabinetNudgeSettings settings) settings.Normalize(); ConfigureKeyboardNudge((KeyboardNudgeMode)settings.keyboardMode, settings.keyboardStrength, settings.keyboardCabinetDamping); - ConfigurePlumb(settings.plumb.enabled, settings.plumb.damping, settings.plumb.thresholdDeg); ConfigureVisualNudge(settings.visualStrength); ConfigureNudgeSensors(settings.ToEngineSensorConfigs()); } @@ -342,7 +343,7 @@ public void ConfigureCabinetInput(CabinetInputSettings settings) { settings ??= new CabinetInputSettings(); settings.Normalize(); - ConfigureNudge(settings.nudge); + settings.nudge.ApplyTo(this); } /// @@ -613,18 +614,6 @@ internal void DrainPlumbTiltEvents(List destination) } } - /// - /// Dispatches pending plumb tilt edges through the main-thread player path. - /// - private void DispatchPendingPlumbTiltEvents() - { - _pendingPlumbTiltEvents.Clear(); - DrainPlumbTiltEvents(_pendingPlumbTiltEvents); - foreach (var high in _pendingPlumbTiltEvents) { - _player?.DispatchInputAction(InputConstants.ActionTilt, high); - } - } - internal void MarkCurrentThreadAsSimulationThread() { Interlocked.Exchange(ref _simulationThreadManagedThreadId, Thread.CurrentThread.ManagedThreadId); @@ -951,6 +940,9 @@ private void Awake() _player = GetComponentInParent(); _nudgeSystem = new NudgeSystem(this); ConfigureVisualNudge(VisualNudgeStrength); + if (TiltBobComponent.FindFor(this) == null) { + SimulatedPlumb = false; + } _ctx.InsideOfs = new InsideOfs(Allocator.Persistent); var table = GetComponentInParent(); _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength, @@ -1122,7 +1114,6 @@ private void Update() } else { // Normal mode: Execute full physics update _threading.ExecutePhysicsUpdate(NowUsec); - DispatchPendingPlumbTiltEvents(); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs index 0a3a1465f..a3b14aeea 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs @@ -22,8 +22,9 @@ namespace VisualPinball.Unity /// /// The boolean "Has..." flags preserve backward compatibility with packages /// created before keyboard nudge, plumb, damping, or visual nudge settings were - /// serialized. Missing fields are restored with the same defaults used by new - /// components. + /// serialized. Plumb fields are retained only so older packages can be read; + /// new packages route tilt through plus player + /// cabinet settings instead of table-owned physics settings. /// public struct PhysicsEnginePackable { @@ -52,10 +53,10 @@ public static byte[] Pack(PhysicsEngine comp) KeyboardNudgeStrength = comp.KeyboardNudgeStrength, HasKeyboardCabinetDamping = true, KeyboardCabinetDamping = comp.KeyboardCabinetDamping, - HasPlumbSettings = true, - SimulatedPlumb = comp.SimulatedPlumb, - PlumbDamping = comp.PlumbDamping, - PlumbThresholdAngle = comp.PlumbThresholdAngle, + HasPlumbSettings = false, + SimulatedPlumb = false, + PlumbDamping = 1f, + PlumbThresholdAngle = 2f, HasVisualNudgeSettings = true, VisualNudgeStrength = comp.VisualNudgeStrength, }); @@ -74,12 +75,7 @@ public static void Unpack(byte[] bytes, PhysicsEngine comp) keyboardCabinetDamping = data.HasKeyboardCabinetDamping ? data.KeyboardCabinetDamping : CabinetPhysicsState.DefaultKeyboardDampingRatio, - visualStrength = data.HasVisualNudgeSettings ? data.VisualNudgeStrength : 1f, - plumb = new CabinetPlumbSettings { - enabled = data.HasPlumbSettings ? data.SimulatedPlumb : true, - damping = data.HasPlumbSettings ? data.PlumbDamping : 1f, - thresholdDeg = data.HasPlumbSettings ? data.PlumbThresholdAngle : 2f - } + visualStrength = data.HasVisualNudgeSettings ? data.VisualNudgeStrength : 1f }); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index f5fffa393..b328bb7e4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -70,13 +70,14 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public event EventHandler OnPlayerStarted; public List SwitchMapping => _tableComponent.MappingConfig.Switches; - public List CoilMapping => _tableComponent.MappingConfig.Coils; - public List LampMapping => _tableComponent.MappingConfig.Lamps; - - public event EventHandler OnUpdate; - - public event EventHandler OnBallCreated; - public event EventHandler OnBallDestroyed; + public List CoilMapping => _tableComponent.MappingConfig.Coils; + public List LampMapping => _tableComponent.MappingConfig.Lamps; + + public event EventHandler OnUpdate; + internal event Action CabinetInputActionChanged; + + public event EventHandler OnBallCreated; + public event EventHandler OnBallDestroyed; public float4x4 PlayfieldToWorldMatrix => PlayfieldComponent.transform.localToWorldMatrix; @@ -178,6 +179,7 @@ internal void DispatchInputAction(string actionName, bool isPressed) { _switchPlayer.DispatchInputAction(actionName, isPressed); _wirePlayer.DispatchInputAction(actionName, isPressed); + CabinetInputActionChanged?.Invoke(actionName, isPressed); } internal bool DispatchCoilSimulationThread(string coilId, bool isEnabled) @@ -506,6 +508,7 @@ private void HandleInput(object obj, InputActionChange change) } if (action.actionMap.name == InputConstants.MapCabinetSwitches) { + HandleCabinetInputAction(action, change); HandleNudgeInput(action, change); } @@ -545,6 +548,20 @@ private void HandleInput(object obj, InputActionChange change) } } + private void HandleCabinetInputAction(InputAction action, InputActionChange change) + { + if (NativeInputManager.TryGetExistingInstance()?.IsPolling == true) { + return; + } + + if (change != InputActionChange.ActionStarted && change != InputActionChange.ActionCanceled) { + return; + } + + CabinetInputActionChanged?.Invoke(InputManager.GetCanonicalActionName(action.name), + change == InputActionChange.ActionStarted); + } + private void HandleNudgeInput(InputAction action, InputActionChange change) { if (NativeInputManager.TryGetExistingInstance()?.IsPolling == true || change != InputActionChange.ActionStarted) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 18e7a368a..7f7248bb4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -258,14 +258,19 @@ public ref readonly SimulationState.Snapshot GetSharedState() public SimulationState SharedState => _sharedState; /// - /// Dispatches a plumb tilt state change on the main thread, driving switch - /// status, key wires and (via the external switch queue) the gamelogic - /// engine; the same route the single-threaded mode takes. Set by - /// ; when null, tilt falls back to a - /// direct GLE switch dispatch on the simulation thread. + /// Dispatches plumb tilt state changes on the main thread through the + /// table's plumb tilt component. Set by + /// only when that component exists and is enabled by player settings. /// public Action MainThreadTiltDispatcher { get; set; } + /// + /// When true, native cabinet Tilt input is routed to + /// instead of directly to any + /// input-action switch mapping. This is used for real cabinet plumb bobs. + /// + public bool DispatchMappedTiltInputToMainThread { get; set; } + private readonly Queue _mainThreadTiltStates = new(); private readonly object _mainThreadTiltLock = new(); @@ -289,6 +294,13 @@ public void FlushMainThreadInputDispatch() } } + private void QueueMainThreadTiltState(bool tiltState) + { + lock (_mainThreadTiltLock) { + _mainThreadTiltStates.Enqueue(tiltState); + } + } + /// /// Publish the latest Unity-scaled simulation clock sample from the /// main thread so the simulation thread can stay aligned with @@ -509,6 +521,14 @@ private void ProcessInputEvents() InputLatencyTracker.RecordInputPolled((NativeInputApi.InputAction)actionIndex, isPressed, evt.TimestampUsec); + if (actionIndex == (int)NativeInputApi.InputAction.Tilt + && DispatchMappedTiltInputToMainThread + && MainThreadTiltDispatcher != null) { + QueueMainThreadTiltState(isPressed); + _inputEventsProcessed++; + continue; + } + // Only forward to GLE once it's ready (or at least has started) if (_gamelogicEngine != null && _gamelogicStarted) { if (isPressed && IsNudgeAction(actionIndex)) { @@ -872,8 +892,8 @@ private void UpdatePhysics() } /// - /// Converts plumb-bob tilt edges produced by physics into input switch - /// events. + /// Queues physics-produced plumb-bob tilt edges for the authored table + /// component that owns switch dispatch. /// /// /// Main-thread gamelogic engines receive these through the same external @@ -882,26 +902,18 @@ private void UpdatePhysics() /// private void ProcessPlumbTiltEvents() { + if (MainThreadTiltDispatcher == null || DispatchMappedTiltInputToMainThread) { + return; + } + _pendingPlumbTiltEvents.Clear(); _physicsEngine.DrainPlumbTiltEvents(_pendingPlumbTiltEvents); if (_pendingPlumbTiltEvents.Count == 0) { return; } - var tiltActionIndex = (int)NativeInputApi.InputAction.Tilt; for (var i = 0; i < _pendingPlumbTiltEvents.Count; i++) { - var isPressed = _pendingPlumbTiltEvents[i]; - _actionStates[tiltActionIndex] = isPressed; - if (MainThreadTiltDispatcher != null) { - // Route through the main thread so switch status and key wires - // update too; the GLE still receives the switch via the external - // switch queue, like in single-threaded mode. - lock (_mainThreadTiltLock) { - _mainThreadTiltStates.Enqueue(isPressed); - } - } else if (_gamelogicEngine != null && _gamelogicStarted) { - SendMappedSwitch(tiltActionIndex, isPressed); - } + QueueMainThreadTiltState(_pendingPlumbTiltEvents[i]); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index a8ebb5386..35459cd43 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -379,9 +379,7 @@ public void StartSimulation() player != null ? new Action((coilId, isEnabled) => player.DispatchCoilSimulationThread(coilId, isEnabled)) : null); - if (player != null) { - _simulationThread.MainThreadTiltDispatcher = isTilted => player.DispatchInputAction(InputConstants.ActionTilt, isTilted); - } + ConfigureTiltBobRouting(); _simulationThread.SyncClockFromMainThread(_physicsEngine.CurrentSimulationClockUsec, _physicsEngine.CurrentSimulationClockScale); // Provide the triple-buffered SimulationState to PhysicsEngine so @@ -536,8 +534,42 @@ public void ApplyNudgeSettings(CabinetNudgeSettings settings, bool applyNudgeToP if (applyNudgeToPhysics) { _physicsEngine ??= GetComponent(); - _physicsEngine?.ConfigureNudge(settings); + settings.ApplyTo(_physicsEngine); + } else { + ConfigureTiltBobRouting(); + } + } + + /// + /// Routes plumb tilt edges between the simulation thread and the table's tilt-bob + /// component that owns the authored switch mapping. + /// + internal void ConfigureTiltBobRouting(TiltBobComponent tiltBob = null) + { + if (_simulationThread == null) { + return; + } + + tiltBob ??= FindTiltBobComponent(); + if (tiltBob != null && (tiltBob.UsesSimulatedPlumb || tiltBob.UsesMappedInput)) { + _simulationThread.MainThreadTiltDispatcher = tiltBob.QueueTiltStateFromSimulationThread; + _simulationThread.DispatchMappedTiltInputToMainThread = tiltBob.UsesMappedInput; + return; } + + _simulationThread.MainThreadTiltDispatcher = null; + _simulationThread.DispatchMappedTiltInputToMainThread = false; + } + + private TiltBobComponent FindTiltBobComponent() + { + _physicsEngine ??= GetComponent(); + if (_physicsEngine != null) { + return TiltBobComponent.FindFor(_physicsEngine); + } + + return GetComponentInParent(true) + ?? GetComponentInChildren(true); } /// diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob.meta new file mode 100644 index 000000000..1d1d5338c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9e0f54d099684344b9cf47b9bbfe6dd0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs new file mode 100644 index 000000000..14719b00a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs @@ -0,0 +1,64 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Runtime switch device for the table tilt-bob component. + /// + internal sealed class TiltBobApi : IApi, IApiSwitch, IApiSwitchDevice + { + private readonly SwitchHandler _switchHandler; + + public event EventHandler Init; + public event EventHandler Switch; + + public bool IsSwitchEnabled => _switchHandler.IsEnabled; + + public TiltBobApi(GameObject gameObject, Player player, PhysicsEngine physicsEngine) + { + _switchHandler = new SwitchHandler(gameObject.name, player, physicsEngine); + } + + public void SetSwitch(bool enabled) + { + _switchHandler.OnSwitch(enabled); + Switch?.Invoke(this, new SwitchEventArgs(enabled)); + } + + IApiSwitchStatus IApiSwitch.AddSwitchDest(SwitchConfig switchConfig, IApiSwitchStatus switchStatus) => + _switchHandler.AddSwitchDest(switchConfig.WithPulse(false).WithDefault(SwitchDefault.NormallyOpen), switchStatus); + + void IApiSwitch.AddWireDest(WireDestConfig wireConfig) => _switchHandler.AddWireDest(wireConfig.WithPulse(false)); + + void IApiSwitch.RemoveWireDest(string destId) => _switchHandler.RemoveWireDest(destId); + + IApiSwitch IApiSwitchDevice.Switch(string deviceItem) => + deviceItem == TiltBobComponent.SwitchItem ? this : null; + + void IApi.OnInit(BallManager ballManager) + { + Init?.Invoke(this, EventArgs.Empty); + } + + void IApi.OnDestroy() + { + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs.meta new file mode 100644 index 000000000..9ca73269a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f6d364cb1794b2b841848b029257a9c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs new file mode 100644 index 000000000..07655bfbd --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs @@ -0,0 +1,207 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using UnityEngine; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity +{ + /// + /// Table-owned tilt-bob switch device. + /// + /// + /// The component is the table author's routing point: map the game logic's + /// tilt switch to this device if the table should have a tilt bob. The + /// player's cabinet settings decide whether the signal comes from the physics + /// plumb-bob simulation or from a real mapped cabinet switch. + /// + [DisallowMultipleComponent] + [PackAs("TiltBob")] + [AddComponentMenu("Pinball/Mechs/Tilt Bob")] + public sealed class TiltBobComponent : MonoBehaviour, ISwitchDeviceComponent, IPackable + { + public const string SwitchItem = "tilt_bob_switch"; + + private readonly Queue _queuedTiltStates = new(); + private readonly List _pendingSimulatedTiltStates = new(8); + private readonly object _queuedTiltLock = new(); + + [NonSerialized] private Player _player; + [NonSerialized] private PhysicsEngine _physicsEngine; + [NonSerialized] private TiltBobApi _api; + [NonSerialized] private TiltBobMode _mode = TiltBobMode.Simulated; + [NonSerialized] private bool _enabled = true; + [NonSerialized] private bool _settingsApplied; + + public IEnumerable AvailableSwitches => new[] { + new GamelogicEngineSwitch(SwitchItem) { Description = "Tilt Bob" } + }; + + public SwitchDefault SwitchDefault => SwitchDefault.NormallyOpen; + + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableSwitches; + + public TiltBobMode Mode => _mode; + public bool UsesSimulatedPlumb => _enabled && _mode == TiltBobMode.Simulated; + public bool UsesMappedInput => _enabled && _mode == TiltBobMode.Mapped; + + public static TiltBobComponent FindFor(PhysicsEngine physicsEngine) + { + if (!physicsEngine) { + return null; + } + + var table = physicsEngine.GetComponentInParent(); + if (table) { + return table.GetComponentInChildren(true); + } + + return physicsEngine.GetComponentInParent(true) + ?? physicsEngine.GetComponentInChildren(true); + } + + public static void ApplySettings(PhysicsEngine physicsEngine, CabinetPlumbSettings settings) + { + settings ??= new CabinetPlumbSettings(); + settings.Normalize(); + + var tiltBob = FindFor(physicsEngine); + if (tiltBob != null) { + tiltBob.ApplySettings(settings); + return; + } + + // Without a table tilt-bob component there is no switch route, so keep + // the physics plumb disabled even if the player prefers simulation. + physicsEngine?.ConfigurePlumb(false, settings.damping, settings.thresholdDeg); + } + + public byte[] Pack() => PackageApi.Packer.Empty; + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => null; + + public void Unpack(byte[] bytes) { } + + public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, PackagedFiles files) { } + + public void ApplySettings(CabinetPlumbSettings settings) + { + settings ??= new CabinetPlumbSettings(); + settings.Normalize(); + + _enabled = settings.enabled; + _mode = settings.mode; + _settingsApplied = true; + + _physicsEngine ??= GetComponentInParent(); + _physicsEngine?.ConfigurePlumb(UsesSimulatedPlumb, settings.damping, settings.thresholdDeg); + ConfigureSimulationThreadRouting(); + } + + internal void QueueTiltStateFromSimulationThread(bool enabled) + { + lock (_queuedTiltLock) { + _queuedTiltStates.Enqueue(enabled); + } + } + + private void Awake() + { + _player = GetComponentInParent(); + _physicsEngine = GetComponentInParent(); + if (_player != null && _physicsEngine != null) { + _api = new TiltBobApi(gameObject, _player, _physicsEngine); + _player.Register(_api, this); + _player.CabinetInputActionChanged += OnCabinetInputActionChanged; + } + } + + private void Start() + { + if (!_settingsApplied) { + ApplySettings(new CabinetPlumbSettings()); + } + } + + private void Update() + { + FlushQueuedTiltStates(); + + if (!UsesSimulatedPlumb || !_physicsEngine || _physicsEngine.UsesExternalTiming) { + return; + } + + _pendingSimulatedTiltStates.Clear(); + _physicsEngine.DrainPlumbTiltEvents(_pendingSimulatedTiltStates); + foreach (var tilted in _pendingSimulatedTiltStates) { + SetSwitch(tilted); + } + } + + private void OnDestroy() + { + _enabled = false; + if (_player != null) { + _player.CabinetInputActionChanged -= OnCabinetInputActionChanged; + } + _physicsEngine?.ConfigurePlumb(false, 1f, 2f); + ConfigureSimulationThreadRouting(); + } + + private void OnCabinetInputActionChanged(string actionName, bool isPressed) + { + if (UsesMappedInput && actionName == InputConstants.ActionTilt) { + SetSwitch(isPressed); + } + } + + private void FlushQueuedTiltStates() + { + while (true) { + bool tilted; + lock (_queuedTiltLock) { + if (_queuedTiltStates.Count == 0) { + return; + } + tilted = _queuedTiltStates.Dequeue(); + } + SetSwitch(tilted); + } + } + + private void SetSwitch(bool enabled) + { + if (!_enabled) { + return; + } + _api?.SetSwitch(enabled); + } + + private void ConfigureSimulationThreadRouting() + { + var simulationThread = _physicsEngine != null + ? _physicsEngine.GetComponent() + ?? _physicsEngine.GetComponentInParent() + ?? _physicsEngine.GetComponentInChildren() + : null; + simulationThread?.ConfigureTiltBobRouting(this); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta new file mode 100644 index 000000000..bdab21763 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2ce2fd55a40b4f549bf7a32ed518e58b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs new file mode 100644 index 000000000..5cb692bce --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs @@ -0,0 +1,36 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + /// + /// Selects where a table's tilt-bob switch signal comes from. + /// + public enum TiltBobMode + { + /// + /// Use the physics engine's simulated plumb-bob edges generated from + /// cabinet acceleration. + /// + Simulated = 0, + + /// + /// Use the player's mapped cabinet tilt input, typically a real plumb bob + /// wired into the cabinet controller. + /// + Mapped = 1 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs.meta new file mode 100644 index 000000000..aad8117cb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b4bda9d8e6747559a48db5b10bb618d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From bfa1cb4960200cbb4b69518d5c03f39a2abaabbb Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 17:13:38 +0200 Subject: [PATCH 23/25] tilt-bob: Author damping and threshold on the component. Move plumb-bob damping and tilt-threshold tuning off the shared physics/nudge settings and onto TiltBobComponent, where the values are authored, range-clamped, and normalized via OnValidate. Persist the tuning through a new TiltBobPackable so it survives pack/unpack, and keep the legacy CabinetPlumbSettings fields only for JSON compatibility. --- .../Game/CabinetInputSettings.cs | 16 ++++-- .../VPT/TiltBob/TiltBobComponent.cs | 49 ++++++++++++++++--- .../VPT/TiltBob/TiltBobPackable.cs | 48 ++++++++++++++++++ .../VPT/TiltBob/TiltBobPackable.cs.meta | 11 +++++ 4 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs index ce996b7f4..7d506aafe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs @@ -123,7 +123,7 @@ public static CabinetInputSettings From(PhysicsEngine physicsEngine, SimulationT /// /// Shared nudge behavior settings for keyboard nudges, visual cabinet motion, - /// player plumb tilt source, and analog sensors. + /// player tilt-bob source selection, and analog sensors. /// /// /// Field names match the original player JSON config so existing @@ -220,9 +220,7 @@ public static CabinetNudgeSettings From(PhysicsEngine physicsEngine) settings.visualStrength = physicsEngine.VisualNudgeStrength; settings.plumb = new CabinetPlumbSettings { enabled = true, - mode = TiltBobComponent.FindFor(physicsEngine)?.Mode ?? TiltBobMode.Simulated, - damping = physicsEngine.PlumbDamping, - thresholdDeg = physicsEngine.PlumbThresholdAngle + mode = TiltBobComponent.FindFor(physicsEngine)?.Mode ?? TiltBobMode.Simulated }; settings.Normalize(); return settings; @@ -243,11 +241,19 @@ public sealed class CabinetPlumbSettings [HideInInspector] public bool enabled = true; public TiltBobMode mode = TiltBobMode.Simulated; + + /// + /// Legacy player-config fields retained for JSON compatibility. Current + /// tilt-bob tuning is authored on . + /// + [HideInInspector] public float damping = 1f; + + [HideInInspector] public float thresholdDeg = 2f; /// - /// Clamps the plumb-bob mode, damping, and tilt threshold to useful ranges. + /// Clamps the tilt-bob source mode and legacy tuning fields to useful ranges. /// public void Normalize() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs index 07655bfbd..931c5ec37 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; using UnityEngine; +using UnityEngine.Serialization; using VisualPinball.Engine.Common; using VisualPinball.Engine.Game.Engines; using VisualPinball.Unity.Simulation; @@ -38,11 +39,27 @@ namespace VisualPinball.Unity public sealed class TiltBobComponent : MonoBehaviour, ISwitchDeviceComponent, IPackable { public const string SwitchItem = "tilt_bob_switch"; + public const float DefaultDamping = 1f; + public const float MinDamping = 0f; + public const float MaxDamping = 2f; + public const float DefaultThresholdAngle = 2f; + public const float MinThresholdAngle = 0.5f; + public const float MaxThresholdAngle = 4f; private readonly Queue _queuedTiltStates = new(); private readonly List _pendingSimulatedTiltStates = new(8); private readonly object _queuedTiltLock = new(); + [Tooltip("Mechanical plumb-bob damping scale. Higher values calm the bob faster after a nudge.")] + [Range(MinDamping, MaxDamping)] + [FormerlySerializedAs("Damping")] + public float PlumbDamping = DefaultDamping; + + [Tooltip("Mechanical plumb-bob tilt threshold angle in degrees. Lower values make the table easier to tilt.")] + [Range(MinThresholdAngle, MaxThresholdAngle)] + [FormerlySerializedAs("ThresholdAngle")] + public float PlumbThresholdAngle = DefaultThresholdAngle; + [NonSerialized] private Player _player; [NonSerialized] private PhysicsEngine _physicsEngine; [NonSerialized] private TiltBobApi _api; @@ -90,14 +107,14 @@ public static void ApplySettings(PhysicsEngine physicsEngine, CabinetPlumbSettin // Without a table tilt-bob component there is no switch route, so keep // the physics plumb disabled even if the player prefers simulation. - physicsEngine?.ConfigurePlumb(false, settings.damping, settings.thresholdDeg); + physicsEngine?.ConfigurePlumb(false, DefaultDamping, DefaultThresholdAngle); } - public byte[] Pack() => PackageApi.Packer.Empty; + public byte[] Pack() => TiltBobPackable.Pack(this); public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => null; - public void Unpack(byte[] bytes) { } + public void Unpack(byte[] bytes) => TiltBobPackable.Unpack(bytes, this); public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, PackagedFiles files) { } @@ -110,8 +127,7 @@ public void ApplySettings(CabinetPlumbSettings settings) _mode = settings.mode; _settingsApplied = true; - _physicsEngine ??= GetComponentInParent(); - _physicsEngine?.ConfigurePlumb(UsesSimulatedPlumb, settings.damping, settings.thresholdDeg); + ConfigurePhysicsPlumb(); ConfigureSimulationThreadRouting(); } @@ -161,10 +177,24 @@ private void OnDestroy() if (_player != null) { _player.CabinetInputActionChanged -= OnCabinetInputActionChanged; } - _physicsEngine?.ConfigurePlumb(false, 1f, 2f); + _physicsEngine?.ConfigurePlumb(false, DefaultDamping, DefaultThresholdAngle); ConfigureSimulationThreadRouting(); } + private void OnValidate() + { + NormalizeSettings(); + if (Application.isPlaying && _settingsApplied) { + ConfigurePhysicsPlumb(); + } + } + + internal void NormalizeSettings() + { + PlumbDamping = Mathf.Clamp(PlumbDamping, MinDamping, MaxDamping); + PlumbThresholdAngle = Mathf.Clamp(PlumbThresholdAngle, MinThresholdAngle, MaxThresholdAngle); + } + private void OnCabinetInputActionChanged(string actionName, bool isPressed) { if (UsesMappedInput && actionName == InputConstants.ActionTilt) { @@ -194,6 +224,13 @@ private void SetSwitch(bool enabled) _api?.SetSwitch(enabled); } + private void ConfigurePhysicsPlumb() + { + NormalizeSettings(); + _physicsEngine ??= GetComponentInParent(); + _physicsEngine?.ConfigurePlumb(UsesSimulatedPlumb, PlumbDamping, PlumbThresholdAngle); + } + private void ConfigureSimulationThreadRouting() { var simulationThread = _physicsEngine != null diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs new file mode 100644 index 000000000..f7e6c5e3e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs @@ -0,0 +1,48 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + /// + /// Packaged representation of table-authored tilt-bob behavior. + /// + public struct TiltBobPackable + { + public float Damping; + public float ThresholdAngle; + + public static byte[] Pack(TiltBobComponent comp) + { + comp.NormalizeSettings(); + return PackageApi.Packer.Pack(new TiltBobPackable { + Damping = comp.PlumbDamping, + ThresholdAngle = comp.PlumbThresholdAngle + }); + } + + public static void Unpack(byte[] bytes, TiltBobComponent comp) + { + if (bytes == null || bytes.Length == 0) { + return; + } + + var data = PackageApi.Packer.Unpack(bytes); + comp.PlumbDamping = data.Damping; + comp.PlumbThresholdAngle = data.ThresholdAngle; + comp.NormalizeSettings(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs.meta new file mode 100644 index 000000000..ee55fd5ed --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a2c4e2f1f0b14a04b8338e9d58ea7d25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From d05408fdd3bed3228dcd781e6586b1b932c317dd Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 17:43:03 +0200 Subject: [PATCH 24/25] tilt-bob: Rename Mapped mode to Physical and add inspector. Rename the TiltBobMode.Mapped enum value and its related APIs (UsesMappedInput, DispatchMappedTiltInputToMainThread) to Physical to better describe a real cabinet plumb bob wired into the controller. Add a TiltBobInspector exposing plumb damping and threshold angle, and document nudging and tilt bobs in the creators and developer guides. --- .../manual/mechanisms/tilt-bobs.md | 54 +++++ .../creators-guide/manual/nudging.md | 126 +++++++++++ .../Documentation~/creators-guide/toc.yml | 12 +- .../Documentation~/developer-guide/index.md | 7 +- .../developer-guide/nudge-system.md | 203 ++++++++++++++++++ .../Documentation~/developer-guide/toc.yml | 2 + .../VPT/TiltBob.meta | 8 + .../VPT/TiltBob/TiltBobInspector.cs | 52 +++++ .../VPT/TiltBob/TiltBobInspector.cs.meta | 11 + .../Game/CabinetInputSettings.cs | 2 +- .../Simulation/SimulationThread.cs | 6 +- .../Simulation/SimulationThreadComponent.cs | 6 +- .../VPT/TiltBob/TiltBobComponent.cs | 6 +- .../VPT/TiltBob/TiltBobMode.cs | 4 +- 14 files changed, 480 insertions(+), 19 deletions(-) create mode 100644 VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/tilt-bobs.md create mode 100644 VisualPinball.Unity/Documentation~/creators-guide/manual/nudging.md create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/nudge-system.md create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs.meta diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/tilt-bobs.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/tilt-bobs.md new file mode 100644 index 000000000..c26266aa0 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/tilt-bobs.md @@ -0,0 +1,54 @@ +--- +uid: tilt_bobs +title: Tilt Bobs +description: VPE can route tilt switch events from a simulated plumb bob or a real cabinet tilt bob. +--- + +# Tilt Bobs + +A tilt bob is the table's tilt switch route. Add a `TiltBobComponent` when the +table should have a tilt switch that can be driven either by VPE's simulated +plumb bob or by a real cabinet tilt bob wired into the player's cabinet +controller. + +VPX imports add a `TiltBobComponent` to the table root by default. Remove it if +the table should not have a tilt bob route. + +## Setup + +To add a tilt bob manually, select the table object or another object under the +table, click *Add Component*, and select *Pinball -> Mechs -> Tilt Bob*. + +Use the [Switch Manager](xref:switch_manager) to map the game logic tilt switch +to the component's `Tilt Bob` switch item. + +If a table does not have a `TiltBobComponent`, VPE does not simulate a plumb bob +for that table and does not pull the player's physical cabinet tilt switch. This +lets tables opt out by omission instead of by a hidden setting. + +## Source + +The player chooses the plumb source in cabinet/player settings: + +| Source | What happens | +| --- | --- | +| `Simulated` | Cabinet acceleration swings VPE's simulated plumb bob. When it crosses the threshold, the `TiltBobComponent` sends its mapped switch. | +| `Physical` | The player's physical cabinet `Tilt` input drives the `TiltBobComponent`. Use this for a real tilt bob wired into a cabinet controller. | + +This source is a player setting, not a table setting. The table only decides +whether a tilt-bob switch route exists and which game logic switch receives the +signal. + +## Simulated Plumb Bob + +For the simulated source, these `TiltBobComponent` fields shape how easily the +bob trips: + +| Field | Lower value | Higher value | +| --- | --- | --- | +| `Plumb Threshold Angle` | Easier to tilt. | Harder to tilt. | +| `Plumb Damping` | Bob rings longer. | Bob settles faster. | + +Test tilt with the same keyboard and sensor defaults you expect players to use. +Very strong nudging plus a low threshold can make a table tilt too easily. For a +real cabinet bob, test the physical source with the actual cabinet input binding. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/nudging.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/nudging.md new file mode 100644 index 000000000..817bfcd19 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/nudging.md @@ -0,0 +1,126 @@ +--- +uid: nudging +title: Nudging +description: How table authors configure nudge behavior and cabinet input defaults. +--- + +# Nudging + +Nudging in VPE moves the virtual cabinet, not the ball directly. When the player +nudges, VPE computes cabinet acceleration and cabinet offset. The ball reacts to +that acceleration through physics, the camera/table can move visually, and the +table's plumb tilt component can tilt from the same movement when the table +author adds one. + +As a table author, think about nudging in two layers: + +- Table feel: how strong keyboard nudges are, how quickly the cabinet settles, + how much visual motion is shown, and whether the table has a tilt bob route. +- Player hardware: which real device and axes are mapped for a specific + cabinet. + +The first layer belongs in your table defaults. The second layer usually belongs +to the player's cabinet or player app configuration. + +## Table Defaults + +The main table-level nudge settings live on the `PhysicsEngine` component. + +| Setting | What it affects | +| --- | --- | +| `Keyboard Nudge Mode` | How keyboard/button nudges are converted into cabinet motion. | +| `Keyboard Nudge Strength` | Strength multiplier for keyboard/button nudges. | +| `Keyboard Cabinet Damping` | How firmly the cabinet settles after `CabModel` keyboard nudges. | +| `Visual Nudge Strength` | How much of the cabinet offset is shown visually. This is cosmetic. | + +The `Keyboard Nudge Mode` setting has three choices: + +| Mode | What it does | When to use it | +| --- | --- | --- | +| `PushRetract` | Applies the older VP-style push/retract pulse. It gives the ball a sharp kick, then quickly retracts the cabinet offset. | Use it only when a table was tuned around this older keyboard nudge feel. | +| `BoxModel` | Treats the table as a displaced box with a spring pulling it back to rest. It can visibly ring if the timing is long. | Use it when matching legacy VP behavior for a table that depends on that spring-style response. | +| `CabModel` | Applies a short cabinet impulse and lets the cabinet oscillator settle it. This is meant to feel like a sturdy cabinet shove rather than a long bounce. | Use it for new tables and for tables where keyboard nudging should resemble cabinet nudging. | + +The `TableComponent` also has `NudgeTime`, imported from VPX table data. In VPE +this is used by the legacy `BoxModel` keyboard nudge path to set the spring +timing. + +Use `CabModel` for new work unless you are matching an existing table's VP +behavior. It models a cabinet shove and a short return rather than a long visible +spring bounce. `PushRetract` and `BoxModel` are available for legacy VP-style +compatibility. + +Higher keyboard cabinet damping makes the cabinet feel sturdier and settle +faster. Lower damping makes it ring more. If the table visibly bounces too many +times after one keyboard nudge, raise the damping or lower the visual strength. + +## Visual Nudging + +Visual nudge is only presentation. It moves the rendered table/cabinet in +response to the same cabinet offset used by the nudge model, but it does not add +extra physical force. + +Keep visual strength modest. A little movement helps the player feel the nudge; +too much looks like camera shake and can make a physically reasonable nudge feel +wrong. + +## Tilt + +Tilt-bob routing and simulated plumb-bob tuning live on the +[`Tilt Bob`](xref:tilt_bobs) mechanism. + +## Hardware Sensors + +Hardware sensor mapping is handled by the `SimulationThreadComponent` nudge +sensor list and by the player app's cabinet input settings. These mappings are +specific to a cabinet, so avoid baking your personal KL25Z/Pinscape device ID +into a distributable table unless you are intentionally building a cabinet +profile for that setup. + +Sensor types mean: + +| Type | Use it for | +| --- | --- | +| `GamepadIntent` | Gamepad sticks or other analog controls where the player expresses "nudge this way". | +| `CabinetIntent` | A real cabinet sensor where motion should be interpreted as a nudge attempt. | +| `CabinetDirect` | A real cabinet sensor where measured motion should drive cabinet physics directly. | + +Most Xbox-style controllers do not provide accelerometer data for nudging. Map +their sticks as `GamepadIntent`. + +KL25Z/Pinscape boards are cabinet sensors. Map their acceleration axes, then use +mount rotation and mirror to match how the board is installed in the cabinet. +The available rotations are 0, 90, 180, and 270 degrees. Mirror flips the board X +axis before rotation. + +## Editor Calibration + +For direct play in the editor, use the `SimulationThreadComponent` inspector: + +1. Enter Play Mode with the cabinet sensor connected. +2. Use auto-configuration if the first device should be mapped as the cabinet + sensor. +3. Set mount rotation and mirror until graph movement matches cabinet movement. +4. Keep the cabinet still and calibrate sensor centers. +5. Nudge the cabinet lightly and watch the input graph. + +Calibration captures the current resting raw value as zero. It does not remove +spikes, pick better axes, or tune the overall gain. If the graph spikes while the +cabinet is still, check the mapped axes, increase dead zone, reduce scale, or +inspect the physical board/device signal. + +## Shipping Tables + +Ship table defaults that feel good without special hardware. A player should be +able to play with keyboard nudging alone, and cabinet owners should be able to +override sensor mappings from their own configuration. + +Good release checks: + +- Test keyboard nudging with the intended `Keyboard Nudge Mode`. +- Verify visual nudge is noticeable but not distracting. +- If the table has a tilt bob, keep or add a `TiltBobComponent`, map the tilt switch to + it, and verify it can tilt the table without tilting from ordinary play. +- If you include sensor defaults, confirm the table remains playable when those + devices are absent. +- Do not rely on a specific personal device ID for public tables. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml index 62dae2c58..71fd85e93 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml @@ -95,6 +95,8 @@ href: manual/displays.md - name: Sound href: manual/sound.md + - name: Nudging + href: manual/nudging.md - name: Pinball Mechanisms items: - name: Troughs / Ball Drains @@ -117,7 +119,9 @@ href: manual/mechanisms/score-reels.md - name: Score Motors href: manual/mechanisms/score-motors.md - - name: Collision Switches - href: manual/mechanisms/collision-switches.md - - name: Lifting Gates - href: manual/mechanisms/lifting-gates.md + - name: Collision Switches + href: manual/mechanisms/collision-switches.md + - name: Tilt Bobs + href: manual/mechanisms/tilt-bobs.md + - name: Lifting Gates + href: manual/mechanisms/lifting-gates.md diff --git a/VisualPinball.Unity/Documentation~/developer-guide/index.md b/VisualPinball.Unity/Documentation~/developer-guide/index.md index a46e1e708..263392b02 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/index.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/index.md @@ -63,11 +63,12 @@ This is the shared asset library. It keeps reusable content out of the engine re Use it for reusable art content rather than engine or gameplay code. -## Future integrations - -The developer guide also tracks design work for integrations that are not yet fully implemented in VPE. These pages are intended to capture architectural direction early, so implementation work across native input, runtime systems, and tooling can converge on the same design. +## Runtime architecture and future integrations + +The developer guide also tracks runtime systems and design work for integrations that are not yet fully implemented in VPE. These pages are intended to capture architectural direction early, so implementation work across native input, runtime systems, and tooling can converge on the same design. - [Packaging](xref:developer-guide-packaging-overview) documents the `.vpe` format, the export/import split, the renderer-agnostic material vocabulary, and the benchmarked optimization work around package loading. +- [Nudge System](xref:developer-guide-nudge-system) explains the current keyboard, sensor, visual nudge, and simulated plumb-bob runtime architecture. - [Accelerometer Input Design](xref:developer-guide-accelerometer-input-design) covers analog nudge input, Open Pinball Device support, calibration, and how a future player app should participate in initial setup. - [B2S Integration Design](xref:developer-guide-b2s-integration-design) proposes modernizing the upstream B2S runtime into a shared cross-platform core with a Windows COM shim, a native second-monitor host, and a Unity texture output for VR backglasses. - [DOF Integration Design](xref:developer-guide-dof-integration-design) covers a Windows-first `DirectOutput` integration for the future player app and the later hybrid path toward a `libdof` backend. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/nudge-system.md b/VisualPinball.Unity/Documentation~/developer-guide/nudge-system.md new file mode 100644 index 000000000..b6ab282d0 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/nudge-system.md @@ -0,0 +1,203 @@ +--- +uid: developer-guide-nudge-system +title: Nudge System +description: How VPE combines keyboard nudges, cabinet sensors, visual motion, and tilt-bob routing. +--- + +# Nudge System + +VPE models nudging as cabinet motion. A nudge is not a special force applied +directly to the ball. Instead, the cabinet/playfield reference frame moves and +accelerates, and the ball reacts to that movement through the normal physics +step. This keeps keyboard nudges, analog cabinet devices, visual cabinet motion, +and plumb-bob tilt on the same physical vocabulary. + +The runtime coordinator is `NudgeState`. It owns keyboard nudging, up to four +analog sensor slots, the selected cabinet acceleration for the current physics +tick, the visual cabinet offset, and nudge telemetry. The physics step consumes +`NudgeState.CabinetAcceleration`; rendering consumes `NudgeState.CabinetOffset` +through `VisualNudgeComponent`; simulated tilt uses the same acceleration through +`PlumbState` and routes the resulting switch edges through `TiltBobComponent`. + +Much of the model is a C# port or adaptation of the current VP cabinet nudge +code: + +- `NudgeState` follows the coordinator shape from + `vpinball/src/physics/cabinet/NudgeHandler.*`. +- `KeyboardNudgeState` ports/adapts + `vpinball/src/physics/cabinet/KeyboardNudge.*`. +- `GamepadNudgeState` adapts + `vpinball/src/physics/cabinet/GamepadNudge.*`. +- `CabinetSensorState` adapts + `vpinball/src/physics/cabinet/CabinetNudgeSensor.*`, with support code from + `MotionKalmanAxis.h` and `MotionGainCalibratorAxis.h`. +- `PlumbState` ports/adapts + `vpinball/src/physics/cabinet/PlumbHandler.*`. + +## Data Flow + +```mermaid +flowchart TD + Keys["Keyboard/button nudge
PhysicsEngine.Nudge(angle, force)"] --> Pending["Pending keyboard queue"] + Native["Native analog input
NativeInputManager"] --> Mapper["NudgeSystem
device/axis mapping"] + Mapper --> Samples["Pending sensor sample queue"] + Pending --> Tick["Physics tick
PhysicsEngineThreading"] + Samples --> Tick + Tick --> State["NudgeState
selects one cabinet source"] + State --> Ball["Ball physics
cabinet acceleration"] + State --> Visual["VisualNudgeComponent
cabinet offset"] + State --> Plumb["PlumbState
tilt switch edges"] + Plumb --> TiltBob["TiltBobComponent
authored switch route"] + PhysicalTilt["Physical cabinet Tilt input
real tilt bob"] --> TiltBob + TiltBob --> Switch["Game logic tilt switch"] +``` + +`PhysicsEngine.Nudge(angleDeg, force)` queues a keyboard-style command. In +threaded mode, native axis input is received on the native input polling thread, +matched by `NudgeSystem`, normalized through `SensorMapping`, and queued into the +simulation thread. Both queues are drained at the deterministic physics cadence, +so all nudge sources advance in one millisecond steps. + +Keyboard input intentionally wins while its synthetic cabinet movement is active. +After that, `NudgeState` chooses the most recently active analog sensor. If two +sensors have the same activity timestamp, the stronger cabinet motion wins. This +keeps a deliberate keyboard/button nudge from being diluted by resting sensor +noise, while still allowing multiple cabinet devices to be configured. + +## Intent + +In this system, "intent" means an input expresses what the player wanted to do, +not what the cabinet physically did. + +Keyboard keys, cabinet buttons, and gamepad sticks are intent sources. They say +"nudge left now" or "nudge forward now"; they do not measure a real cabinet +trajectory. VPE converts that request into a synthetic cabinet impulse and then +lets the cabinet spring model ring down. + +Some real accelerometer devices can also be used as intent sources. A +`CabinetIntent` sensor reads cabinet acceleration, detects the nudge attempt, +and feeds a synthesized cabinet impulse. This is useful when a physical sensor is +too noisy, too device-specific, or too hard to scale as direct measured motion, +but still clearly identifies that the player hit the cabinet. + +Direct cabinet sensors are different. A `CabinetDirect` sensor is treated as +measured physical motion. Its velocity and/or acceleration channels are filtered, +scaled, and fed into the cabinet model as the cabinet's actual movement. + +## Source Types + +`KeyboardNudgeMode` controls how digital nudges become cabinet motion: + +| Mode | Purpose | +| --- | --- | +| `PushRetract` | Legacy VP-style instant shove/retract pulse. | +| `BoxModel` | Legacy VP box model where a table-space offset springs back to rest. | +| `CabModel` | Cabinet oscillator model used by the new nudge stack. | + +`NudgeSensorType` controls how an analog slot is interpreted: + +| Type | Channels | Meaning | +| --- | --- | --- | +| `GamepadIntent` | `X`, `Y` | Stick/controller axes as player intent. | +| `CabinetIntent` | `AccelerationX`, `AccelerationY`, optional velocity channels | Measured cabinet acceleration interpreted as a nudge attempt. | +| `CabinetDirect` | `AccelerationX`, `AccelerationY`, optional velocity channels | Measured cabinet motion used directly. | + +Gamepad intent intentionally does not imply that the controller has an +accelerometer. For example, a typical Xbox controller can be mapped through stick +axes, but it should be treated as `GamepadIntent`, not as a cabinet sensor. + +## Sensor Mapping + +Analog mappings are stored as compact strings and parsed into `SensorMapping` +objects at runtime. The string format is used because native device IDs can +contain punctuation and because the same values need to survive player JSON, +table packaging, and editor presets. + +A mapping identifies: + +- Native device ID. +- Native axis ID. +- Axis kind: `Position`, `Velocity`, or `Acceleration`. +- Dead zone. +- Scale. +- Limit. +- Raw neutral center. + +The native input thread emits raw axis values. `SensorMapping.ProcessRawValue()` +subtracts the saved raw center, applies dead zone/scale/limit, and returns the +normalized value queued into physics. + +## Mount Orientation + +Cabinet devices are often installed in different physical orientations. Each +sensor slot therefore has a mount transform: + +- `NudgeSensorMountRotation`: 0, 90, 180, or 270 degrees. +- `MountMirror`: mirror the board X axis before rotation. + +`NudgeSensorState` applies this transform at the slot boundary before dispatching +samples to the gamepad or cabinet implementation. This keeps the rest of the +physics code in cabinet-space X/Y coordinates regardless of how the board is +mounted. + +## Tilt Bob Routing + +`PlumbState` is deliberately only the physics-side bob simulation. It produces +tilt switch edges when cabinet acceleration swings the bob past the configured +threshold, but it does not know which ROM/table switch should receive that +signal. + +`TiltBobComponent` is the table-authored route and owns the simulated bob's +damping and threshold angle. If the table contains this component, the switch +manager can map the game logic tilt switch to the component's `tilt_bob_switch` +item. If the component is absent, VPE disables the physics plumb route for that +table and does not listen to the player's cabinet Tilt input for plumb tilt. + +The source is player/cabinet configuration, not table configuration: + +| Mode | Source | +| --- | --- | +| `Simulated` | `PhysicsEngine` drains `PlumbState` edges and the component forwards them to its mapped switch. | +| `Physical` | Native input or Unity Input System `Tilt` action changes are queued to the component, which forwards the real cabinet bob state to its mapped switch. | + +This separation is important because the table knows where its tilt switch is +routed, while the cabinet owner knows whether tilt should come from simulation +or from a real physical bob. + +## Calibration + +`SimulationThreadComponent.CalibrateNudgeSensorCenters()` captures the current +raw value of every mapped axis and stores it as that mapping's neutral center. +`ResetNudgeSensorCenters()` clears those saved centers. + +This is intentionally center calibration, not gain calibration and not noise +removal. It solves the common case where a resting KL25Z/Pinscape board does not +report exactly zero. It does not choose better axes, remove spikes, or determine +the correct scale. For direct cabinet sensors, `CabinetSensorState` still runs +its physics-side filtering and gain calibration from live motion data. + +Calibration should be performed while the cabinet is at rest and the sensor is +already mapped to the intended axes. + +## Settings + +Cabinet input settings are shared between editor defaults, runtime components, +and the player app: + +| Type | Responsibility | +| --- | --- | +| `CabinetInputSettings` | Native input enablement, polling interval, and nested nudge settings. | +| `CabinetNudgeSettings` | Keyboard mode/strength/damping, visual strength, player tilt-bob source mode, and sensor list. | +| `CabinetNudgeSensorSettings` | Serializable shape for one sensor slot. | +| `CabinetInputSettingsAsset` | Optional editor preset/default wrapper. | +| `TiltBobComponent` | Table-authored switch route plus simulated tilt-bob damping and threshold. | + +`CabinetInputSettings.ApplyTo(tableRoot)` finds the table's `PhysicsEngine` and +`SimulationThreadComponent` and applies the correct subset to each. The player +configuration uses the same `CabinetNudgeSettings` shape, so runtime UI and +editor/direct-play configuration do not need to maintain parallel models. + +Hardware mappings are still considered user/cabinet configuration. A table can +carry defaults for direct editor play or a known cabinet setup, but the built +player should normally write user-specific settings to player configuration +rather than modifying project assets during play. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml index f20b1fcd8..ed89a281b 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml @@ -4,6 +4,8 @@ href: setup.md - name: Threading Model href: threading-model.md +- name: Nudge System + href: nudge-system.md - name: Packaging items: - name: Overview diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob.meta new file mode 100644 index 000000000..27ef399e2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 78d4155d1b004d119ececd4fa5a4cefe +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs new file mode 100644 index 000000000..e935f15e3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs @@ -0,0 +1,52 @@ +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(TiltBobComponent)), CanEditMultipleObjects] + public class TiltBobInspector : ItemInspector + { + private SerializedProperty _plumbDampingProperty; + private SerializedProperty _plumbThresholdAngleProperty; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + + _plumbDampingProperty = serializedObject.FindProperty(nameof(TiltBobComponent.PlumbDamping)); + _plumbThresholdAngleProperty = serializedObject.FindProperty(nameof(TiltBobComponent.PlumbThresholdAngle)); + } + + public override void OnInspectorGUI() + { + BeginEditing(); + + OnPreInspectorGUI(); + + PropertyField(_plumbDampingProperty, "Plumb Damping"); + PropertyField(_plumbThresholdAngleProperty, "Plumb Threshold Angle"); + + base.OnInspectorGUI(); + + EndEditing(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs.meta new file mode 100644 index 000000000..d9d3b65b4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 72d5236734db419c8ff4c74afc040dc5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs index 7d506aafe..f1f3c5496 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs @@ -258,7 +258,7 @@ public sealed class CabinetPlumbSettings public void Normalize() { enabled = true; - mode = (TiltBobMode)Mathf.Clamp((int)mode, (int)TiltBobMode.Simulated, (int)TiltBobMode.Mapped); + mode = (TiltBobMode)Mathf.Clamp((int)mode, (int)TiltBobMode.Simulated, (int)TiltBobMode.Physical); damping = Mathf.Clamp(damping, 0f, 2f); thresholdDeg = Mathf.Clamp(thresholdDeg, 0.5f, 4f); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 7f7248bb4..0fd4bc8d1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -269,7 +269,7 @@ public ref readonly SimulationState.Snapshot GetSharedState() /// instead of directly to any /// input-action switch mapping. This is used for real cabinet plumb bobs. ///
- public bool DispatchMappedTiltInputToMainThread { get; set; } + public bool DispatchPhysicalTiltInputToMainThread { get; set; } private readonly Queue _mainThreadTiltStates = new(); private readonly object _mainThreadTiltLock = new(); @@ -522,7 +522,7 @@ private void ProcessInputEvents() InputLatencyTracker.RecordInputPolled((NativeInputApi.InputAction)actionIndex, isPressed, evt.TimestampUsec); if (actionIndex == (int)NativeInputApi.InputAction.Tilt - && DispatchMappedTiltInputToMainThread + && DispatchPhysicalTiltInputToMainThread && MainThreadTiltDispatcher != null) { QueueMainThreadTiltState(isPressed); _inputEventsProcessed++; @@ -902,7 +902,7 @@ private void UpdatePhysics() /// private void ProcessPlumbTiltEvents() { - if (MainThreadTiltDispatcher == null || DispatchMappedTiltInputToMainThread) { + if (MainThreadTiltDispatcher == null || DispatchPhysicalTiltInputToMainThread) { return; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 35459cd43..7cdde2bb0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -551,14 +551,14 @@ internal void ConfigureTiltBobRouting(TiltBobComponent tiltBob = null) } tiltBob ??= FindTiltBobComponent(); - if (tiltBob != null && (tiltBob.UsesSimulatedPlumb || tiltBob.UsesMappedInput)) { + if (tiltBob != null && (tiltBob.UsesSimulatedPlumb || tiltBob.UsesPhysicalTiltInput)) { _simulationThread.MainThreadTiltDispatcher = tiltBob.QueueTiltStateFromSimulationThread; - _simulationThread.DispatchMappedTiltInputToMainThread = tiltBob.UsesMappedInput; + _simulationThread.DispatchPhysicalTiltInputToMainThread = tiltBob.UsesPhysicalTiltInput; return; } _simulationThread.MainThreadTiltDispatcher = null; - _simulationThread.DispatchMappedTiltInputToMainThread = false; + _simulationThread.DispatchPhysicalTiltInputToMainThread = false; } private TiltBobComponent FindTiltBobComponent() diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs index 931c5ec37..b9aeb9dab 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs @@ -31,7 +31,7 @@ namespace VisualPinball.Unity /// The component is the table author's routing point: map the game logic's /// tilt switch to this device if the table should have a tilt bob. The /// player's cabinet settings decide whether the signal comes from the physics - /// plumb-bob simulation or from a real mapped cabinet switch. + /// plumb-bob simulation or from a real physical cabinet switch. /// [DisallowMultipleComponent] [PackAs("TiltBob")] @@ -77,7 +77,7 @@ public sealed class TiltBobComponent : MonoBehaviour, ISwitchDeviceComponent, IP public TiltBobMode Mode => _mode; public bool UsesSimulatedPlumb => _enabled && _mode == TiltBobMode.Simulated; - public bool UsesMappedInput => _enabled && _mode == TiltBobMode.Mapped; + public bool UsesPhysicalTiltInput => _enabled && _mode == TiltBobMode.Physical; public static TiltBobComponent FindFor(PhysicsEngine physicsEngine) { @@ -197,7 +197,7 @@ internal void NormalizeSettings() private void OnCabinetInputActionChanged(string actionName, bool isPressed) { - if (UsesMappedInput && actionName == InputConstants.ActionTilt) { + if (UsesPhysicalTiltInput && actionName == InputConstants.ActionTilt) { SetSwitch(isPressed); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs index 5cb692bce..2753c6e12 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs @@ -28,9 +28,9 @@ public enum TiltBobMode Simulated = 0, /// - /// Use the player's mapped cabinet tilt input, typically a real plumb bob + /// Use the player's physical cabinet tilt input, typically a real plumb bob /// wired into the cabinet controller. /// - Mapped = 1 + Physical = 1 } } From 680def46b98f9962fc0d4aefcb1e103a34eecfa4 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 17:47:04 +0200 Subject: [PATCH 25/25] editor: Don't pre-clamp raw value in mapped graph calculation. The raw value was clamped to [-1,1] before subtracting the center, which distorted the displayed direction for off-center inputs. Use the raw value directly so the graph reflects true input offset. --- .../Simulation/SimulationThreadComponentInspector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs index 59aba581f..7324acc25 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs @@ -376,7 +376,7 @@ private static void CollectRawGraphChannels(NativeInputDeviceInfo device, List private static float CalculateMappedGraphValue(SensorMapping mapping, float rawValue) { - var value = Mathf.Clamp(Mathf.Clamp(rawValue, -1f, 1f) - mapping.RawCenter, -1f, 1f); + var value = rawValue - mapping.RawCenter; var deadZone = Mathf.Clamp(mapping.DeadZone, 0f, 0.999f); var absValue = Mathf.Abs(value); if (absValue <= deadZone) {