Compare commits

...

4 commits

Author SHA1 Message Date
Erik
5f2e2e28ff docs(issues): close #42 (self-skip ec59a08), file + close #43 staircase (9e4772a)
#42 — moved from OPEN to DONE in place (rich investigation log preserved
below the new Resolution block). The originally-listed mechanisms (H1
slope-driven AdjustOffset projection, H2 step-down probe, H3 EdgeSlide)
were all RULED OUT by the first evidence run; root cause was self-
collision in FindObjCollisions, not in-sweep mechanism choice. Added
forward-pointer to retail's CObjCell::find_obj_collisions self-skip
(named-retail acclient_2013_pseudo_c.txt:308931).

#43 — new entry in Recently closed for the slope staircase on grounded
player remotes. Diagnosis: PositionManager.ComputeOffset's seqVel-only
fallback returned flat-Z motion because anim cycles bake Z=0 body-local,
producing visible 5 Hz Z stepping at the server-UP cadence. Fix: project
the fallback onto the local terrain plane (mirrors retail's
CTransition::adjust_offset contact-plane projection at the queue-empty
boundary). Verified via 9193 queue-empty-with-non-zero-offset.Z ticks
across a 34m vertical traversal.

Both diagnostic env-vars kept in tree for future regression hunts:
ACDREAM_AIRBORNE_DIAG=1 and ACDREAM_SLOPE_DIAG=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:51:12 +02:00
Erik
9e4772a8f8 fix(motion): project anim root motion onto terrain plane (slope staircase)
Grounded player remotes were showing a ~5 Hz Z staircase when running
up/down slopes — the rate of server UpdatePositions. Body Z stayed flat
between UPs, then ramped over ~100ms during the queue-active chase to
each new server position, then went flat again until the next UP.

Diagnosis (no diagnostic needed — the math is unambiguous):
PositionManager.ComputeOffset has two modes via
InterpolationManager.AdjustOffset:

  - Queue active (body chasing a waypoint): returns
    `(head − body) / dist × min(catchUpSpeed × dt, dist)`. 3D direction,
    Z follows server's reported Z naturally.
  - Queue empty / head-reached (within DESIRED_DISTANCE = 0.05m of the
    most recent UP): returns Vector3.Zero. ComputeOffset falls back to
    `seqVel × dt rotated into world` — pure animation root motion. Every
    locomotion cycle bakes Z=0 in body-local, so the world result has
    Z=0 too. XY advances at the running pace; Z stays at the last UP.

For a runner at maxSpeed ≈ 4 m/s with catchUpSpeed = 2× = 8 m/s and
server UPs at ~5 Hz, body covers ~0.8m per UP, chases for ~100ms
(queue-active 3D path, Z ramps), then sits in seqVel-only mode for
~100ms (Z flat) until the next UP. Visible as a 5 Hz Z staircase.

Fix mirrors retail's CTransition::adjust_offset contact-plane projection
(named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
motion, applied at the queue-empty boundary instead of inside the sweep:

  PositionManager.ComputeOffset gains an optional Vector3? terrainNormal.
  When the seqVel-only fallback runs AND a non-trivial terrain normal is
  supplied, project rootMotionWorld onto the plane:

      result = rootMotionWorld − N × dot(rootMotionWorld, N)

  Anim XY motion gains a corresponding Z component proportional to slope
  angle × forward speed, so body Z follows the terrain mesh between UPs.
  No-op on flat ground (N ≈ +Z, dot ≈ 0); cannot regress L.3 M2's
  flat-ground verification.

GameWindow.TickAnimations grounded-remote path samples
PhysicsEngine.SampleTerrainNormal at the body's current XY each tick
and passes it to ComputeOffset. SampleTerrainNormal is a thin public
wrapper over the existing internal SampleTerrainWalkable that returns
just the plane normal (no need to expose the internal sample shape).

Diagnostic: ACDREAM_SLOPE_DIAG=1 prints a per-tick [SLOPE] line with
guid, body Z before/after, offset, queue active flag, and the sampled
plane Nz so we can grep before/after the fix and confirm Z changes
continuously between UPs on slopes.

Tests: PositionManagerTests gains two cases:
  - slope projection: 30° east-tilted plane, body running due east at
    4 m/s for 1s → expect (3.0, 0, −1.732) (descends along slope, not
    flat). Math: dot(seqVel, N) = 2.0 → result = (4,0,0) − (0.5,0,0.866)
    × 2.0 = (3.0, 0, −1.732).
  - flat-ground no-op: N = +Z, expect identical Y-only motion as the
    pre-fix behavior.

Build green. 357 pass / 6 pre-existing fail (same set as ec59a08;
verified by stashing this change). The pre-existing
`ComputeOffset_BothActive_Combined` failure reflects an outdated
additive-design test docstring; the M2 commit (40d88b9) deliberately
changed the implementation to REPLACE semantics to fix the prior
3×-server-pace overshoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:37:42 +02:00
Erik
ec59a08db5 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>
2026-05-05 19:01:07 +02:00
Erik
a36369d8ca diag(physics): #42 add ACDREAM_AIRBORNE_DIAG [SWEEP] trace
Phase 1 of #42 root-cause investigation per the handoff doc. We
A/B confirmed (commit b37b713) that the ~1m XY drift on retail-
observed stationary jumps comes from inside ResolveWithTransition
when the per-tick airborne sweep runs (CellId fix at GameWindow.cs
3467). What we don't yet know: whether the drift originates in
H1 (initial-overlap depenetration along a tilted-terrain normal),
H2 (step-down probe firing despite isOnGround=false), or H3
(EdgeSlide on near-vertical motion grazing a wall).

This diagnostic gates a one-line Console trace on
ACDREAM_AIRBORNE_DIAG=1 AND !isOnGround so it doesn't pollute
grounded movement, and prints:

  [SWEEP] airborne pre=(...) target=(...) post=(...)
          cell=PRE->POST ok=BOOL deltaXY=(dx,dy)
          cp=valid|none cpN=(nx,ny,nz)

deltaXY = post - target — for a clean stationary +Z jump we
expect (0,0). Non-zero with cp=valid and a tilted cpN confirms
H1; non-zero direction tracking actor facing instead of terrain
orientation points to H2/H3.

Code-walk findings recorded for the next investigation pass:
- K-fix7 already prevents seeding ContactPlane on entry for
  airborne (PhysicsEngine.cs:493-519), so step 0's AdjustOffset
  cannot consume a stale plane.
- BUT ValidateWalkable can still SET ContactPlane during step 0's
  collision pass via the "below plane" branch (TransitionTypes.cs
  1320-1352) when sphere lowPoint dips below the tilted terrain
  triangle. Step 1's AdjustOffset would then consume that fresh
  plane and the "moving away from contact plane" branch
  (TransitionTypes.cs:1749-1754) projects the +Z offset along the
  slope normal, redirecting Z motion into XY.
- Step-down branch is correctly gated on oi.Contact (matches
  retail CTransition::transitional_insert at named-retail
  acclient_2013_pseudo_c.txt:273249, "(state & 1) == 0" returns
  OK without firing step-down).
- Retail's IS_VIEWER_OI=0x4 branch in OBJECTINFO::validate_walkable
  (acclient.h:6185) is never set anywhere in the named decomp,
  so the airborne path runs the same code in retail as in acdream.

User repros at flat plaza / east hillside / north hillside; the
direction-correlation of deltaXY with local terrain orientation
identifies which hypothesis is firing.

Build green; 13 PhysicsEngine tests green. No behavior change
when ACDREAM_AIRBORNE_DIAG is unset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:16:29 +02:00
8 changed files with 433 additions and 9 deletions

View file

@ -124,12 +124,52 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g.
- No spurious cycle thrashing during turning while running (ObservedOmega
doesn't trigger velocity-bucket changes).
## #42 — Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc)
## #42 [DONE 2026-05-05 · ec59a08] Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc)
**Status:** OPEN
**Status:** DONE
**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)
**Component:** physics (`PhysicsEngine.ResolveWithTransition` airborne behavior)
**Closed:** 2026-05-05
**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):**
@ -1188,6 +1228,53 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
# 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
**Closed:** 2026-04-29

View file

@ -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

View file

@ -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
@ -6137,15 +6149,42 @@ public sealed class GameWindow : IDisposable
// velocity, keeping legs and body pace synchronized.
// - Blip-to-tail (tail body) when fail_count > 3.
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(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVel,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeed);
maxSpeed: maxSpeed,
terrainNormal: terrainNormal);
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
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
// the wire's TurnCommand + signed TurnSpeed) over the
@ -6487,7 +6526,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)

View file

@ -169,6 +169,21 @@ public sealed class PhysicsEngine
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>
/// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
@ -473,12 +488,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
@ -625,6 +650,33 @@ public sealed class PhysicsEngine
bool collisionNormalValid = ci.CollisionNormalValid;
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)
{
bool onGround = ci.ContactPlaneValid

View file

@ -34,13 +34,28 @@ public sealed class PositionManager
/// <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="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(
double dt,
Vector3 currentBodyPosition,
Vector3 seqVel,
Quaternion ori,
InterpolationManager interp,
float maxSpeed)
float maxSpeed,
Vector3? terrainNormal = null)
{
// Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
@ -71,6 +86,23 @@ public sealed class PositionManager
return correction;
Vector3 rootMotionLocal = seqVel * (float)dt;
return Vector3.Transform(rootMotionLocal, ori);
Vector3 rootMotionWorld = 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,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,7 +1524,19 @@ 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;

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

View file

@ -176,4 +176,64 @@ public sealed class PositionManagerTests
Assert.Equal(0f, offset.Y, 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);
}
}