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>
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: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
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 carriesSendPositionImmediately(: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
- Build
RuntimeAcceptedPositionRouteRequestderiving every field the wayRuntimeInitialCreateContinuationExecutor.ApplyPositionAction:1907-1946derives it. Specifically:HasContact = update.IsGrounded(the wire bit, never a live body query);HasAnimationsfromSnapshot.MotionTableId ?? Snapshot.Physics?.MotionTableIdnon-zero;CommittedCellId = record.FullCellId;PlacementFactsfromrecord.FinalPhysicsStateandSnapshot.SetupTableId is not null;Source = PositionEvent;UsePositionFromServerandPlayerDistancefrom the same Runtime owners C0 established (RuntimeCharacterState.AutonomyLevel != 2; the live movement controller). Read C0's notes indocs/plans/2026-08-02-placement-cutover.mdfirst. ClassifyAcceptedPosition. Not accepted → stamp-only, mirroring:1950-1984; return without ack.TryBeginExclusiveAuthoredPlacement(record, record.PositionAuthorityVersion, route.OperationKind). Invalid token →Contention; the caller retries on a later packet. Do NOT invent a retry loop.TryPrepareAndSubmitAuthoredPlacement(record, token, route.OperationKind, route.SetPositionFlags, _collisionSource, _clock.SimulationTimeSeconds, out outcome, resolveWorldOffsetFromRuntimeFrame: true).- 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/_currPhysicsPosto the committed body position (kills the render-lerp residual); - calls
UpdateCellId(_body.CellPosition.ObjCellId, "force-position")so the render root chokepointPhysicsEngine.UpdatePlayerCurrCellstill 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 theLocalForcePositionTransaction.Applyblock with the Runtime call. When the status is anything other thanNotApplicable, 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 (_localPlayerOutboundis the precedent); do not add a service locator or a window back-reference. - Headless
RuntimeLiveEntitySessionController.OnPositionUpdated: replace theProjectPosition(isLocalPlayer: true, ForcePosition)+SendImmediatePositionpair with the same call. Leave theApply- dispositionOfferTeleportDestinationandTryCompletePortalalone — 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.mdanddocs/architecture/retail-divergence-register.mdin the SAME commit as the behaviour change. AP-131 is not retired here — its named legacy Position caller is route 4.
6. Acceptance
- 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 trailingisCurrent()covered today; theDeferredCell→ wake → commit → single ack sequence; heading preserved; leash NOT re-armed;NotApplicablewhile a first-entry residence is active. - App tests proving the generic tail no longer double-writes the local player, and that the committed projection is what moves the render entity.
- Headless tests proving the identical Runtime path.
dotnet build -c Releaseclean.- 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.
- 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.