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:
parent
0bd9b9693b
commit
50da2bb81d
1 changed files with 328 additions and 0 deletions
328
docs/research/2026-05-06-issue-38-handoff.md
Normal file
328
docs/research/2026-05-06-issue-38-handoff.md
Normal 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").
|
||||
Loading…
Add table
Add a link
Reference in a new issue