fix(streaming): preserve portal destination ownership

Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
This commit is contained in:
Erik 2026-07-25 08:35:12 +02:00
parent 2c848d4167
commit 823936ec31
57 changed files with 2551 additions and 324 deletions

View file

@ -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 landblocks, while a separate visible-only view feeds radar, picking, status, and
targeting. Raw server PhysicsState and the final state produced by retail's targeting. Raw server PhysicsState and the final state produced by retail's
ordered Lighting/NoDraw/Hidden side effects are stored separately. 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 `LiveEntityPresentationController` drains those accepted transitions only after
the renderer/effect owner is ready: Hidden suppresses the retained root's mesh, the renderer/effect owner is ready: Hidden suppresses the retained root's mesh,
collision, interaction, radar, and target eligibility while preserving the 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 network/event/command delivery, portal presentation, destination streaming, and
readiness continue. Canonical live records remain retained until physical readiness continue. Canonical live records remain retained until physical
teardown converges, so quiescence neither reconstructs server identity nor teardown converges, so quiescence neither reconstructs server identity nor
invents Hidden/UnHide transitions. Full-window and shared-origin retirement invents Hidden/UnHide transitions. A shared-origin recenter atomically removes
advance only from `StreamingController.Tick` through stable resident and the complete old spatial generation and captures one exact receipt per
per-owner entity/stage cursors on the frame's one meter. 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. Accepted publication is likewise retained at its exact priority-queue head.
`LandblockPresentationPipeline` meters render, physics, static, building, and `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 `WorldRevealCoordinator` is the single App-layer owner of each login or portal
reveal lifetime. It composes the canonical `WorldRevealReadinessBarrier` with reveal lifetime. It composes the canonical `WorldRevealReadinessBarrier` with
generation-scoped lifecycle observation and the streaming destination generation-scoped lifecycle observation and the streaming destination
reservation, so begin, preparation, readiness, materialization, world reservation plus a generation-scoped render-resource profile, so begin,
visibility, completion, cancellation, and reserved capacity cannot be wired preparation, readiness, materialization, viewport release, protocol completion,
independently. The barrier joins `StreamingController`/`GpuWorldState` 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 static-mesh publication, `WbDrawDispatcher` composite-texture warmup, and
`PhysicsEngine` destination residency. Login's sky-only gate and portal-space `PhysicsEngine` destination residency. Login's sky-only gate and portal-space
transit consume the same predicate; they differ only in presentation. There is 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, and no world point lights for this pass. Chat, gameplay windows, toolbar,
cursor, and input continue normally above it. The synthetic object never cursor, and input continue normally above it. The synthetic object never
enters `LiveEntityRuntime`, collision, picking, radar, or server GUID state. 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 `TeleportViewPlaneController` is the App-layer projection adapter for retail
`SmartBox::SetOverrideFovDistance` / `Render::set_vdst`. It captures the active `SmartBox::SetOverrideFovDistance` / `Render::set_vdst`. It captures the active

View file

@ -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-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-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-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:309573309597 | | 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:309573309597 |
| 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-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 | | 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 |

View file

@ -1632,8 +1632,11 @@ port in any phase — no separate listing here.
> shadow referee, production cutover, and acceptance matrix. The user > shadow referee, production cutover, and acceptance matrix. The user
> explicitly approved Slices FL, including the F/G and J gates, on > explicitly approved Slices FL, including the F/G and J gates, on
> 2026-07-24. Slice F and G0G4 are implemented; 46,599 automated G4 > 2026-07-24. Slice F and G0G4 are implemented; 46,599 automated G4
> comparisons have zero mismatches. Its physical-display visual gate and G5 > comparisons have zero mismatches. The 2026-07-25 connected visual gate
> old-path deletion remain pending, with exact rollback > 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: > `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete:
> retained UI/frame work, exact bounded light selection, and > retained UI/frame work, exact bounded light selection, and
> pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the > pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the

View file

@ -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 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 FL, including both 84.7%/82.2% lower. The user explicitly approved Slices FL, including both
the F/G ECS and Slice J gameplay-owner gates, on 2026-07-24. Slice F and the F/G ECS and Slice J gameplay-owner gates, on 2026-07-24. Slice F and
G0G4 are implemented; G4's automated gate has zero mismatches while its G0G4 are implemented; G4's automated gate has zero mismatches and its
physical-display visual gate and G5 deletion remain pending. Slice H is 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 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 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 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). [`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`. `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`.
Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's

View file

@ -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: 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). [`../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 ## 10. Acceptance
Slice E closes only when: Slice E closes only when:

View file

@ -2,7 +2,7 @@
**Date:** 2026-07-24 **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` **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`. | | 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`. | | 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`. | | 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`. | | 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 | pending | Physical performance/lifecycle closeout follows the user visual gate. | | 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 The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is
non-drawing diagnostic infrastructure and therefore is not a visual-cutover 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 process closed gracefully. The gate corrected whole-retained-world composite
warmup discovery by sourcing the exact published 3×3 destination neighborhood; warmup discovery by sourcing the exact published 3×3 destination neighborhood;
that correction is draw-path independent. Fixed-camera artifacts are captured. 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 The user completed the connected visual gate on 2026-07-25. The retained scene
deletion and G5 production-only profiling remain blocked. Evidence: itself remained exact; the pass exposed independent portal scheduling, private
`docs/research/2026-07-25-slice-g4-production-cutover-automated-gate.md`. 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 ### G5 — Physical performance and lifecycle gate

View file

@ -2,8 +2,8 @@
**Status:** COMPLETE (2026-07-25) **Status:** COMPLETE (2026-07-25)
**Parent plan:** `2026-07-24-modern-runtime-architecture.md`, Slice H **Parent plan:** `2026-07-24-modern-runtime-architecture.md`, Slice H
**Prerequisite state:** Slice G4 production cutover is automated-gated; its **Prerequisite state at execution:** Slice G4 production cutover was
user visual gate remains pending. automated-gated; its user visual gate later passed on 2026-07-25.
## 1. Safety boundary ## 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 retail gameplay rules, visible-cell authority, light membership, packet
ordering, acknowledgement timing, heartbeat placement, or UI presentation. ordering, acknowledgement timing, heartbeat placement, or UI presentation.
The G4 old-route referee remains available until the user accepts the visual Slice H did not delete or alter the G4 old-route referee while its visual gate
gate. Slice H must not delete or alter that rollback boundary. was pending. That gate has since passed; G5 owns the final deletion boundary.
If the G4 visual result is wrong, revert only: If the G4 visual result is wrong, revert only:

View file

@ -1,7 +1,7 @@
# Modern runtime Slice I — flat collision assets and zero-allocation physics # Modern runtime Slice I — flat collision assets and zero-allocation physics
**Status:** I0 COMPLETE — retail oracle, representative graph fixtures, and **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 **Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I
**Purpose:** remove parsed DAT object graphs and steady collision allocations **Purpose:** remove parsed DAT object graphs and steady collision allocations
without changing one retail collision decision, float, comparison, or traversal without changing one retail collision decision, float, comparison, or traversal

View file

@ -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 full-screen UI overlay: normal retained UI continues to update and draw above
it. 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 ## Begin/end and scene visibility
```text ```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 teleport object's sequence animations, plays `Sound_UI_ExitPortal`, and enters
`WorldFadeIn`. That following state restores the ordinary game view distance. `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 ## Camera motion
The camera rolls around its own AC `+Y` forward axis. It does not yaw around The camera rolls around its own AC `+Y` forward axis. It does not yaw around

View file

@ -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) - [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) - [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 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 conservative fallback. The distance is an adaptation, not a claimed retail
constant, and remains documented in divergence AP-69 until exact ObjCell/PVS constant, and remains documented in divergence AP-69 until exact ObjCell/PVS
membership drives the deadline. 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 ## Port pseudocode
```text ```text
@ -93,14 +116,30 @@ once per second:
for each due candidate: for each due candidate:
re-read canonical record re-read canonical record
if GUID and generation still match: 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: Routing expiry through `LiveEntityRuntime.UnregisterLiveEntity` is important:
it performs the same generation gate, object-table removal, equipped-child it performs the same generation gate, equipped-child cleanup, spatial
cleanup, spatial withdrawal, animation/physics/effect teardown, and render withdrawal, animation/physics/effect teardown, and render resource release as
resource release as an authoritative F747. A second teardown path would drift an authoritative F747. Unlike F747, local visibility expiry preserves the
and recreate the original leak. 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 ## Reclaimable shared-buffer contract

View file

@ -172,8 +172,11 @@ begin destination generation:
silence old-generation audio silence old-generation audio
reject old generation from collision, picking, radar, and targeting 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: over later update/resource-maintenance frames:
detach old landblocks through exact retry receipts
release scripts/effects/lights/plugin projections release scripts/effects/lights/plugin projections
release CPU owners release CPU owners
admit renderer retirement; physical GL release remains fence delayed 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 cursor runs, but it cannot keep simulating, colliding, rendering, targeting, or
emitting audible work after the generation-quiesce edge. 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 ## Port constraints
1. Preserve existing exact retry receipts; successful stages never replay. 1. Preserve existing exact retry receipts; successful stages never replay.

View file

@ -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.

View file

@ -754,7 +754,8 @@ internal sealed class LivePresentationCompositionPhase
d.Gl, d.Gl,
dispatcherLease.Resource, dispatcherLease.Resource,
foundation.SceneLighting, foundation.SceneLighting,
foundation.TextureCache), foundation.TextureCache,
foundation.MeshAdapter),
static value => value.Dispose()); static value => value.Dispose());
IUiViewportRenderer? previousRenderer = viewport.Renderer; IUiViewportRenderer? previousRenderer = viewport.Renderer;
viewport.Renderer = paperdollLease.Resource; viewport.Renderer = paperdollLease.Resource;
@ -795,7 +796,8 @@ internal sealed class LivePresentationCompositionPhase
d.Gl, d.Gl,
dispatcherLease.Resource, dispatcherLease.Resource,
foundation.SceneLighting, foundation.SceneLighting,
foundation.TextureCache), foundation.TextureCache,
foundation.MeshAdapter),
static value => value.Dispose()); static value => value.Dispose());
IUiViewportRenderer? previousRenderer = creatureViewport.Renderer; IUiViewportRenderer? previousRenderer = creatureViewport.Renderer;
creatureViewport.Renderer = creatureAppraisalLease.Resource; creatureViewport.Renderer = creatureAppraisalLease.Resource;

View file

@ -329,6 +329,9 @@ internal sealed class SessionPlayerCompositionPhase
content.Audio?.Engine); content.Audio?.Engine);
var compositeWarmupSource = var compositeWarmupSource =
new CompositeWarmupEntitySource(live.WorldState); new CompositeWarmupEntitySource(live.WorldState);
var revealRenderResources = new WorldRevealRenderResourceScheduler(
foundation.MeshAdapter.SetDestinationRevealUploadPriority,
foundation.TextureCache.SetDestinationRevealUploadPriority);
var worldReveal = new WorldRevealCoordinator( var worldReveal = new WorldRevealCoordinator(
streaming.IsRenderNeighborhoodResident, streaming.IsRenderNeighborhoodResident,
d.PhysicsEngine.IsSpawnCellReady, d.PhysicsEngine.IsSpawnCellReady,
@ -351,7 +354,8 @@ internal sealed class SessionPlayerCompositionPhase
spawnClaimClassifier.IsUnhydratable, spawnClaimClassifier.IsUnhydratable,
d.Log, d.Log,
worldQuiescence, worldQuiescence,
streaming); streaming,
revealRenderResources);
Fault(SessionPlayerCompositionPoint.WorldRevealCreated); Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
return CompleteSessionPlayer( return CompleteSessionPlayer(
@ -450,11 +454,13 @@ internal sealed class SessionPlayerCompositionPhase
live.Lights, live.Lights,
d.ClassificationCache, d.ClassificationCache,
d.PlayerIdentity); d.PlayerIdentity);
var dormantLiveEntities = new DormantLiveEntityStore();
var deletion = new LiveEntityDeletionController( var deletion = new LiveEntityDeletionController(
live.LiveEntities, live.LiveEntities,
d.Objects, d.Objects,
teardown, teardown,
d.PlayerIdentity, d.PlayerIdentity,
dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null); d.Options.DumpLiveSpawns ? d.Log : null);
var hydration = new LiveEntityHydrationController( var hydration = new LiveEntityHydrationController(
live.LiveEntities, live.LiveEntities,
@ -472,6 +478,7 @@ internal sealed class SessionPlayerCompositionPhase
localPhysicsTimestamps, localPhysicsTimestamps,
d.PlayerIdentity, d.PlayerIdentity,
deletion, deletion,
dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null); d.Options.DumpLiveSpawns ? d.Log : null);
bindings.Adopt( bindings.Adopt(
"landblock-loaded hydration", "landblock-loaded hydration",
@ -511,7 +518,8 @@ internal sealed class SessionPlayerCompositionPhase
var liveness = new LiveEntityLivenessController( var liveness = new LiveEntityLivenessController(
live.LiveEntities, live.LiveEntities,
d.PlayerIdentity, d.PlayerIdentity,
deletion); deletion,
dormantLiveEntities);
var sessionEvents = new LiveEntitySessionController( var sessionEvents = new LiveEntitySessionController(
d.InboundEntityEvents, d.InboundEntityEvents,
hydration, hydration,

View file

@ -218,7 +218,7 @@ internal sealed class LiveSessionRuntimeFactory
_world.Hydration.ResetSessionState(); _world.Hydration.ResetSessionState();
_player.Shortcuts.Items = Array.Empty<ShortcutEntry>(); _player.Shortcuts.Items = Array.Empty<ShortcutEntry>();
_player.DesiredComponents.Clear(); _player.DesiredComponents.Clear();
_ui.Paperdoll?.MarkDirty(); _ui.Paperdoll?.ResetSession();
// X/Y are ignored until the next logical player CreateObject claims a // X/Y are ignored until the next logical player CreateObject claims a
// new origin. Keeping the last values avoids inventing a synthetic // new origin. Keeping the last values avoids inventing a synthetic

View file

@ -277,6 +277,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable
internal const long TargetArrayBytes = 4L * 1024 * 1024; internal const long TargetArrayBytes = 4L * 1024 * 1024;
internal const int MaximumLayersPerArray = 64; internal const int MaximumLayersPerArray = 64;
internal const int DefaultMaximumUploadsPerFrame = 16; internal const int DefaultMaximumUploadsPerFrame = 16;
internal const int DestinationRevealMaximumUploadsPerFrame = 64;
internal const long DefaultMaximumUploadBytesPerFrame = 8L * 1024 * 1024; internal const long DefaultMaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const int MaximumLogicalEvictionsPerFrame = 16; internal const int MaximumLogicalEvictionsPerFrame = 16;
internal const int MaximumAtlasCreationsPerFrame = 1; internal const int MaximumAtlasCreationsPerFrame = 1;
@ -298,6 +299,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable
private long _frameUploadBytes; private long _frameUploadBytes;
private int _frameAtlasCreationCount; private int _frameAtlasCreationCount;
private bool _uploadBudgetBlocked; private bool _uploadBudgetBlocked;
private bool _destinationRevealUploadPriority;
private int _pendingAtlasWidth; private int _pendingAtlasWidth;
private int _pendingAtlasHeight; private int _pendingAtlasHeight;
private long _pendingAtlasAllocationBytes; private long _pendingAtlasAllocationBytes;
@ -390,9 +392,16 @@ internal sealed class CompositeTextureArrayCache : IDisposable
internal long FrameUploadBytes => _frameUploadBytes; internal long FrameUploadBytes => _frameUploadBytes;
internal bool CanStartUpload => internal bool CanStartUpload =>
!_uploadBudgetBlocked !_uploadBudgetBlocked
&& _frameUploadCount < _maximumUploadsPerFrame && _frameUploadCount < CurrentMaximumUploadsPerFrame
&& (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame); && (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame);
private int CurrentMaximumUploadsPerFrame =>
_destinationRevealUploadPriority
? Math.Max(
_maximumUploadsPerFrame,
DestinationRevealMaximumUploadsPerFrame)
: _maximumUploadsPerFrame;
internal Residency.ResidencyDomainSnapshot CaptureResidency() internal Residency.ResidencyDomainSnapshot CaptureResidency()
{ {
long retiringBytes = 0; long retiringBytes = 0;
@ -439,7 +448,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable
internal bool CanUpload(long bytes) internal bool CanUpload(long bytes)
{ {
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes);
if (_frameUploadCount >= _maximumUploadsPerFrame) if (_frameUploadCount >= CurrentMaximumUploadsPerFrame)
return false; return false;
// Always allow one item so a texture larger than the normal frame // Always allow one item so a texture larger than the normal frame
@ -479,10 +488,12 @@ internal sealed class CompositeTextureArrayCache : IDisposable
return false; return false;
} }
public void BeginFrame() public void BeginFrame(bool destinationRevealUploadPriority = false)
{ {
ThrowIfUnavailable(); ThrowIfUnavailable();
_retirementLedger.RetryPendingPublications(); _retirementLedger.RetryPendingPublications();
_destinationRevealUploadPriority =
destinationRevealUploadPriority;
_frameUploadCount = 0; _frameUploadCount = 0;
_frameUploadBytes = 0; _frameUploadBytes = 0;
_frameAtlasCreationCount = 0; _frameAtlasCreationCount = 0;

View file

@ -378,13 +378,15 @@ internal sealed class CreatureAppraisalViewportRenderer :
GL gl, GL gl,
WbDrawDispatcher dispatcher, WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo, SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime) IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter)
{ {
_renderer = new PrivateEntityViewportRenderer( _renderer = new PrivateEntityViewportRenderer(
gl, gl,
dispatcher, dispatcher,
lightUbo, lightUbo,
textureLifetime, textureLifetime,
meshAdapter,
CreatureAppraisalEntityBuilder.RenderId, CreatureAppraisalEntityBuilder.RenderId,
_camera, _camera,
"creature examination"); "creature examination");

View file

@ -53,6 +53,7 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view; private readonly IPaperdollFrameView _view;
private readonly IPaperdollDollFactory _factory; private readonly IPaperdollDollFactory _factory;
private WorldEntity? _doll;
private bool _dirty = true; private bool _dirty = true;
public PaperdollFramePresenter( public PaperdollFramePresenter(
@ -78,20 +79,111 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
{ {
if (_factory.TryBuild(out WorldEntity? doll)) 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; _dirty = false;
} }
else else
{ {
// Retail's private viewport remains present while the live // gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
// player projection is still materializing. Keep the dirty // private m_pInventoryObject intact when the SmartBox player
// edge armed and clear any doll from the prior session. // is temporarily unavailable. Keep the successful doll and
_renderer.SetDoll(null); // retry this dirty redress on the next visible frame.
} }
} }
_view.SetTextureHandle(_renderer.Render(width, height)); _view.SetTextureHandle(_renderer.Render(width, height));
} }
/// <summary>
/// Clears the private object only at the owning character-session
/// boundary, matching gmPaperDollUI's private-object lifetime.
/// </summary>
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<uint, uint>? left,
IReadOnlyDictionary<uint, uint>? 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;
}
} }
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary> /// <summary>Retained-UI visibility and texture publication for the doll view.</summary>

View file

@ -21,13 +21,15 @@ public sealed class PaperdollViewportRenderer :
GL gl, GL gl,
WbDrawDispatcher dispatcher, WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo, SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime) IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter)
{ {
_renderer = new PrivateEntityViewportRenderer( _renderer = new PrivateEntityViewportRenderer(
gl, gl,
dispatcher, dispatcher,
lightUbo, lightUbo,
textureLifetime, textureLifetime,
meshAdapter,
DollEntityBuilder.DollRenderId, DollEntityBuilder.DollRenderId,
new DollViewportCamera(), new DollViewportCamera(),
"paperdoll"); "paperdoll");

View file

@ -55,7 +55,7 @@ public sealed class PortalTunnelPresentation : IDisposable
private readonly Random _random; private readonly Random _random;
private readonly Action<string?>? _displayNotice; private readonly Action<string?>? _displayNotice;
private IDisposable? _displayNoticeLifetime; private IDisposable? _displayNoticeLifetime;
private readonly PortalMeshReferenceOwner _meshReferences; private readonly SyntheticEntityMeshReferenceOwner _meshReferences;
private bool _visible; private bool _visible;
private double _rotationElapsed; private double _rotationElapsed;
@ -104,7 +104,7 @@ public sealed class PortalTunnelPresentation : IDisposable
MeshRefs = SetupMesh.Flatten(setup), MeshRefs = SetupMesh.Flatten(setup),
}; };
_meshReferences = new PortalMeshReferenceOwner( _meshReferences = new SyntheticEntityMeshReferenceOwner(
meshAdapter, meshAdapter,
setup.Parts.Select(part => (ulong)(uint)part)); setup.Parts.Select(part => (ulong)(uint)part));
} }

View file

@ -32,9 +32,12 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
private readonly WbDrawDispatcher _dispatcher; private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo; private readonly SceneLightingUboBinding _lightUbo;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease; private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly IWbMeshAdapter _meshAdapter;
private readonly IPrivateEntityViewportCamera _camera; private readonly IPrivateEntityViewportCamera _camera;
private readonly HashSet<uint> _animatedIds; private readonly HashSet<uint> _animatedIds;
private readonly string _diagnosticName; private readonly string _diagnosticName;
private readonly List<SyntheticEntityMeshReferenceOwner>
_retiringMeshReferences = [];
private uint _fbo; private uint _fbo;
private uint _colorTex; private uint _colorTex;
@ -42,12 +45,14 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
private int _fbW; private int _fbW;
private int _fbH; private int _fbH;
private WorldEntity? _entity; private WorldEntity? _entity;
private SyntheticEntityMeshReferenceOwner? _meshReferences;
public PrivateEntityViewportRenderer( public PrivateEntityViewportRenderer(
GL gl, GL gl,
WbDrawDispatcher dispatcher, WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo, SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime, IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter,
uint renderId, uint renderId,
IPrivateEntityViewportCamera camera, IPrivateEntityViewportCamera camera,
string diagnosticName) string diagnosticName)
@ -57,6 +62,8 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
_meshAdapter = meshAdapter
?? throw new ArgumentNullException(nameof(meshAdapter));
_camera = camera ?? throw new ArgumentNullException(nameof(camera)); _camera = camera ?? throw new ArgumentNullException(nameof(camera));
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport" ? "creature viewport"
@ -69,10 +76,63 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
public void SetEntity(WorldEntity? entity) public void SetEntity(WorldEntity? entity)
{ {
ReleaseRetiringMeshReferences();
if (ReferenceEquals(_entity, entity)) if (ReferenceEquals(_entity, entity))
return; 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; _entity = entity;
if (previous is not null)
{
try
{
previous.Dispose();
}
catch
{
_retiringMeshReferences.Add(previous);
throw;
}
}
} }
public uint Render(int width, int height) public uint Render(int width, int height)
@ -246,7 +306,78 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
public void Dispose() public void Dispose()
{ {
_entity = null; _entity = null;
_textureOwnerLease.Dispose(); if (_meshReferences is { } current)
DeleteFramebuffer(); {
_meshReferences = null;
_retiringMeshReferences.Add(current);
}
List<Exception>? 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<ulong> 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<Exception>? 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);
}
} }
} }

View file

@ -3,13 +3,14 @@ using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
/// <summary> /// <summary>
/// Owns the synthetic portal creature's mesh references. Acquisition happens /// Owns one synthetic/private entity's mesh references independently of world
/// only after the presentation has been published to its runtime owner, so a /// cell residence. Acquisition happens only after the presentation has been
/// partial backend failure can never strand an unreachable constructor-local /// published to its runtime owner, so a partial backend failure can never
/// reference. Per-reference physical state makes both acquisition and teardown /// strand an unreachable constructor-local reference. Per-reference physical
/// resumable without replaying successful mutations. /// state makes both acquisition and teardown resumable without replaying
/// successful mutations.
/// </summary> /// </summary>
internal sealed class PortalMeshReferenceOwner : IDisposable internal sealed class SyntheticEntityMeshReferenceOwner : IDisposable
{ {
private sealed class ReferenceState(ulong gfxObjId) private sealed class ReferenceState(ulong gfxObjId)
{ {
@ -25,7 +26,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable
private bool _reconciling; private bool _reconciling;
private bool _reconcileAgain; private bool _reconcileAgain;
public PortalMeshReferenceOwner( public SyntheticEntityMeshReferenceOwner(
IWbMeshAdapter meshAdapter, IWbMeshAdapter meshAdapter,
IEnumerable<ulong> gfxObjIds) IEnumerable<ulong> gfxObjIds)
{ {
@ -62,7 +63,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable
catch (Exception rollbackFailure) catch (Exception rollbackFailure)
{ {
throw new AggregateException( 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, acquisitionFailure,
rollbackFailure); rollbackFailure);
} }
@ -132,7 +133,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable
string operation = targetHeld ? "acquisition" : "release"; string operation = targetHeld ? "acquisition" : "release";
(failures ??= []).Add(new InvalidOperationException( (failures ??= []).Add(new InvalidOperationException(
$"Portal mesh 0x{reference.GfxObjId:X10} reference {operation} failed.", $"Synthetic entity mesh 0x{reference.GfxObjId:X10} reference {operation} failed.",
error)); error));
} }
} }
@ -148,7 +149,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable
if (failures is not null) if (failures is not null)
throw new AggregateException( throw new AggregateException(
"One or more portal mesh references failed to reconcile.", "One or more synthetic entity mesh references failed to reconcile.",
failures); failures);
} }
} }

View file

@ -12,7 +12,9 @@ using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering; 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 GL _gl;
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
@ -39,6 +41,7 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
private readonly Wb.BindlessSupport? _bindless; private readonly Wb.BindlessSupport? _bindless;
private readonly CompositeTextureArrayCache? _compositeTextures; private readonly CompositeTextureArrayCache? _compositeTextures;
private bool _destinationRevealUploadPriority;
// Standalone Texture2DArray caches. Shared world surfaces use WB's atlas; // Standalone Texture2DArray caches. Shared world surfaces use WB's atlas;
// this base cache remains for consumers such as particle rendering. // 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 int CachedUnownedParticleTextureCount => _particleTextures?.UnownedEntryCount ?? 0;
internal long CachedUnownedParticleTextureBytes => _particleTextures?.UnownedBytes ?? 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 // Phase N.6 slice 1 (2026-05-11): per-upload metadata for the
// ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload // ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload
// time so the dump method doesn't have to query GL state. Keyed by // 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
/// </summary> /// </summary>
public void TickParticleTextureCache() => _particleTextures?.Tick(); public void TickParticleTextureCache() => _particleTextures?.Tick();
public void BeginCompositeTextureFrame() => _compositeTextures?.BeginFrame(); public void BeginCompositeTextureFrame() =>
_compositeTextures?.BeginFrame(_destinationRevealUploadPriority);
/// <summary> /// <summary>
/// Cheap 64-bit hash over a palette override's identity so two /// Cheap 64-bit hash over a palette override's identity so two

View file

@ -21,9 +21,12 @@ namespace AcDream.App.Rendering.Wb;
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed. /// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter public sealed class WbMeshAdapter
: IDisposable,
IWbMeshAdapter
{ {
internal const int MaximumUploadsPerFrame = 8; internal const int MaximumUploadsPerFrame = 8;
internal const int DestinationRevealMaximumUploadsPerFrame = 64;
internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024; internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024; internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumMipmapBytesPerFrame = 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 ObjectMeshManager? _meshManager;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement; private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IPreparedAssetSource? _ownedPreparedAssets; private readonly IPreparedAssetSource? _ownedPreparedAssets;
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits( private readonly MeshUploadFrameBudget _ordinaryUploadBudget =
MaximumUploadsPerFrame, CreateUploadBudget(MaximumUploadsPerFrame);
MaximumUploadBytesPerFrame, private readonly MeshUploadFrameBudget _destinationRevealUploadBudget =
MaximumArrayAllocationBytesPerFrame, CreateUploadBudget(DestinationRevealMaximumUploadsPerFrame);
MaximumMipmapBytesPerFrame,
MaximumNewArraysPerFrame,
MaximumBufferUploadBytesPerFrame,
MaximumBufferAllocationBytesPerFrame,
MaximumBufferCopyBytesPerFrame,
MaximumNewBuffersPerFrame,
MaximumSingleUploadBytes,
MaximumSingleArrayAllocationBytes,
MaximumSingleMipmapBytes,
MaximumSingleNewArrays,
MaximumSingleBufferUploadBytes));
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new(); private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
private bool _destinationRevealUploadPriority;
/// <summary> /// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>; /// True when this instance was created via <see cref="CreateUninitialized"/>;
@ -91,6 +84,27 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
internal (int Count, long Bytes) CpuMeshCacheDiagnostics => internal (int Count, long Bytes) CpuMeshCacheDiagnostics =>
_meshManager?.CpuCacheDiagnostics ?? default; _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));
/// <summary> /// <summary>
/// Constructs the UI-Studio/tooling WB pipeline. Production composition /// Constructs the UI-Studio/tooling WB pipeline. Production composition
/// supplies the validated pak source through the internal overload below; /// 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 // single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop. // a genuine defect surfaces loudly instead of the old silent sticky drop.
List<MeshUploadQueueItem>? requeue = null; List<MeshUploadQueueItem>? requeue = null;
_uploadBudget.Reset(); MeshUploadFrameBudget uploadBudget =
_destinationRevealUploadPriority
? _destinationRevealUploadBudget
: _ordinaryUploadBudget;
int maximumUploads =
_destinationRevealUploadPriority
? DestinationRevealMaximumUploadsPerFrame
: MaximumUploadsPerFrame;
uploadBudget.Reset();
_mipmapsBudgeted.Clear(); _mipmapsBudgeted.Clear();
int staleDiscardCount = 0; int staleDiscardCount = 0;
bool arenaBackpressured = false; bool arenaBackpressured = false;
@ -470,7 +492,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
{ {
GlobalMeshMaintenanceStep maintenance = GlobalMeshMaintenanceStep maintenance =
globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame); globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame);
_uploadBudget.RecordBufferMaintenance( uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes, maintenance.AllocationBytes,
maintenance.CopyBytes, maintenance.CopyBytes,
maintenance.NewBufferCount); maintenance.NewBufferCount);
@ -478,8 +500,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
} }
while (globalBuffer?.IsMigrationInProgress != true while (globalBuffer?.IsMigrationInProgress != true
&& _uploadBudget.BufferCopyBytes == 0 && uploadBudget.BufferCopyBytes == 0
&& _uploadBudget.ObjectCount < MaximumUploadsPerFrame) && uploadBudget.ObjectCount < maximumUploads)
{ {
staleDiscardCount += meshManager.DiscardUnownedStagedPrefix( staleDiscardCount += meshManager.DiscardUnownedStagedPrefix(
MaximumStaleDiscardsPerFrame - staleDiscardCount); MaximumStaleDiscardsPerFrame - staleDiscardCount);
@ -501,7 +523,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
} }
if (capacity == GlobalMeshCapacityResult.MigrationStarted) if (capacity == GlobalMeshCapacityResult.MigrationStarted)
{ {
_uploadBudget.RecordBufferMaintenance( uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes, maintenance.AllocationBytes,
maintenance.CopyBytes, maintenance.CopyBytes,
maintenance.NewBufferCount); maintenance.NewBufferCount);
@ -544,7 +566,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
next.Data, next.Data,
_mipmapsBudgeted, _mipmapsBudgeted,
next.Generation); next.Generation);
if (!_uploadBudget.TryAdmit(cost)) if (!uploadBudget.TryAdmit(cost))
break; break;
} }
catch (NotSupportedException error) catch (NotSupportedException error)
@ -597,29 +619,29 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// frames. It never competes with destination materialization, and the // frames. It never competes with destination materialization, and the
// GlobalMeshBuffer policy retains enough hysteresis that portal-route // GlobalMeshBuffer policy retains enough hysteresis that portal-route
// revisits do not alternate shrink/grow every frame. // revisits do not alternate shrink/grow every frame.
if (_uploadBudget.ObjectCount == 0 if (uploadBudget.ObjectCount == 0
&& LastMipmapArrayCount == 0 && LastMipmapArrayCount == 0
&& meshManager.StagedMeshCount == 0 && meshManager.StagedMeshCount == 0
&& globalBuffer?.IsMigrationInProgress == false && globalBuffer?.IsMigrationInProgress == false
&& globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim)) && globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim))
{ {
_uploadBudget.RecordBufferMaintenance( uploadBudget.RecordBufferMaintenance(
trim.AllocationBytes, trim.AllocationBytes,
trim.CopyBytes, trim.CopyBytes,
trim.NewBufferCount); trim.NewBufferCount);
meshManager.SetArenaBackpressure(true); meshManager.SetArenaBackpressure(true);
} }
LastUploadCount = _uploadBudget.ObjectCount; LastUploadCount = uploadBudget.ObjectCount;
LastUploadBytes = _uploadBudget.SourceBytes; LastUploadBytes = uploadBudget.SourceBytes;
LastStaleDiscardCount = staleDiscardCount; LastStaleDiscardCount = staleDiscardCount;
LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes; LastArrayAllocationBytes = uploadBudget.ArrayAllocationBytes;
LastPlannedMipmapBytes = _uploadBudget.MipmapBytes; LastPlannedMipmapBytes = uploadBudget.MipmapBytes;
LastNewArrayCount = _uploadBudget.NewArrayCount; LastNewArrayCount = uploadBudget.NewArrayCount;
LastBufferUploadBytes = _uploadBudget.BufferUploadBytes; LastBufferUploadBytes = uploadBudget.BufferUploadBytes;
LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes; LastBufferAllocationBytes = uploadBudget.BufferAllocationBytes;
LastBufferCopyBytes = _uploadBudget.BufferCopyBytes; LastBufferCopyBytes = uploadBudget.BufferCopyBytes;
LastNewBufferCount = _uploadBudget.NewBufferCount; LastNewBufferCount = uploadBudget.NewBufferCount;
if (texProbe) if (texProbe)
EmitTexFlushProbe(pendingBefore); EmitTexFlushProbe(pendingBefore);

View file

@ -339,7 +339,6 @@ internal sealed class RuntimeWorldFrameVisibilityPreparation
_particles.UseWorldView(); _particles.UseWorldView();
_reveal?.ObserveWorldViewportVisible(); _reveal?.ObserveWorldViewportVisible();
_reveal?.Complete();
} }
public void PublishViewProjection(in WorldCameraFrame camera) => public void PublishViewProjection(in WorldCameraFrame camera) =>

View file

@ -0,0 +1,53 @@
using AcDream.App.Streaming;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
/// </summary>
internal sealed class WorldRevealRenderResourceScheduler
: IWorldRevealRenderResourceScheduler
{
private readonly Action<bool>[] _participants;
private long _activeGeneration;
public WorldRevealRenderResourceScheduler(
params Action<bool>[] 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;
}
}

View file

@ -13,6 +13,17 @@ public sealed record GpuLandblockRetirement(
LandblockRetirementKind Kind, LandblockRetirementKind Kind,
IReadOnlyList<WorldEntity> Entities); IReadOnlyList<WorldEntity> Entities);
/// <summary>
/// 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.
/// </summary>
internal sealed record GpuWorldRecenterRetirement(
IReadOnlyList<GpuLandblockRetirement> Landblocks,
int SpatialOperationCount,
Exception? ObserverFailure);
public enum LandblockRetirementKind public enum LandblockRetirementKind
{ {
Full, Full,

View file

@ -460,6 +460,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
public int PersistentGuidCount => _persistentGuids.Count; public int PersistentGuidCount => _persistentGuids.Count;
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count; public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
internal long VisibilityCommitCount => _visibilityCommitCount; internal long VisibilityCommitCount => _visibilityCommitCount;
internal int OriginRecenterSpatialOperationCount =>
Math.Max(
1,
_loaded.Count
+ _pendingByLandblock.Count
+ _pendingRenderIdsByLandblock.Count
+ _pendingNearTierLandblocks.Count
+ _projectionLocations.Count);
/// <summary> /// <summary>
/// Groups spatial mutations into one visibility publication transaction. /// Groups spatial mutations into one visibility publication transaction.
@ -1257,6 +1265,207 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
detachedEntities); detachedEntities);
} }
/// <summary>
/// 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.
/// </summary>
internal GpuWorldRecenterRetirement DetachAllForOriginRecenter()
{
var ids = new List<uint>(
_loaded.Count
+ _pendingByLandblock.Count
+ _pendingRenderIdsByLandblock.Count);
var seenIds = new HashSet<uint>();
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<GpuLandblockRetirement>(ids.Count);
for (int i = 0; i < ids.Count; i++)
{
uint id = ids[i];
IReadOnlyList<WorldEntity> loaded =
_loaded.TryGetValue(id, out LoadedLandblock? landblock)
? landblock.Entities
: Array.Empty<WorldEntity>();
IReadOnlyList<WorldEntity> pending =
_pendingByLandblock.TryGetValue(id, out List<WorldEntity>? bucket)
? bucket
: Array.Empty<WorldEntity>();
IReadOnlyList<WorldEntity> detached = loaded;
if (pending.Count != 0)
{
if (loaded.Count == 0)
{
detached = pending;
}
else
{
var combined = new List<WorldEntity>(
loaded.Count + pending.Count);
var seenEntities = new HashSet<WorldEntity>(
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<uint, List<(WorldEntity Entity, int BucketIndex)>>();
var rescued = new HashSet<WorldEntity>(
_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<WorldEntity>(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);
}
/// <summary> /// <summary>
/// Compatibility edge for direct state tests. Production streaming uses /// Compatibility edge for direct state tests. Production streaming uses
/// <see cref="LandblockRetirementCoordinator"/> so each presentation owner /// <see cref="LandblockRetirementCoordinator"/> so each presentation owner

View file

@ -240,6 +240,11 @@ public sealed class LandblockPresentationPipeline
public void AdvanceRetirements(StreamingWorkMeter meter) => public void AdvanceRetirements(StreamingWorkMeter meter) =>
_retirements.Advance(meter); _retirements.Advance(meter);
internal void AdvancePriorityRetirement(
uint landblockId,
StreamingWorkMeter meter) =>
_retirements.AdvancePriority(landblockId, meter);
public void BeginFullRetirement(uint landblockId) public void BeginFullRetirement(uint landblockId)
{ {
_retirements.BeginFull(landblockId); _retirements.BeginFull(landblockId);
@ -260,6 +265,29 @@ public sealed class LandblockPresentationPipeline
internal void EnqueueNearLayerRetirement(uint landblockId) => internal void EnqueueNearLayerRetirement(uint landblockId) =>
_retirements.BeginNearLayer(landblockId); _retirements.BeginNearLayer(landblockId);
/// <summary>
/// Withdraws the complete old spatial generation in one transaction, then
/// transfers its exact receipts to the ordinary budgeted retirement FIFO.
/// </summary>
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) public void PublishLoaded(LandblockStreamResult.Loaded loaded)
{ {
ArgumentNullException.ThrowIfNull(loaded); ArgumentNullException.ThrowIfNull(loaded);

View file

@ -59,16 +59,15 @@ public sealed class LandblockPresentationRetirementOwner
public void Advance(LandblockRetirementTicket ticket) public void Advance(LandblockRetirementTicket ticket)
{ {
ArgumentNullException.ThrowIfNull(ticket); ArgumentNullException.ThrowIfNull(ticket);
bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer;
ticket.RunForEachEntity( ticket.RunForEachEntity(
LandblockRetirementStage.EntityLighting, LandblockRetirementStage.EntityLighting,
entity => !staticOnly || entity.ServerGuid == 0, static entity => entity.ServerGuid == 0,
RemoveLighting); _staticPresentation.RemoveLighting);
ticket.RunForEachEntity( ticket.RunForEachEntity(
LandblockRetirementStage.EntityTranslucency, LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0, static entity => entity.ServerGuid == 0,
RemoveTranslucency); _staticPresentation.RemoveTranslucency);
ticket.RunForEachEntity( ticket.RunForEachEntity(
LandblockRetirementStage.PluginProjection, LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0, static entity => entity.ServerGuid == 0,
@ -105,19 +104,18 @@ public sealed class LandblockPresentationRetirementOwner
LandblockRetirementTicket ticket) LandblockRetirementTicket ticket)
{ {
ArgumentNullException.ThrowIfNull(ticket); ArgumentNullException.ThrowIfNull(ticket);
bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer;
return ticket.NextIncompleteStage switch return ticket.NextIncompleteStage switch
{ {
LandblockRetirementStage.EntityLighting => LandblockRetirementStage.EntityLighting =>
ticket.RunEntityStep( ticket.RunEntityStep(
LandblockRetirementStage.EntityLighting, LandblockRetirementStage.EntityLighting,
entity => !staticOnly || entity.ServerGuid == 0, static entity => entity.ServerGuid == 0,
RemoveLighting), _staticPresentation.RemoveLighting),
LandblockRetirementStage.EntityTranslucency => LandblockRetirementStage.EntityTranslucency =>
ticket.RunEntityStep( ticket.RunEntityStep(
LandblockRetirementStage.EntityTranslucency, LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0, static entity => entity.ServerGuid == 0,
RemoveTranslucency), _staticPresentation.RemoveTranslucency),
LandblockRetirementStage.PluginProjection => LandblockRetirementStage.PluginProjection =>
ticket.RunEntityStep( ticket.RunEntityStep(
LandblockRetirementStage.PluginProjection, LandblockRetirementStage.PluginProjection,
@ -152,20 +150,4 @@ public sealed class LandblockPresentationRetirementOwner
_ => LandblockRetirementOperationResult.NoWork, _ => 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);
}
} }

View file

@ -242,6 +242,13 @@ public sealed class LandblockRetirementTicket
/// </summary> /// </summary>
public sealed class LandblockRetirementCoordinator public sealed class LandblockRetirementCoordinator
{ {
private enum BudgetedAdvanceResult : byte
{
Progressed,
Yielded,
Failed,
}
private enum RequestKind : byte private enum RequestKind : byte
{ {
BeginFull, BeginFull,
@ -275,7 +282,11 @@ public sealed class LandblockRetirementCoordinator
_requiredPresentationStages; _requiredPresentationStages;
private readonly Action<GpuLandblockRetirement>? _onDetached; private readonly Action<GpuLandblockRetirement>? _onDetached;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new(); private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly Queue<LandblockRetirementTicket> _pendingOrder = new(); private readonly LinkedList<LandblockRetirementTicket> _pendingOrder = new();
private readonly Dictionary<
LandblockRetirementTicket,
LinkedListNode<LandblockRetirementTicket>> _pendingNodes =
new(ReferenceEqualityComparer.Instance);
private readonly List<uint> _completedIds = new(); private readonly List<uint> _completedIds = new();
private readonly Queue<Request> _requests = new(); private readonly Queue<Request> _requests = new();
private readonly HashSet<Request> _seenDuringDrain = new(); private readonly HashSet<Request> _seenDuringDrain = new();
@ -326,6 +337,104 @@ public sealed class LandblockRetirementCoordinator
ReferenceEquals(_state, state); ReferenceEquals(_state, state);
internal bool UsesBudgetedSteps => _advancePresentationStep is not null; internal bool UsesBudgetedSteps => _advancePresentationStep is not null;
/// <summary>
/// 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.
/// </summary>
internal Exception? AdoptDetachedFull(
IReadOnlyList<GpuLandblockRetirement> retirements)
{
ArgumentNullException.ThrowIfNull(retirements);
var incomingIds = new HashSet<uint>();
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<LandblockRetirementTicket>? 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<Exception>? failures = null;
for (int index = 0; index < retirements.Count; index++)
{
GpuLandblockRetirement retirement = retirements[index];
uint canonical = Canonicalize(retirement.LandblockId);
_pending.TryGetValue(
canonical,
out List<LandblockRetirementTicket>? existing);
var ticket = new LandblockRetirementTicket(
retirement,
required);
if (existing is null)
{
existing = new List<LandblockRetirementTicket>(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( public void BeginFull(uint landblockId) => EnqueueRequest(
new Request(RequestKind.BeginFull, Canonicalize(landblockId))); new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
@ -365,40 +474,56 @@ public sealed class LandblockRetirementCoordinator
return; return;
} }
while (_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket)) while (_pendingOrder.First is { } head)
{ {
LandblockRetirementTicket ticket = head.Value;
if (ticket.IsComplete) if (ticket.IsComplete)
{ {
RemoveCompletedTicket(ticket); RemoveCompletedTicket(ticket);
continue; continue;
} }
LandblockRetirementStage stage = ticket.NextIncompleteStage; if (AdvanceBudgetedTicket(ticket, meter)
if (stage == LandblockRetirementStage.None) != BudgetedAdvanceResult.Progressed)
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)
return; return;
if (ticket.IsComplete)
RemoveCompletedTicket(ticket);
}
}
LandblockRetirementOperationResult result = AdvanceTicketOne(ticket); /// <summary>
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.
/// </summary>
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<LandblockRetirementTicket>? 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; return;
} }
meter.Complete();
if (ticket.IsComplete) if (ticket.IsComplete)
RemoveCompletedTicket(ticket); RemoveCompletedTicket(ticket);
} }
@ -535,7 +660,7 @@ public sealed class LandblockRetirementCoordinator
existing.Add(ticket); existing.Add(ticket);
PendingCount++; PendingCount++;
if (_advancePresentationStep is not null) if (_advancePresentationStep is not null)
_pendingOrder.Enqueue(ticket); EnqueueBudgetedTicket(ticket);
_onDetached?.Invoke(stateRetirement); _onDetached?.Invoke(stateRetirement);
@ -592,8 +717,9 @@ public sealed class LandblockRetirementCoordinator
private void AdvanceBudgetedEagerAttempt() private void AdvanceBudgetedEagerAttempt()
{ {
if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket)) if (_pendingOrder.First is not { } head)
return; return;
LandblockRetirementTicket ticket = head.Value;
if (ticket.IsComplete) if (ticket.IsComplete)
{ {
@ -657,15 +783,63 @@ public sealed class LandblockRetirementCoordinator
return result; return result;
} }
private void RemoveCompletedTicket(LandblockRetirementTicket ticket) private BudgetedAdvanceResult AdvanceBudgetedTicket(
LandblockRetirementTicket ticket,
StreamingWorkMeter meter)
{ {
if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? head) LandblockRetirementStage stage = ticket.NextIncompleteStage;
|| !ReferenceEquals(head, ticket)) if (stage == LandblockRetirementStage.None)
{ {
throw new InvalidOperationException( 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<LandblockRetirementTicket> 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<LandblockRetirementTicket>? node))
{
throw new InvalidOperationException(
"Completed retirement ticket is missing from its budgeted order.");
}
_pendingOrder.Remove(node);
uint id = Canonicalize(ticket.LandblockId); uint id = Canonicalize(ticket.LandblockId);
if (!_pending.TryGetValue(id, out List<LandblockRetirementTicket>? tickets) if (!_pending.TryGetValue(id, out List<LandblockRetirementTicket>? tickets)

View file

@ -506,6 +506,12 @@ internal sealed class LocalPlayerTeleportController
return; return;
break; break;
case TeleportAnimEvent.PlayExitSound: 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(); _presentation.ExitTunnel();
if (!IsCurrentLifetime(generation, sequence)) if (!IsCurrentLifetime(generation, sequence))
return; return;

View file

@ -58,6 +58,9 @@ internal sealed class StreamingCompletionQueue
public int Count => _count; public int Count => _count;
public long RetainedCpuBytes => _retainedCpuBytes; public long RetainedCpuBytes => _retainedCpuBytes;
public bool HasPriority(StreamingCompletionPriority priority) =>
_queues[(int)priority].Count != 0;
public void Enqueue(StreamingQueuedCompletion completion) public void Enqueue(StreamingQueuedCompletion completion)
{ {
_queues[(int)completion.Priority].Enqueue(completion); _queues[(int)completion.Priority].Enqueue(completion);
@ -69,10 +72,15 @@ internal sealed class StreamingCompletionQueue
public bool TryPeekNext( public bool TryPeekNext(
Func<LandblockStreamResult, bool> isBlocked, Func<LandblockStreamResult, bool> isBlocked,
out StreamingQueuedCompletion? completion) out StreamingQueuedCompletion? completion,
StreamingCompletionPriority maximumPriority =
StreamingCompletionPriority.Far)
{ {
ArgumentNullException.ThrowIfNull(isBlocked); 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<StreamingQueuedCompletion> queue = _queues[priority]; Queue<StreamingQueuedCompletion> queue = _queues[priority];
if (queue.Count == 0) if (queue.Count == 0)

View file

@ -33,10 +33,8 @@ public sealed class StreamingController
public bool PendingLoadsCleared; public bool PendingLoadsCleared;
public bool CompletionQueueCleared; public bool CompletionQueueCleared;
public bool RegionCleared; public bool RegionCleared;
public List<uint>? ResidentIds; public bool SpatialGenerationDetached;
public int RetirementCursor;
public bool PreparationCommitted; public bool PreparationCommitted;
public IEnumerator<uint>? ResidentEnumerator;
public (int X, int Y, bool IsSealedDungeon)? Destination; public (int X, int Y, bool IsSealedDungeon)? Destination;
public bool DestinationConfigured; public bool DestinationConfigured;
public bool DestinationLoadEnqueued; public bool DestinationLoadEnqueued;
@ -75,7 +73,6 @@ public sealed class StreamingController
private string? _maximumWorkFrameStage; private string? _maximumWorkFrameStage;
private double _maximumWorkOperationMilliseconds; private double _maximumWorkOperationMilliseconds;
private string? _maximumWorkOperationStage; private string? _maximumWorkOperationStage;
private readonly GpuWorldState _state; private readonly GpuWorldState _state;
private StreamingRegion? _region; private StreamingRegion? _region;
private RadiiReconfiguration? _pendingRadiiReconfiguration; private RadiiReconfiguration? _pendingRadiiReconfiguration;
@ -538,7 +535,8 @@ public sealed class StreamingController
return true; return true;
} }
private bool ConvergePendingPublications() private bool ConvergePendingPublications(
bool preferDestination = false)
{ {
IReadOnlyList<LandblockStreamResult> pending = IReadOnlyList<LandblockStreamResult> pending =
_presentation.GetPendingPublicationResults(); _presentation.GetPendingPublicationResults();
@ -554,22 +552,48 @@ public sealed class StreamingController
try try
{ {
bool progressed = false; bool progressed = false;
for (int i = 0; i < pending.Count; i++) bool destinationPending = false;
if (preferDestination)
{ {
LandblockStreamResult result = pending[i]; for (int i = 0; i < pending.Count; i++)
using StreamingWorkMeter.LaneScope lane = {
_activeWorkMeter.EnterLane( if (!IsDestinationWork(pending[i].LandblockId))
IsDestinationWork(result.LandblockId) continue;
? StreamingWorkLane.Destination destinationPending = true;
: StreamingWorkLane.NonDestination); break;
LandblockPublicationAdvance advance = }
_presentation.ResumePublication( }
result, // A partially prepared background landblock must not serialize the
_activeWorkMeter, // reveal-critical destination behind dictionary insertion order.
ensureProgress: !progressed); // Each publication owns independent receipts, so destination
progressed |= advance.Progressed; // transactions can safely resume first without replaying or
if (!advance.Completed) // discarding the non-destination cursor.
return false; 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; return true;
} }
@ -615,6 +639,7 @@ public sealed class StreamingController
bool retirementWasPending = bool retirementWasPending =
_presentation.PendingRetirementCount != 0; _presentation.PendingRetirementCount != 0;
TryAdvanceFullWindowRetirement(meter); TryAdvanceFullWindowRetirement(meter);
AdvanceDestinationRetirementDependency(meter);
if (_presentation.UsesBudgetedRetirementSteps if (_presentation.UsesBudgetedRetirementSteps
|| retirementWasPending) || retirementWasPending)
{ {
@ -636,6 +661,7 @@ public sealed class StreamingController
bool retirementWasPending = bool retirementWasPending =
_presentation.PendingRetirementCount != 0; _presentation.PendingRetirementCount != 0;
TryAdvanceOriginRecenterPreparation(meter); TryAdvanceOriginRecenterPreparation(meter);
AdvanceDestinationRetirementDependency(meter);
if (_presentation.UsesBudgetedRetirementSteps if (_presentation.UsesBudgetedRetirementSteps
|| retirementWasPending) || retirementWasPending)
{ {
@ -657,8 +683,15 @@ public sealed class StreamingController
// or retire the fully known owner set through the normal ledger. // or retire the fully known owner set through the normal ledger.
bool retirementWasPendingAtFrameStart = bool retirementWasPendingAtFrameStart =
_presentation.PendingRetirementCount != 0; _presentation.PendingRetirementCount != 0;
AdvanceDestinationRetirementDependency(meter);
_presentation.AdvanceRetirements(meter); _presentation.AdvanceRetirements(meter);
if (!ConvergePendingPublications()) bool destinationPublicationIncomplete =
_destinationReservation is not null
&& !IsRenderNeighborhoodResident(
DestinationLandblockId,
DestinationRadius);
if (!ConvergePendingPublications(
preferDestination: destinationPublicationIncomplete))
return; return;
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
@ -691,7 +724,8 @@ public sealed class StreamingController
NormalTick(observerCx, observerCy); NormalTick(observerCx, observerCy);
} }
DrainAndApply(); DrainAndApply(
preferDestination: destinationPublicationIncomplete);
// Retirement is cleanup after immediate spatial withdrawal. Spend // Retirement is cleanup after immediate spatial withdrawal. Spend
// remaining capacity only after visible/destination publication // remaining capacity only after visible/destination publication
// work has had access to this frame's shared meter. // 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) private void ObserveWorkLifetime(StreamingWorkMeterSnapshot snapshot)
{ {
_lifetimeWorkOverruns = SaturatingAdd( _lifetimeWorkOverruns = SaturatingAdd(
@ -912,21 +962,53 @@ public sealed class StreamingController
{ {
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); _region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
var bootstrap = _region.ComputeFirstTickDiff(); 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); foreach (var id in bootstrap.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
_region.MarkResidentFromBootstrap(); _region.MarkResidentFromBootstrap();
} }
else if (_region.CenterX != observerCx || _region.CenterY != observerCy) else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
{ {
var diff = _region.RecenterTo(observerCx, observerCy); var diff = _region.RecenterTo(observerCx, observerCy);
foreach (var id in diff.ToPromote) EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear); EnqueueLoadsByRevealPriority(
foreach (var id in diff.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear); diff.ToPromote,
LandblockStreamJobKind.PromoteToNear);
EnqueueLoadsByRevealPriority(
diff.ToLoadNear,
LandblockStreamJobKind.LoadNear);
foreach (var id in diff.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar); foreach (var id in diff.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToDemote) DemoteLandblock(id); foreach (var id in diff.ToDemote) DemoteLandblock(id);
foreach (var id in diff.ToUnload) EnqueueUnload(id); foreach (var id in diff.ToUnload) EnqueueUnload(id);
} }
} }
/// <summary>
/// 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.
/// </summary>
private void EnqueueLoadsByRevealPriority(
IReadOnlyList<uint> 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);
}
}
}
}
/// <summary> /// <summary>
/// Dungeon-entry edge: cancel the in-flight window load, unload every /// Dungeon-entry edge: cancel the in-flight window load, unload every
/// resident neighbor, and pin streaming to the player's single dungeon /// 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); if (!rebuilt.Resident.Contains(id)) EnqueueUnload(id);
var boot = rebuilt.ComputeFirstTickDiff(); var boot = rebuilt.ComputeFirstTickDiff();
foreach (var id in boot.ToLoadNear) EnqueueLoadsByRevealPriority(
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear); boot.ToLoadNear,
LandblockStreamJobKind.LoadNear,
skipLoaded: true);
foreach (var id in boot.ToLoadFar) foreach (var id in boot.ToLoadFar)
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar); if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
rebuilt.MarkResidentFromBootstrap(); rebuilt.MarkResidentFromBootstrap();
@ -1053,8 +1137,10 @@ public sealed class StreamingController
} }
/// <summary> /// <summary>
/// Advances every retained old-window teardown and reports whether the /// Reports whether every old resident has been detached and captured by
/// composition root may safely change the shared world origin. /// an exact retirement receipt. Deferred receipt cleanup may continue
/// after the shared origin changes; same-landblock publication remains
/// fenced by <see cref="IsPublicationBlockedByRetirement"/>.
/// </summary> /// </summary>
internal bool IsOriginRecenterRetirementComplete() internal bool IsOriginRecenterRetirementComplete()
{ {
@ -1062,14 +1148,14 @@ public sealed class StreamingController
throw new InvalidOperationException( throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending."); "No streaming-origin recenter transaction is pending.");
return _originRecenterRetirement.PreparationCommitted return _originRecenterRetirement.PreparationCommitted;
&& _presentation.PendingRetirementCount == 0;
} }
/// <summary> /// <summary>
/// Releases the streaming bootstrap gate after the composition root has /// Releases the streaming bootstrap gate after the composition root has
/// committed the new shared origin. No old-window presentation owner may /// committed the new shared origin. Old detached receipts continue under
/// still be pending at this edge. /// the shared frame meter and fence only a replacement publication with
/// the same canonical landblock key.
/// </summary> /// </summary>
internal bool TryCommitOriginRecenter( internal bool TryCommitOriginRecenter(
int destinationX, int destinationX,
@ -1104,10 +1190,6 @@ public sealed class StreamingController
if (!transaction.PreparationCommitted) if (!transaction.PreparationCommitted)
throw new InvalidOperationException( throw new InvalidOperationException(
"Streaming-origin retirement preparation has not completed."); "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); var destination = (destinationX, destinationY, isSealedDungeon);
if (transaction.Destination is { } retained && retained != destination) if (transaction.Destination is { } retained && retained != destination)
{ {
@ -1174,8 +1256,6 @@ public sealed class StreamingController
{ {
if (_originRecenterRetirement is not { PreparationCommitted: true }) if (_originRecenterRetirement is not { PreparationCommitted: true })
return false; return false;
if (_presentation.PendingRetirementCount != 0)
return false;
_collapsed = false; _collapsed = false;
_collapsedCenter = 0u; _collapsedCenter = 0u;
@ -1293,35 +1373,33 @@ public sealed class StreamingController
} }
transaction.RegionCleared = true; transaction.RegionCleared = true;
} }
if (transaction.ResidentIds is null) if (!transaction.SpatialGenerationDetached)
{
transaction.ResidentIds = [];
transaction.ResidentEnumerator =
_state.LoadedLandblockIds.GetEnumerator();
}
while (transaction.ResidentEnumerator is { } residentEnumerator)
{ {
StreamingWorkAdmission admission = meter.TryReserve( StreamingWorkAdmission admission = meter.TryReserve(
new StreamingWorkCost(EntityOperations: 1), new StreamingWorkCost(
"recenter-capture-resident-id"); EntityOperations:
_state.OriginRecenterSpatialOperationCount),
"recenter-detach-spatial-generation",
ensureProgress: true);
if (admission == StreamingWorkAdmission.Yielded) if (admission == StreamingWorkAdmission.Yielded)
return false; return false;
bool moved;
try try
{ {
moved = residentEnumerator.MoveNext(); GpuWorldRecenterRetirement detached =
if (moved) _presentation.DetachAllForOriginRecenter();
transaction.ResidentIds.Add(residentEnumerator.Current); transaction.SpatialGenerationDetached = true;
else FullWindowRetirementCount++;
{ LastFullWindowRetirementLandblockCount =
residentEnumerator.Dispose(); detached.Landblocks.Count;
transaction.ResidentEnumerator = null;
FullWindowRetirementCount++;
LastFullWindowRetirementLandblockCount =
transaction.ResidentIds.Count;
}
meter.Complete(); meter.Complete();
if (detached.ObserverFailure is not null)
{
Console.WriteLine(
"streaming: committed origin-recenter spatial " +
$"generation reported failure: {detached.ObserverFailure}");
return false;
}
} }
catch catch
{ {
@ -1330,46 +1408,6 @@ public sealed class StreamingController
} }
} }
List<uint> 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; transaction.PreparationCommitted = true;
return true; return true;
} }
@ -1567,17 +1605,24 @@ public sealed class StreamingController
/// order but no class bypasses time, bytes, entities, uploads, or retire /// order but no class bypasses time, bytes, entities, uploads, or retire
/// operation limits. /// operation limits.
/// </summary> /// </summary>
private void DrainAndApply() private void DrainAndApply(bool preferDestination = false)
{ {
StreamingWorkMeter meter = _activeWorkMeter StreamingWorkMeter meter = _activeWorkMeter
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Completion scheduling requires an active frame meter."); "Completion scheduling requires an active frame meter.");
AdmitCompletions(meter); AdmitCompletions(meter);
bool destinationQueued =
preferDestination
&& _completionQueue.HasPriority(
StreamingCompletionPriority.Destination);
bool executed = false; bool executed = false;
while (_completionQueue.TryPeekNext( while (_completionQueue.TryPeekNext(
_isPublicationBlockedByRetirement, _isPublicationBlockedByRetirement,
out StreamingQueuedCompletion? completion)) out StreamingQueuedCompletion? completion,
destinationQueued
? StreamingCompletionPriority.Unload
: StreamingCompletionPriority.Far))
{ {
StreamingQueuedCompletion work = completion StreamingQueuedCompletion work = completion
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
@ -1650,8 +1695,13 @@ public sealed class StreamingController
: ClassifyCompletion(result); : ClassifyCompletion(result);
LandblockStreamCostEstimate estimate = LandblockStreamCostEstimate estimate =
LandblockStreamResultCost.Estimate(result); 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 StreamingWorkCost admissionCost = stale
? new StreamingWorkCost(CompletionAdmissions: 1) ? default
: new StreamingWorkCost( : new StreamingWorkCost(
CompletionAdmissions: CompletionAdmissions:
estimate.Work.CompletionAdmissions, estimate.Work.CompletionAdmissions,

View file

@ -3,11 +3,11 @@ using AcDream.App.World;
namespace AcDream.App.Streaming; namespace AcDream.App.Streaming;
/// <summary> /// <summary>
/// Coordinates streaming-origin lifetime boundaries. Presentation must retire /// Coordinates streaming-origin lifetime boundaries. Spatial ownership must
/// the complete old window first; a teleport then changes /// detach the complete old window first; a teleport then changes
/// <see cref="LiveWorldOriginState"/> before destination streaming resumes, /// <see cref="LiveWorldOriginState"/> before destination streaming resumes,
/// while a session boundary releases the controller without recentering. The /// while deferred resource receipts continue under the shared frame budget.
/// transaction remains pending across frames when an owner teardown needs retry. /// A session boundary releases the controller without recentering.
/// </summary> /// </summary>
internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConvergence internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConvergence
{ {
@ -72,9 +72,9 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
} }
/// <summary> /// <summary>
/// Observes retained presentation teardown and commits the new origin once /// Observes old-window spatial detachment and commits the new origin once
/// the streaming frame owner has converged it. Returns true only after the /// every detached owner has an exact retirement receipt. Returns true only
/// destination streaming gate has been released. /// after the destination streaming gate has been released.
/// </summary> /// </summary>
public bool Advance() public bool Advance()
{ {

View file

@ -22,7 +22,12 @@ public sealed record StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 2.0, MaxUpdateMilliseconds: 2.0,
MaxCompletionAdmissions: 64, MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 8 * MiB, 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, MaxGpuUploadBytes: 8 * MiB,
MaxGlRetireOperations: 64, MaxGlRetireOperations: 64,
DestinationReserveFraction: 0.75f); DestinationReserveFraction: 0.75f);

View file

@ -15,6 +15,16 @@ internal interface IWorldRevealStreamingScheduler
void EndDestinationReservation(long revealGeneration); void EndDestinationReservation(long revealGeneration);
} }
/// <summary>
/// Generation-scoped renderer-resource profile used only while the normal
/// world viewport is withheld for a login or portal destination.
/// </summary>
internal interface IWorldRevealRenderResourceScheduler
{
void BeginDestinationReveal(long revealGeneration);
void EndDestinationReveal(long revealGeneration);
}
/// <summary> /// <summary>
/// Owns one login/portal reveal lifetime across readiness and lifecycle /// Owns one login/portal reveal lifetime across readiness and lifecycle
/// diagnostics. This keeps the destination barrier and its observations on a /// 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 WorldRevealLifecycleTelemetry _lifecycle;
private readonly WorldGenerationQuiescence? _quiescence; private readonly WorldGenerationQuiescence? _quiescence;
private readonly IWorldRevealStreamingScheduler? _streaming; private readonly IWorldRevealStreamingScheduler? _streaming;
private readonly IWorldRevealRenderResourceScheduler? _renderResources;
private long _activeGeneration; private long _activeGeneration;
private bool _worldViewportReleased;
public WorldRevealCoordinator( public WorldRevealCoordinator(
Func<uint, int, bool> isRenderNeighborhoodReady, Func<uint, int, bool> isRenderNeighborhoodReady,
@ -39,7 +51,8 @@ internal sealed class WorldRevealCoordinator
Func<uint, bool> isSpawnClaimUnhydratable, Func<uint, bool> isSpawnClaimUnhydratable,
Action<string>? log = null, Action<string>? log = null,
WorldGenerationQuiescence? quiescence = null, WorldGenerationQuiescence? quiescence = null,
IWorldRevealStreamingScheduler? streaming = null) IWorldRevealStreamingScheduler? streaming = null,
IWorldRevealRenderResourceScheduler? renderResources = null)
{ {
_readiness = new WorldRevealReadinessBarrier( _readiness = new WorldRevealReadinessBarrier(
isRenderNeighborhoodReady, isRenderNeighborhoodReady,
@ -52,6 +65,7 @@ internal sealed class WorldRevealCoordinator
_lifecycle = new WorldRevealLifecycleTelemetry(log); _lifecycle = new WorldRevealLifecycleTelemetry(log);
_quiescence = quiescence; _quiescence = quiescence;
_streaming = streaming; _streaming = streaming;
_renderResources = renderResources;
} }
public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot; public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot;
@ -66,11 +80,13 @@ internal sealed class WorldRevealCoordinator
_readiness.Begin(); _readiness.Begin();
long generation = _lifecycle.Begin(kind); long generation = _lifecycle.Begin(kind);
_activeGeneration = generation; _activeGeneration = generation;
_worldViewportReleased = false;
_quiescence?.Begin(generation); _quiescence?.Begin(generation);
_streaming?.BeginDestinationReservation( _streaming?.BeginDestinationReservation(
generation, generation,
destinationCell, destinationCell,
WorldRevealReadinessBarrier.RequiredRenderRadius(destinationCell)); WorldRevealReadinessBarrier.RequiredRenderRadius(destinationCell));
_renderResources?.BeginDestinationReveal(generation);
return generation; return generation;
} }
@ -99,26 +115,43 @@ internal sealed class WorldRevealCoordinator
public bool ObserveWait(TimeSpan elapsed) => public bool ObserveWait(TimeSpan elapsed) =>
_lifecycle.ObserveWait(elapsed); _lifecycle.ObserveWait(elapsed);
/// <summary>
/// Reopens the destination world at retail's TunnelFadeOut -&gt;
/// WorldFadeIn viewport swap without completing the one-second
/// WorldFadeIn/LoginComplete tail.
/// </summary>
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() public void Complete()
{ {
_lifecycle.Complete(); _lifecycle.Complete();
EndQuiescence(); EndLifetime();
} }
public void Cancel() public void Cancel()
{ {
_lifecycle.Cancel(); _lifecycle.Cancel();
EndQuiescence(); EndLifetime();
} }
private void EndQuiescence() private void EndLifetime()
{ {
long generation = _activeGeneration; long generation = _activeGeneration;
if (generation == 0) if (generation == 0)
return; return;
RevealWorldViewport();
_activeGeneration = 0; _activeGeneration = 0;
_streaming?.EndDestinationReservation(generation); _worldViewportReleased = false;
_quiescence?.End(generation);
} }
} }

View file

@ -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,
}
/// <summary>
/// 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.
/// </summary>
internal sealed class DormantLiveEntityStore
{
private readonly Dictionary<uint, WorldSession.EntitySpawn> _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;
}

View file

@ -20,6 +20,7 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly ILiveEntityTeardownCoordinator _teardown; private readonly ILiveEntityTeardownCoordinator _teardown;
private readonly ILocalPlayerIdentitySource _identity; private readonly ILocalPlayerIdentitySource _identity;
private readonly DormantLiveEntityStore _dormant;
private readonly Action<string>? _diagnostic; private readonly Action<string>? _diagnostic;
public LiveEntityDeletionController( public LiveEntityDeletionController(
@ -27,12 +28,14 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
ClientObjectTable objects, ClientObjectTable objects,
ILiveEntityTeardownCoordinator teardown, ILiveEntityTeardownCoordinator teardown,
ILocalPlayerIdentitySource identity, ILocalPlayerIdentitySource identity,
DormantLiveEntityStore? dormant = null,
Action<string>? diagnostic = null) Action<string>? diagnostic = null)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
_identity = identity ?? throw new ArgumentNullException(nameof(identity)); _identity = identity ?? throw new ArgumentNullException(nameof(identity));
_dormant = dormant ?? new DormantLiveEntityStore();
_diagnostic = diagnostic; _diagnostic = diagnostic;
} }
@ -46,7 +49,8 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
if (delete.Guid == _identity.ServerGuid) if (delete.Guid == _identity.ServerGuid)
return false; return false;
if (!_runtime.TryGetRecord(delete.Guid, out _)) bool hasActiveRecord = _runtime.TryGetRecord(delete.Guid, out _);
if (!hasActiveRecord)
_teardown.ForgetUnknownOwner(delete.Guid); _teardown.ForgetUnknownOwner(delete.Guid);
bool removed = _runtime.UnregisterLiveEntity( bool removed = _runtime.UnregisterLiveEntity(
@ -54,16 +58,45 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
isLocalPlayer: false, isLocalPlayer: false,
beforeTeardown: () => beforeTeardown: () =>
ObjectTableWiring.ApplyEntityDelete(_objects, delete)); ObjectTableWiring.ApplyEntityDelete(_objects, delete));
bool removedDormant = _dormant.RemoveExact(delete);
if (!removed && removedDormant)
ObjectTableWiring.ApplyEntityDelete(_objects, delete);
if (removed) if (removed)
{ {
_dormant.RemoveExact(delete);
_diagnostic?.Invoke( _diagnostic?.Invoke(
$"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); $"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) => public bool Prune(LiveEntityPruneCandidate candidate)
Delete(new DeleteObject.Parsed( {
candidate.ServerGuid, if (!_runtime.TryGetRecord(
candidate.Generation)); 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;
}
} }

View file

@ -178,6 +178,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
private readonly IAcceptedLocalPhysicsTimestampPublisher _timestamps; private readonly IAcceptedLocalPhysicsTimestampPublisher _timestamps;
private readonly ILocalPlayerIdentitySource _identity; private readonly ILocalPlayerIdentitySource _identity;
private readonly LiveEntityDeletionController _deletion; private readonly LiveEntityDeletionController _deletion;
private readonly DormantLiveEntityStore _dormant;
private readonly Action<string>? _diagnostic; private readonly Action<string>? _diagnostic;
public LiveEntityHydrationController( public LiveEntityHydrationController(
@ -192,6 +193,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
IAcceptedLocalPhysicsTimestampPublisher timestamps, IAcceptedLocalPhysicsTimestampPublisher timestamps,
ILocalPlayerIdentitySource identity, ILocalPlayerIdentitySource identity,
LiveEntityDeletionController deletion, LiveEntityDeletionController deletion,
DormantLiveEntityStore? dormant = null,
Action<string>? diagnostic = null) Action<string>? diagnostic = null)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
@ -205,12 +207,41 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
_timestamps = timestamps ?? throw new ArgumentNullException(nameof(timestamps)); _timestamps = timestamps ?? throw new ArgumentNullException(nameof(timestamps));
_identity = identity ?? throw new ArgumentNullException(nameof(identity)); _identity = identity ?? throw new ArgumentNullException(nameof(identity));
_deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion));
_dormant = dormant ?? new DormantLiveEntityStore();
_diagnostic = diagnostic; _diagnostic = diagnostic;
} }
internal event Action<uint>? AppearanceApplied; internal event Action<uint>? AppearanceApplied;
public void OnCreate(WorldSession.EntitySpawn spawn) 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. // DatCollection uses one mutable reader cursor shared with streaming.
// Registration, canonical reread, and projection construction remain // 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 // An accepted retransmit can still be parked behind a retryable
// teardown tombstone. It owns no active record and therefore must // 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) if (registration.Record is not { } record)
return; return;
_dormant.RemoveThroughAcceptedCreate(spawn);
ulong createIntegrationVersion = record.CreateIntegrationVersion; ulong createIntegrationVersion = record.CreateIntegrationVersion;
try try
@ -243,7 +279,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
_objects, _objects,
spawn, spawn,
replaceGeneration: result.Disposition is replaceGeneration: result.Disposition is
AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration, AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration
|| dormantDisposition is DormantCreateDisposition.NewGeneration,
accepting: () => _runtime.IsCurrentCreateIntegration( accepting: () => _runtime.IsCurrentCreateIntegration(
record, record,
createIntegrationVersion)) createIntegrationVersion))
@ -420,6 +457,11 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
/// </summary> /// </summary>
public void OnLandblockLoaded(uint loadedLandblockId) 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) if (_runtime.Count == 0)
return; return;

View file

@ -89,6 +89,7 @@ internal sealed class LiveEntityLivenessController
private readonly LiveEntityRuntime _runtime; private readonly LiveEntityRuntime _runtime;
private readonly ILocalPlayerIdentitySource _identity; private readonly ILocalPlayerIdentitySource _identity;
private readonly ILiveEntityPruneSink _prune; private readonly ILiveEntityPruneSink _prune;
private readonly DormantLiveEntityStore? _dormant;
private readonly LiveEntityLivenessTracker _tracker = new(); private readonly LiveEntityLivenessTracker _tracker = new();
private readonly List<LiveEntityLivenessSample> _samples = new(); private readonly List<LiveEntityLivenessSample> _samples = new();
private double _nextMaintenanceAt; private double _nextMaintenanceAt;
@ -96,11 +97,13 @@ internal sealed class LiveEntityLivenessController
public LiveEntityLivenessController( public LiveEntityLivenessController(
LiveEntityRuntime runtime, LiveEntityRuntime runtime,
ILocalPlayerIdentitySource identity, ILocalPlayerIdentitySource identity,
ILiveEntityPruneSink prune) ILiveEntityPruneSink prune,
DormantLiveEntityStore? dormant = null)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_identity = identity ?? throw new ArgumentNullException(nameof(identity)); _identity = identity ?? throw new ArgumentNullException(nameof(identity));
_prune = prune ?? throw new ArgumentNullException(nameof(prune)); _prune = prune ?? throw new ArgumentNullException(nameof(prune));
_dormant = dormant;
} }
public void Tick(double now) public void Tick(double now)
@ -153,6 +156,7 @@ internal sealed class LiveEntityLivenessController
public void Clear() public void Clear()
{ {
_tracker.Clear(); _tracker.Clear();
_dormant?.Clear();
_samples.Clear(); _samples.Clear();
_nextMaintenanceAt = 0; _nextMaintenanceAt = 0;
} }

View file

@ -211,7 +211,8 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
/// The one logical record for a server object incarnation. The record survives /// The one logical record for a server object incarnation. The record survives
/// loaded/pending landblock movement and temporary loss of its render bucket. /// loaded/pending landblock movement and temporary loss of its render bucket.
/// Only an accepted DeleteObject, session reset, newer INSTANCE_TS, or the /// 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.
/// </summary> /// </summary>
public sealed class LiveEntityRecord public sealed class LiveEntityRecord
{ {

View file

@ -264,6 +264,29 @@ public sealed class CompositeTextureArrayCachePolicyTests
Assert.Equal(2, backend.Uploads.Count); 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] [Fact]
public void OversizedTextureIsAllowedAsOnlyUploadToGuaranteeProgress() public void OversizedTextureIsAllowedAsOnlyUploadToGuaranteeProgress()
{ {

View file

@ -59,37 +59,100 @@ public sealed class PaperdollFramePresenterTests
presenter.Render(); presenter.Render();
Assert.Equal(2, factory.BuildCount); Assert.Equal(2, factory.BuildCount);
Assert.Equal(2, renderer.Dolls.Count); Assert.Single(renderer.Dolls);
Assert.Equal(3, renderer.RenderCount); Assert.Equal(3, renderer.RenderCount);
Assert.False(presenter.IsDirty); 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, Id = 42u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero, Position = Vector3.Zero,
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
Scale = scale,
}; };
[Fact] [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 view = new RecordingView();
var factory = new RecordingFactory { CanBuild = false }; var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory); var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
factory.CanBuild = false;
presenter.MarkDirty();
presenter.Render(); presenter.Render();
presenter.Render(); presenter.Render();
Assert.True(presenter.IsDirty); 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, factory.BuildCount);
Assert.Equal(2, renderer.Dolls.Count); Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls);
Assert.All(renderer.Dolls, Assert.Null);
Assert.Equal(2, renderer.RenderCount);
Assert.Equal([0u, 0u], view.TextureHandles);
} }
[Fact] [Fact]
@ -222,8 +285,8 @@ public sealed class PaperdollFramePresenterTests
private sealed class RecordingFactory : IPaperdollDollFactory private sealed class RecordingFactory : IPaperdollDollFactory
{ {
public bool CanBuild { get; init; } = true; public bool CanBuild { get; set; } = true;
public WorldEntity? Doll { get; init; } public WorldEntity? Doll { get; set; }
public int BuildCount { get; private set; } public int BuildCount { get; private set; }
public bool TryBuild(out WorldEntity? doll) public bool TryBuild(out WorldEntity? doll)

View file

@ -3,14 +3,31 @@ using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering; 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] [Fact]
public void PartialAcquireRollsBackEveryCommittedSibling() public void PartialAcquireRollsBackEveryCommittedSibling()
{ {
var adapter = new RecordingMeshAdapter(); var adapter = new RecordingMeshAdapter();
adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1); adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1);
var owner = new PortalMeshReferenceOwner( var owner = new SyntheticEntityMeshReferenceOwner(
adapter, adapter,
[0x01000001u, 0x01000002u]); [0x01000001u, 0x01000002u]);
@ -30,7 +47,7 @@ public sealed class PortalMeshReferenceOwnerTests
{ {
var adapter = new RecordingMeshAdapter(); var adapter = new RecordingMeshAdapter();
adapter.FailIncrementAfterCommit(0x01000001u, attempts: 1); adapter.FailIncrementAfterCommit(0x01000001u, attempts: 1);
var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); var owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]);
Assert.Throws<AggregateException>(owner.Acquire); Assert.Throws<AggregateException>(owner.Acquire);
Assert.Equal(0, adapter.TotalReferences); Assert.Equal(0, adapter.TotalReferences);
@ -48,7 +65,7 @@ public sealed class PortalMeshReferenceOwnerTests
var adapter = new RecordingMeshAdapter(); var adapter = new RecordingMeshAdapter();
adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1); adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1);
adapter.FailDecrementBeforeCommit(0x01000002u, attempts: 1); adapter.FailDecrementBeforeCommit(0x01000002u, attempts: 1);
var owner = new PortalMeshReferenceOwner( var owner = new SyntheticEntityMeshReferenceOwner(
adapter, adapter,
[0x01000001u, 0x01000002u]); [0x01000001u, 0x01000002u]);
@ -69,7 +86,7 @@ public sealed class PortalMeshReferenceOwnerTests
public void DisposeAttemptsEveryReferenceAndRetriesOnlyPendingRelease() public void DisposeAttemptsEveryReferenceAndRetriesOnlyPendingRelease()
{ {
var adapter = new RecordingMeshAdapter(); var adapter = new RecordingMeshAdapter();
var owner = new PortalMeshReferenceOwner( var owner = new SyntheticEntityMeshReferenceOwner(
adapter, adapter,
[0x01000001u, 0x01000002u]); [0x01000001u, 0x01000002u]);
owner.Acquire(); owner.Acquire();
@ -92,7 +109,7 @@ public sealed class PortalMeshReferenceOwnerTests
public void CommittedReleaseFailureCompletesOwnershipWithoutDoubleDecrement() public void CommittedReleaseFailureCompletesOwnershipWithoutDoubleDecrement()
{ {
var adapter = new RecordingMeshAdapter(); var adapter = new RecordingMeshAdapter();
var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); var owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]);
owner.Acquire(); owner.Acquire();
adapter.FailDecrementAfterCommit(0x01000001u, attempts: 1); adapter.FailDecrementAfterCommit(0x01000001u, attempts: 1);
@ -107,12 +124,12 @@ public sealed class PortalMeshReferenceOwnerTests
[Fact] [Fact]
public void DisposeReentryDuringAcquireReconcilesBeforeReturning() public void DisposeReentryDuringAcquireReconcilesBeforeReturning()
{ {
PortalMeshReferenceOwner? owner = null; SyntheticEntityMeshReferenceOwner? owner = null;
var adapter = new RecordingMeshAdapter var adapter = new RecordingMeshAdapter
{ {
AfterIncrement = _ => owner!.Dispose(), AfterIncrement = _ => owner!.Dispose(),
}; };
owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]);
Assert.Throws<ObjectDisposedException>(owner.Acquire); Assert.Throws<ObjectDisposedException>(owner.Acquire);
owner.Dispose(); owner.Dispose();

View file

@ -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<bool>();
var second = new List<bool>();
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);
}
}

View file

@ -254,6 +254,119 @@ public sealed class GpuWorldStateVisibilityTests
Assert.Empty(reloaded.Entities); 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<WorldEntity>()));
Assert.Same(remote, Assert.Single(state.Entities));
state.AddLandblock(new LoadedLandblock(
pendingLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
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] [Fact]
public void BatchedVisibilityObserver_SeesCommittedFlatAndResidentViews() public void BatchedVisibilityObserver_SeesCommittedFlatAndResidentViews()
{ {

View file

@ -330,7 +330,7 @@ public sealed class LandblockConcretePresentationPipelineTests
} }
[Fact] [Fact]
public void FullRetirement_DetachesFirstAndBalancesEveryConcreteOwner() public void FullRetirement_DetachesAndCleansStaticsWithoutMutatingLiveOwners()
{ {
var calls = new List<string>(); var calls = new List<string>();
ConcreteFixture fixture = Fixture(calls); ConcreteFixture fixture = Fixture(calls);
@ -360,12 +360,13 @@ public sealed class LandblockConcretePresentationPipelineTests
Assert.Equal(0, pipeline.PendingRetirementCount); Assert.Equal(0, pipeline.PendingRetirementCount);
Assert.Equal(0, fixture.Runner.ActiveOwnerCount); Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _)); 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( Assert.False(fixture.Translucency.TryGetCurrentValue(
staticEntity.Id, staticEntity.Id,
0u, 0u,
out _)); out _));
Assert.False(fixture.Translucency.TryGetCurrentValue( Assert.True(fixture.Translucency.TryGetCurrentValue(
liveEntity.Id, liveEntity.Id,
0u, 0u,
out _)); out _));

View file

@ -101,7 +101,7 @@ public sealed class LandblockPresentationPipelineTests
} }
[Fact] [Fact]
public void OriginRecenter_BlocksOriginAndBootstrapUntilEveryOldOwnerConverges() public void OriginRecenter_CommitsAfterDetachWhileOldOwnerCleanupContinues()
{ {
const uint centerId = 0x1919FFFFu; const uint centerId = 0x1919FFFFu;
const uint neighborId = 0x191AFFFFu; const uint neighborId = 0x191AFFFFu;
@ -154,11 +154,63 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(Converge(recenter, controller, 0x19, 0x19)); Assert.True(Converge(recenter, controller, 0x19, 0x19));
Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY)); Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY));
Assert.Equal(0, controller.PendingRetirementCount); Assert.Equal(1, controller.PendingRetirementCount);
controller.Tick(0x22, 0x23); controller.Tick(0x22, 0x23);
Assert.Equal(0, controller.PendingRetirementCount);
Assert.Equal([0x2223FFFFu], enqueued); 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<WorldEntity>()));
}
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x40, 0x40));
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
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] [Fact]
public void OriginRecenter_PreparationFailureBeforeDetachRetainsOldOriginAndResidents() public void OriginRecenter_PreparationFailureBeforeDetachRetainsOldOriginAndResidents()
{ {

View file

@ -463,6 +463,146 @@ public sealed class LandblockRetirementCoordinatorTests
Assert.Equal(0, coordinator.PendingCount); 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<WorldEntity>()),
new(
landblockId & 0xFFFF0000u,
LandblockRetirementKind.Full,
Array.Empty<WorldEntity>()),
];
Assert.Throws<ArgumentException>(() =>
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<WorldEntity>()))
.ToArray();
Exception? failure = coordinator.AdoptDetachedFull(receipts);
Assert.IsType<InvalidOperationException>(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<uint>();
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<WorldEntity>()))
.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] [Fact]
public void ObserverFailure_DrainsReentrantDifferentIdBeginBeforeRethrow() public void ObserverFailure_DrainsReentrantDifferentIdBeginBeforeRethrow()
{ {

View file

@ -255,6 +255,26 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.False(harness.Reveal.Snapshot.Cancelled); Assert.False(harness.Reveal.Snapshot.Cancelled);
} }
[Fact]
public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive()
{
var order = new List<string>();
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] [Fact]
public void NewerStart_ReplacesOldDestinationWithoutReusingIt() public void NewerStart_ReplacesOldDestinationWithoutReusingIt()
{ {

View file

@ -110,6 +110,39 @@ public sealed class StreamingCompletionQueueTests
Assert.Equal(0, snapshot.OldestAgeMilliseconds); 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] [Fact]
public void ExactReferenceRemovalDoesNotConflateEqualRecords() public void ExactReferenceRemovalDoesNotConflateEqualRecords()
{ {

View file

@ -11,6 +11,17 @@ namespace AcDream.App.Tests.Streaming;
public sealed class StreamingWorkBudgetTests 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( private static StreamingWorkBudget Budget(
double milliseconds = 2, double milliseconds = 2,
int completions = 4, int completions = 4,
@ -563,15 +574,17 @@ public sealed class StreamingWorkBudgetTests
Assert.Equal([center], applied); Assert.Equal([center], applied);
Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions); 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(44, controller.WorkDiagnostics.LastFrame.Used.GpuUploadBytes);
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
controller.Tick(32, 32); 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); Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
controller.EndDestinationReservation(1);
for (int i = 0; for (int i = 0;
i < 4 i < 4
&& (controller.WorkDiagnostics.DeferredCompletions != 0 && (controller.WorkDiagnostics.DeferredCompletions != 0
@ -586,13 +599,51 @@ public sealed class StreamingWorkBudgetTests
} }
[Fact] [Fact]
public void StaleGenerationResultsConsumeAdmissionButRetainNoPayload() public void IncompleteReveal_DoesNotStarveOrdinaryWorkBeforeDestinationArrives()
{ {
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32); uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
var source = new QueueCompletionSource( uint ordinary = StreamingRegion.EncodeLandblockIdForTest(33, 33);
Loaded(center, generation: 1), var source = new QueueCompletionSource(Loaded(ordinary));
Loaded(StreamingRegion.EncodeLandblockIdForTest(32, 33), generation: 1), var applied = new List<uint>();
Loaded(center)); 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; int applied = 0;
StreamingController controller = Controller( StreamingController controller = Controller(
source, source,
@ -601,15 +652,12 @@ public sealed class StreamingWorkBudgetTests
controller.Tick(32, 32); controller.Tick(32, 32);
Assert.Equal(0, applied); Assert.Equal(1, applied);
Assert.Equal(1, source.BacklogCount); Assert.Equal(0, source.BacklogCount);
Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions); Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions);
Assert.Equal(0, controller.WorkDiagnostics.DeferredAdoptedCpuBytes); Assert.Equal(0, controller.WorkDiagnostics.DeferredAdoptedCpuBytes);
Assert.Equal(2, controller.WorkDiagnostics.LastFrame.Used.CompletionAdmissions); Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.CompletionAdmissions);
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.AdoptedCpuBytes); Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.AdoptedCpuBytes);
controller.Tick(32, 32);
Assert.Equal(1, applied);
} }
private static StreamingController Controller( private static StreamingController Controller(
@ -688,6 +736,9 @@ public sealed class StreamingWorkBudgetTests
public int BacklogCount => _results.Count; public int BacklogCount => _results.Count;
public void Enqueue(LandblockStreamResult result) =>
_results.Enqueue(result);
public bool TryPeek(out LandblockStreamResult? result) public bool TryPeek(out LandblockStreamResult? result)
{ {
if (_results.TryPeek(out LandblockStreamResult? peeked)) if (_results.TryPeek(out LandblockStreamResult? peeked))

View file

@ -15,7 +15,8 @@ public sealed class WorldRevealCoordinatorTests
public WorldRevealCoordinator Build( public WorldRevealCoordinator Build(
List<string>? logs = null, List<string>? logs = null,
IWorldRevealStreamingScheduler? streaming = null) => new( IWorldRevealStreamingScheduler? streaming = null,
IWorldRevealRenderResourceScheduler? renderResources = null) => new(
isRenderNeighborhoodReady: (_, _) => RenderReady, isRenderNeighborhoodReady: (_, _) => RenderReady,
isSpawnCellReady: _ => CollisionReady, isSpawnCellReady: _ => CollisionReady,
isTerrainNeighborhoodReady: (_, _) => CollisionReady, isTerrainNeighborhoodReady: (_, _) => CollisionReady,
@ -28,7 +29,8 @@ public sealed class WorldRevealCoordinatorTests
}, },
isSpawnClaimUnhydratable: _ => false, isSpawnClaimUnhydratable: _ => false,
log: logs is null ? null : new Action<string>(logs.Add), log: logs is null ? null : new Action<string>(logs.Add),
streaming: streaming); streaming: streaming,
renderResources: renderResources);
} }
[Fact] [Fact]
@ -152,6 +154,47 @@ public sealed class WorldRevealCoordinatorTests
Assert.Equal(1, audio.ResumeCalls); 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] [Fact]
public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion() public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion()
{ {
@ -176,6 +219,28 @@ public sealed class WorldRevealCoordinatorTests
Assert.Equal([second], scheduler.Ends); 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 private sealed class RecordingAudioQuiescence : AcDream.App.Audio.IWorldAudioQuiescence
{ {
public int SuspendCalls { get; private set; } public int SuspendCalls { get; private set; }
@ -201,4 +266,17 @@ public sealed class WorldRevealCoordinatorTests
public void EndDestinationReservation(long revealGeneration) => public void EndDestinationReservation(long revealGeneration) =>
Ends.Add(revealGeneration); Ends.Add(revealGeneration);
} }
private sealed class RecordingRenderResourceScheduler
: IWorldRevealRenderResourceScheduler
{
public List<long> Begins { get; } = [];
public List<long> Ends { get; } = [];
public void BeginDestinationReveal(long revealGeneration) =>
Begins.Add(revealGeneration);
public void EndDestinationReveal(long revealGeneration) =>
Ends.Add(revealGeneration);
}
} }

View file

@ -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);
}

View file

@ -636,19 +636,140 @@ public sealed class LiveEntityHydrationControllerTests
} }
[Fact] [Fact]
public void Prune_UsesTheSameExactDeleteTransaction() public void Prune_ReleasesActiveOwnersButRetainsColdAuthoritativeSnapshot()
{ {
using var fixture = new Fixture(originKnown: true); using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
ClientObject retainedObject = fixture.Objects.Get(Guid)!;
Assert.True(fixture.Controller.OnPrune( Assert.True(fixture.Controller.OnPrune(
new LiveEntityPruneCandidate(Guid, Generation: 1))); new LiveEntityPruneCandidate(Guid, Generation: 1)));
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); 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); 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] [Fact]
public void Delete_DrainsIndependentFailuresAndRetryDoesNotReplayObjectRemoval() public void Delete_DrainsIndependentFailuresAndRetryDoesNotReplayObjectRemoval()
{ {
@ -1690,6 +1811,7 @@ public sealed class LiveEntityHydrationControllerTests
public readonly RecordingOrigin Origin; public readonly RecordingOrigin Origin;
public readonly RecordingNetworkSink Network = new(); public readonly RecordingNetworkSink Network = new();
public readonly RecordingTeardownCoordinator Teardown = new(); public readonly RecordingTeardownCoordinator Teardown = new();
public readonly DormantLiveEntityStore Dormant = new();
public readonly RecordingTimestampPublisher Timestamps; public readonly RecordingTimestampPublisher Timestamps;
public readonly LiveEntityHydrationController Controller; public readonly LiveEntityHydrationController Controller;
@ -1741,7 +1863,8 @@ public sealed class LiveEntityHydrationControllerTests
Runtime, Runtime,
Objects, Objects,
Teardown, Teardown,
identity); identity,
Dormant);
Controller = new LiveEntityHydrationController( Controller = new LiveEntityHydrationController(
Runtime, Runtime,
Objects, Objects,
@ -1753,7 +1876,8 @@ public sealed class LiveEntityHydrationControllerTests
Network, Network,
Timestamps, Timestamps,
identity, identity,
deletion); deletion,
Dormant);
} }
public LiveEntityRecord Record public LiveEntityRecord Record

View file

@ -70,6 +70,46 @@ public class StreamingControllerTests
Assert.Empty(fake.Unloads); 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] [Fact]
public void ReconfigureRadii_SmallerWindowUnloadsOnlyOutsideResidents() public void ReconfigureRadii_SmallerWindowUnloadsOnlyOutsideResidents()
{ {