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>
18 KiB
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.ParentCellIdalready 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-141–144, 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:296–367
I traced the sabotage myself rather than trusting the claim. With the
record.ServerGuid == _localPlayerGuid() gate at
RuntimePlacementPresentationSink.cs:249 removed:
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)),ReferenceEqualsholds, and it is the current spatial root — so the guard does not short-circuit.ShadowPositionSynchronizer.Sync→UpdatePosition(childId, …). The_entityReg.TryGetValueatShadowObjectRegistry.cs:696now succeeds (the new baselineRegisterat test:309–320put the record there), so the early return that made v1 vacuous no longer fires.Register(childId, …, seedCellId: DestinationCell)→ flood from the destination (the same geometry fact 1 proves floods successfully) →DeregisterCore→ row moves toDestinationCell.
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 :282–293 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:1480–1498) removes the entity from every cell bucket and stashes the cell list in_suspendedEntityCells, but retains_entityReg— it early-returnsfalseif there is no registration and never removes one. Its own xmldoc calls it "the registry counterpart of retailCPhysicsObj::remove_shadows_from_cellsduring temporary leave-world/pending-cell residence; deliberately not logical teardown." - The restore path still works. This is the trap I looked for: if
Suspendhad dropped_entityReg, thenTryApplyWithdrawalRestoration→TryPublishPlace→SyncPose(force: true)→UpdatePositionwould hit the:696not-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:_entityRegsurvivesSuspend,UpdatePositionproceeds, andRegister→DeregisterCore(:1789) clears_suspendedEntitiesso the entity is no longer treated as suspended byRefloodOwnerForLandblock(:1568) or the reflood capture (:1530). The restore is clean. - It matches the established App-layer pairing.
Suspendis exactly whatLiveEntityProjectionWithdrawalController.LeaveWorldalready does (:148 _shadows.Suspend(entity.Id)+:156 _localPlayerShadow.Clear()), andLocalPlayerShadowSynchronizer.Suspend(:109–114) 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.
Suspendis unconditional — unlikeSyncPoseit 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 (:391–406) 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 :419–422 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
(:113–119, asserted Committed) → real
RuntimePlacementProjectionSubscription on the same placement channel
(:309–312) → 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 demandsZ = 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.PositionisLiveEntityRuntime.TryApplyRuntimePlacementProjection, invoked by the sink.HostMaterializerwrites it once at create; there is noLiveEntityNetworkUpdateControllerin 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. positionBeforeForceis 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 :73–81.
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 :73–76 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:245–272 nor the test class doc
(:20–89) 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:53–59) — 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:89tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs:23,:82tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs:22tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs:301
Optional sweep; not a gate.
A5 and A6 — CLOSED, and better than asked
- A5.
PlayerMovementPlacementTransactionTests.cs:23–41now states the asymmetry plainly: render-root publish did move (RuntimeSetPositionState.cs:2774), sticky release did not move anywhere (with thegrepevidence and thepublishSharedState: falsereason), 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:218–219and:236–239now assertIsCommittedon 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/.ResolvePlacementreceivers insrc/; 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 wereResolve'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, namesTryPublishWithdrawaland the#184shape explicitly, states that the sink no longer holds aLocalPlayerShadowStatereference 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'sDeregisterCore,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:1211–1224 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
- 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. - M2 — add the one-sentence guard nuance to the sink's Place comment and the AP-145 row.
- 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.