fix(physics): AD-25 - remote collision response through ported HandleAllCollisions

Campaign P Slice P3 item 2. CPhysicsObj::handle_all_collisions
(0x00514780, pc:282647) is one uniform function retail calls
unconditionally after every SetPositionInternal, player or remote. The
gate is shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding).

RuntimeRemotePhysicsUpdater.Tick's post-resolve reflect was still the
2026-07-05 (#173) hand-inlined block, gated on
resolveResult.CollisionNormalValid and using two ad-hoc branches that
diverge from retail in exactly the cases the register row named:
  - non-sledding: old = "!prevOnWalkable && !nowOnWalkable" (reflects
    ONLY airborne-before-AND-after); retail reflects on every transition
    except grounded-before-AND-after.
  - sledding: old = "!(prevOnWalkable && nowOnWalkable)" (suppresses the
    bounce exactly when both grounded); retail's "!sledding" term forces
    shouldReflect = true unconditionally when sledding, the opposite
    polarity.

Both gaps meant a remote's post-landing reflect never ran on a
grounded-transition tick at all -- the "acdream lands clean and dead"
half of #166's slope-landing composite.

Replace the hand-inlined block with a direct call to
PhysicsObjUpdate.HandleAllCollisions -- the same verbatim port the
local player and every ordinary body already use via
CommitSetPositionTransition -- passing the same
prevContact/prevOnWalkable/nowOnWalkable values the old code already
computed. Narrower swap per the research's explicit recommendation:
does not fold in CommitSetPositionTransition's HitGround/LeaveGround
dispatch, leaving the remote's bespoke landing-detection block
(interp-queue-clear, animation-hook-specific logic) untouched. The call
is now unconditional (matching retail's own unconditional call site)
rather than gated behind CollisionNormalValid, since HandleAllCollisions
already no-ops the reflect step internally when no normal was found but
still runs the frames-stationary-fall bleed regardless.

PhysicsObjUpdate.HandleAllCollisionsTests already exhaustively pins the
retail formula in isolation; this change is a mechanical wiring swap to
the already-tested function using values the removed block already
computed. Full regression suites (Core.Tests 3991/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip) pass unchanged -- no existing test pinned
the old broken formula.

Register: AD-25 retired (both the local-player and remote halves are now
the ported HandleAllCollisions); #166's reattribution note updated to
reflect the closure, leaving only TS-4 as the remaining blocker on that
issue's downhill-jump-glide acceptance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 09:21:53 +02:00
parent dae5b1ea68
commit 8b5425498c
3 changed files with 65 additions and 67 deletions

View file

@ -334,20 +334,25 @@ internal sealed class RuntimeRemotePhysicsUpdater
// setup.Height) × ObjScale — the creature's own dat Setup
// scaled by its wire ObjScale, the same source the local
// player + moveto/sticky use, and consistent with the
// spawn-time shadow registration's entScale. Retail seeds
// the transition from the object's own Setup sphere list ×
// m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
// ObjScale from set_description 0x00514f40). This narrows
// TS-46 (remotes no longer use human dims); the two-scalar
// API is still a lossy stand-in for retail's full (≤2)
// sphere list, and stepUp/stepDown stay 0.4 (retail derives
// those from the Setup too — an adjacent divergence left as-is).
// spawn-time shadow registration's entScale. TS-46 (2026-07-30)
// closed the remaining residual: sphereList/sphereScale below
// now carry the Setup's own verbatim sphere list (falling back
// to this deR/deH reconstruction only when the Setup has no
// sphere rows), and stepUpHeight/stepDownHeight are
// Setup-derived rather than a hardcoded 0.4f.
// Fallback to the human capsule for a shapeless / unresolvable
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
// degenerate the sweep.
float deR = radius;
float deH = height;
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
// AD-25 (2026-07-30): retail handle_all_collisions (pc:282647)
// reads the mover's own transient_state CONTACT_TS/
// ON_WALKABLE_TS bits BEFORE this SetPositionInternal-equivalent
// resolve — capture them here, matching TickHidden's identical
// pre-resolve capture below.
bool previousContact = rm.Body.InContact;
bool previousOnWalkable = rm.Body.OnWalkable;
var resolveResult = _physics.Engine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: deR,
@ -425,54 +430,35 @@ internal sealed class RuntimeRemotePhysicsUpdater
// to actually-moving remotes — the perf risk the review flagged for
// a packed town. (In-place shadow-move + cell-relink-on-change is a
// further optimization if profiling still shows churn.)
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
// (pc:282699-282715) runs after EVERY SetPositionInternal —
// remote objects included; a VectorUpdate-launched jump arc
// is ordinary object physics in retail. acdream ported the
// velocity reflection for the LOCAL player only (L.3a,
// PlayerMovementController ~:940), so a remote jumping into
// a dungeon ceiling had its POSITION pinned by the sweep
// while its +Z velocity kept integrating — the char hovered
// at the roof until gravity burned the arc off, landing
// late (user report, 0x0007 dungeon). Mirror the local
// site exactly:
// v_new = v (1 + elasticity)·dot(v, n)·n
// with the AD-25 suppression (bounce only when airborne
// before AND after — corridor slides and landings don't
// reflect; the landing snap below keeps its
// `Velocity.Z <= 0` gate intact). Inelastic movers
// (missiles, later) zero out instead.
if (resolveResult.CollisionNormalValid)
{
bool prevOnWalkable = rm.Body.OnWalkable;
bool nowOnWalkable = resolveResult.IsOnGround;
bool applyBounce = rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
if (applyBounce)
{
if (rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
{
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
var vRem = rm.Body.Velocity;
var nRem = resolveResult.CollisionNormal;
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
if (dotVN < 0f)
{
rm.Body.Velocity =
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine(
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
}
}
}
}
// AD-25 (2026-07-30): retail CPhysicsObj::handle_all_collisions
// (0x00514780, pc:282647) runs UNCONDITIONALLY after EVERY
// SetPositionInternal — remote objects included; a
// VectorUpdate-launched jump arc is ordinary object physics in
// retail. #173 (2026-07-05) first mirrored the local player's
// reflect math here by hand, but with a narrower gate than
// retail's: `shouldReflect = !(prevOnWalkable && nowOnWalkable
// && !sledding)` collapses to the two ad-hoc branches this
// block used to hand-roll, and got BOTH wrong — the sledding
// branch suppressed the bounce exactly when retail's
// `!sledding` term forces it UNCONDITIONALLY, and the
// non-sledding branch only reflected airborne→airborne where
// retail reflects on every transition except grounded→grounded.
// PhysicsObjUpdate.HandleAllCollisions is the same verbatim
// port the local player and every ordinary body already use
// (PhysicsObjUpdate.CommitSetPositionTransition); call it
// directly instead of re-deriving the gate. It already
// no-ops the reflect step when collisionNormalValid is false,
// but — unlike the old wrapper this replaces — still runs the
// fsf&gt;1 unconditional velocity-zero "bleed" regardless of
// whether this tick found a collision normal, matching
// retail's own unconditional call site.
AcDream.Core.Physics.PhysicsObjUpdate.HandleAllCollisions(
rm.Body,
resolveResult.CollisionNormalValid,
resolveResult.CollisionNormal,
previousContact,
previousOnWalkable,
resolveResult.IsOnGround);
// K-fix15 (2026-04-26): post-resolve landing
// detection for airborne remotes. Mirrors