diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8f0951af..b444f0ad 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -140,6 +140,302 @@ reconciling #281's 43 test failures. fired — the log shows zero `world frame is unreachable` failures and zero parked placements across 9 completed reveals. +## C4 route 2 — ForcePosition placement cutover — 2026-08-03 + +Plan: [`2026-08-03-c4-route-2-implementation-plan.md`](research/2026-08-03-c4-route-2-implementation-plan.md); +contract: [`2026-08-03-c4-route-2-contract.md`](research/2026-08-03-c4-route-2-contract.md). + +- **#285 — DONE (2026-08-03) — a ForcePosition on the local player wrote two + independent stores from one packet, and the outbound ack left before any + canonical commit existed.** `LocalForcePositionTransaction.Apply` + (App)/`HeadlessSessionWorldProjection.BlipLocalPlayer` (headless) drove + `PlayerMovementController.BlipPosition` — a raw `PhysicsBody.SnapToCell` + with no transition, no collision, no contact-plane resolve, no + `FullCellId`/`PlacementCommitVersion` advance — while the generic tail + (`LiveEntityNetworkUpdateController.cs`) independently wrote the + render-facing `WorldEntity` from the same wire frame; the App/no-window ack + (`LocalPlayerOutboundController.SendImmediatePosition`) fired immediately + after the blip, before either write's result was known. Same divergence + class as the remote-placement bug `670f307c` fixed. + **Fix:** `RuntimeAcceptedPositionDriveController` + (`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs`) is + the single Runtime-owned accepted-Position execution seam for a + ForcePosition on an already-live local player: it drives the SAME + `RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement` + + `TryPrepareAndSubmitAuthoredPlacement` transaction every other placement + uses (retail `CPhysicsObj::SetPositionSimple` @0x005162B0, flags `0x1012`, + called from `SmartBox::BlipPlayer` @0x00453940), reconciles the + controller's render-lerp/cell state + (`PlayerMovementController.CommitCanonicalForcePositionFrame`, replacing + the deleted `BlipPosition`), and fires the ack strictly AFTER that commit — + never before it. `LocalForcePositionTransaction.cs` and + `HeadlessSessionWorldProjection.BlipLocalPlayer` are deleted outright, not + adapted; the generic render-tail write is skipped for the local player's + ForcePosition (App now projects the committed result through the existing + `RuntimePlacementPresentationSink`, the same seam every other placement + already uses). + **Two named behaviour changes, both retail-exact per this session's + verification:** (1) the outbound `AutonomousPosition` ack is now an OUTPUT + of the committed route, not a step alongside it — retail's + `cmdinterp->SendPositionEvent()` @0x00454091 runs after + `SmartBox::BlipPlayer` @0x00454074 returns, and the deleted transaction's + trailing `isCurrent()` recheck (which could only suppress the + *continuation*, not an ack that had already left) is now structurally + impossible; (2) the constraint leash is NOT re-armed on this route — every + `CPhysicsObj::ConstrainTo` call in `HandleReceivedPosition` + (@0x00454272/0x0045418A/0x004541EC) is on a branch the FORCE_POSITION early + return (@0x0045409D) never reaches. `BlipPosition`'s leash re-arm (added at + #167 Slice P5, commit `7719d25b`) was an unbacked deviation for this exact + branch — it was correct for retail's "Player, normal" branch that Slice P5 + was modeling in general, but `SmartBox::BlipPlayer` is not on that branch. + #167's own historical write-up (below) is superseded for its `BlipPosition` + half by this entry. + **New behaviour (retail fidelity gain, not a regression):** the ForcePosition + now runs retail's REAL `SetPosition` collision resolve — a placement sphere + or authored Setup, not a bare teleport-shaped snap — so a corrected Z can + differ from the wire's literal Z by the placement sphere's own settle. + **R7 review correction (2026-08-03):** the FIRST implementation pass wrote + this paragraph against a fixture bug, not retail behavior — its headless + test fixture's dummy Setup sphere had its centre AT the origin (offset == + radius), which lifted a settled origin a FULL 0.48 m radius above the + floor and was asserted as if that were the retail-correct answer. Retail's + `BlipPlayer` has never lifted the origin by a sphere radius. The real dat + human Setup `0x02000001`'s foot sphere is `(0,0,0.475) r=.48` + (`Ts46SphereListConformanceTests.cs:35-39`), whose bottom sits at + `origin + 0.475 − 0.48 = origin − 0.005` — so a settled origin lands + **within 5 mm** of the floor it rests on, not a sphere radius above it. + The headless fixture now uses the dat-exact sphere and asserts the + measured `Z = 50.005f` (`HeadlessSessionHostTests.cs`, + `WorldProjectionIgnoresNormalEchoButBlipsForcePosition`). + Runtime tests: `tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs` + (classification/NotApplicable guards, ack-strictly-after-commit, ack + exactly once, the displaced-authority Contention case, the DeferredCell + park→wake→commit sequence, heading preservation, leash NOT re-armed, and — + added in the fix round — re-issue-from-canonical when a subsequent + accepted Position's merge-time `Forget` cancels a watched DeferredCell + park). **R8 review correction (2026-08-03):** the DeferredCell test's name + and assertions in the FIRST pass claimed a single-ack-after-wake sequence + the fixture does not exercise — this fixture's cross-landblock resolve + measures `InContact=false` (no subsequent physics tick sweeps the body + onto the terrain in this bare-Runtime harness), so no ack fires after the + wake at all today. The test is renamed + `DeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakes` + and no longer pins the current zero-ack count with an assertion (a pinning + `Assert.Empty` would fail — and read as a regression — the day Contact + correctly starts flipping); it captures the ack count once and asserts + only that further `Advance()` pumps never change it. The + park→wake→commit→single-ack sequence remains unverified pending a harness + that drives a real physics tick to establish ground contact. + **Fix-round corrections (2026-08-03), both dual reviews having FAILed the + first pass — see + [`2026-08-03-c4-route-2-review-findings.md`](research/2026-08-03-c4-route-2-review-findings.md) + for the full R1-R9 list:** (R1, HIGH) the DeferredCell park could not + survive in production — `RuntimeEntityObjectLifetime.TryApplyPosition` + Forgets the entity's in-flight SetPosition on EVERY accepted Position (any + disposition), so a park outliving one ACE broadcast (~100-200 ms) was + cancelled before its collision generation could ever commit it, silently + losing the correction forever. `RuntimeAcceptedPositionDriveController.Advance` + now detects the dead watch (`IsPlacementCompletionTracked`) and re-issues + the SAME route from the entity's current canonical snapshot rather than + leaking `_pending`. (R2, HIGH) headless lost `BlipLocalPlayer`'s collision- + neighborhood re-centering; restored via the new + `IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition`. (R3, HIGH) + the headless login-window ForcePosition fallback was dropped; restored. + (R4, MEDIUM-HIGH) the force-ack was stealing a `Place` receipt the + presentation sink had legitimately declined for its own retry contract; + removed. (R5, MEDIUM-HIGH) corrected the `Rejected` status doc's false + "no SetPosition ran" claim. (R6, MEDIUM) `_pending` could leak forever on + a mid-session cancellation (now caught by the same R1 detection) and + `SubmitAndResolve` could silently overwrite a still-live pending (now + guarded, throws on the invariant violation). (R9, LOW hygiene) a stale + `BlipPosition` doc cref, `HeadlessSessionHost._currentSession` never + cleared on teardown, and the streaming observer/pose-dirty side effects + firing for a `Rejected`/`Contention` status the route explicitly declined + to place into. Complete Release solution after the fix round: + **10,853 passed / 4 skipped / 0 failed** (10,857 total) — App 4,058/3, + Bake 15/0, Cli 4/0, Content 124/0, Core.Net 762/0, Core 4,247/1, Headless + 79/0, Runtime 1,021/0, UI.Abstractions 543/0. + **Round 2 fix (2026-08-03), both delta reviews having FAILed the fix round + — see the "ROUND 2" section of + [`2026-08-03-c4-route-2-review-findings.md`](research/2026-08-03-c4-route-2-review-findings.md):** + round 1 bolted the re-issue onto ad-hoc per-branch `_pending` bookkeeping, + which had no single owner and no single lifecycle rule; that is the shared + root cause of all three round-2 blockers. Replaced with ONE funnel: + `RuntimeAcceptedPositionDriveController.SettlePending` is the sole terminal + writer of `_pending`, `RetainPending` the sole outstanding writer, and + `AbandonPending` the sole teardown writer. Its ONE decision input is the + terminal operation token's `PositionAuthorityVersion` + (`RuntimeSetPositionState.cs:50`) versus the live record's current value — + equal ⇒ clear, no re-issue (fixes **N1**: one server correction now + produces exactly one canonical placement and exactly one outbound + `AutonomousPosition`, never two); advanced with the newest accepted event + still a ForcePosition ⇒ re-issue it, re-classified from the current record + via the newly recorded `_newestForce` observation (fixes **B1**: a + correction blocked by a woken park's retained completion is no longer lost); + advanced with the newest accepted event now an ordinary `Apply` ⇒ clear, no + re-issue (fixes **N2**: the round-1 shape reused the stale force route and + would have applied `Teleport|Slide` + an ack retail never sends to an + ordinary pose, skipping that branch's `ConstrainTo`). Complete Release + solution after round 2: **10,856 passed / 4 skipped / 0 failed** — App + 4,058/3, Bake 15/0, Cli 4/0, Content 124/0, Core.Net 762/0, Core 4,247/1, + Headless 79/0, Runtime 1,024/0, UI.Abstractions 543/0. + Round 3 closed the conformance reviewer's one remaining blocker: a terminal + outcome that never committed now sends the packet's retail position event + carrying the body's unchanged pose, because `SmartBox::BlipPlayer` + @0x00453940 DISCARDS `CPhysicsObj::SetPositionSimple`'s + @0x005162B0 `enum SetPositionError` (other retail callers test it, + `== OK_SPE` @0x0055605D) and returns `void`, after which + `SmartBox::HandleReceivedPosition` @0x00453FD0 runs + `cmdinterp->SendPositionEvent()` @0x00454091 unconditionally and returns + @0x0045409D. Retail's rule is: attempt once, do not move on failure, + acknowledge regardless, never retry — so route 2 acks exactly once per begun + placement, from the commit path or from the settle, never both and never + zero. + Divergence register: **AD-62 filed** (round 2, rewritten round 3) — the + deferred-placement adaptation means a ForcePosition that cannot commit when + it arrives, and is then retired without commit, is not re-applied. As of + round 3 its position-event ack IS still sent whenever the placement was begun + AND that packet's descriptor reaches its own terminal settle. The ack is lost + in two narrower groups: where no placement was ever begun (an + externally-blocked `Contention`; a re-issue marker that never begins), and — + begun but displaced — where a newer force supersedes the packet before its + settle, since `SettlePending` nulls `_pending` without reading it (AD-62 + shape (v)). Replaying that one would emit a stale-sequence report carrying + the newer packet's pose; the displacing packet always acks. Retail's + `SmartBox::BlipPlayer` is synchronous against a fully resident world and + reaches none of these states. The route-2 cutover ITSELF still adds no + deviation (it closes the + duplicate-authority + premature-ack bug); AP-131 is explicitly NOT retired + here (its named legacy `TryApplyPosition` caller is route 4's job, not + route 2's). +- **#286 — OPEN — headless never calls `RetryPending` on its placement + projection subscription.** `RuntimePlacementProjectionSubscription.RetryPending` + has exactly one caller in the tree, `GraphicalSessionEventRoute.cs:109,113`; + `HeadlessSessionEventRoute` constructs the subscription + (`HeadlessSessionEventRoute.cs:22`) but nothing pumps its retry. C4 route + 2's R4 fix deliberately stopped the force-ack from consuming a `Place` the + sink declined, on the contract that the subscription's OWN retry re-offers + it — so on headless a declined `Place` would sit at the FIFO head and wedge + the ordered stream. Latent: not proven reachable today (the headless sink's + decline conditions may be unreachable given its bounded collision window). + Fix shape: give the headless host the same per-tick retry pump the + graphical route has, or prove the decline unreachable and record why. + Filed from the C4 route-2 round-2 review (N3). +- **#287 — OPEN — `RuntimeAcceptedPositionDriveController.Advance` has no + reentrancy latch.** Its template `RuntimeFirstEntryDriveController.DriveAll` + guards with `_driving` (`RuntimeFirstEntryDriveController.cs:61,130,132,146`); + the accepted-position drive does not, even though `Advance` can now re-enter + `SubmitAndResolve` through the `SettlePending` re-issue. No live re-entrant + path exists today (the funnel's recursion is bounded at one level and every + host pumps `Advance` from a single synchronous cadence point). Hygiene, not + a live defect. Filed from the C4 route-2 round-2 review (N4). +- **#288 — OPEN — two side effects were dropped when + `HeadlessSessionWorldProjection.BlipLocalPlayer` became + `CenterOnAcceptedForcePosition`.** (a) The deleted method also restored + `controller.LocalEntityId = record.LocalEntityId ?? 0u`; the replacement + (`HeadlessSessionWorldProjection.cs`, `CenterOnAcceptedForcePosition`) does + not. Inert today — nothing clears the id between publication and a + ForcePosition — but it is an unreplaced deletion, not a decision. (b) + `_movementTruthDiagnostics.OnServerEcho` no longer fires for a local + ForcePosition on the graphical host, because that route returns before the + generic tail (`LiveEntityNetworkUpdateController.cs`). Diagnostic-only. + Filed from the C4 route-2 round-2 review (N5). +- **#289 — OPEN — two doc comments still cite the deleted + `PlayerMovementController.BlipPosition`.** `src/AcDream.Core/Physics/Motion/ConstraintManager.cs:25` + and `src/AcDream.Core/Physics/PhysicsBody.cs:442` both name it inside `` + tags, so they are build-safe (unlike a ``, which R9 already + fixed) but false: C4 route 2 deleted the member. Left as-is they become the + citation a future session trusts. Filed from the C4 route-2 round-2 review + (R9 residue). +- **#290 — OPEN — route 1's classified `SendPositionImmediately` never fires + an ack.** The C4 plan required confirming whether the outbound position ack + fires while an initial-Create residence owns the record. It does not: + `RuntimeInitialCreateContinuationExecutor` consumes + `SendPositionImmediately` only as a trace fact (`:717`, `:2576`), never as + an outbound send. Not a regression (route 1 predates route 2 and behaves + exactly as before), but retail's FORCE_POSITION branch acks unconditionally + after `BlipPlayer`, so a ForcePosition admitted during the login residence + window is one retail ack we do not send. Decide at route 4/C5 whether the + residence tail should send it. Filed because the plan required filing it. +- **#291 — OPEN — the headless 3x3 collision window needs a divergence + register row.** C4 route 2's R2 fix promoted it to a NAMED member of the + Runtime-facing contract (`IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition`) + with an explicit ordering requirement — re-center BEFORE the placement + submits — that retail has no analogue for (retail has every landblock + resident). No existing row covers it: AD-6 is retired and AD-2 is the + graphical reveal barrier. Recommend one AD row naming the window, the + ordering requirement, and the symptom if it breaks (a `DeferredCell` park + the window can never publish). Filed from the C4 route-2 round-2 review. +- **#292 — OPEN — C4 route 2 acceptance item 2 is source-pinned, not + proven.** Recorded gap from round-2 finding B2 (the plan's own text is + corrected at + [`2026-08-02-placement-cutover.md`](plans/2026-08-02-placement-cutover.md)). + First half — "the generic tail no longer double-writes the local player" — + is pinned only by a source-text regex, which a differently spelled second + write would pass, and no test exercises the branch. Second half — "the + committed projection is what moves the render entity" — is uncovered at any + layer: no test drives a route-2 ForcePosition through + `RuntimePlacementPresentationSink` / `TryApplyRuntimePlacementPlace` and + asserts the `WorldEntity` moved. Given R4, that is exactly the seam whose + failure is silent (canonical body moves, render entity stays). Fix shape: an + App-layer end-to-end test asserting the render entity's position/cell came + from the committed placement receipt. +- **#293 — OPEN — the DeferredCell park still consumes `Withdraw` receipts the + sink may have declined.** `RuntimeAcceptedPositionDriveController.SubmitAndResolve`'s + `DeferredCell` branch drains the head of the placement FIFO while it is a + `Withdraw` for this entity and calls `AcknowledgeProjection` on it + (`RuntimeAcceptedPositionDriveController.cs:709-716`). That is the exact + receipt-stealing shape R4 removed for `Place`: the R4 fix's whole argument is + that `RuntimePlacementProjectionSubscription` deliberately leaves a receipt + the sink declined at the FIFO head for its own later retry, and that this + route has no follow-up binding to cover it. The `Withdraw` drain was left + unchanged in round 1 because it is copied verbatim from + `RuntimeFirstEntryDriveController.TryCompleteContinuationPlacement`, but the + same asymmetry applies — that controller's residence guarantees the decline, + this one's does not. Fix shape: decide whether a declined `Withdraw` is + reachable for this route and either drop the drain (letting the + subscription's retry own it, as `Place` now does) or record why the decline + cannot happen here. Filed from the C4 route-2 round-3 adversarial review. +- **#294 — OPEN — the deferred wake reconciles and acks BEFORE the funnel's + currency guard.** `RuntimeAcceptedPositionDriveController.Advance` consumes + the acknowledged placement and immediately calls `ReconcileAndAcknowledge` + (`:452`), and only then enters `SettlePending`, which is where the + entity-still-active / still-the-local-player / still-the-same-incarnation + checks live (`TryGetActive`, `ServerGuid`, `PhysicsBody`, `key != + terminalToken.Entity`). So a wake that lands after the entity departed the + world, stopped being the local player, or released its incarnation still + runs the controller-local reconcile and can still send an outbound + `AutonomousPosition`. `ReconcileAndAcknowledge`'s own + `record.ServerGuid != _localPlayerServerGuid()` test uses the RETAINED + record, not a re-resolved active one, so it does not cover the departed + case. Ordering, not a workaround: the currency guard belongs before the + reconcile. Filed from the C4 route-2 round-3 adversarial review. +- **#295 — OPEN — the re-issue retry marker inflates + `AcceptedPositionDrivePendingCount`.** When `SettlePending`'s re-issue cannot + begin, it parks the terminal descriptor as a retry marker in `_pending`. That + marker is not an in-flight placement — its token is dead by construction — + but `PendingCount` (`:259`) and the ownership ledger registered at `:255-256` + both report it as one. Any convergence reader (`CaptureOwnership`, + `GameWindowLifetime.DisposeGameRuntime`'s non-convergence throw) therefore + sees a placement that does not exist, and a marker that outlives its + usefulness reads as a wedged operation rather than as "a re-issue is owed". + Fix shape: count in-flight placements and owed re-issues separately, or give + the marker its own field. Filed from the C4 route-2 round-3 adversarial + review. +- **#296 — OPEN — a retryable prepare is reported to hosts as `Contention`.** + `SubmitAndResolve` returns `RuntimeAcceptedPositionExecutionStatus.Contention` + for a retryable preparation status (`RetrySetupUnavailable` / + `RetryWorldFrameUnavailable`, `:661`), reusing the status whose documented + meaning is "begin failed; the entity already owns an operation". The two are + materially different: the retryable case DID begin, IS retained in `_pending`, + and WILL be re-driven by the next `Advance` pump, while true `Contention` may + have recorded nothing at all (register row AD-62 shape (iv)). Hosts cannot + distinguish them — `LiveEntityNetworkUpdateController` branches on the status + — and neither can a future reader of the enum doc. Fix shape: a distinct + status (or a documented union) so the retained-and-pumping case is not + conflated with the dropped case. Filed from the C4 route-2 round-3 + adversarial review. + ## C3c placement cutover — 2026-08-02 - **#276 — OPEN — SpawnPlacementSettler discards the settle's resolved @@ -4913,6 +5209,16 @@ gate is `PhysicsBody.IsFullyConstrained` (former TS-35) via `jump_is_allowed`. Decomp: `docs/research/2026-07-03-r5-managers/`, `docs/research/2026-07-30-constraint-leash-constants.md`. +**2026-08-03 correction (C4 route 2, #285):** the `BlipPosition` half of this +arming site was an unbacked deviation for the ForcePosition branch +specifically — `SmartBox::BlipPlayer` (0x00453940), the function +`HandleReceivedPosition`'s FORCE_POSITION branch calls, is not on the +"Player, normal" branch this slice modeled; retail's FORCE_POSITION early +return (0x0045409D) precedes every `ConstrainTo` call. `BlipPosition` is +deleted; the leash is no longer (re)armed on a ForcePosition. The arming +site for every OTHER inbound position (remotes, the local player's ordinary +teleport/`SetPosition`) is unaffected. + **Acceptance:** the two constants are recovered (byte-decoded from the binary), acdream arms the leash on inbound server positions, `IsFullyConstrained` fires while rubber-banding, and a jump attempt inside diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8e020dab..b07848de 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 47 active rows (AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-42 refreshed same round — its cited App-side login resolve split was deleted by the C3c flip, the split survives only on the unflipped remote-teleport/headless portal-resync paths; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 48 active rows (AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-42 refreshed same round — its cited App-side login resolve split was deleted by the C3c flip, the split survives only on the unflipped remote-teleport/headless portal-resync paths; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -155,6 +155,7 @@ readiness/requeue adaptation. See | AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) | | AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | | AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 | +| AD-62 | **Filed 2026-08-03 (C4 route 2, round 2); rewritten round 3.** General rule: an accepted local-player ForcePosition that this route does not carry through to a committed canonical placement is never re-applied. That half matches retail — `SmartBox::BlipPlayer` attempts the placement exactly once and never retries. What diverges is that acdream has non-commit outcomes retail cannot reach at all, because retail's world is fully resident and its placement synchronous. Round 3 narrowed the loss to the re-apply alone wherever the packet's placement was actually BEGUN: the retail position event now fires at that packet's terminal outcome whether or not the placement committed (`SettlePending`'s `positionEventOwed` path), matching `BlipPlayer` discarding `SetPositionSimple`'s `enum SetPositionError` and `HandleReceivedPosition` acking unconditionally @0x00454091. Shapes losing ONLY the re-apply: (i) the destination landblock's collision generation is unpublished so the placement parks (`DeferredCell`) and is then retired by a non-position cause (collision-generation retirement, the lost-cell deadline, `ParkCollisionResidents`) with the accepted authority unmoved — the funnel's EQUAL branch; (ii) the same park superseded by a newer ordinary `Apply` Position which now owns the pose — the ADVANCED+ordinary branch; (iii) any OTHER `PositionAuthorityVersion` advance moving the record out from under the funnel's re-issue test — `TryApplyPickup` (`RuntimeEntityObjectLifetime.cs:1116`), `CommitPositionChannelUpdate` (`:2041`), `AdvanceCreateAuthority` (`:2466`) — effectively unreachable for a live local player, but they fail silently in the same direction and the funnel cannot tell them from (ii). Shapes still losing BOTH the re-apply and the ack because no placement was ever begun for that packet: (iv) a `Contention` whose blocking operation is EXTERNAL to this drive (a concurrent portal/teleport placement owns the entity) — nothing is recorded in `_pending`, so nothing pumps it and the packet is dropped outright; (vi) a re-issue retry marker whose re-issue never manages to begin before the funnel clears it. Losing BOTH for a DIFFERENT reason — the placement WAS begun, but the descriptor was displaced before reaching its own terminal settle: (v) a packet superseded by a newer force whose own placement begins cleanly — `SettlePending` opens by nulling `_pending` without reading it, so the older descriptor's owed ack is discarded. Replaying it would be worse than losing it (a stale-sequence report carrying the newer packet's committed pose), and the displacing packet always acks, so ACE always receives a report for the newest force. The `DeferredCell` park is NOT a precondition of this row: shapes (iv)-(vi) never park. In every shape the body stays where the last successful placement left it and the next accepted Position (ACE broadcasts at 5-10 Hz) carries the corrected pose forward. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`SettlePending` — the single terminal-outcome funnel: its `positionEventOwed` ack and its two non-reissuing branches; and `TryExecuteAcceptedLocalPosition`'s `Contention` return) | Retail has no park and no external placement authority: `SmartBox::BlipPlayer` runs synchronously against a fully resident world, so "arrived but not yet placeable" and "another placement owns this entity" are both unrepresentable there. Those are our async collision-publication and single-placement-authority adaptations. Re-issuing a retired force instead would be worse than not: shape (ii) would stamp the force route's `Teleport\|Slide` flags and an unconditional ack onto an ordinary echo's pose while skipping the `ConstrainTo` the ordinary branch runs (`RuntimeAuthoritativePositionRouteClassifier.cs:368-388`), and shape (i) can re-issue into the same persistent cancellation cause indefinitely. The drive still owns at most one in-flight placement and still re-issues whenever the newest accepted event IS a still-unserved ForcePosition. | A server correction whose destination collision is slow to publish, or which lands while another placement authority owns the entity, can be silently skipped: the player stays at the pre-correction pose for one broadcast interval (~100-200 ms). Sustained (a slow-publishing destination correcting repeatedly) this reads as rubber-banding that does not take. In shapes (iv)-(vi) ACE additionally receives one fewer `AutonomousPosition` than retail would have sent, so the server cannot tell its force was not applied. | `SmartBox::HandleReceivedPosition` @0x00453FD0 FORCE_POSITION branch (`SendPositionEvent` @0x00454091, early return @0x0045409D); `SmartBox::BlipPlayer` @0x00453940 (discards the error, returns void); `CPhysicsObj::SetPositionSimple` @0x005162B0 (returns `enum SetPositionError`; other callers test `== OK_SPE` @0x0055605D/@0x00556021); `CommandInterpreter::SendPositionEvent` @0x006B4770 | --- diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 1e92af05..565baf2e 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -252,9 +252,70 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. 4 (remote Create/Position; delete `RemoteTeleportController`/`Placement` and the inline MoveOrTeleport duplicate), 5 (projectile authoritative), 6 (drops + split-recovery marking), 7 (residual pickup/parent/delete - polish). — OPEN at the 2026-08-03 handoff.** May land as more than one - commit if a route proves large; - each sub-landing keeps the full review discipline. + polish). — route 2 implementation COMPLETE, pending review, at the + 2026-08-03 handoff; routes 3/4/5/6/7 remain OPEN.** + **Route 2 (ForcePosition) — implemented 2026-08-03, contract:** + [`2026-08-03-c4-route-2-contract.md`](../research/2026-08-03-c4-route-2-contract.md), + **plan:** [`2026-08-03-c4-route-2-implementation-plan.md`](../research/2026-08-03-c4-route-2-implementation-plan.md). + `RuntimeAcceptedPositionDriveController` + (`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs`) + is the single accepted-Position execution seam for a ForcePosition on the + already-live local player; `LocalForcePositionTransaction` and + `HeadlessSessionWorldProjection.BlipLocalPlayer` are deleted, and the + generic App render-tail is skipped for the local player's ForcePosition. + Named behaviour changes (both retail-exact, ISSUES #285): the outbound + ack now fires strictly after the canonical commit, and the constraint + leash is no longer re-armed on this route (retail's FORCE_POSITION branch + never reaches `ConstrainTo`). + **Fix round (2026-08-03):** both independent dual reviews (retail- + conformance + architecture/adversarial) FAILed the first pass — see + [`2026-08-03-c4-route-2-review-findings.md`](../research/2026-08-03-c4-route-2-review-findings.md) + for the full R1-R9 list. The critical finding (R1) was that the + DeferredCell park could not survive a single ACE broadcast interval in + production (`RuntimeEntityObjectLifetime.TryApplyPosition`'s unconditional + `Forget` on every accepted Position cancelled it before its collision + generation could commit), silently dropping the correction forever; + `RuntimeAcceptedPositionDriveController.Advance` now detects the dead + watch and re-issues from the entity's current canonical snapshot. R2/R3 + restored headless's collision re-centering and login-window fallback; R4 + stopped the force-ack from stealing a receipt the presentation sink had + legitimately declined; R5/R6/R9 corrected false doc claims, closed a + `_pending`-leak/overwrite gap, and fixed streaming-observer/pose-dirty + side effects firing on a declined placement. R7 corrected a fixture bug + (a dummy Setup sphere with its centre at the origin) that had been + written up as a retail fidelity gain; R8 added App-layer double-write + source pins and corrected an overclaimed single-ack test. Full detail: + [`2026-08-03-c4-route-2-review-findings.md`](../research/2026-08-03-c4-route-2-review-findings.md). + Complete Release solution after the fix round: **10,853 passed / 4 + skipped / 0 failed** (baseline 10,844/4/0; first pass 10,848/4/0). + + **Acceptance item 2 is NOT met — recorded gap, B2 (2026-08-03 round 2).** + An earlier revision of this paragraph claimed R8 "added the App-layer + double-write source pins the plan's own acceptance item required". That was + a claim of coverage this changeset does not have, and it is corrected here + rather than left as the citation a future session trusts (same rule that + produced R7). The truth, per the adversarial review: + - *First half — "the generic tail no longer double-writes the local + player":* **source-pinned, not proven.** The pin is a regex/`Assert.Single` + over `LiveEntityNetworkUpdateController`'s source text, so it would still + pass if a second write were spelled differently, and **no test exercises + the branch** at runtime. + - *Second half — "the committed projection is what moves the render + entity":* **uncovered at any layer.** No test drives a route-2 + ForcePosition through `RuntimePlacementPresentationSink` / + `TryApplyRuntimePlacementPlace` and asserts the `WorldEntity` actually + moved. Given R4 (the force-ack no longer consumes a declined `Place`), + this is precisely the seam whose failure mode is silent: the canonical + body moves and the render entity stays put. + Closing this gap needs an App-layer test that runs the accepted + ForcePosition end to end and asserts the render entity's position/cell came + from the committed placement receipt — carry it into C5's parity tests or + file it before this sub-landing closes. + **Not yet done:** both reviews must be RE-RUN on this fixed diff, and the + connected (user-gated) acceptance gate this campaign's standing + discipline requires, before this sub-landing is considered closed — those, + and the commit itself, are next. May land as more than one commit if a + route proves large; each sub-landing keeps the full review discipline. - **C5 — legacy deletion + closeout gates — OPEN.** Delete every superseded legacy path; parity tests; exact lifecycle/reconnect + canonical nine-stop connected routes; two-client observation; **user visual matrix** (the diff --git a/docs/research/2026-08-03-c4-route-2-implementation-plan.md b/docs/research/2026-08-03-c4-route-2-implementation-plan.md new file mode 100644 index 00000000..714151f1 --- /dev/null +++ b/docs/research/2026-08-03-c4-route-2-implementation-plan.md @@ -0,0 +1,291 @@ +# 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 () + { + 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 -- `. +- 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. diff --git a/docs/research/2026-08-03-c4-route-2-review-findings.md b/docs/research/2026-08-03-c4-route-2-review-findings.md new file mode 100644 index 00000000..cf5e905f --- /dev/null +++ b/docs/research/2026-08-03-c4-route-2-review-findings.md @@ -0,0 +1,479 @@ +# C4 route 2 — dual review findings and required fixes (2026-08-03) + +Both mandated reviews returned **FAIL** on the first implementation pass. +Nothing is committed. This is the consolidated fix list; it supersedes the +implementer's own closing report where they disagree. + +Reviews: retail-conformance (Opus) and architecture/adversarial (Opus), run +independently against the same uncommitted diff. + +## Verified correct — do not churn these + +Both reviews independently confirmed, with addresses: + +- `AuthoritativeTeleportFlags` = `Teleport|Slide|SendPositionEvent` = `0x1012`, + byte-exact against `CPhysicsObj::SetPositionSimple` @0x005162B0. +- Leash re-arm removal is retail-correct: the FORCE_POSITION branch returns at + 0x0045409D, strictly before all three `ConstrainTo` sites (0x00454272, + 0x0045418A, 0x004541EC). The teleport / `CommitPreparedPosition` / + `ArmConstraintLeashAtCommittedPlacement` callers correctly still constrain. +- Heading preservation happens exactly once, upstream in + `InboundPhysicsStateController.ApplyAcceptedPosition:788-798`. The seam does + not re-apply or drop it. +- Ack ordering is correct and fires exactly once on both the synchronous and + the deferred path; the `CanSendPositionEvent` gate matches retail's + `CommandInterpreter::SendPositionEvent` @0x006B4770, and correctly does NOT + apply `ShouldSendPositionEvent`'s rate limit (retail's force branch calls + SendPositionEvent directly). +- Route-request field derivation matches the continuation executor field for + field. +- Contract items 2, 4 (the Runtime command itself), 6, and 7 pass. AP-131 + correctly not retired. +- The re-modelled `PlayerMovementControllerTests` are relocations, not + weakenings. + +## Required fixes, in priority order + +### R1 — HIGH — the DeferredCell park cannot survive in production + +`RuntimeEntityObjectLifetime.cs:1636` calls `Physics.SetPosition.Forget(canonical)` +on EVERY accepted Position, which unconditionally cancels the entity's in-flight +operation. ACE broadcasts at 5-10 Hz, so any park lasting longer than ~100-200 ms +is guaranteed to be cancelled before its collision generation commits — exactly +the far-destination case the deferred path exists to serve. + +Chain: park -> cancelled -> `RetryDeferred` never runs -> no `Place` receipt -> +`ReconcileAndAcknowledge` never runs -> **the body is never moved and no ack is +ever sent.** The old `LocalForcePositionTransaction` / `BlipLocalPlayer` pair +applied the correction synchronously and unconditionally. Retail's `BlipPlayer` +@0x00453940 has no "give up quietly" state at all. + +**Park-and-hope is not a valid mechanism here. Required direction:** + +1. Do not open a park that can never wake. Before submitting, establish that the + destination's collision is publishable (R2), and mirror the existing + C3c-R1-F7 guard shape (`IHeadlessCollisionNeighborhood.IsWithinServiceWindow`, + `HeadlessSessionWorldProjection.cs:20-27`) rather than inventing a new one. +2. Where a park still legitimately occurs, the seam must detect that its + operation was cancelled — `RuntimeSetPositionState.IsPlacementCompletionTracked` + (`:1245`) is the existing read-only query — and **re-issue the placement from + the current canonical snapshot** on the next accepted Position or `Advance()`. + The snapshot already carries the latest accepted pose, so re-issuing is + correct, not a replay of stale state. +3. A force correction must never be silently dropped. That is the retail + invariant this route exists to preserve. + +Do NOT resolve this with a timeout, a settle window, a retry counter, or by +exempting ForcePosition from `Forget`. If you conclude the correct answer is a +deliberate, cited divergence, STOP and report rather than shipping one. + +### R2 — HIGH — headless lost its destination-collision publication + +`HeadlessSessionWorldProjection.BlipLocalPlayer` (deleted) called +`_collision.CenterOn(position.LandblockId)`. The headless collision neighborhood +is a hard 3x3 window (`BuildPublicationPlan`, `:462-488`) moved ONLY by +`CenterOn`. The surviving callers are spawn (`:562`), the controller-null login +branch (`:603`), teleport prep (`:640`) and portal arrival (`:683`) — a +ForcePosition on a live local player now reaches none of them. `PumpFirstEntry` +(`:624`) polls the stale `_requestedLocalPlayerCell`, which nothing updates on +this path either. + +Restore a real mechanism (re-center plus the `_requestedLocalPlayerCell` +update), or gate on `IsWithinServiceWindow` and handle out-of-window explicitly. +The deleted `CenterCount` assertion in `HeadlessSessionHostTests.cs:430` is the +invariant; restore it rather than the changed number. + +The in-test justification ("retail's BlipPlayer has no streaming-window +concept") is true of retail and irrelevant: the window is OUR adaptation, and +retail has no equivalent because retail has every landblock resident. + +### R3 — HIGH — login-window ForcePosition now does nothing at all + +`RuntimeLiveEntitySessionController.cs:229-254`. Previously every accepted local +Position ran `_worldProjection.ProjectPosition`, whose controller-null branch +(`HeadlessSessionWorldProjection.cs:593-607`) set `_requestedLocalPlayerCell`, +called `CenterOn`, and pumped `_firstEntry.DriveAll()`. Now a ForcePosition +takes the new branch, the drive returns `NotApplicable` (residence active), and +nothing happens. + +Restore the pump for the controller-absent case, and delete the comment at +`:239-240` claiming "there is no legacy fallback to run instead" — there was +one; it is the `else` branch this change routed around. + +### R4 — MEDIUM-HIGH — the force-ack steals a receipt the sink declined + +`RuntimeAcceptedPositionDriveController.cs:366-375` unconditionally calls +`AcknowledgeProjection(outcome.Projection)`. `RuntimePlacementProjectionSubscription` +deliberately leaves a declined `Place` at the FIFO head for a later retry +(`:118-121`); this consumes and destroys it. + +The sink declines for real production reasons — `!_spatial.IsLoaded(landblock)` +(`LiveEntityRuntime.cs:1210-1219`) and stale transit authority +(`RuntimePlacementPresentationSink.cs:90-96`). In those cases `entity.SetPosition` +/ `Rotation` / `ParentCellId` / `RebucketLiveEntity` / `IsSpatiallyProjected` +are never written, and because the generic tail is now skipped there is no +second writer to cover it — the render entity silently stays put while the +canonical body moved. + +The `RuntimeFirstEntryDriveController` mirror is safe ONLY because a residence +makes the sink decline by design and a follow-up `ExecutorCompleted` receipt +re-binds presentation. Route 2 has no such follow-up. Drop the force-ack and let +the subscription's retry contract stand, or provide a real follow-up binding. + +### R5 — MEDIUM-HIGH — `Contention` and `Rejected` silently drop the correction + +`RuntimeAcceptedPositionDriveController.cs:265-271` returns `Contention` when +`TryBeginExclusiveAuthoredPlacement` fails; neither status creates a pending +entry, and both hosts then return without blipping or acking +(`LiveEntityNetworkUpdateController.cs:1140`). The `Contention` doc comment +promises "a later accepted Position, or this controller's own Advance pump, +retries" — the pump provably cannot retry something never recorded, and a later +Position carries a different pose. Retail always applies. + +The most likely trigger is R1's parked operation, which makes every subsequent +ForcePosition `Contention`. Fixing R1 largely fixes this; the status handling +must still not silently drop. + +Also fix the `Rejected` enum doc: it claims "No SetPosition ran; no ack was +sent", which is false at the two `SubmitAndResolve` sites (`:361`, `:415`) where +a SetPosition ran and was cancelled. + +### R6 — MEDIUM — `_pending` leaks and can be silently overwritten + +`RuntimeAcceptedPositionDriveController.cs:289-324`. `Advance()` exits only on +record-key release or acknowledged completion. A mid-session cancellation +(supersession, lost-cell deadline, `ParkCollisionResidents`, generation cancel — +all route through `ForgetPlacementCompletionCore`) leaves `_pending` set +forever, so `AcceptedPositionDrivePendingCount` keeps `IsConverged` false for +the rest of the session. `GameWindowLifetime.DisposeGameRuntime:490-498` throws +on non-convergence. + +Plan §4c required dropping the pending ack on cancellation, supersession, +teardown, generation change and reset. Only teardown and reset shipped. Use +`IsPlacementCompletionTracked`. Also: the `_pending = null` cleanups are all +guarded by `if (!firstAttempt)`, and `SubmitAndResolve(firstAttempt: true)` +assigns `_pending` without inspecting an existing one — make it refuse to +overwrite a live pending. + +### R7 — MEDIUM — the 0.48 fixture, the changed assertion, and the false doc + +**This is a test-fixture artifact, NOT a production regression.** The headless +fixture's `LoadedSetupCollisionSource` returns one sphere +`(Vector3.Zero, 0.48f)` — centre AT the origin, bottom 0.48 m below the feet. +The real human Setup `0x02000001` is `(0,0,0.475) r=0.48` plus +`(0,0,1.350) r=0.48` (`Ts46SphereListConformanceTests.cs:35-39`), so the foot +sphere's bottom is origin - 0.005 and a settled origin lands on the floor within +5 mm. The control is in this same changeset: the new Runtime fixture uses the +dummy sphere (offset == radius) and asserts the origin lands exactly on the +floor (`RuntimeAcceptedPositionDriveControllerTests.cs:178`). + +Required: +1. Give the headless fixture the retail offset `(0f, 0f, 0.475f) r=0.48` and + **restore the `Z == 50f` assertion**. Changing an assertion to match new + output is the plan's own forbidden move. +2. Delete the false comment at `HeadlessSessionHostTests.cs:419-427` — it + describes the sphere CENTRE and then asserts it about `controller.Position`, + which is the ORIGIN (`PlayerMovementController.cs:304` -> `PhysicsBody.cs:153`, + retail `CPhysicsObj::m_position.frame.origin`). +3. Correct the `docs/ISSUES.md` #285 "retail fidelity gain" paragraph. Retail's + `BlipPlayer` has never lifted the origin by a sphere radius. Left as-is this + becomes the citation a future session trusts. + +### R8 — MEDIUM — acceptance gaps + +- No App-layer test exists proving the generic tail no longer double-writes the + local player and that the committed projection is what moves the render + entity. The plan's acceptance item 2 is unmet; three App tests were deleted + and replaced with a comment. Given R4, this is precisely the seam that is + broken. +- `RuntimeAcceptedPositionDriveControllerTests.cs:310-311` asserts + `acksAfterFirstResolve <= 1`, so the test **passes with zero acks** — the + DeferredCell park -> wake -> commit -> single-ack sequence is unverified, + while `docs/ISSUES.md` claims it is covered. Fix the fixture so the contact + gate is satisfied and assert exactly one, or state plainly that it is + unverified. Do not leave the overclaim in the record. + +### R9 — LOW — hygiene + +- `RuntimeAcceptedPositionDriveController` is `public sealed` with an internal + ctor and all-internal members; its template `RuntimeFirstEntryDriveController` + is `internal sealed`. Make it internal unless the public surface is genuinely + required (if it is, say why). +- `PlayerMovementController.cs:632` has a stale `` to + a deleted member. Harmless only while `GenerateDocumentationFile` is off; + `TreatWarningsAsErrors` is on, so it breaks the build the day docs are enabled. +- `HeadlessSessionHost._currentSession` is never cleared on teardown. +- Headless without a content lease leaves the drive controller null, so the + ForcePosition and its ack are dropped entirely + (`HeadlessSessionHost.cs:568-581`); previously the ack fired unconditionally. +- `LiveEntityNetworkUpdateController.cs:1140-1155` fires + `MarkLiveOwnerPoseDirty` and `ObserveAcceptedLocalPosition` for ANY non- + `NotApplicable` status including `Rejected` and `Contention` — moving the + streaming observer to a landblock we explicitly refused to place into. + +## Gate + +Unchanged: complete Release solution suite, not a focused subset. Pre-change +baseline is 10,844 / 4 skipped / 0 failed; the first pass reached 10,848 with +the defects above, so a green suite is necessary and demonstrably not +sufficient. Both reviews must be re-run on the fixed diff before commit. + +--- + +# ROUND 2 — residuals after the R1-R9 fix round (2026-08-03) + +Both delta reviews returned FAIL again. Suite is green at 10,853 / 4 / 0, which +again proves nothing. R2, R3, R4, R5, R6, R7 and R9 are confirmed genuinely +fixed and must not be churned. Three blocking residuals remain, and **two of +them are defects in the R1 reissue mechanism itself.** + +**Root of the problem: `_pending` has no single owner and no single lifecycle +rule.** Round 1 bolted reissue onto ad-hoc per-branch bookkeeping. B1 wants MORE +reissuing, N1 wants LESS, and N2 wants reissue to be a DIFFERENT route — they +look contradictory only because there is no unifying rule. There is one. + +## The unified mechanism (implement exactly this — it replaces the ad-hoc rules) + +`RuntimeEntityPlacementToken` already carries `PositionAuthorityVersion` +(`RuntimeSetPositionState.cs:50`). Make that the single decision input. + +**One rule:** the drive owns at most one in-flight placement for the local +player. After any terminal outcome, and on every `Advance()`, compare the +committed/parked token's `PositionAuthorityVersion` against the live record's +current `PositionAuthorityVersion`: + +- **Equal** — the canonical accepted authority has not moved since this + operation began. Nothing is outstanding. Clear `_pending`. Do not reissue. +- **Advanced** — a newer accepted Position arrived while we were in flight, and + it may have been the thing that killed our operation. Consult the newest + accepted event's disposition (do NOT reuse `stale.Route`): + - still **ForcePosition** — reissue, re-classifying from the current record. + - now an ordinary **Apply** — clear `_pending` and do NOT reissue. The + correction was superseded by newer server truth; the ordinary route owns + that pose. This is not a silent drop: retail applies each event as it + arrives, and a force correction overtaken by a newer position is moot. + +Route every terminal branch through one `_pending` funnel. No branch may assign +or clear it directly. + +### B1 — BLOCKING — a force correction is still silently dropped (conformance) + +When a parked operation wakes and its `Place` is ACCEPTED by the sink, the +operation leaves `_operations` but the completion is retained. `CancelCoreDeferred` +then returns early at `RuntimeSetPositionState.cs:5141` without reaching +`ForgetPlacementCompletionCore`, so the retained completion survives. The next +ForcePosition hits `HasRetainedCompletion` (`:1319`) -> invalid token -> +`Contention` (`RuntimeAcceptedPositionDriveController.cs:289-295`); both hosts +return without placing or acking, and the next `Advance()` consumes the OLD +completion and acks the OLD pose. That packet's correction is lost. + +One-frame window on the graphical host only (`_session.Tick()` inbound dispatch +precedes `RetryPending()` in `RetailLiveFrameCoordinator`); headless is immune +because its pump is adjacent to the readiness check. The DECLINED-`Place` +variant is unaffected and needs no change. + +The unified rule fixes this: the new packet advanced `PositionAuthorityVersion` +past the committed token, and the newest event is a ForcePosition, so it +reissues. + +### N1 — BLOCKING — stale `_pending` causes a second placement AND a second ack + +`SubmitAndResolve(firstAttempt: true)` (`:297`) never inspects `_pending`, and +only the `!firstAttempt` branches clear it (`:508-511`, `:482-484`, `:549-551`). +So: park -> `Forget` wipes the watch -> the same packet's ForcePosition Begins +cleanly and Commits -> ack fires -> `_pending` still holds the dead P1 -> next +`Advance()` finds the watch dead -> `ReissueFromCanonical` -> **a second +canonical placement and a second outbound `AutonomousPosition` for one server +correction.** + +That is the double-apply/double-ack class this entire slice exists to delete +(`670f307c`), reintroduced. `AssignPending` does not catch it because it is only +reached on the DeferredCell/retryable branches. The unified funnel fixes it: +equal versions -> clear, no reissue. + +### N2 — BLOCKING — reissue applies force semantics to an ordinary pose + +`ReissueFromCanonical` (`:418-447`) reuses `stale.Route` verbatim — +`SetPositionSimple` + `Teleport|Slide|SendPositionEvent` + +`SendPositionImmediately: true`. But the commonest way a park dies is an +ordinary `Apply` Position, whose retail route +(`RuntimeAuthoritativePositionRouteClassifier.cs:368-388`) is +`Interpolate`/`NoPositionOperation`, `PhysicsSetPositionFlags.None`, +`ConstrainPhase.BeforePositionOperation`, `SendPositionImmediately: false`. + +So the reissue converts an ordinary server echo into a hard `Teleport|Slide` +canonical placement, sends an ack retail would never send on that branch, and +skips the `ConstrainTo` the ordinary branch runs. My round-1 direction sanctioned +re-issuing THE CORRECTION; it did not sanction re-classifying a different +disposition's pose as a force. The unified rule fixes this by consulting the +newest accepted disposition. + +If any residual divergence remains after this, it needs a +`docs/architecture/retail-divergence-register.md` row in the same commit. + +### B2 — BLOCKING (record accuracy) — the plan claims coverage it does not have + +`docs/plans/2026-08-02-placement-cutover.md:287` reads "R8 added the App-layer +double-write source pins the plan's own acceptance item required." That is a +claim of coverage. The truth, per the adversarial review: + +- acceptance item 2's first half is **source-pinned, not proven** — the + `Assert.Single` regex would still pass if a second write were spelled + differently, and no test exercises the branch; +- acceptance item 2's second half — **"the committed projection is what moves + the render entity"** — is **uncovered at any layer**. No test drives a route-2 + ForcePosition through `RuntimePlacementPresentationSink` / + `TryApplyRuntimePlacementPlace` and asserts the `WorldEntity` moved. + +Correct the text to record the gap explicitly. A documented gap is acceptable; +a false claim of coverage is not. Same rule that produced R7. + +## Non-blocking — record, do not fix in this round + +- **N3** — headless never calls `RetryPending` after construction (grep finds no + caller outside `src/AcDream.App/`). R4's fix depends on the subscription's + retry, so a declined headless `Place` would wedge the ordered stream. Latent, + not proven reachable. File it. +- **N4** — `Advance()` lacks the `_driving` reentrancy latch its template + `RuntimeFirstEntryDriveController.DriveAll:128-148` has. No live re-entrant + path today. Hygiene. +- **N5** — `CenterOnAcceptedForcePosition` does not restore + `controller.LocalEntityId = record.LocalEntityId ?? 0u` (inert today, but an + unreplaced deletion); `_movementTruthDiagnostics.OnServerEcho` no longer fires + for a local ForcePosition (diagnostic only). +- **R9 residue** — `ConstraintManager.cs:25` and `PhysicsBody.cs:442` still cite + the deleted `BlipPosition` in `` tags (build-safe, but false docs). +- **Route-1 ack** — the plan required confirming whether the ack fires while an + initial-Create residence owns the record. It does not; `SendPositionImmediately` + is consumed only as a trace fact in the continuation executor (`:717`, `:2576`). + Not a regression, but the plan said file it. File it. +- **AD register row** — the headless 3x3 collision window is now a named member + of the Runtime-facing `IRuntimeDirectWorldProjection` contract with an ordering + requirement retail has no analogue for, and no existing row covers it (AD-6 is + retired; AD-2 is the graphical reveal barrier). Recommend a row. +- **Stale comment** — the `HeadlessSessionHostTests` comment references "the + previous 50.48f assertion", which does not exist at HEAD. + +--- + +# ROUND 3 — final round (2026-08-03) + +The round-3 **adversarial** review returned **PASS**. The round-3 +**conformance** review returned **FAIL on one item**. This round closes that +item and files the adversarial review's non-blocking findings. The round-2 +unified `_pending` funnel was confirmed sound by both reviewers and was NOT +restructured. + +## The blocker — a terminal-without-commit sent no ack (CLOSED) + +`SettlePending`'s Equal branch was reached both by a successful commit and by a +terminal outcome that never committed (non-retryable prepare failure, the +`default:` Rejected/Cancelled branch, and both `Advance` death branches). In the +non-commit case the body never moved AND no outbound `AutonomousPosition` left. + +The conformance reviewer proposed a `committed` flag plus a +one-re-issue-per-`Advance` guard. That is more than retail requires, and the +retail evidence was re-verified from +`docs/research/named-retail/acclient_2013_pseudo_c.txt` before coding: + +- `SmartBox::BlipPlayer` @0x00453940 (line 92528) calls + `CPhysicsObj::SetPositionSimple(this->player, edi_1, 1)` @0x00453968 and + **discards its return value**. `BlipPlayer` itself returns `void`. +- `CPhysicsObj::SetPositionSimple` @0x005162B0 (line 284276) is declared + `enum SetPositionError __thiscall`. Other retail call sites DO test it — + `if (CPhysicsObj::SetPositionSimple(...) == OK_SPE)` at @0x0055605D and + @0x00556021 — which proves the discard in `BlipPlayer` is deliberate, not a + decompiler artifact. +- `SmartBox::HandleReceivedPosition` @0x00453FD0's FORCE_POSITION branch calls + `SmartBox::BlipPlayer` @0x00454074, stamps `update_times[0]` @0x00454079, then + runs `cmdinterp->SendPositionEvent()` @0x00454091 **unconditionally** and + returns @0x0045409D. + +Retail's semantics are therefore: **attempt the placement once; if it fails the +body simply does not move; acknowledge regardless; never retry.** No commit flag +and no recursion guard are needed to express that. + +**Implemented.** The ack is now sent exactly once per BEGUN placement, at its +terminal outcome — from `ReconcileAndAcknowledge` on the commit path, or from +`SettlePending` on a non-commit terminal. `SettlePending` gained a +`positionEventOwed` parameter; `Pending` gained a `PositionEventOwed` field so +the re-issue retry marker (which stands for a packet whose placement was never +begun) cannot double-ack. The commit paths pass `false` because their ack has +already left. The non-commit ack runs no reconciliation — the body did not move, +so there is no committed frame to reconcile — and therefore carries the body's +unchanged pose, which is exactly what retail's ack carries after a failed +`SetPositionSimple` and is informative to the server: its force did not take. +The `CanSendPositionEvent` gate was left untouched; it is retail's own +(`CommandInterpreter::SendPositionEvent` @0x006B4770 tests the transient-state +contact bits), so a legitimately airborne body still suppresses the send on both +paths. + +`ReconcileAndAcknowledge` was split so both paths share one outbound site, +`SendPositionEvent`. + +## Other items closed this round + +- **AD-62 rewritten.** The `DeferredCell`-park precondition is dropped — it was + never required, and the never-parked failure paths reach the same outcome. The + row now leads with the general rule and keeps the named shapes as examples, + adds the externally-blocked `Contention` shape (nothing recorded, nothing + pumps it) and the other `PositionAuthorityVersion` advances (`TryApplyPickup` + `RuntimeEntityObjectLifetime.cs:1116`, `CommitPositionChannelUpdate` `:2041`, + `AdvanceCreateAuthority` `:2466`), and separates the shapes that now lose only + the re-apply from the narrower shapes that still lose the ack too. +- **Overstated doc corrected.** `AcceptedForceObservation`'s comment claimed the + record's current version equals the recorded force's version *if and only if* + the newest accepted event was that force. False — `AdvancePositionAuthority` + has four call sites. It is now stated as the one-way test it actually is. +- **Vacuous assertion deleted.** The `gameActions.Count <= 2` assertion in + `Advanced_ReissuesWhenTheNewestAcceptedEventIsStillAForcePosition` could not + fail: that fixture's `CommitLandblockCollision` adds both landblocks at + `worldOffsetX/Y: 0f` while the world frame places the deferred landblock at + +192/+192, so the body lands over no terrain, `InContact` is false, and every + ack is suppressed — the count is 0. It was also too loose to encode "at most + one per packet". Deleted rather than shipped; the test's real discriminators + (body position, `PendingCount`) stay. + +## Tests + +Two added, both with a verified discrimination check (the implementation was +temporarily broken in each direction and the intended test observed to fail, +then reverted and re-verified): + +- `TerminalWithoutCommit_SendsExactlyOnePositionEventAndLeavesTheBodyUnmoved` — + a park retired without committing sends exactly one `AutonomousPosition`, + performs no placement of its own, and never repeats on further pumps. +- `Committed_SendsExactlyOnePositionEventAcrossTheCommitAndTheSettle` — the new + settle-side ack does not become a second ack on the committed path. + +Two existing tests changed their ack expectation from `Assert.Empty` to +`Assert.Single`, because they exercise terminal-without-commit paths whose +`Empty` encoded the defect this round removes: +`Equal_ClearsPendingWithoutReissuingWhenNoNewerAcceptedAuthorityArrived` and +`Advanced_DoesNotReissueWhenTheNewestAcceptedEventIsAnOrdinaryApply`. In the +latter the single ack belongs to the FORCE packet, not to the ordinary echo — +retail's ordinary branch has no unconditional `SendPositionEvent`. + +Measurement note, recorded because it corrects an assumption in this file's +round-2 text: the `DeferredCell` park these fixtures use is the POST-engine +quiescence park, so `_physics.Engine.SetPosition` has already moved the +canonical body to the destination while the placement itself is withdrawn and +parked. The new test therefore captures its unmoved/no-further-placement +baselines at the park, and asserts them across the terminal settle, which is the +thing under test. + +## Filed, not fixed + +`docs/ISSUES.md` #293 (the `DeferredCell` branch still consumes `Withdraw` +receipts the sink may have declined — the same shape R4 removed for `Place`), +#294 (`ReconcileAndAcknowledge` runs before the funnel's currency guard on the +deferred wake), #295 (the retry marker inflates +`AcceptedPositionDrivePendingCount`, which is an in-flight-placement counter), +#296 (a retryable prepare is reported to hosts as `Contention`, conflating a +retained-and-pumping case with a dropped one). + +## Gate + +Complete Release solution suite, unchanged discipline: green is necessary and +demonstrably not sufficient — all four prior states were green and three were +defective. diff --git a/docs/research/2026-08-03-c4-route-2-visual-gate.md b/docs/research/2026-08-03-c4-route-2-visual-gate.md new file mode 100644 index 00000000..10dca1f2 --- /dev/null +++ b/docs/research/2026-08-03-c4-route-2-visual-gate.md @@ -0,0 +1,135 @@ +# C4 route 2 — ForcePosition: connected visual gate (2026-08-03) + +The user-facing acceptance test for route 2. Route 2 is code-complete and +suite-green before this runs; this document is the hand-off. + +## Why the obvious recipe does NOT work + +The route-2 contract's acceptance line says *"ACE `@teleport`-style +displacement"*. That is wrong about which route it exercises, and following it +would have produced a false pass. + +Verified in ACE this session: `PositionPack`'s constructor +(`references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs:36-57`) +advances `ObjectTeleport` when `adminMove == true` (lines 49-52) and only ever +*reads* `ObjectForcePosition` (line 54). Every admin move command — +`@teleto`, `@teletome`, `@teleloc`, `@movetome` — routes through +`Player_Location.cs:654 Teleport()` / `SendUpdatePosition(true)` and therefore +advances **TELEPORT_TS, not FORCE_POSITION_TS**. Those commands exercise +**route 3**, not route 2. + +`SequenceType.ObjectForcePosition` is advanced at exactly **two** places in the +whole ACE tree: + +1. `references/ACE/Source/ACE.Server/WorldObjects/Player.cs:1148` — the PK Lite + entry collision bump. +2. `references/ACE/Source/ACE.Server/WorldObjects/Player_Tick.cs:488` — the + anti-cheat z-position rubber-band. + +(2) requires the server to believe you are fly-hacking — same landblock, a +claimed Z more than 10 units above your last ground contact, more than a second +after your last jump, Jump skill under 1000, and still flagged airborne. It is +not reachable by legitimate play and there is no command path into it. Do not +build the gate on it. + +So (1) is the gate. + +## The only reliable lever is deferred — what that means + +The reachable trigger is the PK Lite entry-collision bump, which requires +retail's `@pklite`. That is a **client** command, not a server one — ACE has no +`pklite` text-command handler, so typing `@pklite` into chat today forwards as +inert text. Retail's client turns it into a game action instead: + +- `ClientCommunicationSystem::DoPKLite` @`0x0057A490` + (`acclient_2013_pseudo_c.txt:390106`) — rejects with error `0x507` when + `ACCWeenieObject::IsPlayerKiller` @`0x0058C910` is true (PK bit `0x20` OR + PKLite bit `0x2000000`), otherwise calls +- `CM_Character::Event_EnterPKLite` @`0x006A13F0` (line 680071) — a 12-byte + parameterless game action, opcode `0x28F`, no payload. + +acdream does not implement it, and **the user deferred implementing it +(2026-08-03).** Scoping is preserved in this session's research so it can be +picked up cheaply: ~40 lines of production code across `ClientCommandId`, +`RetailClientCommandCatalog`, `ClientCommandRequests.BuildParameterless`, +`WorldSession`, `ClientCommandController`, `LiveSessionCommandRouter`, and +`LiveSessionRuntimeFactory` — every pattern already exists, including +`WeenieError 0x0507`. + +**Consequence, stated plainly: route 2's own behaviour is NOT visually +verifiable in this campaign.** Nothing a user can do makes ACE emit a +ForcePosition. Route 2's acceptance therefore rests on its automated Runtime / +App / Headless tests and the complete Release suite. The connected pass below +is a **regression check on the blast radius**, not acceptance of the new route. +Do not record it as the latter. + +## What the connected pass actually covers + +Launch acdream in **Release** with `ACDREAM_RETAIL_UI=1` against the local ACE +at `127.0.0.1:9000`. + +Route 2 deletes `PlayerMovementController.BlipPosition` and +`HeadlessSessionWorldProjection.BlipLocalPlayer`, removes App's generic-tail +double-write for the local player, and re-routes the local player's accepted +placement through the canonical Runtime SetPosition transaction. So the things +to confirm are that ordinary play is untouched: + +- Ordinary running, turning, walk/run toggle. No tethering, no rubber-band, no + drift against the server. +- A jump and a landing. +- Walking through a doorway into an interior and back out. +- A portal recall, and a portal into a dungeon. +- Two-client observation: `+Acdream` seen from the retail client moves smoothly + and lands where acdream shows it. + +Anything wrong there is route 2's fault even though route 2 did not intend to +touch it. + +## Optional cheap shot (may not fire) + +The second ACE call site is the anti-cheat z-position rubber-band +(`Player_Tick.cs:459-490`). It needs: same landblock as your last ground +contact, a claimed Z more than 10 units above it, more than a second since your +last jump, Jump skill under 1000, and the server still flagging you airborne. +There is no command path into it and normal play cannot satisfy it (falling +makes the Z delta negative, and walking up terrain keeps refreshing the ground +position), but a `@teleloc` straight up ~15 units within the same landblock is +a two-minute experiment with a definitive tell: ACE's console logs +`z-pos hacking detected for +Acdream` at `Player_Tick.cs:486` immediately +before it force-bumps you. + +If that line appears, you have a genuine ForcePosition and the checks below +apply. If it does not, the route is untested by observation — which is the +expected outcome. + +**If a ForcePosition does occur, must be true:** +- You end up at the corrected position and stay there. No visible double-apply, + no snap-then-yank-back over one or two frames. +- **Your facing does not change.** Retail's force branch replaces the + destination heading with your current heading before placing + (`HandleReceivedPosition` @`0x00453FD0`, `Frame::set_heading` at + `0x00454068`). +- Exactly **one** outbound `AutonomousPosition` for the correction. + +**The two named behaviour changes:** + +1. **The ack now fires after the canonical commit, not before it.** Previously + the client told ACE "got it, I'm here" before deciding where "here" was. + Symptom of a regression: ACE re-sending corrections, or a visible fight + between client and server position after the bump. + +2. **The ForcePosition route no longer re-arms the constraint leash.** Retail's + force branch returns at `0x0045409D`, ahead of all three `ConstrainTo` call + sites (`0x00454272`, `0x0045418A`, `0x004541EC`); our old `BlipPosition` + re-armed the leash citing a branch it was not on. #167 was a leash bug, so + this is the change most worth your eyes if you get a bump. Symptom of a + problem: after the bump, movement feels tethered, rubber-bands back toward + the pre-bump spot, or the leash trips on ordinary running shortly after. + +## Known, deliberately unchanged + +Retail's `SmartBox::PlayerPositionUpdated` (@`0x00453870`) gates its +`set_viewer` / `LScape::update_viewpoint` re-seat on +`distance >= GetAutonomyBlipDistance` (`0x004538C0-0x004538E2`). We publish the +render root unconditionally. Not changed in this slice; recorded here so the +next reader does not rediscover it as a bug. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 84d04dca..a0495a6d 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -573,6 +573,21 @@ internal sealed class SessionPlayerCompositionPhase .RegisteredAuthoredPayload : RuntimeLocalPlayerShadowDisposition.ProvenShapeless); }); + // C4 route 2 (2026-08-03): the graphical accepted-Position drive + // controller — the Runtime-owned execution seam for a ForcePosition + // on the already-live local player. Constructed once per session + // route alongside firstEntryDrive; both share the SAME entity + // lifetime, collision source, and clock. + var acceptedPositionDrive = new RuntimeAcceptedPositionDriveController( + d.EntityObjects, + d.Runtime.Clock, + firstEntryCollision, + d.PlayerOutbound, + () => d.Runtime.Generation, + () => d.PlayerIdentity.ServerGuid, + () => d.PlayerController.Controller, + () => d.Character.UsePositionFromServer, + () => liveSessionSource.CurrentSession); var hydration = new LiveEntityHydrationController( live.LiveEntities, d.EntityObjects, @@ -591,7 +606,8 @@ internal sealed class SessionPlayerCompositionPhase deletion, dormantLiveEntities, d.Options.DumpLiveSpawns ? d.Log : null, - firstEntryDrive); + firstEntryDrive, + acceptedPositionDrive); bindings.Adopt( "landblock-loaded hydration", live.LandblockLoaded.Bind(hydration)); @@ -637,6 +653,7 @@ internal sealed class SessionPlayerCompositionPhase liveSessionSource, localPhysicsTimestamps.Publish, d.MovementDiagnostics, + acceptedPositionDrive, worldDropProjection); var liveness = new LiveEntityLivenessController( live.LiveEntities, @@ -959,7 +976,8 @@ internal sealed class SessionPlayerCompositionPhase live.RenderSceneShadow, live.PlacementProjection, placementProjectionRetry, - firstEntryDrive), + firstEntryDrive, + acceptedPositionDrive), liveSessionCommands, d.Log); LiveSessionHost sessionHost = sessionRuntimeFactory.Create( diff --git a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs index 6bc25897..91549de3 100644 --- a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs +++ b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs @@ -17,6 +17,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting private readonly Func _generation; private readonly RuntimePlacementProjectionRetrySlot _retries; private readonly RuntimeFirstEntryDriveController? _firstEntry; + private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private readonly Action? _localPlayerCompleted; private RuntimePlacementProjectionSubscription? _subscription; private IDisposable? _retryLease; @@ -30,7 +31,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting IRuntimePlacementProjectionSink placements, RuntimePlacementProjectionRetrySlot retries, RuntimeFirstEntryDriveController? firstEntry = null, - Action? localPlayerCompleted = null) + Action? localPlayerCompleted = null, + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) : this( events, () => new RuntimePlacementProjectionSubscription( @@ -40,7 +42,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting () => runtime.Generation, retries, firstEntry, - localPlayerCompleted) + localPlayerCompleted, + acceptedPositionDrive) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(placements); @@ -52,7 +55,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting Func generation, RuntimePlacementProjectionRetrySlot retries, RuntimeFirstEntryDriveController? firstEntry = null, - Action? localPlayerCompleted = null) + Action? localPlayerCompleted = null, + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _createSubscription = createSubscription @@ -62,6 +66,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting _retries = retries ?? throw new ArgumentNullException(nameof(retries)); _firstEntry = firstEntry; _localPlayerCompleted = localPlayerCompleted; + _acceptedPositionDrive = acceptedPositionDrive; } public void Attach() @@ -75,6 +80,10 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // detached — session reset precedes a new route — before this route // takes ownership of the shared drive controller's tracked entries. _firstEntry?.AttachRoute(this, _localPlayerCompleted); + // C4 route 2: same one-route-at-a-time latch for the accepted- + // Position drive controller (RuntimeAcceptedPositionDriveController + // .AttachRoute's doc comment). + _acceptedPositionDrive?.AttachRoute(this); _events.Attach(); RuntimePlacementProjectionSubscription? subscription = null; @@ -90,10 +99,13 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // republishing the pending FIFO head — a conductor's own // Advance is what consumes conductor-owned receipts, and the // subsequent RetryPending lets the presentation sink apply - // whatever new head the drive surfaced. + // whatever new head the drive surfaced. C4 route 2: the + // accepted-Position drive's own Advance resolves a parked + // DeferredCell ForcePosition the same way. () => { _firstEntry?.DriveAll(); + _acceptedPositionDrive?.Advance(); return boundSubscription.RetryPending(); }); _subscription = subscription; @@ -123,6 +135,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // route-scoped — a route that never attached cannot clear a live // route's entries. _firstEntry?.DetachRoute(this); + _acceptedPositionDrive?.DetachRoute(this); if (!_eventsDisposed) { _events.Dispose(); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index c6d4dfda..9ef28c63 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -87,7 +87,8 @@ internal sealed record LiveSessionWorldRuntime( RenderSceneShadowRuntime? RenderSceneShadow, RuntimePlacementPresentationSink PlacementProjection, RuntimePlacementProjectionRetrySlot PlacementRetries, - RuntimeFirstEntryDriveController FirstEntryDrive); + RuntimeFirstEntryDriveController FirstEntryDrive, + RuntimeAcceptedPositionDriveController AcceptedPositionDrive); /// /// Builds the exact per-generation route/reset graph for the canonical live @@ -264,7 +265,8 @@ internal sealed class LiveSessionRuntimeFactory _world.PlacementProjection, _world.PlacementRetries, _world.FirstEntryDrive, - _ => session.SendGameAction(GameActionLoginComplete.Build())); + _ => session.SendGameAction(GameActionLoginComplete.Build()), + _world.AcceptedPositionDrive); } private LiveInventorySessionBindings CreateInventoryBindings() => new( diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index d1e85d1d..3249b909 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -15,6 +15,7 @@ using AcDream.Core.Items; using AcDream.Core.Physics; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; using AcDream.Core.Selection; using AcDream.Core.World; using DatReaderWriter; @@ -63,6 +64,7 @@ internal sealed class LiveEntityNetworkUpdateController private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics; private readonly InventoryWorldDropProjectionController? _worldDropProjection; + private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive; private PlayerMovementController? _playerController => _playerControllerSource.Controller; private EntityPhysicsHost? _playerHost => _playerHostSource.Host; @@ -103,6 +105,7 @@ internal sealed class LiveEntityNetworkUpdateController ILiveWorldSessionSource session, Action publishTimestamps, IMovementTruthDiagnosticSink movementTruthDiagnostics, + RuntimeAcceptedPositionDriveController acceptedPositionDrive, InventoryWorldDropProjectionController? worldDropProjection = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); @@ -138,6 +141,8 @@ internal sealed class LiveEntityNetworkUpdateController publishTimestamps); _movementTruthDiagnostics = movementTruthDiagnostics ?? throw new ArgumentNullException(nameof(movementTruthDiagnostics)); + _acceptedPositionDrive = acceptedPositionDrive + ?? throw new ArgumentNullException(nameof(acceptedPositionDrive)); _worldDropProjection = worldDropProjection; } @@ -1113,20 +1118,61 @@ internal sealed class LiveEntityNetworkUpdateController bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition && update.Guid == _playerServerGuid && _playerController is not null; - if (!LocalForcePositionTransaction.Apply( - forceLocal, - () => IsCurrentPositionOwner(), - () => _playerController!.BlipPosition( - worldPos, - p.LandblockId, - new System.Numerics.Vector3( - p.PositionX, - p.PositionY, - p.PositionZ)), - () => _localPlayerOutbound.SendImmediatePosition( - _session.CurrentSession, - _playerController))) - return; + if (forceLocal) + { + if (!IsCurrentPositionOwner()) + return; + + // C4 route 2 (2026-08-03): the Runtime-owned accepted-Position + // execution seam replaces the deleted LocalForcePositionTransaction. + // Ownership validation is the operation's own currency check, + // the body commit is Runtime's canonical SetPosition transaction + // (retail CPhysicsObj::SetPositionSimple @0x005162B0, called from + // SmartBox::BlipPlayer @0x00453940), and the outbound ack is an + // OUTPUT of that committed route, fired strictly after it. + RuntimeAcceptedPositionExecutionStatus forceStatus = + _acceptedPositionDrive.TryExecuteAcceptedLocalPosition( + acceptedPositionCanonical, + update, + timestampDisposition, + timestamps, + timestamps.PreviousTeleport); + if (forceStatus is RuntimeAcceptedPositionExecutionStatus.Committed + or RuntimeAcceptedPositionExecutionStatus.DeferredCell) + { + // App projects the committed (or parked-toward) result + // through the existing Runtime placement sink + // (RuntimePlacementPresentationSink), which observes the + // SAME Runtime SetPosition FIFO every other placement uses — + // it must not ALSO independently mutate the render-facing + // WorldEntity here (the retired duplicate-write authority: + // the generic tail below). The two local-player side effects + // this neighbourhood still owns independently of WorldEntity + // position (owned-VFX pose-dirty tracking and the + // pre-player-mode streaming landblock tracker) are + // preserved — DeferredCell included, since following the + // destination is what lets its streaming/collision window + // eventually publish the generation the park is waiting on. + _entityEffects?.MarkLiveOwnerPoseDirty(update.Guid); + _authorityGate.ObserveAcceptedLocalPosition( + update.Position.LandblockId); + return; + } + if (forceStatus is not RuntimeAcceptedPositionExecutionStatus.NotApplicable) + { + // R9 review fix (2026-08-03): Rejected/Contention — no + // correction was applied or parked for this exact packet. + // Do NOT move the streaming observer or mark the render pose + // dirty for a landblock this route explicitly declined to + // place into, and do not fall through to the generic tail + // below either (that would resurrect the retired duplicate- + // write authority this whole route exists to remove). + return; + } + // NotApplicable — e.g. an initial-Create residence still owns + // this record (route 1's job). Fall through to the pre-existing + // path unchanged, exactly as every other disposition does. + } // A leave-world transition deliberately retains WorldEntity as the // logical/render-resource owner while IsSpatiallyProjected is false. diff --git a/src/AcDream.App/Physics/LocalForcePositionTransaction.cs b/src/AcDream.App/Physics/LocalForcePositionTransaction.cs deleted file mode 100644 index a48f35fd..00000000 --- a/src/AcDream.App/Physics/LocalForcePositionTransaction.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace AcDream.App.Physics; - -/// -/// Preserves retail ForcePosition's atomic local order: validate the accepted -/// Position authority, blip once, acknowledge once, then stop if the -/// acknowledgement synchronously displaced that authority. -/// -internal static class LocalForcePositionTransaction -{ - internal static bool Apply( - bool isForcePosition, - Func isCurrent, - Action blip, - Action acknowledge) - { - ArgumentNullException.ThrowIfNull(isCurrent); - ArgumentNullException.ThrowIfNull(blip); - ArgumentNullException.ThrowIfNull(acknowledge); - - if (!isForcePosition) - return true; - if (!isCurrent()) - return false; - - blip(); - acknowledge(); - return isCurrent(); - } -} diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 47cbf35d..6930cbe2 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -189,6 +189,13 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded /// controller without one. /// private readonly RuntimeFirstEntryDriveController? _firstEntry; + /// + /// C4 route 2: the graphical accepted-Position drive controller — pumped + /// alongside so a ForcePosition parked awaiting + /// its destination collision generation resolves promptly. Optional for + /// the same reason is. + /// + private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private readonly Dictionary _projectionOperations = new(ReferenceEqualityComparer.Instance); @@ -213,7 +220,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded LiveEntityDeletionController deletion, DormantLiveEntityStore? dormant = null, Action? diagnostic = null, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _entityObjects = entityObjects @@ -230,6 +238,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded _dormant = dormant ?? new DormantLiveEntityStore(); _diagnostic = diagnostic; _firstEntry = firstEntry; + _acceptedPositionDrive = acceptedPositionDrive; } internal event Action? AppearanceApplied; @@ -403,6 +412,8 @@ AppearanceSynchronization: // destination cell, FIFO ahead of us) is retried by the // per-frame placement retry phase. _firstEntry?.DriveAll(); + // C4 route 2: same pump for a parked ForcePosition. + _acceptedPositionDrive?.Advance(); } } diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs index 87ca6e4c..cfb87ef7 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs @@ -17,6 +17,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting private readonly GameRuntime _runtime; private readonly IRuntimePlacementProjectionSink _placements; private readonly RuntimeFirstEntryDriveController? _firstEntry; + private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private readonly Action? _localPlayerCompleted; private RuntimePlacementProjectionSubscription? _subscription; private bool _attachStarted; @@ -28,7 +29,8 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting GameRuntime runtime, IRuntimePlacementProjectionSink placements, RuntimeFirstEntryDriveController? firstEntry = null, - Action? localPlayerCompleted = null) + Action? localPlayerCompleted = null, + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); @@ -36,6 +38,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting ?? throw new ArgumentNullException(nameof(placements)); _firstEntry = firstEntry; _localPlayerCompleted = localPlayerCompleted; + _acceptedPositionDrive = acceptedPositionDrive; } public void Attach() @@ -52,6 +55,9 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // detached — session reset precedes a new route — before this route // takes ownership of the shared drive controller's tracked entries. _firstEntry?.AttachRoute(this, _localPlayerCompleted); + // C4 route 2: same one-route-at-a-time latch for the accepted- + // Position drive controller. + _acceptedPositionDrive?.AttachRoute(this); _events.Attach(); _subscription = new RuntimePlacementProjectionSubscription( _runtime, @@ -73,6 +79,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // route-scoped — a route that never attached cannot clear a live // route's entries. _firstEntry?.DetachRoute(this); + _acceptedPositionDrive?.DetachRoute(this); if (!_eventsDisposed) { _events.Dispose(); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 16d14d80..2fe59664 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -126,6 +126,11 @@ internal sealed class HeadlessSessionHost : IDisposable /// Runtime lifetime) plus the active world projection it pumps /// through. private RuntimeFirstEntryDriveController? _firstEntryDrive; + /// C4 route 2 (2026-08-03): see the ctor comment in + /// — cached across reconnects exactly + /// like . + private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; + private AcDream.Core.Net.WorldSession? _currentSession; private HeadlessSessionWorldProjection? _worldProjection; private int _disposeStage; private long _reconnectDeadline; @@ -311,8 +316,20 @@ internal sealed class HeadlessSessionHost : IDisposable _policy.Tick(Runtime, Commands); } - internal RuntimeTeardownAcknowledgement Stop() => - Commands.Session.Stop(Runtime.Generation); + internal RuntimeTeardownAcknowledgement Stop() + { + RuntimeTeardownAcknowledgement result = + Commands.Session.Stop(Runtime.Generation); + // R9 review fix (2026-08-03): _currentSession is cached across + // reconnects (see CreateEventRoute's comment) so the accepted- + // position drive controller's outbound-ack accessor always reads the + // CURRENT session, never one captured at first construction. Without + // clearing it here, that accessor would keep returning a stopped + // (possibly disposed) WorldSession in the window between this Stop + // and the next CreateEventRoute call. + _currentSession = null; + return result; + } internal void Quarantine(Exception error) { @@ -536,6 +553,25 @@ internal sealed class HeadlessSessionHost : IDisposable private ILiveSessionEventRouting CreateEventRoute( AcDream.Core.Net.WorldSession session) { + // C4 route 2 (2026-08-03): reconnects construct a FRESH WorldSession + // each call, but the accepted-Position drive controller below is + // cached across reconnects (`??=`) exactly like _firstEntryDrive — + // its ack-firing accessor must therefore read the CURRENT session + // through this field, never one captured at first construction. + _currentSession = session; + // R9 review note (2026-08-03): a content-less host (_contentLease is + // null — a validated-legal headless configuration, see + // RuntimeLiveEntitySessionController.OnSpawned's own R3 comment) + // never constructs _acceptedPositionDrive OR worldProjection below, + // so a ForcePosition on such a host resolves NotApplicable with no + // ProjectPosition fallback either. This is NOT a behaviour change: + // a content-less host never registers a first-entry residence, so + // Runtime.MovementOwner.Controller is always null there too, and + // LocalPlayerOutboundController.SendImmediatePosition's own + // controller-null guard already made the PRE-route-2 unconditional + // ack call a no-op in this exact configuration. There is no live + // controller for either the old or the new path to place or + // acknowledge. IRuntimeDirectWorldProjection? worldProjection = null; if (_contentLease is { } content) { @@ -557,10 +593,24 @@ internal sealed class HeadlessSessionHost : IDisposable Radius: 0.48f, Height: 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + // C4 route 2: one drive controller per host, mirroring + // _firstEntryDrive exactly — same persistent Runtime lifetime, + // collision source, and clock. + _acceptedPositionDrive ??= new RuntimeAcceptedPositionDriveController( + Runtime.EntityObjects, + Runtime.Clock, + content.PreparedCollision, + new LocalPlayerOutboundController((_, _, _, _, _, _) => { }), + () => Runtime.Generation, + () => Runtime.PlayerIdentity.ServerGuid, + () => Runtime.MovementOwner.Controller, + () => Runtime.CharacterOwner.UsePositionFromServer, + () => _currentSession); var projection = new HeadlessSessionWorldProjection( Runtime, content, - _firstEntryDrive); + _firstEntryDrive, + _acceptedPositionDrive); _worldProjection = projection; worldProjection = projection; } @@ -571,7 +621,8 @@ internal sealed class HeadlessSessionHost : IDisposable _descriptor.Id, message, Runtime.Generation.Value), - worldProjection); + worldProjection, + _acceptedPositionDrive); var route = new LiveSessionEventRouter( session, entities.CreateSink(), @@ -620,7 +671,8 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime, new HeadlessRuntimePlacementProjectionSink(Runtime), _firstEntryDrive, - _ => session.SendGameAction(GameActionLoginComplete.Build())); + _ => session.SendGameAction(GameActionLoginComplete.Build()), + _acceptedPositionDrive); } private static LiveSessionCharacterSelector MapCharacterSelector( diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 4ee0a614..d7cea790 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -504,29 +504,39 @@ internal sealed class HeadlessSessionWorldProjection private readonly GameRuntime _runtime; private readonly IHeadlessCollisionNeighborhood _collision; private readonly RuntimeFirstEntryDriveController? _firstEntry; + /// + /// C4 route 2 (2026-08-03): pumped alongside so + /// a ForcePosition parked awaiting its destination collision generation + /// resolves promptly. + /// + private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private uint _requestedLocalPlayerCell; internal HeadlessSessionWorldProjection( GameRuntime runtime, HeadlessProcessContentOwner.HeadlessProcessContentLease content, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) : this( runtime, new HeadlessCollisionNeighborhood(runtime, content), - firstEntry) + firstEntry, + acceptedPositionDrive) { } internal HeadlessSessionWorldProjection( GameRuntime runtime, IHeadlessCollisionNeighborhood collision, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _collision = collision ?? throw new ArgumentNullException(nameof(collision)); _firstEntry = firstEntry; + _acceptedPositionDrive = acceptedPositionDrive; } public void ProjectSpawn( @@ -569,6 +579,7 @@ internal sealed class HeadlessSessionWorldProjection .TryConvertInitialResidenceToCellessRoute(record); } _firstEntry?.DriveAll(); + _acceptedPositionDrive?.Advance(); } public void ProjectPosition( @@ -592,11 +603,43 @@ internal sealed class HeadlessSessionWorldProjection _collision.CenterOn(position.LandblockId); } _firstEntry?.DriveAll(); + _acceptedPositionDrive?.Advance(); + } + // C4 route 2 (2026-08-03): a ForcePosition on the local player is + // dispatched directly to RuntimeAcceptedPositionDriveController + // instead of running the branch below this comment — the deleted + // BlipLocalPlayer's body-commit/controller-reconciliation/ack job is + // that controller's now (CenterOnAcceptedForcePosition covers the + // re-centering half). R3 review fix (2026-08-03): when that call + // returns NotApplicable — most commonly THIS controller-null branch, + // reached while route 1 hasn't published a controller yet — + // RuntimeLiveEntitySessionController.OnPositionUpdated still falls + // back to calling this method, exactly like every other disposition. + } + + /// + /// R2 review fix (2026-08-03): the centering half of the deleted + /// BlipLocalPlayer_collision.CenterOn(position.LandblockId) + /// plus the update + /// polls. Without this, a ForcePosition to a + /// destination outside the neighborhood's current 3x3 window + /// (HeadlessCollisionNeighborhood.BuildPublicationPlan) would open + /// a DeferredCell park this host's window can never publish — + /// see 's + /// R1 doc comment. Called BEFORE the drive controller submits so the + /// destination is already inside the window by the time it decides + /// whether to park. + /// + public void CenterOnAcceptedForcePosition(RuntimeEntityRecord record) + { + if (record.ServerGuid != _runtime.PlayerIdentity.ServerGuid + || record.Snapshot.Position is not { LandblockId: not 0u } position) + { return; } - if (disposition is PositionTimestampDisposition.ForcePosition) - BlipLocalPlayer(record); + _requestedLocalPlayerCell = position.LandblockId; + _collision.CenterOn(position.LandblockId); } /// @@ -610,6 +653,7 @@ internal sealed class HeadlessSessionWorldProjection if (_requestedLocalPlayerCell != 0u) _ = _collision.IsReady(_requestedLocalPlayerCell); _firstEntry?.DriveAll(); + _acceptedPositionDrive?.Advance(); } public void BeginTeleport() @@ -704,26 +748,4 @@ internal sealed class HeadlessSessionWorldProjection controller.SetBodyOrientation(orientation); } - private void BlipLocalPlayer(RuntimeEntityRecord record) - { - if (record.ServerGuid - != _runtime.PlayerIdentity.ServerGuid - || record.Snapshot.Position is not { } position - || _runtime.MovementOwner.Controller is not { } controller) - { - return; - } - - _collision.CenterOn(position.LandblockId); - Vector3 wirePosition = new( - position.PositionX, - position.PositionY, - position.PositionZ); - controller.LocalEntityId = record.LocalEntityId ?? 0u; - controller.BlipPosition( - wirePosition, - position.LandblockId, - wirePosition); - } - } diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index d2e49129..73ea5968 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -79,7 +79,16 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( /// Previously outside every ledger; gated by /// like the conductor counts it pumps. /// - int FirstEntryDrivePendingCount = 0) + int FirstEntryDrivePendingCount = 0, + /// + /// C4 route 2: outstanding host RuntimeAcceptedPositionDriveController + /// pending operations (a not-yet-committed or not-yet-acknowledged + /// ForcePosition on the local player), summed over every drive + /// registered against this lifetime via + /// . + /// Gated by — a leaked pending ack cannot hide. + /// + int AcceptedPositionDrivePendingCount = 0) { public bool IsConverged => IsDisposed @@ -104,6 +113,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && LocalPlayerFirstEntryActiveCount == 0 && RemoteFirstEntryActiveCount == 0 && FirstEntryDrivePendingCount == 0 + && AcceptedPositionDrivePendingCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -151,6 +161,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable private Action? _initialResidenceBegan; /// C3c-R1 review F5: see . private readonly List> _firstEntryDriveOwnership = []; + /// C4 route 2: see . + private readonly List> _acceptedPositionDriveOwnership = []; public RuntimeEntityObjectLifetime( uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, @@ -452,7 +464,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.PendingCompletionReceiptCount, LocalPlayerFirstEntry.CaptureOwnership().ActiveCount, RemoteFirstEntry.CaptureOwnership().ActiveCount, - CaptureFirstEntryDrivePendingCount()); + CaptureFirstEntryDrivePendingCount(), + CaptureAcceptedPositionDrivePendingCount()); } private int CaptureFirstEntryDrivePendingCount() @@ -463,6 +476,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return total; } + private int CaptureAcceptedPositionDrivePendingCount() + { + int total = 0; + for (int i = 0; i < _acceptedPositionDriveOwnership.Count; i++) + total = checked(total + _acceptedPositionDriveOwnership[i]()); + return total; + } + /// /// C3c-R1 review F5: registers one host first-entry drive controller's /// pending-count provider into this lifetime's ownership snapshot, so @@ -478,6 +499,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _firstEntryDriveOwnership.Add(pendingCount); } + /// + /// C4 route 2: registers one host RuntimeAcceptedPositionDriveController's + /// pending-count provider into this lifetime's ownership snapshot, + /// mirroring — a leaked + /// pending ForcePosition ack must not sit outside every ledger. The + /// drive controller registers itself at construction; multiple + /// registrations sum (one per host route sharing this lifetime). + /// + public void RegisterAcceptedPositionDriveOwnership(Func pendingCount) + { + ArgumentNullException.ThrowIfNull(pendingCount); + EnsureNotDisposed(); + _acceptedPositionDriveOwnership.Add(pendingCount); + } + public void BindEventContext( Func generation, Func frameNumber) diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index dd1abd03..c49ae0e1 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -629,8 +629,13 @@ public sealed class PlayerMovementController /// stick down (retail teleport_hook @0x00514eee) and, as of /// Campaign P P5 (#167), also tears down and immediately re-arms the /// constraint leash (UnConstrain then ConstrainTo); - /// arms the leash without tearing it down first - /// (retail SmartBox::BlipPlayer survives motion/velocity/stick). + /// 's ForcePosition route + /// does NOT touch the leash at all — retail's FORCE_POSITION branch of + /// SmartBox::HandleReceivedPosition (@0x00453FD0) returns at + /// 0x0045409D, before every ConstrainTo call (C4 route 2 review + /// fix, 2026-08-03 — the deleted BlipPosition's unconditional + /// leash re-arm here was an unbacked deviation for that exact branch; + /// see docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b). /// public AcDream.Core.Physics.Motion.PositionManager? PositionManager { @@ -1915,25 +1920,33 @@ public sealed class PlayerMovementController } /// - /// Retail SmartBox::BlipPlayer (0x00453940): apply a server - /// FORCE_POSITION correction through CPhysicsObj::SetPositionSimple - /// without the teleport hook. Active motion, velocity, contact state, and - /// PositionManager stick relationships deliberately survive the blip. + /// C4 route 2: the controller-local half of a ForcePosition commit whose + /// body write already happened inside Runtime's canonical + /// RuntimeSetPositionState.CommitCanonical (retail + /// CPhysicsObj::SetPositionSimple @0x005162B0 with flags + /// 0x1012, called from SmartBox::BlipPlayer @0x00453940, + /// acclient_2013_pseudo_c.txt:284276/92528). Resets the render-lerp + /// anchors and republishes the render-root cell — the same two + /// controller-local jobs the deleted BlipPosition performed after + /// its own (now-Runtime-owned) body snap. + /// + /// Deliberately does NOT call : + /// retail's FORCE_POSITION branch of SmartBox::HandleReceivedPosition + /// (@0x00453FD0) returns at 0x0045409D, before every + /// CPhysicsObj::ConstrainTo call (0x00454272/0x0045418A/ + /// 0x004541EC) — the deleted BlipPosition's re-arm here was an + /// unbacked deviation (docs/research/2026-08-03-c4-route-2-implementation-plan.md + /// §1b); this route retires it. Active motion, velocity, contact state, + /// and PositionManager stick relationships are untouched, matching + /// retail's BlipPlayer path exactly (Runtime's canonical commit — not + /// this method — is what already wrote the body). /// - public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal) + internal void CommitCanonicalForcePositionFrame() { EnsurePublishedForRuntimeOperation(); - _body.SnapToCell(cellId, pos, cellLocal); - _prevPhysicsPos = pos; - _currPhysicsPos = pos; + _prevPhysicsPos = _body.Position; + _currPhysicsPos = _body.Position; UpdateCellId(_body.CellPosition.ObjCellId, "force-position"); - // #167 (Campaign P P5): retail "Player, normal" branch of - // SmartBox::HandleReceivedPosition (0x00453fd0) — ConstrainTo anchored - // to the received position, with NO teardown call (this is the - // BlipPlayer path: motion, velocity, and PositionManager stick - // relationships all deliberately survive the blip per the class - // comment above, and the leash is no different). - RearmConstraintLeashAtCurrentPosition(); } private Vector3 ComputeRenderPosition() diff --git a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs new file mode 100644 index 00000000..5f5709ce --- /dev/null +++ b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs @@ -0,0 +1,906 @@ +using System.Numerics; +using AcDream.Content; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Session; + +/// +/// Typed yields for +/// . +/// +internal enum RuntimeAcceptedPositionExecutionStatus : byte +{ + /// + /// Out of this route's scope (not a ForcePosition, not the local player, + /// no canonical body, or an initial-Create residence still owns the + /// record). The caller's own pre-flip path runs unchanged. + /// + NotApplicable, + + /// + /// Either (a) the classifier itself rejected this exact frame (non-finite + /// data, an invalid entity kind) before any SetPosition ran and no ack was + /// sent, or (b) a SetPosition WAS begun and submitted but was then + /// rejected/cancelled by + /// (invalid prepared data, authority displaced mid-submit) — R5 review + /// fix (2026-08-03): the two call sites inside SubmitAndResolve + /// both reach this status AFTER a SetPosition ran, so "no SetPosition + /// ran" is only true for case (a). + /// + /// Round 3 (2026-08-03): the two statuses differ in acknowledgement. Case + /// (a) sends nothing — a classifier-rejected frame is not an accepted + /// authority. Case (b) IS an accepted force packet whose placement was + /// begun and failed, so SettlePending sends its retail position + /// event carrying the body's unchanged pose (SmartBox::BlipPlayer + /// @0x00453940 discards SetPositionSimple's error and + /// SmartBox::HandleReceivedPosition @0x00453FD0 acks + /// unconditionally @0x00454091). + /// + Rejected, + + /// + /// The entity already holds an active SetPosition operation (or an + /// unacknowledged completion); begin failed on transient contention, not + /// staleness. This packet's classified force route was still recorded as + /// the drive's newest accepted force observation BEFORE the failed begin + /// (see _newestForce), so when the contending operation is this + /// controller's OWN tracked _pending — the B1 case: a park that + /// woke and had its Place ACCEPTED, whose retained completion + /// survives RuntimeSetPositionState.Forget's early return and + /// blocks the next begin — the single _pending funnel + /// (SettlePending) re-issues THIS packet's correction the moment + /// that older completion is consumed. When it is a genuinely EXTERNAL + /// operation (a concurrent portal/teleport placement on the same entity) + /// and nothing of this route's own is pending, this route correctly + /// yields — a later accepted Position re-attempts once that other + /// placement completes, exactly like every other route yields to a + /// concurrent placement authority. + /// + Contention, + + /// + /// The destination landblock's collision generation was not ready; the + /// operation parked. The ack fires later, once + /// observes + /// the deferred commit. + /// + DeferredCell, + + /// + /// The canonical SetPosition committed synchronously (retail + /// CPhysicsObj::SetPositionSimple @0x005162B0, called from + /// SmartBox::BlipPlayer @0x00453940). The controller-local + /// reconciliation ran and the outbound ack (if any) already went out. + /// + Committed, +} + +/// +/// C4 route 2: the Runtime-owned accepted-Position execution seam for a +/// ForcePosition on an already-live local player. Retail +/// SmartBox::HandleReceivedPosition (@0x00453FD0, +/// acclient_2013_pseudo_c.txt:92896) FORCE_POSITION branch: +/// +/// +/// 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 AFTER the commit +/// return; // 0045409d +/// +/// +/// SmartBox::BlipPlayer (@0x00453940, line 92528) calls +/// CPhysicsObj::SetPositionSimple (@0x005162B0, line 284276), which +/// with a non-null destination frame builds a SetPositionStruct with +/// flags 0x1012 (Teleport|Slide|SendPositionEvent — +/// 's +/// AuthoritativeTeleportFlags) and calls +/// CPhysicsObj::SetPosition. This class is the Runtime consumer the +/// classifier's ForcePosition branch never had: it drives +/// + +/// +/// exactly like 's continuation +/// completion, then fires the ack strictly AFTER the canonical commit. +/// +/// Two named behaviour changes versus the deleted App/no-window authorities +/// this replaces: +/// (1) the outbound AutonomousPosition ack now fires only after the +/// canonical commit (previously it left before any Runtime commit existed — +/// see the deleted LocalForcePositionTransaction/ +/// HeadlessSessionWorldProjection.BlipLocalPlayer pair); +/// (2) the constraint leash is NOT re-armed here — every +/// CPhysicsObj::ConstrainTo call in HandleReceivedPosition +/// (@0x00454272/0x0045418A/0x004541EC) is on a branch the FORCE_POSITION +/// early return (@0x0045409D) never reaches; the deleted +/// PlayerMovementController.BlipPosition's re-arm was an unbacked +/// deviation this route retires (docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b). +/// +/// One instance per host session route (graphical/headless), constructed +/// once per host process and reused across reconnects exactly like +/// / +/// assert the same "session reset precedes a new +/// route" ordering and clear any pending operation left by a torn-down +/// session. The controller tracks at most one pending operation (the local +/// player is the only entity this route ever touches). +/// +/// R9 review note (2026-08-03): kept public — unlike its template +/// (internal sealed), +/// this class is a required parameter type on +/// 's own public +/// constructor (src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs), +/// so C# accessibility rules (CS0051) require it. Every constructor +/// parameter and member below is internal; only the type name itself +/// is public, and only because the class it is threaded through already is. +/// Narrowing itself is a +/// separate, broader change outside this fix's scope. +/// +public sealed class RuntimeAcceptedPositionDriveController +{ + private sealed class Pending + { + internal required RuntimeEntityRecord Record { get; init; } + internal required RuntimeEntityPlacementToken Token { get; init; } + internal required RuntimeAuthoritativePositionRoute Route { get; init; } + internal required bool AwaitingCommitWake { get; init; } + + /// + /// Round 3 (2026-08-03): true while this descriptor stands for an + /// accepted force packet whose placement WAS begun and whose retail + /// position event (CommandInterpreter::SendPositionEvent + /// @0x006B4770, called unconditionally at + /// SmartBox::HandleReceivedPosition @0x00454091) has not gone + /// out yet. It is false only for the re-issue retry marker + /// parks when a re-issue cannot even + /// begin: that marker stands for a packet whose placement was never + /// begun, so its ack is owed by the eventual re-issue's own terminal + /// outcome, not by the marker. + /// + internal required bool PositionEventOwed { get; init; } + } + + /// + /// Round 2 unified mechanism (2026-08-03): the newest accepted local-player + /// ForcePosition this drive has been handed, stamped with the canonical + /// record's PositionAuthorityVersion as observed at that packet's + /// own merge. It is the drive's ONLY knowledge of "what disposition was + /// the newest accepted position event", because + /// is dispatched by both + /// hosts for ForcePosition and only for ForcePosition — an ordinary + /// Apply merges (advancing PositionAuthorityVersion) without + /// ever reaching this class. + /// + /// Round 3 correction (2026-08-03): this is a ONE-WAY test, not a + /// biconditional. If the live record's current + /// PositionAuthorityVersion differs from + /// , then some other authority + /// advance has happened since this force was recorded and the funnel must + /// not re-issue it. The converse does NOT hold: equality does not prove + /// the newest accepted event was this ForcePosition, because + /// RuntimeEntityRecordTable.AdvancePositionAuthority has four call + /// sites, not one — the ordinary accepted-Position merge + /// (RuntimeEntityObjectLifetime.cs:1647) plus + /// TryApplyPickup (:1116), + /// CommitPositionChannelUpdate (:2041) and + /// AdvanceCreateAuthority (:2466). The latter three are + /// effectively unreachable for a live local player, but the funnel's + /// safety does not depend on that: an unnoticed advance can only make the + /// funnel decline a re-issue it might have made (register row + /// AD-62), never make it re-issue a stale pose. + /// + private readonly record struct AcceptedForceObservation( + RuntimeEntityKey Entity, + ulong PositionAuthorityVersion, + RuntimeAuthoritativePositionRoute Route); + + private readonly RuntimeEntityObjectLifetime _entityObjects; + private readonly IGameRuntimeClock _clock; + private readonly IPreparedCollisionSource _collisionSource; + private readonly LocalPlayerOutboundController _localPlayerOutbound; + private readonly Func _generation; + private readonly Func _localPlayerServerGuid; + private readonly Func _localController; + private readonly Func _usePositionFromServer; + private readonly Func _session; + + /// + /// The drive's at-most-one in-flight placement for the local player. + /// Round 2 unified mechanism (2026-08-03): exactly THREE members write + /// this field — (the operation is still + /// outstanding), (the single terminal-outcome + /// funnel), and (route teardown, which is + /// outside the operation lifecycle entirely). No branch of + /// , + /// or assigns or + /// clears it directly. Round 1 spread that ownership across the + /// individual branches, which is the shared root cause of B1, N1 and N2 + /// in docs/research/2026-08-03-c4-route-2-review-findings.md. + /// + private Pending? _pending; + private AcceptedForceObservation? _newestForce; + private object? _routeOwner; + + internal RuntimeAcceptedPositionDriveController( + RuntimeEntityObjectLifetime entityObjects, + IGameRuntimeClock clock, + IPreparedCollisionSource collisionSource, + LocalPlayerOutboundController localPlayerOutbound, + Func generation, + Func localPlayerServerGuid, + Func localController, + Func usePositionFromServer, + Func session) + { + _entityObjects = entityObjects + ?? throw new ArgumentNullException(nameof(entityObjects)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _collisionSource = collisionSource + ?? throw new ArgumentNullException(nameof(collisionSource)); + _localPlayerOutbound = localPlayerOutbound + ?? throw new ArgumentNullException(nameof(localPlayerOutbound)); + _generation = generation + ?? throw new ArgumentNullException(nameof(generation)); + _localPlayerServerGuid = localPlayerServerGuid + ?? throw new ArgumentNullException(nameof(localPlayerServerGuid)); + _localController = localController + ?? throw new ArgumentNullException(nameof(localController)); + _usePositionFromServer = usePositionFromServer + ?? throw new ArgumentNullException(nameof(usePositionFromServer)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _entityObjects.RegisterAcceptedPositionDriveOwnership( + () => _pending is null ? 0 : 1); + } + + internal int PendingCount => _pending is null ? 0 : 1; + + /// + /// C3c-R1-style one-route-at-a-time latch (mirrors + /// ): this + /// controller outlives its session routes (hosts reuse it across + /// reconnects), so the "session reset precedes a new route" ordering is + /// asserted, not assumed. + /// + internal void AttachRoute(object route) + { + ArgumentNullException.ThrowIfNull(route); + if (_routeOwner is not null && !ReferenceEquals(_routeOwner, route)) + { + throw new InvalidOperationException( + "An accepted-position drive controller serves one session " + + "route at a time; the prior route must be disposed " + + "(session reset precedes a new route) before a " + + "replacement attaches."); + } + _routeOwner = route; + } + + /// + /// Route-scoped teardown: abandons any pending operation, but ONLY when + /// is the attached owner. + /// + internal void DetachRoute(object route) + { + ArgumentNullException.ThrowIfNull(route); + if (!ReferenceEquals(_routeOwner, route)) + return; + _routeOwner = null; + AbandonPending(); + } + + /// + /// Route teardown — the one write that is NOT a + /// terminal operation outcome. The torn-down route's newest accepted + /// force observation dies with it: a reconnect re-merges its own + /// positions, and a stale observation must never survive to authorize a + /// re-issue against a later session's record. + /// + private void AbandonPending() + { + _newestForce = null; + if (_pending is not { } pending) + return; + _pending = null; + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + setPosition.ForgetPlacementCompletion(pending.Token); + RuntimePlacementCancellationReceipt cancellation = + setPosition.ForgetExactPlacement(pending.Token); + if (cancellation.IsValid) + setPosition.PublishCancellation(cancellation); + } + + /// + /// Executes a ForcePosition Position update against the canonical + /// Runtime SetPosition owner. 's Snapshot, + /// timestamps, and PositionAuthorityVersion must already reflect the + /// merge + /// performed for this exact — this method + /// never re-merges the wire frame; it only routes the already-accepted + /// pose through the canonical placement transaction. + /// + internal RuntimeAcceptedPositionExecutionStatus TryExecuteAcceptedLocalPosition( + RuntimeEntityRecord record, + in WorldSession.EntityPositionUpdate update, + PositionTimestampDisposition disposition, + in AcceptedPhysicsTimestamps timestamps, + ushort previousTeleportSequence) + { + ArgumentNullException.ThrowIfNull(record); + if (disposition is not PositionTimestampDisposition.ForcePosition + || record.ServerGuid != _localPlayerServerGuid() + || record.PhysicsBody is null + || record.Key is not { } key + // Route 1 owns an active initial-Create residence: the + // executor already retains the Position as its own tail + // action (RuntimeInitialCreateContinuationExecutor + // .ApplyPositionAction) and already carries + // SendPositionImmediately. Route 2 must not double-drive it. + || _entityObjects.TryGetInitialCreateResidence(record, out _)) + { + return RuntimeAcceptedPositionExecutionStatus.NotApplicable; + } + + RuntimeAuthoritativePositionRoute route = ClassifyForcePosition( + record, key, update, disposition, timestamps, previousTeleportSequence); + if (!route.Accepted) + { + // The wire's accepted timestamp/position channels were already + // merged into record.Snapshot upstream + // (RuntimeEntityObjectLifetime.TryApplyPosition) unconditionally + // on the disposition being non-Rejected — unlike the initial- + // Create continuation flow (whose merge happens INSIDE the + // route.Accepted branch), there is no separate "stamp-only" + // write left to perform here. A classifier rejection at this + // point is a data-validity failure (non-finite frame), not a + // staleness one; the caller's own currency re-check already + // covers staleness upstream. + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + + // Round 2 unified mechanism: record THIS packet as the newest accepted + // force BEFORE attempting to begin, so a failed begin (Contention) + // still leaves the funnel able to re-issue this exact correction. A + // classifier-rejected frame deliberately never gets here: it is not an + // accepted authority and must never authorize a re-issue. + ulong acceptedVersion = record.PositionAuthorityVersion; + _newestForce = new AcceptedForceObservation(key, acceptedVersion, route); + + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + RuntimeEntityPlacementToken token = + setPosition.TryBeginExclusiveAuthoredPlacement( + record, + acceptedVersion, + route.OperationKind); + if (!token.IsValid) + { + // The entity already owns an active operation (or an + // unacknowledged completion). Not staleness — see the Contention + // status doc. + return RuntimeAcceptedPositionExecutionStatus.Contention; + } + + return SubmitAndResolve(record, token, route); + } + + /// + /// Host cadence pump: resolves a parked DeferredCell operation once its + /// destination landblock's collision generation eventually commits it + /// (RuntimeSetPositionState.CommitCollisionGeneration → + /// RetryDeferredCommitCanonical, driven entirely by + /// unrelated collision/streaming machinery — this pump never re-submits + /// the operation itself). Retries a preparation-only retry status + /// (RetrySetupUnavailable/RetryWorldFrameUnavailable) by + /// re-calling the SAME prepare+submit pair, exactly like + /// 's own continuation + /// completion. Safe to call from any host cadence point; a no-op when + /// nothing is pending. + /// + /// R1 review fix (2026-08-03): RuntimeEntityObjectLifetime.TryApplyPosition + /// calls RuntimeSetPositionState.Forget on EVERY accepted Position + /// for this entity — any disposition, not only ForcePosition — which + /// unconditionally cancels whatever operation this controller has + /// in-flight (ForgetCancelCoreDeferred → + /// ForgetPlacementCompletionCore, which drops the token from both + /// _placementCompletionWatches and + /// _acknowledgedPlacementCompletions with no trace). ACE broadcasts + /// at 5-10 Hz, so a + /// park surviving past one broadcast interval is cancelled before its + /// collision generation can ever commit it — the exact far-destination + /// case the park exists to serve. The SAME cancellation path is also how + /// supersession, the lost-cell deadline, ParkCollisionResidents, + /// and a generation change retire an operation + /// (ForgetPlacementCompletionCore is their common funnel too). + /// + /// Retail SmartBox::BlipPlayer (@0x00453940) has no "give up + /// quietly" state — every accepted Position it sees gets applied. So + /// rather than silently leaking forever (which + /// would also pin AcceptedPositionDrivePendingCount non-zero for + /// the rest of the session — GameWindowLifetime.DisposeGameRuntime + /// throws on non-convergence), this pump detects the cancellation via the + /// existing read-only RuntimeSetPositionState.IsPlacementCompletionTracked + /// query (a watched-and-not-yet-cancelled DeferredCell park) or + /// IsPlacementCurrent (a still-in-flight prepare retry) and, when + /// neither holds, hands the dead operation to the single + /// funnel, which decides on ONE input whether + /// a re-issue is owed. + /// + internal void Advance() + { + if (_pending is not { } pending) + return; + + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + if (pending.AwaitingCommitWake) + { + if (setPosition.TryPeekAcknowledgedPlacement( + pending.Token, + out RuntimePlacementProjectionToken projection)) + { + if (!setPosition.ConsumeAcknowledgedPlacement( + pending.Token, projection)) + { + // Raced against a concurrent consumer; retry next pump. + return; + } + // Retail order: the deferred commit's own reconciliation and + // ack come first, THEN the funnel decides whether a newer + // accepted force is still owed a placement (B1). + ReconcileAndAcknowledge(pending.Record, pending.Route); + SettlePending( + pending.Record, + pending.Token, + pending.Route, + positionEventOwed: false); + return; + } + + if (setPosition.IsPlacementCompletionTracked(pending.Token)) + { + // Still parked, watch alive. Nothing more this pump can do. + return; + } + + // The watch died — most likely a subsequent accepted Position's + // merge-time Forget. The funnel owns what happens next, including + // this packet's still-unsent position event (retail acks whether + // or not the placement took — see SettlePending). + SettlePending( + pending.Record, + pending.Token, + pending.Route, + pending.PositionEventOwed); + return; + } + + if (setPosition.IsPlacementCurrent(pending.Token)) + { + _ = SubmitAndResolve(pending.Record, pending.Token, pending.Route); + return; + } + + // The prepare-retry operation died the same way. (A re-issue retry + // marker also lands here, carrying PositionEventOwed: false — its + // packet's placement was never begun, so its ack belongs to the + // eventual re-issue's terminal outcome.) + SettlePending( + pending.Record, + pending.Token, + pending.Route, + pending.PositionEventOwed); + } + + /// + /// Round 2 unified mechanism (2026-08-03) — the SINGLE terminal-outcome + /// funnel for . Every branch that ends an operation + /// (committed, rejected, cancelled, watch died, key released) calls this + /// and nothing else touches the field. One decision input: the terminal + /// operation's own PositionAuthorityVersion (carried on its + /// , + /// RuntimeSetPositionState.cs:50) compared against the live + /// canonical record's CURRENT PositionAuthorityVersion: + /// + /// + /// Equal — the canonical accepted authority has + /// not moved since this operation began, so nothing is outstanding. Clear + /// and do not re-issue. This is what makes one server correction produce + /// exactly ONE canonical placement and ONE outbound + /// AutonomousPosition even when a stale dead entry was still parked + /// in when the fresh packet committed (N1 — the + /// double-apply/double-ack class 670f307c deleted). + /// Advanced, newest accepted event still a + /// ForcePosition — a newer force arrived while we were in flight and + /// could not begin (see the Contention status doc). Re-issue it, + /// re-classified from the CURRENT record via — + /// never the terminal operation's own route (B1). + /// Advanced, newest accepted event is an ordinary + /// Apply — clear and do not re-issue. The correction was superseded by + /// newer server truth and the ordinary route owns that pose. Re-issuing + /// here would apply the force route's Teleport|Slide flags to an + /// ordinary pose, send an ack retail never sends on that branch, and skip + /// the ConstrainTo the ordinary branch runs + /// (RuntimeAuthoritativePositionRouteClassifier.cs:368-388) — N2. + /// This is not a silent drop: retail applies each event as it arrives, and + /// a force overtaken by a newer position is moot. + /// + /// + /// Recursion is bounded at one level: a re-issue always begins at the + /// record's CURRENT version, so its own terminal settle necessarily takes + /// the Equal branch (nothing can advance the authority between a begin and + /// its synchronous submit — only an inbound merge does, and inbound + /// dispatch is what called us). + /// + /// Round 3 (2026-08-03) — the terminal-without-commit ack. + /// Retail acknowledges an accepted force packet whether or not its + /// placement took. SmartBox::BlipPlayer @0x00453940 calls + /// CPhysicsObj::SetPositionSimple @0x005162B0 — which returns an + /// enum SetPositionError that other retail call sites DO test + /// (== OK_SPE @0x0055605D, @0x00556021) — and DISCARDS it; + /// BlipPlayer itself returns void. Its caller + /// SmartBox::HandleReceivedPosition @0x00453FD0 then runs + /// cmdinterp->SendPositionEvent() @0x00454091 unconditionally and + /// returns @0x0045409D. So retail's semantics are: attempt the placement; + /// if it fails the body simply does not move; acknowledge regardless; + /// never retry. This funnel therefore sends the position event for any + /// terminal outcome whose placement was begun and did NOT commit + /// (), carrying the body's UNCHANGED + /// pose — which is exactly what retail's ack carries after a failed + /// SetPositionSimple, and is informative to the server: its force + /// did not take. It does NOT re-issue that correction, because retail + /// never retries. The commit paths pass false: their ack already + /// left through , so exactly one + /// position event goes out per begun placement THAT REACHES ITS OWN + /// TERMINAL SETTLE — never two. + /// + /// It is deliberately NOT "never zero per begun placement". This + /// method opens by nulling _pending without reading it, so a + /// descriptor still carrying PositionEventOwed is discarded when a + /// NEWER ForcePosition displaces it (the merge-time + /// Forget clears the block, the newer packet begins cleanly, and + /// its terminal settle nulls the field). That older packet's owed ack is + /// lost — AD-62 shape (v), pinned by + /// OneServerCorrectionProducesExactlyOnePlacementAndOneAck, which + /// feeds two force packets and asserts a single ack. Replaying it would be + /// worse: the message would carry a stale sequence against the newer + /// packet's committed pose. The displacing packet always acks, so ACE + /// always receives a report for the NEWEST force. + /// + /// Divergence: the two non-reissuing branches mean a ForcePosition + /// retired without committing is never re-applied — + /// docs/architecture/retail-divergence-register.md row AD-62 + /// (the park itself is our async collision-publication adaptation; retail + /// SmartBox::BlipPlayer @0x00453940 is synchronous against a fully + /// resident world and cannot reach this state). The ack is no longer part + /// of that loss for a begun placement; AD-62 names the narrower shapes + /// where the packet's placement was never begun at all and the ack is + /// still lost. + /// + private void SettlePending( + RuntimeEntityRecord terminalRecord, + in RuntimeEntityPlacementToken terminalToken, + in RuntimeAuthoritativePositionRoute terminalRoute, + bool positionEventOwed) + { + _pending = null; + + if (!_entityObjects.Entities.TryGetActive( + terminalRecord.ServerGuid, out RuntimeEntityRecord record) + || record.ServerGuid != _localPlayerServerGuid() + || record.PhysicsBody is null + || record.Key is not { } key + || key != terminalToken.Entity) + { + // The entity departed the world, is no longer the local player, + // has no canonical body, or released/replaced the incarnation this + // operation belonged to (the post-teardown key release). Nothing + // left in the world for this operation to place — and nothing left + // to acknowledge a position for either. + return; + } + + // Retail order: the packet's own position event first (@0x00454091), + // THEN whatever the next accepted force is owed. No reconciliation + // runs here — the body did not move, so there is no committed frame to + // reconcile; only the ack is owed. + if (positionEventOwed && _localController() is { } terminalController) + SendPositionEvent(terminalController, terminalRoute); + + ulong current = record.PositionAuthorityVersion; + if (terminalToken.PositionAuthorityVersion == current) + return; + + if (_newestForce is not { } newest + || newest.Entity != key + || newest.PositionAuthorityVersion != current) + { + return; + } + + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + RuntimeEntityPlacementToken token = + setPosition.TryBeginExclusiveAuthoredPlacement( + record, + current, + newest.Route.OperationKind); + if (!token.IsValid) + { + // Could not even begin the re-issue (a concurrent placement + // authority still owns the entity). The correction is still owed, + // so keep the terminal descriptor as the retry marker: the next + // Advance() pump re-enters this funnel and re-evaluates the SAME + // decision against the record as it then stands. + // + // PositionEventOwed: false — the marker stands for the NEWEST + // accepted force, whose placement was never begun. Its ack belongs + // to that re-issue's own terminal outcome, so re-entering this + // funnel through the marker must never fire a second position + // event for a packet the re-issue will ack itself. + _pending = new Pending + { + Record = terminalRecord, + Token = terminalToken, + Route = terminalRoute, + AwaitingCommitWake = false, + PositionEventOwed = false, + }; + return; + } + + _ = SubmitAndResolve(record, token, newest.Route); + } + + private RuntimeAcceptedPositionExecutionStatus SubmitAndResolve( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken token, + in RuntimeAuthoritativePositionRoute route) + { + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + RuntimeSetPositionMoverPreparationStatus status = + setPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + token, + route.OperationKind, + route.SetPositionFlags, + _collisionSource, + _clock.SimulationTimeSeconds, + out RuntimeSetPositionOutcome outcome, + resolveWorldOffsetFromRuntimeFrame: true); + + if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + if (status.IsRetryable()) + { + RetainPending(setPosition, new Pending + { + Record = record, + Token = token, + Route = route, + AwaitingCommitWake = false, + PositionEventOwed = true, + }); + return RuntimeAcceptedPositionExecutionStatus.Contention; + } + + CancelToken(setPosition, token); + SettlePending(record, token, route, positionEventOwed: true); + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + + switch (outcome.Status) + { + case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending: + // R4 review fix (2026-08-03): unlike + // RuntimeFirstEntryDriveController.TryCompleteContinuationPlacement + // (whose ack here is benign because ITS residence is what + // just drained, so the sink's residence gate can no longer + // decline), this route's sink can legitimately decline this + // exact receipt for real production reasons unrelated to + // anything this route does (!IsLoaded(landblock), stale + // transit authority — RuntimePlacementProjectionSubscription + // deliberately leaves a declined Place at the FIFO head for + // its OWN later retry). Acknowledging it here would consume + // and destroy that retry with no second writer to cover the + // render-facing projection (the generic App tail is skipped + // for this route). So this route does NOT acknowledge the + // projection itself — the production subscription already + // did, synchronously, inside the SetPosition call above, if + // it was going to; if it declined, its own retry contract + // owns the receipt from here, exactly like every other + // placement kind. + ReconcileAndAcknowledge(record, route); + SettlePending(record, token, route, positionEventOwed: false); + return RuntimeAcceptedPositionExecutionStatus.Committed; + + case RuntimeSetPositionStatus.DeferredCell: + // Parked with a published Withdraw; consume it if it is + // already the head so the collision-generation wake can + // resubmit (RuntimeFirstEntryDriveController + // .TryCompleteContinuationPlacement:351-365's exact pattern). + while (setPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot parked) + && parked.Token.Entity == token.Entity + && parked.Kind is RuntimePlacementProjectionKind.Withdraw) + { + if (!setPosition.AcknowledgeProjection(parked.Token)) + break; + } + if (!setPosition.WatchPlacementCompletion(token)) + { + // Something displaced the operation between Submit and + // here — no cross-pump tracking is possible; cancel + // rather than leak a watch we can never resolve. + CancelToken(setPosition, token); + SettlePending(record, token, route, positionEventOwed: true); + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + RetainPending(setPosition, new Pending + { + Record = record, + Token = token, + Route = route, + AwaitingCommitWake = true, + PositionEventOwed = true, + }); + return RuntimeAcceptedPositionExecutionStatus.DeferredCell; + + default: + // Rejected/Cancelled — authority moved out from under this + // operation, so the body never moved. Retail still + // acknowledges the packet (SettlePending's positionEventOwed + // path); it just does not re-apply it. + CancelToken(setPosition, token); + SettlePending(record, token, route, positionEventOwed: true); + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + } + + /// + /// R6 review fix (2026-08-03): the previous shape assigned + /// unconditionally, which could silently + /// overwrite a still-tracked live pending (losing the only reference + /// able to later consume its eventual acknowledged completion — a + /// permanent HasRetainedCompletion orphan that would block every + /// future Begin for this entity with Contention forever). Given + /// + /// already refuses to Begin while the entity holds an active operation + /// or an unconsumed acknowledged completion, 's + /// token could only have successfully begun if any DIFFERENT existing + /// is already dead — so this is a defensive + /// invariant check, not a routine code path. + /// + /// Round 2 (2026-08-03): this is the ONLY "the operation is still + /// outstanding" writer of ; every terminal outcome + /// goes through instead. + /// + private void RetainPending( + RuntimeSetPositionState setPosition, + Pending next) + { + if (_pending is { } existing + && existing.Token != next.Token + && setPosition.IsPlacementCurrent(existing.Token)) + { + throw new InvalidOperationException( + "RuntimeAcceptedPositionDriveController tracks at most one " + + "pending accepted-position operation (the local player); " + + "a still-live pending operation must never be silently " + + "overwritten by a new one."); + } + _pending = next; + } + + private static void CancelToken( + RuntimeSetPositionState setPosition, + in RuntimeEntityPlacementToken token) + { + RuntimePlacementCancellationReceipt cancellation = + setPosition.ForgetExactPlacement(token); + if (cancellation.IsValid) + setPosition.PublishCancellation(cancellation); + } + + /// + /// §4a/§4b: the controller-local reconciliation + /// CommitCanonical does not perform, followed by the outbound ack + /// — an OUTPUT of the committed route, never a step performed alongside + /// it (retail cmdinterp->SendPositionEvent() @0x00454091 runs + /// after SmartBox::BlipPlayer @0x00454074 returns). + /// + private void ReconcileAndAcknowledge( + RuntimeEntityRecord record, + in RuntimeAuthoritativePositionRoute route) + { + if (record.ServerGuid != _localPlayerServerGuid()) + return; + if (_localController() is not { } controller) + return; + controller.CommitCanonicalForcePositionFrame(); + SendPositionEvent(controller, route); + } + + /// + /// Retail cmdinterp->SendPositionEvent() @0x00454091 — the sole + /// outbound-ack site for this route, shared by the committed path + /// (, which reconciles the moved + /// frame first) and the terminal-without-commit path + /// (, which has no moved frame to reconcile and + /// so sends the body's unchanged pose). + /// + /// The CanSendPositionEvent admission inside + /// is + /// retail's own (CommandInterpreter::SendPositionEvent @0x006B4770 + /// tests the transient-state contact bits), so a legitimately airborne + /// body still suppresses the send on BOTH paths — that suppression is + /// retail behaviour, not a divergence. + /// + private void SendPositionEvent( + PlayerMovementController controller, + in RuntimeAuthoritativePositionRoute route) + { + if (!route.SendPositionImmediately) + return; + _localPlayerOutbound.SendImmediatePosition(_session(), controller); + } + + private RuntimeAuthoritativePositionRoute ClassifyForcePosition( + RuntimeEntityRecord record, + RuntimeEntityKey key, + in WorldSession.EntityPositionUpdate update, + PositionTimestampDisposition disposition, + in AcceptedPhysicsTimestamps timestamps, + ushort previousTeleportSequence) + { + var authority = new RuntimeAuthoritativePositionAuthority( + _generation(), + key, + record.PositionAuthorityVersion, + update.PositionSequence, + previousTeleportSequence, + timestamps.Teleport, + disposition); + + // Round 3 A3 (mirrored from RuntimeInitialCreateContinuationExecutor + // .ApplyPositionAction): contact is SOLELY the retained wire + // packet's own IsGrounded bit — never a live body query. + bool hasContact = update.IsGrounded; + bool hasAnimations = (record.Snapshot.MotionTableId + ?? record.Snapshot.Physics?.MotionTableId) is { } motionTableId + && motionTableId != 0u; + + bool usePositionFromServer = _usePositionFromServer(); + float playerDistance = 0f; + if (_localController() is { } controllerForDistance + && (record.Snapshot.Physics?.Position + ?? record.Snapshot.Position) is { } acceptedForDistance) + { + var target = new Vector3( + acceptedForDistance.PositionX, + acceptedForDistance.PositionY, + acceptedForDistance.PositionZ); + playerDistance = Vector3.Distance( + target, controllerForDistance.Position); + } + + var request = new RuntimeAcceptedPositionRouteRequest( + authority, + RuntimePositionEntityKind.LocalPlayer, + RuntimeAcceptedPositionSource.PositionEvent, + update.Position, + update.PlacementId, + update.Velocity, + record.FullCellId, + hasContact, + playerDistance, + usePositionFromServer, + hasAnimations, + new RuntimePositionPlacementFacts( + record.FinalPhysicsState, + record.Snapshot.SetupTableId is not null)); + + return RuntimeAuthoritativePositionRouteClassifier + .ClassifyAcceptedPosition(request); + } +} diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index bf7123d2..14e15f7b 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -19,6 +19,24 @@ public interface IRuntimeDirectWorldProjection bool isLocalPlayer, PositionTimestampDisposition disposition); + /// + /// R2 review fix (2026-08-03): a ForcePosition on the local player is + /// dispatched directly to + /// and never reaches at all, so THIS is + /// where a host that keeps a narrow collision/streaming window (the + /// deleted HeadlessSessionWorldProjection.BlipLocalPlayer's own + /// _collision.CenterOn call) re-centers on the destination BEFORE + /// the drive controller submits — establishing that the destination's + /// collision generation is one this host's window can ever publish is a + /// precondition for a DeferredCell park to be a real park rather + /// than a dead end (see RuntimeAcceptedPositionDriveController.Advance's + /// R1 doc comment). A host with no narrow window (the graphical host, + /// whose landblock streaming already follows the accepted position via + /// LiveEntityInboundAuthorityGate.ObserveAcceptedLocalPosition) is + /// a no-op here. + /// + void CenterOnAcceptedForcePosition(RuntimeEntityRecord record); + void BeginTeleport(); RuntimeDestinationReadiness PrepareDestination( @@ -38,20 +56,28 @@ public sealed class RuntimeLiveEntitySessionController private readonly WorldSession _session; private readonly Action _log; private readonly IRuntimeDirectWorldProjection? _worldProjection; - private readonly LocalPlayerOutboundController _localPlayerOutbound = - new((_, _, _, _, _, _) => { }); + /// + /// C4 route 2 (2026-08-03): the headless accepted-Position drive + /// controller. Owns its own outbound-ack collaborator internally; the + /// ForcePosition + manual LocalPlayerOutboundController.SendImmediatePosition + /// pair this class used to drive directly is retired (the deleted + /// HeadlessSessionWorldProjection.BlipLocalPlayer). + /// + private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private bool _initialLoginCompleteSent; public RuntimeLiveEntitySessionController( GameRuntime runtime, WorldSession session, Action? log = null, - IRuntimeDirectWorldProjection? worldProjection = null) + IRuntimeDirectWorldProjection? worldProjection = null, + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _session = session ?? throw new ArgumentNullException(nameof(session)); _log = log ?? (_ => { }); _worldProjection = worldProjection; + _acceptedPositionDrive = acceptedPositionDrive; } public LiveEntitySessionSink CreateSink() => new( @@ -218,16 +244,55 @@ public sealed class RuntimeLiveEntitySessionController update.Guid, out RuntimeEntityRecord record)) { - _worldProjection?.ProjectPosition( - record, - isLocalPlayer: true, - disposition); - } - if (disposition is PositionTimestampDisposition.ForcePosition) - { - _localPlayerOutbound.SendImmediatePosition( - _session, - _runtime.MovementOwner.Controller); + if (disposition is PositionTimestampDisposition.ForcePosition) + { + // R2 review fix (2026-08-03): re-center BEFORE submitting — + // see IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition's + // doc comment. This is what the deleted BlipLocalPlayer's own + // _collision.CenterOn call used to guarantee. + _worldProjection?.CenterOnAcceptedForcePosition(record); + + // C4 route 2 (2026-08-03): the Runtime-owned accepted- + // Position execution seam replaces the deleted + // HeadlessSessionWorldProjection.BlipLocalPlayer + manual + // SendImmediatePosition pair. Canonical commit, controller + // reconciliation, and the outbound ack (an OUTPUT of the + // committed route, not a step alongside it) all run inside + // the call below. + RuntimeAcceptedPositionExecutionStatus forceStatus = + _acceptedPositionDrive?.TryExecuteAcceptedLocalPosition( + record, + update, + disposition, + timestamps, + timestamps.PreviousTeleport) + ?? RuntimeAcceptedPositionExecutionStatus.NotApplicable; + if (forceStatus is RuntimeAcceptedPositionExecutionStatus + .NotApplicable) + { + // R3 review fix (2026-08-03): NotApplicable (e.g. an + // initial-Create residence still owns this record — + // route 1's job, or the login-window controller-null + // branch before route 1 has even published a + // controller) is NOT the terminal case the previous + // comment here claimed. ProjectPosition's own + // controller-null branch is the pre-existing legacy + // fallback this disposition always had — it must still + // run, exactly as every other disposition's fallback + // does below. + _worldProjection?.ProjectPosition( + record, + isLocalPlayer: true, + disposition); + } + } + else + { + _worldProjection?.ProjectPosition( + record, + isLocalPlayer: true, + disposition); + } } TryCompletePortal(); } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs index 1936f0e7..5d319a2f 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using AcDream.App.Physics; namespace AcDream.App.Tests.Physics; @@ -46,51 +47,97 @@ public sealed class LiveEntityNetworkBranchRoutingTests Assert.Equal(["projectile", "canonical", "ordinary"], calls); } - [Fact] - public void ForcePosition_BlipsAndAcknowledgesExactlyOnce() + // C4 route 2 (2026-08-03): LocalForcePositionTransaction and its + // ForcePosition_* coverage here are RETIRED, not adapted — the class is + // deleted outright (docs/research/2026-08-03-c4-route-2-contract.md + // §"The contract" item 2). Its three jobs (ownership validation, the + // blip/commit, and the exactly-once ack including the displaced- + // authority case its trailing isCurrent() covered) are now properties of + // the Runtime-owned RuntimeAcceptedPositionDriveController and are tested + // there: tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs. + + /// + /// R8 review fix (2026-08-03): source pins for the generic-tail + /// double-write guard in LiveEntityNetworkUpdateController.OnPosition. + /// A full behavioral fixture is impractical here for the SAME reason + /// C3cF1ProductionWiringTests gives — the controller's dependency + /// set is composition-only (67+ collaborators wired only by + /// SessionPlayerComposition) — so this follows that file's exact + /// established pattern: assert the STRUCTURE of the production source + /// rather than construct the class. The Runtime-level behavioral + /// coverage for the seam itself lives in + /// RuntimeAcceptedPositionDriveControllerTests; these pins are what stop + /// this App-layer call site from silently reintroducing the retired + /// duplicate-write authority (the deleted LocalForcePositionTransaction + /// pair + the generic render-tail writing the SAME accepted Position a + /// second time — 670f307c's divergence class). + /// + public sealed class LiveEntityNetworkUpdateControllerForcePositionWiringTests { - int currentChecks = 0; - int blips = 0; - int acknowledgements = 0; + [Fact] + public void LocalForcePositionTransactionIsNeverCalledFromThisFile() + { + string source = ReadSource("LiveEntityNetworkUpdateController.cs"); - bool completed = LocalForcePositionTransaction.Apply( - isForcePosition: true, - () => { currentChecks++; return true; }, - () => blips++, - () => acknowledgements++); + // The name may still appear in a comment explaining what + // replaced it (contract §"the deleted LocalForcePositionTransaction"); + // what must be gone is any actual call into it. + Assert.DoesNotContain( + "LocalForcePositionTransaction.Apply(", + source, + StringComparison.Ordinal); + } - Assert.True(completed); - Assert.Equal(2, currentChecks); - Assert.Equal(1, blips); - Assert.Equal(1, acknowledgements); - } + [Fact] + public void GenericTailWriteIsNeverDuplicatedForTheLocalForcePositionPath() + { + string source = ReadSource("LiveEntityNetworkUpdateController.cs"); - [Fact] - public void ForcePosition_AcknowledgementInvalidationStopsTheTail() - { - bool current = true; - int acknowledgements = 0; + // The generic render-tail's WorldEntity write is the ONE + // remaining writer of an accepted Position — it must serve + // remotes only, never a second local-player write alongside the + // Runtime-committed one. + Assert.Single( + Regex.Matches(source, @"entity\.SetPosition\(worldPos\);") + .Cast()); + } - bool completed = LocalForcePositionTransaction.Apply( - isForcePosition: true, - () => current, - () => { }, - () => { acknowledgements++; current = false; }); + [Fact] + public void CommittedOrDeferredCellReturnsBeforeReachingTheGenericTail() + { + string source = ReadSource("LiveEntityNetworkUpdateController.cs"); - Assert.False(completed); - Assert.Equal(1, acknowledgements); - } + // The Committed/DeferredCell branch must still return + // immediately after its two preserved side effects — a missing + // `return` here would fall through into the generic tail below + // and resurrect the double-write. + Assert.Matches( + new Regex( + @"ObserveAcceptedLocalPosition\(\s*" + + @"update\.Position\.LandblockId\);\s*return;", + RegexOptions.Singleline), + source); + } - [Fact] - public void OrdinaryPositionDoesNotBlipOrAcknowledge() - { - int calls = 0; + private static string ReadSource(string fileName) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.App", + "Physics", + fileName)); + } - Assert.True(LocalForcePositionTransaction.Apply( - isForcePosition: false, - () => { calls++; return false; }, - () => calls++, - () => calls++)); - Assert.Equal(0, calls); + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } } } diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index ff9ab063..08a02703 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -393,16 +393,56 @@ public sealed class HeadlessSessionHostTests acknowledgeProjection: null, out PositionTimestampDisposition disposition, out _, - out _)); + out AcceptedPhysicsTimestamps timestamps)); Assert.Equal( PositionTimestampDisposition.ForcePosition, disposition); - projection.ProjectPosition( - record, - isLocalPlayer: true, - disposition); - Assert.Equal(new Vector3(72f, 73f, 50f), controller.Position); + // C4 route 2 (2026-08-03): a ForcePosition on the local player no + // longer routes through HeadlessSessionWorldProjection.ProjectPosition + // at all — RuntimeLiveEntitySessionController.OnPositionUpdated + // dispatches it directly to RuntimeAcceptedPositionDriveController + // instead (the deleted BlipLocalPlayer's replacement), but R2 review + // fix (2026-08-03): it re-centers the collision neighborhood on the + // destination FIRST — the deleted BlipLocalPlayer's own CenterOn + // side effect, restored via CenterOnAcceptedForcePosition, because a + // DeferredCell park this neighborhood's window can never publish is + // a dead end, not a real park + // (RuntimeAcceptedPositionDriveController.Advance's R1 doc comment). + projection.CenterOnAcceptedForcePosition(record); + RuntimeAcceptedPositionDriveController acceptedPositionDrive = + CreateAcceptedPositionDrive(runtime); + RuntimeAcceptedPositionExecutionStatus forceStatus = + acceptedPositionDrive.TryExecuteAcceptedLocalPosition( + record, + force, + disposition, + timestamps, + timestamps.PreviousTeleport); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.Committed, + forceStatus); + // R7 review fix (2026-08-03): Z is 50.005f — within 5 mm of the + // wire's bare 50f — NOT 50.48f. The dat-exact human Setup's foot + // sphere is (0,0,0.475) r=.48 (LoadedSetupCollisionSource above); + // its bottom sits at origin + 0.475 − 0.48 = origin − 0.005, so a + // settled origin lands 0.005 m ABOVE the floor it rests on (measured + // empirically against this exact fixture), not a full sphere RADIUS + // above it. Retail's BlipPlayer (CPhysicsObj::SetPositionSimple + // @0x005162B0, called from SmartBox::BlipPlayer @0x00453940) has + // never lifted the origin by a sphere radius — the C4-route-2 FIRST + // implementation pass (uncommitted; this file asserts a bare 50f at + // HEAD, never 50.48f) had fitted a 50.48f assertion to a dummy + // fixture sphere whose offset happened to equal its own radius, not + // to retail behavior. The comment it carried described the sphere's + // CENTRE, then wrongly asserted that description about + // controller.Position, which is the body's ORIGIN + // (PlayerMovementController.cs -> PhysicsBody.cs Position), not the + // sphere centre. + Assert.Equal(new Vector3(72f, 73f, 50.005f), controller.Position); + // CenterCount is 2: ProjectSpawn's initial centering plus the + // ForcePosition's own re-centering above (R2's restored mechanism). Assert.Equal(2, collision.CenterCount); } @@ -1296,6 +1336,25 @@ public sealed class HeadlessSessionHostTests Height: 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + /// + /// C4 route 2 (2026-08-03): mirrors 's + /// construction pattern for the accepted-Position drive controller. No + /// real WorldSession is needed for these fixture tests — + /// LocalPlayerOutboundController.SendImmediatePosition no-ops on a null + /// session. + /// + private static RuntimeAcceptedPositionDriveController + CreateAcceptedPositionDrive(GameRuntime runtime) => new( + runtime.EntityObjects, + runtime.Clock, + new LoadedSetupCollisionSource(), + new LocalPlayerOutboundController((_, _, _, _, _, _) => { }), + () => runtime.Generation, + () => runtime.PlayerIdentity.ServerGuid, + () => runtime.MovementOwner.Controller, + () => runtime.CharacterOwner.UsePositionFromServer, + () => null); + private sealed class LoadedSetupCollisionSource : AcDream.Content.IPreparedCollisionSource { @@ -1312,7 +1371,23 @@ public sealed class HeadlessSessionHostTests .Loaded(new FlatSetupCollision( System.Collections.Immutable.ImmutableArray< FlatCollisionCylinder>.Empty, - [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + // R7 review fix (2026-08-03): the dat-exact human Setup + // 0x02000001 spheres (Ts46SphereListConformanceTests.cs + // :35-39) — foot (0,0,0.475) r=.48, head/torso + // (0,0,1.350) r=.48. The PREVIOUS single dummy sphere at + // (0,0,0) r=.48 (offset == radius) made a settled origin + // rest a FULL radius above the floor; the real foot + // sphere's bottom is origin + 0.475 − 0.48 = origin − + // 0.005, so a settled origin lands ON the floor within + // 5 mm. Retail's BlipPlayer has never lifted the origin + // by a sphere radius — that was a fixture artifact, not + // a retail-fidelity gain (docs/ISSUES.md #285 correction). + [ + new FlatCollisionSphere( + new Vector3(0f, 0f, 0.475f), 0.48f), + new FlatCollisionSphere( + new Vector3(0f, 0f, 1.350f), 0.48f), + ], height: 0f, radius: 0f, stepUpHeight: 0.4f, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index d4c2c2bb..df499c04 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -569,7 +569,7 @@ public class PlayerMovementControllerTests } [Fact] - public void BlipPosition_ResnapsPoseWithoutStoppingActiveMotion() + public void CommitCanonicalForcePositionFrame_ReconcilesPoseWithoutStoppingActiveMotion() { var controller = new PlayerMovementController(MakeFlatEngine()); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); @@ -579,7 +579,14 @@ public class PlayerMovementControllerTests Assert.True(velocity.LengthSquared() > 0f); var corrected = new Vector3(100f, 98f, 50f); - controller.BlipPosition(corrected, 0x0001, corrected); + // C4 route 2: simulates Runtime's canonical SetPosition commit + // (RuntimeSetPositionState.CommitCanonical:4459-4462), which snaps + // the SAME PhysicsBody directly BEFORE the controller reconciles + // its render-lerp/cell state. The deleted BlipPosition used to do + // both steps itself; CommitCanonicalForcePositionFrame only does the + // second. + controller.PhysicsBody.SnapToCell(0x0001, corrected, corrected); + controller.CommitCanonicalForcePositionFrame(); Assert.Equal(corrected, controller.Position); Assert.Equal(corrected, controller.RenderPosition); @@ -587,13 +594,14 @@ public class PlayerMovementControllerTests } [Fact] - public void BlipPosition_PublishesCanonicalOutdoorCellAndLocalFrame() + public void CommitCanonicalForcePositionFrame_PublishesCanonicalOutdoorCellAndLocalFrame() { var controller = new PlayerMovementController(MakeFlatEngine()); var world = new Vector3(150f, 193f, 50f); var wireLocal = new Vector3(150f, 193f, 50f); - controller.BlipPosition(world, 0xA9B30038u, wireLocal); + controller.PhysicsBody.SnapToCell(0xA9B30038u, world, wireLocal); + controller.CommitCanonicalForcePositionFrame(); Assert.Equal(0xA9B40031u, controller.CellId); Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId); @@ -959,27 +967,35 @@ public class PlayerMovementControllerTests } [Fact] - public void BlipPosition_ArmsConstraintButDoesNotTearDownOrZeroVelocity() + public void CommitCanonicalForcePositionFrame_DoesNotRearmConstraintLeashOrTouchVelocity() { var (controller, _) = MakeControllerWithHost(); - controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + var initial = new Vector3(96f, 96f, 50f); + controller.SetPosition(initial, 0x0001); controller.Update(ObjectTick, new MovementInput(Forward: true)); - Vector3 velocityBeforeBlip = controller.BodyVelocity; - Assert.NotEqual(Vector3.Zero, velocityBeforeBlip); // sanity: actually moving - - controller.BlipPosition( - new Vector3(150f, 150f, 50f), - 0x0001, - new Vector3(150f, 150f, 50f)); - - // BlipPlayer (retail 0x00453940) survives motion/velocity/stick — the - // leash is no different: ConstrainTo runs with NO preceding UnConstrain - // and no StopCompletely. - Assert.Equal(velocityBeforeBlip, controller.BodyVelocity); + Vector3 velocityBeforeCommit = controller.BodyVelocity; + Assert.NotEqual(Vector3.Zero, velocityBeforeCommit); // sanity: actually moving ConstraintManager cm = controller.PositionManager!.Constraint!; - Assert.True(cm.IsConstrained); - Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin); - Assert.Equal(0f, cm.ConstraintPosOffset, 3); + Vector3 leashAnchorBeforeCommit = cm.ConstraintPos.Frame.Origin; + + var corrected = new Vector3(150f, 150f, 50f); + // Simulates Runtime's canonical SetPosition commit writing the SAME + // PhysicsBody directly, exactly as CommitCanonicalForcePositionFrame + // expects to find it. + controller.PhysicsBody.SnapToCell(0x0001, corrected, corrected); + controller.CommitCanonicalForcePositionFrame(); + + // C4 route 2 (2026-08-03): retail's FORCE_POSITION branch of + // SmartBox::HandleReceivedPosition (0x00453FD0) returns at + // 0x0045409D, before every CPhysicsObj::ConstrainTo call + // (0x00454272/0x0045418A/0x004541EC) — the branch BlipPlayer runs + // on is not one of them. Unlike the deleted BlipPosition (which + // re-armed the leash to the corrected position — an unbacked + // deviation), this method must leave the leash anchored exactly + // where it already was. Motion/velocity are untouched either way. + Assert.Equal(velocityBeforeCommit, controller.BodyVelocity); + Assert.Equal(leashAnchorBeforeCommit, cm.ConstraintPos.Frame.Origin); + Assert.NotEqual(corrected, cm.ConstraintPos.Frame.Origin); } [Fact] @@ -1133,10 +1149,8 @@ public class PlayerMovementControllerTests Vector3.One, 0xA9B40021u, Vector3.One)); - Assert.Throws(() => candidate.BlipPosition( - Vector3.One, - 0xA9B40021u, - Vector3.One)); + Assert.Throws(() => + candidate.CommitCanonicalForcePositionFrame()); Assert.Throws(() => candidate.CaptureMovementResult(mouseLookEvent: false)); Assert.Throws(() => diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index 3c8468d2..3e3b9035 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -2975,10 +2975,8 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Vector3.One, Cell, Vector3.One)); - Assert.Throws(() => controller.BlipPosition( - Vector3.One, - Cell, - Vector3.One)); + Assert.Throws(() => + controller.CommitCanonicalForcePositionFrame()); Assert.Throws(() => controller.ApplyPhysicsState(PhysicsStateFlags.Frozen)); Assert.Throws(() => controller.Yaw = 1f); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs new file mode 100644 index 00000000..3208faf8 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs @@ -0,0 +1,1453 @@ +using System.Net; +using System.Numerics; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Spells; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; +using AcDream.Runtime.World; + +namespace AcDream.Runtime.Tests.Session; + +/// +/// C4 route 2 (2026-08-03): the Runtime-owned accepted-Position execution +/// seam for a ForcePosition on an already-live local player. Fixture +/// construction mirrors RuntimeLiveEntitySessionControllerTests' +/// session/first-entry harness — a live +/// only exists once the local player's initial-Create residence has fully +/// drained through . +/// +public sealed class RuntimeAcceptedPositionDriveControllerTests +{ + private const uint PlayerGuid = 0x50000001u; + private const uint SpawnLandblock = 0x01010000u; + private const float SpawnHeight = 5f; + + [Fact] + public void NotApplicable_WhenDispositionIsNotForcePosition() + { + using StartedRuntime started = StartRuntime(); + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(started.Runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(started.Runtime, out List gameActions); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedLocalPosition( + record, + ForceUpdate(new Vector3(30f, 30f, 5f)), + PositionTimestampDisposition.Apply, + Timestamps(teleport: 0), + previousTeleportSequence: 0); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.NotApplicable, status); + Assert.Empty(gameActions); + } + + [Fact] + public void NotApplicable_WhenRecordIsNotTheLocalPlayer() + { + using StartedRuntime started = StartRuntime(); + (RuntimeEntityRecord record, _) = EnterLocalPlayer(started.Runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(started.Runtime, out _); + + // Same record, but the drive's own local-player-guid accessor no + // longer matches it (as if it belonged to some other entity). + started.Runtime.PlayerIdentity.ServerGuid = 0x70000099u; + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedLocalPosition( + record, + ForceUpdate(new Vector3(30f, 30f, 5f)), + PositionTimestampDisposition.ForcePosition, + Timestamps(teleport: 0), + previousTeleportSequence: 0); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.NotApplicable, status); + } + + [Fact] + public void NotApplicable_WhileAnInitialCreateResidenceIsStillActive() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + runtime.PlayerIdentity.ServerGuid = PlayerGuid; + CommitLandblockCollision(runtime, SpawnLandblock); + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntityWithInitialResidence( + Spawn(PlayerGuid), isLocalPlayer: true) + .Canonical!; + // Deliberately do NOT drain the first-entry drive: the residence + // (and hence no PhysicsBody/controller) is still open — route 1's + // job, never route 2's. + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedLocalPosition( + record, + ForceUpdate(new Vector3(30f, 30f, 5f)), + PositionTimestampDisposition.ForcePosition, + Timestamps(teleport: 0), + previousTeleportSequence: 0); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.NotApplicable, status); + Assert.Empty(gameActions); + } + + [Fact] + public void Rejected_WhenTheClassifierDeclinesTheFrame() + { + using StartedRuntime started = StartRuntime(); + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(started.Runtime); + Vector3 positionBefore = controller.Position; + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(started.Runtime, out List gameActions); + + // RuntimeAuthoritativePositionRouteClassifier.ValidAcceptedAuthority's + // ForcePosition case requires PreviousTeleportSequence == + // AcceptedTeleportSequence; a mismatch is a genuine data-validity + // rejection (this call site never re-derives the timestamp gate's + // own freshness rule — it is asserting the classifier's own + // independent structural check). + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedLocalPosition( + record, + ForceUpdate(new Vector3(30f, 30f, 5f)), + PositionTimestampDisposition.ForcePosition, + Timestamps(teleport: 6), + previousTeleportSequence: 5); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Rejected, status); + Assert.Equal(positionBefore, controller.Position); + Assert.Empty(gameActions); + AssertConverged(started.Runtime); + } + + [Fact] + public void Committed_MovesTheBodyPreservesHeadingAndAcksExactlyOnceAfterCommit() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + Quaternion headingBeforeCorrection = controller.BodyOrientation; + + var corrected = new Vector3(30f, 32f, 5f); + WorldSession.EntityPositionUpdate wire = ForceUpdate(corrected); + // A deliberately different wire rotation than the controller's own + // heading, exactly like a real server correction carries whatever + // heading it last observed — proves the seam never applies a SECOND + // heading substitution on top of the one the upstream merge already + // performed (contract §1c: RuntimeEntityObjectLifetime.TryApplyPosition's + // forcePositionRotation argument, exercised for real below via + // MergeAccepted). + wire = wire with + { + Position = wire.Position with + { + RotationX = 0f, + RotationY = 0f, + RotationZ = 1f, + RotationW = 0f, + }, + }; + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, wire); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedLocalPosition( + record, + wire, + disposition, + timestamps, + timestamps.PreviousTeleport); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status); + Assert.Equal(corrected, controller.Position); + Assert.Equal(headingBeforeCorrection, controller.BodyOrientation); + Assert.Single(gameActions); + AssertConverged(runtime); + } + + [Fact] + public void Contention_WhenTheEntityAlreadyOwnsAnActiveOperation() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + Vector3 positionBefore = controller.Position; + WorldSession.EntityPositionUpdate wire = + ForceUpdate(new Vector3(30f, 32f, 5f)); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, wire); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + // Mirrors the displaced-authority scenario the deleted + // LocalForcePositionTransaction's trailing isCurrent() covered: some + // OTHER operation is already in flight for this exact entity (e.g. a + // remote-authoritative resolve mid-transaction) when the + // ForcePosition arrives. + RuntimeEntityPlacementToken displaced = runtime.EntityObjects.Physics + .SetPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(displaced.IsValid); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedLocalPosition( + record, + wire, + disposition, + timestamps, + timestamps.PreviousTeleport); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Contention, status); + Assert.Equal(positionBefore, controller.Position); + Assert.Empty(gameActions); + + // Cleanup: release the displaced operation so the fixture converges. + RuntimePlacementCancellationReceipt cancellation = runtime.EntityObjects + .Physics.SetPosition.ForgetExactPlacement(displaced); + if (cancellation.IsValid) + { + runtime.EntityObjects.Physics.SetPosition + .PublishCancellation(cancellation); + } + } + + [Fact] + public void DeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakes() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + // A destination landblock whose collision generation was never + // committed (mirrors RuntimeSetPositionStateTests' CrossLandblockRequest + // pattern — re-admitting an ALREADY-ready landblock through this + // simple Begin/CommitCollisionGeneration pair does not block a new + // placement; only a genuinely fresh landblock does) parks the + // operation instead of committing it. Runtime's world-frame + // resolution is arithmetic once ANY frame is observed + // (RuntimePhysicsState.TryGetWorldFrameOffset:598-617), so this + // parks on collision readiness specifically, not on an unresolved + // frame. + const uint deferredLandblock = 0x02020000u; + var deferredPosition = new Vector3(10f, 10f, SpawnHeight); + WorldSession.EntityPositionUpdate parkedUpdate = ForceUpdate( + deferredPosition, + landblockId: deferredLandblock | 0x0001u); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, parkedUpdate); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + RuntimeAcceptedPositionExecutionStatus parked = + drive.TryExecuteAcceptedLocalPosition( + record, + parkedUpdate, + disposition, + timestamps, + timestamps.PreviousTeleport); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.DeferredCell, parked); + Assert.Empty(gameActions); + Assert.Equal(1, drive.PendingCount); + + // Advance() before the destination is ready makes no progress. + drive.Advance(); + Assert.Empty(gameActions); + Assert.Equal(1, drive.PendingCount); + + CommitLandblockCollision(runtime, deferredLandblock); + // Production always has a LIVE host subscription + // (RuntimePlacementProjectionSubscription) that applies-and- + // acknowledges the resubmitted Place synchronously the instant + // RetryDeferred's CommitCanonical publishes it — that is what + // promotes this controller's own Watch into an observable + // acknowledged completion (RuntimeSetPositionState.AcknowledgeProjection's + // Place branch). This bare-Runtime fixture has no host wired, so it + // stands in for that subscription exactly like + // RuntimeLiveEntitySessionControllerTests' own DrainPlacementFifo. + DrainPlacementFifo(runtime); + + // A single Advance() pump resolves the deferred commit. + drive.Advance(); + + Assert.Equal(0, drive.PendingCount); + // World position, not the cell-local wire position: the deferred + // landblock (0x0202) sits one landblock diagonally from the spawn + // landblock (0x0101) in Runtime's world frame, a (192, 192, 0) m + // offset (RuntimePhysicsState.TryGetWorldFrameOffset). + Assert.Equal(deferredPosition + new Vector3(192f, 192f, 0f), controller.Position); + // R8 review fix (2026-08-03): the ack is gated on retail's own + // independent CanSendPositionEvent requirement (Contact + OnWalkable + // — PlayerMovementController.cs:1517). Measured directly: this + // synthetic cross-landblock jump resolves with InContact=false in + // this bare-Runtime fixture (no subsequent physics tick runs here to + // sweep the body onto the terrain it was placed exactly tangent to — + // unlike the WITHIN-landblock move + // Committed_MovesTheBodyPreservesHeadingAndAcksExactlyOnceAfterCommit + // exercises, which measures Contact=true and DOES get its ack). This + // test therefore does NOT verify the single-ack-after-wake sequence + // end to end — it only proves the non-double-ack invariant below. + // Asserting exactly one ack here would require driving a real + // physics tick after the wake to establish ground contact, which is + // out of this fixture's scope; do not report this sequence as + // ack-verified (docs/ISSUES.md #285) until a harness does that. + int acksAfterFirstResolve = gameActions.Count; + + // Further pumps are no-ops — the ack must never fire twice, however + // many times Advance() is pumped (still true wants Contact to + // eventually flip and the ack to fire exactly once, whenever that + // happens). + drive.Advance(); + drive.Advance(); + Assert.Equal(acksAfterFirstResolve, gameActions.Count); + AssertConverged(runtime); + } + + /// + /// Round 2 unified rule, branch 1 of 3 — EQUAL. The parked + /// operation dies (here through the exact + /// RuntimeSetPositionState.Forget funnel every mid-session + /// cancellation shares: supersession, the lost-cell deadline, + /// ParkCollisionResidents, a generation change) while NO newer + /// accepted position has taken the authority, so the record's + /// PositionAuthorityVersion still equals the dead operation's own. + /// Nothing is outstanding: _pending clears, and the drive must NOT + /// re-issue. Re-issuing on an unchanged authority is precisely the shape + /// that produced N1's second placement + second ack. + /// + [Fact] + public void Equal_ClearsPendingWithoutReissuingWhenNoNewerAcceptedAuthorityArrived() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + const uint deferredLandblock = 0x02020000u; + var deferredPosition = new Vector3(10f, 10f, SpawnHeight); + WorldSession.EntityPositionUpdate parkedUpdate = ForceUpdate( + deferredPosition, + landblockId: deferredLandblock | 0x0001u); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, parkedUpdate); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + RuntimeAcceptedPositionExecutionStatus parked = + drive.TryExecuteAcceptedLocalPosition( + record, + parkedUpdate, + disposition, + timestamps, + timestamps.PreviousTeleport); + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.DeferredCell, parked); + Assert.Equal(1, drive.PendingCount); + ulong authorityAtPark = record.PositionAuthorityVersion; + Vector3 positionAtPark = controller.Position; + + RuntimePlacementCancellationReceipt cancellation = + runtime.EntityObjects.Physics.SetPosition.Forget(record); + if (cancellation.IsValid) + { + runtime.EntityObjects.Physics.SetPosition + .PublishCancellation(cancellation); + } + + // The precondition this branch is defined by: a bare Forget cancels + // the operation WITHOUT merging anything, so the accepted authority + // has not moved. + Assert.Equal(authorityAtPark, record.PositionAuthorityVersion); + + drive.Advance(); + + // No re-issue: a re-issue against this still-unready destination would + // park AGAIN and leave PendingCount at 1 (that is exactly what the + // round-1 shape did here). + Assert.Equal(0, drive.PendingCount); + Assert.Equal(positionAtPark, controller.Position); + // Round 3 (2026-08-03): the packet's placement WAS begun and then + // died without committing, so retail's unconditional position event is + // owed and goes out here carrying the unchanged pose + // (SmartBox::BlipPlayer @0x00453940 discards SetPositionSimple's + // error; SmartBox::HandleReceivedPosition @0x00453FD0 acks at + // @0x00454091 regardless). Before round 3 this asserted Empty, which + // encoded the defect: neither the body moved NOR an ack left. + Assert.Single(gameActions); + + // Repeated pumps stay silent — the funnel cleared, it did not park a + // retry marker for an authority nothing is waiting on, and retail + // never retries a force that failed. + drive.Advance(); + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + Assert.Single(gameActions); + Assert.Equal(positionAtPark, controller.Position); + AssertConverged(runtime); + } + + /// + /// Round 2 unified rule, branch 2 of 3 — ADVANCED, newest accepted + /// event is still a ForcePosition; also the B1 regression. Exact + /// production shape: a park wakes and its Place is ACCEPTED, so the + /// operation leaves _operations but its completion is RETAINED; + /// Forget then early-returns + /// (RuntimeSetPositionState.CancelCoreDeferred's + /// _operations.Remove guard) and the retained completion survives + /// the next packet's merge. That next ForcePosition therefore cannot + /// begin (HasRetainedCompletionContention) — and before + /// the unified funnel its correction was lost outright, because the next + /// pump consumed the OLD completion and acked the OLD pose with nothing + /// left pointing at the new one. The one-frame window is real on the + /// graphical host, whose inbound dispatch precedes RetryPending. + /// + [Fact] + public void Advanced_ReissuesWhenTheNewestAcceptedEventIsStillAForcePosition() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + const uint deferredLandblock = 0x02020000u; + var firstCorrection = new Vector3(10f, 10f, SpawnHeight); + WorldSession.EntityPositionUpdate parkedUpdate = ForceUpdate( + firstCorrection, + landblockId: deferredLandblock | 0x0001u); + (PositionTimestampDisposition parkedDisposition, + AcceptedPhysicsTimestamps parkedTimestamps) = + MergeAccepted(runtime, controller, parkedUpdate); + Assert.Equal( + PositionTimestampDisposition.ForcePosition, parkedDisposition); + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + drive.TryExecuteAcceptedLocalPosition( + record, + parkedUpdate, + parkedDisposition, + parkedTimestamps, + parkedTimestamps.PreviousTeleport)); + + // The park wakes and its Place is accepted, leaving a RETAINED + // completion. Deliberately no Advance() yet: this is the host frame in + // which the next packet arrives before the pump runs. + CommitLandblockCollision(runtime, deferredLandblock); + DrainPlacementFifo(runtime); + + var secondCorrection = new Vector3(14f, 12f, SpawnHeight); + WorldSession.EntityPositionUpdate secondUpdate = ForceUpdate( + secondCorrection, + landblockId: deferredLandblock | 0x0001u, + positionSequence: 3, + forcePositionSequence: 2); + (PositionTimestampDisposition secondDisposition, + AcceptedPhysicsTimestamps secondTimestamps) = + MergeAccepted(runtime, controller, secondUpdate); + Assert.Equal( + PositionTimestampDisposition.ForcePosition, secondDisposition); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.Contention, + drive.TryExecuteAcceptedLocalPosition( + record, + secondUpdate, + secondDisposition, + secondTimestamps, + secondTimestamps.PreviousTeleport)); + + // One pump: consume the older completion (its own reconcile + ack), + // then re-issue the newest accepted force — which the funnel proves by + // landing the body on the SECOND correction, not the first. + drive.Advance(); + + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + secondCorrection + new Vector3(192f, 192f, 0f), + controller.Position); + // Round 3 (2026-08-03): the former `gameActions.Count <= 2` assertion + // was deleted here. It could not fail: this fixture's + // CommitLandblockCollision adds BOTH landblocks at worldOffsetX/Y 0f + // while Runtime's world frame places the deferred landblock at + // +192/+192, so the body lands over no terrain, resolves with + // InContact=false, and retail's own CanSendPositionEvent gate + // suppresses every ack — gameActions.Count is 0 here. It was also far + // too loose to encode "at most one per packet". The real + // discriminators (body landed on the SECOND correction, PendingCount) + // remain; the ack-count contract is pinned by + // TerminalWithoutCommit_SendsExactlyOnePositionEventAndLeavesTheBodyUnmoved + // and Committed_SendsExactlyOnePositionEventAcrossTheCommitAndTheSettle, + // whose fixtures genuinely satisfy the contact gate. + + // Further pumps are no-ops: the funnel settled on the Equal branch. + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + secondCorrection + new Vector3(192f, 192f, 0f), + controller.Position); + AssertConverged(runtime); + } + + /// + /// Round 2 unified rule, branch 3 of 3 — ADVANCED, newest accepted + /// event is an ordinary Apply (N2). The park is cancelled by the + /// ordinary echo's own merge-time Forget, and that echo — not the + /// force — now owns the accepted pose. Re-issuing here would apply the + /// force route's Teleport|Slide flags to an ordinary pose, send an + /// ack retail never sends on that branch, and skip the ConstrainTo + /// the ordinary branch runs + /// (RuntimeAuthoritativePositionRouteClassifier.cs:368-388). So the + /// funnel must clear without placing and without acking. + /// + [Fact] + public void Advanced_DoesNotReissueWhenTheNewestAcceptedEventIsAnOrdinaryApply() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + const uint deferredLandblock = 0x02020000u; + WorldSession.EntityPositionUpdate parkedUpdate = ForceUpdate( + new Vector3(10f, 10f, SpawnHeight), + landblockId: deferredLandblock | 0x0001u); + (PositionTimestampDisposition parkedDisposition, + AcceptedPhysicsTimestamps parkedTimestamps) = + MergeAccepted(runtime, controller, parkedUpdate); + Assert.Equal( + PositionTimestampDisposition.ForcePosition, parkedDisposition); + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + drive.TryExecuteAcceptedLocalPosition( + record, + parkedUpdate, + parkedDisposition, + parkedTimestamps, + parkedTimestamps.PreviousTeleport)); + Assert.Equal(1, drive.PendingCount); + ulong authorityAtPark = record.PositionAuthorityVersion; + Vector3 positionAtPark = controller.Position; + + // ACE's next ordinary broadcast, ~100-200 ms later. It merges (which + // Forgets the park and advances the accepted authority) and is NEVER + // dispatched to this route — both hosts gate the dispatch on + // ForcePosition. + var ordinaryPose = new Vector3(31f, 33f, SpawnHeight); + (PositionTimestampDisposition ordinaryDisposition, _) = MergeAccepted( + runtime, + controller, + OrdinaryUpdate(ordinaryPose, positionSequence: 3)); + Assert.Equal(PositionTimestampDisposition.Apply, ordinaryDisposition); + Assert.NotEqual(authorityAtPark, record.PositionAuthorityVersion); + + drive.Advance(); + + Assert.Equal(0, drive.PendingCount); + // The force route never ran again: the body is exactly where the + // cancelled park left it, and specifically NOT on the ordinary echo's + // pose — which is what a re-issue would have placed it on, with the + // force route's Teleport|Slide flags and no ConstrainTo. + Assert.Equal(positionAtPark, controller.Position); + Assert.NotEqual(ordinaryPose, controller.Position); + // Exactly ONE AutonomousPosition, and it belongs to the FORCE packet, + // not the ordinary echo: the force's placement was begun and died + // without committing, which retail still acknowledges + // (SmartBox::HandleReceivedPosition @0x00453FD0 acks @0x00454091 with + // whatever SetPositionSimple left the body at). The ordinary echo + // never reaches this route at all — retail's ordinary branch has no + // unconditional SendPositionEvent. Round 3 (2026-08-03) corrected this + // from Empty, which encoded the lost-ack defect. + Assert.Single(gameActions); + + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + Assert.Equal(positionAtPark, controller.Position); + Assert.Single(gameActions); + AssertConverged(runtime); + } + + /// + /// N1 regression (2026-08-03): ONE server correction must produce EXACTLY + /// one canonical placement and EXACTLY one outbound + /// AutonomousPosition — never two. The round-1 shape left a dead + /// _pending entry behind whenever a fresh packet's own placement + /// committed while an older park was still tracked, and the next pump + /// re-issued from that dead entry: a second placement and a second ack for + /// a single correction, which is the exact duplicate-authority class + /// 670f307c deleted and this whole slice exists to remove. + /// + [Fact] + public void OneServerCorrectionProducesExactlyOnePlacementAndOneAck() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + // An earlier force parks on a landblock whose collision is not ready. + const uint deferredLandblock = 0x02020000u; + WorldSession.EntityPositionUpdate parkedUpdate = ForceUpdate( + new Vector3(10f, 10f, SpawnHeight), + landblockId: deferredLandblock | 0x0001u); + (PositionTimestampDisposition parkedDisposition, + AcceptedPhysicsTimestamps parkedTimestamps) = + MergeAccepted(runtime, controller, parkedUpdate); + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + drive.TryExecuteAcceptedLocalPosition( + record, + parkedUpdate, + parkedDisposition, + parkedTimestamps, + parkedTimestamps.PreviousTeleport)); + Assert.Equal(1, drive.PendingCount); + Assert.Empty(gameActions); + + // THE one server correction under test: a second force, into the + // already-ready spawn landblock, so it commits synchronously. Its + // merge Forgets the park. + var corrected = new Vector3(30f, 32f, SpawnHeight); + WorldSession.EntityPositionUpdate correction = ForceUpdate( + corrected, + positionSequence: 3, + forcePositionSequence: 2); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, correction); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.Committed, + drive.TryExecuteAcceptedLocalPosition( + record, + correction, + disposition, + timestamps, + timestamps.PreviousTeleport)); + + Assert.Equal(0, drive.PendingCount); + Assert.Single(gameActions); + Assert.Equal(corrected, controller.Position); + ulong placementsAfterCorrection = record.PlacementCommitVersion; + + // Every subsequent pump must be a no-op. Before the fix the FIRST of + // these re-issued the dead park entry: PlacementCommitVersion advanced + // again and a SECOND AutonomousPosition left for the same correction. + drive.Advance(); + drive.Advance(); + + Assert.Equal(0, drive.PendingCount); + Assert.Single(gameActions); + Assert.Equal(corrected, controller.Position); + Assert.Equal(placementsAfterCorrection, record.PlacementCommitVersion); + AssertConverged(runtime); + } + + /// + /// Round 3 blocker (2026-08-03) — the terminal-without-commit ack. + /// Retail attempts the placement, and if it fails the body simply does not + /// move — but the packet is acknowledged regardless and never retried: + /// SmartBox::BlipPlayer @0x00453940 calls + /// CPhysicsObj::SetPositionSimple @0x005162B0, which returns an + /// enum SetPositionError that other retail sites test + /// (== OK_SPE @0x0055605D) and that BlipPlayer DISCARDS; + /// BlipPlayer returns void, and its caller + /// SmartBox::HandleReceivedPosition @0x00453FD0 then runs + /// cmdinterp->SendPositionEvent() @0x00454091 unconditionally + /// before returning @0x0045409D. + /// + /// Unlike + /// + /// (whose subject is the no-re-issue decision) this test's subject is the + /// ack contract itself: EXACTLY one outbound AutonomousPosition for + /// the failed packet, carrying whatever pose the retired operation left + /// behind, with no further placement performed by the settle. Before round + /// 3 this path sent zero — the correction never took AND the server was + /// never told it had not taken. + /// + [Fact] + public void TerminalWithoutCommit_SendsExactlyOnePositionEventAndLeavesTheBodyUnmoved() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + // The body sits on the spawn landblock's flat terrain, so retail's own + // CanSendPositionEvent admission (Contact + OnWalkable) is genuinely + // satisfied — this fixture can observe the ack rather than silently + // measuring a suppression. + Assert.True(controller.CanSendPositionEvent); + + // A force whose destination landblock has no published collision + // generation parks instead of committing. + const uint deferredLandblock = 0x02020000u; + WorldSession.EntityPositionUpdate correction = ForceUpdate( + new Vector3(10f, 10f, SpawnHeight), + landblockId: deferredLandblock | 0x0001u); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, correction); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + drive.TryExecuteAcceptedLocalPosition( + record, + correction, + disposition, + timestamps, + timestamps.PreviousTeleport)); + + // Measured, not assumed: this park happens AFTER + // `_physics.Engine.SetPosition` has already run, so the canonical body + // already sits at the destination while the placement itself is + // withdrawn and parked. Specifically it is the engine-result deferral + // (RuntimeSetPositionState.cs, the park taken once `operation.Result` + // has been assigned) — NOT either `TryGetBlockingQuiescence` branch: + // `_collisionPrefixQuiescence` is populated only by + // `BeginCollisionPrefixQuiescence`, and this fixture's + // `CommitLandblockCollision` calls `BeginCollisionGeneration` instead, + // so that map is empty and both quiescence branches are unreachable + // here. Same side of the engine call either way; the distinction only + // matters so a future reader does not go looking in the wrong branch. + // The subject of this test is the TERMINAL SETTLE, so the + // unmoved/no-further-placement baselines are captured here, at the park. + Vector3 poseAtPark = controller.Position; + ulong placementVersionAtPark = record.PlacementCommitVersion; + + // Nothing has been acknowledged yet: retail's ack follows BlipPlayer, + // and the placement has not reached any terminal outcome. + Assert.Empty(gameActions); + + // The park is retired without ever committing — the exact + // RuntimeSetPositionState.Forget funnel every mid-session cancellation + // shares (supersession, the lost-cell deadline, ParkCollisionResidents, + // a generation change). + RuntimePlacementCancellationReceipt cancellation = + runtime.EntityObjects.Physics.SetPosition.Forget(record); + if (cancellation.IsValid) + { + runtime.EntityObjects.Physics.SetPosition + .PublishCancellation(cancellation); + } + + drive.Advance(); + + // Retail's two simultaneous facts at @0x0045409D: the placement did + // not take (the settle performs no placement of its own — the body is + // exactly where the retired operation left it and no further + // placement committed), and the position event went out anyway, + // carrying that unchanged pose. Before round 3 this path sent zero. + Assert.Equal(poseAtPark, controller.Position); + Assert.Equal(placementVersionAtPark, record.PlacementCommitVersion); + Assert.Single(gameActions); + Assert.Equal(0, drive.PendingCount); + + // Retail never retries a force whose placement failed, so no further + // pump may place OR acknowledge anything more for this packet. + drive.Advance(); + drive.Advance(); + Assert.Single(gameActions); + Assert.Equal(poseAtPark, controller.Position); + Assert.Equal(placementVersionAtPark, record.PlacementCommitVersion); + AssertConverged(runtime); + } + + /// + /// Round 3 companion invariant (2026-08-03): the terminal-without-commit + /// ack must not become a SECOND ack on the committed path. A commit runs + /// ReconcileAndAcknowledge and then immediately settles through the + /// same funnel, so a funnel that acknowledged unconditionally would send + /// two AutonomousPosition messages for one server correction — the + /// exact duplicate-ack class 670f307c deleted. Retail sends exactly + /// one per handled packet (SmartBox::HandleReceivedPosition + /// @0x00453FD0 reaches SendPositionEvent @0x00454091 once, then + /// returns @0x0045409D). + /// + [Fact] + public void Committed_SendsExactlyOnePositionEventAcrossTheCommitAndTheSettle() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + // Within the already-published spawn landblock, so the placement + // commits synchronously AND the contact gate admits the ack. + var corrected = new Vector3(30f, 32f, SpawnHeight); + WorldSession.EntityPositionUpdate correction = ForceUpdate(corrected); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, correction); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.Committed, + drive.TryExecuteAcceptedLocalPosition( + record, + correction, + disposition, + timestamps, + timestamps.PreviousTeleport)); + + // One commit, one ack — not two. The settle that runs immediately + // after the commit must recognise the ack already left. + Assert.Equal(corrected, controller.Position); + Assert.Single(gameActions); + Assert.Equal(0, drive.PendingCount); + + drive.Advance(); + drive.Advance(); + Assert.Single(gameActions); + AssertConverged(runtime); + } + + private static void AssertConverged(GameRuntime runtime) + { + RuntimeEntityObjectOwnershipSnapshot ownership = + runtime.EntityObjects.CaptureOwnership(); + Assert.Equal(0, ownership.AcceptedPositionDrivePendingCount); + } + + /// + /// Performs the SAME upstream merge production runs BEFORE this route + /// ever sees a Position + /// (, called by + /// LiveEntityInboundAuthorityGate.TryAcceptPosition in App and + /// directly in RuntimeLiveEntitySessionController.OnPositionUpdated + /// in headless) — this is what actually admits the disposition via the + /// REAL PhysicsTimestampGate and substitutes the controller's + /// current heading into record.Snapshot for a ForcePosition + /// (contract §1c), rather than hand-rolling a disposition/timestamps pair + /// the seam's caller could never actually observe. + /// + private static (PositionTimestampDisposition Disposition, AcceptedPhysicsTimestamps Timestamps) + MergeAccepted( + GameRuntime runtime, + PlayerMovementController controller, + in WorldSession.EntityPositionUpdate update) + { + Assert.True(runtime.EntityObjects.TryApplyPosition( + update, + isLocalPlayer: true, + forcePositionRotation: controller.BodyOrientation, + currentLocalVelocity: controller.BodyVelocity, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + return (disposition, timestamps); + } + + /// + /// A wire Position whose FORCE_POSITION_TS strictly advances, so the REAL + /// admits it as + /// (retail + /// SmartBox::HandleReceivedPosition @0x00453FD0's FORCE_POSITION + /// branch: fresh FORCE_POSITION_TS with an exactly-equal TELEPORT_TS). + /// The spawn seeds FORCE_POSITION_TS 0 / POSITION_TS 1, so the defaults + /// are the FIRST such packet; a session's SECOND force must raise + /// again. + /// + private static WorldSession.EntityPositionUpdate ForceUpdate( + Vector3 position, + uint landblockId = SpawnLandblock | 0x0001u, + ushort positionSequence = 2, + ushort forcePositionSequence = 1) => + new( + PlayerGuid, + new CreateObject.ServerPosition( + landblockId, + position.X, + position.Y, + position.Z, + 1f, + 0f, + 0f, + 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: 0, + ForcePositionSequence: forcePositionSequence); + + /// + /// An ORDINARY server position echo: FORCE_POSITION_TS does NOT advance, + /// POSITION_TS strictly does, so the same real gate admits it as + /// . Neither host ever + /// dispatches this disposition to the drive — it merges (advancing + /// PositionAuthorityVersion and Forgetting any in-flight placement) + /// entirely behind the drive's back, which is exactly what makes it the + /// N2 case. + /// + private static WorldSession.EntityPositionUpdate OrdinaryUpdate( + Vector3 position, + ushort positionSequence, + ushort forcePositionSequence = 1, + uint landblockId = SpawnLandblock | 0x0001u) => + ForceUpdate( + position, + landblockId, + positionSequence, + forcePositionSequence); + + private static AcceptedPhysicsTimestamps Timestamps(ushort teleport) => + new( + Instance: 1, + ServerControlledMove: 1, + Teleport: teleport, + ForcePosition: 1, + TeleportAdvanced: false, + TeleportHookRequired: false, + PreviousTeleport: teleport); + + /// + /// Spawns the local player, drives its initial-Create residence to + /// completion, and returns the resulting canonical record + live + /// controller — the fixture every test above needs before a + /// ForcePosition can be route-2-eligible at all. + /// + private static (RuntimeEntityRecord Record, PlayerMovementController Controller) + EnterLocalPlayer(GameRuntime runtime) + { + runtime.PlayerIdentity.ServerGuid = PlayerGuid; + CommitLandblockCollision(runtime, SpawnLandblock); + RuntimeFirstEntryDriveController firstEntry = CreateFirstEntryDrive(runtime); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var sessionController = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection(firstEntry)); + LiveEntitySessionSink sink = sessionController.CreateSink(); + + sink.Spawned(Spawn(PlayerGuid)); + DrainFirstEntry(runtime, firstEntry); + + RuntimeEntityRecord record = Assert.IsType( + GetActive(runtime, PlayerGuid)); + PlayerMovementController controller = Assert.IsType( + runtime.MovementOwner.Controller); + return (record, controller); + } + + private static RuntimeEntityRecord GetActive(GameRuntime runtime, uint guid) + { + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + guid, out RuntimeEntityRecord record)); + return record; + } + + /// + /// Constructs the drive controller with a LIVE (deliberately never + /// disposed within this factory — it must outlive the whole test) + /// fixture whose captured outbound game + /// actions the caller can assert against. + /// + private static RuntimeAcceptedPositionDriveController CreateAcceptedPositionDrive( + GameRuntime runtime, + out List gameActions) + { + var captured = new List(); + gameActions = captured; + var liveSession = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9001), + new FixtureTransport()) + { + GameActionCapture = body => captured.Add(body), + }; + return new RuntimeAcceptedPositionDriveController( + runtime.EntityObjects, + runtime.Clock, + new UnusedCollisionSource(), + new LocalPlayerOutboundController((_, _, _, _, _, _) => { }), + () => runtime.Generation, + () => runtime.PlayerIdentity.ServerGuid, + () => runtime.MovementOwner.Controller, + () => runtime.CharacterOwner.UsePositionFromServer, + () => liveSession); + } + + /// + /// A flat landblock whose terrain surface sits exactly at + /// — every fixture position in this file uses + /// that Z so CommitCanonical's contact resolve genuinely finds + /// ground (retail CommandInterpreter::SendPositionEvent's own + /// admission gate requires Contact+OnWalkable — + /// PlayerMovementController.CanSendPositionEvent — so an airborne + /// fixture body would silently suppress every ack this suite asserts). + /// + private static void CommitLandblockCollision( + GameRuntime runtime, + uint landblockId) + { + // Mirrors HeadlessSessionHostTests.AddFlatLandblock's exact + // proven-working shape (every heightmap byte and every table entry + // participate) rather than a sparse table — a resolve near a + // landblock edge samples neighbouring grid entries too. + var heights = new byte[81]; + Array.Fill(heights, (byte)SpawnHeight); + var heightTable = new float[256]; + for (int index = 0; index < heightTable.Length; index++) + heightTable[index] = index; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + landblockId, 1UL); + runtime.EntityObjects.Physics.Engine.AddLandblock( + landblockId, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + landblockId, 1UL, ready: true); + runtime.EntityObjects.Physics.ObserveLocalWorldFrame( + landblockId | 0x0001u, + teleportAdvanced: false); + } + + private static RuntimeFirstEntryDriveController CreateFirstEntryDrive( + GameRuntime runtime) => + new( + runtime.EntityObjects, + runtime.Clock, + new UnusedCollisionSource(), + () => PlayerMovementConstructionOptions.Fallback, + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + + private static void DrainFirstEntry( + GameRuntime runtime, + RuntimeFirstEntryDriveController drive) + { + for (int attempt = 0; attempt < 8 && drive.PendingCount != 0; attempt++) + { + drive.DriveAll(); + DrainPlacementFifo(runtime); + } + Assert.Equal(0, drive.PendingCount); + } + + private static void DrainPlacementFifo(GameRuntime runtime) + { + while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!runtime.EntityObjects.Physics.SetPosition + .AcknowledgeProjection(head.Token)) + { + break; + } + } + } + + private static WorldSession.EntitySpawn Spawn(uint guid) + { + var position = new CreateObject.ServerPosition( + SpawnLandblock | 0x0001u, + 10f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: null, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + null, + [], + [], + [], + null, + null, + "direct entity", + null, + null, + null, + PhysicsState: physics.RawState, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class UnusedCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + FlatSetupCollision>.Missing; + + public AcDream.Content.PreparedCollisionReadResult< + FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatCellStructureCollisionAsset> ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + + private sealed class FixtureTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) + { + } + + public void Send( + IPEndPoint remote, + ReadOnlySpan datagram) + { + } + + public int Receive( + Span destination, + TimeSpan timeout, + out IPEndPoint? from) + { + from = null; + return -1; + } + + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + ValueTask.FromException( + new OperationCanceledException(cancellationToken)); + + public void Dispose() + { + } + } + + private sealed class FixtureWorldProjection : IRuntimeDirectWorldProjection + { + private readonly RuntimeFirstEntryDriveController _firstEntry; + + internal FixtureWorldProjection(RuntimeFirstEntryDriveController firstEntry) => + _firstEntry = firstEntry; + + public void ProjectSpawn(RuntimeEntityRecord record, bool isLocalPlayer) => + _firstEntry.DriveAll(); + + public void ProjectPosition( + RuntimeEntityRecord record, + bool isLocalPlayer, + PositionTimestampDisposition disposition) + { + } + + public void CenterOnAcceptedForcePosition(RuntimeEntityRecord record) + { + } + + public void BeginTeleport() + { + } + + public RuntimeDestinationReadiness PrepareDestination( + long revealGeneration, + RuntimeTeleportDestination destination) => + new( + revealGeneration, + destination.CellId, + IsIndoor: false, + IsUnhydratable: false, + RequiredRenderRadius: 1, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true); + } + + /// + /// C3c: initial-residence admission requires a live session generation + /// (RuntimeInitialCreateResidenceState.CanAcceptCreate), so this test + /// starts one through the same fixture-session shape + /// DirectGameRuntimeCommandAdapterTests / RuntimeLiveEntitySessionControllerTests use. + /// + private sealed class StartedRuntime : IDisposable + { + internal required GameRuntime Runtime { get; init; } + internal required LiveSessionHost Live { get; init; } + + public void Dispose() + { + _ = Live.Stop(Runtime.Generation); + Runtime.Dispose(); + } + } + + private static StartedRuntime StartRuntime() + { + var operations = new FixtureGameplayOperations(); + var sessionOperations = new FixtureSessionOperations(); + var runtime = new GameRuntime(new GameRuntimeDependencies( + operations, + operations, + operations, + operations, + SessionOperations: sessionOperations)); + operations.Bind(runtime); + var resetHost = new FixtureResetHost(); + var options = new LiveSessionConnectOptions( + true, + "127.0.0.1", + 9000, + "account", + "password"); + var live = new LiveSessionHost( + runtime.Session, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + _ => new FixtureEventRoute(), + _ => new FixtureCommandRoute()), + generation => runtime.ResetGeneration(generation, resetHost), + new LiveSessionSelectionBindings( + id => runtime.PlayerIdentity.ServerGuid = id, + _ => { }, + runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, + _ => { }, + _ => { }, + runtime.ActionOwner.Combat.Clear), + new LiveSessionEnteredWorldBindings( + _ => { }, + () => { }, + () => { }, + _ => { }, + () => { }), + (_, _, _) => { }, + () => { }), + options); + LiveSessionStartResult startResult = live.Start(options); + Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); + Assert.NotEqual(0UL, runtime.Generation.Value); + return new StartedRuntime { Runtime = runtime, Live = live }; + } + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) => + new(endpoint, new FixtureTransport()); + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed GetCharacters(WorldSession session) => + new( + 0u, + [new CharacterList.Character(PlayerGuid, "Direct", 0u)], + [], + 11, + "account", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => + session.Dispose(); + } + + private sealed class FixtureEventRoute : ILiveSessionEventRouting + { + public void Attach() + { + } + + public void Dispose() + { + } + } + + private sealed class FixtureCommandRoute : ILiveSessionCommandRouting + { + public void Activate() + { + } + + public void Dispose() + { + } + } + + private sealed class FixtureResetHost : IRuntimeGenerationResetHost + { + public void RetireEntityProjection(RuntimeEntityRecord entity) + { + } + + public void DrainEntityProjectionBoundary() + { + } + + public void CompleteEntityProjectionRetirement() + { + } + } + + private sealed class FixtureGameplayOperations + : IRuntimeCombatAttackOperations, + IRuntimeCombatTargetOperations, + IRuntimeCombatModeOperations, + IRuntimeSpellCastOperations + { + private GameRuntime? _runtime; + + public void Bind(GameRuntime runtime) => _runtime = runtime; + public bool CanStartAttack() => false; + public void PrepareAttackRequest() + { + } + + public bool SendAttack(AttackHeight height, float power) => false; + public void SendCancelAttack() + { + } + + public bool IsDualWield => false; + public bool PlayerReadyForAttack => false; + public bool AutoRepeatAttack => false; + public bool AutoTarget => false; + public uint? SelectClosestTarget() => null; + public bool IsInWorld => _runtime?.Session.IsInWorld == true; + public IReadOnlyList GetOrderedEquipment() => []; + public void NotifyExplicitCombatModeRequest() + { + } + + public void SendChangeCombatMode(CombatMode mode) + { + } + + public uint LocalPlayerId => + _runtime?.PlayerIdentity.ServerGuid ?? 0u; + public bool CanSend => false; + public bool HasRequiredComponents(uint spellId) => false; + + public bool IsTargetCompatible( + uint targetId, + SpellMetadata spell, + bool showMessage) => false; + + public void StopCompletely() + { + } + + public void SendUntargeted(uint spellId) + { + } + + public void SendTargeted(uint targetId, uint spellId) + { + } + + public void DisplayMessage(string message) + { + } + + public void IncrementBusy() + { + } + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index d30b6a9e..8bbe3632 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -246,6 +246,73 @@ public sealed class RuntimeLiveEntitySessionControllerTests Assert.True(runtime.Portal.Snapshot.Completed); } + /// + /// R2/R3 review fix (2026-08-03). No accepted-position drive is supplied + /// (mirrors 's + /// "content-less" shape, applied to route 2 specifically), so + /// TryExecuteAcceptedLocalPosition can never run and every + /// ForcePosition resolves NotApplicable at the call site. Proves + /// two things the first implementation pass got wrong: (R2) + /// + /// still fires — a host is not left holding a stale collision/streaming + /// window just because the drive is momentarily absent or NotApplicable + /// — and (R3) the pre-existing ProjectPosition fallback still runs + /// for a NotApplicable result, exactly like it did before route 2 existed + /// (the "no legacy fallback to run instead" comment the review found at + /// this exact call site was false). + /// + [Fact] + public void ForcePositionWithoutAnAcceptedPositionDrive_StillCentersAndFallsBackToProjectPosition() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + const uint playerGuid = 0x50000005u; + runtime.PlayerIdentity.ServerGuid = playerGuid; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + session.GameActionCapture = _ => { }; + var projection = new FixtureWorldProjection(); + // Deliberately no acceptedPositionDrive argument — mirrors a + // content-less headless host, where the accepted-position drive is + // null and route 2 can never apply. + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: projection); + LiveEntitySessionSink sink = controller.CreateSink(); + WorldSession.EntitySpawn spawn = + Spawn(playerGuid, incarnation: 1); + + sink.Spawned(spawn); + Assert.Equal(1, projection.SpawnCount); + Assert.Equal(0, projection.CenterOnForceCount); + + sink.PositionUpdated(new WorldSession.EntityPositionUpdate( + playerGuid, + spawn.Position!.Value with + { + PositionX = 30f, + }, + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 1)); + + Assert.Equal(1, projection.CenterOnForceCount); + Assert.Equal(playerGuid, projection.LastCenteredRecord?.ServerGuid); + // The fallback ran: ProjectPosition observed this exact + // ForcePosition disposition, not just the earlier Apply-shaped spawn + // follow-up. + Assert.Equal(1, projection.PositionCount); + Assert.Equal( + PositionTimestampDisposition.ForcePosition, + projection.LastPositionDisposition); + } + /// /// C3c-R1 review F6: the drive controller outlives its session routes, /// so "session reset precedes a new route" is an asserted latch, not a @@ -674,6 +741,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests public int PositionCount { get; private set; } public int TeleportStartCount { get; private set; } public int PrepareCount { get; private set; } + public int CenterOnForceCount { get; private set; } + public RuntimeEntityRecord? LastCenteredRecord { get; private set; } public bool LastSpawnWasLocal { get; private set; } public bool LastPositionWasLocal { get; private set; } public PositionTimestampDisposition LastPositionDisposition @@ -704,6 +773,12 @@ public sealed class RuntimeLiveEntitySessionControllerTests LastPositionDisposition = disposition; } + public void CenterOnAcceptedForcePosition(RuntimeEntityRecord record) + { + CenterOnForceCount++; + LastCenteredRecord = record; + } + public void BeginTeleport() => TeleportStartCount++; public RuntimeDestinationReadiness PrepareDestination(