diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index ea7660ba2..6c2eaa197 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1249,6 +1249,22 @@ outer visibility notice cannot be observed partially. Settings and native callbacks may defer policy changes, but cannot bypass the frame meter to drain pending publication. +A collision refresh can transfer its immutable replacement into the physics +engine while exact resident-restore receipts still await the graphical host. +At that post-engine boundary, the retained publication may commit the same +observer-atomic spatial operation early so the destination backend exists for +those receipts. Physics completion remains pending until every exact receipt +acknowledges, and static plugin projection still follows physics completion. +Pins, default scripts, static-projection reconciliation, and outer observer +callbacks inside that spatial transaction may therefore precede the exact +acknowledgement/static-plugin suffix in this affected case. +Both world-reveal readiness and graphical remote-placement admission consult +the retained transaction: Near/render residency is insufficient until the +presentation and spatial-prefix flags have both committed. EnvCell replay and +live recovery remain downstream of that admission boundary. This is an +explicit asynchronous-host adaptation; retail has no separate engine/backend +receipt split. + Loaded spatial residency is deliberately distinct from world availability. During login/portal quiescence, destination live objects acquire and retain their renderer projection so composite/readiness preparation can converge diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 38c0cb07d..8dc7b328b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -137,7 +137,7 @@ readiness/requeue adaptation. See | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | | ~~AD-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** The legacy recoverable outdoor demote (`Resolve`'s indoor-claim safety net) and the outdoor-restore `max(terrainZ, z)` lift this row described were `PhysicsEngine.Resolve`'s own body — deleted outright with the exhaustive C5a caller census proving zero production callers (every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState`). The divergent mechanism is unreachable from production because it no longer exists. | `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945; `CPhysicsObj::handle_all_collisions` 0x00514780 | -| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency across the DERIVED reveal window. **#280 amendment (2026-08-05):** that outdoor window is no longer a hardcoded radius-1 neighbourhood. Retail has exactly ONE landscape square — `LScape::mid_radius`, assigned directly from the `Render.LandscapeDrawDistance` preference (`SmartBox::SetRegion` @0x004531F0; values `Render_LandscapeDrawDistance_Values` @0x007CA988 = {3,5,8,11,15,25}, default 8, byte-verified) — and that same square is simultaneously the loaded set, the drawn set, and the set `LScape::PreFetchCells` @0x00505660 blocks on, so retail structurally cannot stream farther than it gates. acdream now DERIVES the outdoor radius from the live streaming window (`QualitySettings.FarRadius`, read per evaluation from `StreamingController` so a mid-hold Settings change re-arms the gate the way `SmartBox::set_mid_radius` @0x00453180 does), and the render-completeness predicate is TIER-AWARE to match acdream's two-tier landscape: inside `NearRadius`, full Near publication (`IsNearTier && IsRenderReady`); out to `FarRadius`, terrain publication only (`IsRenderReady`, which a Far-tier landblock satisfies through an empty spawn-adapter registration installed after its terrain upload crossed the render-thread barrier). **#280 review correction (2026-08-06):** as originally written this row asserted that property of a `PublicationKind.Far` *publication* only, which was true but not exhaustive — a landblock also reaches Far tier by Near→Far DEMOTE, and the demote's `LandblockRetirementStage.MeshReferences` left `LandblockSpawnAdapter.WantsLoaded == false` on a landblock that stays loaded and drawn, with no path that re-publishes it. Both review lenses found the same defect: one demoted member anywhere in the far ring made the gate unsatisfiable for the life of the streaming window (permanent portal-space hang, no recovery short of relog), reachable by two consecutive recalls to the same landblock with walking in between, or by a mid-hold quality-preset drop. The two routes are now genuinely equivalent — `GpuWorldState.ReleaseLandblockMeshReferences` re-asserts the empty Far registration after retiring the Near layer — rather than the predicate being taught to tolerate two meanings of "ready". Composite-texture warmup stays `NearRadius`-scoped because it is entity-scoped and Far builds carry no entities, and (same correction) its TRIGGER is scoped the same way: gating warmup on the whole widened gate serialised every composite upload behind the last outer-ring landblock, which is a hold longer than the streaming work requires. The destination reservation opens at exactly the gate's radius, since retail has one square for both. Runtime's readiness invariant is correspondingly a SHAPE check (`indoor ⇒ 0`, `outdoor ⇒ ≥1`), never a re-encoded value — Runtime does not own the graphical host's streaming configuration. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. **C4 route 3 refinement (2026-08-04):** retail places the local player IMMEDIATELY on the accepted destination Position (`SmartBox::TeleportPlayer` @0x00453910) and blocks SIMULATION on DAT prefetch (`CellManager::blocking_for_cells`; `SmartBox::UseTime` @0x00455410 runs only `CheckPrefetchStatus`) behind the portal viewport; acdream defers the PLACEMENT itself to this reveal-ready Place edge, executed by the canonical `RuntimeAcceptedPositionDriveController` portal arm (`TryExecuteAcceptedPortalArrival`). Two load-bearing notes from that route: (1) every accepted local Apply — including the portal destination Position itself — still writes the raw wire pose onto the local player's `WorldEntity` via the ordinary generic-remote-render-pose path while portal space covers the viewport (`LiveEntityNetworkUpdateController.cs`, `OwnsSteadyState` false for the local player's null route); the committed Place receipt's presentation suffix overwrites it with the resolved pose — tolerated, not suppressed, since suppressing it would be an unowned behaviour change on the ordinary local Apply path (AP-131/#275 territory). (2) The constraint-leash re-arm on a committed portal placement anchors at the RESOLVED post-placement body position (`PlayerMovementController.CommitCanonicalTeleportFrame` → `RearmConstraintLeashAtCurrentPosition`), where retail's `ConstrainTo` @0x0045418A anchors at the received WIRE destination; the two differ by at most the placement adjustment (ring search/floor snap) and the anchor is write-only downstream, so the delta is not user-observable — switching to the wire-destination anchor is a deliberately deferred decision, not adopted here. **B4 round-3 review refinement (2026-08-05):** the wait cue's trigger predicate (`LocalPlayerTeleportController.Tick`'s `placementReady = dataReady && TryAdvancePortalCommit(sequence)`, gating the cue at `haveDestination && !placementReady`) now covers a SECOND, distinct cause beyond the original streaming/DAT-readiness gate this row described: `TryAdvancePortalCommit` returning false while a DeferredCell park is outstanding or a fresh placement attempt has not yet succeeded (B1's `TryConsumePortalCommit` gate). The cue's five-second trigger and centered-tunnel behavior are unchanged (that trigger is an acdream divergence in its own right — AP-150, filed 2026-08-06); only the SET of conditions that can hold it open grew from "world data not ready" to "world data not ready OR canonical placement not yet committed" — a slow-publishing destination-landblock collision generation now presents identically to a slow asset stream, which is the correct retail-faithful degradation (both are `blocking_for_cells` causes retail itself does not distinguish), but is worth naming here since a future debugging session seeing the cue must not assume streaming is the only possible cause. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds — that five-second arming is acdream's own and is NOT retail's trigger; see AP-150. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0; `LScape::PreFetchCells` 0x00505660; `LScape::SetMidRadius` 0x00504C00; `SmartBox::set_mid_radius` 0x00453180; `Render_LandscapeDrawDistance_Values` 0x007CA988 | +| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency across the DERIVED reveal window. **#280 amendment (2026-08-05):** that outdoor window is no longer a hardcoded radius-1 neighbourhood. Retail has exactly ONE landscape square — `LScape::mid_radius`, assigned directly from the `Render.LandscapeDrawDistance` preference (`SmartBox::SetRegion` @0x004531F0; values `Render_LandscapeDrawDistance_Values` @0x007CA988 = {3,5,8,11,15,25}, default 8, byte-verified) — and that same square is simultaneously the loaded set, the drawn set, and the set `LScape::PreFetchCells` @0x00505660 blocks on, so retail structurally cannot stream farther than it gates. acdream now DERIVES the outdoor radius from the live streaming window (`QualitySettings.FarRadius`, read per evaluation from `StreamingController` so a mid-hold Settings change re-arms the gate the way `SmartBox::set_mid_radius` @0x00453180 does), and the render-completeness predicate is TIER-AWARE to match acdream's two-tier landscape: inside `NearRadius`, full Near publication (`IsNearTier && IsRenderReady`); out to `FarRadius`, terrain publication only (`IsRenderReady`, which a Far-tier landblock satisfies through an empty spawn-adapter registration installed after its terrain upload crossed the render-thread barrier). **#280 review correction (2026-08-06):** as originally written this row asserted that property of a `PublicationKind.Far` *publication* only, which was true but not exhaustive — a landblock also reaches Far tier by Near→Far DEMOTE, and the demote's `LandblockRetirementStage.MeshReferences` left `LandblockSpawnAdapter.WantsLoaded == false` on a landblock that stays loaded and drawn, with no path that re-publishes it. Both review lenses found the same defect: one demoted member anywhere in the far ring made the gate unsatisfiable for the life of the streaming window (permanent portal-space hang, no recovery short of relog), reachable by two consecutive recalls to the same landblock with walking in between, or by a mid-hold quality-preset drop. The two routes are now genuinely equivalent — `GpuWorldState.ReleaseLandblockMeshReferences` re-asserts the empty Far registration after retiring the Near layer — rather than the predicate being taught to tolerate two meanings of "ready". Composite-texture warmup stays `NearRadius`-scoped because it is entity-scoped and Far builds carry no entities, and (same correction) its TRIGGER is scoped the same way: gating warmup on the whole widened gate serialised every composite upload behind the last outer-ring landblock, which is a hold longer than the streaming work requires. The destination reservation opens at exactly the gate's radius, since retail has one square for both. Runtime's readiness invariant is correspondingly a SHAPE check (`indoor ⇒ 0`, `outdoor ⇒ ≥1`), never a re-encoded value — Runtime does not own the graphical host's streaming configuration. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. **C4 route 3 refinement (2026-08-04):** retail places the local player IMMEDIATELY on the accepted destination Position (`SmartBox::TeleportPlayer` @0x00453910) and blocks SIMULATION on DAT prefetch (`CellManager::blocking_for_cells`; `SmartBox::UseTime` @0x00455410 runs only `CheckPrefetchStatus`) behind the portal viewport; acdream defers the PLACEMENT itself to this reveal-ready Place edge, executed by the canonical `RuntimeAcceptedPositionDriveController` portal arm (`TryExecuteAcceptedPortalArrival`). Two load-bearing notes from that route: (1) every accepted local Apply — including the portal destination Position itself — still writes the raw wire pose onto the local player's `WorldEntity` via the ordinary generic-remote-render-pose path while portal space covers the viewport (`LiveEntityNetworkUpdateController.cs`, `OwnsSteadyState` false for the local player's null route); the committed Place receipt's presentation suffix overwrites it with the resolved pose — tolerated, not suppressed, since suppressing it would be an unowned behaviour change on the ordinary local Apply path (AP-131/#275 territory). (2) The constraint-leash re-arm on a committed portal placement anchors at the RESOLVED post-placement body position (`PlayerMovementController.CommitCanonicalTeleportFrame` → `RearmConstraintLeashAtCurrentPosition`), where retail's `ConstrainTo` @0x0045418A anchors at the received WIRE destination; the two differ by at most the placement adjustment (ring search/floor snap) and the anchor is write-only downstream, so the delta is not user-observable — switching to the wire-destination anchor is a deliberately deferred decision, not adopted here. **B4 round-3 review refinement (2026-08-05):** the wait cue's trigger predicate (`LocalPlayerTeleportController.Tick`'s `placementReady = dataReady && TryAdvancePortalCommit(sequence)`, gating the cue at `haveDestination && !placementReady`) now covers a SECOND, distinct cause beyond the original streaming/DAT-readiness gate this row described: `TryAdvancePortalCommit` returning false while a DeferredCell park is outstanding or a fresh placement attempt has not yet succeeded (B1's `TryConsumePortalCommit` gate). The cue's five-second trigger and centered-tunnel behavior are unchanged (that trigger is an acdream divergence in its own right — AP-150, filed 2026-08-06); only the SET of conditions that can hold it open grew from "world data not ready" to "world data not ready OR canonical placement not yet committed" — a slow-publishing destination-landblock collision generation now presents identically to a slow asset stream, which is the correct retail-faithful degradation (both are `blocking_for_cells` causes retail itself does not distinguish), but is worth naming here since a future debugging session seeing the cue must not assume streaming is the only possible cause. **#474 refinement (2026-09-05):** a collision refresh may commit Runtime's replacement engine generation before its exact resident-restore Place receipt can acknowledge. The retained App publication now installs its one observer-atomic spatial/backend transaction at that post-engine boundary, while physics completion and static-plugin projection remain pending behind the exact acknowledgement. Pins, default scripts, static-projection reconciliation and observer callbacks in that transaction may therefore precede the acknowledgement/static-plugin suffix in this affected case. Reveal and graphical remote-placement admission borrow the same retained-transaction fence (`PresentationCommitted && SpatialPresentationCommitted`); later EnvCell replay/live recovery do not extend it. This engine/backend/receipt split is a modern asynchronous adaptation with no literal retail owner. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds — that five-second arming is acdream's own and is NOT retail's trigger; see AP-150. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0; `LScape::PreFetchCells` 0x00505660; `LScape::SetMidRadius` 0x00504C00; `SmartBox::set_mid_radius` 0x00453180; `Render_LandscapeDrawDistance_Values` 0x007CA988 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | | ~~AD-10~~ | **RETIRED 2026-08-06 by deletion.** The row's justification ("remote bodies don't run a full local transition sweep") was false at HEAD: `RuntimeRemotePhysicsUpdater.Tick` calls `PhysicsEngine.ResolveWithTransition` with the remote's own body, and that sweep runs acdream's port of `CTransition::adjust_offset` once per sub-step. **(Wording corrected 2026-08-06 at the retail review, F2: this row and four other places called that port "verbatim"/"faithful". It is structurally exact BUT carries exactly two divergences, filed the same day as AD-65 and AD-66 -- so the unqualified word was false from the very next commit. It is a STRUCTURALLY EXACT port with two filed exceptions.)** So this was never a relocation of a missing mechanism — it was an EXTRA pre-sweep projection layered on top of the faithful one, against a surface retail never uses (`SampleTerrainNormal(x, y)`, an XY-only landblock lookup blind to the body's Z, its cell, buildings, EnvCells and statics). Measured before deleting: with the projection forced null at both fork sites, the production trajectory of a remote running 30 ticks down a 31-degree ramp is BIT-IDENTICAL, a 8.4-degree ramp differs by at most 2.8e-5 m in Z, and the whole `AcDream.Runtime.Tests` suite is unchanged. Deleted: both `RuntimeRemotePhysicsUpdater` sample sites, the `terrainNormal` parameter and projection block on `RemoteMotionCombiner.ComposeOffset` AND on the production-dead `ComputeOffset`, and the now-callerless `PhysicsEngine.SampleTerrainNormal`. Removing the parameter is what makes an AP-22-shaped one-site-only regression a compile error. **Noted 2026-08-06 at the retail review (F3): this redundancy measurement is CONTINGENT on AD-65 -- the two mechanisms agree today partly because both under-travel downhill. That makes this deletion a PREREQUISITE for fixing AD-65 rather than merely compatible with it: had the projection survived, correcting AdjustOffset would have re-introduced a disagreement between two live projections.** Two claims in the old row were also stale/backwards and did not survive: it described `ComposeOffset`'s guard as "interpolation-active" when the code is `if (!interpolationOverwrote ...)`, and its second cited site (`ComputeOffset` ~:163-168) had zero production callers. The roof clause was stale too — since Bug B (`204d0ae0`) the sample was gated on `OnWalkable`, and a steep roof is `OnWalkable == false`, so the path did not run on #32's geometry at all. **UNTESTED AXIS, recorded 2026-08-06 at the AD-10 architecture review: the contract's T2 -- its mandatory wrong-plane-versus-right-plane discriminator -- was dropped without record, in breach of the contract's own "record it as an untested axis rather than silently dropping it" clause. Consequence: this change's only claimed BENEFIT (a walkable NON-TERRAIN surface -- bridge, dock, dungeon ramp -- now gets the committed contact plane instead of the terrain plane far below) has ZERO automated coverage and rests on source reasoning alone. The deletion itself is measured; the benefit is not.** | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`; `src/AcDream.Core/Physics/RemoteMotionCombiner.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSlopeProjectionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RemoteRampHarness.cs` | — | — | `CTransition::adjust_offset` 0x0050a370, pc:272271-272393 (the old anchor pc:272296-272346 truncated both the sliding-normal validity gate at the head and the entire safety push-out block at the tail); per-step call from `CTransition::find_transitional_position` 0x0050bdf0 | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 7ff381ab7..fa282879c 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -702,16 +702,19 @@ internal sealed class SessionPlayerCompositionPhase portal.Projection.DestinationCell)); // C4 route 4b-2 (2026-08-04): the graphical remote-placement drive // controller — route 4b-1's dormant owner, now driven by the remote - // far snap. Its service window is the graphical host's near-tier - // residency (GraphicalRemotePlacementServiceWindow's own remarks - // justify IsNearTier over IsNearTierOrPending). Shares the SAME entity + // far snap. Its service window is the graphical host's completed + // presentation prefix plus near-tier residency + // (GraphicalRemotePlacementServiceWindow's own remarks justify the + // shared fence and IsNearTier over IsNearTierOrPending). Shares the SAME entity // lifetime, clock, and prepared-collision source as the two drive // controllers above. var remotePlacementDrive = new RuntimeRemotePlacementDriveController( d.EntityObjects, d.Runtime.Clock, firstEntryCollision, - new GraphicalRemotePlacementServiceWindow(live.WorldState)); + new GraphicalRemotePlacementServiceWindow( + live.WorldState, + streaming.IsLandblockPresentationReady)); var hydration = new LiveEntityHydrationController( live.LiveEntities, d.EntityObjects, diff --git a/src/AcDream.App/Streaming/GraphicalRemotePlacementServiceWindow.cs b/src/AcDream.App/Streaming/GraphicalRemotePlacementServiceWindow.cs index 49a5f17f3..4a294f41f 100644 --- a/src/AcDream.App/Streaming/GraphicalRemotePlacementServiceWindow.cs +++ b/src/AcDream.App/Streaming/GraphicalRemotePlacementServiceWindow.cs @@ -11,7 +11,8 @@ namespace AcDream.App.Streaming; /// /// /// -/// Predicate choice — , not +/// Predicate choice — completed presentation plus +/// , not /// . The contract asks /// whether near-tier residency is exactly co-extensive with collision /// publication and, if not, to propose the correct predicate rather than @@ -23,19 +24,19 @@ namespace AcDream.App.Streaming; /// collision-published. Using it here would let this guard say "go" for a /// destination whose collision generation has not committed, reopening /// exactly the DeferredCell park this route exists to prevent. -/// alone is the correct predicate: +/// remains the residency half of the +/// predicate; the borrowed pipeline query closes an in-place refresh window +/// where Near is still true but its exact publication prefix is incomplete: /// /// -/// Promotion direction (verified by reading, not assumed): the only -/// two writers of _tierByLandblock[...] = LandblockStreamTier.Near are -/// GpuWorldState.CommitLandblockSpatialCore (:969) and -/// CommitEntitiesToExistingLandblockSpatialCore (:1972), both called -/// from LandblockPresentationPipeline.Advance's -/// SpatialPresentationCommitted stage — which runs strictly AFTER the -/// preceding PresentationCommitted stage, the one that drives -/// LandblockPhysicsPublisher's staged collision/EnvCell advance to -/// completion (LandblockPresentationPipeline.cs ~826-935). A -/// landblock's tier cannot read Near before its collision has committed. +/// Promotion direction (verified by reading, not assumed): ordinary +/// publication still commits spatial presentation after the full presentation +/// suffix. A resumed post-engine collision refresh is different: it commits +/// the graphical backend before the exact restore acknowledgement and static +/// plugin suffix complete, so that acknowledgement can observe the backend it +/// requires. Near therefore proves graphical residency, but no longer proves +/// that the exact presentation prefix has completed; the borrowed pipeline +/// query supplies that second half of the service-window predicate. /// /// /// Retirement direction (verified by reading, not assumed): @@ -51,27 +52,26 @@ namespace AcDream.App.Streaming; /// retiring. /// /// -/// Residual, not closed: a landblock-prefix collision MUTATION that is -/// not a full retirement (a live in-place quiescence/refresh while the tier -/// stays Near) is not ruled out by this reading and is not exercised by any -/// gate this route adds. RuntimeSetPositionState's private -/// TryGetBlockingQuiescence (:3820-3863) is Core's own check for -/// exactly this case — an active CollisionPrefixQuiescence entry for -/// the destination's landblock prefix, independent of tier/residency — and -/// it is what a placement can still hit even after this guard passes, -/// producing the narrow DeferredCell residual -/// 's -/// class doc describes. +/// #474 refinement: a retained collision refresh can install the +/// replacement engine while exact restore receipts are pending. Its spatial +/// backend now activates at that immutable boundary, but this service window +/// remains closed until the complete presentation prefix commits. The same +/// borrowed query gates world reveal; no duplicate readiness owner exists. /// /// internal sealed class GraphicalRemotePlacementServiceWindow : IRuntimeRemotePlacementServiceWindow { private readonly GpuWorldState _state; + private readonly Func _isPresentationReady; - internal GraphicalRemotePlacementServiceWindow(GpuWorldState state) + internal GraphicalRemotePlacementServiceWindow( + GpuWorldState state, + Func isPresentationReady) { _state = state ?? throw new ArgumentNullException(nameof(state)); + _isPresentationReady = isPresentationReady + ?? throw new ArgumentNullException(nameof(isPresentationReady)); } /// @@ -84,8 +84,13 @@ internal sealed class GraphicalRemotePlacementServiceWindow /// tier writer/reader does /// (DetachNearLayer, CommitLandblockSpatialCore) before the /// dictionary lookup — itself does - /// not canonicalize its argument. + /// not canonicalize its argument. The same canonical id is passed to the + /// retained-presentation query before the tier lookup. /// - public bool IsWithinServiceWindow(uint landblockId) => - _state.IsNearTier((landblockId & 0xFFFF0000u) | 0xFFFFu); + public bool IsWithinServiceWindow(uint landblockId) + { + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + return _isPresentationReady(canonical) + && _state.IsNearTier(canonical); + } } diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 668086585..49b4bd6d5 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -215,6 +215,27 @@ public sealed class LandblockPresentationPipeline internal bool MatchesState(GpuWorldState state) => ReferenceEquals(_state, state); + /// + /// Returns whether the exact landblock has crossed the presentation and + /// observer-atomic spatial activation boundary. Later EnvCell replay and + /// live-recovery work deliberately do not close this admission gate. + /// + internal bool IsLandblockPresentationReady(uint landblockId) + { + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + foreach (PublicationTransaction transaction in _publications.Values) + { + if ((transaction.LandblockId & 0xFFFF0000u) == + (canonical & 0xFFFF0000u) + && (!transaction.PresentationCommitted + || !transaction.SpatialPresentationCommitted)) + { + return false; + } + } + return true; + } + public bool IsRetirementPending(uint landblockId) => _retirements.IsPending(landblockId); @@ -808,6 +829,16 @@ public sealed class LandblockPresentationPipeline } while (!transaction.PhysicsPublication.CompletionCommitted) { + if (transaction.PhysicsPublication.EngineMutationCommitted + && transaction.PhysicsPublication.RuntimeMutationPending + && !transaction.SpatialPresentationCommitted + && !TryRun( + default, + "publication-spatial-commit", + () => CommitSpatialPresentation(transaction))) + { + return new LandblockPublicationAdvance(false, progressed); + } int entityOperations = transaction.PhysicsPublication.GfxCursor < transaction.PhysicsPublication.GfxObjectIds.Length @@ -845,6 +876,15 @@ public sealed class LandblockPresentationPipeline } if (transaction.PhysicsPublication.RuntimeMutationPending) { + if (transaction.PhysicsPublication.EngineMutationCommitted + && !transaction.SpatialPresentationCommitted + && !TryRun( + default, + "publication-spatial-commit", + () => CommitSpatialPresentation(transaction))) + { + return new LandblockPublicationAdvance(false, progressed); + } // #418: Runtime's collision-generation activation is a // deliberate two-poll transaction — the first // CommitCollisionGeneration poll closes the prefix @@ -948,51 +988,7 @@ public sealed class LandblockPresentationPipeline if (!TryRun( default, "publication-spatial-commit", - () => - { - using GpuWorldState.MutationBatch mutation = - _state.BeginMutationBatch(); - if (!transaction.SpatialCommitted) - { - IEnumerable? renderIds = - transaction.Build.EnvCells?.Shells.Select( - static shell => shell.GeometryId); - IEnumerable? ordinaryRenderIds = - transaction.Build.EnvCells?.WalkBuildingMeshDependencies; - transaction.SpatialPublication = transaction.Kind switch - { - PublicationKind.Loaded - or PublicationKind.PromoteSelfContained - or PublicationKind.Far => - _state.CommitLandblockSpatial( - transaction.Build.Landblock, - renderIds, - transaction.Tier, - ordinaryRenderIds), - PublicationKind.PromoteExisting => - _state.CommitEntitiesToExistingLandblockSpatial( - transaction.LandblockId, - transaction.Build.Landblock.Entities, - renderIds, - ordinaryRenderIds), - _ => throw new InvalidOperationException( - $"Unknown landblock publication kind {transaction.Kind}."), - }; - transaction.SpatialCommitted = true; - } - - _state.ActivateLandblockPresentation( - transaction.SpatialPublication - ?? throw new InvalidOperationException( - "A committed spatial publication has no activation receipt.")); - if (transaction.SpatialPublication.RequiresActivation) - { - _staticProjectionSink?.Reconcile( - transaction.Build, - transaction.SpatialPublication); - } - transaction.SpatialPresentationCommitted = true; - })) + () => CommitSpatialPresentation(transaction))) { return new LandblockPublicationAdvance(false, progressed); } @@ -1049,4 +1045,49 @@ public sealed class LandblockPresentationPipeline } return new LandblockPublicationAdvance(true, progressed); } + + private void CommitSpatialPresentation(PublicationTransaction transaction) + { + using GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch(); + if (!transaction.SpatialCommitted) + { + IEnumerable? renderIds = + transaction.Build.EnvCells?.Shells.Select( + static shell => shell.GeometryId); + IEnumerable? ordinaryRenderIds = + transaction.Build.EnvCells?.WalkBuildingMeshDependencies; + transaction.SpatialPublication = transaction.Kind switch + { + PublicationKind.Loaded + or PublicationKind.PromoteSelfContained + or PublicationKind.Far => + _state.CommitLandblockSpatial( + transaction.Build.Landblock, + renderIds, + transaction.Tier, + ordinaryRenderIds), + PublicationKind.PromoteExisting => + _state.CommitEntitiesToExistingLandblockSpatial( + transaction.LandblockId, + transaction.Build.Landblock.Entities, + renderIds, + ordinaryRenderIds), + _ => throw new InvalidOperationException( + $"Unknown landblock publication kind {transaction.Kind}."), + }; + transaction.SpatialCommitted = true; + } + + _state.ActivateLandblockPresentation( + transaction.SpatialPublication + ?? throw new InvalidOperationException( + "A committed spatial publication has no activation receipt.")); + if (transaction.SpatialPublication.RequiresActivation) + { + _staticProjectionSink?.Reconcile( + transaction.Build, + transaction.SpatialPublication); + } + transaction.SpatialPresentationCommitted = true; + } } diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 30bddb951..78c9c621a 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -218,10 +218,14 @@ public sealed class StreamingController } internal bool IsCollapsedToDungeon => _collapsed; + internal bool IsLandblockPresentationReady(uint landblockId) => + _presentation.IsLandblockPresentationReady(landblockId); + /// /// True once every in-bounds landblock in the requested Chebyshev window - /// has crossed the render-thread publication barrier. Worker completion and - /// world-state registration are not sufficient: all static GfxObj and + /// has crossed the complete presentation prefix and render-thread + /// publication barrier. Worker completion and world-state registration + /// are not sufficient: all static GfxObj and /// EnvCell shell meshes must have completed their render-thread upload. /// Portal-space exit uses this alongside physics residency so the world /// cannot be revealed while its render slots are still absent. @@ -270,6 +274,8 @@ public sealed class StreamingController continue; uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu; + if (!_presentation.IsLandblockPresentationReady(canonical)) + return false; // GpuWorldState.IsRenderReady already implies IsLoaded, and it is a // real drawability test out here rather than a stamp: a Far-tier // landblock carries a spawn-adapter registration with an empty mesh diff --git a/tests/AcDream.App.Tests/Streaming/GraphicalRemotePlacementServiceWindowTests.cs b/tests/AcDream.App.Tests/Streaming/GraphicalRemotePlacementServiceWindowTests.cs index 82c742c2e..91301c18a 100644 --- a/tests/AcDream.App.Tests/Streaming/GraphicalRemotePlacementServiceWindowTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GraphicalRemotePlacementServiceWindowTests.cs @@ -8,8 +8,8 @@ namespace AcDream.App.Tests.Streaming; /// C4 route 4b-1: focused tests for the graphical host's /// — the App-side /// implementation of IRuntimeRemotePlacementServiceWindow that did not -/// exist before this route. Verifies the predicate matches -/// exactly (not +/// exist before this route. Verifies the predicate requires the retained +/// presentation fence and (not /// , which is also true for a /// merely-queued landblock — see the class's own doc comment) and /// canonicalizes a full ACE cell id to the landblock the tier map keys on. @@ -28,6 +28,22 @@ public sealed class GraphicalRemotePlacementServiceWindowTests private const uint CanonicalLandblock = LandblockHighWord | 0xFFFFu; private const uint OutdoorCell = LandblockHighWord | 0x0001u; + [Fact] + public void ConstructorRequiresPresentationFence() + { + var state = new GpuWorldState(); + + Assert.Throws(() => + new GraphicalRemotePlacementServiceWindow(state, null!)); + Assert.DoesNotContain( + typeof(GraphicalRemotePlacementServiceWindow) + .GetConstructors( + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic), + constructor => constructor.GetParameters().Length == 1); + } + [Fact] public void True_WhenTheLandblockIsNearTier() { @@ -36,7 +52,9 @@ public sealed class GraphicalRemotePlacementServiceWindowTests CanonicalLandblock, new LandBlock(), Array.Empty())); - var window = new GraphicalRemotePlacementServiceWindow(state); + var window = new GraphicalRemotePlacementServiceWindow( + state, + static _ => true); Assert.True(window.IsWithinServiceWindow(OutdoorCell)); } @@ -45,7 +63,9 @@ public sealed class GraphicalRemotePlacementServiceWindowTests public void False_WhenTheLandblockWasNeverPublished() { var state = new GpuWorldState(); - var window = new GraphicalRemotePlacementServiceWindow(state); + var window = new GraphicalRemotePlacementServiceWindow( + state, + static _ => true); Assert.False(window.IsWithinServiceWindow(OutdoorCell)); } @@ -61,7 +81,9 @@ public sealed class GraphicalRemotePlacementServiceWindowTests state.AddEntitiesToExistingLandblock( LandblockHighWord, Array.Empty())); Assert.True(state.IsNearTierOrPending(CanonicalLandblock)); - var window = new GraphicalRemotePlacementServiceWindow(state); + var window = new GraphicalRemotePlacementServiceWindow( + state, + static _ => true); Assert.False(window.IsWithinServiceWindow(OutdoorCell)); } @@ -74,11 +96,34 @@ public sealed class GraphicalRemotePlacementServiceWindowTests CanonicalLandblock, new LandBlock(), Array.Empty())); - var window = new GraphicalRemotePlacementServiceWindow(state); + var window = new GraphicalRemotePlacementServiceWindow( + state, + static _ => true); Assert.True(window.IsWithinServiceWindow(OutdoorCell)); _ = state.DetachNearLayer(LandblockHighWord); Assert.False(window.IsWithinServiceWindow(OutdoorCell)); } + + [Fact] + public void False_WhenExactPresentationPrefixIsIncomplete() + { + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + CanonicalLandblock, + new LandBlock(), + Array.Empty())); + uint observed = 0; + var window = new GraphicalRemotePlacementServiceWindow( + state, + id => + { + observed = id; + return false; + }); + + Assert.False(window.IsWithinServiceWindow(OutdoorCell)); + Assert.Equal(CanonicalLandblock, observed); + } } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPlacementPublicationCompositionTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPlacementPublicationCompositionTests.cs new file mode 100644 index 000000000..709258abb --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockPlacementPublicationCompositionTests.cs @@ -0,0 +1,784 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Lighting; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.Rendering.Wb; +using AcDream.Core.Terrain; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockPlacementPublicationCompositionTests +{ + private const uint LandblockId = 0x3031FFFFu; + private const uint CellId = 0x30310014u; + private const uint PlayerGuid = 0x5000000Au; + + [Fact] + public void CollisionRefresh_WithMissingGraphicalBackend_PublishesBackendBeforeExactRestoreAck() + { + using var fixture = new Fixture(); + RuntimePlacementProjectionToken seeded = fixture.SeedResident(PlayerGuid); + Assert.True(fixture.Lifetime.Physics.SetPosition.AcknowledgeProjection(seeded)); + using var subscription = fixture.Subscribe(PlayerGuid); + LandblockStreamResult.Loaded result = fixture.Result(); + + fixture.Pipeline.PublishLoaded(result); + + Assert.True(fixture.Pipeline.HasPendingPublication(result)); + Assert.Equal(0, fixture.Pipeline.Diagnostics.Physics.CompleteCount); + Assert.Equal(0, fixture.Pipeline.Diagnostics.Statics.CompleteCount); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restore)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restore.Kind); + Assert.Equal(CellId, restore.Token.ExactCellId); + Assert.True(fixture.State.IsLoaded(LandblockId)); + Assert.Equal( + 1, + fixture.Lifetime.Physics.CaptureOwnership() + .CommittedCollisionPrefixMutationCount); + Assert.False(fixture.Controller.IsRenderNeighborhoodResident( + LandblockId, + nearRadius: 0, + farRadius: 0)); + var serviceWindow = new GraphicalRemotePlacementServiceWindow( + fixture.State, + fixture.Controller.IsLandblockPresentationReady); + Assert.False(serviceWindow.IsWithinServiceWindow(CellId)); + + Assert.True(subscription.RetryPending()); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(out _)); + fixture.Pipeline.ResumePublication(result); + + Assert.False(fixture.Pipeline.HasPendingPublication(result)); + Assert.Equal(1, fixture.Pipeline.Diagnostics.Physics.CompleteCount); + Assert.Equal(1, fixture.Pipeline.Diagnostics.Statics.CompleteCount); + Assert.True(fixture.Controller.IsRenderNeighborhoodResident( + LandblockId, + nearRadius: 0, + farRadius: 0)); + Assert.True(serviceWindow.IsWithinServiceWindow(CellId)); + LiveEntityRecord record = Assert.Single(fixture.Runtime.MaterializedRecords); + Assert.True(record.IsSpatiallyProjected); + Assert.Equal(CellId, record.FullCellId); + WorldEntity projected = Assert.Single( + fixture.State.Entities, + entity => entity.ServerGuid == PlayerGuid); + Assert.Same( + Assert.Single(fixture.Runtime.MaterializedRecords).WorldEntity, + projected); + } + + [Fact] + public void CollisionRefresh_TwoResidentsRetainOrderedExactReceiptsAndCompleteOnce() + { + const uint remoteGuid = 0x5000000Bu; + using var fixture = new Fixture(); + RuntimePlacementProjectionToken firstSeed = + fixture.SeedResident(PlayerGuid, isLocalPlayer: true); + Assert.True(fixture.Lifetime.Physics.SetPosition.AcknowledgeProjection( + firstSeed)); + RuntimePlacementProjectionToken secondSeed = + fixture.SeedResident(remoteGuid, isLocalPlayer: false); + Assert.True(fixture.Lifetime.Physics.SetPosition.AcknowledgeProjection( + secondSeed)); + var observer = new PlacementRecorder(); + using IDisposable observerSubscription = + fixture.Lifetime.Events.SubscribePlacement(observer); + using var subscription = fixture.Subscribe(PlayerGuid); + LandblockStreamResult.Loaded result = fixture.Result(); + + fixture.Pipeline.PublishLoaded(result); + + RuntimePlacementProjectionSnapshot head = AssertHead(fixture); + RuntimeEntityKey[] orderedPlaces = observer.Placements + .Where(static placement => + placement.Kind == RuntimePlacementProjectionKind.Place) + .Select(static placement => placement.Token.Entity) + .ToArray(); + Assert.Equal(2, orderedPlaces.Length); + Assert.Equal(orderedPlaces[0], head.Token.Entity); + Assert.NotEqual(orderedPlaces[0], orderedPlaces[1]); + Assert.Equal(2, fixture.Lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.True(fixture.State.IsLoaded(LandblockId)); + + Assert.True(subscription.RetryPending()); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(out _)); + fixture.Pipeline.ResumePublication(result); + + Assert.False(fixture.Pipeline.HasPendingPublication(result)); + Assert.Equal(1, fixture.Pipeline.Diagnostics.Physics.CompleteCount); + Assert.Equal(1, fixture.Pipeline.Diagnostics.Statics.CompleteCount); + Assert.Equal( + [PlayerGuid, remoteGuid], + fixture.State.Entities + .Where(static entity => entity.ServerGuid != 0u) + .Select(static entity => entity.ServerGuid) + .Order() + .ToArray()); + RuntimePhysicsOwnershipSnapshot ownership = + fixture.Lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, ownership.PendingSetPositionHostAcknowledgementCount); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void MeterExpiryAfterEngineTransfer_YieldsBeforeBackendAndNextFrameProgresses() + { + using var fixture = new Fixture(); + RuntimePlacementProjectionToken seeded = fixture.SeedResident(PlayerGuid); + Assert.True(fixture.Lifetime.Physics.SetPosition.AcknowledgeProjection(seeded)); + using var subscription = fixture.Subscribe(PlayerGuid); + LandblockStreamResult.Loaded result = fixture.Result(); + long timestamp = 0; + var observer = new PlacementRecorder( + onPlacement: placement => + { + if (placement.Kind == RuntimePlacementProjectionKind.Place) + timestamp = 2; + }); + using IDisposable observerSubscription = + fixture.Lifetime.Events.SubscribePlacement(observer); + StreamingWorkBudget budget = MeterBudget(); + var firstMeter = new StreamingWorkMeter( + budget, + () => timestamp, + timestampFrequency: 1_000); + + LandblockPublicationAdvance first = fixture.Pipeline.PublishLoaded( + result, + LandblockStreamResultCost.Estimate(result), + firstMeter, + ensureProgress: false); + firstMeter.FinishFrame(); + + Assert.False(first.Completed); + Assert.True(first.Progressed); + Assert.Equal(StreamingWorkLimit.Time, firstMeter.Snapshot.LastLimit); + Assert.False(fixture.State.IsLoaded(LandblockId)); + RuntimePlacementProjectionSnapshot head = AssertHead(fixture); + Assert.Equal(RuntimePlacementProjectionKind.Place, head.Kind); + Assert.Equal(1, observer.Placements.Count(static placement => + placement.Kind == RuntimePlacementProjectionKind.Place)); + + timestamp = 0; + var secondMeter = new StreamingWorkMeter( + budget, + () => timestamp, + timestampFrequency: 1_000); + LandblockPublicationAdvance second = fixture.Pipeline.ResumePublication( + result, + secondMeter, + ensureProgress: false); + secondMeter.FinishFrame(); + + Assert.False(second.Completed); + Assert.True(second.Progressed); + Assert.True(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(1, fixture.Pipeline.Diagnostics.Physics.BeginCount); + Assert.Equal(0, fixture.Pipeline.Diagnostics.Physics.CompleteCount); + Assert.Equal(1, observer.Placements.Count(static placement => + placement.Kind == RuntimePlacementProjectionKind.Place)); + + Assert.True(subscription.RetryPending()); + var thirdMeter = new StreamingWorkMeter(budget); + LandblockPublicationAdvance third = fixture.Pipeline.ResumePublication( + result, + thirdMeter, + ensureProgress: true); + thirdMeter.FinishFrame(); + Assert.True(third.Completed); + Assert.Equal(1, fixture.Pipeline.Diagnostics.Physics.CompleteCount); + Assert.Equal(1, fixture.Pipeline.Diagnostics.Statics.CompleteCount); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void ControllerResetOrRecenter_AfterEngineTransferRetriesFailedBackendThenRetires( + bool recenter, + bool activateBeforeRequest = false) + { + var projection = new FailFirstStaticProjectionSink(); + using var fixture = new Fixture(projection); + RuntimePlacementProjectionToken seeded = fixture.SeedResident(PlayerGuid); + Assert.True(fixture.Lifetime.Physics.SetPosition.AcknowledgeProjection(seeded)); + using var subscription = fixture.Subscribe(PlayerGuid); + LandblockStreamResult.Loaded result = fixture.Result(); + long timestamp = 0; + var observer = new PlacementRecorder( + onPlacement: placement => + { + if (placement.Kind == RuntimePlacementProjectionKind.Place) + timestamp = 2; + }); + using IDisposable observerSubscription = + fixture.Lifetime.Events.SubscribePlacement(observer); + var firstMeter = new StreamingWorkMeter( + MeterBudget(), + () => timestamp, + timestampFrequency: 1_000); + LandblockPublicationAdvance first = fixture.Pipeline.PublishLoaded( + result, + LandblockStreamResultCost.Estimate(result), + firstMeter, + ensureProgress: false); + firstMeter.FinishFrame(); + Assert.False(first.Completed); + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(0, projection.Attempts); + + if (activateBeforeRequest) + { + Assert.Throws(() => + fixture.Pipeline.ResumePublication(result)); + Assert.True(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(1, projection.Attempts); + Assert.False(fixture.Controller.IsLandblockPresentationReady( + LandblockId)); + } + + if (recenter) + fixture.Controller.BeginOriginRecenter(); + else + fixture.Controller.ForceReloadWindow(); + fixture.Controller.Tick(0x30, 0x31); + + Assert.True(fixture.Pipeline.HasPendingPublication(result)); + Assert.True(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(activateBeforeRequest ? 2 : 1, projection.Attempts); + Assert.False(fixture.Controller.IsLandblockPresentationReady(LandblockId)); + RuntimePhysicsOwnershipSnapshot afterFailure = + fixture.Lifetime.Physics.CaptureOwnership(); + Assert.Equal(1, afterFailure.CommittedCollisionPrefixMutationCount); + Assert.Equal(1, afterFailure.PendingSetPositionHostAcknowledgementCount); + + fixture.Controller.Tick(0x30, 0x31); + Assert.Equal(2, projection.Attempts); + Assert.True(fixture.State.IsLoaded(LandblockId)); + Assert.False(fixture.Controller.IsLandblockPresentationReady(LandblockId)); + + Assert.True(subscription.RetryPending()); + bool recenterCommitted = false; + for (int frame = 0; + frame < 16 + && (fixture.Pipeline.HasPendingPublication(result) + || fixture.State.IsLoaded(LandblockId) + || fixture.Pipeline.PendingRetirementCount != 0); + frame++) + { + fixture.Controller.Tick(0x30, 0x31); + if (recenter + && !recenterCommitted + && fixture.Controller.IsOriginRecenterRetirementComplete()) + { + Assert.True(fixture.Controller.TryCommitOriginRecenter( + destinationX: 0x32, + destinationY: 0x31, + isSealedDungeon: false)); + recenterCommitted = true; + } + } + + Assert.False(fixture.Pipeline.HasPendingPublication(result)); + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(recenter, recenterCommitted); + Assert.Equal(2, projection.Attempts); + Assert.Equal(0, fixture.Meshes.ReferenceCount); + RuntimePhysicsOwnershipSnapshot completed = + fixture.Lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, completed.CollisionPrefixMutationCount); + Assert.Equal(0, completed.CollisionAdmissionCount); + Assert.Equal(0, completed.PendingSetPositionHostAcknowledgementCount); + } + + [Fact] + public void PresentationFence_IsExactLandblockAndStopsBeforeLiveRecovery() + { + const uint unrelated = 0x4040FFFFu; + using (var fixture = new Fixture()) + { + RuntimePlacementProjectionToken seeded = + fixture.SeedResident(PlayerGuid); + Assert.True(fixture.Lifetime.Physics.SetPosition.AcknowledgeProjection( + seeded)); + using var subscription = fixture.Subscribe(PlayerGuid); + LandblockStreamResult.Loaded result = fixture.Result(); + fixture.Pipeline.PublishLoaded(result); + fixture.State.AddLandblock(new LoadedLandblock( + unrelated, + new LandBlock(), + Array.Empty())); + var window = new GraphicalRemotePlacementServiceWindow( + fixture.State, + fixture.Controller.IsLandblockPresentationReady); + + Assert.False(window.IsWithinServiceWindow(CellId)); + Assert.True(window.IsWithinServiceWindow(unrelated)); + } + + bool failRecovery = true; + using (var fixture = new Fixture(onLandblockLoaded: _ => + { + if (failRecovery) + { + failRecovery = false; + throw new InvalidOperationException("injected recovery failure"); + } + })) + { + LandblockStreamResult.Loaded result = fixture.Result(); + Assert.Throws(() => + fixture.Pipeline.PublishLoaded(result)); + Assert.True(fixture.Pipeline.HasPendingPublication(result)); + Assert.True(fixture.Controller.IsLandblockPresentationReady( + LandblockId)); + Assert.True(fixture.Controller.IsRenderNeighborhoodResident( + LandblockId, + nearRadius: 0, + farRadius: 0)); + + fixture.Pipeline.ResumePublication(result); + Assert.False(fixture.Pipeline.HasPendingPublication(result)); + } + } + + [Fact] + public void CollisionRefresh_OutdoorSeamRecordsActualPrefixAndBackend() + { + const uint adjacentLandblock = 0x3131FFFFu; + const uint seamCell = 0x3031003Cu; + var seamPosition = new Vector3(191.5f, 84f, 0f); + using var fixture = new Fixture(); + fixture.AddCollisionLandblock(adjacentLandblock); + RuntimePlacementProjectionToken seeded = fixture.SeedResident( + PlayerGuid, + isLocalPlayer: true, + seamCell, + seamPosition); + Assert.True(fixture.Lifetime.Physics.SetPosition.AcknowledgeProjection( + seeded)); + using var subscription = fixture.Subscribe(PlayerGuid); + LandblockStreamResult.Loaded result = fixture.Result(); + + fixture.Pipeline.PublishLoaded(result); + + RuntimePlacementProjectionSnapshot restore = AssertHead(fixture); + uint actualPrefix = restore.Token.ExactCellId & 0xFFFF0000u; + Console.WriteLine( + $"#474 seam restoreCell=0x{restore.Token.ExactCellId:X8} " + + $"actualPrefix=0x{actualPrefix:X8} " + + $"targetBackend={fixture.State.IsLoaded(LandblockId)} " + + $"adjacentBackend={fixture.State.IsLoaded(adjacentLandblock)}"); + Assert.Contains(actualPrefix, new[] + { + LandblockId & 0xFFFF0000u, + adjacentLandblock & 0xFFFF0000u, + }); + Assert.True(fixture.State.IsLoaded(LandblockId)); + Assert.False(fixture.State.IsLoaded(adjacentLandblock)); + Assert.True( + fixture.State.IsLoaded(actualPrefix | 0xFFFFu), + $"actual restore prefix 0x{actualPrefix:X8} lacked a backend"); + + Assert.True(subscription.RetryPending()); + fixture.Pipeline.ResumePublication(result); + Assert.False(fixture.Pipeline.HasPendingPublication(result)); + Assert.Equal(restore.Token.ExactCellId, + Assert.Single(fixture.Runtime.MaterializedRecords).FullCellId); + } + + private static RuntimePlacementProjectionSnapshot AssertHead(Fixture fixture) + { + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)); + return head; + } + + private static StreamingWorkBudget MeterBudget() => new( + TimeSpan.FromMilliseconds(1), + maxCompletionAdmissions: 64, + maxAdoptedCpuBytes: 1_000_000, + maxEntityOperations: 32, + maxGpuUploadBytes: 1_000_000, + maxGlRetireOperations: 64, + destinationReserveFraction: 0.75f); + + private sealed class Fixture : IDisposable + { + internal Fixture( + IRenderStaticProjectionJournalSink? staticProjectionSink = null, + Action? onLandblockLoaded = null) + { + Lifetime = new RuntimeEntityObjectLifetime(new PhysicsDataCache()); + Lifetime.BindEventContext( + static () => new RuntimeGenerationToken(1UL), + static () => 1UL); + RuntimePhysicsState physics = Lifetime.Physics; + physics.SetPosition.BeginCollisionGeneration(LandblockId, 1UL); + physics.Engine.AddLandblock( + LandblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + physics.SetPosition.CommitCollisionGeneration( + LandblockId, + 1UL, + ready: true); + physics.ObserveLocalWorldFrame(CellId, teleportAdvanced: false); + + Meshes = new CountingMeshAdapter(); + State = new GpuWorldState(new LandblockSpawnAdapter(Meshes)); + Runtime = new LiveEntityRuntime(State, new NoopResources(), Lifetime); + + var render = new LandblockRenderPublisher( + static (_, _, _) => { }, + static _ => { }, + new CellVisibility(), + State); + var physicsPublisher = new LandblockPhysicsPublisher( + physics, + new float[256]); + var lighting = new LightingHookSink( + new LightManager(), + new NullPoseSource()); + var translucency = new TranslucencyFadeManager(); + var staticPublisher = new LandblockStaticPresentationPublisher( + lighting, + translucency, + new WorldGameState(), + new WorldEvents()); + var retirement = new LandblockPresentationRetirementOwner( + render, + physicsPublisher, + staticPublisher, + lighting, + translucency); + Pipeline = new LandblockPresentationPipeline( + render, + physicsPublisher, + staticPublisher, + State, + retirement, + onLandblockLoaded, + ensureEnvCellMeshes: null, + staticProjectionSink: staticProjectionSink); + Controller = new StreamingController( + enqueueLoad: static (_, _, _) => { }, + enqueueUnload: static (_, _) => { }, + drainCompletions: static _ => + Array.Empty(), + State, + nearRadius: 0, + farRadius: 0, + Pipeline); + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal CountingMeshAdapter Meshes { get; } + internal GpuWorldState State { get; } + internal LiveEntityRuntime Runtime { get; } + internal LandblockPresentationPipeline Pipeline { get; } + internal StreamingController Controller { get; } + + internal void AddCollisionLandblock(uint landblockId) + { + RuntimePhysicsState physics = Lifetime.Physics; + physics.SetPosition.BeginCollisionGeneration(landblockId, 1UL); + physics.Engine.AddLandblock( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 192f, + worldOffsetY: 0f); + physics.SetPosition.CommitCollisionGeneration( + landblockId, + 1UL, + ready: true); + } + + internal RuntimePlacementProjectionToken SeedResident( + uint guid, + bool isLocalPlayer = true, + uint cellId = CellId, + Vector3? position = null) + { + Vector3 resolvedPosition = position ?? new Vector3(60f, 84f, 0f); + LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection( + Spawn(guid, cellId, resolvedPosition), + isLocalPlayer: isLocalPlayer); + Assert.False(Runtime.HasActiveInitialCreateResidence(record.Canonical)); + var body = new PhysicsBody + { + Position = resolvedPosition, + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cellId, body.Position, body.Position); + Lifetime.Entities.SetFinalPhysicsState( + record.Canonical, + PhysicsStateFlags.Gravity); + Lifetime.Entities.SetFullCell( + record.Canonical, + cellId, + (cellId & 0xFFFF0000u) | 0xFFFFu); + Lifetime.Entities.SetPhysicsBody(record.Canonical, body); + record.Canonical.ObjectClock.Activate(); + Lifetime.Physics.AcknowledgeSpatialProjection( + record.Canonical, + spatial: true); + + var request = new PhysicsSetPositionRequest( + body.Position, + body.Orientation, + cellId, + body.CellPosition.Frame.Origin, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + 1f, + 0.4f, + 0.4f, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide, + CurrentCellId: cellId); + RuntimeSetPositionOutcome placed = Lifetime.Physics.SetPosition.Apply( + record.Canonical, + record.Canonical.PositionAuthorityVersion, + new RuntimeSetPositionCommand( + request, + RuntimeSetPositionOperationKind.LocalAuthoritative, + 1d, + record.Canonical.VelocityAuthorityVersion)); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + placed.Status); + return placed.Projection; + } + + internal RuntimePlacementProjectionSubscription Subscribe(uint guid) + { + var identity = new LocalPlayerIdentityState { ServerGuid = guid }; + var origin = new LiveWorldOriginState(); + origin.SetPlaceholder(0, 0); + var sink = new RuntimePlacementPresentationSink( + Runtime, + new RuntimeWorldTransitState(), + new WorldGameState(), + new WorldEvents(), + new EntityEffectPoseRegistry(), + new LocalPlayerShadowSynchronizer( + Lifetime.Physics.Engine, + Runtime, + identity, + origin, + new LocalPlayerShadowState()), + () => guid, + static _ => { }); + return new RuntimePlacementProjectionSubscription( + Lifetime.Placements, + static () => new RuntimeGenerationToken(1UL), + sink); + } + + internal LandblockStreamResult.Loaded Result() + { + var landblock = new LoadedLandblock( + LandblockId, + new LandBlock + { + Terrain = new TerrainInfo[81], + Height = new byte[81], + }, + Array.Empty(), + PhysicsDatBundle.Empty); + var shell = new EnvCellShellPlacement( + CellId: 0x30310100u, + GeometryId: 0x2_0000_0474UL, + EnvironmentId: 0x0D000001u, + CellStructure: 1, + Surfaces: ImmutableArray.Empty, + WorldPosition: Vector3.Zero, + Rotation: Quaternion.Identity, + Transform: Matrix4x4.Identity, + LocalBounds: new WbBoundingBox(Vector3.Zero, Vector3.One), + WorldBounds: new WbBoundingBox(Vector3.Zero, Vector3.One)); + var build = new LandblockBuild( + landblock, + new EnvCellLandblockBuild( + LandblockId, + Array.Empty(), + [shell]), + Origin: new LandblockBuildOrigin(0x30, 0x31)); + return new LandblockStreamResult.Loaded( + LandblockId, + LandblockStreamTier.Near, + build, + new LandblockMeshData( + Array.Empty(), + Array.Empty())); + } + + public void Dispose() + { + Runtime.Clear(); + Lifetime.Dispose(); + } + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + uint cellId = CellId, + Vector3? worldPosition = null) + { + Vector3 positionValue = worldPosition ?? new Vector3(60f, 84f, 0f); + var position = new CreateObject.ServerPosition( + cellId, + positionValue.X, + positionValue.Y, + positionValue.Z, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + [], + [], + [], + null, + null, + "#474 resident", + (uint)ItemType.Creature, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class NoopResources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) { } + } + + private sealed class PlacementRecorder( + Action? onPlacement = null) + : IRuntimePlacementObserver + { + internal List Placements { get; } = []; + + public void OnPlacement(in RuntimePlacementDelta delta) + { + Placements.Add(delta.Placement); + onPlacement?.Invoke(delta.Placement); + } + } + + private sealed class FailFirstStaticProjectionSink + : IRenderStaticProjectionJournalSink + { + internal int Attempts { get; private set; } + + public void Reconcile( + LandblockBuild build, + GpuLandblockSpatialPublication publication) + { + Attempts++; + if (Attempts == 1) + throw new InvalidOperationException("injected projection failure"); + } + + public void Retire(GpuLandblockRetirement retirement) { } + } + + internal sealed class CountingMeshAdapter : IWbMeshAdapter + { + internal int ReferenceCount { get; private set; } + + public void IncrementRefCount(ulong id) => ReferenceCount++; + public void PinPreparedRenderData(ulong id) => ReferenceCount++; + public void DecrementRefCount(ulong id) => ReferenceCount--; + } + + private sealed class NullPoseSource : IEntityEffectPoseSource + { + public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) + { + rootWorld = default; + return false; + } + + public bool TryGetPartPose( + uint localEntityId, + int partIndex, + out Matrix4x4 partLocal) + { + partLocal = default; + return false; + } + } +}