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

15 KiB

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:1658PrepareMover:1522SubmitPreparedPlacementCore:2777Engine.SetPosition:2962CommitCanonical:4411
Deferred wake CommitCollisionGeneration:3973RetryDeferred:4192CommitCanonical: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

Graphicalsrc/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).

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

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.