// src/AcDream.App/Rendering/TextureCache.cs using AcDream.Core.Textures; using AcDream.Core.World; using AcDream.Content; using AcDream.App.Rendering.Gpu; using DatReaderWriter; using DatReaderWriter.DBObjs; using System.Linq; using PixelFormatId = DatReaderWriter.Enums.PixelFormat; using SurfaceType = DatReaderWriter.Enums.SurfaceType; using AcDream.App.Rendering.Residency; namespace AcDream.App.Rendering; public sealed class TextureCache : Wb.IEntityTextureLifetime, IDisposable { private readonly IGpuDevice _device; private readonly IDatReaderWriter _dats; private readonly string _diagnosticsDirectory; private readonly Dictionary<(uint SurfaceId, uint OrigTextureId), (int Width, int Height)> _decodedDimensionsByTexture = new(); /// /// Campaign V slice V4a: one registered plus its /// device texture-table , decoded pixel size, /// and the raw GL name 's classic /// texture-unit binding path still needs (see that class's remarks). /// private readonly record struct GpuUiTextureEntry( IGpuTexture Texture, GpuTextureSlot Slot, uint GlName, int Width, int Height); // Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids // decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the // Surface→SurfaceTexture chain that GetOrUpload uses for world materials. private readonly Dictionary _renderSurfaceGpuTextures = new(); // Campaign LA gate round 2: the OTHER magenta cause GetOrUploadRenderSurface can // hit — a non-zero id that simply isn't a RenderSurface in either dat (as opposed // to SurfaceDecoder's own logged causes for an id that DOES resolve but can't // decode). Same "loud, not silent" treatment, same log-once-per-id dedup pattern // already used by EquippedChildRenderController._loggedUnaddressableParentRefusals. private readonly HashSet _loggedMissingRenderSurfaceIds = new(); // Ad-hoc textures produced by the public UploadRgba8(byte[],int,int,bool) wrapper // (used by IconComposer for composited item icons). These are NOT stored in any // of the keyed caches above, so Dispose must sweep this list to avoid leaking // GPU texture objects/slots until process exit. private readonly List _adhocGpuTextures = new(); // Campaign LA gate round 2 (AD-98 filtering fidelity): the ORIGINAL IGpuTexture // behind every handle UploadUiTexture registered nearest (dat-font glyph // atlases, IconComposer's composited icons). Populated at upload time so // GetOrCreateLinearUiTwin never has to search either keyed family above to // find the pixels a twin should reuse. Chrome/background art (nearest: false) // never enters this table — it already samples GpuSamplerDescription.WorldRepeat // (linear) and has no twin to create. private readonly Dictionary _nearestUiTextureSources = new(); // The LINEAR-sampled twin handle for a nearest handle, created lazily by // GetOrCreateLinearUiTwin on its first request and reused after. Empty for // the lifetime of a session that never activates a fixed-canvas screen. private readonly Dictionary _linearUiTwinHandles = new(); private readonly CompositeTextureArrayCache? _compositeTextures; private bool _destinationRevealUploadPriority; // Standalone Texture2DArray caches. Shared world surfaces use WB's atlas; // this base cache remains for consumers such as particle rendering. // Per-entity override composites are owner-scoped but share pooled array // storage. Retail CSurface ownership releases immediately while ImgTex // residency remains separately purgeable; CompositeTextureArrayCache // mirrors that split without one GL object per material composite. private readonly StandaloneBindlessTextureCache? _particleTextures; private readonly Dictionary<(uint surfaceId, uint origTexOverride), bool> _paletteIndexedByTexture = new(); internal int OwnedBindlessTextureCount => _compositeTextures?.ActiveResourceCount ?? 0; internal int TextureOwnerCount => _compositeTextures?.OwnerCount ?? 0; internal int CachedCompositeTextureCount => _compositeTextures?.CachedEntryCount ?? 0; internal int CachedUnownedCompositeCount => _compositeTextures?.UnownedEntryCount ?? 0; internal long CachedUnownedCompositeBytes => _compositeTextures?.UnownedBytes ?? 0; internal int CompositeAtlasCount => _compositeTextures?.AtlasCount ?? 0; internal long CompositeAtlasBytes => _compositeTextures?.AllocatedBytes ?? 0; internal int CompositeFrameUploadCount => _compositeTextures?.FrameUploadCount ?? 0; internal long CompositeFrameUploadBytes => _compositeTextures?.FrameUploadBytes ?? 0; internal bool CanStartCompositeUpload => _compositeTextures?.CanStartUpload == true; internal int CachedParticleTextureCount => _particleTextures?.EntryCount ?? 0; internal int ActiveParticleTextureCount => _particleTextures?.ActiveResourceCount ?? 0; internal int ParticleTextureOwnerCount => _particleTextures?.OwnerCount ?? 0; internal int CachedUnownedParticleTextureCount => _particleTextures?.UnownedEntryCount ?? 0; internal long CachedUnownedParticleTextureBytes => _particleTextures?.UnownedBytes ?? 0; internal void SetDestinationRevealUploadPriority(bool enabled) => _destinationRevealUploadPriority = enabled; // Phase N.6 slice 1 (2026-05-11): per-upload metadata for the // ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload // time so the dump method doesn't have to query GL state. Keyed by // GL texture name (same key used in cache value tuples). Format // label is "RGBA8_DECODED" for the post-decode upload (all uploads // currently land as RGBA8 regardless of source format). private readonly Dictionary _uploadMetadata = new(); // Frame counter for the one-shot ACDREAM_DUMP_SURFACES=1 trigger. // Increments per Tick call; fires the dump once at frame index 600 // and never again for the session. See spec §5. private int _dumpFrameCounter; private bool _surfaceHistogramAlreadyDumped; // internal, not public: IGpuDevice is an internal type (the pinned RHI // contract), and this convenience overload has no real caller today (both // production construction sites already target the internal overload // below) — kept internal rather than deleted to preserve its shape. internal TextureCache(IGpuDevice device, IDatReaderWriter dats) : this( device, dats, ImmediateGpuResourceRetirementQueue.Instance, Path.Combine( Path.GetTempPath(), "acdream", "diagnostics")) { } internal TextureCache( IGpuDevice device, IDatReaderWriter dats, IGpuResourceRetirementQueue retirementQueue, string diagnosticsDirectory, ResidencyBudgetOptions? budgets = null) { budgets ??= ResidencyBudgetOptions.Default; _device = device ?? throw new ArgumentNullException(nameof(device)); _dats = dats; ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory); _diagnosticsDirectory = diagnosticsDirectory; ArgumentNullException.ThrowIfNull(retirementQueue); // Campaign V slice V6l: both owner-scoped caches exist on both arms. // // Everything about them that matters — sharing equivalent surfaces // between owners, the bounded unowned LRU, the metered upload budget, // and retirement behind the frame-flight fence — is already // backend-neutral; only how one entry is created and destroyed // differs, which is exactly what the two backend interfaces are for. var resources = new ResourceCleanupGroup(); CompositeTextureArrayCache? composite = null; StandaloneBindlessTextureCache? particles = null; try { composite = new CompositeTextureArrayCache( new RhiCompositeTextureArrayBackend(device), retirementQueue, budgets.CompositeUnownedBytes, budgets.CompositePhysicalBytes); resources.Add("composite texture cache", composite.Dispose); particles = new StandaloneBindlessTextureCache( new ParticleRhiTextureBackend(this), retirementQueue, budgets.StandaloneUnownedBytes, budgets.StandaloneUnownedEntries); resources.Add("particle texture cache", particles.Dispose); resources.TransferAll(); } catch (Exception constructionFailure) { resources.RollbackConstructionAndThrow( "TextureCache construction failed and its child-cache prefix did not cleanly roll back.", constructionFailure); } _compositeTextures = composite; _particleTextures = particles; } internal void RegisterResidencySources(ResidencyManager manager) { ArgumentNullException.ThrowIfNull(manager); if (_compositeTextures is not null) { manager.RegisterDomainSource(new DelegateResidencyDomainSource( ResidencyDomain.CompositeTextures, _compositeTextures.CaptureResidency)); } if (_particleTextures is not null) { manager.RegisterDomainSource(new DelegateResidencyDomainSource( ResidencyDomain.StandaloneTextures, CaptureStandaloneResidency)); } } private ResidencyDomainSnapshot CaptureStandaloneResidency() { StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable(); return new ResidencyDomainSnapshot( ResidencyDomain.StandaloneTextures, EntryCount: textures.EntryCount, OwnerCount: textures.OwnerCount, Charges: new ResidencyCharges( GpuResidentBytes: checked( textures.AllocatedBytes - textures.RetiringBytes), RetiringBytes: textures.RetiringBytes), BudgetBytes: textures.BudgetBytes); } /// /// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded /// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the /// Surface→SurfaceTexture chain that uses /// for world-geometry materials. This is the correct path for retail UI /// chrome + font glyph sheets, which reference RenderSurface directly. /// Paletted (PFID_P8 / PFID_INDEX16) UI sprites — e.g. the selected-object /// health-bar track 0x0600193E — are decoded against the RenderSurface's own /// DefaultPaletteId (same starting palette /// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns /// a 1x1 magenta handle on miss. /// /// Campaign V slice V6d: the returned value is a /// — a one-based index into the device's /// global texture table — not a raw GL texture name. Every caller passes it /// straight to , which samples the /// table; nothing reads it as a GL name, and on Vulkan there is no GL name. /// Zero still means "no texture", which is what every widget guards on. /// public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false) { if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing)) { width = existing.Width; height = existing.Height; return UiTextureTableHandle.FromSlot(existing.Slot); } DecodedTexture decoded; if (_dats.Portal.TryGet(renderSurfaceId, out var rs) || _dats.HighRes.TryGet(renderSurfaceId, out rs)) { // Resolve the surface's own default palette so paletted UI sprites decode // correctly instead of the magenta fallback (the back-track 0x0600193E behind // the selected-object health bar is PFID_P8/INDEX16). Non-paletted formats // (DefaultPaletteId==0) keep the previous null-palette behaviour unchanged. Palette? palette = rs.DefaultPaletteId != 0 ? _dats.Get(rs.DefaultPaletteId) : null; decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette); } else { if (_loggedMissingRenderSurfaceIds.Add(renderSurfaceId)) { Console.WriteLine( $"[UI] TextureCache: RenderSurface 0x{renderSurfaceId:X8} was not " + "found in Portal or HighRes — drawing the 1x1 magenta placeholder."); } decoded = DecodedTexture.Magenta; } GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}"); _renderSurfaceGpuTextures[renderSurfaceId] = entry; width = decoded.Width; height = decoded.Height; return UiTextureTableHandle.FromSlot(entry.Slot); } /// /// Campaign V slice V4a: creates an for one /// decoded UI sprite/atlas and registers it into the device's global /// texture table. Every UI-path texture uses REPEAT addressing (existing /// behaviour — panel fills and tiled chrome sample UVs greater than 1) and /// a single mip level (UI sprites never mip). /// /// Campaign V slice V6d: the sampler is now what filtering actually /// comes from. Before this slice the draw bound the texture object directly, /// so filtering lived on the texture and was /// applied with a raw glTexParameter before the bindless handle was /// made resident. Sampling through the table means a bound sampler object /// overrides those parameters, so a nearest-requested sprite has to be /// registered with a nearest SAMPLER or every retail icon and dat-font /// glyph would silently become bilinear. /// /// /// Campaign V slice V6k: one world Surface as a device texture-table slot, /// sampled with the wrap mode the caller needs. /// /// The sky's RHI arm is the only consumer, and it exists because that /// arm has no GL texture name to intern a bindless handle from — a Vulkan /// draw cannot sample a GL handle. The decode is the same /// the GL path uses, so the pixels are /// identical; what differs is that the image is created through /// and paired with a real sampler /// object rather than baked into a handle. /// /// Keyed by (surface, wrap) for the same reason the GL arm keys its /// handles that way: a table entry is a combined image sampler, so the dome /// sampled CLAMP_TO_EDGE and a scrolling cloud sheet sampled REPEAT are two /// entries even when they name one decoded texture. /// internal GpuTextureSlot RegisterWorldSurface(uint surfaceId, bool repeat) { var key = (surfaceId, repeat); if (_worldSurfaceGpuTextures.TryGetValue(key, out GpuUiTextureEntry existing)) return existing.Slot; DecodedTexture decoded = DecodeFromDats( surfaceId, origTextureOverride: null, paletteOverride: null); GpuUiTextureEntry entry = UploadWorldSurfaceTexture( decoded, repeat, $"world-surface-0x{surfaceId:X8}{(repeat ? "-repeat" : "-clamp")}"); _worldSurfaceGpuTextures[key] = entry; return entry.Slot; } private readonly Dictionary<(uint SurfaceId, bool Repeat), GpuUiTextureEntry> _worldSurfaceGpuTextures = new(); private GpuUiTextureEntry UploadWorldSurfaceTexture( DecodedTexture decoded, bool repeat, string debugName) { IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription( debugName, GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, Width: decoded.Width, Height: decoded.Height, LayerCount: 1, MipLevelCount: 1)); try { texture.Upload(0, 0, decoded.Rgba8); uint glName = UploadAccountingName(texture); TrackUploadedTexture(glName, decoded.Width, decoded.Height); // Linear/linear with a single level — the filtering // TextureCache's own GL uploads have always used for sky surfaces, // and the wrap mode SamplerCache's two objects express on GL. IGpuSampler sampler = _device.CreateSampler( repeat ? GpuSamplerDescription.WorldRepeat : GpuSamplerDescription.WorldClamp); GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height); } catch { texture.Dispose(); throw; } } private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName) { IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription( debugName, GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, Width: decoded.Width, Height: decoded.Height, LayerCount: 1, MipLevelCount: 1)); try { texture.Upload(0, 0, decoded.Rgba8); uint glName = UploadAccountingName(texture); TrackUploadedTexture(glName, decoded.Width, decoded.Height); IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat); GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); uint handle = UiTextureTableHandle.FromSlot(slot); if (nearest) { // AD-98 filtering fidelity: remember the source texture under its // handle so a fixed-canvas screen can request a linear twin of it // later without re-decoding. See GetOrCreateLinearUiTwin. _nearestUiTextureSources[handle] = texture; } return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height); } catch { texture.Dispose(); throw; } } /// /// Campaign LA gate round 2 (register AD-98): the LINEAR-sampled twin of a /// nearest-sampled UI texture handle, created and table-registered the first /// time it is requested and reused after. /// /// /// Nearest is correct at the UI's native 1:1 scale — it is what makes /// dat-font glyphs and composited item icons pixel-exact retail art. Retail's /// own fixed-canvas pre-world screens never stretch a source texture at all: /// they compose at authored size and the WHOLE FRAME goes through a single /// bilinear-filtered presentation blit (see /// 's doc comment for the /// retail citation). acdream has no present-time frame stretch to hang that /// on, so the equivalent has to live one step earlier, at the source texture: /// while is scaling the composed quads /// themselves, this method gives a nearest handle a same-pixels twin sampled /// LINEAR instead, so the stretch softens the way retail's frame blit did /// rather than aliasing. /// /// /// /// Returns UNCHANGED for anything this cache never /// registered nearest — chrome/background art already samples /// (linear) and has nothing to /// swap, and (DrawFill's untextured /// branch) is not a texture at all. Callers do not need to know which case /// they're in: this is a cheap dictionary probe either way, so /// can call it unconditionally whenever /// the canvas is scaled. /// /// /// /// The twin reuses the ORIGINAL — no re-decode, no /// second upload, no additional bytes tracked in the memory ledger — and /// occupies one more device texture-table slot, exactly the shape /// 's (surface, wrap) keying already uses to /// register one texture under two samplers. Lazy: a session that never /// activates a fixed-canvas screen never creates one. /// /// internal uint GetOrCreateLinearUiTwin(uint handle) { if (!_nearestUiTextureSources.TryGetValue(handle, out IGpuTexture? texture)) return handle; if (_linearUiTwinHandles.TryGetValue(handle, out uint twin)) return twin; IGpuSampler linearSampler = _device.CreateSampler(GpuSamplerDescription.WorldRepeat); GpuTextureSlot twinSlot = _device.RegisterTexture(texture, linearSampler); uint twinHandle = UiTextureTableHandle.FromSlot(twinSlot); _linearUiTwinHandles[handle] = twinHandle; return twinHandle; } /// /// The identity a UI upload is accounted under. There is no GL name on the /// Vulkan-only backend, so a descending synthetic counter supplies one; the /// value is a dictionary key and a dedup token only — Campaign V slice V6d /// removed the last draw-time consumer of a raw GL name, so nothing binds it. /// private uint UploadAccountingName(IGpuTexture texture) => _nextSyntheticUploadName--; private uint _nextSyntheticUploadName = uint.MaxValue; /// /// Point sampling with REPEAT addressing — pixel-exact retail UI art that is /// still tiled by nine-slice chrome and meter tracks. Neither stock preset /// fits: UiNearest clamps, WorldRepeat filters. /// private static readonly GpuSamplerDescription UiNearestRepeat = new( GpuFilter.Nearest, GpuFilter.Nearest, GpuMipFilter.None, GpuAddressMode.Repeat, GpuAddressMode.Repeat, MaxAnisotropy: 1f); /// /// Acquires the exact DAT-decoded one-layer texture array for a live /// particle emitter. Equivalent surfaces are shared; the cache ownership /// ends with . /// /// Campaign V slice V4t: returns the device texture-table /// rather than the raw bindless handle. The /// handle is still created, made resident and destroyed here — only the /// table entry belongs to the device. /// internal GpuTextureSlot AcquireParticleTexture(int emitterHandle, uint surfaceId) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(emitterHandle); ArgumentOutOfRangeException.ThrowIfZero(surfaceId); StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable(); uint ownerId = checked((uint)emitterHandle); if (textures.TryAcquire( ownerId, surfaceId, out StandaloneBindlessTextureResource? existing)) { return existing.Slot; } DecodedTexture decoded = DecodeFromDats( surfaceId, origTextureOverride: null, paletteOverride: null); return AcquireParticleTextureRhi(textures, ownerId, surfaceId, decoded); } /// /// Campaign V slice V6l: one particle surface as a device texture-table /// slot, owned by the same emitter-scoped cache. /// /// Linear/clamped matches the filtering the deleted GL arm's own /// one-layer array upload set on itself, and a particle sheet's UVs never /// leave [0,1] — the quad's own texcoords are the unit square — so the /// wrap mode is not a visible choice, it is just the safe one. /// private GpuTextureSlot AcquireParticleTextureRhi( StandaloneBindlessTextureCache textures, uint ownerId, uint surfaceId, DecodedTexture decoded) { IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription( $"particle-surface-0x{surfaceId:X8}", GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, Width: decoded.Width, Height: decoded.Height, LayerCount: 1, MipLevelCount: 1)); try { texture.Upload(0, 0, decoded.Rgba8); uint accountingName = UploadAccountingName(texture); TrackUploadedTexture(accountingName, decoded.Width, decoded.Height); IGpuSampler sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp); GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); textures.AddAndAcquire(ownerId, new StandaloneBindlessTextureResource { SurfaceId = surfaceId, Name = accountingName, Texture = texture, Slot = slot, Bytes = checked((long)decoded.Width * decoded.Height * 4L), }); return slot; } catch { texture.Dispose(); throw; } } internal void ReleaseParticleTextureOwner(int emitterHandle) { if (emitterHandle <= 0 || _particleTextures is null) return; _particleTextures.ReleaseOwner(checked((uint)emitterHandle)); } /// /// Owner-scoped bindless variant for a server-supplied original-texture /// replacement. Stores compatible composites in a pooled Texture2DArray /// and returns its resident handle plus the assigned layer. Equivalent /// composites are shared until their final live owner leaves. Returns /// (an empty location) if a composite upload /// can't start or the decoded size can't be prepared this frame. /// internal BindlessTextureLocation GetOrUploadWithOrigTextureOverrideBindless( uint ownerLocalId, uint surfaceId, uint overrideOrigTextureId) { CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable(); var key = new CompositeTextureKey( CompositeTextureKind.OriginalTextureOverride, surfaceId, overrideOrigTextureId, Palette: default); if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing)) return existing; if (!composites.CanStartUpload) return default; (int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId); if (!composites.CanPrepareUpload(width, height)) return default; DecodedTexture decoded = DecodeFromDats( surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null, bakeAuthoredTranslucency: true); return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added) ? added : default; } /// /// Owner-scoped bindless palette composite. Applies the palette override on /// top of the texture's default palette before decoding, stores compatible /// composites in a pooled Texture2DArray, and returns its resident handle /// plus the assigned layer. Structural identity is computed once per entity. /// Returns (an empty location) if a composite /// upload can't start or the decoded size can't be prepared this frame. /// internal BindlessTextureLocation GetOrUploadWithPaletteOverrideBindless( uint ownerLocalId, uint surfaceId, uint? overrideOrigTextureId, PaletteOverride paletteOverride, PaletteCompositeIdentity paletteIdentity) { CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable(); uint origTexKey = overrideOrigTextureId ?? 0; var key = new CompositeTextureKey( CompositeTextureKind.PaletteComposite, surfaceId, origTexKey, paletteIdentity); if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing)) return existing; if (!composites.CanStartUpload) return default; (int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId); if (!composites.CanPrepareUpload(width, height)) return default; DecodedTexture decoded = DecodeFromDats( surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride, bakeAuthoredTranslucency: true); return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added) ? added : default; } /// /// Retail applies a palette composite only to P8/INDEX16 image data. /// Cache the resolved source format so animated entities do not reopen the /// DAT chain every frame. /// internal bool IsPaletteIndexed(uint surfaceId, uint? overrideOrigTextureId) { uint origTexKey = overrideOrigTextureId ?? 0; var key = (surfaceId, origTexKey); if (_paletteIndexedByTexture.TryGetValue(key, out bool indexed)) return indexed; Surface? surface = _dats.Get(surfaceId); if (surface is null || surface.Type.HasFlag(SurfaceType.Base1Solid)) return _paletteIndexedByTexture[key] = false; uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId; SurfaceTexture? texture = _dats.Get(surfaceTextureId); if (texture is null || texture.Textures.Count == 0) return _paletteIndexedByTexture[key] = false; uint renderSurfaceId = (uint)texture.Textures[0]; if (!_dats.Portal.TryGet(renderSurfaceId, out RenderSurface? renderSurface) && !_dats.HighRes.TryGet(renderSurfaceId, out renderSurface)) return _paletteIndexedByTexture[key] = false; indexed = renderSurface.Format is PixelFormatId.PFID_P8 or PixelFormatId.PFID_INDEX16; _paletteIndexedByTexture[key] = indexed; return indexed; } private (int Width, int Height) ResolveDecodedDimensions( uint surfaceId, uint? overrideOrigTextureId) { var key = (surfaceId, overrideOrigTextureId ?? 0); if (_decodedDimensionsByTexture.TryGetValue(key, out var cached)) return cached; Surface? surface = _dats.Get(surfaceId); if (surface is null || surface.Type.HasFlag(SurfaceType.Base1Solid) || (uint)surface.OrigTextureId == 0) return _decodedDimensionsByTexture[key] = (1, 1); uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId; SurfaceTexture? texture = _dats.Get(surfaceTextureId); if (texture is null || texture.Textures.Count == 0) return _decodedDimensionsByTexture[key] = (1, 1); uint renderSurfaceId = (uint)texture.Textures[0]; if ((!_dats.Portal.TryGet(renderSurfaceId, out RenderSurface? renderSurface) && !_dats.HighRes.TryGet(renderSurfaceId, out renderSurface)) || renderSurface.Width <= 0 || renderSurface.Height <= 0 || renderSurface.SourceData is null) return _decodedDimensionsByTexture[key] = (1, 1); return _decodedDimensionsByTexture[key] = (renderSurface.Width, renderSurface.Height); } /// /// Retail CSurface::Destroy (0x005361F0) releases its current /// ImgTex. Mirror that ownership boundary for per-entity composites. /// public void ReleaseOwner(uint localEntityId) { EnsureCompositeTexturesAvailable().ReleaseOwner(localEntityId); } /// /// Campaign V slice V6l: no longer gated on bindless. The composite cache is /// constructed on both arms — V6i-2's RHI backend is what serves the one /// without a GL context — so the only failure left is a cache that was never /// built at all. /// private CompositeTextureArrayCache EnsureCompositeTexturesAvailable() => _compositeTextures ?? throw new InvalidOperationException( "This TextureCache owns no composite texture array cache."); /// /// Campaign V slice V6l: no longer gated on bindless. The particle cache is /// constructed on both arms, so the only failure left is a cache that was /// never built at all. /// private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable() => _particleTextures ?? throw new InvalidOperationException( "This TextureCache owns no standalone particle texture cache."); /// /// Campaign V slice V6l: the table slot is released first and the image /// second — a submitted-but-unretired frame may still sample the slot, and /// is what defers its reuse. /// private sealed class ParticleRhiTextureBackend(TextureCache owner) : IStandaloneBindlessTextureBackend { public void MakeNonResident(StandaloneBindlessTextureResource resource) { if (resource.Slot.IsAssigned) owner._device.ReleaseTextureSlot(resource.Slot); } public void Delete(StandaloneBindlessTextureResource resource) { resource.Texture?.Dispose(); owner.UntrackUploadedTexture(resource.Name); } } /// /// Advances bounded composite-cache maintenance once per render frame. /// Logical owner release is immediate; at most one over-budget layer and /// one empty backing array are physically retired in this call. /// public void TickCompositeTextureCache() => _compositeTextures?.Tick(); /// /// Retires at most one over-budget standalone particle texture per render /// frame. Keeping this separate from owner release avoids portal-time GPU /// destruction bursts without changing live particle range or quality. /// public void TickParticleTextureCache() => _particleTextures?.Tick(); public void BeginCompositeTextureFrame() => _compositeTextures?.BeginFrame(_destinationRevealUploadPriority); /// /// Cheap 64-bit hash over a palette override's identity so two /// entities with the same palette setup share a decode. Internal so /// the WB dispatcher can compute it once per entity. /// internal static ulong HashPaletteOverride(PaletteOverride p) { // Not cryptographic — just needs to distinguish override setups // for caching. Start with base palette id, fold in each entry. ulong h = 0xCBF29CE484222325UL; // FNV-1a offset basis const ulong prime = 0x100000001B3UL; h = (h ^ p.BasePaletteId) * prime; foreach (var sp in p.SubPalettes) { h = (h ^ sp.SubPaletteId) * prime; h = (h ^ sp.Offset) * prime; h = (h ^ sp.Length) * prime; } return h; } internal static PaletteCompositeIdentity GetPaletteIdentity(PaletteOverride palette) => new(palette, HashPaletteOverride(palette)); /// /// Phase N.6 slice 1: one-shot surface-format histogram dump for the /// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires /// once after BOTH gates pass: /// 1. _dumpFrameCounter >= 600 — at least 600 OnRender ticks /// have elapsed (catches the "we're already past startup boilerplate" /// bound; ~10s at 60fps, ~3s at 200fps). /// 2. _uploadMetadata.Count >= 100 — the cache contains at /// least 100 uploaded textures, indicating streaming has actually /// pulled in world content (not just sky/UI/font). The original /// frame-only gate fired during the login/handshake phase where /// OnRender ticks at GUI rates but no world has streamed in. /// Output goes to the host-provided portable diagnostics directory. /// Zero cost /// when off. See spec §5 in /// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md. /// public void TickSurfaceHistogramDumpIfEnabled() { if (_surfaceHistogramAlreadyDumped) return; if (!string.Equals(System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SURFACES"), "1", StringComparison.Ordinal)) return; _dumpFrameCounter++; if (_dumpFrameCounter < 600) return; if (_uploadMetadata.Count < 100) return; DumpSurfaceHistogram(); _surfaceHistogramAlreadyDumped = true; } private void DumpSurfaceHistogram() { try { DumpSurfaceHistogramCore(); } catch (Exception ex) { // Diagnostic-only path. If the dump file can't be written // (disk full, permission denied, antivirus lock, path too // long) we must NOT crash OnRender — that would invalidate // the very measurement pass this diagnostic is meant to // support. Log to stderr and let the caller mark the dump // as "already done" so it doesn't retry every frame. Console.Error.WriteLine($"[N6-DUMP] Failed to write surface histogram: {ex.Message}"); } } private void DumpSurfaceHistogramCore() { System.IO.Directory.CreateDirectory(_diagnosticsDirectory); var outPath = System.IO.Path.Combine( _diagnosticsDirectory, "n6-surfaces.txt"); var sb = new System.Text.StringBuilder(); sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}"); sb.AppendLine("# Per-entry: surfaceId(hex), width, height, format, byteCount"); sb.AppendLine(); // Walk every cached entry across the 6 caches, dedupe by GL name. var seen = new HashSet(); long totalBytes = 0; var bucketsByDim = new Dictionary<(int W, int H), int>(); var bucketsByFormat = new Dictionary(); var bucketsByTriple = new Dictionary<(int W, int H, string F), int>(); void Emit(uint surfaceId, uint name) { if (!seen.Add(name)) return; if (!_uploadMetadata.TryGetValue(name, out var meta)) return; int bytes = meta.Width * meta.Height * 4; totalBytes += bytes; sb.AppendLine($"0x{surfaceId:X8}, {meta.Width}, {meta.Height}, {meta.Format}, {bytes}"); var dimKey = (meta.Width, meta.Height); bucketsByDim[dimKey] = bucketsByDim.GetValueOrDefault(dimKey) + 1; bucketsByFormat[meta.Format] = bucketsByFormat.GetValueOrDefault(meta.Format) + 1; var tripleKey = (meta.Width, meta.Height, meta.Format); bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1; } _particleTextures?.VisitEntries(resource => Emit(resource.SurfaceId, resource.Name)); _compositeTextures?.VisitEntries((surfaceId, width, height) => { int bytes = checked(width * height * 4); totalBytes += bytes; sb.AppendLine($"0x{surfaceId:X8}, {width}, {height}, RGBA8_COMPOSITE_LAYER, {bytes}"); bucketsByDim[(width, height)] = bucketsByDim.GetValueOrDefault((width, height)) + 1; bucketsByFormat["RGBA8_COMPOSITE_LAYER"] = bucketsByFormat.GetValueOrDefault("RGBA8_COMPOSITE_LAYER") + 1; bucketsByTriple[(width, height, "RGBA8_COMPOSITE_LAYER")] = bucketsByTriple.GetValueOrDefault((width, height, "RGBA8_COMPOSITE_LAYER")) + 1; }); sb.AppendLine(); sb.AppendLine("# Rollups"); sb.AppendLine($"# Total unique GL textures: {seen.Count}"); sb.AppendLine($"# Total bytes (sum of W*H*4): {totalBytes}"); sb.AppendLine("# Top 10 (W,H) dimension buckets:"); foreach (var kv in bucketsByDim.OrderByDescending(kv => kv.Value).Take(10)) sb.AppendLine($"# {kv.Key.W}x{kv.Key.H}: {kv.Value}"); sb.AppendLine("# Format buckets:"); foreach (var kv in bucketsByFormat.OrderByDescending(kv => kv.Value)) sb.AppendLine($"# {kv.Key}: {kv.Value}"); sb.AppendLine("# Top 10 (W,H,format) triples — atlas-opportunity input:"); foreach (var kv in bucketsByTriple.OrderByDescending(kv => kv.Value).Take(10)) sb.AppendLine($"# {kv.Key.W}x{kv.Key.H} {kv.Key.F}: {kv.Value}"); System.IO.File.WriteAllText(outPath, sb.ToString()); Console.WriteLine($"[N6-DUMP] Surface histogram written to {outPath} ({seen.Count} textures, {totalBytes} bytes)"); } /// /// Apply the surface's authored Translucency to the decoded alpha, the same /// bake the shared-atlas extraction performs. TRUE for the world composite paths /// (palette / original-texture overrides) — without it an override-carrying item's /// translucent part paints alpha=1: it still sorts as see-through in the alpha /// queue but erases the particles composited behind it. FALSE for the sky (its /// shader applies the authored opacity separately — baking would double-apply) /// and for particle sheets (emitter-driven alpha, no authored-translucency /// consumer today). /// private DecodedTexture DecodeFromDats( uint surfaceId, uint? origTextureOverride, PaletteOverride? paletteOverride, bool bakeAuthoredTranslucency = false) { var surface = _dats.Get(surfaceId); if (surface is null) { // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) Console.WriteLine($"[tex-miss] Surface 0x{surfaceId:X8} -> magenta (thread={System.Environment.CurrentManagedThreadId})"); return DecodedTexture.Magenta; } // Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue // instead of a texture chain. Overrides are irrelevant here — there's // no texture chain to swap — so the override is ignored for solid-color // surfaces. Translucency is honored so Base1Solid|Translucent surfaces // with Translucency=1.0 become alpha=0, which the mesh shader's discard // cutout makes invisible. if (surface.Type.HasFlag(SurfaceType.Base1Solid) || (uint)surface.OrigTextureId == 0) return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency); // Use the override SurfaceTexture id when present, otherwise the // Surface's native OrigTextureId. uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId; var surfaceTexture = _dats.Get(surfaceTextureId); if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) { // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) Console.WriteLine($"[tex-miss] SurfaceTexture 0x{surfaceTextureId:X8} (surface 0x{surfaceId:X8}) -> magenta (thread={System.Environment.CurrentManagedThreadId})"); return DecodedTexture.Magenta; } uint renderSurfaceId = (uint)surfaceTexture.Textures[0]; if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) && !_dats.HighRes.TryGet(renderSurfaceId, out rs)) { // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) Console.WriteLine($"[tex-miss] RenderSurface 0x{renderSurfaceId:X8} (surface 0x{surfaceId:X8}) -> magenta (thread={System.Environment.CurrentManagedThreadId})"); return DecodedTexture.Magenta; } // Start with the texture's default palette, then apply overlays. // ACViewer's Render/TextureCache.IndexToColor does the same and never // consults ObjDesc.BasePaletteId for palette-indexed textures — the // RenderSurface's own default palette is the starting point. Palette? basePalette = rs.DefaultPaletteId != 0 ? _dats.Get(rs.DefaultPaletteId) : null; Palette? effectivePalette = basePalette; if (paletteOverride is not null && basePalette is not null && paletteOverride.SubPalettes.Count > 0) { effectivePalette = ComposePalette(basePalette, paletteOverride); } // Clipmap surfaces use palette indices 0..7 as transparent sentinels. bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive); DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive); // The decoders return the shared Magenta sentinel on failure; it must never // be scaled in place. Fresh decodes are caller-owned, so the in-place bake // is safe. if (bakeAuthoredTranslucency && surface.Translucency > 0.0f && !ReferenceEquals(decoded, DecodedTexture.Magenta)) { decoded = SurfaceDecoder.ApplyAuthoredTranslucency(decoded, surface.Translucency); } return decoded; } /// /// Build a composite palette by copying subpalette ranges into a /// mutable copy of the base. Ported from ACViewer's /// Render/TextureCache.IndexToColor, with network-side Offset/Length /// multiplied by 8 to recover the raw palette-index units (ACE's /// writer divides by 8 before writing). /// private Palette ComposePalette(Palette basePalette, PaletteOverride paletteOverride) { var composed = new Palette(); composed.Colors.AddRange(basePalette.Colors); foreach (var sp in paletteOverride.SubPalettes) { var subPal = _dats.Get(sp.SubPaletteId); if (subPal is null) continue; int startIdx = sp.Offset * 8; // Length == 0 is the sentinel for "entire palette" per // Chorizite.ACProtocol.Types.Subpalette docs. Use a value // large enough to cover any real palette; we clamp below. int count = sp.Length == 0 ? 2048 : sp.Length * 8; for (int j = 0; j < count; j++) { int idx = startIdx + j; if (idx >= composed.Colors.Count || idx >= subPal.Colors.Count) break; composed.Colors[idx] = subPal.Colors[idx]; } } return composed; } /// Uploads a raw RGBA8 byte array as a Texture2D. Used by /// to upload CPU-composited icon layers. /// The texture is tracked in and deleted by /// . Callers must NOT also store the returned handle in any /// of the keyed caches — that would cause a double-delete on Dispose. /// /// Campaign V slice V6d: returns a /// rather than a GL texture name, for the reason given on /// . /// public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false) { GpuUiTextureEntry entry = UploadUiTexture( new DecodedTexture(rgba, width, height), nearest, "ui-adhoc-rgba8"); _adhocGpuTextures.Add(entry); return UiTextureTableHandle.FromSlot(entry.Slot); } private void TrackUploadedTexture(uint name, int width, int height) { _uploadMetadata[name] = (width, height, "RGBA8_DECODED"); long bytes = checked((long)width * height * 4L); Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture); Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture); } /// /// Memory-tracking bookkeeping only — used for every /// entry, whose GPU resource is released by /// through the device's own retirement queue. /// private void UntrackUploadedTexture(uint name) { if (_uploadMetadata.Remove(name, out var metadata)) { long bytes = checked((long)metadata.Width * metadata.Height * 4L); Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture); Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture); } } public void Dispose() { // GameWindow drains frame-flight fences before this teardown. The // bindless caches make every handle non-resident before deleting // their backing storage. _particleTextures?.Dispose(); _compositeTextures?.Dispose(); _paletteIndexedByTexture.Clear(); // Campaign LA gate round 2 (AD-98): linear twin slots. Each one is a // SECOND table registration of a texture another family below owns and // disposes — release the slot here, before that texture goes away, and // never touch the texture itself (that would double-dispose it). foreach (uint twinHandle in _linearUiTwinHandles.Values) _device.ReleaseTextureSlot(UiTextureTableHandle.ToSlot(twinHandle)); _linearUiTwinHandles.Clear(); _nearestUiTextureSources.Clear(); // RenderSurface (UI sprite) textures — Campaign V slice V4a: each // entry's IGpuTexture.Dispose() releases the underlying GL name // through the device's own retirement queue, so only the memory- // tracking bookkeeping and the registered slot need releasing here. foreach (GpuUiTextureEntry entry in _renderSurfaceGpuTextures.Values) { entry.Texture.Dispose(); _device.ReleaseTextureSlot(entry.Slot); UntrackUploadedTexture(entry.GlName); } _renderSurfaceGpuTextures.Clear(); // Campaign V slice V6k: world Surface textures created through the RHI // for the sky's backend-neutral arm. Same ownership shape as the UI // entries above — the device retires the image, this releases the slot. foreach (GpuUiTextureEntry entry in _worldSurfaceGpuTextures.Values) { entry.Texture.Dispose(); _device.ReleaseTextureSlot(entry.Slot); UntrackUploadedTexture(entry.GlName); } _worldSurfaceGpuTextures.Clear(); // Ad-hoc textures from the public UploadRgba8(byte[],int,int,bool) wrapper // (IconComposer composited icons). Not stored in any keyed cache. foreach (GpuUiTextureEntry entry in _adhocGpuTextures) { entry.Texture.Dispose(); _device.ReleaseTextureSlot(entry.Slot); UntrackUploadedTexture(entry.GlName); } _adhocGpuTextures.Clear(); } }