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>
6.1 KiB
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, thenUpdateCellId, which also publishes the render root via_physics.UpdatePlayerCurrCell);acknowledge=LocalPlayerOutboundController.SendImmediatePosition— the outboundAutonomousPositionack 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
- One Runtime
SetPositiontransaction owns the accepted frame, exact cell, collision result, shadow/workset membership, and deferred-cell lifetime for a ForcePosition on a live local player. LocalForcePositionTransactionis deleted, not adapted. Its three jobs become properties of the executed route: ownership validation is the operation's own currency check,blipis the SetPosition commit, andacknowledgeisSendPositionImmediatelyfired after commit.- The generic tail must not independently mutate the same accepted Position for the local player. App projects the committed result.
- Graphical and headless drive the identical Runtime command and state path.
- Stale sequences, GUID reuse, missing cells, portal generations, and replaced collision generations cannot commit old state.
- No route reconstructs from a stale spawn; no legacy outdoor demotion or terrain-Z lift.
- 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 trailingisCurrent()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.TryAcceptPositionEventalready returnsForcePositiononly when newer ANDteleport == 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.