diff --git a/docs/research/2026-08-04-c4-route-4b-2-contract.md b/docs/research/2026-08-04-c4-route-4b-2-contract.md new file mode 100644 index 00000000..c859a0d8 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-4b-2-contract.md @@ -0,0 +1,119 @@ +# C4 route 4b-2 — remote far-snap: pinned contract (2026-08-04) + +Split rationale: +[`2026-08-04-c4-route-4b-scoping-and-split.md`](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.