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>
208 lines
7.6 KiB
Markdown
208 lines
7.6 KiB
Markdown
# 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 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.
|