From 50da2bb81d3c38447ed450f7fb605399f14286ca Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 6 May 2026 17:03:43 +0200 Subject: [PATCH] docs(research): #38 handoff prompt for next-session agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained pickup brief for the chase-camera "30 fps" issue introduced by L.5's 30 Hz physics-tick gate. Covers confirmed root cause with file:line citations, recommended fix (render-time interpolation between physics ticks — Fix-Your-Timestep pattern), implementation sketch with edge cases, file pointers, test workflow, and don't-break constraints (physics cadence + network outbound). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/research/2026-05-06-issue-38-handoff.md | 328 +++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/research/2026-05-06-issue-38-handoff.md diff --git a/docs/research/2026-05-06-issue-38-handoff.md b/docs/research/2026-05-06-issue-38-handoff.md new file mode 100644 index 0000000..a2477a6 --- /dev/null +++ b/docs/research/2026-05-06-issue-38-handoff.md @@ -0,0 +1,328 @@ +# 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").