From a7141980fafa74e4398f46baad7c2c10da864f62 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 18 Sep 2026 10:15:11 +0200 Subject: [PATCH 1/2] kicker: log where created balls spawn and get captured A ball created in a trough exit kicker a hundred units below the playfield still left from playfield level in a standalone build, while most balls of the same session left from the kicker's height. Nothing in the code moves that kicker, and the trace cannot tell which of the two transforms-derived heights was wrong. Every created ball now logs its spawn position together with the kicker's local, playfield and world position, the capture logs where the ball ended up and which capture height the kicker state holds, and each kicker logs its capture height once at initialization. A kicker rotated by a rotator mech also keeps its height instead of dropping to playfield level, since the rotated position only carries x and y. --- .../VisualPinball.Unity/VPT/Kicker/KickerApi.cs | 8 ++++++++ .../VisualPinball.Unity/VPT/Kicker/KickerComponent.cs | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs index d17c4fac5..cad066b40 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs @@ -102,6 +102,12 @@ private void CreateCapturedBall(GameObject ballPrefab, float radius, float mass) var kickerId = ItemId; var kickerName = MainComponent.name; // resolved here, the callback runs on the simulation thread + // where the ball was spawned and what the kicker's transforms say right now. A created + // ball that leaves from the wrong height is otherwise impossible to diagnose afterwards. + var creation = MainComponent.GetBallCreationPosition(); + Logger.Info($"Kicker \"{kickerName}\": created ball {ballId} at ({creation.X:F1}, {creation.Y:F1}, {creation.Z:F1}), " + + $"kicker local {MainComponent.Position:F1}, in playfield {MainComponent.PositionInPlayfield:F1}, world {MainComponent.transform.position:F4}."); + PhysicsEngine.MutateState((ref PhysicsState state) => { if (!state.Balls.ContainsKey(ballId)) { Logger.Warn($"Kicker \"{kickerName}\": ball {ballId} was created but is unknown to the physics state, it cannot be captured."); @@ -130,6 +136,8 @@ private void CreateCapturedBall(GameObject ballPrefab, float radius, float mass) // kick launches whatever the kicker holds from wherever it is, so say so. if (!kickerState.Static.FallThrough && kickerState.Collision.BallId != ballId) { Logger.Warn($"Kicker \"{kickerName}\": created ball {ballId} was not captured, the kicker holds ball {kickerState.Collision.BallId}. The new ball is at {ball.Position}."); + } else { + Logger.Info($"Kicker \"{kickerName}\": ball {ballId} captured at {ball.Position}, kicker center {kickerState.Static.Center}, z {kickerState.Static.ZLow}."); } }); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs index 352604925..4f9facd3f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs @@ -25,6 +25,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using NLog; using Unity.Collections; using Unity.Mathematics; using UnityEngine; @@ -34,6 +35,7 @@ using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Kicker; using VisualPinball.Engine.VPT.Table; +using Logger = NLog.Logger; #if UNITY_EDITOR using UnityEditor; @@ -48,6 +50,8 @@ public class KickerComponent : MainRenderableComponent, ICoilDeviceComponent, ITriggerComponent, IBallCreationPosition, IRotatableComponent, ISerializationCallbackReceiver, IPackable { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + #region Data public Vector3 Position { @@ -167,7 +171,8 @@ public float RotateZ { public float2 RotatedPosition { get => new(Position.x, Position.y); set { - Position = new Vector2(value.x, value.y); + // keep the height: a kicker rotated by a mech must not drop to playfield level + Position = new Vector3(value.x, value.y, Position.z); UpdateTransforms(); } } @@ -307,6 +312,8 @@ internal KickerState CreateState() ? new ColliderMeshData(Array.Empty(), 0, float3.zero, Allocator.Persistent) : new ColliderMeshData(KickerHitMesh.Vertices, Radius, position, Allocator.Persistent); + Logger.Info($"Kicker \"{name}\": capture center ({position.x:F1}, {position.y:F1}), z {position.z:F1}, local {Position:F1}."); + return new KickerState( staticData, new KickerCollisionState(), From ab5a58252215ca286fd00733d6a58e88f95fb62a Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 18 Sep 2026 11:03:33 +0200 Subject: [PATCH 2/2] physics: never collide a ball that was not hit-tested A new ball's collision event started out as the struct default, which reads as a hit with collider 0 at time 0. Balls held in a kicker skip the hit tests, so that event was never cleared, but the collision phase ran for every ball, held or not. A ball created in a kicker and frozen there before its first physics step therefore collided with whatever collider 0 happened to be. On a table where that is the playfield floor, the ball was pushed from the trough exit, a hundred units below the playfield, up to playfield level with its x and y untouched, and the kick that followed a tick later launched it over the apron. Whether the kick arrived in the same tick as the capture decided if it happened. New balls now start with no pending collision, and the collision and contact phases leave frozen balls alone, as VP does: a locked ball carries no hit object, and every hit object is consumed after its collision. The contact case is VPE's own: a ball captured by a kicker during the collision phase still had its earlier contacts resolved, whose position recovery could move it out of the kicker. --- .../Physics/StaleCollisionEventTests.cs | 87 +++++++++++++++++++ .../Physics/StaleCollisionEventTests.cs.meta | 11 +++ .../VisualPinball.Unity/Game/PhysicsCycle.cs | 5 ++ .../Game/PhysicsDynamicCollision.cs | 5 ++ .../Game/PhysicsStaticCollision.cs | 5 ++ .../Physics/Collision/CollisionEventData.cs | 8 +- .../VPT/Ball/BallComponent.cs | 3 +- 7 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs new file mode 100644 index 000000000..4200cace7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs @@ -0,0 +1,87 @@ +// 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.Collections; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Unity.Test +{ + /// + /// A ball that is not hit-tested must not collide with anything. The default collision + /// event is a hit with collider 0 at time 0, and a ball held in a kicker skips the hit + /// tests, so a new ball that got captured before its first physics step collided with + /// whatever collider 0 was. On a table where that was the playfield floor, the ball was + /// pushed from the trough exit up to playfield level and kicked from there. + /// + public class StaleCollisionEventTests + { + [Test] + public void NewBallStartsWithoutPendingCollision() + { + var go = new GameObject("Ball"); + try { + var ball = go.AddComponent(); + var state = ball.CreateState(); + + Assert.That(state.CollisionEvent.HasCollider(), Is.False); + } finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void FrozenBallIsLeftAloneByTheCollisionPhase() + { + var nonTransformable = new NativeParallelHashMap(1, Allocator.Persistent); + var references = new ColliderReference(ref nonTransformable, Allocator.Persistent); + try { + // collider 0 is a floor at z = 0, the way a table's playfield plane is + references.Add(new PlaneCollider(new float3(0f, 0f, 1f), 0f, new ColliderInfo { ItemId = 1, ItemType = ItemType.Playfield })); + using var colliders = new NativeColliders(ref references, Allocator.Persistent); + using var balls = new NativeParallelHashMap(2, Allocator.Persistent); + var state = new PhysicsState { Colliders = colliders, Balls = balls }; + + // a ball held below the playfield, with the collision event a new ball comes with + var ball = new BallState { + Id = 7, Radius = 25f, Mass = 1f, IsFrozen = true, + Position = new float3(769f, 1868f, -83f), + }; + Assert.That(ball.CollisionEvent.ColliderId, Is.EqualTo(0), "the default event points at collider 0"); + + PhysicsStaticCollision.Collide(PhysicsConstants.PhysFactor, ref ball, ref state); + Assert.That(ball.Position.z, Is.EqualTo(-83f).Within(1e-4f), "a held ball stays where the kicker put it"); + + // a stale ball-ball event: another ball dropping onto the held one + var other = new BallState { Id = 8, Radius = 25f, Mass = 1f, Position = new float3(769f, 1868f, -33f), Velocity = new float3(0f, 0f, -10f) }; + balls.Add(other.Id, other); + ball.CollisionEvent.SetBallItem(other.Id); + ball.CollisionEvent.HitTime = 0f; + + PhysicsDynamicCollision.Collide(PhysicsConstants.PhysFactor, ref ball, ref state); + + Assert.That(math.length(ball.Velocity), Is.EqualTo(0f), "the held ball is not bounced"); + Assert.That(balls[other.Id].Velocity.z, Is.EqualTo(-10f).Within(1e-4f), "nor is the other ball"); + } finally { + references.Dispose(); + nonTransformable.Dispose(); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs.meta new file mode 100644 index 000000000..6422a5704 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: accfa0b6c35f4c588076dc1ccea95e5f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index 8d5c6ead7..f308c45ec 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -268,6 +268,11 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov continue; } ref var ball = ref state.Balls.GetValueByRef(contact.BallId); + if (ball.IsFrozen) { + // captured by a kicker in this very collision phase: the contact was found + // where the ball was before, its recovery would move it out of the kicker + continue; + } if (contact.CollEvent.IsKinematic) { ContactPhysics.Update(ref contact, ref ball, ref state, ref state.KinematicColliders, hitTime); } else { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicCollision.cs index 553e4fab6..afb29b06c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicCollision.cs @@ -25,6 +25,11 @@ internal static class PhysicsDynamicCollision internal static void Collide(float hitTime, ref BallState ball, ref PhysicsState state) { + // a frozen ball is not hit-tested, so whatever event it carries is not from this cycle + if (ball.IsFrozen) { + return; + } + // pick "other" ball ref var collEvent = ref ball.CollisionEvent; ref var otherId = ref collEvent.BallId; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index eece81766..04b0c7dc0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -26,6 +26,11 @@ internal static class PhysicsStaticCollision { internal static void Collide(float hitTime, ref BallState ball, ref PhysicsState state) { + // a frozen ball is not hit-tested, so whatever event it carries is not from this cycle + if (ball.IsFrozen) { + return; + } + // find balls with hit objects and minimum time if (ball.CollisionEvent.ColliderId < 0 || ball.CollisionEvent.HitTime > hitTime) { return; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs index 015af599b..4862248bb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs @@ -32,7 +32,13 @@ public struct CollisionEventData public int ColliderId; public bool IsKinematic; public int BallId; - + + /// + /// No pending collision. The default value of this struct is a hit with collider 0 at + /// time 0, so every new ball has to start out with this instead. + /// + public static CollisionEventData None => new() { ColliderId = -1 }; + public void SetCollider(int colliderId, bool isKinematic) { // it's either collider id (for static colliders) or ball id (for dynamic colliders) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs index 9ab722862..10ae91b07 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs @@ -48,7 +48,8 @@ internal BallState CreateState() BallOrientation = float3x3.identity, BallOrientationForUnity = float3x3.identity, RingCounterOldPos = 0, - AngularMomentum = float3.zero + AngularMomentum = float3.zero, + CollisionEvent = CollisionEventData.None }; }