fix(physics): C4 route 5 — projectile authoritative placement (#276 partial)
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 <noreply@anthropic.com>
This commit is contained in:
parent
30d3d114b0
commit
36255af0f6
19 changed files with 5390 additions and 393 deletions
|
|
@ -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.
|
- 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.
|
- 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
|
## C4 route 6 — drops and split-recovery closure — 2026-08-04
|
||||||
|
|
||||||
#313 filed from the route 6 closure session (zero production lines; evidence +
|
#313 filed from the route 6 closure session (zero production lines; evidence +
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
465
docs/research/2026-08-04-c4-route-5-architecture-review.md
Normal file
465
docs/research/2026-08-04-c4-route-5-architecture-review.md
Normal file
|
|
@ -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.
|
||||||
804
docs/research/2026-08-04-c4-route-5-contract.md
Normal file
804
docs/research/2026-08-04-c4-route-5-contract.md
Normal file
|
|
@ -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.
|
||||||
418
docs/research/2026-08-04-c4-route-5-retail-review-round2.md
Normal file
418
docs/research/2026-08-04-c4-route-5-retail-review-round2.md
Normal file
|
|
@ -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<bool>` 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.
|
||||||
282
docs/research/2026-08-04-c4-route-5-retail-review-round3.md
Normal file
282
docs/research/2026-08-04-c4-route-5-retail-review-round3.md
Normal file
|
|
@ -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
|
||||||
|
`<paramref name="record"/>` 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.
|
||||||
465
docs/research/2026-08-04-c4-route-5-retail-review.md
Normal file
465
docs/research/2026-08-04-c4-route-5-retail-review.md
Normal file
|
|
@ -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) |
|
||||||
|
|
@ -93,6 +93,21 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
private ulong _remoteArmPositionAuthorityVersion;
|
private ulong _remoteArmPositionAuthorityVersion;
|
||||||
private AcDream.Core.World.WorldEntity? _remoteArmExpectedEntity;
|
private AcDream.Core.World.WorldEntity? _remoteArmExpectedEntity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// (<see cref="RemoteArmCallbacks.IsCurrentProjectilePositionOwner"/>).
|
||||||
|
/// The missile dispatch block stamps these scratch fields immediately
|
||||||
|
/// before calling <see cref="RunRemoteTeleportHook"/> instead of
|
||||||
|
/// allocating a fresh <c>Func<bool></c> 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.
|
||||||
|
/// </summary>
|
||||||
|
private LiveEntityRecord? _projectileArmPositionRecord;
|
||||||
|
private ulong _projectileArmPositionAuthorityVersion;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #315: the two per-packet delegates cached ONCE (constructed here,
|
/// #315: the two per-packet delegates cached ONCE (constructed here,
|
||||||
/// reused for every accepted remote Position) rather than allocated
|
/// reused for every accepted remote Position) rather than allocated
|
||||||
|
|
@ -113,10 +128,22 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
internal readonly Func<bool> IsCurrentPositionOwner;
|
internal readonly Func<bool> IsCurrentPositionOwner;
|
||||||
internal readonly Func<bool> RunTeleportHook;
|
internal readonly Func<bool> RunTeleportHook;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="IsCurrentProjectileArmPositionOwner"/>, which reads
|
||||||
|
/// the <c>_projectileArmPosition*</c> scratch fields the missile
|
||||||
|
/// dispatch block stamps immediately before use.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly Func<bool> IsCurrentProjectilePositionOwner;
|
||||||
|
|
||||||
internal RemoteArmCallbacks(LiveEntityNetworkUpdateController owner)
|
internal RemoteArmCallbacks(LiveEntityNetworkUpdateController owner)
|
||||||
{
|
{
|
||||||
IsCurrentPositionOwner = owner.IsCurrentRemoteArmPositionOwner;
|
IsCurrentPositionOwner = owner.IsCurrentRemoteArmPositionOwner;
|
||||||
RunTeleportHook = owner.RunCachedRemoteTeleportHook;
|
RunTeleportHook = owner.RunCachedRemoteTeleportHook;
|
||||||
|
IsCurrentProjectilePositionOwner =
|
||||||
|
owner.IsCurrentProjectileArmPositionOwner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1478,6 +1505,25 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
&& (_remoteArmExpectedEntity is null
|
&& (_remoteArmExpectedEntity is null
|
||||||
|| ReferenceEquals(record.WorldEntity, _remoteArmExpectedEntity));
|
|| ReferenceEquals(record.WorldEntity, _remoteArmExpectedEntity));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// B4 fix: the cached backing method for
|
||||||
|
/// <see cref="RemoteArmCallbacks.IsCurrentProjectilePositionOwner"/>.
|
||||||
|
/// Reads the <c>_projectileArmPosition*</c> scratch fields the missile
|
||||||
|
/// dispatch block in <c>OnPosition</c> stamps immediately before calling
|
||||||
|
/// <see cref="RunRemoteTeleportHook"/> 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
|
||||||
|
/// <c>ReferenceEquals(positionRecord.WorldEntity, entity)</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
private bool IsCurrentProjectileArmPositionOwner() =>
|
||||||
|
_projectileArmPositionRecord is { } record
|
||||||
|
&& _liveEntities.IsCurrentPositionAuthority(
|
||||||
|
record, _projectileArmPositionAuthorityVersion);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #315: the cached backing method for
|
/// #315: the cached backing method for
|
||||||
/// <see cref="RemoteArmCallbacks.RunTeleportHook"/>. Only ever invoked on
|
/// <see cref="RemoteArmCallbacks.RunTeleportHook"/>. Only ever invoked on
|
||||||
|
|
@ -2102,27 +2148,113 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||||
_movementTruthDiagnostics.OnServerEcho(update, worldPos);
|
_movementTruthDiagnostics.OnServerEcho(update, worldPos);
|
||||||
|
|
||||||
// Missiles reconcile the same predicted PhysicsBody in place. The
|
// C4 route 5 (D-P1/D-P6, REVISED after the review round — A2/R1,
|
||||||
// timestamp gate above already rejected stale corrections; returning
|
// A9): classify ONCE, kind-aware. This single call now decides both
|
||||||
// here prevents the generic remote locomotion path from allocating a
|
// the remote route (unchanged for a non-missile packet — see the
|
||||||
// second body or interpolation owner for the projectile.
|
// reuse below) AND whether this packet is a missile packet,
|
||||||
if (_projectileController?.ApplyAuthoritativePosition(
|
// replacing the former ApplyAuthoritativePosition short-circuit.
|
||||||
acceptedPositionRecord,
|
// The null-classification arm's test is the SAME conjunctive
|
||||||
acceptedPositionAuthorityVersion,
|
// predicate the classifier itself applies
|
||||||
acceptedPositionVelocityAuthorityVersion,
|
// (RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition,
|
||||||
worldPos,
|
// D-P1) — Missile bit AND a bound RuntimeProjectile whose Body is
|
||||||
new System.Numerics.Vector3(
|
// the canonical PhysicsBody, never the bit alone, so an unbindable
|
||||||
p.PositionX,
|
// or not-yet-bound missile takes the ordinary remote tail exactly
|
||||||
p.PositionY,
|
// as it did before this route (the deleted method's TryGetCurrent
|
||||||
p.PositionZ),
|
// fall-through) — and explicitly fenced off the local player (A9):
|
||||||
rot,
|
// update.Guid == _playerServerGuid always takes the null branch
|
||||||
acceptedSpawn.Physics?.Velocity
|
// below, and ACE never sets Missile on a player, but the fence
|
||||||
?? System.Numerics.Vector3.Zero,
|
// makes that structurally true rather than incidentally true.
|
||||||
p.LandblockId,
|
RuntimeAuthoritativePositionRoute? earlyRemoteRoute =
|
||||||
_physicsScriptGameTime,
|
update.Guid != _playerServerGuid
|
||||||
_origin.CenterX,
|
? ClassifyRemoteAcceptedPosition(
|
||||||
_origin.CenterY) == true)
|
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;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_liveEntities.TryGetRecord(
|
if (!_liveEntities.TryGetRecord(
|
||||||
update.Guid,
|
update.Guid,
|
||||||
|
|
@ -2136,26 +2268,18 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// C4 route 4a: classify BEFORE the generic write below so a remote
|
// C4 route 4a/5: `earlyRemoteRoute` was already classified above
|
||||||
// whose accepted Position resolves to NoPositionOperation (retail's
|
// (D-P6) — this is the SAME value, reused so a remote whose accepted
|
||||||
// airborne no-op — writes nothing at all) or Interpolate (retail's
|
// Position resolves to NoPositionOperation (retail's airborne
|
||||||
// near InterpolateTo queue — no direct body write here) never
|
// no-op — writes nothing at all) or Interpolate (retail's near
|
||||||
// receives it. The local player never reaches this generic-remote
|
// InterpolateTo queue — no direct body write here) never receives
|
||||||
// code path at all. C4 route 4b-2 routes the >=96 m far snap and C4
|
// the generic write below. The local player never reaches this
|
||||||
// route 4b-3 routes the teleport/cell-less classification through
|
// generic-remote code path at all. C4 route 4b-2 routes the >=96 m
|
||||||
// the canonical Runtime placement owner (ApplyRemoteContactRouting);
|
// far snap and C4 route 4b-3 routes the teleport/cell-less
|
||||||
// a rejected authority or payload, and "no classification at all",
|
// classification through the canonical Runtime placement owner
|
||||||
// take the stated UnroutedCatchUp policy
|
// (ApplyRemoteContactRouting); a rejected authority or payload, and
|
||||||
// (RuntimeRemoteFarSnapPosition.ResolveArm).
|
// "no classification at all", take the stated UnroutedCatchUp
|
||||||
RuntimeAuthoritativePositionRoute? earlyRemoteRoute =
|
// policy (RuntimeRemoteFarSnapPosition.ResolveArm).
|
||||||
update.Guid != _playerServerGuid
|
|
||||||
? ClassifyRemoteAcceptedPosition(
|
|
||||||
update,
|
|
||||||
acceptedPositionCanonical,
|
|
||||||
timestampDisposition,
|
|
||||||
timestamps,
|
|
||||||
worldPos)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
TryApplyGenericRemoteRenderPose(
|
TryApplyGenericRemoteRenderPose(
|
||||||
entity,
|
entity,
|
||||||
|
|
|
||||||
|
|
@ -490,103 +490,84 @@ internal sealed class ProjectileController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies a timestamp-gated server correction to the same predicted body.
|
/// C4 route 5 (D-P2 closing paragraph): the presentation acknowledgement
|
||||||
/// The caller supplies both render-world and wire cell-local coordinates so
|
/// App still owns after
|
||||||
/// streaming origin changes never leak into the canonical cell frame.
|
/// <c>RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition</c>
|
||||||
|
/// has committed or stored an accepted Position for
|
||||||
|
/// <paramref name="expectedRecord"/>. Projects the Runtime-resolved
|
||||||
|
/// canonical body into the render entity and root pose — the same ack
|
||||||
|
/// contract the deleted <c>ApplyAuthoritativePosition</c> performed via
|
||||||
|
/// its <c>acknowledgeProjection</c> 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.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>B1/B2 fix (round-2 review — reverting round 1's own R6 "fix",
|
||||||
|
/// which the retail reviewer retracted as factually wrong).</b>
|
||||||
|
/// <c>ParentCellId</c> is read from <c>record</c>'s (the local resolved
|
||||||
|
/// by <c>TryGetCurrent</c> below, equal to
|
||||||
|
/// <paramref name="expectedRecord"/> once the currency check passes)
|
||||||
|
/// <c>FullCellId</c> — the WIRE cell — never the body's own
|
||||||
|
/// <c>CellPosition.ObjCellId</c>. On a committed outcome the two agree
|
||||||
|
/// (<c>CommitCanonical</c> writes both together), so the choice is a
|
||||||
|
/// no-op there. On a STORED outcome
|
||||||
|
/// (<c>Refused</c>/<c>Contention</c>/<c>RejectedPreparation</c>),
|
||||||
|
/// <c>StoreAcceptedDestinationPose</c> composes <c>body.Position</c>
|
||||||
|
/// from <c>accepted.PositionX + worldOffset(accepted.LandblockId)</c> —
|
||||||
|
/// the WIRE cell's world frame — while <c>record.FullCellId</c> is that
|
||||||
|
/// SAME wire cell (stamped by the merge's
|
||||||
|
/// <c>RefreshDerivedState</c>/<c>SetFullCell</c>, before classification
|
||||||
|
/// ever runs). The body's OWN <c>CellPosition.ObjCellId</c>, 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 <c>record.FullCellId</c> is the correct
|
||||||
|
/// source: (1) the sibling remote arm's
|
||||||
|
/// <c>TryApplyGenericRemoteRenderPose</c> pairs the wire world position
|
||||||
|
/// with the wire cell the same way; (2) Runtime's own
|
||||||
|
/// <c>SyncProjectilePresentation</c>, forty lines away in the SAME
|
||||||
|
/// packet, publishes the shadow row at <c>record.FullCellId</c> — so
|
||||||
|
/// reading the body's cell here would disagree with the shadow for the
|
||||||
|
/// SAME body in the SAME call; (3) retail's <c>store_position</c>
|
||||||
|
/// @0x00515CE2 writes the object's whole <c>Position</c> INCLUDING
|
||||||
|
/// <c>objcell_id</c>, 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.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Rebases <paramref name="currentTime"/> into the SAME
|
||||||
|
/// <c>_lastFiniteGameTime</c> field the deleted method's <c>currentTime</c>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal bool ApplyAuthoritativePosition(
|
internal bool SyncPresentationFromResolvedBody(
|
||||||
LiveEntityRecord expectedRecord,
|
LiveEntityRecord expectedRecord,
|
||||||
Vector3 worldPosition,
|
double currentTime)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
uint serverGuid = expectedRecord.ServerGuid;
|
if (!TryGetCurrent(
|
||||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out RuntimeProjectile runtime)
|
expectedRecord.ServerGuid,
|
||||||
|
out LiveEntityRecord record,
|
||||||
|
out RuntimeProjectile runtime)
|
||||||
|| !ReferenceEquals(record, expectedRecord)
|
|| !ReferenceEquals(record, expectedRecord)
|
||||||
|| record.PositionAuthorityVersion != expectedPositionAuthorityVersion
|
|
||||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
|
|
||||||
|| record.WorldEntity is not { } entity)
|
|| record.WorldEntity is not { } entity)
|
||||||
return false;
|
|
||||||
|
|
||||||
if (!double.IsFinite(currentTime)
|
|
||||||
|| !IsFinite(worldPosition)
|
|
||||||
|| !IsFinite(cellLocalPosition)
|
|
||||||
|| !IsFinite(velocity)
|
|
||||||
|| !PositionFrameValidation.IsValid(
|
|
||||||
fullCellId,
|
|
||||||
cellLocalPosition,
|
|
||||||
orientation))
|
|
||||||
{
|
{
|
||||||
DiagnosticSink?.Invoke(
|
return false;
|
||||||
$"Rejected invalid PositionUpdate for missile 0x{serverGuid:X8}.");
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
_lastFiniteGameTime = currentTime;
|
|
||||||
|
|
||||||
bool ExternalOwnerValid() =>
|
if (double.IsFinite(currentTime))
|
||||||
TryGetCurrent(
|
_lastFiniteGameTime = currentTime;
|
||||||
serverGuid,
|
|
||||||
out LiveEntityRecord current,
|
entity.SetPosition(runtime.Body.Position);
|
||||||
out RuntimeProjectile currentRuntime)
|
entity.Rotation = runtime.Body.Orientation;
|
||||||
&& ReferenceEquals(current, record)
|
entity.ParentCellId = record.FullCellId;
|
||||||
&& ReferenceEquals(currentRuntime, runtime)
|
_rootPoses?.UpdateRoot(entity);
|
||||||
&& ReferenceEquals(current.WorldEntity, entity)
|
return true;
|
||||||
&& 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -589,14 +589,38 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// C4 route 4a: classifies one REMOTE incarnation's accepted Position
|
/// C4 route 4a: classifies one non-local-player incarnation's accepted
|
||||||
/// through <see cref="RuntimeAuthoritativePositionRouteClassifier"/>, so
|
/// Position through <see cref="RuntimeAuthoritativePositionRouteClassifier"/>,
|
||||||
/// the graphical host and any future no-window remote-motion host make
|
/// so the graphical host and any future no-window remote-motion host make
|
||||||
/// the SAME airborne-no-op / near-interpolate decision from the same
|
/// the SAME airborne-no-op / near-interpolate decision from the same
|
||||||
/// generation, the same authority shape, and the same request builder the
|
/// generation, the same authority shape, and the same request builder the
|
||||||
/// deferred initial-create continuation uses.
|
/// deferred initial-create continuation uses.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// 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 <c>RuntimePositionEntityKind</c> from
|
||||||
|
/// <c>canonical.FinalPhysicsState & PhysicsStateFlags.Missile</c>
|
||||||
|
/// **conjoined with a bound, body-agreeing <see cref="RuntimeProjectile"/>**,
|
||||||
|
/// never the Missile bit alone. Retail's <c>MoveOrTeleport</c> places
|
||||||
|
/// EVERY non-player object unconditionally — it has no concept of
|
||||||
|
/// "client-side machinery not yet bound". A Missile-flagged record whose
|
||||||
|
/// <c>ProjectileController.TryBind</c> permanently refused (an
|
||||||
|
/// unsupported multi-sphere Setup) or has not yet run (the pre-bind
|
||||||
|
/// window) still classifies <c>Remote</c> 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 <c>ApplyAuthoritativePosition</c>'s
|
||||||
|
/// <c>TryGetCurrent</c> 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
|
||||||
|
/// <c>OperationKind</c> differs, which is what lets the App dispatch
|
||||||
|
/// (route 5's <c>OnPosition</c> arm) and
|
||||||
|
/// <see cref="AcDream.Runtime.Session.RuntimeRemotePlacementDriveController.OwnsPlacement"/>
|
||||||
|
/// tell the two apart.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
/// Returns <see langword="null"/> when no classification can honestly be
|
/// Returns <see langword="null"/> when no classification can honestly be
|
||||||
/// made: the lifetime has no bound generation yet, the canonical record
|
/// 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
|
/// 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;
|
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(
|
if (!RuntimeAcceptedPositionRouteRequests.TryBuild(
|
||||||
generation(),
|
generation(),
|
||||||
canonical,
|
canonical,
|
||||||
update,
|
update,
|
||||||
RuntimePositionEntityKind.Remote,
|
kind,
|
||||||
RuntimeAcceptedPositionSource.PositionEvent,
|
RuntimeAcceptedPositionSource.PositionEvent,
|
||||||
disposition,
|
disposition,
|
||||||
timestamps.PreviousTeleport,
|
timestamps.PreviousTeleport,
|
||||||
|
|
|
||||||
|
|
@ -298,130 +298,15 @@ internal sealed class RuntimeProjectilePhysicsUpdater
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool ApplyAuthoritativePosition(
|
// C4 route 5 (2026-08-04): the position-packet authority that used to
|
||||||
RuntimeEntityRecord record,
|
// live here — ApplyAuthoritativePosition — is deleted. A live missile's
|
||||||
ulong expectedPositionAuthorityVersion,
|
// accepted Position now routes through the canonical
|
||||||
ulong expectedVelocityAuthorityVersion,
|
// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
|
||||||
Vector3 worldPosition,
|
// the same shared placement pipeline (TryExecuteAcceptedRemotePosition /
|
||||||
Vector3 cellLocalPosition,
|
// StoreAcceptedDestinationPose / CommitCanonical) the remote teleport/far
|
||||||
Quaternion orientation,
|
// arms use, instead of this class's bespoke SnapToCell + CommitProjectileCell
|
||||||
Vector3 velocity,
|
// + manual shadow-sync tail. CommitProjectileCell remains the per-quantum
|
||||||
uint fullCellId,
|
// path's own cell commit (TryBegin/Complete below) — untouched.
|
||||||
double currentTime,
|
|
||||||
int liveCenterX,
|
|
||||||
int liveCenterY,
|
|
||||||
Func<RuntimePhysicsFrameSnapshot, bool> acknowledgeProjection,
|
|
||||||
Func<bool>? 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsSpatialCurrent(
|
private bool IsSpatialCurrent(
|
||||||
RuntimeEntityRecord record,
|
RuntimeEntityRecord record,
|
||||||
|
|
@ -481,18 +366,6 @@ internal sealed class RuntimeProjectilePhysicsUpdater
|
||||||
0f);
|
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) =>
|
private static bool IsFinite(Vector3 value) =>
|
||||||
float.IsFinite(value.X)
|
float.IsFinite(value.X)
|
||||||
&& float.IsFinite(value.Y)
|
&& float.IsFinite(value.Y)
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,20 @@ internal static class RuntimeRemoteFarSnapPosition
|
||||||
/// <c>arg3 != 0</c>), so this predicate is a strict narrowing of
|
/// <c>arg3 != 0</c>), so this predicate is a strict narrowing of
|
||||||
/// <c>OwnsPlacement</c> to its far half.
|
/// <c>OwnsPlacement</c> to its far half.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// C4 route 5 (D-P3/A10 fix): after the widening,
|
||||||
|
/// <c>OwnsPlacement</c> ALSO admits
|
||||||
|
/// <c>RuntimeSetPositionOperationKind.ProjectileAuthoritative</c> — this
|
||||||
|
/// predicate's <c>OperationKind: RemoteAuthoritative</c> gate below is
|
||||||
|
/// therefore a strict narrowing of <c>OwnsPlacement</c>'s REMOTE far
|
||||||
|
/// half only, not the whole predicate. A projectile far route never
|
||||||
|
/// satisfies this method (it takes the sibling seam,
|
||||||
|
/// <c>RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition</c>,
|
||||||
|
/// which does not call this method and does not go through
|
||||||
|
/// <see cref="AcDream.Runtime.Session.RuntimeRemotePlacementDriveController.ApplyAcceptedRemoteFarSnap"/>/<see cref="ResolveArm"/> —
|
||||||
|
/// both require a <c>RemoteMotion</c> a projectile does not have).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool OwnsFarSnap(RuntimeAuthoritativePositionRoute? route) =>
|
internal static bool OwnsFarSnap(RuntimeAuthoritativePositionRoute? route) =>
|
||||||
route is
|
route is
|
||||||
|
|
|
||||||
|
|
@ -516,9 +516,25 @@ internal sealed class RuntimeRemotePlacementDriveController
|
||||||
/// POSITION-only route; the first-entry conductor owns every Create)
|
/// POSITION-only route; the first-entry conductor owns every Create)
|
||||||
/// without excluding either remote Position shape.
|
/// without excluding either remote Position shape.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// C4 route 5 (D-P3): widened to admit
|
||||||
|
/// <c>ProjectileAuthoritative</c> alongside <c>RemoteAuthoritative</c> —
|
||||||
|
/// 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 <c>LocalPlayer</c>), and route 5's
|
||||||
|
/// <see cref="ApplyAcceptedProjectilePosition"/> is the sibling seam over
|
||||||
|
/// this SAME shared core (<see cref="TryExecuteAcceptedRemotePosition"/> +
|
||||||
|
/// <see cref="StoreAcceptedDestinationPose"/>) — never a second pending
|
||||||
|
/// map, never a sibling controller. <c>OwnsFarSnap</c> and
|
||||||
|
/// <c>OwnsTeleportPlacement</c> are deliberately NOT widened: both remote
|
||||||
|
/// arm methods (<see cref="ApplyAcceptedRemoteFarSnap"/>/
|
||||||
|
/// <see cref="ApplyAcceptedRemoteTeleport"/>) require and throw without a
|
||||||
|
/// <c>RemoteMotion</c>, which a projectile never has.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool OwnsPlacement(RuntimeAuthoritativePositionRoute route) =>
|
internal static bool OwnsPlacement(RuntimeAuthoritativePositionRoute route) =>
|
||||||
route.OperationKind is RuntimeSetPositionOperationKind.RemoteAuthoritative
|
route.OperationKind is RuntimeSetPositionOperationKind.RemoteAuthoritative
|
||||||
|
or RuntimeSetPositionOperationKind.ProjectileAuthoritative
|
||||||
&& route.Disposition is RuntimeAuthoritativePositionDisposition.SetPosition
|
&& route.Disposition is RuntimeAuthoritativePositionDisposition.SetPosition
|
||||||
or RuntimeAuthoritativePositionDisposition.SetPositionSimple
|
or RuntimeAuthoritativePositionDisposition.SetPositionSimple
|
||||||
&& (route.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0;
|
&& (route.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0;
|
||||||
|
|
@ -842,6 +858,234 @@ internal sealed class RuntimeRemotePlacementDriveController
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 5 (D-P2): the projectile arm over this SAME shared core. A
|
||||||
|
/// live missile carries no <see cref="RemoteMotion"/>, so
|
||||||
|
/// <see cref="ApplyAcceptedRemoteFarSnap"/>/<see cref="ApplyAcceptedRemoteTeleport"/>
|
||||||
|
/// are not reusable — both require one and throw without it. This is the
|
||||||
|
/// sibling seam the D-P2 design pins: same
|
||||||
|
/// <see cref="TryExecuteAcceptedRemotePosition"/> +
|
||||||
|
/// <see cref="StoreAcceptedDestinationPose"/> core, same
|
||||||
|
/// <see cref="_pending"/>/<see cref="_awaitingAcknowledgement"/> ledgers,
|
||||||
|
/// no second pending map, no sibling controller (trap T9).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Returns <see langword="null"/> for every disposition this route does
|
||||||
|
/// not own: <c>Interpolate</c> (near, in contact) and
|
||||||
|
/// <c>NoPositionOperation</c> (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); <c>RejectedAuthority</c>/<c>RejectedData</c> 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 <see langword="null"/>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>No velocity write (D-P5).</b> Retail's <c>MoveOrTeleport</c>
|
||||||
|
/// @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
|
||||||
|
/// (<see cref="RuntimeProjectilePhysicsUpdater.ApplyAuthoritativeVector"/>)
|
||||||
|
/// remains the sole velocity authority for a missile.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>No constraint leash armed (D-P4).</b> Unlike the remote arms, this
|
||||||
|
/// method never calls <c>TryArmConstraintAfterOperation</c> — the
|
||||||
|
/// classifier's projectile routes still carry
|
||||||
|
/// <c>ConstrainPhase.AfterPositionOperation</c> (kind-blind), but this arm
|
||||||
|
/// deliberately does not consume it, matching the pinned divergence.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Teleport hook reduction (D-P4).</b> Of retail's six
|
||||||
|
/// <c>teleport_hook</c> @0x00514ED0 actions, five are structurally absent
|
||||||
|
/// for a manager-less missile (no <c>MovementManager</c>/
|
||||||
|
/// <c>PositionManager</c>/<c>TargetManager</c>). The sixth,
|
||||||
|
/// <c>report_collision_end(this, 1)</c> @0x00514F31-@0x00514620, applies
|
||||||
|
/// to any object with a collision table and runs BEFORE the placement —
|
||||||
|
/// ported here as <c>RuntimeCollisionReportingState.LeaveWorld</c> (the
|
||||||
|
/// exact force-end seam the 4b-3 round-2 review validated against the
|
||||||
|
/// same retail address).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Prediction is invalidated once per packet, before any body write on
|
||||||
|
/// this route (placement or the store fallback) — mirroring
|
||||||
|
/// <c>RuntimeProjectilePhysicsUpdater</c>'s existing invalidate-before-
|
||||||
|
/// write ordering — so an in-flight split quantum straddling this packet
|
||||||
|
/// aborts at <c>Complete</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>RuntimeProjectilePhysicsUpdater.ApplyAuthoritativePosition</c>
|
||||||
|
/// tail (former <c>:390-422</c>) to this controller's own seam —
|
||||||
|
/// <paramref name="wasInWorld"/> is the caller's pre-dispatch capture
|
||||||
|
/// (A6: reading <c>body.InWorld</c> here, after the canonical commit's
|
||||||
|
/// own <c>SnapToCell</c> already forced it true, made the re-activation
|
||||||
|
/// branch permanently dead); <see cref="_clock"/> supplies the same
|
||||||
|
/// clock source the deleted method took as an explicit
|
||||||
|
/// <c>currentTime</c> parameter; the world-frame offset comes from
|
||||||
|
/// <see cref="RuntimePhysicsState.TryGetWorldFrameOffset"/> (the same
|
||||||
|
/// source <see cref="StoreAcceptedDestinationPose"/> uses) instead of an
|
||||||
|
/// App-supplied live-center pair.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>CPhysicsObj::store_position</c> @0x00515CE2, reached from
|
/// Retail <c>CPhysicsObj::store_position</c> @0x00515CE2, reached from
|
||||||
/// <c>SetPositionInternal</c>'s no-resolvable-cell branch @0x00515C1D.
|
/// <c>SetPositionInternal</c>'s no-resolvable-cell branch @0x00515C1D.
|
||||||
|
|
@ -975,6 +1219,29 @@ internal sealed class RuntimeRemotePlacementDriveController
|
||||||
}
|
}
|
||||||
_pending.Remove(key);
|
_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
|
// B3 review fix: a retry can sit retained across many host
|
||||||
// cadence pumps (bounded only by how long the asset stayed
|
// cadence pumps (bounded only by how long the asset stayed
|
||||||
// unavailable) while its destination's collision publication
|
// unavailable) while its destination's collision publication
|
||||||
|
|
@ -1000,17 +1267,55 @@ internal sealed class RuntimeRemotePlacementDriveController
|
||||||
CreateObject.ServerPosition? destination =
|
CreateObject.ServerPosition? destination =
|
||||||
pending.Record.Snapshot.Physics?.Position
|
pending.Record.Snapshot.Physics?.Position
|
||||||
?? pending.Record.Snapshot.Position;
|
?? pending.Record.Snapshot.Position;
|
||||||
|
RuntimeRemotePlacementExecutionStatus retryStatus;
|
||||||
if (destination is not { } accepted
|
if (destination is not { } accepted
|
||||||
|| !CanAttemptDestination(
|
|| !CanAttemptDestination(
|
||||||
setPosition,
|
setPosition,
|
||||||
accepted.LandblockId))
|
accepted.LandblockId))
|
||||||
{
|
{
|
||||||
CancelToken(setPosition, pending.Token);
|
CancelToken(setPosition, pending.Token);
|
||||||
|
pendingProjectile?.InvalidatePrediction();
|
||||||
StoreAcceptedDestinationPose(pending.Record);
|
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
|
finally
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,434 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
// both, because there is one call site left to sabotage. Reverted before
|
// both, because there is one call site left to sabotage. Reverted before
|
||||||
// committing.
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// B1 EnvCell id — an indoor-format low word (0x100+, outside
|
||||||
|
/// <c>LandDefs.AdjustToOutside</c>'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, <c>PhysicsBody.Position</c>'s setter
|
||||||
|
/// delta-syncs <c>CellPosition</c> through <c>AdjustToOutside</c> and
|
||||||
|
/// happens to re-derive the correct destination landblock anyway (pure
|
||||||
|
/// geometry, no collision data needed) — so a wrong
|
||||||
|
/// <c>body.CellPosition.ObjCellId</c> read would coincidentally agree
|
||||||
|
/// with <c>record.FullCellId</c> and the test would pass either way.
|
||||||
|
/// An INDOOR source cell takes <c>SyncCellPositionDelta</c>'s OTHER
|
||||||
|
/// branch (<c>PhysicsBody.cs:295-300</c>): it carries the position delta
|
||||||
|
/// but never re-derives the cell id, so <c>body.CellPosition.ObjCellId</c>
|
||||||
|
/// stays PINNED at the stale indoor source cell through the store path.
|
||||||
|
/// <c>record.FullCellId</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
private const uint IndoorSourceCell = SourceLandblock | 0x0100u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Residual 1 close (round-2 review): the STORE path — <c>Refused</c> —
|
||||||
|
/// at the App layer, closing both the coverage gap AND standing as the
|
||||||
|
/// regression test for MAJOR 1 (the <c>ParentCellId</c> revert). The
|
||||||
|
/// destination is deliberately left outside the service window
|
||||||
|
/// (<see cref="RemoteServiceWindow.Allow"/> is never called for
|
||||||
|
/// <see cref="DestinationLandblock"/>), so
|
||||||
|
/// <c>CanAttemptDestination</c> refuses before the engine ever runs and
|
||||||
|
/// <c>StoreAcceptedDestinationPose</c> resolves the destination through
|
||||||
|
/// Runtime's own world frame instead of a commit. <c>Refused</c> is
|
||||||
|
/// still a storing (A1-admitted) outcome, so the App-level presentation
|
||||||
|
/// sync runs — the entity's position AND <c>ParentCellId</c> must both
|
||||||
|
/// move to the DESTINATION (the wire cell), not the stale INDOOR source
|
||||||
|
/// cell a <c>body.CellPosition.ObjCellId</c> read would have produced
|
||||||
|
/// (see <see cref="IndoorSourceCell"/>'s doc comment for why an outdoor
|
||||||
|
/// source cell would not have discriminated here).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A2/R1's regression scenario, driven end to end: Missile bit set, but
|
||||||
|
/// no <c>RuntimeProjectile</c> bound (TryBind refused, or has not run
|
||||||
|
/// yet — <c>ProjectileController.cs:160-166</c>'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.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 — <c>ProjectileController.TryBind</c>'s shared-body
|
||||||
|
/// branch adopts the SAME <c>RemoteMotion</c>/body rather than replacing
|
||||||
|
/// it. Retail's <c>teleport_hook</c> 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
|
||||||
|
/// <c>RunRemoteTeleportHook</c> wiring) must un-arm the leash and clear
|
||||||
|
/// the queue before the placement, exactly like retail's ordering.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// B1/B2 fix (round-2 review): the far-branch counterpart to
|
||||||
|
/// <see cref="MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue"/>.
|
||||||
|
/// Retail's far branch (<c>SetPositionSimple</c>,
|
||||||
|
/// <c>player_distance >= 96 m</c>) runs <c>StopInterpolating</c>
|
||||||
|
/// @0x005163C9-@0x005163CB whenever <c>position_manager != 0</c> — the
|
||||||
|
/// SAME guard the teleport branch's full <c>teleport_hook</c> shares
|
||||||
|
/// for its own <c>StopInterpolating</c> action — but the far branch
|
||||||
|
/// does NOT run the other five teleport_hook actions (no
|
||||||
|
/// <c>UnConstrain</c>). For a bare missile this is structurally inert
|
||||||
|
/// (no <c>RemoteMotion</c>), 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 <c>Interp</c>
|
||||||
|
/// queue the far branch must ALSO clear, while its armed
|
||||||
|
/// <c>ConstrainTo</c> leash must stay armed (proving the far branch
|
||||||
|
/// really does run only <c>StopInterpolating</c>, not the full hook).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Round-3 nit (C2): "leash still armed" pins acdream's OWN divergence,
|
||||||
|
/// not retail's behaviour. Retail's <c>HandleReceivedPosition</c>
|
||||||
|
/// @0x00454254/@0x00454272 re-anchors an existing leash at the object's
|
||||||
|
/// just-updated position on every nonzero <c>MoveOrTeleport</c> return —
|
||||||
|
/// including the far branch's. acdream's far arm never calls
|
||||||
|
/// <c>ConstrainTo</c> 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 <c>StopInterpolating</c> half is ported.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[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
|
private sealed class Fixture : IDisposable
|
||||||
{
|
{
|
||||||
internal RuntimeEntityObjectLifetime Lifetime { get; }
|
internal RuntimeEntityObjectLifetime Lifetime { get; }
|
||||||
|
|
@ -510,6 +938,13 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
internal WorldEntity Entity { get; }
|
internal WorldEntity Entity { get; }
|
||||||
internal RemoteMotion Remote { get; private set; } = null!;
|
internal RemoteMotion Remote { get; private set; } = null!;
|
||||||
internal LiveEntityAnimationState? Animated { get; private set; }
|
internal LiveEntityAnimationState? Animated { get; private set; }
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 5 (A5 fix): the bound projectile component for an
|
||||||
|
/// <c>isMissile</c> fixture — <see langword="null"/> for an ordinary
|
||||||
|
/// remote fixture. Exposed so tests can assert prediction-version
|
||||||
|
/// movement without re-deriving it from <see cref="Lifetime"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal RuntimeProjectile? Projectile { get; private set; }
|
||||||
private readonly GpuWorldState _spatial;
|
private readonly GpuWorldState _spatial;
|
||||||
private readonly uint _guid;
|
private readonly uint _guid;
|
||||||
private readonly bool _nullClassification;
|
private readonly bool _nullClassification;
|
||||||
|
|
@ -517,7 +952,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
internal Fixture(
|
internal Fixture(
|
||||||
uint guid,
|
uint guid,
|
||||||
bool nullClassification = false,
|
bool nullClassification = false,
|
||||||
bool withAnimation = false)
|
bool withAnimation = false,
|
||||||
|
bool isMissile = false)
|
||||||
{
|
{
|
||||||
_guid = guid;
|
_guid = guid;
|
||||||
_nullClassification = nullClassification;
|
_nullClassification = nullClassification;
|
||||||
|
|
@ -559,8 +995,16 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
ForcePosition: 1,
|
ForcePosition: 1,
|
||||||
ObjDesc: 1,
|
ObjDesc: 1,
|
||||||
Instance: 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(
|
var physics = new PhysicsSpawnData(
|
||||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
RawState: (uint)baseState,
|
||||||
Position: wirePosition,
|
Position: wirePosition,
|
||||||
Movement: null,
|
Movement: null,
|
||||||
AnimationFrame: null,
|
AnimationFrame: null,
|
||||||
|
|
@ -593,7 +1037,7 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
0x09000001u,
|
0x09000001u,
|
||||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
PhysicsState: (uint)baseState,
|
||||||
InstanceSequence: 1,
|
InstanceSequence: 1,
|
||||||
PositionSequence: 1,
|
PositionSequence: 1,
|
||||||
MovementSequence: 1,
|
MovementSequence: 1,
|
||||||
|
|
@ -606,24 +1050,65 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
"fixture failed to materialize the remote entity");
|
"fixture failed to materialize the remote entity");
|
||||||
Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell));
|
Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell));
|
||||||
|
|
||||||
var remote = new RemoteMotion();
|
// C4 route 5 (A5 fix): a missile fixture binds a
|
||||||
remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
|
// RuntimeProjectile directly through the SAME production Runtime
|
||||||
remote.CellId = SourceCell;
|
// entry point ProjectileController.TryBind eventually calls
|
||||||
Runtime.SetRemoteMotionRuntime(_guid, remote);
|
// (RuntimePhysicsState.BindProjectile) — never a RemoteMotion.
|
||||||
Remote = remote;
|
// This is the exact shape invariant 8's mutual exclusion pins:
|
||||||
Shadows.Register(
|
// Missile-set means a projectile arm and NO RemoteMotion ever
|
||||||
Entity.Id,
|
// exists for the entity.
|
||||||
0x02000001u,
|
if (isMissile)
|
||||||
Entity.Position,
|
{
|
||||||
Entity.Rotation,
|
var body = new PhysicsBody
|
||||||
radius: 0.48f,
|
{
|
||||||
worldOffsetX: 0f,
|
Position = Entity.Position,
|
||||||
worldOffsetY: 0f,
|
Orientation = Entity.Rotation,
|
||||||
landblockId: SourceLandblock,
|
LastUpdateTime = 1d,
|
||||||
collisionType: ShadowCollisionType.Cylinder,
|
State = baseState,
|
||||||
cylHeight: 1.835f,
|
TransientState = TransientStateFlags.Active,
|
||||||
seedCellId: SourceCell,
|
};
|
||||||
isStatic: false);
|
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();
|
var origin = new LiveWorldOriginState();
|
||||||
origin.SetPlaceholder(
|
origin.SetPlaceholder(
|
||||||
|
|
|
||||||
|
|
@ -164,16 +164,21 @@ public sealed class ProjectileControllerTests
|
||||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
|
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
|
||||||
IRuntimeProjectile runtime = record.ProjectileRuntime!;
|
IRuntimeProjectile runtime = record.ProjectileRuntime!;
|
||||||
|
|
||||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
// C4 route 5: accepted-Position placement now routes through
|
||||||
record,
|
// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition
|
||||||
worldPosition: new Vector3(10f, 202f, 5f),
|
// (Runtime-owned, tested at tests/AcDream.Runtime.Tests). This test is
|
||||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
// about Tick/hydration behaviour for a pending destination, not about
|
||||||
orientation: Quaternion.Identity,
|
// the placement seam itself, so the destination is written directly —
|
||||||
velocity: new Vector3(1f, 2f, 3f),
|
// mirroring what a committed placement leaves on the canonical body —
|
||||||
fullCellId: CellB,
|
// and the presentation ack uses the SAME production method the seam's
|
||||||
currentTime: 2.1,
|
// caller uses (SyncPresentationFromResolvedBody).
|
||||||
liveCenterX: 1,
|
runtime.Body.SnapToCell(
|
||||||
liveCenterY: 1));
|
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.False(record.IsSpatiallyVisible);
|
||||||
Assert.True(record.IsSpatiallyProjected);
|
Assert.True(record.IsSpatiallyProjected);
|
||||||
|
|
@ -365,8 +370,16 @@ public sealed class ProjectileControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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();
|
var fixture = new Fixture();
|
||||||
LiveEntityRecord record = fixture.Spawn(instance: 3);
|
LiveEntityRecord record = fixture.Spawn(instance: 3);
|
||||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 4.0));
|
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 4.0));
|
||||||
|
|
@ -379,43 +392,20 @@ public sealed class ProjectileControllerTests
|
||||||
currentTime: 4.5));
|
currentTime: 4.5));
|
||||||
Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity);
|
Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity);
|
||||||
Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega);
|
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]
|
[Theory]
|
||||||
[InlineData(AuthoritativeMutation.Vector)]
|
[InlineData(AuthoritativeMutation.Vector)]
|
||||||
[InlineData(AuthoritativeMutation.Position)]
|
|
||||||
[InlineData(AuthoritativeMutation.State)]
|
[InlineData(AuthoritativeMutation.State)]
|
||||||
public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction(
|
public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction(
|
||||||
AuthoritativeMutation mutation)
|
AuthoritativeMutation mutation)
|
||||||
|
|
@ -429,7 +419,6 @@ public sealed class ProjectileControllerTests
|
||||||
quantum: 0.05f,
|
quantum: 0.05f,
|
||||||
out ProjectileController.QuantumStep step));
|
out ProjectileController.QuantumStep step));
|
||||||
|
|
||||||
Vector3 correctedPosition = new(30f, 31f, 32f);
|
|
||||||
Vector3 correctedVelocity = new(7f, 8f, 9f);
|
Vector3 correctedVelocity = new(7f, 8f, 9f);
|
||||||
Vector3 correctedOmega = new(0f, 0f, 2f);
|
Vector3 correctedOmega = new(0f, 0f, 2f);
|
||||||
PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity;
|
PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity;
|
||||||
|
|
@ -443,19 +432,6 @@ public sealed class ProjectileControllerTests
|
||||||
currentTime: 1.02));
|
currentTime: 1.02));
|
||||||
break;
|
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:
|
case AuthoritativeMutation.State:
|
||||||
record.FinalPhysicsState = correctedState;
|
record.FinalPhysicsState = correctedState;
|
||||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||||
|
|
@ -477,10 +453,6 @@ public sealed class ProjectileControllerTests
|
||||||
Assert.Equal(correctedVelocity, body.Velocity);
|
Assert.Equal(correctedVelocity, body.Velocity);
|
||||||
Assert.Equal(correctedOmega, body.Omega);
|
Assert.Equal(correctedOmega, body.Omega);
|
||||||
break;
|
break;
|
||||||
case AuthoritativeMutation.Position:
|
|
||||||
Assert.Equal(correctedPosition, body.Position);
|
|
||||||
Assert.Equal(correctedVelocity, body.Velocity);
|
|
||||||
break;
|
|
||||||
case AuthoritativeMutation.State:
|
case AuthoritativeMutation.State:
|
||||||
Assert.Equal(correctedState, body.State);
|
Assert.Equal(correctedState, body.State);
|
||||||
break;
|
break;
|
||||||
|
|
@ -856,14 +828,21 @@ public sealed class ProjectileControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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();
|
var fixture = new Fixture();
|
||||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
|
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
|
||||||
PhysicsBody body = record.PhysicsBody!;
|
PhysicsBody body = record.PhysicsBody!;
|
||||||
Vector3 velocity = body.Velocity;
|
Vector3 velocity = body.Velocity;
|
||||||
Vector3 position = body.Position;
|
|
||||||
|
|
||||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||||
record,
|
record,
|
||||||
|
|
@ -871,19 +850,6 @@ public sealed class ProjectileControllerTests
|
||||||
Vector3.Zero,
|
Vector3.Zero,
|
||||||
currentTime: 2.0));
|
currentTime: 2.0));
|
||||||
Assert.Equal(velocity, body.Velocity);
|
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]
|
[Fact]
|
||||||
|
|
@ -1333,16 +1299,33 @@ public sealed class ProjectileControllerTests
|
||||||
accepted.Position!.Value.LandblockId,
|
accepted.Position!.Value.LandblockId,
|
||||||
_ => throw new InvalidOperationException("re-entry recreated the entity")));
|
_ => throw new InvalidOperationException("re-entry recreated the entity")));
|
||||||
|
|
||||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
// C4 route 5: the placement itself is Runtime-owned now
|
||||||
record,
|
// (RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
|
||||||
worldPosition: new Vector3(12f, 10f, 5f),
|
// tested at tests/AcDream.Runtime.Tests). This test is about the
|
||||||
cellLocalPosition: new Vector3(12f, 10f, 5f),
|
// shadow/clock re-entry after a pickup+leave-world, so the resolved
|
||||||
orientation: Quaternion.Identity,
|
// destination is written directly and the presentation ack reuses
|
||||||
velocity: Vector3.Zero,
|
// the same production method the seam's caller uses.
|
||||||
fullCellId: CellA,
|
// ApplyAuthoritativeState (unchanged) rebases the controller's own
|
||||||
currentTime: 10.0,
|
// `_lastFiniteGameTime` to the packet's receipt time exactly like the
|
||||||
liveCenterX: 1,
|
// deleted ApplyAuthoritativePosition's `currentTime` parameter used
|
||||||
liveCenterY: 1));
|
// 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 incarnation-stable RetailObjectQuantumClock is canonical after
|
||||||
// the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute
|
// the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute
|
||||||
// clock mirror and need not equal the packet receipt time once the
|
// 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);
|
Assert.Equal(0, fixture.Controller.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuthoritativePosition_ReentrantGuidReuseStopsOldPostRebucketWork()
|
public void SyncPresentation_ReentrantGuidReuseNeverTouchesTheReplacement()
|
||||||
{
|
{
|
||||||
var fixture = new Fixture();
|
var fixture = new Fixture();
|
||||||
LiveEntityRecord first = fixture.Spawn(instance: 7);
|
LiveEntityRecord first = fixture.Spawn(instance: 7);
|
||||||
|
|
@ -1450,22 +1445,28 @@ public sealed class ProjectileControllerTests
|
||||||
replacement = fixture.Spawn(instance: 8);
|
replacement = fixture.Spawn(instance: 8);
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
// The trigger: a landblock unload is the SAME visibility-loss edge
|
||||||
first,
|
// LandblockUnload_SuspendsProjectileAtVisibilityEdgeWithoutFrameScan
|
||||||
worldPosition: new Vector3(10f, 202f, 5f),
|
// uses. RebucketLiveEntity is deliberately NOT the trigger here — it
|
||||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
// suppresses ProjectionVisibilityChanged for its OWN guid mid-call
|
||||||
orientation: Quaternion.Identity,
|
// (LiveEntityRuntime.OnSpatialVisibilityChanged's `_rebucketingGuid`
|
||||||
velocity: new Vector3(44f, 0f, 0f),
|
// guard), which is exactly why the real placement seam's reentrant
|
||||||
fullCellId: CellB,
|
// hazard is observable at the SPATIAL edge, not through a caller's
|
||||||
currentTime: 1.1,
|
// own rebucket.
|
||||||
liveCenterX: 1,
|
fixture.Spatial.RemoveLandblock(0x0101FFFFu);
|
||||||
liveCenterY: 1));
|
Assert.True(replaced);
|
||||||
|
|
||||||
|
// The stale ack for the SUPERSEDED incarnation must refuse.
|
||||||
|
Assert.False(fixture.Controller.SyncPresentationFromResolvedBody(first, 1.1));
|
||||||
|
|
||||||
Assert.NotNull(replacement);
|
Assert.NotNull(replacement);
|
||||||
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
||||||
Assert.Same(replacement, current);
|
Assert.Same(replacement, current);
|
||||||
Assert.Null(current.ProjectileRuntime);
|
Assert.Null(current.ProjectileRuntime);
|
||||||
Assert.Null(current.PhysicsBody);
|
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(new Vector3(10f, 10f, 5f), current.WorldEntity!.Position);
|
||||||
Assert.Equal(0, fixture.Controller.Count);
|
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
|
public enum AuthoritativeMutation
|
||||||
{
|
{
|
||||||
Vector,
|
Vector,
|
||||||
Position,
|
|
||||||
State,
|
State,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C4 route 5 (D-P1, REVISED after the review round — A2/R1):
|
||||||
|
/// <see cref="RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition"/>
|
||||||
|
/// derives <c>RuntimePositionEntityKind</c> from
|
||||||
|
/// <c>canonical.FinalPhysicsState & PhysicsStateFlags.Missile</c>
|
||||||
|
/// CONJOINED with a bound, body-agreeing <c>RuntimeProjectile</c> — never
|
||||||
|
/// the Missile bit alone. Retail places every non-player object
|
||||||
|
/// unconditionally; a Missile-flagged record with no bound projectile
|
||||||
|
/// (<c>TryBind</c> 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 <c>ApplyAuthoritativePosition</c>'s
|
||||||
|
/// <c>TryGetCurrent</c> fall-through. Mirrors
|
||||||
|
/// <see cref="RuntimeRemoteTeleportClassificationTests"/>'s fixture shape.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A2/R1: the Missile bit alone is not sufficient. TryBind's permanent
|
||||||
|
/// refusal (an unsupported multi-sphere Setup) or the pre-bind window
|
||||||
|
/// leaves <c>record.Projectile</c> null while the bit stays set — this
|
||||||
|
/// must still classify Remote so the packet is placed by the ordinary
|
||||||
|
/// remote path, never silently dropped.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches a canonical body (if not already present) and binds a
|
||||||
|
/// <c>RuntimeProjectile</c> to it through the SAME production entry
|
||||||
|
/// point <c>ProjectileController.TryBind</c> eventually calls
|
||||||
|
/// (<c>RuntimePhysicsState.BindProjectile</c>) — the classifier's
|
||||||
|
/// conjunctive test (A2/R1) reads exactly this state.
|
||||||
|
/// </summary>
|
||||||
|
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<CreateObject.AnimPartChange>(),
|
||||||
|
Array.Empty<CreateObject.TextureChange>(),
|
||||||
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"remote-projectile-kind",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0x09000001u,
|
||||||
|
PhysicsState: 0x408u,
|
||||||
|
InstanceSequence: instance,
|
||||||
|
MovementSequence: 1,
|
||||||
|
ServerControlSequence: 1,
|
||||||
|
PositionSequence: 1,
|
||||||
|
Physics: physics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -117,9 +117,16 @@ public sealed class RuntimeRemotePlacementDriveControllerTests
|
||||||
/// teleport branches (<c>RuntimeSetPositionOperationKind.LocalAuthoritative</c>),
|
/// teleport branches (<c>RuntimeSetPositionOperationKind.LocalAuthoritative</c>),
|
||||||
/// not only for remotes. The static predicate itself is exercised
|
/// not only for remotes. The static predicate itself is exercised
|
||||||
/// directly (no entity/body needed) since it takes only the route.
|
/// directly (no entity/body needed) since it takes only the route.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// C4 route 5 (D-P3): <c>ProjectileAuthoritative</c> is REMOVED from this
|
||||||
|
/// negative list — the widening makes it a positively-owned kind now
|
||||||
|
/// (see <see cref="OwnsPlacement_TrueForProjectileAuthoritative_SetPositionAndSetPositionSimple"/>).
|
||||||
|
/// Only the two kinds that stay excluded remain here.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative()
|
public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteOrProjectileAuthoritative()
|
||||||
{
|
{
|
||||||
// RuntimeSetPositionOperationKind is internal, so a public [Theory]
|
// RuntimeSetPositionOperationKind is internal, so a public [Theory]
|
||||||
// cannot take it as a parameter (CS0051) — iterate directly instead,
|
// cannot take it as a parameter (CS0051) — iterate directly instead,
|
||||||
|
|
@ -130,7 +137,6 @@ public sealed class RuntimeRemotePlacementDriveControllerTests
|
||||||
{
|
{
|
||||||
RuntimeSetPositionOperationKind.InitialLogin,
|
RuntimeSetPositionOperationKind.InitialLogin,
|
||||||
RuntimeSetPositionOperationKind.LocalAuthoritative,
|
RuntimeSetPositionOperationKind.LocalAuthoritative,
|
||||||
RuntimeSetPositionOperationKind.ProjectileAuthoritative,
|
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||||
|
|
@ -2256,6 +2262,845 @@ public sealed class RuntimeRemotePlacementDriveControllerTests
|
||||||
AssertConverged(lifetime);
|
AssertConverged(lifetime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── C4 route 5: projectile arm (D-P2/D-P3/D-P4/D-P5) ───────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// D-P3: the widening itself, isolated from any entity/body — mirrors
|
||||||
|
/// <see cref="OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative"/>'s
|
||||||
|
/// shape but for the positive case.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>RemoteMotion</c>/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).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>D-P2's far row, mirroring the teleport commit's assertions.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A4 fix (review round): the spatial+hidden branch — the entity stays
|
||||||
|
/// <c>InWorld</c> (retail keeps a Hidden object as a retained live
|
||||||
|
/// <c>CPhysicsObj</c>, not a leave-world) but its shadow row is
|
||||||
|
/// suspended, not published at the resolved pose.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A4 fix (review round): the non-spatial branch — a record that never
|
||||||
|
/// became a spatial root (e.g. still pending a landblock) is left
|
||||||
|
/// <c>InWorld = false</c>, its <c>Active</c> transient flag cleared, and
|
||||||
|
/// its shadow suspended.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Uses the STORE (<c>Refused</c>) 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A6 fix (review round): the re-entry activation edge. A projectile
|
||||||
|
/// that had left the world (suspended, <c>InWorld = false</c>) and comes
|
||||||
|
/// back through a committed accepted Position must be re-flagged
|
||||||
|
/// <c>Active</c> and have its legacy <c>LastUpdateTime</c> rebased — the
|
||||||
|
/// exact branch that reading <c>body.InWorld</c> AFTER the placement
|
||||||
|
/// (instead of capturing it before) made permanently dead.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// D3's <c>Refused</c> 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
|
||||||
|
/// <c>StoreAcceptedDestinationPose</c> (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).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// D-P4's pinned no-op pair: <c>Interpolate</c> (near) and
|
||||||
|
/// <c>NoPositionOperation</c> (airborne) write nothing and do not
|
||||||
|
/// invalidate prediction — the positive fact that a straddling quantum
|
||||||
|
/// may complete over either.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// D-P4's swallow rule (trap T5): a <c>RejectedAuthority</c>/
|
||||||
|
/// <c>RejectedData</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-plan item 6 / proof obligation-11: the SAME "retryable
|
||||||
|
/// preparation" shape <see cref="Teleport_LedgerConverges_AfterDetachRouteClearsARetainedRetry"/>
|
||||||
|
/// uses, driven through the projectile arm — proves the shared
|
||||||
|
/// <c>_pending</c>/<c>_awaitingAcknowledgement</c> ledgers converge for a
|
||||||
|
/// projectile operation with no new code (trap T9: no second map).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-3 architecture review C1: <c>Advance()</c>'s projectile branch
|
||||||
|
/// (parked at round 2 / R3, reordered at round 2 / B5) had never been
|
||||||
|
/// executed by any test — all six pre-existing <c>drive.Advance()</c>
|
||||||
|
/// call sites in this file are remote-kind. This is the re-parked-
|
||||||
|
/// <c>Contention</c> half, mirroring
|
||||||
|
/// <see cref="FarSnap_RetryablePreparation_StoresThePoseAndStillRetainsTheRetry"/>
|
||||||
|
/// against a projectile instead of a remote: <c>UnusedCollisionSource</c>
|
||||||
|
/// never resolves a nonzero Setup id, so BOTH the entry-point call and
|
||||||
|
/// the retry keep returning <c>RetrySetupUnavailable</c> —
|
||||||
|
/// <c>Contention</c> — and the pending entry never drains on its own.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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 <c>Contention</c> writes
|
||||||
|
/// nothing (no store, no commit) — invalidating for it would violate
|
||||||
|
/// "the no-op dispositions invalidate nothing" on an arm that wrote
|
||||||
|
/// nothing. <see cref="RuntimeProjectile.PredictionAuthorityVersion"/>
|
||||||
|
/// captured immediately before and after <c>Advance()</c> must be equal.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-3 architecture review C1, the second half: a retained
|
||||||
|
/// projectile retry whose destination leaves the service window before
|
||||||
|
/// the next cadence pump — mirroring
|
||||||
|
/// <see cref="Advance_DestinationLeavesTheWindow_StoresTheNewestDestinationPose"/>
|
||||||
|
/// 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-<c>Contention</c> branch above)
|
||||||
|
/// because it runs <c>StoreAcceptedDestinationPose</c> — a real body
|
||||||
|
/// write — and it is the second call site (besides the entry point) that
|
||||||
|
/// must run <see cref="RuntimeRemotePlacementDriveController"/>'s
|
||||||
|
/// <c>SyncProjectilePresentation</c>, so the shadow row must follow the
|
||||||
|
/// body here too, closing B3's remaining retry-arm gap.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-plan item 4 (trap T3): a split quantum straddling an accepted
|
||||||
|
/// far/teleport Position must abort at <c>Complete</c> rather than
|
||||||
|
/// clobber the committed placement — the scenario invisible from reading
|
||||||
|
/// the classifier alone, and the one this route's App-level predecessor
|
||||||
|
/// test (<c>AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction</c>)
|
||||||
|
/// used to cover before its Position case retired.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds one collision-table owner row on <paramref name="owner"/> via
|
||||||
|
/// a peer entity's dynamic shadow + one reported collision — the same
|
||||||
|
/// mechanism <c>RuntimeCollisionReportingStateTests</c> uses, reduced to
|
||||||
|
/// the minimum this file's proof obligation P5 needs.
|
||||||
|
/// </summary>
|
||||||
|
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 ──────────────────────────────────────────────────────────
|
// ── Fixture ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue