acdream/src/AcDream.App/Rendering/TextureCache.cs
Erik 749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00

898 lines
39 KiB
C#

// 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<uint, (uint Handle, int Width, int Height)>
_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<uint, uint> _handlesByRenderSurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _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<uint> _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<uint, (int Width, int Height, string Format)> _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);
}
}
/// <summary>
/// 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.
/// </summary>
public uint GetOrUpload(uint surfaceId)
=> GetOrUploadSurfaceCore(surfaceId, out _, out _);
/// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
/// pixel dimensions. UI 9-slice geometry needs the source size to
/// compute slice UVs. Cached alongside the handle.
/// </summary>
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;
}
/// <summary>
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> 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
/// <c>DefaultPaletteId</c> (same starting palette <see cref="DecodeFromDats"/>
/// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns
/// a 1x1 magenta handle on miss.
/// </summary>
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<RenderSurface>(renderSurfaceId, out var rs)
|| _dats.HighRes.TryGet<RenderSurface>(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<Palette>(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;
}
/// <summary>
/// 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
/// <c>ACDREAM_DUMP_SKY=1</c>. Adds ~2ms per texture upload, negligible.
/// </summary>
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)}");
}
/// <summary>
/// Acquires the exact DAT-decoded one-layer texture array for a live
/// particle emitter. Equivalent surfaces are shared; the cache ownership
/// ends with <see cref="ReleaseParticleTextureOwner"/>.
/// </summary>
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<Exception>? 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));
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<Surface>(surfaceId);
if (surface is null || surface.Type.HasFlag(SurfaceType.Base1Solid))
return _paletteIndexedByTexture[key] = false;
uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId;
SurfaceTexture? texture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (texture is null || texture.Textures.Count == 0)
return _paletteIndexedByTexture[key] = false;
uint renderSurfaceId = (uint)texture.Textures[0];
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out RenderSurface? renderSurface)
&& !_dats.HighRes.TryGet<RenderSurface>(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<Surface>(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<SurfaceTexture>(surfaceTextureId);
if (texture is null || texture.Textures.Count == 0)
return _decodedDimensionsByTexture[key] = (1, 1);
uint renderSurfaceId = (uint)texture.Textures[0];
if ((!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out RenderSurface? renderSurface)
&& !_dats.HighRes.TryGet<RenderSurface>(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);
}
/// <summary>
/// Retail <c>CSurface::Destroy</c> (0x005361F0) releases its current
/// <c>ImgTex</c>. Mirror that ownership boundary for per-entity composites.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public void TickCompositeTextureCache() => _compositeTextures?.Tick();
/// <summary>
/// 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.
/// </summary>
public void TickParticleTextureCache() => _particleTextures?.Tick();
public void BeginCompositeTextureFrame() => _compositeTextures?.BeginFrame();
/// <summary>
/// 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.
/// </summary>
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));
/// <summary>
/// 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. <c>_dumpFrameCounter &gt;= 600</c> — at least 600 OnRender ticks
/// have elapsed (catches the "we're already past startup boilerplate"
/// bound; ~10s at 60fps, ~3s at 200fps).
/// 2. <c>_uploadMetadata.Count &gt;= 100</c> — 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.
/// </summary>
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<uint>();
long totalBytes = 0;
var bucketsByDim = new Dictionary<(int W, int H), int>();
var bucketsByFormat = new Dictionary<string, int>();
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<Surface>(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<SurfaceTexture>(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<RenderSurface>(renderSurfaceId, out var rs)
&& !_dats.HighRes.TryGet<RenderSurface>(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<Palette>(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);
}
/// <summary>
/// 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).
/// </summary>
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<Palette>(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;
}
/// <summary>Uploads a raw RGBA8 byte array as a Texture2D. Used by
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.
/// The returned handle is tracked in <see cref="_adhocHandles"/> and deleted by
/// <see cref="Dispose"/>. Callers must NOT also store the handle in any of the
/// keyed caches — that would cause a double-delete on Dispose.</summary>
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);
}
}
/// <summary>
/// Variant of <see cref="UploadRgba8"/> 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.
/// </summary>
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();
}
}