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>
291 lines
15 KiB
Markdown
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.
|