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>
This commit is contained in:
Erik 2026-08-03 14:41:27 +02:00
parent 2ef02f8cbb
commit f2b06f3787

View file

@ -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.