fix(physics): classify before merge on every steady-state Position (C5b, #275, AP-131/AD-60)
The steady-state accepted-Position merge did two things retail never does,
on every single Position packet: it installed the wire placement frame and
unparented unconditionally, and it derived the record's FullCellId from
bare wire acceptance. Both are now correct, and they land together - a
half-flipped intermediate (classified flags with the wire stamp, or vice
versa) is exactly the mixed-residency state this campaign keeps paying for.
WHY the flags need no route. SmartBox::HandleReceivedPosition @0x00453FD0
decides both pre-placement writes BEFORE MoveOrTeleport is consulted: Gate A
@0x0045400C returns @0x0045409D ahead of unset_parent @0x00454129 and ahead
of the HasAnims SetPlacementFrame gate @0x00454137. Neither gate reads the
near/far/teleport classification. So the two flags are a pure function of
(disposition, hasAnimations) and are computable inside the merge, pre-merge,
with no signature change, no route construction and no playerDistance - the
scoping's ~150-400-line route-plumbing estimate over-counted because it did
not see this. That truth table IS
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition's own
ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows; the classifier
stays the oracle and the equality is pinned by test, not by a shared path,
so each computation remains separately sabotage-verifiable.
WHY the cell is withheld. HandleReceivedPosition reads the wire objcell_id
into a LOCAL @0x00453FE3 and hands it only to BlipPlayer / TeleportPlayer /
MoveOrTeleport / ConstrainTo; it never assigns the object's cell. The
object's cell moves inside the placement family (SetPositionInternal
@0x00515BD0 to set_cell, enter_world) or per-frame transit, and nowhere
else. The continuation executor has encoded that rule since the executor
slice; this caller now matches it verbatim.
WHAT DELIBERATELY SURVIVES. Two steady-state wire-cell writers stay,
downstream of the merge and outside the classification window: the
OnPosition prologue rebucket (W2, into CommitRebucket), which is also the
local player's own cell-freshness path, and the post-routing wire-cell adopt
for non-placing arms (W3, AP-135). Gating W2 "for symmetry" would freeze the
player's canonical cell between teleports and #319's child-cell equality
would inherit the freeze. AD-60's rewrite names both so the retirement
cannot be misread as "wire acceptance never changes residency anywhere".
REGISTER. AP-131 RETIRED - the unconditional literals no longer exist; the
caller was corrected, not deleted, so the row's own "deleted at the
production cutover" framing is overtaken. AD-60's legacy half RETIRED and
the row REWRITTEN rather than deleted, naming W2/W3 (route 4b-3's D8
precedent: a silent whole-row deletion would hide surviving channels).
AP-130 amended - the merge consumes the same static HasAnimations proxy,
deliberately not escalated to a live animation-queue read. AP-146 and #320
amended - their "accepted inbound Position (RefreshSnapshot into
RuntimeEntityRecord.cs:234)" local-player cell writer is now the generic
tail's CommitRebucket, and a ForcePosition (which returns before that tail)
is placement-receipt-authoritative. #275 closed.
HEADLINE BEHAVIOURAL DELTA, stated once: a refused or contended local
ForcePosition now leaves FullCellId at the last committed cell where the
merge used to stamp the refused packet's wire cell. Retail cannot refuse
(AD-62) and its body keeps its last placed cell, so the new shape is the
retail-reachable one.
THREE CONSUMER SITES THE CONTRACT'S BLAST-RADIUS SURVEY MISSED, all
D2-caused, all found by the suite rather than by reading, all intended
semantics rather than regressions (recorded in the contract's new section
14):
(1) DatLiveEntityProjectionMaterializer's self-projection branch reads
FullCellId inside OnPosition's prologue recovery, ahead of W2. It now
correctly declines to project from an unplaced wire claim; production
installs the bucket at W2 in the same call (verified: no return between
the recovery call and W2 is conditioned on IsSpatiallyProjected or
FullCellId). Two hydration tests asserted the bucket at the recovery
boundary and now drive the production W2 step - the same shape as trap
T2, one layer up.
(2) ProjectileController.SyncPresentationFromResolvedBody writes
ParentCellId = record.FullCellId. On a refused missile placement that is
now the committed source cell. The MAJOR-1 invariant is unchanged and is
now asserted as the identity it always meant rather than as a wire-cell
constant.
(3) The merge's Rebucketed ternary does NOT become always-Updated as the
contract predicted, and is deliberately kept: the
Forget(restoreCancelledPark: true) above it can roll a wakeable
lost-cell park back, and RestoreParkWithdrawal restores canonical
residency. That is a real cell edge produced inside this method by a
placement owner.
TEST-COUNT RECONCILIATION. Baseline measured at this HEAD by stashing the
change: Runtime.Tests 1176, App.Tests 4135 (4132 passed / 3 skipped),
solution 11,106 passed / 4 skipped - matching the recorded figure at
6921a027 exactly. Post-change: Runtime.Tests 1195, App.Tests 4135 unchanged,
solution 11,125 passed / 4 skipped / 0 failed. Net +19, entirely new Runtime
tests: 3 facts plus a 12-row matrix theory in
InboundPhysicsStateControllerTests, 1 fact plus a 2-row theory in the new
RuntimeSteadyStatePositionMergeTests, and 1 fact in
RuntimeAcceptedPositionDriveControllerTests. No test was deleted; five
existing tests were rewritten in place, never delete-only. No new skip; none
of #302/#308/#321 appeared.
SABOTAGE VERIFICATIONS (each new discriminating test, both directions;
production line broken, suite run, line restored):
installPlacementFrame (!force && !hasAnimations) to (!force)
5 fail: ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame plus
the 4 animated non-force matrix rows.
installPlacementFrame to false
6 fail: ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame,
PositionPlacementAbsentAndPresentZeroBothApplyRetailZero plus the 4
non-animated non-force matrix rows.
clearParent (!force) to true
3 fail: ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment
plus the 2 force+parented matrix rows.
clearParent (!force) to false
4 fail: the 4 Apply+parented matrix rows.
refreshPosition false to acceptedPosition
4 fail: AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary,
ContendedForcePosition_WritesNoResidencyAnywhere,
ReentrantNewerPositionDuringPickupDiscardSuppressesStalePickupDelta,
MissileFarRefused_...ParentCellIdAgreesWithCommittedCell. Confirmed a
second time by the baseline measurement above, where the withhold test
was the sole red.
CommitRebucket publishes Updated instead of Rebucketed
2 fail: both parent classes of
CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation.
RuntimeEntityDirectory.SetFullCell drops PropagateFullCellToChildren
2 fail: the same two rows.
T4 respected: the ForcePosition placement-frame half is inert
(appliedPlacement keeps old.PlacementId under either flag value), so the
force row's discriminating assertion is parent retention, never the frame.
NOT DONE, deliberately: the executor is still not wired into the
steady-state path (#275's alternative branch); W2/W3 are untouched; no probe
added or stripped; AP-130's proxy not escalated; no while-here unification
of the two merge callsites. No automated OnPosition-level test drives the
full pickup / drop / reproject sequence (no fixture covers pickup at that
layer); the contract's connected gate recipe item 1 is the positive evidence
for it and has NOT been run - this commit is not connected-gated.
Contract: docs/research/2026-08-05-c5b-contract.md (committed here, with its
section 14 implementation outcome appended).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0257844106
commit
735f0a72af
11 changed files with 1715 additions and 63 deletions
|
|
@ -35,9 +35,11 @@ question is real and unresolved
|
|||
**Description.** Retail writes the local player's cell on EVERY physics tick
|
||||
(`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional). acdream's
|
||||
canonical `FullCellId` for the LOCAL player is written only at three edges:
|
||||
login activation (`RuntimeSetPositionState.cs:2741-2745`), an accepted inbound
|
||||
Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot` →
|
||||
`RuntimeEntityRecord.cs:234`), and a teleport/portal placement commit
|
||||
login activation (`RuntimeSetPositionState.cs:2741-2745`), the `OnPosition`
|
||||
generic tail's prologue rebucket after an accepted inbound Position
|
||||
(`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity`
|
||||
→ `RuntimeEntityObjectLifetime.CommitRebucket`), and a teleport/portal
|
||||
placement commit
|
||||
(`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`).
|
||||
Ordinary WASD movement passes a LANDBLOCK id, not an exact cell
|
||||
(`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in
|
||||
|
|
@ -47,6 +49,18 @@ and mostly-frozen between teleports — see the register row this issue's fix
|
|||
commit files (AP-146) for the full citation and the argument that this is
|
||||
currently safe for every EXISTING consumer.
|
||||
|
||||
**Amended 2026-08-05 by C5b (#275).** The second edge above used to be the
|
||||
accepted-Position MERGE itself (`RuntimeEntityDirectory.RefreshSnapshot` →
|
||||
`RuntimeEntityRecord.cs:234`). C5b made that merge withhold the wire cell
|
||||
(AD-60), so the writer is now the `OnPosition` prologue rebucket's
|
||||
`CommitRebucket` — one step later in the same call, same value. Nothing about
|
||||
this issue's substance changes: the coarse landblock-preserve branch at
|
||||
`LiveEntityRuntime.cs:935-938` is unchanged and is still what makes the
|
||||
player's cell mostly-frozen. One shape DID change and belongs to this issue's
|
||||
survey: a local **ForcePosition** returns before that tail, so its residency
|
||||
is now placement-receipt-authoritative — a refused or contended force writes
|
||||
no cell at all (retail's own shape; AD-62).
|
||||
|
||||
**Why this is not #319's blast radius.** #319's fix makes a player-parented
|
||||
equipped child inherit the parent's (the player's) canonical cell EXACTLY —
|
||||
an equality invariant, not a freshness one. The child is stale-but-equal
|
||||
|
|
@ -1648,7 +1662,8 @@ Copy this block when adding a new issue:
|
|||
|
||||
## #275 — Unify the legacy Position wire path onto the executor's route classifier
|
||||
|
||||
**Status:** OPEN — post-cutover (physics campaign, filed 2026-08-02)
|
||||
**Status:** CLOSED 2026-08-05 (C5b) — behaviour unified; the remaining
|
||||
structural item is tracked below, not by this issue
|
||||
**Severity:** LOW (internal refactor debt; not a retail divergence)
|
||||
**Component:** Runtime / inbound Position
|
||||
|
||||
|
|
@ -1663,6 +1678,26 @@ in, unify the legacy caller onto the same classifier (or delete it with the
|
|||
route) and retire AP-131/AD-60's legacy halves. See
|
||||
`InboundPhysicsStateController.TryApplyPosition` remarks.
|
||||
|
||||
**Resolution (C5b, 2026-08-05, `docs/research/2026-08-05-c5b-contract.md`).**
|
||||
The steady-state merge was CORRECTED rather than deleted — it is still the only
|
||||
production Position wire caller, and the issue's alternative branch ("or delete
|
||||
it with the route") was not taken. Two behaviour changes landed atomically:
|
||||
`TryApplyPosition` now computes `installPlacementFrame`/`clearParent` pre-merge
|
||||
from `(disposition, hasAnimations(old))`, which is exactly the classifier's own
|
||||
two rows because retail decides both writes ahead of `MoveOrTeleport`; and the
|
||||
merge stops deriving `FullCellId` from bare wire acceptance
|
||||
(`refreshPosition: false`). AP-131 retired, AD-60's legacy half retired and
|
||||
its row rewritten to name the surviving wire-cell channels (W2 the prologue
|
||||
rebucket, W3 the post-routing adopt).
|
||||
|
||||
**What deliberately remains, and is NOT this issue.** The two computations are
|
||||
still separate small pure expressions in two places rather than one shared code
|
||||
path — they are pinned equal by
|
||||
`InboundPhysicsStateControllerTests.MergedPrePlacementFieldsMatchTheClassifiedRouteFlags`,
|
||||
which uses the production classifier as the oracle. Wiring the continuation
|
||||
executor into the steady-state path is a separate structural decision that no
|
||||
longer has any behavioural motivation behind it.
|
||||
|
||||
---
|
||||
|
||||
## #274 — Restricted/barred-house entry needs a connected retail comparison
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
781
docs/research/2026-08-05-c5b-contract.md
Normal file
781
docs/research/2026-08-05-c5b-contract.md
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
# C5b — classify-before-merge on every steady-state Position (#275): pinned contract (2026-08-05)
|
||||
|
||||
Written at HEAD `02578441` (branch `claude/acdream-physics-divergence-5aa784`;
|
||||
C5a landed at `6921a027`, #319 at `392c1e22`, AP-145/#318 at `f8e55ba5`).
|
||||
Every symbol below was re-verified **by symbol at this HEAD**, not inherited;
|
||||
§12 lists what the C5 scoping (`2026-08-05-c5-scoping.md`, written at
|
||||
`52175aa1`) got wrong. Inputs: the scoping's C5b section, register rows
|
||||
AP-131 / AD-60 / AP-130 / AP-135 / AP-146, the 4b-3 contract
|
||||
(`2026-08-04-c4-route-4b-3-contract.md` — D1 is C5b's direct ancestor), the
|
||||
C4 closeout handoff's seven process findings, the C5a contract
|
||||
(`2026-08-05-c5a-contract.md`), and issue #275.
|
||||
|
||||
**One-paragraph verdict:** C5b is smaller than the scoping feared, because of
|
||||
a fact the scoping did not state: the merge's two flags
|
||||
(`installPlacementFrame` / `clearParent`) are a pure function of the
|
||||
**timestamp disposition** and the **hasAnimations proxy** — both available
|
||||
inside the merge itself, pre-merge, with no routing outcome and no
|
||||
`playerDistance`. Retail runs `unset_parent` and the `SetPlacementFrame` gate
|
||||
BEFORE `MoveOrTeleport` is consulted, so the flags are upstream of
|
||||
classification proper. C5b therefore needs **no route plumbing, no signature
|
||||
changes, no App→Runtime threading** — it replaces two `true` literals with a
|
||||
four-row truth table the classifier already encodes, and flips one
|
||||
`refreshPosition:` argument to `false`. The blast radius is correspondingly
|
||||
narrower than "45+ sites mis-reading a withheld wire cell", for a second
|
||||
reason the scoping missed entirely: **the merge stamp is one of THREE
|
||||
steady-state wire-cell writers, and the other two stay** (§5, W2/W3). What
|
||||
genuinely changes is the classification window, the refused-ForcePosition
|
||||
shape, and the (test-only) missile arm. The contract pins all of this.
|
||||
|
||||
---
|
||||
|
||||
## 1. Retail ground truth — Gate A and the pre-placement sequence, verified in `acclient_2013_pseudo_c.txt`
|
||||
|
||||
`SmartBox::HandleReceivedPosition` @0x00453FD0 (pseudo-C lines
|
||||
92896–93051). The exact order, with addresses:
|
||||
|
||||
```
|
||||
00453fe3 objcell_id = arg3->objcell_id // wire cell read into a LOCAL
|
||||
00453ff4 Frame::operator=(&var_40, &arg3->frame) // wire frame copied into local Position var_48
|
||||
0045400c GATE A: if (arg2 == player && newer_event(player, FORCE_POSITION_TS, arg9)):
|
||||
00454044 wrapped-compare update_times[4] (TELEPORT_TS) vs arg8 — teleport must NOT be newer
|
||||
00454056-68 get_heading / Frame::set_heading(&var_40) // preserve body heading
|
||||
00454074 SmartBox::BlipPlayer(this, &var_48)
|
||||
00454079 player->update_times[0] = arg7 // stamp POSITION_TS
|
||||
00454091 cmdinterp->SendPositionEvent()
|
||||
0045409d return // BEFORE unset_parent / SetPlacementFrame
|
||||
004540b7 if (!newer_event(arg2, POSITION_TS, arg7)) → return (the 004540e6 teleport-regression
|
||||
quirk stamps update_times[0] and returns @004540f6)
|
||||
004540f9-11e if (parented && parent->id != player_id): weenie->SetParentedState(0) notification
|
||||
00454129 CPhysicsObj::unset_parent(arg2) // UNCONDITIONAL on this path
|
||||
00454137 if (CPhysicsObj::HasAnims(arg2) == 0):
|
||||
00454142 CPhysicsObj::SetPlacementFrame(arg2, arg4, 1)
|
||||
0045414d if (arg2 != player): // REMOTE branch
|
||||
00454254 if (MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0):
|
||||
00454272 ConstrainTo(arg2, &arg2->m_position, …) // post-placement anchor
|
||||
0045415f else if TELEPORT_TS newer: TeleportPlayer @00454168, ConstrainTo @0045418a
|
||||
anchored at the WIRE &var_48, set_velocity(0) @004541b4
|
||||
else: ConstrainTo @004541ec; if UsePositionFromServer && arg5: InterpolateTo @0045422c
|
||||
```
|
||||
|
||||
Five facts decide C5b:
|
||||
|
||||
1. **The wire cell is never written to the object.** `arg3->objcell_id` is
|
||||
read into a local @0x00453FE3 and flows only into the local `var_48`
|
||||
Position handed to `BlipPlayer` / `TeleportPlayer` / `MoveOrTeleport` /
|
||||
`ConstrainTo`. Nowhere in this function is the object's cell assigned.
|
||||
The object's `cell` changes only inside the placement family
|
||||
(`SetPositionInternal` @0x00515BD0 → `set_cell`; `enter_world`
|
||||
@0x00516310/0x00516170) or per-frame movement transit. This is the retail
|
||||
rule AD-60's executor half already encodes ("a wire position never
|
||||
directly makes the record resident").
|
||||
2. **Gate A (@0x0045400C) is decided on data that exists before any merge:**
|
||||
the entity is the player, FORCE_POSITION_TS advanced, TELEPORT_TS equal.
|
||||
It returns @0x0045409D **before `unset_parent` @0x00454129 and before the
|
||||
`SetPlacementFrame` gate @0x00454137** — a ForcePosition never unparents
|
||||
and never installs a placement frame. acdream's exact analog exists
|
||||
upstream: `PhysicsTimestampGate.TryAcceptPositionEvent:190-203` produces
|
||||
the `ForcePosition` disposition only when `isLocalPlayer &&
|
||||
IsNewer(FORCE_POSITION_TS) && teleport == _timestamps[Teleport]` —
|
||||
retail's Gate A pair, verified at HEAD. **A remote entity can never
|
||||
receive the `ForcePosition` disposition** (`isLocalPlayer` guard at
|
||||
`:190`), so the flag truth table below needs no entity-kind term.
|
||||
3. **The two pre-placement writes are gated on exactly two facts.**
|
||||
`unset_parent` @0x00454129 runs for every accepted non-Gate-A Position
|
||||
(including packets `MoveOrTeleport` will return 0 for — retail unparents
|
||||
BEFORE learning the routing outcome). `SetPlacementFrame` @0x00454142 is
|
||||
gated on `HasAnims(arg2) == 0` alone. **Neither gate reads the
|
||||
near/far/teleport classification** — which is why acdream's merge flags
|
||||
are a function of (disposition, hasAnimations) and nothing else.
|
||||
4. **`MoveOrTeleport` @0x00516330 reads the BODY's own state at entry:**
|
||||
TELEPORT_TS @0x00516375 and `this_1->cell == 0` @0x00516386 — the
|
||||
committed cell, read before any placement — then contact `arg4`
|
||||
@0x0051638E. Route 4b-3's D1 fed the classifier the PRE-merge committed
|
||||
cell for exactly this reason; C5b's withhold (D2) makes that pre-merge
|
||||
value structurally equal to the record's `FullCellId` in the
|
||||
classification window (§4, "strengthened invariant").
|
||||
5. **`store_position` @0x00515CE2 is `SetPositionInternal`'s no-transition
|
||||
branch** (placement ran, no walkable transition → `GotoLostCell`
|
||||
@0x00515CF2), not a `MoveOrTeleport` path. A retail placement that fails
|
||||
leaves the wire pose in `m_position` and the object HIDDEN in the lost
|
||||
cell — acdream's surviving wire-cell-on-non-commit behaviour (W2, §5) is
|
||||
the visible variant of this, already filed across AP-136/AP-138/#309.
|
||||
C5b does not re-litigate it.
|
||||
|
||||
## 2. The two scoping-identified sites, re-verified at HEAD `02578441`
|
||||
|
||||
### S1 — the steady-state merge's unconditional flags
|
||||
|
||||
`InboundPhysicsStateController.TryApplyPosition`
|
||||
(`src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs:610`),
|
||||
hardcoding `installPlacementFrame: true, clearParent: true` at **`:662-663`**
|
||||
(scoping exact). Production chain at HEAD, every hop verified:
|
||||
`LiveEntityNetworkUpdateController.OnPosition:1948` →
|
||||
`LiveEntityInboundAuthorityGate.TryAcceptPosition:161`
|
||||
(`src/AcDream.App/Physics/` — the scoping gave no directory) →
|
||||
`LiveEntityRuntime.TryApplyPosition:2550` →
|
||||
`RuntimeEntityObjectLifetime.TryApplyPosition:1789` (scoping said `:1781`) →
|
||||
`RuntimeEntityDirectory.TryApplyPosition:654` → the S1 overload.
|
||||
|
||||
The route-flag-threaded sibling (`ApplyAcceptedPositionSnapshot:673`,
|
||||
flags at `:681-682`) already exists and is what the continuation executor
|
||||
uses (`RuntimeInitialCreateContinuationExecutor.cs:1983-1993`, classified
|
||||
flags at `:1991-1992`, withhold at `:1996`) — scoping §1b verified exact.
|
||||
|
||||
**What changes semantically (not "what gets deleted"):** the merged
|
||||
snapshot's `Physics.AnimationFrame` / `PlacementId` and its
|
||||
`ParentGuid` / `ParentLocation` / `Physics.Parent` stop being
|
||||
unconditionally wire-installed / wire-cleared and start obeying retail's
|
||||
two gates:
|
||||
|
||||
| disposition | installPlacementFrame | clearParent | classifier row (cite, don't restate) |
|
||||
|---|---|---|---|
|
||||
| `Rejected` | unread (timestamp-only path `:793-794`) | unread | — |
|
||||
| `ForcePosition` (local player only, teleport-equal) | **false** | **false** | `RuntimeAuthoritativePositionRouteClassifier.cs:311-333` (Gate A) |
|
||||
| `Apply` | **`!hasAnimations`** | **true** (unchanged) | classifier `:336-465` — every accepted non-force route carries `UnparentBeforeRouting: true, ApplyPlacementFrameBeforeRouting: !request.HasAnimations` |
|
||||
|
||||
`hasAnimations` is the AP-130 static proxy, computed from the PRE-merge
|
||||
snapshot `old` with the identical expression
|
||||
`RuntimeAcceptedPositionRouteRequests.cs:99-101` uses
|
||||
(`old.MotionTableId ?? old.Physics?.MotionTableId) is { } id && id != 0u`).
|
||||
`WorldSession.EntitySpawn.MotionTableId` exists (`WorldSession.cs:103`); the
|
||||
inbound controller's `_snapshots[guid]` and `canonical.Snapshot` are the
|
||||
same data pre-merge, so the two computations cannot diverge.
|
||||
|
||||
Two observability notes that decide the test plan (§8):
|
||||
|
||||
- The `ForcePosition` placement-frame half is **inert**: for
|
||||
`disposition is not Apply`, `appliedPlacement` keeps `old.PlacementId`
|
||||
under either flag value (`:815-819`). Gate A's force half discriminates
|
||||
ONLY through parent retention (`:855-857`).
|
||||
- The `Apply` placement-frame half is live exactly when
|
||||
`hasAnimations == true`: today the wire frame (`update.PlacementId ?? 0u`)
|
||||
is stamped onto an animated entity's snapshot where retail's `HasAnims`
|
||||
gate skips `SetPlacementFrame`. That is AP-131's animation snap/reset
|
||||
half.
|
||||
|
||||
### S2 — the wire-acceptance-derived `FullCellId` stamp
|
||||
|
||||
`RuntimeEntityObjectLifetime.cs:1923-1926` —
|
||||
`Entities.RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)`
|
||||
(argument at `:1926`, matching the C5a contract's corrected citation; the
|
||||
register's `:1338` and the scoping's `:1918` are both stale — cite by
|
||||
symbol). `refreshPosition: true` routes through
|
||||
`RuntimeEntityRecord.RefreshDerivedState:230-237` →
|
||||
`SetFullCell(position.LandblockId, …)` — the wire cell, stamped on bare
|
||||
acceptance, plus `PropagateFullCellToChildren`
|
||||
(`RuntimeEntityDirectory.cs:240-246`) when it changes.
|
||||
|
||||
**What changes semantically:** `refreshPosition: false`. The merge stops
|
||||
being a residency writer. In the window between the merge and the OnPosition
|
||||
prologue rebucket, `canonical.FullCellId` IS the pre-merge committed cell —
|
||||
which is exactly what 4b-3's D1 threads out-of-band today
|
||||
(`timestamps.PreMergeCommittedCellId`, `:1904-1907`). Post-D2, 4b-3's
|
||||
convention becomes a structural property of the record.
|
||||
|
||||
**The executor's analogue is unchanged and untouched**
|
||||
(`refreshPosition: false` at executor `:1996` — its CANONICAL CELL SEMANTICS
|
||||
comment at `:1966-1976` is the model for S2's new comment).
|
||||
|
||||
## 3. Design decisions — pinned, not open for redesign
|
||||
|
||||
### D1 — the merge flags become the classifier's truth table, computed pre-merge inside the merge
|
||||
|
||||
In S1's `TryApplyPosition`, replace the two literals at `:662-663` with the
|
||||
table in §2-S1, computed from `disposition` and `hasAnimations(old)`. No
|
||||
signature changes; no new parameters; no route construction in the merge;
|
||||
`playerDistance` is never needed (the full route's near/far decision is
|
||||
downstream of retail's unset_parent/SetPlacementFrame order and stays
|
||||
post-merge in `ClassifyRemoteAcceptedPosition`).
|
||||
|
||||
The post-merge classification (`LiveEntityNetworkUpdateController.cs:2167-2175`
|
||||
→ `RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition` →
|
||||
`RuntimeAcceptedPositionRouteRequests.Build`) is **untouched**: it computes
|
||||
the full route from the same retained inputs, so its
|
||||
`ApplyPlacementFrameBeforeRouting` / `UnparentBeforeRouting` fields equal
|
||||
the merge's flags by construction. The consistency is pinned by test (§8
|
||||
test 4), not by a shared code path — the two computations are small, pure,
|
||||
and separately sabotage-verifiable.
|
||||
|
||||
Doc comments that must be made true in the same commit (process rule (c)):
|
||||
the `:595-608` "Round 4 R4-15 … no HasContact or route-classification
|
||||
concept" remarks; the `:657-661` "Legacy immediate-apply reproduces EXACT
|
||||
prior behavior" comment; `ApplyAcceptedPosition`'s `:772-781` two-caller
|
||||
doc; the `:809-814` PositionPack comment's "retail skips it entirely while
|
||||
HasAnimations is true" (already true — now load-bearing for BOTH callers).
|
||||
|
||||
### D2 — the steady-state merge withholds the wire cell (`refreshPosition: false`)
|
||||
|
||||
One argument flip at S2 plus the comment rewrite. The 4b-3 D1 comment at
|
||||
`:1894-1907` is extended, not deleted: it currently explains why the
|
||||
classifier needs the threaded pre-merge value; it must add that after this
|
||||
change the post-merge record CARRIES that same value through the
|
||||
classification window, making the threading a belt-and-suspenders duplicate
|
||||
that stays (the threaded value remains the classifier's input of record —
|
||||
process finding (b), read the observable once).
|
||||
|
||||
**Two publish-side effects, both pinned (trap T3):**
|
||||
|
||||
1. The merge's publish-kind ternary (`:1948`,
|
||||
`beforeCell != canonical.FullCellId ? Rebucketed : Updated`) becomes
|
||||
always-`Updated` — the merge can no longer change the cell. The
|
||||
`Rebucketed` edge for a wire-cell-changing packet moves to the prologue
|
||||
rebucket's `CommitRebucket` (`RuntimeEntityObjectLifetime.cs:1956-1987`),
|
||||
which TODAY early-outs publish-less (`previous == fullCellId` at
|
||||
`:1973` — because the merge already stamped it) and after D2 publishes.
|
||||
**Net observable: exactly one `Rebucketed` delta per cell-changing
|
||||
accepted Position — same kind, same value, same publisher, later point in
|
||||
the same call.** A test pins this at the observable (one delta, not zero,
|
||||
not two); it does not pin the source site.
|
||||
2. `PropagateFullCellToChildren` for a parent whose accepted Position
|
||||
changes its cell moves from the merge's directory wrapper
|
||||
(`RuntimeEntityDirectory.cs:240-246`) to `CommitRebucket`'s public
|
||||
`SetFullCell` (`:355`). Same packet, same value, same single propagation.
|
||||
This is the #319 child-equality channel — a directory-level test pins
|
||||
exactly one propagation per parent cell change on the steady-state path,
|
||||
for BOTH parent classes (player `0x5…` and creature `0x8…`; process rule
|
||||
from #319 — a test population that only ever sees sequence-0 parents is
|
||||
blind to the player class).
|
||||
|
||||
### D3 — the other two wire-cell writers STAY, and the register says so
|
||||
|
||||
The scoping's premise — withholding S2 "moves residency changes onto
|
||||
placement/simulation commits only" — is **false at HEAD** (§12 item 1).
|
||||
Two more steady-state wire-cell writers exist downstream of the merge in
|
||||
`OnPosition`, and C5b keeps both:
|
||||
|
||||
- **W2 — the prologue rebucket** (`LiveEntityNetworkUpdateController.cs:2329`
|
||||
`RebucketLiveEntity(update.Guid, p.LandblockId)` →
|
||||
`LiveEntityRuntime.cs:935-945` → `CommitRebucket` → `SetFullCell(wire cell)`).
|
||||
Runs for EVERY classification reaching the generic tail — near, far,
|
||||
teleport, cell-less, unrouted, and every ordinary local-player Apply echo.
|
||||
Its other jobs (draw-bucket move, bucket-promotion recovery,
|
||||
`prepare_to_enter_world` clock edges) are not cell writes and are not in
|
||||
scope. **Do NOT gate W2 by classification (trap T5):** for the local
|
||||
player W2's echo-driven commit is the mechanism that advances the
|
||||
canonical cell across landblocks during ordinary movement (AP-146/#320
|
||||
machinery — the per-tick projection deliberately passes a landblock id and
|
||||
preserves the cell, `LiveEntityRuntime.cs:935-938`); gating it silently
|
||||
freezes the player's cell between teleports and #319's child equality
|
||||
inherits the freeze.
|
||||
- **W3 — the post-routing wire-cell adopt**
|
||||
(`TryAdoptWireCellAfterRouting`, `LiveEntityNetworkUpdateController.cs:1613-1624`):
|
||||
`RemoteMotion.CellId = wire` for every non-placing arm, writing through to
|
||||
`FullCellId` via `CommitCanonicalCell`. Already filed as retained
|
||||
bookkeeping under **AP-135**; the placing arms (far/teleport) are
|
||||
suppressed there because the placement receipt is the cell authority.
|
||||
|
||||
W2/W3's population note: packets that RETURN before `:2329` never see W2 —
|
||||
the force-local path (Committed/Deferred `:2055`, Rejected/Contention
|
||||
`:2066`) and the missile arm (`:2256`). For those, post-D2 residency is
|
||||
placement-receipt-authoritative (or, on a refused/contended force,
|
||||
unchanged at the last commit — retail's shape, since retail's
|
||||
`BlipPlayer`-era body keeps its last placed cell; AD-62 already files the
|
||||
non-commit force outcomes). This is D2's genuinely new steady-state surface
|
||||
and it is **retail-correcting**, asserted by §8 test 6.
|
||||
|
||||
### D4 — register bookkeeping, in the implementation commit
|
||||
|
||||
- **AP-131 RETIRES** (§7 evidence). Its "legacy caller is deleted at the
|
||||
production cutover" framing is overtaken: the caller is not deleted, it is
|
||||
**corrected** — the unconditional literals are replaced by the classified
|
||||
computation. The retirement commit rewrites the row's text to past tense
|
||||
with the evidence, per register rule 1 (a deviation found without a row is
|
||||
a bug twice over; a retired divergence needs its retirement recorded,
|
||||
which the C5a/AP-1 and 4b-3/AP-137 rewrites established as the house
|
||||
style).
|
||||
- **AD-60 — legacy half RETIRES, row REWRITTEN, not deleted** (4b-3's D8
|
||||
precedent: silent whole-row deletion would hide surviving wire-cell
|
||||
channels). The rewrite keeps the executor half as the standing rule,
|
||||
records the legacy half's retirement with §7's evidence, and **names the
|
||||
surviving wire-cell channels**: W2 (prologue rebucket commit — cross-cited
|
||||
into AP-146 for the local player) and W3 (already AP-135). The row's
|
||||
surviving claim is therefore precise: "a wire Position never makes a
|
||||
record resident *inside the merge or ahead of classification*; the
|
||||
post-routing controller still adopts the wire cell for non-placement
|
||||
outcomes as the bounded-residency bridge, filed at AP-135/W2."
|
||||
- **AP-130 amendment (bookkeeping, not retirement):** its Site column names
|
||||
only the executor's `hasAnimations` local; the merge now consumes the same
|
||||
static proxy. One sentence added. The proxy itself MUST NOT be "improved"
|
||||
to a live animation-queue read in this slice (trap T7).
|
||||
- **AP-146 / #320 amendment (bookkeeping):** #320's edge list cites
|
||||
"an accepted inbound Position/ForcePosition
|
||||
(`RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`)"
|
||||
as a local-player cell writer. After D2 that writer is the generic-tail
|
||||
rebucket's `CommitRebucket` (and, for ForcePosition, the placement
|
||||
receipt). Amend both in the same commit or the next reader files a
|
||||
phantom regression.
|
||||
- **AP-135 stays, its writes stay** (must-remain-true item 3).
|
||||
- **AP-1, AD-1, AP-141–AP-145, AD-61, AD-62: pinned untouchable.**
|
||||
|
||||
## 4. What must REMAIN true (process rule — the contract causes the defect)
|
||||
|
||||
For every path this slice touches, including every refusal and rejection:
|
||||
|
||||
1. **Classification reads pre-merge inputs only.** 4b-3's D1 binds in full:
|
||||
`PreMergeCommittedCellId` is threaded exactly as today
|
||||
(`RuntimeEntityObjectLifetime.cs:1904-1907`), measured by the SAME
|
||||
`TryApplyPosition` call, never re-read after the merge. D2 adds the
|
||||
structural property (the record equals it in the window) but the threaded
|
||||
value stays the classifier's input of record.
|
||||
2. **The D4 constraint-arm partition (4b-3) does not move.** One
|
||||
post-operation `ConstrainTo` site, its partition table unchanged. C5b
|
||||
touches no arming code.
|
||||
3. **AP-135's bookkeeping writes stay** — the server-cell adopt
|
||||
(`RemoteMotion.CellId`) and `LastServerPos`/`LastServerPosTime` samples
|
||||
in `LiveEntityNetworkUpdateController`, on both airborne no-op
|
||||
neighbourhoods.
|
||||
4. **Dual-parent-class test discipline.** Every new test touching parent/
|
||||
child cell propagation runs a player (`0x5…`) parent AND a creature
|
||||
(`0x8…`) parent (#319's lesson; the handoff's corrected gate-2
|
||||
criterion).
|
||||
5. **The executor path is untouched** — its classified flags, its
|
||||
`refreshPosition: false`, its replay semantics
|
||||
(`ApplyAcceptedPositionExecutionRejectedSnapshot`). Do not unify the two
|
||||
merge callsites into one helper "while here" (trap T8).
|
||||
6. **The timestamp gate is untouched**; Gate A's teleport-concurrent shape
|
||||
stays upstream (`PhysicsTimestampGate.cs:190-217`; a force with a
|
||||
teleport advance falls through to `Apply`, and
|
||||
`ValidAcceptedAuthority`'s `PreviousTeleport == AcceptedTeleport` rule
|
||||
handles the classified side).
|
||||
7. **The OnPosition routing/arms are untouched** — generic render-pose
|
||||
gate, W2, the arm dispatch, W3, the unified tail, AP-87's catch-up,
|
||||
AP-139's landing clear, AP-140's `InContact` gates.
|
||||
8. **The local player's cell-freshness path** (echo → generic tail → W2 →
|
||||
`CommitRebucket`) is unchanged. AP-146 stays accurate after its §D4
|
||||
amendment.
|
||||
9. **The initial-residence branch of `TryApplyPosition`**
|
||||
(`:1800-1860`) is untouched — it is the executor's enqueue path, already
|
||||
classified.
|
||||
10. **`Physics.ObserveLocalWorldFrame`** keeps reading the wire landblock
|
||||
directly (`:1887-1889`); it is not a `FullCellId` consumer.
|
||||
11. **No new skip** (process rule (d)) and no test weakened to make the
|
||||
withhold pass (trap T2 — the withhold assertion lives at the merge
|
||||
boundary, never at OnPosition level, where W2 legitimately re-stamps).
|
||||
|
||||
## 5. THE BLAST RADIUS — itemised by consumer class
|
||||
|
||||
Premise: S2's withhold is the only residency write C5b removes; W2/W3 stay
|
||||
(D3). So the consumers that ever read a value that is intentionally NOT the
|
||||
wire cell are (a) everything in the classification window (merge →
|
||||
`:2329`), and (b) steady state after packets that return before W2 (force
|
||||
path, missile arm). Everyone else reads the same value as today. The
|
||||
#319 lesson is applied, not assumed: a stale cell is worse than a zero cell
|
||||
**for derived/presentation copies** — but `FullCellId`'s whole semantic
|
||||
post-D2 is "the last committed cell", which is retail's `this->cell`; that
|
||||
is not staleness, it is the definition. Zero would be the lie here.
|
||||
|
||||
### Class A — placement owners (read at/after placement). Verdict: CORRECT, no gate.
|
||||
|
||||
`RuntimeSetPositionState` (pre-flight reads `:1172-1255`, commit compares
|
||||
`:2305/:2760/:5020`, parks `:3004/:3031/:3677-3697`, `:3795/:3954`,
|
||||
`:5225/:5242`), `RuntimeRemotePlacementDriveController` (`:1053-1068` —
|
||||
verified: reads `record.FullCellId` AFTER the placement commit for the
|
||||
shadow publish; the commit just wrote it), `RuntimeAcceptedPositionDriveController`
|
||||
(receipt `resolvedCell:` reads `:914-1535`). Retail's placement reads
|
||||
`this->cell` — the committed cell, never the wire cell. These get a MORE
|
||||
honest input post-D2.
|
||||
|
||||
### Class B — per-tick simulation. Verdict: CORRECT, no gate.
|
||||
|
||||
`RuntimeOrdinaryPhysicsUpdater:127` (transits FROM `record.FullCellId` —
|
||||
retail's per-frame cell transit reads `this->cell`), `RuntimePhysicsState`
|
||||
(`:958`, commit `:2145-2159`), `RuntimeProjectilePhysicsUpdater`
|
||||
(`:53-293`), the free-fall sweep's `rm.CellId != 0` gate (AP-135). The
|
||||
simulation is the retail-sanctioned residency writer between packets.
|
||||
|
||||
### Class C — classification inputs. Verdict: CORRECT — strengthened.
|
||||
|
||||
`RuntimeAcceptedPositionRouteRequests` (`:57` route-1 overload — executor
|
||||
scope, unchanged; `:641`-family drive-controller builds for the local
|
||||
player — reads the local player's committed cell, whose freshness path is
|
||||
W2, unchanged). The remote PositionEvent build is the threaded
|
||||
`PreMergeCommittedCellId` — post-D2 the record in the window equals it
|
||||
structurally (§3 D2). 4b-3's D1 moves from convention-defended to
|
||||
structure-defended.
|
||||
|
||||
### Class D — presentation/projection, event-time readers. Verdict: HARMLESS, with three named window sites.
|
||||
|
||||
Event-time readers (landblock-loaded, visibility-changed, per-tick sync) run
|
||||
outside the per-packet window, and W2 keeps steady-state values identical to
|
||||
today for every generic-tail packet:
|
||||
`RuntimePlacementPresentationSink:286` (post-commit receipts),
|
||||
`LiveRenderProjectionJournal:270-271` (falls back when 0),
|
||||
`EntityEffectController:481`, `LiveStaticAnimationResidency:25`,
|
||||
`StaticLiveRootCommitter:76`, `ArchRenderScene` (`Residency.FullCellId` —
|
||||
statics), `HeadlessLocalPlayerFrameHost:87`,
|
||||
`HeadlessRuntimePlacementProjectionSink:103` (token compare),
|
||||
`RemotePhysicsUpdater:239/:294` / `LiveEntityOrdinaryPhysicsUpdater:107`
|
||||
(simulation-snapshot → `ParentCellId` sync).
|
||||
|
||||
Named window sites (inside merge→W2, or pre-W2 returns) — verdict per site:
|
||||
|
||||
| site | reads | verdict |
|
||||
|---|---|---|
|
||||
| `LiveEntityHydrationController:592` (`?? candidate.FullCellId` fallback) | landblock-loaded event | HARMLESS — event-time, post-W2 population; the fallback is third in a `??` chain behind projection/snapshot position |
|
||||
| `LiveEntityHydrationController:1077` (`FullCellId != 0` gate) | projection recovery inside the window | VERIFY at implementation: after D2 it reads the committed cell (correct — recovery should re-place from the last commit, not from an unplaced wire claim). Pre-D2 it could read the just-stamped wire cell; that was the AP-1-shaped read this slice exists to remove |
|
||||
| `LiveEntityRuntime:1219` / `:1564` (`token.ExactCellId` compares) | hydration/migration tokens | VERIFY: token cells are create/placement-derived, not wire-derived; the comparison against the committed cell is the intended predicate |
|
||||
| `LiveEntityPresentationController:220` (`RestoreShadow`'s `FullCellId == 0` bail) | visibility-edge restore | HARMLESS/CORRECT — a cell-less body correctly restores no shadow row; the force-path population keeps its committed cell |
|
||||
|
||||
### Class E — residency/liveness predicates (the "45+ sites"). Verdict: HARMLESS, two CORRECT-AND-LOAD-BEARING.
|
||||
|
||||
- `GetRootObjectClockDisposition` (`LiveEntityRuntime:2602-2617`),
|
||||
`HasSpatialRuntimeProjection` (`:3340-3345`), the ordinary-root gates
|
||||
(`:953-956`, `:3452-3455`): tick/edge-time, read the committed cell —
|
||||
retail's `this->cell != 0` predicate shape. CORRECT; unchanged in value
|
||||
for generic-tail packets (W2), changed only for refused-force (retail's
|
||||
shape).
|
||||
- `IsAffectedCollisionResident` (`RuntimeSetPositionState:3949`) and its
|
||||
`!HasCommittedParent` companion gates, the collision-retirement sweeps
|
||||
(`:3771/:3796/:3990/:4052`): event-time (landblock retirement), read the
|
||||
committed cell. CORRECT — a retirement sweep must act on where the body
|
||||
IS, not where an unplaced wire packet claimed.
|
||||
- The initial-create residence `FullCellId != 0` refusal
|
||||
(`RuntimeInitialCreateResidenceState:583`) and executor baselines
|
||||
(`:877-1182`, executor `:1836/:2484`): pending-residence records never
|
||||
take the steady-state merge branch. UNAFFECTED.
|
||||
- 4b-3's cell-less classification input: threaded pre-merge value (Class C).
|
||||
- `RuntimeEntityDirectory:492` child propagation and
|
||||
`RuntimeEntityObjectLifetime:1546-1555` parent-attach propagation:
|
||||
write-side (D2's propagation-source move, pinned in §3 D2 item 2).
|
||||
- `ProjectileController:279/:568/:930` and the missile arm: the ONLY
|
||||
population whose residency becomes exclusively placement-receipt-driven
|
||||
post-D2 (the arm returns before W2, `:2256`). Test-only today (AP-141:
|
||||
ACE never sends a missile UpdatePosition). CORRECT — retail's projectile
|
||||
cell comes from `SetPosition`, full stop.
|
||||
|
||||
### The headline behavioural delta, stated once
|
||||
|
||||
Post-C5b, a **refused or contended local ForcePosition** (AD-62's shapes
|
||||
(iv)-(vi)) leaves `FullCellId` at the last committed cell where today the
|
||||
merge stamps the refused packet's wire cell. Retail cannot refuse (AD-62)
|
||||
and its body keeps the last placed cell — the new shape is the
|
||||
retail-reachable one. Every other steady-state observable is either
|
||||
unchanged (W2/W3) or retail-correcting (the classification window, the
|
||||
placement-authoritative force/portal/missile arms).
|
||||
|
||||
## 6. Proof obligations (must prove, not assume)
|
||||
|
||||
1. **The truth table is the classifier's.** §8 test 4 drives both the merge
|
||||
and `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`
|
||||
(via `RuntimeAcceptedPositionRouteRequests.Build`) over the packet-shape
|
||||
matrix and asserts the merge-installed
|
||||
`AnimationFrame`/`PlacementId`/`ParentGuid`/`ParentLocation`/`Physics.Parent`
|
||||
equal what the route's flags would install. Not a restated table in the
|
||||
test body — the production classifier is the oracle (process rule (e):
|
||||
no test that re-encodes the constant under test).
|
||||
2. **The withhold is at the merge boundary.** §8 test 5 asserts at
|
||||
`RuntimeEntityObjectLifetime.TryApplyPosition` level: an accepted
|
||||
Position whose wire cell differs from the committed cell leaves
|
||||
`canonical.FullCellId == beforeCell` immediately after the call. It MUST
|
||||
NOT be asserted at `OnPosition` level — W2 legitimately commits the wire
|
||||
cell there, and an OnPosition-level assertion would fail and tempt a
|
||||
weakening (trap T2).
|
||||
3. **Publish-delta conservation.** One `Rebucketed` delta per cell-changing
|
||||
accepted Position on the steady-state path (§3 D2 item 1), and one child
|
||||
propagation per parent cell change (item 2), both asserted at the
|
||||
observable, both parent classes (must-remain-true item 4).
|
||||
4. **The refused-force shape.** A ForcePosition whose drive execution
|
||||
refuses/contends writes NO residency change anywhere (§8 test 6) —
|
||||
merge, W2 (unreached), receipt (none). This is the assertion that makes
|
||||
AD-60's retirement mean something.
|
||||
5. Sabotage-verify every new discriminating test in BOTH directions
|
||||
(process rule (e)) — the canonical half and the presentation/snapshot
|
||||
half separately, per route 7's finding.
|
||||
|
||||
## 7. Retirement evidence — AP-131 and AD-60's legacy half (same commit as the change)
|
||||
|
||||
**AP-131** retires because:
|
||||
|
||||
1. The literals at `InboundPhysicsStateController.cs:662-663` no longer
|
||||
exist; the merged flags are computed by the §2-S1 truth table. A grep in
|
||||
the commit message shows no production caller passes unconditional
|
||||
`true/true` (the executor's `:1991-1992` threading is the classified
|
||||
path, already retail-exact).
|
||||
2. §8 tests 1-4 pin the new behaviour against the classifier-as-oracle,
|
||||
sabotage-verified: (a) `Apply` + animated entity → wire frame NOT
|
||||
installed (sabotage `installPlacementFrame: true` fails it); (b)
|
||||
`Apply` + non-animated → wire frame installed (sabotage `false` fails
|
||||
it); (c) `ForcePosition` + parented entity → parent fields retained
|
||||
(sabotage `clearParent: true` fails it); (d) the matrix consistency test.
|
||||
3. The row's risk column ("animated entity installs a placement frame retail
|
||||
would skip; ForcePosition unparents where Gate A never reaches
|
||||
`unset_parent`") describes code that no longer exists — the two
|
||||
production behaviours are now the classifier's own rows
|
||||
(`RuntimeAuthoritativePositionRouteClassifier.cs:311-333` and
|
||||
`:336-465`).
|
||||
|
||||
**AD-60's legacy half** retires because:
|
||||
|
||||
1. `RuntimeEntityObjectLifetime.cs:1926` reads `refreshPosition: false`;
|
||||
`RefreshDerivedState`'s `SetFullCell(position.LandblockId, …)` is
|
||||
unreachable from the steady-state merge.
|
||||
2. §8 tests 5-6 pin the withhold at the merge boundary and the refused-force
|
||||
shape.
|
||||
3. The row rewrite (§3 D4) names W2/W3 as the surviving, separately-filed
|
||||
wire-cell channels, so the retirement cannot be misread as "wire
|
||||
acceptance never changes residency anywhere" — it means "never inside
|
||||
the merge, never ahead of classification, never for a packet whose
|
||||
placement was declined", with the post-routing adoption for
|
||||
non-placement outcomes filed where it belongs (AP-135 / the W2 citation).
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
Focused Runtime tests (`tests/AcDream.Runtime.Tests`), each asserting the
|
||||
layer that broke historically (merged snapshot fields, canonical cell,
|
||||
publish deltas — never source-text pins):
|
||||
|
||||
1. `Apply`, animated remote (`MotionTableId != 0`): merged snapshot keeps
|
||||
`old.Physics.AnimationFrame` / `old.PlacementId`; pose and timestamps
|
||||
still merge. Sabotage: force `installPlacementFrame: true`.
|
||||
2. `Apply`, non-animated remote: merged snapshot installs
|
||||
`update.PlacementId ?? 0u`. Sabotage: force `installPlacementFrame: false`.
|
||||
3. `ForcePosition`, parented local player: merged snapshot retains
|
||||
`ParentGuid` / `ParentLocation` / `Physics.Parent`; heading preservation
|
||||
(`:797-807`) unchanged. Sabotage: force `clearParent: true`.
|
||||
4. Matrix consistency (proof obligation 1): {Apply, ForcePosition} ×
|
||||
{animated, not} × {parented, not} × {player `0x5…`, creature `0x8…`}
|
||||
— merge outcome == classifier route flags' outcome.
|
||||
5. Withhold at the merge boundary (proof obligation 2): wire cell ≠
|
||||
committed cell → `canonical.FullCellId` unchanged after
|
||||
`TryApplyPosition`; `Snapshot.Position` DID refresh (the pose half of
|
||||
the merge is not the withheld half); a subsequent canonical placement
|
||||
commit still changes `FullCellId`.
|
||||
6. Refused-force shape (proof obligation 4): drive a ForcePosition whose
|
||||
execution refuses (destination outside the service window) through the
|
||||
drive controller; assert no residency write from merge, receipt, or
|
||||
rebound — `FullCellId` is the pre-packet value throughout.
|
||||
7. Publish-delta conservation (proof obligation 3): one `Rebucketed` per
|
||||
cell-changing accepted Position; one child propagation per parent cell
|
||||
change; player parent AND creature parent.
|
||||
8. The existing `InboundPhysicsStateController` unit tests that assert the
|
||||
UNCONDITIONAL flags are **rewritten, never delete-only** (the scoping's
|
||||
§3 row, carried): each becomes its classified-flag counterpart.
|
||||
|
||||
## 9. Gates
|
||||
|
||||
- Focused tests above.
|
||||
- Complete Release suite: `$env:ACDREAM_PAK_PATH` set,
|
||||
`dotnet test AcDream.slnx -c Release -m:1`. **Re-measure the baseline; do
|
||||
not inherit.** Recorded figures: 11,112 passed / 4 skipped / 0 failed at
|
||||
`392c1e22` (C5a contract) and 11,106 at `6921a027` post-C5a (the tasking
|
||||
figure). C5b's net count moves only by test rewrites/additions; the
|
||||
commit message reconciles the net explicitly. Three known flakes, filed
|
||||
separately, never conflated, never chased: **#302**
|
||||
(`PortalProjectionTests.ClipToRegion_FrameOwnedStore_…`, App.Tests),
|
||||
**#308** (`NakEmissionTests.LossSoak_…`, Core.Net.Tests, full-suite load),
|
||||
**#321** (`DatSoundCacheTests` concurrent-decode-dedup, Core.Tests,
|
||||
full-suite load — filed 2026-08-05, deliberately separate from the other
|
||||
two; do NOT fold it in). If any appears, re-run and say which.
|
||||
- **Connected gate: YES — argued, not assumed.** The change is on the
|
||||
hottest wire path (every steady-state Position) and the campaign's
|
||||
standing discipline is a connected gate per live-path behaviour change.
|
||||
But the gate's pass evidence must be positive, because C5b's *improvement*
|
||||
is absence-of-signal (a placement frame NOT installed; an animation
|
||||
NOT popping — the #319/rule-(g) unfalsifiable-criterion trap).
|
||||
Recipe (two-client, Release, `ACDREAM_RETAIL_UI=1`,
|
||||
`ACDREAM_PROBE_REMOTE_TELEPORT=1`, pre-C5c so the probe still exists):
|
||||
1. **Pickup-then-drop** a ground item, several times — the positive probe
|
||||
assertion: `[remote-teleport]` lines appear with `hookRan=True
|
||||
placement=Committed`, proving the pre-merge-fed cell-less/teleport
|
||||
classification still fires and commits after the merge stopped being a
|
||||
residency writer. (Gate 4's closure at `af828a8a` established this
|
||||
recipe reaches the arm; the probe label may read `teleport-ts` for the
|
||||
short-circuit reason the handoff records — that is expected, not a
|
||||
gap.)
|
||||
2. **A walking/running creature across ≥1 landblock boundary**, observed:
|
||||
visible, smooth, correct cell, no freeze, no origin-snap — the W2
|
||||
regression watch (the one writer C5b leans on hardest).
|
||||
3. **`@teleto`/`@teleloc` a creature** (never a player character — rule
|
||||
(g)): teleport arm unchanged, leash re-armed, no rubber-band, no
|
||||
run-in-place.
|
||||
4. **Local `@teleto` + one portal recall**: the force/portal paths are
|
||||
now placement-authoritative for residency — arrival pose correct,
|
||||
leash armed, movement immediate.
|
||||
5. **An animated creature idling/fighting at UpdatePosition cadence**:
|
||||
watch for NEW animation popping (regression) — recorded as an
|
||||
observation only, explicitly NOT a pass criterion (the absence of the
|
||||
OLD pop is the improvement and is test-gated, §8 test 1).
|
||||
Graceful close per the standing rule. **No new probe flag for C5b**: the
|
||||
discriminating signals are either absence-of-signal (ungateable) or
|
||||
already carried by `[remote-teleport]`; adding a seventh temporary probe
|
||||
for one session and stripping it in C5c is churn without evidence value.
|
||||
|
||||
## 10. Size and split call
|
||||
|
||||
Calibrated against the campaign (route 3 ~418, #319 76, 4b-3 ~250-700):
|
||||
|
||||
| piece | production lines | test lines |
|
||||
|---|---|---|
|
||||
| D1 truth table + comment rewrites | ~25-40 | ~200-350 (matrix + rewrites) |
|
||||
| D2 withhold + comment + publish-edge pins | ~15-25 | ~100-250 |
|
||||
| Register rewrites + #320/AP-130 amendments | 0 (docs) | 0 |
|
||||
|
||||
**Total ~40-65 production, ~300-600 test** — well under the ~500-line split
|
||||
threshold, and dramatically under the scoping's ~150-400 estimate, because
|
||||
the scoping assumed route plumbing (it did not see that the flags are
|
||||
disposition-determined; §12 item 2). **One landing.** If the test rewrite
|
||||
balloons past ~600 lines, the split line falls between D1's flag tests and
|
||||
D2's withhold tests — but the PRODUCTION change lands atomically: D1+D2 in
|
||||
one commit is one coherent behaviour (classify-then-merge); a half-flipped
|
||||
intermediate (classified flags with the wire stamp, or vice versa) is
|
||||
exactly the mixed-residency state this campaign keeps paying for.
|
||||
|
||||
Single implementer, dual reviews (retail-conformance + architecture) per
|
||||
the campaign's discipline, both re-run on the final diff.
|
||||
|
||||
## 11. Traps (routes 6/7 style)
|
||||
|
||||
- **T1 — reading `hasAnimations` post-merge.** Compute from `old` (the
|
||||
pre-merge snapshot) inside `TryApplyPosition`. For Position packets the
|
||||
merge cannot change `MotionTableId`, so the failure mode is latent, not
|
||||
live — pin the discipline anyway, because the NEXT field added to this
|
||||
computation may not share the property.
|
||||
- **T2 — the withhold assertion at the wrong boundary.** W2 re-stamps the
|
||||
wire cell at `:2329` for every generic-tail packet, BY DESIGN. A test
|
||||
asserting "FullCellId unchanged after OnPosition" fails and invites a
|
||||
weakening or a W2 gate. The assertion lives at
|
||||
`RuntimeEntityObjectLifetime.TryApplyPosition` (§8 test 5). This is the
|
||||
slice's "weaken a test to make the withhold pass" trap.
|
||||
- **T3 — the moved publish edges.** `Rebucketed` moves from the merge's
|
||||
ternary to `CommitRebucket`; child propagation moves with it. Pin the
|
||||
observable (counts), never the source site; do not delete the coverage
|
||||
when the old assertion site goes quiet.
|
||||
- **T4 — the ForcePosition placement-frame half is inert**
|
||||
(`appliedPlacement` keeps `old.PlacementId` under either flag). The
|
||||
force-half discriminating assertion is PARENT RETENTION. Sabotaging the
|
||||
placement frame on a ForcePosition proves nothing.
|
||||
- **T5 — do not gate W2 "for symmetry".** The local player's canonical cell
|
||||
freshness runs THROUGH W2 (echo → generic tail → `CommitRebucket`;
|
||||
AP-146/#320). Gating it freezes the player's cell between teleports and
|
||||
#319's child equality inherits the freeze. W2 is also the far arm's
|
||||
destination-streaming nudge.
|
||||
- **T6 — the missile arm returns before W2** (`:2256`). Post-D2 its
|
||||
residency is placement-receipt-only; fixtures driving refused/deferred
|
||||
missile placements must not assert wire-cell residency.
|
||||
- **T7 — do not "improve" `hasAnimations`** to a live animation-queue read.
|
||||
AP-130 files the static proxy deliberately; the merge consumes the same
|
||||
proxy and the row is amended, not escalated.
|
||||
- **T8 — no while-here unification** of the two merge callsites, the
|
||||
executor's replay merge, or the timestamp gate.
|
||||
- **T9 — flake discipline.** #302/#308/#321 are three separate filed
|
||||
flakes; a full-suite red in any of them is re-run and named, never folded
|
||||
into "the flake class" and never masked (#321's filing note: no retry, no
|
||||
Skip, no delay).
|
||||
|
||||
## 12. Scoping claims found wrong or stale at HEAD `02578441`
|
||||
|
||||
1. **The scoping's core premise for the withhold is false.** §2: withholding
|
||||
S2 "moves residency changes onto placement/simulation commits only — the
|
||||
retail rule". At HEAD the steady-state path has TWO MORE wire-cell
|
||||
writers downstream of the merge (W2 `LiveEntityNetworkUpdateController.cs:2329`
|
||||
→ `CommitRebucket`; W3 `TryAdoptWireCellAfterRouting:1613`), and C5b
|
||||
keeps both (§3 D3). The retirement must be scoped to the merge boundary
|
||||
and the survivors named in the register rewrite, or AD-60's retirement is
|
||||
a shell game. This is the contract's load-bearing correction.
|
||||
2. **The size estimate over-counts.** Scoping §7: "~150-400 changed on the
|
||||
hottest inbound path". The flags are disposition+hasAnimations-determined
|
||||
(§2-S1 truth table; retail runs both gates BEFORE the `MoveOrTeleport`
|
||||
branch decision) — no route construction, no `playerDistance` threading,
|
||||
no signature changes. Re-estimate: ~40-65 production lines (§10).
|
||||
3. **Stale line citations (all re-verified):** `TryApplyPosition` at
|
||||
`RuntimeEntityObjectLifetime.cs:1789` (scoping `:1781`); the threaded
|
||||
overload's signature at `InboundPhysicsStateController.cs:673` (scoping
|
||||
`:681` — that is its flags parameter line); S2's call at
|
||||
`RuntimeEntityObjectLifetime.cs:1923-1926` (C5a's `:1926` correct; the
|
||||
register's `:1338` still stale); `LiveEntityInboundAuthorityGate` lives
|
||||
under `src/AcDream.App/Physics/` (scoping gave no directory; `:161`
|
||||
exact). Everything else in scoping §1b/§2 holds.
|
||||
4. **#320's edge list goes stale the day D2 lands** — its "accepted inbound
|
||||
Position (`RefreshSnapshot` → `RuntimeEntityRecord.cs:234`)" writer
|
||||
becomes the generic-tail rebucket's `CommitRebucket`. Amendment mandated
|
||||
in the implementation commit (§3 D4), or the next reader files a phantom
|
||||
regression.
|
||||
5. **The scoping's §5 cell-less falsification session is overtaken:** gate 4
|
||||
CLOSED at `af828a8a` (the `cause=cellless` label was a probe-artifact —
|
||||
the pickup-then-drop recipe exercised the arm all along). C5b does not
|
||||
inherit that investigation; its connected gate reuses the same recipe as
|
||||
positive evidence (§9). Route-7 thickening and #316's measurement are
|
||||
C5c-ledger items, not C5b design inputs — the implementation session
|
||||
checks the ledger for their status before starting, and does not block on
|
||||
them.
|
||||
|
||||
## 13. What C5b does NOT do
|
||||
|
||||
- Does NOT wire the continuation executor into the steady-state path
|
||||
(#275's alternative branch — the issue's "or delete it with the route";
|
||||
the steady-state merge stays a live, now-classified, production caller).
|
||||
- Does NOT gate, move, or delete W2/W3 (§3 D3).
|
||||
- Does NOT touch the OnPosition routing, arms, constraint partition,
|
||||
generic render-pose gate, or interpolation machinery.
|
||||
- Does NOT touch the timestamp gate, the executor, the initial-residence
|
||||
branch, or the projectile arm's routing.
|
||||
- Does NOT strip any probe (C5c), fix #316/#317/#320, or re-open AP-145's
|
||||
seam.
|
||||
- Does NOT add a live animation-queue read (AP-130 stands).
|
||||
|
||||
---
|
||||
|
||||
## 14. Implementation outcome (appended at landing, 2026-08-05)
|
||||
|
||||
Both design decisions landed exactly as pinned: `installPlacementFrame:
|
||||
!force && !hasAnimations, clearParent: !force` at the S1 call site, and
|
||||
`refreshPosition: false` at S2. No signature changed, no route was plumbed,
|
||||
`playerDistance` was never needed, W2/W3 were not touched. The production
|
||||
diff is 129 insertions / 38 deletions across two files, of which the
|
||||
BEHAVIOUR is **seven lines** — four computing `force`/`hasAnimations`, two
|
||||
flag arguments, one `refreshPosition` argument. Everything else in that
|
||||
delta is the §3-D1 mandated comment rewrites. Well inside §10's ~40-65
|
||||
estimate; the estimate itself was counting comments.
|
||||
|
||||
**§5's blast-radius survey missed three consumer sites.** All three are
|
||||
D2-caused, all three were found by the test suite rather than by reading, and
|
||||
all three turned out to be the intended semantics rather than regressions —
|
||||
but the survey did not enumerate them, so they are recorded here:
|
||||
|
||||
1. **`DatLiveEntityProjectionMaterializer`'s self-projection branch**
|
||||
(`src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs`, the
|
||||
`residence is AwaitRuntimePlacement && expectedCanonical.FullCellId != 0u
|
||||
&& !HasActiveInitialCreateResidence` gate). This is a Class-D window site:
|
||||
it runs inside `OnPosition`'s prologue recovery (`:1973` /
|
||||
`:2089`/`:2102`), ahead of W2. Pre-D2 it read the just-merged wire cell and
|
||||
installed the spatial bucket there; post-D2 it reads 0 for a
|
||||
withdrawn/inventory-only record and correctly declines to project from an
|
||||
unplaced wire claim. Production still installs the bucket in the SAME
|
||||
`OnPosition` call, at W2 (`:2329`) — verified by reading every `return`
|
||||
between the recovery call and W2: none is conditioned on
|
||||
`IsSpatiallyProjected` or `FullCellId`. Two
|
||||
`LiveEntityHydrationControllerTests` cases asserted the bucket at the
|
||||
recovery boundary and were extended to drive the production W2 step; this
|
||||
is the same shape as trap T2, one layer up.
|
||||
2. **`ProjectileController.SyncPresentationFromResolvedBody`**
|
||||
(`entity.ParentCellId = record.FullCellId`). Trap T6 named the missile
|
||||
arm's residency but not this presentation writer. On a REFUSED missile
|
||||
placement the entity's pose still moves to the destination (acdream's
|
||||
`StoreAcceptedDestinationPose` fallback) while `ParentCellId` now stays at
|
||||
the committed source cell. The MAJOR-1 invariant the covering test exists
|
||||
to protect (`ParentCellId == record.FullCellId != body.CellPosition
|
||||
.ObjCellId`) is unchanged and is now asserted as that identity rather than
|
||||
as a wire-cell constant.
|
||||
3. **The `Rebucketed` publish edge does NOT always move to `CommitRebucket`.**
|
||||
§3 D2 item 1 says the merge's ternary "becomes always-`Updated`". It does
|
||||
not, and the ternary is deliberately kept: the `Physics.SetPosition.Forget
|
||||
(canonical, restoreCancelledPark: true)` call a few statements earlier can
|
||||
roll a wakeable lost-cell park back, and `RestoreParkWithdrawal` restores
|
||||
canonical residency at the body's committed cell. That is a real cell edge
|
||||
produced inside `TryApplyPosition` by a placement owner and must still
|
||||
publish as `Rebucketed`. Collapsing the ternary would have silently
|
||||
downgraded it.
|
||||
|
||||
**One §5 verdict was checked and stands:** `LiveEntityHydrationController
|
||||
:1077`'s `FullCellId != 0` gate is unreachable from this change — it sits
|
||||
inside the `canonicalSpawn.Position is null` branch, and a merged
|
||||
steady-state Position always carries one.
|
||||
|
||||
**Not done, deliberately:** no automated OnPosition-level test drives the
|
||||
full pickup→drop→reproject sequence (no such fixture exists; the collapse
|
||||
matrix fixture does not cover pickup). The claim that production reprojects a
|
||||
dropped item at W2 rests on the code reading above plus the two hydration
|
||||
tests now driving recovery-then-W2 in production order. §9's connected gate
|
||||
recipe item 1 (pickup-then-drop, several times) is the positive evidence for
|
||||
it and has not been run.
|
||||
|
|
@ -593,17 +593,23 @@ public sealed class InboundPhysicsStateController
|
|||
/// position payload is rejected. This lets callers publish a freshly
|
||||
/// consumed FORCE_POSITION_TS without applying a stale pose.
|
||||
///
|
||||
/// Round 4 R4-15: this legacy immediate-apply path has no HasContact or
|
||||
/// route-classification concept at all - it merges unconditionally on
|
||||
/// the retained <see cref="PositionTimestampDisposition"/> alone. The
|
||||
/// continuation executor's <c>ApplyPositionAction</c> instead runs
|
||||
/// <c>RuntimeAuthoritativePositionRouteClassifier</c> and derives
|
||||
/// contact solely from the retained wire packet's own
|
||||
/// <c>IsGrounded</c> bit. This is internal refactor debt tracked for
|
||||
/// the eventual cutover unification (this file's <c>TryApplyPosition</c>
|
||||
/// is today's only PRODUCTION Position wire caller; the classifier-based
|
||||
/// path is test-only until a host wires the executor) - it is NOT a
|
||||
/// retail divergence and does not belong in
|
||||
/// C5b (#275): this steady-state merge no longer installs retail's two
|
||||
/// PRE-PLACEMENT writes unconditionally. Retail runs both of them BEFORE
|
||||
/// <c>MoveOrTeleport</c> is consulted, so their gates are a pure function
|
||||
/// of the timestamp disposition and the static HasAnims proxy - which is
|
||||
/// why classifying them here needs no route, no player distance and no
|
||||
/// signature change (see the truth table at the call below). The
|
||||
/// near/far/teleport routing decision proper is still downstream of this
|
||||
/// merge and still belongs to
|
||||
/// <c>RuntimeAuthoritativePositionRouteClassifier</c>, which the
|
||||
/// continuation executor's <c>ApplyPositionAction</c> runs directly and
|
||||
/// which the App-layer OnPosition tail runs post-merge for this caller;
|
||||
/// contact still comes solely from the retained wire packet's own
|
||||
/// <c>IsGrounded</c> bit on both. That remaining structural difference
|
||||
/// (two callers computing the same two flags from the same two inputs
|
||||
/// rather than sharing one code path) is internal refactor debt tracked
|
||||
/// for the eventual cutover unification - it is NOT a retail divergence
|
||||
/// and does not belong in
|
||||
/// docs/architecture/retail-divergence-register.md. See docs/ISSUES.md
|
||||
/// for the tracked follow-up.
|
||||
/// </summary>
|
||||
|
|
@ -646,6 +652,46 @@ public sealed class InboundPhysicsStateController
|
|||
teleportAdvanced: disposition is PositionTimestampDisposition.Apply
|
||||
&& advancesTeleport,
|
||||
previousTeleport: previousTeleport);
|
||||
// C5b (#275) — CLASSIFY BEFORE MERGE. Retires AP-131.
|
||||
//
|
||||
// SmartBox::HandleReceivedPosition (0x00453FD0) decides both
|
||||
// pre-placement writes before it ever consults MoveOrTeleport:
|
||||
// - GATE A @0x0045400C (the local-player FORCE_POSITION self-echo
|
||||
// shortcut) returns @0x0045409D BEFORE CPhysicsObj::unset_parent
|
||||
// @0x00454129 and before the SetPlacementFrame gate @0x00454137.
|
||||
// A ForcePosition therefore neither unparents nor installs a
|
||||
// placement frame.
|
||||
// - unset_parent @0x00454129 is UNCONDITIONAL on every other
|
||||
// accepted path: retail unparents before it learns the routing
|
||||
// outcome, so `clearParent` needs no route.
|
||||
// - SetPlacementFrame @0x00454142 is gated on
|
||||
// CPhysicsObj::HasAnims(arg2) == 0 @0x00454137 and on nothing
|
||||
// else.
|
||||
// Because neither gate reads the near/far/teleport classification,
|
||||
// the two flags are a pure function of (disposition, hasAnimations)
|
||||
// and are computable here, pre-merge, with no route plumbing:
|
||||
//
|
||||
// disposition | installPlacementFrame | clearParent
|
||||
// --------------+-----------------------+------------
|
||||
// Rejected | unread (timestamp-only branch)
|
||||
// ForcePosition | false | false
|
||||
// Apply | !hasAnimations | true
|
||||
//
|
||||
// That is exactly RuntimeAuthoritativePositionRouteClassifier's own
|
||||
// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows
|
||||
// (ClassifyAcceptedPosition: false/false on the force row, and
|
||||
// `!request.HasAnimations`/true on EVERY accepted non-force route).
|
||||
// The classifier stays the oracle; the equality of the two small pure
|
||||
// computations is pinned by test, not by a shared code path, so each
|
||||
// remains separately sabotage-verifiable.
|
||||
bool force = disposition is PositionTimestampDisposition.ForcePosition;
|
||||
// AP-130's static proxy, computed from the PRE-merge snapshot `old`
|
||||
// with the identical expression RuntimeAcceptedPositionRouteRequests
|
||||
// uses. Deliberately NOT a live animation-queue read.
|
||||
bool hasAnimations =
|
||||
(old.MotionTableId ?? old.Physics?.MotionTableId)
|
||||
is { } motionTableId
|
||||
&& motionTableId != 0u;
|
||||
accepted = ApplyAcceptedPosition(
|
||||
old,
|
||||
update,
|
||||
|
|
@ -654,13 +700,8 @@ public sealed class InboundPhysicsStateController
|
|||
isLocalPlayer,
|
||||
forcePositionRotation,
|
||||
currentLocalVelocity,
|
||||
// Legacy immediate-apply reproduces EXACT prior behavior: the
|
||||
// placement frame and parent clear were always unconditional
|
||||
// here (see the Round 3 A1/B6 admission handoff). Only the
|
||||
// continuation executor threads the classified route's own
|
||||
// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting flags.
|
||||
installPlacementFrame: true,
|
||||
clearParent: true);
|
||||
installPlacementFrame: !force && !hasAnimations,
|
||||
clearParent: !force);
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -669,7 +710,10 @@ public sealed class InboundPhysicsStateController
|
|||
/// the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>. The
|
||||
/// executor passes its classified route's own
|
||||
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
|
||||
/// flags rather than the legacy path's unconditional true/true.</summary>
|
||||
/// flags; since C5b (#275) the steady-state
|
||||
/// <see cref="TryApplyPosition"/> caller passes the same two values,
|
||||
/// derived pre-merge from (disposition, hasAnimations) rather than read
|
||||
/// off a route.</summary>
|
||||
internal bool ApplyAcceptedPositionSnapshot(
|
||||
uint guid,
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
|
|
@ -770,14 +814,16 @@ public sealed class InboundPhysicsStateController
|
|||
/// <see cref="AcceptedPhysicsTimestamps"/> captured at admission time.
|
||||
///
|
||||
/// <paramref name="installPlacementFrame"/>/<paramref name="clearParent"/>
|
||||
/// (Round 3 B6) let the two callers reproduce two different retail
|
||||
/// gates: the legacy immediate-apply path always passes true/true
|
||||
/// (retail's HandleReceivedPosition unconditionally runs
|
||||
/// unset_parent/SetPlacementFrame there), while the continuation
|
||||
/// executor passes its classified route's own
|
||||
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c> -
|
||||
/// both false only for the FORCE_POSITION branch, which retail's
|
||||
/// MoveOrTeleport returns from immediately, BEFORE either call.
|
||||
/// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Since C5b (#275)
|
||||
/// BOTH callers supply the same classified values, from the same two
|
||||
/// inputs: the continuation executor reads its classified route's
|
||||
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>,
|
||||
/// and the steady-state <see cref="TryApplyPosition"/> merge derives them
|
||||
/// pre-merge from (disposition, hasAnimations) - see the truth table
|
||||
/// there. Both are false only for the FORCE_POSITION branch, which
|
||||
/// retail's HandleReceivedPosition Gate A returns from immediately,
|
||||
/// BEFORE either call; <paramref name="installPlacementFrame"/> is
|
||||
/// additionally false whenever HasAnims is true.
|
||||
/// </summary>
|
||||
internal static WorldSession.EntitySpawn ApplyAcceptedPosition(
|
||||
WorldSession.EntitySpawn old,
|
||||
|
|
@ -810,8 +856,11 @@ public sealed class InboundPhysicsStateController
|
|||
// id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact
|
||||
// value to SetPlacementFrame on a normal accepted update - but only
|
||||
// when the caller's route actually runs that step
|
||||
// (installPlacementFrame; retail skips it entirely while HasAnimations
|
||||
// is true).
|
||||
// (installPlacementFrame; retail's @0x00454137 gate skips
|
||||
// SetPlacementFrame entirely while HasAnims is true, and Gate A
|
||||
// returns before reaching it at all). Since C5b (#275) that is live
|
||||
// for BOTH callers: an animated entity's accepted Position no longer
|
||||
// stamps the wire frame onto the steady-state merge either.
|
||||
uint? appliedPlacement = installPlacementFrame
|
||||
? (disposition is PositionTimestampDisposition.Apply
|
||||
? update.PlacementId ?? 0u
|
||||
|
|
|
|||
|
|
@ -1893,14 +1893,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
disposition is not PositionTimestampDisposition.Rejected;
|
||||
// C4 route 4b-3 (D1): the classifier's remote cell-less predicate
|
||||
// needs the PRE-merge committed cell — the value this method just
|
||||
// measured as `beforeCell`, before `RefreshSnapshot`
|
||||
// below stamps the accepted wire cell onto the canonical record via
|
||||
// `RefreshDerivedState` -> `SetFullCell`. Reading `canonical.FullCellId`
|
||||
// AFTER that merge (as the classifier's default builder overload
|
||||
// does for every other caller) always sees the wire cell, which is
|
||||
// why the predicate as fed to a remote PositionEvent was dead before
|
||||
// this fix. `hadCanonical` is what makes this an honest value rather
|
||||
// than a fabricated 0 — see the field's own doc.
|
||||
// measured as `beforeCell`. Before C5b `RefreshSnapshot` below
|
||||
// stamped the accepted wire cell onto the canonical record via
|
||||
// `RefreshDerivedState` -> `SetFullCell`, so reading
|
||||
// `canonical.FullCellId` AFTER the merge (as the classifier's default
|
||||
// builder overload does for every other caller) always saw the wire
|
||||
// cell, which is why the predicate as fed to a remote PositionEvent
|
||||
// was dead before this fix. `hadCanonical` is what makes this an
|
||||
// honest value rather than a fabricated 0 — see the field's own doc.
|
||||
//
|
||||
// C5b (#275) does NOT retire this threading. The merge no longer
|
||||
// writes the cell (see the CANONICAL CELL SEMANTICS note below), so
|
||||
// the post-merge record now CARRIES this same value through the whole
|
||||
// classification window — the threaded value became a
|
||||
// belt-and-suspenders duplicate of a structural property. It stays,
|
||||
// and it stays the classifier's input of record: one observable, read
|
||||
// once, at the point it is measured.
|
||||
timestamps = timestamps with
|
||||
{
|
||||
PreMergeCommittedCellId = hadCanonical ? beforeCell : null,
|
||||
|
|
@ -1920,10 +1928,34 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
canonical,
|
||||
restoreCancelledPark: true);
|
||||
}
|
||||
// CANONICAL CELL SEMANTICS (C5b, #275 — retires AD-60's legacy half;
|
||||
// this caller now matches the continuation executor's long-standing
|
||||
// rule verbatim): refreshPosition stays false. A wire position never
|
||||
// directly makes the record resident; only a Runtime SetPosition
|
||||
// commit or a simulation full-cell commit may change FullCellId.
|
||||
// This is retail: SmartBox::HandleReceivedPosition (0x00453FD0) reads
|
||||
// the wire objcell_id into a LOCAL @0x00453FE3 and hands it to
|
||||
// BlipPlayer/TeleportPlayer/MoveOrTeleport/ConstrainTo; it never
|
||||
// assigns the object's own cell. The object's cell moves only inside
|
||||
// the placement family (SetPositionInternal @0x00515BD0 -> set_cell,
|
||||
// enter_world) or per-frame movement transit.
|
||||
//
|
||||
// The snapshot's Position field itself IS still refreshed; only the
|
||||
// derived FullCellId write is withheld.
|
||||
//
|
||||
// Two wire-cell writers deliberately SURVIVE this change, downstream
|
||||
// of the merge and outside the classification window: the OnPosition
|
||||
// prologue rebucket (LiveEntityNetworkUpdateController ->
|
||||
// LiveEntityRuntime.RebucketLiveEntity -> CommitRebucket, which is
|
||||
// also the local player's own cell-freshness path, AP-146/#320) and
|
||||
// the post-routing wire-cell adopt for non-placing arms
|
||||
// (TryAdoptWireCellAfterRouting, filed at AP-135). Neither is gated
|
||||
// here: gating the prologue rebucket would freeze the local player's
|
||||
// canonical cell between teleports.
|
||||
Entities.RefreshSnapshot(
|
||||
canonical,
|
||||
snapshot,
|
||||
refreshPosition: acceptedPosition);
|
||||
refreshPosition: false);
|
||||
if (acceptedPosition
|
||||
&& ReferenceEquals(canonical, beforeCanonical))
|
||||
{
|
||||
|
|
@ -1942,6 +1974,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
&& canonical.SpatialAuthorityVersion == spatialVersion);
|
||||
}
|
||||
|
||||
// C5b (#275): the MERGE can no longer move the cell, so a wire cell
|
||||
// change publishes its Rebucketed delta at the OnPosition prologue
|
||||
// rebucket's CommitRebucket instead — same kind, same value, same
|
||||
// publisher, a later point in the same call. This comparison is NOT
|
||||
// dead, and is deliberately kept rather than collapsed to Updated:
|
||||
// the cancellation above (Forget with restoreCancelledPark) can roll
|
||||
// a wakeable lost-cell park back, and RestoreParkWithdrawal restores
|
||||
// canonical residency at the body's committed cell. That is a real
|
||||
// cell edge produced inside this method by a placement owner, and it
|
||||
// must still publish as Rebucketed.
|
||||
return AcknowledgeProjectionAndPublish(
|
||||
canonical,
|
||||
() => acknowledgeProjection?.Invoke(canonical),
|
||||
|
|
|
|||
|
|
@ -615,14 +615,25 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
/// <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).
|
||||
/// sync runs — the entity's position moves to the DESTINATION and its
|
||||
/// <c>ParentCellId</c> tracks <c>record.FullCellId</c>, never 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).
|
||||
///
|
||||
/// <para>
|
||||
/// C5b (#275), trap T6: <c>record.FullCellId</c> is now the COMMITTED
|
||||
/// cell, so a REFUSED placement leaves it at the source. The missile arm
|
||||
/// returns before the OnPosition generic tail, so its residency is
|
||||
/// placement-receipt-driven and nothing else — which is retail
|
||||
/// (a projectile's cell comes from <c>SetPosition</c>, full stop). The
|
||||
/// MAJOR 1 regression check is unchanged in substance and is asserted
|
||||
/// directly below as the identity it always meant:
|
||||
/// <c>ParentCellId == record.FullCellId != body.CellPosition.ObjCellId</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithWireCell()
|
||||
public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithCommittedCell()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
|
|
@ -649,12 +660,18 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
// 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.
|
||||
// The exact B1 regression check: ParentCellId IS 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);
|
||||
Assert.Equal(canonical.FullCellId, fixture.Entity.ParentCellId);
|
||||
Assert.NotEqual(
|
||||
body.CellPosition.ObjCellId,
|
||||
fixture.Entity.ParentCellId);
|
||||
// C5b: the refused placement committed nothing, so the committed cell
|
||||
// is still the source. The wire cell is NOT residency here.
|
||||
Assert.Equal(SourceCell, canonical.FullCellId);
|
||||
Assert.NotEqual(DestinationCell, fixture.Entity.ParentCellId);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -488,9 +488,25 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
positionAuthority,
|
||||
accepted));
|
||||
|
||||
// C5b (#275): the accepted merge no longer stamps the wire cell onto
|
||||
// the canonical record, so the recovery above rebuilds the logical /
|
||||
// render-resource owner but installs no spatial bucket — the
|
||||
// residence-managed materializer's self-projection branch
|
||||
// (DatLiveEntityProjectionMaterializer, `FullCellId != 0`) correctly
|
||||
// declines to project from an UNPLACED wire claim. Production
|
||||
// installs the bucket a few statements later in the SAME
|
||||
// OnPosition call, at the prologue rebucket
|
||||
// (LiveEntityNetworkUpdateController -> RebucketLiveEntity, W2);
|
||||
// nothing between the recovery call and that rebucket returns early
|
||||
// on an unbucketed record. Drive that exact production step here
|
||||
// rather than asserting a projection at a boundary that no longer
|
||||
// owns it.
|
||||
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, Cell));
|
||||
|
||||
Assert.Same(entity, record.WorldEntity);
|
||||
Assert.Same(body, record.PhysicsBody);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.Equal(Cell, record.Canonical.FullCellId);
|
||||
Assert.False(record.AppearanceProjectionSynchronizationPending);
|
||||
Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId);
|
||||
Assert.Equal(1, applied);
|
||||
|
|
@ -524,8 +540,15 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
authorityVersion,
|
||||
out LiveEntityRecord record));
|
||||
|
||||
// C5b (#275): as above — the wire cell is no longer merged onto the
|
||||
// canonical record, so the first spatial projection's BUCKET arrives
|
||||
// at the OnPosition prologue rebucket (W2), not at recovery. The
|
||||
// logical construction, resource registration and ready publication
|
||||
// this test is about all still happen at recovery.
|
||||
Assert.NotNull(record.WorldEntity);
|
||||
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, Cell));
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.Equal(Cell, canonical.FullCellId);
|
||||
Assert.True(record.InitialHydrationCompleted);
|
||||
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||
Assert.Equal(1, fixture.Ready.PublishCount);
|
||||
|
|
|
|||
|
|
@ -66,12 +66,28 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
Assert.Equal(30f, returned.Position!.Value.PositionX);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) rewrite of the pre-classification test: the retail-zero
|
||||
/// rule (<c>PositionPack::UnPack</c> @0x00516740 initializes an absent
|
||||
/// placement id to zero and <c>HandleReceivedPosition</c> forwards that
|
||||
/// exact value to <c>SetPlacementFrame</c>) is only reachable when the
|
||||
/// gate ABOVE it passes — <c>CPhysicsObj::HasAnims(arg2) == 0</c>
|
||||
/// @0x00454137. The seed is therefore explicitly NON-animated; the
|
||||
/// animated counterpart is
|
||||
/// <see cref="ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PositionPlacementAbsentAndPresentZeroBothApplyRetailZero()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn seed = Spawn(
|
||||
0x70000009u, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u);
|
||||
0x70000009u,
|
||||
7,
|
||||
20,
|
||||
1,
|
||||
Position(0x0101FFFFu, 10f),
|
||||
0x408u,
|
||||
motionTableId: null);
|
||||
seed = seed with
|
||||
{
|
||||
PlacementId = 7,
|
||||
|
|
@ -122,6 +138,306 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
Assert.Equal((uint)0, second.Physics!.Value.AnimationFrame);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) / AP-131 retirement, half 1. Retail gates
|
||||
/// <c>CPhysicsObj::SetPlacementFrame</c> @0x00454142 on
|
||||
/// <c>CPhysicsObj::HasAnims(arg2) == 0</c> @0x00454137: an ANIMATED body
|
||||
/// never takes the wire placement frame, because its animation sequencer
|
||||
/// owns the pose. Before C5b the steady-state merge stamped it anyway,
|
||||
/// which is the animation snap/reset half of AP-131.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn seed = Spawn(
|
||||
0x8000C001u,
|
||||
7,
|
||||
20,
|
||||
1,
|
||||
Position(0x0101FFFFu, 10f),
|
||||
0x408u,
|
||||
motionTableId: 0x09000001u);
|
||||
seed = seed with
|
||||
{
|
||||
PlacementId = 7,
|
||||
Physics = seed.Physics!.Value with { AnimationFrame = 7 },
|
||||
};
|
||||
controller.AcceptCreate(seed);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
seed.Guid,
|
||||
Position(0x0101FFFFu, 20f),
|
||||
Velocity: null,
|
||||
PlacementId: 5,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 7,
|
||||
PositionSequence: 21,
|
||||
TeleportSequence: 0,
|
||||
ForcePositionSequence: 0),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out _));
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
// The gate above SetPlacementFrame never opened.
|
||||
Assert.Equal((uint)7, accepted.PlacementId);
|
||||
Assert.Equal((uint)7, accepted.Physics!.Value.AnimationFrame);
|
||||
// The rest of the merge is unaffected: pose and POSITION_TS still land.
|
||||
Assert.Equal(20f, accepted.Position!.Value.PositionX);
|
||||
Assert.Equal((ushort)21, accepted.PositionSequence);
|
||||
// unset_parent @0x00454129 is unconditional on this path.
|
||||
Assert.Null(accepted.ParentGuid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) / AP-131 retirement, half 1 (the other direction). A
|
||||
/// NON-animated body reaches <c>SetPlacementFrame</c> @0x00454142 and
|
||||
/// takes the wire frame verbatim.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn seed = Spawn(
|
||||
0x8000C002u,
|
||||
7,
|
||||
20,
|
||||
1,
|
||||
Position(0x0101FFFFu, 10f),
|
||||
0x408u,
|
||||
motionTableId: null);
|
||||
seed = seed with
|
||||
{
|
||||
PlacementId = 7,
|
||||
Physics = seed.Physics!.Value with { AnimationFrame = 7 },
|
||||
};
|
||||
controller.AcceptCreate(seed);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
seed.Guid,
|
||||
Position(0x0101FFFFu, 20f),
|
||||
Velocity: null,
|
||||
PlacementId: 5,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 7,
|
||||
PositionSequence: 21,
|
||||
TeleportSequence: 0,
|
||||
ForcePositionSequence: 0),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out _));
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
Assert.Equal((uint)5, accepted.PlacementId);
|
||||
Assert.Equal((uint)5, accepted.Physics!.Value.AnimationFrame);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) / AP-131 retirement, half 2. Retail's Gate A
|
||||
/// (@0x0045400C, the local-player FORCE_POSITION self-echo shortcut)
|
||||
/// returns @0x0045409D BEFORE <c>CPhysicsObj::unset_parent</c>
|
||||
/// @0x00454129, so a ForcePosition never unparents. Parent RETENTION is
|
||||
/// the discriminating observable for the force row: the placement-frame
|
||||
/// half is inert there (<c>appliedPlacement</c> keeps
|
||||
/// <c>old.PlacementId</c> under either flag value), so sabotaging it on a
|
||||
/// ForcePosition proves nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn seed = WithTimestamps(
|
||||
Spawn(0x50000021u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
||||
teleport: 10,
|
||||
forcePosition: 0);
|
||||
seed = seed with
|
||||
{
|
||||
ParentGuid = 0x70004444u,
|
||||
ParentLocation = 9u,
|
||||
Physics = seed.Physics!.Value with
|
||||
{
|
||||
Parent = new PhysicsAttachment(0x70004444u, 9u),
|
||||
},
|
||||
};
|
||||
controller.AcceptCreate(seed);
|
||||
var preserved = new Quaternion(0.1f, 0.2f, 0.3f, 0.9f);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
PositionUpdate(
|
||||
seed.Guid,
|
||||
instance: 3,
|
||||
position: 9,
|
||||
teleport: 10,
|
||||
forcePosition: 1),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: preserved,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out _));
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
||||
Assert.Equal(0x70004444u, accepted.ParentGuid);
|
||||
Assert.Equal(9u, accepted.ParentLocation);
|
||||
Assert.Equal(
|
||||
new PhysicsAttachment(0x70004444u, 9u),
|
||||
accepted.Physics!.Value.Parent);
|
||||
// Heading preservation (BlipPlayer's get_heading/set_heading pair
|
||||
// @0x00454056-68) is unchanged by C5b.
|
||||
Assert.Equal(preserved.W, accepted.Position!.Value.RotationW);
|
||||
Assert.Equal(preserved.X, accepted.Position!.Value.RotationX);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) proof obligation 1: the merge's two pre-placement flags ARE
|
||||
/// <see cref="RuntimeAuthoritativePositionRouteClassifier"/>'s
|
||||
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
|
||||
/// rows. The production classifier is the oracle — this test does not
|
||||
/// re-encode the truth table, it drives both computations over the packet
|
||||
/// matrix and asserts the merged snapshot equals what the route's own
|
||||
/// flags would have produced. Dual parent classes (player <c>0x5…</c> and
|
||||
/// creature <c>0x8…</c>) per the #319 discipline.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
// guid, isLocalPlayer, animated, parented, force
|
||||
[InlineData(0x50000031u, true, true, true, false)]
|
||||
[InlineData(0x50000032u, true, true, false, false)]
|
||||
[InlineData(0x50000033u, true, false, true, false)]
|
||||
[InlineData(0x50000034u, true, false, false, false)]
|
||||
[InlineData(0x50000035u, true, true, true, true)]
|
||||
[InlineData(0x50000036u, true, true, false, true)]
|
||||
[InlineData(0x50000037u, true, false, true, true)]
|
||||
[InlineData(0x50000038u, true, false, false, true)]
|
||||
[InlineData(0x80000031u, false, true, true, false)]
|
||||
[InlineData(0x80000032u, false, true, false, false)]
|
||||
[InlineData(0x80000033u, false, false, true, false)]
|
||||
[InlineData(0x80000034u, false, false, false, false)]
|
||||
public void MergedPrePlacementFieldsMatchTheClassifiedRouteFlags(
|
||||
uint guid,
|
||||
bool isLocalPlayer,
|
||||
bool animated,
|
||||
bool parented,
|
||||
bool force)
|
||||
{
|
||||
WorldSession.EntitySpawn seed = WithTimestamps(
|
||||
Spawn(
|
||||
guid,
|
||||
3,
|
||||
10,
|
||||
1,
|
||||
Position(0x0101FFFFu, 10f),
|
||||
0x408u,
|
||||
motionTableId: animated ? 0x09000001u : null),
|
||||
teleport: 10,
|
||||
forcePosition: 0);
|
||||
seed = seed with
|
||||
{
|
||||
PlacementId = 7,
|
||||
Physics = seed.Physics!.Value with { AnimationFrame = 7 },
|
||||
};
|
||||
if (parented)
|
||||
{
|
||||
seed = seed with
|
||||
{
|
||||
ParentGuid = 0x70004444u,
|
||||
ParentLocation = 9u,
|
||||
Physics = seed.Physics!.Value with
|
||||
{
|
||||
Parent = new PhysicsAttachment(0x70004444u, 9u),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
WorldSession.EntityPositionUpdate update = new(
|
||||
guid,
|
||||
Position(0x0101FFFFu, 20f),
|
||||
Velocity: null,
|
||||
PlacementId: 5,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 3,
|
||||
PositionSequence: force ? (ushort)9 : (ushort)11,
|
||||
TeleportSequence: 10,
|
||||
ForcePositionSequence: force ? (ushort)1 : (ushort)0);
|
||||
|
||||
var merging = new InboundPhysicsStateController();
|
||||
merging.AcceptCreate(seed);
|
||||
Assert.True(merging.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer,
|
||||
forcePositionRotation: isLocalPlayer ? Quaternion.Identity : null,
|
||||
currentLocalVelocity: isLocalPlayer ? Vector3.Zero : null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn merged,
|
||||
out AcceptedPhysicsTimestamps timestamps));
|
||||
Assert.Equal(
|
||||
force
|
||||
? PositionTimestampDisposition.ForcePosition
|
||||
: PositionTimestampDisposition.Apply,
|
||||
disposition);
|
||||
|
||||
// The oracle: the production classifier, fed the same wire packet and
|
||||
// the same static HasAnimations proxy.
|
||||
RuntimeAuthoritativePositionRoute route =
|
||||
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
||||
new RuntimeAcceptedPositionRouteRequest(
|
||||
new RuntimeAuthoritativePositionAuthority(
|
||||
new RuntimeGenerationToken(7),
|
||||
new RuntimeEntityKey(guid, 1),
|
||||
PositionAuthorityVersion: 4UL,
|
||||
AcceptedPositionSequence: update.PositionSequence,
|
||||
timestamps.PreviousTeleport,
|
||||
timestamps.Teleport,
|
||||
disposition),
|
||||
isLocalPlayer
|
||||
? RuntimePositionEntityKind.LocalPlayer
|
||||
: RuntimePositionEntityKind.Remote,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
update.Position,
|
||||
PlacementFrame: update.PlacementId,
|
||||
PositionPackVelocity: null,
|
||||
CommittedCellId: 0x0101FFFFu,
|
||||
HasContact: true,
|
||||
PlayerDistance: 0f,
|
||||
UsePositionFromServer: true,
|
||||
HasAnimations: animated,
|
||||
default));
|
||||
|
||||
// Rebuild what the classified route would have installed, through the
|
||||
// SAME production merge, and require field equality.
|
||||
var classified = new InboundPhysicsStateController();
|
||||
classified.AcceptCreate(seed);
|
||||
Assert.True(classified.ApplyAcceptedPositionSnapshot(
|
||||
guid,
|
||||
update,
|
||||
disposition,
|
||||
timestamps,
|
||||
isLocalPlayer,
|
||||
isLocalPlayer ? Quaternion.Identity : null,
|
||||
isLocalPlayer ? Vector3.Zero : null,
|
||||
installPlacementFrame: route.ApplyPlacementFrameBeforeRouting,
|
||||
clearParent: route.UnparentBeforeRouting,
|
||||
out WorldSession.EntitySpawn expected));
|
||||
|
||||
Assert.Equal(expected.PlacementId, merged.PlacementId);
|
||||
Assert.Equal(
|
||||
expected.Physics!.Value.AnimationFrame,
|
||||
merged.Physics!.Value.AnimationFrame);
|
||||
Assert.Equal(expected.ParentGuid, merged.ParentGuid);
|
||||
Assert.Equal(expected.ParentLocation, merged.ParentLocation);
|
||||
Assert.Equal(
|
||||
expected.Physics!.Value.Parent,
|
||||
merged.Physics!.Value.Parent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemotePositionWithoutVelocityAppliesUnpackedZeroVector()
|
||||
{
|
||||
|
|
@ -645,13 +961,22 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275): <paramref name="motionTableId"/> drives AP-130's static
|
||||
/// HasAnimations proxy, which now gates the steady-state merge's
|
||||
/// placement-frame install exactly as retail's
|
||||
/// <c>CPhysicsObj::HasAnims</c> check @0x00454137 gates
|
||||
/// <c>SetPlacementFrame</c>. The default keeps every pre-existing fixture
|
||||
/// animated, which is what the retail population overwhelmingly is.
|
||||
/// </summary>
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
ushort instance,
|
||||
ushort positionSequence,
|
||||
ushort stateSequence,
|
||||
CreateObject.ServerPosition? position,
|
||||
uint state)
|
||||
uint state,
|
||||
uint? motionTableId = 0x09000001u)
|
||||
{
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
positionSequence,
|
||||
|
|
@ -669,7 +994,7 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
MotionTableId: motionTableId,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
|
|
@ -696,7 +1021,7 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
motionTableId,
|
||||
PhysicsState: state,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: 1,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) — the steady-state accepted-Position merge is no longer a
|
||||
/// residency writer. Retires AD-60's legacy half.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's <c>SmartBox::HandleReceivedPosition</c> @0x00453FD0 reads the
|
||||
/// wire <c>objcell_id</c> into a LOCAL @0x00453FE3 and hands it only to
|
||||
/// <c>BlipPlayer</c>/<c>TeleportPlayer</c>/<c>MoveOrTeleport</c>/
|
||||
/// <c>ConstrainTo</c>; it never assigns the object's own cell. The object's
|
||||
/// cell moves inside the placement family
|
||||
/// (<c>SetPositionInternal</c> @0x00515BD0 -> <c>set_cell</c>,
|
||||
/// <c>enter_world</c>) or per-frame movement transit, and nowhere else.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// TRAP T2: the withhold assertion belongs at THIS boundary
|
||||
/// (<see cref="RuntimeEntityObjectLifetime.TryApplyPosition"/>), never at
|
||||
/// <c>LiveEntityNetworkUpdateController.OnPosition</c> level — the OnPosition
|
||||
/// prologue rebucket legitimately commits the wire cell a few statements
|
||||
/// later (W2, the local player's own cell-freshness path, AP-146/#320), and
|
||||
/// an OnPosition-level assertion would fail and tempt a weakening.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RuntimeSteadyStatePositionMergeTests
|
||||
{
|
||||
private const uint Landblock = 0xA9C60000u;
|
||||
private const uint LandblockSentinel = Landblock | 0xFFFFu;
|
||||
private const uint Cell = Landblock | 0x0001u;
|
||||
private const uint OtherCell = Landblock | 0x0002u;
|
||||
|
||||
/// <summary>
|
||||
/// C5b proof obligation 2. The pose half of the merge still lands; only
|
||||
/// the derived <c>FullCellId</c> write is withheld, and a later canonical
|
||||
/// commit still moves it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary()
|
||||
{
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime);
|
||||
const uint guid = 0x80005001u;
|
||||
RuntimeEntityRecord canonical =
|
||||
lifetime.RegisterEntity(Spawn(guid, 1)).Canonical!;
|
||||
Assert.Equal(Cell, canonical.FullCellId);
|
||||
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
PositionUpdate(guid, OtherCell, positionSequence: 2),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out _,
|
||||
out AcceptedPhysicsTimestamps timestamps));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
|
||||
// THE withhold: an accepted wire cell that differs from the committed
|
||||
// cell leaves the record exactly where the last commit put it.
|
||||
Assert.Equal(Cell, canonical.FullCellId);
|
||||
Assert.NotEqual(OtherCell, canonical.FullCellId);
|
||||
// 4b-3's D1 threading now equals a structural property of the record
|
||||
// through the whole classification window.
|
||||
Assert.Equal(Cell, timestamps.PreMergeCommittedCellId);
|
||||
|
||||
// The POSE half of the merge is emphatically not withheld.
|
||||
Assert.Equal(
|
||||
OtherCell,
|
||||
canonical.Snapshot.Position!.Value.LandblockId);
|
||||
Assert.Equal(30f, canonical.Snapshot.Position!.Value.PositionX);
|
||||
Assert.Equal((ushort)2, canonical.Snapshot.PositionSequence);
|
||||
|
||||
// A canonical commit — the only kind of writer retail allows — still
|
||||
// changes residency.
|
||||
Assert.True(lifetime.CommitRebucket(
|
||||
canonical,
|
||||
OtherCell,
|
||||
LandblockSentinel));
|
||||
Assert.Equal(OtherCell, canonical.FullCellId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b proof obligation 3 — publish-delta conservation across the moved
|
||||
/// edge (trap T3). Pre-C5b the merge itself published the
|
||||
/// <c>Rebucketed</c> delta and propagated to children, and the prologue
|
||||
/// rebucket then early-returned publish-less because the cell already
|
||||
/// matched. Post-C5b the merge publishes <c>Updated</c> and the prologue
|
||||
/// rebucket's <c>CommitRebucket</c> publishes the <c>Rebucketed</c>:
|
||||
/// same kind, same value, same publisher, a later point in the same call.
|
||||
/// Exactly ONE of each, never zero and never two — pinned at the
|
||||
/// observable, not at the source site.
|
||||
///
|
||||
/// <para>Both parent classes per the #319 discipline: a test population
|
||||
/// that only ever sees creature parents is blind to the player class.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x50005101u, 0x50005102u)]
|
||||
[InlineData(0x80005201u, 0x80005202u)]
|
||||
public void CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation(
|
||||
uint parentGuid,
|
||||
uint childGuid)
|
||||
{
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime);
|
||||
RuntimeEntityRecord parent =
|
||||
lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!;
|
||||
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
|
||||
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
|
||||
Assert.True(lifetime.Entities.TryGetActive(
|
||||
childGuid, out RuntimeEntityRecord child));
|
||||
Assert.Equal(Cell, child.FullCellId);
|
||||
|
||||
ulong childSpatialBefore = child.SpatialAuthorityVersion;
|
||||
var deltas = new List<RuntimeEntityDelta>();
|
||||
using IDisposable subscription = lifetime.Events.Subscribe(
|
||||
new RecordingEntityObserver(deltas));
|
||||
|
||||
// The production steady-state sequence, in production order: the
|
||||
// merge, then the OnPosition prologue rebucket (W2) at the wire cell.
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
PositionUpdate(parentGuid, OtherCell, positionSequence: 3),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out _,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
Assert.True(lifetime.CommitRebucket(parent, OtherCell, LandblockSentinel));
|
||||
|
||||
Assert.Equal(OtherCell, parent.FullCellId);
|
||||
RuntimeEntityDelta rebucketed = Assert.Single(
|
||||
deltas,
|
||||
d => d.Change is RuntimeEntityChange.Rebucketed
|
||||
&& d.Entity.Identity.ServerGuid == parentGuid);
|
||||
Assert.Equal(OtherCell, rebucketed.Entity.CellId);
|
||||
|
||||
// #319's child-equality channel: exactly one propagation, and the
|
||||
// child lands on the parent's new cell.
|
||||
Assert.Equal(OtherCell, child.FullCellId);
|
||||
Assert.Equal(childSpatialBefore + 1UL, child.SpatialAuthorityVersion);
|
||||
}
|
||||
|
||||
private sealed class RecordingEntityObserver(List<RuntimeEntityDelta> destination)
|
||||
: IRuntimeEntityObjectObserver
|
||||
{
|
||||
public void OnEntity(in RuntimeEntityDelta delta) => destination.Add(delta);
|
||||
|
||||
public void OnInventory(in RuntimeInventoryDelta delta)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeEntityObjectLifetime EngineLifetime()
|
||||
{
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
engine.AddLandblock(
|
||||
Landblock,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
return new RuntimeEntityObjectLifetime(engine);
|
||||
}
|
||||
|
||||
private static void Bind(RuntimeEntityObjectLifetime lifetime)
|
||||
{
|
||||
var token = new RuntimeGenerationToken(1UL);
|
||||
lifetime.BindEventContext(() => token, static () => 1UL);
|
||||
}
|
||||
|
||||
private static bool CommitAttachment(
|
||||
RuntimeEntityObjectLifetime lifetime,
|
||||
uint parentGuid,
|
||||
ushort parentInstance,
|
||||
uint childGuid,
|
||||
ushort childPositionSequence)
|
||||
{
|
||||
var relation = new ParentAttachmentRelation(
|
||||
parentGuid,
|
||||
childGuid,
|
||||
ParentLocation: 0u,
|
||||
PlacementId: 0u,
|
||||
parentInstance,
|
||||
childPositionSequence);
|
||||
lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation);
|
||||
var update = new ParentEvent.Parsed(
|
||||
parentGuid,
|
||||
childGuid,
|
||||
0u,
|
||||
0u,
|
||||
parentInstance,
|
||||
childPositionSequence);
|
||||
if (!lifetime.TryApplyParent(update, acknowledgeProjection: null, out _))
|
||||
return false;
|
||||
if (!lifetime.TryCommitParent(relation, acknowledgeProjection: null, out _))
|
||||
return false;
|
||||
if (!lifetime.Entities.ParentAttachments.CommitProjection(relation))
|
||||
return false;
|
||||
if (!lifetime.Entities.TryGetActive(
|
||||
childGuid,
|
||||
out RuntimeEntityRecord canonical))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return lifetime.CommitAcceptedParentCellless(
|
||||
canonical,
|
||||
canonical.PositionAuthorityVersion,
|
||||
acknowledgeProjection: null);
|
||||
}
|
||||
|
||||
private static WorldSession.EntityPositionUpdate PositionUpdate(
|
||||
uint guid,
|
||||
uint cell,
|
||||
ushort positionSequence) =>
|
||||
new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(cell, 30f, 20f, 7f, 1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: positionSequence,
|
||||
TeleportSequence: 0,
|
||||
ForcePositionSequence: 0);
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
ushort incarnation,
|
||||
bool includePosition = true)
|
||||
{
|
||||
CreateObject.ServerPosition? position = includePosition
|
||||
? new CreateObject.ServerPosition(Cell, 10f, 20f, 7f, 1f, 0f, 0f, 0f)
|
||||
: null;
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
Position: 1,
|
||||
Movement: 1,
|
||||
State: 1,
|
||||
Vector: 1,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: incarnation);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.Gravity,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: null,
|
||||
MotionTableId: null,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
SetupTableId: null,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PhysicsState: (uint)PhysicsStateFlags.Gravity,
|
||||
InstanceSequence: incarnation,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
|
|
@ -1787,7 +1787,16 @@ public sealed class RuntimeSetPositionStateTests
|
|||
|
||||
Assert.True(reentered);
|
||||
RuntimeEntityDelta only = Assert.Single(entities.Deltas);
|
||||
Assert.Equal(RuntimeEntityChange.Rebucketed, only.Change);
|
||||
// C5b (#275): the reentrant accepted Position publishes Updated, not
|
||||
// Rebucketed — the steady-state merge is no longer a residency writer,
|
||||
// so a wire cell arriving on a withdrawn (picked-up) record does not
|
||||
// make it resident. Production commits the cell one step later, at the
|
||||
// OnPosition prologue rebucket, which this Runtime-level fixture does
|
||||
// not drive. The SUBJECT of this test is unchanged: exactly one delta,
|
||||
// published by the reentrant Position, superseding the stale pickup
|
||||
// discard.
|
||||
Assert.Equal(RuntimeEntityChange.Updated, only.Change);
|
||||
Assert.Equal(0u, record.FullCellId);
|
||||
Assert.True(only.Stamp.Sequence > discardStamp.Sequence);
|
||||
Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
pending.Projection));
|
||||
|
|
|
|||
|
|
@ -255,6 +255,81 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) proof obligation 4 — the assertion that makes AD-60's
|
||||
/// legacy-half retirement mean something. A local ForcePosition whose
|
||||
/// drive execution CONTENDS performs no placement, so nothing may write
|
||||
/// residency for that packet: the merge no longer stamps the wire cell
|
||||
/// (C5b), there is no placement receipt, and the OnPosition generic tail
|
||||
/// (W2) is never reached because the force path returns before it.
|
||||
/// <c>FullCellId</c> is the pre-packet committed cell throughout.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail cannot refuse at all (AD-62), and its body simply keeps the
|
||||
/// last cell <c>set_cell</c> gave it — so the surviving shape here is the
|
||||
/// retail-reachable one. Before C5b the merge stamped the refused
|
||||
/// packet's wire cell, which is the AD-60 legacy-half divergence.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContendedForcePosition_WritesNoResidencyAnywhere()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
uint committedCellBefore = record.FullCellId;
|
||||
Assert.NotEqual(0u, committedCellBefore);
|
||||
|
||||
// A wire cell that is deliberately NOT the committed one, so the
|
||||
// pre-C5b stamp would have been observable.
|
||||
const uint wireCell = SpawnLandblock | 0x0002u;
|
||||
Assert.NotEqual(wireCell, committedCellBefore);
|
||||
WorldSession.EntityPositionUpdate wire = ForceUpdate(
|
||||
new Vector3(30f, 32f, SpawnHeight),
|
||||
landblockId: wireCell);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) =
|
||||
MergeAccepted(runtime, controller, wire);
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
||||
|
||||
// (1) The merge itself wrote no residency.
|
||||
Assert.Equal(committedCellBefore, record.FullCellId);
|
||||
|
||||
RuntimeEntityPlacementToken displaced = runtime.EntityObjects.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative);
|
||||
Assert.True(displaced.IsValid);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeAcceptedPositionExecutionStatus.Contention,
|
||||
drive.TryExecuteAcceptedLocalPosition(
|
||||
record,
|
||||
wire,
|
||||
disposition,
|
||||
timestamps,
|
||||
timestamps.PreviousTeleport));
|
||||
|
||||
// (2) No receipt, so no residency change from the execution either.
|
||||
Assert.Equal(committedCellBefore, record.FullCellId);
|
||||
Assert.Empty(gameActions);
|
||||
|
||||
// (3) And nothing rebounds it on the settle.
|
||||
drive.Advance();
|
||||
Assert.Equal(committedCellBefore, record.FullCellId);
|
||||
|
||||
RuntimePlacementCancellationReceipt cancellation = runtime.EntityObjects
|
||||
.Physics.SetPosition.ForgetExactPlacement(displaced);
|
||||
if (cancellation.IsValid)
|
||||
{
|
||||
runtime.EntityObjects.Physics.SetPosition
|
||||
.PublishCancellation(cancellation);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakes()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue