acdream/docs/research/2026-08-04-c4-route-4b-2-contract.md
Erik d5bdc35598 docs: pin the C4 route 4b-2 contract (remote far-snap)
4b-2 flips one classifier branch on: SetPositionSimple for remotes, contact,
PlayerDistance >= 96 m, routed through 4b-1's dormant owner. Teleport and
cell-less stay legacy for 4b-3.

Pins the two retail facts that decide the slice: StopInterpolating runs BEFORE
SetPositionSimple (@0x005163CB before @0x005163D9), and the branch returns 1 so
HandleReceivedPosition arms ConstrainTo @0x00454272 anchored post-move. Since
MoveOrTeleport discards SetPositionSimple's error return and returns 1
regardless, the leash must be armed on refusal and rejection too — "arm on
Committed" is the natural misreading and is the same shape as the already-filed
unarmed-leash bug.

Names the trap up front: deleting the legacy far block removes the only handler
for null and Rejected* classifications, and during the login window null is
every remote packet, so remotes would not move at all until the local
controller exists. Requires a stated policy rather than a silent drop — the
same shape as route 4a's "'not Interpolate' is not 'far'" finding.

Records that AP-87's 4 m / !willBeDrTicked guards are near-branch only and are
subsumed by the far branch's unconditional snap, so they must not be carried
forward — while the near-branch copies stay, since those are 4a's and still
load-bearing.

Requires behavioural App tests explicitly: route 2 settled for a source pin
(#292) and route 4a's first attempt shipped tautologies that passed with
production reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:09:15 +02:00

5.7 KiB

C4 route 4b-2 — remote far-snap: pinned contract (2026-08-04)

Split rationale: 2026-08-04-c4-route-4b-scoping-and-split.md. 4b-1 (the infrastructure) landed dormant at 2e8e09ac; the park restore it depends on landed at 634bc551.

4b-2 flips ONE classifier branch on: SetPositionSimple — contact, PlayerDistance >= 96 m. Teleport and cell-less stay legacy; 4b-3 owns them.

Retail truth — verify each yourself

CPhysicsObj::MoveOrTeleport @0x00516330, far branch @0x005163C1-@0x005163E8:

if (player_distance >= 96f) {
    if (position_manager != 0) PositionManager::StopInterpolating(position_manager);
    CPhysicsObj::SetPositionSimple(this_1, arg2, 1);
    return 1;                                    // @0x005163E8
}

Two things that decide this slice:

  1. StopInterpolating runs BEFORE SetPositionSimple (@0x005163CB before @0x005163D9). The classifier already carries StopInterpolating: !nearby. Preserve the order.
  2. The branch returns 1, so SmartBox::HandleReceivedPosition @0x00454254 takes the nonzero path and arms ConstrainTo @0x00454272, anchored to &arg2->m_position read live — i.e. POST-move.

SetPositionSimple @0x005162B0 with arg3 != 0 builds flags 0x1012 (Teleport|Slide|SendPositionEvent) — the classifier's AuthoritativeTeleportFlags.

MoveOrTeleport discards SetPositionSimple's SetPositionError return and returns 1 regardless. So retail arms the leash even when the placement failed. 4b-2 must arm on refusal and rejection too, not only on commit. "Arm on Committed" is the natural misreading and is the same shape as the already-filed unarmed-leash bug.

Scope

Flip on: SetPositionSimple for RuntimePositionEntityKind.Remote, routed through 4b-1's RuntimeRemotePlacementDriveController.

Delete (far half only, both arms):

  • The player-remote far branch and its MaxPhysicsDistance = 96f / BodySnapThreshold = 4f constants.
  • The NPC-remote copy and its MaxPhysicsDistanceNpc / BodySnapThresholdNpc.
  • Both _playerController?.Position ?? Vector3.Zero fabrications on those paths. GameRuntime states the rule for this field: a null controller "must yield null, never a fabricated Vector3.Zero that would misclassify every remote entity as implausibly far."

Do NOT touch: RemoteTeleportController, RemoteTeleportPlacement, remotePlacementRequired, the teleport/cell-less branches, AP-135's two airborne writes, the near/interpolate path 4a owns, the airborne no-op, or route 1's executor.

The trap that will bite you

Deleting the legacy far block removes the only handler for null and Rejected* classifications. ClassifyRemoteAcceptedPosition returns null whenever _playerController is null — during the login window that is every remote packet. Today those fall into the legacy block and hard-snap. Delete it without an explicit replacement and remotes will not move at all until the local controller exists.

Retail has no analogue because retail always has a player. This needs a stated acdream policy, decided deliberately and recorded — it is the same shape as route 4a's "'not Interpolate' is not 'far'" finding, one level up.

AP-87 does not apply here

AP-87's bodyToTarget > 4 m and !willBeDrTicked conditions are near-branch guards: they decide snap-vs-enqueue within Interpolate. The far branch snaps unconditionally, so they are subsumed. Do not carry them forward into a branch that does not need them — but do not delete the near-branch copies either; those are 4a's and still load-bearing.

Contract

  1. SetPositionSimple for remotes executes through 4b-1's owner; nothing else changes classification.
  2. StopInterpolating precedes the placement, matching @0x005163CB.
  3. ConstrainTo is armed once, after the operation, anchored post-move, on commit and on refusal/rejection — retail arms on any nonzero return. Do not add a second arming site; retail has exactly one (@0x00454272).
  4. null and Rejected* have an explicit, stated handler. No silent drop.
  5. No behaviour change to teleport, cell-less, near/interpolate, or airborne.
  6. Both hosts drive the identical Runtime entry point, or the divergence is stated plainly rather than satisfied vacuously.
  7. Per-entity currency across GUID reuse, incarnation, generation, teardown.

Acceptance

  • Focused Runtime tests for the far branch, the StopInterpolating ordering, the arm-on-failure case, and the null/Rejected* policy.
  • Behavioural App tests — not source-text pins. Route 2 settled for a pin and that gap is #292; route 4a's first attempt shipped tautologies that passed with production reverted. A test that cannot fail against a broken implementation is worse than no test.
  • Complete Release suite green. Baseline 10,973 / 4 / 0. Two known flakes, do not chase and do NOT conflate: #302 (PortalProjectionTests…, GC-allocation assertion, App.Tests) and #308 (NakEmissionTests.LossSoak_…, wall-clock deadline, Core.Net.Tests, full-suite load only).
  • Connected gate (user-gated), and it is easy: stand still in open ground; second character runs past ~100 m, stops, turns, runs back, three or four times. Correct: they stay visible and correctly positioned at range, and resume smooth interpolation on the way back in with no jolt at the crossover. Regressions: a freeze or stutter at the boundary, a Z pop, a vanish, or — the loud one — invisible-but-solid on the way back in.

Budget

~350-500 non-comment production lines. For calibration: route 4a was 364 (91% of its ~400), and 4b-1 was 230 plus a 57-line park fix. Exceed 500 and stop and report rather than pushing through.