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
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue