acdream/docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md
Erik 823936ec31 fix(streaming): preserve portal destination ownership
Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
2026-07-25 08:35:12 +02:00

178 lines
8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Retail object liveness and modern mesh reclamation
## Symptom and measured cause
After several distant portal transitions, acdream could fall from normal
frame rates to 312 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 visibility 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.
There is an additional ACE compatibility boundary. Retail can destroy the
complete client object because its server/client visibility protocol later
recreates that object. ACE may retain the GUID in `KnownObjects` across a
normal teleport and consequently omit the later `CreateObject`. Deleting
acdream's only accepted `EntitySpawn` at the 25-second deadline therefore
made doors, signs, portals, NPCs, and other server objects permanently absent
when a destination was revisited.
The modern client separates those two costs:
- the 25-second deadline destroys every active projection, collision,
animation, physics, script, light, particle, and render owner;
- a cold `EntitySpawn` snapshot remains in a data-only dormant table;
- reloading its authoritative landblock routes that snapshot through the
ordinary generation-safe CreateObject hydration transaction;
- an explicit F747, a newer INSTANCE timestamp, or session reset removes the
dormant snapshot.
This is deliberately not active-object retention: dormant entries execute no
per-frame work and own no GPU resources. It restores the former retained
spawn-table contract required by ACE while preserving the resource reclamation
that fixed the portal-run FPS collapse.
## Port pseudocode
```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:
capture its latest accepted EntitySpawn
run the normal generation-safe active teardown
retain only the cold EntitySpawn
when a landblock becomes resident:
for each dormant spawn in that canonical landblock:
route it through ordinary CreateObject hydration
remove the dormant entry only after the generation is accepted
on explicit DeleteObject:
delete the exact active or dormant generation
remove its ClientObject qualities
on session reset:
clear active runtime and dormant snapshots
```
Routing expiry through `LiveEntityRuntime.UnregisterLiveEntity` is important:
it performs the same generation gate, equipped-child cleanup, spatial
withdrawal, animation/physics/effect teardown, and render resource release as
an authoritative F747. Unlike F747, local visibility expiry preserves the
small object-quality record and cold spawn because ACE may never resend them.
An actual F747 removes both. A second active-resource teardown path would
drift and recreate the original leak.
## Reclaimable shared-buffer contract
```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 6080 FPS instead of the prior persistent 312 FPS state.