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:
parent
78df1370b6
commit
54d5622960
2 changed files with 177 additions and 147 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue