Merge branch 'claude/jovial-chebyshev-d1d9da' — #38 handoff doc
Adds the agent handoff prompt for Issue #38 (chase-camera 30 fps regression from L.5's physics-tick gate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
88f56d1ead
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