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:
Erik 2026-05-05 19:01:07 +02:00
parent a36369d8ca
commit ec59a08db5
5 changed files with 179 additions and 3 deletions

View file

@ -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);
}
}