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:
Erik 2026-08-05 21:17:20 +02:00
parent 0257844106
commit 735f0a72af
11 changed files with 1715 additions and 63 deletions

View file

@ -35,9 +35,11 @@ question is real and unresolved
**Description.** Retail writes the local player's cell on EVERY physics tick **Description.** Retail writes the local player's cell on EVERY physics tick
(`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional). acdream's (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional). acdream's
canonical `FullCellId` for the LOCAL player is written only at three edges: canonical `FullCellId` for the LOCAL player is written only at three edges:
login activation (`RuntimeSetPositionState.cs:2741-2745`), an accepted inbound login activation (`RuntimeSetPositionState.cs:2741-2745`), the `OnPosition`
Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot` generic tail's prologue rebucket after an accepted inbound Position
`RuntimeEntityRecord.cs:234`), and a teleport/portal placement commit (`LiveEntityNetworkUpdateController``LiveEntityRuntime.RebucketLiveEntity`
`RuntimeEntityObjectLifetime.CommitRebucket`), and a teleport/portal
placement commit
(`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`). (`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`).
Ordinary WASD movement passes a LANDBLOCK id, not an exact cell Ordinary WASD movement passes a LANDBLOCK id, not an exact cell
(`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in (`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 commit files (AP-146) for the full citation and the argument that this is
currently safe for every EXISTING consumer. 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 **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 — 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 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 ## #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) **Severity:** LOW (internal refactor debt; not a retail divergence)
**Component:** Runtime / inbound Position **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 route) and retire AP-131/AD-60's legacy halves. See
`InboundPhysicsStateController.TryApplyPosition` remarks. `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 ## #274 — Restricted/barred-house entry needs a connected retail comparison

File diff suppressed because one or more lines are too long

View 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
9289693051). 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-141AP-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.

View file

@ -593,17 +593,23 @@ public sealed class InboundPhysicsStateController
/// position payload is rejected. This lets callers publish a freshly /// position payload is rejected. This lets callers publish a freshly
/// consumed FORCE_POSITION_TS without applying a stale pose. /// consumed FORCE_POSITION_TS without applying a stale pose.
/// ///
/// Round 4 R4-15: this legacy immediate-apply path has no HasContact or /// C5b (#275): this steady-state merge no longer installs retail's two
/// route-classification concept at all - it merges unconditionally on /// PRE-PLACEMENT writes unconditionally. Retail runs both of them BEFORE
/// the retained <see cref="PositionTimestampDisposition"/> alone. The /// <c>MoveOrTeleport</c> is consulted, so their gates are a pure function
/// continuation executor's <c>ApplyPositionAction</c> instead runs /// of the timestamp disposition and the static HasAnims proxy - which is
/// <c>RuntimeAuthoritativePositionRouteClassifier</c> and derives /// why classifying them here needs no route, no player distance and no
/// contact solely from the retained wire packet's own /// signature change (see the truth table at the call below). The
/// <c>IsGrounded</c> bit. This is internal refactor debt tracked for /// near/far/teleport routing decision proper is still downstream of this
/// the eventual cutover unification (this file's <c>TryApplyPosition</c> /// merge and still belongs to
/// is today's only PRODUCTION Position wire caller; the classifier-based /// <c>RuntimeAuthoritativePositionRouteClassifier</c>, which the
/// path is test-only until a host wires the executor) - it is NOT a /// continuation executor's <c>ApplyPositionAction</c> runs directly and
/// retail divergence and does not belong in /// 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 /// docs/architecture/retail-divergence-register.md. See docs/ISSUES.md
/// for the tracked follow-up. /// for the tracked follow-up.
/// </summary> /// </summary>
@ -646,6 +652,46 @@ public sealed class InboundPhysicsStateController
teleportAdvanced: disposition is PositionTimestampDisposition.Apply teleportAdvanced: disposition is PositionTimestampDisposition.Apply
&& advancesTeleport, && advancesTeleport,
previousTeleport: previousTeleport); 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( accepted = ApplyAcceptedPosition(
old, old,
update, update,
@ -654,13 +700,8 @@ public sealed class InboundPhysicsStateController
isLocalPlayer, isLocalPlayer,
forcePositionRotation, forcePositionRotation,
currentLocalVelocity, currentLocalVelocity,
// Legacy immediate-apply reproduces EXACT prior behavior: the installPlacementFrame: !force && !hasAnimations,
// placement frame and parent clear were always unconditional clearParent: !force);
// 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);
_snapshots[update.Guid] = accepted; _snapshots[update.Guid] = accepted;
return true; return true;
} }
@ -669,7 +710,10 @@ public sealed class InboundPhysicsStateController
/// the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>. The /// the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>. The
/// executor passes its classified route's own /// executor passes its classified route's own
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c> /// <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( internal bool ApplyAcceptedPositionSnapshot(
uint guid, uint guid,
WorldSession.EntityPositionUpdate update, WorldSession.EntityPositionUpdate update,
@ -770,14 +814,16 @@ public sealed class InboundPhysicsStateController
/// <see cref="AcceptedPhysicsTimestamps"/> captured at admission time. /// <see cref="AcceptedPhysicsTimestamps"/> captured at admission time.
/// ///
/// <paramref name="installPlacementFrame"/>/<paramref name="clearParent"/> /// <paramref name="installPlacementFrame"/>/<paramref name="clearParent"/>
/// (Round 3 B6) let the two callers reproduce two different retail /// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Since C5b (#275)
/// gates: the legacy immediate-apply path always passes true/true /// BOTH callers supply the same classified values, from the same two
/// (retail's HandleReceivedPosition unconditionally runs /// inputs: the continuation executor reads its classified route's
/// unset_parent/SetPlacementFrame there), while the continuation /// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>,
/// executor passes its classified route's own /// and the steady-state <see cref="TryApplyPosition"/> merge derives them
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c> - /// pre-merge from (disposition, hasAnimations) - see the truth table
/// both false only for the FORCE_POSITION branch, which retail's /// there. Both are false only for the FORCE_POSITION branch, which
/// MoveOrTeleport returns from immediately, BEFORE either call. /// retail's HandleReceivedPosition Gate A returns from immediately,
/// BEFORE either call; <paramref name="installPlacementFrame"/> is
/// additionally false whenever HasAnims is true.
/// </summary> /// </summary>
internal static WorldSession.EntitySpawn ApplyAcceptedPosition( internal static WorldSession.EntitySpawn ApplyAcceptedPosition(
WorldSession.EntitySpawn old, WorldSession.EntitySpawn old,
@ -810,8 +856,11 @@ public sealed class InboundPhysicsStateController
// id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact // id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact
// value to SetPlacementFrame on a normal accepted update - but only // value to SetPlacementFrame on a normal accepted update - but only
// when the caller's route actually runs that step // when the caller's route actually runs that step
// (installPlacementFrame; retail skips it entirely while HasAnimations // (installPlacementFrame; retail's @0x00454137 gate skips
// is true). // 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 uint? appliedPlacement = installPlacementFrame
? (disposition is PositionTimestampDisposition.Apply ? (disposition is PositionTimestampDisposition.Apply
? update.PlacementId ?? 0u ? update.PlacementId ?? 0u

View file

@ -1893,14 +1893,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
disposition is not PositionTimestampDisposition.Rejected; disposition is not PositionTimestampDisposition.Rejected;
// C4 route 4b-3 (D1): the classifier's remote cell-less predicate // C4 route 4b-3 (D1): the classifier's remote cell-less predicate
// needs the PRE-merge committed cell — the value this method just // needs the PRE-merge committed cell — the value this method just
// measured as `beforeCell`, before `RefreshSnapshot` // measured as `beforeCell`. Before C5b `RefreshSnapshot` below
// below stamps the accepted wire cell onto the canonical record via // stamped the accepted wire cell onto the canonical record via
// `RefreshDerivedState` -> `SetFullCell`. Reading `canonical.FullCellId` // `RefreshDerivedState` -> `SetFullCell`, so reading
// AFTER that merge (as the classifier's default builder overload // `canonical.FullCellId` AFTER the merge (as the classifier's default
// does for every other caller) always sees the wire cell, which is // builder overload does for every other caller) always saw the wire
// why the predicate as fed to a remote PositionEvent was dead before // cell, which is why the predicate as fed to a remote PositionEvent
// this fix. `hadCanonical` is what makes this an honest value rather // was dead before this fix. `hadCanonical` is what makes this an
// than a fabricated 0 — see the field's own doc. // 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 timestamps = timestamps with
{ {
PreMergeCommittedCellId = hadCanonical ? beforeCell : null, PreMergeCommittedCellId = hadCanonical ? beforeCell : null,
@ -1920,10 +1928,34 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
canonical, canonical,
restoreCancelledPark: true); 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( Entities.RefreshSnapshot(
canonical, canonical,
snapshot, snapshot,
refreshPosition: acceptedPosition); refreshPosition: false);
if (acceptedPosition if (acceptedPosition
&& ReferenceEquals(canonical, beforeCanonical)) && ReferenceEquals(canonical, beforeCanonical))
{ {
@ -1942,6 +1974,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
&& canonical.SpatialAuthorityVersion == spatialVersion); && 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( return AcknowledgeProjectionAndPublish(
canonical, canonical,
() => acknowledgeProjection?.Invoke(canonical), () => acknowledgeProjection?.Invoke(canonical),

View file

@ -615,14 +615,25 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
/// <c>StoreAcceptedDestinationPose</c> resolves the destination through /// <c>StoreAcceptedDestinationPose</c> resolves the destination through
/// Runtime's own world frame instead of a commit. <c>Refused</c> is /// Runtime's own world frame instead of a commit. <c>Refused</c> is
/// still a storing (A1-admitted) outcome, so the App-level presentation /// still a storing (A1-admitted) outcome, so the App-level presentation
/// sync runs — the entity's position AND <c>ParentCellId</c> must both /// sync runs — the entity's position moves to the DESTINATION and its
/// move to the DESTINATION (the wire cell), not the stale INDOOR source /// <c>ParentCellId</c> tracks <c>record.FullCellId</c>, never the stale
/// cell a <c>body.CellPosition.ObjCellId</c> read would have produced /// INDOOR source cell a <c>body.CellPosition.ObjCellId</c> read would
/// (see <see cref="IndoorSourceCell"/>'s doc comment for why an outdoor /// have produced (see <see cref="IndoorSourceCell"/>'s doc comment for
/// source cell would not have discriminated here). /// 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> /// </summary>
[Fact] [Fact]
public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithWireCell() public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithCommittedCell()
{ {
using var fixture = new Fixture(MissileGuid, isMissile: true); using var fixture = new Fixture(MissileGuid, isMissile: true);
Assert.True(fixture.Lifetime.Entities.TryGetActive( 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 store fallback never re-derives an INDOOR cell id — confirms
// the divergence this test is built to exercise actually occurred. // the divergence this test is built to exercise actually occurred.
Assert.Equal(IndoorSourceCell, body.CellPosition.ObjCellId); Assert.Equal(IndoorSourceCell, body.CellPosition.ObjCellId);
// The exact B1 regression check: ParentCellId is the DESTINATION // The exact B1 regression check: ParentCellId IS record.FullCellId —
// (wire) cell, matching record.FullCellId — never // never body.CellPosition.ObjCellId, which just asserted it is STILL
// body.CellPosition.ObjCellId, which just asserted it is STILL the // the stale indoor source cell.
// stale indoor source cell.
Assert.Equal(body.Position, fixture.Entity.Position); 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(); fixture.DrainPlacementFifo();
} }

View file

@ -488,9 +488,25 @@ public sealed class LiveEntityHydrationControllerTests
positionAuthority, positionAuthority,
accepted)); 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(entity, record.WorldEntity);
Assert.Same(body, record.PhysicsBody); Assert.Same(body, record.PhysicsBody);
Assert.True(record.IsSpatiallyProjected); Assert.True(record.IsSpatiallyProjected);
Assert.Equal(Cell, record.Canonical.FullCellId);
Assert.False(record.AppearanceProjectionSynchronizationPending); Assert.False(record.AppearanceProjectionSynchronizationPending);
Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId); Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId);
Assert.Equal(1, applied); Assert.Equal(1, applied);
@ -524,8 +540,15 @@ public sealed class LiveEntityHydrationControllerTests
authorityVersion, authorityVersion,
out LiveEntityRecord record)); 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.NotNull(record.WorldEntity);
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, Cell));
Assert.True(record.IsSpatiallyProjected); Assert.True(record.IsSpatiallyProjected);
Assert.Equal(Cell, canonical.FullCellId);
Assert.True(record.InitialHydrationCompleted); Assert.True(record.InitialHydrationCompleted);
Assert.Equal(1, fixture.Resources.RegisterCount); Assert.Equal(1, fixture.Resources.RegisterCount);
Assert.Equal(1, fixture.Ready.PublishCount); Assert.Equal(1, fixture.Ready.PublishCount);

View file

@ -66,12 +66,28 @@ public sealed class InboundPhysicsStateControllerTests
Assert.Equal(30f, returned.Position!.Value.PositionX); 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] [Fact]
public void PositionPlacementAbsentAndPresentZeroBothApplyRetailZero() public void PositionPlacementAbsentAndPresentZeroBothApplyRetailZero()
{ {
var controller = new InboundPhysicsStateController(); var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn seed = Spawn( 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 seed = seed with
{ {
PlacementId = 7, PlacementId = 7,
@ -122,6 +138,306 @@ public sealed class InboundPhysicsStateControllerTests
Assert.Equal((uint)0, second.Physics!.Value.AnimationFrame); 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] [Fact]
public void RemotePositionWithoutVelocityAppliesUnpackedZeroVector() 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( private static WorldSession.EntitySpawn Spawn(
uint guid, uint guid,
ushort instance, ushort instance,
ushort positionSequence, ushort positionSequence,
ushort stateSequence, ushort stateSequence,
CreateObject.ServerPosition? position, CreateObject.ServerPosition? position,
uint state) uint state,
uint? motionTableId = 0x09000001u)
{ {
var timestamps = new PhysicsTimestamps( var timestamps = new PhysicsTimestamps(
positionSequence, positionSequence,
@ -669,7 +994,7 @@ public sealed class InboundPhysicsStateControllerTests
Movement: null, Movement: null,
AnimationFrame: null, AnimationFrame: null,
SetupTableId: 0x02000001u, SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u, MotionTableId: motionTableId,
SoundTableId: null, SoundTableId: null,
PhysicsScriptTableId: null, PhysicsScriptTableId: null,
Parent: null, Parent: null,
@ -696,7 +1021,7 @@ public sealed class InboundPhysicsStateControllerTests
"fixture", "fixture",
null, null,
null, null,
0x09000001u, motionTableId,
PhysicsState: state, PhysicsState: state,
InstanceSequence: instance, InstanceSequence: instance,
MovementSequence: 1, MovementSequence: 1,

View file

@ -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 -&gt; <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);
}
}

View file

@ -1787,7 +1787,16 @@ public sealed class RuntimeSetPositionStateTests
Assert.True(reentered); Assert.True(reentered);
RuntimeEntityDelta only = Assert.Single(entities.Deltas); 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.True(only.Stamp.Sequence > discardStamp.Sequence);
Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection(
pending.Projection)); pending.Projection));

View file

@ -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] [Fact]
public void DeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakes() public void DeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakes()
{ {