diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 6c2eaa19..78f0051e 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1238,6 +1238,15 @@ fenced only by an older receipt for the same canonical landblock; the active destination's exact receipt may advance ahead of unrelated cleanup without reordering the remaining FIFO. +Within a retirement ticket, Runtime's collision-prefix permission continues +through the same invocation after parking when the exact token remains current +and no real projection, placement, resident, or collision-dispatch debt exists. +That lets the coordinator spend its remaining frame meter on subsequent +debt-free tickets instead of manufacturing one frame boundary per prefix. +Synchronous cancellation and parking callbacks are exact-owner revalidation +points; genuine receipt or re-entrant debt still yields at the unchanged FIFO +head and resumes only after its own acknowledgement. + Accepted publication is likewise retained at its exact priority-queue head. `LandblockPresentationPipeline` meters render, physics, static, building, and EnvCell receipts one operation at a time; each owner captures stable ordered diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8dc7b328..995bfe14 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. **#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-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. **#475 refinement (2026-09-05):** collision-prefix retirement no longer invents a mandatory frame boundary after its first parking pass. When the exact quiescence owner survives every synchronous cancellation/parking callback and the existing projection, placement, resident, and collision-dispatch debt guards are all clear, permission and retirement may complete in that same call; the unchanged frame meter can then admit another FIFO ticket. Real or re-entrant debt still yields at the exact head until its acknowledgement. Retail releases the complete landscape square synchronously (`LScape::release_all` -> `CLandBlock::release_all`); this remains an asynchronous metered adaptation, but not a one-empty-prefix-per-frame policy. | `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/docs/research/2026-09-01-overhaul/475-lead-verification.md b/docs/research/2026-09-01-overhaul/475-lead-verification.md index a7c04420..63f4d809 100644 --- a/docs/research/2026-09-01-overhaul/475-lead-verification.md +++ b/docs/research/2026-09-01-overhaul/475-lead-verification.md @@ -1,6 +1,7 @@ # #475 — lead verification and review record -Status2026-09-05: **NOT integration-ready; test-only fix round1 in progress.** +Status2026-09-05: **Test-only fix round1 independently verified; production/ +lifetime review2 PASS with no actionable findings. Graphical timing pending.** Contract: `475-debt-free-retirement-contract.md`, including §6 at3db6546eb. No graphical475 run or owner acceptance is claimed. @@ -119,3 +120,66 @@ whole-fixture rewrite, screenshot repair, budget change or reveal bypass. The production/lifetime review follows the corrected frozen return and lead verification; no extra wholesale retail rerun is planned for test-only edits. Graphical timing/PNG evidence and owner G4 remain unproven. + +## Fix round1 — independently verified corrected return + +Only three test files changed in round1; both production raw hashes above +remain byte-identical. The lead read the entire correction and checked these +raw file hashes against the frozen return: + +| Test file | SHA256 | +|---|---| +| RuntimePhysicsStateTests.cs |3F3BD2FD8A28A806ECB092B02FED419D4385ECBBBD98B4C21DA841B845E19088| +| HeadlessSessionHostTests.cs |4008A19CCBAE4789A0CA46D7E2E58210F88008B2C985BF6FF23C88D951326DE2| +| RuntimeCollisionPrefixQuiescenceTests.DebtFreeRetirement.cs |05787B3017E194AD7E9018F45B153243B0D3D3BEC3C0CC004E976871DBABFF62| + +The allocation tests now assert pre-activation state, then measure the first +actual CommitCollisionGeneration call; existing1MiB/4MiB bounds and payload, +facade, notification, capture/seal and second-prefix rebase checks remain. +The three stale names now truthfully say PayloadBoundedAllocation. Headless's +empty transaction stops at completion with no synthetic yield; its real +held Withdraw/Place path explicitly proves yield, kind, exact entity/cell +identity and token acknowledgement. No Headless production change. + +The direct Runtime theory now installs canonical terrain and a nonempty +indoor CellSurface/EnvCell graph. Before mutation it verifies the actual +installed Cells entry; afterward, withdrawal removes the key/terrain while +demotion retains terrain and empties installed Cells. Both remove the graph +cell, complete on the first admissible call with generation2, and have zero +terminal mutation/quiescence/projection debt. + +Lead inspected the implementer's two effect-mutation TRXs: +`artifacts/475-round1/mutation-bypass-withdraw-final/475-r1-mutation-bypass-withdraw-final.trx` +fails only the withdrawal row (terrain expected absent, actual present); +`mutation-bypass-demote-final/475-r1-mutation-bypass-demote-final.trx` +fails only the demotion row (installed Cells expected empty, actual one cell). +Both are1passed/1failed and fail at the intended postcondition. The +implementer reports raw pre/post restoration of both production files; lead +independently verified their current exact hashes. These two effect mutations +were NOT re-executed by the lead; the four earlier categories were. + +Fresh lead Release build with the same quiet command above:0W0E/exit0, +`artifacts/475-lead/round1-release.log`. After client absence checks before +every command, lead re-ran the same command template above: + +| Stem / project | Filter | Result | +|---|---|---| +| round1-runtime-hermetic / Runtime |Same six Lane exclusions as the earlier Runtime hermetic run|1891/1891,0skips,exit0| +| round1-headless / Headless |Same two caller classes as headless-callers|48/48,0skips,exit0| +| round1-app / App |Union of the exact app-final and app-callers filters above|297/297,0skips,exit0| + +The complete frozen scope is now11files (10modified +1new partial). +Retail review1's test-only findings are reconciled by the lead; production/ +lifetime review2 is examining the complete corrected candidate. No separate +wholesale retail review was launched and no graphical acceptance is inferred. + +Production/lifetime review2 returned PASS, no actionable findings. It checked +all11 source/document hashes, current assemblies, intended mutation failures +and the lead1891/48/297 green TRXs. Both lenses are closed; round1 changed +tests only and the lead reconciled retail review1's two findings. + +The owner then requested the latest build immediately, with a two-minute +availability window. Integrate, rebuild Release and launch a manual portal +re-test before the automated nine-hop run; this explicit owner ordering does +not constitute a timing PASS or G4 acceptance. Do not build/test while that +client is open. The normal automated route remains owed. diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index b8cec4b4..d085479d 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -1887,10 +1887,11 @@ public sealed class RuntimePhysicsState : IDisposable }; _collisionPrefixMutations.Add(admission.LandblockId, mutation); - // The first poll deliberately closes the prefix and marks its - // resident-parking pass complete, even when the prefix is empty. - // A later poll alone may consume mutation permission; affected - // residents also require their exact Withdraw acknowledgements. + // The permission poll closes the prefix and parks affected + // residents. A debt-free prefix may continue through mutation in + // this call; affected residents still require their exact + // Withdraw acknowledgements, and beginning quiescence advances + // authority so activation may independently wait for resealing. } if (!TryAcquireCollisionPrefixMutationPermission( diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 2ddebec5..f4bbea16 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -950,21 +950,40 @@ internal sealed class RuntimeSetPositionState : IDisposable // before evaluating ordinary placement debt, then let the resident // enter the retirement park below. CancelUnpreparedPrefixPlacementDebt(current); + if (!TryGetCurrentQuiescence( + token, + out CollisionPrefixQuiescence? afterCancellation) + || !ReferenceEquals(afterCancellation, current)) + { + return false; + } if (HasOldPrefixPlacementDebt(current)) return false; if (!current.ResidentsParked) { ParkCollisionResidentsForQuiescence(current); + if (!TryGetCurrentQuiescence( + token, + out CollisionPrefixQuiescence? afterParking) + || !ReferenceEquals(afterParking, current)) + { + return false; + } current.ResidentsParked = true; - return false; } else if (HasAffectedCollisionResident( token.LandblockPrefix, current.IncludeOutdoorCells)) { ParkCollisionResidentsForQuiescence(current); - return false; + if (!TryGetCurrentQuiescence( + token, + out CollisionPrefixQuiescence? afterAdditionalParking) + || !ReferenceEquals(afterAdditionalParking, current)) + { + return false; + } } RemoveRetiredQuiescenceWithdrawals(current); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs index d91c40fe..03297459 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -505,9 +505,12 @@ public sealed class LandblockConcretePresentationPipelineTests pipeline.BeginFullRetirement(LandblockId); Assert.False(fixture.State.IsLoaded(LandblockId)); - Assert.Equal(1, pipeline.PendingRetirementCount); - Assert.Equal(1, terrainAttempts); - Assert.Equal(0, fixture.Render.Diagnostics.TerrainRemovalCount); + // #475 lets the first debt-free physics attempt finish, so the + // compatibility path's existing second eager pass retries only the + // failed terrain stage before BeginFullRetirement returns. + Assert.Equal(0, pipeline.PendingRetirementCount); + Assert.Equal(2, terrainAttempts); + Assert.Equal(1, fixture.Render.Diagnostics.TerrainRemovalCount); Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount); Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPlacementPublicationCompositionTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPlacementPublicationCompositionTests.cs index 709258ab..272e2d84 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPlacementPublicationCompositionTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPlacementPublicationCompositionTests.cs @@ -411,6 +411,153 @@ public sealed class LandblockPlacementPublicationCompositionTests Assert.Single(fixture.Runtime.MaterializedRecords).FullCellId); } + [Fact] + public void DebtFreeFullRetirementsDrainMultipleRealOwnersInOneBudgetedFrame() + { + uint[] landblockIds = + [ + 0x3031FFFFu, + 0x3032FFFFu, + 0x3033FFFFu, + ]; + var removalOrder = new List(); + using var fixture = new Fixture(removeTerrain: removalOrder.Add); + foreach (uint landblockId in landblockIds) + { + fixture.AddDebtFreeLandblock(landblockId); + fixture.Pipeline.EnqueueFullRetirement(landblockId); + } + Assert.Equal(landblockIds.Length, fixture.Pipeline.PendingRetirementCount); + + var meter = new StreamingWorkMeter(RetirementBudget( + maxEntityOperations: 64, + maxGlRetireOperations: 16)); + fixture.Pipeline.AdvanceRetirements(meter); + meter.FinishFrame(); + + Assert.Equal(0, fixture.Pipeline.PendingRetirementCount); + Assert.Equal(landblockIds.Length, + fixture.PhysicsPublisher.Diagnostics.FullRemovalCount); + Assert.Equal(landblockIds, removalOrder); + Assert.Equal(removalOrder.Count, removalOrder.Distinct().Count()); + Assert.Equal(33, meter.Snapshot.Operations); + Assert.Equal(33, meter.Snapshot.CompletedOperations); + Assert.Equal(0, meter.Snapshot.YieldCount); + Assert.All(landblockIds, landblockId => + { + Assert.False(fixture.State.IsLoaded(landblockId)); + Assert.False(fixture.Lifetime.Physics.Engine + .IsLandblockTerrainResident(landblockId)); + Assert.False(fixture.Pipeline.IsRetirementPending(landblockId)); + }); + RuntimePhysicsOwnershipSnapshot ownership = + fixture.Lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + } + + [Fact] + public void DebtFreeFullRetirementsRespectSmallMeterAndResumeExactlyOnce() + { + uint[] landblockIds = + [ + 0x3031FFFFu, + 0x3032FFFFu, + 0x3033FFFFu, + ]; + using var fixture = new Fixture(); + foreach (uint landblockId in landblockIds) + { + fixture.AddDebtFreeLandblock(landblockId); + fixture.Pipeline.EnqueueFullRetirement(landblockId); + } + + int frames = 0; + while (fixture.Pipeline.PendingRetirementCount != 0 && frames++ < 8) + { + var meter = new StreamingWorkMeter(RetirementBudget( + maxEntityOperations: 7, + maxGlRetireOperations: 4)); + fixture.Pipeline.AdvanceRetirements(meter); + meter.FinishFrame(); + Assert.InRange(meter.Snapshot.Used.EntityOperations, 1, 7); + Assert.InRange(meter.Snapshot.Used.GlRetireOperations, 0, 4); + Assert.Equal(meter.Snapshot.Operations, + meter.Snapshot.CompletedOperations); + } + + Assert.Equal(5, frames); + Assert.Equal(0, fixture.Pipeline.PendingRetirementCount); + Assert.Equal(landblockIds.Length, + fixture.PhysicsPublisher.Diagnostics.FullRemovalCount); + Assert.All(landblockIds, landblockId => + Assert.False(fixture.Lifetime.Physics.Engine + .IsLandblockTerrainResident(landblockId))); + } + + [Fact] + public void HeldAndThrowingPlaceKeepsExactRetirementFifoHeadUntilProductionSinkAck() + { + uint[] retiringLandblockIds = + [ + 0x3032FFFFu, + 0x3033FFFFu, + ]; + var removalOrder = new List(); + using var fixture = new Fixture(removeTerrain: removalOrder.Add); + fixture.AddDebtFreeLandblock(LandblockId); + RuntimePlacementProjectionToken seeded = fixture.SeedResident(PlayerGuid); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded)); + using RuntimePlacementProjectionSubscription subscription = + fixture.SubscribeWithThrowThenHold(PlayerGuid, out var sink); + RuntimePlacementProjectionToken exact = fixture.MoveResident(PlayerGuid); + Assert.Equal(exact, Assert.Single(sink.Attempts)); + Assert.Equal(RuntimePlacementProjectionKind.Place, + AssertHead(fixture).Kind); + foreach (uint landblockId in retiringLandblockIds) + { + fixture.AddDebtFreeLandblock(landblockId); + fixture.Pipeline.EnqueueFullRetirement(landblockId); + } + + var blockedMeter = new StreamingWorkMeter(RetirementBudget( + maxEntityOperations: 64, + maxGlRetireOperations: 16)); + fixture.Pipeline.AdvanceRetirements(blockedMeter); + blockedMeter.FinishFrame(); + + Assert.Equal(2, fixture.Pipeline.PendingRetirementCount); + Assert.Equal(0, + fixture.PhysicsPublisher.Diagnostics.FullRemovalCount); + Assert.Empty(removalOrder); + Assert.True(fixture.Lifetime.Physics.Engine + .IsLandblockTerrainResident(retiringLandblockIds[0])); + + Assert.True(subscription.RetryPending()); + Assert.Equal([exact, exact], sink.Attempts); + Assert.Equal(2, fixture.Pipeline.PendingRetirementCount); + Assert.True(subscription.RetryPending()); + Assert.Equal([exact, exact, exact], sink.Attempts); + Assert.False(fixture.Lifetime.Physics.SetPosition + .TryPeekProjection(out _)); + + var completedMeter = new StreamingWorkMeter(RetirementBudget( + maxEntityOperations: 64, + maxGlRetireOperations: 16)); + fixture.Pipeline.AdvanceRetirements(completedMeter); + completedMeter.FinishFrame(); + + Assert.Equal(0, fixture.Pipeline.PendingRetirementCount); + Assert.Equal(2, + fixture.PhysicsPublisher.Diagnostics.FullRemovalCount); + Assert.Equal( + retiringLandblockIds, + removalOrder); + Assert.Equal(removalOrder.Count, removalOrder.Distinct().Count()); + } + private static RuntimePlacementProjectionSnapshot AssertHead(Fixture fixture) { Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( @@ -427,11 +574,23 @@ public sealed class LandblockPlacementPublicationCompositionTests maxGlRetireOperations: 64, destinationReserveFraction: 0.75f); + private static StreamingWorkBudget RetirementBudget( + int maxEntityOperations, + int maxGlRetireOperations) => new( + TimeSpan.FromSeconds(10), + maxCompletionAdmissions: 64, + maxAdoptedCpuBytes: 1_000_000, + maxEntityOperations, + maxGpuUploadBytes: 1_000_000, + maxGlRetireOperations, + destinationReserveFraction: 0.75f); + private sealed class Fixture : IDisposable { internal Fixture( IRenderStaticProjectionJournalSink? staticProjectionSink = null, - Action? onLandblockLoaded = null) + Action? onLandblockLoaded = null, + Action? removeTerrain = null) { Lifetime = new RuntimeEntityObjectLifetime(new PhysicsDataCache()); Lifetime.BindEventContext( @@ -458,10 +617,10 @@ public sealed class LandblockPlacementPublicationCompositionTests var render = new LandblockRenderPublisher( static (_, _, _) => { }, - static _ => { }, + removeTerrain ?? (static _ => { }), new CellVisibility(), State); - var physicsPublisher = new LandblockPhysicsPublisher( + PhysicsPublisher = new LandblockPhysicsPublisher( physics, new float[256]); var lighting = new LightingHookSink( @@ -475,13 +634,13 @@ public sealed class LandblockPlacementPublicationCompositionTests new WorldEvents()); var retirement = new LandblockPresentationRetirementOwner( render, - physicsPublisher, + PhysicsPublisher, staticPublisher, lighting, translucency); Pipeline = new LandblockPresentationPipeline( render, - physicsPublisher, + PhysicsPublisher, staticPublisher, State, retirement, @@ -503,6 +662,7 @@ public sealed class LandblockPlacementPublicationCompositionTests internal CountingMeshAdapter Meshes { get; } internal GpuWorldState State { get; } internal LiveEntityRuntime Runtime { get; } + internal LandblockPhysicsPublisher PhysicsPublisher { get; } internal LandblockPresentationPipeline Pipeline { get; } internal StreamingController Controller { get; } @@ -523,6 +683,26 @@ public sealed class LandblockPlacementPublicationCompositionTests ready: true); } + internal void AddDebtFreeLandblock(uint landblockId) + { + if (!Lifetime.Physics.Engine + .IsLandblockTerrainResident(landblockId)) + { + AddCollisionLandblock(landblockId); + } + if (!State.IsLoaded(landblockId)) + { + State.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock + { + Terrain = new TerrainInfo[81], + Height = new byte[81], + }, + Array.Empty())); + } + } + internal RuntimePlacementProjectionToken SeedResident( uint guid, bool isLocalPlayer = true, @@ -582,12 +762,64 @@ public sealed class LandblockPlacementPublicationCompositionTests return placed.Projection; } + internal RuntimePlacementProjectionToken MoveResident(uint guid) + { + Assert.True(Lifetime.Entities.TryGetActive( + guid, + out RuntimeEntityRecord record)); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + var next = body.Position + new Vector3(0.25f, 0f, 0f); + var request = new PhysicsSetPositionRequest( + next, + body.Orientation, + record.FullCellId, + next, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + 1f, + 0.4f, + 0.4f, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide, + CurrentCellId: record.FullCellId); + RuntimeSetPositionOutcome placed = Lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + new RuntimeSetPositionCommand( + request, + RuntimeSetPositionOperationKind.LocalAuthoritative, + 2d, + record.VelocityAuthorityVersion)); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + placed.Status); + return placed.Projection; + } + internal RuntimePlacementProjectionSubscription Subscribe(uint guid) + { + return new RuntimePlacementProjectionSubscription( + Lifetime.Placements, + static () => new RuntimeGenerationToken(1UL), + CreatePlacementSink(guid)); + } + + internal RuntimePlacementProjectionSubscription SubscribeWithThrowThenHold( + uint guid, + out ThrowThenHoldPlacementSink sink) + { + sink = new ThrowThenHoldPlacementSink(CreatePlacementSink(guid)); + return new RuntimePlacementProjectionSubscription( + Lifetime.Placements, + static () => new RuntimeGenerationToken(1UL), + sink); + } + + private IRuntimePlacementProjectionSink CreatePlacementSink(uint guid) { var identity = new LocalPlayerIdentityState { ServerGuid = guid }; var origin = new LiveWorldOriginState(); origin.SetPlaceholder(0, 0); - var sink = new RuntimePlacementPresentationSink( + return new RuntimePlacementPresentationSink( Runtime, new RuntimeWorldTransitState(), new WorldGameState(), @@ -601,10 +833,6 @@ public sealed class LandblockPlacementPublicationCompositionTests new LocalPlayerShadowState()), () => guid, static _ => { }); - return new RuntimePlacementProjectionSubscription( - Lifetime.Placements, - static () => new RuntimeGenerationToken(1UL), - sink); } internal LandblockStreamResult.Loaded Result() @@ -738,6 +966,25 @@ public sealed class LandblockPlacementPublicationCompositionTests } } + internal sealed class ThrowThenHoldPlacementSink( + IRuntimePlacementProjectionSink inner) + : IRuntimePlacementProjectionSink + { + internal List Attempts { get; } = []; + + public bool TryApply(in RuntimePlacementProjectionSnapshot projection) + { + Attempts.Add(projection.Token); + return Attempts.Count switch + { + 1 => throw new InvalidOperationException( + "injected retirement sink failure"), + 2 => false, + _ => inner.TryApply(projection), + }; + } + } + private sealed class FailFirstStaticProjectionSink : IRenderStaticProjectionJournalSink { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 9c087ef1..ac43c438 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -2340,7 +2340,7 @@ public sealed class HeadlessSessionHostTests } [Fact] - public void CollisionTransactionYieldsTheFirstNonterminalRuntimePoll() + public void CollisionTransactionCompletesDebtFreeWithoutSyntheticYield() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; @@ -2361,23 +2361,12 @@ public sealed class HeadlessSessionHostTests { advance = transaction.Advance(); Assert.True(advance.Progressed); + Assert.False(advance.YieldToCaller); } - while (!advance.YieldToCaller); - - Assert.False(advance.Completed); - Assert.False(advance.WaitingForProjectionAcknowledgement); - Assert.False(transaction.CompletionCommitted); - - do - { - advance = transaction.Advance(); - Assert.True(advance.Progressed); - } - while (!advance.Completed && !advance.YieldToCaller); - if (!advance.Completed) - advance = transaction.Advance(); + while (!advance.Completed); Assert.True(advance.Completed); + Assert.False(advance.WaitingForProjectionAcknowledgement); Assert.True(transaction.EngineMutationCommitted); Assert.True(transaction.CompletionCommitted); Assert.Equal(0, physics.CaptureOwnership().CollisionAdmissionCount); @@ -2441,9 +2430,14 @@ public sealed class HeadlessSessionHostTests advance = transaction.Advance(); } while (!advance.WaitingForProjectionAcknowledgement); + Assert.True(advance.YieldToCaller); + Assert.False(advance.Completed); Assert.False(transaction.EngineMutationCommitted); Assert.True(physics.SetPosition.TryPeekProjection( out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawal.Kind); + Assert.Equal(record.Key, withdrawal.Token.Entity); + Assert.Equal(cell, withdrawal.Token.ExactCellId); Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); do @@ -2451,9 +2445,16 @@ public sealed class HeadlessSessionHostTests advance = transaction.Advance(); } while (!advance.WaitingForProjectionAcknowledgement); + Assert.True(advance.YieldToCaller); + Assert.False(advance.Completed); Assert.True(transaction.EngineMutationCommitted); Assert.True(physics.SetPosition.TryPeekProjection( out RuntimePlacementProjectionSnapshot placement)); + Assert.Equal(RuntimePlacementProjectionKind.Place, placement.Kind); + Assert.Equal(withdrawal.Token.Entity, placement.Token.Entity); + Assert.True(placement.Token.Sequence > withdrawal.Token.Sequence); + Assert.NotEqual(withdrawal.Token, placement.Token); + Assert.Equal(withdrawal.Token.ExactCellId, placement.Token.ExactCellId); Assert.False(transaction.TryCancel()); Assert.True(physics.SetPosition.AcknowledgeProjection(placement.Token)); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs index 090a5f29..ae3415fb 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs @@ -128,10 +128,6 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests physics.BeginCollisionAdmission(PrefixP); using PreparedLandblockCollisionGeneration prepared = PrepareSealedMutation(physics, admission, PrefixP); - RuntimeCollisionGenerationCommit pending = - physics.CommitCollisionGeneration(admission, prepared); - Assert.False(pending.Completed); - _ = SealMutation(physics, admission, prepared); RuntimeCollisionGenerationCommit completed = physics.CommitCollisionGeneration(admission, prepared); Assert.True(completed.EngineCommitted); @@ -140,6 +136,7 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests out RuntimePlacementProjectionSnapshot restored)); Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + completed = physics.CommitCollisionGeneration(admission, prepared); Assert.True(completed.Completed); Assert.True(physics.IsSpatialRoot(record)); @@ -203,10 +200,7 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests using (PreparedLandblockCollisionGeneration baseline = PrepareSealedMutation(physics, baselineAdmission, PrefixP)) { - Assert.False(physics.CommitCollisionGeneration( - baselineAdmission, - baseline).Completed); - _ = SealMutation(physics, baselineAdmission, baseline); + // #475: this sealed baseline has no resident or receipt debt. Assert.True(physics.CommitCollisionGeneration( baselineAdmission, baseline).Completed); @@ -263,9 +257,7 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests using (PreparedLandblockCollisionGeneration preparedBaseline = PrepareSealedMutation(physics, baseline, PrefixP)) { - Assert.False(physics.CommitCollisionGeneration( - baseline, - preparedBaseline).Completed); + // #475: this sealed baseline has no resident or receipt debt. Assert.True(physics.CommitCollisionGeneration( baseline, preparedBaseline).Completed); @@ -327,9 +319,7 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests using (PreparedLandblockCollisionGeneration preparedBaseline = PrepareSealedMutation(physics, baseline, PrefixP)) { - Assert.False(physics.CommitCollisionGeneration( - baseline, - preparedBaseline).Completed); + // #475: this sealed baseline has no resident or receipt debt. Assert.True(physics.CommitCollisionGeneration( baseline, preparedBaseline).Completed); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.DebtFreeRetirement.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.DebtFreeRetirement.cs new file mode 100644 index 00000000..743edf52 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.DebtFreeRetirement.cs @@ -0,0 +1,262 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed partial class RuntimeCollisionPrefixQuiescenceTests +{ + [Fact] + public void EmptyPrefixPermissionCompletesOnFirstAdmissibleCall() + { + using var fixture = new Fixture(); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + Assert.True(fixture.TryAcquire(token, out var permission)); + Assert.Equal(token, permission.Quiescence); + Assert.True(permission.Withdrawals.IsDefaultOrEmpty); + Assert.True(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(permission)); + + Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token, + successorGeneration: 2UL, + successorReady: false)); + RuntimePhysicsOwnershipSnapshot ownership = + fixture.Lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DebtFreeRuntimeRetirementCompletesOnFirstAdmissibleCall( + bool demote) + { + const uint canonical = PrefixP | 0xFFFFu; + const uint indoorCell = PrefixP | 0x0100u; + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + var indoorSurface = new CellSurface( + indoorCell, + new Dictionary + { + [0] = new(0f, 0f, 9f), + [1] = new(8f, 0f, 9f), + [2] = new(0f, 8f, 9f), + }, + [new List { 0, 1, 2 }]); + engine.AddLandblock( + canonical, + new TerrainSurface(new byte[81], new float[256]), + [indoorSurface], + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + engine.DataCache.CellGraph.Add(new EnvCell( + indoorCell, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + false, + null)); + using var fixture = new Fixture(engine: engine); + + Assert.True(engine.IsLandblockTerrainResident(canonical)); + Assert.Equal(1, engine.LandblockCount); + Assert.Same(indoorSurface, Assert.Single( + engine.CollisionWorld.Current.Landblocks[canonical].Cells)); + Assert.NotNull(engine.DataCache.CellGraph.GetVisible(indoorCell)); + Assert.Equal(9f, indoorSurface.SampleFloorZ(1f, 1f)); + + RuntimeCollisionMutationResult result = demote + ? fixture.Lifetime.Physics.DemoteCollisionToTerrain(PrefixP) + : fixture.Lifetime.Physics.WithdrawCollision(PrefixP); + + Assert.True(result.Completed); + Assert.Equal(PrefixP | 0xFFFFu, result.LandblockId); + Assert.Equal(2UL, result.Generation); + Assert.True(result.WasResident); + Assert.Equal(demote, result.Ready); + Assert.Equal(demote, engine.IsLandblockTerrainResident(canonical)); + Assert.Equal(demote ? 1 : 0, engine.LandblockCount); + if (demote) + { + Assert.Empty( + engine.CollisionWorld.Current.Landblocks[canonical].Cells); + } + else + { + Assert.False(engine.CollisionWorld.Current.Landblocks + .ContainsKey(canonical)); + } + Assert.Null(engine.DataCache.CellGraph.GetVisible(indoorCell)); + RuntimePhysicsOwnershipSnapshot ownership = + fixture.Lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + } + + [Fact] + public void SynchronousWithdrawAckCanAcquireWithoutSyntheticRetry() + { + using var fixture = new Fixture(bindGeneration: true); + _ = fixture.Add( + 0x70003101u, + 1, + CellP, + new Vector3(10f, 40f, 7f)); + var sink = new RecordingSink(static _ => true); + using RuntimePlacementProjectionSubscription subscription = + fixture.Subscribe(sink); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + Assert.True(fixture.TryAcquire(token, out var permission)); + + RuntimePlacementProjectionToken withdrawal = Assert.Single( + permission.Withdrawals); + Assert.Equal(withdrawal, Assert.Single(sink.Applied).Token); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.True(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(permission)); + } + + [Fact] + public void ParkCallbackSupersessionCannotIssueDetachedPermission() + { + using var fixture = new Fixture(); + _ = fixture.Add( + 0x70003102u, + 1, + CellP, + new Vector3(10f, 41f, 7f)); + RuntimeCollisionPrefixQuiescenceToken first = fixture.Begin(2UL); + RuntimeCollisionPrefixQuiescenceToken successor = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind + is not RuntimePlacementProjectionKind.Withdraw) + { + return; + } + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(delta.Placement.Token)); + successor = fixture.Begin(3UL); + }); + using IDisposable subscription = + fixture.Lifetime.Events.SubscribePlacement(observer); + + Assert.False(fixture.TryAcquire(first, out var stale)); + Assert.False(stale.IsValid); + Assert.True(successor.IsValid); + Assert.False(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(stale)); + Assert.True(fixture.TryAcquire(successor, out var permission)); + Assert.Equal(successor, permission.Quiescence); + Assert.Single(permission.Withdrawals); + } + + [Fact] + public void AdditionalParkCallbackSupersessionCannotIssueDetachedPermission() + { + using var fixture = new Fixture(); + RuntimeCollisionPrefixQuiescenceToken first = fixture.Begin(2UL); + Assert.True(fixture.TryAcquire(first, out var original)); + Assert.True(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(original)); + + _ = fixture.Add( + 0x70003104u, + 1, + CellP, + new Vector3(10f, 43f, 7f)); + Assert.False(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(original)); + + RuntimeCollisionPrefixQuiescenceToken successor = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind + is not RuntimePlacementProjectionKind.Withdraw) + { + return; + } + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(delta.Placement.Token)); + successor = fixture.Begin(3UL); + }); + using IDisposable subscription = + fixture.Lifetime.Events.SubscribePlacement(observer); + + Assert.False(fixture.TryAcquire(first, out var stale)); + Assert.False(stale.IsValid); + Assert.True(successor.IsValid); + Assert.False(fixture.Lifetime.Physics + .IsCollisionPrefixMutationPermissionCurrent(stale)); + Assert.True(fixture.TryAcquire(successor, out var permission)); + Assert.Equal(successor, permission.Quiescence); + Assert.Single(permission.Withdrawals); + } + + [Fact] + public void AlreadyParkedUnpreparedCancellationSupersessionCannotIssueDetachedPermission() + { + using var fixture = new Fixture(); + RuntimeEntityRecord record = fixture.Add( + 0x70003103u, + 1, + CellP, + new Vector3(10f, 42f, 7f)); + RuntimeCollisionPrefixQuiescenceToken first = fixture.Begin(2UL); + + Assert.False(fixture.TryAcquire(first, out _)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + RuntimeEntityPlacementToken retained = fixture.Lifetime.Physics + .SetPosition.BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(retained.IsValid); + + RuntimeCollisionPrefixQuiescenceToken successor = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind + is not RuntimePlacementProjectionKind.Discard) + { + return; + } + + Assert.Equal(withdrawal.Token.Sequence, + delta.Placement.Token.Sequence); + Assert.True(delta.Placement.Token.Revision + > withdrawal.Token.Revision); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(delta.Placement.Token)); + successor = fixture.Begin(3UL); + }); + using IDisposable subscription = + fixture.Lifetime.Events.SubscribePlacement(observer); + + Assert.False(fixture.TryAcquire(first, out var stale)); + Assert.False(stale.IsValid); + Assert.False(fixture.Lifetime.Physics.SetPosition + .IsPlacementCurrent(retained)); + Assert.True(successor.IsValid); + Assert.True(fixture.TryAcquire(successor, out var permission)); + Assert.Equal(successor, permission.Quiescence); + Assert.Single(permission.Withdrawals); + Assert.Equal(withdrawal.Token.Sequence, + permission.Withdrawals[0].Sequence); + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs index c6625730..f3ff5a25 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs @@ -137,7 +137,8 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(record)); Assert.True(fixture.Lifetime.Physics.SetPosition .AcknowledgeProjection(held.Projection)); - Assert.False(fixture.TryAcquire(token, out _)); + // #475: the acknowledged cross-prefix row leaves no real retirement + // debt, so permission is available on this first admissible poll. Assert.True(fixture.TryAcquire(token, out _)); Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( @@ -184,7 +185,7 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests Assert.Equal(2UL, held.Projection.CollisionGeneration); Assert.True(fixture.Lifetime.Physics.SetPosition .AcknowledgeProjection(held.Projection)); - Assert.False(fixture.TryAcquire(token, out _)); + // #475: the acknowledged inbound row leaves no real retirement debt. Assert.True(fixture.TryAcquire(token, out _)); Assert.False(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( @@ -430,7 +431,7 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(record)); Assert.True(fixture.Lifetime.Physics.SetPosition .AcknowledgeProjection(held.Projection)); - Assert.False(fixture.TryAcquire(token, out _)); + // #475: query resolution did not add a host receipt or other debt. Assert.True(fixture.TryAcquire(token, out _)); } @@ -483,7 +484,7 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests { using var fixture = new Fixture(); RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); - Assert.False(fixture.TryAcquire(token, out _)); + // #475: an empty prefix acquires on its first admissible poll. Assert.True(fixture.TryAcquire(token, out var permission)); Assert.True(fixture.Lifetime.Physics .IsCollisionPrefixMutationPermissionCurrent(permission)); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 14957dc3..4053b0dd 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -549,7 +549,7 @@ public sealed class RuntimePhysicsStateTests } [Fact] - public void DenseReplacementSealsOneWorkUnitPerStepAndActivatesWithoutAllocation() + public void DenseReplacementSealsOneWorkUnitPerStepAndActivatesWithPayloadBoundedAllocation() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; @@ -634,11 +634,9 @@ public sealed class RuntimePhysicsStateTests Assert.True(sealSteps > ownerCount); Assert.True(workUnits > ownerCount); - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - _ = SealPrepared(physics, admission, prepared); - + Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.Equal(ownerCount, + physics.Engine.ShadowObjects.TotalRegistered); _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -940,7 +938,7 @@ public sealed class RuntimePhysicsStateTests } [Fact] - public void FirstLoadWithNewStaticBucketActivatesWithZeroAllocation() + public void FirstLoadWithNewStaticBucketActivatesWithPayloadBoundedAllocation() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; @@ -971,10 +969,9 @@ public sealed class RuntimePhysicsStateTests ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; int notifications = 0; physics.CollisionGenerationCommitted += _ => notifications++; - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - _ = SealPrepared(physics, admission, prepared); + Assert.False(physics.Engine.IsLandblockTerrainResident(target)); + Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(0, notifications); _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -1619,7 +1616,7 @@ public sealed class RuntimePhysicsStateTests } [Fact] - public void ChangedEnvCellsBuildingsAndStaticBucketActivateWithZeroAllocation() + public void ChangedEnvCellsBuildingsAndStaticBucketActivateWithPayloadBoundedAllocation() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; @@ -1691,10 +1688,13 @@ public sealed class RuntimePhysicsStateTests PhysicsDataCache cacheFacade = physics.DataCache; CellGraph graphFacade = physics.DataCache.CellGraph; ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - _ = SealPrepared(physics, admission, prepared); + Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.NotNull(physics.DataCache.GetCellStruct(oldCell)); + Assert.Null(physics.DataCache.GetCellStruct(newCell)); + Assert.NotNull(physics.DataCache.GetBuilding(oldBuilding)); + Assert.Null(physics.DataCache.GetBuilding(newBuilding)); + Assert.Equal(600u, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -1754,19 +1754,16 @@ public sealed class RuntimePhysicsStateTests _ = SealPrepared(physics, firstAdmission, first); _ = SealPrepared(physics, secondAdmission, second); - Assert.False(physics.CommitCollisionGeneration( - firstAdmission, - first).Committed); - _ = SealPrepared(physics, firstAdmission, first); - + Assert.False(physics.Engine.IsLandblockTerrainResident(firstLandblock)); + Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); + Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); _ = GC.GetAllocatedBytesForCurrentThread(); long firstBefore = GC.GetAllocatedBytesForCurrentThread(); - Assert.True(CompleteSealedCommit( - physics, - firstAdmission, - first).Committed); + RuntimeCollisionGenerationCommit firstCommit = + physics.CommitCollisionGeneration(firstAdmission, first); long firstAllocated = GC.GetAllocatedBytesForCurrentThread() - firstBefore; + Assert.True(firstCommit.Committed); // O2 (2026-08-02): delta-apply commit allocates O(target payload), // never O(resident world). Assert.InRange(firstAllocated, 0L, 1L * 1024L * 1024L); @@ -1775,18 +1772,17 @@ public sealed class RuntimePhysicsStateTests Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); _ = SealPrepared(physics, secondAdmission, second); - Assert.False(physics.CommitCollisionGeneration( - secondAdmission, - second).Committed); - _ = SealPrepared(physics, secondAdmission, second); + Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); + Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); + Assert.Equal(700u, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); _ = GC.GetAllocatedBytesForCurrentThread(); long secondBefore = GC.GetAllocatedBytesForCurrentThread(); - Assert.True(CompleteSealedCommit( - physics, - secondAdmission, - second).Committed); + RuntimeCollisionGenerationCommit secondCommit = + physics.CommitCollisionGeneration(secondAdmission, second); long secondAllocated = GC.GetAllocatedBytesForCurrentThread() - secondBefore; + Assert.True(secondCommit.Committed); // O2 (2026-08-02): delta-apply commit allocates O(target payload), // never O(resident world). Assert.InRange(secondAllocated, 0L, 1L * 1024L * 1024L);