fix(physics): remote bodies slide on steep faces instead of freezing (#32)

A remote observed in acdream landed on a sloped roof and froze; the server slid
on, the gap passed AP-87's 4 m threshold, and the body snapped — the visible
blip. Live probe capture, two adjacent ticks 63 ms apart:

  t=88420671  rsInContact=True rsOnWalkable=False rsIsOnGround=True
              bodyCpNz=0.6097 floorZ=0.6642 steep=True gravity=True
              vel=(2.146,2.264,-3.549)
  t=88420734  contact=True onWalkable=True   <- forced against the sweep
              gravity=False                   <- cleared
              velBeforeZero=(2.146,2.264,0.000)
              moved=0.0000                    <- and every tick after

The roof is 52.4 degrees against a 48.4 degree limit, so acdream's classifier
was CORRECT and was then overruled. Four independent links each froze the body
on their own: a per-tick force of Contact|OnWalkable, a per-tick velocity zero,
a Gravity clear at landing, and a landing edge testing IsOnGround
(= inContact || ...) instead of OnWalkable. The tick called
HandleAllCollisions alone — the tail of SetPositionInternal without its prefix.

Retail simulates remotes locally and derives these bits rather than asserting
them: CPhysics::UseTime @0x00509950 iterates the whole object table;
update_object @0x00515D10 gates only on parent/cell/FROZEN with no
is_player fork; SetPositionInternal @0x00515330 sets CONTACT from
contact_plane_valid @0x00515430 and ON_WALKABLE from contact_plane.N.z vs
floor_z @0x00515465-@0x0051548E before handle_all_collisions @0x005154FE;
set_on_walkable @0x00511310 fires HitGround @0x00511364 / LeaveGround
@0x00511346 edge-triggered with no ownership gate; calc_acceleration
@0x00510950 zeroes only when CONTACT && ON_WALKABLE && !Sledding @0x0051096B;
calc_friction @0x0050EE70 returns at its first line when ON_WALKABLE is clear.
acdream had copied retail's airborne no-op WITHOUT retail's local simulation.

The fix is mostly deletion: stop forging the transients, stop discarding the
authoritative velocity, stop clearing Gravity, and route the remote tick
through the same SetPositionInternal commit TickHidden and the local player
already use, with the landing edge derived from the sweep's own OnWalkable.
AP-87's threshold and conditions and InterpolationManager's node_fail_counter
snap-to-tail are deliberately untouched — this removes the CAUSE of the
divergence rather than weakening the backstop.

Cross-checked against ACE: its only creature-side VectorUpdate emitters are the
jump broadcast and spell projectiles, so integrating the wire velocity cannot
double-move a walking remote; and PhysicsGlobals.DefaultState already carries
Gravity, so deleting the manufactured State |= Gravity is safe.

Register: AP-81 narrowed (its GRAVITY half retired outright), AP-87 annotated,
AP-139 filed (the interpolation-queue clear on the landing edge), AP-140 filed
(the two routing gates select snap-vs-interpolate on walkability where retail
uses CONTACT — adjust_offset @0x00555D30 gates on transient_state & 1
@0x00555D52). AP-140's follow-up is deliberately shaped as "point the two gates
at Body.InContact", NOT "re-derive Airborne", which would perturb five writers
and collide with a pinned RemoteTeleportPlacementTests assertion.

Three gaps recorded in #32 rather than papered over: the new LeaveGround
dispatch is untested for chatter; a persistently !Ok transition can latch a
remote airborne; and — the visual-gate watch item — the deleted forge was a
blanket guarantee of Contact|OnWalkable, and contact_allows_move @0x00528dd0
silently refuses action animations without both, which is the literal root
cause of closed #270. Retail-correct on a steep face, a regression anywhere
else.

10 discriminating tests over a real PhysicsEngine landblock whose contact
normal Z is 0.61 against FloorZ 0.6642 — the live roof's exact relationship.
Suite 11,019 passed / 4 skipped / 0 failed. Includes the temporary
ACDREAM_PROBE_REMOTE_LANDING / ACDREAM_PROBE_REMOTE_SLIDE probe family that
produced the capture above; strip with the family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 10:21:16 +02:00
parent f058dfc9f9
commit 204d0ae047
11 changed files with 3103 additions and 245 deletions

View file

@ -198,13 +198,25 @@ internal sealed class LiveEntityNetworkUpdateController
// Retail's spawn contact comes from the FIRST GRAVITY FRAME, not the
// placement itself: every retail CPhysicsObj simulates, so a freshly
// placed creature falls the few centimetres onto the floor and the
// transition's touch grants the contact plane. Our stationary remotes
// never run a physics frame (the DR tick resolves only movers), so
// the settle is compressed here: a short downward sweep from the
// server position. Its touch handler produces exactly the state
// retail's first frame would (position snapped onto the floor,
// contact plane + CONTACT/ON_WALKABLE committed below). A sweep that
// finds no floor (true airborne spawn) leaves the body airborne.
// transition's touch grants the contact plane. Our remotes reach that
// state SLOWLY or not at all — the DR tick only sweeps when the
// composed candidate actually moved, so a remote spawned exactly on
// its floor never sweeps and a remote spawned above one needs however
// many ticks gravity takes to close the gap. The settle is therefore
// compressed here: a short downward sweep from the server position.
// Its touch handler produces exactly the state retail's first frame
// would (position snapped onto the floor, contact plane +
// CONTACT/ON_WALKABLE committed below). A sweep that finds no floor
// (true airborne spawn) leaves the body airborne.
//
// Bug B (2026-08-04) weakened — but did not remove — the reason this
// exists. The deleted per-tick `Contact | OnWalkable` forge used to
// make a stationary remote's transients permanent, so a contact-free
// remote could NEVER settle on its own; now gravity survives and one
// WILL settle by itself after a few ticks of falling. This compressed
// settle is what keeps it from spending those ticks visibly
// contact-free, which is the #270 window (`contact_allows_move`
// @0x00528dd0 refuses action animations without both transients).
if (!AcDream.Core.Physics.SpawnPlacementSettler.TrySettle(
_physicsEngine,
remote.Body,
@ -1028,21 +1040,38 @@ internal sealed class LiveEntityNetworkUpdateController
ArgumentNullException.ThrowIfNull(placementDrive);
ArgumentNullException.ThrowIfNull(canonical);
ArgumentNullException.ThrowIfNull(remote);
// Bug B (2026-08-04): stamp the GUID that any [remote-slide-*] line
// emitted from inside this synchronous routing window belongs to —
// ApplyInterpolate (blip producer Candidate 1) has no GUID of its own.
// TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family.
AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution(
canonical.ServerGuid);
if (remote.Airborne)
{
// Verbatim from the pre-4a branch, queue deliberately NOT
// cleared: the arc integrates locally (K-fix15), and clearing
// stale waypoints is owned by the per-tick LANDING detection
// (RuntimeRemotePhysicsUpdater.cs:497-502), not by this snap.
// stale waypoints is owned by the per-tick LANDING detection —
// the `!previousOnWalkable && finalOnWalkable` arm of
// RuntimeRemotePhysicsUpdater.Tick's SetPositionInternal commit,
// whose `rm.Interp.Clear()` is register row AP-139 — not by this
// snap. Cited by SYMBOL on purpose: the same reference was a line
// range twice and went stale both times, once within a single
// review round.
//
// Do NOT restate this as "the queue is already empty here" — it
// is not. Nothing that sets Airborne clears the queue except the
// teleport hook's StopInterpolating: neither the 0xF74E
// VectorUpdate (:1059) nor the three `Airborne = !Body.OnWalkable`
// sites do. A walking NPC can enqueue a near waypoint and then
// step off a lip, arriving here with a populated queue. That is
// exactly why the landing clear exists, and a reader who believes
// the queue is empty here could delete it.
// VectorUpdate (OnVector, below) nor any of the five
// `Airborne = !Body.OnWalkable` sites do. Those five are
// SettleSpawnedRemoteContact (this file),
// RemoteTeleportPlacement.Apply,
// RuntimeSetPositionState's canonical placement commit, and
// RuntimeRemotePhysicsUpdater's two — the SetPositionInternal
// commit in Tick and the TickHidden resolve. A walking NPC can
// enqueue a near waypoint and then step off a lip, arriving here
// with a populated queue. That is exactly why the landing clear
// exists, and a reader who believes the queue is empty here could
// delete it.
remote.Body.Position = worldPos;
remote.Body.Orientation = rotation;
return new RemoteContactRouting(
@ -1257,6 +1286,31 @@ internal sealed class LiveEntityNetworkUpdateController
return;
}
// Bug B (2026-08-04) — [remote-slide-vec]. NOT ESTABLISHED #4 asks
// whether ACE relays a 0xF74E at all while a sender slides; the
// ABSENCE of these lines across a captured slide window is the
// answer, so this sits on the committed path rather than inside the
// +Z airborne branch below (a downhill slide has Velocity.Z < 0 and
// would never reach it). willMarkAirborne restates that branch's own
// test so the log states the outcome rather than making the reader
// re-derive it. Pure reads. TEMPORARY — strip with the probe family.
if (AcDream.Core.Physics.PhysicsDiagnostics.ShouldLogRemoteSlide(
update.Guid))
{
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideVector(
guid: update.Guid,
wireVelocity: update.Velocity,
wireOmega: update.Omega,
willMarkAirborne: update.Velocity.Z > 0.5f,
airborneBefore: rm.Airborne,
contact: rm.Body.InContact,
onWalkable: rm.Body.OnWalkable,
gravity: rm.Body.HasGravity,
bodyVelocity: rm.Body.Velocity,
contactPlaneValid: rm.Body.ContactPlaneValid,
contactPlaneNormalZ: rm.Body.ContactPlane.Normal.Z);
}
// Mark airborne when the launch has meaningful +Z. Threshold
// 0.5 m/s rejects noise / horizontal-only updates (server might
// also use VectorUpdate for non-jump events). The per-tick
@ -1265,12 +1319,26 @@ internal sealed class LiveEntityNetworkUpdateController
if (update.Velocity.Z > 0.5f)
{
rm.Airborne = true;
// Clear ground-contact bits + enable gravity so calc_acceleration
// returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then
// produces the parabolic arc.
// Clear the ground-contact transients so calc_acceleration
// (0x00510950) releases gravity and UpdatePhysicsInternal produces
// the parabolic arc. Retail reaches the same state one frame later
// through check_contact (0x0050F5B0) failing on the ascending
// velocity; clearing them here is the AP-81 head start, and it is
// what keeps the per-tick `set_on_walkable` edge from ALSO firing
// LeaveGround for this same departure.
//
// Bug B (2026-08-04): the `State |= Gravity` that used to follow is
// DELETED. GRAVITY_PS is a persistent object property owned by the
// wire — retail's CPhysicsObj constructor seeds it (state 0x400C08
// @0x00512508) and set_description's set_state (0x00514DD0) assigns
// the description's state wholesale without ever masking it. Now
// that neither landing block clears the bit, manufacturing it here
// would be the only remaining non-retail gravity write, and it
// would mask a server that genuinely sent a gravity-free state.
// ACE agrees: PhysicsGlobals.DefaultState and the player login
// state both carry PhysicsState.Gravity.
rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable);
rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
// R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism.
// The remote's ground departure fires LeaveGround (0x00528b00):
@ -1924,6 +1992,53 @@ internal sealed class LiveEntityNetworkUpdateController
remoteConstraintHost);
}
// Bug B (2026-08-04) — [remote-slide-up]. This is the ONE point
// both remote arms pass through, and it deliberately sits AHEAD of
// the two IsAirborneNoOperation early returns below: in the
// diagnosis's Shape A (ACE reports IsGrounded == false for the
// whole slide) acdream writes nothing at all, so a line emitted
// after those returns would leave the entire slide window blank
// and NOT ESTABLISHED #1 unanswerable. `wireGrounded` is the raw
// ACE PositionFlags.IsGrounded bit for this packet.
// docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2.
// Pure reads. TEMPORARY — strip with the probe family.
if (AcDream.Core.Physics.PhysicsDiagnostics.ShouldLogRemoteSlide(
update.Guid))
{
(int slideQueueDepth, int slideFailCount) =
rmState.Interp.DiagnosticInterpolationState;
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideUp(
guid: update.Guid,
wireGrounded: update.IsGrounded,
wireVelocity: update.Velocity,
disposition: earlyRemoteRoute is { } slideRoute
? slideRoute.Disposition.ToString()
: "unclassified",
playerDistance: _playerController is { } slideController
? System.Numerics.Vector3.Distance(
worldPos,
slideController.Position)
: null,
bodyToTarget: System.Numerics.Vector3.Distance(
rmState.Body.Position,
worldPos),
bodySnapThreshold:
RuntimeRemoteSteadyStatePosition.DiagnosticBodySnapThreshold,
willBeDrTicked: WillAdvanceRemoteMotion(update.Guid, rmState),
firstUp: rmState.LastServerPosTime <= 0.0,
airborne: rmState.Airborne,
contact: rmState.Body.InContact,
onWalkable: rmState.Body.OnWalkable,
gravity: rmState.Body.HasGravity,
bodyVelocity: rmState.Body.Velocity,
contactPlaneValid: rmState.Body.ContactPlaneValid,
contactPlaneNormalZ: rmState.Body.ContactPlane.Normal.Z,
wirePosition: worldPos,
bodyPosition: rmState.Body.Position,
interpQueueDepth: slideQueueDepth,
interpFailCount: slideFailCount);
}
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
@ -2013,15 +2128,36 @@ internal sealed class LiveEntityNetworkUpdateController
}
// ── LANDING TRANSITION ────────────────────────────────────────
// First IsGrounded=true UP after rmState.Airborne signals landed.
// Clear airborne flags, hard-snap to authoritative landing position,
// clear interpolation queue (any pre-jump waypoints are stale).
// First IsGrounded=true UP while the client still considers the
// body airborne (`!Body.OnWalkable`, now derived by the per-tick
// SetPositionInternal commit rather than latched here).
// Hard-snap to the authoritative landing position and clear the
// interpolation queue (an airborne remote's Positions hard-snap
// and never enqueue, so any pre-arc waypoints are stale).
// `rmState.Airborne` is deliberately NOT cleared here: the next
// tick derives it from the sweep, which is the only thing that
// can tell walkable ground from a steep face.
//
// Bug B (2026-08-04) — the twin of the per-tick forge. This
// block used to additionally zero the body velocity, assert
// `Contact | OnWalkable`, invoke MovementManager::HitGround, and
// clear the Gravity STATE bit. All four are deleted:
// • the velocity zero discarded the authoritative vector ACE
// delivered (retail MoveOrTeleport 0x00516330 never reads or
// writes the wire velocity for a remote at all);
// • the transient assert forged the two facts retail derives
// from the contact plane in SetPositionInternal
// (0x00515430 / 0x00515465-0x0051548E) — on a steep roof it
// declared a non-walkable surface walkable;
// • HitGround has exactly ONE retail source,
// `set_on_walkable(1)` @0x00511358, which the per-tick
// SetPositionInternal commit now owns. Firing it from here
// as well would double-dispatch the landing re-apply;
// • retail never toggles GRAVITY_PS on a ground edge — see the
// per-tick commit's comment.
// What remains is AP-87's acdream-only snap, unchanged.
if (rmState.Airborne)
{
rmState.Airborne = false;
rmState.Body.Velocity = System.Numerics.Vector3.Zero;
rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
rmState.Body.Orientation = rot;
@ -2052,16 +2188,10 @@ internal sealed class LiveEntityNetworkUpdateController
entity.ParentCellId = rmState.CellId;
entity.Rotation = rmState.Body.Orientation;
// #161: retail landing = MovementManager::HitGround
// (minterp → moveto, 0x00524300 — the R5-V5 facade
// relay) with the Gravity state bit STILL SET
// (CMotionInterp::HitGround gates on state&0x400). The
// re-apply dispatches the PRESERVED pre-fall forward
// command → landing link → cycle. This replaces the
// forced SetCycle, which read the then-clobbered
// ForwardCommand (Falling) and re-set the pose it meant
// to clear. See the twin block in TickAnimations
// (VU.land).
// The motion bindings still have to exist before the next
// per-tick commit can dispatch this remote's ground edge.
// Only the HitGround CALL moved (see the block comment);
// binding is the packet's own responsibility.
if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
&& aeForLand.Sequencer is not null)
{
@ -2069,11 +2199,14 @@ internal sealed class LiveEntityNetworkUpdateController
}
// Bug A investigation (2026-08-04, docs/ISSUES.md #32):
// capture the exact state HitGround is about to act on —
// see PhysicsDiagnostics.LogRemoteLanding for the field
// list and PhysicsDiagnostics.ProbeRemoteLandingEnabled
// for the discriminator table. TEMPORARY — strip once
// the live-test run has landed.
// the packet-side half of the landing capture. Bug B moved
// the HitGround call itself onto the per-tick
// `set_on_walkable` edge, so `hitGroundInvoked` is now
// false here and the "per-tick" pair is the one that
// reports the dispatch. This line still records the exact
// state the authoritative landing snap installed, which is
// what the discriminator table reads it for.
// TEMPORARY — strip once the live-test run has landed.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled)
{
bool gravitySetForProbe = rmState.Body.HasGravity;
@ -2093,26 +2226,23 @@ internal sealed class LiveEntityNetworkUpdateController
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp(
"controller", update.Guid);
}
}
ulong landingStateAuthorityVersion =
positionRecord.StateAuthorityVersion;
rmState.Movement.HitGround();
if (!IsCurrentPositionOwner(entity)
|| !ReferenceEquals(
positionRecord.RemoteMotionRuntime,
rmState))
{
return;
}
// DR bookkeeping only (partner of the jump-start
// `State |= Gravity`).
if (_liveEntities.IsCurrentStateAuthority(
positionRecord,
landingStateAuthorityVersion))
{
rmState.Body.State &=
~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
// Zero the sink-dispatch latches before reading them
// back. Nothing at THIS site dispatches — the arming
// call lives only next to the per-tick HitGround — so
// without this the line below would print
// sinkApplyCalls/sinkLastMotion/sinkLastResult left
// over from a previous per-tick capture on this
// thread and invite a reader to attribute them to the
// packet. Zeros are the honest report here.
AcDream.Core.Physics.PhysicsDiagnostics
.BeginRemoteLandingDispatchCapture();
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter(
site: "controller",
guid: update.Guid,
hitGroundInvoked: false,
sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0,
sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0,
forwardCommand: rmState.Motion.InterpretedState.ForwardCommand);
}
return;
}