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. // declaration for the full retail trace evidence.
var preIntegratePos = _body.Position; var preIntegratePos = _body.Position;
bool physicsTickRan = false; bool physicsTickRan = false;
float lastTickDt = 0f; // the dt actually integrated this frame (for cached_velocity)
Vector3 oldTickEndPos = _currPhysicsPos; Vector3 oldTickEndPos = _currPhysicsPos;
_physicsAccum += dt; _physicsAccum += dt;
@ -842,6 +843,7 @@ public sealed class PlayerMovementController
// Integrate accumulated dt, clamped to MaxQuantum so a long // Integrate accumulated dt, clamped to MaxQuantum so a long
// pause doesn't produce one giant integration step. // pause doesn't produce one giant integration step.
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum); float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
lastTickDt = tickDt;
// R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) — // R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) —
// PositionManager::adjust_offset (@0x00512d0e) composes the // PositionManager::adjust_offset (@0x00512d0e) composes the
@ -876,6 +878,14 @@ public sealed class PlayerMovementController
// (motion commands, animation, return) runs normally. // (motion commands, animation, return) runs normally.
var postIntegratePos = _body.Position; 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 ───────────── // ── 5. Collision resolution via CTransition sphere-sweep ─────────────
// The Transition system subdivides the movement from pre→post into // The Transition system subdivides the movement from pre→post into
// sphere-radius steps, testing terrain collision at each step. // sphere-radius steps, testing terrain collision at each step.
@ -928,7 +938,23 @@ public sealed class PlayerMovementController
$"isOnGround={resolveResult.IsOnGround}"); $"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; _body.Position = resolveResult.Position;
if (physicsTickRan) if (physicsTickRan)
{ {
@ -939,169 +965,63 @@ public sealed class PlayerMovementController
// UseTime (@0x005159b3) runs AFTER the quantum's integration // UseTime (@0x005159b3) runs AFTER the quantum's integration
// (whose head hosts adjust_offset) and only on frames where a // (whose head hosts adjust_offset) and only on frames where a
// physics quantum executed (retail's update_object MinQuantum // physics quantum executed (retail's update_object MinQuantum
// gate skips the whole UpdateObjectInternal). Diff-review find: // gate skips the whole UpdateObjectInternal).
// 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.
PositionManager?.UseTime(); PositionManager?.UseTime();
} }
// L.3a (2026-04-30): retail wall-bounce / velocity reflection. // SetPositionInternal contact determination (pc:283468-510). acdream's resolver
// // reports IsOnGround even during an UPWARD jump (it always step-downs), so the
// Retail's CPhysicsObj::handle_all_collisions runs after every // contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a
// SetPositionInternal. It reads the wall normal that the // jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the
// transition's slide computed and reflects the body's velocity: // 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
// v_new = v - (1 + elasticity) * dot(v, n) * n // (the old code reflected FIRST, so the reflected +Z defeated the landing gate).
//
// 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}");
}
}
}
}
}
bool justLanded = false; bool justLanded = false;
if (resolveResult.IsOnGround) if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
{ {
if (_body.Velocity.Z <= 0f) bool wasAirborne = !_body.OnWalkable;
{ _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
// Grounded — snap to resolved position and land. _body.calc_acceleration();
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.calc_acceleration();
if (_body.Velocity.Z < 0f) // Stop the fall on landing (retail settles the into-ground component via
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); // 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) 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
{ {
// Moving upward (jump) — stay airborne even though terrain is below. // R4-V5 → R5-V5: retail order — minterp then moveto
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); // (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the
_body.calc_acceleration(); // airborne UseTime contact gate. LeaveGround has NO moveto side (§2e).
Movement.HitGround();
justLanded = true;
} }
} }
else else
{ {
// No ground found — airborne. // Airborne: jumping up (IsOnGround but v.z>0) OR no ground found.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration(); _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 // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's LeaveGround (0x00528b00) —
// LeaveGround (0x00528b00) — jump launches (jump() cleared // jump launches and walk-off-a-ledge both route here. The landing branch above fires
// OnWalkable earlier this frame) AND walk-off-a-ledge both route // HitGround on the opposite edge.
// 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.
if (!_body.OnWalkable && !_wasAirborneLastFrame) if (!_body.OnWalkable && !_wasAirborneLastFrame)
_motion.LeaveGround(); _motion.LeaveGround();

View file

@ -0,0 +1,110 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// End-to-end (Core) proof of the #182 airborne-stuck fix: a jump velocity blocked by a
/// creature BLEEDS to zero within a few frames so gravity can resume — the player falls /
/// glides off instead of hanging in the falling animation. Chains the same steps the
/// PlayerMovementController per-frame loop runs, minus the App layer:
/// integrate (UpdatePhysicsInternal + gravity) → ResolveWithTransition → apply →
/// handle_all_collisions (PhysicsObjUpdate).
/// Before the rebuild the velocity persisted (only an airborne-only reflect that barely
/// touched +Z against a near-horizontal normal); now fs>1 zeros it.
/// </summary>
public class Issue182CrowdJumpTests
{
private readonly ITestOutputHelper _out;
public Issue182CrowdJumpTests(ITestOutputHelper output) => _out = output;
private const uint Lb = 0xA9B40000u;
private const uint Cell = Lb | 0x0001u;
private const float R = 0.48f, H = 1.835f, StepUp = 0.60f, StepDown = 0.04f;
private const float Dt = 1f / 30f; // one physics quantum
private static PhysicsEngine BuildEngine()
{
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
return engine;
}
private static void RegisterCreatureAt(PhysicsEngine e, uint id, Vector3 c)
=> e.ShadowObjects.Register(id, 0u, c, Quaternion.Identity, R,
0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u,
EntityCollisionFlags.IsCreature, isStatic: false);
[Fact]
public void BlockedJump_VelocityBleedsToZero_ThenGravityResumes()
{
var engine = BuildEngine();
RegisterCreatureAt(engine, 0xC0B0u, new Vector3(12f, 10f, 4.5f)); // creature overhead
var body = new PhysicsBody
{
Position = new Vector3(12f, 10f, 3.0f),
Orientation = Quaternion.Identity,
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
TransientState = TransientStateFlags.None, // airborne
Velocity = new Vector3(0f, 0f, 12f), // a jump: moving straight up
};
uint cell = Cell;
bool bled = false;
for (int frame = 0; frame < 20; frame++)
{
// 1) integrate velocity + gravity into a candidate (mirrors the controller's §4).
var pre = body.Position;
body.calc_acceleration();
body.UpdatePhysicsInternal(Dt);
var post = body.Position;
bool candidateMoved = post != pre; // retail candidate != m_position gate
// 2) resolve the candidate against the crowd.
var r = engine.ResolveWithTransition(pre, post, cell, R, H, StepUp, StepDown,
isOnGround: false, body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
// 3) apply + contact determination (mirrors the controller's SetPositionInternal).
bool prevOnWalkable = body.OnWalkable;
body.Position = r.Position;
cell = r.CellId;
if (r.IsOnGround && body.Velocity.Z <= 0f)
{
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);
}
else
{
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
body.calc_acceleration();
}
// 4) handle_all_collisions: reflect (fsf<=1) or zero (fsf>1) — but ONLY when the
// candidate moved (retail skips SetPositionInternal otherwise), so gravity can
// rebuild the velocity after the bleed instead of it being re-zeroed each frame.
if (candidateMoved)
PhysicsObjUpdate.HandleAllCollisions(body,
r.CollisionNormalValid, r.CollisionNormal,
prevContact: false, prevOnWalkable, nowOnWalkable: body.OnWalkable);
_out.WriteLine($"frame{frame,2}: z={body.Position.Z:F3} vz={body.Velocity.Z:F3} " +
$"fsf={body.FramesStationaryFall}");
// The upward jump velocity must collapse to ~0 (or reverse to falling) within a few
// frames of being blocked — the bleed. Before the fix it persisted near +12.
if (frame >= 1 && frame <= 6 && body.Velocity.Z <= 0.5f)
bled = true;
}
Assert.True(bled, "the blocked jump velocity must bleed to ~0 within a few frames (fsf>1 → v=0)");
// After bleeding, gravity has taken over — the body is no longer being shoved upward.
Assert.True(body.Velocity.Z <= 0.5f, $"velocity should not persist upward; got vz={body.Velocity.Z:F3}");
}
}