feat(physics): C4 route 2 — ForcePosition through the canonical placement
A local-player ForcePosition had TWO independent writers for one accepted
packet: LocalForcePositionTransaction snapped the physics body
(PlayerMovementController.BlipPosition, a raw SnapToCell with no collision
resolve), while LiveEntityNetworkUpdateController's generic tail separately
wrote position/cell/rotation to the render WorldEntity from the raw wire and
rebucketed it. Two stores, one packet — the divergence class 670f307c fixed on
the remote path. The outbound AutonomousPosition ack also fired BEFORE any
canonical commit existed: we told ACE "got it, I'm here" before deciding where
"here" was, and the trailing isCurrent() could only suppress the continuation,
never recall the packet.
RuntimeAcceptedPositionDriveController is now the one Runtime-owned seam. Both
hosts call the identical TryExecuteAcceptedLocalPosition; App and headless
project the committed result through the existing placement projection sink
(LiveEntityRuntime.TryApplyRuntimePlacementPlace already performed the same
four writes, from committed state rather than a wire guess).
Retail: SmartBox::HandleReceivedPosition @0x00453FD0's FORCE_POSITION branch is
get_heading -> Frame::set_heading -> SmartBox::BlipPlayer @0x00453940 -> stamp
POSITION_TS -> SendPositionEvent @0x00454091 -> return @0x0045409D. BlipPlayer
is CPhysicsObj::SetPositionSimple @0x005162B0 with flags 0x1012
(Teleport|Slide|SendPositionEvent) — a real collision-resolving SetPosition,
not a snap. The pinned classifier already encoded this exactly.
Named behaviour changes:
* The ack is now an OUTPUT of the committed route, fired strictly after the
canonical commit and exactly once per accepted force packet.
* The ForcePosition route no longer re-arms the constraint leash. The force
branch returns at 0x0045409D, ahead of all three ConstrainTo sites
(0x00454272, 0x0045418A, 0x004541EC); the old re-arm cited retail's "Player,
normal" branch, which BlipPlayer is not on. The teleport, CommitPreparedPosition
and first-entry callers legitimately still constrain and are untouched.
* A force correction that terminates WITHOUT committing still sends its
position event and is not retried — retail's BlipPlayer discards
SetPositionSimple's SetPositionError return and acks unconditionally.
A single _pending funnel owns the in-flight placement, deciding on the token's
PositionAuthorityVersion against the record's: equal -> clear; advanced with the
newest accepted event still a force -> re-issue, re-classified; advanced to an
ordinary Apply -> clear, since newer server truth owns that pose. This closes a
double-apply/double-ack and a silently-dropped correction that two earlier
iterations of this slice each introduced.
AD-62 records the residual: a ForcePosition our async collision publication
cannot carry to a committed placement is not re-applied. Retail has no park —
its world is fully resident and its placement synchronous — so the state is
unreachable there. AP-131 is NOT retired; its legacy Position caller is route 4.
Deleted: LocalForcePositionTransaction, PlayerMovementController.BlipPosition,
HeadlessSessionWorldProjection.BlipLocalPlayer.
Gates: complete Release solution 10,858 passed / 4 skipped / 0 failed (baseline
10,844/4/0). Two independent Opus reviews (retail-conformance and
architecture/adversarial) PASS on the final diff after three FAIL rounds; every
intermediate state was fully green, so the suite caught none of the four real
defects. Connected acceptance is NOT run: nothing a user can do makes ACE emit
a ForcePosition without retail's @pklite, which acdream does not implement — see
docs/research/2026-08-03-c4-route-2-visual-gate.md.
Known gap, recorded not claimed: the plan's acceptance item 2 is unmet. The App
double-write check is a source pin, and "the committed projection moves the
render entity" is uncovered at any layer (#292). Filed alongside: #286-#291,
#293-#296.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
22a5c95400
commit
9966b53174
25 changed files with 4292 additions and 195 deletions
306
docs/ISSUES.md
306
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
|
fired — the log shows zero `world frame is unreachable` failures and zero
|
||||||
parked placements across 9 completed reveals.
|
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 `<c>`
|
||||||
|
tags, so they are build-safe (unlike a `<see cref="..."/>`, 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
|
## C3c placement cutover — 2026-08-02
|
||||||
|
|
||||||
- **#276 — OPEN — SpawnPlacementSettler discards the settle's resolved
|
- **#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/`,
|
`jump_is_allowed`. Decomp: `docs/research/2026-07-03-r5-managers/`,
|
||||||
`docs/research/2026-07-30-constraint-leash-constants.md`.
|
`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
|
**Acceptance:** the two constants are recovered (byte-decoded from the
|
||||||
binary), acdream arms the leash on inbound server positions,
|
binary), acdream arms the leash on inbound server positions,
|
||||||
`IsFullyConstrained` fires while rubber-banding, and a jump attempt inside
|
`IsFullyConstrained` fires while rubber-banding, and a jump attempt inside
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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-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-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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,9 +252,70 @@ same commit) → docs/handoff commit. No workarounds; no fused slices.
|
||||||
4 (remote Create/Position; delete `RemoteTeleportController`/`Placement`
|
4 (remote Create/Position; delete `RemoteTeleportController`/`Placement`
|
||||||
and the inline MoveOrTeleport duplicate), 5 (projectile authoritative),
|
and the inline MoveOrTeleport duplicate), 5 (projectile authoritative),
|
||||||
6 (drops + split-recovery marking), 7 (residual pickup/parent/delete
|
6 (drops + split-recovery marking), 7 (residual pickup/parent/delete
|
||||||
polish). — OPEN at the 2026-08-03 handoff.** May land as more than one
|
polish). — route 2 implementation COMPLETE, pending review, at the
|
||||||
commit if a route proves large;
|
2026-08-03 handoff; routes 3/4/5/6/7 remain OPEN.**
|
||||||
each sub-landing keeps the full review discipline.
|
**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
|
- **C5 — legacy deletion + closeout gates — OPEN.** Delete every superseded legacy
|
||||||
path; parity tests; exact lifecycle/reconnect + canonical nine-stop
|
path; parity tests; exact lifecycle/reconnect + canonical nine-stop
|
||||||
connected routes; two-client observation; **user visual matrix** (the
|
connected routes; two-client observation; **user visual matrix** (the
|
||||||
|
|
|
||||||
291
docs/research/2026-08-03-c4-route-2-implementation-plan.md
Normal file
291
docs/research/2026-08-03-c4-route-2-implementation-plan.md
Normal file
|
|
@ -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 (<force ts is not older>)
|
||||||
|
{
|
||||||
|
get_heading(player);
|
||||||
|
Frame::set_heading(&dest, heading); // 00454068 preserve OUR heading
|
||||||
|
SmartBox::BlipPlayer(this, &dest); // 00454074
|
||||||
|
player->update_times[0] = arg7; // 00454079 stamp POSITION_TS
|
||||||
|
cmdinterp->SendPositionEvent(); // 00454091 ack
|
||||||
|
return; // 0045409d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SmartBox::BlipPlayer` @0x00453940 (line 92528) is:
|
||||||
|
|
||||||
|
```
|
||||||
|
distance = Position::distance(&player->m_position, dest);
|
||||||
|
CPhysicsObj::SetPositionSimple(player, dest, 1); // 00453968
|
||||||
|
SmartBox::PlayerPositionUpdated(this, 0, distance); // 00453976
|
||||||
|
```
|
||||||
|
|
||||||
|
`CPhysicsObj::SetPositionSimple` @0x005162B0 (line 284276) with `arg3 != 0`
|
||||||
|
builds `SetPositionStruct` with flags **`0x1012`** and calls
|
||||||
|
`CPhysicsObj::SetPosition`. `0x1012` decodes against
|
||||||
|
`src/AcDream.Core/Physics/PhysicsSetPosition.cs:63-76` as
|
||||||
|
`Teleport(0x002) | Slide(0x010) | SendPositionEvent(0x1000)` — byte-for-byte
|
||||||
|
`RuntimeAuthoritativePositionRouteClassifier.AuthoritativeTeleportFlags`
|
||||||
|
(`RuntimeAuthoritativePositionRouteClassifier.cs:198-200`). **The pinned
|
||||||
|
classifier route is confirmed correct; do not touch it.**
|
||||||
|
|
||||||
|
Three consequences that decide this slice:
|
||||||
|
|
||||||
|
### 1a. ForcePosition is a real SetPosition, not a snap
|
||||||
|
|
||||||
|
Retail runs the full transition with Teleport|Slide. Today's
|
||||||
|
`PlayerMovementController.BlipPosition`
|
||||||
|
(`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1923-1937`) is
|
||||||
|
`_body.SnapToCell(...)` — no transition, no collision, no contact plane, no
|
||||||
|
shadow commit, no `FullCellId`/`PlacementCommitVersion` advance. Closing that
|
||||||
|
gap is the point of route 2.
|
||||||
|
|
||||||
|
### 1b. The force branch runs NO ConstrainTo
|
||||||
|
|
||||||
|
Every `CPhysicsObj::ConstrainTo` call in `HandleReceivedPosition` is at
|
||||||
|
0x00454272 (remote MoveOrTeleport tail), 0x0045418A (player teleport-newer
|
||||||
|
branch), and 0x004541EC (player ordinary branch). The force branch returns at
|
||||||
|
0x0045409D, **before all three**. The classifier already encodes this as
|
||||||
|
`ConstrainPhase: None`.
|
||||||
|
|
||||||
|
`BlipPosition` calls `RearmConstraintLeashAtCurrentPosition()` and its comment
|
||||||
|
cites *"retail 'Player, normal' branch"* — a branch `BlipPlayer` is not on.
|
||||||
|
That leash re-arm is an unbacked deviation on this route.
|
||||||
|
|
||||||
|
**Required:** the new route honours `ConstrainPhase: None` — no leash re-arm on
|
||||||
|
ForcePosition. Call it out explicitly in the commit message as a named
|
||||||
|
behaviour change with these addresses, and add it to the connected gate's watch
|
||||||
|
list (#167 was a leash bug; the user's live observation governs). Do not touch
|
||||||
|
`ArmConstraintLeashAtCommittedPlacement` (C3c/AD-42 first-entry) or the
|
||||||
|
teleport/`CommitPreparedPosition` callers — they are on branches that DO
|
||||||
|
constrain.
|
||||||
|
|
||||||
|
### 1c. Heading preservation already happens upstream — do not re-derive it
|
||||||
|
|
||||||
|
Retail replaces the destination heading with the player's current heading
|
||||||
|
BEFORE the SetPosition. Our accepted-position merge already does this via the
|
||||||
|
`forcePositionRotation` argument
|
||||||
|
(`RuntimeLiveEntitySessionController.cs:179`, App's
|
||||||
|
`_authorityGate.TryAcceptPosition(..., _playerController.BodyOrientation, ...)`
|
||||||
|
at `LiveEntityNetworkUpdateController.cs:1050-1052`). Verify it lands in
|
||||||
|
`record.Snapshot` before you build the route request; assert it in a test. The
|
||||||
|
seam must NOT apply a second heading substitution.
|
||||||
|
|
||||||
|
Also noted, NOT in scope: retail's `PlayerPositionUpdated(this, 0, distance)`
|
||||||
|
gates `set_viewer`/`LScape::update_viewpoint` on
|
||||||
|
`distance >= GetAutonomyBlipDistance` (0x004538C0-0x004538E2). We publish the
|
||||||
|
render root unconditionally. File it as a follow-up observation in the closeout
|
||||||
|
note; do not change it here.
|
||||||
|
|
||||||
|
## 2. Verified mechanism map
|
||||||
|
|
||||||
|
| Thing | Location | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| Route classifier (pinned, correct) | `RuntimeAuthoritativePositionRouteClassifier.cs:308` | one production consumer today: `RuntimeInitialCreateContinuationExecutor.cs:1948` |
|
||||||
|
| Begin | `RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement:1309` | no first-entry-only precondition |
|
||||||
|
| Prepare+submit+commit | `RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement:1658` → `PrepareMover:1522` → `SubmitPreparedPlacementCore:2777` → `Engine.SetPosition:2962` → `CommitCanonical:4411` | |
|
||||||
|
| Deferred wake | `CommitCollisionGeneration:3973` → `RetryDeferred:4192` → `CommitCanonical:4374` | drives parked operations without our help |
|
||||||
|
| **The template to mirror** | `RuntimeFirstEntryDriveController.TryCompleteContinuationPlacement:280-380` | begin/prepare/submit + outcome switch + projection acknowledgement |
|
||||||
|
| Runtime world frame | `RuntimePhysicsState.ObserveLocalWorldFrame:535`; `resolveWorldOffsetFromRuntimeFrame` param | **use it** — satisfies contract §7, one conversion site |
|
||||||
|
| App projector (already exists) | `LiveEntityRuntime.TryApplyRuntimePlacementPlace` (`LiveEntityRuntime.cs:1231+`) | writes `entity.SetPosition(projection.WorldPosition)`, `entity.Rotation`, `entity.ParentCellId = token.ExactCellId`, `RebucketLiveEntity` — from the COMMITTED result |
|
||||||
|
| App sink | `RuntimePlacementPresentationSink.TryApply:67` / `TryPublishPlace:162` | |
|
||||||
|
| Headless sink | `HeadlessRuntimePlacementProjectionSink` | |
|
||||||
|
|
||||||
|
**This is the crux:** `TryApplyRuntimePlacementPlace` already performs, from
|
||||||
|
canonical committed state, exactly the four writes the generic tail performs
|
||||||
|
from raw wire (`LiveEntityNetworkUpdateController.cs:1264-1281`). Deleting the
|
||||||
|
generic tail for the local player is a straight substitution of the committed
|
||||||
|
result for the wire guess — not a loss of function.
|
||||||
|
|
||||||
|
## 3. The two duplicate authorities to delete
|
||||||
|
|
||||||
|
**Graphical** — `src/AcDream.App/Physics/LocalForcePositionTransaction.cs`
|
||||||
|
(whole file) and its single call site
|
||||||
|
`LiveEntityNetworkUpdateController.cs:1113-1129`; plus the generic tail
|
||||||
|
`:1264-1282` **for the local player only** (remotes still need it — that is
|
||||||
|
route 4).
|
||||||
|
|
||||||
|
**Headless** — `RuntimeLiveEntitySessionController.OnPositionUpdated:217-231`'s
|
||||||
|
`ProjectPosition(..., isLocalPlayer: true, ForcePosition)` +
|
||||||
|
`SendImmediatePosition` pair, and
|
||||||
|
`HeadlessSessionWorldProjection.BlipLocalPlayer:707-727`. Headless has no
|
||||||
|
`WorldEntity` in this path, so it has only the first duplicate — but it is a
|
||||||
|
duplicate all the same, and contract §4 requires both hosts on the identical
|
||||||
|
Runtime path.
|
||||||
|
|
||||||
|
## 4. Design
|
||||||
|
|
||||||
|
New Runtime type, modelled directly on `RuntimeFirstEntryDriveController`:
|
||||||
|
|
||||||
|
**`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs`**
|
||||||
|
|
||||||
|
Constructor takes the same collaborators that controller takes
|
||||||
|
(`_entityObjects`, `IPreparedCollisionSource`, the simulation clock,
|
||||||
|
`LocalPlayerOutboundController`, the session accessor, the local-player
|
||||||
|
identity). Follow that file's ctor and null-validation style exactly.
|
||||||
|
|
||||||
|
### Entry point
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal RuntimeAcceptedPositionExecutionStatus TryExecuteAcceptedLocalPosition(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
in WorldSession.EntityPositionUpdate update,
|
||||||
|
PositionTimestampDisposition disposition,
|
||||||
|
in AcceptedPhysicsTimestamps timestamps,
|
||||||
|
ushort previousTeleportSequence);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scope this slice to ForcePosition on the live local player.** Any other
|
||||||
|
disposition, any other entity kind, and the not-applicable cases below return
|
||||||
|
`NotApplicable`, and the caller then does exactly what it does today. Route 2
|
||||||
|
must not perturb routes 1/3/4.
|
||||||
|
|
||||||
|
Return `NotApplicable` when:
|
||||||
|
- `disposition is not PositionTimestampDisposition.ForcePosition`;
|
||||||
|
- the record is not the local player;
|
||||||
|
- `record.PhysicsBody is null` (no canonical body → nothing to place);
|
||||||
|
- **an initial-Create residence is still active for the record.** Route 1 owns
|
||||||
|
it: the executor already retains the Position as a tail action
|
||||||
|
(`RuntimeInitialCreateContinuationExecutor.ApplyPositionAction`) and already
|
||||||
|
carries `SendPositionImmediately` (`:717`, `:2576`). Confirm that ack
|
||||||
|
actually fires on that path and say so in the closeout; if it does not, that
|
||||||
|
is a route-1 defect — file it, do not paper over it here.
|
||||||
|
|
||||||
|
### Body
|
||||||
|
|
||||||
|
1. Build `RuntimeAcceptedPositionRouteRequest` deriving every field the way
|
||||||
|
`RuntimeInitialCreateContinuationExecutor.ApplyPositionAction:1907-1946`
|
||||||
|
derives it. Specifically: `HasContact = update.IsGrounded` (the wire bit,
|
||||||
|
never a live body query); `HasAnimations` from
|
||||||
|
`Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId` non-zero;
|
||||||
|
`CommittedCellId = record.FullCellId`; `PlacementFacts` from
|
||||||
|
`record.FinalPhysicsState` and `Snapshot.SetupTableId is not null`;
|
||||||
|
`Source = PositionEvent`; `UsePositionFromServer` and `PlayerDistance` from
|
||||||
|
the same Runtime owners C0 established (`RuntimeCharacterState.AutonomyLevel
|
||||||
|
!= 2`; the live movement controller). Read C0's notes in
|
||||||
|
`docs/plans/2026-08-02-placement-cutover.md` first.
|
||||||
|
2. `ClassifyAcceptedPosition`. Not accepted → stamp-only, mirroring
|
||||||
|
`:1950-1984`; return without ack.
|
||||||
|
3. `TryBeginExclusiveAuthoredPlacement(record, record.PositionAuthorityVersion,
|
||||||
|
route.OperationKind)`. Invalid token → `Contention`; the caller retries on a
|
||||||
|
later packet. Do NOT invent a retry loop.
|
||||||
|
4. `TryPrepareAndSubmitAuthoredPlacement(record, token, route.OperationKind,
|
||||||
|
route.SetPositionFlags, _collisionSource, _clock.SimulationTimeSeconds,
|
||||||
|
out outcome, resolveWorldOffsetFromRuntimeFrame: true)`.
|
||||||
|
5. Outcome switch, mirroring `TryCompleteContinuationPlacement:340-380`:
|
||||||
|
- `CommittedHostAcknowledgementPending` → §4a reconcile, then §4b ack.
|
||||||
|
- `DeferredCell` → §4c.
|
||||||
|
- anything else → forget + `PublishCancellation`; **no ack**.
|
||||||
|
|
||||||
|
### 4a. Post-commit local reconciliation
|
||||||
|
|
||||||
|
`CommitCanonical` writes the body, contact plane, `FullCellId`,
|
||||||
|
`PlacementCommitVersion`, shadow membership and the spatial acknowledgement. It
|
||||||
|
does NOT do the three controller-local things `BlipPosition` also did. Add ONE
|
||||||
|
new method to `PlayerMovementController` next to `BlipPosition`, e.g.
|
||||||
|
`CommitCanonicalForcePositionFrame()`, that:
|
||||||
|
|
||||||
|
- resets `_prevPhysicsPos`/`_currPhysicsPos` to the committed body position
|
||||||
|
(kills the render-lerp residual);
|
||||||
|
- calls `UpdateCellId(_body.CellPosition.ObjCellId, "force-position")` so the
|
||||||
|
render root chokepoint `PhysicsEngine.UpdatePlayerCurrCell` still runs;
|
||||||
|
- does **not** re-arm the constraint leash (§1b);
|
||||||
|
- does **not** write the body — the canonical commit already did.
|
||||||
|
|
||||||
|
Then `BlipPosition` has no remaining caller: **delete it** along with
|
||||||
|
`HeadlessSessionWorldProjection.BlipLocalPlayer`. If a test is its only other
|
||||||
|
caller, the test moves to the new path — do not keep the method alive for
|
||||||
|
tests.
|
||||||
|
|
||||||
|
### 4b. The ack
|
||||||
|
|
||||||
|
`SendPositionImmediately` is an OUTPUT of the committed route. Fire
|
||||||
|
`LocalPlayerOutboundController.SendImmediatePosition(session, controller)`
|
||||||
|
only after §4a, only when `route.SendPositionImmediately`, and only once. This
|
||||||
|
is the named behaviour change the contract calls out: today the packet leaves
|
||||||
|
before any commit and the trailing `isCurrent()` cannot recall it.
|
||||||
|
|
||||||
|
### 4c. Deferred cell
|
||||||
|
|
||||||
|
`DeferredCell` means the destination landblock's collision generation is not
|
||||||
|
ready; the operation parks and the existing `CommitCollisionGeneration` wake
|
||||||
|
resubmits and commits it. The ack must still fire exactly once, after that
|
||||||
|
commit.
|
||||||
|
|
||||||
|
Retain the pending ack keyed by the placement token, and resolve it on a pump
|
||||||
|
`Advance()` called from the SAME two host sites that already call
|
||||||
|
`RuntimeFirstEntryDriveController.DriveAll()` —
|
||||||
|
`LiveEntityHydrationController.cs:405` (graphical) and
|
||||||
|
`HeadlessSessionWorldProjection.cs:571,594,612` (headless). Find the existing
|
||||||
|
read-only way to ask "did this token's operation commit / is it gone" before
|
||||||
|
adding anything; only add a minimal internal query to `RuntimeSetPositionState`
|
||||||
|
if none exists. Fire once on the commit transition; drop the pending ack on
|
||||||
|
cancellation, supersession, entity teardown, generation change and reset, and
|
||||||
|
fold its count into the ownership ledger / `IsConverged` so a leaked pending
|
||||||
|
ack cannot hide.
|
||||||
|
|
||||||
|
Also consume the parked `Withdraw` at the FIFO head exactly the way
|
||||||
|
`TryCompleteContinuationPlacement:351-365` does, if and only if it is ours.
|
||||||
|
|
||||||
|
### 4d. Host cutover
|
||||||
|
|
||||||
|
- **App** `LiveEntityNetworkUpdateController.OnPosition`: replace the
|
||||||
|
`LocalForcePositionTransaction.Apply` block with the Runtime call. When the
|
||||||
|
status is anything other than `NotApplicable`, return before the generic tail
|
||||||
|
— App projects the committed result through the existing placement sink. Keep
|
||||||
|
every currency re-check that is still meaningful. Inject the Runtime seam the
|
||||||
|
way the class already borrows Runtime owners (`_localPlayerOutbound` is the
|
||||||
|
precedent); do not add a service locator or a window back-reference.
|
||||||
|
- **Headless** `RuntimeLiveEntitySessionController.OnPositionUpdated`: replace
|
||||||
|
the `ProjectPosition(isLocalPlayer: true, ForcePosition)` +
|
||||||
|
`SendImmediatePosition` pair with the same call. Leave the `Apply`-
|
||||||
|
disposition `OfferTeleportDestination` and `TryCompletePortal` alone — route 3.
|
||||||
|
- Delete `LocalForcePositionTransaction.cs`.
|
||||||
|
|
||||||
|
## 5. Non-negotiables
|
||||||
|
|
||||||
|
- Root causes only. No timeout, grace period, suppression flag,
|
||||||
|
catch-and-swallow, duplicated placement writer, or test-only bypass.
|
||||||
|
- Never `git add -A` / `git add .` / `git reset --hard` / `git checkout -- <path>`.
|
||||||
|
- Do not weaken an existing assertion to make a test pass. If a fixture models
|
||||||
|
the old duplicate-write behaviour, re-model it on the committed projection.
|
||||||
|
- Cite retail as `named symbol @address` (+ the pseudo-C line) in every comment
|
||||||
|
on ported behaviour.
|
||||||
|
- Update `docs/ISSUES.md` and
|
||||||
|
`docs/architecture/retail-divergence-register.md` in the SAME commit as the
|
||||||
|
behaviour change. AP-131 is **not** retired here — its named legacy Position
|
||||||
|
caller is route 4.
|
||||||
|
|
||||||
|
## 6. Acceptance
|
||||||
|
|
||||||
|
1. Focused Runtime tests for the seam: force route classification; ack strictly
|
||||||
|
after commit; ack exactly once; no ack on a rejected/cancelled operation;
|
||||||
|
the displaced-authority case that `LocalForcePositionTransaction`'s trailing
|
||||||
|
`isCurrent()` covered today; the `DeferredCell` → wake → commit → single ack
|
||||||
|
sequence; heading preserved; leash NOT re-armed; `NotApplicable` while a
|
||||||
|
first-entry residence is active.
|
||||||
|
2. App tests proving the generic tail no longer double-writes the local player,
|
||||||
|
and that the committed projection is what moves the render entity.
|
||||||
|
3. Headless tests proving the identical Runtime path.
|
||||||
|
4. `dotnet build -c Release` clean.
|
||||||
|
5. **Complete Release solution suite green — not a focused subset.** Baseline
|
||||||
|
**10,844 passed / 4 skipped / 0 failed**. Any deviation is a regression
|
||||||
|
introduced by this work.
|
||||||
|
6. Connected (user-gated): a server-forced correction leaves the player at the
|
||||||
|
corrected position, heading preserved, exactly one outbound
|
||||||
|
`AutonomousPosition`, no double-apply, and no leash misbehaviour after the
|
||||||
|
correction.
|
||||||
479
docs/research/2026-08-03-c4-route-2-review-findings.md
Normal file
479
docs/research/2026-08-03-c4-route-2-review-findings.md
Normal file
|
|
@ -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 `<see cref="BlipPosition"/>` 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 `<c>` 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.
|
||||||
135
docs/research/2026-08-03-c4-route-2-visual-gate.md
Normal file
135
docs/research/2026-08-03-c4-route-2-visual-gate.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -573,6 +573,21 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
.RegisteredAuthoredPayload
|
.RegisteredAuthoredPayload
|
||||||
: RuntimeLocalPlayerShadowDisposition.ProvenShapeless);
|
: 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(
|
var hydration = new LiveEntityHydrationController(
|
||||||
live.LiveEntities,
|
live.LiveEntities,
|
||||||
d.EntityObjects,
|
d.EntityObjects,
|
||||||
|
|
@ -591,7 +606,8 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
deletion,
|
deletion,
|
||||||
dormantLiveEntities,
|
dormantLiveEntities,
|
||||||
d.Options.DumpLiveSpawns ? d.Log : null,
|
d.Options.DumpLiveSpawns ? d.Log : null,
|
||||||
firstEntryDrive);
|
firstEntryDrive,
|
||||||
|
acceptedPositionDrive);
|
||||||
bindings.Adopt(
|
bindings.Adopt(
|
||||||
"landblock-loaded hydration",
|
"landblock-loaded hydration",
|
||||||
live.LandblockLoaded.Bind(hydration));
|
live.LandblockLoaded.Bind(hydration));
|
||||||
|
|
@ -637,6 +653,7 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
liveSessionSource,
|
liveSessionSource,
|
||||||
localPhysicsTimestamps.Publish,
|
localPhysicsTimestamps.Publish,
|
||||||
d.MovementDiagnostics,
|
d.MovementDiagnostics,
|
||||||
|
acceptedPositionDrive,
|
||||||
worldDropProjection);
|
worldDropProjection);
|
||||||
var liveness = new LiveEntityLivenessController(
|
var liveness = new LiveEntityLivenessController(
|
||||||
live.LiveEntities,
|
live.LiveEntities,
|
||||||
|
|
@ -959,7 +976,8 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
live.RenderSceneShadow,
|
live.RenderSceneShadow,
|
||||||
live.PlacementProjection,
|
live.PlacementProjection,
|
||||||
placementProjectionRetry,
|
placementProjectionRetry,
|
||||||
firstEntryDrive),
|
firstEntryDrive,
|
||||||
|
acceptedPositionDrive),
|
||||||
liveSessionCommands,
|
liveSessionCommands,
|
||||||
d.Log);
|
d.Log);
|
||||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
||||||
private readonly Func<RuntimeGenerationToken> _generation;
|
private readonly Func<RuntimeGenerationToken> _generation;
|
||||||
private readonly RuntimePlacementProjectionRetrySlot _retries;
|
private readonly RuntimePlacementProjectionRetrySlot _retries;
|
||||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||||
|
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||||
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
||||||
private RuntimePlacementProjectionSubscription? _subscription;
|
private RuntimePlacementProjectionSubscription? _subscription;
|
||||||
private IDisposable? _retryLease;
|
private IDisposable? _retryLease;
|
||||||
|
|
@ -30,7 +31,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
||||||
IRuntimePlacementProjectionSink placements,
|
IRuntimePlacementProjectionSink placements,
|
||||||
RuntimePlacementProjectionRetrySlot retries,
|
RuntimePlacementProjectionRetrySlot retries,
|
||||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
Action<RuntimeEntityRecord>? localPlayerCompleted = null,
|
||||||
|
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||||
: this(
|
: this(
|
||||||
events,
|
events,
|
||||||
() => new RuntimePlacementProjectionSubscription(
|
() => new RuntimePlacementProjectionSubscription(
|
||||||
|
|
@ -40,7 +42,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
||||||
() => runtime.Generation,
|
() => runtime.Generation,
|
||||||
retries,
|
retries,
|
||||||
firstEntry,
|
firstEntry,
|
||||||
localPlayerCompleted)
|
localPlayerCompleted,
|
||||||
|
acceptedPositionDrive)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(runtime);
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
ArgumentNullException.ThrowIfNull(placements);
|
ArgumentNullException.ThrowIfNull(placements);
|
||||||
|
|
@ -52,7 +55,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
||||||
Func<RuntimeGenerationToken> generation,
|
Func<RuntimeGenerationToken> generation,
|
||||||
RuntimePlacementProjectionRetrySlot retries,
|
RuntimePlacementProjectionRetrySlot retries,
|
||||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
Action<RuntimeEntityRecord>? localPlayerCompleted = null,
|
||||||
|
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||||
{
|
{
|
||||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
_createSubscription = createSubscription
|
_createSubscription = createSubscription
|
||||||
|
|
@ -62,6 +66,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
||||||
_retries = retries ?? throw new ArgumentNullException(nameof(retries));
|
_retries = retries ?? throw new ArgumentNullException(nameof(retries));
|
||||||
_firstEntry = firstEntry;
|
_firstEntry = firstEntry;
|
||||||
_localPlayerCompleted = localPlayerCompleted;
|
_localPlayerCompleted = localPlayerCompleted;
|
||||||
|
_acceptedPositionDrive = acceptedPositionDrive;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Attach()
|
public void Attach()
|
||||||
|
|
@ -75,6 +80,10 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
||||||
// detached — session reset precedes a new route — before this route
|
// detached — session reset precedes a new route — before this route
|
||||||
// takes ownership of the shared drive controller's tracked entries.
|
// takes ownership of the shared drive controller's tracked entries.
|
||||||
_firstEntry?.AttachRoute(this, _localPlayerCompleted);
|
_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();
|
_events.Attach();
|
||||||
|
|
||||||
RuntimePlacementProjectionSubscription? subscription = null;
|
RuntimePlacementProjectionSubscription? subscription = null;
|
||||||
|
|
@ -90,10 +99,13 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
||||||
// republishing the pending FIFO head — a conductor's own
|
// republishing the pending FIFO head — a conductor's own
|
||||||
// Advance is what consumes conductor-owned receipts, and the
|
// Advance is what consumes conductor-owned receipts, and the
|
||||||
// subsequent RetryPending lets the presentation sink apply
|
// 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();
|
_firstEntry?.DriveAll();
|
||||||
|
_acceptedPositionDrive?.Advance();
|
||||||
return boundSubscription.RetryPending();
|
return boundSubscription.RetryPending();
|
||||||
});
|
});
|
||||||
_subscription = subscription;
|
_subscription = subscription;
|
||||||
|
|
@ -123,6 +135,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
||||||
// route-scoped — a route that never attached cannot clear a live
|
// route-scoped — a route that never attached cannot clear a live
|
||||||
// route's entries.
|
// route's entries.
|
||||||
_firstEntry?.DetachRoute(this);
|
_firstEntry?.DetachRoute(this);
|
||||||
|
_acceptedPositionDrive?.DetachRoute(this);
|
||||||
if (!_eventsDisposed)
|
if (!_eventsDisposed)
|
||||||
{
|
{
|
||||||
_events.Dispose();
|
_events.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,8 @@ internal sealed record LiveSessionWorldRuntime(
|
||||||
RenderSceneShadowRuntime? RenderSceneShadow,
|
RenderSceneShadowRuntime? RenderSceneShadow,
|
||||||
RuntimePlacementPresentationSink PlacementProjection,
|
RuntimePlacementPresentationSink PlacementProjection,
|
||||||
RuntimePlacementProjectionRetrySlot PlacementRetries,
|
RuntimePlacementProjectionRetrySlot PlacementRetries,
|
||||||
RuntimeFirstEntryDriveController FirstEntryDrive);
|
RuntimeFirstEntryDriveController FirstEntryDrive,
|
||||||
|
RuntimeAcceptedPositionDriveController AcceptedPositionDrive);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the exact per-generation route/reset graph for the canonical live
|
/// Builds the exact per-generation route/reset graph for the canonical live
|
||||||
|
|
@ -264,7 +265,8 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
_world.PlacementProjection,
|
_world.PlacementProjection,
|
||||||
_world.PlacementRetries,
|
_world.PlacementRetries,
|
||||||
_world.FirstEntryDrive,
|
_world.FirstEntryDrive,
|
||||||
_ => session.SendGameAction(GameActionLoginComplete.Build()));
|
_ => session.SendGameAction(GameActionLoginComplete.Build()),
|
||||||
|
_world.AcceptedPositionDrive);
|
||||||
}
|
}
|
||||||
|
|
||||||
private LiveInventorySessionBindings CreateInventoryBindings() => new(
|
private LiveInventorySessionBindings CreateInventoryBindings() => new(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ using AcDream.Core.Items;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Runtime.Entities;
|
using AcDream.Runtime.Entities;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.Core.Selection;
|
using AcDream.Core.Selection;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
|
|
@ -63,6 +64,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
|
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
|
||||||
private readonly InventoryWorldDropProjectionController?
|
private readonly InventoryWorldDropProjectionController?
|
||||||
_worldDropProjection;
|
_worldDropProjection;
|
||||||
|
private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive;
|
||||||
|
|
||||||
private PlayerMovementController? _playerController => _playerControllerSource.Controller;
|
private PlayerMovementController? _playerController => _playerControllerSource.Controller;
|
||||||
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
|
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
|
||||||
|
|
@ -103,6 +105,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
ILiveWorldSessionSource session,
|
ILiveWorldSessionSource session,
|
||||||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||||||
IMovementTruthDiagnosticSink movementTruthDiagnostics,
|
IMovementTruthDiagnosticSink movementTruthDiagnostics,
|
||||||
|
RuntimeAcceptedPositionDriveController acceptedPositionDrive,
|
||||||
InventoryWorldDropProjectionController? worldDropProjection = null)
|
InventoryWorldDropProjectionController? worldDropProjection = null)
|
||||||
{
|
{
|
||||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||||
|
|
@ -138,6 +141,8 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
publishTimestamps);
|
publishTimestamps);
|
||||||
_movementTruthDiagnostics = movementTruthDiagnostics
|
_movementTruthDiagnostics = movementTruthDiagnostics
|
||||||
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
|
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
|
||||||
|
_acceptedPositionDrive = acceptedPositionDrive
|
||||||
|
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
||||||
_worldDropProjection = worldDropProjection;
|
_worldDropProjection = worldDropProjection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1113,20 +1118,61 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
||||||
&& update.Guid == _playerServerGuid
|
&& update.Guid == _playerServerGuid
|
||||||
&& _playerController is not null;
|
&& _playerController is not null;
|
||||||
if (!LocalForcePositionTransaction.Apply(
|
if (forceLocal)
|
||||||
forceLocal,
|
{
|
||||||
() => IsCurrentPositionOwner(),
|
if (!IsCurrentPositionOwner())
|
||||||
() => _playerController!.BlipPosition(
|
return;
|
||||||
worldPos,
|
|
||||||
p.LandblockId,
|
// C4 route 2 (2026-08-03): the Runtime-owned accepted-Position
|
||||||
new System.Numerics.Vector3(
|
// execution seam replaces the deleted LocalForcePositionTransaction.
|
||||||
p.PositionX,
|
// Ownership validation is the operation's own currency check,
|
||||||
p.PositionY,
|
// the body commit is Runtime's canonical SetPosition transaction
|
||||||
p.PositionZ)),
|
// (retail CPhysicsObj::SetPositionSimple @0x005162B0, called from
|
||||||
() => _localPlayerOutbound.SendImmediatePosition(
|
// SmartBox::BlipPlayer @0x00453940), and the outbound ack is an
|
||||||
_session.CurrentSession,
|
// OUTPUT of that committed route, fired strictly after it.
|
||||||
_playerController)))
|
RuntimeAcceptedPositionExecutionStatus forceStatus =
|
||||||
return;
|
_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
|
// A leave-world transition deliberately retains WorldEntity as the
|
||||||
// logical/render-resource owner while IsSpatiallyProjected is false.
|
// logical/render-resource owner while IsSpatiallyProjected is false.
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
namespace AcDream.App.Physics;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
internal static class LocalForcePositionTransaction
|
|
||||||
{
|
|
||||||
internal static bool Apply(
|
|
||||||
bool isForcePosition,
|
|
||||||
Func<bool> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -189,6 +189,13 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
||||||
/// controller without one.
|
/// controller without one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 2: the graphical accepted-Position drive controller — pumped
|
||||||
|
/// alongside <see cref="_firstEntry"/> so a ForcePosition parked awaiting
|
||||||
|
/// its destination collision generation resolves promptly. Optional for
|
||||||
|
/// the same reason <see cref="_firstEntry"/> is.
|
||||||
|
/// </summary>
|
||||||
|
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||||
private readonly Dictionary<RuntimeEntityRecord, CanonicalProjectionOperation>
|
private readonly Dictionary<RuntimeEntityRecord, CanonicalProjectionOperation>
|
||||||
_projectionOperations =
|
_projectionOperations =
|
||||||
new(ReferenceEqualityComparer.Instance);
|
new(ReferenceEqualityComparer.Instance);
|
||||||
|
|
@ -213,7 +220,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
||||||
LiveEntityDeletionController deletion,
|
LiveEntityDeletionController deletion,
|
||||||
DormantLiveEntityStore? dormant = null,
|
DormantLiveEntityStore? dormant = null,
|
||||||
Action<string>? diagnostic = null,
|
Action<string>? diagnostic = null,
|
||||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||||
|
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||||
{
|
{
|
||||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
_entityObjects = entityObjects
|
_entityObjects = entityObjects
|
||||||
|
|
@ -230,6 +238,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
||||||
_dormant = dormant ?? new DormantLiveEntityStore();
|
_dormant = dormant ?? new DormantLiveEntityStore();
|
||||||
_diagnostic = diagnostic;
|
_diagnostic = diagnostic;
|
||||||
_firstEntry = firstEntry;
|
_firstEntry = firstEntry;
|
||||||
|
_acceptedPositionDrive = acceptedPositionDrive;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal event Action<uint>? AppearanceApplied;
|
internal event Action<uint>? AppearanceApplied;
|
||||||
|
|
@ -403,6 +412,8 @@ AppearanceSynchronization:
|
||||||
// destination cell, FIFO ahead of us) is retried by the
|
// destination cell, FIFO ahead of us) is retried by the
|
||||||
// per-frame placement retry phase.
|
// per-frame placement retry phase.
|
||||||
_firstEntry?.DriveAll();
|
_firstEntry?.DriveAll();
|
||||||
|
// C4 route 2: same pump for a parked ForcePosition.
|
||||||
|
_acceptedPositionDrive?.Advance();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
||||||
private readonly GameRuntime _runtime;
|
private readonly GameRuntime _runtime;
|
||||||
private readonly IRuntimePlacementProjectionSink _placements;
|
private readonly IRuntimePlacementProjectionSink _placements;
|
||||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||||
|
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||||
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
||||||
private RuntimePlacementProjectionSubscription? _subscription;
|
private RuntimePlacementProjectionSubscription? _subscription;
|
||||||
private bool _attachStarted;
|
private bool _attachStarted;
|
||||||
|
|
@ -28,7 +29,8 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
IRuntimePlacementProjectionSink placements,
|
IRuntimePlacementProjectionSink placements,
|
||||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
Action<RuntimeEntityRecord>? localPlayerCompleted = null,
|
||||||
|
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||||
{
|
{
|
||||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
|
|
@ -36,6 +38,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
||||||
?? throw new ArgumentNullException(nameof(placements));
|
?? throw new ArgumentNullException(nameof(placements));
|
||||||
_firstEntry = firstEntry;
|
_firstEntry = firstEntry;
|
||||||
_localPlayerCompleted = localPlayerCompleted;
|
_localPlayerCompleted = localPlayerCompleted;
|
||||||
|
_acceptedPositionDrive = acceptedPositionDrive;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Attach()
|
public void Attach()
|
||||||
|
|
@ -52,6 +55,9 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
||||||
// detached — session reset precedes a new route — before this route
|
// detached — session reset precedes a new route — before this route
|
||||||
// takes ownership of the shared drive controller's tracked entries.
|
// takes ownership of the shared drive controller's tracked entries.
|
||||||
_firstEntry?.AttachRoute(this, _localPlayerCompleted);
|
_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();
|
_events.Attach();
|
||||||
_subscription = new RuntimePlacementProjectionSubscription(
|
_subscription = new RuntimePlacementProjectionSubscription(
|
||||||
_runtime,
|
_runtime,
|
||||||
|
|
@ -73,6 +79,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
||||||
// route-scoped — a route that never attached cannot clear a live
|
// route-scoped — a route that never attached cannot clear a live
|
||||||
// route's entries.
|
// route's entries.
|
||||||
_firstEntry?.DetachRoute(this);
|
_firstEntry?.DetachRoute(this);
|
||||||
|
_acceptedPositionDrive?.DetachRoute(this);
|
||||||
if (!_eventsDisposed)
|
if (!_eventsDisposed)
|
||||||
{
|
{
|
||||||
_events.Dispose();
|
_events.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,11 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
/// Runtime lifetime) plus the active world projection it pumps
|
/// Runtime lifetime) plus the active world projection it pumps
|
||||||
/// through.</summary>
|
/// through.</summary>
|
||||||
private RuntimeFirstEntryDriveController? _firstEntryDrive;
|
private RuntimeFirstEntryDriveController? _firstEntryDrive;
|
||||||
|
/// <summary>C4 route 2 (2026-08-03): see the ctor comment in
|
||||||
|
/// <see cref="CreateEventRoute"/> — cached across reconnects exactly
|
||||||
|
/// like <see cref="_firstEntryDrive"/>.</summary>
|
||||||
|
private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||||
|
private AcDream.Core.Net.WorldSession? _currentSession;
|
||||||
private HeadlessSessionWorldProjection? _worldProjection;
|
private HeadlessSessionWorldProjection? _worldProjection;
|
||||||
private int _disposeStage;
|
private int _disposeStage;
|
||||||
private long _reconnectDeadline;
|
private long _reconnectDeadline;
|
||||||
|
|
@ -311,8 +316,20 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_policy.Tick(Runtime, Commands);
|
_policy.Tick(Runtime, Commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal RuntimeTeardownAcknowledgement Stop() =>
|
internal RuntimeTeardownAcknowledgement Stop()
|
||||||
Commands.Session.Stop(Runtime.Generation);
|
{
|
||||||
|
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)
|
internal void Quarantine(Exception error)
|
||||||
{
|
{
|
||||||
|
|
@ -536,6 +553,25 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
private ILiveSessionEventRouting CreateEventRoute(
|
private ILiveSessionEventRouting CreateEventRoute(
|
||||||
AcDream.Core.Net.WorldSession session)
|
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;
|
IRuntimeDirectWorldProjection? worldProjection = null;
|
||||||
if (_contentLease is { } content)
|
if (_contentLease is { } content)
|
||||||
{
|
{
|
||||||
|
|
@ -557,10 +593,24 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
Radius: 0.48f,
|
Radius: 0.48f,
|
||||||
Height: 1.835f,
|
Height: 1.835f,
|
||||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
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(
|
var projection = new HeadlessSessionWorldProjection(
|
||||||
Runtime,
|
Runtime,
|
||||||
content,
|
content,
|
||||||
_firstEntryDrive);
|
_firstEntryDrive,
|
||||||
|
_acceptedPositionDrive);
|
||||||
_worldProjection = projection;
|
_worldProjection = projection;
|
||||||
worldProjection = projection;
|
worldProjection = projection;
|
||||||
}
|
}
|
||||||
|
|
@ -571,7 +621,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_descriptor.Id,
|
_descriptor.Id,
|
||||||
message,
|
message,
|
||||||
Runtime.Generation.Value),
|
Runtime.Generation.Value),
|
||||||
worldProjection);
|
worldProjection,
|
||||||
|
_acceptedPositionDrive);
|
||||||
var route = new LiveSessionEventRouter(
|
var route = new LiveSessionEventRouter(
|
||||||
session,
|
session,
|
||||||
entities.CreateSink(),
|
entities.CreateSink(),
|
||||||
|
|
@ -620,7 +671,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
Runtime,
|
Runtime,
|
||||||
new HeadlessRuntimePlacementProjectionSink(Runtime),
|
new HeadlessRuntimePlacementProjectionSink(Runtime),
|
||||||
_firstEntryDrive,
|
_firstEntryDrive,
|
||||||
_ => session.SendGameAction(GameActionLoginComplete.Build()));
|
_ => session.SendGameAction(GameActionLoginComplete.Build()),
|
||||||
|
_acceptedPositionDrive);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LiveSessionCharacterSelector MapCharacterSelector(
|
private static LiveSessionCharacterSelector MapCharacterSelector(
|
||||||
|
|
|
||||||
|
|
@ -504,29 +504,39 @@ internal sealed class HeadlessSessionWorldProjection
|
||||||
private readonly GameRuntime _runtime;
|
private readonly GameRuntime _runtime;
|
||||||
private readonly IHeadlessCollisionNeighborhood _collision;
|
private readonly IHeadlessCollisionNeighborhood _collision;
|
||||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 2 (2026-08-03): pumped alongside <see cref="_firstEntry"/> so
|
||||||
|
/// a ForcePosition parked awaiting its destination collision generation
|
||||||
|
/// resolves promptly.
|
||||||
|
/// </summary>
|
||||||
|
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||||
private uint _requestedLocalPlayerCell;
|
private uint _requestedLocalPlayerCell;
|
||||||
|
|
||||||
internal HeadlessSessionWorldProjection(
|
internal HeadlessSessionWorldProjection(
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
HeadlessProcessContentOwner.HeadlessProcessContentLease content,
|
HeadlessProcessContentOwner.HeadlessProcessContentLease content,
|
||||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||||
|
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||||
: this(
|
: this(
|
||||||
runtime,
|
runtime,
|
||||||
new HeadlessCollisionNeighborhood(runtime, content),
|
new HeadlessCollisionNeighborhood(runtime, content),
|
||||||
firstEntry)
|
firstEntry,
|
||||||
|
acceptedPositionDrive)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
internal HeadlessSessionWorldProjection(
|
internal HeadlessSessionWorldProjection(
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
IHeadlessCollisionNeighborhood collision,
|
IHeadlessCollisionNeighborhood collision,
|
||||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||||
|
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||||
{
|
{
|
||||||
_runtime = runtime
|
_runtime = runtime
|
||||||
?? throw new ArgumentNullException(nameof(runtime));
|
?? throw new ArgumentNullException(nameof(runtime));
|
||||||
_collision = collision
|
_collision = collision
|
||||||
?? throw new ArgumentNullException(nameof(collision));
|
?? throw new ArgumentNullException(nameof(collision));
|
||||||
_firstEntry = firstEntry;
|
_firstEntry = firstEntry;
|
||||||
|
_acceptedPositionDrive = acceptedPositionDrive;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ProjectSpawn(
|
public void ProjectSpawn(
|
||||||
|
|
@ -569,6 +579,7 @@ internal sealed class HeadlessSessionWorldProjection
|
||||||
.TryConvertInitialResidenceToCellessRoute(record);
|
.TryConvertInitialResidenceToCellessRoute(record);
|
||||||
}
|
}
|
||||||
_firstEntry?.DriveAll();
|
_firstEntry?.DriveAll();
|
||||||
|
_acceptedPositionDrive?.Advance();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ProjectPosition(
|
public void ProjectPosition(
|
||||||
|
|
@ -592,11 +603,43 @@ internal sealed class HeadlessSessionWorldProjection
|
||||||
_collision.CenterOn(position.LandblockId);
|
_collision.CenterOn(position.LandblockId);
|
||||||
}
|
}
|
||||||
_firstEntry?.DriveAll();
|
_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.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R2 review fix (2026-08-03): the centering half of the deleted
|
||||||
|
/// <c>BlipLocalPlayer</c> — <c>_collision.CenterOn(position.LandblockId)</c>
|
||||||
|
/// plus the <see cref="_requestedLocalPlayerCell"/> update
|
||||||
|
/// <see cref="PumpFirstEntry"/> polls. Without this, a ForcePosition to a
|
||||||
|
/// destination outside the neighborhood's current 3x3 window
|
||||||
|
/// (<c>HeadlessCollisionNeighborhood.BuildPublicationPlan</c>) would open
|
||||||
|
/// a <c>DeferredCell</c> park this host's window can never publish —
|
||||||
|
/// see <see cref="AcDream.Runtime.Session.RuntimeAcceptedPositionDriveController.Advance"/>'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.
|
||||||
|
/// </summary>
|
||||||
|
public void CenterOnAcceptedForcePosition(RuntimeEntityRecord record)
|
||||||
|
{
|
||||||
|
if (record.ServerGuid != _runtime.PlayerIdentity.ServerGuid
|
||||||
|
|| record.Snapshot.Position is not { LandblockId: not 0u } position)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disposition is PositionTimestampDisposition.ForcePosition)
|
_requestedLocalPlayerCell = position.LandblockId;
|
||||||
BlipLocalPlayer(record);
|
_collision.CenterOn(position.LandblockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -610,6 +653,7 @@ internal sealed class HeadlessSessionWorldProjection
|
||||||
if (_requestedLocalPlayerCell != 0u)
|
if (_requestedLocalPlayerCell != 0u)
|
||||||
_ = _collision.IsReady(_requestedLocalPlayerCell);
|
_ = _collision.IsReady(_requestedLocalPlayerCell);
|
||||||
_firstEntry?.DriveAll();
|
_firstEntry?.DriveAll();
|
||||||
|
_acceptedPositionDrive?.Advance();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void BeginTeleport()
|
public void BeginTeleport()
|
||||||
|
|
@ -704,26 +748,4 @@ internal sealed class HeadlessSessionWorldProjection
|
||||||
controller.SetBodyOrientation(orientation);
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,16 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
|
||||||
/// Previously outside every ledger; gated by <see cref="IsConverged"/>
|
/// Previously outside every ledger; gated by <see cref="IsConverged"/>
|
||||||
/// like the conductor counts it pumps.
|
/// like the conductor counts it pumps.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
int FirstEntryDrivePendingCount = 0)
|
int FirstEntryDrivePendingCount = 0,
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 2: outstanding host <c>RuntimeAcceptedPositionDriveController</c>
|
||||||
|
/// pending operations (a not-yet-committed or not-yet-acknowledged
|
||||||
|
/// ForcePosition on the local player), summed over every drive
|
||||||
|
/// registered against this lifetime via
|
||||||
|
/// <see cref="RuntimeEntityObjectLifetime.RegisterAcceptedPositionDriveOwnership"/>.
|
||||||
|
/// Gated by <see cref="IsConverged"/> — a leaked pending ack cannot hide.
|
||||||
|
/// </summary>
|
||||||
|
int AcceptedPositionDrivePendingCount = 0)
|
||||||
{
|
{
|
||||||
public bool IsConverged =>
|
public bool IsConverged =>
|
||||||
IsDisposed
|
IsDisposed
|
||||||
|
|
@ -104,6 +113,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
|
||||||
&& LocalPlayerFirstEntryActiveCount == 0
|
&& LocalPlayerFirstEntryActiveCount == 0
|
||||||
&& RemoteFirstEntryActiveCount == 0
|
&& RemoteFirstEntryActiveCount == 0
|
||||||
&& FirstEntryDrivePendingCount == 0
|
&& FirstEntryDrivePendingCount == 0
|
||||||
|
&& AcceptedPositionDrivePendingCount == 0
|
||||||
&& StreamSubscriberCount == 0
|
&& StreamSubscriberCount == 0
|
||||||
&& PlacementStreamSubscriberCount == 0
|
&& PlacementStreamSubscriberCount == 0
|
||||||
&& PendingDispatchCount == 0
|
&& PendingDispatchCount == 0
|
||||||
|
|
@ -151,6 +161,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
private Action<RuntimeEntityRecord>? _initialResidenceBegan;
|
private Action<RuntimeEntityRecord>? _initialResidenceBegan;
|
||||||
/// <summary>C3c-R1 review F5: see <see cref="RegisterFirstEntryDriveOwnership"/>.</summary>
|
/// <summary>C3c-R1 review F5: see <see cref="RegisterFirstEntryDriveOwnership"/>.</summary>
|
||||||
private readonly List<Func<int>> _firstEntryDriveOwnership = [];
|
private readonly List<Func<int>> _firstEntryDriveOwnership = [];
|
||||||
|
/// <summary>C4 route 2: see <see cref="RegisterAcceptedPositionDriveOwnership"/>.</summary>
|
||||||
|
private readonly List<Func<int>> _acceptedPositionDriveOwnership = [];
|
||||||
|
|
||||||
public RuntimeEntityObjectLifetime(
|
public RuntimeEntityObjectLifetime(
|
||||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
|
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
|
||||||
|
|
@ -452,7 +464,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
InitialCreateExecution.PendingCompletionReceiptCount,
|
InitialCreateExecution.PendingCompletionReceiptCount,
|
||||||
LocalPlayerFirstEntry.CaptureOwnership().ActiveCount,
|
LocalPlayerFirstEntry.CaptureOwnership().ActiveCount,
|
||||||
RemoteFirstEntry.CaptureOwnership().ActiveCount,
|
RemoteFirstEntry.CaptureOwnership().ActiveCount,
|
||||||
CaptureFirstEntryDrivePendingCount());
|
CaptureFirstEntryDrivePendingCount(),
|
||||||
|
CaptureAcceptedPositionDrivePendingCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
private int CaptureFirstEntryDrivePendingCount()
|
private int CaptureFirstEntryDrivePendingCount()
|
||||||
|
|
@ -463,6 +476,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int CaptureAcceptedPositionDrivePendingCount()
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < _acceptedPositionDriveOwnership.Count; i++)
|
||||||
|
total = checked(total + _acceptedPositionDriveOwnership[i]());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// C3c-R1 review F5: registers one host first-entry drive controller's
|
/// C3c-R1 review F5: registers one host first-entry drive controller's
|
||||||
/// pending-count provider into this lifetime's ownership snapshot, so
|
/// pending-count provider into this lifetime's ownership snapshot, so
|
||||||
|
|
@ -478,6 +499,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
_firstEntryDriveOwnership.Add(pendingCount);
|
_firstEntryDriveOwnership.Add(pendingCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 2: registers one host <c>RuntimeAcceptedPositionDriveController</c>'s
|
||||||
|
/// pending-count provider into this lifetime's ownership snapshot,
|
||||||
|
/// mirroring <see cref="RegisterFirstEntryDriveOwnership"/> — 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).
|
||||||
|
/// </summary>
|
||||||
|
public void RegisterAcceptedPositionDriveOwnership(Func<int> pendingCount)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(pendingCount);
|
||||||
|
EnsureNotDisposed();
|
||||||
|
_acceptedPositionDriveOwnership.Add(pendingCount);
|
||||||
|
}
|
||||||
|
|
||||||
public void BindEventContext(
|
public void BindEventContext(
|
||||||
Func<RuntimeGenerationToken> generation,
|
Func<RuntimeGenerationToken> generation,
|
||||||
Func<ulong> frameNumber)
|
Func<ulong> frameNumber)
|
||||||
|
|
|
||||||
|
|
@ -629,8 +629,13 @@ public sealed class PlayerMovementController
|
||||||
/// stick down (retail <c>teleport_hook</c> @0x00514eee) and, as of
|
/// stick down (retail <c>teleport_hook</c> @0x00514eee) and, as of
|
||||||
/// Campaign P P5 (#167), also tears down and immediately re-arms the
|
/// Campaign P P5 (#167), also tears down and immediately re-arms the
|
||||||
/// constraint leash (<c>UnConstrain</c> then <c>ConstrainTo</c>);
|
/// constraint leash (<c>UnConstrain</c> then <c>ConstrainTo</c>);
|
||||||
/// <see cref="BlipPosition"/> arms the leash without tearing it down first
|
/// <see cref="CommitCanonicalForcePositionFrame"/>'s ForcePosition route
|
||||||
/// (retail <c>SmartBox::BlipPlayer</c> survives motion/velocity/stick).
|
/// does NOT touch the leash at all — retail's FORCE_POSITION branch of
|
||||||
|
/// <c>SmartBox::HandleReceivedPosition</c> (@0x00453FD0) returns at
|
||||||
|
/// 0x0045409D, before every <c>ConstrainTo</c> call (C4 route 2 review
|
||||||
|
/// fix, 2026-08-03 — the deleted <c>BlipPosition</c>'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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AcDream.Core.Physics.Motion.PositionManager? PositionManager
|
public AcDream.Core.Physics.Motion.PositionManager? PositionManager
|
||||||
{
|
{
|
||||||
|
|
@ -1915,25 +1920,33 @@ public sealed class PlayerMovementController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>SmartBox::BlipPlayer</c> (0x00453940): apply a server
|
/// C4 route 2: the controller-local half of a ForcePosition commit whose
|
||||||
/// FORCE_POSITION correction through <c>CPhysicsObj::SetPositionSimple</c>
|
/// body write already happened inside Runtime's canonical
|
||||||
/// without the teleport hook. Active motion, velocity, contact state, and
|
/// <c>RuntimeSetPositionState.CommitCanonical</c> (retail
|
||||||
/// PositionManager stick relationships deliberately survive the blip.
|
/// <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0 with flags
|
||||||
|
/// <c>0x1012</c>, called from <c>SmartBox::BlipPlayer</c> @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 <c>BlipPosition</c> performed after
|
||||||
|
/// its own (now-Runtime-owned) body snap.
|
||||||
|
///
|
||||||
|
/// Deliberately does NOT call <see cref="RearmConstraintLeashAtCurrentPosition"/>:
|
||||||
|
/// retail's FORCE_POSITION branch of <c>SmartBox::HandleReceivedPosition</c>
|
||||||
|
/// (@0x00453FD0) returns at 0x0045409D, before every
|
||||||
|
/// <c>CPhysicsObj::ConstrainTo</c> call (0x00454272/0x0045418A/
|
||||||
|
/// 0x004541EC) — the deleted <c>BlipPosition</c>'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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
internal void CommitCanonicalForcePositionFrame()
|
||||||
{
|
{
|
||||||
EnsurePublishedForRuntimeOperation();
|
EnsurePublishedForRuntimeOperation();
|
||||||
_body.SnapToCell(cellId, pos, cellLocal);
|
_prevPhysicsPos = _body.Position;
|
||||||
_prevPhysicsPos = pos;
|
_currPhysicsPos = _body.Position;
|
||||||
_currPhysicsPos = pos;
|
|
||||||
UpdateCellId(_body.CellPosition.ObjCellId, "force-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()
|
private Vector3 ComputeRenderPosition()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed yields for
|
||||||
|
/// <see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal enum RuntimeAcceptedPositionExecutionStatus : byte
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
NotApplicable,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement"/>
|
||||||
|
/// (invalid prepared data, authority displaced mid-submit) — R5 review
|
||||||
|
/// fix (2026-08-03): the two call sites inside <c>SubmitAndResolve</c>
|
||||||
|
/// 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 <c>SettlePending</c> sends its retail position
|
||||||
|
/// event carrying the body's unchanged pose (<c>SmartBox::BlipPlayer</c>
|
||||||
|
/// @0x00453940 discards <c>SetPositionSimple</c>'s error and
|
||||||
|
/// <c>SmartBox::HandleReceivedPosition</c> @0x00453FD0 acks
|
||||||
|
/// unconditionally @0x00454091).
|
||||||
|
/// </summary>
|
||||||
|
Rejected,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>_newestForce</c>), so when the contending operation is this
|
||||||
|
/// controller's OWN tracked <c>_pending</c> — the B1 case: a park that
|
||||||
|
/// woke and had its <c>Place</c> ACCEPTED, whose retained completion
|
||||||
|
/// survives <c>RuntimeSetPositionState.Forget</c>'s early return and
|
||||||
|
/// blocks the next begin — the single <c>_pending</c> funnel
|
||||||
|
/// (<c>SettlePending</c>) 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.
|
||||||
|
/// </summary>
|
||||||
|
Contention,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The destination landblock's collision generation was not ready; the
|
||||||
|
/// operation parked. The ack fires later, once
|
||||||
|
/// <see cref="RuntimeAcceptedPositionDriveController.Advance"/> observes
|
||||||
|
/// the deferred commit.
|
||||||
|
/// </summary>
|
||||||
|
DeferredCell,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The canonical SetPosition committed synchronously (retail
|
||||||
|
/// <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0, called from
|
||||||
|
/// <c>SmartBox::BlipPlayer</c> @0x00453940). The controller-local
|
||||||
|
/// reconciliation ran and the outbound ack (if any) already went out.
|
||||||
|
/// </summary>
|
||||||
|
Committed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 2: the Runtime-owned accepted-Position execution seam for a
|
||||||
|
/// ForcePosition on an already-live local player. Retail
|
||||||
|
/// <c>SmartBox::HandleReceivedPosition</c> (@0x00453FD0,
|
||||||
|
/// acclient_2013_pseudo_c.txt:92896) FORCE_POSITION branch:
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// 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
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <c>SmartBox::BlipPlayer</c> (@0x00453940, line 92528) calls
|
||||||
|
/// <c>CPhysicsObj::SetPositionSimple</c> (@0x005162B0, line 284276), which
|
||||||
|
/// with a non-null destination frame builds a <c>SetPositionStruct</c> with
|
||||||
|
/// flags <c>0x1012</c> (<c>Teleport|Slide|SendPositionEvent</c> —
|
||||||
|
/// <see cref="RuntimeAuthoritativePositionRouteClassifier"/>'s
|
||||||
|
/// <c>AuthoritativeTeleportFlags</c>) and calls
|
||||||
|
/// <c>CPhysicsObj::SetPosition</c>. This class is the Runtime consumer the
|
||||||
|
/// classifier's ForcePosition branch never had: it drives
|
||||||
|
/// <see cref="RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement"/> +
|
||||||
|
/// <see cref="RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement"/>
|
||||||
|
/// exactly like <see cref="RuntimeFirstEntryDriveController"/>'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 <c>AutonomousPosition</c> ack now fires only after the
|
||||||
|
/// canonical commit (previously it left before any Runtime commit existed —
|
||||||
|
/// see the deleted <c>LocalForcePositionTransaction</c>/
|
||||||
|
/// <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c> pair);
|
||||||
|
/// (2) the constraint leash is NOT re-armed here — every
|
||||||
|
/// <c>CPhysicsObj::ConstrainTo</c> call in <c>HandleReceivedPosition</c>
|
||||||
|
/// (@0x00454272/0x0045418A/0x004541EC) is on a branch the FORCE_POSITION
|
||||||
|
/// early return (@0x0045409D) never reaches; the deleted
|
||||||
|
/// <c>PlayerMovementController.BlipPosition</c>'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
|
||||||
|
/// <see cref="RuntimeFirstEntryDriveController"/> — <see cref="AttachRoute"/>/
|
||||||
|
/// <see cref="DetachRoute"/> 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 <c>public</c> — unlike its template
|
||||||
|
/// <see cref="RuntimeFirstEntryDriveController"/> (<c>internal sealed</c>),
|
||||||
|
/// this class is a required parameter type on
|
||||||
|
/// <see cref="RuntimeLiveEntitySessionController"/>'s own <c>public</c>
|
||||||
|
/// constructor (<c>src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs</c>),
|
||||||
|
/// so C# accessibility rules (CS0051) require it. Every constructor
|
||||||
|
/// parameter and member below is <c>internal</c>; only the type name itself
|
||||||
|
/// is public, and only because the class it is threaded through already is.
|
||||||
|
/// Narrowing <see cref="RuntimeLiveEntitySessionController"/> itself is a
|
||||||
|
/// separate, broader change outside this fix's scope.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round 3 (2026-08-03): true while this descriptor stands for an
|
||||||
|
/// accepted force packet whose placement WAS begun and whose retail
|
||||||
|
/// position event (<c>CommandInterpreter::SendPositionEvent</c>
|
||||||
|
/// @0x006B4770, called unconditionally at
|
||||||
|
/// <c>SmartBox::HandleReceivedPosition</c> @0x00454091) has not gone
|
||||||
|
/// out yet. It is <c>false</c> only for the re-issue retry marker
|
||||||
|
/// <see cref="SettlePending"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal required bool PositionEventOwed { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round 2 unified mechanism (2026-08-03): the newest accepted local-player
|
||||||
|
/// ForcePosition this drive has been handed, stamped with the canonical
|
||||||
|
/// record's <c>PositionAuthorityVersion</c> 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
|
||||||
|
/// <see cref="TryExecuteAcceptedLocalPosition"/> is dispatched by both
|
||||||
|
/// hosts for ForcePosition and only for ForcePosition — an ordinary
|
||||||
|
/// <c>Apply</c> merges (advancing <c>PositionAuthorityVersion</c>) 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
|
||||||
|
/// <c>PositionAuthorityVersion</c> differs from
|
||||||
|
/// <see cref="PositionAuthorityVersion"/>, 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
|
||||||
|
/// <c>RuntimeEntityRecordTable.AdvancePositionAuthority</c> has four call
|
||||||
|
/// sites, not one — the ordinary accepted-Position merge
|
||||||
|
/// (<c>RuntimeEntityObjectLifetime.cs:1647</c>) plus
|
||||||
|
/// <c>TryApplyPickup</c> (<c>:1116</c>),
|
||||||
|
/// <c>CommitPositionChannelUpdate</c> (<c>:2041</c>) and
|
||||||
|
/// <c>AdvanceCreateAuthority</c> (<c>:2466</c>). 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
|
||||||
|
/// <b>AD-62</b>), never make it re-issue a stale pose.
|
||||||
|
/// </summary>
|
||||||
|
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<RuntimeGenerationToken> _generation;
|
||||||
|
private readonly Func<uint> _localPlayerServerGuid;
|
||||||
|
private readonly Func<PlayerMovementController?> _localController;
|
||||||
|
private readonly Func<bool> _usePositionFromServer;
|
||||||
|
private readonly Func<WorldSession?> _session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 — <see cref="RetainPending"/> (the operation is still
|
||||||
|
/// outstanding), <see cref="SettlePending"/> (the single terminal-outcome
|
||||||
|
/// funnel), and <see cref="AbandonPending"/> (route teardown, which is
|
||||||
|
/// outside the operation lifecycle entirely). No branch of
|
||||||
|
/// <see cref="TryExecuteAcceptedLocalPosition"/>,
|
||||||
|
/// <see cref="SubmitAndResolve"/> or <see cref="Advance"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
private Pending? _pending;
|
||||||
|
private AcceptedForceObservation? _newestForce;
|
||||||
|
private object? _routeOwner;
|
||||||
|
|
||||||
|
internal RuntimeAcceptedPositionDriveController(
|
||||||
|
RuntimeEntityObjectLifetime entityObjects,
|
||||||
|
IGameRuntimeClock clock,
|
||||||
|
IPreparedCollisionSource collisionSource,
|
||||||
|
LocalPlayerOutboundController localPlayerOutbound,
|
||||||
|
Func<RuntimeGenerationToken> generation,
|
||||||
|
Func<uint> localPlayerServerGuid,
|
||||||
|
Func<PlayerMovementController?> localController,
|
||||||
|
Func<bool> usePositionFromServer,
|
||||||
|
Func<WorldSession?> 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C3c-R1-style one-route-at-a-time latch (mirrors
|
||||||
|
/// <see cref="RuntimeFirstEntryDriveController.AttachRoute"/>): this
|
||||||
|
/// controller outlives its session routes (hosts reuse it across
|
||||||
|
/// reconnects), so the "session reset precedes a new route" ordering is
|
||||||
|
/// asserted, not assumed.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Route-scoped teardown: abandons any pending operation, but ONLY when
|
||||||
|
/// <paramref name="route"/> is the attached owner.
|
||||||
|
/// </summary>
|
||||||
|
internal void DetachRoute(object route)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(route);
|
||||||
|
if (!ReferenceEquals(_routeOwner, route))
|
||||||
|
return;
|
||||||
|
_routeOwner = null;
|
||||||
|
AbandonPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Route teardown — the one <see cref="_pending"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a ForcePosition Position update against the canonical
|
||||||
|
/// Runtime SetPosition owner. <paramref name="record"/>'s Snapshot,
|
||||||
|
/// timestamps, and PositionAuthorityVersion must already reflect the
|
||||||
|
/// merge <see cref="RuntimeEntityObjectLifetime.TryApplyPosition"/>
|
||||||
|
/// performed for this exact <paramref name="update"/> — this method
|
||||||
|
/// never re-merges the wire frame; it only routes the already-accepted
|
||||||
|
/// pose through the canonical placement transaction.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host cadence pump: resolves a parked DeferredCell operation once its
|
||||||
|
/// destination landblock's collision generation eventually commits it
|
||||||
|
/// (<c>RuntimeSetPositionState.CommitCollisionGeneration</c> →
|
||||||
|
/// <c>RetryDeferred</c> → <c>CommitCanonical</c>, driven entirely by
|
||||||
|
/// unrelated collision/streaming machinery — this pump never re-submits
|
||||||
|
/// the operation itself). Retries a preparation-only retry status
|
||||||
|
/// (<c>RetrySetupUnavailable</c>/<c>RetryWorldFrameUnavailable</c>) by
|
||||||
|
/// re-calling the SAME prepare+submit pair, exactly like
|
||||||
|
/// <see cref="RuntimeFirstEntryDriveController"/>'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): <c>RuntimeEntityObjectLifetime.TryApplyPosition</c>
|
||||||
|
/// calls <c>RuntimeSetPositionState.Forget</c> on EVERY accepted Position
|
||||||
|
/// for this entity — any disposition, not only ForcePosition — which
|
||||||
|
/// unconditionally cancels whatever operation this controller has
|
||||||
|
/// in-flight (<c>Forget</c> → <c>CancelCoreDeferred</c> →
|
||||||
|
/// <c>ForgetPlacementCompletionCore</c>, which drops the token from both
|
||||||
|
/// <c>_placementCompletionWatches</c> and
|
||||||
|
/// <c>_acknowledgedPlacementCompletions</c> with no trace). ACE broadcasts
|
||||||
|
/// at 5-10 Hz, so a <see cref="RuntimeAcceptedPositionExecutionStatus.DeferredCell"/>
|
||||||
|
/// 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, <c>ParkCollisionResidents</c>,
|
||||||
|
/// and a generation change retire an operation
|
||||||
|
/// (<c>ForgetPlacementCompletionCore</c> is their common funnel too).
|
||||||
|
///
|
||||||
|
/// Retail <c>SmartBox::BlipPlayer</c> (@0x00453940) has no "give up
|
||||||
|
/// quietly" state — every accepted Position it sees gets applied. So
|
||||||
|
/// rather than silently leaking <see cref="_pending"/> forever (which
|
||||||
|
/// would also pin <c>AcceptedPositionDrivePendingCount</c> non-zero for
|
||||||
|
/// the rest of the session — <c>GameWindowLifetime.DisposeGameRuntime</c>
|
||||||
|
/// throws on non-convergence), this pump detects the cancellation via the
|
||||||
|
/// existing read-only <c>RuntimeSetPositionState.IsPlacementCompletionTracked</c>
|
||||||
|
/// query (a watched-and-not-yet-cancelled DeferredCell park) or
|
||||||
|
/// <c>IsPlacementCurrent</c> (a still-in-flight prepare retry) and, when
|
||||||
|
/// neither holds, hands the dead operation to the single
|
||||||
|
/// <see cref="SettlePending"/> funnel, which decides on ONE input whether
|
||||||
|
/// a re-issue is owed.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round 2 unified mechanism (2026-08-03) — the SINGLE terminal-outcome
|
||||||
|
/// funnel for <see cref="_pending"/>. 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 <c>PositionAuthorityVersion</c> (carried on its
|
||||||
|
/// <see cref="RuntimeEntityPlacementToken"/>,
|
||||||
|
/// <c>RuntimeSetPositionState.cs:50</c>) compared against the live
|
||||||
|
/// canonical record's CURRENT <c>PositionAuthorityVersion</c>:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><b>Equal</b> — 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
|
||||||
|
/// <c>AutonomousPosition</c> even when a stale dead entry was still parked
|
||||||
|
/// in <see cref="_pending"/> when the fresh packet committed (N1 — the
|
||||||
|
/// double-apply/double-ack class <c>670f307c</c> deleted).</description></item>
|
||||||
|
/// <item><description><b>Advanced, newest accepted event still a
|
||||||
|
/// ForcePosition</b> — 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 <see cref="_newestForce"/> —
|
||||||
|
/// never the terminal operation's own route (B1).</description></item>
|
||||||
|
/// <item><description><b>Advanced, newest accepted event is an ordinary
|
||||||
|
/// Apply</b> — 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 <c>Teleport|Slide</c> flags to an
|
||||||
|
/// ordinary pose, send an ack retail never sends on that branch, and skip
|
||||||
|
/// the <c>ConstrainTo</c> the ordinary branch runs
|
||||||
|
/// (<c>RuntimeAuthoritativePositionRouteClassifier.cs:368-388</c>) — N2.
|
||||||
|
/// This is not a silent drop: retail applies each event as it arrives, and
|
||||||
|
/// a force overtaken by a newer position is moot.</description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// 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).
|
||||||
|
///
|
||||||
|
/// <para><b>Round 3 (2026-08-03) — the terminal-without-commit ack.</b>
|
||||||
|
/// Retail acknowledges an accepted force packet whether or not its
|
||||||
|
/// placement took. <c>SmartBox::BlipPlayer</c> @0x00453940 calls
|
||||||
|
/// <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0 — which returns an
|
||||||
|
/// <c>enum SetPositionError</c> that other retail call sites DO test
|
||||||
|
/// (<c>== OK_SPE</c> @0x0055605D, @0x00556021) — and DISCARDS it;
|
||||||
|
/// <c>BlipPlayer</c> itself returns <c>void</c>. Its caller
|
||||||
|
/// <c>SmartBox::HandleReceivedPosition</c> @0x00453FD0 then runs
|
||||||
|
/// <c>cmdinterp->SendPositionEvent()</c> @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
|
||||||
|
/// (<paramref name="positionEventOwed"/>), carrying the body's UNCHANGED
|
||||||
|
/// pose — which is exactly what retail's ack carries after a failed
|
||||||
|
/// <c>SetPositionSimple</c>, 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 <c>false</c>: their ack already
|
||||||
|
/// left through <see cref="ReconcileAndAcknowledge"/>, so exactly one
|
||||||
|
/// position event goes out per begun placement THAT REACHES ITS OWN
|
||||||
|
/// TERMINAL SETTLE — never two.</para>
|
||||||
|
///
|
||||||
|
/// <para>It is deliberately NOT "never zero per begun placement". This
|
||||||
|
/// method opens by nulling <c>_pending</c> without reading it, so a
|
||||||
|
/// descriptor still carrying <c>PositionEventOwed</c> is discarded when a
|
||||||
|
/// NEWER ForcePosition displaces it (the merge-time
|
||||||
|
/// <c>Forget</c> 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
|
||||||
|
/// <c>OneServerCorrectionProducesExactlyOnePlacementAndOneAck</c>, 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.</para>
|
||||||
|
///
|
||||||
|
/// <para>Divergence: the two non-reissuing branches mean a ForcePosition
|
||||||
|
/// retired without committing is never re-applied —
|
||||||
|
/// <c>docs/architecture/retail-divergence-register.md</c> row <b>AD-62</b>
|
||||||
|
/// (the park itself is our async collision-publication adaptation; retail
|
||||||
|
/// <c>SmartBox::BlipPlayer</c> @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.</para>
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R6 review fix (2026-08-03): the previous shape assigned
|
||||||
|
/// <see cref="_pending"/> unconditionally, which could silently
|
||||||
|
/// overwrite a still-tracked live pending (losing the only reference
|
||||||
|
/// able to later consume its eventual acknowledged completion — a
|
||||||
|
/// permanent <c>HasRetainedCompletion</c> orphan that would block every
|
||||||
|
/// future Begin for this entity with Contention forever). Given
|
||||||
|
/// <see cref="RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement"/>
|
||||||
|
/// already refuses to Begin while the entity holds an active operation
|
||||||
|
/// or an unconsumed acknowledged completion, <paramref name="next"/>'s
|
||||||
|
/// token could only have successfully begun if any DIFFERENT existing
|
||||||
|
/// <see cref="_pending"/> 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 <see cref="_pending"/>; every terminal outcome
|
||||||
|
/// goes through <see cref="SettlePending"/> instead.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// §4a/§4b: the controller-local reconciliation
|
||||||
|
/// <c>CommitCanonical</c> does not perform, followed by the outbound ack
|
||||||
|
/// — an OUTPUT of the committed route, never a step performed alongside
|
||||||
|
/// it (retail <c>cmdinterp->SendPositionEvent()</c> @0x00454091 runs
|
||||||
|
/// after <c>SmartBox::BlipPlayer</c> @0x00454074 returns).
|
||||||
|
/// </summary>
|
||||||
|
private void ReconcileAndAcknowledge(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
in RuntimeAuthoritativePositionRoute route)
|
||||||
|
{
|
||||||
|
if (record.ServerGuid != _localPlayerServerGuid())
|
||||||
|
return;
|
||||||
|
if (_localController() is not { } controller)
|
||||||
|
return;
|
||||||
|
controller.CommitCanonicalForcePositionFrame();
|
||||||
|
SendPositionEvent(controller, route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>cmdinterp->SendPositionEvent()</c> @0x00454091 — the sole
|
||||||
|
/// outbound-ack site for this route, shared by the committed path
|
||||||
|
/// (<see cref="ReconcileAndAcknowledge"/>, which reconciles the moved
|
||||||
|
/// frame first) and the terminal-without-commit path
|
||||||
|
/// (<see cref="SettlePending"/>, which has no moved frame to reconcile and
|
||||||
|
/// so sends the body's unchanged pose).
|
||||||
|
///
|
||||||
|
/// The <c>CanSendPositionEvent</c> admission inside
|
||||||
|
/// <see cref="LocalPlayerOutboundController.SendImmediatePosition"/> is
|
||||||
|
/// retail's own (<c>CommandInterpreter::SendPositionEvent</c> @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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,24 @@ public interface IRuntimeDirectWorldProjection
|
||||||
bool isLocalPlayer,
|
bool isLocalPlayer,
|
||||||
PositionTimestampDisposition disposition);
|
PositionTimestampDisposition disposition);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R2 review fix (2026-08-03): a ForcePosition on the local player is
|
||||||
|
/// dispatched directly to <see cref="RuntimeAcceptedPositionDriveController"/>
|
||||||
|
/// and never reaches <see cref="ProjectPosition"/> at all, so THIS is
|
||||||
|
/// where a host that keeps a narrow collision/streaming window (the
|
||||||
|
/// deleted <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c>'s own
|
||||||
|
/// <c>_collision.CenterOn</c> 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 <c>DeferredCell</c> park to be a real park rather
|
||||||
|
/// than a dead end (see <c>RuntimeAcceptedPositionDriveController.Advance</c>'s
|
||||||
|
/// R1 doc comment). A host with no narrow window (the graphical host,
|
||||||
|
/// whose landblock streaming already follows the accepted position via
|
||||||
|
/// <c>LiveEntityInboundAuthorityGate.ObserveAcceptedLocalPosition</c>) is
|
||||||
|
/// a no-op here.
|
||||||
|
/// </summary>
|
||||||
|
void CenterOnAcceptedForcePosition(RuntimeEntityRecord record);
|
||||||
|
|
||||||
void BeginTeleport();
|
void BeginTeleport();
|
||||||
|
|
||||||
RuntimeDestinationReadiness PrepareDestination(
|
RuntimeDestinationReadiness PrepareDestination(
|
||||||
|
|
@ -38,20 +56,28 @@ public sealed class RuntimeLiveEntitySessionController
|
||||||
private readonly WorldSession _session;
|
private readonly WorldSession _session;
|
||||||
private readonly Action<string> _log;
|
private readonly Action<string> _log;
|
||||||
private readonly IRuntimeDirectWorldProjection? _worldProjection;
|
private readonly IRuntimeDirectWorldProjection? _worldProjection;
|
||||||
private readonly LocalPlayerOutboundController _localPlayerOutbound =
|
/// <summary>
|
||||||
new((_, _, _, _, _, _) => { });
|
/// C4 route 2 (2026-08-03): the headless accepted-Position drive
|
||||||
|
/// controller. Owns its own outbound-ack collaborator internally; the
|
||||||
|
/// ForcePosition + manual <c>LocalPlayerOutboundController.SendImmediatePosition</c>
|
||||||
|
/// pair this class used to drive directly is retired (the deleted
|
||||||
|
/// <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c>).
|
||||||
|
/// </summary>
|
||||||
|
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||||
private bool _initialLoginCompleteSent;
|
private bool _initialLoginCompleteSent;
|
||||||
|
|
||||||
public RuntimeLiveEntitySessionController(
|
public RuntimeLiveEntitySessionController(
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
Action<string>? log = null,
|
Action<string>? log = null,
|
||||||
IRuntimeDirectWorldProjection? worldProjection = null)
|
IRuntimeDirectWorldProjection? worldProjection = null,
|
||||||
|
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||||
{
|
{
|
||||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||||
_log = log ?? (_ => { });
|
_log = log ?? (_ => { });
|
||||||
_worldProjection = worldProjection;
|
_worldProjection = worldProjection;
|
||||||
|
_acceptedPositionDrive = acceptedPositionDrive;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LiveEntitySessionSink CreateSink() => new(
|
public LiveEntitySessionSink CreateSink() => new(
|
||||||
|
|
@ -218,16 +244,55 @@ public sealed class RuntimeLiveEntitySessionController
|
||||||
update.Guid,
|
update.Guid,
|
||||||
out RuntimeEntityRecord record))
|
out RuntimeEntityRecord record))
|
||||||
{
|
{
|
||||||
_worldProjection?.ProjectPosition(
|
if (disposition is PositionTimestampDisposition.ForcePosition)
|
||||||
record,
|
{
|
||||||
isLocalPlayer: true,
|
// R2 review fix (2026-08-03): re-center BEFORE submitting —
|
||||||
disposition);
|
// see IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition's
|
||||||
}
|
// doc comment. This is what the deleted BlipLocalPlayer's own
|
||||||
if (disposition is PositionTimestampDisposition.ForcePosition)
|
// _collision.CenterOn call used to guarantee.
|
||||||
{
|
_worldProjection?.CenterOnAcceptedForcePosition(record);
|
||||||
_localPlayerOutbound.SendImmediatePosition(
|
|
||||||
_session,
|
// C4 route 2 (2026-08-03): the Runtime-owned accepted-
|
||||||
_runtime.MovementOwner.Controller);
|
// 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();
|
TryCompletePortal();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using AcDream.App.Physics;
|
using AcDream.App.Physics;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Physics;
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
@ -46,51 +47,97 @@ public sealed class LiveEntityNetworkBranchRoutingTests
|
||||||
Assert.Equal(["projectile", "canonical", "ordinary"], calls);
|
Assert.Equal(["projectile", "canonical", "ordinary"], calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
// C4 route 2 (2026-08-03): LocalForcePositionTransaction and its
|
||||||
public void ForcePosition_BlipsAndAcknowledgesExactlyOnce()
|
// 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.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R8 review fix (2026-08-03): source pins for the generic-tail
|
||||||
|
/// double-write guard in <c>LiveEntityNetworkUpdateController.OnPosition</c>.
|
||||||
|
/// A full behavioral fixture is impractical here for the SAME reason
|
||||||
|
/// <c>C3cF1ProductionWiringTests</c> gives — the controller's dependency
|
||||||
|
/// set is composition-only (67+ collaborators wired only by
|
||||||
|
/// <c>SessionPlayerComposition</c>) — 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LiveEntityNetworkUpdateControllerForcePositionWiringTests
|
||||||
{
|
{
|
||||||
int currentChecks = 0;
|
[Fact]
|
||||||
int blips = 0;
|
public void LocalForcePositionTransactionIsNeverCalledFromThisFile()
|
||||||
int acknowledgements = 0;
|
{
|
||||||
|
string source = ReadSource("LiveEntityNetworkUpdateController.cs");
|
||||||
|
|
||||||
bool completed = LocalForcePositionTransaction.Apply(
|
// The name may still appear in a comment explaining what
|
||||||
isForcePosition: true,
|
// replaced it (contract §"the deleted LocalForcePositionTransaction");
|
||||||
() => { currentChecks++; return true; },
|
// what must be gone is any actual call into it.
|
||||||
() => blips++,
|
Assert.DoesNotContain(
|
||||||
() => acknowledgements++);
|
"LocalForcePositionTransaction.Apply(",
|
||||||
|
source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.True(completed);
|
[Fact]
|
||||||
Assert.Equal(2, currentChecks);
|
public void GenericTailWriteIsNeverDuplicatedForTheLocalForcePositionPath()
|
||||||
Assert.Equal(1, blips);
|
{
|
||||||
Assert.Equal(1, acknowledgements);
|
string source = ReadSource("LiveEntityNetworkUpdateController.cs");
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
// The generic render-tail's WorldEntity write is the ONE
|
||||||
public void ForcePosition_AcknowledgementInvalidationStopsTheTail()
|
// remaining writer of an accepted Position — it must serve
|
||||||
{
|
// remotes only, never a second local-player write alongside the
|
||||||
bool current = true;
|
// Runtime-committed one.
|
||||||
int acknowledgements = 0;
|
Assert.Single(
|
||||||
|
Regex.Matches(source, @"entity\.SetPosition\(worldPos\);")
|
||||||
|
.Cast<Match>());
|
||||||
|
}
|
||||||
|
|
||||||
bool completed = LocalForcePositionTransaction.Apply(
|
[Fact]
|
||||||
isForcePosition: true,
|
public void CommittedOrDeferredCellReturnsBeforeReachingTheGenericTail()
|
||||||
() => current,
|
{
|
||||||
() => { },
|
string source = ReadSource("LiveEntityNetworkUpdateController.cs");
|
||||||
() => { acknowledgements++; current = false; });
|
|
||||||
|
|
||||||
Assert.False(completed);
|
// The Committed/DeferredCell branch must still return
|
||||||
Assert.Equal(1, acknowledgements);
|
// 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]
|
private static string ReadSource(string fileName)
|
||||||
public void OrdinaryPositionDoesNotBlipOrAcknowledge()
|
{
|
||||||
{
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
int calls = 0;
|
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(
|
directory = directory.Parent;
|
||||||
isForcePosition: false,
|
}
|
||||||
() => { calls++; return false; },
|
|
||||||
() => calls++,
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||||
() => calls++));
|
}
|
||||||
Assert.Equal(0, calls);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -393,16 +393,56 @@ public sealed class HeadlessSessionHostTests
|
||||||
acknowledgeProjection: null,
|
acknowledgeProjection: null,
|
||||||
out PositionTimestampDisposition disposition,
|
out PositionTimestampDisposition disposition,
|
||||||
out _,
|
out _,
|
||||||
out _));
|
out AcceptedPhysicsTimestamps timestamps));
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
PositionTimestampDisposition.ForcePosition,
|
PositionTimestampDisposition.ForcePosition,
|
||||||
disposition);
|
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);
|
Assert.Equal(2, collision.CenterCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1296,6 +1336,25 @@ public sealed class HeadlessSessionHostTests
|
||||||
Height: 1.835f,
|
Height: 1.835f,
|
||||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 2 (2026-08-03): mirrors <see cref="CreateFirstEntryDrive"/>'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.
|
||||||
|
/// </summary>
|
||||||
|
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
|
private sealed class LoadedSetupCollisionSource
|
||||||
: AcDream.Content.IPreparedCollisionSource
|
: AcDream.Content.IPreparedCollisionSource
|
||||||
{
|
{
|
||||||
|
|
@ -1312,7 +1371,23 @@ public sealed class HeadlessSessionHostTests
|
||||||
.Loaded(new FlatSetupCollision(
|
.Loaded(new FlatSetupCollision(
|
||||||
System.Collections.Immutable.ImmutableArray<
|
System.Collections.Immutable.ImmutableArray<
|
||||||
FlatCollisionCylinder>.Empty,
|
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,
|
height: 0f,
|
||||||
radius: 0f,
|
radius: 0f,
|
||||||
stepUpHeight: 0.4f,
|
stepUpHeight: 0.4f,
|
||||||
|
|
|
||||||
|
|
@ -569,7 +569,7 @@ public class PlayerMovementControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BlipPosition_ResnapsPoseWithoutStoppingActiveMotion()
|
public void CommitCanonicalForcePositionFrame_ReconcilesPoseWithoutStoppingActiveMotion()
|
||||||
{
|
{
|
||||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||||
|
|
@ -579,7 +579,14 @@ public class PlayerMovementControllerTests
|
||||||
Assert.True(velocity.LengthSquared() > 0f);
|
Assert.True(velocity.LengthSquared() > 0f);
|
||||||
|
|
||||||
var corrected = new Vector3(100f, 98f, 50f);
|
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.Position);
|
||||||
Assert.Equal(corrected, controller.RenderPosition);
|
Assert.Equal(corrected, controller.RenderPosition);
|
||||||
|
|
@ -587,13 +594,14 @@ public class PlayerMovementControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BlipPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
|
public void CommitCanonicalForcePositionFrame_PublishesCanonicalOutdoorCellAndLocalFrame()
|
||||||
{
|
{
|
||||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||||
var world = new Vector3(150f, 193f, 50f);
|
var world = new Vector3(150f, 193f, 50f);
|
||||||
var wireLocal = 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(0xA9B40031u, controller.CellId);
|
||||||
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
|
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
|
||||||
|
|
@ -959,27 +967,35 @@ public class PlayerMovementControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BlipPosition_ArmsConstraintButDoesNotTearDownOrZeroVelocity()
|
public void CommitCanonicalForcePositionFrame_DoesNotRearmConstraintLeashOrTouchVelocity()
|
||||||
{
|
{
|
||||||
var (controller, _) = MakeControllerWithHost();
|
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));
|
controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||||
Vector3 velocityBeforeBlip = controller.BodyVelocity;
|
Vector3 velocityBeforeCommit = controller.BodyVelocity;
|
||||||
Assert.NotEqual(Vector3.Zero, velocityBeforeBlip); // sanity: actually moving
|
Assert.NotEqual(Vector3.Zero, velocityBeforeCommit); // 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);
|
|
||||||
ConstraintManager cm = controller.PositionManager!.Constraint!;
|
ConstraintManager cm = controller.PositionManager!.Constraint!;
|
||||||
Assert.True(cm.IsConstrained);
|
Vector3 leashAnchorBeforeCommit = cm.ConstraintPos.Frame.Origin;
|
||||||
Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin);
|
|
||||||
Assert.Equal(0f, cm.ConstraintPosOffset, 3);
|
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]
|
[Fact]
|
||||||
|
|
@ -1133,10 +1149,8 @@ public class PlayerMovementControllerTests
|
||||||
Vector3.One,
|
Vector3.One,
|
||||||
0xA9B40021u,
|
0xA9B40021u,
|
||||||
Vector3.One));
|
Vector3.One));
|
||||||
Assert.Throws<InvalidOperationException>(() => candidate.BlipPosition(
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
Vector3.One,
|
candidate.CommitCanonicalForcePositionFrame());
|
||||||
0xA9B40021u,
|
|
||||||
Vector3.One));
|
|
||||||
Assert.Throws<InvalidOperationException>(() =>
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
candidate.CaptureMovementResult(mouseLookEvent: false));
|
candidate.CaptureMovementResult(mouseLookEvent: false));
|
||||||
Assert.Throws<InvalidOperationException>(() =>
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
|
|
||||||
|
|
@ -2975,10 +2975,8 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
||||||
Vector3.One,
|
Vector3.One,
|
||||||
Cell,
|
Cell,
|
||||||
Vector3.One));
|
Vector3.One));
|
||||||
Assert.Throws<InvalidOperationException>(() => controller.BlipPosition(
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
Vector3.One,
|
controller.CommitCanonicalForcePositionFrame());
|
||||||
Cell,
|
|
||||||
Vector3.One));
|
|
||||||
Assert.Throws<InvalidOperationException>(() =>
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
controller.ApplyPhysicsState(PhysicsStateFlags.Frozen));
|
controller.ApplyPhysicsState(PhysicsStateFlags.Frozen));
|
||||||
Assert.Throws<InvalidOperationException>(() => controller.Yaw = 1f);
|
Assert.Throws<InvalidOperationException>(() => controller.Yaw = 1f);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -246,6 +246,73 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
||||||
Assert.True(runtime.Portal.Snapshot.Completed);
|
Assert.True(runtime.Portal.Snapshot.Completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R2/R3 review fix (2026-08-03). No accepted-position drive is supplied
|
||||||
|
/// (mirrors <see cref="ContentLessDirectSink_KeepsPreFlipLegacyRegistration"/>'s
|
||||||
|
/// "content-less" shape, applied to route 2 specifically), so
|
||||||
|
/// <c>TryExecuteAcceptedLocalPosition</c> can never run and every
|
||||||
|
/// ForcePosition resolves <c>NotApplicable</c> at the call site. Proves
|
||||||
|
/// two things the first implementation pass got wrong: (R2)
|
||||||
|
/// <see cref="IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition"/>
|
||||||
|
/// 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 <c>ProjectPosition</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// C3c-R1 review F6: the drive controller outlives its session routes,
|
/// C3c-R1 review F6: the drive controller outlives its session routes,
|
||||||
/// so "session reset precedes a new route" is an asserted latch, not a
|
/// 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 PositionCount { get; private set; }
|
||||||
public int TeleportStartCount { get; private set; }
|
public int TeleportStartCount { get; private set; }
|
||||||
public int PrepareCount { 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 LastSpawnWasLocal { get; private set; }
|
||||||
public bool LastPositionWasLocal { get; private set; }
|
public bool LastPositionWasLocal { get; private set; }
|
||||||
public PositionTimestampDisposition LastPositionDisposition
|
public PositionTimestampDisposition LastPositionDisposition
|
||||||
|
|
@ -704,6 +773,12 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
||||||
LastPositionDisposition = disposition;
|
LastPositionDisposition = disposition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CenterOnAcceptedForcePosition(RuntimeEntityRecord record)
|
||||||
|
{
|
||||||
|
CenterOnForceCount++;
|
||||||
|
LastCenteredRecord = record;
|
||||||
|
}
|
||||||
|
|
||||||
public void BeginTeleport() => TeleportStartCount++;
|
public void BeginTeleport() => TeleportStartCount++;
|
||||||
|
|
||||||
public RuntimeDestinationReadiness PrepareDestination(
|
public RuntimeDestinationReadiness PrepareDestination(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue