fix(physics): route local-player shadow presentation through SyncPose (#318, AP-145)

RuntimePlacementPresentationSink.TryPublishPlace previously published the
local player's collision-shadow pose with a direct LocalPlayerShadowState.Set
call — a plain cache write that never touched PhysicsEngine.ShadowObjects.
Because LocalPlayerShadowSynchronizer.SyncPose's own dedup check compares
against that same cache, the direct write could pre-seed the cache with the
destination pose and cause the next real SyncPose call to see "nothing
changed" and skip its own ShadowObjects publish — leaving the real collision
shadow at the pre-teleport position until an unrelated movement tick forced
a real publish.

Fix: TryPublishPlace now calls _localPlayerShadowSync.SyncPose(...,
force: true), the same publisher ordinary per-tick movement uses, so Place
always drives a real ShadowObjects write before the cache updates.
TryPublishWithdrawal carried the exact mirror asymmetry (a bare
LocalPlayerShadowState.Clear with no ShadowObjects.Suspend, leaving a live
phantom shadow row at the park's source cell for the whole park window — the
#184 shape) and is fixed in the same commit, same one-call shape:
_localPlayerShadowSync.Suspend(entity). The sink no longer holds a direct
LocalPlayerShadowState reference; both halves route exclusively through the
one synchronizer, which owns the cache internally.

The single LocalPlayerShadowSynchronizer instance is now constructed in
LivePresentationComposition (before the sink) and threaded through
LivePresentationResult to SessionPlayerComposition, which no longer builds
its own — this guarantees the sink's Place/Withdraw edge and ordinary
per-tick movement publish through the exact same publisher and cache rather
than two independent instances that could drift out of sync with each other.

TryPublishPlace's xmldoc now states the behavioural nuance directly: routing
through SyncPose means Place inherits SyncPose's own admission guard
(IsHidden, cellId == 0, not-current-visible-projection), which the old
direct .Set() call never consulted. Under those conditions SyncPose now
calls Suspend instead of publishing — correct and symmetric, but new
behaviour worth flagging at the call site, not just in a test comment.

RuntimePlacementShadowCompositionTests.cs (#318) proves four facts against
the real ShadowObjects registry, not the cache: a bare Place publishes a
real row at the destination cell with the source cell's row gone; a
subsequent ordinary per-tick Sync is then a correct no-op; a Place for a
registered non-local-player entity leaves its row at the source cell
untouched and never touches the player's cache (route 7 P4 — the fix lives
entirely inside the pre-existing player-only gate); and Withdraw suspends
the real registry row, not just the cache, with the retained
(suspendable) registration surviving for a later restore. All four were
sabotage-verified in both directions.

RuntimeForcePositionRenderCommitTests.cs (B2) drives a real end-to-end
accepted ForcePosition through RuntimeEntityObjectLifetime.TryApplyPosition
and RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition
against a live HostFixture, asserting both the committed render position
AND a cell change that deliberately crosses out of the spawn's outdoor grid
cell, so the cell assertion is independently falsifiable rather than riding
along with the position assertion.

Retires AP-145 (this fix) in docs/architecture/retail-divergence-register.md.
AP-1 and AD-1 are untouched by this commit — they retire separately in the
deletion-sweep commit that follows.

Evidence chain: docs/research/2026-08-05-c5a-contract.md (the governing C5a
slice contract), docs/research/2026-08-05-c5a-architecture-review.md (round
1, FAIL — three MAJORs: vacuous route-7 P4 test, unfixed Withdraw-side
mirror asymmetry, non-driving B2 test), docs/research/2026-08-05-c5a-architecture-review-round2.md
(round 2, PASS with two MINORs — an unfalsifiable B2 cell assertion and the
undocumented SyncPose guard nuance, both fixed here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-05 14:09:11 +02:00
parent 392c1e22c1
commit f8e55ba5e4
11 changed files with 2660 additions and 22 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,348 @@
# C5a — architecture / adversarial review, round 2 (delta)
**Reviewer:** architecture / adversarial (Opus)
**Scope:** delta over round 1
(`docs/research/2026-08-05-c5a-architecture-review.md`). Working tree at
branch `claude/acdream-physics-divergence-5aa784`, HEAD `392c1e22`,
uncommitted. Part 2 (the deletion sweep) and the composition-graph
verification are carried forward from round 1 and not re-litigated — but I
did re-confirm the two survivors and the production blast radius are
unchanged by the fix round (see "Regression check" below).
**Date:** 2026-08-05
---
## VERDICT: **PASS**
All three MAJORs are genuinely closed. A1 and A2 are now backed by tests I
independently traced as discriminating; A2's fix is the *correct* mirror, not a
symmetric-looking wrong one, and the constructor-parameter removal is safe on
every path. A3 built the real drive rather than arguing around it, and the
position half of route 2's B2 is now pinned end-to-end through real production
machinery with a value the wire cannot supply.
Two **MINOR** items to fold in before commit (neither blocks the slice):
- **M1** — the **cell** half of B2 is still not pinned: `entity.ParentCellId`
already equals the asserted value before the drive runs.
- **M2** — the `SyncPose`-inherits-the-guard behavioural nuance is **not**
documented anywhere, contrary to the handoff's claim.
Plus one **INFO** (five test-file comments still cite the deleted
`PhysicsEngine.Resolve` as live).
---
## Gates I re-measured
| Gate | Result |
|---|---|
| `dotnet build AcDream.slnx -c Release -m:1` | **Build succeeded. 0 Warning(s), 0 Error(s)** |
| Complete Release suite (`--no-build -m:1`) | **11,106 passed / 4 skipped / 0 failed** |
| Reconciliation | 11,112 11 (deleted) + 5 (3 shadow-composition + 1 Withdraw fact + 1 force-position) = **11,106 ✓ exact** |
| Skips | 3 (App) + 1 (Core) = **4, unchanged from baseline ✓** |
| Per-assembly vs handoff | Core 4,259/1, Runtime 1,176/0, Headless 86/0, App 4,132/3 — **matches the handoff's numbers exactly ✓** |
| Sink ctor call sites updated | **6/6** (1 production `LivePresentationComposition.cs:514`, 5 test fixtures) |
| Register blast radius | still **3 rows + 2 section headers**; AP-131, AD-60, AD-61/62, AP-135, AP-141144, AP-146 untouched ✓ |
**Regression check on Part 2 (carried, re-verified):** `IsSpawnCellReady` +
`AdjustPosition` still `diff`-clean against HEAD over the full 45-line span.
Production `--numstat` shows executable changes confined to
`LivePresentationComposition` / `SessionPlayerComposition` /
`RuntimePlacementPresentationSink` (+ the deletions and the one seed rename);
`CellTransit`, `ConstraintManager`, `PhysicsBody`, `ResolveResult`,
`HeadlessSessionWorldProjection`, `RuntimeSetPositionState`, and
`RuntimeAcceptedPositionDriveController` are comment/xmldoc only. The fix round
introduced no new executable surface beyond the two sink lines.
---
## A1 — the P4 test now genuinely discriminates. **CLOSED.**
`tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs:296367`
I traced the sabotage myself rather than trusting the claim. With the
`record.ServerGuid == _localPlayerGuid()` gate at
`RuntimePlacementPresentationSink.cs:249` removed:
1. `SyncPose(childEntity, DestinationPosition, …, DestinationCell, force: true)`
runs. `_liveEntities.IsHidden(0x7000A101)` is false; `cellId != 0`;
`IsCurrentVisibleProjection(childEntity)` resolves the child's **own** record
(`TryGetRecord(entity.ServerGuid)`), `ReferenceEquals` holds, and it is the
current spatial root — so the guard does **not** short-circuit.
2. `ShadowPositionSynchronizer.Sync``UpdatePosition(childId, …)`. The
`_entityReg.TryGetValue` at `ShadowObjectRegistry.cs:696` now **succeeds**
(the new baseline `Register` at test `:309320` put the record there), so
the early return that made v1 vacuous no longer fires.
3. `Register(childId, …, seedCellId: DestinationCell)` → flood from the
destination (the same geometry fact 1 proves floods successfully) →
`DeregisterCore` → row **moves** to `DestinationCell`.
Result: `Assert.Contains(GetObjectsInCell(SourceCell), child)` at `:352`
**fails**, and `Assert.Null(fixture.LocalShadow.Current)` at `:366` **also
fails** (`_state.Set` runs as `SyncPose`'s last step). Two independent
discriminators, both keyed to the gate.
The baseline precondition `Assert.Contains(GetObjectsInCell(SourceCell))` at
`:321` is what makes step 2 reachable — it is the thing v1 lacked, and it is
now asserted, not assumed. The `Assert.Equal(1, TotalRegistered)` at `:358` is
supporting only (a move keeps the count at 1); correctly not relied on. The
xmldoc at `:282293` records the v1 failure honestly rather than quietly
replacing it.
**Verified discriminating. No second vacuous version.**
---
## A2 — the Withdraw fix is the *correct* mirror, and the parameter removal is safe. **CLOSED.**
### Is `Suspend` the right counterpart to `SyncPose`'s publish?
Yes, and I checked the two ways it could have been subtly wrong.
- **It is not `Deregister`.** `ShadowObjectRegistry.Suspend`
(`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:14801498`) removes the
entity from every cell bucket and stashes the cell list in
`_suspendedEntityCells`, but **retains `_entityReg`** — it early-returns
`false` if there is no registration and never removes one. Its own xmldoc
calls it "the registry counterpart of retail
`CPhysicsObj::remove_shadows_from_cells` during temporary
leave-world/pending-cell residence; deliberately not logical teardown."
- **The restore path still works.** This is the trap I looked for: if `Suspend`
had dropped `_entityReg`, then `TryApplyWithdrawalRestoration`
`TryPublishPlace``SyncPose(force: true)``UpdatePosition` would hit the
`:696` not-registered early return and **silently no-op while still writing
the cache** — reintroducing the exact AP-145 class on the restore edge. It
does not: `_entityReg` survives `Suspend`, `UpdatePosition` proceeds, and
`Register``DeregisterCore` (`:1789`) clears `_suspendedEntities` so the
entity is no longer treated as suspended by `RefloodOwnerForLandblock`
(`:1568`) or the reflood capture (`:1530`). The restore is clean.
- **It matches the established App-layer pairing.** `Suspend` is exactly what
`LiveEntityProjectionWithdrawalController.LeaveWorld` already does
(`:148 _shadows.Suspend(entity.Id)` + `:156 _localPlayerShadow.Clear()`), and
`LocalPlayerShadowSynchronizer.Suspend` (`:109114`) is precisely that pair in
one call. This is not a novel choice invented for the fix; the sink was the
odd one out.
- **No new early-return.** `Suspend` is unconditional — unlike `SyncPose` it has
no hidden/celless/current-projection guard — so the Withdraw edge cannot
silently skip the way the Place edge theoretically can.
### Is the constructor-parameter removal safe on every path?
Yes. `_localPlayerShadow` had exactly two uses in the sink (Place `.Set`,
Withdraw `.Clear`); both are now synchronizer calls, so the field is genuinely
dead. All **6** `new RuntimePlacementPresentationSink(` sites are updated
(1 production + 5 test fixtures) and the Release build is 0-warning. The
production site still constructs the synchronizer from `d.LocalPlayerShadow`,
so the same single `LocalPlayerShadowState` instance is still the one cache —
the removal narrows the sink's surface without changing which object holds
state. This is a genuine simplification, not just a shuffle.
### Does the new 4th fact discriminate?
`Withdraw_SuspendsRealPhysicsShadow_NotOnlyTheDedupCache` (`:383…`) establishes
a **real** source-cell registration (`:391406`) plus a non-null cache, then
asserts after the Withdraw that `LocalShadow.Current` is null **and**
`GetObjectsInCell(SourceCell)` no longer contains the entity. Under the pre-fix
`_localPlayerShadow.Clear()` the first passes and the **second fails** — and the
test comment at `:419422` says exactly that, correctly labelling the cache
assertion as the non-discriminating half. Right shape.
---
## A3 — the real drive was built; the **position** half of B2 is closed. **SUBSTANTIALLY CLOSED**, see M1.
`tests/AcDream.App.Tests/World/RuntimeForcePositionRenderCommitTests.cs`
This is a real correction, not a re-labelling. The chain is now:
`LiveEntityHydrationController.OnCreate` → real `RuntimeFirstEntryDriveController`
pump → real `RuntimeEntityObjectLifetime.TryApplyPosition` (asserted to yield
`PositionTimestampDisposition.ForcePosition`, `:111`) → real
`RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition`
(`:113119`, asserted `Committed`) → real
`RuntimePlacementProjectionSubscription` on the same placement channel
(`:309312`) → real `RuntimePlacementPresentationSink` → render `WorldEntity`.
Nothing between the wire update and the assertion is hand-authored.
**Why the position assertion is a true discriminator, verified:**
- The wire carries `Z = 0` (`ForceUpdate``ServerPosition(Cell, 15, 15, 0, …)`).
The assertion demands `Z = 0.48f` — the grounded foot-sphere clearance the
**resolver** produces. A value that cannot be an echo of the input is exactly
the right shape for a "came from the committed receipt" claim.
- In this fixture the **only** post-materialization writer of
`entity.Position` is `LiveEntityRuntime.TryApplyRuntimePlacementProjection`,
invoked by the sink. `HostMaterializer` writes it once at create; there is no
`LiveEntityNetworkUpdateController` in the composition, so B1's tolerated
generic write cannot mask anything. Sever the receipt→render write and the
entity stays at the first-entry pose `(10, 10, 0.48)``:127`
(`Assert.Equal(ForcedPosition, entity.Position)`) is the assertion that fails,
and `:126` (`NotEqual(positionBeforeForce, …)`) fails with it.
- `positionBeforeForce` is captured live (`:93`) rather than assumed, so the
"it moved" claim cannot be satisfied by a coincidence of constants.
**Fixture seams — both acceptable, neither touches production:**
`WorldSession.GameActionCapture` is a pre-existing Phase-I.3 test seam
(`src/AcDream.Core.Net/WorldSession.cs:2026`, unmodified by this diff), and
`usePositionFromServer: true` is a legitimate autonomy-level-2 configuration,
not a suppression flag added for the test. Resolving the three obstacles in the
assertions rather than in production code was the correct call.
**B2's status:** the seam B2 actually named — "canonical body moves, render
entity stays put" — is now genuinely covered end to end. I would record B2 as
**closed for position**, with the cell half called out (M1) rather than assumed.
---
## M1 — MINOR. The **cell** half of B2 is still not pinned
**File:** `tests/AcDream.App.Tests/World/RuntimeForcePositionRenderCommitTests.cs:128`
(`Assert.Equal(Cell, entity.ParentCellId)`), with the staging at `:7381`.
`HostMaterializer` sets `ParentCellId = position.LandblockId` = `Cell` at
materialization (`:412`), and the test deliberately picks landblock-local
`(15,15)` so it lands in the **same** outdoor grid cell as the spawn `(10,10)`
(`TerrainSurface.CellSize = 24``cx=0, cy=0` → low word `0x0001` for both).
So `entity.ParentCellId` already equals `Cell` **before** the drive runs — this
is caught vacuous-class #1 (*asserting a field written unconditionally
earlier*). The comment at `:7376` states the choice as a simplification
("without coupling this test to the outdoor grid-cell formula"); the
consequence is that the cell assertion cannot fail.
B2's recorded wording is "asserting the render entity's **position/cell** came
from the committed placement receipt." The position half is now airtight; the
cell half is asserted but unfalsifiable.
**Why it is MINOR, not MAJOR:** the discriminator carrying the test's claim is
the position (including the resolver-only `Z`), it is sabotage-verified, and
the round-1 shadow-composition fact 1 already pins a real cross-cell
`ParentCellId` change (`SourceCell``DestinationCell`) through the same sink
code path. Nothing is unprotected; the cell half is simply not proven *by this
test*.
**Fix direction (small):** force to landblock-local `(30,30)` instead of
`(15,15)``cx=1, cy=1` → outdoor low word `0x000A`, i.e. committed cell
`0x0101000A ≠ Cell`. Then assert `entity.ParentCellId` equals the **committed**
cell and differs from the spawn cell, and capture `cellBeforeForce` the way
`positionBeforeForce` is captured. If that is judged out of scope, record in the
commit message that B2 is closed for position and open for cell — do not book
it as full closure.
---
## M2 — MINOR. The `SyncPose`-inherits-the-guard nuance is not documented
The handoff states the nuance "is now stated in the sink's comment and the test
class doc rather than left implicit." It is not. I grepped both files for
`hidden` / `suspend` / `celless` / `not-current` / `visible projection` /
`IsCurrentVisibleProjection` / `guard`: the only hit is
`RuntimePlacementShadowCompositionTests.cs:347`, inside the P4 test's *sabotage*
reasoning (explaining why the child's own projection is current) — not a
statement of the Place-edge behaviour change. Neither
`RuntimePlacementPresentationSink.cs:245272` nor the test class doc
(`:2089`) mentions it.
The nuance is real and worth one sentence: routing Place through `SyncPose`
means the Place edge now inherits `SyncPose`'s guard
(`LocalPlayerShadowSynchronizer.cs:5359`) — if `IsHidden(playerGuid)`,
`cellId == 0`, or `!IsCurrentVisibleProjection(entity)`, the Place now
**suspends** the shadow where the old direct write merely cached. Both
`TryApplyInitialCreateCompletion` and `TryApplyWithdrawalRestoration` reach
`TryPublishPlace`, so this is reachable on more than the portal edge. The new
behaviour is *correct* (it is what the next per-tick `Sync` would do anyway, and
it is honest about a shadow that should not be published) — which is exactly
why it belongs in a comment and the commit message rather than being discovered
later as a surprise.
Same class as round 1's A5: a statement made in the handoff that the code does
not carry.
---
## INFO — five test-file comments still cite the deleted `PhysicsEngine.Resolve` as live
Production is now clean: every remaining mention in `src/` is an explicit
"deleted, cite by symbol" correction (`CellTransit.cs:880,:1064`,
`HeadlessSessionWorldProjection.cs:797`, `PlayerMovementController.cs:147`). The
A4 fixes are accurate and the `PlayerMovementController` class summary no longer
claims a per-frame call to a deleted method.
Still stale, in test comments only (no behavioural weight, no compiler signal):
- `tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs:89`
- `tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs:23,:82`
- `tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs:22`
- `tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs:301`
Optional sweep; not a gate.
---
## A5 and A6 — **CLOSED, and better than asked**
- **A5.** `PlayerMovementPlacementTransactionTests.cs:2341` now states the
asymmetry plainly: render-root publish **did** move
(`RuntimeSetPositionState.cs:2774`), sticky release **did not move anywhere**
(with the `grep` evidence and the `publishSharedState: false` reason), the
behaviour was dead code so nothing regresses today, and "no layer pins the
invariant … any more. That is disposition 3.6's one real coverage loss." That
is the honest version. I re-verified both halves independently.
- **A6.** `TransitionScratchDifferentialTests.cs:218219` and `:236239` now
assert `IsCommitted` on both engines with distinguishing messages. The
differential can no longer pass on symmetric failure.
---
## Register evidence — re-verified
- **AP-1 — retire: still justified.** Zero `PhysicsEngine.Resolve` /
`.ResolvePlacement` receivers in `src/`; the resolver-shaped entry points no
longer exist, so the row's condition is structurally unreopenable.
- **AD-1 — retire: still justified.** The recoverable outdoor demote and the
outdoor-restore `max(terrainZ, z)` lift were `Resolve`'s body; the body is
gone.
- **AP-145 — retire: now correctly scoped, and it does not overclaim.** The row
(`retail-divergence-register.md:175`) covers **both** halves, names
`TryPublishWithdrawal` and the `#184` shape explicitly, states that the sink
no longer holds a `LocalPlayerShadowState` reference at all, and — notably —
**records that the first version of the P4 fact was vacuous and was corrected
at review**. Every claim in it now maps to something I verified: the
publish-before-cache ordering, `Register`'s `DeregisterCore`, `Suspend`'s
retained registration, the single-instance composition, and four
discriminating facts. Nothing in the row claims more than the fix delivers.
The one thing it does **not** mention is the M2 guard nuance — worth a clause.
---
## Flake attribution — confirmed **#302**, not diff-caused
The reproduced failure is
`PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray`
(`tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs:503`) — exactly the
test `docs/ISSUES.md:1197` files as **#302**: a
`GC.GetAllocatedBytesForCurrentThread()` assertion in `AcDream.App.Tests`,
JIT-tiering sensitive, measured 1-in-6 in isolation and once under full-suite
load. That is the #302 signature, not the load-sensitive `NakEmissionTests`
#308 look-alike that `ISSUES.md:12111224` warns has been conflated twice.
It cannot be diff-caused: the file is untouched (last commit `749e8cee`, zero
working-tree diff), no rendering or portal-projection code is in this change
set, and the assertion measures thread-local GC bytes in a component this diff
does not reach. It passed clean in my own full-suite run
(App 4,132 passed / 0 failed). Correctly named and not chased.
---
## Before commit
1. **M1** — extend the B2 test to a different outdoor grid cell (local `(30,30)`
`0x0101000A`) so the cell half is falsifiable, **or** record B2 as
position-closed / cell-open in the commit message. Do not book full closure
silently.
2. **M2** — add the one-sentence guard nuance to the sink's Place comment and
the AP-145 row.
3. Carry forward round 1's commit-message requirements: the §3.1 audit outcome
(11 deleted / 0 re-pointed), the §3.3 covering-test judgment, the §3.6
coverage-loss declaration (now correctly worded in the test's xmldoc), and
the count reconciliation **11,112 11 + 5 = 11,106 / 4 skips**.

View file

@ -0,0 +1,411 @@
# C5a — independent architecture / adversarial review
**Reviewer:** architecture / adversarial (Opus)
**Scope:** the uncommitted working-tree diff at branch
`claude/acdream-physics-divergence-5aa784`, HEAD `392c1e22`
(`git diff HEAD` + the two untracked test files). Contract:
`docs/research/2026-08-05-c5a-contract.md` (input, not under review).
**Date:** 2026-08-05
---
## VERDICT: **FAIL**
The **deletion sweep (Part 2) is clean and I would pass it on its own.** Every
structural claim I could falsify held: the two survivors are byte-identical,
the build is 0-warning/0-error, the suite reconciles to the line, the register
edits are exactly three rows, and six of the seven test dispositions are
executed as pinned (one better than pinned).
The failure is concentrated in **Part 1 — the parity tests and the AP-145
retirement's evidence chain**:
- **A1** — the route-7-P4 test cited *by name in the AP-145 retirement row* as
proof does not discriminate. Removing the behaviour it claims to pin leaves
all three of its assertions green.
- **A2** — the AP-145 fix closes the `Place` half of the cache-vs-publish
asymmetry and leaves the **exact mirror image on the `Withdraw` half of the
same method pair**, unfixed and unfiled, with a real collision consequence
during a park.
- **A3** — §5.2's carried route-2 B2 acceptance gap is **not closed**. The new
test never drives an accepted ForcePosition; it hand-authors the receipt, and
the surface it exercises is already covered by an existing test.
Under this campaign's own standard — "the review IS the coverage gate", and
four vacuous-test classes already caught this session — A1 alone is
disqualifying: a divergence-register retirement must not rest on a test that
passes under its own sabotage.
---
## Gate evidence I measured myself
| Gate | Result |
|---|---|
| `dotnet build AcDream.slnx -c Release -m:1` | **Build succeeded. 0 Warning(s), 0 Error(s)** |
| Complete Release suite (`--no-build -m:1`) | **11,105 passed / 4 skipped / 0 failed** |
| Reconciliation vs baseline 11,112 | 11,112 11 (`PhysicsEngineTests` methods deleted) + 4 (3 shadow-composition + 1 force-position) = **11,105 ✓ exact** |
| Skips | 3 (App) + 1 (Core) = **4 — same as baseline ✓** |
| Survivors byte-identical | `IsSpawnCellReady` + `AdjustPosition`: `PhysicsEngine.cs:17971839` (new) vs `18071849` (HEAD) — **`diff` clean over the whole 45-line span ✓** |
| Survivor production callers intact | `RuntimeSetPositionState.cs:2188,:4397`; `SessionPlayerComposition.cs:374`; `PhysicsCameraCollisionProbe.cs:38,:100`**all present ✓** |
| Register blast radius | `git diff -U0` = **6 changed lines**: AD/AP section headers + AD-1, AP-1, AP-145 rows. AP-131, AD-60, AD-61/62, AP-135, AP-141144, AP-146 **untouched ✓** |
| #316-preserving pair | `LiveEntityNetworkOnPositionCollapseMatrixTests.cs` **not in the modified-file set — zero diff ✓** |
| `SetPosition``SeedPlacementForTest` re-point | **83 removals / 83 additions**, receivers all controller-typed; **zero `entity.`/`child.`/`Entity.SetPosition` lines touched ✓** |
---
## The composition-graph change — my judgment: **CORRECT, and correctly argued**
I attacked this first as instructed. It holds.
- **Exactly one instance, on every host path.** `new LocalPlayerShadowSynchronizer(`
now has **one** production site in the tree
(`LivePresentationComposition.cs:508`);
`SessionPlayerComposition.cs:804` consumes `live.LocalPlayerShadowSynchronizer`.
`RuntimePlacementPresentationSink` has exactly one production construction
site, also in `LivePresentationComposition.cs:514`. **No other host
constructs either** — `grep` over `src/` for `LivePresentationCompositionPhase`
/ `SessionPlayerCompositionPhase` / `new RuntimePlacementPresentationSink`
returns only `GameWindow.cs:1342/1395` and that one file. Headless and the
no-window Runtime host never touch this sink at all.
- **Same arguments before and after.** `GameWindow.cs:1359` feeds
`_localPlayerShadow` into `LivePresentationDependencies.LocalPlayerShadow`
and `GameWindow.cs:1429` feeds *the same field* into
`SessionPlayerDependencies.PlayerShadow`; `_physicsEngine`, `_liveWorldOrigin`
and `_localPlayerIdentity` are likewise the same instances in both records.
`liveEntities` is the same `LiveEntityRuntime` the old
`live.LiveEntities` read. The relocated construction therefore receives an
argument-identical closure.
- **Ordering is safe.** Construction at `:508` precedes the sink at `:514`;
`LivePresentationResult` has a single construction site (`:1209`) reached only
after `:508`; the field is non-nullable and the sink's ctor
`throw`s on null (`RuntimePlacementPresentationSink.cs:60`). There is no path
to a null or a second instance.
- **Lifetime unchanged.** Both phases publish into the same `GameWindow` shell
through `PublishSessionPlayer`, whose "already owns session/player state"
guard (`GameWindow.cs:10631085`) proves the two phases are composed as one
transaction. Moving construction one phase earlier does not straddle a reset
boundary.
One behavioural nuance worth recording (not a defect): routing through
`SyncPose` means the Place edge now inherits `SyncPose`'s guard — if
`IsHidden`, `cellId == 0`, or `!IsCurrentVisibleProjection`, the Place
**suspends** the shadow where the old direct write merely cached. That is the
correct, symmetric behaviour (it is what the very next per-tick `Sync` would do
anyway) and it is inside the §5.1 pre-authorised production change, so it needs
no separate row. It should be stated in the commit message, since it is the one
place the fix does more than "also publish".
---
## Findings
### A1 — MAJOR. The route-7-P4 test does not discriminate; the AP-145 retirement row cites it as proof
**File:** `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs:264293`
(`Place_ForNonLocalPlayerEntity_NeverTouchesShadowObjects`)
The test's stated job is to prove that the player-only gate at
`src/AcDream.App/World/RuntimePlacementPresentationSink.cs:249`
(`record.ServerGuid == _localPlayerGuid()`) is what keeps a committed CHILD
from gaining a broadphase row — "This drives that directly rather than arguing
it from inspection."
It does not. The fixture never registers the child in `ShadowObjects`, and
`ShadowObjectRegistry.UpdatePosition` returns immediately when the entity has
no registration record:
```
src/AcDream.Core/Physics/ShadowObjectRegistry.cs:696
if (!_entityReg.TryGetValue(entityId, out var reg))
return; // not registered — no-op (callers don't have to gate)
```
**Concrete failure scenario (the sabotage that should fail and doesn't):**
delete the `record.ServerGuid == _localPlayerGuid()` gate so every Place calls
`SyncPose`. Trace it: `IsHidden(0x7000A101)` is false (that guid was never
materialised); `IsCurrentVisibleProjection(childEntity)` resolves the child's
own record and returns true; `ShadowPositionSynchronizer.Sync`
`UpdatePosition(childId, …)` → the early return above → nothing registered.
`TotalRegistered` is still `0`, `GetObjectsInCell(DestinationCell)` is still
empty, `entity.Position` still equals `DestinationPosition`. **All three
assertions pass with the gate removed.** (If instead the `Suspend` branch were
taken, `ShadowObjects.Suspend` on an unregistered id is likewise a no-op — the
test passes either way. It is vacuous on both branches.)
This is caught vacuous-class #4: *a precondition that made the sabotage
irrelevant*. It is also caught class #2 in part — the two load-bearing
assertions are pure negatives against a registry the fixture guaranteed empty.
**Why it matters beyond the test file:** the retired AP-145 row
(`docs/architecture/retail-divergence-register.md:175`) lists, among the four
things "#318's composition test … proves", "*a Place for a non-local-player
entity never touches `ShadowObjects` at all (route 7 P4 …)*". A register
retirement is now standing on a claim the cited test does not establish.
**Fix direction:** give the child a real registration first — mirror fact 1's
baseline `ShadowObjects.Register(entity.Id, …, seedCellId: SourceCell)` and
`Synchronizer.Sync(…, force: true)` — then assert after the Place that the
child's row is **still at `SourceCell` and absent from `DestinationCell`**.
Add `Assert.Null(fixture.LocalShadow.Current)` so the dedup cache is proven
un-polluted too (removing the gate writes the child's pose into the *player's*
cache — a second thing the current test cannot see).
---
### A2 — MAJOR. The fix closes `Place` and leaves the identical asymmetry on `Withdraw`, unfixed and unfiled
**Files:** `src/AcDream.App/World/RuntimePlacementPresentationSink.cs:307308`
vs `src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs:148,:156`
AP-145 was, verbatim, "a plain cache write with no side effect beyond
recording `Current`" on the local-player shadow. The fix routes `TryPublishPlace`
through the real publisher. Six lines further down in the same class,
`TryPublishWithdrawal` still does:
```
src/AcDream.App/World/RuntimePlacementPresentationSink.cs:307
if (record.ServerGuid == _localPlayerGuid())
_localPlayerShadow.Clear();
```
— a bare cache clear with **no** `ShadowObjects.Suspend`. The correct pairing
exists elsewhere in the same subsystem and shows what the sink is missing:
```
src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs:148,156
if (!retainedProjectileShadow)
_shadows.Suspend(entity.Id); // registry
...
_localPlayerShadow.Clear(); // cache
```
**Concrete failure scenario:** a local-player park (`Withdraw`) — the path
`TryApplyWithdrawalRestoration`'s own xmldoc (`:202`) names as touching "the
local-player shadow". The cache says "no shadow"; the registry still carries a
live row for the player at the park's **source** cell. For the whole park
window every other entity's collision sweep in that cell collides with a
phantom player, and nothing self-heals, because a withdrawn player receives no
per-tick `Sync`. Restoration papers over it (`TryPublishPlace``SyncPose`
force-republishes), so the symptom is a transient phantom obstruction during a
park — exactly the "why not observed live" shape AP-145 itself carried.
This is **pre-existing**, not introduced by C5a. But (a) register rule 1 makes
an unrecorded deviation "a bug twice over", (b) this diff is the commit that
retires AP-145 and its retirement text asserts the seam is now symmetric with
ordinary per-tick movement, and (c) it is six lines from the line being fixed —
this is precisely the review's job to catch.
**Fix direction:** either route the withdrawal through
`_localPlayerShadowSync.Suspend(entity)` (a production behaviour change → its
own commit with its own gate, per the no-workarounds rule), **or** file a new
AP row / issue in this same commit recording the Withdraw-half asymmetry and
its "risk if the assumption breaks" column, and narrow AP-145's retirement text
to the `Place` edge it actually covers.
---
### A3 — MAJOR. §5.2's route-2 B2 acceptance gap is not closed; the test largely duplicates existing coverage
**File:** `tests/AcDream.App.Tests/World/RuntimeForcePositionRenderCommitTests.cs:57105`
The contract's §5.2 deliverable: *"an App-layer test driving an **accepted
ForcePosition end to end** through `RuntimePlacementPresentationSink` /
`TryApplyRuntimePlacementPlace` and asserting the render entity's position/cell
came from the committed placement receipt."* B2's original finding is about a
**ForcePosition** producing a receipt that the render entity then follows.
What landed does not drive a ForcePosition at all. It hand-authors a
`RuntimePlacementProjectionSnapshot` (`:107133`) and calls `Sink.TryApply`.
The test's own xmldoc concedes it: *"rather than driving the full
`RuntimeAcceptedPositionDriveController` pipeline."* The receipt's contents are
therefore the **test's assumption**, not the ForcePosition path's output — the
half of B2 that could actually be wrong ("canonical body moves, render entity
stays put") is asserted by narrative.
Worse, the surface it does exercise is already pinned at HEAD:
```
tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs:29
Place_ReframesAndRebucketsExactSidecarWithoutMutatingRuntimePhysics
:57 Assert.Equal(place.WorldPosition, entity.Position);
:58 Assert.Equal(place.Orientation, entity.Rotation);
:59 Assert.Equal(DestinationCell, entity.ParentCellId);
:60 Assert.True(record.IsSpatiallyProjected);
:61 Assert.True(record.IsSpatiallyVisible);
```
Those are the same five facts the new test asserts (`:90104`). The only deltas
are a stale-wire-pose pre-state and `Portal: default`. That is a real but small
increment; it is not the recorded gap.
**Fix direction:** drive `RuntimeAcceptedPositionDriveController
.TryExecuteAcceptedLocalPosition` (or the accepted-ForcePosition entry the
route-2 landing added) so the receipt is **produced** by the path under test,
then assert the render entity against the emitted receipt. If that fixture cost
is judged disproportionate, then B2 must be recorded as **still unmet** in the
plan and the commit message, not marked closed — a partial closure silently
booked as full is how an acceptance gap disappears.
---
### A4 — MINOR (one line is borderline MAJOR). Stale citations of the deleted `PhysicsEngine.Resolve` survive the D7 sweep
All four are plain `<c>`/comment text, so the 0-warning build cannot catch
them:
| File:line | Text | Why it's wrong now |
|---|---|---|
| `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:145` | "PhysicsEngine.Resolve is still used each frame to snap the player to terrain/cell floor Z and detect ground contact." | Class-summary architecture note asserting a **per-frame** call to a method that no longer exists. First thing a reader of the local-movement controller sees. |
| `src/AcDream.Core/Physics/CellTransit.cs:878` | "…mirrors the NO-LANDBLOCK contract in `PhysicsEngine.Resolve`." | Cites a deleted contract as the authority for a live early-return. |
| `src/AcDream.Core/Physics/CellTransit.cs:1059` | "handled at the SNAP by `PhysicsEngine.Resolve`'s `AdjustPosition` validation since #107/#111" | The snap path is gone; `AdjustPosition` survives but is now reached only from `PhysicsCameraCollisionProbe`. |
| `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:794` | "retiring the duplicate `Resolve`/`ResolvePlacement`/`SetPosition` authority" | The contract acknowledged this mention; the retirement it forecasts has now happened, so the comment should close, not linger as a to-do. |
The contract's D7 enumerated only three doc targets and scoped the `cref` sweep
to `PhysicsEngine.cs`, so this is strictly beyond-contract — but the campaign's
"cite by symbol, these move" discipline exists for exactly this, and
`PlayerMovementController.cs:145` is materially misleading.
**Fix direction:** rewrite `:145` to name `ResolveWithTransition` (the real
per-frame resolver) and correct the two `CellTransit` notes to cite
`PhysicsEngine.SetPosition` / `AdjustPosition` by symbol.
---
### A5 — MINOR. A factual error in the rewritten `CommitPreparedPosition` test's xmldoc
**File:** `tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs:2428`
> "The render-root-publish-on-commit and **sticky-target-release-on-commit**
> invariants this test originally pinned now live INSIDE that Runtime
> final-commit transaction."
Half true. Render-root publish **does** live there —
`RuntimeSetPositionState.cs:2774` `_physics.Engine.UpdatePlayerCurrCell(result.CellId)`
inside the dormant-activation final commit. Verified.
The sticky release does **not**. `grep -rn "UnStick" src/` returns **zero**
call sites in `src/AcDream.Runtime/` on that path; the only local-player
`UnStick` is `PlayerMovementController.cs:1892`, inside `SetPositionCore` and
gated on `if (publishSharedState)` — and `PreparePositionForCommit` passes
`publishSharedState: false`. So the unstick-at-first-entry-commit behaviour
was not relocated; it went away with the (already caller-free, therefore
already dead) `CommitPreparedPosition`. That is harmless — it was never running
in production — but the comment states a relocation that did not happen, and a
future reader chasing "where does first-entry unstick happen now?" will be sent
to a method that does not do it.
**Fix direction:** say plainly that the sticky release had no production caller
and is not performed at first-entry commit today; cite
`RuntimeSetPositionState`'s `UpdatePlayerCurrCell` by symbol for the half that
did move.
The rewritten test itself is **good**: `Assert.Null(Constraint)` before /
`Assert.NotNull(Constraint)` + `IsConstrained` after makes it discriminating,
and the two retained negatives are correctly framed as "this layer does not do
this" rather than as the pin.
---
### A6 — MINOR. The re-pointed scratch differential never asserts a placement actually happened
**File:** `tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs:474520`
(`AssertSetPositionBitwise`), used by `ReusedScratch_MatchesFreshPlacementSearch`
The differential compares `expected` against `actual` field-by-field, but there
is no positive assertion that either result committed (`Assert.True(expected.IsCommitted)`,
or `Assert.NotEqual(input, expected.Position)` as `InitialPlacementOverlapTests`
does). A future regression that makes canonical `SetPosition` fail *identically*
on the fresh and reused engines leaves the differential green while the
scratch-reuse surface it guards goes unexercised.
The pre-deletion `ResolvePlacement` arm had the same weakness (it compared `Ok`
rather than asserting it), so this is not a regression introduced here — but
the re-point was the moment to close it, and the new `AssertSetPositionBitwise`
is otherwise excellent (the `ImmutableArray.Equals` reference-comparison note
at `:501505` is the kind of thing that would have produced a false-fail).
---
## Dispositions — did any of the seven lose coverage?
| # | Contract disposition | Executed | Coverage verdict |
|---|---|---|---|
| 3.1 | `PhysicsEngineTests` — audit, then delete | 11 methods deleted, 0 re-pointed, 6 `ResolveWithTransition` methods retained | **No loss.** I re-audited all 11 against HEAD: none touches `AdjustPosition` or `IsSpawnCellReady`; `Resolve_ZeroDeltaSnapTrace_IsExplicitlyOptIn` pinned the `[snap]` diagnostic emitted *inside* the deleted body; the rest pinned legacy floor-snap / step-height / portal-transition semantics that die with the method. `PhysicsEngineAdjustPositionTests` (3 tests: sibling-resolve, no-cell, outdoor-snap) covers the survivor. **The §3.1 audit outcome must still appear in the commit message.** |
| 3.2 | `Issue133…` — re-point (named-bug pin) | Re-pointed to canonical `PhysicsEngine.SetPosition` with the exact #133 geometry (dungeon claim `0x00070143`, dungeon block at world-Y 130 → local Y 60, resident Holtburg neighbour at origin) | **No loss.** Asserts `result.CellId == 0x00070143` and `CellId & 0xFFFF0000 == 0x00070000` on a committed result. If anyone reintroduced an lbPrefix resident-block scan into the canonical path, this fails. Best available pin for a defect whose mechanism no longer exists. |
| 3.3 | `InitialPlacementOverlapTests` — verify-then-delete-or-re-point | Re-pointed | **Judgment correct, verified independently.** `grep -c "ShadowObjects.Register" tests/…/PhysicsSetPositionTests.cs` = **0** — that suite has zero other-entity occupancy; its `placementPasses >= 2` arm (`:11521194`) is BSP-hook-injection driven, exactly as the implementer said. The re-point is discriminating (`Assert.NotEqual(savedFeet, result.Position)` + the centre-distance ≥ 2r check + the 4 m bound). The two-sphere capsule reconstruction matches the legacy scalar `InitPath(0.48, 1.835)` shape. |
| 3.4 | `TransitionScratchDifferentialTests` — re-point or drop explicitly | Re-pointed with `AssertSetPositionBitwise` | **Preserved.** Both the bitwise fresh-vs-reused comparison and the second-identity (`0x80000102`, hostile) leak check survive; the new asserter covers every `PhysicsSetPositionResult` member including the three id arrays. See **A6** for the one gap. |
| 3.5 | `SetPosition``SeedPlacementForTest` | 83 sites / 19 files | **No loss, no meaning change.** 83 removals ↔ 83 additions; the 2-arg → 3-arg conversions all pass `pos` as `cellLocal`, byte-for-byte what the deleted 2-arg overload did; the seed calls the same production `SetPositionCore`, so the AD-61 grounded/zero-velocity start is unchanged. **Zero `entity.`/`child.`/`Entity.SetPosition` (`WorldEntity`) lines touched** — verified by regex over the whole test diff. Both `Assert.Throws<InvalidOperationException>` guard sites re-pointed, preserving `EnsurePublishedForRuntimeOperation` coverage. |
| 3.6 | `CommitPreparedPosition` — re-point at the replacement | 1 rewrite + 2 throw-site re-points | **Partial, honestly declared.** The two throw sites re-point cleanly onto `ArmConstraintLeashAtCommittedPlacement`, which carries the same guard. The rewrite is discriminating. **Coverage genuinely lost:** render-root-publish-on-commit and sticky-release-on-commit are no longer pinned at any layer — the former does exist in Runtime (`RuntimeSetPositionState.cs:2774`) but is not asserted by the re-point; the latter does not exist at all (see **A5**). The contract permitted this only with a commit-message note; the note is in the xmldoc and is **half wrong**. |
| 3.7 | `Begin*` wrappers — keep as documented seam | Kept, xmldoc added to both (`RuntimeSetPositionState.cs:1321,:1343`) | **Correct, as pinned.** |
**Summary: one disposition (3.6) lost real coverage, declared but mis-described.
The other six are clean.** The silent-coverage-loss risk the slice was designed
around did **not** materialise in the deletion sweep — it materialised in the
*new* tests (A1, A3).
---
## Register retirements — verified
- **AP-1 — retire: justified.** I re-ran the census: zero `PhysicsEngine.Resolve`
/ `.ResolvePlacement` receivers in `src/`; the only
`_physics.Engine.SetPosition` sites are in `RuntimeSetPositionState`. After
D1D3 the resolver-shaped entry points do not exist, so the row's condition
is structurally unreopenable. Correct.
- **AD-1 — retire: justified.** The recoverable outdoor demote and the
outdoor-restore `max(terrainZ, z)` lift were `Resolve`'s body; the body is
gone. Correct.
- **AP-145 — retire: justified in mechanism, overstated in evidence.** The
mechanism claim ("`SyncPose` publishes before it records the cache, so the
cache can no longer be pre-seeded ahead of the real publish") is true and I
verified the `Register``DeregisterCore` ordering
(`ShadowObjectRegistry.cs:403`) that backs the no-stale-source-row claim. The
row's fourth proof bullet (route-7 P4) rests on the vacuous test — see **A1**;
and the row's framing implies a symmetry the `Withdraw` half does not have —
see **A2**.
- **Untouchable set held.** AP-131, AD-60, AD-61, AD-62, AP-135, AP-141144,
AP-146 — all unmodified. Section counts updated correctly (AD 48→47, AP
103→101 for two retirements). Strikethrough-plus-RETIRED matches the file's
established convention (31 existing `| ~~…~~` rows).
- **#275 surface untouched.** `InboundPhysicsStateController` and
`RuntimeEntityObjectLifetime` are not in the modified-file set. ✓
---
## Vacuous-test hunt — all four classes, result
| Class | Hunted in | Result |
|---|---|---|
| Asserting a field written unconditionally earlier | all new/changed assertions | Clean. The shadow-composition tests assert `ShadowObjects` rows, which only `SyncPose` writes; the B2 test's `entity.Position` is written only by `TryApplyRuntimePlacementProjection`. |
| Asserting only negatives | `Place_ForNonLocalPlayerEntity_…`, the rewritten transaction test | **HIT on `Place_ForNonLocalPlayerEntity_…`** (two of three assertions are negatives against a guaranteed-empty registry). The transaction test is clean — it pairs its negatives with a positive (`Constraint` non-null) and a precondition (`Constraint` null before). |
| Fixture staging makes the wrong expression compute the right answer | the destination-cell flood geometry (`DestinationPosition = (202,10,5)` with `worldOffsetX: 192`), the two-sphere capsule reconstructions | Clean. The flood-lands-under-`DestinationCell` construction is load-bearing and documented at `:8084`; if it were wrong the discriminating assertion would fail, not falsely pass. |
| A precondition that made the sabotage irrelevant | all three shadow-composition facts | **HIT on `Place_ForNonLocalPlayerEntity_…`** — the child is never registered, so `UpdatePosition`'s not-registered early return makes the gate's presence unobservable. Facts 1 and 2 are clean: both establish a **real** source-cell registration first, which is exactly what makes the "row moved to destination / source row gone" assertions bite. |
Facts 1 and 2 of `RuntimePlacementShadowCompositionTests` are genuinely good
discriminators, and the negative-control comment at `:159165` (naming the
cache assertion as the shape that would have passed under the bug) is exactly
the right way to document a sabotage argument. The problem is confined to the
third fact.
---
## What must happen before a re-review
1. **A1** — make `Place_ForNonLocalPlayerEntity_NeverTouchesShadowObjects`
discriminate (register the child first; assert its row stays at `SourceCell`;
assert the player's cache is not polluted). Re-verify by sabotage: remove the
`_localPlayerGuid()` gate and confirm the **new** assertion is the one that
fails.
2. **A2** — either fix the `Withdraw` half as its own reviewed commit, or file
the deviation as a register row **in this commit** and narrow AP-145's
retirement text to the `Place` edge.
3. **A3** — drive an actual accepted ForcePosition, or record B2 as still open.
4. **A4/A5** — correct the four stale `PhysicsEngine.Resolve` citations and the
sticky-release claim in the transaction test's xmldoc.
5. **A6** — add the "a placement actually committed" positive to the differential.
6. **Commit message** must carry: the §3.1 audit outcome (11 deleted / 0
re-pointed, with the reason), the §3.3 covering-test judgment, the §3.6
coverage-loss declaration, the count reconciliation (11,112 11 + 4 =
11,105 / 4 skips), and the `SyncPose` suspend-guard nuance noted in the
composition section above.

View file

@ -0,0 +1,470 @@
# C5a contract — legacy deletion sweep + carried parity tests (pinned 2026-08-05)
Pinned at HEAD **`392c1e22`** (branch `claude/acdream-physics-divergence-5aa784`),
i.e. AFTER #319 landed. Every symbol, caller census, and line number below was
**re-verified against this HEAD by grep/read**, not inherited from the C5
scoping (`2026-08-05-c5-scoping.md`, written at `52175aa1`) — §9 lists every
place the scoping's picture moved. Baseline: complete Release suite
**11,112 passed / 4 skipped / 0 failed**, measured at `392c1e22` (the #319
commit message records the measurement; re-measure at implementation start,
never inherit — process rule (c)).
**Scope, stated negatively first:**
- **NOT #275.** The steady-state inbound-Position merge
(`InboundPhysicsStateController.TryApplyPosition`, the simple overload) and
`RuntimeEntityObjectLifetime`'s wire-derived `FullCellId` refresh (the
`refreshPosition: acceptedPosition` call, **now at `:1926`** post-#319) are
the C5b behaviour change with its own contract. C5a must not modify either
file's executable code (one test-file doc-comment correction is the only
permitted touch near this surface, §1 D7).
- **NOT the probe strip.** All six `ACDREAM_PROBE_*` temporary flags stay
(C5c); they are env-gated and inert to everything here.
- **NOT AP-131, NOT AD-60's legacy half, NOT AP-145's seam** (except the
pre-authorized red branch in §5.1). Those rows stay in the register
untouched.
**Scope, positively:** the six deletion groups in §1 (~490 production lines),
the seven test-caller dispositions in §3, retirement of register rows **AP-1**
and **AD-1** in the same commit as the deletions, and the two carried parity
tests in §5 (#318 composition; route-2 B2).
---
## 1. Deletion inventory — re-verified at `392c1e22` by symbol
Caller censuses below are exhaustive over `src/` (all `*.cs`). Method: for
`Resolve`, every `.Resolve(` receiver in `src/` was enumerated and typed — 38
distinct receiver/site classes, **none** a `PhysicsEngine` (see the grep-hygiene
note in §3.8: two of them are #319's NEW `ParentAttachmentState.Resolve`, a
name collision that did not exist when the scoping ran its census). For the
others, direct symbol grep over `src/` and `tests/`.
| # | Symbol | Location at HEAD | Production callers | Test callers | ~Lines |
|---|---|---|---|---|---|
| D1 | `PhysicsEngine.Resolve(Vector3, uint, Vector3, float)` | `src/AcDream.Core/Physics/PhysicsEngine.cs:1863``~2200` (body ends before `ResolveWithTransition`'s xmldoc; the live method at `:2223` is a **different member** and stays) | **ZERO** | `PhysicsEngineTests.cs` ×12 (`:41,:48,:66,:88,:111,:150,:186,:211,:391,:434,:446,:460`); `Issue133DungeonTeleportPrefixTests.cs:58` | ~360 |
| D2 | `PhysicsEngine.HasCellSurface` | `PhysicsEngine.cs:1767``~1789` | only `Resolve` itself (`:1887`) — deletes with D1 | none | ~23 |
| D3 | `PhysicsEngine.ResolvePlacement` | `PhysicsEngine.cs:2748``~2815` | **ZERO** (sole non-test mention is the already-recorded retirement comment at `HeadlessSessionWorldProjection.cs:794`) | `InitialPlacementOverlapTests.cs:42`; `TransitionScratchDifferentialTests.cs:185,:194,:208,:217` | ~70 |
| D4 | `PlayerMovementController.SetPosition` (both overloads) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1746,:1760` | **ZERO** — every `.SetPosition(` in `src/` outside `PhysicsEngine.cs` is `WorldEntity.SetPosition` (receivers `entity.`/`child.`) or Core `PhysicsEngine.SetPosition` via `_physics.Engine.` from `RuntimeSetPositionState` (`:2028,:3125,:4789`), the canonical path | 19 test files, ~80 sites (44 in `PlayerMovementControllerTests.cs` alone) — **fixture setup**, not subject (§3.5) | ~30 gross; ~15 net after the retained seed (§3.5) |
| D5 | `PlayerMovementController.CommitPreparedPosition` | `PlayerMovementController.cs:1789` | **ZERO** — production replacement is `ArmConstraintLeashAtCommittedPlacement` (`:1815`), called from `RuntimeLocalPlayerPhysicsPublicationState.cs:774`; `PreparePositionForCommit` (`:1776`) remains production via `RuntimeLocalPlayerPhysicsPublicationState.cs:219` | `PlayerMovementPlacementTransactionTests.cs:42`; `PlayerMovementControllerTests.cs:1158`; `RuntimeLocalPlayerPhysicsPublicationStateTests.cs:3007` | ~25 |
| D6 | `RuntimeSetPositionState.BeginAcceptedPlacement` / `BeginAuthoredPlacement` | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:1321,:1333` | **ZERO** — pure pass-throughs to `BeginAcceptedPlacementCore`; production reaches the core via `Apply` (`:1304`) and the authored sequence | **39 sites across 9 Runtime test files** (scoping said ~40/10; re-censused) | **0 — KEEP as documented test seam** (§3.7) |
| D7 | Doc hygiene | stale `BlipPosition`/`PlayerMovementController.SetPosition` doc refs at `src/AcDream.Core/Physics/Motion/ConstraintManager.cs:25` (note: the scoping's path lacked `Motion/`) and `src/AcDream.Core/Physics/PhysicsBody.cs:442`; the stale `TryApplyPickup (:1116)` citation in `RuntimeAcceptedPositionDriveControllerTests.cs` (~`:221` region; the method lives in `RuntimeEntityObjectLifetime`, currently ~`:1258+`) — **cite by symbol, not line**, in the correction | — | — | ~20 comment lines |
Net production deletion: **~490 lines** (the scoping's ~540 minus D6's kept
~25 and D4's retained seed). All deletions are compile-loud.
**Confirmed unchanged from the scoping's §1c (NOT deletable, re-spot-checked):**
`ILocalPlayerTeleportPlacement`/`LocalPlayerTeleportPlacement.Place` (live
post-commit presentation suffix, `LocalPlayerTeleportController.cs:243` still
calls `entity.SetPosition(controller.Position)` inside it);
`PlayerMovementController.SetPositionCore` (`:1845`, production via
`PreparePositionForCommit`); the route-4 leftovers; AP-135's bookkeeping;
`RuntimeLiveEntitySessionController.cs:141`'s pre-flip path (its file WAS
touched by #319, but the C4/C5-revisit comment's unblock condition is still
unmet).
---
## 2. The survivor hazard — TWO production members inside the deletion region, not one
The scoping named one. Re-verification at HEAD finds **two**:
1. **`IsSpawnCellReady` (`PhysicsEngine.cs:1807`)** — production callers
`RuntimeSetPositionState.cs:2169,:4378` and
`SessionPlayerComposition.cs:374`. Sits between `HasCellSurface` (delete)
and `Resolve` (delete).
2. **`AdjustPosition` (`PhysicsEngine.cs:1813`)** — **the scoping never
dispositioned it.** It is production: `PhysicsCameraCollisionProbe.cs:38,:100`
(the camera collision probe), plus six-plus test files
(`PhysicsEngineAdjustPositionTests`, the camera replay suites,
`Issue177StairDescentCameraFloodTests`, ...). It sits between
`IsSpawnCellReady` and `Resolve` — dead centre of the physical block.
**Pinned survival rule:** the deletion is **member-wise, never region-wise**.
Delete exactly the bodies of `HasCellSurface`, `Resolve`, and
`ResolvePlacement`; `IsSpawnCellReady` (`:1807``:1811`) and `AdjustPosition`
(`:1813``:1861`) remain byte-identical in executable code.
**Xmldoc fallout (same commit):** `IsSpawnCellReady`'s summary contains
`<see cref="Resolve"/>` (in the `:1791``:1806` block) and `HasCellSurface`'s
summary names "the Resolve safety net"; the second dies with its method, the
first must be rewritten (the "loud outdoor-demote safety net" sentence
describes machinery this commit deletes — rewrite the paragraph to describe
the canonical `SetPosition` reality, do not leave a cref to a deleted symbol).
Sweep `PhysicsEngine.cs` for any other `cref="Resolve"`/`cref="ResolvePlacement"`
after the deletion; the build must be warning-clean on missing crefs.
---
## 3. The seven test-only-caller dispositions — re-verified, each carried forward
Framing (from the scoping, still correct): deleting a method whose callers are
tests is compile-loud. The silent hazard is the **disposition of the tests
afterward** — deleting a test that pinned MOVED behaviour loses the pin with a
green build. Each case below is a binding disposition; any deviation must be
argued in the commit message.
### 3.1 `PhysicsEngine.Resolve` unit tests — behaviour GONE → DELETE, after a one-pass audit
`PhysicsEngineTests.cs` (565 lines, 12 `engine.Resolve(` sites). The
outdoor-demote / legacy floor-snap / terrain-lift semantics die with the
method; canonical replacements have their own suites (`PhysicsSetPositionTests`,
`RuntimeSetPositionStateTests`). **Audit before deleting:** any individual
assertion that actually pins canonical-owned behaviour — specifically
`AdjustPosition` semantics, which SURVIVE — is re-pointed at `AdjustPosition`
directly (note `PhysicsEngineAdjustPositionTests.cs` already exists; a
re-point may land there). The audit's outcome (N assertions re-pointed, M
deleted) goes in the commit message.
### 3.2 `Issue133DungeonTeleportPrefixTests` — behaviour MOVED → RE-POINT (named-bug regression pin)
Verified at HEAD: the defect mechanism this test pins — the `lbPrefix`
resident-landblock scan that re-stamped a validated dungeon claim with a
neighbour's prefix — lives **entirely inside `Resolve`'s body**
(`PhysicsEngine.cs:1928,:1937,:1984-1995,:2193`). The canonical
`PhysicsSetPosition` path has **no lbPrefix scan** — the defect class cannot
recur there by construction. That is precisely why the pin must be
**re-pointed, not deleted**: #133 is a closed named bug, and the invariant
(a validated dungeon claim's landblock prefix is authoritative; a committed
cell never gets re-stamped from a neighbouring resident block) must stay
assertable against whatever path owns placement now.
**Re-point spec:** drive a teleport-classified canonical placement
(`RuntimeSetPositionState`, or Core `PhysicsEngine.SetPosition` if the fixture
cost is lower) with the test's exact geometry — dungeon claim `0x00070143`,
dungeon block world-offset so its local Y is negative, resident neighbour
block at origin containing the same XY — and assert the committed cell keeps
the `0x0007` prefix. Alternative accepted by this contract: PROVE an existing
canonical test already pins prefix authority for an off-bounds dungeon claim
and record the proof (test name + assertion) in the deleting commit. Silent
deletion is a contract violation.
### 3.3 `InitialPlacementOverlapTests` — behaviour MOVED → VERIFY-THEN-DELETE
The ring-search half of enter-world placement is ported inside canonical
`SetPosition` (`TransitionTypes.cs:1596` `FindPlacementPosition`, retail
0x0050C170; `PhysicsSetPositionTests.cs` header cites it and has
placement-probe scenarios, e.g. the `placementPasses >= 2` retry arm at
`:1152-1194`). **The audit criterion:** confirm `PhysicsSetPositionTests`
covers the **other-entity-occupancy** ring search this test pins (a relogging
player overlapping a registered creature sphere searches outward to the
nearest clear ring) — occupancy-driven, not merely BSP-failure-driven. If
covered: delete, citing the covering test by name. If not: re-point this
test's scenario through canonical `SetPosition` with a placement class that
reaches `FindPlacementPosition` (~50100 lines), then delete the
`ResolvePlacement` call.
### 3.4 `TransitionScratchDifferentialTests` — differential arm → RE-POINT OR DROP EXPLICITLY
Verified at HEAD: the spec-based sequence arms (`ResolveSpec.Resolve` at
`:618`) call `ResolveWithTransition`**untouched by this slice**. Only
`ReusedScratch_MatchesFreshPlacementSearch` (`:180``:227`, four
`ResolvePlacement` sites) is affected. It is a Slice-I zero-alloc scratch-reuse
differential over the placement search (including the hostile-identity
leak check). **Disposition:** re-point the arm at the canonical entry that
reaches `FindPlacementPos` (Core `SetPosition` with the appropriate placement
class), preserving both the bitwise fresh-vs-reused comparison and the
second-identity leak check. If re-pointing is disproportionate, the arm may be
dropped ONLY with an explicit commit-message decision naming what coverage the
I-slice differential loses — never silently.
### 3.5 `PlayerMovementController.SetPosition` fixture usage — NEITHER gone nor moved → RETAINED SEED + mechanical re-point
Census at HEAD: **19 test files** reference `PlayerMovementController` and
call `.SetPosition(`; ~80 sites total; 44 in `PlayerMovementControllerTests.cs`,
8 in `LocalPlayerTeleportControllerTests.cs`, 5 in `HeadlessSessionHostTests.cs`,
the rest 14 each. (Per-site care: a file can reference the controller and
still call `WorldEntity.SetPosition` — type each site during the re-point,
don't regex-replace blind.)
**Pinned design (the scoping's "cheaper and honest" option, adopted):** keep
**ONE** internal, explicitly-named test seed on the controller — rename the
3-arg overload to `SeedPlacementForTest(Vector3 pos, uint cellId, Vector3 cellLocal)`
(internal; xmldoc states it exists ONLY to seed fixtures and that production
placement flows through `PreparePositionForCommit`
`ArmConstraintLeashAtCommittedPlacement`), delete the 2-arg overload, and
mechanically re-point all ~80 sites. Semantics are reproduced by construction:
the seed calls the SAME `SetPositionCore` (which stays production), so the
grounded, zero-velocity start (the AD-61 force-seed) that dozens of movement
tests assume is unchanged. This is the one production-file signature change
in the slice; its body is untouched.
### 3.6 `CommitPreparedPosition` tests — behaviour MOVED → RE-POINT at the arm/commit replacement
Three sites, each audited individually:
- `PlayerMovementPlacementTransactionTests.cs:42` (100-line file): the
prepared-position transaction assertions run against the production pair
(`PreparePositionForCommit` + `ArmConstraintLeashAtCommittedPlacement`) —
rewrite the test against that pair, or delete it if
`RuntimeLocalPlayerPhysicsPublicationStateTests` provably covers the same
transaction shape (cite which test).
- `PlayerMovementControllerTests.cs:1158` and
`RuntimeLocalPlayerPhysicsPublicationStateTests.cs:3007` both assert
`Throws<InvalidOperationException>` on the uncommitted/displaced state.
Audit whether the replacement arm carries an equivalent guard; if yes,
re-point the throw assertion at it; if the guard died with the method,
delete the assertion WITH a commit-message note (guard semantics gone, not
overlooked).
### 3.7 `BeginAcceptedPlacement`/`BeginAuthoredPlacement` — NEITHER → KEEP AS DOCUMENTED SEAM
39 sites across 9 Runtime test files at HEAD. The wrappers are pure
pass-throughs to the production core (`BeginAcceptedPlacementCore`); deleting
them buys zero behaviour and costs broad mechanical churn across the Runtime
suite. **Disposition: keep, with an xmldoc sentence on each wrapper naming it
a test seam** (so a future sweep does not re-litigate this). This is a
recorded deliberate exception to "delete every superseded legacy path": the
wrappers are not a legacy PATH — the core they call IS the canonical path.
### 3.8 Landmines and grep hygiene
- **The #316-preserving pair** (`LiveEntityNetworkOnPositionCollapseMatrixTests.cs:131,:180`)
pins a defect **preserved verbatim**. C5a must not touch it; it inverts only
with #316's measured fix (C5-gate session / later).
- **#319 introduced `ParentAttachmentState.Resolve`** (`ParentAttachmentState.cs:432`),
called at `EquippedChildRenderController.cs:920` and
`RuntimeLiveEntitySessionController.cs:390`. A mechanical grep for
`.Resolve(` now hits relation resolution — neither site is `PhysicsEngine`.
Any "prove zero callers" re-run during implementation must type receivers,
not count matches.
- The affected-file overlap between #319 and this slice is **empty**: #319
touched `EquippedChildRenderController`, `LiveEntityHydrationController`,
`LiveEntityPresentationController`, `ParentAttachmentState`,
`RuntimeEntityObjectLifetime`, `RuntimeLiveEntitySessionController` — none
contains a C5a deletion target. Verified.
---
## 4. Register retirements — AP-1 and AD-1, with the code evidence; four rows explicitly untouchable
A row retires because the code proves its condition met. Both retirements ride
**in the same commit as the D1D5 deletions** (register rule 1).
### AP-1 — RETIRE. Evidence at `392c1e22`:
Row text: "Production zero-delta routes deliberately remain on the legacy
resolver until 4B2..." — **false at HEAD**:
1. The "legacy resolver" is `PhysicsEngine.Resolve`/`ResolvePlacement`. The
exhaustive receiver census (§1) shows **zero** `PhysicsEngine.Resolve` or
`.ResolvePlacement` call sites in `src/`.
2. Every production placement writer reaches Core `PhysicsEngine.SetPosition`
**only** through `RuntimeSetPositionState` (`:2028,:3125,:4789` — the only
three `_physics.Engine.SetPosition` sites in `src/`).
3. The row's named prerequisites (authored mover, rebucketing,
prefix-quiescence, body publication, atomic route cutover) landed across
C0C4; the local controller's body adoption landed at C3c.
4. Deleting D1D5 makes the retirement **structural**: the resolver-shaped
entry points cease to exist, so no future caller can re-open the row's
condition.
The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62
non-commit outcomes) are separately filed rows/issues and do not block AP-1's
own condition — deleting AP-1 does not orphan them.
### AD-1 — RETIRE. Evidence at `392c1e22`:
Row text: "Production authoritative placement still routes through the legacy
recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift" —
**false at HEAD**: that code is `Resolve`'s body (demote at
`PhysicsEngine.cs:~1890-1910`, the outdoor `max(terrain, z)` lift inside the
snap block ~`:2160-2175`) and `Resolve` has zero production callers. The
lost-cell stand-in the row describes is unreachable from production. Deleting
D1/D2 removes the divergent mechanism outright.
### Must NOT be touched (each blocked on work outside this slice):
- **AP-131** — retires only with **#275** (C5b): the legacy
`TryApplyPosition` unconditional `installPlacementFrame: true, clearParent: true`
is still the ONLY steady-state production Position merge at HEAD.
- **AD-60's legacy half** — same gate (#275): the
`RefreshSnapshot(..., refreshPosition: acceptedPosition)` site — **`:1926`
at HEAD** (the scoping's `:1918` and the register's `:1338` are both stale;
cite the symbol) — still derives `FullCellId` from bare wire acceptance.
- **AP-145** — retires with **#318's fix**, never with its test. The §5.1
composition test makes the asymmetry falsifiable; only the pre-authorized
red branch may touch the seam, and then AP-145 retires in THAT commit.
- **AD-61 / AD-62 / AP-135 / AP-141146** — all carry their own retirement
conditions; none is met by anything in this slice. (AP-146 and the AP-132
amendment are #319's, three days old — do not disturb.)
---
## 5. The two carried parity tests
Both are test-only against HEAD's production code, land BEFORE the deletion
commit (they are independent of it and de-risk the slice's review), and both
follow process rule (e): sabotage-verified, with the WHICH-assertion-fails
check, both directions for dual-layer assertions.
### 5.1 #318 composition test (~150300 lines, App.Tests)
Drive a real portal arrival through the canonical drive controller + the
**REAL** `RuntimePlacementPresentationSink` + the **REAL** `PhysicsEngine`
(fixture patterns exist: `RuntimePlacementPresentationSinkTests.cs`,
`RuntimeFirstEntryHostIntegrationTests.cs`). The discriminating assertion:
> **`PhysicsEngine.ShadowObjects` (`PhysicsEngine.cs:147`) holds a row at the
> destination cell/position** — NEVER merely `LocalPlayerShadowState`'s dedup
> cache. AP-145's bypass (`RuntimePlacementPresentationSink.cs:243`
> `_localPlayerShadow.Set(...)` skipping `LocalPlayerShadowSynchronizer.SyncPose`'s
> publish) both skips the publish AND pre-seeds `SyncPose`'s dedup — a
> cache-only assertion is satisfied by the bug.
Plus the T8 write-ordering assertion from route 3 §8. Sabotage: perturb
`TryPublishPlace` to the cache-only shape and confirm the `ShadowObjects`
assertion (not an incidental one) fails; separately confirm a cache-only
assertion would pass under the same sabotage — proving the discriminator
discriminates.
**Pre-authorized red branch:** this test may legitimately FAIL at HEAD — the
composition drives placement with no subsequent movement tick, which is
exactly the window AP-145 says is unpublished. If red: **C5a's deletion work
does not absorb the fix.** The seam fix (routing the placement's shadow update
through the real publish) is a production behaviour change on a
narrow, low-frequency path; it lands as its **own reviewed commit** together
with the now-green test, retires **AP-145**, and closes **#318** — and the
composition test itself is its designed gate (the C4 handoff explicitly ruled
the connected route out as #318 coverage). If green: land as-is; #318 closes;
AP-145's row is then re-argued (its "why not observed" column may become its
retirement argument) — but only with the green evidence cited.
### 5.2 Route-2 B2 parity test (~100200 lines, App.Tests)
The campaign plan's recorded acceptance gap (plan §C4 route 2, recorded unmet
since 2026-08-03): an App-layer test driving an **accepted ForcePosition end
to end** through `RuntimePlacementPresentationSink` /
`TryApplyRuntimePlacementPlace` and asserting **the render entity's
position/cell came from the committed placement receipt** — closing the
"canonical body moves, render entity stays put" silent seam. Expected green
at HEAD (route 2 landed; the seam is merely uncovered). If red, the same
stop-and-report protocol as 5.1: a red parity test is a found defect, not a
test problem; it gets its own investigation before any deletion lands.
Sabotage: sever the receipt→render write and confirm the position/cell
assertion is the one that fails.
---
## 6. What must REMAIN true — the slice's invariants
1. **Zero production behaviour change.** The production diff consists of:
member deletions with zero callers (D1D5), comment/xmldoc edits (D7, §2),
and exactly one signature change with an untouched body (§3.5's seed
rename). No executable production statement is added or modified —
**except** in the pre-authorized 5.1 red-branch commit, which is its own
reviewed landing with its own register action.
2. **The two survivors survive.** `IsSpawnCellReady` and `AdjustPosition`
keep their exact executable bodies and all production callers
(`RuntimeSetPositionState.cs:2169,:4378`; `SessionPlayerComposition.cs:374`;
`PhysicsCameraCollisionProbe.cs:38,:100`).
3. **Every deleted symbol's absence is proven** by the compiler (all deletions
are compile-loud) AND every test caller has an explicit §3 disposition
executed in the same commit — no test deleted whose pinned behaviour moved
without its re-point landing alongside.
4. **AP-1 and AD-1 retire in the SAME commit as the D1D5 deletions** — never
before (the code proof is the deletion), never after (register rule 1).
5. **The DO-NOT-TOUCH set holds:** AP-131, AD-60, AP-145 (modulo 5.1 red
branch), AD-61/62, AP-135, AP-141146; the #275 surface files' executable
code; the six probe flags; the #316-preserving test pair.
6. **No skips.** The suite ends at 0 failed with the same 4 skips as
baseline — a new skip is a contract violation (process rule (d)).
7. **Counts are measured and reconciled.** The final suite total will move
(deleted legacy tests down, re-points and two parity tests up); the commit
message reconciles the net against baseline 11,112 explicitly (N deleted,
M added, expected total), never hand-waves it.
---
## 7. Gates
- **Complete Release suite** (`dotnet test AcDream.slnx -c Release -m:1` with
`ACDREAM_PAK_PATH` set), baseline **11,112 passed / 4 skipped / 0 failed**
at `392c1e22` — re-measured at slice start AND at each commit. Known flakes,
never conflated (they have been conflated twice): **#302**
(`PortalProjectionTests.ClipToRegion_FrameOwnedStore_…`, GC-allocation,
App.Tests — the `WarmedSteadyContactRefreshDoesNotAllocate` look-alike is
this class) and **#308** (`NakEmissionTests.LossSoak_…`, wall-clock,
Core.Net.Tests, full-suite load only).
- **NO connected gate for C5a — argued, not assumed.** Process rule (g): a
gate must be able to see the defect it gates. C5a's reachable defect
classes are (i) a compile break — seen by the build; (ii) silent coverage
loss — seen only by §3's dispositions and the review, invisible to any live
session; (iii) a behavioural regression — **structurally excluded** by
invariant 1: the production binary's reachable code is byte-equivalent, so
a connected session would exercise identical behaviour and measure nothing.
Precedents: route 5 recorded "no live gate can exist" rather than inventing
one; route 6 was a zero-production-line closure. The 5.1 red-branch commit,
if taken, ALSO needs no connected gate: #318's evidence channel is the
composition test **by design** — the C4 handoff explicitly refused to score
the connected route against it.
- **The review IS the coverage gate.** One dual review over the combined
slice diff (deletions + dispositions + parity tests), reviewers on Opus per
the standing audit rule, with §3's table as the review checklist: for each
of the seven cases, the reviewer confirms the disposition was executed as
pinned or the deviation argued.
---
## 8. Size, commit plan, and the split call
Calibration: campaign landings ran ~127 (route 7) to ~418 (route 3) to
~500 (4b-2) production lines each under full discipline.
| Piece | Production lines | Test lines | Risk |
|---|---|---|---|
| D1D5+D7 deletions + §2 xmldoc | ~490 deleted, ~0 added | ~1,5002,000 deleted/re-pointed across ~25 files | Low — compile-loud; the §3 dispositions are the judgment work |
| §5.1 #318 composition test | 0 (green) / ~1040 (red branch, own commit) | ~150300 | Low; red branch is a decision point, pre-planned |
| §5.2 route-2 B2 test | 0 | ~100200 | Low |
**Call: C5a HOLDS as one slice, in two (possibly three) ordered commits under
this single contract:**
1. **Commit 1 — the two parity tests** (test-only). Lands first: independent
of the deletions, de-risks review, and settles 5.1's green/red question
before the sweep. If 5.1 is red, its fix is **commit 1b** (own review
round, retires AP-145, closes #318) before proceeding.
2. **Commit 2 — the deletion sweep**: D1D5, D7, §2 xmldoc, all §3
dispositions, AP-1 + AD-1 row deletions. One diff, reviewable as one unit.
The ~490-line figure is at the top of the campaign's calibration band, but a
deletion of caller-free code is a different risk class from route 3's ~418
changed lines — the compiler proves most of it. What justifies keeping it
whole rather than splitting D1/D2/D3 (Core) from D4/D5 (Runtime): AP-1's
retirement evidence spans BOTH groups ("the last resolver-shaped entry points"
includes D4/D5), so splitting would either retire AP-1 on a half-proof or
leave the register straddling two commits — both worse than one larger
reviewable deletion. Do NOT fold in: #276's remainder, #317's audit, any
probe change (including the gate-4 `cause=` label improvement — C5c), or any
#275-adjacent edit.
---
## 9. What moved between the scoping (`09911821`, at `52175aa1`) and this contract (`392c1e22`)
1. **`AdjustPosition` is a second production survivor inside the deletion
region** (`PhysicsCameraCollisionProbe.cs:38,:100`) — the scoping's hazard
note named only `IsSpawnCellReady`. A region-wise delete would have taken
the camera collision probe's cell resolver with it. §2 pins member-wise
deletion.
2. **#319 created a `.Resolve(` name collision**: `ParentAttachmentState.Resolve`
(`:432`), called from two files. The scoping's census predates it. Callers
must be typed, not counted (§3.8).
3. **AD-60's legacy-half site moved to `RuntimeEntityObjectLifetime.cs:1926`**
(scoping said `:1918`; the register row still says `:1338`). C5a doesn't
touch it, but C5b's contract must cite by symbol.
4. **Begin* wrapper census: 39 sites / 9 files** (scoping: ~40 / 10).
Immaterial to the disposition.
5. **The scoping's D7 path `ConstraintManager.cs` is actually
`Motion/ConstraintManager.cs`** (`src/AcDream.Core/Physics/Motion/`).
6. **Everything else in the scoping's §1a/§1c/§2/§3 holds exactly at
`392c1e22`**: zero production callers re-proven for all six groups; #319
added no caller to any deletable symbol; the #319-touched file set is
disjoint from every deletion target; the seven dispositions carry forward
unchanged in substance.
7. **New since the scoping, absorbed here:** gate 4 closed 2026-08-05 as a
probe-label artifact (`af828a8a`) — the cell-less falsification is no
longer C5-gate-session work; and route 7's gate criterion was corrected to
the positive child-cell-equals-parent assertion (`2687d893`), whose
still-owed connected run belongs to the C4/#319 ledger, not to C5a.

View file

@ -97,6 +97,12 @@ internal sealed record LivePresentationResult(
RenderSceneShadowRuntime? RenderSceneShadow,
LiveEntityRuntime LiveEntities,
RuntimePlacementPresentationSink PlacementProjection,
// AP-145 fix (2026-08-05, #318): constructed HERE (before the sink that
// consumes it) rather than later in SessionPlayerComposition, so both
// consumers share the ONE synchronizer instance / ONE
// LocalPlayerShadowState cache. Two instances would themselves
// reintroduce a cache-desync class this fix exists to close.
LocalPlayerShadowSynchronizer LocalPlayerShadowSynchronizer,
ProjectileController ProjectileController,
LiveEntityProjectionWithdrawalController ProjectionWithdrawal,
LiveEntityLightController Lights,
@ -491,13 +497,27 @@ internal sealed class LivePresentationCompositionPhase
if (visible)
entityEffects?.OnPresentationBound(record);
});
// AP-145 fix (2026-08-05, #318): constructed here, BEFORE the
// sink, so the sink can publish the local player's Place
// through the same seam ordinary per-tick movement uses rather
// than writing LocalPlayerShadowState directly. Threaded through
// to SessionPlayerComposition via LivePresentationResult so
// there remains exactly one synchronizer / one cache for the
// whole session — SessionPlayerComposition no longer constructs
// its own.
var localPlayerShadowSynchronizer = new LocalPlayerShadowSynchronizer(
d.PhysicsEngine,
liveEntities,
d.PlayerIdentity,
d.WorldOrigin,
d.LocalPlayerShadow);
var placementProjection = new RuntimePlacementPresentationSink(
liveEntities,
worldTransit,
d.WorldGameState,
d.WorldEvents,
d.EffectPoses,
d.LocalPlayerShadow,
localPlayerShadowSynchronizer,
() => d.PlayerIdentity.ServerGuid,
guid =>
{
@ -668,6 +688,7 @@ internal sealed class LivePresentationCompositionPhase
renderSceneShadowLease,
liveEntities,
placementProjection,
localPlayerShadowSynchronizer,
projectileController,
projectionWithdrawal,
lightsLease,
@ -717,6 +738,7 @@ internal sealed class LivePresentationCompositionPhase
RenderSceneShadowRuntime>? renderSceneShadowLease,
LiveEntityRuntime liveEntities,
RuntimePlacementPresentationSink placementProjection,
LocalPlayerShadowSynchronizer localPlayerShadowSynchronizer,
ProjectileController projectileController,
LiveEntityProjectionWithdrawalController projectionWithdrawal,
CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityLightController> lightsLease,
@ -1196,6 +1218,7 @@ internal sealed class LivePresentationCompositionPhase
renderSceneShadow,
liveEntities,
placementProjection,
localPlayerShadowSynchronizer,
projectileController,
projectionWithdrawal,
lightsLease.Resource,

View file

@ -796,12 +796,12 @@ internal sealed class SessionPlayerCompositionPhase
d.AnimatedEntities,
live.AnimationPresenter,
content.AnimationHookFrames);
var localPlayerShadow = new LocalPlayerShadowSynchronizer(
d.PhysicsEngine,
live.LiveEntities,
d.PlayerIdentity,
d.WorldOrigin,
d.PlayerShadow);
// AP-145 fix (2026-08-05, #318): the ONE synchronizer instance is
// now constructed earlier, in LivePresentationComposition, so
// RuntimePlacementPresentationSink's Place edge and this session's
// ordinary per-tick movement publish through the exact same
// publisher / cache rather than two independent instances.
var localPlayerShadow = live.LocalPlayerShadowSynchronizer;
var localPlayerProjection = new LocalPlayerProjectionController(
new LiveLocalPlayerProjectionRuntime(
live.LiveEntities,

View file

@ -26,7 +26,7 @@ internal sealed class RuntimePlacementPresentationSink
private readonly WorldGameState _worldState;
private readonly WorldEvents _worldEvents;
private readonly EntityEffectPoseRegistry _effectPoses;
private readonly LocalPlayerShadowState _localPlayerShadow;
private readonly LocalPlayerShadowSynchronizer _localPlayerShadowSync;
private readonly Func<uint> _localPlayerGuid;
private readonly Action<uint> _clearSelectionForUnavailableEntity;
private readonly Action<LiveEntityRecord, bool>[] _visibilitySinks;
@ -37,7 +37,7 @@ internal sealed class RuntimePlacementPresentationSink
WorldGameState worldState,
WorldEvents worldEvents,
EntityEffectPoseRegistry effectPoses,
LocalPlayerShadowState localPlayerShadow,
LocalPlayerShadowSynchronizer localPlayerShadowSync,
Func<uint> localPlayerGuid,
Action<uint> clearSelectionForUnavailableEntity,
IEnumerable<Action<LiveEntityRecord, bool>>? visibilitySinks = null)
@ -49,8 +49,13 @@ internal sealed class RuntimePlacementPresentationSink
_worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents));
_effectPoses = effectPoses
?? throw new ArgumentNullException(nameof(effectPoses));
_localPlayerShadow = localPlayerShadow
?? throw new ArgumentNullException(nameof(localPlayerShadow));
// AP-145 fix (2026-08-05, #318, architecture review A2): BOTH the
// Place and Withdraw halves now route the local player's shadow
// exclusively through this ONE publisher (SyncPose / Suspend), which
// owns the LocalPlayerShadowState cache internally — this sink no
// longer needs a direct reference to the cache at all.
_localPlayerShadowSync = localPlayerShadowSync
?? throw new ArgumentNullException(nameof(localPlayerShadowSync));
_localPlayerGuid = localPlayerGuid
?? throw new ArgumentNullException(nameof(localPlayerGuid));
_clearSelectionForUnavailableEntity = clearSelectionForUnavailableEntity
@ -240,10 +245,46 @@ internal sealed class RuntimePlacementPresentationSink
if (record.ServerGuid == _localPlayerGuid())
{
_localPlayerShadow.Set(
// AP-145 fix (2026-08-05, #318): route through the SAME
// publisher ordinary per-tick movement uses
// (LocalPlayerShadowSynchronizer.SyncPose), not a direct
// LocalPlayerShadowState.Set. The old direct write updated only
// the dedup cache, never PhysicsEngine.ShadowObjects — the
// portal jump's real collision shadow stayed at the SOURCE cell
// until an unrelated movement tick happened to drift far enough
// to defeat SyncPose's own dedup check (which the direct write
// had just pre-seeded with the destination pose, so even that
// recovery could silently miss). SyncPose both publishes the
// real ShadowObjects row (via Register, which first deregisters
// any prior cell rows — no stale source-cell row, no duplicate)
// and records the dedup cache as its own last step, in the
// correct order. force:true because this IS the authoritative
// placement commit, not an ordinary per-tick refresh — it must
// never be skipped by the dedup path.
//
// Behaviour-change nuance (architecture review, 2026-08-05):
// routing through SyncPose means Place now inherits SyncPose's
// own admission guard — IsHidden(...), cellId == 0, or
// !IsCurrentVisibleProjection(entity) (not the current spatial
// root / not a current record) — none of which the old direct
// .Set() call ever consulted. Under any of those conditions
// SyncPose calls Suspend(entity) instead of publishing: the real
// ShadowObjects row is REMOVED and the cache is cleared, where
// the old write would have left a stale ShadowObjects row in
// place and simply overwritten the cache. This is the correct,
// symmetric behaviour — it is exactly what the very next
// ordinary per-tick Sync call would do in the same situation —
// and it is covered by the same player-only gate this method
// already had, but it IS new: a Place that lands while the
// record is momentarily not the current visible spatial root
// (a narrow, low-frequency window) now suspends the real shadow
// where it previously left a possibly-stale one untouched.
_localPlayerShadowSync.SyncPose(
entity,
entity.Position,
entity.Rotation,
record.FullCellId);
record.FullCellId,
force: true);
}
for (int i = 0; i < _visibilitySinks.Length; i++)
@ -279,7 +320,20 @@ internal sealed class RuntimePlacementPresentationSink
if (!IsCurrent(record, entity))
return false;
if (record.ServerGuid == _localPlayerGuid())
_localPlayerShadow.Clear();
{
// AP-145 fix, Withdraw half (2026-08-05, architecture review
// A2): the exact mirror of the Place-side fix. The old direct
// _localPlayerShadow.Clear() only cleared the dedup cache,
// leaving a LIVE phantom row in PhysicsEngine.ShadowObjects at
// the park's source cell for the whole park window (the #184
// shape) — every other entity's collision sweep in that cell
// would collide with a player who is, per every other acdream
// predicate, gone. Suspend() does both: real registry suspend
// (ShadowObjectRegistry.Suspend) AND the cache clear, in the
// one call LocalPlayerShadowSynchronizer already exposes for
// exactly this pairing (see its own Suspend/SyncPose split).
_localPlayerShadowSync.Suspend(entity);
}
if (!IsCurrent(record, entity))
return false;
_clearSelectionForUnavailableEntity(record.ServerGuid);

View file

@ -502,13 +502,26 @@ public sealed class RuntimeFirstEntryHostIntegrationTests
AcDream.Runtime.Gameplay
.RuntimeLocalPlayerShadowDisposition
.ProvenShapeless));
var localShadowState = new LocalPlayerShadowState();
var localShadowIdentity = new LocalPlayerIdentityState
{
ServerGuid = playerGuid,
};
var localShadowOrigin = new LiveWorldOriginState();
localShadowOrigin.SetPlaceholder(0, 0);
var localShadowSynchronizer = new LocalPlayerShadowSynchronizer(
EntityObjects.Physics.Engine,
Runtime,
localShadowIdentity,
localShadowOrigin,
localShadowState);
var sink = new RuntimePlacementPresentationSink(
Runtime,
new RuntimeWorldTransitState(),
WorldState,
new WorldEvents(),
new EntityEffectPoseRegistry(),
new LocalPlayerShadowState(),
localShadowSynchronizer,
() => playerGuid,
_ => { },
[

View file

@ -0,0 +1,593 @@
using System.Net;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Content.Pak;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.World;
/// <summary>
/// C5a §5.2 (2026-08-05) — the route-2 B2 closure, corrected at the
/// architecture-review re-pass (A3). Route 2's review
/// (<c>docs/research/2026-08-03-c4-route-2-review-findings.md</c> B2) found:
/// "no test drives a route-2 ForcePosition through
/// <see cref="RuntimePlacementPresentationSink"/> /
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementPlace</c> and asserts the
/// <see cref="WorldEntity"/> moved."
///
/// <para>
/// <b>A3 correction:</b> the first version of this test hand-authored a
/// <c>RuntimePlacementProjectionSnapshot</c> and called <c>Sink.TryApply</c>
/// directly — it never drove a ForcePosition at all, and the five facts it
/// asserted were already pinned by
/// <c>RuntimePlacementPresentationSinkTests.Place_ReframesAndRebucketsExactSidecarWithoutMutatingRuntimePhysics</c>.
/// This version drives the REAL production path: a bare
/// <see cref="RuntimeEntityObjectLifetime"/> hosts a live local player
/// (<see cref="LiveEntityHydrationController"/> → the real
/// <see cref="AcDream.Runtime.Session.RuntimeFirstEntryDriveController"/>
/// pump, mirroring <c>RuntimeFirstEntryHostIntegrationTests</c>'s established
/// composition), then a REAL
/// <see cref="AcDream.Runtime.Session.RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition"/>
/// call — fed by the REAL <see cref="RuntimeEntityObjectLifetime.TryApplyPosition"/>
/// merge, exactly like <c>RuntimeAcceptedPositionDriveControllerTests.MergeAccepted</c>
/// — commits the canonical placement. The REAL
/// <see cref="AcDream.Runtime.Physics.RuntimePlacementProjectionSubscription"/>
/// (subscribed to the SAME <see cref="RuntimeEntityObjectLifetime"/>'s
/// placement channel) synchronously forwards the resulting receipt to the
/// REAL <see cref="RuntimePlacementPresentationSink"/>, which writes the
/// render <see cref="WorldEntity"/>. Nothing in this chain is hand-authored;
/// the receipt is the ForcePosition path's OWN output.
/// </para>
///
/// <para>
/// <b>Sabotage-verified (manual):</b> with the receipt→render write severed
/// (<c>RuntimePlacementPresentationSink.TryApply</c> short-circuited to
/// acknowledge-and-ignore a <c>Place</c> without calling
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementProjection</c>), the test
/// fails at exactly the position assertion (the entity stays at its
/// pre-force pose); restored, it is green.
/// </para>
/// </summary>
public sealed class RuntimeForcePositionRenderCommitTests
{
private const uint Cell = 0x01010001u;
private const uint PlayerGuid = 0x7000B201u;
// M1 (architecture review round 2, 2026-08-05): landblock-local (30,30)
// deliberately crosses OUT of the spawn's outdoor grid cell (cx=0,cy=0 ->
// low word 0x0001) into cx=1,cy=1 (TerrainSurface.CellSize=24) -> low
// word 0x000A, so ForcedCell != Cell and the cell assertion below is
// actually falsifiable. The prior (15,15) choice stayed inside the SAME
// grid cell as the spawn, so Assert.Equal(Cell, entity.ParentCellId)
// passed before the drive ever ran — a vacuous cell assertion the
// position assertion's own strength (wire Z=0 vs resolved Z=0.48) had
// been masking.
private static readonly Vector3 ForcedPosition = new(30f, 30f, 0.48f);
private const uint ForcedCell = 0x0101000Au;
[Fact]
public void AcceptedForcePosition_DrivenEndToEnd_MovesRenderEntityFromTheCommittedReceipt()
{
using var fixture = new HostFixture();
fixture.Controller.OnCreate(Spawn(PlayerGuid, Cell));
PlayerMovementController controller = Assert.IsType<PlayerMovementController>(
fixture.Movement.Controller);
Assert.True(fixture.Runtime.TryGetRecord(PlayerGuid, out LiveEntityRecord record));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
Vector3 positionBeforeForce = entity.Position;
// The accepted ForcePosition wire update — a genuine forced position
// and heading correction, admitted by the REAL PhysicsTimestampGate
// (fresh FORCE_POSITION_TS with an equal TELEPORT_TS, retail
// SmartBox::HandleReceivedPosition @0x00453FD0's FORCE_POSITION
// branch) exactly like RuntimeAcceptedPositionDriveControllerTests
// drives it in Runtime.Tests.
WorldSession.EntityPositionUpdate wire = ForceUpdate(ForcedPosition);
Assert.True(fixture.EntityObjects.TryApplyPosition(
wire,
isLocalPlayer: true,
forcePositionRotation: controller.BodyOrientation,
currentLocalVelocity: controller.BodyVelocity,
acknowledgeProjection: null,
out PositionTimestampDisposition disposition,
out _,
out AcceptedPhysicsTimestamps timestamps));
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
RuntimeAcceptedPositionExecutionStatus status =
fixture.Drive.TryExecuteAcceptedLocalPosition(
record.Canonical,
wire,
disposition,
timestamps,
timestamps.PreviousTeleport);
// THE DISCRIMINATING ASSERTIONS. Nothing here is asserted from the
// wire update or a hand-authored snapshot — every value is read back
// from the render entity AFTER the real drive + real subscription +
// real sink chain ran.
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status);
Assert.NotEqual(positionBeforeForce, entity.Position);
Assert.Equal(ForcedPosition, entity.Position);
// M1 (architecture review round 2): ForcedCell genuinely differs
// from the spawn Cell — this assertion is independently
// falsifiable, verified by isolating it ahead of the position
// asserts under the same receipt->render sabotage: it fails on its
// own (entity.ParentCellId stays at the spawn Cell), not merely
// alongside the position assertion.
Assert.NotEqual(Cell, entity.ParentCellId);
Assert.Equal(ForcedCell, entity.ParentCellId);
Assert.True(record.IsSpatiallyProjected);
Assert.True(record.IsSpatiallyVisible);
}
private static WorldSession.EntityPositionUpdate ForceUpdate(
Vector3 position,
ushort positionSequence = 2,
ushort forcePositionSequence = 1) =>
new(
PlayerGuid,
new CreateObject.ServerPosition(
Cell,
position.X,
position.Y,
position.Z,
1f,
0f,
0f,
0f),
Velocity: null,
PlacementId: null,
IsGrounded: true,
InstanceSequence: 1,
PositionSequence: positionSequence,
TeleportSequence: 0,
ForcePositionSequence: forcePositionSequence);
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
{
var position = new CreateObject.ServerPosition(
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: 1);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
[],
[],
[],
null,
null,
"force-position fixture",
(uint)ItemType.Creature,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
/// <summary>
/// Mirrors <c>RuntimeFirstEntryHostIntegrationTests.HostFixture</c>'s
/// established composition (bare <see cref="RuntimeEntityObjectLifetime"/>
/// + REAL <see cref="RuntimePlacementProjectionSubscription"/> + REAL
/// <see cref="RuntimePlacementPresentationSink"/>), extended with a
/// <see cref="RuntimeAcceptedPositionDriveController"/> constructed
/// against the SAME entity objects, movement state, and generation —
/// exactly the shape <c>RuntimeAcceptedPositionDriveControllerTests
/// .CreateAcceptedPositionDrive</c> uses in Runtime.Tests, adapted to
/// the App-layer bare-lifetime pattern instead of a full
/// <c>GameRuntime</c>.
/// </summary>
private sealed class HostFixture : IDisposable
{
internal readonly RuntimeEntityObjectLifetime EntityObjects = new();
internal readonly LiveEntityRuntime Runtime;
internal readonly LiveEntityHydrationController Controller;
internal readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController
FirstEntry;
internal readonly RuntimeAcceptedPositionDriveController Drive;
internal readonly RuntimeLocalPlayerMovementState Movement;
internal readonly WorldGameState WorldState = new();
private readonly WorldSession _session;
internal HostFixture()
{
EntityObjects.BindEventContext(
static () => new RuntimeGenerationToken(1UL),
static () => 1UL);
EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
Cell & 0xFFFF0000u, 1UL);
EntityObjects.Physics.Engine.AddLandblock(
Cell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
Cell & 0xFFFF0000u, 1UL, ready: true);
EntityObjects.Physics.ObserveLocalWorldFrame(
Cell, teleportAdvanced: false);
Movement = new RuntimeLocalPlayerMovementState();
var runtimeIdentity = new RuntimeLocalPlayerIdentityState();
var publication = new RuntimeLocalPlayerPhysicsPublicationState(
EntityObjects.Entities,
EntityObjects.Physics,
Movement,
runtimeIdentity);
Movement.AttachPhysicsPublication(publication);
EntityObjects.LocalPlayerFirstEntry.BindPublication(publication);
runtimeIdentity.ServerGuid = PlayerGuid;
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
(Cell & 0xFFFF0000u) | 0xFFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
Runtime = new LiveEntityRuntime(
spatial,
new NoopResources(),
EntityObjects);
FirstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController(
EntityObjects,
new GameRuntimeClock(),
new UnusedCollisionSource(),
static () => PlayerMovementConstructionOptions.Fallback,
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
0.48f,
1.835f,
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
var localShadowState = new LocalPlayerShadowState();
var localShadowIdentity = new LocalPlayerIdentityState
{
ServerGuid = PlayerGuid,
};
var localShadowOrigin = new LiveWorldOriginState();
localShadowOrigin.SetPlaceholder(0, 0);
var localShadowSynchronizer = new LocalPlayerShadowSynchronizer(
EntityObjects.Physics.Engine,
Runtime,
localShadowIdentity,
localShadowOrigin,
localShadowState);
var sink = new RuntimePlacementPresentationSink(
Runtime,
new RuntimeWorldTransitState(),
WorldState,
new WorldEvents(),
new EntityEffectPoseRegistry(),
localShadowSynchronizer,
() => PlayerGuid,
_ => { },
[(_, _) => { }]);
_ = new AcDream.Runtime.Physics.RuntimePlacementProjectionSubscription(
EntityObjects.Placements,
static () => new RuntimeGenerationToken(1UL),
sink);
var materializer = new HostMaterializer(Runtime);
var identity = new LocalPlayerIdentityState { ServerGuid = PlayerGuid };
var dormant = new DormantLiveEntityStore();
var deletion = new LiveEntityDeletionController(
Runtime,
EntityObjects,
new NoopTeardown(),
identity,
dormant);
Controller = new LiveEntityHydrationController(
Runtime,
EntityObjects,
new object(),
materializer,
new NoopRelationships(),
new AcceptingReady(),
new KnownOrigin(),
new NoopNetworkSink(),
new NoopTimestamps(),
identity,
deletion,
dormant,
firstEntry: FirstEntry);
_session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
new FixtureTransport())
{
// Phase I.3 test seam: intercepts the outbound ack body
// before the wire-write path, which would otherwise NPE on
// an unseeded ISAAC keystream (this fixture never runs a
// real Connect() handshake). This test's subject is the
// canonical commit -> render entity chain, not the outbound
// ack itself.
GameActionCapture = _ => { },
};
Drive = new RuntimeAcceptedPositionDriveController(
EntityObjects,
new GameRuntimeClock(),
new UnusedCollisionSource(),
new LocalPlayerOutboundController((_, _, _, _, _, _) => { }),
static () => new RuntimeGenerationToken(1UL),
static () => PlayerGuid,
() => Movement.Controller,
// usePositionFromServer: true (autonomy level 2) suppresses
// the outbound ack this test doesn't exercise — the fixture
// WorldSession never negotiates ISAAC (no real Connect()),
// so an attempted send would throw. This test's subject is
// the canonical commit -> render entity chain, not the
// outbound ack (that is AP-144's own separately-filed row).
static () => true,
() => _session);
}
public void Dispose()
{
_session.Dispose();
try
{
Runtime.Clear();
}
catch
{
// Failure-path assertions are made before Dispose runs.
}
}
}
private sealed class HostMaterializer(LiveEntityRuntime runtime)
: ILiveEntityProjectionMaterializer
{
public bool TryMaterialize(
RuntimeEntityRecord expectedCanonical,
WorldSession.EntitySpawn canonicalSpawn,
LiveProjectionPurpose purpose,
ulong expectedCreateIntegrationVersion,
AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null)
{
if (canonicalSpawn.Position is not { } position
|| canonicalSpawn.SetupTableId is null)
{
return false;
}
WorldEntity? entity = runtime.MaterializeLiveEntity(
expectedCanonical,
position.LandblockId,
id => new WorldEntity
{
Id = id,
ServerGuid = canonicalSpawn.Guid,
SourceGfxObjOrSetupId = canonicalSpawn.SetupTableId.Value,
Position = new Vector3(
position.PositionX,
position.PositionY,
position.PositionZ),
Rotation = Quaternion.Identity,
MeshRefs = [],
ParentCellId = position.LandblockId,
},
LiveEntityProjectionKind.World,
initializeProjection: null,
out LiveEntityRecord? record,
LiveEntityMaterializationResidence.AwaitRuntimePlacement);
if (entity is null || record is null)
return false;
if (runtime.IsCurrentCreateIntegration(
expectedCanonical,
expectedCreateIntegrationVersion)
&& expectedCanonical.FullCellId != 0u
&& !runtime.HasActiveInitialCreateResidence(expectedCanonical)
&& !runtime.RebucketLiveEntity(
canonicalSpawn.Guid,
expectedCanonical.FullCellId))
{
return false;
}
return runtime.IsCurrentRecord(record);
}
public void ResetSessionState()
{
}
}
private sealed class NoopResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity)
{
}
public void Unregister(WorldEntity entity)
{
}
}
private sealed class NoopTeardown : ILiveEntityTeardownCoordinator
{
public void TearDown(LiveEntityRecord record)
{
}
public void ForgetUnknownOwner(uint serverGuid)
{
}
}
private sealed class NoopRelationships : ILiveEntityRelationshipProjection
{
public void OnSpawn(WorldSession.EntitySpawn spawn)
{
}
public void OnParent(ParentEvent.Parsed update)
{
}
public void OnCreateParentAccepted(CreateParentUpdate update)
{
}
public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) =>
ChildUnparentDisposition.Completed;
public bool TryApplyAttachedAppearance(
LiveEntityRecord record,
ulong objDescAuthorityVersion) => false;
}
private sealed class AcceptingReady : ILiveEntityReadyPublisher
{
public bool Publish(LiveEntityReadyCandidate candidate) => true;
}
private sealed class KnownOrigin : ILiveEntityWorldOriginCoordinator
{
public bool IsKnown => true;
public LiveEntityOriginInitialization TryInitialize(
WorldSession.EntitySpawn spawn) => new(true, []);
}
private sealed class NoopNetworkSink : ILiveEntityNetworkUpdateSink
{
public void ApplySameGeneration(SameGenerationCreateObjectEvents events)
{
}
}
private sealed class NoopTimestamps : IAcceptedLocalPhysicsTimestampPublisher
{
public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps)
{
}
}
private sealed class UnusedCollisionSource : IPreparedCollisionSource
{
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId) => PreparedAssetPresence.Available;
public PreparedCollisionReadResult<FlatSetupCollision> ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<FlatSetupCollision>.Loaded(
new FlatSetupCollision(
System.Collections.Immutable.ImmutableArray<
FlatCollisionCylinder>.Empty,
[new FlatCollisionSphere(Vector3.Zero, 0.48f)],
height: 0f,
radius: 0f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f));
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public PreparedCollisionReadResult<FlatEnvCellTopology> ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public PreparedCollisionSourceStats CollisionStats => default;
public void Dispose()
{
}
}
private sealed class FixtureTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram)
{
}
public void Send(
IPEndPoint remote,
ReadOnlySpan<byte> datagram)
{
}
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromException<NetReceiveResult>(
new OperationCanceledException(cancellationToken));
public void Dispose()
{
}
}
}

View file

@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.App.Net;
@ -869,7 +870,8 @@ public sealed class RuntimePlacementPresentationSinkTests
WorldGameState worldState,
WorldEvents worldEvents,
EntityEffectPoseRegistry effectPoses,
LocalPlayerShadowState localShadow)
LocalPlayerShadowState localShadow,
LocalPlayerShadowSynchronizer synchronizer)
{
Spatial = spatial;
Runtime = runtime;
@ -878,13 +880,14 @@ public sealed class RuntimePlacementPresentationSinkTests
WorldEvents = worldEvents;
EffectPoses = effectPoses;
LocalShadow = localShadow;
Synchronizer = synchronizer;
Sink = new RuntimePlacementPresentationSink(
runtime,
transit,
worldState,
worldEvents,
effectPoses,
localShadow,
synchronizer,
() => Guid,
ClearedSelection.Add,
[
@ -908,6 +911,7 @@ public sealed class RuntimePlacementPresentationSinkTests
internal WorldEvents WorldEvents { get; }
internal EntityEffectPoseRegistry EffectPoses { get; }
internal LocalPlayerShadowState LocalShadow { get; }
internal LocalPlayerShadowSynchronizer Synchronizer { get; }
internal List<(LiveEntityRecord Record, bool Visible)> Visibility { get; } = [];
internal List<uint> ClearedSelection { get; } = [];
internal int VisibilityFailuresRemaining { get; set; }
@ -915,6 +919,24 @@ public sealed class RuntimePlacementPresentationSinkTests
internal static Fixture Create(bool twoLandblocks = false)
{
var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() };
physics.AddLandblock(
SourceCell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
if (twoLandblocks)
{
physics.AddLandblock(
DestinationCell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 192f,
worldOffsetY: 0f);
}
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(SourceCell | 0xFFFFu));
if (twoLandblocks)
@ -922,7 +944,18 @@ public sealed class RuntimePlacementPresentationSinkTests
var resources = new RecordingResources();
LiveEntityRuntime runtime = LiveEntityRuntimeFixture.Create(
spatial,
resources);
resources,
physics);
var identity = new LocalPlayerIdentityState { ServerGuid = Guid };
var origin = new LiveWorldOriginState();
origin.SetPlaceholder(0, 0);
var localShadow = new LocalPlayerShadowState();
var synchronizer = new LocalPlayerShadowSynchronizer(
physics,
runtime,
identity,
origin,
localShadow);
return new Fixture(
spatial,
runtime,
@ -930,7 +963,8 @@ public sealed class RuntimePlacementPresentationSinkTests
new WorldGameState(),
new WorldEvents(),
new EntityEffectPoseRegistry(),
new LocalPlayerShadowState());
localShadow,
synchronizer);
}
internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn)
@ -1069,6 +1103,15 @@ public sealed class RuntimePlacementPresentationSinkTests
var worldEvents = new WorldEvents();
var effectPoses = new EntityEffectPoseRegistry();
var localShadow = new LocalPlayerShadowState();
var localShadowIdentity = new LocalPlayerIdentityState { ServerGuid = Guid };
var localShadowOrigin = new LiveWorldOriginState();
localShadowOrigin.SetPlaceholder(0, 0);
var synchronizer = new LocalPlayerShadowSynchronizer(
engine,
runtime,
localShadowIdentity,
localShadowOrigin,
localShadow);
WorldEntity entity = record.WorldEntity!;
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
entity.Id,
@ -1087,7 +1130,7 @@ public sealed class RuntimePlacementPresentationSinkTests
worldState,
worldEvents,
effectPoses,
localShadow,
synchronizer,
() => Guid,
_ => { },
[

View file

@ -0,0 +1,683 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
using AcDream.Runtime.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.World;
/// <summary>
/// C5a §5.1 (2026-08-05) — the #318 composition test, landed WITH its fix
/// (coordinator override of the contract's original "red branch, fix lands
/// separately" plan — every commit in this campaign stays green). Drives a
/// REAL portal arrival through <see cref="RuntimePlacementPresentationSink"/>
/// AND the REAL <see cref="PhysicsEngine"/> / <see cref="LocalPlayerShadowSynchronizer"/>
/// pair, closing the gap every prior sink test left open: those tests never
/// wired a real <see cref="PhysicsEngine"/> at all, so they could only ever
/// assert on <see cref="LocalPlayerShadowState"/>'s dedup cache — which
/// AP-145's bug pre-seeded regardless of whether the real collision shadow
/// moved.
///
/// <para>
/// <b>AP-145, RETIRED by this commit:</b>
/// <c>RuntimePlacementPresentationSink.TryPublishPlace</c> used to call
/// <c>_localPlayerShadow.Set(...)</c> directly — a pure cache write — instead
/// of routing through <see cref="LocalPlayerShadowSynchronizer.SyncPose"/>,
/// the ONLY code path that calls <c>ShadowPositionSynchronizer.Sync</c> →
/// <c>ShadowObjectRegistry.UpdatePosition</c>, the real collision-shadow
/// publish. Two things went wrong together: (1) the portal jump's real
/// shadow entry never moved to the destination, and (2) the direct cache
/// write PRE-SEEDED <c>SyncPose</c>'s own dedup check, so even a later
/// per-tick <c>SyncPose</c> call would see "nothing changed" and skip the
/// publish it would otherwise have performed. A cache-only assertion — the
/// shape every prior sink test used — was satisfied by the bug: the cache
/// said the right thing, only the real registry didn't.
/// </para>
///
/// <para>
/// <b>The fix:</b> <c>TryPublishPlace</c> now calls
/// <c>_localPlayerShadowSync.SyncPose(entity, entity.Position, entity.Rotation,
/// record.FullCellId, force: true)</c> — the SAME publisher ordinary per-tick
/// movement uses, constructed once and shared (composition root:
/// <c>LivePresentationComposition.cs</c> now builds the ONE
/// <see cref="LocalPlayerShadowSynchronizer"/> instance BEFORE the sink and
/// threads it through <c>LivePresentationResult</c> to
/// <c>SessionPlayerComposition.cs</c>, which no longer constructs its own).
/// <c>force: true</c> because this is the authoritative placement commit,
/// not an ordinary per-tick refresh — it must never be skipped by the dedup
/// path. <see cref="ShadowObjectRegistry.Register"/> (which
/// <c>UpdatePosition</c> calls internally) deregisters every prior cell row
/// for the entity before adding the new ones, so the SOURCE cell's row is
/// replaced, not duplicated — verified explicitly below.
/// </para>
///
/// <para>
/// <b>Architecture review A2:</b> the exact mirror asymmetry lived six lines
/// below the Place fix, on <c>TryPublishWithdrawal</c> — a bare
/// <c>_localPlayerShadow.Clear()</c> with no <c>ShadowObjects.Suspend</c>,
/// leaving a LIVE phantom collision row at the park's source cell for the
/// whole park window (the #184 shape). Fixed in this same commit, in the
/// same one-call shape: <c>TryPublishWithdrawal</c> now calls
/// <c>_localPlayerShadowSync.Suspend(entity)</c>.
/// </para>
///
/// <para>
/// <b>Architecture review A1:</b> the original route-7-P4 test
/// (<see cref="Place_ForNonLocalPlayerEntity_NeverTouchesShadowObjects"/>)
/// never registered the non-player entity, so
/// <c>ShadowObjectRegistry.UpdatePosition</c>'s own not-registered early
/// return made every assertion pass whether or not the player-only gate
/// existed — it was vacuous on both branches. Corrected to establish a REAL
/// baseline registration first, so removing the gate would actually move the
/// row and actually pollute the player's cache.
/// </para>
///
/// <para>
/// <b>Sabotage-verified (manual, all four facts, both directions):</b> with
/// each fix/gate reverted in turn, the corresponding fact fails at exactly
/// its discriminating assertion; with the fix applied, all four are green.
/// </para>
/// </summary>
public sealed class RuntimePlacementShadowCompositionTests
{
private const uint SourceCell = 0x01010001u;
private const uint DestinationCell = 0x01020001u;
private const uint Guid = 0x7000A101u;
private static readonly Vector3 SourcePosition = new(10f, 10f, 5f);
// Landblock-local (10,10) relative to the destination landblock's
// worldOffsetX=192 — falls in the same grid cell (0x0001, cx=0,cy=0,
// TerrainSurface.CellSize=24) as SourcePosition does in ITS landblock,
// so ShadowObjectRegistry's flood actually lands under DestinationCell.
private static readonly Vector3 DestinationPosition = new(202f, 10f, 5f);
private static readonly Vector3 WirePoseDoubleWrite = new(-900f, -900f, -900f);
private static readonly Quaternion DestinationOrientation =
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
/// <summary>
/// The discriminating composition. Establishes a REAL baseline shadow
/// registration at the source pose (proving the synchronizer mechanism
/// works in general), drives a real portal arrival through the sink
/// (T8: with a tolerated "wire pose" double-write already sitting on the
/// entity, exactly like <c>LiveEntityNetworkUpdateController</c>'s early
/// generic write — the committed suffix must overwrite it), then checks
/// the dedup cache (informational negative control — would pass even
/// under the AP-145 bug, kept to make the sabotage argument
/// self-contained), the REAL <see cref="PhysicsEngine.ShadowObjects"/>
/// registry at the destination (the load-bearing assertion — this is
/// what the fix makes true), AND that the source cell's row is gone, not
/// duplicated.
/// </summary>
[Fact]
public void Place_PublishesRealPhysicsShadowAtDestination_NotOnlyTheDedupCache()
{
Fixture fixture = Fixture.Create();
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
// Baseline: establish the player's REAL collision shadow at the
// source pose exactly like ordinary world entry does, and prove the
// synchronizer mechanism is not itself broken (sanity, not the gate).
fixture.Physics.ShadowObjects.Register(
entity.Id,
gfxObjId: entity.SourceGfxObjOrSetupId,
worldPos: SourcePosition,
rotation: Quaternion.Identity,
radius: 0.48f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: SourceCell & 0xFFFF0000u,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 1.835f,
seedCellId: SourceCell);
fixture.Synchronizer.Sync(entity, SourceCell, force: true);
Assert.Contains(
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
e => e.EntityId == entity.Id);
// T8 (route 3 §8 item 9): model the tolerated generic render-pose
// write (LiveEntityNetworkUpdateController.cs:2284-2314) landing on
// the entity BEFORE the committed placement suffix runs. The
// intermediate "wire pose" must never leak past the Place edge.
entity.Position = WirePoseDoubleWrite;
entity.Rotation = Quaternion.Identity;
// A real portal arrival through the canonical placement authority.
RuntimePortalPlacementAuthority portal = fixture.BeginPortal(
DestinationCell,
teleportSequence: 1);
record.FullCellId = DestinationCell;
record.CanonicalLandblockId = (DestinationCell & 0xFFFF0000u) | 0xFFFFu;
record.Canonical.AdvancePlacementCommit();
RuntimePlacementProjectionSnapshot place = Placement(
fixture,
record,
portal,
DestinationPosition,
DestinationOrientation);
Assert.True(fixture.Sink.TryApply(in place));
// T8 END STATE: the committed suffix's resolved pose wins — the
// intermediate wire-pose write never leaks past the Place edge.
Assert.Equal(DestinationPosition, entity.Position);
Assert.Equal(DestinationOrientation, entity.Rotation);
Assert.Equal(DestinationCell, entity.ParentCellId);
// NEGATIVE CONTROL — deliberately NOT the gate. This is the exact
// assertion shape every prior sink test uses
// (RuntimePlacementPresentationSinkTests.Place_Reframes...); it
// would pass EVEN under the AP-145 bug, since the cache is what the
// buggy direct write updated. Kept here to make the sabotage
// argument self-contained: if this assertion were the only
// coverage, the bug would have been invisible.
Assert.Equal(
new LocalPlayerShadowState.Snapshot(
DestinationPosition,
DestinationOrientation,
DestinationCell),
fixture.LocalShadow.Current);
// THE DISCRIMINATING ASSERTION — this is what AP-145's bug broke and
// the fix restores: the real collision shadow used by every OTHER
// entity's collision sweep now actually followed the portal jump.
Assert.Contains(
fixture.Physics.ShadowObjects.GetObjectsInCell(DestinationCell),
e => e.EntityId == entity.Id);
// No double-publish, no stale row left behind: Register/UpdatePosition
// deregisters every prior cell row before adding the new ones, so the
// SOURCE cell must carry zero rows for this entity now.
Assert.DoesNotContain(
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
e => e.EntityId == entity.Id);
}
/// <summary>
/// The second half of AP-145's mechanism, and the second half of the
/// fix's proof: the OLD direct cache write did not merely skip the
/// publish once — it pre-seeded
/// <see cref="LocalPlayerShadowSynchronizer.SyncPose"/>'s own dedup
/// check, so even a SUBSEQUENT ordinary per-tick <c>Sync</c> call (the
/// thing that would normally self-heal a one-frame miss) saw "nothing
/// changed" and skipped the publish too. With the fix, <c>TryPublishPlace</c>
/// itself now runs the real publish through the SAME synchronizer, so
/// the subsequent ordinary tick's dedup skip is no longer a bug — it is
/// CORRECTLY a no-op, because the real registry is already right.
/// </summary>
[Fact]
public void Place_ThenOrdinaryTick_DoesNotNeedToSelfHeal_RealShadowAlreadyRight()
{
Fixture fixture = Fixture.Create();
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
fixture.Physics.ShadowObjects.Register(
entity.Id,
gfxObjId: entity.SourceGfxObjOrSetupId,
worldPos: SourcePosition,
rotation: Quaternion.Identity,
radius: 0.48f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: SourceCell & 0xFFFF0000u,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 1.835f,
seedCellId: SourceCell);
fixture.Synchronizer.Sync(entity, SourceCell, force: true);
RuntimePortalPlacementAuthority portal = fixture.BeginPortal(
DestinationCell,
teleportSequence: 1);
record.FullCellId = DestinationCell;
record.CanonicalLandblockId = (DestinationCell & 0xFFFF0000u) | 0xFFFFu;
record.Canonical.AdvancePlacementCommit();
RuntimePlacementProjectionSnapshot place = Placement(
fixture,
record,
portal,
DestinationPosition,
DestinationOrientation);
Assert.True(fixture.Sink.TryApply(in place));
// Simulate the very next ordinary frame's shadow-sync tick — exactly
// what production runs every frame for the local player. Not forced:
// this is the real per-tick call shape, dedup and all. It is
// EXPECTED to be a no-op now: TryPublishPlace's own SyncPose call
// already did the real work, so the cache already matches.
fixture.Synchronizer.Sync(entity, DestinationCell);
Assert.Contains(
fixture.Physics.ShadowObjects.GetObjectsInCell(DestinationCell),
e => e.EntityId == entity.Id);
Assert.DoesNotContain(
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
e => e.EntityId == entity.Id);
// Exactly one row for this entity anywhere — the ordinary tick's
// dedup no-op did not create a second one.
Assert.Single(
fixture.Physics.ShadowObjects.AllEntriesForDebug(),
e => e.EntityId == entity.Id);
}
/// <summary>
/// Route 7 P4 still binds: a committed CHILD (or any non-local-player
/// entity) must not gain a broadphase row, and must not have its OWN
/// pose written into the PLAYER's dedup cache. The fix lives entirely
/// inside <c>TryPublishPlace</c>'s pre-existing
/// <c>record.ServerGuid == _localPlayerGuid()</c> gate — unchanged by
/// this fix, only what runs INSIDE it changed.
///
/// <para>
/// C5a architecture review A1 (2026-08-05): the FIRST version of this
/// test registered nothing for the child, so
/// <c>ShadowObjectRegistry.UpdatePosition</c>'s own
/// not-registered early return (<c>ShadowObjectRegistry.cs:696</c>) made
/// every assertion pass whether or not the gate existed — removing the
/// gate was unobservable because the sabotaged code path was ALSO a
/// no-op. This version establishes a REAL baseline registration for the
/// child first (mirroring fact 1's own baseline), so a gate-removed
/// sabotage would actually move the child's row and would actually
/// pollute the player's cache — both of which the assertions below now
/// check directly, not by absence of registration.
/// </para>
/// </summary>
[Fact]
public void Place_ForNonLocalPlayerEntity_NeverTouchesShadowObjects()
{
const uint ChildGuid = 0x7000A102u;
Fixture fixture = Fixture.Create();
LiveEntityRecord record = fixture.Materialize(Spawn(ChildGuid, 1, SourceCell));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
Assert.NotEqual(Guid, ChildGuid);
// REAL baseline: the child has its own genuine collision shadow at
// the source pose, exactly like fact 1 establishes for the player.
// A sabotaged (gate-removed) TryPublishPlace WOULD move this row to
// the destination and WOULD write the child's pose into the
// player's LocalShadow cache — both are asserted against below.
fixture.Physics.ShadowObjects.Register(
entity.Id,
gfxObjId: entity.SourceGfxObjOrSetupId,
worldPos: SourcePosition,
rotation: Quaternion.Identity,
radius: 0.48f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: SourceCell & 0xFFFF0000u,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 1.835f,
seedCellId: SourceCell);
Assert.Contains(
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
e => e.EntityId == entity.Id);
RuntimePortalPlacementAuthority portal = fixture.BeginPortal(
DestinationCell,
teleportSequence: 1);
record.FullCellId = DestinationCell;
record.CanonicalLandblockId = (DestinationCell & 0xFFFF0000u) | 0xFFFFu;
record.Canonical.AdvancePlacementCommit();
RuntimePlacementProjectionSnapshot place = Placement(
fixture,
record,
portal,
DestinationPosition,
DestinationOrientation);
Assert.True(fixture.Sink.TryApply(in place));
// The render entity DID move (Place still works for a non-player
// entity) — only the shadow-publish branch is player-gated.
Assert.Equal(DestinationPosition, entity.Position);
// THE DISCRIMINATING ASSERTIONS. Under a sabotaged (gate-removed)
// TryPublishPlace, SyncPose would run for the child: the child's own
// record IS its own current visible projection, so
// IsCurrentVisibleProjection would be true and the real publish
// would execute — moving the row to DestinationCell and vacating
// SourceCell (exactly what fact 1 asserts is CORRECT for the
// player). Here it must NOT happen — this is what tells the
// sabotage apart from the fix.
Assert.Contains(
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
e => e.EntityId == entity.Id);
Assert.DoesNotContain(
fixture.Physics.ShadowObjects.GetObjectsInCell(DestinationCell),
e => e.EntityId == entity.Id);
Assert.Equal(1, fixture.Physics.ShadowObjects.TotalRegistered);
// The player's OWN dedup cache must stay untouched by a non-player
// Place — a sabotaged TryPublishPlace would call
// _localPlayerShadowSync.SyncPose using the CHILD's guid check but
// the PLAYER's cache field (the gate exists to prevent exactly a
// non-player entity's pose from being written where the player's
// pose belongs).
Assert.Null(fixture.LocalShadow.Current);
}
/// <summary>
/// Architecture review A2 (2026-08-05): the exact mirror of AP-145's
/// Place-side bug lived six lines below it, on the <c>Withdraw</c> path
/// — <c>TryPublishWithdrawal</c> cleared only the dedup cache, leaving a
/// LIVE phantom row in <see cref="PhysicsEngine.ShadowObjects"/> at the
/// park's source cell for the whole park window (the #184 shape: every
/// other entity's collision sweep in that cell would collide with a
/// player who is, per every other acdream predicate, gone). Fixed in the
/// same commit as the Place half, via the SAME one-call shape:
/// <c>TryPublishWithdrawal</c> now calls
/// <c>_localPlayerShadowSync.Suspend(entity)</c>, which does the real
/// registry suspend AND the cache clear together.
/// </summary>
[Fact]
public void Withdraw_SuspendsRealPhysicsShadow_NotOnlyTheDedupCache()
{
Fixture fixture = Fixture.Create();
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
// Baseline: a real collision shadow at the source pose, exactly like
// ordinary world entry / fact 1 establishes.
fixture.Physics.ShadowObjects.Register(
entity.Id,
gfxObjId: entity.SourceGfxObjOrSetupId,
worldPos: SourcePosition,
rotation: Quaternion.Identity,
radius: 0.48f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: SourceCell & 0xFFFF0000u,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 1.835f,
seedCellId: SourceCell);
fixture.Synchronizer.Sync(entity, SourceCell, force: true);
Assert.Contains(
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
e => e.EntityId == entity.Id);
Assert.NotNull(fixture.LocalShadow.Current);
RuntimePlacementProjectionSnapshot withdraw = Placement(
fixture,
record,
portal: default,
entity.Position,
entity.Rotation,
RuntimePlacementProjectionKind.Withdraw);
Assert.True(fixture.Sink.TryApply(in withdraw));
// THE DISCRIMINATING ASSERTIONS. Under the pre-fix bug, the cache
// clears (this passes either way) but the real registry keeps the
// SOURCE-cell row (a live phantom for the park's duration) — that is
// what the sabotage below must be able to catch.
Assert.Null(fixture.LocalShadow.Current);
Assert.DoesNotContain(
fixture.Physics.ShadowObjects.GetObjectsInCell(SourceCell),
e => e.EntityId == entity.Id);
// Suspend removes the entity from every cell bucket (TotalRegistered
// — what any collision sweep can find) but is deliberately NOT
// logical teardown (ShadowObjectRegistry.Suspend's own xmldoc: "the
// registry counterpart of retail CPhysicsObj::remove_shadows_from_cells
// during temporary leave-world/pending-cell residence"), so the
// RETAINED registration survives for TryPublishPlace's later
// force:true SyncPose (WithdrawalRestored, or a fresh Place) to
// re-publish from.
Assert.Equal(0, fixture.Physics.ShadowObjects.TotalRegistered);
Assert.Equal(1, fixture.Physics.ShadowObjects.RetainedRegistrationCount);
}
private static RuntimePlacementProjectionSnapshot Placement(
Fixture fixture,
LiveEntityRecord record,
RuntimePortalPlacementAuthority portal,
Vector3 position,
Quaternion orientation,
RuntimePlacementProjectionKind kind = RuntimePlacementProjectionKind.Place)
{
RuntimeEntityRecord canonical = record.Canonical;
var token = new RuntimePlacementProjectionToken(
Sequence: 1,
Revision: 1,
Entity: record.ProjectionKey!.Value,
PositionAuthorityVersion: canonical.PositionAuthorityVersion,
SpatialAuthorityVersion: canonical.SpatialAuthorityVersion,
PlacementCommitVersion: canonical.PlacementCommitVersion,
SessionLifetimeVersion: fixture.Runtime.SessionLifetimeVersion,
ExactCellId: canonical.FullCellId,
CollisionGeneration: 1,
Portal: portal);
return new RuntimePlacementProjectionSnapshot(
token,
kind,
position,
orientation,
CellLocalPosition: position,
InContact: false,
OnWalkable: false);
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort instance,
uint cell)
{
var position = new CreateObject.ServerPosition(
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: instance);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed class Fixture
{
private Fixture(
PhysicsEngine physics,
GpuWorldState spatial,
LiveEntityRuntime runtime,
RuntimeWorldTransitState transit,
WorldGameState worldState,
WorldEvents worldEvents,
EntityEffectPoseRegistry effectPoses,
LocalPlayerShadowState localShadow,
LocalPlayerShadowSynchronizer synchronizer)
{
Physics = physics;
Spatial = spatial;
Runtime = runtime;
Transit = transit;
WorldState = worldState;
WorldEvents = worldEvents;
EffectPoses = effectPoses;
LocalShadow = localShadow;
Synchronizer = synchronizer;
Sink = new RuntimePlacementPresentationSink(
runtime,
transit,
worldState,
worldEvents,
effectPoses,
synchronizer,
() => Guid,
_ => { },
[(_, _) => { }]);
}
internal PhysicsEngine Physics { get; }
internal GpuWorldState Spatial { get; }
internal LiveEntityRuntime Runtime { get; }
internal RuntimeWorldTransitState Transit { get; }
internal WorldGameState WorldState { get; }
internal WorldEvents WorldEvents { get; }
internal EntityEffectPoseRegistry EffectPoses { get; }
internal LocalPlayerShadowState LocalShadow { get; }
internal LocalPlayerShadowSynchronizer Synchronizer { get; }
internal RuntimePlacementPresentationSink Sink { get; }
internal static Fixture Create()
{
var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() };
physics.AddLandblock(
SourceCell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
physics.AddLandblock(
DestinationCell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 192f,
worldOffsetY: 0f);
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(SourceCell | 0xFFFFu));
spatial.AddLandblock(EmptyLandblock(DestinationCell | 0xFFFFu));
var resources = new RecordingResources();
LiveEntityRuntime runtime = LiveEntityRuntimeFixture.Create(
spatial,
resources,
physics);
var identity = new LocalPlayerIdentityState { ServerGuid = Guid };
var origin = new LiveWorldOriginState();
origin.SetPlaceholder(0, 0);
var localShadow = new LocalPlayerShadowState();
var synchronizer = new LocalPlayerShadowSynchronizer(
physics,
runtime,
identity,
origin,
localShadow);
return new Fixture(
physics,
spatial,
runtime,
new RuntimeWorldTransitState(),
new WorldGameState(),
new WorldEvents(),
new EntityEffectPoseRegistry(),
localShadow,
synchronizer);
}
internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn)
{
LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(spawn);
Assert.False(Runtime.HasActiveInitialCreateResidence(
record.Canonical));
Assert.True(record.ResourcesRegistered);
WorldEntity entity = record.WorldEntity!;
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
entity.Id,
entity.SourceGfxObjOrSetupId,
entity.Position,
entity.Rotation);
WorldState.Add(snapshot);
WorldEvents.UpsertCurrent(snapshot);
EffectPoses.PublishMeshRefs(entity);
return record;
}
internal RuntimePortalPlacementAuthority BeginPortal(
uint cell,
ushort teleportSequence)
{
Assert.True(Transit.TryQueueTeleportStart(teleportSequence));
Assert.True(Transit.ActivateQueuedTeleport());
Assert.True(Transit.OfferTeleportDestination(
new RuntimeTeleportDestination(
Guid,
InstanceSequence: 1,
PositionSequence: 1,
TeleportSequence: teleportSequence,
ForcePositionSequence: 1,
new Position(
cell,
new Vector3(1f, 2f, 3f),
Quaternion.Identity)),
teleportTimestampAdvanced: true));
Assert.True(Transit.TryBeginPortalReveal(
teleportSequence,
cell,
out long generation));
Assert.True(Transit.TryRegisterHostProjection(
generation,
cell,
out RuntimeWorldHostProjectionToken host));
return new RuntimePortalPlacementAuthority(
true,
generation,
teleportSequence,
host);
}
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
}
private sealed class RecordingResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity) { }
}
}