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.
|
||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||
|
||||
## #317 — `TryCommitAuthoritativeVelocity`'s call site has no established retail basis
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (tracking only; no known observable defect)
|
||||
**Filed:** 2026-08-04, C4 route 5 (projectile authoritative placement) round-2
|
||||
review, MINOR (c)
|
||||
**Component:** physics / remote velocity
|
||||
|
||||
**Description:** `LiveEntityNetworkUpdateController.cs`'s 4a-family remote
|
||||
velocity commit (`_liveEntities.TryCommitAuthoritativeVelocity(...)`, call
|
||||
site around line 2444) carried a comment claiming
|
||||
"`MoveOrTeleport` installs that exact vector with `set_velocity`." A
|
||||
byte-level Capstone disassembly of the PDB-paired retail binary
|
||||
(`CPhysicsObj::MoveOrTeleport` @0x00516330-@0x00516438, every branch,
|
||||
performed as C4 route 5's mandatory Step 1 hard gate) shows
|
||||
`MoveOrTeleport` never reads its velocity argument's stack slot at all, and
|
||||
`UnpackPositionEvent` performs no `set_velocity` either — the only
|
||||
`set_velocity` call in the whole accepted-Position chain zeroes the LOCAL
|
||||
player (@0x004541B4), which is a different call site entirely. The comment
|
||||
at the 4a call site has been corrected in place to state this plainly, but
|
||||
the call itself was left in production unchanged (out of C4 route 5's
|
||||
scope — the route's contract governs `RuntimeSetPositionOperationKind`
|
||||
placement dispatch, not the pre-existing remote velocity commit) and
|
||||
nothing currently tracks auditing or removing it.
|
||||
|
||||
**Root cause:** unverified assumption inherited from an earlier port pass;
|
||||
never checked against the named retail decomp until C4 route 5's Step 1
|
||||
gate incidentally required decoding the neighboring function.
|
||||
|
||||
**Files:** `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs`
|
||||
(call site + corrected comment, ~line 2420); `LiveEntityRuntime`'s
|
||||
`TryCommitAuthoritativeVelocity` (the method itself, unaudited).
|
||||
|
||||
**Acceptance:** A dedicated retail audit of the ENTIRE accepted-Position
|
||||
velocity chain (not just `MoveOrTeleport`) — likely `UpdateObjectInternal`,
|
||||
`update_object`, and whatever actually feeds the remote's `Velocity` field
|
||||
retail-side — determines whether this call has a retail basis at all, and
|
||||
either finds the correct source function to cite or removes the call as an
|
||||
acdream-only adaptation with a divergence-register row.
|
||||
|
||||
## C4 route 6 — drops and split-recovery closure — 2026-08-04
|
||||
|
||||
#313 filed from the route 6 closure session (zero production lines; evidence +
|
||||
|
|
|
|||
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 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>
|
||||
/// #315: the two per-packet delegates cached ONCE (constructed here,
|
||||
/// reused for every accepted remote Position) rather than allocated
|
||||
|
|
@ -113,10 +128,22 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
internal readonly Func<bool> IsCurrentPositionOwner;
|
||||
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)
|
||||
{
|
||||
IsCurrentPositionOwner = owner.IsCurrentRemoteArmPositionOwner;
|
||||
RunTeleportHook = owner.RunCachedRemoteTeleportHook;
|
||||
IsCurrentProjectilePositionOwner =
|
||||
owner.IsCurrentProjectileArmPositionOwner;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1478,6 +1505,25 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
&& (_remoteArmExpectedEntity is null
|
||||
|| 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>
|
||||
/// #315: the cached backing method for
|
||||
/// <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);
|
||||
_movementTruthDiagnostics.OnServerEcho(update, worldPos);
|
||||
|
||||
// Missiles reconcile the same predicted PhysicsBody in place. The
|
||||
// timestamp gate above already rejected stale corrections; returning
|
||||
// here prevents the generic remote locomotion path from allocating a
|
||||
// second body or interpolation owner for the projectile.
|
||||
if (_projectileController?.ApplyAuthoritativePosition(
|
||||
acceptedPositionRecord,
|
||||
acceptedPositionAuthorityVersion,
|
||||
acceptedPositionVelocityAuthorityVersion,
|
||||
worldPos,
|
||||
new System.Numerics.Vector3(
|
||||
p.PositionX,
|
||||
p.PositionY,
|
||||
p.PositionZ),
|
||||
rot,
|
||||
acceptedSpawn.Physics?.Velocity
|
||||
?? System.Numerics.Vector3.Zero,
|
||||
p.LandblockId,
|
||||
_physicsScriptGameTime,
|
||||
_origin.CenterX,
|
||||
_origin.CenterY) == true)
|
||||
// C4 route 5 (D-P1/D-P6, REVISED after the review round — A2/R1,
|
||||
// A9): classify ONCE, kind-aware. This single call now decides both
|
||||
// the remote route (unchanged for a non-missile packet — see the
|
||||
// reuse below) AND whether this packet is a missile packet,
|
||||
// replacing the former ApplyAuthoritativePosition short-circuit.
|
||||
// The null-classification arm's test is the SAME conjunctive
|
||||
// predicate the classifier itself applies
|
||||
// (RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition,
|
||||
// D-P1) — Missile bit AND a bound RuntimeProjectile whose Body is
|
||||
// the canonical PhysicsBody, never the bit alone, so an unbindable
|
||||
// or not-yet-bound missile takes the ordinary remote tail exactly
|
||||
// as it did before this route (the deleted method's TryGetCurrent
|
||||
// fall-through) — and explicitly fenced off the local player (A9):
|
||||
// update.Guid == _playerServerGuid always takes the null branch
|
||||
// below, and ACE never sets Missile on a player, but the fence
|
||||
// makes that structurally true rather than incidentally true.
|
||||
RuntimeAuthoritativePositionRoute? earlyRemoteRoute =
|
||||
update.Guid != _playerServerGuid
|
||||
? ClassifyRemoteAcceptedPosition(
|
||||
update,
|
||||
acceptedPositionCanonical,
|
||||
timestampDisposition,
|
||||
timestamps,
|
||||
worldPos)
|
||||
: null;
|
||||
bool isMissilePacket = earlyRemoteRoute is { } classifiedRoute
|
||||
? classifiedRoute.OperationKind
|
||||
is RuntimeSetPositionOperationKind.ProjectileAuthoritative
|
||||
: update.Guid != _playerServerGuid
|
||||
&& (acceptedPositionCanonical.FinalPhysicsState
|
||||
& AcDream.Core.Physics.PhysicsStateFlags.Missile) != 0
|
||||
&& acceptedPositionCanonical.Projectile is { } boundProjectile
|
||||
&& ReferenceEquals(
|
||||
acceptedPositionCanonical.PhysicsBody,
|
||||
boundProjectile.Body);
|
||||
if (isMissilePacket)
|
||||
{
|
||||
// The projectile arm over the canonical Runtime placement owner
|
||||
// (RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition)
|
||||
// — never the generic remote locomotion path below, which would
|
||||
// allocate a second body or interpolation owner for the
|
||||
// projectile. A null classification (login-window shape) or a
|
||||
// RejectedAuthority/RejectedData disposition is swallowed:
|
||||
// nothing to route, the shared authority gate above already
|
||||
// rejected an invalid payload.
|
||||
if (earlyRemoteRoute is { } route)
|
||||
{
|
||||
// A3/R2 fix: retail's teleport_hook @0x00514ED0 runs five
|
||||
// manager-guarded actions BEFORE the placement, in addition
|
||||
// to the collision force-end the Runtime seam performs on
|
||||
// its own (action 6). For a bare arrow/bolt (no RemoteMotion
|
||||
// adopted) all five are structurally absent no-ops through
|
||||
// retail's own per-manager guards — matching what the
|
||||
// Runtime seam already does unaided. For the ADOPTED-BODY
|
||||
// case (TryBind's shared-body branch: an ordinary remote
|
||||
// whose Missile bit was set by a later State packet, so it
|
||||
// still carries a live RemoteMotion with a populated Interp
|
||||
// queue and possibly an armed ConstrainTo leash) those five
|
||||
// actions are LIVE and must run — using the SAME ordered
|
||||
// hook seam and per-packet currency check the remote
|
||||
// teleport arm already uses (RunRemoteTeleportHook, #315
|
||||
// pattern), so retail's per-manager guards decide for
|
||||
// themselves rather than being re-derived here.
|
||||
if (route.Disposition
|
||||
is RuntimeAuthoritativePositionDisposition.SetPosition
|
||||
&& acceptedPositionCanonical.RemoteMotion is RemoteMotion adoptedRemote)
|
||||
{
|
||||
// B4 fix (round-2 review): cache the currency-check
|
||||
// delegate the same way the remote arm's #315 collapse
|
||||
// already does, instead of allocating a fresh closure
|
||||
// every accepted missile packet. Scratch fields stamped
|
||||
// immediately before use; nothing reads them between
|
||||
// calls, so last-packet staleness is harmless.
|
||||
_projectileArmPositionRecord = acceptedPositionRecord;
|
||||
_projectileArmPositionAuthorityVersion =
|
||||
acceptedPositionAuthorityVersion;
|
||||
RunRemoteTeleportHook(
|
||||
acceptedPositionCanonical,
|
||||
adoptedRemote,
|
||||
_remoteArmCallbacks.IsCurrentProjectilePositionOwner);
|
||||
}
|
||||
|
||||
RuntimeRemotePlacementExecutionStatus? placementStatus =
|
||||
_remotePlacementDrive.ApplyAcceptedProjectilePosition(
|
||||
acceptedPositionCanonical,
|
||||
route);
|
||||
// A1/R5 fix: mirror Runtime's OWN presentation gate
|
||||
// (ApplyAcceptedProjectilePosition/SyncProjectilePresentation
|
||||
// — every outcome except Deferred/RejectedByPlacement) rather
|
||||
// than acknowledging unconditionally. Deferred already
|
||||
// snapped the body to the PARKED result and withdrew the
|
||||
// entity; RejectedByPlacement leaves the body exactly where
|
||||
// it was. Acknowledging either would move the render entity
|
||||
// to (or through) a pose/cell Runtime explicitly declined to
|
||||
// publish — the concrete Interpolate/RejectedByPlacement
|
||||
// wrong-cell scenario the review found.
|
||||
if (placementStatus is not null
|
||||
and not RuntimeRemotePlacementExecutionStatus.Deferred
|
||||
and not RuntimeRemotePlacementExecutionStatus.RejectedByPlacement)
|
||||
{
|
||||
_projectileController?.SyncPresentationFromResolvedBody(
|
||||
acceptedPositionRecord,
|
||||
_physicsScriptGameTime);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_liveEntities.TryGetRecord(
|
||||
update.Guid,
|
||||
|
|
@ -2136,26 +2268,18 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
return;
|
||||
}
|
||||
|
||||
// C4 route 4a: classify BEFORE the generic write below so a remote
|
||||
// whose accepted Position resolves to NoPositionOperation (retail's
|
||||
// airborne no-op — writes nothing at all) or Interpolate (retail's
|
||||
// near InterpolateTo queue — no direct body write here) never
|
||||
// receives it. The local player never reaches this generic-remote
|
||||
// code path at all. C4 route 4b-2 routes the >=96 m far snap and C4
|
||||
// route 4b-3 routes the teleport/cell-less classification through
|
||||
// the canonical Runtime placement owner (ApplyRemoteContactRouting);
|
||||
// a rejected authority or payload, and "no classification at all",
|
||||
// take the stated UnroutedCatchUp policy
|
||||
// (RuntimeRemoteFarSnapPosition.ResolveArm).
|
||||
RuntimeAuthoritativePositionRoute? earlyRemoteRoute =
|
||||
update.Guid != _playerServerGuid
|
||||
? ClassifyRemoteAcceptedPosition(
|
||||
update,
|
||||
acceptedPositionCanonical,
|
||||
timestampDisposition,
|
||||
timestamps,
|
||||
worldPos)
|
||||
: null;
|
||||
// C4 route 4a/5: `earlyRemoteRoute` was already classified above
|
||||
// (D-P6) — this is the SAME value, reused so a remote whose accepted
|
||||
// Position resolves to NoPositionOperation (retail's airborne
|
||||
// no-op — writes nothing at all) or Interpolate (retail's near
|
||||
// InterpolateTo queue — no direct body write here) never receives
|
||||
// the generic write below. The local player never reaches this
|
||||
// generic-remote code path at all. C4 route 4b-2 routes the >=96 m
|
||||
// far snap and C4 route 4b-3 routes the teleport/cell-less
|
||||
// classification through the canonical Runtime placement owner
|
||||
// (ApplyRemoteContactRouting); a rejected authority or payload, and
|
||||
// "no classification at all", take the stated UnroutedCatchUp
|
||||
// policy (RuntimeRemoteFarSnapPosition.ResolveArm).
|
||||
|
||||
TryApplyGenericRemoteRenderPose(
|
||||
entity,
|
||||
|
|
|
|||
|
|
@ -490,103 +490,84 @@ internal sealed class ProjectileController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a timestamp-gated server correction to the same predicted body.
|
||||
/// The caller supplies both render-world and wire cell-local coordinates so
|
||||
/// streaming origin changes never leak into the canonical cell frame.
|
||||
/// C4 route 5 (D-P2 closing paragraph): the presentation acknowledgement
|
||||
/// App still owns after
|
||||
/// <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>
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
internal bool SyncPresentationFromResolvedBody(
|
||||
LiveEntityRecord expectedRecord,
|
||||
Vector3 worldPosition,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
Vector3 velocity,
|
||||
uint fullCellId,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY) =>
|
||||
ApplyAuthoritativePosition(
|
||||
expectedRecord,
|
||||
expectedRecord.PositionAuthorityVersion,
|
||||
expectedRecord.VelocityAuthorityVersion,
|
||||
worldPosition,
|
||||
cellLocalPosition,
|
||||
orientation,
|
||||
velocity,
|
||||
fullCellId,
|
||||
currentTime,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedPositionAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
Vector3 worldPosition,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
Vector3 velocity,
|
||||
uint fullCellId,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
double currentTime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
uint serverGuid = expectedRecord.ServerGuid;
|
||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out RuntimeProjectile runtime)
|
||||
if (!TryGetCurrent(
|
||||
expectedRecord.ServerGuid,
|
||||
out LiveEntityRecord record,
|
||||
out RuntimeProjectile runtime)
|
||||
|| !ReferenceEquals(record, expectedRecord)
|
||||
|| record.PositionAuthorityVersion != expectedPositionAuthorityVersion
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
|
||||
|| record.WorldEntity is not { } entity)
|
||||
return false;
|
||||
|
||||
if (!double.IsFinite(currentTime)
|
||||
|| !IsFinite(worldPosition)
|
||||
|| !IsFinite(cellLocalPosition)
|
||||
|| !IsFinite(velocity)
|
||||
|| !PositionFrameValidation.IsValid(
|
||||
fullCellId,
|
||||
cellLocalPosition,
|
||||
orientation))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Rejected invalid PositionUpdate for missile 0x{serverGuid:X8}.");
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
bool ExternalOwnerValid() =>
|
||||
TryGetCurrent(
|
||||
serverGuid,
|
||||
out LiveEntityRecord current,
|
||||
out RuntimeProjectile currentRuntime)
|
||||
&& ReferenceEquals(current, record)
|
||||
&& ReferenceEquals(currentRuntime, runtime)
|
||||
&& ReferenceEquals(current.WorldEntity, entity)
|
||||
&& current.PositionAuthorityVersion
|
||||
== expectedPositionAuthorityVersion;
|
||||
return _runtimeUpdater.ApplyAuthoritativePosition(
|
||||
record.Canonical,
|
||||
expectedPositionAuthorityVersion,
|
||||
expectedVelocityAuthorityVersion,
|
||||
worldPosition,
|
||||
cellLocalPosition,
|
||||
orientation,
|
||||
velocity,
|
||||
fullCellId,
|
||||
currentTime,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
snapshot =>
|
||||
{
|
||||
if (!ExternalOwnerValid())
|
||||
return false;
|
||||
entity.SetPosition(snapshot.Position);
|
||||
entity.Rotation = snapshot.Orientation;
|
||||
entity.ParentCellId = snapshot.FullCellId;
|
||||
_rootPoses?.UpdateRoot(entity);
|
||||
return ExternalOwnerValid();
|
||||
},
|
||||
ExternalOwnerValid);
|
||||
if (double.IsFinite(currentTime))
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
entity.SetPosition(runtime.Body.Position);
|
||||
entity.Rotation = runtime.Body.Orientation;
|
||||
entity.ParentCellId = record.FullCellId;
|
||||
_rootPoses?.UpdateRoot(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -589,14 +589,38 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4a: classifies one REMOTE incarnation's accepted Position
|
||||
/// through <see cref="RuntimeAuthoritativePositionRouteClassifier"/>, so
|
||||
/// the graphical host and any future no-window remote-motion host make
|
||||
/// C4 route 4a: classifies one non-local-player incarnation's accepted
|
||||
/// Position through <see cref="RuntimeAuthoritativePositionRouteClassifier"/>,
|
||||
/// so the graphical host and any future no-window remote-motion host make
|
||||
/// the SAME airborne-no-op / near-interpolate decision from the same
|
||||
/// generation, the same authority shape, and the same request builder the
|
||||
/// deferred initial-create continuation uses.
|
||||
///
|
||||
/// <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
|
||||
/// made: the lifetime has no bound generation yet, the canonical record
|
||||
/// has not claimed a local id, or there is no live local-player position
|
||||
|
|
@ -635,11 +659,32 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
{
|
||||
return null;
|
||||
}
|
||||
// C4 route 5 (D-P1, revised — A2/R1): the Missile bit alone is
|
||||
// data-driven but not sufficient — retail places every non-player
|
||||
// object regardless of what client-side machinery exists for it, so
|
||||
// this packet must classify Projectile ONLY when the arm can
|
||||
// actually own the placement (a bound RuntimeProjectile whose Body
|
||||
// is the exact canonical PhysicsBody). Anything else — no
|
||||
// component bound (TryBind refused or has not run yet), or a
|
||||
// component bound to a stale/displaced body — classifies Remote,
|
||||
// matching retail's unconditional placement and the pre-route-5
|
||||
// fall-through the deleted ApplyAuthoritativePosition performed via
|
||||
// its own TryGetCurrent failure. A mid-life Missile flip (ACE clears
|
||||
// it on impact; a State packet installs it, and TryBind subsequently
|
||||
// binds the component) is handled by construction: whichever
|
||||
// FinalPhysicsState AND binding state the canonical record carries
|
||||
// for THIS packet decides the packet's kind.
|
||||
RuntimePositionEntityKind kind =
|
||||
(canonical.FinalPhysicsState & PhysicsStateFlags.Missile) != 0
|
||||
&& canonical.Projectile is RuntimeProjectile boundProjectile
|
||||
&& ReferenceEquals(canonical.PhysicsBody, boundProjectile.Body)
|
||||
? RuntimePositionEntityKind.Projectile
|
||||
: RuntimePositionEntityKind.Remote;
|
||||
if (!RuntimeAcceptedPositionRouteRequests.TryBuild(
|
||||
generation(),
|
||||
canonical,
|
||||
update,
|
||||
RuntimePositionEntityKind.Remote,
|
||||
kind,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
disposition,
|
||||
timestamps.PreviousTeleport,
|
||||
|
|
|
|||
|
|
@ -298,130 +298,15 @@ internal sealed class RuntimeProjectilePhysicsUpdater
|
|||
return true;
|
||||
}
|
||||
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
RuntimeEntityRecord record,
|
||||
ulong expectedPositionAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
Vector3 worldPosition,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
Vector3 velocity,
|
||||
uint fullCellId,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
Func<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;
|
||||
}
|
||||
// C4 route 5 (2026-08-04): the position-packet authority that used to
|
||||
// live here — ApplyAuthoritativePosition — is deleted. A live missile's
|
||||
// accepted Position now routes through the canonical
|
||||
// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
|
||||
// the same shared placement pipeline (TryExecuteAcceptedRemotePosition /
|
||||
// StoreAcceptedDestinationPose / CommitCanonical) the remote teleport/far
|
||||
// arms use, instead of this class's bespoke SnapToCell + CommitProjectileCell
|
||||
// + manual shadow-sync tail. CommitProjectileCell remains the per-quantum
|
||||
// path's own cell commit (TryBegin/Complete below) — untouched.
|
||||
|
||||
private bool IsSpatialCurrent(
|
||||
RuntimeEntityRecord record,
|
||||
|
|
@ -481,18 +366,6 @@ internal sealed class RuntimeProjectilePhysicsUpdater
|
|||
0f);
|
||||
}
|
||||
|
||||
private static void Activate(PhysicsBody body, double currentTime)
|
||||
{
|
||||
if ((body.State & PhysicsStateFlags.Static) != 0)
|
||||
return;
|
||||
if ((body.TransientState & TransientStateFlags.Active) == 0)
|
||||
body.LastUpdateTime = currentTime;
|
||||
body.TransientState |= TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
private static void Deactivate(PhysicsBody body) =>
|
||||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
|
||||
private static bool IsFinite(Vector3 value) =>
|
||||
float.IsFinite(value.X)
|
||||
&& float.IsFinite(value.Y)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,20 @@ internal static class RuntimeRemoteFarSnapPosition
|
|||
/// <c>arg3 != 0</c>), so this predicate is a strict narrowing of
|
||||
/// <c>OwnsPlacement</c> to its far half.
|
||||
/// </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>
|
||||
internal static bool OwnsFarSnap(RuntimeAuthoritativePositionRoute? route) =>
|
||||
route is
|
||||
|
|
|
|||
|
|
@ -516,9 +516,25 @@ internal sealed class RuntimeRemotePlacementDriveController
|
|||
/// POSITION-only route; the first-entry conductor owns every Create)
|
||||
/// without excluding either remote Position shape.
|
||||
/// </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>
|
||||
internal static bool OwnsPlacement(RuntimeAuthoritativePositionRoute route) =>
|
||||
route.OperationKind is RuntimeSetPositionOperationKind.RemoteAuthoritative
|
||||
or RuntimeSetPositionOperationKind.ProjectileAuthoritative
|
||||
&& route.Disposition is RuntimeAuthoritativePositionDisposition.SetPosition
|
||||
or RuntimeAuthoritativePositionDisposition.SetPositionSimple
|
||||
&& (route.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0;
|
||||
|
|
@ -842,6 +858,234 @@ internal sealed class RuntimeRemotePlacementDriveController
|
|||
return status;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Retail <c>CPhysicsObj::store_position</c> @0x00515CE2, reached from
|
||||
/// <c>SetPositionInternal</c>'s no-resolvable-cell branch @0x00515C1D.
|
||||
|
|
@ -975,6 +1219,29 @@ internal sealed class RuntimeRemotePlacementDriveController
|
|||
}
|
||||
_pending.Remove(key);
|
||||
|
||||
// R3 fix (review round): a retained retry can belong to a
|
||||
// projectile operation exactly as it can belong to a
|
||||
// remote's — this is the SAME shared _pending map (trap T9:
|
||||
// no second map), and pending.Route carries the OperationKind
|
||||
// that was classified when the retry was first parked.
|
||||
// Neither invariant this route pins (prediction invalidated
|
||||
// before every body write; presentation advances on every
|
||||
// committed/stored outcome) may hold on the direct arm only.
|
||||
// Real nullable locals (not a stored bool) so the compiler
|
||||
// can track definite assignment through the branches below.
|
||||
RuntimeProjectile? pendingProjectile = null;
|
||||
PhysicsBody? pendingBody = null;
|
||||
if (pending.Route.OperationKind
|
||||
is RuntimeSetPositionOperationKind.ProjectileAuthoritative
|
||||
&& pending.Record.Projectile is RuntimeProjectile candidateProjectile
|
||||
&& pending.Record.PhysicsBody is { } candidateBody
|
||||
&& ReferenceEquals(candidateBody, candidateProjectile.Body))
|
||||
{
|
||||
pendingProjectile = candidateProjectile;
|
||||
pendingBody = candidateBody;
|
||||
}
|
||||
bool pendingWasInWorld = pendingBody?.InWorld ?? false;
|
||||
|
||||
// B3 review fix: a retry can sit retained across many host
|
||||
// cadence pumps (bounded only by how long the asset stayed
|
||||
// unavailable) while its destination's collision publication
|
||||
|
|
@ -1000,17 +1267,55 @@ internal sealed class RuntimeRemotePlacementDriveController
|
|||
CreateObject.ServerPosition? destination =
|
||||
pending.Record.Snapshot.Physics?.Position
|
||||
?? pending.Record.Snapshot.Position;
|
||||
RuntimeRemotePlacementExecutionStatus retryStatus;
|
||||
if (destination is not { } accepted
|
||||
|| !CanAttemptDestination(
|
||||
setPosition,
|
||||
accepted.LandblockId))
|
||||
{
|
||||
CancelToken(setPosition, pending.Token);
|
||||
pendingProjectile?.InvalidatePrediction();
|
||||
StoreAcceptedDestinationPose(pending.Record);
|
||||
continue;
|
||||
retryStatus = RuntimeRemotePlacementExecutionStatus.Refused;
|
||||
}
|
||||
else
|
||||
{
|
||||
// B5 fix (round-2 review): invalidating BEFORE this call
|
||||
// unconditionally was wrong when SubmitAndResolve itself
|
||||
// re-parks (returns Contention) — that outcome writes
|
||||
// NOTHING (no store, no commit), so invalidating for it
|
||||
// violates invariant 4's "the no-op dispositions
|
||||
// invalidate nothing" on this arm specifically (unlike
|
||||
// the entry point, where StoresAcceptedDestination()
|
||||
// treats Contention as a storing outcome via the
|
||||
// caller's own StoreAcceptedDestinationPose — this retry
|
||||
// arm does not store on a re-parked Contention, matching
|
||||
// the pre-existing residual A3/round-1 already named).
|
||||
// Invalidating AFTER the call instead of before is safe
|
||||
// here: this method is single-threaded and synchronous,
|
||||
// so a write performed inside SubmitAndResolve and the
|
||||
// very next statement's invalidate are never observably
|
||||
// separated by a quantum's Complete call.
|
||||
retryStatus = SubmitAndResolve(
|
||||
pending.Record, pending.Token, pending.Route);
|
||||
if (retryStatus
|
||||
is not RuntimeRemotePlacementExecutionStatus.Contention)
|
||||
{
|
||||
pendingProjectile?.InvalidatePrediction();
|
||||
}
|
||||
}
|
||||
|
||||
_ = SubmitAndResolve(pending.Record, pending.Token, pending.Route);
|
||||
if (pendingProjectile is { } confirmedProjectile
|
||||
&& pendingBody is { } confirmedBody
|
||||
&& retryStatus is not RuntimeRemotePlacementExecutionStatus.Deferred
|
||||
and not RuntimeRemotePlacementExecutionStatus.RejectedByPlacement)
|
||||
{
|
||||
SyncProjectilePresentation(
|
||||
pending.Record,
|
||||
confirmedProjectile,
|
||||
confirmedBody,
|
||||
pendingWasInWorld);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -500,6 +500,434 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
// both, because there is one call site left to sabotage. Reverted before
|
||||
// committing.
|
||||
|
||||
// ── C4 route 5 (A5 fix): missile matrix ─────────────────────────────
|
||||
//
|
||||
// The architecture review's FAIL-level finding: zero App tests drove
|
||||
// OnPosition with a missile packet, so A1 (App discards the seam's
|
||||
// status) and A2 (an unbound missile's Position silently dropped) both
|
||||
// lived in the ~35 lines of "thin glue" the implementer argued were
|
||||
// covered by two well-tested layers. These tests extend THIS file's own
|
||||
// fixture — never a lighter one — with a genuine Missile-flagged,
|
||||
// RuntimeProjectile-bound, RemoteMotion-less incarnation, and assert the
|
||||
// SAME observable surface the remote scenarios above assert: body/entity
|
||||
// position and ParentCellId (that pair is the exact A1 assertion), plus
|
||||
// the projectile-specific half — no RemoteMotion is EVER created
|
||||
// (invariant 8's mutual exclusion) and no early wire-pose write occurs.
|
||||
// Destinations for the commit scenarios are airborne (well above
|
||||
// PublishDestinationCollision's flat terrain), isolating the placement
|
||||
// assertions from the shared pipeline's ordinary ground-contact response
|
||||
// (Claim 4's confound, restated here for the App layer).
|
||||
|
||||
private const uint MissileGuid = 0x80007101u;
|
||||
private static readonly Vector3 MissileAirborneDestination =
|
||||
new(12f, 14f, SpawnHeight + 10f);
|
||||
|
||||
[Fact]
|
||||
public void MissileTeleportCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.ServiceWindow.Allow(DestinationLandblock);
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
MissileAirborneDestination, DestinationCell, teleportSequence: 5,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
Assert.Null(canonical.RemoteMotion);
|
||||
Assert.NotNull(canonical.Projectile);
|
||||
PhysicsBody body = canonical.PhysicsBody!;
|
||||
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
|
||||
Assert.Equal(resolved, body.Position);
|
||||
Assert.Equal(body.Position, fixture.Entity.Position);
|
||||
// A1's own assertion: ParentCellId agrees with the RESOLVED body's
|
||||
// OWN cell — the review's concrete wrong-cell scenario, checked
|
||||
// positively here rather than only on the no-op scenarios below.
|
||||
Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId);
|
||||
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
||||
// D-P4: the collision table was force-ended (no seeded owner here to
|
||||
// assert a 1->0 transition — that half is covered at
|
||||
// tests/AcDream.Runtime.Tests — but the call must not throw or
|
||||
// leave the entity uncollidable long-term; Tick below proves it is
|
||||
// still a live, ordinary object).
|
||||
ShadowEntry shadowEntry = Assert.Single(
|
||||
fixture.Shadows.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == fixture.Entity.Id);
|
||||
Assert.Equal(body.Position, shadowEntry.Position);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.ServiceWindow.Allow(DestinationLandblock);
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
Assert.Null(canonical.RemoteMotion);
|
||||
PhysicsBody body = canonical.PhysicsBody!;
|
||||
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
|
||||
Assert.Equal(resolved, body.Position);
|
||||
Assert.Equal(body.Position, fixture.Entity.Position);
|
||||
Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId);
|
||||
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
internal RuntimeEntityObjectLifetime Lifetime { get; }
|
||||
|
|
@ -510,6 +938,13 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
internal WorldEntity Entity { get; }
|
||||
internal RemoteMotion Remote { get; private set; } = null!;
|
||||
internal LiveEntityAnimationState? Animated { get; private set; }
|
||||
/// <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 uint _guid;
|
||||
private readonly bool _nullClassification;
|
||||
|
|
@ -517,7 +952,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
internal Fixture(
|
||||
uint guid,
|
||||
bool nullClassification = false,
|
||||
bool withAnimation = false)
|
||||
bool withAnimation = false,
|
||||
bool isMissile = false)
|
||||
{
|
||||
_guid = guid;
|
||||
_nullClassification = nullClassification;
|
||||
|
|
@ -559,8 +995,16 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
ForcePosition: 1,
|
||||
ObjDesc: 1,
|
||||
Instance: 1);
|
||||
// C4 route 5 (A5 fix): a missile fixture carries the Missile bit
|
||||
// from spawn — the SAME data-driven bit
|
||||
// RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition
|
||||
// (D-P1) reads, so this fixture's record classifies exactly like
|
||||
// a real missile would once TryBind/BindProjectile below binds
|
||||
// the component.
|
||||
PhysicsStateFlags baseState = PhysicsStateFlags.ReportCollisions
|
||||
| (isMissile ? PhysicsStateFlags.Missile : PhysicsStateFlags.None);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
RawState: (uint)baseState,
|
||||
Position: wirePosition,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
|
|
@ -593,7 +1037,7 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
PhysicsState: (uint)baseState,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 1,
|
||||
MovementSequence: 1,
|
||||
|
|
@ -606,24 +1050,65 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
"fixture failed to materialize the remote entity");
|
||||
Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell));
|
||||
|
||||
var remote = new RemoteMotion();
|
||||
remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
|
||||
remote.CellId = SourceCell;
|
||||
Runtime.SetRemoteMotionRuntime(_guid, remote);
|
||||
Remote = remote;
|
||||
Shadows.Register(
|
||||
Entity.Id,
|
||||
0x02000001u,
|
||||
Entity.Position,
|
||||
Entity.Rotation,
|
||||
radius: 0.48f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: SourceLandblock,
|
||||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: 1.835f,
|
||||
seedCellId: SourceCell,
|
||||
isStatic: false);
|
||||
// C4 route 5 (A5 fix): a missile fixture binds a
|
||||
// RuntimeProjectile directly through the SAME production Runtime
|
||||
// entry point ProjectileController.TryBind eventually calls
|
||||
// (RuntimePhysicsState.BindProjectile) — never a RemoteMotion.
|
||||
// This is the exact shape invariant 8's mutual exclusion pins:
|
||||
// Missile-set means a projectile arm and NO RemoteMotion ever
|
||||
// exists for the entity.
|
||||
if (isMissile)
|
||||
{
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = Entity.Position,
|
||||
Orientation = Entity.Rotation,
|
||||
LastUpdateTime = 1d,
|
||||
State = baseState,
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
|
||||
RuntimeEntityRecord canonical = record.Canonical!;
|
||||
Lifetime.Entities.SetPhysicsBody(canonical, body);
|
||||
canonical.ObjectClock.Activate();
|
||||
Lifetime.Physics.AcknowledgeSpatialProjection(canonical, spatial: true);
|
||||
Projectile = (RuntimeProjectile)Lifetime.Physics.BindProjectile(
|
||||
canonical, body, new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
|
||||
Shadows.Register(
|
||||
Entity.Id,
|
||||
0x02000001u,
|
||||
Entity.Position,
|
||||
Entity.Rotation,
|
||||
radius: 0.1f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: SourceLandblock,
|
||||
collisionType: ShadowCollisionType.Sphere,
|
||||
state: (uint)baseState,
|
||||
seedCellId: SourceCell,
|
||||
isStatic: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var remote = new RemoteMotion();
|
||||
remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
|
||||
remote.CellId = SourceCell;
|
||||
Runtime.SetRemoteMotionRuntime(_guid, remote);
|
||||
Remote = remote;
|
||||
Shadows.Register(
|
||||
Entity.Id,
|
||||
0x02000001u,
|
||||
Entity.Position,
|
||||
Entity.Rotation,
|
||||
radius: 0.48f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: SourceLandblock,
|
||||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: 1.835f,
|
||||
seedCellId: SourceCell,
|
||||
isStatic: false);
|
||||
}
|
||||
|
||||
var origin = new LiveWorldOriginState();
|
||||
origin.SetPlaceholder(
|
||||
|
|
|
|||
|
|
@ -164,16 +164,21 @@ public sealed class ProjectileControllerTests
|
|||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
|
||||
IRuntimeProjectile runtime = record.ProjectileRuntime!;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(1f, 2f, 3f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// C4 route 5: accepted-Position placement now routes through
|
||||
// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition
|
||||
// (Runtime-owned, tested at tests/AcDream.Runtime.Tests). This test is
|
||||
// about Tick/hydration behaviour for a pending destination, not about
|
||||
// the placement seam itself, so the destination is written directly —
|
||||
// mirroring what a committed placement leaves on the canonical body —
|
||||
// and the presentation ack uses the SAME production method the seam's
|
||||
// caller uses (SyncPresentationFromResolvedBody).
|
||||
runtime.Body.SnapToCell(
|
||||
CellB,
|
||||
new Vector3(10f, 202f, 5f),
|
||||
new Vector3(10f, 10f, 5f));
|
||||
runtime.Body.Orientation = Quaternion.Identity;
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellB));
|
||||
Assert.True(fixture.Controller.SyncPresentationFromResolvedBody(record, 2.1));
|
||||
|
||||
Assert.False(record.IsSpatiallyVisible);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
|
|
@ -365,8 +370,16 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshVectorAndPositionCorrectionsMutateSameBody()
|
||||
public void FreshVectorCorrectionsMutateTheCanonicalBody()
|
||||
{
|
||||
// C4 route 5: the Position half of this test (a correction commits a
|
||||
// velocity too) is RETIRED by design (D-P5) — a byte-level decode of
|
||||
// retail's MoveOrTeleport (@0x00516330-@0x00516438) confirms it never
|
||||
// reads its velocity argument, and RuntimeRemotePlacementDriveController
|
||||
// .ApplyAcceptedProjectilePosition commits no velocity from a Position
|
||||
// packet. The no-velocity invariant (a Position correction leaves an
|
||||
// in-flight body's velocity bit-identical) is exercised at
|
||||
// tests/AcDream.Runtime.Tests, the seam's own layer, not here.
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 3);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 4.0));
|
||||
|
|
@ -379,43 +392,20 @@ public sealed class ProjectileControllerTests
|
|||
currentTime: 4.5));
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity);
|
||||
Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega);
|
||||
|
||||
var correction = new Vector3(30f, 31f, 32f);
|
||||
var correctedVelocity = new Vector3(3f, 4f, 5f);
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 5.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.Same(body, record.PhysicsBody);
|
||||
Assert.Equal(correction, body.Position);
|
||||
Assert.Equal(correction, record.WorldEntity!.Position);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
|
||||
// InboundPhysicsStateController normalizes an absent PositionPack
|
||||
// velocity to zero before this contract is called.
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 5.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
// C4 route 5: the AuthoritativeMutation.Position case of this theory is
|
||||
// RETIRED — accepted-Position corrections no longer route through
|
||||
// ProjectileController at all (RuntimeRemotePlacementDriveController
|
||||
// .ApplyAcceptedProjectilePosition owns them). The successor —
|
||||
// "a split quantum straddling an accepted far/teleport Position aborts
|
||||
// Complete rather than clobbering the committed placement" — is a
|
||||
// Runtime-level test (trap T3) at tests/AcDream.Runtime.Tests, exercised
|
||||
// directly against RuntimeProjectilePhysicsUpdater.TryBegin/Complete and
|
||||
// the new seam, since that pairing is what now shares the prediction
|
||||
// version this scenario discards.
|
||||
[Theory]
|
||||
[InlineData(AuthoritativeMutation.Vector)]
|
||||
[InlineData(AuthoritativeMutation.Position)]
|
||||
[InlineData(AuthoritativeMutation.State)]
|
||||
public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction(
|
||||
AuthoritativeMutation mutation)
|
||||
|
|
@ -429,7 +419,6 @@ public sealed class ProjectileControllerTests
|
|||
quantum: 0.05f,
|
||||
out ProjectileController.QuantumStep step));
|
||||
|
||||
Vector3 correctedPosition = new(30f, 31f, 32f);
|
||||
Vector3 correctedVelocity = new(7f, 8f, 9f);
|
||||
Vector3 correctedOmega = new(0f, 0f, 2f);
|
||||
PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity;
|
||||
|
|
@ -443,19 +432,6 @@ public sealed class ProjectileControllerTests
|
|||
currentTime: 1.02));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correctedPosition,
|
||||
correctedPosition,
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 1.02,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.State:
|
||||
record.FinalPhysicsState = correctedState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
|
|
@ -477,10 +453,6 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
Assert.Equal(correctedOmega, body.Omega);
|
||||
break;
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.Equal(correctedPosition, body.Position);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
break;
|
||||
case AuthoritativeMutation.State:
|
||||
Assert.Equal(correctedState, body.State);
|
||||
break;
|
||||
|
|
@ -856,14 +828,21 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedFreshUpdates_DoNotPoisonCanonicalBodyOrPose()
|
||||
public void MalformedFreshVectorUpdate_DoesNotPoisonCanonicalBody()
|
||||
{
|
||||
// C4 route 5: the Position half of this test moved. A malformed
|
||||
// accepted-Position payload for a missile is rejected further
|
||||
// upstream now — the shared CanAcceptPositionPayload gate
|
||||
// (unchanged, its own tests still cover it directly) runs
|
||||
// unconditionally in OnPosition BEFORE the D-P6 dispatch even
|
||||
// decides this is a missile packet, so the malformed-payload swallow
|
||||
// is exercised at that layer (LiveEntityNetworkUpdateController's own
|
||||
// "invalid-payload swallow" test), not here.
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
Vector3 velocity = body.Velocity;
|
||||
Vector3 position = body.Position;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
record,
|
||||
|
|
@ -871,19 +850,6 @@ public sealed class ProjectileControllerTests
|
|||
Vector3.Zero,
|
||||
currentTime: 2.0));
|
||||
Assert.Equal(velocity, body.Velocity);
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(position, body.Position);
|
||||
Assert.Equal(position, record.WorldEntity!.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1333,16 +1299,33 @@ public sealed class ProjectileControllerTests
|
|||
accepted.Position!.Value.LandblockId,
|
||||
_ => throw new InvalidOperationException("re-entry recreated the entity")));
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
worldPosition: new Vector3(12f, 10f, 5f),
|
||||
cellLocalPosition: new Vector3(12f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: Vector3.Zero,
|
||||
fullCellId: CellA,
|
||||
currentTime: 10.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// C4 route 5: the placement itself is Runtime-owned now
|
||||
// (RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
|
||||
// tested at tests/AcDream.Runtime.Tests). This test is about the
|
||||
// shadow/clock re-entry after a pickup+leave-world, so the resolved
|
||||
// destination is written directly and the presentation ack reuses
|
||||
// the same production method the seam's caller uses.
|
||||
// ApplyAuthoritativeState (unchanged) rebases the controller's own
|
||||
// `_lastFiniteGameTime` to the packet's receipt time exactly like the
|
||||
// deleted ApplyAuthoritativePosition's `currentTime` parameter used
|
||||
// to — it is a same-value state re-assert (no functional State
|
||||
// change), used here purely as the clock anchor.
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
record, record.FinalPhysicsState, currentTime: 10.0, 1, 1));
|
||||
record.PhysicsBody!.SnapToCell(
|
||||
CellA,
|
||||
new Vector3(12f, 10f, 5f),
|
||||
new Vector3(12f, 10f, 5f));
|
||||
record.PhysicsBody.Orientation = Quaternion.Identity;
|
||||
// D-P5: a Position packet no longer commits velocity (the retired
|
||||
// behaviour this test used to lean on via the deleted method's
|
||||
// `velocity: Vector3.Zero` argument). The Vector channel is a
|
||||
// separate concern (untouched by route 5); stop the body directly so
|
||||
// this test's clock/shadow assertions are not confounded by ordinary
|
||||
// straight-line motion.
|
||||
record.PhysicsBody.set_velocity(Vector3.Zero);
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellA));
|
||||
Assert.True(fixture.Controller.SyncPresentationFromResolvedBody(record, 10.0));
|
||||
// The incarnation-stable RetailObjectQuantumClock is canonical after
|
||||
// the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute
|
||||
// clock mirror and need not equal the packet receipt time once the
|
||||
|
|
@ -1431,8 +1414,20 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public void AuthoritativePosition_ReentrantGuidReuseStopsOldPostRebucketWork()
|
||||
public void SyncPresentation_ReentrantGuidReuseNeverTouchesTheReplacement()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord first = fixture.Spawn(instance: 7);
|
||||
|
|
@ -1450,22 +1445,28 @@ public sealed class ProjectileControllerTests
|
|||
replacement = fixture.Spawn(instance: 8);
|
||||
};
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
first,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(44f, 0f, 0f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// The trigger: a landblock unload is the SAME visibility-loss edge
|
||||
// LandblockUnload_SuspendsProjectileAtVisibilityEdgeWithoutFrameScan
|
||||
// uses. RebucketLiveEntity is deliberately NOT the trigger here — it
|
||||
// suppresses ProjectionVisibilityChanged for its OWN guid mid-call
|
||||
// (LiveEntityRuntime.OnSpatialVisibilityChanged's `_rebucketingGuid`
|
||||
// guard), which is exactly why the real placement seam's reentrant
|
||||
// hazard is observable at the SPATIAL edge, not through a caller's
|
||||
// own rebucket.
|
||||
fixture.Spatial.RemoveLandblock(0x0101FFFFu);
|
||||
Assert.True(replaced);
|
||||
|
||||
// The stale ack for the SUPERSEDED incarnation must refuse.
|
||||
Assert.False(fixture.Controller.SyncPresentationFromResolvedBody(first, 1.1));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
Assert.Null(current.PhysicsBody);
|
||||
// The replacement's own spawn position (Fixture.Spawn's default),
|
||||
// untouched by the stale ack — the positive half of the assertion,
|
||||
// not merely "the ack returned false".
|
||||
Assert.Equal(new Vector3(10f, 10f, 5f), current.WorldEntity!.Position);
|
||||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
|
@ -1580,10 +1581,11 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
// C4 route 5: Position is retired from this enum — see the comment on
|
||||
// AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction.
|
||||
public enum AuthoritativeMutation
|
||||
{
|
||||
Vector,
|
||||
Position,
|
||||
State,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
/// not only for remotes. The static predicate itself is exercised
|
||||
/// 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>
|
||||
[Fact]
|
||||
public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative()
|
||||
public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteOrProjectileAuthoritative()
|
||||
{
|
||||
// RuntimeSetPositionOperationKind is internal, so a public [Theory]
|
||||
// cannot take it as a parameter (CS0051) — iterate directly instead,
|
||||
|
|
@ -130,7 +137,6 @@ public sealed class RuntimeRemotePlacementDriveControllerTests
|
|||
{
|
||||
RuntimeSetPositionOperationKind.InitialLogin,
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative,
|
||||
RuntimeSetPositionOperationKind.ProjectileAuthoritative,
|
||||
})
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
|
|
@ -2256,6 +2262,845 @@ public sealed class RuntimeRemotePlacementDriveControllerTests
|
|||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
// ── C4 route 5: projectile arm (D-P2/D-P3/D-P4/D-P5) ───────────────────
|
||||
|
||||
/// <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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue