fix(streaming): retire stale portal-region entities
Port retail's 25-second leave-visibility lifetime over canonical live records, retaining spatially resident and owned entities while using the conservative ACE visibility envelope for nonresident records. Route expiry through the normal generation-safe F747 teardown so animations, effects, physics, and render owners unwind symmetrically. Replace the append-only modern mesh buffer with coalescing vertex/index ranges and upload each mesh's vertices once instead of once per material. Released zero-reference meshes can now reuse GPU ranges after portal and cache churn. A connected five-region round trip returned animation ownership to baseline, recreated the starting region on revisit, and held normal FPS. Release build succeeds and all 5,927 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
2cbf34a668
commit
3971997689
13 changed files with 918 additions and 117 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -460,6 +460,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// <see cref="OnLiveEntityDeleted"/>.
|
||||
/// </summary>
|
||||
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
|
||||
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
|
||||
|
||||
/// <summary>
|
||||
/// 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server broadcast a <c>0xF625 ObjDescEvent</c> — 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
|
||||
|
|
|
|||
117
src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs
Normal file
117
src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
internal readonly record struct MeshBufferRange(int Offset, int Length)
|
||||
{
|
||||
public int End => checked(Offset + Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class ContiguousRangeAllocator
|
||||
{
|
||||
private readonly List<MeshBufferRange> _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<MeshBufferRange>.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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int> BatchFirstIndices);
|
||||
|
||||
public GlobalMeshBuffer(GL gl) {
|
||||
_gl = gl;
|
||||
InitBuffers();
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ushort[]> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
public uint VBO { get; set; }
|
||||
public int VertexCount { get; set; }
|
||||
public List<ObjectRenderBatch> 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);
|
||||
|
|
|
|||
182
src/AcDream.App/World/LiveEntityLivenessController.cs
Normal file
182
src/AcDream.App/World/LiveEntityLivenessController.cs
Normal file
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Deadline state for ACE's retained KnownObjects behavior. Retail
|
||||
/// <c>CPhysicsObj::prepare_to_leave_visibility</c> (0x00511F40) schedules the
|
||||
/// same 25-second expiry through <c>CObjectMaint::AddObjectToBeDestroyed</c>
|
||||
/// (0x00508F70), while <c>prepare_to_enter_world</c> (0x00511FA0) cancels it.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityLivenessTracker
|
||||
{
|
||||
internal const double DestructionTimeoutSeconds = 25.0;
|
||||
|
||||
private readonly Dictionary<uint, Deadline> _deadlines = new();
|
||||
|
||||
internal int DeadlineCount => _deadlines.Count;
|
||||
|
||||
internal IReadOnlyList<LiveEntityPruneCandidate> Tick(
|
||||
double now,
|
||||
IReadOnlyList<LiveEntityLivenessSample> samples)
|
||||
{
|
||||
var present = new HashSet<uint>(samples.Count);
|
||||
var due = new List<LiveEntityPruneCandidate>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityLivenessController
|
||||
{
|
||||
internal const float ConservativeVisibilityDistance = 384f;
|
||||
private const double MaintenanceIntervalSeconds = 1.0;
|
||||
|
||||
private readonly LiveEntityRuntime _runtime;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Action<LiveEntityPruneCandidate> _prune;
|
||||
private readonly LiveEntityLivenessTracker _tracker = new();
|
||||
private readonly List<LiveEntityLivenessSample> _samples = new();
|
||||
private double _nextMaintenanceAt;
|
||||
|
||||
public LiveEntityLivenessController(
|
||||
LiveEntityRuntime runtime,
|
||||
Func<uint> playerGuid,
|
||||
Action<LiveEntityPruneCandidate> 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<LiveEntityPruneCandidate> 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;
|
||||
}
|
||||
|
|
@ -99,7 +99,8 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
|
|||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class LiveEntityRecord
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue