diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 22d773ff..07736112 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -452,6 +452,13 @@ script creation. Its canonical materialized view remains stable across pending landblocks, while a separate visible-only view feeds radar, picking, status, and targeting. Raw server PhysicsState and the final state produced by retail's ordered Lighting/NoDraw/Hidden side effects are stored separately. +When retail's 25-second leave-visibility deadline expires, active ownership +still ends completely. The ACE adapter retains only the accepted +`EntitySpawn` in `DormantLiveEntityStore`, because ACE can keep that GUID in +`KnownObjects` and omit a later CreateObject on revisit. A matching landblock +load re-enters the ordinary timestamp- and generation-gated hydration path; +F747, a newer incarnation, or session reset removes the cold snapshot. Dormant +records own no frame-time, physics, render, effect, audio, or GPU resource. `LiveEntityPresentationController` drains those accepted transitions only after the renderer/effect owner is ready: Hidden suppresses the retained root's mesh, collision, interaction, radar, and target eligibility while preserving the @@ -735,9 +742,14 @@ liveness, and spatial reconciliation stop; world-space audio is silenced; UI, network/event/command delivery, portal presentation, destination streaming, and readiness continue. Canonical live records remain retained until physical teardown converges, so quiescence neither reconstructs server identity nor -invents Hidden/UnHide transitions. Full-window and shared-origin retirement -advance only from `StreamingController.Tick` through stable resident and -per-owner entity/stage cursors on the frame's one meter. +invents Hidden/UnHide transitions. A shared-origin recenter atomically removes +the complete old spatial generation and captures one exact receipt per +landblock before the origin changes. Expensive script, physics, render, and GL +release then advances only from `StreamingController.Tick` through stable +per-owner entity/stage cursors on the frame's one meter. Publication remains +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. Accepted publication is likewise retained at its exact priority-queue head. `LandblockPresentationPipeline` meters render, physics, static, building, and @@ -767,9 +779,14 @@ The Slice E connected closeout is recorded in `WorldRevealCoordinator` is the single App-layer owner of each login or portal reveal lifetime. It composes the canonical `WorldRevealReadinessBarrier` with generation-scoped lifecycle observation and the streaming destination -reservation, so begin, preparation, readiness, materialization, world -visibility, completion, cancellation, and reserved capacity cannot be wired -independently. The barrier joins `StreamingController`/`GpuWorldState` +reservation plus a generation-scoped render-resource profile, so begin, +preparation, readiness, materialization, viewport release, protocol completion, +cancellation, and reserved capacity cannot be wired independently. While the +normal world viewport is withheld, mesh and composite upload owners may admit +more small destination items per frame, but retain the ordinary 8 MiB byte, +array, buffer, and mipmap ceilings. The exact profile ends at the retail +portal-to-world viewport swap; it does not reduce the readiness radius or +reveal incomplete content. The barrier joins `StreamingController`/`GpuWorldState` static-mesh publication, `WbDrawDispatcher` composite-texture warmup, and `PhysicsEngine` destination residency. Login's sky-only gate and portal-space transit consume the same predicate; they differ only in presentation. There is @@ -822,6 +839,12 @@ and before retained UI. The shared dispatcher is reset to no world-cell clips and no world point lights for this pass. Chat, gameplay windows, toolbar, cursor, and input continue normally above it. The synthetic object never enters `LiveEntityRuntime`, collision, picking, radar, or server GUID state. +Portal, paperdoll, and creature-appraisal viewports share +`SyntheticEntityMeshReferenceOwner` but own independent leases. World +landblock retirement therefore cannot evict a mesh still used by retained UI. +The paperdoll keeps its last successful private object while the SmartBox +player is temporarily unavailable and clears it only at the character-session +boundary, matching `gmPaperDollUI::RedressCreature`. `TeleportViewPlaneController` is the App-layer projection adapter for retail `SmartBox::SetOverrideFovDistance` / `Render::set_vdst`. It captures the active diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 30977ddf..e707cec6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -172,7 +172,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` | | ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` | | AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` | -| AP-69 | acdream now preserves one accepted live record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry routes through the exact generation-safe F747 teardown. DIVERGENCE: the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `LiveEntityHydrationController.OnPrune` | Prevents stale portal destinations from accumulating animation/effect/render owners while retaining loaded long-view objects and canonical identity | A nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` | +| AP-69 | acdream preserves one accepted active record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry uses the exact generation-safe active teardown, then retains only a cold `EntitySpawn` because ACE can keep the GUID in `KnownObjects` and omit CreateObject on revisit; explicit F747/new generation/session reset removes it. DIVERGENCE: retail can delete the complete object under its visibility protocol; the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/World/DormantLiveEntityStore.cs`; `LiveEntityHydrationController.OnPrune` | Prevents stale portal destinations from accumulating animation/effect/render owners while still allowing doors, signs, portals, and other ACE-known objects to rematerialize when the server does not resend them | Dormant data-only snapshots can grow with every unique ACE destination until F747 or session reset; a nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; ACE `KnownObjects`; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` | | AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 | | AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 | | AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 3a8a4a3a..2b496e86 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1632,8 +1632,11 @@ port in any phase — no separate listing here. > shadow referee, production cutover, and acceptance matrix. The user > explicitly approved Slices F–L, including the F/G and J gates, on > 2026-07-24. Slice F and G0–G4 are implemented; 46,599 automated G4 -> comparisons have zero mismatches. Its physical-display visual gate and G5 -> old-path deletion remain pending, with exact rollback +> comparisons have zero mismatches. The 2026-07-25 connected visual gate +> accepted the retained scene after independent portal scheduling/private-view/ +> ACE-revisit ownership regressions were corrected; G5 old-path deletion and +> production-only profiling are now active. The historical exact cutover +> rollback remains > `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete: > retained UI/frame work, exact bounded light selection, and > pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 7f9ff90f..c65f6251 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -108,13 +108,15 @@ publication, retirement, upload, warmup, or resource backlog. Capped/uncapped CPU p99 is 34.7%/30.4% below Slice A and maximum frame allocation is 84.7%/82.2% lower. The user explicitly approved Slices F–L, including both the F/G ECS and Slice J gameplay-owner gates, on 2026-07-24. Slice F and -G0–G4 are implemented; G4's automated gate has zero mismatches while its -physical-display visual gate and G5 deletion remain pending. Slice H is +G0–G4 are implemented; G4's automated gate has zero mismatches and its +2026-07-25 connected visual gate passes after the portal scheduling/private +viewport/ACE revisit regression correction. G5 deletion and production-only +profiling are active. Slice H is complete: retained UI/frame work, exact bounded light selection, and pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the seven-checkpoint connected lifecycle/reconnect gate. Slice I is active under [`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md). -The exact G4 visual rollback remains +The historical exact G4 visual rollback remains `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's diff --git a/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md b/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md index a178e869..83f4ff14 100644 --- a/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md +++ b/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md @@ -423,6 +423,40 @@ frame allocation fell 84.7%/82.2%. Relative to Slice C, CPU p99 improved 21.4%/16.1% and largest frame allocation fell 33.5%/9.7%. Full evidence: [`../research/2026-07-24-slice-e-cost-budgeted-streaming-report.md`](../research/2026-07-24-slice-e-cost-budgeted-streaming-report.md). +### Post-closeout portal regression correction — 2026-07-25 + +The first retained-scene visual pass exposed four scheduling/ownership defects +that the original E6 route did not isolate: + +- a 25x25 shared-origin window admitted 625 detach operations over many + frames even though no intermediate old spatial subset was observable; +- destination publication could sit behind an unrelated retirement receipt, + small-mesh upload count, or composite upload count while most byte budget + remained unused; +- private paperdoll/appraisal meshes borrowed world ownership and the + paperdoll discarded its private object during temporary player + unavailability; +- ACE could retain an expired GUID in `KnownObjects`, omit CreateObject on + revisit, and leave doors, signs, portals, and NPCs permanently absent. + +The correction atomically swaps the old spatial generation into exact deferred +receipts, advances only the destination's same-key receipt out of order, and +orders destination worker/publication work first. A reveal-generation render +profile raises only the small-object mesh count from 8 to 64 and composite +count from 16 to 64; all 8 MiB byte/array/buffer/mipmap ceilings remain +unchanged. Ordinary work still advances when no destination completion exists. +Private viewports now own independent mesh leases and the paperdoll survives +temporary SmartBox absence. `DormantLiveEntityStore` retains only cold accepted +spawn data after active teardown so an ACE revisit can hydrate through the +ordinary generation-safe transaction. + +The world-availability edge now ends at retail's portal/world viewport swap; +the one-second `WorldFadeIn` protocol tail still sends LoginComplete and closes +teleport state later. The user confirmed faster portal transit, continuous +paperdoll presentation, and persistent server-spawned objects after repeated +portal travel. Full evidence: +[`../research/2026-07-25-portal-regression-closeout.md`](../research/2026-07-25-portal-regression-closeout.md). + ## 10. Acceptance Slice E closes only when: diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index c539ff20..48f6aa2f 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -2,7 +2,7 @@ **Date:** 2026-07-24 -**Status:** approved 2026-07-24; Slice F complete, Slice G active +**Status:** approved 2026-07-24; Slice F and G4 complete, G5 active **Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md` @@ -27,8 +27,8 @@ retail-faithful gameplay | G1 — scene-query replacement | complete | Exact `e346f8bb` passed capped and uncapped nine-stop routes plus uncapped dense Arwic: 29,392 + 29,025 + 1,522 same-frame candidate comparisons, zero mismatches, equal counts/digests at every checkpoint, exact binary/source identity, and graceful shutdown. Evidence: `docs/research/2026-07-25-slice-g1-scene-query-candidate-gate.md`. | | G2 — packed dispatcher input | complete | Exact `f9829d5f` passed capped and uncapped nine-stop routes plus dense Arwic. Candidate order, packed input, classified output, and selection each completed 16,879 + 17,282 + 770 comparisons with zero mismatch; journal/rejection counts were zero and shutdown graceful. Evidence: `docs/research/2026-07-25-slice-g2-packed-dispatcher-gate.md`. | | G3 — retained classification/storage | complete | Exact `6a026c5a` passed capped and uncapped nine-stop routes plus dense Arwic with 17,069 + 16,827 + 757 exact comparisons in every channel, zero mismatch, warm cross-frame reuse, and graceful shutdown. Evidence: `docs/research/2026-07-25-slice-g3-retained-classification-gate.md`. | -| G4 — production cutover | automated gate complete; visual gate pending | Exact cutover `ef1d263337997bb030eadb7b8e71d73dc659907a` makes the retained frame product the production entity source at the existing five retail PView stages. Exact `03b10183` passed capped/uncapped nine-stop and uncapped dense-Arwic automation with 46,599 total product comparisons, zero mismatch, every reveal ready, and graceful shutdown. The old route referee remains until the user visual gate passes. Evidence: `docs/research/2026-07-25-slice-g4-production-cutover-automated-gate.md`. | -| G5 | pending | Physical performance/lifecycle closeout follows the user visual gate. | +| G4 — production cutover | complete | Exact cutover `ef1d263337997bb030eadb7b8e71d73dc659907a` makes the retained frame product the production entity source at the existing five retail PView stages. Exact `03b10183` passed capped/uncapped nine-stop and uncapped dense-Arwic automation with 46,599 total product comparisons and zero mismatch. The 2026-07-25 connected visual pass accepted the retained scene after its independent portal scheduling/ownership regressions were corrected. Evidence: `docs/research/2026-07-25-slice-g4-production-cutover-automated-gate.md` and `docs/research/2026-07-25-portal-regression-closeout.md`. | +| G5 | active | The accepted visual gate unblocks production-only performance/lifecycle closeout and old-route deletion. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is non-drawing diagnostic infrastructure and therefore is not a visual-cutover @@ -646,9 +646,12 @@ the scene journal was empty, source and binary hashes matched, and every process closed gracefully. The gate corrected whole-retained-world composite warmup discovery by sourcing the exact published 3×3 destination neighborhood; that correction is draw-path independent. Fixed-camera artifacts are captured. -The user visual gate is the only remaining G4 acceptance item, so old-route -deletion and G5 production-only profiling remain blocked. Evidence: -`docs/research/2026-07-25-slice-g4-production-cutover-automated-gate.md`. +The user completed the connected visual gate on 2026-07-25. The retained scene +itself remained exact; the pass exposed independent portal scheduling, private +viewport ownership, and ACE revisit-liveness defects. Their root-cause +correction is recorded in +`docs/research/2026-07-25-portal-regression-closeout.md`. G4 is accepted and +old-route deletion plus G5 production-only profiling are unblocked. ### G5 — Physical performance and lifecycle gate diff --git a/docs/plans/2026-07-25-modern-runtime-slice-h.md b/docs/plans/2026-07-25-modern-runtime-slice-h.md index 1c211aa9..af4c2af6 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-h.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-h.md @@ -2,8 +2,8 @@ **Status:** COMPLETE (2026-07-25) **Parent plan:** `2026-07-24-modern-runtime-architecture.md`, Slice H -**Prerequisite state:** Slice G4 production cutover is automated-gated; its -user visual gate remains pending. +**Prerequisite state at execution:** Slice G4 production cutover was +automated-gated; its user visual gate later passed on 2026-07-25. ## 1. Safety boundary @@ -11,8 +11,8 @@ Slice H removes work which scales with presentation rate. It does not change retail gameplay rules, visible-cell authority, light membership, packet ordering, acknowledgement timing, heartbeat placement, or UI presentation. -The G4 old-route referee remains available until the user accepts the visual -gate. Slice H must not delete or alter that rollback boundary. +Slice H did not delete or alter the G4 old-route referee while its visual gate +was pending. That gate has since passed; G5 owns the final deletion boundary. If the G4 visual result is wrong, revert only: diff --git a/docs/plans/2026-07-25-modern-runtime-slice-i.md b/docs/plans/2026-07-25-modern-runtime-slice-i.md index a72df5ad..54290fdd 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-i.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-i.md @@ -1,7 +1,7 @@ # Modern runtime Slice I — flat collision assets and zero-allocation physics **Status:** I0 COMPLETE — retail oracle, representative graph fixtures, and -four-mover allocation baselines fixed; I1 reusable scratch is next +four-mover allocation baselines fixed; accepted G5 closeout now precedes I1 **Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I **Purpose:** remove parsed DAT object graphs and steady collision allocations without changing one retail collision decision, float, comparison, or traversal diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md index b3e557e2..e1b5c945 100644 --- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md +++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md @@ -55,6 +55,15 @@ The portal viewport is a replacement for the world viewport. It is not a full-screen UI overlay: normal retained UI continues to update and draw above it. +Private retained-UI viewports also keep their own objects. In particular, +`gmPaperDollUI::RedressCreature @ 0x004A3BC0` creates +`m_pInventoryObject` only when it is absent and the SmartBox player is +available; a temporarily unavailable world player does not clear an already +constructed inventory object. `gmPaperDollUI::~gmPaperDollUI @ 0x004A3590` +removes that private object at the UI/session lifetime boundary. Therefore a +portal-space world quiescence may defer a paperdoll redress, but it must not +empty and later reconstruct the visible private paperdoll. + ## Begin/end and scene visibility ```text @@ -96,6 +105,36 @@ distance, hides the portal viewport, shows the world viewport, clears the teleport object's sequence animations, plays `Sound_UI_ExitPortal`, and enters `WorldFadeIn`. That following state restores the ordinary game view distance. +The viewport swap is also the end of the destination-cell blocking edge, not +the end of the complete teleport protocol: + +```text +TunnelFadeOut completes: + destination cells are already available + release SmartBox world blocking + hide portal viewport + show world viewport + enter WorldFadeIn + +WorldFadeIn completes one second later: + send LoginComplete + clear teleport-in-progress + enter Off +``` + +The two edges must remain distinct in an asynchronous client. Holding world +blocking until `LoginComplete` creates a one-second clear-color/void interval +after the tunnel viewport has already been hidden. Conversely, marking the +whole teleport complete at the viewport swap loses retail's final +`WorldFadeIn` protocol state. + +The asynchronous renderer may temporarily spend otherwise-idle resource +capacity on the withheld destination, but that policy belongs to the exact +reveal generation. Acdream raises only the count of small mesh/composite +uploads while keeping the ordinary byte, array, buffer, and mipmap ceilings. +The profile ends at the viewport swap above. It never changes the required +destination radius, the readiness predicate, or the resulting pixels. + ## Camera motion The camera rolls around its own AC `+Y` forward axis. It does not yaw around diff --git a/docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md b/docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md index 0d3d4e26..648ec93d 100644 --- a/docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md +++ b/docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md @@ -59,12 +59,35 @@ preview-owned objects. Sources: - [ACE Player teleport tracking](https://github.com/ACEmulator/ACE/blob/650c5b75ae909957feaf58db320e46be16502653/Source/ACE.Server/WorldObjects/Player_Tracking.cs) - [Holtburger liveness](https://github.com/merklejerk/holtburger/blob/main/crates/holtburger-world/src/state/liveness.rs) -acdream uses the same compatibility boundary: currently spatially resident +acdream uses the same visibility boundary: currently spatially resident records are visible; otherwise a 384-unit 3-D global-landblock distance is the conservative fallback. The distance is an adaptation, not a claimed retail constant, and remains documented in divergence AP-69 until exact ObjCell/PVS membership drives the deadline. +There is an additional ACE compatibility boundary. Retail can destroy the +complete client object because its server/client visibility protocol later +recreates that object. ACE may retain the GUID in `KnownObjects` across a +normal teleport and consequently omit the later `CreateObject`. Deleting +acdream's only accepted `EntitySpawn` at the 25-second deadline therefore +made doors, signs, portals, NPCs, and other server objects permanently absent +when a destination was revisited. + +The modern client separates those two costs: + +- the 25-second deadline destroys every active projection, collision, + animation, physics, script, light, particle, and render owner; +- a cold `EntitySpawn` snapshot remains in a data-only dormant table; +- reloading its authoritative landblock routes that snapshot through the + ordinary generation-safe CreateObject hydration transaction; +- an explicit F747, a newer INSTANCE timestamp, or session reset removes the + dormant snapshot. + +This is deliberately not active-object retention: dormant entries execute no +per-frame work and own no GPU resources. It restores the former retained +spawn-table contract required by ACE while preserving the resource reclamation +that fixed the portal-run FPS collapse. + ## Port pseudocode ```text @@ -93,14 +116,30 @@ once per second: for each due candidate: re-read canonical record if GUID and generation still match: - route through the normal accepted DeleteObject lifecycle + capture its latest accepted EntitySpawn + run the normal generation-safe active teardown + retain only the cold EntitySpawn + +when a landblock becomes resident: + for each dormant spawn in that canonical landblock: + route it through ordinary CreateObject hydration + remove the dormant entry only after the generation is accepted + +on explicit DeleteObject: + delete the exact active or dormant generation + remove its ClientObject qualities + +on session reset: + clear active runtime and dormant snapshots ``` Routing expiry through `LiveEntityRuntime.UnregisterLiveEntity` is important: -it performs the same generation gate, object-table removal, equipped-child -cleanup, spatial withdrawal, animation/physics/effect teardown, and render -resource release as an authoritative F747. A second teardown path would drift -and recreate the original leak. +it performs the same generation gate, equipped-child cleanup, spatial +withdrawal, animation/physics/effect teardown, and render resource release as +an authoritative F747. Unlike F747, local visibility expiry preserves the +small object-quality record and cold spawn because ACE may never resend them. +An actual F747 removes both. A second active-resource teardown path would +drift and recreate the original leak. ## Reclaimable shared-buffer contract diff --git a/docs/research/2026-07-24-retail-streaming-retirement-pseudocode.md b/docs/research/2026-07-24-retail-streaming-retirement-pseudocode.md index 00d17df4..578da928 100644 --- a/docs/research/2026-07-24-retail-streaming-retirement-pseudocode.md +++ b/docs/research/2026-07-24-retail-streaming-retirement-pseudocode.md @@ -172,8 +172,11 @@ begin destination generation: silence old-generation audio reject old generation from collision, picking, radar, and targeting +at the shared-origin transaction boundary: + atomically swap the complete old spatial generation out of reach + retain one exact teardown receipt per old landblock + over later update/resource-maintenance frames: - detach old landblocks through exact retry receipts release scripts/effects/lights/plugin projections release CPU owners admit renderer retirement; physical GL release remains fence delayed @@ -200,6 +203,48 @@ Release-later never means run-later: an old owner may retain memory until its cursor runs, but it cannot keep simulating, colliding, rendering, targeting, or emitting audible work after the generation-quiesce edge. +The shared-origin commit barrier is the completion of **spatial detachment**, +not completion of every deferred resource release: + +```text +capture exact old landblock/entity teardown receipts +clear the old loaded/pending/traversal/collision-visible spatial indexes +repark nonpersistent live projections by their canonical landblock +rescue the persistent local-player projection +publish the resulting live visibility edges once + +once that single spatial-generation swap commits: + recenter the shared coordinate origin + configure and enqueue the destination window + +on later frames: + destination publication uses its reserved work lane + old retirement receipts use remaining cleanup capacity + a publication for landblock X waits only while X has an older receipt + the exact receipt for destination X may advance ahead of unrelated FIFO + ordinary publication continues when no destination result is available +``` + +The per-landblock publication fence makes key-based terrain, physics, +EnvCell, building, mesh-reference, and static-presentation cleanup safe: a +late old receipt cannot remove a replacement owner with the same landblock +key. Live objects are different. Their logical owner survives spatial +withdrawal and may be reprojected immediately at the destination, so deferred +landblock retirement must never clear a live object's lighting or +translucency by reusable local ID. Live cell-scoped lighting is withdrawn by +the incarnation-aware projection-visibility edge; logical translucency stays +with the live record until its actual teardown. Landblock receipts clean only +DAT-static entity presentation. + +This swap is deliberately one **atomic oversized progress operation** in the +modern frame meter. Splitting it into 625 budgeted landblock detach admissions +created a five-second portal stall even though the old generation was already +quiesced and no intermediate subset was observable. The atomic work scans the +small live-projection index, clears the old spatial maps, and transfers their +existing entity lists into receipts; it does not synchronously destroy every +static resource. Expensive script, renderer, physics, light, plugin, and GL +cleanup still advances through the ordinary retryable receipt cursors. + ## Port constraints 1. Preserve existing exact retry receipts; successful stages never replay. diff --git a/docs/research/2026-07-25-portal-regression-closeout.md b/docs/research/2026-07-25-portal-regression-closeout.md new file mode 100644 index 00000000..e8384efc --- /dev/null +++ b/docs/research/2026-07-25-portal-regression-closeout.md @@ -0,0 +1,105 @@ +# Portal regression closeout — 2026-07-25 + +## Scope + +The first connected retained-scene visual pass reported: + +1. portal transit remained in the tunnel too long; +2. a clear/void interval appeared between tunnel exit and destination world; +3. the inventory paperdoll disappeared and rebuilt during transit; +4. doors, signs, portals, NPCs, and other server-spawned objects disappeared + after repeated portal travel. + +This note records the root causes and the accepted correction. It does not +reduce world radius, texture quality, particles, readiness, or retail +presentation. + +## Retail and protocol anchors + +- `gmSmartBoxUI::UseTime @ 0x004D6E30`: the + `TunnelFadeOut -> WorldFadeIn` edge hides the portal viewport, shows the + world viewport, releases cell blocking, and starts the final one-second + WorldFadeIn state. +- The later WorldFadeIn completion sends LoginComplete and ends logical + teleport state. Viewport release and protocol completion are distinct. +- `gmPaperDollUI::RedressCreature @ 0x004A3BC0` creates the private inventory + object only when absent and a SmartBox player is available; temporary player + absence does not delete an existing private object. +- `gmPaperDollUI::~gmPaperDollUI @ 0x004A3590` owns final private-object + destruction. +- `CPhysicsObj::prepare_to_leave_visibility @ 0x00511F40`, + `CObjectMaint::AddObjectToBeDestroyed @ 0x00508F70`, and + `CObjectMaint::UseTime @ 0x005089B0` define retail's delayed active-object + destruction. +- `CPhysicsObj::set_hidden @ 0x00514C60` plays typed UnHide at intensity 1 + when materializing. The destination purple/pink effect is DAT-driven retail + behavior, not a generic exit effect to suppress. + +## Root causes and corrections + +### Shared-origin retirement head-of-line blocking + +The 25x25 old spatial window was detached as hundreds of independently metered +operations. No intermediate subset was observable because the complete old +generation was already quiesced, but the shared origin could not change until +all admissions finished. + +`GpuWorldState.DetachAllForOriginRecenter` now performs one atomic spatial +generation swap and returns exact per-landblock teardown receipts. Deferred +resource release remains budgeted. A destination publication waits only on an +older receipt for that same landblock; that exact receipt may advance ahead of +unrelated cleanup while the remaining retirement FIFO order stays unchanged. + +### Destination scheduling and upload utilization + +Near worker jobs, retained publication receipts, mesh uploads, and composite +uploads could each serialize destination work behind unrelated work. The +destination neighborhood is now ordered first at worker and publication +boundaries. When a destination completion actually exists, ordinary +publication is temporarily preempted; when it does not exist or failed, +ordinary Near/Far work continues under the same budget. + +`WorldRevealRenderResourceScheduler` owns an exact reveal-generation profile: + +- mesh item count: 8 -> 64; +- composite item count: 16 -> 64; +- upload bytes: unchanged at 8 MiB; +- array allocation, mipmap, buffer upload/allocation/copy, and new-resource + ceilings: unchanged. + +The profile ends at the portal/world viewport swap. The complete canonical +render/composite/collision readiness predicate remains mandatory. + +### Private retained-UI ownership + +The paperdoll and creature appraisal used shared mesh data without independent +references, so old-world retirement could evict their meshes. Both now use +`SyntheticEntityMeshReferenceOwner`, with transactional acquisition and +retryable release. `PaperdollFramePresenter` retains the last successful +private object across temporary SmartBox unavailability and equivalent +appearance refreshes; character-session reset is the explicit clear edge. + +### ACE KnownObjects revisit + +The active 25-second liveness teardown was correct for resource reclamation, +but ACE may keep the GUID in `KnownObjects` and omit CreateObject when the +client revisits that landblock. Deleting acdream's final accepted spawn +snapshot therefore made the object permanently absent. + +`DormantLiveEntityStore` retains only the accepted data snapshot after every +active owner is destroyed. Landblock residency re-enters ordinary hydration +with the retained timestamp gate. Exact F747, a newer instance generation, and +session reset remove the dormant snapshot. Dormant objects own no physics, +animation, script, effect, light, audio, render, collision, or per-frame work. +This ACE adaptation remains explicit in divergence AP-69. + +## Gates + +- Focused portal/retirement/reveal/private-viewport/dormant tests: green. +- Complete App Release suite: 3,813 passed / 3 skipped. +- Release solution build: green with zero warnings. +- Complete Release solution suite: 8,322 passed / 5 skipped. +- User connected visual gate: + - paperdoll remains visible; + - portal transit is materially faster; + - server-spawned objects remain after repeated travel. diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 38f8bb02..62075072 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -754,7 +754,8 @@ internal sealed class LivePresentationCompositionPhase d.Gl, dispatcherLease.Resource, foundation.SceneLighting, - foundation.TextureCache), + foundation.TextureCache, + foundation.MeshAdapter), static value => value.Dispose()); IUiViewportRenderer? previousRenderer = viewport.Renderer; viewport.Renderer = paperdollLease.Resource; @@ -795,7 +796,8 @@ internal sealed class LivePresentationCompositionPhase d.Gl, dispatcherLease.Resource, foundation.SceneLighting, - foundation.TextureCache), + foundation.TextureCache, + foundation.MeshAdapter), static value => value.Dispose()); IUiViewportRenderer? previousRenderer = creatureViewport.Renderer; creatureViewport.Renderer = creatureAppraisalLease.Resource; diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 6cabe84b..716d3bae 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -329,6 +329,9 @@ internal sealed class SessionPlayerCompositionPhase content.Audio?.Engine); var compositeWarmupSource = new CompositeWarmupEntitySource(live.WorldState); + var revealRenderResources = new WorldRevealRenderResourceScheduler( + foundation.MeshAdapter.SetDestinationRevealUploadPriority, + foundation.TextureCache.SetDestinationRevealUploadPriority); var worldReveal = new WorldRevealCoordinator( streaming.IsRenderNeighborhoodResident, d.PhysicsEngine.IsSpawnCellReady, @@ -351,7 +354,8 @@ internal sealed class SessionPlayerCompositionPhase spawnClaimClassifier.IsUnhydratable, d.Log, worldQuiescence, - streaming); + streaming, + revealRenderResources); Fault(SessionPlayerCompositionPoint.WorldRevealCreated); return CompleteSessionPlayer( @@ -450,11 +454,13 @@ internal sealed class SessionPlayerCompositionPhase live.Lights, d.ClassificationCache, d.PlayerIdentity); + var dormantLiveEntities = new DormantLiveEntityStore(); var deletion = new LiveEntityDeletionController( live.LiveEntities, d.Objects, teardown, d.PlayerIdentity, + dormantLiveEntities, d.Options.DumpLiveSpawns ? d.Log : null); var hydration = new LiveEntityHydrationController( live.LiveEntities, @@ -472,6 +478,7 @@ internal sealed class SessionPlayerCompositionPhase localPhysicsTimestamps, d.PlayerIdentity, deletion, + dormantLiveEntities, d.Options.DumpLiveSpawns ? d.Log : null); bindings.Adopt( "landblock-loaded hydration", @@ -511,7 +518,8 @@ internal sealed class SessionPlayerCompositionPhase var liveness = new LiveEntityLivenessController( live.LiveEntities, d.PlayerIdentity, - deletion); + deletion, + dormantLiveEntities); var sessionEvents = new LiveEntitySessionController( d.InboundEntityEvents, hydration, diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 96c19546..45a9a40f 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -218,7 +218,7 @@ internal sealed class LiveSessionRuntimeFactory _world.Hydration.ResetSessionState(); _player.Shortcuts.Items = Array.Empty(); _player.DesiredComponents.Clear(); - _ui.Paperdoll?.MarkDirty(); + _ui.Paperdoll?.ResetSession(); // X/Y are ignored until the next logical player CreateObject claims a // new origin. Keeping the last values avoids inventing a synthetic diff --git a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs index 86ef9286..cb9899a8 100644 --- a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs +++ b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs @@ -277,6 +277,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable internal const long TargetArrayBytes = 4L * 1024 * 1024; internal const int MaximumLayersPerArray = 64; internal const int DefaultMaximumUploadsPerFrame = 16; + internal const int DestinationRevealMaximumUploadsPerFrame = 64; internal const long DefaultMaximumUploadBytesPerFrame = 8L * 1024 * 1024; internal const int MaximumLogicalEvictionsPerFrame = 16; internal const int MaximumAtlasCreationsPerFrame = 1; @@ -298,6 +299,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable private long _frameUploadBytes; private int _frameAtlasCreationCount; private bool _uploadBudgetBlocked; + private bool _destinationRevealUploadPriority; private int _pendingAtlasWidth; private int _pendingAtlasHeight; private long _pendingAtlasAllocationBytes; @@ -390,9 +392,16 @@ internal sealed class CompositeTextureArrayCache : IDisposable internal long FrameUploadBytes => _frameUploadBytes; internal bool CanStartUpload => !_uploadBudgetBlocked - && _frameUploadCount < _maximumUploadsPerFrame + && _frameUploadCount < CurrentMaximumUploadsPerFrame && (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame); + private int CurrentMaximumUploadsPerFrame => + _destinationRevealUploadPriority + ? Math.Max( + _maximumUploadsPerFrame, + DestinationRevealMaximumUploadsPerFrame) + : _maximumUploadsPerFrame; + internal Residency.ResidencyDomainSnapshot CaptureResidency() { long retiringBytes = 0; @@ -439,7 +448,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable internal bool CanUpload(long bytes) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes); - if (_frameUploadCount >= _maximumUploadsPerFrame) + if (_frameUploadCount >= CurrentMaximumUploadsPerFrame) return false; // Always allow one item so a texture larger than the normal frame @@ -479,10 +488,12 @@ internal sealed class CompositeTextureArrayCache : IDisposable return false; } - public void BeginFrame() + public void BeginFrame(bool destinationRevealUploadPriority = false) { ThrowIfUnavailable(); _retirementLedger.RetryPendingPublications(); + _destinationRevealUploadPriority = + destinationRevealUploadPriority; _frameUploadCount = 0; _frameUploadBytes = 0; _frameAtlasCreationCount = 0; diff --git a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs index 5b80672c..1cecd9ef 100644 --- a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs +++ b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs @@ -378,13 +378,15 @@ internal sealed class CreatureAppraisalViewportRenderer : GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo, - IEntityTextureLifetime textureLifetime) + IEntityTextureLifetime textureLifetime, + IWbMeshAdapter meshAdapter) { _renderer = new PrivateEntityViewportRenderer( gl, dispatcher, lightUbo, textureLifetime, + meshAdapter, CreatureAppraisalEntityBuilder.RenderId, _camera, "creature examination"); diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index 8ee3167e..33883803 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -53,6 +53,7 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollFrameView _view; private readonly IPaperdollDollFactory _factory; + private WorldEntity? _doll; private bool _dirty = true; public PaperdollFramePresenter( @@ -78,20 +79,111 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame { if (_factory.TryBuild(out WorldEntity? doll)) { - _renderer.SetDoll(doll); + // Same-generation CreateObject refreshes can repeat the exact + // player ObjDesc at a portal boundary. Retail redresses its + // private inventory object in place; releasing and reacquiring + // an identical synthetic owner briefly blanks the viewport and + // churns its texture composites. + if (!HasEquivalentAppearance(_doll, doll)) + { + _renderer.SetDoll(doll); + _doll = doll; + } _dirty = false; } else { - // Retail's private viewport remains present while the live - // player projection is still materializing. Keep the dirty - // edge armed and clear any doll from the prior session. - _renderer.SetDoll(null); + // gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its + // private m_pInventoryObject intact when the SmartBox player + // is temporarily unavailable. Keep the successful doll and + // retry this dirty redress on the next visible frame. } } _view.SetTextureHandle(_renderer.Render(width, height)); } + + /// + /// Clears the private object only at the owning character-session + /// boundary, matching gmPaperDollUI's private-object lifetime. + /// + public void ResetSession() + { + _renderer.SetDoll(null); + _doll = null; + _dirty = true; + } + + private static bool HasEquivalentAppearance( + WorldEntity? current, + WorldEntity? candidate) + { + if (current is null || candidate is null) + return ReferenceEquals(current, candidate); + if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId + || current.Scale != candidate.Scale + || current.HiddenPartsMask != candidate.HiddenPartsMask + || current.MeshRefs.Count != candidate.MeshRefs.Count + || current.PartOverrides.Count != candidate.PartOverrides.Count) + { + return false; + } + + for (int i = 0; i < current.MeshRefs.Count; i++) + { + MeshRef left = current.MeshRefs[i]; + MeshRef right = candidate.MeshRefs[i]; + if (left.GfxObjId != right.GfxObjId + || left.PartTransform != right.PartTransform + || !DictionaryEquals( + left.SurfaceOverrides, + right.SurfaceOverrides)) + { + return false; + } + } + + for (int i = 0; i < current.PartOverrides.Count; i++) + { + if (current.PartOverrides[i] != candidate.PartOverrides[i]) + return false; + } + + PaletteOverride? leftPalette = current.PaletteOverride; + PaletteOverride? rightPalette = candidate.PaletteOverride; + if (leftPalette is null || rightPalette is null) + return leftPalette is null && rightPalette is null; + if (leftPalette.BasePaletteId != rightPalette.BasePaletteId + || leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count) + { + return false; + } + for (int i = 0; i < leftPalette.SubPalettes.Count; i++) + { + if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i]) + return false; + } + return true; + } + + private static bool DictionaryEquals( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) + { + if (left is null || right is null) + return left is null && right is null; + if (left.Count != right.Count) + return false; + foreach ((uint key, uint value) in left) + { + if (!right.TryGetValue(key, out uint rightValue) + || rightValue != value) + { + return false; + } + } + return true; + } } /// Retained-UI visibility and texture publication for the doll view. diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs index 303e008e..b01395d3 100644 --- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs @@ -21,13 +21,15 @@ public sealed class PaperdollViewportRenderer : GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo, - IEntityTextureLifetime textureLifetime) + IEntityTextureLifetime textureLifetime, + IWbMeshAdapter meshAdapter) { _renderer = new PrivateEntityViewportRenderer( gl, dispatcher, lightUbo, textureLifetime, + meshAdapter, DollEntityBuilder.DollRenderId, new DollViewportCamera(), "paperdoll"); diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index b9562f75..8b10eb24 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -55,7 +55,7 @@ public sealed class PortalTunnelPresentation : IDisposable private readonly Random _random; private readonly Action? _displayNotice; private IDisposable? _displayNoticeLifetime; - private readonly PortalMeshReferenceOwner _meshReferences; + private readonly SyntheticEntityMeshReferenceOwner _meshReferences; private bool _visible; private double _rotationElapsed; @@ -104,7 +104,7 @@ public sealed class PortalTunnelPresentation : IDisposable MeshRefs = SetupMesh.Flatten(setup), }; - _meshReferences = new PortalMeshReferenceOwner( + _meshReferences = new SyntheticEntityMeshReferenceOwner( meshAdapter, setup.Parts.Select(part => (ulong)(uint)part)); } diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index 02686955..a42ebaf1 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -32,9 +32,12 @@ internal sealed unsafe class PrivateEntityViewportRenderer : private readonly WbDrawDispatcher _dispatcher; private readonly SceneLightingUboBinding _lightUbo; private readonly FixedEntityTextureOwnerLease _textureOwnerLease; + private readonly IWbMeshAdapter _meshAdapter; private readonly IPrivateEntityViewportCamera _camera; private readonly HashSet _animatedIds; private readonly string _diagnosticName; + private readonly List + _retiringMeshReferences = []; private uint _fbo; private uint _colorTex; @@ -42,12 +45,14 @@ internal sealed unsafe class PrivateEntityViewportRenderer : private int _fbW; private int _fbH; private WorldEntity? _entity; + private SyntheticEntityMeshReferenceOwner? _meshReferences; public PrivateEntityViewportRenderer( GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo, IEntityTextureLifetime textureLifetime, + IWbMeshAdapter meshAdapter, uint renderId, IPrivateEntityViewportCamera camera, string diagnosticName) @@ -57,6 +62,8 @@ internal sealed unsafe class PrivateEntityViewportRenderer : _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); + _meshAdapter = meshAdapter + ?? throw new ArgumentNullException(nameof(meshAdapter)); _camera = camera ?? throw new ArgumentNullException(nameof(camera)); _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) ? "creature viewport" @@ -69,10 +76,63 @@ internal sealed unsafe class PrivateEntityViewportRenderer : public void SetEntity(WorldEntity? entity) { + ReleaseRetiringMeshReferences(); + if (ReferenceEquals(_entity, entity)) return; - _textureOwnerLease.Replace(entity is not null); + + SyntheticEntityMeshReferenceOwner? replacement = null; + if (entity is not null) + { + replacement = new SyntheticEntityMeshReferenceOwner( + _meshAdapter, + CollectMeshIds(entity)); + replacement.Acquire(); + } + + SyntheticEntityMeshReferenceOwner? previous = _meshReferences; + try + { + _textureOwnerLease.Replace(entity is not null); + } + catch (Exception textureFailure) + { + if (replacement is null) + throw; + + try + { + replacement.Dispose(); + } + catch (Exception rollbackFailure) + { + throw new AggregateException( + $"The {_diagnosticName} texture-owner replacement failed " + + "and the replacement mesh-owner rollback did not converge.", + textureFailure, + rollbackFailure); + } + + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(textureFailure) + .Throw(); + } + + _meshReferences = replacement; _entity = entity; + + if (previous is not null) + { + try + { + previous.Dispose(); + } + catch + { + _retiringMeshReferences.Add(previous); + throw; + } + } } public uint Render(int width, int height) @@ -246,7 +306,78 @@ internal sealed unsafe class PrivateEntityViewportRenderer : public void Dispose() { _entity = null; - _textureOwnerLease.Dispose(); - DeleteFramebuffer(); + if (_meshReferences is { } current) + { + _meshReferences = null; + _retiringMeshReferences.Add(current); + } + + List? failures = null; + try + { + _textureOwnerLease.Dispose(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + try + { + ReleaseRetiringMeshReferences(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + try + { + DeleteFramebuffer(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + + if (failures is not null) + { + throw new AggregateException( + $"The {_diagnosticName} resources did not fully release.", + failures); + } + } + + private static IEnumerable CollectMeshIds(WorldEntity entity) + { + for (int i = 0; i < entity.MeshRefs.Count; i++) + yield return entity.MeshRefs[i].GfxObjId; + for (int i = 0; i < entity.PartOverrides.Count; i++) + yield return entity.PartOverrides[i].GfxObjId; + } + + private void ReleaseRetiringMeshReferences() + { + List? failures = null; + for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--) + { + SyntheticEntityMeshReferenceOwner owner = + _retiringMeshReferences[i]; + try + { + owner.Dispose(); + if (owner.IsDisposed) + _retiringMeshReferences.RemoveAt(i); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (failures is not null) + { + throw new AggregateException( + $"One or more {_diagnosticName} mesh owners remain pending.", + failures); + } } } diff --git a/src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs b/src/AcDream.App/Rendering/SyntheticEntityMeshReferenceOwner.cs similarity index 83% rename from src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs rename to src/AcDream.App/Rendering/SyntheticEntityMeshReferenceOwner.cs index 7110769b..cac09bd9 100644 --- a/src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs +++ b/src/AcDream.App/Rendering/SyntheticEntityMeshReferenceOwner.cs @@ -3,13 +3,14 @@ using AcDream.App.Rendering.Wb; namespace AcDream.App.Rendering; /// -/// Owns the synthetic portal creature's mesh references. Acquisition happens -/// only after the presentation has been published to its runtime owner, so a -/// partial backend failure can never strand an unreachable constructor-local -/// reference. Per-reference physical state makes both acquisition and teardown -/// resumable without replaying successful mutations. +/// Owns one synthetic/private entity's mesh references independently of world +/// cell residence. Acquisition happens only after the presentation has been +/// published to its runtime owner, so a partial backend failure can never +/// strand an unreachable constructor-local reference. Per-reference physical +/// state makes both acquisition and teardown resumable without replaying +/// successful mutations. /// -internal sealed class PortalMeshReferenceOwner : IDisposable +internal sealed class SyntheticEntityMeshReferenceOwner : IDisposable { private sealed class ReferenceState(ulong gfxObjId) { @@ -25,7 +26,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable private bool _reconciling; private bool _reconcileAgain; - public PortalMeshReferenceOwner( + public SyntheticEntityMeshReferenceOwner( IWbMeshAdapter meshAdapter, IEnumerable gfxObjIds) { @@ -62,7 +63,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable catch (Exception rollbackFailure) { throw new AggregateException( - "Portal mesh acquisition failed and its rollback did not fully converge.", + "Synthetic entity mesh acquisition failed and its rollback did not fully converge.", acquisitionFailure, rollbackFailure); } @@ -132,7 +133,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable string operation = targetHeld ? "acquisition" : "release"; (failures ??= []).Add(new InvalidOperationException( - $"Portal mesh 0x{reference.GfxObjId:X10} reference {operation} failed.", + $"Synthetic entity mesh 0x{reference.GfxObjId:X10} reference {operation} failed.", error)); } } @@ -148,7 +149,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable if (failures is not null) throw new AggregateException( - "One or more portal mesh references failed to reconcile.", + "One or more synthetic entity mesh references failed to reconcile.", failures); } } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index f8bf9452..7e5c3046 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -12,7 +12,9 @@ using AcDream.App.Rendering.Residency; namespace AcDream.App.Rendering; -public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable +public sealed unsafe class TextureCache + : Wb.IEntityTextureLifetime, + IDisposable { private readonly GL _gl; private readonly IDatReaderWriter _dats; @@ -39,6 +41,7 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable private readonly Wb.BindlessSupport? _bindless; private readonly CompositeTextureArrayCache? _compositeTextures; + private bool _destinationRevealUploadPriority; // Standalone Texture2DArray caches. Shared world surfaces use WB's atlas; // this base cache remains for consumers such as particle rendering. @@ -65,6 +68,9 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable internal int CachedUnownedParticleTextureCount => _particleTextures?.UnownedEntryCount ?? 0; internal long CachedUnownedParticleTextureBytes => _particleTextures?.UnownedBytes ?? 0; + internal void SetDestinationRevealUploadPriority(bool enabled) => + _destinationRevealUploadPriority = enabled; + // Phase N.6 slice 1 (2026-05-11): per-upload metadata for the // ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload // time so the dump method doesn't have to query GL state. Keyed by @@ -557,7 +563,8 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable /// public void TickParticleTextureCache() => _particleTextures?.Tick(); - public void BeginCompositeTextureFrame() => _compositeTextures?.BeginFrame(); + public void BeginCompositeTextureFrame() => + _compositeTextures?.BeginFrame(_destinationRevealUploadPriority); /// /// Cheap 64-bit hash over a palette override's identity so two diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index b05d7a8d..1bc0fd88 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -21,9 +21,12 @@ namespace AcDream.App.Rendering.Wb; /// DefaultDatReaderWriter file-handle set has been removed. /// /// -public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter +public sealed class WbMeshAdapter + : IDisposable, + IWbMeshAdapter { internal const int MaximumUploadsPerFrame = 8; + internal const int DestinationRevealMaximumUploadsPerFrame = 64; internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024; internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024; internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024; @@ -48,22 +51,12 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter private readonly ObjectMeshManager? _meshManager; private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement; private readonly IPreparedAssetSource? _ownedPreparedAssets; - private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits( - MaximumUploadsPerFrame, - MaximumUploadBytesPerFrame, - MaximumArrayAllocationBytesPerFrame, - MaximumMipmapBytesPerFrame, - MaximumNewArraysPerFrame, - MaximumBufferUploadBytesPerFrame, - MaximumBufferAllocationBytesPerFrame, - MaximumBufferCopyBytesPerFrame, - MaximumNewBuffersPerFrame, - MaximumSingleUploadBytes, - MaximumSingleArrayAllocationBytes, - MaximumSingleMipmapBytes, - MaximumSingleNewArrays, - MaximumSingleBufferUploadBytes)); + private readonly MeshUploadFrameBudget _ordinaryUploadBudget = + CreateUploadBudget(MaximumUploadsPerFrame); + private readonly MeshUploadFrameBudget _destinationRevealUploadBudget = + CreateUploadBudget(DestinationRevealMaximumUploadsPerFrame); private readonly HashSet _mipmapsBudgeted = new(); + private bool _destinationRevealUploadPriority; /// /// True when this instance was created via ; @@ -91,6 +84,27 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter internal (int Count, long Bytes) CpuMeshCacheDiagnostics => _meshManager?.CpuCacheDiagnostics ?? default; + internal void SetDestinationRevealUploadPriority(bool enabled) => + _destinationRevealUploadPriority = enabled; + + private static MeshUploadFrameBudget CreateUploadBudget( + int maximumObjects) => + new(new MeshUploadBudgetLimits( + maximumObjects, + MaximumUploadBytesPerFrame, + MaximumArrayAllocationBytesPerFrame, + MaximumMipmapBytesPerFrame, + MaximumNewArraysPerFrame, + MaximumBufferUploadBytesPerFrame, + MaximumBufferAllocationBytesPerFrame, + MaximumBufferCopyBytesPerFrame, + MaximumNewBuffersPerFrame, + MaximumSingleUploadBytes, + MaximumSingleArrayAllocationBytes, + MaximumSingleMipmapBytes, + MaximumSingleNewArrays, + MaximumSingleBufferUploadBytes)); + /// /// Constructs the UI-Studio/tooling WB pipeline. Production composition /// supplies the validated pak source through the internal overload below; @@ -457,7 +471,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so // a genuine defect surfaces loudly instead of the old silent sticky drop. List? requeue = null; - _uploadBudget.Reset(); + MeshUploadFrameBudget uploadBudget = + _destinationRevealUploadPriority + ? _destinationRevealUploadBudget + : _ordinaryUploadBudget; + int maximumUploads = + _destinationRevealUploadPriority + ? DestinationRevealMaximumUploadsPerFrame + : MaximumUploadsPerFrame; + uploadBudget.Reset(); _mipmapsBudgeted.Clear(); int staleDiscardCount = 0; bool arenaBackpressured = false; @@ -470,7 +492,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter { GlobalMeshMaintenanceStep maintenance = globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame); - _uploadBudget.RecordBufferMaintenance( + uploadBudget.RecordBufferMaintenance( maintenance.AllocationBytes, maintenance.CopyBytes, maintenance.NewBufferCount); @@ -478,8 +500,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter } while (globalBuffer?.IsMigrationInProgress != true - && _uploadBudget.BufferCopyBytes == 0 - && _uploadBudget.ObjectCount < MaximumUploadsPerFrame) + && uploadBudget.BufferCopyBytes == 0 + && uploadBudget.ObjectCount < maximumUploads) { staleDiscardCount += meshManager.DiscardUnownedStagedPrefix( MaximumStaleDiscardsPerFrame - staleDiscardCount); @@ -501,7 +523,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter } if (capacity == GlobalMeshCapacityResult.MigrationStarted) { - _uploadBudget.RecordBufferMaintenance( + uploadBudget.RecordBufferMaintenance( maintenance.AllocationBytes, maintenance.CopyBytes, maintenance.NewBufferCount); @@ -544,7 +566,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter next.Data, _mipmapsBudgeted, next.Generation); - if (!_uploadBudget.TryAdmit(cost)) + if (!uploadBudget.TryAdmit(cost)) break; } catch (NotSupportedException error) @@ -597,29 +619,29 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // frames. It never competes with destination materialization, and the // GlobalMeshBuffer policy retains enough hysteresis that portal-route // revisits do not alternate shrink/grow every frame. - if (_uploadBudget.ObjectCount == 0 + if (uploadBudget.ObjectCount == 0 && LastMipmapArrayCount == 0 && meshManager.StagedMeshCount == 0 && globalBuffer?.IsMigrationInProgress == false && globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim)) { - _uploadBudget.RecordBufferMaintenance( + uploadBudget.RecordBufferMaintenance( trim.AllocationBytes, trim.CopyBytes, trim.NewBufferCount); meshManager.SetArenaBackpressure(true); } - LastUploadCount = _uploadBudget.ObjectCount; - LastUploadBytes = _uploadBudget.SourceBytes; + LastUploadCount = uploadBudget.ObjectCount; + LastUploadBytes = uploadBudget.SourceBytes; LastStaleDiscardCount = staleDiscardCount; - LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes; - LastPlannedMipmapBytes = _uploadBudget.MipmapBytes; - LastNewArrayCount = _uploadBudget.NewArrayCount; - LastBufferUploadBytes = _uploadBudget.BufferUploadBytes; - LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes; - LastBufferCopyBytes = _uploadBudget.BufferCopyBytes; - LastNewBufferCount = _uploadBudget.NewBufferCount; + LastArrayAllocationBytes = uploadBudget.ArrayAllocationBytes; + LastPlannedMipmapBytes = uploadBudget.MipmapBytes; + LastNewArrayCount = uploadBudget.NewArrayCount; + LastBufferUploadBytes = uploadBudget.BufferUploadBytes; + LastBufferAllocationBytes = uploadBudget.BufferAllocationBytes; + LastBufferCopyBytes = uploadBudget.BufferCopyBytes; + LastNewBufferCount = uploadBudget.NewBufferCount; if (texProbe) EmitTexFlushProbe(pendingBefore); diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 35ccbc20..41916cd3 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -339,7 +339,6 @@ internal sealed class RuntimeWorldFrameVisibilityPreparation _particles.UseWorldView(); _reveal?.ObserveWorldViewportVisible(); - _reveal?.Complete(); } public void PublishViewProjection(in WorldCameraFrame camera) => diff --git a/src/AcDream.App/Rendering/WorldRevealRenderResourceScheduler.cs b/src/AcDream.App/Rendering/WorldRevealRenderResourceScheduler.cs new file mode 100644 index 00000000..c42ca44c --- /dev/null +++ b/src/AcDream.App/Rendering/WorldRevealRenderResourceScheduler.cs @@ -0,0 +1,53 @@ +using AcDream.App.Streaming; + +namespace AcDream.App.Rendering; + +/// +/// Applies one reveal generation to every render-resource participant and +/// restores their ordinary profile only when that exact generation ends. +/// Replacing an active generation keeps priority continuously enabled. +/// +internal sealed class WorldRevealRenderResourceScheduler + : IWorldRevealRenderResourceScheduler +{ + private readonly Action[] _participants; + private long _activeGeneration; + + public WorldRevealRenderResourceScheduler( + params Action[] participants) + { + ArgumentNullException.ThrowIfNull(participants); + if (participants.Length == 0) + throw new ArgumentException( + "At least one reveal upload participant is required.", + nameof(participants)); + if (participants.Any(static participant => participant is null)) + throw new ArgumentException( + "Reveal upload participants cannot contain null.", + nameof(participants)); + + _participants = [.. participants]; + } + + public void BeginDestinationReveal(long revealGeneration) + { + if (revealGeneration <= 0) + throw new ArgumentOutOfRangeException(nameof(revealGeneration)); + + _activeGeneration = revealGeneration; + for (int i = 0; i < _participants.Length; i++) + _participants[i](true); + } + + public void EndDestinationReveal(long revealGeneration) + { + if (revealGeneration <= 0) + throw new ArgumentOutOfRangeException(nameof(revealGeneration)); + if (_activeGeneration != revealGeneration) + return; + + for (int i = 0; i < _participants.Length; i++) + _participants[i](false); + _activeGeneration = 0; + } +} diff --git a/src/AcDream.App/Streaming/GpuLandblockRetirement.cs b/src/AcDream.App/Streaming/GpuLandblockRetirement.cs index d97f72c7..0cfee190 100644 --- a/src/AcDream.App/Streaming/GpuLandblockRetirement.cs +++ b/src/AcDream.App/Streaming/GpuLandblockRetirement.cs @@ -13,6 +13,17 @@ public sealed record GpuLandblockRetirement( LandblockRetirementKind Kind, IReadOnlyList Entities); +/// +/// Exact result of the atomic spatial-generation swap used before a shared +/// world-origin recenter. Spatial membership is already unreachable when this +/// value returns; the retained per-landblock receipts let presentation owners +/// retire their resources later under the ordinary frame budget. +/// +internal sealed record GpuWorldRecenterRetirement( + IReadOnlyList Landblocks, + int SpatialOperationCount, + Exception? ObserverFailure); + public enum LandblockRetirementKind { Full, diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index f88f7bf8..6d9be87f 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -460,6 +460,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery public int PersistentGuidCount => _persistentGuids.Count; public int PendingVisibilityTransitionCount => _visibilityTransitions.Count; internal long VisibilityCommitCount => _visibilityCommitCount; + internal int OriginRecenterSpatialOperationCount => + Math.Max( + 1, + _loaded.Count + + _pendingByLandblock.Count + + _pendingRenderIdsByLandblock.Count + + _pendingNearTierLandblocks.Count + + _projectionLocations.Count); /// /// Groups spatial mutations into one visibility publication transaction. @@ -1257,6 +1265,207 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery detachedEntities); } + /// + /// Atomically withdraws the complete old spatial generation before a + /// shared-origin change. The origin is common to every resident transform, + /// so admitting hundreds of independent landblock detaches across frames + /// only prolongs portal transit; it does not expose a useful intermediate + /// state. This operation swaps the spatial indexes as one transaction, + /// retains live logical objects, and returns exact landblock receipts so + /// renderer/physics/script destruction can remain frame-budgeted. + /// + internal GpuWorldRecenterRetirement DetachAllForOriginRecenter() + { + var ids = new List( + _loaded.Count + + _pendingByLandblock.Count + + _pendingRenderIdsByLandblock.Count); + var seenIds = new HashSet(); + + void AddId(uint id) + { + uint canonical = (id & 0xFFFF0000u) | 0xFFFFu; + if (seenIds.Add(canonical)) + ids.Add(canonical); + } + + // Preserve the accepted renderer traversal order for already-resident + // landblocks, then append presentation-only and pending-only owners in + // their stable insertion order. + for (int i = 0; i < _renderTraversalLandblockSlots.Count; i++) + { + uint id = _renderTraversalLandblockSlots[i]; + if (id != 0u) + AddId(id); + } + foreach (uint id in _pendingByLandblock.Keys) + AddId(id); + foreach (uint id in _pendingRenderIdsByLandblock.Keys) + AddId(id); + foreach (uint id in _pendingNearTierLandblocks) + AddId(id); + foreach (uint id in _tierByLandblock.Keys) + AddId(id); + foreach (uint id in _aabbs.Keys) + AddId(id); + + var retirements = new List(ids.Count); + for (int i = 0; i < ids.Count; i++) + { + uint id = ids[i]; + IReadOnlyList loaded = + _loaded.TryGetValue(id, out LoadedLandblock? landblock) + ? landblock.Entities + : Array.Empty(); + IReadOnlyList pending = + _pendingByLandblock.TryGetValue(id, out List? bucket) + ? bucket + : Array.Empty(); + IReadOnlyList detached = loaded; + if (pending.Count != 0) + { + if (loaded.Count == 0) + { + detached = pending; + } + else + { + var combined = new List( + loaded.Count + pending.Count); + var seenEntities = new HashSet( + ReferenceEqualityComparer.Instance); + for (int entityIndex = 0; + entityIndex < loaded.Count; + entityIndex++) + { + WorldEntity entity = loaded[entityIndex]; + if (seenEntities.Add(entity)) + combined.Add(entity); + } + for (int entityIndex = 0; + entityIndex < pending.Count; + entityIndex++) + { + WorldEntity entity = pending[entityIndex]; + if (seenEntities.Add(entity)) + combined.Add(entity); + } + detached = combined; + } + } + + retirements.Add(new GpuLandblockRetirement( + id, + LandblockRetirementKind.Full, + detached)); + } + + // Capture live projections from their small dedicated index rather + // than walking every DAT-static entity in the 25x25 world window. + var retainedByLandblock = + new Dictionary>(); + var rescued = new HashSet( + _persistentRescued, + ReferenceEqualityComparer.Instance); + foreach ((WorldEntity entity, ProjectionLocation location) in + _projectionLocations) + { + if (_persistentGuids.Contains(entity.ServerGuid)) + { + if (rescued.Add(entity)) + { + _persistentRescued.Add(entity); + EntityVanishProbe.Log( + $"[ent] RESCUE guid=0x{entity.ServerGuid:X8} " + + $"from=recenter lb=0x{location.LandblockId:X8}"); + } + continue; + } + + if (!retainedByLandblock.TryGetValue( + location.LandblockId, + out List<(WorldEntity Entity, int BucketIndex)>? retained)) + { + retained = []; + retainedByLandblock.Add(location.LandblockId, retained); + } + retained.Add((entity, location.BucketIndex)); + } + + int spatialOperationCount = OriginRecenterSpatialOperationCount; + Exception? observerFailure = null; + MutationBatch mutation = BeginMutationBatch(); + try + { + foreach ((uint guid, int count) in _visibleLiveProjectionCounts) + { + if (count > 0 && !_visibilityBeforeMutation.ContainsKey(guid)) + _visibilityBeforeMutation.Add(guid, true); + } + _visibleLiveProjectionCounts.Clear(); + + if (_flatEntities.Count != 0) + _flatMembershipDirty = true; + _flatEntities.Clear(); + _flatEntityIndices.Clear(); + _projectionLocations.Clear(); + _loadedLiveByLandblock.Clear(); + _primaryProjectionByGuid.Clear(); + _additionalProjectionsByGuid.Clear(); + + _loaded.Clear(); + _renderTraversalLandblockSlots.Clear(); + _freeRenderTraversalLandblockSlots.Clear(); + _renderTraversalSlotByLandblock.Clear(); + _tierByLandblock.Clear(); + _aabbs.Clear(); + _animatedIndexByLandblock.Clear(); + _pendingByLandblock.Clear(); + _pendingRenderIdsByLandblock.Clear(); + _pendingNearTierLandblocks.Clear(); + + _landblockEntriesView.Clear(); + _landblockEntriesWithoutAnimatedIndexView.Clear(); + _landblockBoundsView.Clear(); + InvalidateLandblockRenderViews(entries: true, bounds: true); + + foreach ((uint id, List<(WorldEntity Entity, int BucketIndex)> retained) in + retainedByLandblock) + { + retained.Sort(static (left, right) => + left.BucketIndex.CompareTo(right.BucketIndex)); + var pending = new List(retained.Count); + for (int i = 0; i < retained.Count; i++) + { + WorldEntity entity = retained[i].Entity; + pending.Add(entity); + SetProjectionLocation( + entity, + id, + isLoaded: false, + i); + } + _pendingByLandblock.Add(id, pending); + } + } + finally + { + try + { + mutation.Dispose(); + } + catch (Exception error) + { + observerFailure = error; + } + } + + return new GpuWorldRecenterRetirement( + retirements, + spatialOperationCount, + observerFailure); + } + /// /// Compatibility edge for direct state tests. Production streaming uses /// so each presentation owner diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index de1e8d89..f5ae4f9d 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -240,6 +240,11 @@ public sealed class LandblockPresentationPipeline public void AdvanceRetirements(StreamingWorkMeter meter) => _retirements.Advance(meter); + internal void AdvancePriorityRetirement( + uint landblockId, + StreamingWorkMeter meter) => + _retirements.AdvancePriority(landblockId, meter); + public void BeginFullRetirement(uint landblockId) { _retirements.BeginFull(landblockId); @@ -260,6 +265,29 @@ public sealed class LandblockPresentationPipeline internal void EnqueueNearLayerRetirement(uint landblockId) => _retirements.BeginNearLayer(landblockId); + /// + /// Withdraws the complete old spatial generation in one transaction, then + /// transfers its exact receipts to the ordinary budgeted retirement FIFO. + /// + internal GpuWorldRecenterRetirement DetachAllForOriginRecenter() + { + GpuWorldRecenterRetirement detached = + _state.DetachAllForOriginRecenter(); + Exception? adoptionFailure = + _retirements.AdoptDetachedFull(detached.Landblocks); + Exception? failure = (detached.ObserverFailure, adoptionFailure) switch + { + (null, null) => null, + ({ } observer, null) => observer, + (null, { } adoption) => adoption, + ({ } observer, { } adoption) => new AggregateException( + "Origin-recenter spatial observers and retirement adoption failed.", + observer, + adoption), + }; + return detached with { ObserverFailure = failure }; + } + public void PublishLoaded(LandblockStreamResult.Loaded loaded) { ArgumentNullException.ThrowIfNull(loaded); diff --git a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs index cb1462ca..f9e0e937 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs @@ -59,16 +59,15 @@ public sealed class LandblockPresentationRetirementOwner public void Advance(LandblockRetirementTicket ticket) { ArgumentNullException.ThrowIfNull(ticket); - bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer; ticket.RunForEachEntity( LandblockRetirementStage.EntityLighting, - entity => !staticOnly || entity.ServerGuid == 0, - RemoveLighting); + static entity => entity.ServerGuid == 0, + _staticPresentation.RemoveLighting); ticket.RunForEachEntity( LandblockRetirementStage.EntityTranslucency, - entity => !staticOnly || entity.ServerGuid == 0, - RemoveTranslucency); + static entity => entity.ServerGuid == 0, + _staticPresentation.RemoveTranslucency); ticket.RunForEachEntity( LandblockRetirementStage.PluginProjection, static entity => entity.ServerGuid == 0, @@ -105,19 +104,18 @@ public sealed class LandblockPresentationRetirementOwner LandblockRetirementTicket ticket) { ArgumentNullException.ThrowIfNull(ticket); - bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer; return ticket.NextIncompleteStage switch { LandblockRetirementStage.EntityLighting => ticket.RunEntityStep( LandblockRetirementStage.EntityLighting, - entity => !staticOnly || entity.ServerGuid == 0, - RemoveLighting), + static entity => entity.ServerGuid == 0, + _staticPresentation.RemoveLighting), LandblockRetirementStage.EntityTranslucency => ticket.RunEntityStep( LandblockRetirementStage.EntityTranslucency, - entity => !staticOnly || entity.ServerGuid == 0, - RemoveTranslucency), + static entity => entity.ServerGuid == 0, + _staticPresentation.RemoveTranslucency), LandblockRetirementStage.PluginProjection => ticket.RunEntityStep( LandblockRetirementStage.PluginProjection, @@ -152,20 +150,4 @@ public sealed class LandblockPresentationRetirementOwner _ => LandblockRetirementOperationResult.NoWork, }; } - - private void RemoveLighting(WorldEntity entity) - { - if (entity.ServerGuid == 0) - _staticPresentation.RemoveLighting(entity); - else - _lighting.UnregisterOwner(entity.Id); - } - - private void RemoveTranslucency(WorldEntity entity) - { - if (entity.ServerGuid == 0) - _staticPresentation.RemoveTranslucency(entity); - else - _translucency.ClearEntity(entity.Id); - } } diff --git a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs index 21c61ba2..82046fa1 100644 --- a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs +++ b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs @@ -242,6 +242,13 @@ public sealed class LandblockRetirementTicket /// public sealed class LandblockRetirementCoordinator { + private enum BudgetedAdvanceResult : byte + { + Progressed, + Yielded, + Failed, + } + private enum RequestKind : byte { BeginFull, @@ -275,7 +282,11 @@ public sealed class LandblockRetirementCoordinator _requiredPresentationStages; private readonly Action? _onDetached; private readonly Dictionary> _pending = new(); - private readonly Queue _pendingOrder = new(); + private readonly LinkedList _pendingOrder = new(); + private readonly Dictionary< + LandblockRetirementTicket, + LinkedListNode> _pendingNodes = + new(ReferenceEqualityComparer.Instance); private readonly List _completedIds = new(); private readonly Queue _requests = new(); private readonly HashSet _seenDuringDrain = new(); @@ -326,6 +337,104 @@ public sealed class LandblockRetirementCoordinator ReferenceEquals(_state, state); internal bool UsesBudgetedSteps => _advancePresentationStep is not null; + /// + /// Adopts exact receipts produced by the atomic origin-recenter spatial + /// swap. Spatial detachment has already committed; this method only + /// establishes the same retryable presentation ledgers as BeginFull. + /// + internal Exception? AdoptDetachedFull( + IReadOnlyList retirements) + { + ArgumentNullException.ThrowIfNull(retirements); + var incomingIds = new HashSet(); + for (int index = 0; index < retirements.Count; index++) + { + GpuLandblockRetirement retirement = retirements[index]; + if (retirement.Kind != LandblockRetirementKind.Full) + { + throw new ArgumentException( + "Origin-recenter adoption accepts only full retirements.", + nameof(retirements)); + } + + uint canonical = Canonicalize(retirement.LandblockId); + if (!incomingIds.Add(canonical)) + { + throw new ArgumentException( + $"Origin-recenter receipts contain duplicate landblock " + + $"0x{canonical:X8}.", + nameof(retirements)); + } + if (_pending.TryGetValue( + canonical, + out List? existing) + && existing.Any( + static ticket => + ticket.Kind == LandblockRetirementKind.Full)) + { + throw new InvalidOperationException( + $"Landblock 0x{canonical:X8} already has a full " + + "retirement receipt."); + } + } + + LandblockRetirementStage required = + CoreStages + | _requiredPresentationStages(LandblockRetirementKind.Full); + List? failures = null; + for (int index = 0; index < retirements.Count; index++) + { + GpuLandblockRetirement retirement = retirements[index]; + uint canonical = Canonicalize(retirement.LandblockId); + _pending.TryGetValue( + canonical, + out List? existing); + + var ticket = new LandblockRetirementTicket( + retirement, + required); + if (existing is null) + { + existing = new List(1); + _pending.Add(canonical, existing); + } + existing.Add(ticket); + PendingCount++; + if (_advancePresentationStep is not null) + EnqueueBudgetedTicket(ticket); + + try + { + _onDetached?.Invoke(retirement); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + + if (_advancePresentationStep is null) + { + AdvanceTicket(ticket); + if (ticket.IsComplete) + { + existing.Remove(ticket); + PendingCount--; + if (existing.Count == 0) + _pending.Remove(canonical); + } + } + } + + return failures switch + { + null => null, + { Count: 1 } => failures[0], + _ => new AggregateException( + "One or more detached landblock observers failed.", + failures), + }; + } + public void BeginFull(uint landblockId) => EnqueueRequest( new Request(RequestKind.BeginFull, Canonicalize(landblockId))); @@ -365,40 +474,56 @@ public sealed class LandblockRetirementCoordinator return; } - while (_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket)) + while (_pendingOrder.First is { } head) { + LandblockRetirementTicket ticket = head.Value; if (ticket.IsComplete) { RemoveCompletedTicket(ticket); continue; } - LandblockRetirementStage stage = ticket.NextIncompleteStage; - if (stage == LandblockRetirementStage.None) - throw new InvalidOperationException( - "An incomplete retirement ticket has no unfinished stage."); - - StreamingWorkCost cost = new( - EntityOperations: 1, - GlRetireOperations: - stage is LandblockRetirementStage.MeshReferences - or LandblockRetirementStage.Terrain - ? 1 - : 0); - StreamingWorkAdmission admission = meter.TryReserve( - cost, - $"retire-{stage}-0x{ticket.LandblockId:X8}"); - if (admission == StreamingWorkAdmission.Yielded) + if (AdvanceBudgetedTicket(ticket, meter) + != BudgetedAdvanceResult.Progressed) return; + if (ticket.IsComplete) + RemoveCompletedTicket(ticket); + } + } - LandblockRetirementOperationResult result = AdvanceTicketOne(ticket); - if (result == LandblockRetirementOperationResult.Failed) + /// + /// Advances only retirement receipts for one canonical landblock. This is + /// the dependency lane used by a destination replacement: unrelated + /// detached-world cleanup retains stable FIFO order, while the exact old + /// owner that fences the destination may complete out of order under the + /// same frame meter. + /// + internal void AdvancePriority(uint landblockId, StreamingWorkMeter meter) + { + ArgumentNullException.ThrowIfNull(meter); + if (_advancePresentationStep is null) + { + Advance(); + return; + } + + uint canonical = Canonicalize(landblockId); + while (_pending.TryGetValue( + canonical, + out List? tickets)) + { + if (tickets.Count == 0) + throw new InvalidOperationException( + "A pending retirement owner has no tickets."); + + LandblockRetirementTicket ticket = tickets[0]; + if (!ticket.IsComplete + && AdvanceBudgetedTicket(ticket, meter) + != BudgetedAdvanceResult.Progressed) { - meter.Fail(); return; } - meter.Complete(); if (ticket.IsComplete) RemoveCompletedTicket(ticket); } @@ -535,7 +660,7 @@ public sealed class LandblockRetirementCoordinator existing.Add(ticket); PendingCount++; if (_advancePresentationStep is not null) - _pendingOrder.Enqueue(ticket); + EnqueueBudgetedTicket(ticket); _onDetached?.Invoke(stateRetirement); @@ -592,8 +717,9 @@ public sealed class LandblockRetirementCoordinator private void AdvanceBudgetedEagerAttempt() { - if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket)) + if (_pendingOrder.First is not { } head) return; + LandblockRetirementTicket ticket = head.Value; if (ticket.IsComplete) { @@ -657,15 +783,63 @@ public sealed class LandblockRetirementCoordinator return result; } - private void RemoveCompletedTicket(LandblockRetirementTicket ticket) + private BudgetedAdvanceResult AdvanceBudgetedTicket( + LandblockRetirementTicket ticket, + StreamingWorkMeter meter) { - if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? head) - || !ReferenceEquals(head, ticket)) + LandblockRetirementStage stage = ticket.NextIncompleteStage; + if (stage == LandblockRetirementStage.None) { throw new InvalidOperationException( - "Retirement FIFO completion did not match its active head."); + "An incomplete retirement ticket has no unfinished stage."); } - _pendingOrder.Dequeue(); + + StreamingWorkCost cost = new( + EntityOperations: 1, + GlRetireOperations: + stage is LandblockRetirementStage.MeshReferences + or LandblockRetirementStage.Terrain + ? 1 + : 0); + StreamingWorkAdmission admission = meter.TryReserve( + cost, + $"retire-{stage}-0x{ticket.LandblockId:X8}"); + if (admission == StreamingWorkAdmission.Yielded) + return BudgetedAdvanceResult.Yielded; + + LandblockRetirementOperationResult result = AdvanceTicketOne(ticket); + if (result == LandblockRetirementOperationResult.Failed) + { + meter.Fail(); + return BudgetedAdvanceResult.Failed; + } + + meter.Complete(); + return BudgetedAdvanceResult.Progressed; + } + + private void EnqueueBudgetedTicket(LandblockRetirementTicket ticket) + { + LinkedListNode node = + _pendingOrder.AddLast(ticket); + if (!_pendingNodes.TryAdd(ticket, node)) + { + _pendingOrder.Remove(node); + throw new InvalidOperationException( + "A retirement ticket cannot enter the budgeted FIFO twice."); + } + } + + private void RemoveCompletedTicket(LandblockRetirementTicket ticket) + { + if (!_pendingNodes.Remove( + ticket, + out LinkedListNode? node)) + { + throw new InvalidOperationException( + "Completed retirement ticket is missing from its budgeted order."); + } + _pendingOrder.Remove(node); uint id = Canonicalize(ticket.LandblockId); if (!_pending.TryGetValue(id, out List? tickets) diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index e5d6e0f2..ca94817a 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -506,6 +506,12 @@ internal sealed class LocalPlayerTeleportController return; break; case TeleportAnimEvent.PlayExitSound: + // gmSmartBoxUI::UseTime @ 0x004D6E30 releases destination + // cell blocking at the exact portal/world viewport swap. + // LoginComplete remains one WorldFadeIn second later. + _worldReveal.RevealWorldViewport(); + if (!IsCurrentLifetime(generation, sequence)) + return; _presentation.ExitTunnel(); if (!IsCurrentLifetime(generation, sequence)) return; diff --git a/src/AcDream.App/Streaming/StreamingCompletionQueue.cs b/src/AcDream.App/Streaming/StreamingCompletionQueue.cs index 6a606cb0..147767aa 100644 --- a/src/AcDream.App/Streaming/StreamingCompletionQueue.cs +++ b/src/AcDream.App/Streaming/StreamingCompletionQueue.cs @@ -58,6 +58,9 @@ internal sealed class StreamingCompletionQueue public int Count => _count; public long RetainedCpuBytes => _retainedCpuBytes; + public bool HasPriority(StreamingCompletionPriority priority) => + _queues[(int)priority].Count != 0; + public void Enqueue(StreamingQueuedCompletion completion) { _queues[(int)completion.Priority].Enqueue(completion); @@ -69,10 +72,15 @@ internal sealed class StreamingCompletionQueue public bool TryPeekNext( Func isBlocked, - out StreamingQueuedCompletion? completion) + out StreamingQueuedCompletion? completion, + StreamingCompletionPriority maximumPriority = + StreamingCompletionPriority.Far) { ArgumentNullException.ThrowIfNull(isBlocked); - for (int priority = 0; priority < _queues.Length; priority++) + int lastPriority = Math.Min( + (int)maximumPriority, + _queues.Length - 1); + for (int priority = 0; priority <= lastPriority; priority++) { Queue queue = _queues[priority]; if (queue.Count == 0) diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 5b96cd1c..08f20cc8 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -33,10 +33,8 @@ public sealed class StreamingController public bool PendingLoadsCleared; public bool CompletionQueueCleared; public bool RegionCleared; - public List? ResidentIds; - public int RetirementCursor; + public bool SpatialGenerationDetached; public bool PreparationCommitted; - public IEnumerator? ResidentEnumerator; public (int X, int Y, bool IsSealedDungeon)? Destination; public bool DestinationConfigured; public bool DestinationLoadEnqueued; @@ -75,7 +73,6 @@ public sealed class StreamingController private string? _maximumWorkFrameStage; private double _maximumWorkOperationMilliseconds; private string? _maximumWorkOperationStage; - private readonly GpuWorldState _state; private StreamingRegion? _region; private RadiiReconfiguration? _pendingRadiiReconfiguration; @@ -538,7 +535,8 @@ public sealed class StreamingController return true; } - private bool ConvergePendingPublications() + private bool ConvergePendingPublications( + bool preferDestination = false) { IReadOnlyList pending = _presentation.GetPendingPublicationResults(); @@ -554,22 +552,48 @@ public sealed class StreamingController try { bool progressed = false; - for (int i = 0; i < pending.Count; i++) + bool destinationPending = false; + if (preferDestination) { - LandblockStreamResult result = pending[i]; - using StreamingWorkMeter.LaneScope lane = - _activeWorkMeter.EnterLane( - IsDestinationWork(result.LandblockId) - ? StreamingWorkLane.Destination - : StreamingWorkLane.NonDestination); - LandblockPublicationAdvance advance = - _presentation.ResumePublication( - result, - _activeWorkMeter, - ensureProgress: !progressed); - progressed |= advance.Progressed; - if (!advance.Completed) - return false; + for (int i = 0; i < pending.Count; i++) + { + if (!IsDestinationWork(pending[i].LandblockId)) + continue; + destinationPending = true; + break; + } + } + // A partially prepared background landblock must not serialize the + // reveal-critical destination behind dictionary insertion order. + // Each publication owns independent receipts, so destination + // transactions can safely resume first without replaying or + // discarding the non-destination cursor. + for (int destinationPass = 1; destinationPass >= 0; destinationPass--) + { + bool requireDestination = destinationPass != 0; + for (int i = 0; i < pending.Count; i++) + { + LandblockStreamResult result = pending[i]; + bool isDestination = IsDestinationWork(result.LandblockId); + if (destinationPending && !isDestination) + continue; + if (isDestination != requireDestination) + continue; + + using StreamingWorkMeter.LaneScope lane = + _activeWorkMeter.EnterLane( + isDestination + ? StreamingWorkLane.Destination + : StreamingWorkLane.NonDestination); + LandblockPublicationAdvance advance = + _presentation.ResumePublication( + result, + _activeWorkMeter, + ensureProgress: !progressed); + progressed |= advance.Progressed; + if (!advance.Completed) + return false; + } } return true; } @@ -615,6 +639,7 @@ public sealed class StreamingController bool retirementWasPending = _presentation.PendingRetirementCount != 0; TryAdvanceFullWindowRetirement(meter); + AdvanceDestinationRetirementDependency(meter); if (_presentation.UsesBudgetedRetirementSteps || retirementWasPending) { @@ -636,6 +661,7 @@ public sealed class StreamingController bool retirementWasPending = _presentation.PendingRetirementCount != 0; TryAdvanceOriginRecenterPreparation(meter); + AdvanceDestinationRetirementDependency(meter); if (_presentation.UsesBudgetedRetirementSteps || retirementWasPending) { @@ -657,8 +683,15 @@ public sealed class StreamingController // or retire the fully known owner set through the normal ledger. bool retirementWasPendingAtFrameStart = _presentation.PendingRetirementCount != 0; + AdvanceDestinationRetirementDependency(meter); _presentation.AdvanceRetirements(meter); - if (!ConvergePendingPublications()) + bool destinationPublicationIncomplete = + _destinationReservation is not null + && !IsRenderNeighborhoodResident( + DestinationLandblockId, + DestinationRadius); + if (!ConvergePendingPublications( + preferDestination: destinationPublicationIncomplete)) return; uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); @@ -691,7 +724,8 @@ public sealed class StreamingController NormalTick(observerCx, observerCy); } - DrainAndApply(); + DrainAndApply( + preferDestination: destinationPublicationIncomplete); // Retirement is cleanup after immediate spatial withdrawal. Spend // remaining capacity only after visible/destination publication // work has had access to this frame's shared meter. @@ -709,6 +743,22 @@ public sealed class StreamingController } } + private void AdvanceDestinationRetirementDependency( + StreamingWorkMeter meter) + { + if (_destinationReservation is null + || !_presentation.IsRetirementPending(DestinationLandblockId)) + { + return; + } + + using StreamingWorkMeter.LaneScope lane = + meter.EnterLane(StreamingWorkLane.Destination); + _presentation.AdvancePriorityRetirement( + DestinationLandblockId, + meter); + } + private void ObserveWorkLifetime(StreamingWorkMeterSnapshot snapshot) { _lifetimeWorkOverruns = SaturatingAdd( @@ -912,21 +962,53 @@ public sealed class StreamingController { _region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); var bootstrap = _region.ComputeFirstTickDiff(); - foreach (var id in bootstrap.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear); + EnqueueLoadsByRevealPriority( + bootstrap.ToLoadNear, + LandblockStreamJobKind.LoadNear); foreach (var id in bootstrap.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar); _region.MarkResidentFromBootstrap(); } else if (_region.CenterX != observerCx || _region.CenterY != observerCy) { var diff = _region.RecenterTo(observerCx, observerCy); - foreach (var id in diff.ToPromote) EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear); - foreach (var id in diff.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear); + EnqueueLoadsByRevealPriority( + diff.ToPromote, + LandblockStreamJobKind.PromoteToNear); + EnqueueLoadsByRevealPriority( + diff.ToLoadNear, + LandblockStreamJobKind.LoadNear); foreach (var id in diff.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar); foreach (var id in diff.ToDemote) DemoteLandblock(id); foreach (var id in diff.ToUnload) EnqueueUnload(id); } } + /// + /// Places the canonical reveal neighborhood at the front of the worker's + /// existing Near FIFO. Completion priority alone is too late: a + /// non-destination build can otherwise begin a multi-frame publication + /// before the destination result has even reached the render thread. + /// + private void EnqueueLoadsByRevealPriority( + IReadOnlyList landblockIds, + LandblockStreamJobKind kind, + bool skipLoaded = false) + { + for (int destinationPass = 1; destinationPass >= 0; destinationPass--) + { + bool requireDestination = destinationPass != 0; + for (int i = 0; i < landblockIds.Count; i++) + { + uint id = landblockIds[i]; + if ((!skipLoaded || !_state.IsLoaded(id)) + && IsDestinationWork(id) == requireDestination) + { + EnqueueLoad(id, kind); + } + } + } + } + /// /// Dungeon-entry edge: cancel the in-flight window load, unload every /// resident neighbor, and pin streaming to the player's single dungeon @@ -1016,8 +1098,10 @@ public sealed class StreamingController if (!rebuilt.Resident.Contains(id)) EnqueueUnload(id); var boot = rebuilt.ComputeFirstTickDiff(); - foreach (var id in boot.ToLoadNear) - if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear); + EnqueueLoadsByRevealPriority( + boot.ToLoadNear, + LandblockStreamJobKind.LoadNear, + skipLoaded: true); foreach (var id in boot.ToLoadFar) if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar); rebuilt.MarkResidentFromBootstrap(); @@ -1053,8 +1137,10 @@ public sealed class StreamingController } /// - /// Advances every retained old-window teardown and reports whether the - /// composition root may safely change the shared world origin. + /// Reports whether every old resident has been detached and captured by + /// an exact retirement receipt. Deferred receipt cleanup may continue + /// after the shared origin changes; same-landblock publication remains + /// fenced by . /// internal bool IsOriginRecenterRetirementComplete() { @@ -1062,14 +1148,14 @@ public sealed class StreamingController throw new InvalidOperationException( "No streaming-origin recenter transaction is pending."); - return _originRecenterRetirement.PreparationCommitted - && _presentation.PendingRetirementCount == 0; + return _originRecenterRetirement.PreparationCommitted; } /// /// Releases the streaming bootstrap gate after the composition root has - /// committed the new shared origin. No old-window presentation owner may - /// still be pending at this edge. + /// committed the new shared origin. Old detached receipts continue under + /// the shared frame meter and fence only a replacement publication with + /// the same canonical landblock key. /// internal bool TryCommitOriginRecenter( int destinationX, @@ -1104,10 +1190,6 @@ public sealed class StreamingController if (!transaction.PreparationCommitted) throw new InvalidOperationException( "Streaming-origin retirement preparation has not completed."); - if (_presentation.PendingRetirementCount != 0) - throw new InvalidOperationException( - "The streaming origin cannot change while old-window presentation retirement is pending."); - var destination = (destinationX, destinationY, isSealedDungeon); if (transaction.Destination is { } retained && retained != destination) { @@ -1174,8 +1256,6 @@ public sealed class StreamingController { if (_originRecenterRetirement is not { PreparationCommitted: true }) return false; - if (_presentation.PendingRetirementCount != 0) - return false; _collapsed = false; _collapsedCenter = 0u; @@ -1293,35 +1373,33 @@ public sealed class StreamingController } transaction.RegionCleared = true; } - if (transaction.ResidentIds is null) - { - transaction.ResidentIds = []; - transaction.ResidentEnumerator = - _state.LoadedLandblockIds.GetEnumerator(); - } - while (transaction.ResidentEnumerator is { } residentEnumerator) + if (!transaction.SpatialGenerationDetached) { StreamingWorkAdmission admission = meter.TryReserve( - new StreamingWorkCost(EntityOperations: 1), - "recenter-capture-resident-id"); + new StreamingWorkCost( + EntityOperations: + _state.OriginRecenterSpatialOperationCount), + "recenter-detach-spatial-generation", + ensureProgress: true); if (admission == StreamingWorkAdmission.Yielded) return false; - bool moved; try { - moved = residentEnumerator.MoveNext(); - if (moved) - transaction.ResidentIds.Add(residentEnumerator.Current); - else - { - residentEnumerator.Dispose(); - transaction.ResidentEnumerator = null; - FullWindowRetirementCount++; - LastFullWindowRetirementLandblockCount = - transaction.ResidentIds.Count; - } + GpuWorldRecenterRetirement detached = + _presentation.DetachAllForOriginRecenter(); + transaction.SpatialGenerationDetached = true; + FullWindowRetirementCount++; + LastFullWindowRetirementLandblockCount = + detached.Landblocks.Count; meter.Complete(); + if (detached.ObserverFailure is not null) + { + Console.WriteLine( + "streaming: committed origin-recenter spatial " + + $"generation reported failure: {detached.ObserverFailure}"); + return false; + } } catch { @@ -1330,46 +1408,6 @@ public sealed class StreamingController } } - List residentIds = transaction.ResidentIds - ?? throw new InvalidOperationException( - "Origin-recenter resident capture did not commit."); - while (transaction.RetirementCursor < residentIds.Count) - { - uint id = residentIds[transaction.RetirementCursor]; - int entityCount = _state.TryGetLandblock( - id, - out LoadedLandblock? loaded) - ? loaded!.Entities.Count - : 0; - StreamingWorkAdmission admission = meter.TryReserve( - new StreamingWorkCost( - EntityOperations: Math.Max(1, entityCount)), - $"recenter-detach-0x{id:X8}"); - if (admission == StreamingWorkAdmission.Yielded) - return false; - - try - { - _presentation.EnqueueFullRetirement(id); - transaction.RetirementCursor++; - meter.Complete(); - } - catch (Exception error) - { - // A delivered visibility-observer failure may surface after - // detachment has committed. Advance that exact cursor only - // when world state proves the old resident is unreachable; - // otherwise retain it for the next frame. - if (!_state.IsLoaded(id)) - transaction.RetirementCursor++; - meter.Fail(); - Console.WriteLine( - $"streaming: origin-recenter retirement for 0x{id:X8} " + - $"will resume: {error}"); - return false; - } - } - transaction.PreparationCommitted = true; return true; } @@ -1567,17 +1605,24 @@ public sealed class StreamingController /// order but no class bypasses time, bytes, entities, uploads, or retire /// operation limits. /// - private void DrainAndApply() + private void DrainAndApply(bool preferDestination = false) { StreamingWorkMeter meter = _activeWorkMeter ?? throw new InvalidOperationException( "Completion scheduling requires an active frame meter."); AdmitCompletions(meter); + bool destinationQueued = + preferDestination + && _completionQueue.HasPriority( + StreamingCompletionPriority.Destination); bool executed = false; while (_completionQueue.TryPeekNext( _isPublicationBlockedByRetirement, - out StreamingQueuedCompletion? completion)) + out StreamingQueuedCompletion? completion, + destinationQueued + ? StreamingCompletionPriority.Unload + : StreamingCompletionPriority.Far)) { StreamingQueuedCompletion work = completion ?? throw new InvalidOperationException( @@ -1650,8 +1695,13 @@ public sealed class StreamingController : ClassifyCompletion(result); LandblockStreamCostEstimate estimate = LandblockStreamResultCost.Estimate(result); + // A stale result belongs to a generation that was already + // cancelled. Reading it releases worker-outbox ownership; it does + // not admit payload into the current world. Charge only elapsed + // time so a large completed old window cannot consume the + // destination generation's completion quota for many frames. StreamingWorkCost admissionCost = stale - ? new StreamingWorkCost(CompletionAdmissions: 1) + ? default : new StreamingWorkCost( CompletionAdmissions: estimate.Work.CompletionAdmissions, diff --git a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs index af516bd0..5ede6f93 100644 --- a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs +++ b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs @@ -3,11 +3,11 @@ using AcDream.App.World; namespace AcDream.App.Streaming; /// -/// Coordinates streaming-origin lifetime boundaries. Presentation must retire -/// the complete old window first; a teleport then changes +/// Coordinates streaming-origin lifetime boundaries. Spatial ownership must +/// detach the complete old window first; a teleport then changes /// before destination streaming resumes, -/// while a session boundary releases the controller without recentering. The -/// transaction remains pending across frames when an owner teardown needs retry. +/// while deferred resource receipts continue under the shared frame budget. +/// A session boundary releases the controller without recentering. /// internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConvergence { @@ -72,9 +72,9 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve } /// - /// Observes retained presentation teardown and commits the new origin once - /// the streaming frame owner has converged it. Returns true only after the - /// destination streaming gate has been released. + /// Observes old-window spatial detachment and commits the new origin once + /// every detached owner has an exact retirement receipt. Returns true only + /// after the destination streaming gate has been released. /// public bool Advance() { diff --git a/src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs b/src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs index dcaef9f6..edc4800c 100644 --- a/src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs +++ b/src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs @@ -22,7 +22,12 @@ public sealed record StreamingWorkBudgetOptions( MaxUpdateMilliseconds: 2.0, MaxCompletionAdmissions: 64, MaxAdoptedCpuBytes: 8 * MiB, - MaxEntityOperations: 256, + // Entity cursors are intentionally very small operations (often one + // dictionary/index write). The elapsed-time ceiling remains the + // authoritative CPU guard; 256 left more than 90% of that budget + // unused and stretched destination publication past retail's + // five-second wait-notice edge. + MaxEntityOperations: 4_096, MaxGpuUploadBytes: 8 * MiB, MaxGlRetireOperations: 64, DestinationReserveFraction: 0.75f); diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index de9ea6a6..f63b9e2e 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -15,6 +15,16 @@ internal interface IWorldRevealStreamingScheduler void EndDestinationReservation(long revealGeneration); } +/// +/// Generation-scoped renderer-resource profile used only while the normal +/// world viewport is withheld for a login or portal destination. +/// +internal interface IWorldRevealRenderResourceScheduler +{ + void BeginDestinationReveal(long revealGeneration); + void EndDestinationReveal(long revealGeneration); +} + /// /// Owns one login/portal reveal lifetime across readiness and lifecycle /// diagnostics. This keeps the destination barrier and its observations on a @@ -27,7 +37,9 @@ internal sealed class WorldRevealCoordinator private readonly WorldRevealLifecycleTelemetry _lifecycle; private readonly WorldGenerationQuiescence? _quiescence; private readonly IWorldRevealStreamingScheduler? _streaming; + private readonly IWorldRevealRenderResourceScheduler? _renderResources; private long _activeGeneration; + private bool _worldViewportReleased; public WorldRevealCoordinator( Func isRenderNeighborhoodReady, @@ -39,7 +51,8 @@ internal sealed class WorldRevealCoordinator Func isSpawnClaimUnhydratable, Action? log = null, WorldGenerationQuiescence? quiescence = null, - IWorldRevealStreamingScheduler? streaming = null) + IWorldRevealStreamingScheduler? streaming = null, + IWorldRevealRenderResourceScheduler? renderResources = null) { _readiness = new WorldRevealReadinessBarrier( isRenderNeighborhoodReady, @@ -52,6 +65,7 @@ internal sealed class WorldRevealCoordinator _lifecycle = new WorldRevealLifecycleTelemetry(log); _quiescence = quiescence; _streaming = streaming; + _renderResources = renderResources; } public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot; @@ -66,11 +80,13 @@ internal sealed class WorldRevealCoordinator _readiness.Begin(); long generation = _lifecycle.Begin(kind); _activeGeneration = generation; + _worldViewportReleased = false; _quiescence?.Begin(generation); _streaming?.BeginDestinationReservation( generation, destinationCell, WorldRevealReadinessBarrier.RequiredRenderRadius(destinationCell)); + _renderResources?.BeginDestinationReveal(generation); return generation; } @@ -99,26 +115,43 @@ internal sealed class WorldRevealCoordinator public bool ObserveWait(TimeSpan elapsed) => _lifecycle.ObserveWait(elapsed); + /// + /// Reopens the destination world at retail's TunnelFadeOut -> + /// WorldFadeIn viewport swap without completing the one-second + /// WorldFadeIn/LoginComplete tail. + /// + public void RevealWorldViewport() + { + long generation = _activeGeneration; + if (generation == 0 || _worldViewportReleased) + return; + + _streaming?.EndDestinationReservation(generation); + _renderResources?.EndDestinationReveal(generation); + _quiescence?.End(generation); + _worldViewportReleased = true; + } + public void Complete() { _lifecycle.Complete(); - EndQuiescence(); + EndLifetime(); } public void Cancel() { _lifecycle.Cancel(); - EndQuiescence(); + EndLifetime(); } - private void EndQuiescence() + private void EndLifetime() { long generation = _activeGeneration; if (generation == 0) return; + RevealWorldViewport(); _activeGeneration = 0; - _streaming?.EndDestinationReservation(generation); - _quiescence?.End(generation); + _worldViewportReleased = false; } } diff --git a/src/AcDream.App/World/DormantLiveEntityStore.cs b/src/AcDream.App/World/DormantLiveEntityStore.cs new file mode 100644 index 00000000..0d742227 --- /dev/null +++ b/src/AcDream.App/World/DormantLiveEntityStore.cs @@ -0,0 +1,123 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.World; + +internal enum DormantCreateDisposition +{ + NoDormantRecord, + ExistingGeneration, + NewGeneration, + StaleGeneration, +} + +/// +/// Cold, data-only ownership for ACE objects that left the client's active +/// visibility set. Active render, physics, animation, effect, and collision +/// owners are torn down before a snapshot enters this store. +/// +/// ACE retains KnownObjects across ordinary teleports and does not reliably +/// repeat CreateObject when a destination is revisited. Keeping the accepted +/// CreateObject snapshot therefore preserves the server object's identity +/// without retaining any frame-time or GPU work. +/// +internal sealed class DormantLiveEntityStore +{ + private readonly Dictionary _spawns = new(); + + public int Count => _spawns.Count; + + public DormantCreateDisposition ClassifyCreate( + WorldSession.EntitySpawn incoming) + { + if (!_spawns.TryGetValue(incoming.Guid, out WorldSession.EntitySpawn retained)) + return DormantCreateDisposition.NoDormantRecord; + + if (PhysicsTimestampGate.IsNewer( + retained.InstanceSequence, + incoming.InstanceSequence)) + { + return DormantCreateDisposition.NewGeneration; + } + + if (PhysicsTimestampGate.IsNewer( + incoming.InstanceSequence, + retained.InstanceSequence)) + { + return DormantCreateDisposition.StaleGeneration; + } + + return DormantCreateDisposition.ExistingGeneration; + } + + public void Retain(WorldSession.EntitySpawn snapshot) + { + if (_spawns.TryGetValue(snapshot.Guid, out WorldSession.EntitySpawn current) + && PhysicsTimestampGate.IsNewer( + snapshot.InstanceSequence, + current.InstanceSequence)) + { + return; + } + + _spawns[snapshot.Guid] = snapshot; + } + + public bool RemoveThroughAcceptedCreate(WorldSession.EntitySpawn accepted) + { + DormantCreateDisposition disposition = ClassifyCreate(accepted); + if (disposition is DormantCreateDisposition.StaleGeneration + or DormantCreateDisposition.NoDormantRecord) + { + return false; + } + + return _spawns.Remove(accepted.Guid); + } + + public bool RemoveExact(DeleteObject.Parsed delete) + { + if (!_spawns.TryGetValue(delete.Guid, out WorldSession.EntitySpawn retained) + || retained.InstanceSequence != delete.InstanceSequence) + { + return false; + } + + return _spawns.Remove(delete.Guid); + } + + public WorldSession.EntitySpawn[] SnapshotLandblock(uint landblockId) + { + uint canonical = CanonicalLandblock(landblockId); + return _spawns.Values + .Where(spawn => spawn.Position is { } position + && CanonicalLandblock(position.LandblockId) == canonical + && spawn.SetupTableId is not null) + .ToArray(); + } + + public bool Contains(uint guid, ushort generation) => + _spawns.TryGetValue(guid, out WorldSession.EntitySpawn spawn) + && spawn.InstanceSequence == generation; + + public bool TryGetExact( + uint guid, + ushort generation, + out WorldSession.EntitySpawn snapshot) + { + if (_spawns.TryGetValue(guid, out snapshot) + && snapshot.InstanceSequence == generation) + { + return true; + } + + snapshot = default; + return false; + } + + public void Clear() => _spawns.Clear(); + + private static uint CanonicalLandblock(uint cellId) => + (cellId & 0xFFFF0000u) | 0xFFFFu; +} diff --git a/src/AcDream.App/World/LiveEntityDeletionController.cs b/src/AcDream.App/World/LiveEntityDeletionController.cs index b0436883..4098501b 100644 --- a/src/AcDream.App/World/LiveEntityDeletionController.cs +++ b/src/AcDream.App/World/LiveEntityDeletionController.cs @@ -20,6 +20,7 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink private readonly ClientObjectTable _objects; private readonly ILiveEntityTeardownCoordinator _teardown; private readonly ILocalPlayerIdentitySource _identity; + private readonly DormantLiveEntityStore _dormant; private readonly Action? _diagnostic; public LiveEntityDeletionController( @@ -27,12 +28,14 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink ClientObjectTable objects, ILiveEntityTeardownCoordinator teardown, ILocalPlayerIdentitySource identity, + DormantLiveEntityStore? dormant = null, Action? diagnostic = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _dormant = dormant ?? new DormantLiveEntityStore(); _diagnostic = diagnostic; } @@ -46,7 +49,8 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink if (delete.Guid == _identity.ServerGuid) return false; - if (!_runtime.TryGetRecord(delete.Guid, out _)) + bool hasActiveRecord = _runtime.TryGetRecord(delete.Guid, out _); + if (!hasActiveRecord) _teardown.ForgetUnknownOwner(delete.Guid); bool removed = _runtime.UnregisterLiveEntity( @@ -54,16 +58,45 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink isLocalPlayer: false, beforeTeardown: () => ObjectTableWiring.ApplyEntityDelete(_objects, delete)); + bool removedDormant = _dormant.RemoveExact(delete); + if (!removed && removedDormant) + ObjectTableWiring.ApplyEntityDelete(_objects, delete); if (removed) { + _dormant.RemoveExact(delete); _diagnostic?.Invoke( $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); } - return removed; + else if (removedDormant) + { + _diagnostic?.Invoke( + $"live: delete dormant guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); + } + return removed || removedDormant; } - public bool Prune(LiveEntityPruneCandidate candidate) => - Delete(new DeleteObject.Parsed( - candidate.ServerGuid, - candidate.Generation)); + public bool Prune(LiveEntityPruneCandidate candidate) + { + if (!_runtime.TryGetRecord( + candidate.ServerGuid, + out LiveEntityRecord record) + || record.Generation != candidate.Generation) + { + return false; + } + + WorldSession.EntitySpawn snapshot = record.Snapshot; + bool removed = _runtime.UnregisterLiveEntity( + new DeleteObject.Parsed( + candidate.ServerGuid, + candidate.Generation), + isLocalPlayer: false); + if (!removed) + return false; + + _dormant.Retain(snapshot); + _diagnostic?.Invoke( + $"live: dormant guid=0x{candidate.ServerGuid:X8} instSeq={candidate.Generation}"); + return true; + } } diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 32fbd9e6..5b380456 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -178,6 +178,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded private readonly IAcceptedLocalPhysicsTimestampPublisher _timestamps; private readonly ILocalPlayerIdentitySource _identity; private readonly LiveEntityDeletionController _deletion; + private readonly DormantLiveEntityStore _dormant; private readonly Action? _diagnostic; public LiveEntityHydrationController( @@ -192,6 +193,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded IAcceptedLocalPhysicsTimestampPublisher timestamps, ILocalPlayerIdentitySource identity, LiveEntityDeletionController deletion, + DormantLiveEntityStore? dormant = null, Action? diagnostic = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); @@ -205,12 +207,41 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded _timestamps = timestamps ?? throw new ArgumentNullException(nameof(timestamps)); _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); + _dormant = dormant ?? new DormantLiveEntityStore(); _diagnostic = diagnostic; } internal event Action? AppearanceApplied; public void OnCreate(WorldSession.EntitySpawn spawn) + { + DormantCreateDisposition dormantDisposition = + _dormant.ClassifyCreate(spawn); + if (dormantDisposition is DormantCreateDisposition.StaleGeneration) + return; + + // A same-incarnation packet must advance from the exact accepted + // dormant timestamps, not seed a fresh gate from whichever packet + // happened to wake the object. Rehydrate the cold canonical snapshot, + // then let the ordinary same-generation path compare every channel. + if (dormantDisposition is DormantCreateDisposition.ExistingGeneration + && _dormant.TryGetExact( + spawn.Guid, + spawn.InstanceSequence, + out WorldSession.EntitySpawn retained)) + { + OnCreateCore(retained, dormantDisposition); + if (!spawn.Equals(retained)) + OnCreateCore(spawn, DormantCreateDisposition.NoDormantRecord); + return; + } + + OnCreateCore(spawn, dormantDisposition); + } + + private void OnCreateCore( + WorldSession.EntitySpawn spawn, + DormantCreateDisposition dormantDisposition) { // DatCollection uses one mutable reader cursor shared with streaming. // Registration, canonical reread, and projection construction remain @@ -228,9 +259,14 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded // An accepted retransmit can still be parked behind a retryable // teardown tombstone. It owns no active record and therefore must - // not mutate retained qualities or route an update tail. + // not mutate retained qualities or route an update tail. Keep its + // dormant snapshot until an active record actually accepts + // ownership; otherwise a transient teardown failure would discard + // the only ACE revisit source. if (registration.Record is not { } record) return; + + _dormant.RemoveThroughAcceptedCreate(spawn); ulong createIntegrationVersion = record.CreateIntegrationVersion; try @@ -243,7 +279,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded _objects, spawn, replaceGeneration: result.Disposition is - AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration, + AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration + || dormantDisposition is DormantCreateDisposition.NewGeneration, accepting: () => _runtime.IsCurrentCreateIntegration( record, createIntegrationVersion)) @@ -420,6 +457,11 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded /// public void OnLandblockLoaded(uint loadedLandblockId) { + WorldSession.EntitySpawn[] dormant = + _dormant.SnapshotLandblock(loadedLandblockId); + for (int i = 0; i < dormant.Length; i++) + OnCreate(dormant[i]); + if (_runtime.Count == 0) return; diff --git a/src/AcDream.App/World/LiveEntityLivenessController.cs b/src/AcDream.App/World/LiveEntityLivenessController.cs index 1645ce29..79bf4240 100644 --- a/src/AcDream.App/World/LiveEntityLivenessController.cs +++ b/src/AcDream.App/World/LiveEntityLivenessController.cs @@ -89,6 +89,7 @@ internal sealed class LiveEntityLivenessController private readonly LiveEntityRuntime _runtime; private readonly ILocalPlayerIdentitySource _identity; private readonly ILiveEntityPruneSink _prune; + private readonly DormantLiveEntityStore? _dormant; private readonly LiveEntityLivenessTracker _tracker = new(); private readonly List _samples = new(); private double _nextMaintenanceAt; @@ -96,11 +97,13 @@ internal sealed class LiveEntityLivenessController public LiveEntityLivenessController( LiveEntityRuntime runtime, ILocalPlayerIdentitySource identity, - ILiveEntityPruneSink prune) + ILiveEntityPruneSink prune, + DormantLiveEntityStore? dormant = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _prune = prune ?? throw new ArgumentNullException(nameof(prune)); + _dormant = dormant; } public void Tick(double now) @@ -153,6 +156,7 @@ internal sealed class LiveEntityLivenessController public void Clear() { _tracker.Clear(); + _dormant?.Clear(); _samples.Clear(); _nextMaintenanceAt = 0; } diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index a62d5579..95109d79 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -211,7 +211,8 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif /// The one logical record for a server object incarnation. The record survives /// loaded/pending landblock movement and temporary loss of its render bucket. /// Only an accepted DeleteObject, session reset, newer INSTANCE_TS, or the -/// retail 25-second leave-visibility lifecycle ends it. +/// active side of the leave-visibility lifecycle ends it. ACE-compatible +/// cold snapshot ownership lives outside this active runtime. /// public sealed class LiveEntityRecord { diff --git a/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs b/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs index b963fa55..bf134599 100644 --- a/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs @@ -264,6 +264,29 @@ public sealed class CompositeTextureArrayCachePolicyTests Assert.Equal(2, backend.Uploads.Count); } + [Fact] + public void DestinationReveal_RaisesOnlyItemCountAndRestoresNormalProfile() + { + var backend = new FakeBackend(maximumLayers: 64); + using var cache = new CompositeTextureArrayCache( + backend, + new DeferredRetirementQueue(), + unownedBudgetBytes: long.MaxValue, + physicalBudgetBytes: long.MaxValue, + maximumUploadsPerFrame: 2, + maximumUploadBytesPerFrame: long.MaxValue); + + cache.BeginFrame(destinationRevealUploadPriority: true); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(1, 1), out _)); + Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(1, 1), out _)); + Assert.True(cache.TryAddAndAcquire(1, Key(3), Texture(1, 1), out _)); + + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(4), Texture(1, 1), out _)); + Assert.True(cache.TryAddAndAcquire(1, Key(5), Texture(1, 1), out _)); + Assert.False(cache.TryAddAndAcquire(1, Key(6), Texture(1, 1), out _)); + } + [Fact] public void OversizedTextureIsAllowedAsOnlyUploadToGuaranteeProgress() { diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs index 80ca7706..7228a136 100644 --- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs @@ -59,37 +59,100 @@ public sealed class PaperdollFramePresenterTests presenter.Render(); Assert.Equal(2, factory.BuildCount); - Assert.Equal(2, renderer.Dolls.Count); + Assert.Single(renderer.Dolls); Assert.Equal(3, renderer.RenderCount); Assert.False(presenter.IsDirty); } - private static WorldEntity CreateDoll() => new() + [Fact] + public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner() + { + WorldEntity first = CreateDoll(); + WorldEntity repeated = CreateDoll(); + var renderer = new RecordingRenderer(); + var view = new RecordingView(); + var factory = new RecordingFactory { Doll = first }; + var presenter = new PaperdollFramePresenter(renderer, view, factory); + + presenter.Render(); + factory.Doll = repeated; + presenter.MarkDirty(); + presenter.Render(); + + Assert.Equal(2, factory.BuildCount); + Assert.Equal([first], renderer.Dolls); + Assert.Equal(2, renderer.RenderCount); + Assert.False(presenter.IsDirty); + } + + [Fact] + public void ChangedScale_ReplacesPrivateDoll() + { + WorldEntity first = CreateDoll(); + WorldEntity changed = CreateDoll(scale: 1.25f); + var renderer = new RecordingRenderer(); + var view = new RecordingView(); + var factory = new RecordingFactory { Doll = first }; + var presenter = new PaperdollFramePresenter(renderer, view, factory); + + presenter.Render(); + factory.Doll = changed; + presenter.MarkDirty(); + presenter.Render(); + + Assert.Equal([first, changed], renderer.Dolls); + } + + private static WorldEntity CreateDoll(float scale = 1f) => new() { Id = 42u, SourceGfxObjOrSetupId = 0x02000001u, Position = Vector3.Zero, Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), + Scale = scale, }; [Fact] - public void MissingPlayer_ClearsOldDollPublishesEmptyTextureAndRetries() + public void TemporaryMissingPlayer_KeepsSuccessfulDollAndRetriesRedress() { - var renderer = new RecordingRenderer { TextureHandle = 0u }; + var firstDoll = CreateDoll(); + var renderer = new RecordingRenderer { TextureHandle = 81u }; var view = new RecordingView(); - var factory = new RecordingFactory { CanBuild = false }; + var factory = new RecordingFactory { Doll = firstDoll }; var presenter = new PaperdollFramePresenter(renderer, view, factory); + presenter.Render(); + factory.CanBuild = false; + presenter.MarkDirty(); presenter.Render(); presenter.Render(); Assert.True(presenter.IsDirty); + Assert.Equal(3, factory.BuildCount); + Assert.Equal([firstDoll], renderer.Dolls); + Assert.Equal(3, renderer.RenderCount); + Assert.Equal([81u, 81u, 81u], view.TextureHandles); + } + + [Fact] + public void ResetSession_ClearsPrivateDollOnceAndArmsNextCharacterBuild() + { + var firstDoll = CreateDoll(); + var secondDoll = CreateDoll(); + var renderer = new RecordingRenderer(); + var view = new RecordingView(); + var factory = new RecordingFactory { Doll = firstDoll }; + var presenter = new PaperdollFramePresenter(renderer, view, factory); + + presenter.Render(); + presenter.ResetSession(); + factory.Doll = secondDoll; + presenter.Render(); + + Assert.False(presenter.IsDirty); Assert.Equal(2, factory.BuildCount); - Assert.Equal(2, renderer.Dolls.Count); - Assert.All(renderer.Dolls, Assert.Null); - Assert.Equal(2, renderer.RenderCount); - Assert.Equal([0u, 0u], view.TextureHandles); + Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls); } [Fact] @@ -222,8 +285,8 @@ public sealed class PaperdollFramePresenterTests private sealed class RecordingFactory : IPaperdollDollFactory { - public bool CanBuild { get; init; } = true; - public WorldEntity? Doll { get; init; } + public bool CanBuild { get; set; } = true; + public WorldEntity? Doll { get; set; } public int BuildCount { get; private set; } public bool TryBuild(out WorldEntity? doll) diff --git a/tests/AcDream.App.Tests/Rendering/PortalMeshReferenceOwnerTests.cs b/tests/AcDream.App.Tests/Rendering/SyntheticEntityMeshReferenceOwnerTests.cs similarity index 86% rename from tests/AcDream.App.Tests/Rendering/PortalMeshReferenceOwnerTests.cs rename to tests/AcDream.App.Tests/Rendering/SyntheticEntityMeshReferenceOwnerTests.cs index 03db4f37..1c1091d8 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalMeshReferenceOwnerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/SyntheticEntityMeshReferenceOwnerTests.cs @@ -3,14 +3,31 @@ using AcDream.App.Rendering.Wb; namespace AcDream.App.Tests.Rendering; -public sealed class PortalMeshReferenceOwnerTests +public sealed class SyntheticEntityMeshReferenceOwnerTests { + [Fact] + public void PrivateOwnerSurvivesIndependentWorldPresentationRelease() + { + const ulong mesh = 0x01000001u; + var adapter = new RecordingMeshAdapter(); + adapter.IncrementRefCount(mesh); + var privateOwner = + new SyntheticEntityMeshReferenceOwner(adapter, [mesh]); + privateOwner.Acquire(); + + adapter.DecrementRefCount(mesh); + + Assert.Equal(1, adapter.ReferenceCount(mesh)); + privateOwner.Dispose(); + Assert.Equal(0, adapter.TotalReferences); + } + [Fact] public void PartialAcquireRollsBackEveryCommittedSibling() { var adapter = new RecordingMeshAdapter(); adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1); - var owner = new PortalMeshReferenceOwner( + var owner = new SyntheticEntityMeshReferenceOwner( adapter, [0x01000001u, 0x01000002u]); @@ -30,7 +47,7 @@ public sealed class PortalMeshReferenceOwnerTests { var adapter = new RecordingMeshAdapter(); adapter.FailIncrementAfterCommit(0x01000001u, attempts: 1); - var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); + var owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]); Assert.Throws(owner.Acquire); Assert.Equal(0, adapter.TotalReferences); @@ -48,7 +65,7 @@ public sealed class PortalMeshReferenceOwnerTests var adapter = new RecordingMeshAdapter(); adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1); adapter.FailDecrementBeforeCommit(0x01000002u, attempts: 1); - var owner = new PortalMeshReferenceOwner( + var owner = new SyntheticEntityMeshReferenceOwner( adapter, [0x01000001u, 0x01000002u]); @@ -69,7 +86,7 @@ public sealed class PortalMeshReferenceOwnerTests public void DisposeAttemptsEveryReferenceAndRetriesOnlyPendingRelease() { var adapter = new RecordingMeshAdapter(); - var owner = new PortalMeshReferenceOwner( + var owner = new SyntheticEntityMeshReferenceOwner( adapter, [0x01000001u, 0x01000002u]); owner.Acquire(); @@ -92,7 +109,7 @@ public sealed class PortalMeshReferenceOwnerTests public void CommittedReleaseFailureCompletesOwnershipWithoutDoubleDecrement() { var adapter = new RecordingMeshAdapter(); - var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); + var owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]); owner.Acquire(); adapter.FailDecrementAfterCommit(0x01000001u, attempts: 1); @@ -107,12 +124,12 @@ public sealed class PortalMeshReferenceOwnerTests [Fact] public void DisposeReentryDuringAcquireReconcilesBeforeReturning() { - PortalMeshReferenceOwner? owner = null; + SyntheticEntityMeshReferenceOwner? owner = null; var adapter = new RecordingMeshAdapter { AfterIncrement = _ => owner!.Dispose(), }; - owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); + owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]); Assert.Throws(owner.Acquire); owner.Dispose(); diff --git a/tests/AcDream.App.Tests/Rendering/WorldRevealRenderResourceSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRevealRenderResourceSchedulerTests.cs new file mode 100644 index 00000000..9436053f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/WorldRevealRenderResourceSchedulerTests.cs @@ -0,0 +1,25 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class WorldRevealRenderResourceSchedulerTests +{ + [Fact] + public void ReplacementGeneration_KeepsPriorityUntilExactGenerationEnds() + { + var first = new List(); + var second = new List(); + var scheduler = new WorldRevealRenderResourceScheduler( + first.Add, + second.Add); + + scheduler.BeginDestinationReveal(3); + scheduler.BeginDestinationReveal(4); + scheduler.EndDestinationReveal(3); + scheduler.EndDestinationReveal(4); + scheduler.EndDestinationReveal(4); + + Assert.Equal([true, true, false], first); + Assert.Equal([true, true, false], second); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs index e804f4c8..abb30753 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs @@ -254,6 +254,119 @@ public sealed class GpuWorldStateVisibilityTests Assert.Empty(reloaded.Entities); } + [Fact] + public void OriginRecenterSpatialSwap_RetainsLiveIdentityAndReturnsExactReceipts() + { + const uint firstLandblock = 0x1010FFFFu; + const uint secondLandblock = 0x1011FFFFu; + const uint pendingLandblock = 0x2020FFFFu; + const uint playerGuid = 0x50000001u; + var firstStatic = Entity(100u, 0u); + var secondStatic = Entity(101u, 0u); + var remote = Entity(1u, 0x70000001u); + var pending = Entity(2u, 0x70000002u); + var player = Entity(3u, playerGuid); + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + firstLandblock, + new LandBlock(), + [firstStatic])); + state.AddLandblock(new LoadedLandblock( + secondLandblock, + new LandBlock(), + [secondStatic])); + state.PlaceLiveEntityProjection(firstLandblock, remote); + state.MarkPersistent(playerGuid); + state.PlaceLiveEntityProjection(secondLandblock, player); + state.PlaceLiveEntityProjection(pendingLandblock, pending); + var edges = new List<(uint Guid, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (guid, visible) => + edges.Add((guid, visible)); + + GpuWorldRecenterRetirement result = + state.DetachAllForOriginRecenter(); + + Assert.Null(result.ObserverFailure); + Assert.Equal( + [firstLandblock, secondLandblock, pendingLandblock], + result.Landblocks.Select(retirement => retirement.LandblockId)); + Assert.Contains( + result.Landblocks.Single( + retirement => retirement.LandblockId == firstLandblock).Entities, + entity => ReferenceEquals(entity, firstStatic)); + Assert.Contains( + result.Landblocks.Single( + retirement => retirement.LandblockId == firstLandblock).Entities, + entity => ReferenceEquals(entity, remote)); + Assert.Contains( + result.Landblocks.Single( + retirement => retirement.LandblockId == secondLandblock).Entities, + entity => ReferenceEquals(entity, secondStatic)); + Assert.Contains( + result.Landblocks.Single( + retirement => retirement.LandblockId == secondLandblock).Entities, + entity => ReferenceEquals(entity, player)); + Assert.Same( + pending, + Assert.Single(result.Landblocks.Single( + retirement => retirement.LandblockId == pendingLandblock).Entities)); + + Assert.Empty(state.LoadedLandblockIds); + Assert.Empty(state.Entities); + Assert.Equal(2, state.PendingLiveEntityCount); + Assert.Same(player, Assert.Single(state.DrainRescued())); + Assert.Equal( + [(remote.ServerGuid, false), (player.ServerGuid, false)], + edges); + + state.AddLandblock(new LoadedLandblock( + firstLandblock, + new LandBlock(), + Array.Empty())); + Assert.Same(remote, Assert.Single(state.Entities)); + state.AddLandblock(new LoadedLandblock( + pendingLandblock, + new LandBlock(), + Array.Empty())); + Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pending)); + } + + [Fact] + public void OriginRecenterSpatialSwap_PreservesCurrentLiveBucketOrder() + { + const uint landblock = 0x3030FFFFu; + var staticEntity = Entity(100u, 0u); + var removed = Entity(1u, 0x70000001u); + var middle = Entity(2u, 0x70000002u); + var movedTail = Entity(3u, 0x70000003u); + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + [staticEntity])); + state.PlaceLiveEntityProjection(landblock, removed); + state.PlaceLiveEntityProjection(landblock, middle); + state.PlaceLiveEntityProjection(landblock, movedTail); + + // Removing the first live projection swap-moves the tail into its + // bucket slot. Dictionary insertion order is now intentionally + // different from the canonical bucket order. + state.RemoveLiveEntityProjection(removed); + Assert.Equal( + [staticEntity, movedTail, middle], + state.Entities); + + state.DetachAllForOriginRecenter(); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + [staticEntity])); + + Assert.Equal( + [staticEntity, movedTail, middle], + state.Entities); + } + [Fact] public void BatchedVisibilityObserver_SeesCommittedFlatAndResidentViews() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs index dc9da3ed..476bf5d2 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -330,7 +330,7 @@ public sealed class LandblockConcretePresentationPipelineTests } [Fact] - public void FullRetirement_DetachesFirstAndBalancesEveryConcreteOwner() + public void FullRetirement_DetachesAndCleansStaticsWithoutMutatingLiveOwners() { var calls = new List(); ConcreteFixture fixture = Fixture(calls); @@ -360,12 +360,13 @@ public sealed class LandblockConcretePresentationPipelineTests Assert.Equal(0, pipeline.PendingRetirementCount); Assert.Equal(0, fixture.Runner.ActiveOwnerCount); Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _)); - Assert.Equal(0, fixture.Lights.RegisteredCount); + Assert.Single(fixture.Lighting.GetOwnedLights(liveEntity.Id)!); + Assert.Null(fixture.Lighting.GetOwnedLights(staticEntity.Id)); Assert.False(fixture.Translucency.TryGetCurrentValue( staticEntity.Id, 0u, out _)); - Assert.False(fixture.Translucency.TryGetCurrentValue( + Assert.True(fixture.Translucency.TryGetCurrentValue( liveEntity.Id, 0u, out _)); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index c6be6df5..7303b3ef 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -101,7 +101,7 @@ public sealed class LandblockPresentationPipelineTests } [Fact] - public void OriginRecenter_BlocksOriginAndBootstrapUntilEveryOldOwnerConverges() + public void OriginRecenter_CommitsAfterDetachWhileOldOwnerCleanupContinues() { const uint centerId = 0x1919FFFFu; const uint neighborId = 0x191AFFFFu; @@ -154,11 +154,63 @@ public sealed class LandblockPresentationPipelineTests Assert.True(Converge(recenter, controller, 0x19, 0x19)); Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY)); - Assert.Equal(0, controller.PendingRetirementCount); + Assert.Equal(1, controller.PendingRetirementCount); controller.Tick(0x22, 0x23); + Assert.Equal(0, controller.PendingRetirementCount); Assert.Equal([0x2223FFFFu], enqueued); } + [Fact] + public void OriginRecenter_DetachesFullTwentyFiveByTwentyFiveWindowAtomically() + { + const int radius = 12; + const int side = radius * 2 + 1; + const int residentCount = side * side; + var state = new GpuWorldState(); + for (int x = 0x40 - radius; x <= 0x40 + radius; x++) + for (int y = 0x40 - radius; y <= 0x40 + radius; y++) + { + uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; + state.AddLandblock(new LoadedLandblock( + id, + new LandBlock(), + Array.Empty())); + } + + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x40, 0x40)); + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + workBudgetOptions: new StreamingWorkBudgetOptions( + MaxUpdateMilliseconds: 100, + MaxCompletionAdmissions: 1, + MaxAdoptedCpuBytes: 1, + MaxEntityOperations: 1, + MaxGpuUploadBytes: 1, + MaxGlRetireOperations: 1, + DestinationReserveFraction: 0.5f)); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x50, 0x50, isSealedDungeon: false)); + int frames = 0; + while (!recenter.Advance() && frames < 8) + { + controller.Tick(0x40, 0x40); + frames++; + } + + Assert.InRange(frames, 1, 4); + Assert.Empty(state.LoadedLandblockIds); + Assert.Equal(residentCount, controller.LastFullWindowRetirementLandblockCount); + Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY)); + } + [Fact] public void OriginRecenter_PreparationFailureBeforeDetachRetainsOldOriginAndResidents() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs index 4043a423..5904dddb 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs @@ -463,6 +463,146 @@ public sealed class LandblockRetirementCoordinatorTests Assert.Equal(0, coordinator.PendingCount); } + [Fact] + public void OriginRecenterAdoption_DuplicateReceiptFailsBeforeLedgerMutation() + { + const uint landblockId = 0x4647FFFFu; + var state = new GpuWorldState(); + int detachedCallbacks = 0; + LandblockRetirementCoordinator coordinator = + LandblockRetirementCoordinator.CreateBudgeted( + state, + ticket => AdvancePresentationStep(ticket), + ticket => CompletePresentation(ticket), + _ => detachedCallbacks++); + GpuLandblockRetirement[] receipts = + [ + new( + landblockId, + LandblockRetirementKind.Full, + Array.Empty()), + new( + landblockId & 0xFFFF0000u, + LandblockRetirementKind.Full, + Array.Empty()), + ]; + + Assert.Throws(() => + coordinator.AdoptDetachedFull(receipts)); + + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + Assert.Equal(0, detachedCallbacks); + } + + [Fact] + public void OriginRecenterAdoption_ObserverFailureRetainsEveryCleanupReceipt() + { + uint[] landblockIds = [0x4648FFFFu, 0x4649FFFFu]; + var state = new GpuWorldState(); + int detachedCallbacks = 0; + LandblockRetirementCoordinator coordinator = + LandblockRetirementCoordinator.CreateBudgeted( + state, + ticket => AdvancePresentationStep(ticket), + ticket => CompletePresentation(ticket), + retirement => + { + detachedCallbacks++; + if (retirement.LandblockId == landblockIds[0]) + { + throw new InvalidOperationException( + "injected detached observer failure"); + } + }); + GpuLandblockRetirement[] receipts = landblockIds + .Select(id => new GpuLandblockRetirement( + id, + LandblockRetirementKind.Full, + Array.Empty())) + .ToArray(); + + Exception? failure = coordinator.AdoptDetachedFull(receipts); + + Assert.IsType(failure); + Assert.Equal(2, detachedCallbacks); + Assert.Equal(2, coordinator.PendingCount); + Assert.All(landblockIds, id => Assert.True(coordinator.IsPending(id))); + + int frames = 0; + while (coordinator.PendingCount != 0 && frames++ < 64) + { + var meter = new StreamingWorkMeter(Budget(maxEntityOperations: 64)); + coordinator.Advance(meter); + meter.FinishFrame(); + } + + Assert.Equal(0, coordinator.PendingCount); + Assert.All(landblockIds, id => Assert.False(coordinator.IsPending(id))); + } + + [Fact] + public void DestinationDependency_CompletesOutOfOrder_WithoutReorderingCleanupFifo() + { + uint[] landblockIds = + [ + 0x5151FFFFu, + 0x5252FFFFu, + 0x5353FFFFu, + ]; + uint destinationId = landblockIds[2]; + var terrainOrder = new List(); + var state = new GpuWorldState(); + LandblockRetirementCoordinator coordinator = + LandblockRetirementCoordinator.CreateBudgeted( + state, + ticket => + { + if (ticket.NextIncompleteStage + == LandblockRetirementStage.Terrain) + { + return ticket.RunOnceStep( + LandblockRetirementStage.Terrain, + () => terrainOrder.Add(ticket.LandblockId)); + } + + return AdvancePresentationStep(ticket); + }, + ticket => CompletePresentation( + ticket, + terrain: () => terrainOrder.Add(ticket.LandblockId))); + GpuLandblockRetirement[] receipts = landblockIds + .Select(id => new GpuLandblockRetirement( + id, + LandblockRetirementKind.Full, + Array.Empty())) + .ToArray(); + Assert.Null(coordinator.AdoptDetachedFull(receipts)); + + var destinationMeter = new StreamingWorkMeter( + Budget(maxEntityOperations: 64), + destinationReservationActive: true); + using (destinationMeter.EnterLane(StreamingWorkLane.Destination)) + coordinator.AdvancePriority(destinationId, destinationMeter); + destinationMeter.FinishFrame(); + + Assert.False(coordinator.IsPending(destinationId)); + Assert.True(coordinator.IsPending(landblockIds[0])); + Assert.True(coordinator.IsPending(landblockIds[1])); + Assert.Equal(2, coordinator.PendingCount); + Assert.Equal([destinationId], terrainOrder); + + var cleanupMeter = new StreamingWorkMeter( + Budget(maxEntityOperations: 64)); + coordinator.Advance(cleanupMeter); + cleanupMeter.FinishFrame(); + + Assert.Equal(0, coordinator.PendingCount); + Assert.Equal( + [destinationId, landblockIds[0], landblockIds[1]], + terrainOrder); + } + [Fact] public void ObserverFailure_DrainsReentrantDifferentIdBeginBeforeRethrow() { diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index 0caa5586..e502d0ff 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -255,6 +255,26 @@ public sealed class LocalPlayerTeleportControllerTests Assert.False(harness.Reveal.Snapshot.Cancelled); } + [Fact] + public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive() + { + var order = new List(); + var harness = new Harness(order: order); + harness.Controller.OnTeleportStarted(91); + harness.Controller.OfferDestination( + Position(0x20210001u, 91, 4f, 5f, 6f), + teleportTimestampAdvanced: true); + harness.Streaming.ReservationEnds.Clear(); + harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound); + + harness.Controller.Tick(0.016f); + + Assert.False(harness.Presentation.IsPortalViewportVisible); + Assert.True(harness.Controller.IsActive); + Assert.False(harness.Reveal.Snapshot.Completed); + Assert.Single(harness.Streaming.ReservationEnds); + } + [Fact] public void NewerStart_ReplacesOldDestinationWithoutReusingIt() { diff --git a/tests/AcDream.App.Tests/Streaming/StreamingCompletionQueueTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingCompletionQueueTests.cs index 4def42a4..2ffb27c7 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingCompletionQueueTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingCompletionQueueTests.cs @@ -110,6 +110,39 @@ public sealed class StreamingCompletionQueueTests Assert.Equal(0, snapshot.OldestAgeMilliseconds); } + [Fact] + public void PriorityCeilingLeavesNearAndFarWorkRetained() + { + var queue = new StreamingCompletionQueue(); + StreamingQueuedCompletion near = Completion( + new LandblockStreamResult.Failed(20, "near"), + StreamingCompletionPriority.Near, + sequence: 1); + StreamingQueuedCompletion unload = Completion( + new LandblockStreamResult.Unloaded(21), + StreamingCompletionPriority.Unload, + sequence: 2); + queue.Enqueue(near); + queue.Enqueue(unload); + + Assert.True(queue.TryPeekNext( + static _ => false, + out StreamingQueuedCompletion? selected, + StreamingCompletionPriority.Unload)); + Assert.Equal(unload.Sequence, selected?.Sequence); + queue.RemoveHead(unload); + + Assert.False(queue.TryPeekNext( + static _ => false, + out selected, + StreamingCompletionPriority.Unload)); + Assert.Equal(1, queue.Count); + Assert.True(queue.TryPeekNext( + static _ => false, + out selected)); + Assert.Equal(near.Sequence, selected?.Sequence); + } + [Fact] public void ExactReferenceRemovalDoesNotConflateEqualRecords() { diff --git a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs index 5241ea96..6ef578a4 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs @@ -11,6 +11,17 @@ namespace AcDream.App.Tests.Streaming; public sealed class StreamingWorkBudgetTests { + [Fact] + public void DefaultProfile_UsesElapsedTimeAsTheMicroOperationGuard() + { + Assert.Equal( + 4_096, + StreamingWorkBudgetOptions.Default.MaxEntityOperations); + Assert.Equal( + 2.0, + StreamingWorkBudgetOptions.Default.MaxUpdateMilliseconds); + } + private static StreamingWorkBudget Budget( double milliseconds = 2, int completions = 4, @@ -563,15 +574,17 @@ public sealed class StreamingWorkBudgetTests Assert.Equal([center], applied); Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions); - Assert.Equal(2, controller.WorkDiagnostics.LastFrame.YieldCount); + Assert.Equal(1, controller.WorkDiagnostics.LastFrame.YieldCount); Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.GpuUploadBytes); Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); controller.Tick(32, 32); - Assert.Equal([center, ordinary], applied); + Assert.Equal([center], applied); + Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions); Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); + controller.EndDestinationReservation(1); for (int i = 0; i < 4 && (controller.WorkDiagnostics.DeferredCompletions != 0 @@ -586,13 +599,51 @@ public sealed class StreamingWorkBudgetTests } [Fact] - public void StaleGenerationResultsConsumeAdmissionButRetainNoPayload() + public void IncompleteReveal_DoesNotStarveOrdinaryWorkBeforeDestinationArrives() { uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32); - var source = new QueueCompletionSource( - Loaded(center, generation: 1), - Loaded(StreamingRegion.EncodeLandblockIdForTest(32, 33), generation: 1), - Loaded(center)); + uint ordinary = StreamingRegion.EncodeLandblockIdForTest(33, 33); + var source = new QueueCompletionSource(Loaded(ordinary)); + var applied = new List(); + StreamingController controller = Controller( + source, + id => applied.Add(id), + WorkOptions(admissions: 4, cpuBytes: 1_000)); + controller.BeginDestinationReservation(1, center, 0); + + controller.Tick(32, 32); + + Assert.Equal([ordinary], applied); + Assert.Equal(0, controller.WorkDiagnostics.NearBacklog); + + source.Enqueue(Loaded(center)); + controller.Tick(32, 32); + + Assert.Equal([ordinary, center], applied); + Assert.Equal(0, controller.WorkDiagnostics.NearBacklog); + + controller.EndDestinationReservation(1); + controller.Tick(32, 32); + + Assert.Equal([ordinary, center], applied); + Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions); + } + + [Fact] + public void StaleGenerationResultsDrainWithoutStarvingCurrentGeneration() + { + uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32); + LandblockStreamResult[] results = + [ + .. Enumerable.Range(0, 625) + .Select(index => Loaded( + StreamingRegion.EncodeLandblockIdForTest( + 32 + index % 25, + 32 + index / 25), + generation: 1)), + Loaded(center), + ]; + var source = new QueueCompletionSource(results); int applied = 0; StreamingController controller = Controller( source, @@ -601,15 +652,12 @@ public sealed class StreamingWorkBudgetTests controller.Tick(32, 32); - Assert.Equal(0, applied); - Assert.Equal(1, source.BacklogCount); + Assert.Equal(1, applied); + Assert.Equal(0, source.BacklogCount); Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions); Assert.Equal(0, controller.WorkDiagnostics.DeferredAdoptedCpuBytes); - Assert.Equal(2, controller.WorkDiagnostics.LastFrame.Used.CompletionAdmissions); - Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.AdoptedCpuBytes); - - controller.Tick(32, 32); - Assert.Equal(1, applied); + Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.CompletionAdmissions); + Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.AdoptedCpuBytes); } private static StreamingController Controller( @@ -688,6 +736,9 @@ public sealed class StreamingWorkBudgetTests public int BacklogCount => _results.Count; + public void Enqueue(LandblockStreamResult result) => + _results.Enqueue(result); + public bool TryPeek(out LandblockStreamResult? result) { if (_results.TryPeek(out LandblockStreamResult? peeked)) diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs index 0c719067..6b18fc41 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs @@ -15,7 +15,8 @@ public sealed class WorldRevealCoordinatorTests public WorldRevealCoordinator Build( List? logs = null, - IWorldRevealStreamingScheduler? streaming = null) => new( + IWorldRevealStreamingScheduler? streaming = null, + IWorldRevealRenderResourceScheduler? renderResources = null) => new( isRenderNeighborhoodReady: (_, _) => RenderReady, isSpawnCellReady: _ => CollisionReady, isTerrainNeighborhoodReady: (_, _) => CollisionReady, @@ -28,7 +29,8 @@ public sealed class WorldRevealCoordinatorTests }, isSpawnClaimUnhydratable: _ => false, log: logs is null ? null : new Action(logs.Add), - streaming: streaming); + streaming: streaming, + renderResources: renderResources); } [Fact] @@ -152,6 +154,47 @@ public sealed class WorldRevealCoordinatorTests Assert.Equal(1, audio.ResumeCalls); } + [Fact] + public void PortalViewportReveal_ReopensWorldWithoutCompletingProtocolTail() + { + var availability = new WorldGenerationAvailabilityState(); + var world = new GpuWorldState(availability: availability); + var audio = new RecordingAudioQuiescence(); + var quiescence = new WorldGenerationQuiescence( + availability, + new SelectionState(), + world, + audio); + var scheduler = new RecordingDestinationScheduler(); + var coordinator = new WorldRevealCoordinator( + isRenderNeighborhoodReady: (_, _) => true, + isSpawnCellReady: _ => true, + isTerrainNeighborhoodReady: (_, _) => true, + areCompositeTexturesReady: () => true, + prepareCompositeTextures: (_, _) => { }, + invalidateCompositeTextures: () => { }, + isSpawnClaimUnhydratable: _ => false, + quiescence: quiescence, + streaming: scheduler); + long generation = coordinator.Begin( + WorldRevealKind.Portal, + 0x3032001Cu); + + coordinator.RevealWorldViewport(); + coordinator.RevealWorldViewport(); + + Assert.True(availability.IsWorldAvailable); + Assert.False(coordinator.Snapshot.Completed); + Assert.Equal(1, audio.ResumeCalls); + Assert.Equal([generation], scheduler.Ends); + + coordinator.Complete(); + + Assert.True(coordinator.Snapshot.Completed); + Assert.Equal(1, audio.ResumeCalls); + Assert.Equal([generation], scheduler.Ends); + } + [Fact] public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion() { @@ -176,6 +219,28 @@ public sealed class WorldRevealCoordinatorTests Assert.Equal([second], scheduler.Ends); } + [Fact] + public void RevealGeneration_OwnsRenderUploadProfileUntilViewportRelease() + { + var state = new State(); + var resources = new RecordingRenderResourceScheduler(); + WorldRevealCoordinator coordinator = + state.Build(renderResources: resources); + + long first = coordinator.Begin( + WorldRevealKind.Portal, + 0x11340021u); + long second = coordinator.Begin( + WorldRevealKind.Portal, + 0x20210123u); + + Assert.Equal([first, second], resources.Begins); + coordinator.RevealWorldViewport(); + coordinator.Complete(); + + Assert.Equal([second], resources.Ends); + } + private sealed class RecordingAudioQuiescence : AcDream.App.Audio.IWorldAudioQuiescence { public int SuspendCalls { get; private set; } @@ -201,4 +266,17 @@ public sealed class WorldRevealCoordinatorTests public void EndDestinationReservation(long revealGeneration) => Ends.Add(revealGeneration); } + + private sealed class RecordingRenderResourceScheduler + : IWorldRevealRenderResourceScheduler + { + public List Begins { get; } = []; + public List Ends { get; } = []; + + public void BeginDestinationReveal(long revealGeneration) => + Begins.Add(revealGeneration); + + public void EndDestinationReveal(long revealGeneration) => + Ends.Add(revealGeneration); + } } diff --git a/tests/AcDream.App.Tests/World/DormantLiveEntityStoreTests.cs b/tests/AcDream.App.Tests/World/DormantLiveEntityStoreTests.cs new file mode 100644 index 00000000..63a7a855 --- /dev/null +++ b/tests/AcDream.App.Tests/World/DormantLiveEntityStoreTests.cs @@ -0,0 +1,115 @@ +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests.World; + +public sealed class DormantLiveEntityStoreTests +{ + private const uint Guid = 0x7000_0042u; + + [Theory] + [InlineData(10, 10, (int)DormantCreateDisposition.ExistingGeneration)] + [InlineData(10, 11, (int)DormantCreateDisposition.NewGeneration)] + [InlineData(11, 10, (int)DormantCreateDisposition.StaleGeneration)] + [InlineData(0xFFFF, 0, (int)DormantCreateDisposition.NewGeneration)] + [InlineData(0, 0xFFFF, (int)DormantCreateDisposition.StaleGeneration)] + public void CreateClassification_UsesRetailWrapSafeInstanceOrdering( + ushort retained, + ushort incoming, + int expected) + { + var store = new DormantLiveEntityStore(); + store.Retain(Spawn(retained, 0x0101_0001u)); + + Assert.Equal((DormantCreateDisposition)expected, store.ClassifyCreate( + Spawn(incoming, 0x0101_0001u))); + } + + [Fact] + public void LandblockSnapshot_IsCanonicalAndDoesNotConsumeRecords() + { + var store = new DormantLiveEntityStore(); + WorldSession.EntitySpawn first = Spawn(1, 0x0101_0001u); + WorldSession.EntitySpawn second = Spawn(2, 0x0202_0123u) with + { + Guid = Guid + 1, + }; + store.Retain(first); + store.Retain(second); + + Assert.Equal(first, Assert.Single(store.SnapshotLandblock(0x0101_FFFFu))); + Assert.Equal(first, Assert.Single(store.SnapshotLandblock(0x0101_0123u))); + Assert.Equal(2, store.Count); + } + + [Fact] + public void ExactDelete_CannotRemoveReusedGuidGeneration() + { + var store = new DormantLiveEntityStore(); + store.Retain(Spawn(2, 0x0101_0001u)); + + Assert.False(store.RemoveExact( + new DeleteObject.Parsed(Guid, InstanceSequence: 1))); + Assert.True(store.Contains(Guid, generation: 2)); + Assert.True(store.RemoveExact( + new DeleteObject.Parsed(Guid, InstanceSequence: 2))); + Assert.Equal(0, store.Count); + } + + [Fact] + public void Retain_CannotReplaceNewerGenerationWithStaleSnapshot() + { + var store = new DormantLiveEntityStore(); + WorldSession.EntitySpawn newer = Spawn(2, 0x0101_0001u) with + { + Name = "newer", + }; + store.Retain(newer); + + store.Retain(Spawn(1, 0x0202_0001u) with + { + Name = "stale", + }); + + Assert.True(store.TryGetExact(Guid, generation: 2, out var retained)); + Assert.Equal(newer, retained); + } + + [Fact] + public void Clear_DrainsSessionLifetime() + { + var store = new DormantLiveEntityStore(); + store.Retain(Spawn(1, 0x0101_0001u)); + + store.Clear(); + + Assert.Equal(0, store.Count); + } + + private static WorldSession.EntitySpawn Spawn( + ushort generation, + uint cell) => + new( + Guid, + new CreateObject.ServerPosition( + cell, + 10f, + 20f, + 5f, + 1f, + 0f, + 0f, + 0f), + SetupTableId: 0x0200_0001u, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: "fixture", + ItemType: 1u, + MotionState: null, + MotionTableId: null, + InstanceSequence: generation); +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index 30be7b3a..3574b6e7 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -636,19 +636,140 @@ public sealed class LiveEntityHydrationControllerTests } [Fact] - public void Prune_UsesTheSameExactDeleteTransaction() + public void Prune_ReleasesActiveOwnersButRetainsColdAuthoritativeSnapshot() { using var fixture = new Fixture(originKnown: true); fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + ClientObject retainedObject = fixture.Objects.Get(Guid)!; Assert.True(fixture.Controller.OnPrune( new LiveEntityPruneCandidate(Guid, Generation: 1))); Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); - Assert.Null(fixture.Objects.Get(Guid)); + Assert.Same(retainedObject, fixture.Objects.Get(Guid)); + Assert.True(fixture.Dormant.Contains(Guid, generation: 1)); Assert.Equal(1, fixture.Teardown.TearDownCount); } + [Fact] + public void LoadedLandblock_RestoresDormantSnapshotThroughCanonicalHydration() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + Assert.True(fixture.Controller.OnPrune( + new LiveEntityPruneCandidate(Guid, Generation: 1))); + + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord restored)); + Assert.Equal((ushort)1, restored.Generation); + Assert.NotNull(restored.WorldEntity); + Assert.False(fixture.Dormant.Contains(Guid, generation: 1)); + Assert.Equal(2, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Resources.UnregisterCount); + Assert.Equal(2, fixture.Ready.PublishCount); + } + + [Fact] + public void ExactDelete_RemovesDormantSnapshotAndRetainedObject() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + Assert.True(fixture.Controller.OnPrune( + new LiveEntityPruneCandidate(Guid, Generation: 1))); + + Assert.True(fixture.Controller.OnDelete( + new DeleteObject.Parsed(Guid, InstanceSequence: 1))); + + Assert.False(fixture.Dormant.Contains(Guid, generation: 1)); + Assert.Null(fixture.Objects.Get(Guid)); + fixture.Controller.OnLandblockLoaded(Cell); + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + } + + [Fact] + public void DormantNewerGeneration_RejectsStaleCreateAndAcceptsReplacement() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1)); + Assert.True(fixture.Controller.OnPrune( + new LiveEntityPruneCandidate(Guid, Generation: 2))); + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "stale")); + + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.True(fixture.Dormant.Contains(Guid, generation: 2)); + + fixture.Controller.OnCreate(Spawn( + Generation: 3, + PositionSequence: 1, + Name: "replacement")); + + Assert.Equal((ushort)3, fixture.Record.Generation); + Assert.Equal("replacement", fixture.Objects.Get(Guid)!.Name); + Assert.False(fixture.Dormant.Contains(Guid, generation: 2)); + } + + [Fact] + public void DormantSameGenerationCreate_UsesRetainedTimestampGate() + { + using var fixture = new Fixture(originKnown: true); + WorldSession.EntitySpawn accepted = Spawn( + Generation: 1, + PositionSequence: 5); + fixture.Controller.OnCreate(accepted); + Assert.True(fixture.Controller.OnPrune( + new LiveEntityPruneCandidate(Guid, Generation: 1))); + + CreateObject.ServerPosition stalePosition = + accepted.Position!.Value with + { + LandblockId = 0x02020001u, + PositionX = 99f, + }; + PhysicsSpawnData stalePhysics = accepted.Physics!.Value with + { + Position = stalePosition, + Timestamps = accepted.Physics.Value.Timestamps with + { + Position = 4, + }, + }; + fixture.Controller.OnCreate(accepted with + { + Position = stalePosition, + PositionSequence = 4, + Physics = stalePhysics, + }); + + Assert.Equal(accepted.Position, fixture.Record.Snapshot.Position); + Assert.Equal((ushort)5, fixture.Record.Snapshot.PositionSequence); + } + + [Fact] + public void DormantReplacementCreate_ReplacesAbsentOldQualities() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate( + Spawn(Generation: 1, PositionSequence: 1) with + { + Value = 123, + }); + Assert.True(fixture.Controller.OnPrune( + new LiveEntityPruneCandidate(Guid, Generation: 1))); + + fixture.Controller.OnCreate( + Spawn(Generation: 2, PositionSequence: 1) with + { + Value = null, + }); + + Assert.Equal(0, fixture.Objects.Get(Guid)!.Value); + } + [Fact] public void Delete_DrainsIndependentFailuresAndRetryDoesNotReplayObjectRemoval() { @@ -1690,6 +1811,7 @@ public sealed class LiveEntityHydrationControllerTests public readonly RecordingOrigin Origin; public readonly RecordingNetworkSink Network = new(); public readonly RecordingTeardownCoordinator Teardown = new(); + public readonly DormantLiveEntityStore Dormant = new(); public readonly RecordingTimestampPublisher Timestamps; public readonly LiveEntityHydrationController Controller; @@ -1741,7 +1863,8 @@ public sealed class LiveEntityHydrationControllerTests Runtime, Objects, Teardown, - identity); + identity, + Dormant); Controller = new LiveEntityHydrationController( Runtime, Objects, @@ -1753,7 +1876,8 @@ public sealed class LiveEntityHydrationControllerTests Network, Timestamps, identity, - deletion); + deletion, + Dormant); } public LiveEntityRecord Record diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index 635e7cf3..8f82a1b5 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -70,6 +70,46 @@ public class StreamingControllerTests Assert.Empty(fake.Unloads); } + [Fact] + public void FirstTick_EnqueuesRevealNeighborhoodBeforeOtherNearWork() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + var controller = new StreamingController( + enqueueLoad: fake.EnqueueLoad, + enqueueUnload: fake.EnqueueUnload, + drainCompletions: fake.DrainCompletions, + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 2, + farRadius: 2); + uint destination = + StreamingRegion.EncodeLandblockIdForTest(50, 50); + controller.BeginDestinationReservation( + revealGeneration: 1, + destinationCell: destination, + requiredRenderRadius: 1); + + controller.Tick(observerCx: 50, observerCy: 50); + + Assert.Equal(25, fake.LoadJobs.Count); + Assert.All( + fake.LoadJobs.Take(9), + job => Assert.InRange( + Math.Max( + Math.Abs((int)((job.Id >> 24) & 0xFFu) - 50), + Math.Abs((int)((job.Id >> 16) & 0xFFu) - 50)), + 0, + 1)); + Assert.All( + fake.LoadJobs.Skip(9), + job => Assert.Equal( + 2, + Math.Max( + Math.Abs((int)((job.Id >> 24) & 0xFFu) - 50), + Math.Abs((int)((job.Id >> 16) & 0xFFu) - 50)))); + } + [Fact] public void ReconfigureRadii_SmallerWindowUnloadsOnlyOutsideResidents() {