diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index a4a05915..8e8093ed 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -79,20 +79,39 @@ order inside one instanced draw, with only DAT blend-mode changes splitting a
run. The identical location recovered to about 153 FPS / 6.6 ms in the
connected Release client.
+A second, genuinely cumulative portal regression remained: ACE retains
+`KnownObjects` across normal teleports, while acdream retained every old
+`LiveEntityRecord` indefinitely. Each destination therefore left animation,
+effect, and render owners active; after enough trips the render thread blocked
+inside the GL driver and C95B fell persistently to 3–12 FPS. The client now
+ports retail's 25-second leave-visibility destruction queue, using current
+spatial residency plus holtburger's conservative 384-unit ACE envelope until
+exact ObjCell PVS is available. Expiry uses the normal generation-safe F747
+teardown. The modern `GlobalMeshBuffer` also allocates one vertex range per
+mesh, coalesces released ranges, and reuses them on cache eviction instead of
+duplicating vertices per material and growing forever. A connected five-region
+round trip returned live/animation ownership to baseline, recreated C95B on
+revisit, and held its normal 60–80 FPS.
+
**Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`;
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`;
`src/AcDream.App/Rendering/ParticleRenderer.cs`;
`src/AcDream.App/Rendering/Shaders/particle.vert`;
`src/AcDream.App/Rendering/Shaders/particle.frag`;
-`src/AcDream.App/Rendering/RetailPViewRenderer.cs`.
+`src/AcDream.App/Rendering/RetailPViewRenderer.cs`;
+`src/AcDream.App/World/LiveEntityLivenessController.cs`;
+`src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs`.
**Research:**
-`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`.
+`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`;
+`docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`.
**Acceptance:** At a translucent lifestone, smoke or flame behind the crystal
is attenuated by it while an effect in front remains bright. The lifestone's
own transparency, nearby candle/portal particles, indoor/outdoor PView
transitions, paperdoll rendering, and portal-space rendering do not regress.
+Repeated distant portal trips do not accumulate live/animation owners or
+degrade FPS, and revisiting an expired region restores its objects.
---
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 6ce6e706..127cf64e 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -169,7 +169,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` |
| ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` |
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
-| AP-69 | On a landblock unload/reload, acdream retains the accepted live record and the same `WorldEntity`. Full unload moves non-persistent live projections to that landblock's pending bucket; Near→Far demotion drops only dat-static layers and leaves live projections resident. Neither path replays logical registration or reconstructs from a stale CreateObject. ACE will NOT re-broadcast objects whose guid remains in its per-player `KnownObjects` set, matching retail's retained object-table model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained record is pruned ONLY by an explicit server `DeleteObject` (0xF747) or session teardown. (#138) | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `GameWindow.RehydrateServerEntitiesForLandblock`; `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Stable logical identity is retail-faithful and prevents duplicated meshes, scripts, emitters, or stale-spawn teleports while remaining independent of an ACE re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) can reappear at its last-known position, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | `CPhysicsObj::change_cell` 0x00513390; `CPhysicsObj::leave_world` 0x005155A0; ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS` |
+| 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`; `GameWindow.OnLiveEntityPruned` | 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-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
| AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values |
diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
index 85f0b8eb..feb3cb8f 100644
--- a/docs/architecture/worldbuilder-inventory.md
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -296,7 +296,7 @@ their render transaction could publish (#218).
| Component | What it does |
|---|---|
| `MinimapRenderer` | Top-down minimap |
-| `GlobalMeshBuffer` | Shared GPU mesh buffer |
+| `GlobalMeshBuffer` | Shared GPU mesh buffer; one reclaimable vertex/index allocation per mesh, released by `ObjectMeshManager` eviction |
| `GpuResourceManager` | GPU resource lifecycle |
| `InstanceData` | Instanced draw data |
| `TextureHelpers` | INDEX16, P8, BGRA, DXT decode + alpha (canonical port) |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 79d5e49d..cc7c3861 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -558,9 +558,11 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant.
- **Missile/portal VFX campaign Step 7 implemented and independently reviewed 2026-07-14.** `ProjectileController` now attaches the Step 6 body to the canonical `LiveEntityRecord` after materialization and advances arrows, bolts, and spell missiles without another GUID map, renderer registration, or stale-spawn reconstruction. It commits predicted frames through `WorldEntity.SetPosition`, republishes static effect roots, and keeps collision shadows plus canonical full-cell buckets synchronized. A predicted crossing into an unloaded bucket suspends the body and shadow in that same quantum; pending/pickup residence retains the shadow registration for exact re-entry, and hydration plus the 96-unit retail activity gate restart at the current clock without backlog. Fresh State/Vector/Position/Movement packets correct or stop the same body after retail timestamp gates; SetState reaches the canonical body before optional projectile acquisition, and a non-finite local receipt clock cannot consume first classification. Removing Missile retains ordinary active-body physics (delegating to MovementManager when present), while both owners share the body and incarnation-scoped live-record cell. CreateObject vectors are installed only at body construction, never replayed by later SetState. Delete, reset, and GUID reuse use normal logical teardown; ACE remains authoritative for impact, damage, effects, and deletion.
-- **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains because the separate 25-second/384-metre liveness cull is still unported.
+- **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains for exact ObjCell-PVS/preview-retention parity after the 2026-07-18 liveness port.
- **Missile/portal VFX campaign Step 9 automated hardening implemented and independently reviewed 2026-07-14; final two-client visual gate pending.** A deterministic 96-owner App fixture drives canonical missiles and effect owners through projectile updates, repeated DAT-effect scheduling, loaded↔pending↔loaded landblock churn, light/particle withdrawal and recovery, accepted deletes, same-GUID generation reuse, never-created F754 queues, and session reset. It asserts balanced logical render registration and zero residual records/bodies/projectiles, spatial buckets/rescues, shadows (including suspended registrations), effect profiles/packets, PhysicsScript FIFOs/anchors/delayed calls, particle bindings/logical IDs/render-pass owners, poses, light-controller/sink/manager owners, and stale record component references. A companion twelve-cycle gate drives exact recall motion `0x10000153` through AnimationSequencer/CallPES, Hidden, deferred remote placement, hydration, and UnHide; a second 96-owner `EntitySpawnAdapter` gate balances actual mesh-adapter reference counts without GL. The pass fixed undrained persistent rescue retention, delayed stale visibility edges, GUID-scoped teardown, create resurrection after a nested delete/reset, non-atomic resource registration, double teardown from a re-entrant session-clear callback, observer failure stranding later visibility edges, and stale outer projection transactions overwriting callback-created replacements. Teardown now removes exact projection references and generation/local-ID owners, uses per-GUID/session lifetime epochs plus per-record projection tokens, drains visibility fan-out before aggregating failures, and finishes or supersedes canonical commits before surfacing observer errors. Core remains GL/backend-free, panels remain on UI abstractions, and projectiles still draw through ordinary `WbDrawDispatcher` live-entity submission rather than a global projectile pass. AP-69 and TS-49 remain; AP-83/AP-91 now explicitly describe only a future mover that enables PerfectClip.
+- **Portal lifetime/performance correction 2026-07-18; user visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. Expiry reuses the exact generation-safe F747 teardown. The modern `GlobalMeshBuffer` now uploads vertices once per mesh and owns coalescing vertex/index ranges released by zero-reference LRU eviction. A connected five-region route returned animation ownership to baseline, reclaimed/reused GPU ranges, recreated the starting region on revisit, and prevented the prior persistent 3–12 FPS collapse. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain.
+
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
**Acceptance:** chat messages display in a retail-style panel, health/stamina/mana orbs fill correctly, attributes panel shows player stats, inventory opens with drag-drop working, and sound plays on hit/footstep.
diff --git a/docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md b/docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md
new file mode 100644
index 00000000..0d3d4e26
--- /dev/null
+++ b/docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md
@@ -0,0 +1,139 @@
+# Retail object liveness and modern mesh reclamation
+
+## Symptom and measured cause
+
+After several distant portal transitions, acdream could fall from normal
+frame rates to 3–12 FPS and remain there. Lightweight ownership counters
+showed that each destination left its server objects in `LiveEntityRuntime`:
+the live-record count, animated-owner count, particle/effect ownership, and
+materialized render owners grew monotonically. The render thread eventually
+blocked in the native GL driver even though the current destination submitted
+only a small number of visible particles.
+
+The modern shared mesh buffers had a second cumulative ownership defect. The
+old `GlobalMeshBuffer.Append` copied the complete vertex array once for every
+material batch and only advanced append offsets. `ObjectMeshManager` eviction
+removed cache entries and textures but could not return those vertex/index
+ranges. This was not the first-frame collapse mechanism, but it guaranteed
+that portal and cache churn permanently consumed GPU address space.
+
+## Named-retail oracle
+
+The retail client owns one object table and separates leaving visibility from
+logical destruction:
+
+- `CPhysicsObj::prepare_to_leave_visibility` (`0x00511F40`) removes shadows,
+ leaves the cell, and calls `CObjectMaint::AddObjectToBeDestroyed` for the
+ object and each child.
+- `CObjectMaint::AddObjectToBeDestroyed` (`0x00508F70`) replaces any existing
+ deadline with `Timer::cur_time + 25.0`, then inserts the GUID in the ordered
+ destruction queue.
+- `CPhysicsObj::prepare_to_enter_world` (`0x00511FA0`) cancels the queued
+ destruction for the object and its children before re-entry.
+- `CObjectMaint::UseTime` (`0x005089B0`) rebuilds the visible-object table at
+ one-second intervals and drains every due destruction entry in time order.
+- `CObjectMaint::DeleteObject` (`0x00508460`) performs symmetric teardown:
+ exit/leave world, remove object/null-object table ownership, remove the
+ destruction entry, unparent, unparent children, then destroy the object.
+- `SmartBox::Reset` (`0x00453BF0`) destroys the complete object table only for
+ a true session/reset path. A portal is not a session reset.
+
+The 25-second mechanism is exact retail behavior. The client decides that an
+object left visibility from ObjCell/PVS membership, not from a raw distance
+constant.
+
+## ACE compatibility boundary
+
+ACE retains a player's `KnownObjects` across normal teleports. Its optional
+`teleport_visibility_fix` is disabled by default and is explicitly described
+as a non-retail automated workaround. Therefore a client cannot depend on an
+F747 `DeleteObject` when an old destination leaves visibility.
+
+Holtburger independently implements the client half with the exact retail
+25-second timeout and a conservative 384-world-unit distance envelope while
+exact ACE ObjCell visibility is unavailable. It also treats scene-nearby
+objects as visible and retains held, equipped, container, wielder, parent, and
+preview-owned objects. Sources:
+
+- [ACE ObjectMaint](https://github.com/ACEmulator/ACE/blob/650c5b75ae909957feaf58db320e46be16502653/Source/ACE.Server/Physics/Common/ObjectMaint.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)
+
+acdream uses the same compatibility boundary: currently spatially resident
+records are visible; otherwise a 384-unit 3-D global-landblock distance is the
+conservative fallback. The distance is an adaptation, not a claimed retail
+constant, and remains documented in divergence AP-69 until exact ObjCell/PVS
+membership drives the deadline.
+
+## Port pseudocode
+
+```text
+once per second:
+ if player has no authoritative world position:
+ do nothing
+
+ for each live record with a world position except the player:
+ retained = record is attached
+ OR has container owner
+ OR has wielder owner
+ OR has physics parent
+
+ visible = record has a resident spatial projection
+ OR global_3d_distance(player, record) <= 384
+
+ if visible OR retained:
+ cancel record deadline
+ else if no deadline exists for this GUID + generation:
+ deadline = now + 25 seconds
+ else if deadline <= now:
+ emit (GUID, generation) prune candidate
+
+ discard deadlines for records no longer in the canonical table
+
+for each due candidate:
+ re-read canonical record
+ if GUID and generation still match:
+ route through the normal accepted DeleteObject lifecycle
+```
+
+Routing expiry through `LiveEntityRuntime.UnregisterLiveEntity` is important:
+it performs the same generation gate, object-table removal, equipped-child
+cleanup, spatial withdrawal, animation/physics/effect teardown, and render
+resource release as an authoritative F747. A second teardown path would drift
+and recreate the original leak.
+
+## Reclaimable shared-buffer contract
+
+```text
+upload one mesh:
+ collect non-empty material index batches in authored order
+ allocate one contiguous vertex range
+ allocate one contiguous index range
+ upload vertices exactly once
+ append each index batch inside that index range
+ record each batch's first-index and the shared base-vertex
+
+evict one zero-reference mesh:
+ release its index range
+ release its vertex range
+ coalesce adjacent free ranges
+ release atlas texture ownership
+
+if no contiguous range fits:
+ grow the GL buffer
+ copy through the allocator high-water mark
+ extend/coalesce the trailing free range
+ allocate again
+```
+
+The range allocator uses best fit to preserve larger holes, rejects overlap or
+double release before changing accounting, and keeps buffer resizing on the
+existing update/render thread.
+
+## Connected verification
+
+A Release client was driven through C95B → 3032 → F682 → 0905 → 0904 → C95B.
+Old-region deadlines expired after 25 seconds; live and animation-owner counts
+fell instead of accumulating. Released mesh ranges became reusable. Returning
+to C95B rebuilt its objects correctly, and the location remained at its normal
+roughly 60–80 FPS instead of the prior persistent 3–12 FPS state.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index f17dcf99..ab1d9862 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -460,6 +460,7 @@ public sealed class GameWindow : IDisposable
/// .
///
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
+ private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
///
/// Per-remote-entity physics + motion stack — verbatim application of
@@ -2535,6 +2536,10 @@ public sealed class GameWindow : IDisposable
if (record.WorldEntity is { } entity)
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
};
+ _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
+ _liveEntities,
+ () => _playerServerGuid,
+ OnLiveEntityPruned);
_projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities,
_physicsEngine,
@@ -2879,6 +2884,7 @@ public sealed class GameWindow : IDisposable
_particleVisibility.Reset();
try
{
+ _liveEntityLiveness?.Clear();
_liveEntities?.Clear();
}
finally
@@ -4376,6 +4382,17 @@ public sealed class GameWindow : IDisposable
}
}
+ private void OnLiveEntityPruned(AcDream.App.World.LiveEntityPruneCandidate candidate)
+ {
+ // ACE retains KnownObjects across normal teleports and consequently
+ // does not issue an F747 when an old region leaves client visibility.
+ // Route the 25-second client liveness expiry through the exact same
+ // generation gate and symmetric teardown as a wire ObjectDelete.
+ OnLiveEntityDeleted(new AcDream.Core.Net.Messages.DeleteObject.Parsed(
+ candidate.ServerGuid,
+ candidate.Generation));
+ }
+
///
/// Server broadcast a 0xF625 ObjDescEvent — a creature/player's
/// appearance changed (equip / unequip / tailoring / recipe result /
@@ -9142,6 +9159,7 @@ public sealed class GameWindow : IDisposable
// the recall action retires before ACE's Hidden SetState freezes its
// PartArray at the teleport boundary.
_liveFrameCoordinator.Tick(frameDelta);
+ _liveEntityLiveness?.Tick(ClientTimerNow());
// Usually F751 activates immediately. This no-op convergence check
// covers the session edge where the canonical player exists before
diff --git a/src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs b/src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs
new file mode 100644
index 00000000..9ce0370b
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs
@@ -0,0 +1,117 @@
+namespace AcDream.App.Rendering.Wb;
+
+internal readonly record struct MeshBufferRange(int Offset, int Length)
+{
+ public int End => checked(Offset + Length);
+}
+
+///
+/// Best-fit allocator for one element-addressed GPU buffer. Released ranges
+/// are coalesced immediately so ObjectMeshManager eviction returns storage to
+/// the shared modern-rendering buffers instead of only dropping cache keys.
+///
+internal sealed class ContiguousRangeAllocator
+{
+ private readonly List _free = new();
+
+ public ContiguousRangeAllocator(int capacity)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
+ Capacity = capacity;
+ _free.Add(new MeshBufferRange(0, capacity));
+ }
+
+ public int Capacity { get; private set; }
+ public int Used { get; private set; }
+ public int HighWaterMark { get; private set; }
+ public int Free => Capacity - Used;
+ public int LargestFreeRange => _free.Count == 0 ? 0 : _free.Max(range => range.Length);
+
+ public bool TryAllocate(int length, out MeshBufferRange allocation)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length);
+
+ int bestIndex = -1;
+ int bestLength = int.MaxValue;
+ for (int i = 0; i < _free.Count; i++)
+ {
+ int candidateLength = _free[i].Length;
+ if (candidateLength >= length && candidateLength < bestLength)
+ {
+ bestIndex = i;
+ bestLength = candidateLength;
+ if (candidateLength == length)
+ break;
+ }
+ }
+
+ if (bestIndex < 0)
+ {
+ allocation = default;
+ return false;
+ }
+
+ MeshBufferRange free = _free[bestIndex];
+ allocation = new MeshBufferRange(free.Offset, length);
+ if (free.Length == length)
+ _free.RemoveAt(bestIndex);
+ else
+ _free[bestIndex] = new MeshBufferRange(free.Offset + length, free.Length - length);
+
+ Used = checked(Used + length);
+ HighWaterMark = Math.Max(HighWaterMark, allocation.End);
+ return true;
+ }
+
+ public void Grow(int newCapacity)
+ {
+ if (newCapacity <= Capacity)
+ throw new ArgumentOutOfRangeException(nameof(newCapacity));
+
+ int oldCapacity = Capacity;
+ Capacity = newCapacity;
+ InsertAndCoalesce(new MeshBufferRange(oldCapacity, newCapacity - oldCapacity));
+ }
+
+ public void Release(MeshBufferRange allocation)
+ {
+ if (allocation.Length <= 0
+ || allocation.Offset < 0
+ || allocation.End > Capacity)
+ {
+ throw new ArgumentOutOfRangeException(nameof(allocation));
+ }
+
+ InsertAndCoalesce(allocation);
+ Used = checked(Used - allocation.Length);
+ }
+
+ private void InsertAndCoalesce(MeshBufferRange released)
+ {
+ int index = _free.BinarySearch(
+ released,
+ Comparer.Create((left, right) => left.Offset.CompareTo(right.Offset)));
+ if (index < 0)
+ index = ~index;
+
+ if (index > 0 && _free[index - 1].End > released.Offset)
+ throw new InvalidOperationException("GPU buffer range was released more than once.");
+ if (index < _free.Count && released.End > _free[index].Offset)
+ throw new InvalidOperationException("GPU buffer range overlaps an existing free range.");
+
+ int start = released.Offset;
+ int end = released.End;
+ if (index > 0 && _free[index - 1].End == start)
+ {
+ start = _free[index - 1].Offset;
+ _free.RemoveAt(--index);
+ }
+ if (index < _free.Count && end == _free[index].Offset)
+ {
+ end = _free[index].End;
+ _free.RemoveAt(index);
+ }
+
+ _free.Insert(index, new MeshBufferRange(start, end - start));
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs
index 3e2c78e2..5d010fe5 100644
--- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs
+++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs
@@ -1,128 +1,252 @@
using AcDream.Content;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
-using System;
-namespace AcDream.App.Rendering.Wb {
- public class GlobalMeshBuffer : IDisposable {
- private readonly GL _gl;
- public uint VAO { get; private set; }
- public uint VBO { get; private set; }
- public uint IBO { get; private set; }
+namespace AcDream.App.Rendering.Wb;
- private int _vboCapacity = 1024 * 1024; // 1M vertices (~32MB)
- private int _iboCapacity = 3 * 1024 * 1024; // 3M indices (~6MB)
- private int _vboOffset = 0;
- private int _iboOffset = 0;
+internal sealed record GlobalMeshAllocation(
+ MeshBufferRange Vertices,
+ MeshBufferRange Indices,
+ IReadOnlyList BatchFirstIndices);
- public GlobalMeshBuffer(GL gl) {
- _gl = gl;
- InitBuffers();
+///
+/// Shared modern-rendering vertex/index buffers with reclaimable ranges.
+/// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges
+/// when its zero-reference LRU entry is evicted.
+///
+public sealed class GlobalMeshBuffer : IDisposable
+{
+ private readonly GL _gl;
+ private readonly ContiguousRangeAllocator _vertices;
+ private readonly ContiguousRangeAllocator _indices;
+
+ public uint VAO { get; private set; }
+ public uint VBO { get; private set; }
+ public uint IBO { get; private set; }
+
+ public GlobalMeshBuffer(GL gl)
+ {
+ _gl = gl;
+ _vertices = new ContiguousRangeAllocator(1024 * 1024); // ~32 MB
+ _indices = new ContiguousRangeAllocator(3 * 1024 * 1024); // ~6 MB
+ InitBuffers();
+ }
+
+ private unsafe void InitBuffers()
+ {
+ _gl.GenVertexArrays(1, out uint vao);
+ VAO = vao;
+ _gl.BindVertexArray(VAO);
+
+ _gl.GenBuffers(1, out uint vbo);
+ VBO = vbo;
+ _gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
+ _gl.BufferData(
+ GLEnum.ArrayBuffer,
+ (nuint)(_vertices.Capacity * VertexPositionNormalTexture.Size),
+ null,
+ GLEnum.StaticDraw);
+
+ int stride = VertexPositionNormalTexture.Size;
+ _gl.EnableVertexAttribArray(0);
+ _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
+ _gl.EnableVertexAttribArray(1);
+ _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
+ _gl.EnableVertexAttribArray(2);
+ _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
+
+ _gl.GenBuffers(1, out uint ibo);
+ IBO = ibo;
+ _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
+ _gl.BufferData(
+ GLEnum.ElementArrayBuffer,
+ (nuint)(_indices.Capacity * sizeof(ushort)),
+ null,
+ GLEnum.StaticDraw);
+
+ _gl.BindVertexArray(0);
+ }
+
+ internal unsafe GlobalMeshAllocation UploadMesh(
+ VertexPositionNormalTexture[] vertices,
+ IReadOnlyList indexBatches)
+ {
+ ArgumentNullException.ThrowIfNull(vertices);
+ ArgumentNullException.ThrowIfNull(indexBatches);
+ if (vertices.Length == 0)
+ throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices));
+
+ int totalIndices = 0;
+ for (int i = 0; i < indexBatches.Count; i++)
+ {
+ ushort[] batch = indexBatches[i]
+ ?? throw new ArgumentException("Index batches cannot contain null.", nameof(indexBatches));
+ totalIndices = checked(totalIndices + batch.Length);
+ }
+ if (totalIndices == 0)
+ throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches));
+
+ MeshBufferRange vertexRange = AllocateVertices(vertices.Length);
+ MeshBufferRange indexRange;
+ try
+ {
+ indexRange = AllocateIndices(totalIndices);
+ }
+ catch
+ {
+ _vertices.Release(vertexRange);
+ throw;
}
- private unsafe void InitBuffers() {
- _gl.GenVertexArrays(1, out uint vao);
- VAO = vao;
- _gl.BindVertexArray(VAO);
-
- _gl.GenBuffers(1, out uint vbo);
- VBO = vbo;
+ var firstIndices = new int[indexBatches.Count];
+ try
+ {
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
- _gl.BufferData(GLEnum.ArrayBuffer, (nuint)(_vboCapacity * VertexPositionNormalTexture.Size), null, GLEnum.StaticDraw);
-
- int stride = VertexPositionNormalTexture.Size;
- _gl.EnableVertexAttribArray(0);
- _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
- _gl.EnableVertexAttribArray(1);
- _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
- _gl.EnableVertexAttribArray(2);
- _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
-
- _gl.GenBuffers(1, out uint ibo);
- IBO = ibo;
- _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
- _gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(_iboCapacity * sizeof(ushort)), null, GLEnum.StaticDraw);
-
- _gl.BindVertexArray(0);
- }
-
- public unsafe (int baseVertex, int firstIndex) Append(VertexPositionNormalTexture[] vertices, ushort[] indices) {
- if (vertices.Length == 0 || indices.Length == 0) return (0, 0);
-
- // Check capacity
- if (_vboOffset + vertices.Length > _vboCapacity) {
- ResizeVBO(Math.Max(_vboCapacity * 2, _vboCapacity + vertices.Length));
- }
- if (_iboOffset + indices.Length > _iboCapacity) {
- ResizeIBO(Math.Max(_iboCapacity * 2, _iboCapacity + indices.Length));
- }
-
- int baseVertex = _vboOffset;
- int firstIndex = _iboOffset;
-
- _gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
- fixed (VertexPositionNormalTexture* ptr = vertices) {
- _gl.BufferSubData(GLEnum.ArrayBuffer, (nint)(baseVertex * VertexPositionNormalTexture.Size), (nuint)(vertices.Length * VertexPositionNormalTexture.Size), ptr);
+ fixed (VertexPositionNormalTexture* ptr = vertices)
+ {
+ _gl.BufferSubData(
+ GLEnum.ArrayBuffer,
+ (nint)(vertexRange.Offset * VertexPositionNormalTexture.Size),
+ (nuint)(vertices.Length * VertexPositionNormalTexture.Size),
+ ptr);
}
_gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
- fixed (ushort* ptr = indices) {
- _gl.BufferSubData(GLEnum.ElementArrayBuffer, (nint)(firstIndex * sizeof(ushort)), (nuint)(indices.Length * sizeof(ushort)), ptr);
+ int indexOffset = indexRange.Offset;
+ for (int i = 0; i < indexBatches.Count; i++)
+ {
+ ushort[] batch = indexBatches[i];
+ firstIndices[i] = indexOffset;
+ if (batch.Length > 0)
+ {
+ fixed (ushort* ptr = batch)
+ {
+ _gl.BufferSubData(
+ GLEnum.ElementArrayBuffer,
+ (nint)(indexOffset * sizeof(ushort)),
+ (nuint)(batch.Length * sizeof(ushort)),
+ ptr);
+ }
+ indexOffset += batch.Length;
+ }
}
-
- _vboOffset += vertices.Length;
- _iboOffset += indices.Length;
-
- return (baseVertex, firstIndex);
+ }
+ catch
+ {
+ _indices.Release(indexRange);
+ _vertices.Release(vertexRange);
+ throw;
}
- private unsafe void ResizeVBO(int newCapacity) {
- _gl.GenBuffers(1, out uint newVbo);
- _gl.BindBuffer(GLEnum.ArrayBuffer, newVbo);
- _gl.BufferData(GLEnum.ArrayBuffer, (nuint)(newCapacity * VertexPositionNormalTexture.Size), null, GLEnum.StaticDraw);
+ return new GlobalMeshAllocation(vertexRange, indexRange, firstIndices);
+ }
- _gl.BindBuffer(GLEnum.CopyReadBuffer, VBO);
- _gl.BindBuffer(GLEnum.CopyWriteBuffer, newVbo);
- _gl.CopyBufferSubData(GLEnum.CopyReadBuffer, GLEnum.CopyWriteBuffer, 0, 0, (nuint)(_vboOffset * VertexPositionNormalTexture.Size));
+ internal void Release(GlobalMeshAllocation allocation)
+ {
+ ArgumentNullException.ThrowIfNull(allocation);
+ _indices.Release(allocation.Indices);
+ _vertices.Release(allocation.Vertices);
+ }
+ private MeshBufferRange AllocateVertices(int count)
+ {
+ if (_vertices.TryAllocate(count, out MeshBufferRange allocation))
+ return allocation;
+
+ int newCapacity = GrowCapacity(_vertices.Capacity, count);
+ ResizeVBO(newCapacity);
+ _vertices.Grow(newCapacity);
+ if (!_vertices.TryAllocate(count, out allocation))
+ throw new InvalidOperationException("Failed to allocate a vertex range after growing the buffer.");
+ return allocation;
+ }
+
+ private MeshBufferRange AllocateIndices(int count)
+ {
+ if (_indices.TryAllocate(count, out MeshBufferRange allocation))
+ return allocation;
+
+ int newCapacity = GrowCapacity(_indices.Capacity, count);
+ ResizeIBO(newCapacity);
+ _indices.Grow(newCapacity);
+ if (!_indices.TryAllocate(count, out allocation))
+ throw new InvalidOperationException("Failed to allocate an index range after growing the buffer.");
+ return allocation;
+ }
+
+ private static int GrowCapacity(int capacity, int requiredContiguousLength)
+ {
+ int minimum = checked(capacity + requiredContiguousLength);
+ int doubled = capacity <= int.MaxValue / 2 ? capacity * 2 : int.MaxValue;
+ return Math.Max(doubled, minimum);
+ }
+
+ private unsafe void ResizeVBO(int newCapacity)
+ {
+ _gl.GenBuffers(1, out uint newVbo);
+ _gl.BindBuffer(GLEnum.ArrayBuffer, newVbo);
+ _gl.BufferData(
+ GLEnum.ArrayBuffer,
+ (nuint)(newCapacity * VertexPositionNormalTexture.Size),
+ null,
+ GLEnum.StaticDraw);
+
+ _gl.BindBuffer(GLEnum.CopyReadBuffer, VBO);
+ _gl.BindBuffer(GLEnum.CopyWriteBuffer, newVbo);
+ _gl.CopyBufferSubData(
+ GLEnum.CopyReadBuffer,
+ GLEnum.CopyWriteBuffer,
+ 0,
+ 0,
+ (nuint)(_vertices.HighWaterMark * VertexPositionNormalTexture.Size));
+
+ _gl.DeleteBuffer(VBO);
+ VBO = newVbo;
+
+ _gl.BindVertexArray(VAO);
+ _gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
+ int stride = VertexPositionNormalTexture.Size;
+ _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
+ _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
+ _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
+ _gl.BindVertexArray(0);
+ }
+
+ private unsafe void ResizeIBO(int newCapacity)
+ {
+ _gl.GenBuffers(1, out uint newIbo);
+ _gl.BindBuffer(GLEnum.ElementArrayBuffer, newIbo);
+ _gl.BufferData(
+ GLEnum.ElementArrayBuffer,
+ (nuint)(newCapacity * sizeof(ushort)),
+ null,
+ GLEnum.StaticDraw);
+
+ _gl.BindBuffer(GLEnum.CopyReadBuffer, IBO);
+ _gl.BindBuffer(GLEnum.CopyWriteBuffer, newIbo);
+ _gl.CopyBufferSubData(
+ GLEnum.CopyReadBuffer,
+ GLEnum.CopyWriteBuffer,
+ 0,
+ 0,
+ (nuint)(_indices.HighWaterMark * sizeof(ushort)));
+
+ _gl.DeleteBuffer(IBO);
+ IBO = newIbo;
+
+ _gl.BindVertexArray(VAO);
+ _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
+ _gl.BindVertexArray(0);
+ }
+
+ public void Dispose()
+ {
+ if (VAO != 0)
+ _gl.DeleteVertexArray(VAO);
+ if (VBO != 0)
_gl.DeleteBuffer(VBO);
- VBO = newVbo;
- _vboCapacity = newCapacity;
-
- // Re-bind to VAO
- _gl.BindVertexArray(VAO);
- _gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
- int stride = VertexPositionNormalTexture.Size;
- _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
- _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
- _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
- _gl.BindVertexArray(0);
- }
-
- private unsafe void ResizeIBO(int newCapacity) {
- _gl.GenBuffers(1, out uint newIbo);
- _gl.BindBuffer(GLEnum.ElementArrayBuffer, newIbo);
- _gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(newCapacity * sizeof(ushort)), null, GLEnum.StaticDraw);
-
- _gl.BindBuffer(GLEnum.CopyReadBuffer, IBO);
- _gl.BindBuffer(GLEnum.CopyWriteBuffer, newIbo);
- _gl.CopyBufferSubData(GLEnum.CopyReadBuffer, GLEnum.CopyWriteBuffer, 0, 0, (nuint)(_iboOffset * sizeof(ushort)));
-
+ if (IBO != 0)
_gl.DeleteBuffer(IBO);
- IBO = newIbo;
- _iboCapacity = newCapacity;
-
- // Re-bind to VAO
- _gl.BindVertexArray(VAO);
- _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
- _gl.BindVertexArray(0);
- }
-
- public void Dispose() {
- if (VAO != 0) _gl.DeleteVertexArray(VAO);
- if (VBO != 0) _gl.DeleteBuffer(VBO);
- if (IBO != 0) _gl.DeleteBuffer(IBO);
- VAO = VBO = IBO = 0;
- }
+ VAO = VBO = IBO = 0;
}
}
diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
index 856a95ad..3d9d7ae8 100644
--- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
+++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
@@ -29,6 +29,7 @@ namespace AcDream.App.Rendering.Wb {
public uint VBO { get; set; }
public int VertexCount { get; set; }
public List Batches { get; set; } = new();
+ internal GlobalMeshAllocation? GlobalAllocation { get; set; }
public bool IsSetup { get; set; }
public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new();
@@ -712,9 +713,17 @@ namespace AcDream.App.Rendering.Wb {
var gl = _graphicsDevice.GL;
uint vao = 0, vbo = 0;
+ var modernIndexBatches = meshData.TextureBatches.Values
+ .SelectMany(batches => batches)
+ .Where(batch => batch.Indices.Count != 0)
+ .Select(batch => batch.Indices.ToArray())
+ .ToArray();
+ GlobalMeshAllocation? globalAllocation = null;
if (_useModernRendering) {
- // Everything goes into the global VBO/IBO
+ // One mesh owns one vertex range and one contiguous index
+ // range. The former append path duplicated the full vertex
+ // array per material and never reclaimed evicted ranges.
vao = GlobalBuffer!.VAO;
vbo = GlobalBuffer!.VBO;
}
@@ -787,9 +796,6 @@ namespace AcDream.App.Rendering.Wb {
if (_useModernRendering) {
ibo = GlobalBuffer!.IBO;
- var appended = GlobalBuffer.Append(meshData.Vertices, batch.Indices.ToArray());
- batchBaseVertex = appended.baseVertex;
- firstIndex = (uint)appended.firstIndex;
}
else {
gl.GenBuffers(1, out ibo);
@@ -824,11 +830,25 @@ namespace AcDream.App.Rendering.Wb {
}
}
+ if (_useModernRendering && modernIndexBatches.Length != 0) {
+ globalAllocation = GlobalBuffer!.UploadMesh(meshData.Vertices, modernIndexBatches);
+ if (renderBatches.Count != globalAllocation.BatchFirstIndices.Count) {
+ GlobalBuffer.Release(globalAllocation);
+ throw new InvalidOperationException("Global mesh batch allocation count mismatch.");
+ }
+
+ for (int i = 0; i < renderBatches.Count; i++) {
+ renderBatches[i].BaseVertex = (uint)globalAllocation.Vertices.Offset;
+ renderBatches[i].FirstIndex = (uint)globalAllocation.BatchFirstIndices[i];
+ }
+ }
+
var renderData = new ObjectRenderData {
VAO = vao,
VBO = vbo,
VertexCount = meshData.Vertices.Length,
Batches = renderBatches,
+ GlobalAllocation = globalAllocation,
ParticleEmitters = meshData.ParticleEmitters,
DIDDegrade = meshData.DIDDegrade,
CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(),
@@ -958,6 +978,10 @@ namespace AcDream.App.Rendering.Wb {
}
}
else {
+ if (data.GlobalAllocation is { } allocation) {
+ GlobalBuffer!.Release(allocation);
+ data.GlobalAllocation = null;
+ }
foreach (var batch in data.Batches) {
if (batch.Atlas != null) {
batch.Atlas.ReleaseTexture(batch.Key);
diff --git a/src/AcDream.App/World/LiveEntityLivenessController.cs b/src/AcDream.App/World/LiveEntityLivenessController.cs
new file mode 100644
index 00000000..c6ebfd3c
--- /dev/null
+++ b/src/AcDream.App/World/LiveEntityLivenessController.cs
@@ -0,0 +1,182 @@
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.App.World;
+
+internal readonly record struct LiveEntityLivenessSample(
+ uint ServerGuid,
+ ushort Generation,
+ bool IsConservativelyVisible,
+ bool HasNonWorldRetention);
+
+internal readonly record struct LiveEntityPruneCandidate(
+ uint ServerGuid,
+ ushort Generation);
+
+///
+/// Deadline state for ACE's retained KnownObjects behavior. Retail
+/// CPhysicsObj::prepare_to_leave_visibility (0x00511F40) schedules the
+/// same 25-second expiry through CObjectMaint::AddObjectToBeDestroyed
+/// (0x00508F70), while prepare_to_enter_world (0x00511FA0) cancels it.
+///
+internal sealed class LiveEntityLivenessTracker
+{
+ internal const double DestructionTimeoutSeconds = 25.0;
+
+ private readonly Dictionary _deadlines = new();
+
+ internal int DeadlineCount => _deadlines.Count;
+
+ internal IReadOnlyList Tick(
+ double now,
+ IReadOnlyList samples)
+ {
+ var present = new HashSet(samples.Count);
+ var due = new List();
+ for (int i = 0; i < samples.Count; i++)
+ {
+ LiveEntityLivenessSample sample = samples[i];
+ present.Add(sample.ServerGuid);
+ if (sample.IsConservativelyVisible || sample.HasNonWorldRetention)
+ {
+ _deadlines.Remove(sample.ServerGuid);
+ continue;
+ }
+
+ if (!_deadlines.TryGetValue(sample.ServerGuid, out Deadline deadline)
+ || deadline.Generation != sample.Generation)
+ {
+ _deadlines[sample.ServerGuid] = new Deadline(
+ sample.Generation,
+ now + DestructionTimeoutSeconds);
+ continue;
+ }
+
+ if (deadline.ExpiresAt > now)
+ continue;
+
+ due.Add(new LiveEntityPruneCandidate(sample.ServerGuid, sample.Generation));
+ _deadlines.Remove(sample.ServerGuid);
+ }
+
+ uint[] stale = _deadlines.Keys
+ .Where(guid => !present.Contains(guid))
+ .ToArray();
+ for (int i = 0; i < stale.Length; i++)
+ _deadlines.Remove(stale[i]);
+
+ return due;
+ }
+
+ internal void Clear() => _deadlines.Clear();
+
+ private readonly record struct Deadline(ushort Generation, double ExpiresAt);
+}
+
+///
+/// App-layer owner of client-side retained-object liveness. The canonical live
+/// entity remains intact while spatially resident, near the player, attached,
+/// held, wielded, or in a container. A nonresident top-level world object
+/// farther than 384 metres for 25 seconds is torn down through the same
+/// generation-safe lifecycle as ObjectDelete. The distance is holtburger's
+/// conservative ACE-compatibility envelope until exact ObjCell PVS is wired.
+///
+internal sealed class LiveEntityLivenessController
+{
+ internal const float ConservativeVisibilityDistance = 384f;
+ private const double MaintenanceIntervalSeconds = 1.0;
+
+ private readonly LiveEntityRuntime _runtime;
+ private readonly Func _playerGuid;
+ private readonly Action _prune;
+ private readonly LiveEntityLivenessTracker _tracker = new();
+ private readonly List _samples = new();
+ private double _nextMaintenanceAt;
+
+ public LiveEntityLivenessController(
+ LiveEntityRuntime runtime,
+ Func playerGuid,
+ Action prune)
+ {
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
+ _prune = prune ?? throw new ArgumentNullException(nameof(prune));
+ }
+
+ public void Tick(double now)
+ {
+ if (now < _nextMaintenanceAt)
+ return;
+ _nextMaintenanceAt = now + MaintenanceIntervalSeconds;
+
+ uint playerGuid = _playerGuid();
+ if (playerGuid == 0
+ || !_runtime.TryGetRecord(playerGuid, out LiveEntityRecord player)
+ || player.Snapshot.Position is not { } playerPosition)
+ {
+ return;
+ }
+
+ _samples.Clear();
+ foreach (LiveEntityRecord record in _runtime.Records)
+ {
+ if (record.ServerGuid == playerGuid
+ || record.Snapshot.Position is not { } position)
+ {
+ continue;
+ }
+
+ bool retained = record.ProjectionKind is LiveEntityProjectionKind.Attached
+ || NonZero(record.Snapshot.ContainerId)
+ || NonZero(record.Snapshot.WielderId)
+ || NonZero(record.Snapshot.ParentGuid);
+ _samples.Add(new LiveEntityLivenessSample(
+ record.ServerGuid,
+ record.Generation,
+ record.IsSpatiallyVisible
+ || IsWithinConservativeVisibility(playerPosition, position),
+ retained));
+ }
+
+ IReadOnlyList due = _tracker.Tick(now, _samples);
+ for (int i = 0; i < due.Count; i++)
+ {
+ LiveEntityPruneCandidate candidate = due[i];
+ if (_runtime.TryGetRecord(candidate.ServerGuid, out LiveEntityRecord current)
+ && current.Generation == candidate.Generation)
+ {
+ _prune(candidate);
+ }
+ }
+ }
+
+ public void Clear()
+ {
+ _tracker.Clear();
+ _samples.Clear();
+ _nextMaintenanceAt = 0;
+ }
+
+ internal static bool IsWithinConservativeVisibility(
+ CreateObject.ServerPosition player,
+ CreateObject.ServerPosition entity)
+ {
+ (double playerX, double playerY) = GlobalXY(player);
+ (double entityX, double entityY) = GlobalXY(entity);
+ double dx = entityX - playerX;
+ double dy = entityY - playerY;
+ double dz = entity.PositionZ - player.PositionZ;
+ double maximum = ConservativeVisibilityDistance;
+ return dx * dx + dy * dy + dz * dz <= maximum * maximum;
+ }
+
+ private static (double X, double Y) GlobalXY(CreateObject.ServerPosition position)
+ {
+ uint landblockX = (position.LandblockId >> 24) & 0xFFu;
+ uint landblockY = (position.LandblockId >> 16) & 0xFFu;
+ return (
+ landblockX * 192.0 + position.PositionX,
+ landblockY * 192.0 + position.PositionY);
+ }
+
+ private static bool NonZero(uint? value) => value.GetValueOrDefault() != 0u;
+}
diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index 91df4f68..bba60382 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -99,7 +99,8 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
///
/// The one logical record for a server object incarnation. The record survives
/// loaded/pending landblock movement and temporary loss of its render bucket.
-/// Only an accepted DeleteObject, session reset, or newer INSTANCE_TS ends it.
+/// Only an accepted DeleteObject, session reset, newer INSTANCE_TS, or the
+/// retail 25-second leave-visibility lifecycle ends it.
///
public sealed class LiveEntityRecord
{
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/ContiguousRangeAllocatorTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/ContiguousRangeAllocatorTests.cs
new file mode 100644
index 00000000..385b7f6c
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Wb/ContiguousRangeAllocatorTests.cs
@@ -0,0 +1,84 @@
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.App.Tests.Rendering.Wb;
+
+public sealed class ContiguousRangeAllocatorTests
+{
+ [Fact]
+ public void ReleasedRangesAreReusedWithoutGrowing()
+ {
+ var allocator = new ContiguousRangeAllocator(100);
+
+ Assert.True(allocator.TryAllocate(30, out MeshBufferRange first));
+ Assert.True(allocator.TryAllocate(20, out MeshBufferRange second));
+ allocator.Release(first);
+
+ Assert.True(allocator.TryAllocate(25, out MeshBufferRange reused));
+ Assert.Equal(0, reused.Offset);
+ Assert.Equal(25, reused.Length);
+ Assert.Equal(45, allocator.Used);
+ Assert.Equal(100, allocator.Capacity);
+ Assert.Equal(50, allocator.LargestFreeRange);
+ Assert.Equal(30, second.Offset);
+ }
+
+ [Fact]
+ public void AdjacentReleasedRangesCoalesceInEitherOrder()
+ {
+ var allocator = new ContiguousRangeAllocator(100);
+ Assert.True(allocator.TryAllocate(20, out MeshBufferRange first));
+ Assert.True(allocator.TryAllocate(30, out MeshBufferRange second));
+ Assert.True(allocator.TryAllocate(10, out MeshBufferRange third));
+
+ allocator.Release(second);
+ allocator.Release(first);
+ allocator.Release(third);
+
+ Assert.Equal(0, allocator.Used);
+ Assert.Equal(100, allocator.LargestFreeRange);
+ Assert.True(allocator.TryAllocate(100, out MeshBufferRange whole));
+ Assert.Equal(new MeshBufferRange(0, 100), whole);
+ }
+
+ [Fact]
+ public void GrowthJoinsTheTrailingFreeRange()
+ {
+ var allocator = new ContiguousRangeAllocator(64);
+ Assert.True(allocator.TryAllocate(48, out _));
+
+ allocator.Grow(128);
+
+ Assert.Equal(80, allocator.LargestFreeRange);
+ Assert.True(allocator.TryAllocate(72, out MeshBufferRange allocation));
+ Assert.Equal(48, allocation.Offset);
+ Assert.Equal(120, allocator.HighWaterMark);
+ }
+
+ [Fact]
+ public void DoubleReleaseIsRejectedBeforeAccountingChanges()
+ {
+ var allocator = new ContiguousRangeAllocator(32);
+ Assert.True(allocator.TryAllocate(8, out MeshBufferRange allocation));
+ allocator.Release(allocation);
+
+ Assert.Throws(() => allocator.Release(allocation));
+ Assert.Equal(0, allocator.Used);
+ Assert.Equal(32, allocator.LargestFreeRange);
+ }
+
+ [Fact]
+ public void BestFitPreservesTheLargerHole()
+ {
+ var allocator = new ContiguousRangeAllocator(100);
+ Assert.True(allocator.TryAllocate(20, out MeshBufferRange small));
+ Assert.True(allocator.TryAllocate(10, out _));
+ Assert.True(allocator.TryAllocate(40, out MeshBufferRange large));
+ Assert.True(allocator.TryAllocate(30, out _));
+ allocator.Release(small);
+ allocator.Release(large);
+
+ Assert.True(allocator.TryAllocate(18, out MeshBufferRange selected));
+ Assert.Equal(small.Offset, selected.Offset);
+ Assert.Equal(40, allocator.LargestFreeRange);
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/LiveEntityLivenessControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLivenessControllerTests.cs
new file mode 100644
index 00000000..2d473658
--- /dev/null
+++ b/tests/AcDream.App.Tests/World/LiveEntityLivenessControllerTests.cs
@@ -0,0 +1,91 @@
+using AcDream.App.World;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.App.Tests.World;
+
+public sealed class LiveEntityLivenessControllerTests
+{
+ [Fact]
+ public void OutOfRangeWorldEntityExpiresAfterTwentyFiveSeconds()
+ {
+ var tracker = new LiveEntityLivenessTracker();
+ var samples = new[] { Sample(0x7000_0001u, generation: 4, visible: false) };
+
+ Assert.Empty(tracker.Tick(10.0, samples));
+ Assert.Empty(tracker.Tick(34.999, samples));
+ Assert.Equal(
+ new LiveEntityPruneCandidate(0x7000_0001u, 4),
+ Assert.Single(tracker.Tick(35.0, samples)));
+ Assert.Equal(0, tracker.DeadlineCount);
+ }
+
+ [Fact]
+ public void ReturningToVisibilityCancelsTheDeadline()
+ {
+ var tracker = new LiveEntityLivenessTracker();
+ Assert.Empty(tracker.Tick(0.0, [Sample(1, 1, visible: false)]));
+ Assert.Empty(tracker.Tick(20.0, [Sample(1, 1, visible: true)]));
+ Assert.Empty(tracker.Tick(40.0, [Sample(1, 1, visible: false)]));
+ Assert.Empty(tracker.Tick(64.9, [Sample(1, 1, visible: false)]));
+ Assert.Single(tracker.Tick(65.0, [Sample(1, 1, visible: false)]));
+ }
+
+ [Fact]
+ public void NonWorldRetentionCancelsTheDeadline()
+ {
+ var tracker = new LiveEntityLivenessTracker();
+ Assert.Empty(tracker.Tick(0.0, [Sample(1, 1, visible: false)]));
+ Assert.Empty(tracker.Tick(30.0, [Sample(1, 1, visible: false, retained: true)]));
+ Assert.Equal(0, tracker.DeadlineCount);
+ }
+
+ [Fact]
+ public void ReusedGuidGetsANewGenerationDeadline()
+ {
+ var tracker = new LiveEntityLivenessTracker();
+ Assert.Empty(tracker.Tick(0.0, [Sample(1, 1, visible: false)]));
+ Assert.Empty(tracker.Tick(24.0, [Sample(1, 2, visible: false)]));
+ Assert.Empty(tracker.Tick(25.0, [Sample(1, 2, visible: false)]));
+ Assert.Equal(
+ new LiveEntityPruneCandidate(1, 2),
+ Assert.Single(tracker.Tick(49.0, [Sample(1, 2, visible: false)])));
+ }
+
+ [Fact]
+ public void RemovedRecordDropsItsDeadline()
+ {
+ var tracker = new LiveEntityLivenessTracker();
+ Assert.Empty(tracker.Tick(0.0, [Sample(1, 1, visible: false)]));
+
+ Assert.Empty(tracker.Tick(30.0, []));
+
+ Assert.Equal(0, tracker.DeadlineCount);
+ }
+
+ [Fact]
+ public void ConservativeVisibilityUsesGlobalLandblockCoordinates()
+ {
+ CreateObject.ServerPosition player = Position(0x3032_0001u, 190f, 20f, 5f);
+ CreateObject.ServerPosition adjacent = Position(0x3132_0001u, 1f, 20f, 5f);
+ CreateObject.ServerPosition exactlyTwoBlocks = Position(0x3232_0001u, 190f, 20f, 5f);
+ CreateObject.ServerPosition beyond = Position(0x3332_0001u, 1f, 20f, 5f);
+
+ Assert.True(LiveEntityLivenessController.IsWithinConservativeVisibility(player, adjacent));
+ Assert.True(LiveEntityLivenessController.IsWithinConservativeVisibility(player, exactlyTwoBlocks));
+ Assert.False(LiveEntityLivenessController.IsWithinConservativeVisibility(player, beyond));
+ }
+
+ private static LiveEntityLivenessSample Sample(
+ uint guid,
+ ushort generation,
+ bool visible,
+ bool retained = false) =>
+ new(guid, generation, visible, retained);
+
+ private static CreateObject.ServerPosition Position(
+ uint cell,
+ float x,
+ float y,
+ float z) =>
+ new(cell, x, y, z, 1f, 0f, 0f, 0f);
+}