diff --git a/docs/research/2026-05-06-issue-38-handoff.md b/docs/research/2026-05-06-issue-38-handoff.md deleted file mode 100644 index a2477a6c..00000000 --- a/docs/research/2026-05-06-issue-38-handoff.md +++ /dev/null @@ -1,328 +0,0 @@ -# Issue #38 handoff — chase-camera + player feel "30 fps" since L.5 physics-tick gate - -**Use this whole document as the prompt** when handing off to a fresh agent. -Everything they need to pick up cold is below. - ---- - -## Background you'll need - -You're working in `acdream`, a from-scratch C# .NET 10 reimplementation -of Asheron's Call's retail client. The project's house rule (in -`CLAUDE.md`) is **the code is modern, the behavior is retail**: every -gameplay-affecting algorithm is ported faithfully from the named retail -decomp at `docs/research/named-retail/` (Sept 2013 EoR build PDB, -99.6% function-name recovery, full pseudo-C). Read `CLAUDE.md` end-to- -end before touching code — the workflow ("grep named-retail first, -decompile, pseudocode, port, conformance test, integrate") is mandatory. - -Phase L is the movement / collision conformance work. Phase L.5 -(2026-04-30) ported retail's `CPhysicsObj::update_object` quantum -gate — physics integration runs at 30 Hz (`MinQuantum = 1/30 s`) even -when the renderer is at 60+ Hz. That fixed real correctness bugs (the -"steep-roof wedge" problem). It also introduced the gameplay-feel -regression this handoff is about. - -## The bug, in one paragraph - -In third-person / chase camera the player's character + camera motion -**look** like they're updating at ~30 fps even though the FPS counter -reads 60+. The world rotates with yaw smoothly (mouse-look stays at -render rate), but translation — running forward / strafing / falling — -visibly steps in 33 ms increments. First-person is much less affected -because the camera origin is the eye and rotation dominates the -percept; third-person is hit hardest because the *character* is the -moving object you're staring at. - -This is **not a correctness bug** — physics, collision, network, and -animation are all running fine at the retail-correct 30 Hz cadence. -It's purely a render-time visual smoothness issue. - -## Acceptance criterion - -- Running around in chase camera at 60+ FPS feels as smooth as the - render rate suggests (no perceptual stepping). User confirms - visually. -- **Don't break:** physics tick rate stays at 30 Hz (the L.5 fix - must keep working — wedge / steep-roof scenarios still resolve). -- **Don't break:** outbound network (`MoveToState` / `AutonomousPosition` - cadence + values) unchanged. Observers in a parallel retail client - see the same wire as today. -- **Don't break:** collision resolution unchanged. The player still - cannot walk through walls / clip into geometry / unstick from - steep slopes. - -User has retail running in parallel for side-by-side comparison; the -final acceptance is **smoother chase-camera motion at the same physics -cadence**, not "match retail at 30 fps render." - -## Confirmed root cause - -Retail integrates physics at 30 Hz with `MinQuantum = 1/30 s`. We -ported that faithfully in L.5: `_physicsAccum` accumulates per-frame -`dt`, integration runs only when accumulator hits `MinQuantum`, and -the integration step uses the accumulated value. Side effect: -`_body.Position` only updates on physics ticks, every 33 ms. - -The renderer reads the position **directly** every frame: - -```csharp -// GameWindow.cs:5725 -var result = _playerController.Update((float)dt, input); - -// 5731 — player entity render position comes from result.Position -pe.Position = result.Position; -// 5753 — chase camera target also reads result.Position -_chaseCamera.Update(result.Position, _playerController.Yaw, ...); -``` - -Between physics ticks `result.Position` is the *same value*, so the -player renders at the same world coordinate for ~2 render frames in a -row, then jumps to the next sample. That's the "30 fps" visual -percept. - -In retail (2013) this wasn't visible because render was also ~30 fps — -render rate ≈ physics rate. Our 60+ Hz render exposes the gap. - -## Quick confirmation test (do this first) - -Before any code change, prove the diagnosis is correct by temporarily -defeating the gate and seeing if the chase camera feels smooth again. -**Do not ship this** — it undoes the L.5 collision fixes. - -In `src/AcDream.Core/Physics/PhysicsBody.cs:74`, change: - -```csharp -public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s -``` - -to - -```csharp -public const float MinQuantum = 1.0f / 60.0f; -``` - -Build, launch with the standard live env-vars -(`CLAUDE.md → "Running the client against the live server"`), run -around in chase view, verify smoothness. Then **revert that change -before committing anything**. With confirmation in hand, implement the -proper fix below. - -## Recommended fix — render-time interpolation - -This is the standard fixed-timestep + interpolated-rendering pattern -(Quake / Source / Unreal use it). At each physics tick, snapshot the -position before and after; render at a linear interpolation between -the two based on `_physicsAccum / MinQuantum`. The integration cadence -stays at 30 Hz; the rendered position updates every render frame. - -### Sketch (in `PlayerMovementController`) - -```csharp -// New fields alongside _physicsAccum: -private Vector3 _prevPhysicsPos; // body.Position at the start of the last completed tick -private Vector3 _currPhysicsPos; // body.Position at the end of the last completed tick -// (Initialize both to the spawn position when the controller is created -// or when the player teleports — search for SetPosition / RelocateLocalPlayer.) - -// Inside Update, around lines 526-547 — when integration actually runs, -// roll the snapshots: -if (_physicsAccum >= PhysicsBody.MinQuantum) -{ - _prevPhysicsPos = _currPhysicsPos; // old "current" becomes "previous" - // ... existing integrate + collision-resolve sequence ... - _body.Position = resolveResult.Position; - _currPhysicsPos = _body.Position; // new resolved position - _physicsAccum -= tickDt; -} - -// Compute the interpolated render position at the END of Update, -// AFTER the gate decision and any collision resolve. Use _physicsAccum -// (the leftover) to compute alpha. Cap alpha at 1.0 — never extrapolate -// past the most recent tick. -float alpha = MathF.Min(_physicsAccum / PhysicsBody.MinQuantum, 1f); -Vector3 renderPos = Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); -``` - -Expose `renderPos` on the controller — either as a new property -`RenderPosition` or fold it into the result struct returned from -`Update`. Two callers in `GameWindow.cs:5731` and `:5753` need to -switch from `result.Position` (physics, 30 Hz) to the new -interpolated value. **Network outbound (everything below `~5760` in -GameWindow that builds `MoveToState` / `AutonomousPosition`) keeps -using the discrete physics value** — observers must see the -authoritative tick position, not the interpolation. - -### Edge cases to think about - -- **First frame:** `_prev == _curr` until the second integration - tick fires. That's fine — `Lerp(p, p, alpha) == p`. Interpolation - is a no-op until two ticks have run. -- **Teleport / login-position-set:** when you call `SetPosition` - (line ~289), set both `_prev` and `_curr` to the new position so - the lerp doesn't visibly tween from the old location. Same for - any RelocateLocalPlayer / network teleport path. -- **Stale-frame discard:** when `_physicsAccum > HugeQuantum` and - you reset to 0, also resnap `_prev = _curr = _body.Position` so - the lerp doesn't dredge up a value from before the pause. -- **MaxQuantum clamp:** when `tickDt = min(_physicsAccum, - MaxQuantum)`, you may have leftover accum > MinQuantum. The - current code subtracts only `tickDt`. With render-time interp, - consider running multiple integration ticks in one Update call - to drain the accumulator — or accept that one frame after a - long pause does a single big tick. Match whatever the existing - L.5 code does; don't change correctness behavior. -- **Camera at rest:** when the player isn't moving, `_prev == _curr` - every tick. Interpolation is a no-op. ✓ - -### Cost - -- ~33 ms visual latency between input and what you see (input - affects velocity, velocity integrates on the next tick, render - shows the lerp toward the new position). Retail had the same - latency in 2013 because render and physics were both 30 Hz; the - perceived feel matches retail. -- Trivial CPU cost per frame: one Vector3 lerp. -- Zero network impact — outbound stays on physics-tick values. - -### Things that should NOT change behavior - -- `_body.Position` itself stays the authoritative physics position. - Don't write the interpolated value back to `_body.Position` — that - would feed the next physics tick a non-tick-aligned position and - re-introduce the 60 Hz integration cadence we're avoiding. -- The collision resolve `result.Position` stays the physics-tick - value. Network code that reads it is correct. -- `Yaw` is already render-rate (mouse input → instantaneous yaw - update). Don't touch it. - -## Files most likely to need edits - -- `src/AcDream.App/Input/PlayerMovementController.cs:215` — - declaration of `_physicsAccum`. Add `_prevPhysicsPos` / - `_currPhysicsPos` here. -- `src/AcDream.App/Input/PlayerMovementController.cs:526-547` — - the gate. Snapshot rolling lives here. -- `src/AcDream.App/Input/PlayerMovementController.cs:130` — - `Position => _body.Position` exposed property. Add `RenderPosition` - or extend the result struct returned from `Update`. -- `src/AcDream.App/Input/PlayerMovementController.cs:289` — - `SetPosition`. Resnap `_prev`/`_curr` here. -- `src/AcDream.App/Rendering/GameWindow.cs:5725-5753` — the call site. - Switch player entity render position + chase camera target from - `result.Position` to the new interpolated value. Network section - starting around `:5757` keeps `result.Position`. -- `src/AcDream.App/Rendering/ChaseCamera.cs:70` — no change needed - IF you feed it the interpolated position; the camera's existing - smoothing applies on top. -- `src/AcDream.Core/Physics/PhysicsBody.cs:74-76` — `MinQuantum` / - `MaxQuantum` / `HugeQuantum` constants. **Don't touch.** - -## Tests to add - -- Unit test in `tests/AcDream.Core.Tests/Physics/` (or a new file in - `tests/AcDream.App.Tests/` if PlayerMovementController is testable - there): drive `Update` with sub-MinQuantum dt repeatedly, assert - that `RenderPosition` advances smoothly between snapshots while - `_body.Position` only changes on tick boundaries. -- Edge cases: alpha clamps at 1 when leftover accum exceeds - MinQuantum (no extrapolation past the most recent tick); SetPosition - resnaps both endpoints; HugeQuantum stale-frame path resnaps both. - -## Workflow (per `CLAUDE.md`) - -1. **Step 0 — grep named-retail.** - `grep "update_object\|UpdatePhysicsInternal\|render_object\|draw_object" docs/research/named-retail/acclient_2013_pseudo_c.txt` - to confirm retail's render-vs-physics integration story matches the - description above. Look for any retail render-time interpolation we - might want to mirror exactly. (Heads-up: retail probably DOESN'T - interpolate — it ran render at 30 Hz, so it didn't need to. This is - one of the few places acdream legitimately diverges from retail - because we render at 60+ Hz. Document the divergence in the commit - message.) -2. **Cross-reference.** Check ACE / ACME / holtburger for any - render-time-interp patterns. They probably don't either, since - ACE is server-only and ACME / holtburger don't render animated - characters at 60+ Hz the way we do. -3. **Pseudocode.** Add `docs/research/2026-05-XX-issue-38-render-interp-pseudocode.md` - with the algorithm in plain language before porting. Cite the - precedent (Quake/Source/Unreal fixed-timestep article — Glenn - Fiedler's "Fix Your Timestep!" is the canonical reference). -4. **Port.** Implement in `PlayerMovementController` + GameWindow - wire-up. -5. **Conformance test.** Unit tests above. -6. **Visual verification.** User runs the client, runs around in - chase view, confirms smoothness without breaking the L.5 wedge - scenarios (run up a steep roof, fall back, jump on rooftops, walk - off cliffs). - -## Constraints / don't-break - -- Physics tick rate **must** stay 30 Hz. Don't change `MinQuantum`. - L.5 wedge / steep-roof tests are the regression suite. -- Network outbound **must** be unchanged. Run with a parallel retail - client observing your `+Acdream` and confirm the same blippy/laggy - baseline (separately tracked as #46). -- Tests must stay green: `dotnet build AcDream.slnx -c Debug`, - `dotnet test AcDream.slnx`. There are 8 pre-existing motion test - failures in `AcDream.Core.Tests` that aren't yours — leave them. - -## When to stop and ask - -Per `CLAUDE.md`, ask only for: - -- Visual verification (user looking at the client) -- Genuine architectural disagreements (e.g. if you discover this - needs a different approach than render-time interpolation) -- Hard-to-reverse destructive actions - -Otherwise act. - -## Test workflow (live verification) - -```powershell -Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force -Start-Sleep -Seconds 4 - -$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" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | - Tee-Object -FilePath "launch_issue38.log" -``` - -Spawn point is Holtburg. Run forward / strafe / jump / run up the -nearest building's slope / fall off — should feel smooth, not stepped. -Drudges nearby, NPCs, retail observer (a parallel retail client) all -work as control comparisons. - -## References to consult - -- `src/AcDream.App/Input/PlayerMovementController.cs:191-215` — the - L.5 gate, with full retail decomp citation in comments -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — retail - pseudo-C, **grep this FIRST** -- `memory/project_retail_debugger.md` — L.5 background; how the - 30 Hz cadence was discovered via cdb attach to live retail -- `docs/research/2026-04-30-retail-motion-trace/` — L.5 trace data -- Glenn Fiedler, "Fix Your Timestep!" — - https://gafferongames.com/post/fix_your_timestep/ — canonical - fixed-timestep + interpolated rendering article - -## Final note - -This is a **render-only fix**. Don't change physics, network, or -collision behavior. The L.5 30 Hz integration is correct and -load-bearing for retail-faithful collision; you're just smoothing the -*display* of the position between ticks. If you find yourself touching -`MinQuantum`, `_body`, or any wire-side code, stop and reconsider. - -When this lands, update `docs/ISSUES.md` to mark #38 DONE with the -commit SHA, update the pseudocode doc with anything you learned, and -add a one-line memory entry if there's a durable lesson (e.g. "render- -rate-vs-physics-rate gap is the standard fixed-timestep-interp -pattern; we now apply this for the player and may want to extend to -remote entities later if they show similar stepping").