# Handoff prompt — fix #42 PhysicsEngine airborne XY drift Paste this into a fresh Claude Code session at the acdream repo root. The branch you'll work on is `main` at or after commit `086e65d` ("Merge L.3 motion port"). Read `CLAUDE.md` first for project conventions. ## What you're fixing When a retail-controlled toon jumps **in place** (no horizontal input, just press jump) while observed through acdream, the visible jump arc renders with a **~1 m horizontal offset** from the actor's actual XY position. Body lands at the offset. Position snaps back to the actor's real XY when the actor next sends any UM (turn / step / etc.). User's exact wording: *"I stand at position X and jump, it looks like im jumping slightly to the left of X like 1 m-ish (if I observe jumping char from behind). It also lands at X + 1 m-ish. Position resets to X when I issue some other command to the client like turning."* This is **filed as `#42` in `docs/ISSUES.md`** with severity MEDIUM. Pre-existing PhysicsEngine bug, exposed by L.3 M2 (which removed the mid-arc UP hard-snap that was masking it) and by L.3 M4 (which re-enabled `ResolveWithTransition` per-tick airborne). ## What's been ruled out (already, A/B-tested 2026-05-05) The drift is **definitively inside `PhysicsEngine.ResolveWithTransition`**. Verified by toggling a single line in `GameWindow.OnLivePositionUpdated`: - `rmState.CellId = p.LandblockId;` REMOVED → sweep skipped → jumps render geometrically clean (no XY drift), but body falls through the floor (no terrain catch). - `rmState.CellId = p.LandblockId;` PRESENT → sweep runs → jumps land cleanly on the floor, but arc shows ~1 m horizontal drift. That A/B narrows the bug to one place. **Do not waste time** on: - ACE wire `VectorUpdate` velocity components (not the source — drift exists with sweep enabled regardless of wire data) - Local Euler integration error (not the source — drift is geometric, visible immediately on jump start) - Stale `body.Velocity` from before the jump (M3 grounded path zeroes it every tick; verified clean) - Wire diagnostic instrumentation (`[VU.WIRE]`, `[UM_RAW]`, etc.) — irrelevant. The bug is in the sweep, not the inputs. The bug is **inside `ResolveWithTransition`** (or one of the helpers it delegates to: `Transition`, `SpherePath`, `CollisionInfo`, `AdjustOffset`). ## Why the fix can't just disable the sweep The user explicitly raised: *"What about jumping in to buildings or on a jumping puzzle?"* Right. We need full sphere-vs-geometry resolution mid-arc for: - Building doorways (jumping into a doorway you can stand in) - Jumping puzzles (threading between platforms, around obstacles) - Ceiling clips (hitting an overhang at jump apex) - Edge collision (clipping against the corner of a platform) So the fix has to **keep the sweep running, but stop it from pushing horizontally on stationary jumps**. Pure-vertical motion against a roughly-horizontal floor must produce zero XY delta from the sweep. ## The three ranked hypotheses These are the in-sweep mechanisms most likely producing the drift. Listed by probability; (1) is the leading candidate. ### H1: Initial-overlap depenetration along non-+Z terrain normal At jump start the collision sphere is touching the floor: `body.Z` is at ground level, sphere radius extends downward into the terrain triangle. The sweep's first tick has to resolve that overlap by **separating the sphere along the contact normal**. Outdoor terrain is rarely perfectly flat. Most terrain triangles tilt 0.5–10° from horizontal. Their normals have a small horizontal component. When the sweep separates the sphere along that tilted normal, the separation has horizontal magnitude → body shoves sideways → drift carries through the rest of the arc. **Direction-correlation test confirms this hypothesis if true:** jumping at multiple landblock positions, the drift direction should correlate with **local terrain slope orientation**, not the actor's facing. (If drift is always relative to actor's facing, H1 is wrong and H2 or H3 is the source.) ### H2: Step-down probe firing despite `isOnGround: false` `PhysicsEngine.ResolveWithTransition` is called with `isOnGround: !rm.Airborne` → `false` for airborne. But the sweep's internals may still execute step-down logic regardless of that flag. Step-down searches **horizontally** within `stepDownHeight` (0.4 m) for the nearest walkable surface — even a small horizontal probe can shift an airborne body sideways if the search finds a "better" surface. Already a known asymmetry exists in this region — see `K-fix7` / `K-fix9` comments in `GameWindow.cs:6611-6620` and the matching contact-plane gating in `PhysicsEngine.cs:493-519`. Step-up/down may have a similar gap. ### H3: EdgeSlide on near-vertical motion grazing a near-vertical surface If the sphere even slightly grazes a wall while ascending or descending, `EdgeSlide` projects the motion vector tangent to the wall surface, redirecting some Z velocity into XY. Less likely for open-ground stationary jumps but cannot be ruled out without testing in different environments. ## Files to investigate ``` src/AcDream.Core/Physics/PhysicsEngine.cs (658 LOC) Line 470: ResolveWithTransition entry point Lines 478-491: Transition / ObjectInfo setup Lines 493-519: K-fix7 contact-plane gating (existing airborne fix) Lines 521-530: SlidingNormal seed Line 532: SpherePath.InitPath (path setup) Lines 534-558: WalkablePolygon seed (grounded only) src/AcDream.Core/Physics/TransitionTypes.cs (2200 LOC) Top-level: Transition, SpherePath, CollisionInfo, ObjectInfo classes Search for: find_valid_position, step_sphere, AdjustOffset, StepUp / StepDown / EdgeSlide branches Lines 786-846: existing step-down + edge-slide branch logic (where H2/H3 might be firing inappropriately for airborne) Lines 1305-1311: another step-down/contact branch worth checking src/AcDream.Core/Physics/CollisionPrimitives.cs (718 LOC) Sphere-vs-triangle math; depenetration-related primitives if any ``` The L.4 collision-resolution work added the existing airborne gates (K-fix7 ContactPlane, K-fix9 ContactPlane during VectorUpdate) but didn't touch step-up/down or initial-overlap depenetration paths. Those are the gaps. ## Reference: retail named-retail decomp Mandatory cross-reference. Located at: ``` docs/research/named-retail/acclient_2013_pseudo_c.txt (1.4 M lines, PDB-named) docs/research/named-retail/acclient.h (verbatim retail headers) docs/research/named-retail/symbols.json (greppable name → address) ``` Functions to grep for in the named pseudo-C: - `CTransition::init` @ `0x00509dd0` — explicitly clears `contact_plane_valid = 0` at start of every resolve. Confirms the airborne should not be running on a stale contact plane (we already match this — K-fix7). - `CTransition::find_valid_position` — entry to the actual sweep. Read this carefully for the airborne-vs-grounded branching. - `CSpherePath::InitPath` — path-setup; check whether retail does any initial-overlap separation here. - `CSpherePath::step_sphere` — the per-step inner loop. - `CTransition::ValidateWalkable` — rebuilds contact plane when sphere bottom is within EPSILON of terrain plane (per K-fix7 comment); for airborne this should not establish a plane. - `CCollisionInfo::set_contact_plane` and the depenetration paths that use it. **Compare retail behavior against our port byte-for-byte for the specific airborne-stationary-jump path.** Our port may have inadvertently introduced behavior retail doesn't have (e.g., unconditional initial separation), or omitted a retail gate (e.g., "skip depenetration if `transient_state.Contact == 0`"). ## Investigation plan Two phases. Don't skip phase 1. ### Phase 1: Confirm hypothesis (≤ 2 hours) 1. Verify the M4 `CellId` fix is on `main` by grepping `GameWindow.cs` for `rmState.CellId = p.LandblockId`. It should be present at the top of the M2 player-remote branch in `OnLivePositionUpdated`. 2. Add lightweight diagnostic logging inside `ResolveWithTransition`: ``` [SWEEP] guid= pre=(x,y,z) target=(x,y,z) post=(x,y,z) airborne= ``` Gate behind `ACDREAM_AIRBORNE_DIAG=1` so the diagnostic doesn't spam logs in normal use. 3. Have the user jump in place at 3+ different landblock positions with visibly different terrain orientation: - flat plaza tile - hillside facing east - hillside facing north For each, capture the sweep's `pre→target→post` deltas across the first 5 frames of the arc. 4. **Check direction correlation:** - If drift direction varies with terrain orientation → H1 confirmed. The first frame of the sweep is shifting the body horizontally by an amount proportional to the local terrain slope. - If drift direction is always relative to actor facing → H1 wrong; check H2 (step-down probe) by comparing sweeps with non-zero vs zero `stepDownHeight`. - If no drift in open ground but drift near walls → H3. 5. Stop at the first confirmed hypothesis. Move to phase 2 with precise knowledge of which mechanism is firing. ### Phase 2: Fix (1–4 hours depending on hypothesis) **If H1 confirmed (most likely):** - Find the depenetration / "separate sphere from initial contact" path in `Transition` / `SpherePath` / `CollisionInfo`. - Gate it on `ObjectInfo.State.HasFlag(ObjectInfoState.Contact)` (i.e., grounded). Trust the previous tick's resolve to have left the body in a non-overlapping position. - Compare against retail's `CTransition::init` and `find_valid_position` — likely retail has this gate and we don't. **If H2 confirmed:** - Find the step-up/step-down branches in `TransitionTypes.cs`. - Gate them on `oi.Contact` (already exists at line 787 — verify it fires correctly when airborne). - May need to additionally zero `StepUpHeight` / `StepDownHeight` when airborne at the call site in `GameWindow.cs`. **If H3 confirmed:** - Find the EdgeSlide branch in `TransitionTypes.cs`. - Add a gate that EdgeSlide only fires when motion has horizontal velocity component above a threshold (so pure-vertical motion is exempt). - Verify against retail's edge-slide branch behavior. ### Phase 3: Visual verification User will run retail + acdream side-by-side. Test cases: - ✅ Jump in place on flat ground — no XY drift - ✅ Jump in place on hillside (different orientations) — no XY drift - ✅ Jump while running forward — arc carries forward momentum (XY delta from initial wire velocity, NOT from sweep) - ✅ Jump into a building doorway — body collides with door frame / threads through doorway correctly - ✅ Jump from a platform onto another (jumping puzzle mechanic) — body lands on the target platform - ✅ Land on slope — sequencer leaves Falling correctly If any of these regress, the fix is wrong or incomplete. Iterate. ## Diagnostic toolchain ### cdb attach to retail (high-value when comparing retail vs ours) Toolchain documented in CLAUDE.md "Retail debugger toolchain" section. The relevant breakpoints for #42: ``` bp acclient!CTransition::init "r $t0 = @$t0 + 1; gc" bp acclient!CTransition::find_valid_position "r $t1 = @$t1 + 1; gc" bp acclient!CSpherePath::step_sphere "r $t2 = @$t2 + 1; gc" ``` Have the user (running retail with cdb attached) jump in place. The hit counts and any printed state from breakpoint actions reveal retail's actual airborne sweep call pattern. Compare against acdream's instrumented `[SWEEP]` log. Differences are bugs. **Important warnings from CLAUDE.md:** - `qd` / `q` / `qq` are FORBIDDEN inside breakpoint actions — use `.detach`. - High-frequency breakpoints lag retail to ACE timeout — counter-only actions for hot paths. - `cdb -pd` does NOT survive `Stop-Process -Force`; detach cleanly. ### Live launch ```powershell $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = "1" $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_AIRBORNE_DIAG = "1" # enable sweep diagnostic once added dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug *>&1 | Tee-Object -FilePath "launch-42.log" ``` Wait 3–5 s between launches (ACE session cleanup race; CLAUDE.md). ### Test character `+Acdream` at server guid `0x5000000A`. Per CLAUDE.md, this is the GM-marker test toon. The user will control it (or another retail toon) from a parallel retail client and you observe in acdream. ## Acceptance criteria The fix is done when ALL of these hold: - [ ] Stationary jump on flat ground: arc + landing render at actor's actual XY (zero perceptible drift, zero snap-back on next UM). - [ ] Stationary jump on hillside: same — drift independent of terrain orientation. - [ ] Forward-running jump: arc carries actor's forward XY velocity (this is server-driven, expected to track). - [ ] Jump into a doorway: collision resolves correctly. - [ ] Jump puzzle: thread between two platforms. - [ ] Build green (`dotnet build`). - [ ] Tests green (`dotnet test`) — modulo the 8 pre-existing `Core.Tests` failures unrelated to this work. - [ ] `[SWEEP]` diagnostic shows zero XY change on a stationary jump's first sweep frame (the H1 confirmation residue). ## Hard rules - **Don't blame ACE.** ACE is fixed; retail clients work against ACE without this drift, so our PhysicsEngine port is the bug. - **Don't disable the sweep wholesale.** The user explicitly needs jumping puzzles + wall collision to keep working. - **Don't change `OnLivePositionUpdated` or `TickAnimations`.** The L.3 motion-port work is correct; this is a `PhysicsEngine` bug. - **Don't touch `MotionInterpreter` or `AnimationSequencer`.** Same reason. - **Don't reduce sphere dims** to "avoid intersection". 0.48 m radius / 1.2 m height is retail human-scale and matches local-player collision; changing it on remotes only would cause asymmetric behavior between local jumps (working) and remote jumps (different collision profile). - **Don't add diagnostic logging that prints every frame** without an env-var gate — already burned the `[VEL_DIAG]` budget once. - **Don't commit a partial fix that breaks any test in the acceptance criteria.** Trade off completeness for correctness. ## Pre-session reading list In order, before writing any code: 1. `docs/ISSUES.md` § `#42 — Airborne XY drift` (the spec) 2. This handoff prompt (you're here) 3. `src/AcDream.Core/Physics/PhysicsEngine.cs:470-560` (`ResolveWithTransition`) 4. `src/AcDream.Core/Physics/TransitionTypes.cs:780-870` (existing step-down + edge-slide branches) 5. Grep `K-fix7` and `K-fix9` in `GameWindow.cs` and `PhysicsEngine.cs` (these are the prior airborne-related fixes; matching style is helpful) 6. The L.3 motion-port research at `docs/research/2026-05-04-l3-port/` — especially `01-per-tick.md` § 5 (`CPhysicsObj::transition`) and `01-per-tick.md` § 6 (`SetPositionInternal`) for retail's per-tick collision-sweep contract. 7. Retail's `CTransition::init` at `acclient_2013_pseudo_c.txt` line 271954 (cited in K-fix7 comment). ## Operating notes - **Branch off `main`.** Do not work directly on main; use a feature branch for the fix. Worktree is fine; CLAUDE.md and prior sessions use `.claude/worktrees/`. - **TDD where it fits.** A unit test that asserts `ResolveWithTransition` returns zero XY delta for a stationary vertical sweep on a tilted-normal triangle would lock the fix. Existing `tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs` is the right home. - **Visual verification is the acceptance test.** The unit test proves the math but the user's eye proves the bug. Plan time for the user to run the test sequence above. - **One-session estimate: half a day to a day.** Phase 1 should identify the hypothesis in under 2 hours; Phase 2 fix depends on which one. Plan for two iterations if the first fix attempt doesn't fully resolve. ## Out of scope - `#41` (sub-decimeter velocity-synthesis blips) — separate issue. - Refactoring NPC vs player-remote convergence in `TickAnimations` (filed in audit § 6 of `06-acdream-audit.md`) — separate session. - Local-player jump behavior — already works; don't touch. - ACE-side fixes — out of scope. ## Final advice The L.3 motion-port history (commits `de129bc`..`5cc2812`) shows what this codebase rewards: spec-faithful reads of named-retail before writing any code. Apply the same here. The fix is probably small — maybe 5–20 lines — but finding it requires reading retail's `find_valid_position` and our `Transition` carefully and identifying the diff. Don't guess; verify.