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:
parent
2ef02f8cbb
commit
f2b06f3787
1 changed files with 120 additions and 0 deletions
120
docs/research/2026-08-03-c4-route-2-contract.md
Normal file
120
docs/research/2026-08-03-c4-route-2-contract.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue