docs(motion): #42 root cause confirmed — ResolveWithTransition airborne drift

A/B-tested 2026-05-05 with user observing retail-controlled remote:

  - With CellId fix removed: jumps render with geometrically-correct
    XY (no drift) but body falls through the floor.
  - With CellId fix applied: jumps land cleanly but arc shows ~1 m
    horizontal offset; snaps back on next UM.

Confirms the drift originates inside ResolveWithTransition, not from
wire data, local Euler error, or stale velocity. CellId fix kept in
place because falling through the floor is more disruptive than
~1 m visual jitter that resolves on next input.

#42 updated with the verified diagnosis, three ranked-by-probability
hypotheses for the in-sweep mechanism (initial-overlap depenetration
along non-+Z terrain normal is the leading candidate), three matching
fix paths, and a deterministic repro recipe for the next session.

The right next step is investigating PhysicsEngine.ResolveWithTransition
and comparing against retail's CTransition::find_valid_position
(docs/research/named-retail/) — out of scope for the L.3 motion port,
files as a follow-up PhysicsEngine bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-05 15:47:40 +02:00
parent c26bbbb84e
commit b37b7137f6
2 changed files with 97 additions and 44 deletions

View file

@ -127,9 +127,30 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g.
## #42 — 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:** OPEN **Status:** OPEN
**Severity:** MEDIUM (pre-existing; exposed by L.3 M2 airborne UP no-op) **Severity:** MEDIUM (pre-existing PhysicsEngine bug; exposed by L.3 M2 airborne UP no-op + M4 CellId fix)
**Filed:** 2026-05-05 **Filed:** 2026-05-05 (root cause confirmed same day)
**Component:** physics / motion (airborne local-integration) **Component:** physics (`PhysicsEngine.ResolveWithTransition` airborne behavior)
**Root cause (verified 2026-05-05 via A/B test):**
`ResolveWithTransition` running per-tick during the airborne arc is the
source of the drift. Verified by A/B-toggling the M4 CellId fix
(`rmState.CellId = p.LandblockId`) which is the gate that lets the
sweep run for player-remote jumps:
- **CellId line removed** → sweep skipped → jumps render with
geometrically-correct XY (no drift) but body falls through the
floor (no terrain catch).
- **CellId line present** → sweep runs → jumps land correctly but
arc shows ~1 m horizontal offset from actor's actual XY; body
snaps back on next inbound UM.
So the drift originates inside `ResolveWithTransition` itself, not
from wire data, not from local Euler integration, not from stale
velocity. Decision recorded in commit history: kept CellId fix in
production code so jumps land (`fall-through-floor` is more disruptive
to gameplay than `~1m visual jitter that resolves on next input`).
This issue tracks the proper fix.
**Description:** When observing a retail-controlled remote that jumps **Description:** When observing a retail-controlled remote that jumps
in place (no horizontal input), the visible jump arc renders with in place (no horizontal input), the visible jump arc renders with
@ -175,57 +196,91 @@ accumulated local-integration drift. The drift is pre-existing — the
user reports having seen it before — but is now visible for the user reports having seen it before — but is now visible for the
full arc duration instead of being corrected every ~200 ms. full arc duration instead of being corrected every ~200 ms.
**Suspected sources of XY drift on a stationary jump:** **Likely mechanism (ranked by probability):**
1. **ACE wire VectorUpdate may have non-trivial XY components** even 1. **Initial-overlap depenetration along non-+Z terrain normal** — at
when the actor is standing still. `OnLiveVectorUpdated` (line jump start the collision sphere is touching the floor at body Z.
3235) sets `rm.Body.Velocity = update.Velocity` verbatim; no Most outdoor terrain triangles are not perfectly horizontal — their
filtering. Worth instrumenting `[VU.WIRE]` to confirm wire XY for normals have a small horizontal component. The sweep's first action
stationary jumps. each tick is to resolve overlap by separating the sphere along the
contact normal; on a tilted terrain triangle that separation has
horizontal magnitude. The body gets shoved sideways the first frame
of the jump and the rest of the arc carries that initial drift.
Direction-correlation with terrain orientation would confirm
(test in different landblocks; if drift direction varies with the
slope of the launch tile, this is it).
2. **Pre-jump `rm.Body.Velocity` residual** — should be zero for the 2. **Step-down probe firing despite `isOnGround: false`** — sweep's
M3 grounded path (cleared each tick at line 6118 area), but worth internal "search for nearest walkable surface" might still scan
confirming via diag. horizontally during airborne ticks even when we pass `isOnGround:
!rm.Airborne` (= false for airborne). Check whether the
`stepUpHeight` / `stepDownHeight` parameters are unconditionally
used inside `ResolveWithTransition` regardless of the
`isOnGround` flag.
3. **EdgeSlide during sphere sweep** — if `ResolveWithTransition` 3. **EdgeSlide on near-vertical motion against a near-vertical
catches an edge mid-arc (unlikely for a stationary in-place jump surface** — if the sphere even slightly grazes a wall while
but possible), the sweep could push the body horizontally. ascending or descending, EdgeSlide projects motion tangent to the
wall, redirecting some Z velocity into XY. Less likely for
4. **Render-rate-dependent dt** — local Euler integration uses open-ground stationary jumps but could explain drift near
`Silk.NET.OnRender(double deltaSeconds)` raw; retail clamps to buildings.
30 Hz. Sub-tick error accumulates over a 2 s arc.
**Fix paths:** **Fix paths:**
a. **Pragmatic** (revert to legacy behavior): hard-snap a. **Skip initial-overlap depenetration when airborne** — gate the
`rm.Body.Position = worldPos` on airborne UPs too. Diverges from "separate from initial contact plane" step inside
retail spec, but ACE behavior diverges from retail too (ACE `ResolveWithTransition` on `isOnGround: true`. Trusts the previous
broadcasts mid-arc UPs while retail apparently doesn't). Masks tick's resolve to have left the body in a non-overlapping position.
the drift identically to pre-M2. Lowest-risk visual fix. This is the most likely-correct fix if hypothesis (1) is right.
b. **Investigate** the actual XY source via VU/UP instrumentation b. **Zero step-up/down for airborne sweeps** — pass
and `body.Velocity` snapshots, then fix the root cause. May be `stepUpHeight: 0f, stepDownHeight: 0f` when `rm.Airborne`. Kills
an ACE-specific velocity-quirk we should clamp at hypothesis (2) without other side effects (airborne bodies don't
`OnLiveVectorUpdated`, or a clock-source mismatch in our Euler. step anyway).
c. **Hybrid**: keep airborne no-op for body.Position translation but c. **Stripped airborne sweep** — replace the full sphere sweep with
re-introduce a soft-correction on mid-arc UPs (server-position- a simpler vertical sphere-vs-terrain intersection + wall-collision
biased lerp) — slowly pulls body toward server truth without stop. Loses some retail fidelity but eliminates all three
the rubber-band. mechanisms. Probably overkill if (a) or (b) suffices.
**Files:** **Files:**
- `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveVectorUpdated` - `src/AcDream.Core/Physics/PhysicsEngine.cs`
L3228+ (sets velocity from wire verbatim) `ResolveWithTransition` and any internal `CTransition` /
- `src/AcDream.App/Rendering/GameWindow.cs` `OnLivePositionUpdated` `find_valid_position` helpers. The initial-overlap depenetration
player-remote airborne no-op L3502+ (the no-op that exposed this) path is the primary investigation target.
- `src/AcDream.App/Rendering/GameWindow.cs` legacy airborne TickAnimations - `src/AcDream.App/Rendering/GameWindow.cs:6478+` (legacy airborne
L6478+ (gravity integration via UpdatePhysicsInternal) TickAnimations, the call site) — reference only; not the bug.
**Reference:**
Retail equivalent at
`docs/research/named-retail/acclient_2013_pseudo_c.txt`:
- `CTransition::find_valid_position` (called from `transition()`)
- `SpherePath` initialization
- The verbatim retail depenetration logic for airborne bodies
If our port differs from retail in this region, that diff is likely
the bug.
**Repro:**
1. Launch acdream + retail client side-by-side connected to local ACE.
2. Have retail char stand still on outdoor terrain at any position X.
3. Jump in place.
4. Observe acdream window: arc renders ~1 m offset from X, lands
offset, snaps back on next UM.
To verify the depenetration hypothesis specifically, repeat the jump
in different landblock spots — drift direction should correlate with
the local terrain normal, not the actor's facing.
**Acceptance:** **Acceptance:**
- Visual jump arc + landing render at the actor's actual XY position, - Visual jump arc + landing render at the actor's actual XY position,
no perceptible horizontal offset, no snap-back on next UM/UP. no perceptible horizontal offset, no snap-back on next UM.
- Wall-collision airborne (jumping into building doorways, jumping
puzzles) still works — fix must not strip collision wholesale.
--- ---

View file

@ -3434,12 +3434,10 @@ public sealed class GameWindow : IDisposable
// Adopt server's cell ID on every UP (airborne or grounded). // Adopt server's cell ID on every UP (airborne or grounded).
// Required by the legacy airborne path's per-tick // Required by the legacy airborne path's per-tick
// ResolveWithTransition gate (rm.CellId != 0) — without this, // ResolveWithTransition gate (rm.CellId != 0); without this
// an airborne player remote falls through the floor because // an airborne player remote falls through the floor because
// the sphere sweep is skipped, K-fix15 landing detection never // the sphere sweep is skipped. Note: enabling the sweep also
// fires, and the body only re-grounds when the next UM forces // exposes a pre-existing depenetration bug — see #42.
// ACE to broadcast a fresh IsGrounded=true UP that hits our
// landing transition branch below.
rmState.CellId = p.LandblockId; rmState.CellId = p.LandblockId;
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous