acdream/docs/research/2026-08-05-c5a-architecture-review-round2.md
Erik f8e55ba5e4 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>
2026-08-05 14:09:11 +02:00

348 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**.