acdream/docs/research/2026-08-03-c4-route-2-contract.md
Erik f2b06f3787 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 <noreply@anthropic.com>
2026-08-03 14:41:27 +02:00

6.1 KiB
Raw Permalink Blame History

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 allLiveEntityNetworkUpdateController.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 0xF748WorldSession.cs:1767LiveEntitySessionController.cs:67LiveEntityNetworkUpdateController.OnPosition:1024_authorityGate.TryAcceptPositionPhysicsTimestampGate.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.SendImmediatePositionthe 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.