fix(test): cover the atlas-tier seam the D-1 fix depends on; correct AP-150's citation

Both items come from the D-1 fix review (both lenses PASS, D-1 genuinely
closed). No production behaviour changes.

L1 — THE SEAM HAD NO COVERAGE. The D-1 fix's "empty by construction" claim
rests on LandblockSpawnAdapter's atlas-tier filter (`if (entity.ServerGuid
!= 0) continue;`) skipping the live server projections DetachNearLayer
deliberately RETAINS across a demote. The reviewer removed that filter and
all 4,170 App tests passed — only two Core unit tests caught it, none
through a demote. So the invariant the re-assert depends on could have been
deleted silently, re-opening D-1 by another route: a non-empty re-assert
whose mesh reference is never satisfied leaves IsRenderReady false, which is
the portal hang again.

NearToFarDemote_WithALiveServerEntity_StaysRenderReady now demotes a
landblock that CARRIES a live server-spawned entity through the real
GpuWorldState + LandblockSpawnAdapter + LandblockPresentationPipeline, and
asserts the retained entity never enters the desired set.

Sabotage-verified: with the filter removed, exactly one test fails — this
one — and the other 25 pass, including all four D-1 regression tests. That
is the finding restated as a measurement: the D-1 tests genuinely do not
cover this seam, and now something does.

AP-150 citation corrected: the row cited 0x004D7064 as the
ECM_UI::SendNotice_DisplayStringInfo call site. That address is the
PStringBase construction of the "In Portal Space - Please Wait..." literal
(:219516); the actual call is 0x004D70A1 (-> 0x006925B0). Same class of slip
the #280 commit had just corrected for #326 — worth noting that a row filed
WITH a byte-level disassembly still mis-cited a neighbouring address.

Also refactored the existing pipeline demote test to keep its doc comment
attached to its own method (an earlier insertion had orphaned its [Fact]).

App.Tests 4,170 -> 4,171 passed / 3 skipped, net +1 for the new test. No new
skips; none of #302/#308/#321 surfaced. src/ is byte-unchanged (the sabotage
was reverted and verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 07:54:03 +02:00
parent 43cfdc4a40
commit bcb66ccdf3
3 changed files with 552 additions and 1 deletions

View file

@ -176,7 +176,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-147 | **Filed 2026-08-05 at the C5b architecture review (finding D3) — an unfiled delta-stream cardinality change C5b introduced, which its own conservation test could not see.** A cell-changing accepted steady-state Position now publishes **two** `RuntimeEntityDelta`s for the moved entity where it published one, and the intermediate one carries a torn cell/position pair. Pre-C5b the merge itself moved `FullCellId`, so it published `Rebucketed` and the `OnPosition` prologue rebucket's `CommitRebucket` then early-returned publish-less (`previous == fullCellId`) — stream `[Rebucketed]`. Post-C5b the merge moves nothing, so it publishes `Updated` and `CommitRebucket` publishes the `Rebucketed` — stream `[Updated, Rebucketed]`. The `Updated` element is assembled from the canonical record BETWEEN the two writes, so its `CellId` is the OLD (committed) cell while its `Position` is the NEW wire pose: a pair that did not previously exist on this stream, because pre-C5b both halves moved inside one publish. Total per packet is conserved in KIND and final VALUE — exactly one `Rebucketed`, at the same cell, from the same publisher — but not in COUNT, and not in intermediate consistency. **AMENDED 2026-08-05 at the C5b closeout (bookkeeping only — nothing in this row was false, it was un-updated).** This row was written from the graphical host at a moment when it was the only host producing the two-delta stream at all: pre-D1 the no-window host had no post-merge cell writer, so its accepted Position published `[Updated]` alone and simply LOST the `Rebucketed`. D1 gave that host its own `CommitWireCellRebucket` caller, so both hosts now produce `[Updated, Rebucketed]` with the same torn intermediate. The row's analysis, its "no production consumer identified today" verdict, and its retirement condition are unchanged; what changed is the population — a headless bot's event log is now a REAL instance of the "future consumer that SNAPSHOTS a delta" this row warns about, not a hypothetical one, because the no-window host is the one whose consumers are event streams by construction. | `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`'s terminal `AcknowledgeProjectionAndPublish`, and `CommitRebucket`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs` (`Snapshot` — the `record.FullCellId` / `record.Snapshot.Position` pairing that makes the intermediate torn) | Retail has no delta stream at all, so there is no retail shape to match — this is acdream's own observer contract. The alternative, suppressing the merge's `Updated` when a rebucket is about to follow, is not available at that layer: the merge cannot know whether its caller will reach W2 (the local force arm, the missile arm, and the `ChildUnparentDisposition` Superseded/Pending arm all return before it), so suppressing would silently drop the pose delta on exactly the packets where it is the only one. Collapsing the merge's ternary to a constant `Updated` is likewise wrong — the retained `Rebucketed` arm has a real producer, the cancelled-park rollback inside the merge. | Any consumer that treats one accepted Position as one entity delta now sees two, and any consumer that reads `CellId` and `Position` from the SAME delta and assumes they agree can transiently pair a new position with the old cell. No production consumer identified today: `LiveEntityRuntime` and the plugin/world-event surfaces re-read canonical state rather than trusting a delta's paired fields, and the pair reconverges inside the same `OnPosition` call. A future consumer that SNAPSHOTS a delta — a recorder, a plugin, a headless bot event log — would capture the torn intermediate. Retire together with W2, if the local player's canonical cell ever becomes per-crossing-fresh (AP-146/#320) and the merge and the rebucket can be one write again. | No retail anchor — acdream-only observer contract. Evidence: `RuntimeSteadyStatePositionMergeTests.CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation` asserts the complete ordered stream `[Updated, Rebucketed]` plus both elements' `CellId`/`Position.ObjCellId`, and `RuntimeSetPositionStateTests.AcceptedPositionCancellingWakeableParkPublishesRebucketedThroughTheMerge` pins the retained arm; both sabotage-verified in both directions at the C5b review. | | AP-147 | **Filed 2026-08-05 at the C5b architecture review (finding D3) — an unfiled delta-stream cardinality change C5b introduced, which its own conservation test could not see.** A cell-changing accepted steady-state Position now publishes **two** `RuntimeEntityDelta`s for the moved entity where it published one, and the intermediate one carries a torn cell/position pair. Pre-C5b the merge itself moved `FullCellId`, so it published `Rebucketed` and the `OnPosition` prologue rebucket's `CommitRebucket` then early-returned publish-less (`previous == fullCellId`) — stream `[Rebucketed]`. Post-C5b the merge moves nothing, so it publishes `Updated` and `CommitRebucket` publishes the `Rebucketed` — stream `[Updated, Rebucketed]`. The `Updated` element is assembled from the canonical record BETWEEN the two writes, so its `CellId` is the OLD (committed) cell while its `Position` is the NEW wire pose: a pair that did not previously exist on this stream, because pre-C5b both halves moved inside one publish. Total per packet is conserved in KIND and final VALUE — exactly one `Rebucketed`, at the same cell, from the same publisher — but not in COUNT, and not in intermediate consistency. **AMENDED 2026-08-05 at the C5b closeout (bookkeeping only — nothing in this row was false, it was un-updated).** This row was written from the graphical host at a moment when it was the only host producing the two-delta stream at all: pre-D1 the no-window host had no post-merge cell writer, so its accepted Position published `[Updated]` alone and simply LOST the `Rebucketed`. D1 gave that host its own `CommitWireCellRebucket` caller, so both hosts now produce `[Updated, Rebucketed]` with the same torn intermediate. The row's analysis, its "no production consumer identified today" verdict, and its retirement condition are unchanged; what changed is the population — a headless bot's event log is now a REAL instance of the "future consumer that SNAPSHOTS a delta" this row warns about, not a hypothetical one, because the no-window host is the one whose consumers are event streams by construction. | `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`'s terminal `AcknowledgeProjectionAndPublish`, and `CommitRebucket`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs` (`Snapshot` — the `record.FullCellId` / `record.Snapshot.Position` pairing that makes the intermediate torn) | Retail has no delta stream at all, so there is no retail shape to match — this is acdream's own observer contract. The alternative, suppressing the merge's `Updated` when a rebucket is about to follow, is not available at that layer: the merge cannot know whether its caller will reach W2 (the local force arm, the missile arm, and the `ChildUnparentDisposition` Superseded/Pending arm all return before it), so suppressing would silently drop the pose delta on exactly the packets where it is the only one. Collapsing the merge's ternary to a constant `Updated` is likewise wrong — the retained `Rebucketed` arm has a real producer, the cancelled-park rollback inside the merge. | Any consumer that treats one accepted Position as one entity delta now sees two, and any consumer that reads `CellId` and `Position` from the SAME delta and assumes they agree can transiently pair a new position with the old cell. No production consumer identified today: `LiveEntityRuntime` and the plugin/world-event surfaces re-read canonical state rather than trusting a delta's paired fields, and the pair reconverges inside the same `OnPosition` call. A future consumer that SNAPSHOTS a delta — a recorder, a plugin, a headless bot event log — would capture the torn intermediate. Retire together with W2, if the local player's canonical cell ever becomes per-crossing-fresh (AP-146/#320) and the merge and the rebucket can be one write again. | No retail anchor — acdream-only observer contract. Evidence: `RuntimeSteadyStatePositionMergeTests.CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation` asserts the complete ordered stream `[Updated, Rebucketed]` plus both elements' `CellId`/`Position.ObjCellId`, and `RuntimeSetPositionStateTests.AcceptedPositionCancellingWakeableParkPublishesRebucketedThroughTheMerge` pins the retained arm; both sabotage-verified in both directions at the C5b review. |
| AP-148 | **Filed 2026-08-05 at the C5b closeout, from disassembly of the PDB-paired binary — NOT from the pseudo-C, which cannot show it.** acdream's local-player Gate A (the FORCE_POSITION self-echo shortcut) requires the wire TELEPORT_TS to be EXACTLY EQUAL to the stored one; retail requires only that it not be OLDER, so equal AND newer both take the shortcut. `SmartBox::HandleReceivedPosition` @0x0045402B-54 loads `player->update_times[4]` (TELEPORT_TS; base 0x164, 2 bytes/entry, confirmed by the POSITION_TS store `mov word [edx+0x164], ax` @0x00454084 and `acclient.h:6090`), takes `abs(stored - wire)`, picks a wrapped or unwrapped 16-bit compare on `> 0x7fff`, materialises the carry with `sbb eax,eax / neg eax`, and SKIPS Gate A on CF — where CF means the wire stamp is strictly older. It is `CPhysicsObj::newer_event` @0x00451B10's identical idiom with the compare operands swapped. **Binary Ninja drops the flag test and renders the whole sequence as `if (-((eax_7 - eax_7)) == 0)`, vacuously true**, which is why two C5b review rounds read this function carefully and both recorded the term backwards (`docs/research/2026-08-05-c5b-contract.md` §1 said first "teleport must NOT be newer", then "TELEPORT_TS equal"; both corrected at §15). **Consequence:** acdream's `ForcePosition` disposition is a strict SUBSET of retail's Gate A set. A local ForcePosition carrying a NEWER teleport stamp is misrouted into a full `Apply`, which is four separate behaviour changes at once — it takes the WIRE heading instead of preserving the body's (`InboundPhysicsStateController.ApplyAcceptedPosition:846-856`, force-gated), it UNPARENTS and may install a placement frame (`clearParent: !force`, `installPlacementFrame: !force && !hasAnimations` — C5b's own truth table), it sets `TeleportAdvanced` and therefore ZEROES local velocity (`:882-885`), and it advances TELEPORT_TS and calls `OfferTeleportDestination`, starting teleport/portal presentation for a packet retail never starts it for. Retail's Gate A deliberately lets a force ride PAST a pending teleport advance without consuming it (it returns @0x0045409D before `newer_event(arg2, TELEPORT_TS, arg8)` @0x00454158); the ordinary Position channel is what processes that teleport. **Not fixed in the filing commit**, deliberately: see issue #325 for why it is not a one-line comparison swap. **C5b made this marginally BETTER, not worse**`clearParent` was unconditionally `true` pre-C5b and is unchanged for the misrouted packet, and `installPlacementFrame` went unconditional-`true` to `!force && !hasAnimations`, i.e. toward retail's "Gate A never reaches `SetPlacementFrame`". | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptPositionEvent:199`, the `teleport == _timestamps[Teleport]` term); `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs` (`ValidAcceptedAuthority`, the `PreviousTeleportSequence == AcceptedTeleportSequence` term — the SAME predicate encoded a second time, and the reason the fix is not one line) | None argued — this is an unintended narrowing found at a closeout, not a chosen approximation. It is filed as an approximation rather than a defect only because the resulting behaviour is a strictly SMALLER shortcut set, i.e. more packets take the fully-processed path rather than fewer, which fails safe for pose correctness even where it is wrong about heading, parent, velocity, and presentation. The exact retail predicate already exists verbatim in the same file — `IsFreshTeleportStart:163` is `!IsNewer(teleport, _timestamps[Teleport])` — so the correction itself is trivial; the consumers are not. | A server correction that arrives while the client's TELEPORT_TS is behind ACE's (a teleport whose Position packet was lost, or arrived after the force) is promoted from "blip me in place" to a full teleporting apply: the player's facing snaps to the wire heading instead of staying where the mouse left it, local velocity is zeroed mid-stride, an equipped child is unparented, and the portal/transit presentation owner is offered a destination for a packet that is not a teleport. Reachability against ACE is UNMEASURED — ACE's two `ObjectForcePosition` bumps (`Player.cs:1148` PKLite re-placement, `Player_Tick.cs:488` z-hack correction) do not themselves bump the teleport sequence, but `PositionPack` serialises the CURRENT teleport sequence, so any client whose TELEPORT_TS lags ACE's is in the divergent window on its next force. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (Gate A's teleport test @0x0045402B-0x00454054; the return @0x0045409D; the TELEPORT_TS advance it skips @0x00454158); `CPhysicsObj::newer_event` 0x00451B10 (the same idiom, operands unswapped); `acclient.h:6090` (`update_times[4] == TELEPORT_TS`) | | AP-148 | **Filed 2026-08-05 at the C5b closeout, from disassembly of the PDB-paired binary — NOT from the pseudo-C, which cannot show it.** acdream's local-player Gate A (the FORCE_POSITION self-echo shortcut) requires the wire TELEPORT_TS to be EXACTLY EQUAL to the stored one; retail requires only that it not be OLDER, so equal AND newer both take the shortcut. `SmartBox::HandleReceivedPosition` @0x0045402B-54 loads `player->update_times[4]` (TELEPORT_TS; base 0x164, 2 bytes/entry, confirmed by the POSITION_TS store `mov word [edx+0x164], ax` @0x00454084 and `acclient.h:6090`), takes `abs(stored - wire)`, picks a wrapped or unwrapped 16-bit compare on `> 0x7fff`, materialises the carry with `sbb eax,eax / neg eax`, and SKIPS Gate A on CF — where CF means the wire stamp is strictly older. It is `CPhysicsObj::newer_event` @0x00451B10's identical idiom with the compare operands swapped. **Binary Ninja drops the flag test and renders the whole sequence as `if (-((eax_7 - eax_7)) == 0)`, vacuously true**, which is why two C5b review rounds read this function carefully and both recorded the term backwards (`docs/research/2026-08-05-c5b-contract.md` §1 said first "teleport must NOT be newer", then "TELEPORT_TS equal"; both corrected at §15). **Consequence:** acdream's `ForcePosition` disposition is a strict SUBSET of retail's Gate A set. A local ForcePosition carrying a NEWER teleport stamp is misrouted into a full `Apply`, which is four separate behaviour changes at once — it takes the WIRE heading instead of preserving the body's (`InboundPhysicsStateController.ApplyAcceptedPosition:846-856`, force-gated), it UNPARENTS and may install a placement frame (`clearParent: !force`, `installPlacementFrame: !force && !hasAnimations` — C5b's own truth table), it sets `TeleportAdvanced` and therefore ZEROES local velocity (`:882-885`), and it advances TELEPORT_TS and calls `OfferTeleportDestination`, starting teleport/portal presentation for a packet retail never starts it for. Retail's Gate A deliberately lets a force ride PAST a pending teleport advance without consuming it (it returns @0x0045409D before `newer_event(arg2, TELEPORT_TS, arg8)` @0x00454158); the ordinary Position channel is what processes that teleport. **Not fixed in the filing commit**, deliberately: see issue #325 for why it is not a one-line comparison swap. **C5b made this marginally BETTER, not worse**`clearParent` was unconditionally `true` pre-C5b and is unchanged for the misrouted packet, and `installPlacementFrame` went unconditional-`true` to `!force && !hasAnimations`, i.e. toward retail's "Gate A never reaches `SetPlacementFrame`". | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptPositionEvent:199`, the `teleport == _timestamps[Teleport]` term); `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs` (`ValidAcceptedAuthority`, the `PreviousTeleportSequence == AcceptedTeleportSequence` term — the SAME predicate encoded a second time, and the reason the fix is not one line) | None argued — this is an unintended narrowing found at a closeout, not a chosen approximation. It is filed as an approximation rather than a defect only because the resulting behaviour is a strictly SMALLER shortcut set, i.e. more packets take the fully-processed path rather than fewer, which fails safe for pose correctness even where it is wrong about heading, parent, velocity, and presentation. The exact retail predicate already exists verbatim in the same file — `IsFreshTeleportStart:163` is `!IsNewer(teleport, _timestamps[Teleport])` — so the correction itself is trivial; the consumers are not. | A server correction that arrives while the client's TELEPORT_TS is behind ACE's (a teleport whose Position packet was lost, or arrived after the force) is promoted from "blip me in place" to a full teleporting apply: the player's facing snaps to the wire heading instead of staying where the mouse left it, local velocity is zeroed mid-stride, an equipped child is unparented, and the portal/transit presentation owner is offered a destination for a packet that is not a teleport. Reachability against ACE is UNMEASURED — ACE's two `ObjectForcePosition` bumps (`Player.cs:1148` PKLite re-placement, `Player_Tick.cs:488` z-hack correction) do not themselves bump the teleport sequence, but `PositionPack` serialises the CURRENT teleport sequence, so any client whose TELEPORT_TS lags ACE's is in the divergent window on its next force. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (Gate A's teleport test @0x0045402B-0x00454054; the return @0x0045409D; the TELEPORT_TS advance it skips @0x00454158); `CPhysicsObj::newer_event` 0x00451B10 (the same idiom, operands unswapped); `acclient.h:6090` (`update_times[4] == TELEPORT_TS`) |
| AP-149 | **Filed 2026-08-05 at the #280 fix (portal destination prefetch).** The reveal gate's OUTER ring accepts terrain-only publication where retail requires the landblock's full static-DAT closure. Retail's `LScape::PreFetchCells` @0x00505660 walks the whole `mid_radius` square and, for EVERY in-bounds landblock, requires (1) its terrain record resident, (2) its `LandBlockInfo` type-2 record resident, and (3) via `CLandBlock::PreFetchCells` @0x00530240 -> `CLandBlockInfo::PreFetchCells` @0x0052E7C0 -> `CBldPortal::PreFetchCells` @0x0053BD00, every EnvCell of every building it contains. acdream's outer ring is Far-tier: heightmap + terrain render mesh + terrain collision, with NO LandBlockInfo, no buildings, no building EnvCells and no procedural scenery, because the Far tier does not load them at all. The gate therefore converges on a strictly weaker condition than retail's out beyond `NearRadius`. **#280 closed the 11.4:1 reveal-window/visible-window ratio; it did NOT close this. Do not let a later closeout claim parity.** | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`, the far arm); `src/AcDream.App/Streaming/LandblockBuildFactory.cs` (the Far build's contents); `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs` | Closing it would mean promoting the entire Far window to Near, i.e. deleting the two-tier streaming design that exists precisely because full hydration of a 25x25 window is unaffordable. Retail affords it because retail's ONE square is 17x17 at its default draw distance and it blocks the whole simulation while loading it (`CellManager::blocking_for_cells`), which acdream deliberately does not do (see AD-2). The residual is bounded to content that is only ever seen at Far distances. | A distant BUILDING, its interior EnvCell shells, or distant procedural scenery can still appear after the viewport opens, at Far-ring distances (beyond ~768 m at the shipped High preset), where retail would have kept blocking. Distant TERRAIN — the reported #280 symptom — no longer can. | `LScape::PreFetchCells` 0x00505660; `CLandBlock::PreFetchCells` 0x00530240; `CLandBlockInfo::PreFetchCells` 0x0052E7C0; `CBldPortal::PreFetchCells` 0x0053BD00 | | AP-149 | **Filed 2026-08-05 at the #280 fix (portal destination prefetch).** The reveal gate's OUTER ring accepts terrain-only publication where retail requires the landblock's full static-DAT closure. Retail's `LScape::PreFetchCells` @0x00505660 walks the whole `mid_radius` square and, for EVERY in-bounds landblock, requires (1) its terrain record resident, (2) its `LandBlockInfo` type-2 record resident, and (3) via `CLandBlock::PreFetchCells` @0x00530240 -> `CLandBlockInfo::PreFetchCells` @0x0052E7C0 -> `CBldPortal::PreFetchCells` @0x0053BD00, every EnvCell of every building it contains. acdream's outer ring is Far-tier: heightmap + terrain render mesh + terrain collision, with NO LandBlockInfo, no buildings, no building EnvCells and no procedural scenery, because the Far tier does not load them at all. The gate therefore converges on a strictly weaker condition than retail's out beyond `NearRadius`. **#280 closed the 11.4:1 reveal-window/visible-window ratio; it did NOT close this. Do not let a later closeout claim parity.** | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`, the far arm); `src/AcDream.App/Streaming/LandblockBuildFactory.cs` (the Far build's contents); `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs` | Closing it would mean promoting the entire Far window to Near, i.e. deleting the two-tier streaming design that exists precisely because full hydration of a 25x25 window is unaffordable. Retail affords it because retail's ONE square is 17x17 at its default draw distance and it blocks the whole simulation while loading it (`CellManager::blocking_for_cells`), which acdream deliberately does not do (see AD-2). The residual is bounded to content that is only ever seen at Far distances. | A distant BUILDING, its interior EnvCell shells, or distant procedural scenery can still appear after the viewport opens, at Far-ring distances (beyond ~768 m at the shipped High preset), where retail would have kept blocking. Distant TERRAIN — the reported #280 symptom — no longer can. | `LScape::PreFetchCells` 0x00505660; `CLandBlock::PreFetchCells` 0x00530240; `CLandBlockInfo::PreFetchCells` 0x0052E7C0; `CBldPortal::PreFetchCells` 0x0053BD00 |
| AP-150 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F2).** acdream arms the `"In Portal Space - Please Wait..."` cue only after the hold has run five seconds (`RuntimeWorldTransitState.RetailWaitCueDelay = TimeSpan.FromSeconds(5)`, enforced at the readiness tick; `PortalTunnelPresentation.TickRotation` then re-emits per rotation segment only `if (_waitCueVisible)`). Retail has no such threshold. The emit site is inside `gmSmartBoxUI::UseTime`'s `TAS_TUNNEL*` branch, in the `else` arm of the rotation-segment-expiry test at 0x004D6FCD: when a segment expires retail picks a new random segment and calls `ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...)` UNCONDITIONALLY, whether or not `CellManager::blocking_for_cells` is set — the notice is a property of being in the tunnel, not of being blocked. Byte-decoded at 0x004D6FE6-0x004D7049: `teleportRotationDuration = RandDouble(0.6, 1.8)` s (`0x3ffccccc/0xcccccccd` = 1.8, `0x3fe33333/0x33333333` = 0.6) and `teleportRotationEndAngle = RandDouble(0, 360)` (`0x40768000`). The unrelated 5.0 s constant at 0x007991B0 belongs to `CellManager::CheckPrefetchStatus` @0x00455BE0, the prefetch RETRY cadence, and was mis-attributed to the cue by #280's commit message. acdream's own segment constants (`RotationDurationMin = 0.6f`, `RotationDurationMax = 1.8f`) already match retail exactly, so the cadence is faithful and only the ARMING is not. Pre-dates #280; filed here because #280 reasoned from the wrong model and because the row did not exist. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs` (`RetailWaitCueDelay`); `src/AcDream.App/Rendering/PortalTunnelPresentation.cs` (`RotationDurationMin`/`Max`, `TickRotation`); `src/AcDream.App/UI/PortalWaitNoticeController.cs` | Deliberate at the time as a "don't flash a scary notice on a fast portal" softening, but it was never recorded as a divergence and AD-2/AP-115 described it as acdream behaviour without stating that retail has no threshold. Adopting retail's unconditional per-segment emit is a one-line arming change; it is not made here because it is a user-visible presentation change outside the defect this commit fixes, and it wants the user's eyes. Filed as issue #329. | Every acdream portal shorter than 5 s shows a silent tunnel where retail shows the notice; every portal longer than 5 s shows it 3.2-4.4 s late (retail's first segment expires at 0.6-1.8 s). #280 makes holds longer, which MASKS this rather than fixing it. | `gmSmartBoxUI::UseTime` 0x004D6E30 (emit at the 0x004D6FCD segment-expiry else-arm); `ECM_UI::SendNotice_DisplayStringInfo` call @0x004D7064; wait-cue string VA 0x007BD6A8; `CellManager::CheckPrefetchStatus` 0x00455BE0 (the 5.0 s constant, VA 0x007991B0) | | AP-150 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F2).** acdream arms the `"In Portal Space - Please Wait..."` cue only after the hold has run five seconds (`RuntimeWorldTransitState.RetailWaitCueDelay = TimeSpan.FromSeconds(5)`, enforced at the readiness tick; `PortalTunnelPresentation.TickRotation` then re-emits per rotation segment only `if (_waitCueVisible)`). Retail has no such threshold. The emit site is inside `gmSmartBoxUI::UseTime`'s `TAS_TUNNEL*` branch, in the `else` arm of the rotation-segment-expiry test at 0x004D6FCD: when a segment expires retail picks a new random segment and calls `ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...)` UNCONDITIONALLY, whether or not `CellManager::blocking_for_cells` is set — the notice is a property of being in the tunnel, not of being blocked. Byte-decoded at 0x004D6FE6-0x004D7049: `teleportRotationDuration = RandDouble(0.6, 1.8)` s (`0x3ffccccc/0xcccccccd` = 1.8, `0x3fe33333/0x33333333` = 0.6) and `teleportRotationEndAngle = RandDouble(0, 360)` (`0x40768000`). The unrelated 5.0 s constant at 0x007991B0 belongs to `CellManager::CheckPrefetchStatus` @0x00455BE0, the prefetch RETRY cadence, and was mis-attributed to the cue by #280's commit message. acdream's own segment constants (`RotationDurationMin = 0.6f`, `RotationDurationMax = 1.8f`) already match retail exactly, so the cadence is faithful and only the ARMING is not. Pre-dates #280; filed here because #280 reasoned from the wrong model and because the row did not exist. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs` (`RetailWaitCueDelay`); `src/AcDream.App/Rendering/PortalTunnelPresentation.cs` (`RotationDurationMin`/`Max`, `TickRotation`); `src/AcDream.App/UI/PortalWaitNoticeController.cs` | Deliberate at the time as a "don't flash a scary notice on a fast portal" softening, but it was never recorded as a divergence and AD-2/AP-115 described it as acdream behaviour without stating that retail has no threshold. Adopting retail's unconditional per-segment emit is a one-line arming change; it is not made here because it is a user-visible presentation change outside the defect this commit fixes, and it wants the user's eyes. Filed as issue #329. | Every acdream portal shorter than 5 s shows a silent tunnel where retail shows the notice; every portal longer than 5 s shows it 3.2-4.4 s late (retail's first segment expires at 0.6-1.8 s). #280 makes holds longer, which MASKS this rather than fixing it. | `gmSmartBoxUI::UseTime` 0x004D6E30 (emit at the 0x004D6FCD segment-expiry else-arm); `ECM_UI::SendNotice_DisplayStringInfo` call @0x004D70A1 (-> 0x006925B0); the wait-cue string's `PStringBase` construction is the neighbouring @0x004D7064, `:219516`**corrected 2026-08-06 at the D-1 fix review; this row originally cited the string constructor as the call site**; wait-cue string VA 0x007BD6A8; `CellManager::CheckPrefetchStatus` 0x00455BE0 (the 5.0 s constant, VA 0x007991B0) |
| AP-151 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F3).** The reveal gate is materially STRICTER than retail's prefetch predicate on the mesh-build/GPU-upload axis, over an equally large square. Retail's `LScape::PreFetchCells` @0x00505660 requires, per member, only that the DAT records be resident in memory (`DBObj::PreFetch` -> `IN_MEMORY` or `IN_FILE` -> `DBObj::Get` non-null); no geometry construction, no vertex arrays and no GPU upload are part of the blocking predicate — that work happens lazily at draw. acdream's gate requires, for every member of the derived window (25x25 at the shipped High preset): a worker-thread DAT read, a terrain mesh build, a render-thread `TerrainModernRenderer.AddLandblock` upload, a spatial commit, a physics collision-generation admission, and a spawn-adapter activation, all metered at `MaxCompletionsPerFrame`. The hold is therefore systematically longer than retail's for identical content, and nothing currently bounds it. Note this is the OPPOSITE asymmetry from AP-149, which records where the outer ring is WEAKER than retail; both are live simultaneously, on different axes. | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Streaming/StreamingWorkBudget.cs` | It is what makes "no visible assembly after reveal" true at all: acdream draws through a bindless/MDI pipeline whose landblock slots must exist before the viewport opens, where retail can begin drawing a landblock the frame its DAT record lands. Weakening the predicate to DAT residency would restore retail's hold duration and reintroduce the visible-assembly artifact #280 exists to remove. AD-2's blanket "async readiness gates replace retail's synchronous destination cell load" pre-dates the window being 625 members wide and does not name this axis. | Portal/recall holds of several seconds where retail (warm cache) is near-instant, on EVERY transit rather than only on cold DAT. No upper bound is enforced and no progress readout is shown (#327). A slow disk or a saturated upload budget lengthens the hold without limit. | `LScape::PreFetchCells` 0x00505660; `DBObj::PreFetch`/`DBObj::Get` call sites @0x0050575C, @0x0050579C; `CellManager::PreFetchCells` 0x00455820 | | AP-151 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F3).** The reveal gate is materially STRICTER than retail's prefetch predicate on the mesh-build/GPU-upload axis, over an equally large square. Retail's `LScape::PreFetchCells` @0x00505660 requires, per member, only that the DAT records be resident in memory (`DBObj::PreFetch` -> `IN_MEMORY` or `IN_FILE` -> `DBObj::Get` non-null); no geometry construction, no vertex arrays and no GPU upload are part of the blocking predicate — that work happens lazily at draw. acdream's gate requires, for every member of the derived window (25x25 at the shipped High preset): a worker-thread DAT read, a terrain mesh build, a render-thread `TerrainModernRenderer.AddLandblock` upload, a spatial commit, a physics collision-generation admission, and a spawn-adapter activation, all metered at `MaxCompletionsPerFrame`. The hold is therefore systematically longer than retail's for identical content, and nothing currently bounds it. Note this is the OPPOSITE asymmetry from AP-149, which records where the outer ring is WEAKER than retail; both are live simultaneously, on different axes. | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Streaming/StreamingWorkBudget.cs` | It is what makes "no visible assembly after reveal" true at all: acdream draws through a bindless/MDI pipeline whose landblock slots must exist before the viewport opens, where retail can begin drawing a landblock the frame its DAT record lands. Weakening the predicate to DAT residency would restore retail's hold duration and reintroduce the visible-assembly artifact #280 exists to remove. AD-2's blanket "async readiness gates replace retail's synchronous destination cell load" pre-dates the window being 625 members wide and does not name this axis. | Portal/recall holds of several seconds where retail (warm cache) is near-instant, on EVERY transit rather than only on cold DAT. No upper bound is enforced and no progress readout is shown (#327). A slow disk or a saturated upload budget lengthens the hold without limit. | `LScape::PreFetchCells` 0x00505660; `DBObj::PreFetch`/`DBObj::Get` call sites @0x0050575C, @0x0050579C; `CellManager::PreFetchCells` 0x00455820 |
| ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync``ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom 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 at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync``ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom 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 at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from |
| ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | | ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 |

View file

@ -0,0 +1,474 @@
# #280 D-1 fix review — commit `73cdb95c`
**Date:** 2026-08-06
**Subject:** `fix(streaming): make a demoted landblock render-ready like a
published one (#280 D-1)`, commit `73cdb95c`, branch
`claude/acdream-physics-divergence-5aa784`.
**Mode:** adversarial, read-only, both lenses (retail conformance +
architecture). No subagents; everything below was executed inline.
## Verdicts
| Lens | Verdict |
|---|---|
| **Retail conformance** | **PASS** (one LOW documentation defect) |
| **Architecture** | **PASS** (three LOW latent risks, three style notes) |
**Is D-1 genuinely closed?** Yes, for the transition it names, and the fix is
at the state owner rather than at the predicate — the non-workaround shape.
Verified end to end: after a Near→Far demote the landblock ends with exactly
the spawn-adapter registration a `PublicationKind.Far` activation installs, on
both retirement call sites, and the reveal gate reopens.
---
## 1. What was reproduced independently
Everything below was run in this worktree at `73cdb95c`, from a **genuinely
clean rebuild** — all 42 `obj`/`bin` directories under `src/`, `tests/`,
`tools/` deleted first, so no stale test DLL could carry a deleted symbol.
| Claim | Result |
|---|---|
| Release build, 0 errors | ✅ reproduced (21 warnings total: 18 xUnit analyzer + 3 CS8767 nullability, all pre-existing) |
| Full suite `-m:1`, `ACDREAM_PAK_PATH` set | ✅ **11,192 passed / 4 skipped / 0 failed** |
| App.Tests 4157 → 4170 | ✅ App.Tests = 4,170 / 3 skips |
| +13 net reconciles to this commit | ✅ 3 readiness Facts + 1 integration Fact + 2 warmup Facts + 7 Theory rows = 13 |
| No new skips | ✅ 4 skips, unchanged from the `fafc0b65` baseline figure |
| Sabotage: no-op re-assert → the four new tests fail, others pass | ✅ **4 failed / 324 passed** in the Streaming filter; the four failures are exactly the four named tests |
| The `?? true` tautology is really dead | ✅ verified independently (below) |
| Blast radius: four types App-internal | ✅ Headless/Runtime/Core/Core.Net/UI.Abstractions reference them only in comments; Headless tests 89/89 green |
| Retail binary pairs with our PDB | ✅ `check_exe_pdb.py``MATCH` (GUID `9e847e2f-…`, linker 2013-09-06) |
Per-assembly breakdown of the 11,192: App 4170/3, Bake 15, Cli 4, Content 124,
Core.Net 764, Core 4263/1, Headless 89, Runtime 1217, UI.Abstractions 546.
**Tree restored.** Both sabotages (and a third, described in L1) were reverted;
`git status --short` is empty and the solution rebuilds clean.
### 1.1 The sabotage, reproduced
Inserted `if (landblockId != 0u) return;` immediately after the
`OnLandblockUnloaded` call in
`src/AcDream.App/Streaming/GpuWorldState.cs:1602`, rebuilt, ran the Streaming
filter:
```
NearToFarDemote_LeavesTheLandblockRenderReadyThroughTheRealPipeline [FAIL]
NearToFarDemote_LeavesTheLandblockRenderReadyUnderBudgetedRetirement [FAIL]
TieredWindow_StaysResidentAfterAnOuterRingDemote [FAIL]
OutdoorReveal_SurvivesAnOuterRingDemoteDuringTheHold [FAIL]
Failed: 4, Passed: 324, Total: 328
```
The commit's claim was "all four fail, 315 others pass"; the true sibling count
under a `~Streaming` filter is 324. The substance — exactly these four, nothing
else — holds.
### 1.2 The tautology-kill, verified rather than accepted
The commit claims the pre-existing `WorldRevealDerivedWindowIntegrationTests`
fixtures were stubbed out by `IsRenderReady`'s
`(_wbSpawnAdapter?.IsLandblockRenderReady(id) ?? true)`
(`GpuWorldState.cs:180-181`). I tested that directly: with the **production fix
still sabotaged**, I reverted only the new D-1 integration test's world
construction back to `new GpuWorldState()` (no adapter).
`OutdoorReveal_SurvivesAnOuterRingDemoteDuringTheHold` **passed**.
That is conclusive: the null adapter degenerated `IsRenderReady` to `IsLoaded`,
the fixture change is load-bearing, and the new test would have been another
green-covering-nothing test without it.
---
## 2. Architecture lens — the four attack surfaces
### 2.1 Can the re-assert fire when it must not? — No.
The guard is
`_loaded.TryGetValue(canonical, …)` **and**
`_tierByLandblock[canonical] == Far` (`GpuWorldState.cs:1604-1611`).
Checked by symbol against every teardown path:
- **Full retirement.** `DetachLandblock` removes both maps —
`_tierByLandblock.Remove(canonical)` at `GpuWorldState.cs:1298` and
`RemoveLoadedLandblock` at `:1299` (→ `_loaded.Remove` at `:435`). It runs at
`LandblockRetirementCoordinator.cs:680-682`, **before** the ticket is created
at `:702`, so by the time `AdvanceTicket`/`AdvanceTicketOne` reach the
`MeshReferences` stage both conditions are already false.
- **Origin recenter.** `DetachAllForOriginRecenter` does `_loaded.Clear()`
(`:1481`) and `_tierByLandblock.Clear()` (`:1485`) synchronously inside the
call; `AdoptDetachedFull` only creates ledgers afterwards
(`LandblockRetirementCoordinator.cs:393-474`).
- **Landblock replacement at the same canonical id while a receipt is still
draining.** Fenced. `StreamingController.IsPublicationBlockedByRetirement`
(`:2094`) is installed as the completion queue's peek predicate (`:1707`), so
no `Loaded`/`Promoted` completion is applied while any retirement is pending
for that id — the one funnel every completion goes through. This is what
makes the resurrection scenario unreachable, and it is also why the
`retained` record the re-assert reads is guaranteed to be the post-demote
record and not a newer generation.
- **Near-layer retirement of a landblock that is not loaded** (pending-near or
pending-render-ids only): `DetachNearLayer` returns a receipt without ever
entering the `_loaded` branch (`:1854-1889`), so `_tierByLandblock` is
untouched and the guard's `_loaded` lookup fails.
- **Mixed-kind ordering.** NearLayer ticket pending, then a Full begins:
`BeginCore`'s same-kind scan (`:647-668`) does not match, `DetachLandblock`
clears both maps, and the stale NearLayer ticket's release finds nothing.
Reverse order: `DetachNearLayer` returns `null` (`hadNearLayer` false), so no
second ticket exists.
**Verdict:** the guard cannot resurrect a registration on a genuinely unloaded
landblock. No scenario found.
### 2.2 Ordering, interleaving, double-fire, clobbering — clean.
- **"Converges before the re-assert" is structurally enforced, not asserted.**
`LandblockSpawnAdapter.OnLandblockUnloaded` ends in `ThrowFailures`
(`:180-182`), so any unreleased reference throws out of the method and the
re-assert is unreachable. The retirement ticket retries; the second pass
finds the registration already dropped (`:161-162` early return), does not
throw, and the re-assert then fires. Convergent.
- **No reentrancy.** For a demote the re-assert's `unique` set is empty, so
`OnLandblockLoaded` makes **zero** adapter calls. The unload's
`DecrementRefCount` path (`WbMeshAdapter.cs:405-409`) only forwards to
`ObjectMeshManager` and never re-enters `GpuWorldState`.
- **Cannot fire twice per ticket.** `RunOnce`/`RunOnceStep` mark the
`MeshReferences` stage complete on success.
- **Cannot clobber a live registration.** This is the important one, and it is
a real safety property rather than an accident:
`OnLandblockLoaded` calls `MarkAllUndesired` **only** inside
`else if (!registration.WantsLoaded)` (`LandblockSpawnAdapter.cs:103-112`).
Re-asserting an empty set over a still-live registration therefore marks
nothing undesired, releases only already-undesired leftovers, and re-acquires
desired-but-unheld ones. It is a convergence step, never a teardown.
### 2.3 Is the empty set genuinely empty? — Yes, verified both halves.
- `DetachNearLayer` (`GpuWorldState.cs:1866-1885`) puts only `ServerGuid != 0`
entities into `retainedLive`; every `ServerGuid == 0` entity goes to
`retiredEntities` and `RemoveFlatEntity`.
- `OnLandblockLoaded`'s atlas-tier filter skips exactly `ServerGuid != 0`
(`LandblockSpawnAdapter.cs:88`). Intersection is empty by construction.
- **Live server entities lose nothing**, because they were never registered
here — they belong to `EntitySpawnAdapter`, and nothing in
`ReleaseLandblockMeshReferences` touches it.
- **Prepared (EnvCell shell) ids also match**, which the commit asserts but does
not show. Verified: a `PublicationKind.Far` transaction builds
`farLandblock` with `Array.Empty<WorldEntity>()` and
`new LandblockBuild(farLandblock, Origin)` — no `EnvCells`
(`LandblockPresentationPipeline.cs:442-464`), so
`renderIds = transaction.Build.EnvCells?.Shells…` is `null` at `:899-901`.
The re-assert's `additionalReadinessIds: null` therefore reproduces the Far
registration exactly, `Prepared` dictionary included.
- The ordering claim added to `StreamingController.cs:271-279` also checks out:
presentation (terrain upload) commits at `PresentationCommitted`
(`LandblockPresentationPipeline.cs:874`) **before**
`SpatialPresentationCommitted` runs `ActivateLandblockPresentation`
`OnLandblockLoaded` (`:882-925`).
### 2.4 All four call sites — correct, and the placement argument holds.
| Site | Path | Behaviour under the new meaning |
|---|---|---|
| `LandblockRetirementCoordinator.cs:740` (`AdvanceTicket`, legacy) | after detach commit | full → guard fails; near-layer → re-assert. Correct. |
| `LandblockRetirementCoordinator.cs:801` (`AdvanceTicketOne`, budgeted) | after detach commit | same. This is production's path (`CreateBudgeted`) and has its own test. |
| `GpuWorldState.cs:1549` (`RemoveLandblock`) | after `DetachLandblock` | both maps cleared → guard fails. Correct. |
| `GpuWorldState.cs:1907` (`RemoveEntitiesFromLandblock`) | after `DetachNearLayer` | re-assert fires. Correct — it is the same demote semantics. |
The architecture review's "test-only compatibility edge" flag on
`RemoveEntitiesFromLandblock` is accurate and harmless: `grep` shows zero
production callers for it or for `GpuWorldState.RemoveLandblock` (the many
`.RemoveLandblock(` hits are on `TerrainModernRenderer`, `EnvCellRenderer`,
`CellVisibility`, `PhysicsEngine.ShadowObjects`, `CellGraph` — different
types). Placing the fix at the state owner rather than the retirement stage is
the right call: it covers all four with one invariant, and the two compat edges
now agree with production instead of diverging from it.
### 2.5 R-2 (composite-warmup trigger) — a real restoration, gate not weakened.
Checked against `3aab05b0^` (pre-#280): `Prepare` used a single
`radius = RequiredRenderRadius(cell)` = `OutdoorNeighborhoodRadius` = 1 for
**both** the readiness test and `_prepareCompositeTextures`. Trigger scope ==
domain scope. #280 widened only the gate. The new
`_isRenderNeighborhoodReady(cell, near, near)` restores that identity.
- **The gate is untouched.** `Evaluate` still calls
`_isRenderNeighborhoodReady(cell, required.NearRadius, required.FarRadius)`
and still ANDs `_areCompositeTexturesReady()`
(`WorldRevealReadinessBarrier.cs:184-194`).
- **The trigger is not a rubber stamp.** With `farRadius == nearRadius`, every
member of `IsRenderNeighborhoodResident`'s loop satisfies `isInnerRing`, so it
demands `IsNearTier && IsRenderReady` for the whole near square — the strict
pre-#280 predicate, not a relaxation.
- **Starting earlier cannot latch a stale ready.**
`WbDrawDispatcher.PrepareCompositeTextures` is built for repeated per-frame
calls: `RequiresCompositeWarmupRebuild` / `ShouldBeginCompositeWarmupRescan`
run *before* the `if (CompositeTexturesReady) return` early-out (`:168-189`),
and a rescan resets `CompositeTexturesReady` (`:330-332`). Far-tier builds
carry no entities, so far-ring publication adds nothing to the domain anyway.
- Two tests cover both directions
(`Prepare_StartsWarmupOnceTheNearSubWindowIsPublished`,
`Prepare_StillWaitsWhenTheNearSubWindowIsIncomplete`), and the first also
asserts `IsReady` is still false.
It **is** a behaviour change riding in a fix commit. It is disclosed in the
message, scoped to warmup timing, tested, and provably a restoration rather
than a new position. Acceptable.
### 2.6 R-1 (`ParseRadius` floor 1) — correct and inert.
`ACDREAM_PROBE_REVEAL_RADIUS=0` yielded `far = 0` for an outdoor destination
(`ApplyRevealRadiusOverride`, `StreamingDiagnostics.cs:48-51`), which Runtime's
`invalid-readiness-shape` invariant rejects on every acknowledgement. Rejecting
it at the parser makes the probe fall back to the derivation. Diagnostic-only,
7-case table test, no production behaviour touched.
### 2.7 Blast radius across both hosts — verified.
`GpuWorldState`, `LandblockSpawnAdapter`, `WorldRevealReadinessBarrier`,
`StreamingDiagnostics` appear in `AcDream.Headless`, `AcDream.Runtime`,
`AcDream.Core`, `AcDream.Core.Net`, `AcDream.UI.Abstractions` **only in
comments**: `HeadlessSessionWorldProjection.cs:963`,
`RuntimeRemotePlacementDriveController.cs:13` and `:356`,
`RuntimeWorldTransitState.cs:576`, `WorldEntity.cs:14`. `AcDream.Headless.Tests`
ran 89/89 green in my own clean run. C5b's "survey skipped the no-window host"
failure mode is not repeated.
Worth recording: `tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs`
exercises `GpuWorldState` from the *Core* test project. That is a test-project
reference, not a production dependency, and all 4,263 Core tests are green — but
it means "App-internal" is true of production only.
---
## 3. Retail-conformance lens
All addresses below were decoded from
`C:\Users\erikn\Downloads\acclient.exe`, confirmed `MATCH` against
`refs/acclient.pdb`. **Where a comparison or constant is load-bearing I
disassembled rather than trusting Binary Ninja**, per the C5b/#317 lesson: BN
prints `bool p_2 = /* unimplemented {test ah, 0x41} */` at the exact site
AP-150 rests on.
### 3.1 AP-150 clause 1 — "unconditional per tunnel rotation segment": CONFIRMED
`gmSmartBoxUI::UseTime` @`0x004D6E30`. Decoded from `0x004D6FC1`:
```
004D6FC1 dc 86 40 06 00 00 fadd qword [esi+0x640] ; + teleportRotationStartTime
004D6FC7 dc 5c 24 18 fcomp qword [esp+0x18] ; vs Timer::cur_time
004D6FCB df e0 fnstsw ax
004D6FCD f6 c4 41 test ah, 0x41 ; C0|C3
004D6FD0 0f 8a de 00 00 00 jp 0x004D70B6
```
`test ah,0x41` sets PF from the popcount of `ah & 0x41`: `0x00` (segment still
running) and `0x41` (unordered) are even → **jump taken**, into the
angle-interpolation block at `0x004D70B6`. `0x01` (expired) and `0x40` (exactly
equal) are odd → **fall through**, into the re-randomise + emit block. So the
notice sits on the segment-**expired** path, which is what AP-150 says.
Within that block the only conditional jumps are the temporary string's
refcount-release guard:
```
004D708C 75 0c jnz 0x004D709A
004D7090 74 08 jz 0x004D709A
...
004D709A 8d 4c 24 30 lea ecx, [esp+0x30]
004D709E 51 push ecx
004D709F 6a 1a push 0x1A
004D70A1 e8 0a b5 1b 00 call 0x006925B0 ; ECM_UI::SendNotice_DisplayStringInfo
```
Both branch targets are `0x004D709A`, i.e. immediately *before* the call. There
is no `blocking_for_cells` test, no elapsed-time test, no once-only latch. The
emit is unconditional on every segment expiry while the state is one of
`TAS_TUNNEL{,_FADE_IN,_CONTINUE,_FADE_OUT}`. **Claim confirmed.**
### 3.2 AP-150 clause 2 — `RandDouble(0.6, 1.8)` at `0x004D6FE6`: CONFIRMED
```
004D6FE6 68 cc cc fc 3f push 0x3FFCCCCC ; hi(1.8)
004D6FEB 68 cd cc cc cc push 0xCCCCCCCD ; lo(1.8)
004D6FFA 68 33 33 e3 3f push 0x3FE33333 ; hi(0.6)
004D6FFF 68 33 33 33 33 push 0x33333333 ; lo(0.6)
004D7016 e8 b5 d3 1a 00 call 0x006843D0 ; RandDouble (symbols.json ✓)
004D701B dd 9e 48 06 00 00 fstp qword [esi+0x648] ; teleportRotationDuration
```
`0x3FFCCCCCCCCCCCCD` = 1.8, `0x3FE3333333333333` = 0.6. cdecl pushes reverse,
so the last push (0.6) is arg 1 → **`RandDouble(0.6, 1.8)`**, exactly as
claimed, and `0x004D6FE6` is the correct anchor. The sibling
`teleportRotationEndAngle = RandDouble(0, 360)` also checks out
(`push 0x40768000` @`0x004D702D` = hi(360.0), three `push 0` for lo/0.0).
acdream's `PortalTunnelPresentation.cs:62-63` are `RotationDurationMin = 0.6f`,
`RotationDurationMax = 1.8f`, and `TickRotation` re-randomises with
`NextDouble(0.0, 360.0)` — cadence is faithful. The arming is not:
`RuntimeWorldTransitState.cs:66-67` `RetailWaitCueDelay = 5 s`, enforced at
`:680` (`elapsed < RetailWaitCueDelay → return false`) with a once-only
`WaitCueShown` latch, and `PortalTunnelPresentation.cs:380` re-emits only
`if (_waitCueVisible)`. **"Only the arming is wrong" is exactly right.**
### 3.3 AP-150 clause 3 — the 5.0 at `0x007991B0` is unrelated: CONFIRMED
`CellManager::CheckPrefetchStatus` @`0x00455BE0`:
```
00455BE0 dd 05 a8 69 83 00 fld qword [0x008369A8] ; Timer::cur_time
00455BE9 dc 66 10 fsub qword [esi+0x10] ; - last_prefetch_check
00455BEE dc 1d b0 91 79 00 fcomp qword [0x007991B0] ; vs 5.0
00455BF4 df e0 fnstsw ax
00455BF6 f6 c4 41 test ah, 0x41
00455BF9 75 2a jnz -> return 0
```
The double at `0x007991B0` is exactly `5.0`. It is a prefetch **retry throttle**
— skip `PreFetchCells` unless 5 s have elapsed. The tunnel-cue code path
`0x004D6FC0``0x004D70B2` contains no reference to `0x007991B0`. **#280's
commit message mis-attribution is correctly retracted.**
### 3.4 AP-151 — the gate is stricter than retail's DAT-residency predicate: CONFIRMED
`LScape::PreFetchCells` @`0x00505660` walks the `mid_radius` square, applies the
`>= 0x7f8` bounds test the `StreamingController` comment cites, and per member
does only `DBObj::PreFetch``CACHE_OBJECT_IN_MEMORY`/`IN_FILE``DBObj::Get`
non-null → `CLandBlock::PreFetchCells`. No geometry construction, no vertex
buffers, no upload — all of that is lazy at draw. acdream's per-member predicate
(worker DAT read, terrain mesh build, render-thread upload, spatial commit,
collision admission, spawn-adapter activation, metered) is unambiguously
heavier. **Claim confirmed**, and correctly recorded as the *opposite*
asymmetry from AP-149.
### 3.5 Collateral citations, all spot-checked
| Citation | Result |
|---|---|
| `SmartBox::set_mid_radius` @`0x00453180` (#326 correction) | ✅ `symbols.json`; and `0x004531D0` is genuinely the mid-function re-arm — `CellManager::ChangePosition(cell_manager, &player->m_position, 1)` |
| `SmartBox::SetRegion` @`0x004531F0` | ✅ and it assigns `mid_radius` from `Render::m_RenderPrefs.LandscapeDrawDistance` |
| `Render_LandscapeDrawDistance_Values` @`0x007CA988` = {3,5,8,11,15,25} | ✅ byte-read from `.rdata` |
| `LScape::SetMidRadius` @`0x00504C00`, `SmartBox::UseTime` @`0x00455410`, `CellManager::PreFetchCells` @`0x00455820`, `CEnvCell::PreFetchCells` @`0x0052D1E0`, `CLandBlock/CLandBlockInfo/CBldPortal::PreFetchCells` @`0x00530240`/`0x0052E7C0`/`0x0053BD00`, `gmSmartBoxUI::BeginTeleportAnimation` @`0x004D6300` | ✅ all match |
| Wait-cue string VA `0x007BD6A8` | ✅ `push 0x7BD6A8` @`0x004D705B` |
| Issue `#327` referenced by AP-151 | ✅ exists (`docs/ISSUES.md:1761`) |
---
## 4. Findings
### Defect — LOW (documentation)
**D1. AP-150 cites the wrong address for the notice call.**
`docs/architecture/retail-divergence-register.md:179` reads
"`ECM_UI::SendNotice_DisplayStringInfo` call @0x004D7064". `0x004D7064` is the
`PStringBase<unsigned short>::PStringBase` constructor call. The
`SendNotice_DisplayStringInfo` call is at **`0x004D70A1`**
(`e8 0a b5 1b 00``0x006925B0`, which `symbols.json` confirms is
`ECM_UI::SendNotice_DisplayStringInfo`). Every other address in the row is
correct, and neither `docs/ISSUES.md` #329 nor the contract doc repeats the
error. This is precisely the class of off-by-one-call-site citation error the
same commit corrected for #326 — worth fixing so a future reader setting a cdb
breakpoint from the register lands on the right instruction.
### Latent risks
**L1 — LOW. The "empty by construction" invariant is guarded, but not at the
seam this fix newly depends on.**
The new doc-comment on `ReleaseLandblockMeshReferences` states the re-assert is
safe because "the retained entity list holds only live server projections,
which the adapter's atlas-tier filter skips". I tested that dependency: I
removed the filter (`if (entity.ServerGuid != 0) continue;`,
`LandblockSpawnAdapter.cs:88`) and **all 4,170 App tests passed**. Only two
tests anywhere caught it, both in `AcDream.Core.Tests`
(`LandblockSpawnAdapterTests.OnLandblockLoaded_SkipsServerSpawnedEntities`,
`PendingSpawnIntegrationTests.LiveEntity_ParkedBeforeLandblock_DrainsButIsNotRegisteredWithAdapter`),
and both at the adapter's unit level rather than through a demote.
No test demotes a landblock retaining a live server projection that carries
`MeshRefs` — the discriminating case. The filter itself is protected, so this
is not a defect; but if it were ever relaxed deliberately, a Far-tier
landblock's `IsRenderReady` would start depending on live-entity mesh
readiness, which is a D-1 variant (a live entity whose mesh never becomes ready
makes the outer ring permanently un-ready) and nothing at the composition level
would fail. One test with a live entity carrying a `MeshRef` through
`BeginNearLayerRetirement` would close it.
**L2 — LOW. The fix is repair-after-self-inflicted-teardown, not
release-the-near-layer-only.**
`ReleaseLandblockMeshReferences` unregisters then re-registers. That is
functionally sound today because (a) nothing observes `WantsLoaded` between the
two synchronous calls, and (b) `RunOnce`/`RunOnceStep` make the whole method
all-or-nothing so it can never be suspended between them. The stronger shape
would be "release the Near layer's references and leave the registration
standing", which would carry the invariant structurally. As written, a future
budgeted split of this method into two metered steps would silently reopen
D-1 — the ticket-stage granularity is the only thing preventing it, and that is
not stated at the site.
**L3 — LOW. The restated P2 obligation is literally stronger than what the code
guarantees.**
`docs/research/2026-08-05-280-contract.md` now states: *"no transition may
revoke `IsRenderReady` from a landblock that stays inside `FarRadius`"*. A
Far→Near **promote** does revoke it transiently — new entity mesh references
are registered before their upload completes — and that is correct behaviour,
and is how the inner arm is meant to be satisfied. The obligation that actually
holds is *"…may revoke it with no path to restoration"*. Since the first
wording of P2 (discharged against residency instead of `IsRenderReady`) is
exactly what let D-1 ship, the precision of the restatement matters more here
than usual.
### Style
**S1.** `ReleaseLandblockMeshReferences` calls
`_wbSpawnAdapter.OnLandblockUnloaded(landblockId)` with the **raw** id
(`GpuWorldState.cs:1602`) but performs both guard lookups on `canonical`
(`:1604-1611`). Production always passes canonical ids (both coordinator paths
canonicalize at `Begin*`; both compat edges pass `retirement.LandblockId`), so
this is inert today. But inside a nine-line method the asymmetry invites a
future non-canonical caller into "unregister nothing, then register something",
which the registration's idempotence would silently absorb. Canonicalize once
at the top.
**S2.** `WorldRevealDerivedWindowIntegrationTests.RecordingMeshAdapter`
implements `IsRenderDataReady(ulong) => true`, so the mesh-readiness arm of
`IsLandblockRenderReady` is still stubbed in that file — the sibling
`ReadinessMeshAdapter` in `StreamingControllerReadinessTests` uses honest set
membership. The fixture change removed the load-bearing tautology (the
`?? true`), which is the one that mattered; this residual is worth a comment so
the next reader does not over-read "real spawn adapter" as "real readiness".
**S3.** `StreamingDiagnostics.ParseRadius` widened `private``internal` purely
so the 7-case table test could call it. Reasonable, noted only because it is a
test-driven visibility relaxation on a diagnostic owner.
---
## 5. Process-rule compliance
Checked against `CLAUDE.md`: no suppression flag, no grace period, no symptom
guard, no retry loop, no `try/catch` swallow, no new skip, no weakened
assertion. Skips stayed at 4. The rejected alternative
(`|| (IsFarTier && IsLoaded)` at the gate) is correctly identified as the
symptom-guard shape and correctly declined. The register rule was honoured —
AP-150 and AP-151 were filed in the same commit as the finding, AD-2's false
clause was corrected rather than left standing, AP-115 was scope-noted, and the
user-visible presentation change (retail's unconditional emit) was filed as
#329 rather than folded in. Known flakes #302/#308/#321 did not surface in my
run and are not conflated with anything above.
## 6. What was checked and found clean (so the PASS is auditable)
Guard reachability during full retirement, removal, origin recenter, session
reset, generation change and same-id replacement · retirement-ticket ordering
relative to spatial detach · same-kind and mixed-kind ticket interleaving ·
throwing-release retry semantics · adapter reentrancy into `GpuWorldState` ·
double-fire · live-registration clobbering · atlas-tier emptiness · prepared
(EnvCell) id parity between demote and Far arrival · terrain-upload-before-
registration ordering · all four `ReleaseLandblockMeshReferences` call sites ·
production-caller census for both compat edges · `Evaluate` unchanged by R-2 ·
`IsRenderNeighborhoodResident(cell, near, near)` strictness · composite-warmup
rescan/reopen semantics · R-1 probe floor · cross-assembly references from
Headless/Runtime/Core/Core.Net/UI.Abstractions · clean-rebuild suite totals and
their per-assembly decomposition · the +13 delta reconciliation · both
sabotages · nine retail symbol addresses, four byte-level decodes, and two
IEEE-754 constant pairs.

View file

@ -629,6 +629,83 @@ public sealed class StreamingControllerReadinessTests
/// </summary> /// </summary>
[Fact] [Fact]
public void NearToFarDemote_LeavesTheLandblockRenderReadyThroughTheRealPipeline() public void NearToFarDemote_LeavesTheLandblockRenderReadyThroughTheRealPipeline()
=> NearToFarDemoteLeavesTheLandblockRenderReadyCore();
/// <summary>
/// L1, filed at the D-1 fix review: the fix's "empty by construction"
/// claim rests on <c>LandblockSpawnAdapter</c>'s atlas-tier filter
/// (<c>if (entity.ServerGuid != 0) continue;</c>) skipping the live
/// server projections that <c>DetachNearLayer</c> deliberately RETAINS.
/// That seam had no composition-level coverage — the review removed the
/// filter and all 4,170 App tests still passed, with only two Core unit
/// tests catching it and none through a demote. So the invariant the
/// re-assert depends on could have been deleted silently.
///
/// <para>
/// Here the demoted landblock CARRIES a live server-spawned entity. If
/// the filter stops skipping it, the re-asserted registration is no
/// longer empty, its mesh reference is never satisfied, and
/// <c>IsRenderReady</c> goes false — which is D-1 again by another
/// route. Sabotage that reddens this: delete the `ServerGuid != 0`
/// continue in <c>LandblockSpawnAdapter.OnLandblockLoaded</c>.
/// </para>
/// </summary>
[Fact]
public void NearToFarDemote_WithALiveServerEntity_StaysRenderReady()
{
const uint landblockId = 0x1237FFFFu;
const ulong staticGfxObjId = 0x01000011ul;
const ulong serverGfxObjId = 0x01000012ul;
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
var pipeline = new LandblockPresentationPipeline(
publishBeforeSpatialCommit: (_, _) => { },
state);
meshes.ReadyIds.Add(staticGfxObjId);
meshes.ReadyIds.Add(serverGfxObjId);
// An ordinary atlas-tier prop, and a server-spawned entity of the
// kind DetachNearLayer retains across the demote.
var staticEntity = new WorldEntity
{
Id = 1,
ServerGuid = 0,
SourceGfxObjOrSetupId = (uint)staticGfxObjId,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = [new MeshRef((uint)staticGfxObjId, System.Numerics.Matrix4x4.Identity)],
};
var serverEntity = new WorldEntity
{
Id = 2,
ServerGuid = 0x50000123u,
SourceGfxObjOrSetupId = (uint)serverGfxObjId,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = [new MeshRef((uint)serverGfxObjId, System.Numerics.Matrix4x4.Identity)],
};
state.AddLandblock(new LoadedLandblock(
landblockId,
new LandBlock(),
new[] { staticEntity, serverEntity }));
// Only the atlas-tier prop ever drove a reference count.
Assert.True(state.IsRenderReady(landblockId));
Assert.Equal(1, meshes.ReferenceCounts[staticGfxObjId]);
Assert.DoesNotContain(serverGfxObjId, meshes.ReferenceCounts.Keys);
pipeline.BeginNearLayerRetirement(landblockId);
Assert.False(state.IsNearTier(landblockId));
Assert.True(state.IsLoaded(landblockId));
// The retained server entity must not have re-entered the desired
// set: the re-assert stays empty and the block stays render-ready.
Assert.DoesNotContain(serverGfxObjId, meshes.ReferenceCounts.Keys);
Assert.DoesNotContain(staticGfxObjId, meshes.ReferenceCounts.Keys);
Assert.True(state.IsRenderReady(landblockId));
}
private static void NearToFarDemoteLeavesTheLandblockRenderReadyCore()
{ {
const uint landblockId = 0x1236FFFFu; const uint landblockId = 0x1236FFFFu;
const ulong gfxObjId = 0x01000010ul; const ulong gfxObjId = 0x01000010ul;