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