// src/AcDream.App/Rendering/TextureCache.cs using AcDream.Core.Textures; using AcDream.Core.World; using AcDream.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; using Silk.NET.OpenGL; using System.Linq; using PixelFormatId = DatReaderWriter.Enums.PixelFormat; using SurfaceType = DatReaderWriter.Enums.SurfaceType; namespace AcDream.App.Rendering; public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable { private readonly GL _gl; private readonly IDatReaderWriter _dats; // Handle and decoded dimensions are one atomic cache entry. Keeping them // in separate dictionaries allowed GetOrUpload(surfaceId) followed by the // sized overload to upload a second GL texture and orphan the first. private readonly Dictionary _surfacesById = new(); private readonly Dictionary<(uint SurfaceId, uint OrigTextureId), (int Width, int Height)> _decodedDimensionsByTexture = new(); private uint _magentaHandle; // 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 _handlesByRenderSurfaceId = new(); private readonly Dictionary _rsSizeById = new(); // Ad-hoc handles 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 // GL texture objects until process exit. private readonly List _adhocHandles = new(); private readonly Wb.BindlessSupport? _bindless; private readonly CompositeTextureArrayCache? _compositeTextures; // 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; // 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; public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) : this(gl, dats, bindless, ImmediateGpuResourceRetirementQueue.Instance) { } internal TextureCache( GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless, IGpuResourceRetirementQueue retirementQueue) { _gl = gl; _dats = dats; _bindless = bindless; ArgumentNullException.ThrowIfNull(retirementQueue); if (bindless is not null) { _compositeTextures = new CompositeTextureArrayCache(gl, bindless, retirementQueue); _particleTextures = new StandaloneBindlessTextureCache( new ParticleTextureBackend(this), retirementQueue); } } /// /// Get or upload the GL texture handle for a Surface id. Returns a /// 1x1 magenta fallback if the Surface or its RenderSurface chain is /// missing or uses an unsupported format. /// public uint GetOrUpload(uint surfaceId) => GetOrUploadSurfaceCore(surfaceId, out _, out _); /// /// Like but also returns the decoded /// pixel dimensions. UI 9-slice geometry needs the source size to /// compute slice UVs. Cached alongside the handle. /// public uint GetOrUpload(uint surfaceId, out int width, out int height) => GetOrUploadSurfaceCore(surfaceId, out width, out height); private uint GetOrUploadSurfaceCore(uint surfaceId, out int width, out int height) { if (_surfacesById.TryGetValue(surfaceId, out var existing)) { width = existing.Width; height = existing.Height; return existing.Handle; } DecodedTexture decoded = DecodeFromDats( surfaceId, origTextureOverride: null, paletteOverride: null); if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1") DumpAlphaHistogram(surfaceId, decoded); uint h = UploadRgba8(decoded); _surfacesById.Add(surfaceId, (h, decoded.Width, decoded.Height)); width = decoded.Width; height = decoded.Height; return h; } /// /// 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. /// public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false) { if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing) && _rsSizeById.TryGetValue(renderSurfaceId, out var sz)) { width = sz.w; height = sz.h; return existing; } 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 { decoded = DecodedTexture.Magenta; } uint h = UploadRgba8(decoded, nearest); _handlesByRenderSurfaceId[renderSurfaceId] = h; _rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height); width = decoded.Width; height = decoded.Height; return h; } /// /// Alpha-channel histogram for one decoded texture. Used to diagnose /// "why are clouds not transparent" — if cloud textures come out with /// alpha = 1.0 everywhere we know the decode path strips the alpha /// channel somewhere. Printed once per unique surfaceId under /// ACDREAM_DUMP_SKY=1. Adds ~2ms per texture upload, negligible. /// private static void DumpAlphaHistogram(uint surfaceId, DecodedTexture decoded) { if (decoded.Rgba8.Length == 0 || decoded.Width == 0 || decoded.Height == 0) { System.Console.WriteLine($"[tex-alpha] surf=0x{surfaceId:X8} empty"); return; } int total = decoded.Rgba8.Length / 4; // Bucket alpha in 10 bins. var buckets = new int[10]; int aMin = 255, aMax = 0; long aSum = 0; for (int i = 0; i < decoded.Rgba8.Length; i += 4) { int a = decoded.Rgba8[i + 3]; if (a < aMin) aMin = a; if (a > aMax) aMax = a; aSum += a; int b = a * 10 / 256; if (b > 9) b = 9; buckets[b]++; } float aMean = aSum / (float)total / 255f; var pct = new string[10]; for (int i = 0; i < 10; i++) pct[i] = $"{100.0 * buckets[i] / total:F0}%"; System.Console.WriteLine( $"[tex-alpha] surf=0x{surfaceId:X8} {decoded.Width}x{decoded.Height} " + $"a_min={aMin / 255f:F3} a_max={aMax / 255f:F3} a_mean={aMean:F3} " + $"bins[0-9]={string.Join(",", pct)}"); } /// /// Acquires the exact DAT-decoded one-layer texture array for a live /// particle emitter. Equivalent surfaces are shared; the cache ownership /// ends with . /// internal ulong 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.Handle; } DecodedTexture decoded = DecodeFromDats( surfaceId, origTextureOverride: null, paletteOverride: null); uint name = UploadRgba8AsLayer1Array(decoded); ulong handle = 0; try { handle = _bindless!.GetResidentHandle(name); Wb.GLHelpers.ThrowOnResourceError( _gl, $"making particle surface 0x{surfaceId:X8} resident"); var resource = new StandaloneBindlessTextureResource { SurfaceId = surfaceId, Name = name, Handle = handle, Bytes = checked((long)decoded.Width * decoded.Height * 4L), }; textures.AddAndAcquire(ownerId, resource); return handle; } catch (Exception residencyFailure) { List? cleanupFailures = null; void Attempt(Action cleanup) { try { cleanup(); } catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } } bool residencyReleased = handle == 0; if (handle != 0) { Attempt(() => { _bindless!.MakeNonResident(handle); Wb.GLHelpers.ThrowOnResourceError( _gl, "rolling back particle texture residency"); residencyReleased = true; }); } if (residencyReleased) Attempt(() => DeleteUploadedTexture(name)); if (cleanupFailures is not null) { cleanupFailures.Insert(0, residencyFailure); throw new AggregateException( "Particle texture residency and rollback both failed.", cleanupFailures); } 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. Throws if /// BindlessSupport wasn't provided. /// 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); 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. /// Throws if BindlessSupport wasn't provided to the constructor. /// 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); 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); } private void EnsureBindlessAvailable() { if (_bindless is null) throw new InvalidOperationException( "TextureCache constructed without BindlessSupport — cannot generate bindless handles. " + "WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport)."); } private CompositeTextureArrayCache EnsureCompositeTexturesAvailable() { EnsureBindlessAvailable(); return _compositeTextures!; } private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable() { EnsureBindlessAvailable(); return _particleTextures!; } private sealed class ParticleTextureBackend(TextureCache owner) : IStandaloneBindlessTextureBackend { public void MakeNonResident(StandaloneBindlessTextureResource resource) { owner._bindless!.MakeNonResident(resource.Handle); Wb.GLHelpers.ThrowOnResourceError( owner._gl, $"releasing particle texture handle {resource.Handle}"); } public void Delete(StandaloneBindlessTextureResource resource) => owner.DeleteUploadedTexture(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(); /// /// 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 %LOCALAPPDATA%\acdream\n6-surfaces.txt. 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() { var localAppData = System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData); var outDir = System.IO.Path.Combine(localAppData, "acdream"); System.IO.Directory.CreateDirectory(outDir); var outPath = System.IO.Path.Combine(outDir, "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; } foreach (var kv in _surfacesById) Emit(kv.Key, kv.Value.Handle); _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)"); } private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride, PaletteOverride? paletteOverride) { 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); return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive); } /// /// 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 returned handle is tracked in and deleted by /// . Callers must NOT also store the handle in any of the /// keyed caches — that would cause a double-delete on Dispose. public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false) { uint h = UploadRgba8(new DecodedTexture(rgba, width, height), nearest); _adhocHandles.Add(h); return h; } private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) { uint tex = _gl.GenTexture(); if (tex == 0) throw new InvalidOperationException("OpenGL did not create a 2D texture."); try { _gl.BindTexture(TextureTarget.Texture2D, tex); fixed (byte* p = decoded.Rgba8) _gl.TexImage2D( TextureTarget.Texture2D, 0, InternalFormat.Rgba8, (uint)decoded.Width, (uint)decoded.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p); // Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat // font's small glyphs. Other surfaces use bilinear. int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear; _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); Wb.GLHelpers.ThrowOnResourceError( _gl, $"uploading 2D RGBA8 texture {decoded.Width}x{decoded.Height}"); TrackUploadedTexture(tex, decoded.Width, decoded.Height); return tex; } catch { _gl.DeleteTexture(tex); throw; } finally { _gl.BindTexture(TextureTarget.Texture2D, 0); } } /// /// Variant of that uploads pixel data as a 1-layer /// Texture2DArray. Required by the WB modern rendering path which samples via /// sampler2DArray in its bindless shader. Pixel data is identical. /// private uint UploadRgba8AsLayer1Array(DecodedTexture decoded) { uint tex = _gl.GenTexture(); if (tex == 0) throw new InvalidOperationException("OpenGL did not create a one-layer texture array."); try { _gl.BindTexture(TextureTarget.Texture2DArray, tex); fixed (byte* p = decoded.Rgba8) _gl.TexImage3D( TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, (uint)decoded.Width, (uint)decoded.Height, depth: 1, border: 0, PixelFormat.Rgba, PixelType.UnsignedByte, p); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); Wb.GLHelpers.ThrowOnResourceError( _gl, $"uploading one-layer RGBA8 array {decoded.Width}x{decoded.Height}"); TrackUploadedTexture(tex, decoded.Width, decoded.Height); return tex; } catch { _gl.DeleteTexture(tex); throw; } finally { _gl.BindTexture(TextureTarget.Texture2DArray, 0); } } 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); } private void DeleteUploadedTexture(uint name) { _gl.DeleteTexture(name); Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting uploaded texture {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(); // Legacy Texture2D textures. foreach (var entry in _surfacesById.Values) DeleteUploadedTexture(entry.Handle); _surfacesById.Clear(); if (_magentaHandle != 0) { DeleteUploadedTexture(_magentaHandle); _magentaHandle = 0; } // RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated // by GetOrUploadRenderSurface but was not swept here before this fix. foreach (var h in _handlesByRenderSurfaceId.Values) DeleteUploadedTexture(h); _handlesByRenderSurfaceId.Clear(); // Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper // (IconComposer composited icons). Not stored in any keyed cache. foreach (var h in _adhocHandles) DeleteUploadedTexture(h); _adhocHandles.Clear(); } }