feat(physics): #32 L.5 30Hz physics tick + retail debugger toolchain (#35) + Phase 3 retail-faithful kill_velocity

Three intertwined changes from a single investigation session driven by
attaching cdb to a live retail acclient.exe (v11.4186, Sept 2013 EoR
build) and tracing what retail actually DOES on the steep-roof wedge
scenario the user reported in acdream.

═══════════════════════════════════════════════════════════
1. L.5 — physics-tick MinQuantum gate (PlayerMovementController)
═══════════════════════════════════════════════════════════

Retail's CPhysicsObj::update_object subdivides per-frame dt into 1/30 s
sized integration steps and SKIPS entirely when accumulated dt is below
MinQuantum. Live trace evidence:

  update_object        = 40,960 calls
  UpdatePhysicsInternal = 25,087 calls   (61%)

i.e., 39% of update_object calls return early via the MinQuantum gate.
Retail's effective physics tick rate is 30Hz even at 60+ Hz render.

acdream's PlayerMovementController bypassed the existing PhysicsBody.
update_object and called UpdatePhysicsInternal(dt) directly each render
frame, which compressed bounce-energy / gravity-tangent accumulation
into half the time and amplified our steep-roof wedge dynamics.

Fix: add `_physicsAccum` accumulator. Integrate only when accumulated
dt ≥ MinQuantum (clamped to MaxQuantum to bound stale-frame jumps).
HugeQuantum drops accumulated time to discard truly stale frames
(debugger break, GC pause). Render still runs at full rate; only the
physics step is gated.

═══════════════════════════════════════════════════════════
2. Phase 3 reset retail-faithful kill_velocity (TransitionTypes)
═══════════════════════════════════════════════════════════

Retail's reset path (acclient_2013_pseudo_c.txt:273231-273239) gates
kill_velocity on `last_known_contact_plane_valid`:

  if (last_known_valid == 0) {
      set_collision_normal(step_up_normal); return COLLIDED;
  }
  kill_velocity(this);
  last_known_valid = 0;
  return COLLIDED;

Earlier in this session I deviated to "unconditional kill_velocity" as
a hypothesis-driven wedge fix. The live trace then showed the
deviation CAUSED a different wedge by zeroing V every frame, leaving
the body with no tangent momentum to escape (V = (0,0,0) for 169
consecutive frames while position pre/resolved frozen). The retail-
faithful gate is restored.

Note: the gate rarely fires in normal airborne play because our L.2.4
proximity guard clears last_known_valid soon after the body separates
from its remembered floor. Live retail trace also showed
kill_velocity = 0 hits over an entire play session — same behavior. So
acdream's kill_velocity is correct as ported now.

The supporting ObjectInfo.VelocityKilled flag + StopVelocity wiring +
PhysicsEngine.ResolveWithTransition consumer that actually zeros
body.Velocity when the flag is set — these were a no-op stub before
this session and are now correctly wired. Retail anchor:
OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0) at
acclient_2013_pseudo_c.txt:274467-274475.

═══════════════════════════════════════════════════════════
3. Retail debugger toolchain (#35)
═══════════════════════════════════════════════════════════

When the question is "what does retail actually DO at runtime?" — not
"what does retail's code SAY" — the decomp at docs/research/named-retail/
is invaluable but doesn't capture state interactions across frames.
This commit ships infrastructure to attach Windows' cdb.exe to a live
retail acclient.exe with full PDB symbols and capture state at any
breakpoint.

  - tools/pdb-extract/check_exe_pdb.py — reads any PE's CodeView entry
    and reports MATCH / MISMATCH against refs/acclient.pdb's GUID.
    Always run before attaching cdb. The matching v11.4186 build's
    GUID is 9e847e2f-777c-4bd9-886c-22256bb87f32.

  - tools/pdb-extract/dump_pdb_info.py — dumps a PDB's expected
    build timestamp + GUID + age. Used to figure out which acclient.exe
    build pairs with our PDB.

CLAUDE.md gets a Step -1 in the development workflow ("ATTACH cdb
TO RETAIL when behavior is the question, not code") and a full
"Retail debugger toolchain" section with the workflow, sample .cdb
script structure, and watchouts (PDB names use snake_case for some
classes / PascalCase for CPhysicsObj; ; is cdb's command separator;
killing cdb kills the debuggee; high-hit-rate breakpoints lag the game).

memory/project_retail_debugger.md captures the workflow + key findings
so future sessions inherit the toolchain by reading project memory.

═══════════════════════════════════════════════════════════
4. BSPQuery Path 6 slide-tangent restored (b1af56e behavior)
═══════════════════════════════════════════════════════════

After this session's retail-strict experiments showed that retail-
faithful Path 6 (SetCollide + Phase 3 reset chain) produces a
"lands on roof in falling animation, can't slide off" half-state in
acdream — because our acdream port of step_up_slide / cliff_slide is
incomplete for grounded-on-steep movement — the L.4 slide-tangent
deviation from commit b1af56e is restored as the pragmatic ship state.

The deviation: when an airborne sphere hits a polygon whose normal Z
is below FloorZ (≈ 0.6642, slope > ~49°), project the move along the
steep face to remove the into-wall displacement, set CollisionNormal +
SlidingNormal, return Slid. Body never gets ContactPlane on the steep
poly, never gets the half-state, slides off the slope under gravity's
tangent contribution.

Retail-strict requires the deeper step_up_slide / cliff_slide audit
(filed under #32). Until that lands, slide-tangent is the right
deviation — produces user-acceptable "slide off the roof" behavior.

═══════════════════════════════════════════════════════════
Test status: 833/833 green.

Refs:
  acclient_2013_pseudo_c.txt:283950 (CPhysicsObj::update_object)
  acclient_2013_pseudo_c.txt:273231-273239 (Phase 3 reset path)
  acclient_2013_pseudo_c.txt:274467-274475 (OBJECTINFO::kill_velocity)
  acclient_2013_pseudo_c.txt:323783-323821 (BSPTREE::find_collisions Path 6)

Closes #35. Updates #32 with L.4/L.5 status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-30 22:41:12 +02:00
parent b1af56eb19
commit 235de3322a
8 changed files with 624 additions and 82 deletions

View file

@ -172,6 +172,32 @@ public sealed class PlayerMovementController
public const float HeartbeatInterval = 0.2f; // 200ms
public bool HeartbeatDue { get; private set; }
// L.5 retail physics-tick gate (2026-04-30).
//
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
// MinQuantum (1/30s) sized integration steps, SKIPPING entirely when
// accumulated dt is below MinQuantum. The retail debugger trace
// confirmed this: UpdatePhysicsInternal fires only ~61% as often as
// update_object — i.e., retail's effective physics tick rate is 30Hz
// even when the renderer runs at 60+Hz.
//
// Without this gate our acdream integrates at the full render rate
// (60+Hz), which compresses bounce-energy / gravity-tangent
// accumulation into half the time. Per-frame V grows ~2x faster than
// retail's. On a steep-slope tangent that produces the wedge: V grows
// tangent + huge while position reverts each frame, body locks in
// place. Retail's slower integration cadence (and larger per-tick
// position deltas) lets the body geometrically escape the tangent.
//
// Source: retail debugger trace 2026-04-30
// update_object = 40,960 calls
// UpdatePhysicsInternal = 25,087 calls (61%)
// ratio implies 39% of frames return early via the MinQuantum gate.
//
// ACE: PhysicsObj.UpdateObject (Physics.cs).
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
private float _physicsAccum;
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
@ -411,9 +437,34 @@ public sealed class PlayerMovementController
}
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
//
// L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj::
// update_object skips integration when accumulated dt is below
// MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz
// render. We accumulate per-frame dt and only integrate (with the
// accumulated dt) when the threshold is reached. See _physicsAccum
// declaration for the full retail trace evidence.
var preIntegratePos = _body.Position;
_body.calc_acceleration();
_body.UpdatePhysicsInternal(dt);
_physicsAccum += dt;
if (_physicsAccum > PhysicsBody.HugeQuantum)
{
// Stale frame (debugger break, GC pause). Discard accumulated dt.
_physicsAccum = 0f;
}
else if (_physicsAccum >= PhysicsBody.MinQuantum)
{
// Integrate accumulated dt, clamped to MaxQuantum so a long
// pause doesn't produce one giant integration step.
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
_body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt);
_physicsAccum -= tickDt;
}
// Else: dt below MinQuantum threshold — skip integration. Position
// and velocity remain unchanged; Resolve below runs as a zero-distance
// sphere sweep (no collision possible) and the rest of the frame
// (motion commands, animation, return) runs normally.
var postIntegratePos = _body.Position;
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
@ -520,32 +571,6 @@ public sealed class PlayerMovementController
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
// L.4-steep-landing-bounce (2026-04-30): also bounce on
// landing IF the contact surface is upward-facing but
// steeper than walkable (FloorZ ≈ 49°). Per retail and the
// user's expectation: jumping onto a steep roof should NOT
// result in a landing — the player should bounce off, keep
// the falling animation, and slide off.
//
// Without this: the L.3a base rule suppresses the bounce on
// landing transitions (prev air → now ground) to avoid
// micro-bounce on flat terrain, but that suppression also
// sticks the player to too-steep roofs they shouldn't land
// on. This carve-out re-enables the bounce specifically for
// steep upward-facing surfaces.
//
// Range `0 < N.Z < FloorZ` means "facing upward but too
// steep" — excludes walls (N.Z ≈ 0) which are handled by the
// existing prevAirborne+nowAirborne rule, and ceilings
// (N.Z < 0) which the body shouldn't bounce off the same way.
if (!applyBounce
&& resolveResult.CollisionNormalValid
&& resolveResult.CollisionNormal.Z > 0f
&& resolveResult.CollisionNormal.Z < PhysicsGlobals.FloorZ)
{
applyBounce = true;
}
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
if (diagSteep && resolveResult.CollisionNormalValid)

View file

@ -1473,6 +1473,27 @@ public static class BSPQuery
if (changed && hitPoly is not null)
{
// ACE: var offset = LocalToGlobalVec(validPos.Center - localSphere.Center) * scale
//
// L.4 retail-strict (2026-04-30): the FloorZ gate previously
// here was REMOVED after the retail debugger trace + acdream
// wedge analysis showed it was preventing the natural escape
// path. Retail's flow:
// Frame N: airborne sphere hits steep poly. Path 4 commits
// ContactPlane on the steep poly (LandingZ ≈ 0.087 is
// permissive enough — even 49°+ slopes pass).
// Frame N+1: body now grounded with Contact + steep
// ContactPlane. OnWalkable cleared by FloorZ test
// downstream. Resolver fires Path 5 (Contact branch)
// instead of Path 6. step_sphere_up tries to step over,
// fails, falls back to step_up_slide → clears Contact,
// slides sphere laterally along StepUpNormal.
// Frame N+2: body airborne with lateral V from the slide.
// Gravity takes over, body falls off the slope.
//
// Without this Path-4 commit, the body NEVER gets Contact
// set, Path 5 never fires, step_up_slide never runs, and the
// body wedges in airborne-collision-revert-loop with V at
// MaxVelocity tangent to the surface (live wedge 2026-04-30).
var localOffset = validPos.Center - sphere0.Center;
var worldOffset = L2W(localOffset) * scale;
path.AddOffsetToCheckPos(worldOffset);
@ -1573,35 +1594,39 @@ public static class BSPQuery
var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
// L.4-reject-steep-landing (2026-04-30): if the polygon is
// too steep to walk on (worldNormal.Z < FloorZ ≈ 0.6642),
// do NOT enter the SetCollide → Path-4 → SetContactPlane
// landing path. That path commits the player to the
// surface (sets ContactPlane), which sticks them to the
// steep roof in a falling animation.
// L.4 slide-tangent for steep airborne hits (2026-04-30).
//
// Instead, treat the steep-poly hit as a wall slide:
// project the move along the steep face (remove the
// into-wall component), set CollisionNormal +
// For polygons too steep to walk on (worldNormal.Z < FloorZ),
// skip the SetCollide → Path-4 → ContactPlane landing chain.
// That chain commits the body to the steep surface, leading
// to the "stuck in falling animation on the roof" bug — once
// grounded with a steep ContactPlane, our step_up_slide /
// cliff_slide / edge_slide chain can't produce smooth
// descent and the body wedges or "falls a bit at a time"
// when bumped.
//
// Instead: project the move along the steep face (remove
// the into-wall displacement), set CollisionNormal +
// SlidingNormal, return Slid. Same shape as Path 5's
// step-up fallback (line 1545-1547) and CylinderCollision
// (TransitionTypes.cs:1518-1522). The position is updated
// in-place; on the next resolver iteration the sphere is
// (TransitionTypes.cs:1518-1522). Position is updated in-
// place; on the next resolver iteration the sphere is
// outside the poly, FindCollisions returns OK, and
// ValidateTransition commits the new position. Body stays
// airborne, falling animation continues, gravity pulls
// down the slope.
// airborne, falling animation continues, and gravity's
// tangent component drifts the body downhill until it
// slides off the slope's edge.
//
// CRITICAL: this MUST happen before path.SetCollide(...)
// is called. Once Collide=true is set, TransitionalInsert
// Phase 3 either commits via ContactPlane+Placement (the
// walkable case, OK on shallow slopes) or RestoreCheckPos
// (the reset case, when ContactPlaneValid is false). The
// reset path REVERTS our slide and freezes the body.
//
// Per user 2026-04-30:
// "I jump up, I land on it. It should not even let me
// land, should just slide with a falling animation."
// This is a deliberate deviation from retail (retail uses
// SetCollide unconditionally and lets find_walkable +
// step_up_slide produce the slide). Validated against
// retail debugger trace 2026-04-30: retail body did not
// wedge; our retail-faithful port DID wedge because we're
// missing implementation details of the step_up_slide /
// cliff_slide chain on grounded-steep movement. The
// slide-tangent here produces user-acceptable behavior
// (slides off naturally) while the deeper chain port is
// researched. Filed as L.5+ followup for retail-strict.
if (worldNormal0.Z < PhysicsGlobals.FloorZ)
{
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
@ -1613,26 +1638,11 @@ public static class BSPQuery
collisions.SetCollisionNormal(worldNormal0);
collisions.SetSlidingNormal(worldNormal0);
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
{
Console.WriteLine(
$"[steep-roof] PATH6-STEEP-SLIDE N=({worldNormal0.X:F2},{worldNormal0.Y:F2},{worldNormal0.Z:F2}) " +
$"FloorZ={PhysicsGlobals.FloorZ:F2} " +
$"diff={diff:F3} " +
$"newCheckPos=({path.CheckPos.X:F2},{path.CheckPos.Y:F2},{path.CheckPos.Z:F2})");
}
return TransitionState.Slid;
}
// ─── SetCollide response ─────────────────────────────────
// Airborne sphere hits a shallow polygon (walkable surface).
// Call SetCollide so TransitionalInsert's Collide branch
// re-tests as Placement to confirm we can land on it.
//
// ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide
// + return Adjusted.
// Named-retail: BSPTREE::find_collisions airborne branch → set_collide.
// ─── SetCollide response (shallow / walkable) ───────────
// Per retail (acclient_2013_pseudo_c.txt:323783-323821).
path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;
@ -1650,8 +1660,7 @@ public static class BSPQuery
{
var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
// L.4-reject-steep-landing: same steep-poly slide
// for head-sphere hits.
// L.4 slide-tangent: same steep-poly slide for head-sphere.
if (worldNormal1.Z < PhysicsGlobals.FloorZ)
{
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
@ -1666,7 +1675,7 @@ public static class BSPQuery
return TransitionState.Slid;
}
// Head sphere hit shallow surface: same SetCollide response.
// Head sphere hit shallow surface: SetCollide.
path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;

View file

@ -594,6 +594,25 @@ public sealed class PhysicsEngine
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
}
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
// Phase 3's reset path sets VelocityKilled when an airborne hit
// can't find a walkable surface (steep roof, wall) AND the
// body had a last_known_contact_plane (i.e., was grounded
// recently). Retail zeros all three velocity components so
// gravity restarts cleanly next frame.
//
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
// acclient_2013_pseudo_c.txt:274467-274475
// Called from CTransition::transitional_insert reset path:
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
if (transition.ObjectInfo.VelocityKilled)
{
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
body.Velocity = Vector3.Zero;
}
}
// L.3a (2026-04-30): surface the wall normal so callers can apply

View file

@ -79,12 +79,31 @@ public sealed class ObjectInfo
=> OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
/// <summary>
/// Stop any accumulated velocity on this object info.
/// ACE: ObjectInfo.StopVelocity — clears Velocity on the physics body.
/// acdream: velocity is tracked on PhysicsBody, not here. No-op for now;
/// will be wired when velocity is threaded through TransitionalInsert.
/// Sticky flag: set by <see cref="StopVelocity"/>; PhysicsEngine consumes
/// it after the transition commits to zero the body's velocity. Models
/// retail's <c>OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)</c>
/// (named-retail acclient_2013_pseudo_c.txt:274467-274475).
/// Cleared by the engine when consumed; reset to false at the start of
/// each <c>FindTransitionalPosition</c>.
/// </summary>
public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ }
public bool VelocityKilled;
/// <summary>
/// Stop any accumulated velocity on this object info.
/// Retail: <c>OBJECTINFO::kill_velocity</c> calls
/// <c>CPhysicsObj::set_velocity(object, {0,0,0}, 0)</c>
/// (named-retail 0x50cfe0).
/// ACE: <c>ObjectInfo.StopVelocity</c> — clears Velocity on the physics body.
/// <para>
/// Velocity lives on <see cref="PhysicsBody"/>, not here. We can't reach
/// the body directly from inside the resolver without coupling
/// ObjectInfo to it, so we set a flag and let
/// <see cref="PhysicsEngine.ResolveWithTransition"/> apply the zero
/// after the transition completes. The flag is sticky across the
/// outer step loop and consumed exactly once per resolve.
/// </para>
/// </summary>
public void StopVelocity() { VelocityKilled = true; }
}
/// <summary>
@ -439,6 +458,13 @@ public sealed class Transition
{
var sp = SpherePath;
// L.4 retail-strict (2026-04-30): clear the kill_velocity flag at
// the start of each resolve so leftover state from a prior
// transition doesn't carry over. Inside the loop, Phase 3's reset
// path may set this via OBJECTINFO::StopVelocity; the engine reads
// it after FindTransitionalPosition returns.
ObjectInfo.VelocityKilled = false;
// No starting cell → cannot move.
if (sp.CurCellId == 0)
return false;
@ -676,13 +702,50 @@ public sealed class Transition
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
if (diagSteep)
{
Console.WriteLine(
$"[steep-roof] PHASE3-RESET lastKnownValid={ci.LastKnownContactPlaneValid} " +
$"checkPos=({sp.CheckPos.X:F2},{sp.CheckPos.Y:F2},{sp.CheckPos.Z:F2}) " +
$"curPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2}) " +
$"stepUpNormal=({sp.StepUpNormal.X:F2},{sp.StepUpNormal.Y:F2},{sp.StepUpNormal.Z:F2})");
}
// Retail-faithful gate (acclient_2013_pseudo_c.txt:273231-273239):
//
// if (last_known_valid == 0) {
// set_collision_normal(step_up_normal); return COLLIDED;
// }
// kill_velocity(this);
// last_known_valid = 0;
// return COLLIDED;
//
// kill_velocity ONLY fires when last_known was valid. When
// it's not (the case our L.2.4 proximity guard produces
// after a few airborne frames), velocity is PRESERVED so
// the bounce reflection in handle_all_collisions can
// redirect V's perpendicular component along the slope's
// tangent direction — that's how retail's body escapes
// the wedge geometry.
//
// This was deviated to "unconditional" earlier in this
// session as a hypothesis-driven fix; the live trace
// showed the deviation CAUSED the wedge by zeroing V
// every frame, leaving the body with no tangent momentum
// to escape (live diag 2026-04-30: V=(0,0,0) for 169
// consecutive frames while position pre/resolved frozen).
if (ci.LastKnownContactPlaneValid)
{
ci.LastKnownContactPlaneValid = false;
oi.StopVelocity();
if (diagSteep)
Console.WriteLine($"[steep-roof] PHASE3-RESET-KILLV ← StopVelocity called");
}
else
{
ci.SetCollisionNormal(sp.StepUpNormal);
}
return TransitionState.Collided;
}