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

8 KiB
Raw Blame History

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:

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

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

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.