# 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=<guid> pre=(x,y,z) target=(x,y,z) post=(x,y,z) airborne=<bool>
   ```
   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.
