From f2b06f378738474b0861e4d990d17f9645e07615 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 14:41:27 +0200 Subject: [PATCH] docs: pin the C4 route 2 (ForcePosition) contract Scoping complete; implementation not started. Key finding that changes the slice's shape: ClassifyAcceptedPosition already produces the retail-exact ForcePosition route, but its ONLY production consumer is RuntimeInitialCreateContinuationExecutor:1948 - route 1's Create continuation. For an already-live local player receiving a Position there is no Runtime consumer at all; LiveEntityNetworkUpdateController.OnPosition does the work in App. Route 2 therefore has to build the accepted-Position execution seam and then cut App over, rather than wire up an existing one. The contract records both duplicate authorities with exact file:line, the retail evidence (SmartBox::HandleReceivedPosition @0x00453FD0 - the FORCE_POSITION early return preceding unset_parent and the !HasAnims-gated SetPlacementFrame), the seven contract points, acceptance including the complete-suite gate, and three implementer risk notes. Called out for the implementer: the outbound AutonomousPosition ack currently fires BEFORE any canonical commit, and its trailing isCurrent() only suppresses the continuation - the ack has already gone out. Moving to retail's SendPositionImmediately (an output of the executed route) fixes that by construction, and is a real behaviour change that must be named in the commit. Co-Authored-By: Claude Opus 5 --- .../2026-08-03-c4-route-2-contract.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/research/2026-08-03-c4-route-2-contract.md diff --git a/docs/research/2026-08-03-c4-route-2-contract.md b/docs/research/2026-08-03-c4-route-2-contract.md new file mode 100644 index 00000000..0dd0d61d --- /dev/null +++ b/docs/research/2026-08-03-c4-route-2-contract.md @@ -0,0 +1,120 @@ +# C4 route 2 — ForcePosition: pinned contract (2026-08-03) + +Scoping is complete; implementation has not started. This is the pinned +contract the plan's standing discipline requires before code +(`docs/plans/2026-08-02-placement-cutover.md`, "Standing discipline per +slice"). + +## Scoping finding — this is not a wiring job + +`RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` already +produces the retail-exact ForcePosition route, but grep shows exactly ONE +production consumer: `RuntimeInitialCreateContinuationExecutor.cs:1948`, the +route-1 Create continuation. For an already-live local player receiving a +Position there is **no Runtime consumer at all** — `LiveEntityNetworkUpdateController.OnPosition` +does the work itself in App. + +So route 2 must build the accepted-Position execution seam and then cut App +over. The Create executor is the model to follow, not a component to reuse +unchanged. + +## Today's behaviour (the two duplicate authorities) + +Wire `0xF748` → `WorldSession.cs:1767` → `LiveEntitySessionController.cs:67` +→ `LiveEntityNetworkUpdateController.OnPosition:1024` +→ `_authorityGate.TryAcceptPosition` → `PhysicsTimestampGate.TryAcceptPositionEvent` +(retail `SmartBox::HandleReceivedPosition` @0x00453FD0, FORCE_POSITION branch) +→ `forceLocal` computed at `:1112`. + +**Authority 1 — `LocalForcePositionTransaction.Apply` +(`src/AcDream.App/Physics/LocalForcePositionTransaction.cs`):** +`if (!isCurrent()) return false; blip(); acknowledge(); return isCurrent();` +where + +- `blip` = `PlayerMovementController.BlipPosition` (`_body.SnapToCell`, then + `UpdateCellId`, which also publishes the render root via + `_physics.UpdatePlayerCurrCell`); +- `acknowledge` = `LocalPlayerOutboundController.SendImmediatePosition` — + **the outbound `AutonomousPosition` ack is sent to ACE BEFORE any canonical + Runtime commit exists.** We tell the server "got it, I'm here" before + deciding where "here" is. + +**Authority 2 — the generic tail (`LiveEntityNetworkUpdateController.cs:1264-1281`):** +execution falls through unconditionally into `entity.SetPosition(worldPos)`, +`entity.ParentCellId = p.LandblockId`, `entity.Rotation = rot`, then +`RebucketLiveEntity`. That is a SECOND independent mutation of the SAME +accepted Position, against the render-facing `WorldEntity`, parallel to the +`PlayerMovementController` body mutated by authority 1. + +Two stores, one packet — the same divergence class as the remote-placement bug +`670f307c` fixed. + +## Retail truth + +`SmartBox::HandleReceivedPosition` @0x00453FD0. The FORCE_POSITION branch is an +early return (~92932) that precedes `unset_parent` (~92990) and the +`!HasAnims`-gated `SetPlacementFrame` (~92992). The classifier already encodes +this exactly: + +| Field | Value | Meaning | +|---|---|---| +| Disposition | `SetPositionSimple` | one plain SetPosition | +| Flags | `AuthoritativeTeleportFlags` | server-authoritative placement | +| `UnparentBeforeRouting` | false | branch precedes `unset_parent` | +| `ApplyPlacementFrameBeforeRouting` | false | branch precedes `SetPlacementFrame` | +| `TeleportHookPhase` | `None` | not a teleport | +| `PreserveHeading` | **true** | force keeps the player's facing | +| `ZeroVelocity` | false | velocity untouched | +| `SendPositionImmediately` | **true** | ack AFTER the position operation | + +`SendPositionImmediately` being a property of the executed route is the key +point: the acknowledgement is an *output of* the committed operation, never a +step performed alongside it. + +## The contract + +1. One Runtime `SetPosition` transaction owns the accepted frame, exact cell, + collision result, shadow/workset membership, and deferred-cell lifetime for + a ForcePosition on a live local player. +2. `LocalForcePositionTransaction` is **deleted**, not adapted. Its three jobs + become properties of the executed route: ownership validation is the + operation's own currency check, `blip` is the SetPosition commit, and + `acknowledge` is `SendPositionImmediately` fired after commit. +3. The generic tail must not independently mutate the same accepted Position + for the local player. App projects the committed result. +4. Graphical and headless drive the identical Runtime command and state path. +5. Stale sequences, GUID reuse, missing cells, portal generations, and replaced + collision generations cannot commit old state. +6. No route reconstructs from a stale spawn; no legacy outdoor demotion or + terrain-Z lift. +7. The world-frame invariant from #283 + (`LiveWorldOriginState.EnsureAgreesWithRuntimeFrame`) holds across the new + path — route 2 converts landblock-local wire origins and must not introduce + a second conversion site. + +## Acceptance + +- Focused Runtime tests for the accepted-Position execution seam, including + the ack-after-commit ordering and the displaced-authority case that + `LocalForcePositionTransaction`'s trailing `isCurrent()` currently covers. +- App tests proving the generic tail no longer double-writes the local player. +- **Complete Release solution suite green before the commit** — not a focused + subset. This gate is what the #281–#284 regressions bypassed. +- Connected: a server-forced correction (ACE `@teleport`-style displacement or + a rubber-band) leaves the player at the corrected position with heading + preserved, one outbound ack, and no double-apply. +- Divergence ledger: AP-131 is retired only when the legacy Position caller it + names is actually gone, which is route 4, not route 2. + +## Risk notes for the implementer + +- This is the hottest inbound path in the client; every local-player Position + crosses it. Prefer extending the existing accepted-Position classifier + consumption over inventing a parallel executor. +- `PhysicsTimestampGate.TryAcceptPositionEvent` already returns + `ForcePosition` only when newer AND `teleport == current teleport stamp`. + Do not re-derive that condition at the new seam. +- The ack currently fires even when the commit is subsequently displaced; the + trailing `isCurrent()` only suppresses the CONTINUATION, not the ack that + already went out. Ack-after-commit fixes this by construction — call it out + in the commit message as a behaviour change, because it is one.