acdream/docs/research/2026-08-03-c4-route-2-implementation-plan.md
Erik 9966b53174 feat(physics): C4 route 2 — ForcePosition through the canonical placement
A local-player ForcePosition had TWO independent writers for one accepted
packet: LocalForcePositionTransaction snapped the physics body
(PlayerMovementController.BlipPosition, a raw SnapToCell with no collision
resolve), while LiveEntityNetworkUpdateController's generic tail separately
wrote position/cell/rotation to the render WorldEntity from the raw wire and
rebucketed it. Two stores, one packet — the divergence class 670f307c fixed on
the remote path. The outbound AutonomousPosition ack also fired BEFORE any
canonical commit existed: we told ACE "got it, I'm here" before deciding where
"here" was, and the trailing isCurrent() could only suppress the continuation,
never recall the packet.

RuntimeAcceptedPositionDriveController is now the one Runtime-owned seam. Both
hosts call the identical TryExecuteAcceptedLocalPosition; App and headless
project the committed result through the existing placement projection sink
(LiveEntityRuntime.TryApplyRuntimePlacementPlace already performed the same
four writes, from committed state rather than a wire guess).

Retail: SmartBox::HandleReceivedPosition @0x00453FD0's FORCE_POSITION branch is
get_heading -> Frame::set_heading -> SmartBox::BlipPlayer @0x00453940 -> stamp
POSITION_TS -> SendPositionEvent @0x00454091 -> return @0x0045409D. BlipPlayer
is CPhysicsObj::SetPositionSimple @0x005162B0 with flags 0x1012
(Teleport|Slide|SendPositionEvent) — a real collision-resolving SetPosition,
not a snap. The pinned classifier already encoded this exactly.

Named behaviour changes:

* The ack is now an OUTPUT of the committed route, fired strictly after the
  canonical commit and exactly once per accepted force packet.
* The ForcePosition route no longer re-arms the constraint leash. The force
  branch returns at 0x0045409D, ahead of all three ConstrainTo sites
  (0x00454272, 0x0045418A, 0x004541EC); the old re-arm cited retail's "Player,
  normal" branch, which BlipPlayer is not on. The teleport, CommitPreparedPosition
  and first-entry callers legitimately still constrain and are untouched.
* A force correction that terminates WITHOUT committing still sends its
  position event and is not retried — retail's BlipPlayer discards
  SetPositionSimple's SetPositionError return and acks unconditionally.

A single _pending funnel owns the in-flight placement, deciding on the token's
PositionAuthorityVersion against the record's: equal -> clear; advanced with the
newest accepted event still a force -> re-issue, re-classified; advanced to an
ordinary Apply -> clear, since newer server truth owns that pose. This closes a
double-apply/double-ack and a silently-dropped correction that two earlier
iterations of this slice each introduced.

AD-62 records the residual: a ForcePosition our async collision publication
cannot carry to a committed placement is not re-applied. Retail has no park —
its world is fully resident and its placement synchronous — so the state is
unreachable there. AP-131 is NOT retired; its legacy Position caller is route 4.

Deleted: LocalForcePositionTransaction, PlayerMovementController.BlipPosition,
HeadlessSessionWorldProjection.BlipLocalPlayer.

Gates: complete Release solution 10,858 passed / 4 skipped / 0 failed (baseline
10,844/4/0). Two independent Opus reviews (retail-conformance and
architecture/adversarial) PASS on the final diff after three FAIL rounds; every
intermediate state was fully green, so the suite caught none of the four real
defects. Connected acceptance is NOT run: nothing a user can do makes ACE emit
a ForcePosition without retail's @pklite, which acdream does not implement — see
docs/research/2026-08-03-c4-route-2-visual-gate.md.

Known gap, recorded not claimed: the plan's acceptance item 2 is unmet. The App
double-write check is a source pin, and "the committed projection moves the
render entity" is uncovered at any layer (#292). Filed alongside: #286-#291,
#293-#296.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:46:36 +02:00

291 lines
15 KiB
Markdown

# C4 route 2 — ForcePosition: implementation plan (2026-08-03)
Executes `docs/research/2026-08-03-c4-route-2-contract.md`. The contract is the
WHAT; this is the verified HOW. Every claim below was checked against source or
the named retail decomp in this session — do not re-derive them, and do not
contradict them without new evidence.
## 1. Retail truth (verified this session, not inherited)
`SmartBox::HandleReceivedPosition` @0x00453FD0
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:92896`). The
FORCE_POSITION branch is the whole route:
```
if (arg2 == player && newer_event(player, FORCE_POSITION_TS, arg9))
{
if (<force ts is not older>)
{
get_heading(player);
Frame::set_heading(&dest, heading); // 00454068 preserve OUR heading
SmartBox::BlipPlayer(this, &dest); // 00454074
player->update_times[0] = arg7; // 00454079 stamp POSITION_TS
cmdinterp->SendPositionEvent(); // 00454091 ack
return; // 0045409d
}
}
```
`SmartBox::BlipPlayer` @0x00453940 (line 92528) is:
```
distance = Position::distance(&player->m_position, dest);
CPhysicsObj::SetPositionSimple(player, dest, 1); // 00453968
SmartBox::PlayerPositionUpdated(this, 0, distance); // 00453976
```
`CPhysicsObj::SetPositionSimple` @0x005162B0 (line 284276) with `arg3 != 0`
builds `SetPositionStruct` with flags **`0x1012`** and calls
`CPhysicsObj::SetPosition`. `0x1012` decodes against
`src/AcDream.Core/Physics/PhysicsSetPosition.cs:63-76` as
`Teleport(0x002) | Slide(0x010) | SendPositionEvent(0x1000)` — byte-for-byte
`RuntimeAuthoritativePositionRouteClassifier.AuthoritativeTeleportFlags`
(`RuntimeAuthoritativePositionRouteClassifier.cs:198-200`). **The pinned
classifier route is confirmed correct; do not touch it.**
Three consequences that decide this slice:
### 1a. ForcePosition is a real SetPosition, not a snap
Retail runs the full transition with Teleport|Slide. Today's
`PlayerMovementController.BlipPosition`
(`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1923-1937`) is
`_body.SnapToCell(...)` — no transition, no collision, no contact plane, no
shadow commit, no `FullCellId`/`PlacementCommitVersion` advance. Closing that
gap is the point of route 2.
### 1b. The force branch runs NO ConstrainTo
Every `CPhysicsObj::ConstrainTo` call in `HandleReceivedPosition` is at
0x00454272 (remote MoveOrTeleport tail), 0x0045418A (player teleport-newer
branch), and 0x004541EC (player ordinary branch). The force branch returns at
0x0045409D, **before all three**. The classifier already encodes this as
`ConstrainPhase: None`.
`BlipPosition` calls `RearmConstraintLeashAtCurrentPosition()` and its comment
cites *"retail 'Player, normal' branch"* — a branch `BlipPlayer` is not on.
That leash re-arm is an unbacked deviation on this route.
**Required:** the new route honours `ConstrainPhase: None` — no leash re-arm on
ForcePosition. Call it out explicitly in the commit message as a named
behaviour change with these addresses, and add it to the connected gate's watch
list (#167 was a leash bug; the user's live observation governs). Do not touch
`ArmConstraintLeashAtCommittedPlacement` (C3c/AD-42 first-entry) or the
teleport/`CommitPreparedPosition` callers — they are on branches that DO
constrain.
### 1c. Heading preservation already happens upstream — do not re-derive it
Retail replaces the destination heading with the player's current heading
BEFORE the SetPosition. Our accepted-position merge already does this via the
`forcePositionRotation` argument
(`RuntimeLiveEntitySessionController.cs:179`, App's
`_authorityGate.TryAcceptPosition(..., _playerController.BodyOrientation, ...)`
at `LiveEntityNetworkUpdateController.cs:1050-1052`). Verify it lands in
`record.Snapshot` before you build the route request; assert it in a test. The
seam must NOT apply a second heading substitution.
Also noted, NOT in scope: retail's `PlayerPositionUpdated(this, 0, distance)`
gates `set_viewer`/`LScape::update_viewpoint` on
`distance >= GetAutonomyBlipDistance` (0x004538C0-0x004538E2). We publish the
render root unconditionally. File it as a follow-up observation in the closeout
note; do not change it here.
## 2. Verified mechanism map
| Thing | Location | Note |
|---|---|---|
| Route classifier (pinned, correct) | `RuntimeAuthoritativePositionRouteClassifier.cs:308` | one production consumer today: `RuntimeInitialCreateContinuationExecutor.cs:1948` |
| Begin | `RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement:1309` | no first-entry-only precondition |
| Prepare+submit+commit | `RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement:1658``PrepareMover:1522``SubmitPreparedPlacementCore:2777``Engine.SetPosition:2962``CommitCanonical:4411` | |
| Deferred wake | `CommitCollisionGeneration:3973``RetryDeferred:4192``CommitCanonical:4374` | drives parked operations without our help |
| **The template to mirror** | `RuntimeFirstEntryDriveController.TryCompleteContinuationPlacement:280-380` | begin/prepare/submit + outcome switch + projection acknowledgement |
| Runtime world frame | `RuntimePhysicsState.ObserveLocalWorldFrame:535`; `resolveWorldOffsetFromRuntimeFrame` param | **use it** — satisfies contract §7, one conversion site |
| App projector (already exists) | `LiveEntityRuntime.TryApplyRuntimePlacementPlace` (`LiveEntityRuntime.cs:1231+`) | writes `entity.SetPosition(projection.WorldPosition)`, `entity.Rotation`, `entity.ParentCellId = token.ExactCellId`, `RebucketLiveEntity` — from the COMMITTED result |
| App sink | `RuntimePlacementPresentationSink.TryApply:67` / `TryPublishPlace:162` | |
| Headless sink | `HeadlessRuntimePlacementProjectionSink` | |
**This is the crux:** `TryApplyRuntimePlacementPlace` already performs, from
canonical committed state, exactly the four writes the generic tail performs
from raw wire (`LiveEntityNetworkUpdateController.cs:1264-1281`). Deleting the
generic tail for the local player is a straight substitution of the committed
result for the wire guess — not a loss of function.
## 3. The two duplicate authorities to delete
**Graphical**`src/AcDream.App/Physics/LocalForcePositionTransaction.cs`
(whole file) and its single call site
`LiveEntityNetworkUpdateController.cs:1113-1129`; plus the generic tail
`:1264-1282` **for the local player only** (remotes still need it — that is
route 4).
**Headless**`RuntimeLiveEntitySessionController.OnPositionUpdated:217-231`'s
`ProjectPosition(..., isLocalPlayer: true, ForcePosition)` +
`SendImmediatePosition` pair, and
`HeadlessSessionWorldProjection.BlipLocalPlayer:707-727`. Headless has no
`WorldEntity` in this path, so it has only the first duplicate — but it is a
duplicate all the same, and contract §4 requires both hosts on the identical
Runtime path.
## 4. Design
New Runtime type, modelled directly on `RuntimeFirstEntryDriveController`:
**`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs`**
Constructor takes the same collaborators that controller takes
(`_entityObjects`, `IPreparedCollisionSource`, the simulation clock,
`LocalPlayerOutboundController`, the session accessor, the local-player
identity). Follow that file's ctor and null-validation style exactly.
### Entry point
```csharp
internal RuntimeAcceptedPositionExecutionStatus TryExecuteAcceptedLocalPosition(
RuntimeEntityRecord record,
in WorldSession.EntityPositionUpdate update,
PositionTimestampDisposition disposition,
in AcceptedPhysicsTimestamps timestamps,
ushort previousTeleportSequence);
```
**Scope this slice to ForcePosition on the live local player.** Any other
disposition, any other entity kind, and the not-applicable cases below return
`NotApplicable`, and the caller then does exactly what it does today. Route 2
must not perturb routes 1/3/4.
Return `NotApplicable` when:
- `disposition is not PositionTimestampDisposition.ForcePosition`;
- the record is not the local player;
- `record.PhysicsBody is null` (no canonical body → nothing to place);
- **an initial-Create residence is still active for the record.** Route 1 owns
it: the executor already retains the Position as a tail action
(`RuntimeInitialCreateContinuationExecutor.ApplyPositionAction`) and already
carries `SendPositionImmediately` (`:717`, `:2576`). Confirm that ack
actually fires on that path and say so in the closeout; if it does not, that
is a route-1 defect — file it, do not paper over it here.
### Body
1. Build `RuntimeAcceptedPositionRouteRequest` deriving every field the way
`RuntimeInitialCreateContinuationExecutor.ApplyPositionAction:1907-1946`
derives it. Specifically: `HasContact = update.IsGrounded` (the wire bit,
never a live body query); `HasAnimations` from
`Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId` non-zero;
`CommittedCellId = record.FullCellId`; `PlacementFacts` from
`record.FinalPhysicsState` and `Snapshot.SetupTableId is not null`;
`Source = PositionEvent`; `UsePositionFromServer` and `PlayerDistance` from
the same Runtime owners C0 established (`RuntimeCharacterState.AutonomyLevel
!= 2`; the live movement controller). Read C0's notes in
`docs/plans/2026-08-02-placement-cutover.md` first.
2. `ClassifyAcceptedPosition`. Not accepted → stamp-only, mirroring
`:1950-1984`; return without ack.
3. `TryBeginExclusiveAuthoredPlacement(record, record.PositionAuthorityVersion,
route.OperationKind)`. Invalid token → `Contention`; the caller retries on a
later packet. Do NOT invent a retry loop.
4. `TryPrepareAndSubmitAuthoredPlacement(record, token, route.OperationKind,
route.SetPositionFlags, _collisionSource, _clock.SimulationTimeSeconds,
out outcome, resolveWorldOffsetFromRuntimeFrame: true)`.
5. Outcome switch, mirroring `TryCompleteContinuationPlacement:340-380`:
- `CommittedHostAcknowledgementPending` → §4a reconcile, then §4b ack.
- `DeferredCell` → §4c.
- anything else → forget + `PublishCancellation`; **no ack**.
### 4a. Post-commit local reconciliation
`CommitCanonical` writes the body, contact plane, `FullCellId`,
`PlacementCommitVersion`, shadow membership and the spatial acknowledgement. It
does NOT do the three controller-local things `BlipPosition` also did. Add ONE
new method to `PlayerMovementController` next to `BlipPosition`, e.g.
`CommitCanonicalForcePositionFrame()`, that:
- resets `_prevPhysicsPos`/`_currPhysicsPos` to the committed body position
(kills the render-lerp residual);
- calls `UpdateCellId(_body.CellPosition.ObjCellId, "force-position")` so the
render root chokepoint `PhysicsEngine.UpdatePlayerCurrCell` still runs;
- does **not** re-arm the constraint leash (§1b);
- does **not** write the body — the canonical commit already did.
Then `BlipPosition` has no remaining caller: **delete it** along with
`HeadlessSessionWorldProjection.BlipLocalPlayer`. If a test is its only other
caller, the test moves to the new path — do not keep the method alive for
tests.
### 4b. The ack
`SendPositionImmediately` is an OUTPUT of the committed route. Fire
`LocalPlayerOutboundController.SendImmediatePosition(session, controller)`
only after §4a, only when `route.SendPositionImmediately`, and only once. This
is the named behaviour change the contract calls out: today the packet leaves
before any commit and the trailing `isCurrent()` cannot recall it.
### 4c. Deferred cell
`DeferredCell` means the destination landblock's collision generation is not
ready; the operation parks and the existing `CommitCollisionGeneration` wake
resubmits and commits it. The ack must still fire exactly once, after that
commit.
Retain the pending ack keyed by the placement token, and resolve it on a pump
`Advance()` called from the SAME two host sites that already call
`RuntimeFirstEntryDriveController.DriveAll()` —
`LiveEntityHydrationController.cs:405` (graphical) and
`HeadlessSessionWorldProjection.cs:571,594,612` (headless). Find the existing
read-only way to ask "did this token's operation commit / is it gone" before
adding anything; only add a minimal internal query to `RuntimeSetPositionState`
if none exists. Fire once on the commit transition; drop the pending ack on
cancellation, supersession, entity teardown, generation change and reset, and
fold its count into the ownership ledger / `IsConverged` so a leaked pending
ack cannot hide.
Also consume the parked `Withdraw` at the FIFO head exactly the way
`TryCompleteContinuationPlacement:351-365` does, if and only if it is ours.
### 4d. Host cutover
- **App** `LiveEntityNetworkUpdateController.OnPosition`: replace the
`LocalForcePositionTransaction.Apply` block with the Runtime call. When the
status is anything other than `NotApplicable`, return before the generic tail
— App projects the committed result through the existing placement sink. Keep
every currency re-check that is still meaningful. Inject the Runtime seam the
way the class already borrows Runtime owners (`_localPlayerOutbound` is the
precedent); do not add a service locator or a window back-reference.
- **Headless** `RuntimeLiveEntitySessionController.OnPositionUpdated`: replace
the `ProjectPosition(isLocalPlayer: true, ForcePosition)` +
`SendImmediatePosition` pair with the same call. Leave the `Apply`-
disposition `OfferTeleportDestination` and `TryCompletePortal` alone — route 3.
- Delete `LocalForcePositionTransaction.cs`.
## 5. Non-negotiables
- Root causes only. No timeout, grace period, suppression flag,
catch-and-swallow, duplicated placement writer, or test-only bypass.
- Never `git add -A` / `git add .` / `git reset --hard` / `git checkout -- <path>`.
- Do not weaken an existing assertion to make a test pass. If a fixture models
the old duplicate-write behaviour, re-model it on the committed projection.
- Cite retail as `named symbol @address` (+ the pseudo-C line) in every comment
on ported behaviour.
- Update `docs/ISSUES.md` and
`docs/architecture/retail-divergence-register.md` in the SAME commit as the
behaviour change. AP-131 is **not** retired here — its named legacy Position
caller is route 4.
## 6. Acceptance
1. Focused Runtime tests for the seam: force route classification; ack strictly
after commit; ack exactly once; no ack on a rejected/cancelled operation;
the displaced-authority case that `LocalForcePositionTransaction`'s trailing
`isCurrent()` covered today; the `DeferredCell` → wake → commit → single ack
sequence; heading preserved; leash NOT re-armed; `NotApplicable` while a
first-entry residence is active.
2. App tests proving the generic tail no longer double-writes the local player,
and that the committed projection is what moves the render entity.
3. Headless tests proving the identical Runtime path.
4. `dotnet build -c Release` clean.
5. **Complete Release solution suite green — not a focused subset.** Baseline
**10,844 passed / 4 skipped / 0 failed**. Any deviation is a regression
introduced by this work.
6. Connected (user-gated): a server-forced correction leaves the player at the
corrected position, heading preserved, exactly one outbound
`AutonomousPosition`, no double-apply, and no leash misbehaviour after the
correction.