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>
120 lines
6.1 KiB
Markdown
120 lines
6.1 KiB
Markdown
# 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.
|