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>
This commit is contained in:
Erik 2026-05-06 17:03:43 +02:00
parent 0bd9b9693b
commit 50da2bb81d

View file

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