Compare commits

..

No commits in common. "5f2e2e28ff9bfd8e945cba0263cb07f6dec10f61" and "ce638eb56f0e66c68a153d9c14712b6b55f40388" have entirely different histories.

8 changed files with 9 additions and 433 deletions

View file

@ -124,52 +124,12 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g.
- No spurious cycle thrashing during turning while running (ObservedOmega - No spurious cycle thrashing during turning while running (ObservedOmega
doesn't trigger velocity-bucket changes). doesn't trigger velocity-bucket changes).
## #42 [DONE 2026-05-05 · ec59a08] Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc) ## #42 — Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc)
**Status:** DONE **Status:** OPEN
**Severity:** MEDIUM (pre-existing PhysicsEngine bug; exposed by L.3 M2 airborne UP no-op + M4 CellId fix) **Severity:** MEDIUM (pre-existing PhysicsEngine bug; exposed by L.3 M2 airborne UP no-op + M4 CellId fix)
**Filed:** 2026-05-05 (root cause confirmed same day) **Filed:** 2026-05-05 (root cause confirmed same day)
**Closed:** 2026-05-05 **Component:** physics (`PhysicsEngine.ResolveWithTransition` airborne behavior)
**Commit:** `ec59a08`
**Component:** physics (`PhysicsEngine.ResolveWithTransition``FindObjCollisions` self-skip)
**Resolution (2026-05-05):** Self-collision in `FindObjCollisions`, not
any of the three originally-hypothesised mechanisms below. Live
entities (local player, remotes) register a Cylinder in
`ShadowObjectRegistry` at spawn (`GameWindow.cs:2545`) which
`UpdatePosition` keeps tracking the entity's live world position.
With no self-skip filter, the moving sphere's own cylinder is always
sitting at the body's exact position and `CylinderCollision` slides
the sphere out of overlap on every airborne tick. Validated by the
[SWEEP-OBJ] diagnostic added in commit `a36369d`: every drift event
showed `gfxObj=0x02000001` (humanoid setup) at `obj.Position` exactly
matching the body's `pre`. Mirrors retail's `CObjCell::find_obj_collisions`
self-skip at named-retail line 308931:
```c
if ((physobj->parent == 0 && physobj != arg2->object_info.object))
result = CPhysicsObj::FindObjCollisions(physobj, arg2);
```
Plumbing: `ObjectInfo.SelfEntityId` field, optional
`movingEntityId = 0` parameter on `ResolveWithTransition`,
`PlayerMovementController.LocalEntityId` refreshed per-tick from
`_entitiesByServerGuid[_playerServerGuid].Id`, remote sweep at
`GameWindow.cs:6474` passes `kv.Key`. Lock-the-fix unit test at
`PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches`.
Verified via two visual + log runs (`launch-42-verify.log` /
`launch-42-verify2.log`): zero stationary-jump drift across both,
`gfxObj=0x02000001` phantom no longer appears in `[SWEEP-OBJ]`,
no >0.5m pushes anywhere. The originally-listed hypotheses (H1
slope-driven AdjustOffset projection, H2 step-down probe, H3
EdgeSlide) were all RULED OUT by the first evidence run — `cpN`
was `(0, 0, 1)` flat for every drift event.
**Diagnostic kept in tree:** `ACDREAM_AIRBORNE_DIAG=1` enables the
`[SWEEP]` + `[SWEEP-OBJ]` traces for future regression hunts.
The original investigation log is preserved below for context.
**Root cause (verified 2026-05-05 via A/B test):** **Root cause (verified 2026-05-05 via A/B test):**
@ -1228,53 +1188,6 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
# Recently closed # Recently closed
## #43 — [DONE 2026-05-05 · 9e4772a] Slope staircase on observed player remotes (anim-only fallback ignored slope)
**Closed:** 2026-05-05
**Commit:** `9e4772a`
**Component:** motion (`PositionManager.ComputeOffset` queue-empty fallback)
**Resolution:** Grounded player remotes showed a ~5 Hz Z staircase when
running up/down hills. `PositionManager.ComputeOffset` has two modes:
queue-active (3D direction toward server's broadcast position, Z
follows naturally) and queue-empty / head-reached (`seqVel × dt`
rotated into world). Every locomotion cycle bakes Z=0 in body-local,
so the world result has Z=0 too. With server UPs at ~5 Hz and
catchUpSpeed = 2× maxSpeed, body chases each waypoint in ~100ms (Z
ramps), then sits in seqVel-only mode for ~100ms (Z flat) until the
next UP. Visible 5 Hz staircase.
Fix mirrors retail's `CTransition::adjust_offset` contact-plane
projection (named-retail acclient_2013_pseudo_c.txt:272296-272346),
applied at the queue-empty boundary instead of inside the sweep.
`ComputeOffset` gains an optional `Vector3? terrainNormal`; when
the seqVel fallback runs and the supplied normal is non-trivial,
`rootMotionWorld -= N × dot(rootMotionWorld, N)`. XY motion gains a
Z component proportional to slope × forward speed; body Z follows the
terrain mesh between UPs. No-op on flat ground (N ≈ +Z, dot ≈ 0) so
no regression to L.3 M2's flat-ground verification.
`GameWindow.TickAnimations` grounded-remote path samples
`PhysicsEngine.SampleTerrainNormal` (a thin public wrapper over the
existing internal `SampleTerrainWalkable`) at the body's current XY
each tick and passes it to `ComputeOffset`.
Two unit tests in `PositionManagerTests`: 30° east-tilted slope
(asserts `(3.0, 0, 1.732)` for 4 m/s east motion over 1s — body
descends along slope) + flat-ground no-op (asserts unchanged
behaviour with `N = +Z`).
Verified via `launch-slope-verify.log` over a 34m vertical traversal:
9,193 queue-empty-with-non-zero-offset.Z ticks on slopes (the path
that previously stair-cased), 26,497 sloped-normal ticks total, zero
#42 regressions.
**Diagnostic kept in tree:** `ACDREAM_SLOPE_DIAG=1` enables the
`[SLOPE]` per-tick trace (`bodyZ` before/after, offset, queue active,
sampled `cpN.Z`) for future regression hunts.
---
## #31 — [DONE 2026-04-29] Low outdoor cell id can go stale after transition movement ## #31 — [DONE 2026-04-29] Low outdoor cell id can go stale after transition movement
**Closed:** 2026-04-29 **Closed:** 2026-04-29

View file

@ -130,17 +130,6 @@ public sealed class PlayerMovementController
public Vector3 Position => _body.Position; public Vector3 Position => _body.Position;
public uint CellId { get; private set; } 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; public bool IsAirborne => !_body.OnWalkable;
/// <summary> /// <summary>
@ -569,12 +558,7 @@ public sealed class PlayerMovementController
// through other non-PK players, which is retail's default for // through other non-PK players, which is retail's default for
// ACE's character creation defaults too). // ACE's character creation defaults too).
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer 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 // 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 // whether the body is actually moving frame-to-frame on the steep

View file

@ -5264,18 +5264,6 @@ public sealed class GameWindow : IDisposable
MouseDeltaX: 0f, MouseDeltaX: 0f,
Jump: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump)); 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); var result = _playerController.Update((float)dt, input);
// Update the player entity's position + rotation so it renders at // Update the player entity's position + rotation so it renders at
@ -6149,42 +6137,15 @@ public sealed class GameWindow : IDisposable
// velocity, keeping legs and body pace synchronized. // velocity, keeping legs and body pace synchronized.
// - Blip-to-tail (tail body) when fail_count > 3. // - Blip-to-tail (tail body) when fail_count > 3.
float maxSpeed = rm.Motion.GetMaxSpeed(); float maxSpeed = rm.Motion.GetMaxSpeed();
// Slope-staircase fix (2026-05-05): sample terrain normal
// at the body's current XY so PositionManager can project
// the seqVel-only fallback onto the local slope. Without
// this, the queue-empty interval between UPs left Z flat
// (anim cycles bake Z=0 body-local) — visible ~5 Hz
// staircase when a remote runs up/down hills. The
// projection is a no-op on flat ground.
System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
rm.Body.Position.X, rm.Body.Position.Y);
System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
System.Numerics.Vector3 offset = rm.Position.ComputeOffset( System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt, dt: (double)dt,
currentBodyPosition: rm.Body.Position, currentBodyPosition: rm.Body.Position,
seqVel: seqVel, seqVel: seqVel,
ori: rm.Body.Orientation, ori: rm.Body.Orientation,
interp: rm.Interp, interp: rm.Interp,
maxSpeed: maxSpeed, maxSpeed: maxSpeed);
terrainNormal: terrainNormal);
rm.Body.Position += offset; rm.Body.Position += offset;
// Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
// Prints per-tick body Z trajectory + queue state + projected
// offset.Z so we can grep before/after the fix and confirm Z
// changes continuously between UPs on slopes (no flat
// intervals followed by snaps).
if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
{
bool queueActive = rm.Interp.IsActive;
float nz = terrainNormal?.Z ?? 1.0f;
System.Console.WriteLine(
$"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
+ $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
+ $"queue={queueActive} cpN.Z={nz:F3}");
}
// Step 2.5: angular velocity → body orientation. Prefer // Step 2.5: angular velocity → body orientation. Prefer
// ObservedOmega (set explicitly in OnLiveMotionUpdated from // ObservedOmega (set explicitly in OnLiveMotionUpdated from
// the wire's TurnCommand + signed TurnSpeed) over the // the wire's TurnCommand + signed TurnSpeed) over the
@ -6526,16 +6487,7 @@ public sealed class GameWindow : IDisposable
// Retail default physics state includes EdgeSlide. // Retail default physics state includes EdgeSlide.
// Remote dead-reckoning should exercise the same // Remote dead-reckoning should exercise the same
// edge/cliff branch as local movement. // 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; rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0) if (resolveResult.CellId != 0)

View file

@ -169,21 +169,6 @@ public sealed class PhysicsEngine
return null; return null;
} }
/// <summary>
/// Public surface for callers that only need the local terrain plane
/// normal at a world-space XY (e.g., the grounded-remote tick path
/// projecting anim root motion onto the slope to avoid the staircase
/// between server position updates). Returns null when no registered
/// landblock covers the point. Mirrors the plane component of
/// <see cref="SampleTerrainWalkable"/> without exposing the internal
/// <c>TerrainWalkableSample</c> shape.
/// </summary>
public Vector3? SampleTerrainNormal(float worldX, float worldY)
{
var sample = SampleTerrainWalkable(worldX, worldY);
return sample?.Plane.Normal;
}
/// <summary> /// <summary>
/// Sample the outdoor terrain walkable triangle at the given world-space /// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/> /// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
@ -488,22 +473,12 @@ public sealed class PhysicsEngine
float stepUpHeight, float stepDownHeight, float stepUpHeight, float stepDownHeight,
bool isOnGround, bool isOnGround,
PhysicsBody? body = null, PhysicsBody? body = null,
ObjectInfoState moverFlags = ObjectInfoState.None, ObjectInfoState moverFlags = ObjectInfoState.None)
uint movingEntityId = 0)
{ {
var transition = new Transition(); var transition = new Transition();
transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true; 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 // Commit C 2026-04-29 — caller-supplied mover flags drive the
// retail PvP exemption block in FindObjCollisions. The local // retail PvP exemption block in FindObjCollisions. The local
@ -650,33 +625,6 @@ public sealed class PhysicsEngine
bool collisionNormalValid = ci.CollisionNormalValid; bool collisionNormalValid = ci.CollisionNormalValid;
Vector3 collisionNormal = ci.CollisionNormal; Vector3 collisionNormal = ci.CollisionNormal;
// #42 diagnostic (2026-05-05): trace airborne sweeps to identify the
// source of the ~1m XY drift on retail-observed stationary jumps.
// Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per
// resolve call. deltaXY = post - target tells us how much the sweep
// diverged from the requested target; for a clean stationary +Z
// jump we expect (0,0). cp=valid with a tilted normal would confirm
// H1 (initial-overlap depenetration → next-step AdjustOffset projects
// the +Z offset along a non-+Z normal). User repros at flat plaza /
// east hillside / north hillside; if drift direction tracks terrain
// orientation, H1 is the cause; if it tracks actor facing, H2 / H3.
if (!isOnGround
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1")
{
var post = sp.CheckPos;
float dx = post.X - targetPos.X;
float dy = post.Y - targetPos.Y;
string cpInfo = ci.ContactPlaneValid
? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})"
: "none";
Console.WriteLine(
$"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " +
$"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " +
$"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " +
$"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " +
$"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
}
if (ok) if (ok)
{ {
bool onGround = ci.ContactPlaneValid bool onGround = ci.ContactPlaneValid

View file

@ -34,28 +34,13 @@ public sealed class PositionManager
/// <param name="ori">Body orientation; used to rotate seqVel from body-local to world.</param> /// <param name="ori">Body orientation; used to rotate seqVel from body-local to world.</param>
/// <param name="interp">The remote's InterpolationManager (for AdjustOffset call).</param> /// <param name="interp">The remote's InterpolationManager (for AdjustOffset call).</param>
/// <param name="maxSpeed">From <c>MotionInterpreter.GetMaxSpeed()</c> — passed to AdjustOffset for the catch-up clamp.</param> /// <param name="maxSpeed">From <c>MotionInterpreter.GetMaxSpeed()</c> — passed to AdjustOffset for the catch-up clamp.</param>
/// <param name="terrainNormal">
/// Optional local terrain plane normal at the body's current XY. When
/// supplied AND the queue-empty / head-reached fallback path runs, the
/// world-space anim root motion is projected onto the plane so XY motion
/// produces a corresponding Z change on slopes. Without this, the
/// fallback advances XY at the locomotion cycle's pace but leaves Z at
/// the last UP's reported Z — visible as a ~5 Hz staircase on slopes
/// (the rate of server UpdatePositions). Mirrors retail's
/// <c>CTransition::adjust_offset</c> contact-plane projection
/// (named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
/// motion, applied here at the queue-empty boundary instead of inside
/// the sweep. Pass <c>null</c> on flat ground / when no terrain sample
/// is available — projection is a no-op when normal == +Z.
/// </param>
public Vector3 ComputeOffset( public Vector3 ComputeOffset(
double dt, double dt,
Vector3 currentBodyPosition, Vector3 currentBodyPosition,
Vector3 seqVel, Vector3 seqVel,
Quaternion ori, Quaternion ori,
InterpolationManager interp, InterpolationManager interp,
float maxSpeed, float maxSpeed)
Vector3? terrainNormal = null)
{ {
// Retail-faithful per-frame combiner. Mirrors // Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) + // CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
@ -86,23 +71,6 @@ public sealed class PositionManager
return correction; return correction;
Vector3 rootMotionLocal = seqVel * (float)dt; Vector3 rootMotionLocal = seqVel * (float)dt;
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori); return Vector3.Transform(rootMotionLocal, ori);
// Slope projection (queue-empty fallback only). Locomotion cycles
// bake Z=0 in body-local, so without projection the body's Z stays
// at the last UP's reported value while XY advances at the running
// pace — visible ~5 Hz staircase between UPs on hills. Projecting
// the world-space anim motion onto the local terrain plane gives
// it a Z component proportional to slope × forward speed, so the
// body follows the terrain mesh smoothly. No-op on flat ground
// (normal ≈ +Z, dot ≈ 0) so it can't regress the M2 flat-ground
// verification.
if (terrainNormal.HasValue && terrainNormal.Value.Z > 0.01f)
{
Vector3 N = terrainNormal.Value;
float into = Vector3.Dot(rootMotionWorld, N);
rootMotionWorld -= N * into;
}
return rootMotionWorld;
} }
} }

View file

@ -56,19 +56,6 @@ public sealed class ObjectInfo
public bool StepDown = true; public bool StepDown = true;
public float Scale = 1.0f; 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 // Convenience flag checks
public bool Contact => State.HasFlag(ObjectInfoState.Contact); public bool Contact => State.HasFlag(ObjectInfoState.Contact);
public bool OnWalkable => State.HasFlag(ObjectInfoState.OnWalkable); public bool OnWalkable => State.HasFlag(ObjectInfoState.OnWalkable);
@ -1388,19 +1375,6 @@ public sealed class Transition
if (engine.DataCache is null) return TransitionState.OK; if (engine.DataCache is null) return TransitionState.OK;
var sp = SpherePath; 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 checkPos = sp.GlobalSphere[0].Origin;
Vector3 currPos = sp.GlobalCurrCenter[0].Origin; Vector3 currPos = sp.GlobalCurrCenter[0].Origin;
@ -1423,21 +1397,6 @@ public sealed class Transition
foreach (var obj in nearbyObjs) 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? // Broad-phase: can the moving sphere reach this object?
Vector3 deltaToCurr = currPos - obj.Position; Vector3 deltaToCurr = currPos - obj.Position;
float distToCurr; float distToCurr;
@ -1524,19 +1483,7 @@ public sealed class Transition
} }
if (result != TransitionState.OK) 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 result;
}
} }
return TransitionState.OK; return TransitionState.OK;

View file

@ -377,80 +377,4 @@ public class PhysicsEngineTests
Assert.False(result.IsOnGround); 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);
}
} }

View file

@ -176,64 +176,4 @@ public sealed class PositionManagerTests
Assert.Equal(0f, offset.Y, precision: 4); Assert.Equal(0f, offset.Y, precision: 4);
Assert.Equal(0f, offset.Z, precision: 4); Assert.Equal(0f, offset.Z, precision: 4);
} }
// =========================================================================
// Test 7: slope projection — anim root motion gains Z proportional to slope
//
// Lock-the-fix for the "remote running on a slope shows ~5 Hz Z staircase"
// bug: the queue-empty fallback was returning a flat (Z=0) world motion
// because animation cycles bake Z=0 in body-local. Projecting onto the
// local terrain plane gives the motion a Z component matching slope angle
// × forward speed.
// =========================================================================
[Fact]
public void ComputeOffset_SeqVelFallback_SlopedTerrainNormal_ProjectsZOntoSlope()
{
var pm = Make();
var interp = EmptyInterp(); // queue empty → fallback path runs
// Slope tilted 30° eastward (+X is downhill). Plane normal points
// up-and-east-of-vertical: (sin 30°, 0, cos 30°) = (0.5, 0, 0.866).
Vector3 N = Vector3.Normalize(new Vector3(0.5f, 0f, MathF.Sqrt(3f) / 2f));
// Body running due east at 4 m/s, dt = 1s → rootMotionWorld initially
// (4, 0, 0). After projection onto the plane:
// into = dot((4,0,0), (0.5,0,0.866)) = 2.0
// result = (4,0,0) - (0.5,0,0.866) * 2.0 = (3.0, 0, -1.732)
// i.e. body moves east AND descends ~1.73m for the second.
Vector3 offset = pm.ComputeOffset(
dt: 1.0,
currentBodyPosition: Vector3.Zero,
seqVel: new Vector3(4f, 0f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 0f,
terrainNormal: N);
Assert.Equal( 3.000f, offset.X, precision: 3);
Assert.Equal( 0.000f, offset.Y, precision: 3);
Assert.Equal(-1.732f, offset.Z, precision: 3);
}
[Fact]
public void ComputeOffset_SeqVelFallback_FlatTerrainNormal_NoZChange()
{
var pm = Make();
var interp = EmptyInterp();
// Flat ground: normal = +Z. Projection should be a no-op.
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
seqVel: new Vector3(0f, 4f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 0f,
terrainNormal: Vector3.UnitZ);
Assert.Equal(0f, offset.X, precision: 4);
Assert.Equal(0.4f, offset.Y, precision: 4);
Assert.Equal(0f, offset.Z, precision: 4);
}
} }