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

208 lines
7.6 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 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
```text
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
```text
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
```text
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
```text
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
```text
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
```
```text
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.