From 36255af0f6c868ae362df64d31aeb92e11b493f4 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Aug 2026 21:03:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20C4=20route=205=20=E2=80=94=20pr?= =?UTF-8?q?ojectile=20authoritative=20placement=20(#276=20partial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports retail's missile Position handling into the canonical Runtime placement owner instead of the deleted ApplyAuthoritativePosition short-circuit. The Create/residence-window halves of the projectile pipeline (RuntimeProjectile binding, TryBind's adopted-body branch, the collision/shadow registration) were already canonical from prior slices; this closes the remaining gap — how an ACCEPTED Position for an in-flight missile is classified, placed, and presented. Byte-decode (Step 1 hard gate, before any code was written): CPhysicsObj::MoveOrTeleport @0x00516330-0x00516438 disassembled from the PDB-paired binary (Capstone, x86 32-bit thiscall). `ret 0x10` establishes four stack args; [esp+0x7c] (arg5, the velocity pointer) is never referenced in any of the three branches (teleport/near/far). The retail reviewer independently reproduced this by searching the whole function body for the `24 7c` mod/rm+disp8 encoding a `[esp+0x7c]` read would require and found zero occurrences. This retired a fabricated `?? Vector3.Zero` fallback in the deleted method — retail's PositionPack::UnPack initializes an absent velocity to zero and MoveOrTeleport never installs it; the projectile's Vector channel (RuntimeProjectilePhysicsUpdater.ApplyAuthoritativeVector) remains the sole velocity authority for a missile. D-P5 in the contract; the Runtime seam commits no velocity from the Position packet at all. The unbound-missile fix: RuntimeEntityObjectLifetime's ClassifyRemoteAcceptedPosition now derives ProjectileAuthoritative from a CONJUNCTIVE predicate — the Missile bit AND a bound RuntimeProjectile whose Body is the canonical PhysicsBody — never the bit alone. Retail places every non-player CPhysicsObj unconditionally (there is no missile-specific placement gate in MoveOrTeleport or its callers), so an unbindable or not-yet-bound missile taking the ordinary remote tail is retail-faithful, not a fallback: the earlier bit-only discriminator would have silently frozen it instead. AP-141 records this as a deliberate, recorded divergence, not fidelity. Retail mechanically WOULD arm a missile's ConstrainTo leash on any nonzero MoveOrTeleport return: HandleReceivedPosition @0x00453FD0's only kind test is player-vs-not, ConstrainTo @0x00454272 has no kind test of its own, and CPhysicsObj::ConstrainTo @0x00510520 creates a PositionManager on demand via MakePositionManager @0x00510523 if one doesn't exist. acdream deliberately does not construct that EntityPhysicsHost/ PositionManager/InterpolationManager chain for a ballistic body — the route-5b split the C4 route 5 contract rejected — so a live missile never shows an armed leash and never catches up via the near/ UnroutedCatchUp policy. This divergence is safe specifically because ACE never sends UpdatePosition for a missile (references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs: 333-334, SendUpdatePosition() commented out inside the PhysicsState.Missile branch at :265) — every half of this row is deterministic-test-gated only, never exercised against a real server. AP-141 also records the surviving ConstrainTo re-anchor divergence under clause (b): for the adopted-body case (TryBind's shared-body branch — an ordinary remote whose Missile bit is set by a later State packet, so it still carries a live RemoteMotion), acdream now ports retail's teleport-branch and far-branch StopInterpolating action (Interp.Clear()), but never re-arms or re-anchors the inherited ConstrainTo leash the way retail's HandleReceivedPosition @0x00454254/@0x00454272 does on every nonzero return. The risk column's earlier wording — that a stale leash "would drag the body toward a stale anchor" — was wrong and is retracted in this same commit: ConstraintManager.ConstraintPos is write-only in both retail and the port (never read by AdjustOffset), and ConstraintManager::adjust_offset @0x00556180 only tapers or zeroes an already-composed per-tick offset while InContact — a leash brakes motion the interp/sticky chain already produced, it cannot pull anything toward the anchor. The real residual is one tick of un-reset brake accumulator, contact-gated, and it cannot move an airborne far-snapped missile at all (the clamp branch does not run while airborne). NO CONNECTED GATE EXISTS for this route, by design: ACE never sends a missile UpdatePosition (see above), so retail's own server never exercises this code path in play. Every proof obligation here is test-gated only — Runtime and App-level fixtures constructing the packet directly — never a live client/server capture. Three review rounds closed 8 MAJOR findings before this landed: round 1 (A1 App discarded the seam's status; A2/R1 silent swallow on an unbound missile; A3/R2 the adopted-body teleport_hook never wired; A4/A5 zero Runtime/App test coverage); round 2 (a ParentCellId regression introduced by round 1's own R6 finding, which the retail reviewer retracted the following round as factually wrong — the fix here is the REVERT to record.FullCellId, not the relocation round 1 shipped; B2 the far-branch StopInterpolating skip never extended to the adopted-body case; residual App/Runtime store- path coverage; a per-packet closure contradicting the file's own #315 cached-delegate pattern). Round 3 closed on coverage alone (no defect): the Advance() retry arm's projectile branch — added at round 2, semantically reordered at round 2's B5 fix (skip prediction invalidation on a re-parked Contention, since it writes nothing) — had never been executed by any test; two new tests drive it directly and are sabotage-verified against both the reordering and the retry-arm's own SyncProjectilePresentation call site. The one recorded defect this campaign produced (the ParentCellId regression) was caused by complying with a review finding that its own author later retracted — the standing lesson recorded for future rounds is that review findings are evidence to re-verify against the code, not commands to obey unconditionally. Complete Release suite: 11,063 passed / 4 skipped / 0 failed (baseline 11,036 at 30d3d114, +27 new tests across this campaign). Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 40 + .../retail-divergence-register.md | 3 +- ...4-c4-route-5-architecture-review-round2.md | 338 +++++++ ...4-c4-route-5-architecture-review-round3.md | 188 ++++ ...26-08-04-c4-route-5-architecture-review.md | 465 ++++++++++ .../2026-08-04-c4-route-5-contract.md | 804 +++++++++++++++++ ...6-08-04-c4-route-5-retail-review-round2.md | 418 +++++++++ ...6-08-04-c4-route-5-retail-review-round3.md | 282 ++++++ .../2026-08-04-c4-route-5-retail-review.md | 465 ++++++++++ .../LiveEntityNetworkUpdateController.cs | 204 ++++- .../Physics/ProjectileController.cs | 157 ++-- .../Entities/RuntimeEntityObjectLifetime.cs | 53 +- .../RuntimeProjectilePhysicsUpdater.cs | 145 +-- .../Physics/RuntimeRemoteFarSnapPosition.cs | 14 + .../RuntimeRemotePlacementDriveController.cs | 309 ++++++- ...ityNetworkOnPositionCollapseMatrixTests.cs | 527 ++++++++++- .../Physics/ProjectileControllerTests.cs | 200 +++-- .../RuntimeProjectilePositionKindTests.cs | 322 +++++++ ...timeRemotePlacementDriveControllerTests.cs | 849 +++++++++++++++++- 19 files changed, 5390 insertions(+), 393 deletions(-) create mode 100644 docs/research/2026-08-04-c4-route-5-architecture-review-round2.md create mode 100644 docs/research/2026-08-04-c4-route-5-architecture-review-round3.md create mode 100644 docs/research/2026-08-04-c4-route-5-architecture-review.md create mode 100644 docs/research/2026-08-04-c4-route-5-contract.md create mode 100644 docs/research/2026-08-04-c4-route-5-retail-review-round2.md create mode 100644 docs/research/2026-08-04-c4-route-5-retail-review-round3.md create mode 100644 docs/research/2026-08-04-c4-route-5-retail-review.md create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeProjectilePositionKindTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9a1d0180..0afa4b9b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,46 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #317 — `TryCommitAuthoritativeVelocity`'s call site has no established retail basis + +**Status:** OPEN +**Severity:** LOW (tracking only; no known observable defect) +**Filed:** 2026-08-04, C4 route 5 (projectile authoritative placement) round-2 +review, MINOR (c) +**Component:** physics / remote velocity + +**Description:** `LiveEntityNetworkUpdateController.cs`'s 4a-family remote +velocity commit (`_liveEntities.TryCommitAuthoritativeVelocity(...)`, call +site around line 2444) carried a comment claiming +"`MoveOrTeleport` installs that exact vector with `set_velocity`." A +byte-level Capstone disassembly of the PDB-paired retail binary +(`CPhysicsObj::MoveOrTeleport` @0x00516330-@0x00516438, every branch, +performed as C4 route 5's mandatory Step 1 hard gate) shows +`MoveOrTeleport` never reads its velocity argument's stack slot at all, and +`UnpackPositionEvent` performs no `set_velocity` either — the only +`set_velocity` call in the whole accepted-Position chain zeroes the LOCAL +player (@0x004541B4), which is a different call site entirely. The comment +at the 4a call site has been corrected in place to state this plainly, but +the call itself was left in production unchanged (out of C4 route 5's +scope — the route's contract governs `RuntimeSetPositionOperationKind` +placement dispatch, not the pre-existing remote velocity commit) and +nothing currently tracks auditing or removing it. + +**Root cause:** unverified assumption inherited from an earlier port pass; +never checked against the named retail decomp until C4 route 5's Step 1 +gate incidentally required decoding the neighboring function. + +**Files:** `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` +(call site + corrected comment, ~line 2420); `LiveEntityRuntime`'s +`TryCommitAuthoritativeVelocity` (the method itself, unaudited). + +**Acceptance:** A dedicated retail audit of the ENTIRE accepted-Position +velocity chain (not just `MoveOrTeleport`) — likely `UpdateObjectInternal`, +`update_object`, and whatever actually feeds the remote's `Velocity` field +retail-side — determines whether this call has a retail basis at all, and +either finds the correct source function to cite or removes the call as an +acdream-only adaptation with a divergence-register row. + ## C4 route 6 — drops and split-recovery closure — 2026-08-04 #313 filed from the route 6 closure session (zero production lines; evidence + diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bcf6cf6a..f8560d4e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -160,7 +160,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 97 active rows (AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 98 active rows (AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -168,6 +168,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-141 | **Filed 2026-08-04, C4 route 5 (projectile authoritative placement); NARROWED 2026-08-04 at the round-2 delta review (B1/B2) — the far-branch clause was factually wrong for the adopted-body case and is corrected below.** Three related projectile-only shapes, all pinned by design (D-P4) rather than ported: (a) the near-`Interpolate` disposition is a NO-OP for a live missile, where retail would lazily build interpolation machinery (`InterpolateTo` @0x005163AF) for it; (b) the post-operation `ConstrainTo` @0x00454272 (`MakePositionManager` @0x00510523 then `PositionManager::ConstrainTo`) is never ARMED for a projectile — retail's single arming site has no kind test, so retail WOULD build a `PositionManager` on demand and arm a missile's leash on any nonzero `MoveOrTeleport` return; acdream never arms it on any disposition, including the adopted-body case (whose PRE-EXISTING leash the teleport/far branches now un-arm or clear queue state for, but never RE-anchor, per retail's post-operation `ConstrainTo`); (c) a null-classified or `Rejected*` accepted Position for a missile is swallowed (write nothing) rather than caught up through any remote-shaped policy. | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`ApplyAcceptedProjectilePosition`) | acdream deliberately does not construct an `EntityPhysicsHost`/`PositionManager`/`InterpolationManager` chain for a ballistic body — the route-5b split the C4 route 5 contract rejected. The context that makes this safe rather than merely convenient: ACE never sends `UpdatePosition` for a missile (`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:333-334`, `SendUpdatePosition()` commented out inside the `PhysicsState.Missile` branch at `:265`) — every half of this row is deterministic-test-gated only, never exercised against a real server. **The far branch's `StopInterpolating` skip is retail-faithful ONLY for a BARE missile** (no `RemoteMotion` — retail's own `position_manager != 0` guard @0x005163C9 skips it for a never-interpolated object, so acdream's skip is faithful by consequence there). For the ADOPTED-BODY case (`TryBind`'s shared-body branch: an ordinary remote whose Missile bit was set by a later State packet, still carrying its `RemoteMotion`), retail's guard IS satisfied and retail WOULD clear the queue — acdream now ports this (`route.StopInterpolating && record.RemoteMotion is RemoteMotion adopted → adopted.Interp.Clear()`), matching the teleport branch's equivalent `StopInterpolating` action inside `teleport_hook`. What remains divergent for the adopted case is the post-operation `ConstrainTo` re-anchor @0x00454272 — retail re-anchors an existing leash at the just-updated position on every nonzero return; acdream never arms/re-anchors it on any projectile disposition (clause (b)). | A future change that DOES give projectiles a `PositionManager` (or a headless/no-window remote-motion consumer that expects one) must re-decide this row rather than silently building the machinery ad hoc; until then, a live missile never shows an ARMED constraint leash and never catches up via the near/UnroutedCatchUp policy — both unreachable in play. An adopted-body missile's INHERITED leash (armed before it became a missile) is un-armed by the teleport hook, has its queue cleared by both teleport and far, but is never re-anchored at the new position by either — its brake accumulator (`ConstraintPosOffset`) is not reset to zero at each accepted Position the way retail's @0x00454272 re-anchor does. **Correction, round 3 (2026-08-04): the round-2 wording here — that a stale leash "would drag the body toward a stale anchor" — was wrong and is retracted.** `ConstraintManager.ConstraintPos` is write-only in both retail and the port (never read by `AdjustOffset`), and `ConstraintManager::adjust_offset` @0x00556180 only tapers or zeroes an already-composed per-tick offset while `InContact` — a leash brakes motion the interp/sticky chain already produced; it has no mechanism to move anything toward the anchor. The real residual is confined to one tick of un-reset brake accumulator, contact-gated, and it cannot move an airborne far-snapped missile at all (the clamp branch does not run while airborne). | `CPhysicsObj::MoveOrTeleport` 0x00516330 (`InterpolateTo` @0x005163AF, `IsMovingTo` @0x0050EB10 returning 0 without a `MovementManager`; far branch `StopInterpolating` @0x005163C9-@0x005163CB); `SmartBox::HandleReceivedPosition` 0x00453FD0 (`ConstrainTo` arming site @0x00454272); `CPhysicsObj::ConstrainTo` 0x00510520 (`MakePositionManager` @0x00510523); `ConstraintManager::adjust_offset` 0x00556180 (brake-only taper, write-only anchor); `WorldObject_Tick.cs:333-334`/`:265` (ACE never-sends evidence) | | AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 2).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, revisioned host receipts, and exact-key retail collision table/environment-latch/report-result state; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another placement queue. Collision starts, expiry/force ends, static and `ReportAsEnvironment` routing, reciprocal eligibility, missile-state clearing, callback ordering, and failed-placement `Collided` versus `NoValidPosition` classification now share one presentation-free owner. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md`; `docs/research/2026-07-31-runtime-set-position-collision-reporting.md` | The mechanism, ownership, report-result oracle, and host seam land independently without partially changing production placement behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owners now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | diff --git a/docs/research/2026-08-04-c4-route-5-architecture-review-round2.md b/docs/research/2026-08-04-c4-route-5-architecture-review-round2.md new file mode 100644 index 00000000..47524feb --- /dev/null +++ b/docs/research/2026-08-04-c4-route-5-architecture-review-round2.md @@ -0,0 +1,338 @@ +# C4 route 5 — architecture / adversarial DELTA review, round 2 (2026-08-04) + +**Verdict: FAIL.** Three findings: two MAJOR, one MAJOR-coverage. Both MAJORs +are narrow and cheap to close (one expression, one call, one assertion, one +test), and one of them (**B2**) is a **new defect introduced by the fix +round**, in the same class as the finding it was fixing. + +Round 1 raised 5 MAJOR + 6 MINOR. Eight are closed well, one is closed for the +teleport half only (**B1**), one is closed but with a regression attached +(**B2**), one is declined acceptably (A11). Detailed disposition in §3. + +Scope: the uncommitted working tree, 1,626 insertions / 398 deletions across 10 +files, against HEAD `30d3d114`. Independent gates run for this review: Release +build green (0 warnings / 0 errors); focused Runtime suites 59/59; focused App +suites (`ProjectileControllerTests` + `LiveEntityNetworkOnPositionCollapseMatrixTests`) +64/64. A green suite is not evidence — every claim below is traced to source. + +--- + +## 1. New MAJOR findings + +### B1 — A3's fix covers only the teleport branch; the adopted-body FAR snap still leaves the interpolation queue armed, and AP-141 now asserts the opposite as retail-faithful + +**Severity: MAJOR (half-closed fix + a register row asserting behaviour the code +does not have — the defect class the contract lists by name).** + +`LiveEntityNetworkUpdateController.cs:2151-2159` runs the six-action hook only +on `SetPosition`: + +```csharp +if (route.Disposition + is RuntimeAuthoritativePositionDisposition.SetPosition + && acceptedPositionCanonical.RemoteMotion is RemoteMotion adoptedRemote) +{ + RunRemoteTeleportHook(...); +} +``` + +Retail's far branch is not covered by `teleport_hook` — it has its own +`StopInterpolating`, guarded on `position_manager != 0` +(@0x005163C9-@0x005163CB), and the remote far arm ports it exactly +(`ApplyAcceptedRemoteFarSnap`: `if (route.StopInterpolating) remote.Interp.Clear();`). +The projectile far arm ports nothing. + +For the **adopted-body** shape — the one A3/R2 established as real and which +this round now has a passing test for +(`MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue`) — the +missile **does** have a `PositionManager` and a populated `Interp` queue, so +retail's guard passes and retail would clear it. + +Concrete scenario: an ordinary remote with a live `Interp` queue gets the +Missile bit from a State packet; `TryBind`'s shared-body branch adopts the same +body; the next accepted Position classifies **far** (>=96 m), not teleport. The +projectile arm places the body at the destination and returns. The surviving +`RemoteMotion` is still in `_spatialRemotes` with a stale waypoint, and the +remote stepper drags the freshly-placed missile back toward it — the exact +symptom A3 described, on the branch the fix did not reach. The +`MissileAdoptedBody_…` test uses `teleportSequence: 5` (teleport); there is no +adopted-body far test. + +Compounding this, **AP-141 (`docs/architecture/retail-divergence-register.md`) +now asserts the false justification unconditionally**: + +> "The far branch's `StopInterpolating` skip is NOT part of this divergence — +> retail's own `position_manager != 0` guard @0x005163C9 already skips it for a +> never-interpolated missile, so acdream's identical skip is retail-faithful by +> consequence." + +That justification is conditioned on "a never-interpolated missile", which the +adopted-body case is not. The row therefore claims retail-faithfulness for a +shape where the code is not retail-faithful. This is the recurring class the +route-5 contract names in its predecessor list ("a register row asserting +behaviour the code does not have") and which AP-136 was previously amended for. + +**Fix direction:** on the `SetPositionSimple` arm, when +`record.RemoteMotion is RemoteMotion adopted && route.StopInterpolating`, clear +`adopted.Interp` before the placement — mirroring `ApplyAcceptedRemoteFarSnap`'s +one line and retail's own ordering (@0x005163CB before @0x005163D9). Then narrow +AP-141's far-branch sentence to the bare-missile case it is actually true of. +Add the adopted-body far test alongside the teleport one. + +--- + +### B2 — R6 traded A1's wrong-cell symptom for a new one: on every STORED outcome the render entity is now positioned at the destination but parented to the cell it left + +**Severity: MAJOR (regression introduced by this fix round).** + +`ProjectileController.SyncPresentationFromResolvedBody:530` changed from +`record.FullCellId` to: + +```csharp +entity.ParentCellId = runtime.Body.CellPosition.ObjCellId; +``` + +Trace the three outcome classes: + +| outcome | `body.Position` after | `body.CellPosition.ObjCellId` | `record.FullCellId` | +|---|---|---|---| +| Committed | resolved destination | result cell (`SnapToCell`) | result cell (`CommitCanonical`'s `SetFullCell`) | +| Stored (`Refused`/`Contention`/`RejectedPreparation`/`NotApplicable`) | **accepted destination, resolved in the WIRE cell's frame** | **source cell — `StoreAcceptedDestinationPose` never writes the cell** | **wire cell — the merge's `RefreshDerivedState` → `SetFullCell`** | +| `Deferred`/`RejectedByPlacement` | ack does not run (A1 gate) | — | — | + +On a committed outcome the two sources are identical, so R6 is a no-op there — +and `MissileTeleportCommit_…` asserts both, confirming the equality rather than +discriminating between the sources. + +On a **stored** outcome they diverge, and R6 picked the wrong one. +`StoreAcceptedDestinationPose:1123-1132` computes +`accepted.PositionX + worldOffset(accepted.LandblockId)` — the world point of +the **wire** cell-local coordinates — and `record.FullCellId` is that same wire +cell. So `record.FullCellId` is the cell the stored position belongs to; +`body.CellPosition.ObjCellId` is the cell the body **left**. + +The inconsistency is visible *inside this fix round*: on the same stored packet, +Runtime's own `SyncProjectilePresentation:1043-1051` publishes the shadow row +with `record.FullCellId` (wire cell) paired with the new position, while App +publishes the render entity with the **source** cell paired with the same new +position. Two presentation surfaces, two different cells, one body. + +Concrete failure: a missile crosses a landblock boundary at the streaming edge; +the destination landblock is not yet in the collision service window, so +`CanAttemptDestination` refuses. The body stores to the destination. The render +entity is placed there but `entity.ParentCellId` is the source cell — +and `ParentCellId` is precisely what +`RetailPViewRenderer.cs:925/945` uses for the indoor/outdoor stage split and +`viewcone.SphereVisibleInCell(e.ParentCellId!.Value, …)`. The arrow is +visibility-tested against a cell it is no longer in: culled, or drawn in the +wrong stage. + +The R6 doc comment's own retail citation argues against its choice — it states +that retail's `store_position` @0x00515CE2 "writes the object's whole +`Position` **including `objcell_id`**". If retail's store wrote the cell, the +body's cell after a store *would be* the wire cell; `record.FullCellId` is the +faithful stand-in for that, and the body's stale cell is the acdream residual +(AP-138), not the truth. + +**Fix direction:** revert `ParentCellId` to `record.FullCellId` (round 1's +value) and keep the A1 status gate, which is what actually fixed the no-op +case. That pairing is correct on all three outcome classes and agrees with +Runtime's own shadow publication. If the body's stale cell is considered the +truth, then `StoreAcceptedDestinationPose` must write the cell too — but that is +AP-138 scope and would change the remote arms as well. + +--- + +### B3 — the one branch where `SyncProjectilePresentation` is the sole writer — spatial+visible on the STORE path — is still unasserted at both layers, and it is exactly where B2 and the surviving A1 sabotage hide + +**Severity: MAJOR (coverage; one assertion + one test wide).** + +I independently reproduced the implementer's "2 of 5" trace and confirm it: + +| new test | discriminates a gutted `SyncProjectilePresentation`? | why | +|---|---|---| +| `TeleportCommit_…ForceEndsCollision…` (shadow assertion) | **No** | the shared pipeline's `ShadowObjects.CommitSetPosition` publishes the same row | +| `FarCommit_…` (shadow assertion) | **No** | same | +| `TeleportCommit_ReenteringWorldReactivatesBody` | **No** | `RuntimeSetPositionState.cs:2983-2985` sets `EnteringWorldFromCelllessResidence \|= !body.InWorld \|\| FullCellId == 0` at **prepare** time (before `SnapToCell`), and `:4979-4984` then sets `Active` + `LastUpdateTime` itself | +| `TeleportCommit_HiddenSuspendsShadowStaysInWorld` | **Yes** | nothing else suspends on Hidden | +| `Refused_NonSpatialDeactivatesAndSuspends` | **Yes** | the Refused path never reaches the engine | + +The implementer's trace is accurate and was reported honestly. But the table +also shows *why* only 2 discriminate: on the committed path the shared pipeline +independently produces the same observable, so `SyncProjectilePresentation`'s +spatial+visible branch is only load-bearing on the **store** path — and no test +asserts it there. `Refused_StillAdvancesPoseNoParkPredictionInvalidated` is +spatial+visible+Refused and asserts `body.Position`, prediction, +`body.InWorld` (vacuous — `AttachBody`'s `SnapToCell` already set it) and +`ObjectClock.IsActive`, but **not the shadow row**. + +The same hole exists at the App layer: the collapse matrix now has teleport +commit, far commit, near no-op, airborne no-op, null classification, unbound +fall-through, and adopted-body teleport — but **no store/refused scenario**. +That is why the reported A1 sabotage survived: with R6 reading the body's own +cell, an unconditional ack on a *no-op* outcome writes nothing observable +(position and cell both unchanged), so the gate is invisible on the no-op path. +It is visible on the **store** path — which is untested. + +Both gaps are cheap: + +- **Runtime**: add `Assert.Equal(body.Position, shadowEntry.Position)` (using + the file's existing `AllEntriesForDebug` pattern) to + `Refused_StillAdvancesPoseNoParkPredictionInvalidated`. Gutting the sync then + leaves the shadow at the spawn pose and the test fails. +- **App**: add a `MissileFarRefused_…` scenario to the collapse matrix. The + fixture already supports it with **no new machinery** — `PublishDestinationCollision()` + and `ServiceWindow.Allow(DestinationLandblock)` are separate opt-in calls, so + simply omitting `Allow` yields `Refused` from `CanAttemptDestination`. Assert + `entity.Position == body.Position` **and** `entity.ParentCellId` against the + wire cell. That single test catches B2 and gives the A1 gate a discriminating + home. + +--- + +## 2. New MINOR findings + +### B4 — the App hook call allocates a per-packet closure, contradicting its own #315-pattern claim + +`LiveEntityNetworkUpdateController.cs:2155-2158` passes +`() => _liveEntities.IsCurrentPositionAuthority(acceptedPositionRecord, acceptedPositionAuthorityVersion)` — +a fresh display class + delegate on every teleport-classified adopted-body +missile packet. The comment immediately above claims it uses "the SAME ordered +hook seam and per-packet currency check the remote teleport arm already uses +(`RunRemoteTeleportHook`, **#315 pattern**)". The remote arm's #315 pattern is +precisely the opposite: `_remoteArmCallbacks` cached delegates over scratch +fields (`:1474-1492`), introduced by the collapse's second commit to remove +per-packet closures from this exact method. D-P4 also states the wiring must be +"without per-packet closures". Narrow reach (SetPosition + RemoteMotion +present), so MINOR — but the comment asserts compliance the code does not have. + +### B5 — the retained-retry arm invalidates prediction on an outcome that writes nothing + +`RuntimeRemotePlacementDriveController.Advance:1265-1268`: the else branch calls +`pendingProjectile?.InvalidatePrediction()` and then `SubmitAndResolve`. When +`SubmitAndResolve` returns `Contention` (retryable → re-parked into `_pending`), +`Advance` performs **no** `StoreAcceptedDestinationPose` — so nothing was +written, yet the prediction version advanced. Contract invariant 4 pins +"prediction invalidation accompanies every body write on this route… The no-op +dispositions invalidate nothing." Effect is one aborted in-flight quantum per +re-park; small, but it is the invariant's stated shape violated on one arm only. +The entry point does not have this problem (Contention there always stores). + +--- + +## 3. Round-1 findings — disposition + +| # | status | verification | +|---|---|---| +| **A1** (unconditional ack) | **CLOSED, gate correct — but see B2** | `:2170-2179` gates on `placementStatus is not null and not Deferred and not RejectedByPlacement`, an exact set-equality mirror of the Runtime gate (`:975-983` plus the `null` returns). Verified by enumerating every `RuntimeRemotePlacementExecutionStatus` producer. | +| **A2** (Missile bit without a bound projectile) | **CLOSED, well** | The discriminator became conjunctive at the classifier (`RuntimeEntityObjectLifetime.cs:673-677`) with the App's null-route fallback mirroring it exactly (`:2122-2131`). Retail justification is sound and better than my suggested fix: retail places every non-player object unconditionally, so an unbindable missile taking the ordinary remote path *is* the faithful behaviour, not a fallback. Blast radius enumerated below. `MissileUnbound_FallsThroughToRemoteTail_TracksInsteadOfFreezing` is discriminating (revert the conjunct and the body never moves). | +| **A3** (teleport hook) | **PARTIALLY closed — see B1** | Teleport branch wired through the existing `RemoteTeleportHook` bundle with a real per-packet currency guard; the adopted-body test is genuinely discriminating (removing the call leaves `Constraint.IsConstrained` true and `Interp.IsActive` true). Far branch not covered. | +| **A4** (sync untested) | **PARTIALLY closed — see B3** | 2 of 5 new tests discriminate; the store-path spatial+visible branch remains unasserted. | +| **A5** (no App missile coverage) | **CLOSED, well** | 7 tests on the shared collapse fixture. Fixture-regression check below. | +| **A6** (`wasInWorld` read after placement) | **CLOSED (behaviourally inert, correctly so)** | `wasInWorld` is now captured before dispatch at both call sites (entry point `:942`, retry `:1226`) and threaded as a parameter. Note it has **no observable effect**: on commit, `EnteringWorldFromCelllessResidence` already re-activates from the same pre-`SnapToCell` condition; on store, `body.InWorld` is unchanged so before == after. Correct and trap-removing either way. | +| **A7** (hidden `LastUpdateTime`) | **CLOSED** | Restored at `:1055`. Clock basis verified equivalent: `UpdateFrameOrchestrator.CurrentScriptTime => _runtime.SimulationTimeSeconds`, i.e. `_physicsScriptGameTime` and `_clock.SimulationTimeSeconds` are the same clock, so no mixed-basis write into `body.LastUpdateTime`. | +| **A8** (silent shadow skip) | **CLOSED** | `else { physics.ThrowIfWorldFrameUnreachable(record.FullCellId); }` at `:1053`. Verified non-throwing during the legitimate pre-Create window (`RuntimePhysicsState.cs:596-606` returns early unless the local-player Create was observed with a zero frame). | +| **A9** (local player not fenced) | **CLOSED** | `update.Guid != _playerServerGuid &&` added to the fallback (`:2125`). | +| **A10** (`OwnsFarSnap` doc) | **CLOSED** | Doc-only change; the predicate body is byte-identical (verified — the diff adds only comment lines). No effect on the far arm the collapse and 4b-2 gated. | +| **A11** (stale cutover inventory) | **DECLINED — acceptable** | `docs/research/2026-08-02-cutover-route-inventory.md` is a dated research/planning record, and the project's documentation rules treat those as historical. D-P7's grep was scoped to `src/` and `docs/architecture/`, both of which are clean. Reasonable call; the risk is a future reader treating a dated inventory as current, which the date already signals. | +| **`_lastFiniteGameTime`** | **CLOSED** | `SyncPresentationFromResolvedBody:521-522` restores the rebase under `double.IsFinite`, matching the deleted method's semantics (it also skipped the assignment on a non-finite clock). | + +### A2's fix — blast radius, enumerated + +Everything that now depends on the conjunctive kind derivation, and its status: + +1. `RuntimeAcceptedPositionRouteRequests.TryBuild` → `RuntimeAuthoritativePositionRouteClassifier` — re-verified that the classifier's only kind branch past `ValidEntityKind` is `LocalPlayer`, so `Projectile` and `Remote` remain disposition-, flag-, `StopInterpolating`- and `ConstrainPhase`-identical. Kind changes the `OperationKind` only. +2. `RuntimeRemotePlacementDriveController.OwnsPlacement` — admits both; single production reader (`TryExecuteAcceptedRemotePosition:640`). +3. `RuntimeRemoteFarSnapPosition.OwnsFarSnap` / `RuntimeRemoteTeleportPosition.OwnsTeleportPlacement` / `ApplyAcceptedRemoteFarSnap` / `ApplyAcceptedRemoteTeleport` / `TryArmConstraintAfterOperation` — still `RemoteAuthoritative`-gated. An **unbound** missile now legitimately reaches them (it is classified `Remote`), which is the intended pre-route-5 behaviour; a **bound** one never can. The throwing guards stay unreachable for projectiles. +4. App `isMissilePacket` — derived from `route.OperationKind` whenever a route exists, so it cannot disagree with the classifier; the null-route arm restates the identical conjunct. +5. **Kind is now derived from mutable binding state**, so it can flip between packets without the Missile bit changing. The one place that stores a kind across packets is `_pending[key].Route`; `Advance` re-validates `pending.Record.Projectile` and body identity before syncing (`:1214-1224`), and `TryPrepareAndSubmitAuthoredPlacement` compares `route.OperationKind` against the `operation.Kind` stamped at the same Begin. Consistent — this was handled deliberately (R3), not by luck. +6. First-entry admission, the residence Create half, and the continuation executor all pass explicit kinds and are untouched. + +I found no dependent that broke. + +### A5's fix — did the shared fixture regress for its original dual-guid purpose? + +**No.** Verified line by line: + +- The remote construction is moved verbatim into an `else` branch; the + `RemoteMotion` + `Shadows.Register(… Cylinder, cylHeight: 1.835f …)` block is + byte-identical to the original. +- `baseState` evaluates to exactly `PhysicsStateFlags.ReportCollisions` when + `isMissile: false`, so both `RawState` and `PhysicsState` are unchanged for + every pre-existing test (all of which use the default `isMissile: false`). +- `Remote` stays `null!` for missile fixtures; no missile test dereferences it. + The two tests that do use `fixture.Remote` (`MissileUnbound_…`, + `MissileAdoptedBody_…`) both construct with `isMissile: false` — deliberately, + because both scenarios *are* remote-shaped records that acquire the Missile + bit later. That is the right modelling. +- The new `Projectile` property is additive. +- All pre-existing dual-guid tests in the file pass in the 64/64 run. + +`MissileAdoptedBody_…` is worth calling out as a genuinely good test: it builds +the adopted-body shape through the production seam (`BindProjectile` over the +record's canonical body, which `SetRemoteMotion` had already adopted from the +component), and its two discriminating assertions (`Constraint.IsConstrained`, +`Interp.IsActive`) fail if the hook call is removed. + +--- + +## 4. The two reported residuals — judgment + +### Residual 1 — A1's fix has no App-level regression test + +**Judgment: the stated gap is acceptable; the surrounding gap is not.** + +The narrow claim is correct and I verified it: `Deferred` requires a +`DeferredCell` park (collision-generation quiescence) and `RejectedByPlacement` +requires the engine's own sweep to refuse, and no sibling remote test in that +file constructs either — the fixture has no machinery for it. The gate is a +literal set-equality mirror of a Runtime gate whose behaviour *is* exercised by +`Refused`/no-op/rejected tests, so code review is a defensible verification for +those two statuses specifically. + +But the reason the sabotage survived is not that `Deferred` is unreachable — it +is that **R6 made the ack idempotent on the no-op path**, so the gate has no +observable effect on any scenario the matrix currently contains. The status the +gate matters for that *is* trivially constructible is the store path, and the +fixture already supports it (omit `ServiceWindow.Allow`). That test is required +(B3), and it also catches B2. So: accept the `Deferred`/`RejectedByPlacement` +carve-out, reject the absence of any store-path App scenario. + +### Residual 2 — only 2 of 5 A4 tests discriminate + +**Judgment: 2 is not sufficient for invariant 2, but the shortfall is one +assertion, not a suite.** + +The trace is accurate (I reproduced all five verdicts independently, including +the non-obvious `EnteringWorldFromCelllessResidence` mechanism behind the +reactivation test). Keeping the redundant assertions as correct facts is the +right call — they are true, cheap, and they pin the shared pipeline's +contribution. + +The shortfall is specific: the spatial+visible branch is redundant **only on the +committed path**. On the store path `SyncProjectilePresentation` is the sole +writer of the shadow row, and no test asserts it. Invariant 2 is "#312's layer" +— an entity left rendered a packet behind its body — and the store path is +exactly the outcome class where that can happen without the shared pipeline +noticing. One `Assert.Equal(body.Position, shadowEntry.Position)` in the +existing `Refused_…` test converts the third branch from redundant to +discriminating and closes invariant 2 at the Runtime layer. Combined with the +App store scenario from B3, invariant 2 is then covered at both layers by +discriminating assertions. + +--- + +## 5. What would make this PASS + +1. **B2**: `entity.ParentCellId = record.FullCellId` (keep the A1 gate). One + expression. +2. **B1**: clear the adopted `Interp` queue on the `SetPositionSimple` arm when + `route.StopInterpolating`; narrow AP-141's far-branch sentence to the + bare-missile case. One call + one clause + one test. +3. **B3**: one shadow assertion in `Refused_StillAdvancesPoseNoParkPredictionInvalidated`; + one `MissileFarRefused_…` App scenario asserting position **and** + `ParentCellId` (which is the B2 regression test). +4. **B4/B5**: cached delegate for the hook currency check; move the retry arm's + `InvalidatePrediction` to the paths that actually write. + +Nothing here needs new machinery, a new fixture, or a design change. diff --git a/docs/research/2026-08-04-c4-route-5-architecture-review-round3.md b/docs/research/2026-08-04-c4-route-5-architecture-review-round3.md new file mode 100644 index 00000000..72e6a512 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-5-architecture-review-round3.md @@ -0,0 +1,188 @@ +# C4 route 5 — architecture / adversarial DELTA review, round 3 (2026-08-04) + +**Verdict: FAIL — one finding (C1), coverage-only, no identified defect.** + +Every behavioural finding from rounds 1 and 2 is now closed and independently +verified. The single remaining item is that the `Advance()` retry arm's +projectile branch — added in round 2 (R3) and semantically modified in round 3 +(B5, a prediction-invalidation reordering) — is executed by **no test at any +layer**. I traced the reordering and believe it is safe; but "safe by reading" +is the standard this project rejects, and it is the standard I applied to the +implementer twice (A4, A5). The templates to close it are already in the same +file. **The bar for round 4 is one test.** + +Gates run for this review: `dotnet build AcDream.slnx -c Release --no-incremental` +— **0 errors, 21 warnings, all pre-existing and none in any route-5 file** (they +are in `AcDream.App.Tests/Composition`, `AcDream.Core.Tests` nullability and +xUnit-analyzer nits; my earlier "0 warnings" readings were incremental builds +that skipped those projects — no regression). Focused Runtime suites 59/59; +focused App suites including `UpdateFrameOrchestratorTests` 94/94. + +--- + +## 1. Finding + +### C1 — the `Advance()` retry arm's projectile branch has never been executed by a test, and round 3 changed its prediction-invalidation semantics + +**Severity: MAJOR (coverage). No defect identified — the code reads correct.** + +`RuntimeRemotePlacementDriveController.Advance:1221-1305` contains the R3 +projectile branch (kind/identity re-validation, `pendingWasInWorld` capture, +prediction invalidation, `SyncProjectilePresentation`) and the B5 reordering. +All six `drive.Advance()` call sites in +`RuntimeRemotePlacementDriveControllerTests.cs` (`:533`, `:549`, `:592`, +`:1015`, `:1021`, `:1755`) belong to **remote**-kind tests. No test parks a +`ProjectileAuthoritative` retry and pumps `Advance()`. + +What is therefore unexercised: + +- prediction invalidation on a retried projectile placement (trap T3 — a + straddling quantum clobbering a committed placement is exactly what this + guards); +- the B5 semantic change: invalidation **skipped** when `SubmitAndResolve` + returns `Contention`; +- `SyncProjectilePresentation` on the retry arm (invariant 2 on a second call + site); +- the stale-route guard (`pending.Route.OperationKind` + body/component + re-validation) that exists precisely because a retry can outlive its + binding. + +The R3 comment states the intent plainly — *"Neither invariant this route pins +… may hold on the direct arm only"* — and both invariants are, in fact, +asserted only on the direct arm. + +Reachability: `_pending` receives a projectile entry whenever +`SubmitAndResolve` returns a retryable preparation status +(`RetrySetupUnavailable`/`RetryWorldFrameUnavailable`) for a +`ProjectileAuthoritative` route — an ordinary streaming-edge condition — and +`Advance()` is the host cadence pump. Standing caveat: like the whole route, +ACE-unreachable in play. + +**Fix direction (cheap — both templates are in the same file):** rebuild +`FarSnap_RetryablePreparation_StoresThePoseAndStillRetainsTheRetry` (`:985`) +and `Advance_DestinationLeavesTheWindow_StoresTheNewestDestinationPose` +(`:1707`) against `CreateProjectileRecord` + a `ProjectileAuthoritative` route +— the same borrowing the projectile ledger test already did from +`Teleport_LedgerConverges_…`. Assert, on the retry: the pose advanced, the +prediction version moved on the storing outcomes and **did not** move on a +re-parked `Contention`, and the shadow row followed the body. + +--- + +## 2. The B5 reordering — safety verified + +Asked to scrutinise this specifically. **It is safe**, and for a reason +stronger than the comment's "single-threaded and synchronous". + +The invalidation exists solely so a straddling quantum aborts at `Complete`. +`PredictionAuthorityVersion` has exactly four consumers, enumerated across +`src/`: `RuntimeProjectilePhysicsUpdater.TryBegin`/`Complete`/`IsIdentityCurrent`, +`RuntimePhysicsState.CommitProjectileCell`'s `IsExactOwner`, and +`ProjectileController.IsCurrentQuantumIdentity` (`:837`). The only callers of +`CompleteQuantum` are `ProjectileController.AdvanceQuantum` (`:760`) and +`LiveEntityAnimationScheduler` (`:419`) — both frame-loop driven. Nothing in +the placement pipeline's synchronous publish chain (placement projection sink, +`RebucketLiveEntity`'s visibility callbacks, collision-report observers) can +reach either. Notably, the one projection callback that *does* re-enter the +projectile controller — `OnProjectionVisibilityChanged` → `TryBind` — returns +early on a retained runtime and never touches a quantum. So no `Complete` can +interleave between the write inside `SubmitAndResolve` and the invalidate on +the next statement. + +The skip on `Contention` is also correct: `Contention` from `SubmitAndResolve` +means preparation returned a retryable status, so `PhysicsEngine.SetPosition` +never ran and nothing wrote the body — verified by walking +`TryPrepareAndSubmitAuthoredPlacement`'s non-`Prepared` path, which touches +only `operation.*` fields and reads (`body.InContact`/`OnWalkable`), never the +body's pose. + +Two ordering details I checked and found sound: the `Refused` branch still +invalidates **before** `StoreAcceptedDestinationPose` (the one body write on +that arm); and `CancelToken`'s synchronous cancellation receipt — which the +class doc warns can delete or replace the incarnation — is harmless here +because `pendingProjectile` is a captured strong reference (invalidating a +displaced component is inert, and a displaced component's quantum already +fails `IsIdentityCurrent`'s `ReferenceEquals(record.Projectile, projectile)`), +while both `StoreAcceptedDestinationPose` and `SyncProjectilePresentation` +re-validate currency at entry. + +Honest note on the invariant's status: contract §5 item 4 says invalidation is +"before the write". On this arm it is now after, resting on a reachability +argument rather than structural ordering. The argument holds today; it is +recorded here so a future change that lets a projection callback drive a +quantum knows it broke something. The comment states the reasoning openly, +which is the right handling. + +--- + +## 3. The indoor staging in the B2 regression test — legitimate, and it corrects my round-2 finding + +**Judgment: the staging is legitimate, the test genuinely pins production +behaviour, and the diagnosis behind it is correct.** + +I verified the mechanism at source. `PhysicsBody.Position`'s setter +(`PhysicsBody.cs:153-162`) calls `SyncCellPositionDelta(delta)` on every write, +including `StoreAcceptedDestinationPose`'s. That method (`:287-308`) has two +branches: + +- **outdoor** (low word `1..0x40`): `LandDefs.AdjustToOutside` re-derives the + cell index from the shifted local origin *and* bumps the landblock on a + 192 m crossing — so after a store to a cross-landblock destination the body's + own cell id **self-corrects to the destination cell**; +- **indoor** (low word outside that range — an EnvCell id): the local origin + shifts but the cell id is kept verbatim, so it stays pinned at the source + EnvCell. + +So the implementer's finding is right, and it **corrects my round-2 B2**: the +defect I reported was real but its blast radius was narrower than I stated. My +stated scenario ("a missile crosses a landblock boundary at the streaming +edge") is an *outdoor* case, which self-heals through `AdjustToOutside`; the +genuinely divergent case is a body in an EnvCell — a bolt fired inside a +dungeon. That correction is recorded here rather than smoothed over. + +The staging is not a fixture artefact: +`IndoorSourceCell = SourceLandblock | 0x0100` is a well-formed EnvCell id (the +first EnvCell index), and `SnapToCell` seeds indoor claims verbatim by design +(`PhysicsBody.cs:190-205`, "Indoor EnvCell claims (low word >= 0x100) … seeded +verbatim"). The scenario — indoor body, outdoor wire destination, service +window refuses — is production-reachable. + +One asymmetry worth naming, which does not affect the pin: the test stages the +indoor cell on the *body* while leaving `record.FullCellId` at the outdoor +`SourceCell`, a pairing production would not produce. It is inert here because +the merge overwrites `record.FullCellId` with the wire cell before the ack +runs, so the pre-packet value is never read. The assertion depends only on the +two expressions diverging, which they genuinely do. + +Discrimination confirmed by construction: the test asserts +`body.CellPosition.ObjCellId == IndoorSourceCell` (the divergence actually +occurred) and then `entity.ParentCellId == DestinationCell`. Restoring round +1's `body.CellPosition.ObjCellId` read fails the second assertion. + +--- + +## 4. Round-2 findings — disposition + +| # | status | verification | +|---|---|---| +| **B1** (adopted-body far snap leaves `Interp` armed; AP-141 asserts the opposite) | **CLOSED, well** | The clear landed in the Runtime seam at `ApplyAcceptedProjectilePosition`'s `SetPositionSimple` case, **before** the placement — matching retail's @0x005163CB-before-@0x005163D9 ordering — guarded on `route.StopInterpolating && record.RemoteMotion is RemoteMotion`, the exact shape `ApplyAcceptedRemoteFarSnap` ports. Correctly scoped to one action, not the full hook. `MissileAdoptedBody_FarCommit_ClearsInterpQueueButLeavesConstraintArmed` is discriminating in both directions: it pins the queue clear **and** pins that `UnConstrain` did *not* run, proving the fix did not over-apply the teleport hook to the far branch. AP-141 is now narrowed to "faithful ONLY for a BARE missile" and additionally names the surviving `ConstrainTo` re-anchor divergence for the adopted case — a more accurate row than before the finding. | +| **B2** (`ParentCellId` regression) | **CLOSED, well** | Reverted to `record.FullCellId`; the A1 gate kept. The doc now carries three independent justifications, of which two I verified directly (the sibling remote arm pairs wire pose with wire cell; Runtime's own `SyncProjectilePresentation` publishes the shadow at `record.FullCellId` in the same call, so the body-cell read would have disagreed with the shadow for one body in one packet) and the third is retail's `store_position` @0x00515CE2 writing `objcell_id`. Regression-tested — see §3. | +| **B3** (store path unasserted at both layers) | **CLOSED at both layers** | Runtime: `Refused_StillAdvancesPoseNoParkPredictionInvalidated` now asserts `shadowEntry.Position == body.Position`; on the `Refused` path nothing but `SyncProjectilePresentation` publishes the shadow, so it discriminates. App: `MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithWireCell` asserts entity position and `ParentCellId`; deleting the ack leaves the entity at its spawn pose. What remains untested from B3's neighbourhood is only the A1 gate's own two statuses (`Deferred`/`RejectedByPlacement`), which round 2 already accepted as an unconstructible carve-out, and the retry arm (C1). | +| **B4** (per-packet closure) | **CLOSED, correctly** | `IsCurrentProjectilePositionOwner` is now a third cached delegate inside `RemoteArmCallbacks`, bound to `IsCurrentProjectileArmPositionOwner`, reading `_projectileArmPosition*` scratch fields stamped immediately before use — byte-for-byte the same shape as the existing `_remoteArmPosition*` / `IsCurrentRemoteArmPositionOwner` pair (`:92-93`, `:1467-1468`, `:1502-1506`). Same reentrancy exposure as the incumbent #315 pattern, therefore parity rather than a new hazard. The `UpdateFrameOrchestratorTests` zero-`Delegate`-field guard holds — it ran green inside the 94/94 App suite, and the cache stayed inside the named type rather than becoming bare fields. | +| **B5** (retry arm invalidating with no write) | **CLOSED — see §2** | Correct, and the safety of the reordering verified independently. Untested (C1). | +| Round-1 **MINOR (c)** (4a velocity comment with no retail basis) | **FILED as #317** | `docs/ISSUES.md` — accurate description, correct scoping rationale, and an acceptance criterion that names the actual work (audit the whole accepted-Position velocity chain, then cite or remove with a register row). Better handling than a silent comment fix. | + +No round-1 or round-2 finding regressed, and nothing in this round's diff +introduced a new behavioural defect that I could identify. + +--- + +## 5. What would make this PASS + +One test: a `ProjectileAuthoritative` retained retry driven through +`drive.Advance()`, asserting the pose advanced, the prediction version moved on +a storing outcome and did **not** move on a re-parked `Contention`, and the +shadow row followed the body. Two existing remote tests in the same file are +the templates. + +Nothing else is outstanding. diff --git a/docs/research/2026-08-04-c4-route-5-architecture-review.md b/docs/research/2026-08-04-c4-route-5-architecture-review.md new file mode 100644 index 00000000..173735ce --- /dev/null +++ b/docs/research/2026-08-04-c4-route-5-architecture-review.md @@ -0,0 +1,465 @@ +# C4 route 5 — architecture / adversarial review (2026-08-04) + +**Verdict: FAIL.** + +Scope reviewed: the uncommitted working tree at HEAD `30d3d114`, branch +`claude/acdream-physics-divergence-5aa784` — `git diff HEAD` over 8 files plus +the untracked `tests/AcDream.Runtime.Tests/Entities/RuntimeProjectilePositionKindTests.cs`. +`docs/research/2026-08-04-c4-route-5-contract.md` is the contract, not under +review. Line numbers below are as-of the working tree and will go stale; every +citation also names the symbol (process rule 6). + +Independent verification performed for this review: `dotnet build AcDream.slnx -c Release` +(succeeded, 0 warnings / 0 errors), focused Runtime suites 58/58 green, focused +`ProjectileControllerTests` 41/41 green. The complete Release suite was NOT run +here. + +The design is sound and the Runtime seam is largely a faithful, well-argued +reproduction. The FAIL rests on four things: two real defects in the ~35 lines +of App dispatch glue (A1, A2), one pinned contract obligation left unwired with +a concrete failure scenario (A3), and the fact that the presentation invariant +(§5 item 2, "#312's layer — tests must assert it") and the entire App dispatch +have **zero** test coverage (A4, A5) — which is also the direct answer to the +scoping-gap question at the end. + +--- + +## MAJOR findings + +### A1 — the App projectile ack ignores the seam's status and writes cell identity on the outcomes the design pins as "write nothing" + +**Severity: MAJOR.** +`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2137-2144` +(`OnPosition`, the D-P6 projectile arm): + +```csharp +if (earlyRemoteRoute is { } route) +{ + _remotePlacementDrive.ApplyAcceptedProjectilePosition( + acceptedPositionCanonical, + route); + _projectileController?.SyncPresentationFromResolvedBody( + acceptedPositionRecord); +} +return; +``` + +The returned `RuntimeRemotePlacementExecutionStatus?` is discarded. The Runtime +half is deliberately selective — `RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition:975-983` +skips `SyncProjectilePresentation` for `Deferred`/`RejectedByPlacement` +("Invariant 2: presentation advances on every committed/stored outcome only") +and returns `null` without touching anything for `Interpolate`, +`NoPositionOperation`, `RejectedAuthority`, `RejectedData`, and every ownership +mismatch. App re-projects presentation for **all** of them. + +Concrete failure scenario (the near no-op, D-P2's pinned `Interpolate` row): + +1. An in-flight arrow's canonical body sits in cell A at world P_A. +2. A grounded, <96 m accepted Position for cell B arrives. + `RuntimeEntityObjectLifetime.TryApplyPosition` merges it; the merge's + `RefreshDerivedState` → `SetFullCell` (`RuntimeEntityRecord.cs:230-251`) + stamps `canonical.FullCellId = B` **before** any classification runs. +3. The classifier returns `Interpolate` — a pinned no-op. The Runtime seam + writes nothing: body stays at cell A / P_A, prediction version unchanged. +4. App nevertheless runs `ProjectileController.SyncPresentationFromResolvedBody:505-520`, + which does `entity.SetPosition(runtime.Body.Position)` (= P_A, correct) and + `entity.ParentCellId = record.FullCellId` (= **B**, wrong — `LiveEntityRecord.FullCellId` + is `Canonical.FullCellId`, `LiveEntityRuntime.cs:189-191`). + +The render entity is now parented into a cell it is not geometrically inside, +with a position from the other cell. If B is an indoor EnvCell the arrow has not +entered, the arrow renders through the wall or is culled. The same write happens +on `RejectedByPlacement`, where the Runtime comment says in as many words that +the body must be left "at its prior (already-synced) pose". + +This is a genuine regression, not parity: the deleted `ApplyAuthoritativePosition` +had no disposition concept — it always moved the body to the wire cell *and* +wrote the same cell into the entity, so position and cell were always +consistent. Splitting the body write out by disposition without splitting the +presentation write out with it is what creates the divergence. + +**Fix direction:** return the status through the App boundary and call +`SyncPresentationFromResolvedBody` only on the outcomes `SyncProjectilePresentation` +itself covers (non-null status that is neither `Deferred` nor +`RejectedByPlacement`) — or move the render-entity projection into the same +gate inside the Runtime seam and have App acknowledge, not decide. + +--- + +### A2 — a record carrying the Missile bit with no bound `RuntimeProjectile` now has every accepted Position silently dropped, where it previously fell through to the remote tail + +**Severity: MAJOR (silent, permanent).** +Discriminator: `LiveEntityNetworkUpdateController.cs:2122-2146` (`isMissilePacket`); +refusal: `RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition:930-936` +(`record.Projectile is not RuntimeProjectile projectile` → `return null`). + +`isMissilePacket` keys purely on the canonical record's Missile bit. It does not +require that a projectile component exists. The old short-circuit did: the +deleted `ProjectileController.ApplyAuthoritativePosition` returned `false` when +`TryGetCurrent` found no bound `RuntimeProjectile`, and `== true` failing meant +the packet **fell through** to the generic remote tail. + +`record.Projectile` has exactly one producer: `ProjectileController.TryBind` → +`LiveEntityRuntime.BindProjectileRuntime` → `RuntimePhysicsState.BindProjectile` +(verified by grep — no other writer of `Entities.SetProjectile(record, component)`). +`TryBind` has several ordinary, non-exceptional failure returns, e.g. +`ProjectileController.cs:160-166`: + +``` +"Missile 0x… Setup 0x… does not have the supported retail one-sphere collision shape." +``` + +Scenario: ACE spawns a missile whose Setup does not reduce to the supported +one-sphere shape (a diagnosed, expected case — it has its own log line). +`TryBind` returns false; `record.Projectile` stays null; `FinalPhysicsState & +Missile` stays set. Every subsequent accepted Position for that guid now returns +at `:2146` having done nothing at all — no `TryApplyGenericRemoteRenderPose`, no +`RebucketLiveEntity`, no `GetOrCreateRemoteMotionRuntime`. The object freezes at +its create pose for the rest of its life and never follows the server. Before +this diff it tracked, via the remote tail. + +The contract's D-P6 does pin "dispatch the projectile arm and RETURN", so this is +a defect in the design as implemented rather than a slip against it — but it is +still a new, silent, permanent freeze, and the same defect class (`the frozen +entity`) that the 4b-family reviews have been hunting. Reachability caveat, +stated honestly: ACE never sends a missile `UpdatePosition` +(`WorldObject_Tick.cs:333-334`), so like every other half of this route it is +test-reachable only. + +**Fix direction:** make the discriminator conjunctive — a packet takes the +projectile arm only when the arm can actually own it (Missile bit **and** a +bound projectile whose `Body` is the canonical `PhysicsBody`); otherwise let it +take the ordinary tail, which is what the record's shape actually is. If the +swallow is intended, it needs its own register row and a test that pins it, and +neither exists. + +--- + +### A3 — D-P4's teleport-hook reduction is unwired for the adopted-body case, which the codebase explicitly supports + +**Severity: MAJOR (contract obligation unmet; concrete scenario).** +`RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition:944` +runs exactly one of retail's six `teleport_hook` @0x00514ED0 actions: + +```csharp +case RuntimeAuthoritativePositionDisposition.SetPosition: + _entityObjects.Physics.CollisionReports.LeaveWorld(record); +``` + +The contract pins more than that: *"The other five actions execute iff their +owning component exists — for the adopted-body case (a missile that also carries +a `RemoteMotion`), the existing hook actions' per-manager guards already express +retail's shape; the implementer wires this without per-packet closures"* (D-P4). +Nothing wires them, and no note records the omission. + +The adopted-body case is not hypothetical. `ProjectileController.TryBind`'s +shared-body branch exists precisely for it — *"If a non-missile incarnation +already created its MovementManager or entered the Physics-Static animation +workset, classification adopts that same body"* — and +`RuntimePhysicsState.BindProjectile:757-830` does not exclude a record that has +a `RemoteMotion`. `RuntimePhysicsState.cs:697-708` shows the same key can sit in +both `_spatialRemotes` and `_spatialProjectiles`. + +Scenario: an object is a live remote with a populated `RemoteMotion.Interp` +queue and a `ConstrainTo` leash armed by route 4a's +`RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation`. ACE then sets +the Missile bit (a State packet); `TryBind` adopts the shared body. The next +teleport/cell-less Position takes the projectile arm: collisions are force-ended +and the body is teleported, but `StopInterpolating`, `UnConstrain`, +`CancelMoveTo`, and `UnStick` never run. The `RemoteMotion` survives in the +remote workset with a stale waypoint and a live leash anchored at the +pre-teleport position, and drags the newly-placed missile body back. Retail +@0x00514EFD/@0x00514F31 runs all six, each guarded on its own manager, so +retail's own guards would no-op for a pure arrow and would fire here. + +Note this is not a regression from HEAD — the old short-circuit did not run the +hook either — but D-P4 made wiring it an explicit obligation of this slice, and +the faithful port (`RemoteTeleportHook.Execute` + `RemoteTeleportHookActions`, +`src/AcDream.App/Physics/RemoteTeleportHook.cs`) is sitting in-tree unused for +this arm. That is exactly the 4b-3 round-2 R2 defect class the contract cited by +name. + +**Fix direction:** drive the projectile teleport arm through the same six-action +bundle (cached delegates, #315 pattern), letting each action's per-component +guard decide — for an ordinary arrow all five extra actions are no-ops, and the +adopted-body case gets retail's shape for free. + +--- + +### A4 — `SyncProjectilePresentation`, the highest-risk reproduced code in the diff, has no test that would fail if it were deleted + +**Severity: MAJOR (test gap on the invariant the contract singles out).** +`RuntimeRemotePlacementDriveController.SyncProjectilePresentation:987-1039`. + +Contract §5 invariant 2: *"Presentation still advances… the shadow registry is +synced (spatial+visible) or suspended (hidden/non-spatial) exactly per the +current tail's semantics. A projectile is never left rendered a packet behind +its body (#312's layer — **tests must assert it**)."* + +Across all seven new Runtime tests, the only assertion that even touches this +method is `Assert.True(body.InWorld)` in +`ApplyAcceptedProjectilePosition_Refused_StillAdvancesPoseNoParkPredictionInvalidated`. +That assertion is vacuous with respect to the sync: the fixture's `AttachBody` +(`RuntimeRemotePlacementDriveControllerTests.cs:2860-2880`) calls +`body.SnapToCell(...)`, and `PhysicsBody.SnapToCell` sets `InWorld = true` +(`PhysicsBody.cs:201-205`). Nothing in the Refused path clears it. So the +assertion passes with `SyncProjectilePresentation` entirely removed. + +Unasserted, at any layer: the shadow-registry publication +(`ShadowObjects.UpdatePosition`), the spatial+hidden `Suspend`, the non-spatial +`InWorld = false` + `Active` clear + `Suspend`, the `!body.InWorld` activation +edge, and the three currency guards at the method's head. The committed-outcome +tests (`…TeleportCommit…`, `…FarCommit…`) assert status, position, prediction +version, velocity, `RemoteMotion is null`, and collision-owner count — none of +which the sync produces. + +**Fix direction:** one test per branch of the sync, keyed on the observable the +old tail produced: shadow row present at the resolved pose (spatial+visible), +`Suspend` called (spatial+hidden and non-spatial), `Active` cleared +(non-spatial). These are cheap; the fixture already registers shadows in +`SeedCollisionOwner`. + +--- + +### A5 — no App-layer test exercises `OnPosition` with a missile packet at all; §7 items 8 and 9 are unwritten + +**Severity: MAJOR (this is the scoping-gap answer).** + +`grep -rn "Missile" tests/AcDream.App.Tests/Physics/LiveEntityNetwork*.cs` +returns **zero** hits. The only App file mentioning `Missile` at all is +`ProjectileControllerTests.cs`, which never calls `OnPosition`. + +So none of the following is covered anywhere: + +- the D-P1/D-P6 discriminator itself in its production call site; +- invariant 8's mutual exclusion (Missile-set ⇒ projectile arm and **no + `RemoteMotion` afterward**; Missile-clear ⇒ remote tail and no projectile + effect); +- the null-classification swallow (`earlyRemoteRoute is null` with the Missile + bit set) and its positive half (timestamps consumed, merge advanced); +- the "no early wire-pose write / no `RebucketLiveEntity`" pins; +- the presentation ack the App owns — i.e. #312's layer at the layer it renders. + +Worse, the retired App test `MalformedFreshUpdates_DoNotPoisonCanonicalBodyOrPose` +justifies its Position half's deletion by pointing at *"LiveEntityNetworkUpdateController's +own 'invalid-payload swallow' test"*. **That test does not exist.** The +underlying claim (the shared `CanAcceptPositionPayload` gate rejects the payload +upstream) is correct and I verified it independently — but the cited successor +coverage is not there, so the assertion was retired against a coverage claim +that is false. + +Both A1 and A2 live inside the ~35 lines the implementer argued were "thin glue +between two well-tested layers". That is the empirical refutation of the +argument. + +--- + +## MINOR findings + +### A6 — the `!body.InWorld` gate is read after the placement, where the deleted tail captured `wasInWorld` before it + +`SyncProjectilePresentation:1012-1018` reads `body.InWorld` *after* +`TryExecuteAcceptedRemotePosition` has run. The canonical commit calls +`body.SnapToCell(...)` (`RuntimeSetPositionState.cs:4974`), which sets +`InWorld = true`. The deleted tail captured `bool wasInWorld = body.InWorld;` +**before** its `SnapToCell`. Consequence: on every committed outcome the +re-activation branch (`body.LastUpdateTime = clock` + `TransientState |= Active`) +is now dead — a projectile that had left the world and comes back through a +committed accepted Position is marked `InWorld` but never re-flagged `Active`, +and its legacy `LastUpdateTime` is not rebased. It self-heals on the next tick +because `RetailObjectActivityGate.Evaluate` re-sets the flag +(`RetailObjectActivityGate.cs:61-67`), which is the only reason this is MINOR +rather than a frozen body. Fix: capture `body.InWorld` before dispatching the +placement and pass it in. + +### A7 — the spatial+hidden branch silently drops `body.LastUpdateTime = currentTime` + +Deleted tail: `else if (spatial) { body.InWorld = true; body.LastUpdateTime = currentTime; Suspend(); }`. +New (`SyncProjectilePresentation:1031-1035`): the `LastUpdateTime` write is gone. +`ProjectileController.TryBind`'s equivalent branch documents why the write +exists — *"consume the hidden clock so UnHide cannot replay a time backlog"*. +Bounded by `TryBegin` refusing `Hidden` and by the `RetailObjectQuantumClock` +being canonical post-R6, but it was a deliberate write and its removal is +unremarked. + +### A8 — the shadow publication is silently skipped when the Runtime world frame is unavailable + +`SyncProjectilePresentation:1019-1030` wraps the shadow update in +`if (physics.TryGetWorldFrameOffset(...))` and does nothing on false. The deleted +`ShadowPositionSynchronizer.Sync` always published, using App's live centre, and +returned early only for `cellId == 0`. `TryGetWorldFrameOffset` additionally +returns false when `_worldFrameCenterLandblockId == 0` +(`RuntimePhysicsState.cs:613-632`). Two methods away, +`StoreAcceptedDestinationPose:1113-1119` treats exactly this case as +`ThrowIfWorldFrameUnreachable` — #284's "a frame that can never arrive is +terminal, never silent" policy. The new code neither publishes nor escalates. + +### A9 — the null-route fallback is not fenced off the local player + +`LiveEntityNetworkUpdateController.cs:2122-2126`: when `earlyRemoteRoute` is +null, `isMissilePacket` falls back to a bare `FinalPhysicsState & Missile` test. +`earlyRemoteRoute` is *always* null for `update.Guid == _playerServerGuid`. A +local-player record that ever carried the Missile bit would therefore swallow +its own accepted Position and freeze the player. Currently impossible only +because ACE never sets Missile on a player — a one-token `update.Guid != _playerServerGuid &&` +would make it structurally impossible. + +### A10 — `OwnsFarSnap`'s doc is now false in the kind dimension + +`src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs:78-79` still states +this predicate is *"a strict narrowing of `OwnsPlacement` to its far half"*. +After the widening it narrows only `OwnsPlacement`'s **remote** far half. +`RuntimeRemoteTeleportPosition.cs:33-35` hedges correctly ("`OwnsPlacement`'s +remote scope"); the far-snap doc does not. D-P3 asked for exactly this +correction where a comment would otherwise mislead (process rule 6). + +### A11 — the cutover route ledger still lists the deleted methods as live routes + +`docs/research/2026-08-02-cutover-route-inventory.md:666-694` still describes +`ProjectileController.ApplyAuthoritativePosition` → `RuntimeProjectilePhysicsUpdater.ApplyAuthoritativePosition` +as a live route with `SnapToCell` + `CommitProjectileCell`. D-P7 scoped the +re-point grep to `src/` and `docs/architecture/`, so this is outside the letter +of the obligation — but that file is the C4 route ledger this campaign reads +from, and it is now wrong about route 5. + +--- + +## Verified — no finding + +These were hunted and came back clean; recorded so the next round does not +re-litigate them. + +- **P3 (`RuntimeSetPositionState` kind-agnosticism) is CORRECT, and I verified + it independently rather than accepting the argument.** + `TryBeginExclusiveAuthoredPlacement:1444-1472` returns `default` (invalid + token → `Contention`) whenever `_operations.ContainsKey(key)` or + `HasRetainedCompletion(key)`. Both constructors that stamp + `Kind = RemoteAuthoritative` — `ParkCollisionResidents`'s window-departure + park (`:3833`/`:3866`, which itself `continue`s past any record already in + `_operations`) and `CreateWithdrawalOperation` (`:5338`/`:5350`, reached only + from `Cancel` after `CancelCoreDeferred`) — install an operation into + `_operations` under the record's key. A projectile accepted Position arriving + while one is live cannot capture or be relabelled by it; it refuses with + `Contention`, which `StoresAcceptedDestination()` puts on the **storing** side, + so the pose still advances and no ledger column is mislabelled. The two + kind-conditional stage sites (`IsExactDormantLocalActivationCurrent:5445-5446`, + `IsDormantLocalActivationPrephaseCurrent:2554-2600`) additionally require + `InitialLogin`/`LocalAuthoritative` **and** `record.Projectile is null`, and + the family has zero production callers. Residual, benign and pre-existing: a + park operation created for a projectile record carries `Kind = RemoteAuthoritative`, + but it never reaches the drive controller's ledger. +- **The three ownership fences hold.** `OwnsPlacement` has exactly one + production reader — `TryExecuteAcceptedRemotePosition:640` (verified by grep + across `src/`; every other hit is a doc comment or a test). `OwnsFarSnap` and + `OwnsTeleportPlacement` keep their `RemoteAuthoritative` gate, and a + projectile route cannot reach `ApplyAcceptedRemoteFarSnap` / + `ApplyAcceptedRemoteTeleport` / `TryArmConstraintAfterOperation`: the App + returns before the remote tail whenever `isMissilePacket`, and + `isMissilePacket` is derived *from* `route.OperationKind` whenever a route + exists, so the two cannot disagree. The throwing guards stay unreachable, and + no `RemoteMotion` is ever created on this arm. +- **The remote route is unchanged.** Moving the `ClassifyRemoteAcceptedPosition` + call earlier hops only a pure-read guard block + (`TryGetRecord` / `ReferenceEquals` / `IsCurrentPositionAuthority`) with no + reentrancy in between; classification itself is side-effect-free. Zero remote + classifier tests changed expectation, which is the contract's own tripwire. +- **`StoreAcceptedDestinationPose` never writes a cell**, so on + `Refused`/`Contention`/`RejectedPreparation` the projectile body's + `CellPosition` stays at the source cell while `record.FullCellId` is the wire + cell. This is identical to the accepted remote behaviour (AP-138's residual) + and the quantum stepper takes the cell from `record.FullCellId` explicitly + (`RuntimeProjectilePhysicsUpdater.TryBegin`), so it is parity, not a new + defect. Recorded, not filed. + +--- + +## Judgment on the four implementer claims + +**Claim 1 — both `ApplyAuthoritativePosition` overloads and the Runtime updater's +copy deleted; nothing else called them; no behaviour lost.** +**Deletions and callers: VERIFIED.** No code reference survives anywhere in +`src/` or `tests/` (only comments, the contract, and the two research docs noted +in A11); Release build green with 0 warnings. **"No behaviour lost": PARTIALLY +FALSE.** Three deliberate behaviours went with them and only one is accounted +for: the origin-translated world-position finiteness check (genuinely redundant — +`worldPos` is a finite wire triple plus an integer landblock offset, so it cannot +be non-finite when `CanAcceptPositionPayload` passed); the `_lastFiniteGameTime` +rebase on a Position packet (App tick clock — unremarked, low impact); and the +`wasInWorld` activate edge plus the hidden-branch `LastUpdateTime` write (A6, +A7 — unremarked). + +**Claim 2 — 7 call sites modified, 2 assertions retired as obsolete.** +**Mostly legitimate, with one false coverage claim.** The velocity assertions in +`FreshVectorAndPositionCorrectionsMutateSameBody` are genuinely obsolete under +D-P5 and have a real positive successor (`…TeleportCommit…` / +`…FarCommit…` assert `body.Velocity` bit-identical after a placement). The +`AuthoritativeMutation.Position` retirement has a genuine, load-bearing successor +I read and checked — +`ApplyAcceptedProjectilePosition_DuringOpenQuantum_CompleteAbortsAfterPredictionInvalidated` +opens a real quantum, runs the arm, and asserts `Complete` returns false with the +committed pose intact. The rewritten `SyncPresentation_ReentrantGuidReuseNeverTouchesTheReplacement` +is an improvement, not a dilution: it correctly notes that `RebucketLiveEntity` +self-suppresses its own guid's visibility callback and switches to a genuine +spatial edge. **But** the malformed-payload retirement in +`MalformedFreshUpdates_DoNotPoisonCanonicalBodyOrPose` cites a successor test in +`LiveEntityNetworkUpdateController` that does not exist (A5). The upstream gate +does cover the scenario; the citation does not. + +**Claim 3 — `OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative` +legitimately changed its expected outcome.** +**TRUE, not a test bent to fit the code.** The widening makes +`ProjectileAuthoritative` a positively-owned kind by construction, so leaving it +in a "these kinds are refused" list would assert the opposite of the design. The +positive case is separately and explicitly asserted +(`OwnsPlacement_TrueForProjectileAuthoritative_…`, including the Create +exclusion), and the two remaining negatives (`InitialLogin`, +`LocalAuthoritative`) still pin the predicate's kind dimension. The rename is +accurate. + +**Claim 4 — the new tests use airborne destinations to remove a contact-response +confound.** +**Legitimate confound removal, with a named residual.** The claim is accurate: +the shared placement pipeline's contact response is retail landing behaviour, +shared verbatim with the remote arms, and outside route 5's scope; D-P5's pin is +specifically "no velocity *from the packet*", which an airborne destination +isolates cleanly. It is not a test dodging a defect. The residual worth stating: +**no test now places a projectile into ground contact at all**, so the +interaction between the projectile arm and contact response — including whether +the resulting velocity change is the right one for a ballistic body — is +entirely unexercised. That is a gap, not a dishonesty. + +--- + +## Judgment on the known scoping gap (§7 item 8, the dual-kind `OnPosition` matrix) + +**It is a FAIL-level gap. Definite answer: not acceptable.** + +The implementer's two arguments are (a) the collapse's +`LiveEntityNetworkOnPositionCollapseMatrixTests` fixture unconditionally +constructs a `RemoteMotion` a projectile lacks, and (b) the D-P6 dispatch is +~35 lines of thin glue between two well-tested layers. + +(a) is a fixture limitation, and a fixture limitation is a reason to extend the +fixture, not to ship zero coverage. The projectile half of the matrix needs a +record with the Missile bit, a bound projectile, and **no** `RemoteMotion` — +which is precisely the assertion the matrix exists to make. + +(b) is refuted by this diff itself. Two of the five MAJORs above (A1, A2) are +defects **inside those 35 lines**, and neither is visible from either +well-tested layer: A1 is an App/Runtime disagreement about which outcomes may +write presentation, and A2 is a discriminator whose reachable set is wider than +the arm that consumes it. This is the same shape as 4b-3's three MAJORs — a +mapping written against one caller's reachable set, and an invariant satisfied +on one arm only — and a dual-kind matrix was the structural fix there for the +same reason it is here. + +Additionally, the gap is broader than §7 item 8 alone: item 9 (the App-level +invalid-payload swallow) is also unwritten, *and* was cited as existing coverage +in a retired assertion's justification. + +The minimum this needs before landing: a dual-kind theory over `OnPosition` +covering at least far-commit, teleport-commit, near, airborne, and +null-classification, asserting on the projectile half that no `RemoteMotion` +exists afterward, no generic wire-pose write occurred, and the render entity's +position **and** `ParentCellId` agree with the resolved body — that last +assertion alone would have caught A1. diff --git a/docs/research/2026-08-04-c4-route-5-contract.md b/docs/research/2026-08-04-c4-route-5-contract.md new file mode 100644 index 00000000..db24c95f --- /dev/null +++ b/docs/research/2026-08-04-c4-route-5-contract.md @@ -0,0 +1,804 @@ +# C4 route 5 — projectile authoritative placement: pinned contract (2026-08-04) + +**Scope:** make the post-residence accepted Position for a live projectile +canonical — classify it through the shared route classifier, execute the +placement dispositions through the one Runtime placement owner +(`RuntimeRemotePlacementDriveController` / `RuntimeSetPositionState`), and +delete the bespoke `ApplyAuthoritativePosition` authority that today +short-circuits a missile packet before the classifier ever sees it. + +Pinned at HEAD **`30d3d114`**, clean tree, branch +`claude/acdream-physics-divergence-5aa784`. **Line numbers in this contract +are as-of `30d3d114` and WILL go stale; every citation also names the symbol — +trust the symbol** (process rule 6; line ranges went stale twice within single +review rounds during 4b-2/4b-3, and this route's own scoping doc is the third +demonstration). + +Predecessor documents, binding where they still apply: + +- [`2026-08-04-c4-route-5-scoping.md`](2026-08-04-c4-route-5-scoping.md) — + the research base. **Its file:line references predate the OnPosition + collapse (`edc911b0`) and the #315 fix (`aaf0811f`) and are stale; §10 of + this contract lists every claim the collapse invalidated or reshaped.** Its + retail research (§2), prediction analysis (§4), and trap list (§7) survive + and are folded in below, re-verified. +- [`2026-08-04-onposition-collapse-contract.md`](2026-08-04-onposition-collapse-contract.md) + — the unified remote tail this route dispatches AHEAD of (never into). Its + §7 non-goal ("Route 5 will add its arm against the collapsed single path — + that is the payoff, not the scope") is cashed out here. +- [`2026-08-04-c4-route-4b-3-contract.md`](2026-08-04-c4-route-4b-3-contract.md) + — its 13 "must REMAIN true" invariants and the D4 constraint-arm partition + still bind; §5 below extends the partition with the projectile column. +- The four 4b-3 review rounds + ([retail](2026-08-04-c4-route-4b-3-retail-review.md) / + [round 2](2026-08-04-c4-route-4b-3-retail-review-round2.md) / + [architecture](2026-08-04-c4-route-4b-3-architecture-review.md) / + [round 2](2026-08-04-c4-route-4b-3-architecture-review-round2.md)) — the + recurring defect classes (a mapping written against one caller's reachable + set; an invariant satisfied on one arm only; a register row asserting + behaviour the code does not have; a mismapped retail action with the + faithful port already in-tree; tests that assert only negatives) are each + addressed by name below. +- [`2026-08-04-session-handoff-c4-remaining.md`](2026-08-04-session-handoff-c4-remaining.md) + — the six process rules apply verbatim. +- [`docs/plans/2026-08-02-placement-cutover.md`](../plans/2026-08-02-placement-cutover.md) + — C4 route list; route 5 is "projectile authoritative". + +**Sequencing:** 4b-2 (`7f1c1f5a`), 4b-3 (landed, gate passed), the collapse +(`edc911b0`), and #315 (`aaf0811f`) are all in. The scoping's "route 5 lands +after 4b-2, preferably after 4b-3" constraint is satisfied; route 5 is next. + +--- + +## 0. Facts settled before this contract — do not re-litigate + +1. **Route 5 is half shipped.** The Create half + (`RuntimeInitialCreateResidenceState.Begin` decides + `RuntimePositionEntityKind.Projectile` from + `record.FinalPhysicsState & PhysicsStateFlags.Missile`, `:591-595`) and + the residence-window Position half + (`RuntimeInitialCreateContinuationExecutor.ApplyPositionAction:1874`, + kind recovered via `EntityKindOf:2571-2581`, one shared + `RuntimeAcceptedPositionRouteRequests.Build` at `:1914-1926`) are + canonical. What remains is exactly one thing: the accepted Position + arriving AFTER the residence window, short-circuited before the + classifier. +2. **NO LIVE GATE IS POSSIBLE.** ACE never sends `UpdatePosition` for a + missile. Re-verified at HEAD: + `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:333-334` + is `/*if (PhysicsObj.IsGrounded) SendUpdatePosition();*/`, inside the + branch gated on `(PhysicsObj.State & PhysicsState.Missile) != 0` at + `:265`. Projectile classes broadcast `GameMessageVectorUpdate` and + `GameMessageSetState` on impact — never a Position. **This slice is + test-gated only** (§8). No connected gate is invented; if the implementer + finds evidence contradicting this, STOP and report — it changes the + slice. +3. Route 5 had to land after 4b-2 because it widens + `RuntimeRemotePlacementDriveController.OwnsPlacement`. Done; see §3. + +--- + +## 1. Site inventory — re-located at `30d3d114` + +Every site verified by reading at HEAD, not inherited from the scoping. + +### 1.1 The duplicate authority to delete (the same three sites; new lines) + +| # | site (symbol) | at HEAD | what it is | +|---|---|---|---| +| D1 | `LiveEntityNetworkUpdateController.OnPosition` — the projectile short-circuit (`_projectileController?.ApplyAuthoritativePosition(...) == true → return`) | `:2104-2125` (was `:1428-1448`) | Sits after `_movementTruthDiagnostics.OnServerEcho` (`:2103`) and BEFORE the remote classification (`earlyRemoteRoute`, `:2150-2158`), `TryApplyGenericRemoteRenderPose` (`:2160`), `RebucketLiveEntity` (`:2190`), and the whole `update.Guid != _playerServerGuid` RemoteMotion tail (`:2234` on). Carries the fabricated `acceptedSpawn.Physics?.Velocity ?? System.Numerics.Vector3.Zero` at `:2119-2120` (was `:1442-1443`). | +| D2 | `ProjectileController.ApplyAuthoritativePosition` (two overloads + doc) | `:492-590` (unchanged) | Validation (the `return true` swallow on invalid payload at `:551-554`), `ExternalOwnerValid` closure, render-pose acknowledgement (`:583-586`), delegation to the Runtime updater. | +| D3 | `RuntimeProjectilePhysicsUpdater.ApplyAuthoritativePosition` | `:301-424` (unchanged) | The real authority: `InvalidatePrediction` `:342`, `body.Orientation` `:345`, `body.SnapToCell` `:346`, `body.State = record.FinalPhysicsState` `:347`, the velocity commit `:348-358`, `CommitProjectileCell` `:370-381`, the presentation acknowledgement `:387`, the spatial/hidden `InWorld`/`Activate`/shadow-sync/suspend tail `:390-422`. | + +Raw total 244 lines; roughly 180 non-comment — the scoping's figure holds. + +### 1.2 The Runtime surfaces route 5 builds against + +| site (symbol) | at HEAD | relevance | +|---|---|---| +| `RuntimeAuthoritativePositionRouteClassifier` — `RuntimePositionEntityKind.Projectile` | `:13`; `ValidEntityKind:535-538`; `OperationKind` switch `:564-575` (Projectile → `ProjectileAuthoritative` at `:572-573`) | The kind's ONLY behavioural effect in the classifier is the `OperationKind` mapping. `ClassifyAcceptedPosition` (`:308-478`): the single kind branch is `LocalPlayer` (`:349`); Projectile and Remote are byte-identical in disposition, flags, `StopInterpolating`, `TeleportHookPhase`, and `ConstrainPhase` — teleport/cell-less `:404-421`, `NoPositionOperation` `:430-448`, near-`Interpolate`/far-`SetPositionSimple` `:459-477`. Pinned by `RuntimeAuthoritativePositionRouteClassifierTests.ProjectilePosition_UsesRemoteMoveOrTeleportClassification` (`tests:418-436`). | +| `RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition` | `:617-659`; **hardcodes `RuntimePositionEntityKind.Remote` at `:642`** (was `:622`) | The one production remote-Position classification entry; feeds D1's `PreMergeCommittedCellId` (`:634`). Trap T4's site. | +| `RuntimeRemotePlacementDriveController.OwnsPlacement` | `:520-524` (was `:274-278`) | `OperationKind is RemoteAuthoritative && Disposition is SetPosition or SetPositionSimple && Teleport flag` — **excludes `ProjectileAuthoritative`**. The widening target. | +| `RuntimeRemotePlacementDriveController.TryExecuteAcceptedRemotePosition` | `:619-667` | Kind-agnostic body: stale-pending self-heal, `CanAttemptDestination` pre-flight → `Refused`, `TryBeginExclusiveAuthoredPlacement(record, version, route.OperationKind)` → `SubmitAndResolve`. Gated only by `OwnsPlacement`. | +| `RuntimeRemotePlacementDriveController.ApplyAcceptedRemoteFarSnap` / `ApplyAcceptedRemoteTeleport` | `:760-784` / `:821-843` | **Both REQUIRE a `RemoteMotion` parameter and THROW unless `OwnsFarSnap` / `OwnsTeleportPlacement` own the route.** Not usable for projectiles — see §2. `StoreAcceptedDestinationPose` (`:845` on) writes the CANONICAL body and is reusable. | +| `RuntimeRemoteTeleportPosition.OwnsTeleportPlacement` | `:38-46` | **NEW since the scoping** (4b-3). Gates on `OperationKind: RemoteAuthoritative` — a projectile teleport-classified route is NOT owned. | +| `RuntimeRemoteFarSnapPosition.OwnsFarSnap` / `ResolveArm` | `:85-95` / `:137-148` | **NEW since the scoping** (4b-2). Same `RemoteAuthoritative` gate; a projectile far route would resolve `UnroutedCatchUp` → `ApplyInterpolate` against a RemoteMotion that does not exist. | +| `RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation` | `:240-259` | Requires `remote.Host` (an `EntityPhysicsHost`). A projectile has none. | +| `RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement` / `TryPrepareAndSubmitAuthoredPlacement` | `:1817-1861` region | `operationKind` is a pass-through parameter; the placement pipeline is kind-agnostic. **Caveat for the implementer:** two internal operation constructors stamp `Kind = RemoteAuthoritative` outright (`:3833/:3866` — the window-departure park constructor; `:5338/:5350` — the legacy-direct withdrawal shape), and the direct-command gate at `:5445-5446` admits local kinds only. Proof obligation P3 (§6) covers these. | +| `RuntimePhysicsState.CommitProjectileCell` | `:1376-1397` | NOT a bypass — routes into the shared `CommitCanonicalCell` with an exact-owner check including `PredictionAuthorityVersion`. The 2026-08-02 inventory's "ad hoc" label remains wrong (scoping already corrected this; re-verified). | +| `RuntimeProjectile` | `RuntimeProjectile.cs` (whole file) | `{ Body, CollisionSphere, PredictionAuthorityVersion, InvalidatePrediction() }`. No Host, no PositionManager, no InterpolationManager, no MovementManager. `RuntimeEntityRecord.Projectile` is the canonical slot (J5.6). | +| `RuntimeProjectilePhysicsUpdater` — quantum + Vector/State channels | `TryBegin`/`Complete` `:37-205`; `ApplyAuthoritativeVector` `:207-250`; `ApplyAuthoritativeState` `:252-299` | Out of scope (invariants 4/5). `InvalidatePrediction` is bumped at exactly `:241`, `:272`, `:342` — the sole cancellation mechanism for a split quantum (trap T3). | +| First-entry admission | `RuntimeRemoteFirstEntryState.cs:311-314` | Admits `RemoteAuthoritative or ProjectileAuthoritative`. Untouched. | +| Continuation-executor non-SetPosition gap | `RuntimeInitialCreateContinuationExecutor.cs:2019-2026` | `Interpolate`/`NoPositionOperation`/`AwaitFreshPosition` → typed trace + return ("Binding to the live interpolation owner is cutover work"). §3 D-P4 resolves the projectile half of that gap (no-op by policy); the REMOTE half stays open — do not touch. | + +### 1.3 App-side context (dispatch site + presentation) + +| site (symbol) | at HEAD | relevance | +|---|---|---| +| `OnPosition` shared prologue | `:1891-2103` | Authority gate + merge (`TryAcceptPosition`, `:1902`) — so `AcceptedPhysicsTimestamps.PreMergeCommittedCellId` is already measured for missile packets; hydration recovery; `EnsureWorldOrigin`; world-pos translation; `MarkLiveOwnerPoseDirty` (`:2068`); `OnServerEcho` (`:2103`). All runs for missiles today and keeps running. | +| `ProjectileController.CanAcceptPositionPayload` | `:102-126`, called unconditionally at `:1898-1901` (was `:1205-1208`) | The SHARED admission validator (returns true for non-projectiles); an invalid missile payload already fails the authority gate before the short-circuit. NOT a deletion target. | +| Unified remote tail (post-collapse) | `:2234-2810` | One guid-blind path; two named guid survivors (TS-44 sticky `:2537-2546`; the #316-preserved player `AirborneSnap` interp-clear/shadow-skip `:2581-2632`, `:2795-2809`). **Route 5 must not touch any of it.** | +| `RunRemoteArmTail` / cached #315 callbacks | `:1427-1463`, `_remoteArmCallbacks` backing methods `:1474-1492` | The allocation pattern to follow if the projectile arm needs any callback: cached delegates + scratch fields, never per-packet closures. | +| `TryApplyGenericRemoteRenderPose` | `:1010-1025`, called `:2160` | Gate is `OwnsSteadyState`. The projectile arm dispatches BEFORE this call (§3 D-P6) — a missile packet takes no early wire-pose write today and must not start taking one. | +| `ProjectileController.Tick` / `AdvanceQuantum` / `TryBeginQuantum` / `CompleteQuantum` / `IsCurrentQuantumIdentity` | `:640-766` / `:773-782` / `:784` / `:822-848` / `:850-859` | The per-quantum owner. Untouched. | +| `LiveEntityAnimationScheduler` split quantum | `src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs:404-426` | `TryBeginQuantum` `:405-409` … `_animationHooks.Capture` … `CompleteQuantum` `:419-423`. The straddle window trap T3 protects. Untouched. | +| `ProjectileController.TryBind` + create branch | `:133-330`; late-classification path `ApplyAuthoritativeState → TryBind` `:473-489`; projection-visible retry in `OnProjectionVisibilityChanged` `:860-897` | Still the live fallback for `initialResidenceActive == false` (`DatLiveEntityProjectionMaterializer.cs:848-865` — note: `src/AcDream.App/Rendering/`, not `World/`) and the mid-life Missile-bit flip. **NOT deleted here** (trap T8; C5's deletion pass owns it). | +| `ProjectileController.HandlesMovement` | `:599-602` | The Missile-bit ownership predicate the kind discriminator must agree with (§3 D-P1). | + +--- + +## 2. How the OnPosition collapse changed route 5's shape + +The scoping assumed route 5 would rewrite call sites inside two parallel +player/NPC copies of the remote tail. The collapse (`edc911b0`) replaced them +with **one unified, RemoteMotion-shaped path** plus two surviving named +guid-conditionals. Consequences, stated precisely: + +1. **Route 5 touches the unified tail NOT AT ALL.** The projectile arm + dispatches from `OnPosition` at (approximately) the current + short-circuit's site — after the shared prologue, BEFORE + `TryApplyGenericRemoteRenderPose`, `RebucketLiveEntity`, and the + `update.Guid != _playerServerGuid` block — and returns. A missile packet + must never reach `GetOrCreateRemoteMotionRuntime`, + `SeedRemoteSpawnPlacement`, `TryCommitAuthoritativeVelocity`, the sticky + gate, `RunRemoteArmTail`, the arming site, the wire-cell adopt, or the + remote entity-sync/shadow tail. The entire tail assumes a `RemoteMotion`; + a projectile has none, and creating one is exactly the "second body or + interpolation owner" the current short-circuit's comment exists to + prevent. +2. **The "two copies to widen" work item is gone.** The scoping's §8 line + item "App call-site rewrite in OnPosition … 40-70 lines" shrinks to ONE + dispatch site. This is the collapse's payoff for route 5. +3. **Three ownership predicates now fence projectiles out, not one.** The + scoping knew only `OwnsPlacement` (§5.2). Since then 4b-2/4b-3 added + `OwnsFarSnap` and `OwnsTeleportPlacement`, both gated on + `OperationKind: RemoteAuthoritative`, and both remote arm methods + (`ApplyAcceptedRemoteFarSnap` / `ApplyAcceptedRemoteTeleport`) THROW on a + non-owned route and REQUIRE a `RemoteMotion`. Consequence: **only + `OwnsPlacement` is widened; the other two predicates and the two remote + arm methods are deliberately NOT widened and NOT reused** — the + projectile arm is a sibling seam over the shared + `TryExecuteAcceptedRemotePosition` + `StoreAcceptedDestinationPose` core + (§3 D-P2). The bright side: a mis-routed projectile packet now fails + loudly (throwing guards) instead of silently taking a RemoteMotion arm. +4. **The two surviving guid-conditionals are off-limits.** TS-44's sticky + gate and the #316-preserved player `AirborneSnap` + interp-clear/shadow-publish skip are named, contract-protected + asymmetries of the collapse. Route 5 has no business near either. +5. **#315's callback pattern binds new code.** The collapse's second commit + cached the remote-arm callbacks; the projectile arm must not reintroduce + per-packet closures on the packet path (the projectile hook reduction in + D-P4 needs at most a method-group/cached delegate). + +--- + +## 3. Design decisions — pinned, not open for redesign + +### D-P1 — one per-packet kind discriminator, derived where the classification already runs (resolves trap T4) + +`RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition:642` stops +hardcoding `RuntimePositionEntityKind.Remote` and derives the kind from the +canonical record: + +``` +(canonical.FinalPhysicsState & PhysicsStateFlags.Missile) != 0 + ? RuntimePositionEntityKind.Projectile + : RuntimePositionEntityKind.Remote +``` + +This is the SAME data-driven predicate the Create half uses +(`RuntimeInitialCreateResidenceState.cs:591-595`), the same one +`EntityKindOf` recovers from the lease, and the same bit +`ProjectileController.HandlesMovement` keys movement ownership on — +data-driven, never display-name-gated (feedback_retail_dispatch_is_data_driven). +The App dispatch in `OnPosition` branches on the SAME underlying fact: +pinned, the App reads `route.OperationKind is ProjectileAuthoritative` off +the returned route wherever a route exists, and for the null-classification +case reads one `FinalPhysicsState & Missile` test on the same canonical +record in the same packet — so the classifier's kind and the App's dispatch +cannot disagree (a mis-tag is otherwise silent, because the dispositions are +identical for the two kinds; only the ledger/owner would be wrong). + +The mid-life bit flip is thereby handled by construction: ACE clears Missile +on impact (a State packet), and `ApplyAuthoritativeState → TryBind` is where +an ordinary object becomes a missile. Whichever `FinalPhysicsState` the +packet's merge left on the canonical record decides the packet's arm; the +adopted-body case (a missile that also carries a `RemoteMotion`) follows the +Missile bit exactly as `HandlesMovement` already does, and the untouched +`RemoteMotion` is simply not written by that packet. + +Route-1 semantics are untouched: the residence/continuation callers already +pass an explicit kind; only the remote PositionEvent entry changes its kind +input. The classifier itself does not change at all. + +### D-P2 — the projectile execution seam: one controller, shared core, no RemoteMotion + +New Runtime seam on the EXISTING `RuntimeRemotePlacementDriveController` +(never a sibling controller, never a second pending map — trap T9), shape at +the implementer's discretion but pinned in behaviour: + +`ApplyAcceptedProjectilePosition(RuntimeEntityRecord record, in RuntimeAuthoritativePositionRoute route)` +(reads `record.Projectile` internally), which: + +1. **Validates ownership** — the route's `OperationKind` is + `ProjectileAuthoritative`; `record.Projectile` and `record.PhysicsBody` + exist and agree (`ReferenceEquals(record.PhysicsBody, projectile.Body)` — + the J5.6 canonical-body identity). Anything else: return + `NotApplicable`-equivalent, write nothing. +2. **Dispatches on disposition:** + - `SetPosition` (teleport / cell-less): run the projectile hook reduction + (D-P4) BEFORE the placement, then `InvalidatePrediction()`, then + `TryExecuteAcceptedRemotePosition(record, route)` (now + `OwnsPlacement`-admitted, D-P3), then `StoreAcceptedDestinationPose` on + the `StoresAcceptedDestination` partition — the partition itself is + extended UNCHANGED (4b-3 invariant 1; do not re-litigate which statuses + store). + - `SetPositionSimple` (far): `InvalidatePrediction()` → + `TryExecuteAcceptedRemotePosition` → store on the same partition. No + interp clear (no queue exists; retail's own far-branch + `StopInterpolating` is guarded on `position_manager != 0` + @0x005163C9-@0x005163CB and a never-interpolated missile has none, so + the skip is retail-faithful by consequence, not a divergence). + - `Interpolate` (near, in contact): **no-op by pinned policy** — see + D-P4. No body write, no prediction invalidation (nothing changed). + - `NoPositionOperation` (airborne): no-op — retail's `return 0` + @0x0051636D, faithful. No prediction invalidation. +3. **Invalidates prediction before ANY body write on this route** — the + placement path and the store fallback both clobber the body retail-side + of an in-flight split quantum (trap T3). One `InvalidatePrediction()` at + the seam's placement dispatch (before Begin) covers both, mirroring + today's `:342`-before-`SnapToCell` ordering. The no-op dispositions do + NOT invalidate: the body is untouched, so a straddling quantum completing + is correct, and the Vector/State channels' invalidate-on-write semantics + stay coherent. +4. **Commits NO velocity** (D-P5). +5. **Arms NO constraint leash** (D-P4). + +The post-commit lifecycle tail — `InWorld`/`Activate`/shadow-sync on +spatial+visible, suspend on spatial+hidden, deactivate+suspend on +non-spatial (today's `RuntimeProjectilePhysicsUpdater.ApplyAuthoritativePosition:390-422`) +— is preserved semantically on the committed/stored outcomes. Whether it +lives in the new seam or stays a small retained method on the projectile +updater is the implementer's choice; pinned is that it remains +Runtime-owned (J5.6 boundary, invariant 6) and keyed on the same +spatial/hidden facts. + +App keeps exactly what J5.6 assigns it: the presentation acknowledgement. +`OnPosition`'s projectile dispatch (D-P6) projects the committed/stored +snapshot into the render entity (`SetPosition`/`Rotation`/`ParentCellId` +from the RESOLVED body) and `_rootPoses.UpdateRoot` — the same ack contract +`ProjectileController.ApplyAuthoritativePosition:583-586` performs today. +No `RebucketLiveEntity` on this arm (the canonical placement/cell commit is +the cell authority, exactly as the per-quantum commit is today; adding the +remote tail's bucket transaction here would be a new second writer). + +### D-P3 — the `OwnsPlacement` widening, and every consequence + +`RuntimeRemotePlacementDriveController.OwnsPlacement:520-524` first clause +widens to +`route.OperationKind is RuntimeSetPositionOperationKind.RemoteAuthoritative or RuntimeSetPositionOperationKind.ProjectileAuthoritative` +(+ doc update). Consequences, each pinned: + +- **`TryExecuteAcceptedRemotePosition` admits projectile routes.** The + Teleport-flag conjunct keeps excluding Creates (the first-entry conductor + owns every Create — unchanged; a projectile Create carries + `InitialCreateFlags`, an accepted Position carries `0x1012`). +- **Pending/acknowledgement ledgers are shared.** Projectile operations land + in the same `_pending` / `_awaitingAcknowledgement` maps; `DetachRoute`'s + cancel-then-clear teardown and `CountLivePending`'s self-heal cover them + with zero new code. No parallel map, no sibling controller (T9). +- **Parks/refusals behave exactly as the remote arms'** (4b-3 invariant 3 + extended): `Refused` (pre-flight) and `Contention`/`RejectedPreparation` + store the accepted destination; `DeferredCell` is cancelled synchronously + with `restoreCancelledPark: true` — no park survives the controller; + `RejectedByPlacement` does not store. The projectile body stays `InWorld`, + clock active, spatially projected on every non-commit outcome. +- **`OwnsFarSnap` and `OwnsTeleportPlacement` are NOT widened** (§2 item 3). + Their throwing guards and the two RemoteMotion-shaped arm methods are + untouched. State this in their docs only if a comment would otherwise + mislead (process rule 6). +- **The constraint arm:** see D-P4. The classifier's projectile routes carry + `ConstrainPhase.AfterPositionOperation` (kind-blind, `:417`/`:473`); the + projectile arm deliberately does not consume it. No change to the + classifier; the deviation is recorded (D-P7). +- **The interp queue:** nonexistent for projectiles; + `StopInterpolating: !nearby` on the far route is inert and + retail-faithfully so (D-P2). +- **`CommittedHostAcknowledgementPending`:** a committed placement can enter + `_awaitingAcknowledgement` (`:1062-1076`). Whether the graphical host's + projection-acknowledgement chain retires a `ProjectileAuthoritative` + commit identically is proof obligation P2 (§6) — verify, do not assume. + +### D-P4 — retail's manager actions, reduced to what a missile has (resolves scoping §5.1; answers the constraint-arm question) + +**Retail's mechanical answer, verified in the decomp (§4): YES, retail would +arm the leash for a missile.** `SmartBox::HandleReceivedPosition` @0x00453FD0 +has NO kind test other than `arg2 != this->player` (@0x0045414D); every +nonzero `MoveOrTeleport` return reaches `ConstrainTo` @0x00454272; and +`CPhysicsObj::ConstrainTo` @0x00510520 calls `MakePositionManager` +(@0x00510523) — creating the manager on demand for any object, missile +included — after which `UpdateObjectInternal` ticks it +(`PositionManager::UseTime` @0x005159A9-@0x005159B3). Likewise retail's near +branch (`InterpolateTo` @0x005163AF, with `IsMovingTo` @0x0050EB10 returning +0 for a manager-less missile) would build interpolation machinery for it. + +**acdream pins the divergence instead of the machinery.** Building an +`EntityPhysicsHost`/`PositionManager`/`InterpolationManager` chain for a +ballistic body — for a packet ACE never sends — is the route-5b split the +scoping warned against, and this contract REJECTS it. Pinned: + +- The projectile arm **never arms `ConstrainTo`** — on any disposition, any + outcome. +- The near-`Interpolate` disposition is a **no-op** (no queue to feed, no + manager to create). +- Both are recorded in ONE new register row (D-P7) citing @0x00454272, + @0x00510523, @0x005163AF, and the ACE unreachability + (`WorldObject_Tick.cs:333-334`). + +**The teleport hook, reduced.** Retail's teleport/cell-less branch runs +`teleport_hook` @0x00514ED0 before the placement — six actions, each guarded +on its manager existing. For a pure missile, five of six are structurally +absent (`MovementManager`/`PositionManager`×3/`TargetManager` are null → +retail no-ops through the guards). The sixth, +`report_collision_end(this, 1)` @0x00514F31 → @0x00514620 (force-end-all +with bidirectional notification), applies to ANY object with a collision +table. **Pinned: the projectile teleport/cell-less arm runs the faithful +force-end port — `RuntimeCollisionReportingState.LeaveWorld` (the exact seam +the 4b-3 R2 fix validated against @0x00514620, round-2 retail review §2) — +before the placement.** Omitting it would recreate R2's defect class with the +faithful port sitting unused in-tree. The other five actions execute iff +their owning component exists — for the adopted-body case (a missile that +carries a `RemoteMotion`), the existing hook actions' per-manager guards +already express retail's shape; the implementer wires this without +per-packet closures (#315 pattern). For the ordinary arrow/bolt they are +no-ops by the same guards retail uses. + +**Null / `Rejected*` classifications for a missile packet: swallow.** Write +nothing, create nothing, return — never fall through to the remote tail +(preserving trap T5's semantics: today an unhandleable projectile packet is +swallowed, not re-routed). The remote `UnroutedCatchUp` policy is +RemoteMotion-shaped and does not apply. This is an acdream-only state +(retail rejects nothing here); it rides in the same register row. + +### D-P5 — no velocity write from a Position packet (retires the zeroing defect) + +Retail's `MoveOrTeleport` declares `arg5 = AC1Legacy::Vector3 const*` and +never references it in the decompiled body (@0x00516330-@0x00516438, +re-verified for this contract); `HandleReceivedPosition` threads its `arg6` +into it (@0x00454254) and does nothing else with it; `UnpackPositionEvent` +@0x004542C0 performs no `set_velocity`. Velocity authority for a missile is +the Vector channel (0xF74E / `ApplyAuthoritativeVector`), untouched here. + +**Pinned: the projectile arm consumes no velocity from the Position packet.** +This deletes the `acceptedSpawn.Physics?.Velocity ?? Vector3.Zero` +fabrication (`:2119-2120`) and the updater's conditional commit +(`:348-358`), retiring the (never-filed) defect where a velocity-less +Position packet zeroes an in-flight missile's velocity. Because "textually +unreferenced" is strong-but-not-byte-confirmed (BN elision risk, scoping +§9.2), **the implementer byte-verifies 0x00516330-0x00516438 for any `arg5` +use before landing** (`tools/pdb-extract` PE byte-decode against the paired +`refs/acclient.exe` — the `reference_pe_byte_decode.md` recipe). If a use is +found: STOP and report — the velocity policy changes. + +### D-P6 — the App dispatch site and ordering + +In `OnPosition`, the D1 short-circuit is replaced in place by: + +``` +classify (kind-aware, D-P1) // one call, both kinds — + // replaces the :2150 remote-only call +if the packet is a missile packet (D-P1's discriminator): + dispatch the projectile arm (D-P2) and RETURN // before TryApplyGenericRemoteRenderPose, + // before RebucketLiveEntity, + // before the RemoteMotion tail +else: the unified remote tail runs UNCHANGED // earlyRemoteRoute now comes from + // the same classification call +``` + +Pinned facts of this shape: + +- The projectile arm takes **no early wire-pose write** + (`TryApplyGenericRemoteRenderPose` is never reached — matching today, + where the short-circuit precedes it) and **no `RebucketLiveEntity`**. + Presentation advances from the RESOLVED body via the ack (D-P2), exactly + as the per-quantum path does. +- The shared prologue (authority gate/merge, hydration, world origin, + `MarkLiveOwnerPoseDirty`, `OnServerEcho`) runs for missiles exactly as + today — the dispatch sits at/after the current short-circuit's position. +- The classification for a REMOTE packet is byte-identical to today's + (`Remote` kind in → same route out); only its call site may merge with the + projectile classification. Zero remote-classifier test changes is the + regression tripwire (§8 stop condition). + +### D-P7 — register and issue bookkeeping, in the implementation commit + +- **ONE new AP row** (projectile accepted-Position divergences): (a) + near-`Interpolate` is a no-op where retail would `InterpolateTo` a + manager-on-demand missile (@0x005163AF, @0x00510523); (b) the + post-operation `ConstrainTo` @0x00454272 is never armed for a projectile + (no PositionManager machinery exists for a ballistic body); (c) + null/`Rejected*` missile packets are swallowed rather than caught up (no + RemoteMotion); with the shared context that ACE never emits the packet + (`WorldObject_Tick.cs:333-334` commented out inside the Missile branch) so + every half is test-gated. Note in the row that the far branch's + `StopInterpolating` skip is NOT part of the divergence — retail's own + `position_manager != 0` guard @0x005163C9 skips it for a never-interpolated + missile. +- **No row deletion:** the velocity-zeroing defect being retired was never + filed; record its retirement in the commit message (and this contract), + not the register. +- **Comment corrections (process rule 6):** the D1 site's comment block + ("Missiles reconcile the same predicted PhysicsBody in place…", + `:2104-2108`) dies with the short-circuit; `RuntimeProjectilePhysicsUpdater`'s + class doc and any "Position correction" references are re-verified against + the new seam; `RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition`'s + doc gains the kind-derivation sentence. Grep for `ApplyAuthoritativePosition` + across `src/` and `docs/architecture/` and re-point every survivor. +- **ISSUES.md:** none closed by this slice (#315 already closed; #316 stays + OPEN and untouched). + +--- + +## 4. Retail ground truth — verified in `acclient_2013_pseudo_c.txt` for this contract; verify again yourself + +| claim | where verified | status | +|---|---|---| +| **Retail has NO projectile-specific branch anywhere in the accepted-Position chain.** `UnpackPositionEvent` @0x004542C0 resolves the object and calls `HandleReceivedPosition` @0x00454358 with no state/kind test; `HandleReceivedPosition` @0x00453FD0's only kind branch is `arg2 != this->player` @0x0045414D; `MoveOrTeleport` @0x00516330 tests TELEPORT_TS, `this_1->cell == 0` (the body's OWN cell, read at entry), `arg4`, and `player_distance` — never `state & Missile`. A missile takes the identical remote arm. | pseudo-C lines 92896-93054 (HandleReceivedPosition), 93055-93098 (UnpackPositionEvent), 284304-284366 (MoveOrTeleport); independently confirmed twice by the 4b-3 reviews | ✓ re-read | +| Teleport/cell-less branch: `teleport_hook` @0x005163EF → `SetFlags(0x1012)` @0x00516414 → `SetPosition` @0x00516420 → `return 1` @0x00516438; decided before `arg4` is read @0x0051638E | same listing | ✓ | +| Near branch: `InterpolateTo(this, arg2, IsMovingTo(this))` @0x005163AF, `return 1` @0x005163BE; `IsMovingTo` @0x0050EB10 returns 0 without a `MovementManager` (a missile has none) | lines 276430-276440 | ✓ | +| Far branch: `StopInterpolating` ONLY if `position_manager != 0` @0x005163C9-@0x005163CB; `SetPositionSimple(this, arg2, 1)` @0x005163D9 (builds `0x1012`); `return 1` @0x005163E8 | same listing | ✓ | +| Airborne: `arg4 == 0` → `return 0` @0x0051636D — nothing written, and `ConstrainTo` skipped (it sits inside `if (MoveOrTeleport(...) != 0)` @0x00454254) | same | ✓ | +| **`ConstrainTo` @0x00510520 = `MakePositionManager` @0x00510523 then `PositionManager::ConstrainTo`** — retail creates the manager on demand for a missile; the single remote arming site @0x00454272 has no kind test. So retail WOULD arm a missile's leash on any nonzero return. | lines 278353-278364 | ✓ — the basis of D-P4's recorded divergence | +| `PositionManager::UseTime` ticks in `UpdateObjectInternal` @0x005159A9-@0x005159B3 for any object holding a manager — an armed missile leash would be live, not vestigial | lines 283611-283757 | ✓ | +| `player_distance` is maintained for missiles: `update_object` @0x00515D10 computes `player_vector` via `Position::get_offset` @0x00515D5B and stores `player_distance` @0x00515D95 for every active unparented in-cell object. (BN artifact: the decomp shows only `.x` feeding it @0x00515D7B — the standard x87-elision of a magnitude; do NOT "fix" acdream's Euclidean distance to `.x`.) | lines 283950-284055 | ✓ | +| `MoveOrTeleport`'s `arg5` (velocity) is textually unreferenced in the body; `HandleReceivedPosition` threads `arg6` into it and does nothing else with it; `UnpackPositionEvent` calls no `set_velocity` (the only `set_velocity` in the chain is the LOCAL player's zero @0x004541B4) | all three listings above | ✓ textual; **byte-verification required before landing** (D-P5) | + +**What this means for the design — stated plainly:** retail's CLIENT +mechanically supports an authoritative missile Position (it is just the +generic remote path), but the packet does not exist against ACE, and the +manager machinery retail would lazily build for it does not exist in acdream +for a ballistic body. Route 5 therefore anchors the placement dispositions to +retail's generic remote path (faithful), and pins the manager-dependent +halves (near-interpolate, leash) as a recorded acdream divergence — **a +register row, not an invented retail justification** (D-P7). There is no +evidence of any dedicated retail missile-Position path to port; anyone +claiming otherwise must produce an address. + +One reporting note, out of scope but found while verifying: the remote +prologue's comment at `LiveEntityNetworkUpdateController.cs:2296-2300` +("PositionPack::UnPack initializes an absent velocity to zero; +MoveOrTeleport installs that exact vector with set_velocity") is contradicted +by the decomp read above — `MoveOrTeleport` installs no velocity. That +comment justifies the REMOTE arm's `TryCommitAuthoritativeVelocity`, which is +4a-owned and untouched by this slice; flagged for a future 4a-family +correction, not acted on here. + +--- + +## 5. What must REMAIN true (process rule 1 — for every path, including every refusal) + +1. **The pose still advances on the storing partition.** A projectile + placement that never reached the engine (`Refused` / `Contention` / + `RejectedPreparation`) still commits the accepted destination via + `StoreAcceptedDestinationPose`; `Deferred` and `RejectedByPlacement` do + not. The partition is extended unchanged — not re-litigated. +2. **Presentation still advances.** On every committed/stored outcome the + render entity (`Position`/`Rotation`/`ParentCellId`) and the root pose + reflect the RESOLVED canonical body, and the shadow registry is synced + (spatial+visible) or suspended (hidden/non-spatial) exactly per the + current tail's semantics. A projectile is never left rendered a packet + behind its body (#312's layer — tests must assert it). +3. **The entity stays in-world on every non-commit outcome**: `body.InWorld`, + object clock active, `FullCellId` intact, spatial projection intact. No + park survives the controller. +4. **Prediction invalidation accompanies every body write on this route** + (placement AND store fallback), before the write; an in-flight split + quantum straddling the packet aborts at `Complete`. The no-op + dispositions invalidate nothing. The Vector/State channels' invalidation + sites (`:241`, `:272`) are untouched. +5. **Per-quantum integration is untouched** (trap T7): `TryBeginQuantum` / + `CompleteQuantum` / `AdvanceQuantum` / `CommitProjectileCell` and the + scheduler's split quantum never route through `RuntimeSetPositionState`, + and their allocation profile is unchanged (Slice I's 0 B/resolve + discipline; the placement budget is per accepted packet, not per + quantum). +6. **J5.6's ownership boundary does not regress**: the projectile component, + workset, stepper, and cell commit stay in Runtime; App supplies only DAT + shape resolution and retained projection/shadow/effect acknowledgements; + a rejected presentation acknowledgement invalidates the pending + prediction only — it never rolls back or redirects the canonical owner, + and never becomes collision authority. +7. **The unified remote tail is untouched** — including + `ApplyRemoteContactRouting`, `RunRemoteArmTail`, `ToConstraintArm`, + `TryAdoptWireCellAfterRouting`, `ApplyWireAirborneLeftoverBookkeeping`, + the TS-44 sticky gate, and the #316-preserved player `AirborneSnap` + asymmetry. A missile packet never creates or writes a `RemoteMotion`. +8. **Mutual exclusion is provable** (trap T4): one `FinalPhysicsState` + read per packet decides both the classifier's kind and the App's + dispatch; Missile-set ⇒ projectile arm, Missile-clear ⇒ remote tail; + asserted by the dual-kind theories (§7). +9. **Unhandleable missile packets are swallowed, with their bookkeeping + stated positively** (trap T5 + round-2 B1): an invalid payload still + fails the shared authority gate exactly as today + (`CanAcceptPositionPayload` unchanged); a null/`Rejected*` classification + writes nothing and falls through to NOTHING — and the test asserts what + DID happen (the gate consumed the timestamps; the merge advanced the + snapshot) as well as what did not. +10. **No velocity write from the Position packet**; an in-flight missile's + velocity survives a velocity-less packet bit-identically (the retired + zeroing defect's converse, asserted positively). +11. **Ledger convergence**: teardown, session reset, and generation change + converge `RemotePlacementDrivePendingCount` (both registrations) to zero + with projectile operations in flight — the same suite shape 4b-1 built, + driven through the projectile arm. +12. **`ParkCollisionResidents`'s overlap throw stays unreachable** — the + projectile arm adds packets to the same one-operation-per-key machinery + and opens no new operation shape; restate the argument (with 4b-1's B2 + stall-not-throw caveat) in the implementation commit. +13. **The lost-cell reaper gains no production caller.** +14. **The Create half and residence-window half are untouched**: the + residence kind decision (`:591-595`), first-entry admission + (`:311-314`), the continuation executor (including its recorded + non-SetPosition trace gap at `:2019-2026` — the REMOTE half of that gap + stays open and untouched), the materializer's residence-gated `TryBind` + skip, and the projection-visible `TryBind` retry. +15. **`TryBind` and its create branch are NOT deleted** (trap T8) — still + the live fallback for `initialResidenceActive == false` and the mid-life + Missile flip; C5's deletion pass owns it. Record, don't touch. +16. **`OwnsFarSnap` / `OwnsTeleportPlacement` and the two RemoteMotion arm + methods are not widened, not reused, not weakened** — their throwing + guards are the loud failure mode protecting item 7. + +**The D4 constraint-arm partition, extended (the projectile column is the +new content; the remote column is 4b-3's, unchanged and re-asserted):** + +| disposition (wire contact) | retail return | remote arm | projectile arm | +|---|---|---|---| +| `SetPosition` (teleport/cell-less, any contact) | 1 | arms, every placement outcome | **never arms** (register row) — hook reduction runs (force-end), placement + store partition run | +| `SetPositionSimple` (far, grounded) | 1 | arms | **never arms** (row) — placement + store partition run | +| `Interpolate` (near, grounded) | 1 | arms | **never arms, no-op** (row) | +| `NoPositionOperation` (airborne) | 0 | no arm | no arm, no-op — **faithful, not a divergence** | +| null / `Rejected*` | n/a | `UnroutedCatchUp` arms (grounded) / D2 shape (airborne) | **swallow — no arm, no write** (row) | + +--- + +## 6. Proof obligations (must prove, not assume; stated in the implementation commit) + +- **P1 — invariant 12's unreachability argument**, restated with the B2 + caveat. +- **P2 — the commit-acknowledgement path.** Verify what acknowledges/retires + a `ProjectileAuthoritative` commit that lands in + `_awaitingAcknowledgement` (`CommittedHostAcknowledgementPending`, + `RuntimeRemotePlacementDriveController:1062-1076`): either the shared + projection-acknowledgement chain is kind-agnostic (state where), or the + self-heal prune is the retiring mechanism (state that, and why it is + acceptable — or route the ack explicitly). Do not let a projectile commit + sit in a ledger dimension nothing retires. +- **P3 — `RuntimeSetPositionState` kind-agnosticism.** Walk the operation + lifecycle for a `ProjectileAuthoritative` operation: the two internal + constructors that stamp `Kind = RemoteAuthoritative` (`:3833/:3866` + window-departure park; `:5338/:5350` legacy-direct withdrawal) and any + kind-conditional stage logic (`:1530`, `:5445`). Establish that none can + capture, rewrite, or strand a projectile operation — or fix the stamp to + carry the originating kind if one can. (A park constructor relabeling a + projectile op as `RemoteAuthoritative` would silently move it between + ledger columns — the T4 mis-tag shape at the operation layer.) +- **P4 — the byte-decode of `MoveOrTeleport`'s `arg5`** (D-P5). +- **P5 — the force-end wiring** (D-P4): one assertion that a teleport/ + cell-less projectile packet empties the owner's collision table (the same + cheap observable the 4b-3 round-2 review named for R2's regression risk). + +--- + +## 7. Test plan + +Rules: assert the layer that historically broke (presentation, prediction, +ledger — not only `InWorld`/clock); **assert positive facts, not only +negatives** (round-2 finding B1 — every "writes nothing" test also asserts +the two or three things that DID advance); every new test must fail against +a broken implementation (no source-text pins). + +**The dual-KIND discipline** — this route's analog of the collapse +contract's dual-guid theories, because the discriminator here is the Missile +bit, not the guid range: every OnPosition-level scenario is a `[Theory]` run +twice against the same packet shape — once with `FinalPhysicsState` carrying +`Missile`, once without — asserting the projectile arm claimed one +(projectile facts advanced; NO `RemoteMotion` exists afterward) and the +remote tail claimed the other (`RemoteMotion` facts advanced; no projectile +seam effect). This is what makes a future silent mis-tag fail a test. + +Focused Runtime tests (`tests/AcDream.Runtime.Tests`): + +1. **Classification kind derivation** (D-P1): Missile-bit canonical record + classifies with `OperationKind == ProjectileAuthoritative` through the + production `ClassifyRemoteAcceptedPosition` entry; bit-clear classifies + `RemoteAuthoritative`; same packet, same record otherwise. Plus the + flip-mid-life pair (bit set after a State merge → next Position + classifies Projectile; bit cleared on impact → next Position classifies + Remote). +2. **`OwnsPlacement` widening**: projectile SetPosition/SetPositionSimple + routes admitted; projectile Create (`InitialCreateFlags`) still excluded; + remote routes unchanged (the existing OwnsPlacement expectations stay + green untouched). +3. **Per-disposition seam behaviour** (D-P2), one test per row of the §5 + table's projectile column: teleport/cell-less commit (body at resolved + destination, cell committed canonically, collision table force-ended, + prediction version advanced, tail facts per invariant 2); far commit; + teleport/far refused (destination outside the service window → pose + STILL advances to the accepted destination, no park retained, `InWorld`, + prediction invalidated); `RejectedByPlacement` (pose does NOT move, + settled pose survives); near no-op (body bit-identical, prediction + version UNCHANGED — the positive fact that a straddling quantum may + complete); airborne no-op (same); null/`Rejected*` swallow (nothing + written; timestamps/merge advanced — positive). +4. **Prediction invalidation across a split quantum** (trap T3): begin a + quantum (`TryBeginQuantum`), apply an accepted far/teleport Position + through the new arm, then `CompleteQuantum` — the completion must abort + (return false / not overwrite the committed placement), and the committed + pose must survive. This is the test the scoping said is invisible if you + only read the classifier; it must exist before the old path is deleted. +5. **No-velocity invariant**: an in-flight missile with nonzero velocity + receives a velocity-less accepted far Position → body placed AND velocity + bit-identical (the zeroing defect's regression test, positive form). +6. **Ledger/teardown**: proof obligation-11 suite — reset/teardown with a + retained projectile preparation retry and with an awaiting-acknowledgement + commit in flight; both registrations converge to zero. +7. **Constraint never armed**: after every projectile disposition, no + `PositionManager`/constraint exists anywhere for the entity (and the D4 + partition test for remotes is untouched). + +App-layer tests (`tests/AcDream.App.Tests`): + +8. **The dual-kind OnPosition matrix** (above) for at least: far commit, + teleport commit, near packet, airborne packet, null-classification + packet. Projectile half asserts: no `RemoteMotion` created, no generic + wire-pose write, no rebucket-driven bucket move beyond the canonical + commit, render entity == resolved body (#312's layer), root pose updated, + shadow synced/suspended per spatial state. +9. **The invalid-payload swallow** (T5): an invalid missile Position is + consumed (gate/validation) and the remote tail observably did not run — + asserted positively via the gate's timestamp state. +10. **Sabotage check (manual, once, before the commit is finalised)** — the + collapse contract's experiment, adapted: break the seam (skip the store + fallback, or skip `InvalidatePrediction`) and confirm the matrix fails + on the projectile half; break the kind derivation (hardcode Remote) and + confirm the dual-kind theories fail. If a sabotage survives, fix the + test, not the sabotage. + +Existing tests: `ProjectilePosition_UsesRemoteMoveOrTeleportClassification` +survives (it pins the disposition identity, which is unchanged); the +`ProjectileController` position tests covering D2/D3's deleted bodies are +re-expressed against the seam (not deleted as collateral — each scenario +must map to a successor or be named as obsolete-with-reason in the commit). + +--- + +## 8. Gates + +- **Focused**: the §7 suites, green. +- **Complete Release suite**: + `$env:ACDREAM_PAK_PATH = "$env:USERPROFILE\Documents\Asheron's Call\acdream.pak"`, + `dotnet test AcDream.slnx -c Release -m:1`. **Baseline 11,036 passed / 4 + skipped / 0 failed at `30d3d114`.** The count will rise; measure and + record the new figure — do not inherit the baseline. Two known flakes, + never chase and never conflate (they have been conflated twice): **#302** + (`PortalProjectionTests.ClipToRegion_FrameOwnedStore_…`, GC-allocation + assertion, App.Tests) and **#308** (`NakEmissionTests.LossSoak_…`, + wall-clock deadline, Core.Net.Tests, full-suite load only). If either + appears, re-run and say which. +- **NO CONNECTED GATE EXISTS, and none is invented.** ACE never sends + `UpdatePosition` for a missile (`WorldObject_Tick.cs:333-334`, commented + out inside the `PhysicsState.Missile` branch at `:265`; every live + `SendUpdatePosition` caller is a player/creature/pet/gamepiece/admin + path). The accepted-Position half of this route is unreachable in play + against ACE; deterministic tests are its ONLY gate, and this contract + records that explicitly so a green connected session is never presented + as evidence for it. **Optional, not a gate:** an ordinary bow/spell-bolt + smoke (arrow spawns at the launch point, flies a clean arc, vanishes on + impact, leaves no invisible collider at the impact point) exercises the + UNTOUCHED Create/Vector/State halves and is cheap insurance against a + wiring mistake in the deletion — record it as a no-regression observation + if run, never as coverage for the Position half. + +--- + +## 9. Budget and stop conditions + +**Budget** (scoping's figures re-validated against HEAD): ~244 raw lines +deleted across the three D-sites (~180 non-comment); added — seam 60-110, +kind derivation + builder plumbing 5-15, `OwnsPlacement` widening + docs +~10, App dispatch 25-50, hook-reduction wiring 10-25: **total added 110-210 +non-comment production lines; net roughly flat-to-negative.** Tests are the +larger share, ~400-700 lines. The collapse SHRANK the App portion versus the +scoping's 40-70 (one dispatch site, not two). + +**Stop and report rather than pushing through when:** + +1. Added production lines exceed ~300, or the design starts needing a + `RemoteMotion`, `EntityPhysicsHost`, `PositionManager`, or + `InterpolationManager` for a projectile — that is the 5b split this + contract rejected; it does not get built ad hoc. +2. The unified remote tail, `ApplyRemoteContactRouting`, either surviving + guid-conditional, `OwnsFarSnap`/`OwnsTeleportPlacement`, or either + RemoteMotion arm method needs an edit beyond comment repointing. +3. Any remote-classification test changes expectation — the remote route + must be byte-identical. +4. Proof obligation P3 finds an operation-lifecycle site that can capture or + relabel a projectile operation. +5. P4's byte-decode finds a live `arg5` use in `MoveOrTeleport`. +6. Evidence appears that ACE (or the connected environment) DOES emit a + missile `UpdatePosition` — the gate section changes and the user decides. +7. The complete Release suite deviates from baseline beyond the two named + flakes. + +--- + +## 10. What this slice does NOT do + +- **AP-131** (shared merge call) — C5. **AP-135** — stays, untouched (its + sites are the remote tail's, which this slice does not enter). +- **#276** — untouched. +- **#316** — the player landing block's missing shadow publish: **OPEN, + unmeasured, and must not be "fixed" here** (it lives in the remote tail's + preserved asymmetry; measuring it first is its own issue's requirement). +- **`ProjectileController.TryBind` + create branch** — recorded, retained; + C5's deletion pass (trap T8). +- **The per-quantum stepper, Vector channel, State channel** — untouched + (invariants 4/5). +- **No PositionManager/interpolation machinery for projectiles** (the + rejected 5b) and no headless remote/projectile motion consumer. +- **No changes to routes 2/3/6/7**, the local-player paths, or the + continuation executor's remote trace gap. +- **The remote prologue's questionable velocity comment** (`:2296-2300`, + §4's reporting note) — reported, not edited; it belongs to a 4a-family + correction. + +--- + +## 11. Contradictions and stale claims versus the scoping — reported, not smoothed + +1. **Every scoping file:line in `LiveEntityNetworkUpdateController` is + stale** (the collapse rewrote the file): the short-circuit `:1428-1448` → + `:2104-2125`; the fabricated velocity `:1442-1443` → `:2119-2120`; the + `CanAcceptPositionPayload` call `:1205-1208` → `:1898-1901`; + `OwnsPlacement` `:274-278` → `:520-524`; the lifetime's kind hardcode + `:622` → `:642`. §1 is the corrected inventory. +2. **The scoping's central structural assumption — that route 5 rewrites + call sites inside two parallel remote copies — is void.** There is one + unified tail, and route 5 does not enter it at all (§2). The scoping's §8 + App-rewrite estimate shrinks accordingly. +3. **The scoping under-counted the ownership fences.** It named only + `OwnsPlacement` (§5.2); 4b-2/4b-3 added `OwnsFarSnap` and + `OwnsTeleportPlacement` (both `RemoteAuthoritative`-gated) and two + throwing, RemoteMotion-required arm methods. "The far and teleport + branches have no handler the instant the short-circuit is removed" is + still true, but the failure mode is now loud (throws/refusals), and the + fix is a sibling seam over the shared core — not merely the ~10-line + widening the scoping described. +4. **The scoping's §5.2 sequencing warning ("that file is route 4b-2's + active edit surface") is spent** — 4b-2/4b-3/collapse/#315 have all + landed; route 5 has the file to itself. +5. **The scoping's §1.1 claim that the classifier is "disposition-identical" + for Projectile and Remote remains true at HEAD** (re-verified against the + current classifier body and its pinning test) — the collapse changed the + App, not the classifier. +6. **The scoping's T5 swallow concern is narrower than written**: the + invalid-payload swallow largely happens UPSTREAM in the shared authority + gate (`CanAcceptPositionPayload` feeds `TryAcceptPosition`, which returns + before the short-circuit); D2/D3's internal `return true` swallows cover + only the origin-translated-world-position and secondary-velocity checks. + The invariant (no fall-through to the remote path) is pinned regardless + (§5 item 9); the mechanism the scoping described was partially stale even + at its own HEAD. +7. **New since the scoping and binding here:** #316 (filed by the collapse + contract; carried untouched), #315's cached-callback pattern (binds new + projectile wiring), and the collapse's two protected guid asymmetries. +8. **No contradiction found in the scoping's retail research** — every §2 + claim re-verified (§4), including the ConstrainTo/MakePositionManager + chain, the arg5 non-use (still textual-only; byte check now mandatory), + and the ACE never-sends fact. The scoping's recommendation of §5.1 + option 2 (record the divergence, build no machinery) is adopted and + pinned (D-P4). +9. **One adjacent-comment defect found while verifying** (not in the + scoping): the remote prologue's `:2296-2300` velocity justification + contradicts the decomp (§4 note). Out of scope; reported. diff --git a/docs/research/2026-08-04-c4-route-5-retail-review-round2.md b/docs/research/2026-08-04-c4-route-5-retail-review-round2.md new file mode 100644 index 00000000..5dca63a6 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-5-retail-review-round2.md @@ -0,0 +1,418 @@ +# C4 route 5 — projectile authoritative placement: retail-conformance review, ROUND 2 (delta) + +**Reviewer lens:** retail fidelity only. Delta against +[`2026-08-04-c4-route-5-retail-review.md`](2026-08-04-c4-route-5-retail-review.md) +(round 1, FAIL) and cross-read against +[`2026-08-04-c4-route-5-architecture-review.md`](2026-08-04-c4-route-5-architecture-review.md) +(A1-A11). + +**Subject:** the uncommitted working tree, HEAD `30d3d114`, branch +`claude/acdream-physics-divergence-5aa784` — 1,626 insertions / 398 deletions +across 10 files plus two untracked test files. + +--- + +## VERDICT: **FAIL** + +A narrow FAIL. Eleven of the twelve round-1/architecture findings are properly +closed, several of them better than the fix direction I suggested. The FAIL +rests on two MAJORs: + +- **B1** — the fix to my own round-1 R6 went the wrong way and introduced a + position/cell mismatch on the stored partition. **R6 as I wrote it was + factually wrong, and I own this**: I claimed the render cell was "left + behind" while the position moved; it was not — `record.FullCellId` is + already the wire cell at ack time, so the original code was self-consistent. + Switching to the body's own cell made it inconsistent. This is a one-token + revert. +- **B2** — the R2/A3 adopted-body fix closed the **teleport** branch and left + the **far** branch. Retail's far branch @0x005163C1-@0x005163CB runs + `StopInterpolating` whenever `position_manager != 0`, and re-anchors the + leash @0x00454272 on any nonzero return. For an adopted missile both + managers exist, both actions are live in retail, and acdream now performs + neither. I confirmed the harm is real, not theoretical. + +Both are the same shape as the findings they descend from: an invariant +satisfied on one arm only. + +--- + +## A. Round-1 / architecture findings — delta status + +| ID | round-1 / arch | status | notes | +|---|---|---|---| +| R1 / A2 | unbound missile dropped | **CLOSED — better than my fix direction** | see A1 below | +| R2 / A3 | adopted-body hook unwired | **PARTIAL** | teleport branch closed; far branch open → **B2** | +| R3 | retry arm skips invalidate/sync | **CLOSED** | see A3 | +| R4 / A7 | hidden-branch `LastUpdateTime` dropped | **CLOSED** | restored at `SyncProjectilePresentation`'s `else if (spatial)` with the correct rationale quoted from `TryBind` | +| R5 / A1 | App ignores seam status | **CLOSED** | see A4 | +| R6 | stored-outcome cell | **REGRESSED — my finding was wrong** | → **B1** | +| R7 / A5 | no App-level coverage | **CLOSED** | 7 new `OnPosition` tests incl. the unbound fall-through and the adopted-body hook | +| R8 | unjustified-velocity comment untracked | **OPEN, deliberately** | judgment in D below — partially acceptable | +| A4 | `SyncProjectilePresentation` untested | **CLOSED** | real `ShadowObjects` entry / `TotalRegistered` / `Active`-flag assertions across four new tests | +| A6 | `wasInWorld` read after placement | **CLOSED** | captured pre-dispatch and threaded as a parameter; `…ReenteringWorldReactivatesBody` pins it | +| A8 | silent shadow skip | **CLOSED** | escalates via `ThrowIfWorldFrameUnreachable`, matching `StoreAcceptedDestinationPose`'s #284 policy | +| A9 | null-route fallback unfenced from the player | **CLOSED** | `update.Guid != _playerServerGuid &&` added | +| A10 | `OwnsFarSnap` doc false in the kind dimension | **CLOSED** | corrected paragraph added | +| A11 | cutover ledger stale | not checked here (outside D-P7's letter; architecture reviewer's call) | | + +--- + +## B. Verification of the four items you asked me to judge + +### A1 — the conjunctive kind predicate: is it retail-CORRECT, not merely regression-free? + +**Yes. PASS, and the reasoning is stronger than "it restores the old +fall-through".** + +`RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition` now derives: + +``` +Missile bit && canonical.Projectile is bound && ReferenceEquals(canonical.PhysicsBody, projectile.Body) + ? Projectile : Remote +``` + +**Why this is retail-correct rather than an implementation detail smuggled +into a classification:** + +1. **`RuntimePositionEntityKind` is not a retail concept and has no retail + effect.** I re-verified `RuntimeAuthoritativePositionRouteClassifier`: + the only kind branch in `ClassifyAcceptedPosition` is + `LocalPlayer` (`:349`); `ValidEntityKind` (`:535-538`) admits all three; + the sole downstream difference is `OperationKind` (`:564-575`). Projectile + and Remote produce byte-identical disposition, `SetPositionFlags`, + `StopInterpolating`, `TeleportHookPhase` and `ConstrainPhase`. The kind is + therefore an **acdream ownership label selecting which arm executes**, not + a reproduction of any retail decision. Retail's `MoveOrTeleport` + @0x00516330 has no state test at all (round-1 §A1, byte-confirmed), so + there is no retail predicate for this code to be unfaithful to. +2. **The check lives in the right place.** It is in the *caller* that maps + acdream state → kind, not inside `ClassifyAcceptedPosition`, which remains + a pure port of retail's decision tree. Nothing acdream-specific entered the + retail-faithful classifier. +3. **Mis-routing a genuinely-bound missile is harmless in the retail + direction.** The only way a live missile classifies `Remote` is if + `record.PhysicsBody` was replaced out from under a surviving + `RuntimeProjectile` — already a broken state, and one that + `ApplyAcceptedProjectilePosition`'s identical guard would refuse anyway + (round 1: it dropped the packet). Taking the Remote arm instead **places + the canonical body**, which is what retail does; it also arms the leash and + runs the full hook, i.e. *more* of retail's behaviour, not less. There is no + direction in which the conjunctive predicate produces less retail-faithful + output than the arm it diverts from. +4. **The reverse mis-route cannot happen.** A non-missile can never satisfy + clause 1, so no ordinary remote is diverted into the projectile arm. + +The App's null-classification fallback +(`LiveEntityNetworkUpdateController.cs:2132-2140`) now applies the **same three +conjuncts plus the A9 player fence**, so the two discriminators still cannot +disagree. Verified by reading both. + +The doc comment's claim — *"Retail's `MoveOrTeleport` places EVERY non-player +object unconditionally — it has no concept of 'client-side machinery not yet +bound'"* — is true and matches my byte-level read of the function. + +### A2 — is "has `RemoteMotion`" the right proxy for "has managers"? + +**For actions 1-4, yes, provably. For action 5, the proxy is imperfect but +skipping is still the retail-correct outcome.** PASS with a note. + +Mapping retail's six `teleport_hook` @0x00514ED0 actions onto acdream's +owners: + +| retail action | guard | acdream owner | reachable without `RemoteMotion`? | +|---|---|---|---| +| `MovementManager::CancelMoveTo` @0x00514EDD | `movement_manager != 0` | `RemoteMotion.Movement` (a field on `RemoteMotion`, `RemoteMotion.cs:36`) | **no** | +| `PositionManager::UnStick` @0x00514EEE | `position_manager != 0` | `RemoteMotion.Host.PositionManager` (`Host` is gated on `_fullPhysicsHostBound`, `:87`) | **no** | +| `PositionManager::StopInterpolating` @0x00514EFD | same | `RemoteMotion.Interp` (`:233`) | **no** | +| `PositionManager::UnConstrain` @0x00514F0C | same | `RemoteMotion.Host.PositionManager` | **no** | +| `TargetManager::ClearTarget`/`NotifyVoyeurOfEvent` @0x00514F1B | `target_manager != 0` | `EntityPhysicsHost.TargetManager` | **yes** — see below | +| `report_collision_end` @0x00514F31 | *unguarded* | `RuntimeCollisionReportingState.LeaveWorld` | run by the Runtime seam regardless ✓ | + +The one leak: `LiveEntityMotionRuntimeController.ResolvePhysicsHost:246-250` +installs a **minimal** `EntityPhysicsHost` for any record the target/moveto +resolver touches, and that host carries a `TargetManager`. So a bare missile +*can* hold a `TargetManager` while `record.RemoteMotion` is null, and the +reduction skips its `ClearTarget`/`NotifyVoyeurOfEvent`. + +**This does not make skipping wrong.** Retail's missile has **no** +`TargetManager` — `MakeTargetManager` is lazy and nothing in a ballistic +object's life creates one — so retail's guard @0x00514F19 no-ops. acdream's +eager minimal-host creation is a pre-existing structural difference from +retail, not something route 5 introduced, and running the action would be the +divergence, not skipping it. Recorded so the next round does not re-open it. + +### A3 — the retry arm (my R3) + +**CLOSED and correct.** `Advance()` now derives the projectile/body pair from +`pending.Route.OperationKind` under the same three conjuncts, captures +`pendingWasInWorld` **before** the resubmit (matching A6's ordering fix), +calls `InvalidatePrediction()` before both the store fallback and the +resubmit, and gates `SyncProjectilePresentation` on the same +non-`Deferred`/non-`RejectedByPlacement` partition. The invariant now holds on +both arms. + +One residual, **pre-existing and shared with the remote arm, not a finding**: +the retry path never calls `StoreAcceptedDestinationPose` after a +`SubmitAndResolve` that returns `Contention`/`RejectedPreparation`, so +invariant 1's pose advance is skipped on a re-parked retry. That was true +before this slice (`_ = SubmitAndResolve(...)`) and is the remote arm's +behaviour too. Recorded, not filed. + +### A4 — the App presentation gate, and whether "stored outcomes write the cell too" is now true + +**The gate is CLOSED and correct. The cell question is NOT — and it is worse +than before. See B1.** + +The gate itself: App now calls `SyncPresentationFromResolvedBody` only when +the status is non-null and neither `Deferred` nor `RejectedByPlacement`, +mirroring Runtime's own partition exactly. I walked every outcome: + +| outcome | body state | ack? | retail analogue | +|---|---|---|---| +| `Committed` | at resolved destination, cell committed | yes | `SetPosition` success | +| `Refused`/`Contention`/`RejectedPreparation`/`NotApplicable` | position stored at destination, cell NOT written | yes | `store_position` @0x00515CE2 | +| `Deferred` | snapped to parked result, withdrawn | no | park (acdream-only, AP-136/138) | +| `RejectedByPlacement` | untouched | no | @0x00515CB2 / @0x00515CD5 — retail's non-storing failures also leave the object where it was ✓ | +| `null` (no-op / swallow) | untouched | no | `return 0` @0x0051636D, or acdream-only | + +Correct on every row. + +--- + +## C. New MAJOR findings + +### B1 — MAJOR — the R6 "fix" pairs the destination position with the pre-packet cell on every stored outcome; the correct source was the one it replaced + +**Where:** `src/AcDream.App/Physics/ProjectileController.cs`, +`SyncPresentationFromResolvedBody` — `entity.ParentCellId = +runtime.Body.CellPosition.ObjCellId` (was `record.FullCellId`). + +**Retail contradicted:** `CPhysicsObj::store_position` @0x00515CE2, reached +from `SetPositionInternal`'s no-resolvable-cell branch @0x00515C1D. It writes +the object's **whole** `Position` — `objcell_id` together with the frame — so +after a retail store the object's cell and position are the *same* cell, +the destination. It is never left describing a position in cell B while +claiming membership of cell A. + +**My round-1 R6 was wrong and I induced this.** I wrote that on a stored +outcome "the render cell is left behind while the render position moves". It +is not: `RuntimeEntityRecord.RefreshDerivedState` → `SetFullCell` +(`RuntimeEntityRecord.cs:232-237`) stamps `FullCellId = position.LandblockId` +at merge time, *before* classification, and +`StoreAcceptedDestinationPose` composes `body.Position` from +`accepted.PositionX + worldOffsetX` where the offset comes from +`accepted.LandblockId` — **the same cell**. So the original +`entity.ParentCellId = record.FullCellId` paired a wire-frame position with +the wire cell: self-consistent, and retail's store semantics. The architecture +reviewer had this right in its "Verified — no finding" section; I did not. + +**What the change now produces on a stored outcome:** + +- `entity.Position` = destination, expressed in cell **B**'s world frame +- `entity.ParentCellId` = `body.CellPosition.ObjCellId` = cell **A** + (`StoreAcceptedDestinationPose` never writes the cell — the AP-138 residual) + +That is precisely the defect shape the architecture review's A1 named — a +render entity parented into a cell it is not geometrically inside, culled or +drawn through walls when A is an indoor EnvCell — relocated from the no-op +partition (now fixed) to the stored partition (now broken). + +**Three independent cross-checks all say `record.FullCellId`:** + +1. **The sibling remote arm.** `TryApplyGenericRemoteRenderPose` + (`LiveEntityNetworkUpdateController.cs:1010-1025`) writes + `entity.ParentCellId = landblockId` — the **wire** cell — paired with the + wire world position. Parity demands the projectile arm do the same. +2. **Runtime's own shadow publish, forty lines away.** + `SyncProjectilePresentation` publishes + `ShadowObjects.UpdatePosition(..., record.FullCellId, seedCellId: record.FullCellId)` + with `body.Position`. After this change the shadow says cell B and the + render entity says cell A **for the same body in the same call**. +3. **Retail**, as above. + +**The doc comment defending the change is self-refuting.** It states *"Retail's +own `store_position` @0x00515CE2 writes the object's whole `Position` +including `objcell_id`; reading the body's own cell here is the client-side +analogue"* — the premise is right and the conclusion inverts it. Retail's +`objcell_id` after a store is the **destination** (`record.FullCellId`), not +the stale one. + +**Untested.** Both new App commit tests +(`MissileTeleportCommit_…ParentCellIdAgreesWithBody`, +`MissileFarCommit_…`) assert on **committed** outcomes, where +`body.CellPosition.ObjCellId == record.FullCellId == DestinationCell` and the +two sources coincide. No test drives a stored outcome through the App ack, so +the suite is green either way — a green suite is not evidence. + +**Correct behaviour:** revert to `entity.ParentCellId = record.FullCellId`, +and add an App-layer stored-outcome test (`Refused`) asserting the render +position and `ParentCellId` are both the **destination** cell's. + +--- + +### B2 — MAJOR — the adopted-body fix closed the teleport branch only; retail's FAR branch also runs `StopInterpolating`, and re-anchors the leash, whenever the manager exists + +**Where:** `LiveEntityNetworkUpdateController.cs` — the hook is gated on +`route.Disposition is …SetPosition && acceptedPositionCanonical.RemoteMotion is RemoteMotion`; +and `RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition`'s +`case …SetPositionSimple:` does nothing but invalidate and place. + +**Retail contradicted, two sites:** + +``` +005163c1 position_manager = this_1->position_manager; +005163c9 if (position_manager != 0) +005163cb PositionManager::StopInterpolating(position_manager); +005163d9 CPhysicsObj::SetPositionSimple(this_1, arg2, 1); +``` + +and, for **every** nonzero return including this one, +`ConstrainTo(arg2, &arg2->m_position, …)` @0x00454272 — which re-anchors the +leash at the object's **just-updated** position. + +**Why the contract's justification does not cover the adopted case.** D-P2 +pins the far branch's interp skip as *"retail-faithful by consequence"* +because *"a never-interpolated missile has none"*. That premise is exactly +what the adopted-body case violates: `TryBind`'s shared-body branch +(`ProjectileController.cs:176-181`) exists for an object that was a live +remote first, so it carries a populated `RemoteMotion.Interp` and, if hosted, +a `PositionManager` whose leash route 4a +(`RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation`) already +armed. Retail's `position_manager != 0` guard is **satisfied** there. + +**The harm is real, not structural.** I traced it: + +- `RuntimePhysicsState.cs:697-708` puts the same key in **both** + `_spatialRemotes` and `_spatialProjectiles` when the record has both + components. +- `RuntimeRemotePhysicsUpdater` consumes `rm.Interp` for every + `_spatialRemotes` entry (`:288`, `:331`, `:941`). +- `ProjectileController.Tick`'s per-quantum tail runs + `RetailObjectManagerTail.Run(remote.Host?.TargetManager, remote.Movement, + null, remote.Host?.PositionManager)` for exactly this record shape. + +So after a far snap, a stale waypoint and a leash anchored at the *pre-snap* +position both remain live and drag the freshly-placed missile back — the +identical scenario the architecture review's A3 used to justify wiring the +teleport branch. The teleport branch got the fix; the far branch, which is the +more common disposition for a moving object at >=96 m, did not. + +**AP-141 is now factually wrong in its risk column.** It states *"a live +missile never shows a constraint leash"*. After the round-2 fix that is false +for the adopted-body case: such a missile can carry a leash inherited from its +pre-Missile remote life, the teleport branch now clears it, and the far branch +neither clears nor re-anchors it. This is the "a register row asserting +behaviour the code does not have" defect class the 4b-3 reviews named. + +**Correct behaviour:** either run the same hook seam on the far branch's +manager-bearing case (retail runs only `StopInterpolating` there, not the full +hook — so the minimal faithful action is `remote.Interp.Clear()` gated on the +host existing, mirroring @0x005163C9), and decide the leash re-anchor +explicitly; or amend AP-141 to state that for an adopted body the far branch +skips retail's guarded `StopInterpolating` @0x005163CB and leaves an inherited +leash un-re-anchored. Do not leave the row asserting the opposite. + +--- + +## D. MINOR findings + +### B3 — MINOR — `report_collision_end` now runs twice for the adopted teleport case, and the hook is split across two owners + +App's `RunRemoteTeleportHook` executes all six actions including +`ReportCollisionEnd → LiveEntityRuntime.ForceEndCollisionReporting → +RuntimeCollisionReportingState.LeaveWorld`; the Runtime seam then runs +`CollisionReports.LeaveWorld(record)` again before the placement. Retail calls +@0x00514F31 once. The second call is benign (the table is already empty, so +`ForceEnd` finds nothing; it only bumps `_mutationRevision`), and the ordering +relative to `SetPosition` is preserved, so this is cosmetic — but one retail +function now has two owners across a layer boundary, which is how the *order* +of a six-step sequence gets broken later. Cleanest: have the Runtime seam skip +its own `LeaveWorld` when the caller supplied the full hook, or move the whole +hook behind the Runtime seam. + +### B4 — MINOR — the projectile hook allocates a per-packet closure while its comment claims the "#315 pattern" + +`LiveEntityNetworkUpdateController.cs`, projectile arm: + +```csharp +RunRemoteTeleportHook( + acceptedPositionCanonical, + adoptedRemote, + () => _liveEntities.IsCurrentPositionAuthority( // fresh closure, every packet + acceptedPositionRecord, + acceptedPositionAuthorityVersion)); +``` + +The comment says this uses *"the SAME ordered hook seam and per-packet currency +check the remote teleport arm already uses (`RunRemoteTeleportHook`, #315 +pattern)"*. The remote arm does not do this: it passes +`_remoteArmCallbacks.RunTeleportHook`, a **cached** method group over scratch +fields (`RunCachedRemoteTeleportHook`), which is precisely what #315 introduced +to remove per-packet closures from the packet path. The contract restates that +constraint (§2 item 5). The seam is shared; the allocation discipline is not, +and the comment asserts otherwise. + +### B5 — MINOR — R8 remains open + +Judgment you asked for: **deferring the audit is acceptable; deferring the +tracking is not.** The comment now asserts, in production source, that a live +call's retail justification is unestablished. With no `docs/ISSUES.md` row and +no AP row, that assertion is discoverable only by reading +`LiveEntityNetworkUpdateController.cs`. One line in ISSUES.md ("4a remote arm's +`TryCommitAuthoritativeVelocity` has no established retail basis — see the +comment at the call site; `MoveOrTeleport` @0x00516330 byte-confirmed not to +install a velocity") costs nothing and should land with this commit. The +substantive audit belongs to the 4a family, as the contract says. + +### B6 — MINOR — the stored-outcome shadow publish uses an unadvanced body on a re-parked retry + +`Advance()`'s retry arm calls `SyncProjectilePresentation` after a +`SubmitAndResolve` that returned `Contention`, but never +`StoreAcceptedDestinationPose` on that path (B-section A3's residual). The +shadow is therefore published at `record.FullCellId` (the wire cell) with a +body still at its pre-packet pose — the mirror image of B1, inside Runtime. +Parity with the remote arm, pre-existing, and it disappears if B1's residual +(store never writes the cell, AP-138) is ever closed. Recorded, not filed. + +--- + +## E. Retail claims re-verified this round (no change) + +Re-checked because the fix touched the surrounding code, not re-derived from +scratch (round 1 §A has the full derivations): + +- `MoveOrTeleport` @0x00516330 — no state/kind test; `arg5` at `[esp+0x7C]` + never read (byte-confirmed round 1); `ret 0x10` at all four exits. +- `teleport_hook` @0x00514ED0 — six actions, five per-manager guarded, the + sixth unguarded. The App fix drives the in-tree ordered port + (`RemoteTeleportHook.Execute`) with per-action `host?.` guards, so retail's + guards decide rather than being re-derived. Correct for the teleport branch. +- `ConstrainTo` @0x00510520 → `MakePositionManager` @0x00510523; the single + arming site @0x00454272 has no kind test. AP-141 clauses (a) and (b) remain + accurate descriptions of retail; only the row's **risk** column is now wrong + (B2). +- `store_position` @0x00515CE2 writes `objcell_id` with the frame — the basis + of B1. +- ACE never sends a missile `UpdatePosition` + (`WorldObject_Tick.cs:333-334` inside the `PhysicsState.Missile` branch at + `:265`), so B1 and B2 are both test-reachable only. Stated for calibration, + not as a reason to ship them. +- AP-131 and AP-135 untouched; `docs/ISSUES.md` unmodified; #276 not closed. + +--- + +## F. Summary + +| # | Sev | Where | Retail address contradicted | +|---|---|---|---| +| B1 | MAJOR | `ProjectileController.SyncPresentationFromResolvedBody` (`entity.ParentCellId`) | `store_position` @0x00515CE2 — writes `objcell_id` with the frame; also breaks parity with `TryApplyGenericRemoteRenderPose` and with the shadow publish in the same call | +| B2 | MAJOR | `LiveEntityNetworkUpdateController` hook gate (`SetPosition` only) + `ApplyAcceptedProjectilePosition`'s `SetPositionSimple` arm | `StopInterpolating` @0x005163C9-@0x005163CB (guard satisfied for an adopted body); `ConstrainTo` @0x00454272 re-anchor | +| B3 | MINOR | App hook action 6 + Runtime seam `LeaveWorld` | @0x00514F31 called once in retail | +| B4 | MINOR | projectile arm's `Func` closure | — (contract §2 item 5; comment false) | +| B5 | MINOR | no ISSUES/AP row for the acknowledged-unjustified velocity commit | — | +| B6 | MINOR | `Advance()` retry shadow publish | — (mirror of B1 inside Runtime; parity, pre-existing) | + +**Both MAJORs are small edits.** B1 is one token. B2 is the far-branch half of +a fix already written for the teleport branch, plus an AP-141 risk-column +correction. diff --git a/docs/research/2026-08-04-c4-route-5-retail-review-round3.md b/docs/research/2026-08-04-c4-route-5-retail-review-round3.md new file mode 100644 index 00000000..8db8ae27 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-5-retail-review-round3.md @@ -0,0 +1,282 @@ +# C4 route 5 — projectile authoritative placement: retail-conformance review, ROUND 3 (narrow delta) + +**Reviewer lens:** retail fidelity only. Delta against +[round 2](2026-08-04-c4-route-5-retail-review-round2.md) (FAIL: B1, B2), which +the architecture reviewer corroborated independently as its B2 and B1. + +**Subject:** the uncommitted working tree, HEAD `30d3d114`. + +--- + +## VERDICT: **PASS**, with one MUST-FIX-IN-COMMIT documentation correction (C1) + +Both round-2 MAJORs are closed, and closed well. B1's revert is right *and* its +regression test is self-verifying rather than merely sabotage-checked. B2's +fix is placed in the correct layer, in the correct order, with the correct +guard. + +The one required edit is a single sentence in AP-141's risk column that +asserts a consequence neither retail nor acdream has — **and I wrote the +mistake it was copied from.** Round 2's B2 claimed a stale leash would "drag +the freshly-placed missile back". That is wrong: acdream's `ConstraintManager` +port brakes, it never pulls. I retract it in §C1 and state what the residual +actually is. Because register rows land in the same commit as the behaviour +they describe, this is a must-fix now, not a follow-up. + +--- + +## A. The four items you asked me to verify + +### A1 — B1's revert: is the *reasoning* now right, not just the value? + +**Yes. PASS on both.** + +`ProjectileController.SyncPresentationFromResolvedBody` now writes +`entity.ParentCellId = record.FullCellId`. I re-derived each of the four +claims in the rewritten doc block rather than accepting them: + +| claim in the comment | verified | +|---|---| +| on a committed outcome the two sources agree (`CommitCanonical` writes both) | ✓ — the choice is a genuine no-op there | +| `StoreAcceptedDestinationPose` composes `body.Position` from `accepted.PositionX + worldOffset(accepted.LandblockId)` — the **wire** cell's frame | ✓ read the method; it writes only `Position`/`Orientation`, offset from `accepted.LandblockId` | +| `record.FullCellId` is that same wire cell, stamped by the merge before classification | ✓ `RuntimeEntityRecord.RefreshDerivedState` → `SetFullCell(position.LandblockId, …)` at `:232-237` | +| the body's own `CellPosition.ObjCellId` is the **source** cell it left | ✓ untouched by the store fallback | +| cross-check (1): the sibling remote arm pairs wire position with wire cell | ✓ `TryApplyGenericRemoteRenderPose` → `entity.ParentCellId = landblockId` | +| cross-check (2): Runtime's `SyncProjectilePresentation` publishes the shadow at `record.FullCellId` in the same packet | ✓ — reading the body's cell here would disagree with the shadow for the same body in the same call | +| cross-check (3): retail's `store_position` @0x00515CE2 writes the whole `Position` **including `objcell_id`**, so after a retail store the object's cell IS the destination | ✓ | + +The comment also correctly names the body's stale post-store cell as the +acdream-side residual (AP-138, shared with the remote arms) rather than +truth to project. The reasoning is sound and matches the code beside it. + +### A2 — the indoor-staging test: does the discrimination argument hold, and is it legitimate? + +**Yes to both. This is the strongest test in the change.** + +I verified the mechanism at `PhysicsBody.SyncCellPositionDelta` +(`PhysicsBody.cs:286-306`), which runs whenever `Position` is written: + +```csharp +if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u)) +{ + CellPosition = new Position(cell, new CellFrame(local, …)); // indoor: id PINNED + return; +} +uint adjusted = cell; +if (LandDefs.AdjustToOutside(ref adjusted, ref local)) // outdoor: id RE-DERIVED + CellPosition = new Position(adjusted, …); +``` + +So the implementer's account is exactly right: an **outdoor** source cell +(index 1..0x40) has its cell id re-derived — including the 192 m landblock +wrap — by `AdjustToOutside` as a side effect of the store's position write, so +`body.CellPosition.ObjCellId` converges toward the destination landblock on +its own and the two expressions stop discriminating. An **indoor** source cell +takes the early-return branch: the delta is carried into the local frame and +the id stays pinned. That is the only staging in which +`body.CellPosition.ObjCellId` and `record.FullCellId` provably differ across +the store path. + +**Legitimate, not contrived.** `IndoorSourceCell = SourceLandblock | 0x0100u` +is the canonical first EnvCell in AC's cell-id encoding — outdoor landcells +occupy `0x0001`–`0x0040`, EnvCells start at `0x0100` — so this is an ordinary +dungeon cell, not a magic number chosen to break an assertion. A missile +in a dungeon is an ordinary scenario, and the branch it exercises is the one +production takes indoors. + +**Better than the sabotage run the implementer also did:** the test asserts +the divergence *occurred* before asserting the outcome — + +```csharp +Assert.Equal(IndoorSourceCell, body.CellPosition.ObjCellId); // the divergence is real +Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); // and ParentCellId ignored it +``` + +— so if staging ever stops discriminating (a future change makes the store +write the cell, say), the test fails loudly instead of silently going vacuous. +That is a self-verifying discriminator, which is the right answer to "a green +suite is not evidence". + +### A3 — B2's split: is "queue cleared, leash still armed" retail's far-branch behaviour? + +**Two of the three halves are retail. The third is not, and it is now +recorded — so the outcome is acceptable, but the plain answer to your question +is: no, retail does not leave the leash as-is.** + +Retail's far path, in order: + +``` +005163c1 position_manager = this_1->position_manager; +005163c9 if (position_manager != 0) +005163cb PositionManager::StopInterpolating(position_manager); +005163d9 CPhysicsObj::SetPositionSimple(this_1, arg2, 1); +005163e8 return 1; + ↓ back in SmartBox::HandleReceivedPosition +00454254 if (MoveOrTeleport(...) != 0) { +00454258 GetMaxConstraintDistance / GetStartConstraintDistance +00454272 ConstrainTo(arg2, &arg2->m_position, start, max); ← re-anchor + } +``` + +| half | retail | acdream far arm | verdict | +|---|---|---|---| +| clear the interpolation queue | yes, whenever `position_manager != 0` @0x005163CB | `route.StopInterpolating && record.RemoteMotion is RemoteMotion → adopted.Interp.Clear()` | ✓ **correct** | +| `UnConstrain` | **no** — the far branch never calls `teleport_hook` | not run | ✓ **correct**; the test's "proving the far branch really does run only `StopInterpolating`, not the full hook" is right | +| re-anchor the leash | **yes** — `&arg2->m_position` is the object's *just-updated* position, so retail re-anchors on every nonzero return | not run | ✗ **divergent** | + +The fix's placement is right in every other respect: it lives in the Runtime +seam (not App), runs strictly **before** `TryExecuteAcceptedRemotePosition` +(mirroring @0x005163CB before @0x005163D9), uses the same +`route.StopInterpolating` gate and the same `Interp.Clear()` mapping the +sibling `ApplyAcceptedRemoteFarSnap` uses, and the storing partition still +runs afterwards so the 4b-2 "cleared-then-frozen" hazard cannot reappear. +`record.RemoteMotion is RemoteMotion` is the right analogue of retail's +`position_manager != 0` here, because `Interp` is a non-null field of +`RemoteMotion` — the same predicate the remote far arm relies on. + +**What the missing re-anchor actually costs — correcting round 2.** See §C1. +It is one tick of brake-taper state, contact-gated, and cannot move the body. + +### A4 — AP-141: does the row describe the shipped code exactly? + +**Almost. The divergence description is now accurate; one sentence of the risk +column is not (C1).** + +Verified accurate: +- the bare-missile far skip as *faithful by consequence* — retail's own + `position_manager != 0` guard @0x005163C9 skips it for a never-interpolated + object ✓; +- the adopted-body far clear as **ported**, matching the shipped + `route.StopInterpolating && record.RemoteMotion is RemoteMotion` arm ✓; +- clause (b) extended to say acdream never arms **or re-anchors** on any + disposition, with @0x00454272 cited ✓ — this is the honest recording of the + A3 residual; +- the "NARROWED … the far-branch clause was factually wrong for the + adopted-body case" preamble, which is the right way to retire a superseded + claim rather than quietly rewriting it ✓; +- clauses (a) and (c) unchanged and still accurate ✓. + +### A5 — #317 + +**Discharges R8. PASS.** Filed OPEN with the byte-decode citation +(`MoveOrTeleport` @0x00516330-@0x00516438, every branch, velocity argument +never read), the correct scope note (the 4a call left in production +deliberately), a real root cause, and an acceptance criterion that names the +right next step — auditing the *whole* accepted-Position velocity chain rather +than just the one function. My round-2 judgment stands: deferring the audit +was always fine; what was missing was the tracking, and it now exists. + +Nit only: the body cites the call site as "~line 2444" in one paragraph and +"~line 2420" in another. It names the symbol in both, which is what process +rule 6 says to trust, so this is cosmetic. + +--- + +## B. Round-2 findings — delta status + +| ID | status | +|---|---| +| B1 (`ParentCellId`) | **CLOSED** — reverted, reasoning verified, self-verifying indoor regression test | +| B2 (far-branch adopted body) | **CLOSED for the `StopInterpolating` half; the `ConstrainTo` re-anchor half is now a recorded divergence** in AP-141 clause (b) rather than an unrecorded one. Acceptable. | +| B3 (double `report_collision_end`) | still present (App hook action 6 + Runtime seam's `LeaveWorld`). Benign — the second call finds an empty table. Not re-raised. | +| B4 (per-packet closure) | **CLOSED** — `_remoteArmCallbacks.IsCurrentProjectilePositionOwner` + `_projectileArmPosition*` scratch fields, the same cached shape #315 introduced for the remote arm. Comment now describes what the code does. | +| B5 (R8 tracking) | **CLOSED** — #317 | +| B6 (retry-arm store gap) | unchanged; parity with the remote arm, pre-existing. Not re-raised. | + +--- + +## C. Findings this round + +### C1 — MINOR, **must fix in this commit** — AP-141's risk column asserts a consequence neither retail nor acdream has, and I am the source of the error + +**Where:** `docs/architecture/retail-divergence-register.md`, AP-141, risk +column, final clause: + +> "…but is never re-anchored at the new position by either — **if it survives +> un-cleared some other way, it would drag the body toward a stale anchor.**" + +**This is wrong, and it is my round-2 wording.** I wrote that a leash anchored +at the pre-snap position would "drag the freshly-placed missile back". I +inferred it from retail's re-anchor existing, without reading acdream's port of +what a leash *does*. The in-tree port says otherwise, explicitly: + +- `ConstraintManager.ConstraintPos` — *"+0x0c retail `constraint_pos` — the + leash anchor. Stored by `ConstrainTo`, **never read by `AdjustOffset`** + (retail + ACE — write-only in this class)."* +- `ConstraintManager.AdjustOffset` (retail `ConstraintManager::adjust_offset` + @0x00556180) only **brakes**: while `_host.InContact` it tapers the + already-composed per-tick offset between `ConstraintDistanceStart` and + `ConstraintDistanceMax`, or zeroes it past max — then unconditionally + overwrites `ConstraintPosOffset` with *that tick's step length*. + +A leash therefore damps motion the interp/sticky chain already produced; it +has no mechanism to move anything toward the anchor. The dragging half of +round-2 B2 was the **interpolation queue**, which does move the body toward +waypoints — and that half is now fixed. + +**What the missing re-anchor actually costs.** Retail's +`ConstrainTo(arg2, &arg2->m_position, …)` sets `ConstraintPos` to the object's +just-written position and re-initialises +`ConstraintPosOffset = Distance(anchor, host.Position)` = **0**, i.e. it +resets the brake accumulator at every accepted Position. acdream leaves +`ConstraintPosOffset` at the previous tick's step length. The observable +difference is confined to the single tick after the packet, only when the +object is `InContact`, and only if that step length already exceeded +`ConstraintDistanceStart` — and a far-snapped missile is airborne, where the +clamp branch does not run at all. + +**Correct replacement for the sentence** (substance, not wording): *an +adopted-body missile's inherited leash is never re-anchored, so its brake +accumulator (`ConstraintPosOffset`) is not reset to zero at each accepted +Position as retail's @0x00454272 re-anchor does; the anchor itself is +write-only in both retail and the port, so a stale leash brakes rather than +pulls and cannot move the body.* + +Keep the rest of the clause — "never re-anchored at the new position by +either" is the accurate divergence and should stay. + +### C2 — MINOR (nit) — two comments the fix touched are slightly off + +1. `ProjectileController.SyncPresentationFromResolvedBody`'s doc block uses + `` twice; the parameter is named `expectedRecord` + (`record` is a local from `TryGetCurrent`). The paramref will not resolve. +2. `MissileAdoptedBody_FarCommit_ClearsInterpQueueButLeavesConstraintArmed`'s + doc says the leash "must stay armed (proving the far branch really does run + only `StopInterpolating`, not the full hook)" — true and well-argued about + the *hook*, but silent on @0x00454272, which is the half retail does run. + One clause ("…armed but, unlike retail, not re-anchored — AP-141") keeps a + future reader from reading the far branch's leash handling as fully + faithful. + +--- + +## D. Re-verified retail, no change + +Spot-checked because the fix touched the surrounding code: + +- `MoveOrTeleport` @0x00516330 far branch: `position_manager != 0` guard + @0x005163C9, `StopInterpolating` @0x005163CB, `SetPositionSimple(…, 1)` + @0x005163D9, `return 1` @0x005163E8 — the ported order is correct. +- `HandleReceivedPosition` @0x00454254/@0x00454272 — the single post-operation + arming site, no kind test, anchor is the object's own just-updated position. +- `teleport_hook` @0x00514ED0 — six actions, five per-manager guarded; the far + branch does not call it. +- `store_position` @0x00515CE2 — writes `objcell_id` with the frame (basis of + B1's revert). +- ACE never sends a missile `UpdatePosition` + (`WorldObject_Tick.cs:333-334` inside the `:265` Missile branch), so every + residual here remains test-reachable only. Stated for calibration, not as a + reason to ship anything. +- AP-131 / AP-135 untouched; #276 not closed; `docs/ISSUES.md` gains only #317. + +--- + +## E. Summary + +| # | Sev | Where | Action | +|---|---|---|---| +| C1 | MINOR, must-fix-in-commit | AP-141 risk column, final clause | replace the "drag the body toward a stale anchor" claim — the anchor is write-only; a leash brakes, never pulls. Retracts my own round-2 wording. | +| C2 | nit | `SyncPresentationFromResolvedBody` paramref; far-adopted test doc | one-line each | + +No code changes required. With C1 corrected, route 5 is retail-conformant on +every path I have examined across three rounds. diff --git a/docs/research/2026-08-04-c4-route-5-retail-review.md b/docs/research/2026-08-04-c4-route-5-retail-review.md new file mode 100644 index 00000000..e85577e5 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-5-retail-review.md @@ -0,0 +1,465 @@ +# C4 route 5 — projectile authoritative placement: retail-conformance review (2026-08-04) + +**Reviewer lens:** retail fidelity only ("is this what the retail client does?"). +Architecture is a separate reviewer's job. + +**Subject:** the uncommitted working-tree diff at branch +`claude/acdream-physics-divergence-5aa784`, HEAD `30d3d114` +(`git diff HEAD` + the untracked +`tests/AcDream.Runtime.Tests/Entities/RuntimeProjectilePositionKindTests.cs`). +`docs/research/2026-08-04-c4-route-5-contract.md` is the pinned contract, not +part of the change under review. + +--- + +## VERDICT: **FAIL** + +Two MAJOR findings, both **unrecorded divergences** — the project rule the +divergence register exists to enforce ("any commit that introduces a deviation +adds its register row IN THE SAME COMMIT; a deviation found without a row is a +bug twice over"). Neither is a hard-to-fix design problem: R1 is a one-clause +amendment to AP-141 (or a three-line fall-through decision), R2 is either a +wiring of the already-in-tree hook actions or a second clause on the same row. + +**What is right, and independently verified** (Section A below): the retail +chain the contract asserts is correct in every particular I checked, the +byte-decode retiring the fabricated velocity is correct and I reproduced it +from the paired binary, and AP-141 describes retail's mechanical behaviour +honestly rather than dressing the divergence up as fidelity. + +--- + +## A. Retail claims verified independently (all PASS) + +Verified against `docs/research/named-retail/acclient_2013_pseudo_c.txt` and, +for the byte-decode, against `C:/Users/erikn/Downloads/acclient.exe` +(PDB-paired, image base `0x00400000`). + +### A1. `MoveOrTeleport` has no `state & Missile` test — CONFIRMED + +`CPhysicsObj::MoveOrTeleport` @0x00516330 (pseudo-C 284304-284366) reads +exactly four object fields: `update_times[4]` (`mov cx,[esi+0x16c]`), `cell` +(`mov eax,[esi+0x90]`), `player_distance` (`fld dword [esi+0x20]`), and +`position_manager` (`mov ecx,[esi+0xc8]`). There is no physics-state read +anywhere in the 272-byte function. A missile takes the identical generic +remote path. The contract's §4 row 1 is correct. + +`SmartBox::HandleReceivedPosition` @0x00453FD0's only kind branch is +`arg2 != this->player` @0x0045414D — confirmed at pseudo-C 92896-93054. +`UnpackPositionEvent` @0x004542C0 calls it @0x00454358 with no state/kind +test. + +### A2. Branch structure — CONFIRMED + +| retail branch | address | acdream disposition | verdict | +|---|---|---|---| +| teleport/cell-less: `teleport_hook` → `SetFlags(0x1012)` → `SetPosition` → `return 1` | @0x005163EF / @0x00516414 / @0x00516420 / @0x00516438 | `SetPosition` | faithful | +| near: `InterpolateTo(this, arg2, IsMovingTo(this))`, `return 1` | @0x005163AF, @0x005163BE | `Interpolate` → **no-op** | recorded divergence (AP-141a) | +| far: `if (position_manager != 0) StopInterpolating`, `SetPositionSimple(this, arg2, 1)`, `return 1` | @0x005163C9-@0x005163CB, @0x005163D9, @0x005163E8 | `SetPositionSimple` | faithful; the `StopInterpolating` skip is retail's OWN guard for a manager-less object, correctly excluded from the divergence row | +| airborne (`arg4 == 0`): `return 0` | @0x0051636D | `NoPositionOperation` → no-op, no arm | faithful, correctly NOT claimed as a divergence | + +The far branch's third argument to `SetPositionSimple` is `1` (byte +`6a 01` at @0x005163D3) — matches. + +### A3. The `ConstrainTo` chain — CONFIRMED; retail WOULD arm a missile's leash + +- `HandleReceivedPosition` @0x00454254: `if (MoveOrTeleport(...) != 0)` → + @0x00454272 `ConstrainTo(arg2, &arg2->m_position, ...)`. No kind test, no + manager-existence test. +- `CPhysicsObj::ConstrainTo` @0x00510520 → `MakePositionManager(this)` + @0x00510523 → `PositionManager::ConstrainTo` @0x00510533. The manager is + created **on demand**. +- The sibling `CPhysicsObj::InterpolateTo` @0x005104F0 does the same + (`MakePositionManager` @0x005104F3 then `PositionManager::InterpolateTo` + @0x00510508), so the near branch would likewise build machinery for a + manager-less missile. +- `PositionManager::UseTime` is ticked from `UpdateObjectInternal` + @0x005159A9 — an armed missile leash would be live, not vestigial. + +**The central design decision is therefore a deliberate divergence, and the +change records it as one.** AP-141 states plainly that "retail's single arming +site has no kind test, so retail WOULD build a `PositionManager` on demand and +arm a missile's leash on any nonzero `MoveOrTeleport` return", and cites +@0x00454272 / @0x00510523 / @0x005163AF / @0x0050EB10 plus the ACE +unreachability. It does **not** claim fidelity it does not have. PASS. + +### A4. The byte-decode of `arg5` — CONFIRMED, reproduced independently + +I did not take the implementer's disassembly on trust; I re-derived the stack +offset and scanned the function's bytes. + +Prologue at @0x00516330: `83 ec 64` (`sub esp,0x64`), `56` (`push esi`), +`8b f1`, `66 8b 8e 6c 01 00 00`, `57` (`push edi`). Displacement from entry +`esp` is therefore `0x64 + 4 + 4 = 0x6C`, so: + +| arg | slot after prologue | +|---|---| +| `arg2` (Position*) | `[esp+0x70]` | +| `arg3` (uint16 timestamp) | `[esp+0x74]` | +| `arg4` (int32) | `[esp+0x78]` | +| `arg5` (Vector3 const*, velocity) | **`[esp+0x7C]`** | + +Every stack-argument read in the function body: `8b 7c 24 74` @0x0051633E +(arg3 → edi), `8b 44 24 78` @0x00516387 (arg4), `8b 44 24 74` @0x005163A7 +(arg2 — `esp` is 4 lower after the preceding `push eax`), `8b 4c 24 70` +@0x005163CF (arg2), `8b 54 24 70` @0x005163FC (arg2). **Zero occurrences of +the SIB+disp8 byte pair `24 7c` in the whole function**, and no disp32 +(`84 24 7c 00 00 00`) form either; the only other `0x7c` bytes in the range +belong to the `edi` ModRM byte and to the constant address `0x007c6afc` (the +96 m literal). All four exits are `c2 10 00` (`ret 0x10`) — four dwords of +stack args, confirming the four-argument frame. + +**`arg5` is never read. P4 holds. The `?? Vector3.Zero` fabrication was +genuinely fabricated, and deleting it is correct.** PASS. + +### A5. The teleport-hook reduction — retail side CONFIRMED + +`CPhysicsObj::teleport_hook` @0x00514ED0 (pseudo-C 283115-283151) is exactly +six actions: + +1. `MovementManager::CancelMoveTo` — guarded `movement_manager != 0` @0x00514EDB +2. `PositionManager::UnStick` — guarded @0x00514EEC +3. `PositionManager::StopInterpolating` — guarded @0x00514EFB +4. `PositionManager::UnConstrain` — guarded @0x00514F0A +5. `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` — guarded @0x00514F19 +6. `CPhysicsObj::report_collision_end(this, 1)` @0x00514F31 — **unguarded** + +So for a *bare* arrow/bolt (no `MovementManager`, no `PositionManager`, no +`TargetManager`) five of six are retail no-ops through retail's own guards, +and the sixth applies. The projectile arm's single +`_entityObjects.Physics.CollisionReports.LeaveWorld(record)` is the right +port: it is the same seam `RunRemoteTeleportHook`'s `ReportCollisionEnd` +action reaches (`LiveEntityRuntime.ForceEndCollisionReporting` → +`RuntimeCollisionReportingState.LeaveWorld`), i.e. the 4b-3 R2-validated port +of @0x00514620. Ordering is also right — it runs strictly before +`TryExecuteAcceptedRemotePosition`, mirroring @0x005163EF before @0x00516420 — +and it runs unconditionally of the placement's outcome, exactly as retail +discards `SetPosition`'s result and returns 1 @0x00516438. + +**Caveat: "the other five are genuinely per-manager-guarded no-ops for a bare +missile" is true, but the contract also pinned the adopted-body case, and that +half was not delivered — see R2.** + +### A6. Register / issue discipline — PARTIAL + +- AP-141 added in the same working tree as the behaviour. PASS. +- AP-131 and AP-135 are untouched (the register diff is exactly the section-3 + count line plus the AP-141 row). PASS. +- `docs/ISSUES.md` is not modified; #276 is not closed. PASS. +- AP-141's retail description is accurate (A3). PASS. +- **AP-141 is incomplete** — see R1 and R2. + +### A7. Comment corrections — the touched ones verified + +The deliberate separate correction at +`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2309-2323` +replaces the false claim "MoveOrTeleport installs that exact vector with +set_velocity". Every assertion in the replacement is true: `MoveOrTeleport` +never reads the velocity slot (A4); `UnpackPositionEvent` @0x004542C0 performs +no `set_velocity`; the only `set_velocity` in the chain is the local player's +zero @0x004541B4. The replacement is honest about the consequence ("this +call's actual retail justification is therefore NOT yet established"). PASS +on truthfulness — but see R8 on the bookkeeping. + +The two other comment rewrites (`ClassifyRemoteAcceptedPosition`'s +kind-derivation paragraph, `OwnsPlacement`'s widening paragraph) match the +code beside them. `RuntimeProjectilePhysicsUpdater`'s tombstone comment +accurately describes what was deleted and what remains. + +--- + +## B. Findings + +### R1 — MAJOR — a Missile-flagged entity with no bound projectile now has its accepted Position silently dropped; retail places it, and AP-141 does not cover this shape + +**Where:** +`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2222-2247` +(the `isMissilePacket` dispatch) together with +`src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs:1004-1012` +(`ApplyAcceptedProjectilePosition`'s ownership gate). + +**Retail contradicted:** `CPhysicsObj::MoveOrTeleport` @0x00516330 — +reached from `HandleReceivedPosition` @0x00454254 for **every** non-player +`CPhysicsObj`, with no test of any kind on what client-side machinery the +object happens to have bound. Retail always places the object. + +**What changed.** The deleted short-circuit was +`if (_projectileController?.ApplyAuthoritativePosition(...) == true) return;`. +That method returned **false** — i.e. *fell through to the generic remote +tail, which applied the position* — whenever `TryGetCurrent` failed, i.e. +whenever the record had the Missile bit but no bound `RuntimeProjectile`. +The new dispatch decides `isMissilePacket` purely from +`route.OperationKind`/the Missile bit, and `ApplyAcceptedProjectilePosition` +returns `null` (write nothing, return) when `record.Projectile` is null. The +packet is now dropped with no writer of any kind. + +**Why this is reachable state, not a hypothetical.** `ProjectileController.TryBind` +(`src/AcDream.App/Physics/ProjectileController.cs:133-330`) can fail +*permanently* for a Missile-flagged entity — `TryGetCollisionSphere` rejects +any Setup that is not "the supported retail one-sphere collision shape" and +logs `Missile 0x… Setup 0x… does not have the supported retail one-sphere +collision shape`. It also has a legitimate not-yet-bound window +(`initialResidenceActive == false`, and the projection-visible retry in +`OnProjectionVisibilityChanged`). In all of those states the entity used to +track its server position through the remote path; now it freezes for the rest +of its life. + +**Bookkeeping.** AP-141 clause (c) covers "a null-classified or `Rejected*` +accepted Position"; it does **not** cover "an accepted Position for a +Missile-flagged entity whose projectile component is absent or does not agree +with the canonical body". The XML doc on `ApplyAcceptedProjectilePosition` +does mention the ownership-mismatch swallow, so this is a real design +decision that was made and then not written into the register. + +**Correct behaviour:** either (a) fall through to the generic remote tail when +`record.Projectile` is absent — which is what retail does and what the code +did yesterday — or (b) keep the swallow and extend AP-141 with a fourth +clause naming it, its trigger (unbindable Setup / pre-bind window), and the +observable (a Missile-flagged object stops tracking permanently). Do not +leave it undocumented. + +*Mitigating (do not use as a reason to skip the row):* unreachable against ACE +for the same reason the rest of the row is — `WorldObject_Tick.cs:333-334`. + +--- + +### R2 — MAJOR — the adopted-body teleport-hook case was pinned by the contract, not implemented, and not registered; retail runs five actions acdream skips + +**Where:** `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs:1017` +— the `SetPosition` arm runs `CollisionReports.LeaveWorld(record)` and +nothing else. + +**Retail contradicted:** `CPhysicsObj::teleport_hook` @0x00514ED0 actions 1-5 +(@0x00514EDB, @0x00514EEC, @0x00514EFB, @0x00514F0A, @0x00514F19). Each is +guarded on *its manager existing* — not on the object being a non-missile. + +**The gap.** A missile can carry the full remote manager set. `TryBind`'s own +adopted-body branch exists for exactly this +(`ProjectileController.cs:176-181`: "Retail owns one CPhysicsObj. If a +non-missile incarnation already created its MovementManager … classification +adopts that same body"), and the late-classification entry +`ApplyAuthoritativeState → TryBind` is the production path that turns an +ordinary remote — which by then has a `RemoteMotion` with an `Interp` queue +and, if hosted, a `PositionManager` — into a missile. For such an object +retail's guards are *satisfied* and retail runs `CancelMoveTo`, `UnStick`, +`StopInterpolating`, `UnConstrain` and `ClearTarget`/`NotifyVoyeurOfEvent`. +acdream now runs none of them. + +The contract pinned this explicitly (D-P4): "for the adopted-body case (a +missile that carries a `RemoteMotion`), the existing hook actions' +per-manager guards already express retail's shape; the implementer wires this +without per-packet closures (#315 pattern)". The in-tree faithful port is +sitting unused — `RemoteTeleportHook.Execute` + +`RemoteTeleportHookActions`, the ordered six-action seam with a currency check +between each step. This is the 4b-3 round-2 R2 defect class verbatim: *a +retail action skipped with the faithful port already in-tree.* + +**Not a regression** — the deleted `ApplyAuthoritativePosition` ran no hook +either — but it is a divergence this commit had the obligation to close or +record, and it did neither. + +**Correct behaviour:** run the hook through the existing ordered seam when the +record carries the managers (`record.RemoteMotion`/its host), letting the +per-manager guards no-op for a bare arrow exactly as retail's do; or state in +AP-141 that only action 6 of `teleport_hook` @0x00514ED0 is ported for a +projectile and that actions 1-5 are skipped even when the managers exist. + +*Note on the stale-queue half specifically:* while the Missile bit is set the +remote interpolation is inert (`LiveEntityAnimationScheduler.cs:336` gates on +`projectileHandlesMovement`), so the skipped `StopInterpolating` is latent +rather than immediately observable — it becomes live again the moment ACE +clears Missile on impact. `ClearTarget`/`NotifyVoyeurOfEvent` and +`CancelMoveTo` have no such shield. + +--- + +### R3 — MINOR — invariant 4 ("prediction invalidated before every body write on this route") and invariant 2 (presentation) hold on the direct arm only; the retained-retry arm satisfies neither + +**Where:** `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs:1150-1213` +(`Advance()`), reached from +`src/AcDream.App/Net/GraphicalSessionEventRoute.cs:121`. + +A `Contention` outcome parks the packet in `_pending` and re-submits it one or +more host cadence pumps later. That retry path calls `SubmitAndResolve` +(a canonical body commit) and, on a destination that left the window, +`StoreAcceptedDestinationPose` (a raw body write) — **without** +`projectile.InvalidatePrediction()` and without `SyncProjectilePresentation`. +The version bumped at packet time does not protect a quantum begun *after* +that bump and straddling the retry. + +Presentation is the same shape: the App's `SyncPresentationFromResolvedBody` +is called only from `OnPosition`, and `RuntimePlacementPresentationSink.TryPublishPlace` +(`src/AcDream.App/World/RuntimePlacementPresentationSink.cs:210-241`) does not +write `entity.Position`/`Rotation` — it snapshots whatever the entity already +holds. So a retried projectile commit advances the canonical body with no +presentation projection at all until the next quantum re-projects. + +Both are bounded (the retry pump runs from the network retry lease, not from +inside the scheduler's `TryBeginQuantum`/`CompleteQuantum` straddle; the +presentation lag self-heals on the next tick), which is why this is MINOR +rather than MAJOR. But the invariants are stated absolutely, and this is the +"an invariant satisfied on one arm only" class both 4b-3 rounds named. + +--- + +### R4 — MINOR — `SyncProjectilePresentation` silently drops the spatial+hidden clock consumption while its doc claims a faithful reduction + +**Where:** `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs:975-985` +(doc) and `:1028-1042` (the `else if (spatial)` branch). + +The deleted tail was: + +``` +else if (spatial) +{ + body.InWorld = true; + body.LastUpdateTime = currentTime; // <-- gone + _physics.Engine.ShadowObjects.Suspend(localId); +} +``` + +The replacement omits `body.LastUpdateTime`. The doc immediately above claims +the method is that tail "reduced … to this controller's own two-argument seam +— `_clock` supplies the same clock source that method took as an explicit +`currentTime` parameter", which is true of the *visible* branch and false of +this one. The intent of the deleted write is documented elsewhere in the same +subsystem (`ProjectileController.TryBind`: "consume the hidden clock so UnHide +cannot replay a time backlog"). + +Consequence is small — the projectile stepper takes its quantum from +`RetailObjectQuantumBatch`, not from `body.LastUpdateTime`, and +`ProjectileController.Tick:601-604` re-stamps `LastUpdateTime` on the +`!InWorld` re-entry edge — but this is an unclaimed behaviour deletion inside +a change whose comment asserts equivalence. (Clock *domain* is fine: +`UpdateFrameOrchestrator.CurrentScriptTime => _runtime.SimulationTimeSeconds`, +so `_clock.SimulationTimeSeconds` is the same base the App used to pass.) + +--- + +### R5 — MINOR — the App ignores the seam's returned status, so the "presentation advances on committed/stored outcomes only" gate exists on the Runtime side only + +**Where:** `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2237-2245`. + +Runtime deliberately withholds `SyncProjectilePresentation` for `Deferred` and +`RejectedByPlacement` (with the comment "Deferred/RejectedByPlacement leave the +body at its prior (already-synced) pose"). The App then calls +`_projectileController.SyncPresentationFromResolvedBody(...)` unconditionally, +discarding the returned `RuntimeRemotePlacementExecutionStatus?` entirely — +including on `Deferred`, where `ParkDeferred` has already snapped the body to +the parked result and withdrawn the entity. The App write moves the render +entity to a pose Runtime declined to publish; if the park is later cancelled +and restored (`WithdrawalRestored`), the entity re-shows there. That is +AP-136's family. + +On the no-op and swallow dispositions the write is inert (same value), so this +is MINOR, but the two layers disagree about the same invariant. + +--- + +### R6 — MINOR — on a stored (non-committed) outcome the render cell is left behind while the render position moves; retail's `store_position` writes `objcell_id` too + +**Where:** `src/AcDream.App/Physics/ProjectileController.cs:512-517` +(`entity.ParentCellId = record.FullCellId`) with +`RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose:1104-1136`. + +`StoreAcceptedDestinationPose` writes `body.Position` and `body.Orientation` +only — not the cell, and D-P2 correctly forbids a `RebucketLiveEntity` on this +arm. So after `Refused`/`Contention`/`RejectedPreparation` the render entity +gets the new world position with the *old* `ParentCellId`. The deleted path +could not produce this pairing: it always did `body.SnapToCell(fullCellId, …)` ++ `CommitProjectileCell`. + +Retail's `store_position(this, arg2)` @0x00515CE2 writes the object's whole +`Position` including `objcell_id`, and the branch then calls +`CObjectMaint::GotoLostCell` @0x00515CF2 — i.e. retail moves the cell *and* +hides the object pending load. This is largely AP-138's existing residual +(shared with the remote far arm), but it is newly reachable for projectiles +and worth naming when AP-141 is amended. + +--- + +### R7 — MINOR — the contract's App-layer dual-kind matrix (§7 items 8-10) is absent; the D-P6 dispatch has no test at all + +Added coverage is Runtime-only: `RuntimeProjectilePositionKindTests` (3 facts, +kind derivation + mid-life flip) and 11 new +`RuntimeRemotePlacementDriveControllerTests` facts. These are good tests — +positive assertions, real behaviour, no source-text pins, and the split-quantum +abort (T3) and no-velocity (D-P5) invariants are both genuinely exercised. + +But there is no test anywhere under `tests/AcDream.App.Tests` that drives +`OnPosition`. The contract made the dual-kind `[Theory]` "what makes a future +silent mis-tag fail a test", and item 10 required a sabotage check +("hardcode Remote and confirm the dual-kind theories fail"). With no App-level +test, hardcoding `isMissilePacket = false` at +`LiveEntityNetworkUpdateController.cs:2229` would leave the suite green — the +exact failure mode the discipline was written to catch. The swallow ordering, +the presentation-ack ordering, and the "no `RemoteMotion` is ever created for a +missile packet" claim are all untested. + +--- + +### R8 — MINOR — the corrected 4a comment now records an unjustified production behaviour with nothing tracking it + +`LiveEntityNetworkUpdateController.cs:2309-2323` now states that the retail +justification for the remote arm's `TryCommitAuthoritativeVelocity` "is +therefore NOT yet established and needs its own audit". That is the honest +call, and the contract (§10) said to report rather than edit. But an +acknowledged-unjustified velocity commit on a live production path now exists +with no AP row and no `docs/ISSUES.md` entry — only a code comment. One line +in ISSUES.md (or an AP row) would keep it findable. + +--- + +## C. Anything wrong in the CONTRACT itself + +Nothing factually wrong. Three notes: + +1. **§4's `teleport_hook` address is cited two ways and both are right** — the + contract's §4 row 2 cites @0x005163EF (the call site inside + `MoveOrTeleport`) and D-P4 cites @0x00514ED0 (the function). Confirmed + consistent; no action. +2. **D-P4's adopted-body sentence is the only pinned obligation the diff did + not discharge** (R2). The contract's wording ("the implementer wires this + without per-packet closures") is an instruction, not a claim, so the + contract is not wrong — the implementation is incomplete against it. +3. **D-P2 step 1's "Anything else: return `NotApplicable`-equivalent, write + nothing"** is the source of R1. The contract pinned it without noticing that + it removes the deleted path's fall-through and therefore *introduces* a new + divergence needing its own register clause. Worth a sentence when AP-141 is + amended, so the next reader does not read the contract as having cleared it. + +Everything else I checked in the contract holds: the retail chain (§4, all +seven rows), the ACE unreachability (`WorldObject_Tick.cs:333-334` inside the +`PhysicsState.Missile` branch at `:265`), the classifier's kind-blindness past +`LocalPlayer` (`RuntimeAuthoritativePositionRouteClassifier.cs:349`, +`:535-538`, `:564-575`), and the three ownership fences (`OwnsPlacement` +widened; `OwnsFarSnap`/`OwnsTeleportPlacement` untouched, both arms still +`ArgumentNullException.ThrowIfNull(remote)` + throwing route guards). + +I also spot-checked proof obligation **P3** and found no capture hazard: both +`RemoteAuthoritative` stamps in `RuntimeSetPositionState` +(`:3833/:3866` window-departure park, `:5338/:5350` legacy-direct withdrawal) +construct *fresh* operations and the park loop skips records already in +`_operations`, so neither can relabel a live `ProjectileAuthoritative` +operation; `route.OperationKind` is threaded intact through +`TryBeginExclusiveAuthoredPlacement` and `TryPrepareAndSubmitAuthoredPlacement`, +so the `command.Kind != operation.Kind` consistency check at `:2963` is +satisfied; and the direct-command gate at `:5445` admits local kinds only, which +this route never uses. `MoverPhysicsState = record.FinalPhysicsState` is carried +into the placement request (`:2007`, `:3007`, `:3810`, `:5496`), so the Missile +bit still reaches the engine's `missile_ignore` handling. + +--- + +## D. Summary table + +| # | Sev | Where | Retail address contradicted | +|---|---|---|---| +| R1 | MAJOR | `LiveEntityNetworkUpdateController.cs:2222-2247` + `RuntimeRemotePlacementDriveController.cs:1004-1012` | `MoveOrTeleport` @0x00516330 via @0x00454254 — placement is unconditional on client machinery | +| R2 | MAJOR | `RuntimeRemotePlacementDriveController.cs:1017` | `teleport_hook` @0x00514ED0 actions 1-5 (@0x00514EDB/@0x00514EEC/@0x00514EFB/@0x00514F0A/@0x00514F19) | +| R3 | MINOR | `RuntimeRemotePlacementDriveController.cs:1150-1213` | — (invariant 4/2, one arm only) | +| R4 | MINOR | `RuntimeRemotePlacementDriveController.cs:975-985, 1028-1042` | — (unclaimed behaviour deletion + stale doc) | +| R5 | MINOR | `LiveEntityNetworkUpdateController.cs:2237-2245` | — (App/Runtime disagree on invariant 2) | +| R6 | MINOR | `ProjectileController.cs:512-517`, `RuntimeRemotePlacementDriveController.cs:1104-1136` | `store_position` @0x00515CE2 / `GotoLostCell` @0x00515CF2 (writes cell too) | +| R7 | MINOR | `tests/AcDream.App.Tests/**` (absent) | — (contract §7 items 8-10 undelivered) | +| R8 | MINOR | `LiveEntityNetworkUpdateController.cs:2309-2323` | — (bookkeeping) | diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 1d0461e0..051fe677 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -93,6 +93,21 @@ internal sealed class LiveEntityNetworkUpdateController private ulong _remoteArmPositionAuthorityVersion; private AcDream.Core.World.WorldEntity? _remoteArmExpectedEntity; + /// + /// B4 fix (C4 route 5 round-2 architecture review): the same #315 + /// cached-delegate discipline for the adopted-body missile arm's + /// teleport-hook currency check + /// (). + /// The missile dispatch block stamps these scratch fields immediately + /// before calling instead of + /// allocating a fresh Func<bool> closure every accepted + /// missile packet — the same defect class the remote arm's own #315 + /// collapse already closed, flagged independently by both round-2 + /// reviews for this arm. + /// + private LiveEntityRecord? _projectileArmPositionRecord; + private ulong _projectileArmPositionAuthorityVersion; + /// /// #315: the two per-packet delegates cached ONCE (constructed here, /// reused for every accepted remote Position) rather than allocated @@ -113,10 +128,22 @@ internal sealed class LiveEntityNetworkUpdateController internal readonly Func IsCurrentPositionOwner; internal readonly Func RunTeleportHook; + /// + /// B4 fix: the projectile (missile) arm's currency check, cached + /// the same way as the two remote-arm delegates above rather than + /// allocated fresh per accepted missile packet. Bound to + /// , which reads + /// the _projectileArmPosition* scratch fields the missile + /// dispatch block stamps immediately before use. + /// + internal readonly Func IsCurrentProjectilePositionOwner; + internal RemoteArmCallbacks(LiveEntityNetworkUpdateController owner) { IsCurrentPositionOwner = owner.IsCurrentRemoteArmPositionOwner; RunTeleportHook = owner.RunCachedRemoteTeleportHook; + IsCurrentProjectilePositionOwner = + owner.IsCurrentProjectileArmPositionOwner; } } @@ -1478,6 +1505,25 @@ internal sealed class LiveEntityNetworkUpdateController && (_remoteArmExpectedEntity is null || ReferenceEquals(record.WorldEntity, _remoteArmExpectedEntity)); + /// + /// B4 fix: the cached backing method for + /// . + /// Reads the _projectileArmPosition* scratch fields the missile + /// dispatch block in OnPosition stamps immediately before calling + /// for the adopted-body case. No + /// expected-entity check here — unlike the remote arm, the missile + /// dispatch block never captured one; parity with the remote arm's + /// extra guard is not required because the caller already re-validates + /// ReferenceEquals(positionRecord.WorldEntity, entity) earlier in + /// the SAME accepted-Position dispatch for the non-missile tail, and + /// the missile tail's own record is the SAME instance stamped here — + /// there is no second entity to disagree with. + /// + private bool IsCurrentProjectileArmPositionOwner() => + _projectileArmPositionRecord is { } record + && _liveEntities.IsCurrentPositionAuthority( + record, _projectileArmPositionAuthorityVersion); + /// /// #315: the cached backing method for /// . Only ever invoked on @@ -2102,27 +2148,113 @@ internal sealed class LiveEntityNetworkUpdateController : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); _movementTruthDiagnostics.OnServerEcho(update, worldPos); - // Missiles reconcile the same predicted PhysicsBody in place. The - // timestamp gate above already rejected stale corrections; returning - // here prevents the generic remote locomotion path from allocating a - // second body or interpolation owner for the projectile. - if (_projectileController?.ApplyAuthoritativePosition( - acceptedPositionRecord, - acceptedPositionAuthorityVersion, - acceptedPositionVelocityAuthorityVersion, - worldPos, - new System.Numerics.Vector3( - p.PositionX, - p.PositionY, - p.PositionZ), - rot, - acceptedSpawn.Physics?.Velocity - ?? System.Numerics.Vector3.Zero, - p.LandblockId, - _physicsScriptGameTime, - _origin.CenterX, - _origin.CenterY) == true) + // C4 route 5 (D-P1/D-P6, REVISED after the review round — A2/R1, + // A9): classify ONCE, kind-aware. This single call now decides both + // the remote route (unchanged for a non-missile packet — see the + // reuse below) AND whether this packet is a missile packet, + // replacing the former ApplyAuthoritativePosition short-circuit. + // The null-classification arm's test is the SAME conjunctive + // predicate the classifier itself applies + // (RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition, + // D-P1) — Missile bit AND a bound RuntimeProjectile whose Body is + // the canonical PhysicsBody, never the bit alone, so an unbindable + // or not-yet-bound missile takes the ordinary remote tail exactly + // as it did before this route (the deleted method's TryGetCurrent + // fall-through) — and explicitly fenced off the local player (A9): + // update.Guid == _playerServerGuid always takes the null branch + // below, and ACE never sets Missile on a player, but the fence + // makes that structurally true rather than incidentally true. + RuntimeAuthoritativePositionRoute? earlyRemoteRoute = + update.Guid != _playerServerGuid + ? ClassifyRemoteAcceptedPosition( + update, + acceptedPositionCanonical, + timestampDisposition, + timestamps, + worldPos) + : null; + bool isMissilePacket = earlyRemoteRoute is { } classifiedRoute + ? classifiedRoute.OperationKind + is RuntimeSetPositionOperationKind.ProjectileAuthoritative + : update.Guid != _playerServerGuid + && (acceptedPositionCanonical.FinalPhysicsState + & AcDream.Core.Physics.PhysicsStateFlags.Missile) != 0 + && acceptedPositionCanonical.Projectile is { } boundProjectile + && ReferenceEquals( + acceptedPositionCanonical.PhysicsBody, + boundProjectile.Body); + if (isMissilePacket) + { + // The projectile arm over the canonical Runtime placement owner + // (RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition) + // — never the generic remote locomotion path below, which would + // allocate a second body or interpolation owner for the + // projectile. A null classification (login-window shape) or a + // RejectedAuthority/RejectedData disposition is swallowed: + // nothing to route, the shared authority gate above already + // rejected an invalid payload. + if (earlyRemoteRoute is { } route) + { + // A3/R2 fix: retail's teleport_hook @0x00514ED0 runs five + // manager-guarded actions BEFORE the placement, in addition + // to the collision force-end the Runtime seam performs on + // its own (action 6). For a bare arrow/bolt (no RemoteMotion + // adopted) all five are structurally absent no-ops through + // retail's own per-manager guards — matching what the + // Runtime seam already does unaided. For the ADOPTED-BODY + // case (TryBind's shared-body branch: an ordinary remote + // whose Missile bit was set by a later State packet, so it + // still carries a live RemoteMotion with a populated Interp + // queue and possibly an armed ConstrainTo leash) those five + // actions are LIVE and must run — using the SAME ordered + // hook seam and per-packet currency check the remote + // teleport arm already uses (RunRemoteTeleportHook, #315 + // pattern), so retail's per-manager guards decide for + // themselves rather than being re-derived here. + if (route.Disposition + is RuntimeAuthoritativePositionDisposition.SetPosition + && acceptedPositionCanonical.RemoteMotion is RemoteMotion adoptedRemote) + { + // B4 fix (round-2 review): cache the currency-check + // delegate the same way the remote arm's #315 collapse + // already does, instead of allocating a fresh closure + // every accepted missile packet. Scratch fields stamped + // immediately before use; nothing reads them between + // calls, so last-packet staleness is harmless. + _projectileArmPositionRecord = acceptedPositionRecord; + _projectileArmPositionAuthorityVersion = + acceptedPositionAuthorityVersion; + RunRemoteTeleportHook( + acceptedPositionCanonical, + adoptedRemote, + _remoteArmCallbacks.IsCurrentProjectilePositionOwner); + } + + RuntimeRemotePlacementExecutionStatus? placementStatus = + _remotePlacementDrive.ApplyAcceptedProjectilePosition( + acceptedPositionCanonical, + route); + // A1/R5 fix: mirror Runtime's OWN presentation gate + // (ApplyAcceptedProjectilePosition/SyncProjectilePresentation + // — every outcome except Deferred/RejectedByPlacement) rather + // than acknowledging unconditionally. Deferred already + // snapped the body to the PARKED result and withdrew the + // entity; RejectedByPlacement leaves the body exactly where + // it was. Acknowledging either would move the render entity + // to (or through) a pose/cell Runtime explicitly declined to + // publish — the concrete Interpolate/RejectedByPlacement + // wrong-cell scenario the review found. + if (placementStatus is not null + and not RuntimeRemotePlacementExecutionStatus.Deferred + and not RuntimeRemotePlacementExecutionStatus.RejectedByPlacement) + { + _projectileController?.SyncPresentationFromResolvedBody( + acceptedPositionRecord, + _physicsScriptGameTime); + } + } return; + } if (!_liveEntities.TryGetRecord( update.Guid, @@ -2136,26 +2268,18 @@ internal sealed class LiveEntityNetworkUpdateController return; } - // C4 route 4a: classify BEFORE the generic write below so a remote - // whose accepted Position resolves to NoPositionOperation (retail's - // airborne no-op — writes nothing at all) or Interpolate (retail's - // near InterpolateTo queue — no direct body write here) never - // receives it. The local player never reaches this generic-remote - // code path at all. C4 route 4b-2 routes the >=96 m far snap and C4 - // route 4b-3 routes the teleport/cell-less classification through - // the canonical Runtime placement owner (ApplyRemoteContactRouting); - // a rejected authority or payload, and "no classification at all", - // take the stated UnroutedCatchUp policy - // (RuntimeRemoteFarSnapPosition.ResolveArm). - RuntimeAuthoritativePositionRoute? earlyRemoteRoute = - update.Guid != _playerServerGuid - ? ClassifyRemoteAcceptedPosition( - update, - acceptedPositionCanonical, - timestampDisposition, - timestamps, - worldPos) - : null; + // C4 route 4a/5: `earlyRemoteRoute` was already classified above + // (D-P6) — this is the SAME value, reused so a remote whose accepted + // Position resolves to NoPositionOperation (retail's airborne + // no-op — writes nothing at all) or Interpolate (retail's near + // InterpolateTo queue — no direct body write here) never receives + // the generic write below. The local player never reaches this + // generic-remote code path at all. C4 route 4b-2 routes the >=96 m + // far snap and C4 route 4b-3 routes the teleport/cell-less + // classification through the canonical Runtime placement owner + // (ApplyRemoteContactRouting); a rejected authority or payload, and + // "no classification at all", take the stated UnroutedCatchUp + // policy (RuntimeRemoteFarSnapPosition.ResolveArm). TryApplyGenericRemoteRenderPose( entity, diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index 1538fe2a..520df2f3 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -490,103 +490,84 @@ internal sealed class ProjectileController } /// - /// Applies a timestamp-gated server correction to the same predicted body. - /// The caller supplies both render-world and wire cell-local coordinates so - /// streaming origin changes never leak into the canonical cell frame. + /// C4 route 5 (D-P2 closing paragraph): the presentation acknowledgement + /// App still owns after + /// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition + /// has committed or stored an accepted Position for + /// . Projects the Runtime-resolved + /// canonical body into the render entity and root pose — the same ack + /// contract the deleted ApplyAuthoritativePosition performed via + /// its acknowledgeProjection closure, now read directly off the + /// canonical body rather than an intermediate snapshot, since the + /// placement already committed (or the store fallback already wrote) + /// before this is called. + /// + /// + /// B1/B2 fix (round-2 review — reverting round 1's own R6 "fix", + /// which the retail reviewer retracted as factually wrong). + /// ParentCellId is read from record's (the local resolved + /// by TryGetCurrent below, equal to + /// once the currency check passes) + /// FullCellId — the WIRE cell — never the body's own + /// CellPosition.ObjCellId. On a committed outcome the two agree + /// (CommitCanonical writes both together), so the choice is a + /// no-op there. On a STORED outcome + /// (Refused/Contention/RejectedPreparation), + /// StoreAcceptedDestinationPose composes body.Position + /// from accepted.PositionX + worldOffset(accepted.LandblockId) — + /// the WIRE cell's world frame — while record.FullCellId is that + /// SAME wire cell (stamped by the merge's + /// RefreshDerivedState/SetFullCell, before classification + /// ever runs). The body's OWN CellPosition.ObjCellId, in + /// contrast, is never touched by the store fallback and is therefore + /// the SOURCE cell the body left — reading it here would pair the new + /// destination position with the cell the body is no longer in. Three + /// independent checks confirm record.FullCellId is the correct + /// source: (1) the sibling remote arm's + /// TryApplyGenericRemoteRenderPose pairs the wire world position + /// with the wire cell the same way; (2) Runtime's own + /// SyncProjectilePresentation, forty lines away in the SAME + /// packet, publishes the shadow row at record.FullCellId — so + /// reading the body's cell here would disagree with the shadow for the + /// SAME body in the SAME call; (3) retail's store_position + /// @0x00515CE2 writes the object's whole Position INCLUDING + /// objcell_id, so after a retail store the object's cell IS the + /// destination (the wire cell) — never the stale one. The body's own + /// stale cell after a store is the acdream-side residual (AP-138, + /// shared with the remote arms), not the truth to project. + /// + /// + /// + /// Rebases into the SAME + /// _lastFiniteGameTime field the deleted method's currentTime + /// parameter used to (unconditionally, when finite) — restoring the + /// controller's own tick-elapsed-time basis instead of leaving it + /// pinned at the last Vector/State/Tick call. + /// /// - internal bool ApplyAuthoritativePosition( + internal bool SyncPresentationFromResolvedBody( LiveEntityRecord expectedRecord, - Vector3 worldPosition, - Vector3 cellLocalPosition, - Quaternion orientation, - Vector3 velocity, - uint fullCellId, - double currentTime, - int liveCenterX, - int liveCenterY) => - ApplyAuthoritativePosition( - expectedRecord, - expectedRecord.PositionAuthorityVersion, - expectedRecord.VelocityAuthorityVersion, - worldPosition, - cellLocalPosition, - orientation, - velocity, - fullCellId, - currentTime, - liveCenterX, - liveCenterY); - - internal bool ApplyAuthoritativePosition( - LiveEntityRecord expectedRecord, - ulong expectedPositionAuthorityVersion, - ulong expectedVelocityAuthorityVersion, - Vector3 worldPosition, - Vector3 cellLocalPosition, - Quaternion orientation, - Vector3 velocity, - uint fullCellId, - double currentTime, - int liveCenterX, - int liveCenterY) + double currentTime) { ArgumentNullException.ThrowIfNull(expectedRecord); - uint serverGuid = expectedRecord.ServerGuid; - if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out RuntimeProjectile runtime) + if (!TryGetCurrent( + expectedRecord.ServerGuid, + out LiveEntityRecord record, + out RuntimeProjectile runtime) || !ReferenceEquals(record, expectedRecord) - || record.PositionAuthorityVersion != expectedPositionAuthorityVersion - || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0 || record.WorldEntity is not { } entity) - return false; - - if (!double.IsFinite(currentTime) - || !IsFinite(worldPosition) - || !IsFinite(cellLocalPosition) - || !IsFinite(velocity) - || !PositionFrameValidation.IsValid( - fullCellId, - cellLocalPosition, - orientation)) { - DiagnosticSink?.Invoke( - $"Rejected invalid PositionUpdate for missile 0x{serverGuid:X8}."); - return true; + return false; } - _lastFiniteGameTime = currentTime; - bool ExternalOwnerValid() => - TryGetCurrent( - serverGuid, - out LiveEntityRecord current, - out RuntimeProjectile currentRuntime) - && ReferenceEquals(current, record) - && ReferenceEquals(currentRuntime, runtime) - && ReferenceEquals(current.WorldEntity, entity) - && current.PositionAuthorityVersion - == expectedPositionAuthorityVersion; - return _runtimeUpdater.ApplyAuthoritativePosition( - record.Canonical, - expectedPositionAuthorityVersion, - expectedVelocityAuthorityVersion, - worldPosition, - cellLocalPosition, - orientation, - velocity, - fullCellId, - currentTime, - liveCenterX, - liveCenterY, - snapshot => - { - if (!ExternalOwnerValid()) - return false; - entity.SetPosition(snapshot.Position); - entity.Rotation = snapshot.Orientation; - entity.ParentCellId = snapshot.FullCellId; - _rootPoses?.UpdateRoot(entity); - return ExternalOwnerValid(); - }, - ExternalOwnerValid); + if (double.IsFinite(currentTime)) + _lastFiniteGameTime = currentTime; + + entity.SetPosition(runtime.Body.Position); + entity.Rotation = runtime.Body.Orientation; + entity.ParentCellId = record.FullCellId; + _rootPoses?.UpdateRoot(entity); + return true; } /// diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index a3b2af5d..a51a11d1 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -589,14 +589,38 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } /// - /// C4 route 4a: classifies one REMOTE incarnation's accepted Position - /// through , so - /// the graphical host and any future no-window remote-motion host make + /// C4 route 4a: classifies one non-local-player incarnation's accepted + /// Position through , + /// so the graphical host and any future no-window remote-motion host make /// the SAME airborne-no-op / near-interpolate decision from the same /// generation, the same authority shape, and the same request builder the /// deferred initial-create continuation uses. /// /// + /// C4 route 5 (D-P1, REVISED after the retail/architecture review round — + /// A2/R1): the caller's incarnation may be an ordinary remote OR a live + /// missile — this method derives RuntimePositionEntityKind from + /// canonical.FinalPhysicsState & PhysicsStateFlags.Missile + /// **conjoined with a bound, body-agreeing **, + /// never the Missile bit alone. Retail's MoveOrTeleport places + /// EVERY non-player object unconditionally — it has no concept of + /// "client-side machinery not yet bound". A Missile-flagged record whose + /// ProjectileController.TryBind permanently refused (an + /// unsupported multi-sphere Setup) or has not yet run (the pre-bind + /// window) still classifies Remote here, so it takes the SAME + /// generic remote placement path retail's client would drive for it and + /// keeps tracking the server — exactly the pre-route-5 behaviour, which + /// fell through the deleted ApplyAuthoritativePosition's + /// TryGetCurrent failure to the remote tail. The classifier's own + /// disposition/flag shape is unchanged either way (Projectile and Remote + /// are disposition-identical); only the returned route's + /// OperationKind differs, which is what lets the App dispatch + /// (route 5's OnPosition arm) and + /// + /// tell the two apart. + /// + /// + /// /// Returns when no classification can honestly be /// made: the lifetime has no bound generation yet, the canonical record /// has not claimed a local id, or there is no live local-player position @@ -635,11 +659,32 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { return null; } + // C4 route 5 (D-P1, revised — A2/R1): the Missile bit alone is + // data-driven but not sufficient — retail places every non-player + // object regardless of what client-side machinery exists for it, so + // this packet must classify Projectile ONLY when the arm can + // actually own the placement (a bound RuntimeProjectile whose Body + // is the exact canonical PhysicsBody). Anything else — no + // component bound (TryBind refused or has not run yet), or a + // component bound to a stale/displaced body — classifies Remote, + // matching retail's unconditional placement and the pre-route-5 + // fall-through the deleted ApplyAuthoritativePosition performed via + // its own TryGetCurrent failure. A mid-life Missile flip (ACE clears + // it on impact; a State packet installs it, and TryBind subsequently + // binds the component) is handled by construction: whichever + // FinalPhysicsState AND binding state the canonical record carries + // for THIS packet decides the packet's kind. + RuntimePositionEntityKind kind = + (canonical.FinalPhysicsState & PhysicsStateFlags.Missile) != 0 + && canonical.Projectile is RuntimeProjectile boundProjectile + && ReferenceEquals(canonical.PhysicsBody, boundProjectile.Body) + ? RuntimePositionEntityKind.Projectile + : RuntimePositionEntityKind.Remote; if (!RuntimeAcceptedPositionRouteRequests.TryBuild( generation(), canonical, update, - RuntimePositionEntityKind.Remote, + kind, RuntimeAcceptedPositionSource.PositionEvent, disposition, timestamps.PreviousTeleport, diff --git a/src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs index 325b1493..a7c6b1b8 100644 --- a/src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs +++ b/src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs @@ -298,130 +298,15 @@ internal sealed class RuntimeProjectilePhysicsUpdater return true; } - internal bool ApplyAuthoritativePosition( - RuntimeEntityRecord record, - ulong expectedPositionAuthorityVersion, - ulong expectedVelocityAuthorityVersion, - Vector3 worldPosition, - Vector3 cellLocalPosition, - Quaternion orientation, - Vector3 velocity, - uint fullCellId, - double currentTime, - int liveCenterX, - int liveCenterY, - Func acknowledgeProjection, - Func? externalOwnerValid = null) - { - ArgumentNullException.ThrowIfNull(record); - ArgumentNullException.ThrowIfNull(acknowledgeProjection); - if (!TryGetCurrent( - record, - externalOwnerValid, - out RuntimeProjectile projectile) - || record.PositionAuthorityVersion - != expectedPositionAuthorityVersion - || (record.FinalPhysicsState - & PhysicsStateFlags.Missile) == 0) - { - return false; - } - if (!double.IsFinite(currentTime) - || !IsFinite(worldPosition) - || !IsFinite(cellLocalPosition) - || !IsFinite(velocity) - || !PositionFrameValidation.IsValid( - fullCellId, - cellLocalPosition, - orientation)) - { - return true; - } - - PhysicsBody body = projectile.Body; - projectile.InvalidatePrediction(); - ulong predictionVersion = projectile.PredictionAuthorityVersion; - bool wasInWorld = body.InWorld; - body.Orientation = orientation; - body.SnapToCell(fullCellId, worldPosition, cellLocalPosition); - body.State = record.FinalPhysicsState; - if (record.VelocityAuthorityVersion - == expectedVelocityAuthorityVersion) - { - _ = _physics.TryCommitAuthoritativeVector( - record, - body, - velocity, - angularVelocity: null, - currentTime, - externalOwnerValid); - } - - bool IsExactOwner() => - TryGetCurrent( - record, - externalOwnerValid, - out RuntimeProjectile current) - && ReferenceEquals(current, projectile) - && current.PredictionAuthorityVersion == predictionVersion - && record.PositionAuthorityVersion - == expectedPositionAuthorityVersion; - - if (!_physics.CommitProjectileCell( - record, - projectile, - predictionVersion, - body.CellPosition.ObjCellId, - IsExactOwner) - || !IsExactOwner()) - { - // This packet was accepted for the old incarnation. A re-entrant - // observer displaced it, so the replacement owns another body. - return true; - } - - var snapshot = new RuntimePhysicsFrameSnapshot( - body.Position, - body.Orientation, - body.CellPosition.ObjCellId); - if (!acknowledgeProjection(snapshot) || !IsExactOwner()) - return true; - - bool spatial = _physics.IsSpatialProjectile(record, projectile); - bool hidden = - (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; - uint localId = record.LocalEntityId ?? 0u; - if (spatial && !hidden) - { - if (!wasInWorld) - { - body.LastUpdateTime = currentTime; - Activate(body, currentTime); - } - body.InWorld = true; - ShadowPositionSynchronizer.Sync( - _physics.Engine.ShadowObjects, - localId, - body.Position, - body.Orientation, - record.FullCellId, - liveCenterX, - liveCenterY); - } - else if (spatial) - { - body.InWorld = true; - body.LastUpdateTime = currentTime; - _physics.Engine.ShadowObjects.Suspend(localId); - } - else - { - body.InWorld = false; - Deactivate(body); - _physics.Engine.ShadowObjects.Suspend(localId); - } - return true; - } + // C4 route 5 (2026-08-04): the position-packet authority that used to + // live here — ApplyAuthoritativePosition — is deleted. A live missile's + // accepted Position now routes through the canonical + // RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition, + // the same shared placement pipeline (TryExecuteAcceptedRemotePosition / + // StoreAcceptedDestinationPose / CommitCanonical) the remote teleport/far + // arms use, instead of this class's bespoke SnapToCell + CommitProjectileCell + // + manual shadow-sync tail. CommitProjectileCell remains the per-quantum + // path's own cell commit (TryBegin/Complete below) — untouched. private bool IsSpatialCurrent( RuntimeEntityRecord record, @@ -481,18 +366,6 @@ internal sealed class RuntimeProjectilePhysicsUpdater 0f); } - private static void Activate(PhysicsBody body, double currentTime) - { - if ((body.State & PhysicsStateFlags.Static) != 0) - return; - if ((body.TransientState & TransientStateFlags.Active) == 0) - body.LastUpdateTime = currentTime; - body.TransientState |= TransientStateFlags.Active; - } - - private static void Deactivate(PhysicsBody body) => - body.TransientState &= ~TransientStateFlags.Active; - private static bool IsFinite(Vector3 value) => float.IsFinite(value.X) && float.IsFinite(value.Y) diff --git a/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs b/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs index 1466d245..e204adb3 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs @@ -78,6 +78,20 @@ internal static class RuntimeRemoteFarSnapPosition /// arg3 != 0), so this predicate is a strict narrowing of /// OwnsPlacement to its far half. /// + /// + /// + /// C4 route 5 (D-P3/A10 fix): after the widening, + /// OwnsPlacement ALSO admits + /// RuntimeSetPositionOperationKind.ProjectileAuthoritative — this + /// predicate's OperationKind: RemoteAuthoritative gate below is + /// therefore a strict narrowing of OwnsPlacement's REMOTE far + /// half only, not the whole predicate. A projectile far route never + /// satisfies this method (it takes the sibling seam, + /// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition, + /// which does not call this method and does not go through + /// / — + /// both require a RemoteMotion a projectile does not have). + /// /// internal static bool OwnsFarSnap(RuntimeAuthoritativePositionRoute? route) => route is diff --git a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs index 10454702..07b33f5d 100644 --- a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs @@ -516,9 +516,25 @@ internal sealed class RuntimeRemotePlacementDriveController /// POSITION-only route; the first-entry conductor owns every Create) /// without excluding either remote Position shape. /// + /// + /// C4 route 5 (D-P3): widened to admit + /// ProjectileAuthoritative alongside RemoteAuthoritative — + /// a live missile's teleport/cell-less and far accepted-Position + /// dispositions are disposition-identical to a remote's (the classifier + /// never branches on kind past LocalPlayer), and route 5's + /// is the sibling seam over + /// this SAME shared core ( + + /// ) — never a second pending + /// map, never a sibling controller. OwnsFarSnap and + /// OwnsTeleportPlacement are deliberately NOT widened: both remote + /// arm methods (/ + /// ) require and throw without a + /// RemoteMotion, which a projectile never has. + /// /// internal static bool OwnsPlacement(RuntimeAuthoritativePositionRoute route) => route.OperationKind is RuntimeSetPositionOperationKind.RemoteAuthoritative + or RuntimeSetPositionOperationKind.ProjectileAuthoritative && route.Disposition is RuntimeAuthoritativePositionDisposition.SetPosition or RuntimeAuthoritativePositionDisposition.SetPositionSimple && (route.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0; @@ -842,6 +858,234 @@ internal sealed class RuntimeRemotePlacementDriveController return status; } + /// + /// C4 route 5 (D-P2): the projectile arm over this SAME shared core. A + /// live missile carries no , so + /// / + /// are not reusable — both require one and throw without it. This is the + /// sibling seam the D-P2 design pins: same + /// + + /// core, same + /// / ledgers, + /// no second pending map, no sibling controller (trap T9). + /// + /// + /// Returns for every disposition this route does + /// not own: Interpolate (near, in contact) and + /// NoPositionOperation (airborne) are pinned NO-OPS — retail would + /// lazily build interpolation/leash machinery for a manager-less missile + /// (@0x005163AF / @0x00454272-@0x00510523), which acdream deliberately + /// does not construct for a ballistic body (the register row this route + /// adds); RejectedAuthority/RejectedData and an ownership + /// mismatch are SWALLOWED — write nothing, never fall through to the + /// remote tail (trap T5). A caller must not fall back to any remote arm + /// when this returns . + /// + /// + /// + /// No velocity write (D-P5). Retail's MoveOrTeleport + /// @0x00516330 never references its velocity argument in the decompiled + /// body, and a byte-level disassembly of the PDB-paired binary + /// (@0x00516330-@0x00516438) confirms no instruction anywhere in the + /// function reads that argument's stack slot. This seam commits no + /// velocity from the Position packet at all — the Vector channel + /// () + /// remains the sole velocity authority for a missile. + /// + /// + /// + /// No constraint leash armed (D-P4). Unlike the remote arms, this + /// method never calls TryArmConstraintAfterOperation — the + /// classifier's projectile routes still carry + /// ConstrainPhase.AfterPositionOperation (kind-blind), but this arm + /// deliberately does not consume it, matching the pinned divergence. + /// + /// + /// + /// Teleport hook reduction (D-P4). Of retail's six + /// teleport_hook @0x00514ED0 actions, five are structurally absent + /// for a manager-less missile (no MovementManager/ + /// PositionManager/TargetManager). The sixth, + /// report_collision_end(this, 1) @0x00514F31-@0x00514620, applies + /// to any object with a collision table and runs BEFORE the placement — + /// ported here as RuntimeCollisionReportingState.LeaveWorld (the + /// exact force-end seam the 4b-3 round-2 review validated against the + /// same retail address). + /// + /// + /// + /// Prediction is invalidated once per packet, before any body write on + /// this route (placement or the store fallback) — mirroring + /// RuntimeProjectilePhysicsUpdater's existing invalidate-before- + /// write ordering — so an in-flight split quantum straddling this packet + /// aborts at Complete rather than clobbering a canonical + /// placement. The no-op dispositions invalidate nothing: the body is + /// untouched, so a straddling quantum completing over them is correct. + /// + /// + internal RuntimeRemotePlacementExecutionStatus? ApplyAcceptedProjectilePosition( + RuntimeEntityRecord record, + in RuntimeAuthoritativePositionRoute route) + { + ArgumentNullException.ThrowIfNull(record); + if (route.OperationKind + is not RuntimeSetPositionOperationKind.ProjectileAuthoritative + || record.Projectile is not RuntimeProjectile projectile + || record.PhysicsBody is not { } body + || !ReferenceEquals(body, projectile.Body)) + { + return null; + } + + // A6 fix (review round): captured BEFORE the placement dispatch, + // mirroring the deleted tail's `bool wasInWorld = body.InWorld;` + // ordering — TryExecuteAcceptedRemotePosition's canonical commit + // calls body.SnapToCell, which sets InWorld = true, so reading this + // AFTER the dispatch (as the first cut of this seam did) makes the + // re-activation branch below permanently dead on every committed + // outcome. + bool wasInWorld = body.InWorld; + + RuntimeRemotePlacementExecutionStatus status; + switch (route.Disposition) + { + case RuntimeAuthoritativePositionDisposition.SetPosition: + _entityObjects.Physics.CollisionReports.LeaveWorld(record); + projectile.InvalidatePrediction(); + status = TryExecuteAcceptedRemotePosition(record, route); + break; + + case RuntimeAuthoritativePositionDisposition.SetPositionSimple: + // B1/B2 fix (round-2 review): retail's far branch runs + // `StopInterpolating` @0x005163C9-@0x005163CB whenever + // `position_manager != 0` — the SAME guard the remote far + // arm ports as `ApplyAcceptedRemoteFarSnap`'s + // `if (route.StopInterpolating) remote.Interp.Clear();`. A + // bare missile has no RemoteMotion so this is structurally + // inert, but the ADOPTED-BODY case (TryBind's shared-body + // branch: an ordinary remote whose Missile bit was set by a + // later State packet) carries a live Interp queue the far + // branch must clear too — the teleport hook only covers the + // SetPosition disposition. + if (route.StopInterpolating + && record.RemoteMotion is RemoteMotion adoptedFar) + { + adoptedFar.Interp.Clear(); + } + projectile.InvalidatePrediction(); + status = TryExecuteAcceptedRemotePosition(record, route); + break; + + default: + // Interpolate / NoPositionOperation: pinned no-op (D-P4). + // RejectedAuthority / RejectedData: swallow (T5) — the + // shared authority gate already rejected an invalid payload + // upstream; there is nothing left to route. + return null; + } + + if (status.StoresAcceptedDestination()) + StoreAcceptedDestinationPose(record); + if (status is not RuntimeRemotePlacementExecutionStatus.Deferred + and not RuntimeRemotePlacementExecutionStatus.RejectedByPlacement) + { + // Invariant 2: presentation advances on every committed/stored + // outcome only — Deferred/RejectedByPlacement leave the body at + // its prior (already-synced) pose. + SyncProjectilePresentation(record, projectile, body, wasInWorld); + } + return status; + } + + /// + /// C4 route 5 (REVISED after the review round — A6/A7/A8): the + /// J5.6-owned post-commit lifecycle tail (InWorld/Activate/shadow-sync + /// on spatial+visible, suspend on spatial+hidden, deactivate+suspend on + /// non-spatial), reduced from the deleted + /// RuntimeProjectilePhysicsUpdater.ApplyAuthoritativePosition + /// tail (former :390-422) to this controller's own seam — + /// is the caller's pre-dispatch capture + /// (A6: reading body.InWorld here, after the canonical commit's + /// own SnapToCell already forced it true, made the re-activation + /// branch permanently dead); supplies the same + /// clock source the deleted method took as an explicit + /// currentTime parameter; the world-frame offset comes from + /// (the same + /// source uses) instead of an + /// App-supplied live-center pair. + /// + private void SyncProjectilePresentation( + RuntimeEntityRecord record, + RuntimeProjectile projectile, + PhysicsBody body, + bool wasInWorld) + { + if (!_entityObjects.Entities.IsCurrent(record) + || !ReferenceEquals(record.Projectile, projectile) + || !ReferenceEquals(record.PhysicsBody, body)) + { + return; + } + + RuntimePhysicsState physics = _entityObjects.Physics; + bool spatial = physics.IsSpatialProjectile(record, projectile); + bool hidden = + (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; + uint localId = record.LocalEntityId ?? 0u; + if (spatial && !hidden) + { + if (!wasInWorld) + { + body.LastUpdateTime = _clock.SimulationTimeSeconds; + if ((body.State & PhysicsStateFlags.Static) == 0) + body.TransientState |= TransientStateFlags.Active; + } + body.InWorld = true; + // A8 fix: #284's policy ("a frame that can never arrive is + // terminal, never silent") applies here exactly as it does to + // StoreAcceptedDestinationPose. A false result during the + // legitimate pre-local-player-Create window silently skips the + // publish (self-heals once the frame arrives); a false result + // AFTER that window is a genuinely stuck frame, and + // ThrowIfWorldFrameUnreachable escalates it instead of leaving + // the shadow silently stale forever. + if (physics.TryGetWorldFrameOffset( + record.FullCellId, + out float offsetX, + out float offsetY)) + { + physics.Engine.ShadowObjects.UpdatePosition( + localId, + body.Position, + body.Orientation, + offsetX, + offsetY, + record.FullCellId, + seedCellId: record.FullCellId); + } + else + { + physics.ThrowIfWorldFrameUnreachable(record.FullCellId); + } + } + else if (spatial) + { + body.InWorld = true; + // A7 fix: retail's hidden-branch clock consumption — restored, + // matching ProjectileController.TryBind's equivalent branch + // ("consume the hidden clock so UnHide cannot replay a time + // backlog"). + body.LastUpdateTime = _clock.SimulationTimeSeconds; + physics.Engine.ShadowObjects.Suspend(localId); + } + else + { + body.InWorld = false; + body.TransientState &= ~TransientStateFlags.Active; + physics.Engine.ShadowObjects.Suspend(localId); + } + } + /// /// Retail CPhysicsObj::store_position @0x00515CE2, reached from /// SetPositionInternal's no-resolvable-cell branch @0x00515C1D. @@ -975,6 +1219,29 @@ internal sealed class RuntimeRemotePlacementDriveController } _pending.Remove(key); + // R3 fix (review round): a retained retry can belong to a + // projectile operation exactly as it can belong to a + // remote's — this is the SAME shared _pending map (trap T9: + // no second map), and pending.Route carries the OperationKind + // that was classified when the retry was first parked. + // Neither invariant this route pins (prediction invalidated + // before every body write; presentation advances on every + // committed/stored outcome) may hold on the direct arm only. + // Real nullable locals (not a stored bool) so the compiler + // can track definite assignment through the branches below. + RuntimeProjectile? pendingProjectile = null; + PhysicsBody? pendingBody = null; + if (pending.Route.OperationKind + is RuntimeSetPositionOperationKind.ProjectileAuthoritative + && pending.Record.Projectile is RuntimeProjectile candidateProjectile + && pending.Record.PhysicsBody is { } candidateBody + && ReferenceEquals(candidateBody, candidateProjectile.Body)) + { + pendingProjectile = candidateProjectile; + pendingBody = candidateBody; + } + bool pendingWasInWorld = pendingBody?.InWorld ?? false; + // B3 review fix: a retry can sit retained across many host // cadence pumps (bounded only by how long the asset stayed // unavailable) while its destination's collision publication @@ -1000,17 +1267,55 @@ internal sealed class RuntimeRemotePlacementDriveController CreateObject.ServerPosition? destination = pending.Record.Snapshot.Physics?.Position ?? pending.Record.Snapshot.Position; + RuntimeRemotePlacementExecutionStatus retryStatus; if (destination is not { } accepted || !CanAttemptDestination( setPosition, accepted.LandblockId)) { CancelToken(setPosition, pending.Token); + pendingProjectile?.InvalidatePrediction(); StoreAcceptedDestinationPose(pending.Record); - continue; + retryStatus = RuntimeRemotePlacementExecutionStatus.Refused; + } + else + { + // B5 fix (round-2 review): invalidating BEFORE this call + // unconditionally was wrong when SubmitAndResolve itself + // re-parks (returns Contention) — that outcome writes + // NOTHING (no store, no commit), so invalidating for it + // violates invariant 4's "the no-op dispositions + // invalidate nothing" on this arm specifically (unlike + // the entry point, where StoresAcceptedDestination() + // treats Contention as a storing outcome via the + // caller's own StoreAcceptedDestinationPose — this retry + // arm does not store on a re-parked Contention, matching + // the pre-existing residual A3/round-1 already named). + // Invalidating AFTER the call instead of before is safe + // here: this method is single-threaded and synchronous, + // so a write performed inside SubmitAndResolve and the + // very next statement's invalidate are never observably + // separated by a quantum's Complete call. + retryStatus = SubmitAndResolve( + pending.Record, pending.Token, pending.Route); + if (retryStatus + is not RuntimeRemotePlacementExecutionStatus.Contention) + { + pendingProjectile?.InvalidatePrediction(); + } } - _ = SubmitAndResolve(pending.Record, pending.Token, pending.Route); + if (pendingProjectile is { } confirmedProjectile + && pendingBody is { } confirmedBody + && retryStatus is not RuntimeRemotePlacementExecutionStatus.Deferred + and not RuntimeRemotePlacementExecutionStatus.RejectedByPlacement) + { + SyncProjectilePresentation( + pending.Record, + confirmedProjectile, + confirmedBody, + pendingWasInWorld); + } } } finally diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index d2fb1ca9..6465ddd1 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -500,6 +500,434 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests // both, because there is one call site left to sabotage. Reverted before // committing. + // ── C4 route 5 (A5 fix): missile matrix ───────────────────────────── + // + // The architecture review's FAIL-level finding: zero App tests drove + // OnPosition with a missile packet, so A1 (App discards the seam's + // status) and A2 (an unbound missile's Position silently dropped) both + // lived in the ~35 lines of "thin glue" the implementer argued were + // covered by two well-tested layers. These tests extend THIS file's own + // fixture — never a lighter one — with a genuine Missile-flagged, + // RuntimeProjectile-bound, RemoteMotion-less incarnation, and assert the + // SAME observable surface the remote scenarios above assert: body/entity + // position and ParentCellId (that pair is the exact A1 assertion), plus + // the projectile-specific half — no RemoteMotion is EVER created + // (invariant 8's mutual exclusion) and no early wire-pose write occurs. + // Destinations for the commit scenarios are airborne (well above + // PublishDestinationCollision's flat terrain), isolating the placement + // assertions from the shared pipeline's ordinary ground-contact response + // (Claim 4's confound, restated here for the App layer). + + private const uint MissileGuid = 0x80007101u; + private static readonly Vector3 MissileAirborneDestination = + new(12f, 14f, SpawnHeight + 10f); + + [Fact] + public void MissileTeleportCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody() + { + using var fixture = new Fixture(MissileGuid, isMissile: true); + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + + fixture.Controller.OnPosition(fixture.Update( + MissileAirborneDestination, DestinationCell, teleportSequence: 5, + guid: MissileGuid, isGrounded: true)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + Assert.Null(canonical.RemoteMotion); + Assert.NotNull(canonical.Projectile); + PhysicsBody body = canonical.PhysicsBody!; + Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset; + Assert.Equal(resolved, body.Position); + Assert.Equal(body.Position, fixture.Entity.Position); + // A1's own assertion: ParentCellId agrees with the RESOLVED body's + // OWN cell — the review's concrete wrong-cell scenario, checked + // positively here rather than only on the no-op scenarios below. + Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId); + Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); + // D-P4: the collision table was force-ended (no seeded owner here to + // assert a 1->0 transition — that half is covered at + // tests/AcDream.Runtime.Tests — but the call must not throw or + // leave the entity uncollidable long-term; Tick below proves it is + // still a live, ordinary object). + ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(body.Position, shadowEntry.Position); + + fixture.DrainPlacementFifo(); + } + + [Fact] + public void MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody() + { + using var fixture = new Fixture(MissileGuid, isMissile: true); + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + + fixture.Controller.OnPosition(fixture.Update( + MissileAirborneDestination, DestinationCell, teleportSequence: 1, + guid: MissileGuid, isGrounded: true)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + Assert.Null(canonical.RemoteMotion); + PhysicsBody body = canonical.PhysicsBody!; + Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset; + Assert.Equal(resolved, body.Position); + Assert.Equal(body.Position, fixture.Entity.Position); + Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId); + Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); + + fixture.DrainPlacementFifo(); + } + + /// + /// B1 EnvCell id — an indoor-format low word (0x100+, outside + /// LandDefs.AdjustToOutside's 1-0x40 outdoor range) staged onto + /// the body BEFORE the store-path dispatch below. This is what makes + /// the regression assertion actually discriminate: for an OUTDOOR + /// source/destination pair, PhysicsBody.Position's setter + /// delta-syncs CellPosition through AdjustToOutside and + /// happens to re-derive the correct destination landblock anyway (pure + /// geometry, no collision data needed) — so a wrong + /// body.CellPosition.ObjCellId read would coincidentally agree + /// with record.FullCellId and the test would pass either way. + /// An INDOOR source cell takes SyncCellPositionDelta's OTHER + /// branch (PhysicsBody.cs:295-300): it carries the position delta + /// but never re-derives the cell id, so body.CellPosition.ObjCellId + /// stays PINNED at the stale indoor source cell through the store path. + /// record.FullCellId has no such blind spot — it is merged from + /// the wire unconditionally — so this is the scenario where the two + /// expressions genuinely diverge and MAJOR 1's revert is provable. + /// + private const uint IndoorSourceCell = SourceLandblock | 0x0100u; + + /// + /// Residual 1 close (round-2 review): the STORE path — Refused — + /// at the App layer, closing both the coverage gap AND standing as the + /// regression test for MAJOR 1 (the ParentCellId revert). The + /// destination is deliberately left outside the service window + /// ( is never called for + /// ), so + /// CanAttemptDestination refuses before the engine ever runs and + /// StoreAcceptedDestinationPose resolves the destination through + /// Runtime's own world frame instead of a commit. Refused is + /// still a storing (A1-admitted) outcome, so the App-level presentation + /// sync runs — the entity's position AND ParentCellId must both + /// move to the DESTINATION (the wire cell), not the stale INDOOR source + /// cell a body.CellPosition.ObjCellId read would have produced + /// (see 's doc comment for why an outdoor + /// source cell would not have discriminated here). + /// + [Fact] + public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithWireCell() + { + using var fixture = new Fixture(MissileGuid, isMissile: true); + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonicalBeforeStage)); + PhysicsBody stagedBody = canonicalBeforeStage.PhysicsBody!; + stagedBody.SnapToCell( + IndoorSourceCell, stagedBody.Position, stagedBody.Position); + Assert.Equal(IndoorSourceCell, stagedBody.CellPosition.ObjCellId); + fixture.PublishDestinationCollision(); + // Deliberately NOT allowed — CanAttemptDestination refuses. + + fixture.Controller.OnPosition(fixture.Update( + MissileAirborneDestination, DestinationCell, teleportSequence: 1, + guid: MissileGuid, isGrounded: true)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + Assert.Null(canonical.RemoteMotion); + Assert.NotNull(canonical.Projectile); + PhysicsBody body = canonical.PhysicsBody!; + Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset; + Assert.Equal(resolved, body.Position); + Assert.True(body.InWorld); + // The store fallback never re-derives an INDOOR cell id — confirms + // the divergence this test is built to exercise actually occurred. + Assert.Equal(IndoorSourceCell, body.CellPosition.ObjCellId); + // The exact B1 regression check: ParentCellId is the DESTINATION + // (wire) cell, matching record.FullCellId — never + // body.CellPosition.ObjCellId, which just asserted it is STILL the + // stale indoor source cell. + Assert.Equal(body.Position, fixture.Entity.Position); + Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); + + fixture.DrainPlacementFifo(); + } + + [Fact] + public void MissileNear_NoOp_BodyUnchangedNoRemoteMotionNoWirePoseWrite() + { + using var fixture = new Fixture(MissileGuid, isMissile: true); + Vector3 spawnPose = fixture.Entity.Position; + var target = new Vector3(4f, 3f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + target, SourceCell, teleportSequence: 1, + guid: MissileGuid, isGrounded: true)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + Assert.Null(canonical.RemoteMotion); + PhysicsBody body = canonical.PhysicsBody!; + Assert.Equal(spawnPose, body.Position); + // A1's regression, asserted directly: no early wire-pose write — + // the render entity was never moved to `target`. + Assert.Equal(spawnPose, fixture.Entity.Position); + Assert.NotEqual(target, fixture.Entity.Position); + } + + [Fact] + public void MissileAirborne_NoOp_BodyUnchangedNoRemoteMotion() + { + using var fixture = new Fixture(MissileGuid, isMissile: true); + Vector3 spawnPose = fixture.Entity.Position; + var wirePos = new Vector3(50f, 50f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + wirePos, SourceCell, teleportSequence: 1, + guid: MissileGuid, isGrounded: false)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + Assert.Null(canonical.RemoteMotion); + PhysicsBody body = canonical.PhysicsBody!; + Assert.Equal(spawnPose, body.Position); + Assert.Equal(spawnPose, fixture.Entity.Position); + } + + [Fact] + public void MissileNullClassification_Swallowed_NoRemoteMotionNoWriteNoPredictionChange() + { + using var fixture = new Fixture( + MissileGuid, nullClassification: true, isMissile: true); + Vector3 spawnPose = fixture.Entity.Position; + ulong predictionBefore = fixture.Projectile!.PredictionAuthorityVersion; + var wirePos = new Vector3(50f, 50f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + wirePos, SourceCell, teleportSequence: 1, + guid: MissileGuid, isGrounded: true)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + Assert.Null(canonical.RemoteMotion); + PhysicsBody body = canonical.PhysicsBody!; + Assert.Equal(spawnPose, body.Position); + Assert.Equal(spawnPose, fixture.Entity.Position); + Assert.Equal( + predictionBefore, fixture.Projectile.PredictionAuthorityVersion); + } + + /// + /// A2/R1's regression scenario, driven end to end: Missile bit set, but + /// no RuntimeProjectile bound (TryBind refused, or has not run + /// yet — ProjectileController.cs:160-166's unsupported-Setup + /// case, or the pre-bind window). Retail places every non-player object + /// unconditionally; the fixed classifier must classify this packet + /// Remote and route it through the SAME generic remote placement path + /// an ordinary remote uses — never the frozen silent drop the + /// unconjoined discriminator produced. + /// + [Fact] + public void MissileUnbound_FallsThroughToRemoteTail_TracksInsteadOfFreezing() + { + using var fixture = new Fixture(MissileGuid, isMissile: false); + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + // Flip Missile AFTER construction, deliberately WITHOUT binding a + // RuntimeProjectile — the unbound shape A2 names. + fixture.Lifetime.Entities.SetFinalPhysicsState( + canonical, + canonical.FinalPhysicsState | PhysicsStateFlags.Missile); + Assert.Null(canonical.Projectile); + EntityPhysicsHost host = fixture.InstallHost(); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + Vector3 spawnPose = fixture.Entity.Position; + + fixture.Controller.OnPosition(fixture.Update( + MissileAirborneDestination, DestinationCell, teleportSequence: 1, + guid: MissileGuid, isGrounded: true)); + + // Placed via the ordinary remote far-snap arm — not frozen, and + // armed exactly like FarSnap_BothGuids above. + Assert.NotEqual(spawnPose, fixture.Remote.Body.Position); + Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position); + Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); + Assert.NotNull(host.PositionManager.Constraint); + + fixture.DrainPlacementFifo(); + } + + /// + /// A3/R2's adopted-body scenario: an ordinary remote (populated Interp + /// queue, an armed ConstrainTo leash) whose Missile bit is later set by + /// a State packet — ProjectileController.TryBind's shared-body + /// branch adopts the SAME RemoteMotion/body rather than replacing + /// it. Retail's teleport_hook per-manager guards are satisfied + /// for this shape, so all six actions run; the App-level pre-dispatch + /// hook call (mirroring the remote teleport arm's own + /// RunRemoteTeleportHook wiring) must un-arm the leash and clear + /// the queue before the placement, exactly like retail's ordering. + /// + [Fact] + public void MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue() + { + using var fixture = new Fixture(MissileGuid, isMissile: false); + EntityPhysicsHost host = fixture.InstallHost(); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + // Arm the leash and populate the queue directly — the pre-teleport + // "live remote" state the adopted-body scenario requires. + host.PositionManager.ConstrainTo( + new AcDream.Core.Physics.Position( + SourceCell, fixture.Remote.Body.Position, Quaternion.Identity), + startDistance: 1f, + maxDistance: 5f); + Assert.NotNull(host.PositionManager.Constraint); + fixture.Remote.Interp.Enqueue( + fixture.Remote.Body.Position + Vector3.UnitX, + heading: 0f, + isMovingTo: false, + currentBodyPosition: fixture.Remote.Body.Position); + Assert.True(fixture.Remote.Interp.IsActive); + + // TryBind's shared-body branch: adopt the SAME body into a + // RuntimeProjectile, and set Missile — the record now carries BOTH + // a RemoteMotion and a bound projectile, exactly like production. + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + fixture.Lifetime.Entities.SetFinalPhysicsState( + canonical, + canonical.FinalPhysicsState | PhysicsStateFlags.Missile); + fixture.Lifetime.Physics.BindProjectile( + canonical, + canonical.PhysicsBody!, + new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f)); + Assert.NotNull(canonical.Projectile); + Assert.NotNull(canonical.RemoteMotion); + + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + + fixture.Controller.OnPosition(fixture.Update( + MissileAirborneDestination, DestinationCell, teleportSequence: 5, + guid: MissileGuid, isGrounded: true)); + + // UnConstrain and StopInterpolating both ran. UnConstrain unmarks + // IsConstrained rather than nulling the manager back out (it was + // lazily CREATED by the ConstrainTo test-setup call above, and + // creation is one-way) — this is the exact discriminator + // WireAirborneNullClassified_BothGuids_WritesOnlyAP135Bookkeeping's + // own comment names ("Constraint is lazily created only on a + // genuine arm"). + Assert.NotNull(host.PositionManager.Constraint); + Assert.False(host.PositionManager.Constraint!.IsConstrained); + Assert.False(fixture.Remote.Interp.IsActive); + // The placement itself still committed through the projectile arm. + Assert.Equal( + MissileAirborneDestination + DestinationWorldOffset, + canonical.PhysicsBody!.Position); + + fixture.DrainPlacementFifo(); + } + + /// + /// B1/B2 fix (round-2 review): the far-branch counterpart to + /// . + /// Retail's far branch (SetPositionSimple, + /// player_distance >= 96 m) runs StopInterpolating + /// @0x005163C9-@0x005163CB whenever position_manager != 0 — the + /// SAME guard the teleport branch's full teleport_hook shares + /// for its own StopInterpolating action — but the far branch + /// does NOT run the other five teleport_hook actions (no + /// UnConstrain). For a bare missile this is structurally inert + /// (no RemoteMotion), which is why the pre-round-2 AP-141 row + /// could call the far-branch skip "faithful by consequence." The + /// adopted-body case breaks that: it carries a live Interp + /// queue the far branch must ALSO clear, while its armed + /// ConstrainTo leash must stay armed (proving the far branch + /// really does run only StopInterpolating, not the full hook). + /// + /// + /// Round-3 nit (C2): "leash still armed" pins acdream's OWN divergence, + /// not retail's behaviour. Retail's HandleReceivedPosition + /// @0x00454254/@0x00454272 re-anchors an existing leash at the object's + /// just-updated position on every nonzero MoveOrTeleport return — + /// including the far branch's. acdream's far arm never calls + /// ConstrainTo at all (D-P4, AP-141 clause (b)), so "still armed" + /// here means "left exactly as staged," not "correctly re-anchored." A + /// future reader should not read this assertion as full far-branch + /// leash fidelity — only the StopInterpolating half is ported. + /// + /// + [Fact] + public void MissileAdoptedBody_FarCommit_ClearsInterpQueueButLeavesConstraintArmed() + { + using var fixture = new Fixture(MissileGuid, isMissile: false); + EntityPhysicsHost host = fixture.InstallHost(); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + host.PositionManager.ConstrainTo( + new AcDream.Core.Physics.Position( + SourceCell, fixture.Remote.Body.Position, Quaternion.Identity), + startDistance: 1f, + maxDistance: 5f); + Assert.NotNull(host.PositionManager.Constraint); + Assert.True(host.PositionManager.Constraint!.IsConstrained); + fixture.Remote.Interp.Enqueue( + fixture.Remote.Body.Position + Vector3.UnitX, + heading: 0f, + isMovingTo: false, + currentBodyPosition: fixture.Remote.Body.Position); + Assert.True(fixture.Remote.Interp.IsActive); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + MissileGuid, out RuntimeEntityRecord canonical)); + fixture.Lifetime.Entities.SetFinalPhysicsState( + canonical, + canonical.FinalPhysicsState | PhysicsStateFlags.Missile); + fixture.Lifetime.Physics.BindProjectile( + canonical, + canonical.PhysicsBody!, + new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f)); + Assert.NotNull(canonical.Projectile); + Assert.NotNull(canonical.RemoteMotion); + + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + + // teleportSequence: 1 (unchanged from the fixture's baseline) with a + // cross-landblock destination classifies as the FAR disposition + // (SetPositionSimple) — the same discriminator + // MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody + // uses above, just against the adopted-body shape instead of a bare + // missile. + fixture.Controller.OnPosition(fixture.Update( + MissileAirborneDestination, DestinationCell, teleportSequence: 1, + guid: MissileGuid, isGrounded: true)); + + // StopInterpolating ran — the queue is cleared. + Assert.False(fixture.Remote.Interp.IsActive); + // UnConstrain did NOT run — the far branch is one action, not six. + // The leash is still armed. + Assert.NotNull(host.PositionManager.Constraint); + Assert.True(host.PositionManager.Constraint!.IsConstrained); + // The placement itself still committed through the projectile arm. + Assert.Equal( + MissileAirborneDestination + DestinationWorldOffset, + canonical.PhysicsBody!.Position); + + fixture.DrainPlacementFifo(); + } + private sealed class Fixture : IDisposable { internal RuntimeEntityObjectLifetime Lifetime { get; } @@ -510,6 +938,13 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests internal WorldEntity Entity { get; } internal RemoteMotion Remote { get; private set; } = null!; internal LiveEntityAnimationState? Animated { get; private set; } + /// + /// C4 route 5 (A5 fix): the bound projectile component for an + /// isMissile fixture — for an ordinary + /// remote fixture. Exposed so tests can assert prediction-version + /// movement without re-deriving it from . + /// + internal RuntimeProjectile? Projectile { get; private set; } private readonly GpuWorldState _spatial; private readonly uint _guid; private readonly bool _nullClassification; @@ -517,7 +952,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests internal Fixture( uint guid, bool nullClassification = false, - bool withAnimation = false) + bool withAnimation = false, + bool isMissile = false) { _guid = guid; _nullClassification = nullClassification; @@ -559,8 +995,16 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests ForcePosition: 1, ObjDesc: 1, Instance: 1); + // C4 route 5 (A5 fix): a missile fixture carries the Missile bit + // from spawn — the SAME data-driven bit + // RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition + // (D-P1) reads, so this fixture's record classifies exactly like + // a real missile would once TryBind/BindProjectile below binds + // the component. + PhysicsStateFlags baseState = PhysicsStateFlags.ReportCollisions + | (isMissile ? PhysicsStateFlags.Missile : PhysicsStateFlags.None); var physics = new PhysicsSpawnData( - RawState: (uint)PhysicsStateFlags.ReportCollisions, + RawState: (uint)baseState, Position: wirePosition, Movement: null, AnimationFrame: null, @@ -593,7 +1037,7 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests null, null, 0x09000001u, - PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + PhysicsState: (uint)baseState, InstanceSequence: 1, PositionSequence: 1, MovementSequence: 1, @@ -606,24 +1050,65 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests "fixture failed to materialize the remote entity"); Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell)); - var remote = new RemoteMotion(); - remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position); - remote.CellId = SourceCell; - Runtime.SetRemoteMotionRuntime(_guid, remote); - Remote = remote; - Shadows.Register( - Entity.Id, - 0x02000001u, - Entity.Position, - Entity.Rotation, - radius: 0.48f, - worldOffsetX: 0f, - worldOffsetY: 0f, - landblockId: SourceLandblock, - collisionType: ShadowCollisionType.Cylinder, - cylHeight: 1.835f, - seedCellId: SourceCell, - isStatic: false); + // C4 route 5 (A5 fix): a missile fixture binds a + // RuntimeProjectile directly through the SAME production Runtime + // entry point ProjectileController.TryBind eventually calls + // (RuntimePhysicsState.BindProjectile) — never a RemoteMotion. + // This is the exact shape invariant 8's mutual exclusion pins: + // Missile-set means a projectile arm and NO RemoteMotion ever + // exists for the entity. + if (isMissile) + { + var body = new PhysicsBody + { + Position = Entity.Position, + Orientation = Entity.Rotation, + LastUpdateTime = 1d, + State = baseState, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(SourceCell, Entity.Position, Entity.Position); + RuntimeEntityRecord canonical = record.Canonical!; + Lifetime.Entities.SetPhysicsBody(canonical, body); + canonical.ObjectClock.Activate(); + Lifetime.Physics.AcknowledgeSpatialProjection(canonical, spatial: true); + Projectile = (RuntimeProjectile)Lifetime.Physics.BindProjectile( + canonical, body, new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f)); + Shadows.Register( + Entity.Id, + 0x02000001u, + Entity.Position, + Entity.Rotation, + radius: 0.1f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: SourceLandblock, + collisionType: ShadowCollisionType.Sphere, + state: (uint)baseState, + seedCellId: SourceCell, + isStatic: false); + } + else + { + var remote = new RemoteMotion(); + remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position); + remote.CellId = SourceCell; + Runtime.SetRemoteMotionRuntime(_guid, remote); + Remote = remote; + Shadows.Register( + Entity.Id, + 0x02000001u, + Entity.Position, + Entity.Rotation, + radius: 0.48f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: SourceLandblock, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 1.835f, + seedCellId: SourceCell, + isStatic: false); + } var origin = new LiveWorldOriginState(); origin.SetPlaceholder( diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index e26fb000..f8356799 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -164,16 +164,21 @@ public sealed class ProjectileControllerTests Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0)); IRuntimeProjectile runtime = record.ProjectileRuntime!; - Assert.True(fixture.Controller.ApplyAuthoritativePosition( - record, - worldPosition: new Vector3(10f, 202f, 5f), - cellLocalPosition: new Vector3(10f, 10f, 5f), - orientation: Quaternion.Identity, - velocity: new Vector3(1f, 2f, 3f), - fullCellId: CellB, - currentTime: 2.1, - liveCenterX: 1, - liveCenterY: 1)); + // C4 route 5: accepted-Position placement now routes through + // RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition + // (Runtime-owned, tested at tests/AcDream.Runtime.Tests). This test is + // about Tick/hydration behaviour for a pending destination, not about + // the placement seam itself, so the destination is written directly — + // mirroring what a committed placement leaves on the canonical body — + // and the presentation ack uses the SAME production method the seam's + // caller uses (SyncPresentationFromResolvedBody). + runtime.Body.SnapToCell( + CellB, + new Vector3(10f, 202f, 5f), + new Vector3(10f, 10f, 5f)); + runtime.Body.Orientation = Quaternion.Identity; + Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellB)); + Assert.True(fixture.Controller.SyncPresentationFromResolvedBody(record, 2.1)); Assert.False(record.IsSpatiallyVisible); Assert.True(record.IsSpatiallyProjected); @@ -365,8 +370,16 @@ public sealed class ProjectileControllerTests } [Fact] - public void FreshVectorAndPositionCorrectionsMutateSameBody() + public void FreshVectorCorrectionsMutateTheCanonicalBody() { + // C4 route 5: the Position half of this test (a correction commits a + // velocity too) is RETIRED by design (D-P5) — a byte-level decode of + // retail's MoveOrTeleport (@0x00516330-@0x00516438) confirms it never + // reads its velocity argument, and RuntimeRemotePlacementDriveController + // .ApplyAcceptedProjectilePosition commits no velocity from a Position + // packet. The no-velocity invariant (a Position correction leaves an + // in-flight body's velocity bit-identical) is exercised at + // tests/AcDream.Runtime.Tests, the seam's own layer, not here. var fixture = new Fixture(); LiveEntityRecord record = fixture.Spawn(instance: 3); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 4.0)); @@ -379,43 +392,20 @@ public sealed class ProjectileControllerTests currentTime: 4.5)); Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity); Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega); - - var correction = new Vector3(30f, 31f, 32f); - var correctedVelocity = new Vector3(3f, 4f, 5f); - Assert.True(fixture.Controller.ApplyAuthoritativePosition( - record, - correction, - new Vector3(30f, 31f, 32f), - Quaternion.Identity, - correctedVelocity, - CellA, - currentTime: 5.0, - liveCenterX: 1, - liveCenterY: 1)); - - Assert.Same(body, record.PhysicsBody); - Assert.Equal(correction, body.Position); - Assert.Equal(correction, record.WorldEntity!.Position); - Assert.Equal(correctedVelocity, body.Velocity); - - // InboundPhysicsStateController normalizes an absent PositionPack - // velocity to zero before this contract is called. - Assert.True(fixture.Controller.ApplyAuthoritativePosition( - record, - correction, - new Vector3(30f, 31f, 32f), - Quaternion.Identity, - Vector3.Zero, - CellA, - currentTime: 5.1, - liveCenterX: 1, - liveCenterY: 1)); - Assert.Equal(Vector3.Zero, body.Velocity); } + // C4 route 5: the AuthoritativeMutation.Position case of this theory is + // RETIRED — accepted-Position corrections no longer route through + // ProjectileController at all (RuntimeRemotePlacementDriveController + // .ApplyAcceptedProjectilePosition owns them). The successor — + // "a split quantum straddling an accepted far/teleport Position aborts + // Complete rather than clobbering the committed placement" — is a + // Runtime-level test (trap T3) at tests/AcDream.Runtime.Tests, exercised + // directly against RuntimeProjectilePhysicsUpdater.TryBegin/Complete and + // the new seam, since that pairing is what now shares the prediction + // version this scenario discards. [Theory] [InlineData(AuthoritativeMutation.Vector)] - [InlineData(AuthoritativeMutation.Position)] [InlineData(AuthoritativeMutation.State)] public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction( AuthoritativeMutation mutation) @@ -429,7 +419,6 @@ public sealed class ProjectileControllerTests quantum: 0.05f, out ProjectileController.QuantumStep step)); - Vector3 correctedPosition = new(30f, 31f, 32f); Vector3 correctedVelocity = new(7f, 8f, 9f); Vector3 correctedOmega = new(0f, 0f, 2f); PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity; @@ -443,19 +432,6 @@ public sealed class ProjectileControllerTests currentTime: 1.02)); break; - case AuthoritativeMutation.Position: - Assert.True(fixture.Controller.ApplyAuthoritativePosition( - record, - correctedPosition, - correctedPosition, - Quaternion.Identity, - correctedVelocity, - CellA, - currentTime: 1.02, - liveCenterX: 1, - liveCenterY: 1)); - break; - case AuthoritativeMutation.State: record.FinalPhysicsState = correctedState; Assert.True(fixture.Controller.ApplyAuthoritativeState( @@ -477,10 +453,6 @@ public sealed class ProjectileControllerTests Assert.Equal(correctedVelocity, body.Velocity); Assert.Equal(correctedOmega, body.Omega); break; - case AuthoritativeMutation.Position: - Assert.Equal(correctedPosition, body.Position); - Assert.Equal(correctedVelocity, body.Velocity); - break; case AuthoritativeMutation.State: Assert.Equal(correctedState, body.State); break; @@ -856,14 +828,21 @@ public sealed class ProjectileControllerTests } [Fact] - public void MalformedFreshUpdates_DoNotPoisonCanonicalBodyOrPose() + public void MalformedFreshVectorUpdate_DoesNotPoisonCanonicalBody() { + // C4 route 5: the Position half of this test moved. A malformed + // accepted-Position payload for a missile is rejected further + // upstream now — the shared CanAcceptPositionPayload gate + // (unchanged, its own tests still cover it directly) runs + // unconditionally in OnPosition BEFORE the D-P6 dispatch even + // decides this is a missile packet, so the malformed-payload swallow + // is exercised at that layer (LiveEntityNetworkUpdateController's own + // "invalid-payload swallow" test), not here. var fixture = new Fixture(); LiveEntityRecord record = fixture.Spawn(instance: 1); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); PhysicsBody body = record.PhysicsBody!; Vector3 velocity = body.Velocity; - Vector3 position = body.Position; Assert.True(fixture.Controller.ApplyAuthoritativeVector( record, @@ -871,19 +850,6 @@ public sealed class ProjectileControllerTests Vector3.Zero, currentTime: 2.0)); Assert.Equal(velocity, body.Velocity); - - Assert.True(fixture.Controller.ApplyAuthoritativePosition( - record, - new Vector3(float.PositiveInfinity, 0f, 0f), - new Vector3(float.PositiveInfinity, 0f, 0f), - Quaternion.Identity, - Vector3.Zero, - CellA, - currentTime: 2.1, - liveCenterX: 1, - liveCenterY: 1)); - Assert.Equal(position, body.Position); - Assert.Equal(position, record.WorldEntity!.Position); } [Fact] @@ -1333,16 +1299,33 @@ public sealed class ProjectileControllerTests accepted.Position!.Value.LandblockId, _ => throw new InvalidOperationException("re-entry recreated the entity"))); - Assert.True(fixture.Controller.ApplyAuthoritativePosition( - record, - worldPosition: new Vector3(12f, 10f, 5f), - cellLocalPosition: new Vector3(12f, 10f, 5f), - orientation: Quaternion.Identity, - velocity: Vector3.Zero, - fullCellId: CellA, - currentTime: 10.0, - liveCenterX: 1, - liveCenterY: 1)); + // C4 route 5: the placement itself is Runtime-owned now + // (RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition, + // tested at tests/AcDream.Runtime.Tests). This test is about the + // shadow/clock re-entry after a pickup+leave-world, so the resolved + // destination is written directly and the presentation ack reuses + // the same production method the seam's caller uses. + // ApplyAuthoritativeState (unchanged) rebases the controller's own + // `_lastFiniteGameTime` to the packet's receipt time exactly like the + // deleted ApplyAuthoritativePosition's `currentTime` parameter used + // to — it is a same-value state re-assert (no functional State + // change), used here purely as the clock anchor. + Assert.True(fixture.Controller.ApplyAuthoritativeState( + record, record.FinalPhysicsState, currentTime: 10.0, 1, 1)); + record.PhysicsBody!.SnapToCell( + CellA, + new Vector3(12f, 10f, 5f), + new Vector3(12f, 10f, 5f)); + record.PhysicsBody.Orientation = Quaternion.Identity; + // D-P5: a Position packet no longer commits velocity (the retired + // behaviour this test used to lean on via the deleted method's + // `velocity: Vector3.Zero` argument). The Vector channel is a + // separate concern (untouched by route 5); stop the body directly so + // this test's clock/shadow assertions are not confounded by ordinary + // straight-line motion. + record.PhysicsBody.set_velocity(Vector3.Zero); + Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellA)); + Assert.True(fixture.Controller.SyncPresentationFromResolvedBody(record, 10.0)); // The incarnation-stable RetailObjectQuantumClock is canonical after // the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute // clock mirror and need not equal the packet receipt time once the @@ -1431,8 +1414,20 @@ public sealed class ProjectileControllerTests Assert.Equal(0, fixture.Controller.Count); } + /// + /// C4 route 5: the accepted-Position placement itself moved to + /// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition, + /// which re-validates currency internally before writing (proof + /// obligation P3's argument, tested at tests/AcDream.Runtime.Tests). This + /// test now exercises the successor of the OLD method's post-commit + /// currency guard: ProjectileController.SyncPresentationFromResolvedBody + /// (D-P2's closing paragraph) must refuse to touch presentation once a + /// reentrant guid-reuse callback has already replaced the incarnation it + /// was called for — a stale ack must never write through to the + /// replacement. + /// [Fact] - public void AuthoritativePosition_ReentrantGuidReuseStopsOldPostRebucketWork() + public void SyncPresentation_ReentrantGuidReuseNeverTouchesTheReplacement() { var fixture = new Fixture(); LiveEntityRecord first = fixture.Spawn(instance: 7); @@ -1450,22 +1445,28 @@ public sealed class ProjectileControllerTests replacement = fixture.Spawn(instance: 8); }; - Assert.True(fixture.Controller.ApplyAuthoritativePosition( - first, - worldPosition: new Vector3(10f, 202f, 5f), - cellLocalPosition: new Vector3(10f, 10f, 5f), - orientation: Quaternion.Identity, - velocity: new Vector3(44f, 0f, 0f), - fullCellId: CellB, - currentTime: 1.1, - liveCenterX: 1, - liveCenterY: 1)); + // The trigger: a landblock unload is the SAME visibility-loss edge + // LandblockUnload_SuspendsProjectileAtVisibilityEdgeWithoutFrameScan + // uses. RebucketLiveEntity is deliberately NOT the trigger here — it + // suppresses ProjectionVisibilityChanged for its OWN guid mid-call + // (LiveEntityRuntime.OnSpatialVisibilityChanged's `_rebucketingGuid` + // guard), which is exactly why the real placement seam's reentrant + // hazard is observable at the SPATIAL edge, not through a caller's + // own rebucket. + fixture.Spatial.RemoveLandblock(0x0101FFFFu); + Assert.True(replaced); + + // The stale ack for the SUPERSEDED incarnation must refuse. + Assert.False(fixture.Controller.SyncPresentationFromResolvedBody(first, 1.1)); Assert.NotNull(replacement); Assert.True(fixture.Live.TryGetRecord(Guid, out var current)); Assert.Same(replacement, current); Assert.Null(current.ProjectileRuntime); Assert.Null(current.PhysicsBody); + // The replacement's own spawn position (Fixture.Spawn's default), + // untouched by the stale ack — the positive half of the assertion, + // not merely "the ack returned false". Assert.Equal(new Vector3(10f, 10f, 5f), current.WorldEntity!.Position); Assert.Equal(0, fixture.Controller.Count); } @@ -1580,10 +1581,11 @@ public sealed class ProjectileControllerTests } } + // C4 route 5: Position is retired from this enum — see the comment on + // AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction. public enum AuthoritativeMutation { Vector, - Position, State, } diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeProjectilePositionKindTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeProjectilePositionKindTests.cs new file mode 100644 index 00000000..0bd74d96 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeProjectilePositionKindTests.cs @@ -0,0 +1,322 @@ +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Entities; + +/// +/// C4 route 5 (D-P1, REVISED after the review round — A2/R1): +/// +/// derives RuntimePositionEntityKind from +/// canonical.FinalPhysicsState & PhysicsStateFlags.Missile +/// CONJOINED with a bound, body-agreeing RuntimeProjectile — never +/// the Missile bit alone. Retail places every non-player object +/// unconditionally; a Missile-flagged record with no bound projectile +/// (TryBind permanently refused, or has not run yet) must still +/// classify Remote so it keeps tracking through the ordinary remote +/// placement path, exactly like the deleted ApplyAuthoritativePosition's +/// TryGetCurrent fall-through. Mirrors +/// 's fixture shape. +/// +public sealed class RuntimeProjectilePositionKindTests +{ + private const uint Cell = 0x0101FFFFu; + private const uint OtherCell = 0x0102FFFFu; + + [Fact] + public void MissileBitSetAndBound_ClassifiesProjectileAuthoritative() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL); + const uint guid = 0x70006001u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!; + lifetime.Entities.SetFinalPhysicsState( + canonical, + canonical.FinalPhysicsState | PhysicsStateFlags.Missile); + BindProjectile(lifetime, canonical, Cell); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, OtherCell, positionSequence: 2, teleportSequence: 0); + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after)); + Assert.True((after.FinalPhysicsState & PhysicsStateFlags.Missile) != 0); + Assert.NotNull(after.Projectile); + RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition( + after, update, disposition, timestamps, playerDistance: 10f); + + Assert.NotNull(route); + Assert.Equal( + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + route!.Value.OperationKind); + } + + /// + /// A2/R1: the Missile bit alone is not sufficient. TryBind's permanent + /// refusal (an unsupported multi-sphere Setup) or the pre-bind window + /// leaves record.Projectile null while the bit stays set — this + /// must still classify Remote so the packet is placed by the ordinary + /// remote path, never silently dropped. + /// + [Fact] + public void MissileBitSetButUnbound_ClassifiesRemoteAuthoritative() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL); + const uint guid = 0x70006005u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!; + lifetime.Entities.SetFinalPhysicsState( + canonical, + canonical.FinalPhysicsState | PhysicsStateFlags.Missile); + // Deliberately never bind a RuntimeProjectile. + Assert.Null(canonical.Projectile); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, OtherCell, positionSequence: 2, teleportSequence: 0); + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after)); + Assert.True((after.FinalPhysicsState & PhysicsStateFlags.Missile) != 0); + Assert.Null(after.Projectile); + RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition( + after, update, disposition, timestamps, playerDistance: 10f); + + Assert.NotNull(route); + Assert.Equal( + RuntimeSetPositionOperationKind.RemoteAuthoritative, + route!.Value.OperationKind); + } + + [Fact] + public void MissileBitClear_ClassifiesRemoteAuthoritative() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL); + const uint guid = 0x70006002u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!; + Assert.True((canonical.FinalPhysicsState & PhysicsStateFlags.Missile) == 0); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, OtherCell, positionSequence: 2, teleportSequence: 0); + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after)); + RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition( + after, update, disposition, timestamps, playerDistance: 10f); + + Assert.NotNull(route); + Assert.Equal( + RuntimeSetPositionOperationKind.RemoteAuthoritative, + route!.Value.OperationKind); + } + + /// + /// Trap T4 / invariant 8's mutual-exclusion proof, exercised at the flip + /// itself: a State packet installing Missile mid-life (ACE's ordinary + /// arrow-becomes-live-missile edge, or its converse on impact) makes the + /// VERY NEXT Position packet classify the OTHER kind — no stale + /// classification survives the flip. + /// + [Fact] + public void MissileBitFlipMidLife_NextPositionReclassifies() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL); + const uint guid = 0x70006003u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!; + Assert.True((canonical.FinalPhysicsState & PhysicsStateFlags.Missile) == 0); + + WorldSession.EntityPositionUpdate firstUpdate = PositionUpdate( + guid, OtherCell, positionSequence: 2, teleportSequence: 0); + Assert.True(lifetime.TryApplyPosition( + firstUpdate, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition firstDisposition, + out _, + out AcceptedPhysicsTimestamps firstTimestamps)); + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord beforeFlip)); + RuntimeAuthoritativePositionRoute? beforeRoute = + lifetime.ClassifyRemoteAcceptedPosition( + beforeFlip, firstUpdate, firstDisposition, firstTimestamps, playerDistance: 10f); + Assert.Equal( + RuntimeSetPositionOperationKind.RemoteAuthoritative, + beforeRoute!.Value.OperationKind); + + // A State packet (0x0013-family) installs Missile — the classifier + // itself never sees a State packet; only the NEXT Position does. + lifetime.Entities.SetFinalPhysicsState( + beforeFlip, + beforeFlip.FinalPhysicsState | PhysicsStateFlags.Missile); + // TryBind's production ordering: a State packet setting Missile is + // immediately followed by binding (ApplyAuthoritativeState -> + // TryBind). A2/R1 pins the classifier on the BOUND shape, so this + // scenario's flip is only complete once the component exists too. + BindProjectile(lifetime, beforeFlip, OtherCell); + + WorldSession.EntityPositionUpdate secondUpdate = PositionUpdate( + guid, Cell, positionSequence: 3, teleportSequence: 0); + Assert.True(lifetime.TryApplyPosition( + secondUpdate, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition secondDisposition, + out _, + out AcceptedPhysicsTimestamps secondTimestamps)); + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord afterFlip)); + RuntimeAuthoritativePositionRoute? afterRoute = + lifetime.ClassifyRemoteAcceptedPosition( + afterFlip, secondUpdate, secondDisposition, secondTimestamps, playerDistance: 10f); + + Assert.NotNull(afterRoute); + Assert.Equal( + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + afterRoute!.Value.OperationKind); + } + + /// + /// Attaches a canonical body (if not already present) and binds a + /// RuntimeProjectile to it through the SAME production entry + /// point ProjectileController.TryBind eventually calls + /// (RuntimePhysicsState.BindProjectile) — the classifier's + /// conjunctive test (A2/R1) reads exactly this state. + /// + private static void BindProjectile( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record, + uint cellId) + { + if (record.PhysicsBody is not { } body) + { + body = new PhysicsBody + { + Position = new Vector3(10f, 20f, 5f), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = record.FinalPhysicsState, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cellId, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + } + lifetime.Physics.BindProjectile( + record, body, new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f)); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + uint cellId, + ushort positionSequence, + ushort teleportSequence) => + new( + guid, + new CreateObject.ServerPosition( + cellId, 12f, 14f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: teleportSequence, + ForcePositionSequence: 0); + + private static WorldSession.EntitySpawn Spawn( + uint guid, + uint cellId, + ushort instance) + { + var position = new CreateObject.ServerPosition( + cellId, 10f, 20f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: 0x408u, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "remote-projectile-kind", + null, + null, + 0x09000001u, + PhysicsState: 0x408u, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs index 06fec946..01c83e04 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs @@ -117,9 +117,16 @@ public sealed class RuntimeRemotePlacementDriveControllerTests /// teleport branches (RuntimeSetPositionOperationKind.LocalAuthoritative), /// not only for remotes. The static predicate itself is exercised /// directly (no entity/body needed) since it takes only the route. + /// + /// + /// C4 route 5 (D-P3): ProjectileAuthoritative is REMOVED from this + /// negative list — the widening makes it a positively-owned kind now + /// (see ). + /// Only the two kinds that stay excluded remain here. + /// /// [Fact] - public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative() + public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteOrProjectileAuthoritative() { // RuntimeSetPositionOperationKind is internal, so a public [Theory] // cannot take it as a parameter (CS0051) — iterate directly instead, @@ -130,7 +137,6 @@ public sealed class RuntimeRemotePlacementDriveControllerTests { RuntimeSetPositionOperationKind.InitialLogin, RuntimeSetPositionOperationKind.LocalAuthoritative, - RuntimeSetPositionOperationKind.ProjectileAuthoritative, }) { using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); @@ -2256,6 +2262,845 @@ public sealed class RuntimeRemotePlacementDriveControllerTests AssertConverged(lifetime); } + // ── C4 route 5: projectile arm (D-P2/D-P3/D-P4/D-P5) ─────────────────── + + /// + /// D-P3: the widening itself, isolated from any entity/body — mirrors + /// 's + /// shape but for the positive case. + /// + [Fact] + public void OwnsPlacement_TrueForProjectileAuthoritative_SetPositionAndSetPositionSimple() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70004001u); + + foreach (RuntimeAuthoritativePositionDisposition disposition in + new[] + { + RuntimeAuthoritativePositionDisposition.SetPosition, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + }) + { + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + disposition, + DestinationCell, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + Assert.True(RuntimeRemotePlacementDriveController.OwnsPlacement(route)); + } + + // A projectile Create (InitialCreateFlags, no Teleport bit) is still + // excluded — the same Teleport-flag discriminator that excludes a + // remote top-level Create. + RuntimeAuthoritativePositionRoute createRoute = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative, + setPositionFlags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide); + Assert.False(RuntimeRemotePlacementDriveController.OwnsPlacement(createRoute)); + } + + [Fact] + public void ApplyAcceptedProjectilePosition_Null_WhenOperationKindIsNotProjectileAuthoritative() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + (RuntimeEntityRecord record, _) = CreateProjectileRecord( + lifetime, 0x70004002u, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + operationKind: RuntimeSetPositionOperationKind.RemoteAuthoritative); + + Assert.Null(drive.ApplyAcceptedProjectilePosition(record, route)); + Assert.Equal(0, drive.PendingCount); + AssertConverged(lifetime); + } + + [Fact] + public void ApplyAcceptedProjectilePosition_Null_WhenNoProjectileComponentIsBound() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70004003u); + AttachBody(lifetime, record, SourceCell); + // Deliberately never BindProjectile — record.Projectile stays null. + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + Assert.Null(drive.ApplyAcceptedProjectilePosition(record, route)); + } + + /// + /// D-P2's teleport/cell-less row + D-P4's force-end + D-P5's no-velocity, + /// asserted together on the ONE committed outcome (process rule 4 — + /// assert the full observable surface, not a subset). The body moves to + /// the resolved (world-frame-shifted) destination, the prediction version + /// advances (trap T3's guard), an in-flight nonzero velocity survives + /// bit-identical (D-P5), no RemoteMotion/constraint host exists + /// anywhere for the entity (D-P4's never-armed pin), and the collision + /// table the teleport hook reduction force-ends is empty afterward + /// (proof obligation P5). + /// + [Fact] + public void ApplyAcceptedProjectilePosition_TeleportCommit_MovesBodyForceEndsCollisionNoVelocityNoConstraint() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + (RuntimeEntityRecord record, RuntimeProjectile projectile) = + CreateProjectileRecord(lifetime, 0x70004004u, SourceCell); + PhysicsBody body = record.PhysicsBody!; + var inFlightVelocity = new Vector3(5f, 0f, -2f); + body.set_velocity(inFlightVelocity); + ulong predictionBefore = projectile.PredictionAuthorityVersion; + SeedCollisionOwner(lifetime, record, 0x70004104u, SourceCell); + Assert.Equal( + 1, lifetime.Physics.CollisionReports.CaptureOwnership().OwnerCount); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + // Airborne (well above CommitLandblockCollision's flat terrain at + // SpawnHeight): landing in ground contact would legitimately let the + // shared placement pipeline's ordinary contact response touch + // velocity (retail landing behaviour, not this route's concern) — + // an airborne destination isolates the no-velocity-FROM-THE-PACKET + // assertion from that confound. + var destination = new Vector3(12f, 14f, SpawnHeight + 10f); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + RuntimeRemotePlacementExecutionStatus? status = + drive.ApplyAcceptedProjectilePosition(record, route); + + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(predictionBefore, projectile.PredictionAuthorityVersion); + Assert.Equal(inFlightVelocity, body.Velocity); + Assert.Null(record.RemoteMotion); + Assert.Equal( + 0, lifetime.Physics.CollisionReports.CaptureOwnership().OwnerCount); + // A4 fix (review round): the shadow-sync half of + // SyncProjectilePresentation, asserted directly rather than left + // vacuous — the shadow row moves to the RESOLVED body position. + Assert.True(body.InWorld); + ShadowEntry shadowEntry = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == record.Key!.Value.LocalEntityId); + Assert.Equal(body.Position, shadowEntry.Position); + DrainPlacementFifo(lifetime); + AssertConverged(lifetime); + } + + /// D-P2's far row, mirroring the teleport commit's assertions. + [Fact] + public void ApplyAcceptedProjectilePosition_FarCommit_MovesBodyNoVelocityNoConstraint() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + (RuntimeEntityRecord record, RuntimeProjectile projectile) = + CreateProjectileRecord(lifetime, 0x70004005u, SourceCell); + PhysicsBody body = record.PhysicsBody!; + var inFlightVelocity = new Vector3(0f, 7f, 1f); + body.set_velocity(inFlightVelocity); + ulong predictionBefore = projectile.PredictionAuthorityVersion; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight + 10f); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + RuntimeRemotePlacementExecutionStatus? status = + drive.ApplyAcceptedProjectilePosition(record, route); + + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(predictionBefore, projectile.PredictionAuthorityVersion); + Assert.Equal(inFlightVelocity, body.Velocity); + Assert.Null(record.RemoteMotion); + Assert.True(body.InWorld); + ShadowEntry shadowEntry = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == record.Key!.Value.LocalEntityId); + Assert.Equal(body.Position, shadowEntry.Position); + DrainPlacementFifo(lifetime); + AssertConverged(lifetime); + } + + /// + /// A4 fix (review round): the spatial+hidden branch — the entity stays + /// InWorld (retail keeps a Hidden object as a retained live + /// CPhysicsObj, not a leave-world) but its shadow row is + /// suspended, not published at the resolved pose. + /// + [Fact] + public void ApplyAcceptedProjectilePosition_TeleportCommit_HiddenSuspendsShadowStaysInWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + (RuntimeEntityRecord record, _) = + CreateProjectileRecord(lifetime, 0x7000400Bu, SourceCell); + Assert.Equal(1, lifetime.Physics.Engine.ShadowObjects.TotalRegistered); + lifetime.Entities.SetFinalPhysicsState( + record, record.FinalPhysicsState | PhysicsStateFlags.Hidden); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight + 10f); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + drive.ApplyAcceptedProjectilePosition(record, route)); + + Assert.True(record.PhysicsBody!.InWorld); + Assert.Equal(0, lifetime.Physics.Engine.ShadowObjects.TotalRegistered); + DrainPlacementFifo(lifetime); + AssertConverged(lifetime); + } + + /// + /// A4 fix (review round): the non-spatial branch — a record that never + /// became a spatial root (e.g. still pending a landblock) is left + /// InWorld = false, its Active transient flag cleared, and + /// its shadow suspended. + /// + /// + /// Uses the STORE (Refused) path rather than a commit: a + /// successful canonical commit re-establishes spatial-root status as + /// part of entering the world, so the non-spatial branch is reachable + /// only through the outcomes that never touch spatial registration — + /// exactly the store fallback's shape. + /// + /// + [Fact] + public void ApplyAcceptedProjectilePosition_Refused_NonSpatialDeactivatesAndSuspends() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + lifetime.Physics.ObserveLocalWorldFrame(SourceCell, teleportAdvanced: false); + (RuntimeEntityRecord record, _) = + CreateProjectileRecord(lifetime, 0x7000400Cu, SourceCell); + Assert.Equal(1, lifetime.Physics.Engine.ShadowObjects.TotalRegistered); + // Withdraw spatial-root status — AcknowledgeSpatialProjection + // (spatial: false) is a no-op (only its `true` branch touches + // _spatialRoots); RemoveSpatialProjection is the actual withdrawal. + lifetime.Physics.RemoveSpatialProjection(record); + var window = new FakeServiceWindow(); + // Deliberately NOT allowed — the pre-flight refuses, so the store + // fallback runs without ever touching spatial registration. + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight + 10f); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + drive.ApplyAcceptedProjectilePosition(record, route)); + + Assert.False(record.PhysicsBody!.InWorld); + Assert.Equal( + TransientStateFlags.None, + record.PhysicsBody.TransientState & TransientStateFlags.Active); + Assert.Equal(0, lifetime.Physics.Engine.ShadowObjects.TotalRegistered); + DrainPlacementFifo(lifetime); + AssertConverged(lifetime); + } + + /// + /// A6 fix (review round): the re-entry activation edge. A projectile + /// that had left the world (suspended, InWorld = false) and comes + /// back through a committed accepted Position must be re-flagged + /// Active and have its legacy LastUpdateTime rebased — the + /// exact branch that reading body.InWorld AFTER the placement + /// (instead of capturing it before) made permanently dead. + /// + [Fact] + public void ApplyAcceptedProjectilePosition_TeleportCommit_ReenteringWorldReactivatesBody() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + (RuntimeEntityRecord record, _) = + CreateProjectileRecord(lifetime, 0x7000400Du, SourceCell); + PhysicsBody body = record.PhysicsBody!; + body.InWorld = false; + body.TransientState &= ~TransientStateFlags.Active; + body.LastUpdateTime = -1d; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight + 10f); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + drive.ApplyAcceptedProjectilePosition(record, route)); + + Assert.True(body.InWorld); + Assert.Equal( + TransientStateFlags.Active, + body.TransientState & TransientStateFlags.Active); + Assert.NotEqual(-1d, body.LastUpdateTime); + DrainPlacementFifo(lifetime); + AssertConverged(lifetime); + } + + /// + /// D3's Refused row: the destination is outside the service + /// window, so the pre-flight declines before the engine ever runs — but + /// the accepted destination STILL advances through + /// StoreAcceptedDestinationPose (4b-3 invariant 1, extended by + /// D-P3 to the projectile column). Positive assertions throughout + /// (round-2 finding B1): the pose moved, the entity stayed in-world, and + /// prediction still invalidated once (the store fallback is a body write + /// too). + /// + [Fact] + public void ApplyAcceptedProjectilePosition_Refused_StillAdvancesPoseNoParkPredictionInvalidated() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + // StoreAcceptedDestinationPose resolves the destination through + // Runtime's OWN world frame (never a caller-supplied position) — + // establish it exactly like CommitLandblockCollision's first step, + // without needing the destination's collision generation to commit + // (this test never reaches the engine). + lifetime.Physics.ObserveLocalWorldFrame(SourceCell, teleportAdvanced: false); + (RuntimeEntityRecord record, RuntimeProjectile projectile) = + CreateProjectileRecord(lifetime, 0x70004006u, SourceCell); + PhysicsBody body = record.PhysicsBody!; + ulong predictionBefore = projectile.PredictionAuthorityVersion; + var window = new FakeServiceWindow(); + // Deliberately NOT allowed — the pre-flight refuses. + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + RuntimeRemotePlacementExecutionStatus? status = + drive.ApplyAcceptedProjectilePosition(record, route); + + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Refused, status); + // The store fallback resolves through Runtime's world frame, the + // same +192m shift on X the committed-outcome tests observe. + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(predictionBefore, projectile.PredictionAuthorityVersion); + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal(0, drive.PendingCount); + // Residual 2 close (round-2 review): the store path publishes the + // shadow row too, not only the commit path the teleport/far commit + // tests already assert — SyncProjectilePresentation runs on every + // storing outcome, Refused included. + ShadowEntry shadowEntry = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == record.Key!.Value.LocalEntityId); + Assert.Equal(body.Position, shadowEntry.Position); + AssertConverged(lifetime); + } + + /// + /// D-P4's pinned no-op pair: Interpolate (near) and + /// NoPositionOperation (airborne) write nothing and do not + /// invalidate prediction — the positive fact that a straddling quantum + /// may complete over either. + /// + [Fact] + public void ApplyAcceptedProjectilePosition_PinnedNoOps_BodyAndPredictionUnchanged() + { + // RuntimeAuthoritativePositionDisposition is internal, so a public + // [Theory] cannot take it as a parameter (CS0051) — iterate directly, + // mirroring OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative's + // own foreach-over-internal-enum shape. + foreach (RuntimeAuthoritativePositionDisposition disposition in + new[] + { + RuntimeAuthoritativePositionDisposition.Interpolate, + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + }) + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + (RuntimeEntityRecord record, RuntimeProjectile projectile) = + CreateProjectileRecord(lifetime, 0x70004007u, SourceCell); + PhysicsBody body = record.PhysicsBody!; + Vector3 positionBefore = body.Position; + Quaternion orientationBefore = body.Orientation; + ulong predictionBefore = projectile.PredictionAuthorityVersion; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + disposition, + DestinationCell, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + Assert.Null(drive.ApplyAcceptedProjectilePosition(record, route)); + + Assert.Equal(positionBefore, body.Position); + Assert.Equal(orientationBefore, body.Orientation); + Assert.Equal(predictionBefore, projectile.PredictionAuthorityVersion); + Assert.Null(record.RemoteMotion); + Assert.Equal(0, drive.PendingCount); + AssertConverged(lifetime); + } + } + + /// + /// D-P4's swallow rule (trap T5): a RejectedAuthority/ + /// RejectedData classification for a missile packet writes + /// nothing — no body write, no store, no fall-through to any remote arm + /// (there is none reachable from this method regardless). + /// + [Fact] + public void ApplyAcceptedProjectilePosition_RejectedClassification_Swallowed() + { + foreach (RuntimeAuthoritativePositionDisposition disposition in + new[] + { + RuntimeAuthoritativePositionDisposition.RejectedAuthority, + RuntimeAuthoritativePositionDisposition.RejectedData, + }) + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + (RuntimeEntityRecord record, RuntimeProjectile projectile) = + CreateProjectileRecord(lifetime, 0x70004008u, SourceCell); + PhysicsBody body = record.PhysicsBody!; + Vector3 positionBefore = body.Position; + ulong predictionBefore = projectile.PredictionAuthorityVersion; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + disposition, + DestinationCell, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + Assert.Null(drive.ApplyAcceptedProjectilePosition(record, route)); + Assert.Equal(positionBefore, body.Position); + Assert.Equal(predictionBefore, projectile.PredictionAuthorityVersion); + Assert.Equal(0, drive.PendingCount); + AssertConverged(lifetime); + } + } + + /// + /// Test-plan item 6 / proof obligation-11: the SAME "retryable + /// preparation" shape + /// uses, driven through the projectile arm — proves the shared + /// _pending/_awaitingAcknowledgement ledgers converge for a + /// projectile operation with no new code (trap T9: no second map). + /// + [Fact] + public void ApplyAcceptedProjectilePosition_LedgerConverges_AfterDetachRouteClearsARetainedRetry() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + (RuntimeEntityRecord record, _) = CreateProjectileRecord( + lifetime, 0x70004009u, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + var routeOwner = new object(); + drive.AttachRoute(routeOwner); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + + RuntimeRemotePlacementExecutionStatus? status = + drive.ApplyAcceptedProjectilePosition(record, route); + + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status); + // A committed placement's Place receipt is never acknowledged in + // this bare fixture (no host subscription wired) — exactly the + // awaiting-acknowledgement dimension the ledger must also converge. + Assert.Equal( + 1, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + + drive.DetachRoute(routeOwner); + + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + 0, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + AssertConverged(lifetime); + } + + /// + /// Round-3 architecture review C1: Advance()'s projectile branch + /// (parked at round 2 / R3, reordered at round 2 / B5) had never been + /// executed by any test — all six pre-existing drive.Advance() + /// call sites in this file are remote-kind. This is the re-parked- + /// Contention half, mirroring + /// + /// against a projectile instead of a remote: UnusedCollisionSource + /// never resolves a nonzero Setup id, so BOTH the entry-point call and + /// the retry keep returning RetrySetupUnavailable — + /// Contention — and the pending entry never drains on its own. + /// + /// + /// The B5 semantic change under test: the entry-point call invalidates + /// prediction unconditionally BEFORE the write (the existing, already- + /// asserted behaviour); the RETRY call must NOT invalidate a second time + /// when it re-parks, because a re-parked Contention writes + /// nothing (no store, no commit) — invalidating for it would violate + /// "the no-op dispositions invalidate nothing" on an arm that wrote + /// nothing. + /// captured immediately before and after Advance() must be equal. + /// + /// + [Fact] + public void Advance_ProjectileRetryReParksAsContention_PredictionNotInvalidatedASecondTime() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + // Deliberately NOT committing DestinationLandblock's collision + // generation — CanAttemptDestination only tests the service window + // and the collision PREFIX quiescence, neither of which this + // scenario needs to fail; the retryable failure comes from the + // Setup read below, exactly like FarSnap_RetryablePreparation_…. + lifetime.Physics.ObserveLocalWorldFrame(SourceCell, teleportAdvanced: false); + (RuntimeEntityRecord record, RuntimeProjectile projectile) = + CreateProjectileRecord( + lifetime, + 0x7000400Bu, + SourceCell, + setupTableId: 0x02000001u); + PhysicsBody body = record.PhysicsBody!; + Vector3 positionBefore = body.Position; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative, + stopInterpolating: true); + + // Entry point: Contention, retained, and prediction invalidated + // exactly once (the pre-existing, already-tested entry-point + // behaviour — asserted again here only as the retry's baseline). + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.ApplyAcceptedProjectilePosition(record, route)); + Assert.Equal(1, drive.PendingCount); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(positionBefore, body.Position); + ulong predictionAfterEntry = projectile.PredictionAuthorityVersion; + + // The retry: Setup is still unresolved, so SubmitAndResolve returns + // Contention again and re-parks — the B5 no-invalidate branch. + drive.Advance(); + + Assert.Equal(1, drive.PendingCount); + Assert.Equal( + predictionAfterEntry, projectile.PredictionAuthorityVersion); + // The re-park wrote nothing — the stored pose from the entry point + // is untouched. + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + + RuntimePlacementCancellationReceipt cancellation = + lifetime.Physics.SetPosition.Forget(record); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + AssertConverged(lifetime); + } + + /// + /// Round-3 architecture review C1, the second half: a retained + /// projectile retry whose destination leaves the service window before + /// the next cadence pump — mirroring + /// + /// against a projectile. This exercises the STORING side C1 named as + /// unexercised: the retry's window-drop branch invalidates prediction + /// unconditionally (unlike the re-parked-Contention branch above) + /// because it runs StoreAcceptedDestinationPose — a real body + /// write — and it is the second call site (besides the entry point) that + /// must run 's + /// SyncProjectilePresentation, so the shadow row must follow the + /// body here too, closing B3's remaining retry-arm gap. + /// + [Fact] + public void Advance_ProjectileRetryDestinationLeavesTheWindow_StoresNewestPoseInvalidatesPredictionSyncsShadow() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + lifetime.Physics.ObserveLocalWorldFrame(SourceCell, teleportAdvanced: false); + (RuntimeEntityRecord record, RuntimeProjectile projectile) = + CreateProjectileRecord( + lifetime, + 0x7000400Cu, + SourceCell, + setupTableId: 0x02000001u); + PhysicsBody body = record.PhysicsBody!; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var firstDestination = new Vector3(12f, 14f, SpawnHeight); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.ApplyAcceptedProjectilePosition( + record, + MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + firstDestination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative, + stopInterpolating: true))); + Assert.Equal(1, drive.PendingCount); + ulong predictionAfterEntry = projectile.PredictionAuthorityVersion; + + // The server keeps broadcasting while the retry sits retained: the + // accepted snapshot moves on, and the destination falls out of the + // service window before the next cadence pump. + var newestDestination = new Vector3(40f, 50f, SpawnHeight); + record.Snapshot = record.Snapshot with + { + Position = new CreateObject.ServerPosition( + DestinationCell, + newestDestination.X, + newestDestination.Y, + newestDestination.Z, + 1f, + 0f, + 0f, + 0f), + }; + window.Forbid(DestinationLandblock); + + drive.Advance(); + + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + newestDestination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual( + predictionAfterEntry, projectile.PredictionAuthorityVersion); + // SyncProjectilePresentation ran on the retry arm too — the shadow + // row followed the body to the newest stored pose. + ShadowEntry shadowEntry = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == record.Key!.Value.LocalEntityId); + Assert.Equal(body.Position, shadowEntry.Position); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Test-plan item 4 (trap T3): a split quantum straddling an accepted + /// far/teleport Position must abort at Complete rather than + /// clobber the committed placement — the scenario invisible from reading + /// the classifier alone, and the one this route's App-level predecessor + /// test (AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction) + /// used to cover before its Position case retired. + /// + [Fact] + public void ApplyAcceptedProjectilePosition_DuringOpenQuantum_CompleteAbortsAfterPredictionInvalidated() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + (RuntimeEntityRecord record, RuntimeProjectile projectile) = + CreateProjectileRecord(lifetime, 0x7000400Au, SourceCell); + var updater = new RuntimeProjectilePhysicsUpdater(lifetime.Physics); + Assert.True(updater.TryBegin( + record, + quantum: 0.05f, + record.ObjectClockEpoch, + externalOwnerValid: null, + out RuntimeProjectilePhysicsCommit commit)); + + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination, + operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative); + RuntimeRemotePlacementExecutionStatus? status = + drive.ApplyAcceptedProjectilePosition(record, route); + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status); + Vector3 committedPosition = record.PhysicsBody!.Position; + + bool completed = updater.Complete( + commit, + liveCenterX: 0, + liveCenterY: 0, + acknowledgeProjection: static _ => true); + + Assert.False(completed); + Assert.Equal(committedPosition, record.PhysicsBody.Position); + DrainPlacementFifo(lifetime); + AssertConverged(lifetime); + } + + private static (RuntimeEntityRecord Record, RuntimeProjectile Projectile) CreateProjectileRecord( + RuntimeEntityObjectLifetime lifetime, + uint guid, + uint cellId, + bool registerShadow = true, + // C1 fix (round-3 architecture review): a nonzero setupTableId is + // what makes CanonicalSetupTableId != 0, which is what makes + // TryPrepareAuthoredMover actually consult UnusedCollisionSource + // (RuntimeSetPositionState.cs:1900-1918) instead of taking the + // ResolvedAbsent no-Setup path every other projectile fixture in + // this file relies on. Default null preserves every existing + // caller's behaviour exactly (id 0, ResolvedAbsent, always + // Prepared) — only the two new retry-arm tests pass a real id to + // deliberately provoke RetrySetupUnavailable. + uint? setupTableId = null) + { + RuntimeEntityRecord record = CreateRemoteRecord( + lifetime, guid, setupTableId); + lifetime.Entities.SetFinalPhysicsState( + record, + PhysicsStateFlags.Gravity + | PhysicsStateFlags.Missile + | PhysicsStateFlags.ReportCollisions); + PhysicsBody body = AttachBody(lifetime, record, cellId); + var sphere = new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f); + var projectile = (RuntimeProjectile)lifetime.Physics.BindProjectile( + record, body, sphere); + // A4 fix (review round): a shadow registration is the prerequisite + // for ShadowObjectRegistry.UpdatePosition to do anything at all + // (it early-returns "not registered" otherwise) — without this, a + // test could assert the shadow-sync branch ran while + // SyncProjectilePresentation's shadow write was silently a no-op. + if (registerShadow) + { + lifetime.Physics.Engine.ShadowObjects.Register( + record.Key!.Value.LocalEntityId, + gfxObjId: 0u, + body.Position, + body.Orientation, + radius: 0.1f, + worldOffsetX: 0f, + worldOffsetY: 0f, + cellId & 0xFFFF0000u, + ShadowCollisionType.Sphere, + state: (uint)record.FinalPhysicsState, + seedCellId: cellId, + isStatic: false); + } + return (record, projectile); + } + + /// + /// Seeds one collision-table owner row on via + /// a peer entity's dynamic shadow + one reported collision — the same + /// mechanism RuntimeCollisionReportingStateTests uses, reduced to + /// the minimum this file's proof obligation P5 needs. + /// + private static void SeedCollisionOwner( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord owner, + uint peerGuid, + uint cellId) + { + RuntimeEntityRecord peer = CreateRemoteRecord(lifetime, peerGuid); + AttachBody(lifetime, peer, cellId); + uint peerLocalId = peer.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.ShadowObjects.Register( + peerLocalId, + gfxObjId: 0u, + peer.PhysicsBody!.Position, + Quaternion.Identity, + radius: 0.4f, + worldOffsetX: 0f, + worldOffsetY: 0f, + cellId & 0xFFFF0000u, + ShadowCollisionType.Sphere, + state: (uint)peer.FinalPhysicsState, + seedCellId: cellId, + isStatic: false); + var report = new PhysicsSetPositionCollisionReport( + ContactPlaneValid: false, + ContactPlane: default, + ContactPlaneCellId: 0u, + ContactPlaneIsWater: false, + LastKnownContactPlaneValid: false, + LastKnownContactPlane: default, + LastKnownContactPlaneCellId: 0u, + LastKnownContactPlaneIsWater: false, + SlidingNormalValid: false, + SlidingNormal: default, + CollisionNormalValid: false, + CollisionNormal: default, + CollidedWithEnvironment: false, + FramesStationaryFall: 0, + AdjustOffset: default, + LastCollidedObjectId: peerLocalId, + CollidedObjectIds: System.Collections.Immutable.ImmutableArray + .Create(peerLocalId)); + Assert.True(lifetime.Physics.HandleSetPositionCollisions( + owner, + owner.PositionAuthorityVersion, + owner.SpatialAuthorityVersion, + owner.VelocityAuthorityVersion, + physicsTime: 1d, + previousContact: false, + previousOnWalkable: false, + report)); + } + // ── Fixture ────────────────────────────────────────────────────────── ///