acdream/docs/research/2026-05-06-issue-38-handoff.md
Erik 50da2bb81d docs(research): #38 handoff prompt for next-session agent
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) <noreply@anthropic.com>
2026-05-06 17:03:43 +02:00

15 KiB

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:

// 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:

public const float MinQuantum         = 1.0f / 30.0f;   // ~0.0333 s

to

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.

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)

// 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:130Position => _body.Position exposed property. Add RenderPosition or extend the result struct returned from Update.
  • src/AcDream.App/Input/PlayerMovementController.cs:289SetPosition. 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-76MinQuantum / 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)

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").