acdream/docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md
Erik 749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00

7.6 KiB
Raw Blame History

Retail texture and per-object material lifetime

Scope

This note records the retail ownership rules needed to fix the repeated-recall resource buildup in acdream. It does not attempt to reproduce Direct3D 9 or retail's whole database cache. The modern renderer keeps shared atlas textures, but per-object palette/texture composites must follow the same owner lifetime as retail's CSurface/ImgTex pair.

Retail oracle

Named Sept-2013 client symbols and pseudo-C:

  • CSurface::SetTextureAndPalette 0x00535FB0
  • CSurface::Destroy 0x005361F0
  • CSurface::UseTextureMap(ImgTex*, SurfaceHandlerEnum) 0x00536350
  • CSurface::InitEnd 0x00536400
  • CSurface::RestorePalShiftSurface 0x00536530
  • ImgTex::PurgeResource 0x0053E550
  • ImgTex::GetD3DTexture 0x0053E5B0
  • ImgTex::CreateD3DTexture 0x0053EDB0
  • DBCache::UseTime 0x00414090
  • DBCache::FlushFreeObjects 0x004141E0
  • GraphicsResource::PurgeOldResources 0x00446C60
  • SceneTool::PurgeOldGraphicsResources 0x0043E5C0

Modern reference seam:

  • WorldBuilder's extracted TextureAtlasManager reference-counts a texture layer and releases it when its last mesh user disappears.
  • acdream's ObjectMeshManager.UnloadObject already disposes an empty atlas and removes it from _globalAtlases; the missing lifetime is the separate TextureCache used by live-object composites.

Pseudocode

A database surface owns its current image texture

CSurface::UseTextureMap(newTexture):
    if current base1map exists:
        current base1map.Release()
        current base1map = null

    current base1map = newTexture
    newTexture.AddRef()

Replacing a palette composite releases the previous composite

CSurface::SetTextureAndPalette(sourceTexture, palette):
    combined = ImgTex::CreateCombinedTexture(sourceTexture, palette, clipmap)
    if combined is null:
        fail

    if current base1map exists:
        current base1map.Release()
        current base1map = null

    current base1map = combined

RestorePalShiftSurface follows the same order: release the old base1map, create the replacement combined texture, then install it.

Object destruction releases its material resources immediately

CSurface::Destroy():
    if base1map exists:
        base1map.Release()
        base1map = null

    if base1pal exists:
        Palette::releasePalette(base1pal)
        base1pal = null

    reset surface fields

This is the important portal/recall rule: a palette-shifted texture is not a session-global retain-forever entry. Its owning surface contributes a reference, and destroying/replacing that surface releases the reference.

Shared image resources are separately purgeable

ImgTex::GetD3DTexture():
    if resource is lost:
        RestoreResource()
    TimeUsed = Timer.local_time
    FrameUsed = render_device.frame_stamp
    return D3D texture

SceneTool::PurgeOldGraphicsResources():
    every 5 seconds:
        if available video memory is low:
            GraphicsResource::PurgeOldResources(unused-age threshold)

GraphicsResource::PurgeOldResources(age):
    for each registered graphics resource:
        if not lost
           and not used this frame
           and it is thrashable
           and TimeUsed is older than age:
            resource.PurgeResource()
            mark resource lost

Shared DAT image residency and owner-scoped composites are therefore two different lifetimes. The modern atlas is acdream's shared-image adaptation; palette and original-texture overrides remain owner-scoped.

acdream port contract

ResolveTexture(entity, meshBatch):
    if no original-texture override
       and no applicable palette override:
        use meshBatch's existing shared atlas handle and layer

    if entity has a palette override
       and the resolved RenderSurface is P8 or INDEX16:
        get/create one owner-scoped palette composite
        record (entity local id -> composite key)
        use composite handle, layer 0

    else if entity has an original-texture override:
        get/create one owner-scoped override texture
        record (entity local id -> override key)
        use override handle, layer 0

    else:
        use shared atlas handle and layer
ReleaseTextureOwner(entity local id):
    for each composite key first acquired by that owner:
        decrement the key's owner count
        if count becomes zero:
            mark the cache entry unowned and therefore evictable

EvictUnownedTexture(key):
    remove key from every public lookup first
    enqueue its handle/resource against the current GPU frame serial
    after the retirement fence signals:
        make bindless handle non-resident
        delete GL texture or recycle the array layer
        remove physical diagnostic metadata

The registry is idempotent per (owner, key), so classifying the same animated object every frame does not add references. Appearance replacement resolves and acquires the complete desired set first, publishes that exact incarnation, then releases resources no longer used by the old description. Logical entity teardown withdraws publication and releases the same set exactly once.

Keeping a bounded unowned cache is the modern equivalent of retail's separately purgeable GraphicsResource: it does not change the owner contract. Current production budgets are 32 MiB / 256 unowned standalone textures and 64 MiB unowned / 128 MiB physical composite arrays. Physical destruction/reuse waits for the three-frame fence owner so the GPU cannot still be sampling a resource whose logical owner has disappeared.

Captured failure evidence

The connected C95B -> 3032 -> F682 -> 0904 -> C95B sequence retained normal world ownership (139 live network objects, 13 animated objects, 1,705 particle emitters) but TextureCache held 1,945 GL textures:

  • 527 legacy palette composites pre-warmed by EntitySpawnAdapter but never consumed by the modern draw path;
  • 366 base bindless textures duplicating textures already resident in the WorldBuilder atlas;
  • 587 bindless palette composites, including stale objects from earlier destinations and duplicates for non-indexed surfaces.

The process reached about 2.7 GB private memory, about 891 MB dedicated GPU memory, and 5-8 FPS. A managed CPU trace spent nearly the entire render thread inside native rendering, while a GC snapshot reduced working set but did not recover frame rate. That combination isolates retained GL texture residency, not live-entity, particle, or managed-update counts, as the remaining recall-triggered buildup.

Final connected conformance evidence

The integrated build ran Caul → Sawato → Rynthid → Aerlinthe → Sawato → Holtburg → Caul with 2530 second destination dwells and a 60-second final reclamation dwell. Compared with the failing build on the same route:

Metric Failing build Integrated build
Peak working set 2,954.5 MiB 1,493.4 MiB
Peak private bytes 3,502.3 MiB 1,969.5 MiB
Final Caul working set 1,826.2 MiB 1,030.6 MiB
Final Caul private bytes 2,433.7 MiB 1,638.2 MiB

Final local GPU residency was 831.6 MiB and stable through the dwell. The composite cache ended at 65,780,224 unowned bytes and 122,437,632 physical bytes, below its 64 MiB policy modulo one entry's admission granularity and below the strict 128 MiB physical ceiling. Emitter and binding indexes balanced at 1,715 each. Caul held 125153 FPS (141.8 average) on the local display. There was no application WER event or AMD display-driver reset. The old run's 32 FPS was imposed by the RDP monitor's 32 Hz refresh and is not a performance comparison; its process-memory measurements remain valid.