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
};
}
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(),