Compare commits
4 commits
ce638eb56f
...
5f2e2e28ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f2e2e28ff | ||
|
|
9e4772a8f8 | ||
|
|
ec59a08db5 | ||
|
|
a36369d8ca |
8 changed files with 433 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue