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>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -46,7 +46,7 @@ Copy this block when adding a new issue:
## #225 — Scene particles overpaint translucent world objects
**Status:** IN-PROGRESS — implementation complete; connected visual gate pending
**Status:** IN-PROGRESS — implementation/reviews and connected stress gate pass; final visual gate pending
**Severity:** MEDIUM
**Filed:** 2026-07-18
**Component:** rendering / world translucency / particles
@ -79,7 +79,7 @@ order inside one instanced draw, with only DAT blend-mode changes splitting a
run. The identical location recovered to about 153 FPS / 6.6 ms in the
connected Release client.
A second, genuinely cumulative portal regression remained: ACE retains
A second, genuinely cumulative portal regression was first isolated: ACE retains
`KnownObjects` across normal teleports, while acdream retained every old
`LiveEntityRecord` indefinitely. Each destination therefore left animation,
effect, and render owners active; after enough trips the render thread blocked
@ -93,6 +93,30 @@ duplicating vertices per material and growing forever. A connected five-region
round trip returned live/animation ownership to baseline, recreated C95B on
revisit, and held its normal 6080 FPS.
That shorter route did not close the process-lifetime problem. A subsequent
multi-recall run still climbed to about 3.0 GiB working set / 3.5 GiB private
memory and reproduced the 512 FPS collapse, with effect emitters, composite
textures, decoded DAT objects, texture atlases, and physical GL stores remaining
resident after their owners left. The final integration therefore makes the
whole chain owner-scoped and bounded: exact-incarnation appearance replacement,
retryable live/landblock/UI/portal teardown, emitter retirement indexes,
bounded DAT/decoded/standalone/composite caches, reclaimable mesh/atlas storage,
incremental arena migration, and three-frame GPU-fenced physical reuse. It also
reuses per-frame scratch storage without clearing a legitimate large working set.
No draw distance, texture resolution, particle range, or visual effect was
reduced.
The final connected route (Caul → Sawato → Rynthid → Aerlinthe → Sawato →
Holtburg → Caul, with 2530 second destination dwells and 60 seconds after the
return) passed without an exception, WER report, or AMD display-driver reset.
Peak working set fell from 2,954.5 MiB to 1,493.4 MiB and peak private memory
from 3,502.3 MiB to 1,969.5 MiB versus the failing build. Returned Caul settled
at 1,030.6 MiB working set / 1,638.2 MiB private / 831.6 MiB local GPU; the final
local-display dwell held 125153 FPS (141.8 average). Emitter/binding ownership
balanced at 1,715/1,715 and composite physical residency remained below its
128 MiB ceiling. The older 32 FPS comparison run was RDP-refresh-capped and is
used only for memory comparison. The lifestone/particle visual gate remains.
**Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`;
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`;
`src/AcDream.App/Rendering/ParticleRenderer.cs`;
@ -100,11 +124,17 @@ revisit, and held its normal 6080 FPS.
`src/AcDream.App/Rendering/Shaders/particle.frag`;
`src/AcDream.App/Rendering/RetailPViewRenderer.cs`;
`src/AcDream.App/World/LiveEntityLivenessController.cs`;
`src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs`.
`src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs`;
`src/AcDream.App/Rendering/GpuFrameFlightController.cs`;
`src/AcDream.App/Rendering/CompositeTextureArrayCache.cs`;
`src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs`;
`src/AcDream.Content/BoundedDatObjectCache.cs`;
`src/AcDream.Content/DecodedTextureCache.cs`.
**Research:**
`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`;
`docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`.
`docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`;
`docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md`.
**Acceptance:** At a translucent lifestone, smoke or flame behind the crystal
is attenuated by it while an effect in front remains bright. The lifestone's

View file

@ -488,6 +488,44 @@ identity plus animation, motion, physics, collision, selection, and
dead-reckoning owners. Only a real delete/despawn tears those owners down. This
matches retail's `CPhysicsObj::DoObjDescChangesFromDefault` behavior.
### Runtime resource ownership and bounded residency
Logical lifetime, spatial residency, and physical GPU lifetime are separate
contracts. A live entity or landblock owns stable logical references; moving it
between buckets does not reacquire resources. Appearance replacement acquires
the complete new mesh/texture set before publication, then releases the old set.
Despawn and landblock demotion withdraw every public render reference before
their physical resources become reclaimable. All of these transactions are
retryable and generation-scoped, so a failed release cannot silently strand a
half-retired owner or affect a reused server GUID.
Runtime content residency is deliberately bounded rather than proportional to
every region visited:
- `RuntimeDatCollectionFactory` keeps DAT indexes on demand but uses
`FileCachingStrategy.Never`; `DatCollection` remains the sole raw reader.
- Each typed DAT facade has a 256-entry / 64 MiB estimated LRU. Unknown object
graphs are conservatively charged at least 128 KiB. Canonical decoded texture
pixels use a separate 128-entry / 64 MiB cache.
- Standalone bindless textures retain at most 256 unowned entries / 32 MiB and
retire at most one per frame. Owner-scoped composite textures use a 64 MiB
unowned budget and 128 MiB physical budget, admitting at most 16 uploads or
8 MiB per frame.
- `ObjectMeshManager` may retain at most 32 empty texture atlases / 64 MiB.
`GlobalMeshBuffer` owns reclaimable vertex/index ranges capped at 384 MiB and
128 MiB respectively, with an 896 MiB physical ceiling that includes an
in-progress migration and its retired predecessor.
OpenGL deletion and range/slot reuse are not synonymous with logical release.
`GpuFrameFlightController` fences three frames in flight. Mesh-buffer stores,
texture handles, atlas layers, terrain slots, and landblock render records enter
retirement only after they are no longer publishable, and their physical ids are
recycled only after the corresponding fence signals. Shutdown follows the same
dependency order and remains retryable: UI/controllers and render registrations
withdraw first, then owner leases and caches, then GL backing stores. This keeps
drivers from reading freed memory without adding a portal-specific purge or a
visual-distance reduction.
---
## Per-Frame Update Order (current runtime)

View file

@ -4,7 +4,7 @@
"Code Structure Rules" section in `CLAUDE.md`.
**Purpose:** Describe the desired structural state of the App layer,
explain the rules we've adopted, and lay out the safe extraction
sequence from today's reality (one 10,304-line `GameWindow.cs`) to the
sequence from today's reality (one 15,288-line `GameWindow.cs`) to the
target (thin `GameWindow`, small focused collaborators).
**Companion to:** [`acdream-architecture.md`](acdream-architecture.md)
(the layered architecture) and
@ -20,7 +20,7 @@ layer is wire-compatible, the UI has a stable contract, plugins load.
The structural debt is concentrated in **one file**:
```
src/AcDream.App/Rendering/GameWindow.cs 10,304 lines
src/AcDream.App/Rendering/GameWindow.cs 15,288 lines
```
`GameWindow` is the single object that:
@ -75,28 +75,13 @@ delegate to a collaborator for the substance.
a GL or windowing namespace, we've lost the ability to test it without
a graphics context, and the layer split becomes fiction.
**How to apply:** The only currently-allowed seams from Core into the
WB / GL world are:
- `WorldBuilder.Shared` — stateless helpers (`TerrainUtils`,
`TerrainEntry`, `RegionInfo`).
- `Chorizite.OpenGLSDLBackend.Lib` — stateless helpers
(`SceneryHelpers`, `TextureHelpers`).
Both are leaf namespaces with no GL state. If you need a new seam (e.g.
WB's `ObjectMeshManager` needs to be visible from Core), the change
**must** come with an inventory-doc update justifying it and ideally a
slim interface in Core that the App layer implements.
**Current reality:** `src/AcDream.Core/AcDream.Core.csproj` references
`Chorizite.OpenGLSDLBackend` (not just `OpenGLSDLBackend.Lib`). This is
historical. Two Core files import from it:
- `World/SceneryGenerator.cs``Chorizite.OpenGLSDLBackend.Lib`
- `Textures/SurfaceDecoder.cs``Chorizite.OpenGLSDLBackend.Lib`
Both use the stateless `Lib` namespace only. The full project reference
is wider than it needs to be; tightening it to just `WorldBuilder.Shared`
+ a stateless-helpers shim is a candidate future cut, but not urgent.
**How to apply:** Phase O removed both external WorldBuilder/backend project
references. The only currently allowed seams are the GL-free helpers owned in
our tree under `src/AcDream.Core/Rendering/Wb/`: `TerrainUtils`,
`TerrainEntry`, `RegionInfo`, `SceneryHelpers`, and `TextureHelpers`.
`ObjectMeshManager` and every GL resource owner remain in App. If Core needs a
new capability, define a narrow Core interface and implement it in App; adding
a new project reference requires an inventory-doc update explaining why.
### Rule 3: UI panels target `AcDream.UI.Abstractions` only
@ -159,11 +144,12 @@ Today:
- `tests/AcDream.Core.Tests/``src/AcDream.Core/`
- `tests/AcDream.Core.Net.Tests/``src/AcDream.Core.Net/`
- `tests/AcDream.UI.Abstractions.Tests/``src/AcDream.UI.Abstractions/`
- `tests/AcDream.App.Tests/``src/AcDream.App/`
`AcDream.App` does **not** yet have a test project. The RuntimeOptions
extraction is the trigger to create `tests/AcDream.App.Tests/`. Future
App-layer tests (LiveSessionController, SelectionInteractionController,
etc.) go there.
`tests/AcDream.App.Tests/` now exists and owns App-layer controller, streaming,
render-resource lifetime, retained-UI, and `RuntimeOptions` tests. New App tests
belong there; do not place GL-free Core behavior in that project merely because
App currently wires it.
---

View file

@ -83,7 +83,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |

View file

@ -186,6 +186,20 @@ rendering and landblock mesh pins while retaining the terrain slot. Core's
matching physics demotion preserves the terrain surface but removes indoor
cells, portals, buildings, and static shadow registrations.
**Bounded residency and GPU retirement seam (2026-07-18).** Runtime DAT access
keeps raw file payload caching disabled and layers bounded typed-object and
decoded-pixel LRUs above the single `DatCollection`. `ObjectMeshManager`, the
standalone bindless texture cache, and the owner-scoped composite texture-array
cache all distinguish an active owner from an evictable unowned entry. Appearance
changes and landblock demotion acquire-before-publish and withdraw-before-release;
rebucketing never creates a second owner. `GlobalMeshBuffer` uses reclaimable,
coalescing vertex/index ranges and migrates incrementally within explicit physical
ceilings. Texture layers, terrain slots, mesh ranges, and old backing stores are
returned only after `GpuFrameFlightController` observes the frame fence that can
no longer reference them. This lifetime machinery is acdream-owned integration
around the extracted WB mesh pipeline; it does not add a second DAT decoder or a
reduced-distance rendering path.
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
algorithm, **check this inventory first**. If we already extracted it (🟢
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has

View file

@ -561,7 +561,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains for exact ObjCell-PVS/preview-retention parity after the 2026-07-18 liveness port.
- **Missile/portal VFX campaign Step 9 automated hardening implemented and independently reviewed 2026-07-14; final two-client visual gate pending.** A deterministic 96-owner App fixture drives canonical missiles and effect owners through projectile updates, repeated DAT-effect scheduling, loaded↔pending↔loaded landblock churn, light/particle withdrawal and recovery, accepted deletes, same-GUID generation reuse, never-created F754 queues, and session reset. It asserts balanced logical render registration and zero residual records/bodies/projectiles, spatial buckets/rescues, shadows (including suspended registrations), effect profiles/packets, PhysicsScript FIFOs/anchors/delayed calls, particle bindings/logical IDs/render-pass owners, poses, light-controller/sink/manager owners, and stale record component references. A companion twelve-cycle gate drives exact recall motion `0x10000153` through AnimationSequencer/CallPES, Hidden, deferred remote placement, hydration, and UnHide; a second 96-owner `EntitySpawnAdapter` gate balances actual mesh-adapter reference counts without GL. The pass fixed undrained persistent rescue retention, delayed stale visibility edges, GUID-scoped teardown, create resurrection after a nested delete/reset, non-atomic resource registration, double teardown from a re-entrant session-clear callback, observer failure stranding later visibility edges, and stale outer projection transactions overwriting callback-created replacements. Teardown now removes exact projection references and generation/local-ID owners, uses per-GUID/session lifetime epochs plus per-record projection tokens, drains visibility fan-out before aggregating failures, and finishes or supersedes canonical commits before surfacing observer errors. Core remains GL/backend-free, panels remain on UI abstractions, and projectiles still draw through ordinary `WbDrawDispatcher` live-entity submission rather than a global projectile pass. AP-69 and TS-49 remain; AP-83/AP-91 now explicitly describe only a future mover that enables PerfectClip.
- **Portal lifetime/performance correction 2026-07-18; user visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. Expiry reuses the exact generation-safe F747 teardown. The modern `GlobalMeshBuffer` now uploads vertices once per mesh and owns coalescing vertex/index ranges released by zero-reference LRU eviction. A connected five-region route returned animation ownership to baseline, reclaimed/reused GPU ranges, recreated the starting region on revisit, and prevented the prior persistent 312 FPS collapse. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain.
- **Portal/resource lifetime correction 2026-07-18; connected stress gate passed, final visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. The broader integration balances generation-scoped appearance/live/landblock/effect/UI/portal owners, bounds DAT/decoded/standalone/composite/atlas/mesh residency, performs incremental mesh-arena migration, and defers GL deletion or slot reuse through three frames of GPU fences. Render/UI scratch storage is reused with hysteretic trimming. No visual range or quality setting was reduced. The final seven-destination connected route cut peak working/private memory from 2,954.5/3,502.3 MiB to 1,493.4/1,969.5 MiB, returned Caul to 1,030.6/1,638.2 MiB, held 125153 FPS locally through the final dwell, and produced no WER/driver reset. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain.
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
@ -1311,6 +1311,10 @@ port in any phase — no separate listing here.
> number and accepts the rewrite risk. Rust rejected. Parked (resume from the table
> below): MP1b pak/bake (needs the 865 GB EnvCell-dedup slice; loading-speed lever),
> MP-Alloc hard sites, MP1c, MP2. Full rationale: `project_mp_track_findings.md`.
> The 2026-07-18 portal/resource-lifetime repair does not resume MP's ECS, pak,
> or throughput redesign. It is corrective ownership/reclamation work for a
> reproduced crash-class regression in the mandatory renderer, using the current
> architecture and preserving pixels/ranges.
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
umbrella design — read it first). **Goal:** smoothness first (no frame over

View file

@ -5,9 +5,12 @@
**Currently working toward:** **M3 — "Cast a spell." M2 — "Kill a drudge"
LANDED 2026-07-15.** The complete connected melee/missile, death/loot,
inventory, and item-giving demo is user-gated. The shared projectile/effect
foundation is hardened through Step 9 and its single-client visual gate passes.
Next is the connected F.5 component-book/favorite-bar visual gate, followed by
cast/enchantment presentation and the two-client portal/observer VFX gate.
foundation is hardened through Step 9. Spellbook/component-book filtering,
favorite spell bar, connected casting/enchantment effects, and portal-space
presentation have passed their single-client visual gates. The corrective bounded
render/resource-lifetime integration passed its seven-destination connected
stress gate without a crash or cumulative collapse. The remaining M3 visual
requirement is the final two-client portal-out/materialize observer gate.
Carried:
#145-residual, #116 slide-response, R6/TS-42, and Track MP0.
@ -285,6 +288,11 @@ as ad-hoc rework. Rule 2's freeze still binds all NON-MP work. MP runs in
dedicated side-track sessions; the M1.5 critical path wins every conflict,
and MP4 is hard-queued behind #137.
The 2026-07-18 repeated-portal lifetime repair is a freeze-allowed corrective
change, not a resumption of Track MP. It keeps the current renderer and visual
ranges while balancing owners, bounding residency, and fence-delaying physical
GPU reuse; MP's pak/ECS/throughput redesign remains parked.
**Dungeon support (Phase G.3, issue #133) — SHIPPED.** The original
premise here (terrain-less dungeon landblocks unsupported anywhere in the
streaming/load/render/physics pipeline) was refuted at design time

View file

@ -0,0 +1,208 @@
# 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.