fix(physics): #42 skip self in FindObjCollisions — airborne XY drift
Root cause confirmed via two-run diagnostic and the named-retail decomp:
the airborne sweep was colliding with the moving entity's OWN ShadowEntry
because FindObjCollisions had no self-skip filter. Live entities (local
player, remotes) register a Cylinder in ShadowObjectRegistry on spawn
(GameWindow.cs:2545) and UpdatePosition tracks its world position each
tick, so the moving sphere's own cylinder is always at the body's
position. Without a gate, CylinderCollision sees the sphere overlapping
its own cylinder volume and slides the sphere ~1m horizontally on every
frame the path produces non-zero motion.
Why grounded mostly hides it and airborne exposes it:
- Stationary grounded → numSteps=0, TransitionalInsert never runs.
- Walking grounded → push fires but motion escapes the cyl radius and
the deflection blends into normal motion.
- Stationary airborne (jump) → pure +Z motion; the cyl push is the
only horizontal contribution and manifests as a clean ~1m drift.
Run-2 evidence (launch-42-r2.log) — 152 [SWEEP-OBJ] events, every one
with type=Cylinder, gfxObj=0x02000001 (humanoid setup), R=0.679,
H=1.835, at obj.Position EXACTLY matching the body's pre.Position. Run
1 had already ruled out H1 (cpN=(0,0,1) flat, no slope projection).
Retail does the same skip — CObjCell::find_obj_collisions at
named-retail acclient_2013_pseudo_c.txt:308931:
if ((physobj->parent == 0 && physobj != arg2->object_info.object))
`arg2->object_info.object` is the OBJECTINFO::object self-pointer set
by OBJECTINFO::init at acclient_2013_pseudo_c.txt:274435. Our port
mirrors this with an EntityId-based filter:
- ObjectInfo gains a SelfEntityId field (default 0 = no filter).
- ResolveWithTransition gains an optional `uint movingEntityId = 0`
parameter that sets it.
- FindObjCollisions skips entries whose EntityId matches
SelfEntityId when the id is non-zero.
- PlayerMovementController gains a LocalEntityId property; GameWindow
refreshes it per-tick from `_entitiesByServerGuid[_playerServerGuid]`.
- GameWindow's airborne-remote ResolveWithTransition call site passes
`movingEntityId: kv.Key` (kv.Key is the local entity id keying
`_animatedEntities`, same id used at the spawn-time
ShadowObjects.Register).
Default 0 keeps tests and one-shot callers (no registered ShadowEntry)
working unchanged.
Lock-the-fix unit test:
`PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches`
registers a humanoid Cylinder at the body's exact position (matching
GameWindow's spawn pattern), then asserts that:
- movingEntityId=0 (control) → unfiltered XY drift > 0.5m
- movingEntityId=registered id (fix) → XY drift ≈ 0
Diagnostic wiring (a36369d + this commit's [SWEEP-OBJ] addition) stays
in tree, env-var gated (ACDREAM_AIRBORNE_DIAG=1) so it produces no
output in normal use but lets us verify the fix on the live client and
debug future regressions.
Build: green. Tests: 355 pass, 6 fail (all pre-existing per the handoff
prompt — verified by stashing this change; the BSPStepUp C3 failure is
on the prior commit too).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a36369d8ca
commit
ec59a08db5
5 changed files with 179 additions and 3 deletions
|
|
@ -130,6 +130,17 @@ public sealed class PlayerMovementController
|
|||
public Vector3 Position => _body.Position;
|
||||
public uint CellId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Local-player entity id used to skip self-collision in the
|
||||
/// airborne sweep. GameWindow updates this whenever the local
|
||||
/// `+Acdream` entity (re)spawns. Default 0 = no filter (matches
|
||||
/// retail's CObjCell::find_obj_collisions self-skip when the
|
||||
/// caller's OBJECTINFO::object pointer is null). Without this the
|
||||
/// sweep collides with its own ShadowEntry registered at
|
||||
/// GameWindow.cs:2545 — see #42.
|
||||
/// </summary>
|
||||
public uint LocalEntityId { get; set; }
|
||||
|
||||
public bool IsAirborne => !_body.OnWalkable;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -558,7 +569,12 @@ public sealed class PlayerMovementController
|
|||
// through other non-PK players, which is retail's default for
|
||||
// ACE's character creation defaults too).
|
||||
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||
// Fix #42: skip self in FindObjCollisions. Wired by GameWindow
|
||||
// when the local player entity spawns (or stays 0 in tests, in
|
||||
// which case there's no registered ShadowEntry to collide with
|
||||
// anyway).
|
||||
movingEntityId: LocalEntityId);
|
||||
|
||||
// L.4-diag (2026-04-30): trace position transitions so we can see
|
||||
// whether the body is actually moving frame-to-frame on the steep
|
||||
|
|
|
|||
|
|
@ -5264,6 +5264,18 @@ public sealed class GameWindow : IDisposable
|
|||
MouseDeltaX: 0f,
|
||||
Jump: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump));
|
||||
|
||||
// Fix #42 (2026-05-05): keep PlayerMovementController's
|
||||
// LocalEntityId in sync with the live local player entity so
|
||||
// FindObjCollisions skips its own ShadowEntry. Re-fetched per
|
||||
// tick so re-spawns / character switches don't leave a stale
|
||||
// id on the controller. Pre-spawn or between-character it
|
||||
// stays 0 (no filter), which is harmless because there's no
|
||||
// ShadowEntry registered yet.
|
||||
_playerController.LocalEntityId =
|
||||
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var localEnt)
|
||||
? localEnt.Id
|
||||
: 0u;
|
||||
|
||||
var result = _playerController.Update((float)dt, input);
|
||||
|
||||
// Update the player entity's position + rotation so it renders at
|
||||
|
|
@ -6487,7 +6499,16 @@ public sealed class GameWindow : IDisposable
|
|||
// Retail default physics state includes EdgeSlide.
|
||||
// Remote dead-reckoning should exercise the same
|
||||
// edge/cliff branch as local movement.
|
||||
moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
|
||||
moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||
// Fix #42 (2026-05-05): skip the moving remote's
|
||||
// own ShadowEntry. _animatedEntities is keyed by
|
||||
// entity.Id so kv.Key matches the EntityId the
|
||||
// ShadowObjectRegistry has for this remote.
|
||||
// Without this, the airborne sweep collides with
|
||||
// the remote's own cylinder and produces ~1m of
|
||||
// horizontal drift on the first jump frame
|
||||
// (validated by [SWEEP-OBJ] traces).
|
||||
movingEntityId: kv.Key);
|
||||
|
||||
rm.Body.Position = resolveResult.Position;
|
||||
if (resolveResult.CellId != 0)
|
||||
|
|
|
|||
|
|
@ -473,12 +473,22 @@ public sealed class PhysicsEngine
|
|||
float stepUpHeight, float stepDownHeight,
|
||||
bool isOnGround,
|
||||
PhysicsBody? body = null,
|
||||
ObjectInfoState moverFlags = ObjectInfoState.None)
|
||||
ObjectInfoState moverFlags = ObjectInfoState.None,
|
||||
uint movingEntityId = 0)
|
||||
{
|
||||
var transition = new Transition();
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
transition.ObjectInfo.StepDown = true;
|
||||
// Fix #42 (2026-05-05): the moving entity's ShadowEntry must be
|
||||
// skipped in FindObjCollisions or the sweep collides with self.
|
||||
// Default 0 keeps tests / one-shot callers (no registered entity)
|
||||
// working. Plumbed through ObjectInfo because retail stores the
|
||||
// self pointer on OBJECTINFO::object (named-retail
|
||||
// acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init →
|
||||
// this->object = arg2). The skip itself is at
|
||||
// CObjCell::find_obj_collisions line 308931.
|
||||
transition.ObjectInfo.SelfEntityId = movingEntityId;
|
||||
|
||||
// Commit C 2026-04-29 — caller-supplied mover flags drive the
|
||||
// retail PvP exemption block in FindObjCollisions. The local
|
||||
|
|
|
|||
|
|
@ -56,6 +56,19 @@ public sealed class ObjectInfo
|
|||
public bool StepDown = true;
|
||||
public float Scale = 1.0f;
|
||||
|
||||
/// <summary>
|
||||
/// EntityId of the moving entity, used by <c>FindObjCollisions</c> to
|
||||
/// skip the moving object's own ShadowEntry. Mirrors retail
|
||||
/// <c>CObjCell::find_obj_collisions</c> at named-retail line 308931
|
||||
/// (<c>physobj != arg2->object_info.object</c>) — the self pointer
|
||||
/// stored on <c>OBJECTINFO::object</c>. Default 0 = no filter (tests
|
||||
/// and one-shot callers that don't have a registered entity).
|
||||
/// Without this gate the local player and airborne remotes collide
|
||||
/// with their own registered cylinder in <c>ShadowObjectRegistry</c>,
|
||||
/// producing the ~1m horizontal push-out documented in #42.
|
||||
/// </summary>
|
||||
public uint SelfEntityId;
|
||||
|
||||
// Convenience flag checks
|
||||
public bool Contact => State.HasFlag(ObjectInfoState.Contact);
|
||||
public bool OnWalkable => State.HasFlag(ObjectInfoState.OnWalkable);
|
||||
|
|
@ -1375,6 +1388,19 @@ public sealed class Transition
|
|||
if (engine.DataCache is null) return TransitionState.OK;
|
||||
|
||||
var sp = SpherePath;
|
||||
var oi = ObjectInfo;
|
||||
|
||||
// #42 diagnostic (2026-05-05): identify which static object causes
|
||||
// the airborne first-frame ~1m push. Capture sphere check pos at
|
||||
// entry; on a non-OK return, we'll log the (object, delta) pair
|
||||
// gated on ACDREAM_AIRBORNE_DIAG=1 + airborne. The first evidence
|
||||
// run ruled out H1 (slope-driven AdjustOffset projection); cpN was
|
||||
// (0,0,1) flat for every drift event, so the horizontal push must
|
||||
// come from CylinderCollision or BSPQuery.FindCollisions inside
|
||||
// this function. Logging the object identity tells us which one.
|
||||
bool airborneDiag = !oi.Contact
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1";
|
||||
Vector3 sphereCheckBefore = sp.CheckPos;
|
||||
|
||||
Vector3 checkPos = sp.GlobalSphere[0].Origin;
|
||||
Vector3 currPos = sp.GlobalCurrCenter[0].Origin;
|
||||
|
|
@ -1397,6 +1423,21 @@ public sealed class Transition
|
|||
|
||||
foreach (var obj in nearbyObjs)
|
||||
{
|
||||
// Self-skip — fix #42 (2026-05-05). Mirrors retail
|
||||
// CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt
|
||||
// 308931: `physobj != arg2->object_info.object` rejects the
|
||||
// moving entity's own shadow entry. Live entities (creatures,
|
||||
// players) register a Cylinder in ShadowObjectRegistry at
|
||||
// GameWindow.cs:2545 which UpdatePosition tracks live, so
|
||||
// without this skip the moving sphere collides with itself
|
||||
// every frame the path produces non-zero motion. The grounded
|
||||
// case mostly hides behind motion that escapes the radius;
|
||||
// airborne stationary jumps expose it as a one-shot ~1m
|
||||
// horizontal push (validated by [SWEEP-OBJ] traces with
|
||||
// gfxObj=0x02000001 at exactly the entity's own position).
|
||||
if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId)
|
||||
continue;
|
||||
|
||||
// Broad-phase: can the moving sphere reach this object?
|
||||
Vector3 deltaToCurr = currPos - obj.Position;
|
||||
float distToCurr;
|
||||
|
|
@ -1483,8 +1524,20 @@ public sealed class Transition
|
|||
}
|
||||
|
||||
if (result != TransitionState.OK)
|
||||
{
|
||||
if (airborneDiag)
|
||||
{
|
||||
var sphereCheckAfter = sp.CheckPos;
|
||||
var d = sphereCheckAfter - sphereCheckBefore;
|
||||
Console.WriteLine(
|
||||
$"[SWEEP-OBJ] type={obj.CollisionType} gfxObj=0x{obj.GfxObjId:X8} " +
|
||||
$"objPos=({obj.Position.X:F3},{obj.Position.Y:F3},{obj.Position.Z:F3}) " +
|
||||
$"objR={obj.Radius:F3} cylH={obj.CylHeight:F3} " +
|
||||
$"state={result} pushDelta=({d.X:F3},{d.Y:F3},{d.Z:F3})");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -377,4 +377,80 @@ public class PhysicsEngineTests
|
|||
|
||||
Assert.False(result.IsOnGround);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #42 lock — when the moving entity's own ShadowEntry is registered
|
||||
/// in <see cref="ShadowObjectRegistry"/> at the body's exact position
|
||||
/// (the production pattern from <c>GameWindow.cs:2545</c> spawn → register
|
||||
/// + <c>UpdatePosition</c> live tracking), the airborne sweep MUST skip
|
||||
/// it. Without the gate, <c>FindObjCollisions</c> sees the cylinder as
|
||||
/// a foreign collidable and slides the sphere ~1m horizontally on the
|
||||
/// first non-zero-motion frame — the bug observed by the [SWEEP-OBJ]
|
||||
/// trace and reported as the post-jump XY drift in #42.
|
||||
/// <para>
|
||||
/// Mirrors retail's self-skip at <c>CObjCell::find_obj_collisions</c>
|
||||
/// (named-retail <c>acclient_2013_pseudo_c.txt:308931</c>):
|
||||
/// <c>physobj != arg2->object_info.object</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches()
|
||||
{
|
||||
var engine = MakeFlatEngine(terrainZ: 50f);
|
||||
// FindObjCollisions early-returns when DataCache is null. An empty
|
||||
// cache is enough for cylinder objects; only BSP objects look up
|
||||
// entries inside.
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
|
||||
const uint movingEntityId = 0xDEADBEEFu;
|
||||
var bodyPos = new Vector3(96f, 96f, 50f);
|
||||
var targetPos = bodyPos + new Vector3(0f, 0f, 0.022f); // stationary +Z
|
||||
|
||||
// Register the moving entity's own ShadowEntry — humanoid Cylinder
|
||||
// sized to match the live-spawn registration in production
|
||||
// (GameWindow.cs:2545). The gfxObj id 0x02000001 is the standard
|
||||
// human setup; radius/height match the [SWEEP-OBJ] trace observed
|
||||
// during run #2 of the #42 investigation.
|
||||
engine.ShadowObjects.Register(
|
||||
entityId: movingEntityId,
|
||||
gfxObjId: 0x02000001u,
|
||||
worldPos: bodyPos,
|
||||
rotation: Quaternion.Identity,
|
||||
radius: 0.679f,
|
||||
worldOffsetX: 0f, worldOffsetY: 0f,
|
||||
landblockId: 0xA9B4FFFFu,
|
||||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: 1.835f);
|
||||
|
||||
// Without the gate (movingEntityId == 0): the sweep must self-push.
|
||||
// This proves the registry actually causes a collision, so the
|
||||
// following filtered case is not a vacuous pass.
|
||||
var unfiltered = engine.ResolveWithTransition(
|
||||
currentPos: bodyPos, targetPos: targetPos,
|
||||
cellId: 0xA9B40039u,
|
||||
sphereRadius: 0.48f, sphereHeight: 1.2f,
|
||||
stepUpHeight: 0.4f, stepDownHeight: 0.4f,
|
||||
isOnGround: false,
|
||||
movingEntityId: 0u);
|
||||
|
||||
float unfilteredXY = MathF.Sqrt(
|
||||
(unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) +
|
||||
(unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y));
|
||||
Assert.True(unfilteredXY > 0.5f,
|
||||
$"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)");
|
||||
|
||||
// With the gate: the sweep must leave XY unchanged.
|
||||
var filtered = engine.ResolveWithTransition(
|
||||
currentPos: bodyPos, targetPos: targetPos,
|
||||
cellId: 0xA9B40039u,
|
||||
sphereRadius: 0.48f, sphereHeight: 1.2f,
|
||||
stepUpHeight: 0.4f, stepDownHeight: 0.4f,
|
||||
isOnGround: false,
|
||||
movingEntityId: movingEntityId);
|
||||
|
||||
float filteredXY = MathF.Sqrt(
|
||||
(filtered.Position.X - targetPos.X) * (filtered.Position.X - targetPos.X) +
|
||||
(filtered.Position.Y - targetPos.Y) * (filtered.Position.Y - targetPos.Y));
|
||||
Assert.InRange(filteredXY, 0f, 0.001f);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue