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>
7.6 KiB
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::SetTextureAndPalette0x00535FB0CSurface::Destroy0x005361F0CSurface::UseTextureMap(ImgTex*, SurfaceHandlerEnum)0x00536350CSurface::InitEnd0x00536400CSurface::RestorePalShiftSurface0x00536530ImgTex::PurgeResource0x0053E550ImgTex::GetD3DTexture0x0053E5B0ImgTex::CreateD3DTexture0x0053EDB0DBCache::UseTime0x00414090DBCache::FlushFreeObjects0x004141E0GraphicsResource::PurgeOldResources0x00446C60SceneTool::PurgeOldGraphicsResources0x0043E5C0
Modern reference seam:
- WorldBuilder's extracted
TextureAtlasManagerreference-counts a texture layer and releases it when its last mesh user disappears. - acdream's
ObjectMeshManager.UnloadObjectalready disposes an empty atlas and removes it from_globalAtlases; the missing lifetime is the separateTextureCacheused 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:
527legacy palette composites pre-warmed byEntitySpawnAdapterbut never consumed by the modern draw path;366base bindless textures duplicating textures already resident in the WorldBuilder atlas;587bindless 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 25–30 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 125–153 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.