feat(#182): route player collision response through the ported UpdateObjectInternal chain (Slice 2b/2c)

Replaces PlayerMovementController's ad-hoc airborne-only reflect + Velocity.Z landing
snap with the verbatim retail chain: cached_velocity = (resolved-old)/dt (separate
reporting value), SetPositionInternal contact determination (kept Velocity.Z<=0 gate for
acdream's always-step-down resolver), then handle_all_collisions (fsf<=1 reflect / fsf>1
zero — the airborne-stuck bleed). Contact is committed BEFORE the reflect so the landing
gate isn't defeated by a reflected +Z; that ordering + the ungated small-velocity-zero
(Slice 1a) retire AD-25's micro-bounce spiral.

Load-bearing: handle_all_collisions + cached_velocity are gated on candidateMoved (retail
UpdateObjectInternal pc:283657 only reaches SetPositionInternal when the integrated
candidate moved off m_position). After fsf>1 zeros a blocked jump, the next frame
integrates zero motion (velMag2==0), so the candidate hasn't moved — skipping the response
that frame lets gravity rebuild the velocity instead of re-zeroing it and re-wedging.

End-to-end Core test (Issue182CrowdJumpTests): a jump blocked by an overhead creature
bleeds its +12 up-velocity to ~0 within a couple frames (fsf>1) and the body grounds on
the manufactured plane instead of hanging with persistent +12. Core 2617 / App 741 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-07 14:25:47 +02:00
parent 78df1370b6
commit 54d5622960
2 changed files with 177 additions and 147 deletions

View file

@ -827,6 +827,7 @@ public sealed class PlayerMovementController
// declaration for the full retail trace evidence.
var preIntegratePos = _body.Position;
bool physicsTickRan = false;
float lastTickDt = 0f; // the dt actually integrated this frame (for cached_velocity)
Vector3 oldTickEndPos = _currPhysicsPos;
_physicsAccum += dt;
@ -842,6 +843,7 @@ public sealed class PlayerMovementController
// Integrate accumulated dt, clamped to MaxQuantum so a long
// pause doesn't produce one giant integration step.
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
lastTickDt = tickDt;
// R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) —
// PositionManager::adjust_offset (@0x00512d0e) composes the
@ -876,6 +878,14 @@ public sealed class PlayerMovementController
// (motion commands, animation, return) runs normally.
var postIntegratePos = _body.Position;
// retail UpdateObjectInternal (pc:283657): the transition + handle_all_collisions are
// reached ONLY when the integrated candidate actually MOVED off m_position. This gate
// is load-bearing for the #182 bleed: after fsf>1 zeros a blocked jump's velocity, the
// next frame integrates zero motion (velMag2==0 → no position step, just v += gravity),
// so the candidate hasn't moved yet — handle_all_collisions MUST be skipped that frame
// or it re-zeros the gravity velocity and the body re-wedges instead of falling off.
bool candidateMoved = postIntegratePos != preIntegratePos;
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
// The Transition system subdivides the movement from pre→post into
// sphere-radius steps, testing terrain collision at each step.
@ -928,7 +938,23 @@ public sealed class PlayerMovementController
$"isOnGround={resolveResult.IsOnGround}");
}
// Apply resolved position.
// ── Apply the resolve: retail UpdateObjectInternal (0x005156b0) →
// SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780).
// #182 verbatim rebuild: this REPLACES the ad-hoc airborne-only bounce with the
// frames_stationary_fall-driven velocity model — the airborne-stuck bleed
// (fsf>1 → velocity zeroed) that lets a blocked jump fall/glide off a monster crowd.
// cached_velocity = realized displacement / dt (retail's SEPARATE reporting/DR value,
// pc:005158cb-5158ff; not fed back into the integrator velocity).
_body.CachedVelocity = (candidateMoved && physicsTickRan && lastTickDt > 0f)
? (resolveResult.Position - preIntegratePos) / lastTickDt
: Vector3.Zero;
// Capture prev contact/walkable BEFORE committing the new state — retail
// SetPositionInternal reads transient_state at entry for handle_all_collisions' args.
bool prevContact = _body.InContact;
bool prevOnWalkable = _body.OnWalkable;
_body.Position = resolveResult.Position;
if (physicsTickRan)
{
@ -939,169 +965,63 @@ public sealed class PlayerMovementController
// UseTime (@0x005159b3) runs AFTER the quantum's integration
// (whose head hosts adjust_offset) and only on frames where a
// physics quantum executed (retail's update_object MinQuantum
// gate skips the whole UpdateObjectInternal). Diff-review find:
// a head-of-frame placement tore an expiring stick down BEFORE
// the crossing quantum's final steer/turn — retail applies that
// last adjust_offset, THEN the tail watchdog tears down.
// gate skips the whole UpdateObjectInternal).
PositionManager?.UseTime();
}
// L.3a (2026-04-30): retail wall-bounce / velocity reflection.
//
// Retail's CPhysicsObj::handle_all_collisions runs after every
// SetPositionInternal. It reads the wall normal that the
// transition's slide computed and reflects the body's velocity:
//
// v_new = v - (1 + elasticity) * dot(v, n) * n
//
// This is what gives retail its "bouncy" feel — fast head-on
// jumps push the player back from the wall, glancing angles
// produce a small deflection. acdream's transition resolver
// SLID position correctly but never updated velocity, so the
// player kept driving into walls until the controller's input
// changed direction. Felt sticky / fragile.
//
// Suppression rule (apply_bounce): grounded movement on a wall
// SHOULDN'T bounce — sliding along a corridor is expected. Only
// airborne wall hits reflect. Mirrors retail's `var_10_1` guard
// and ACE PhysicsObj.cs:2656-2660 `apply_bounce`.
//
// Inelastic flag (spell projectiles, missiles) zeros velocity
// entirely instead of reflecting. The player never has it set.
//
// Sources:
// acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions)
// acclient.h:2834 (INELASTIC_PS = 0x20000)
// ACE PhysicsObj.cs:2656-2721 (line-for-line port)
// PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f
if (resolveResult.CollisionNormalValid)
{
bool prevOnWalkable = _body.OnWalkable;
bool nowOnWalkable = resolveResult.IsOnGround;
// apply_bounce: bounce ONLY when the body stays airborne both
// before and after this step. That is: jumping into a wall
// mid-flight, hitting a ceiling, etc. Specifically NOT:
//
// - prev grounded + now grounded → wall-slide along corridor
// (bounce would feel sticky on every wall touch).
// - prev airborne + now grounded → terrain landing
// (terrain normal is mostly +Z; reflecting downward velocity
// would push the body upward and prevent the landing snap
// from firing — player perpetually micro-bouncing on the
// floor instead of resting).
// - prev grounded + now airborne → walked off cliff
// (gravity should take over, not lateral bounce).
//
// Sledding mode reverts to retail's broader rule (bounce
// unless both grounded), since sledding intentionally bounces
// off ramps.
//
// This is more conservative than retail's strict
// `!(prev && now && !sledding)` rule — retail bounces on
// landing too, but at elasticity 0.05 the visual effect is
// imperceptible there. acdream's per-frame architecture
// amplifies the artifact (the post-reflection upward Z
// defeats the controller's `Velocity.Z <= 0` landing-snap
// gate), so we suppress it on landing to avoid the
// micro-bounce death spiral.
bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding)
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
bool diagSteep = AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled;
if (diagSteep && resolveResult.CollisionNormalValid)
{
var n0 = resolveResult.CollisionNormal;
var v0 = _body.Velocity;
Console.WriteLine(
$"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " +
$"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " +
$"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " +
$"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " +
$"dot={Vector3.Dot(v0, n0):F3} " +
$"isOnGround={resolveResult.IsOnGround}");
}
if (applyBounce)
{
if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
{
// Full stop on impact. Spell projectiles / missiles.
_body.Velocity = Vector3.Zero;
}
else
{
var v = _body.Velocity;
var n = resolveResult.CollisionNormal;
float dotVN = Vector3.Dot(v, n);
if (dotVN < 0f)
{
// Reflect the into-wall component back out.
// Player elasticity is 0.05 → 105% of perpendicular
// velocity reflects (subtle bounce).
float k = -(dotVN * (_body.Elasticity + 1f));
_body.Velocity = v + n * k;
if (diagSteep)
{
var v1 = _body.Velocity;
Console.WriteLine(
$"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}");
}
}
}
}
}
// SetPositionInternal contact determination (pc:283468-510). acdream's resolver
// reports IsOnGround even during an UPWARD jump (it always step-downs), so the
// contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a
// jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the
// landing state is committed before any reflect — this ordering plus the ungated
// small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral
// (the old code reflected FIRST, so the reflected +Z defeated the landing gate).
bool justLanded = false;
if (resolveResult.IsOnGround)
if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
{
if (_body.Velocity.Z <= 0f)
{
// Grounded — snap to resolved position and land.
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.calc_acceleration();
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.calc_acceleration();
if (_body.Velocity.Z < 0f)
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
// Stop the fall on landing (retail settles the into-ground component via
// calc_friction next frame; the hand-zero avoids a one-frame floor dip and makes
// handle_all_collisions' landing reflect a no-op — dot(v,n)=0).
if (_body.Velocity.Z < 0f)
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
if (wasAirborne)
{
// R4-V5 → R5-V5: retail order — minterp first, then
// moveto (MovementManager::HitGround 0x00524300, decomp
// §2d — now the literal facade relay); re-arms a moveto
// suspended by the airborne UseTime contact gate.
// LeaveGround has NO moveto side (COMDAT no-op, §2e) —
// do not add one.
Movement.HitGround();
justLanded = true;
}
}
else
if (wasAirborne)
{
// Moving upward (jump) — stay airborne even though terrain is below.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration();
// R4-V5 → R5-V5: retail order — minterp then moveto
// (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the
// airborne UseTime contact gate. LeaveGround has NO moveto side (§2e).
Movement.HitGround();
justLanded = true;
}
}
else
{
// No ground found — airborne.
// Airborne: jumping up (IsOnGround but v.z>0) OR no ground found.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration();
}
// handle_all_collisions (0x00514780): reflect the into-surface velocity (fsf≤1) or
// ZERO it entirely (fsf>1 — THE airborne-stuck fix). The Stationary* bit round-trip is
// owned by the Core resolve writeback. Restores retail's should_reflect rule; on a
// landing the Velocity.Z hand-zero above makes the reflect a no-op (no micro-bounce).
// Gated on candidateMoved (retail SetPositionInternal is only reached when the candidate
// moved) so a no-move frame doesn't re-zero the gravity velocity rebuilding after a bleed.
if (candidateMoved)
{
PhysicsObjUpdate.HandleAllCollisions(
_body,
resolveResult.CollisionNormalValid, resolveResult.CollisionNormal,
prevContact, prevOnWalkable, nowOnWalkable: _body.OnWalkable);
}
// R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's
// LeaveGround (0x00528b00) — jump launches (jump() cleared
// OnWalkable earlier this frame) AND walk-off-a-ledge both route
// here, replacing the old manual jump-block call (and giving
// ledge departures the momentum fallback + link strip they never
// had). Edge = grounded last frame, airborne now; the landing
// branch above already fires HitGround on the opposite edge.
// R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's LeaveGround (0x00528b00) —
// jump launches and walk-off-a-ledge both route here. The landing branch above fires
// HitGround on the opposite edge.
if (!_body.OnWalkable && !_wasAirborneLastFrame)
_motion.LeaveGround();