fix(physics): route remote Positions on contact, not walkability (AP-140)

The two gates that decide whether an accepted remote Position is interpolated
or hard-snapped read `Airborne`, which is `!Body.OnWalkable` — WALKABILITY.
Retail reads CONTACT: InterpolationManager::adjust_offset @0x00555D30 gates its
entire body on `transient_state & 1` @0x00555D52, so a retail body in contact
with a non-walkable face still interpolates.

The two predicates disagree in exactly one state — in contact, not on walkable
ground — which 204d0ae0 turned from unreachable into ordinary. Before it, the
per-tick forge made every non-airborne remote walkable by construction, so the
disagreement could not occur.

Both gates now read `!Body.InContact`: ApplyRemoteContactRouting's flight
carve-out and OnPosition's player-remote arm.

`Airborne` is deliberately NOT re-derived from CONTACT. That would perturb all
five of its writers and contradict a pinned assertion in
RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_ (InContact:
true, OnWalkable: false -> Assert.True(remote.Airborne)); a previous
implementer attempted it and correctly backed out rather than editing the
assertion. This narrower shape touches no existing test.

AP-140's register row is retired in this commit, as the row itself specified.

Honest scope: this is a faithfulness fix, not a visible one. ACE derives its
IsGrounded flag with the same floor_z test, so during a slide it almost
certainly reports not-grounded, the classifier returns NoPositionOperation, and
neither arm is taken. Expect no observable change against ACE.

Suite 11,027 passed / 4 skipped / 0 failed (baseline 11,023).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 11:53:48 +02:00
parent f4f2579575
commit 2eb39a0250
6 changed files with 273 additions and 33 deletions

View file

@ -945,14 +945,22 @@ internal sealed class LiveEntityNetworkUpdateController
/// arm selection testable and must not be collapsed to a bool.</summary>
internal enum RemoteContactArm : byte
{
/// <summary>The body was airborne. Hard-snapped, exactly as before
/// route 4a. Gated on <c>remote.Airborne</c> ALONE — the wire contact
/// bit is never read here. The common case is the landing packet, but
/// a not-in-contact packet also reaches this arm whenever the
/// classification is one route 4a does not own (null, cell-less
/// <summary>The body was in free flight — NOT in contact with any
/// surface. Hard-snapped. Gated on the body's own
/// <c>Body.InContact</c> ALONE (AP-140, retired 2026-08-04) — the wire
/// contact bit is never read here. The common case is the landing
/// packet, but a not-in-contact packet also reaches this arm whenever
/// the classification is one route 4a does not own (null, cell-less
/// <c>SetPosition</c>, or rejected), because the
/// <c>IsAirborneNoOperation</c> early return fires only for
/// classifications it does own. That matches pre-4a behaviour.</summary>
/// classifications it does own. That matches pre-4a behaviour.
///
/// <para>The name is retained for continuity with route 4a; read
/// "airborne" here as retail's CONTACT_TS-clear, not as the client
/// <c>RemoteMotion.Airborne</c> flag, which is
/// <c>!Body.OnWalkable</c> — a strictly WIDER set. The two disagree on
/// a body in contact with a non-walkable face (a steep slide), which
/// retail interpolates.</para></summary>
AirborneSnap,
/// <summary>Route 4a's near InterpolateTo branch.</summary>
@ -1008,7 +1016,7 @@ internal sealed class LiveEntityNetworkUpdateController
/// packet classifies <c>Interpolate</c>, so if the 4a test came first it
/// would ENQUEUE a body that must PLANT, and a creature knocked off a
/// ledge would glide down over a packet interval. The player-remote caller
/// reaches this method only with <c>Airborne == false</c> (its landing
/// reaches this method only with <c>Body.InContact == true</c> (its landing
/// block sits ahead of its routing and returns), so the carve-out is inert
/// there and the two callers stay one decision.
/// </para>
@ -1046,7 +1054,29 @@ internal sealed class LiveEntityNetworkUpdateController
// TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family.
AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution(
canonical.ServerGuid);
if (remote.Airborne)
// AP-140 (retired 2026-08-04): retail's predicate for exactly this
// decision is CONTACT, not walkability.
// `InterpolationManager::adjust_offset` @0x00555D30 gates its ENTIRE
// body on `physics_obj->transient_state & 1` @0x00555D52 — and
// TransientState bit 0 is `CONTACT_TS` (acclient.h:3690), NOT
// `ON_WALKABLE_TS` (0x2). A retail body in contact with a non-walkable
// face therefore still walks toward its queued waypoint. This gate used
// to read `remote.Airborne`, which is `!Body.OnWalkable` — WALKABILITY,
// a strictly wider set. The two disagree on precisely one state (in
// contact, not on walkable ground), and Bug B (`204d0ae0`) turned that
// state from unreachable into ordinary by deleting the per-tick
// `TransientState |= Contact | OnWalkable` forge that had made every
// non-airborne remote walkable by construction. A remote sliding on a
// steep roof lives in it, and was being hard-snapped at UpdatePosition
// cadence instead of interpolated.
//
// `Airborne` itself is deliberately NOT re-derived from CONTACT: it has
// five writers, all spelling `!Body.OnWalkable`, and the per-tick
// updater's ground-clamp branch plus
// `RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_…`
// both depend on the walkability reading. Only the two ROUTING gates
// move (this one and `OnPosition`'s player-remote landing block).
if (!remote.Body.InContact)
{
// Verbatim from the pre-4a branch, queue deliberately NOT
// cleared: the arc integrates locally (K-fix15), and clearing
@ -1059,19 +1089,16 @@ internal sealed class LiveEntityNetworkUpdateController
// 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 (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.
// is not. Nothing that CLEARS the body's CONTACT bit clears the
// queue: it is dropped by the per-tick sweep's SetPositionInternal
// commit (and by TickHidden's resolve) whenever the contact plane
// stops being valid, and by nothing else — the teleport hook's
// StopInterpolating is the only thing that empties the queue on a
// leave-ground edge, and the 0xF74E VectorUpdate (OnVector, below)
// does not. 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(
@ -2145,16 +2172,32 @@ internal sealed class LiveEntityNetworkUpdateController
}
// ── LANDING TRANSITION ────────────────────────────────────────
// 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).
// First IsGrounded=true UP while the body is still in FREE
// FLIGHT — not in contact with any surface (`!Body.InContact`,
// derived by the per-tick SetPositionInternal commit from the
// sweep's contact plane).
// 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).
// interpolation queue (a free-flying 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.
//
// AP-140 (retired 2026-08-04): this gate read `rmState.Airborne`
// (`!Body.OnWalkable` — WALKABILITY). Retail's predicate for
// exactly this snap-vs-interpolate decision is CONTACT:
// `InterpolationManager::adjust_offset` @0x00555D30 gates its
// entire body on `transient_state & 1` @0x00555D52, and bit 0 is
// `CONTACT_TS` (acclient.h:3690), not `ON_WALKABLE_TS` (0x2). A
// remote in contact with a NON-walkable face — sliding down a
// steep roof, ordinary since Bug B `204d0ae0` deleted the
// per-tick walkability forge — was taking this hard snap at
// UpdatePosition cadence; it now falls through to the grounded
// routing below and interpolates, as retail does. See the twin
// gate in ApplyRemoteContactRouting for why `Airborne` itself is
// left alone.
//
// 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
@ -2173,7 +2216,7 @@ internal sealed class LiveEntityNetworkUpdateController
// • 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)
if (!rmState.Body.InContact)
{
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
@ -2269,8 +2312,8 @@ internal sealed class LiveEntityNetworkUpdateController
// is the SAME shared entry point the NPC arm below calls —
// retail's disassembly makes no `this == player` distinction
// on any of these branches. The player arm reaches it only
// with Airborne == false (the landing block above returns), so
// the airborne carve-out inside is inert here.
// with Body.InContact == true (the landing block above
// returns), so the free-flight carve-out inside is inert here.
bool willBeDrTicked = WillAdvanceRemoteMotion(update.Guid, rmState);
RemoteContactRouting playerRouting = ApplyRemoteContactRouting(
_remotePlacementDrive,