using AcDream.App.Rendering.Gpu; using AcDream.Core.Textures; using DatReaderWriter; using AcDream.Content; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatPixelFormat = DatReaderWriter.Enums.PixelFormat; namespace AcDream.App.Rendering; /// /// Holds both texture arrays the terrain renderer samples from: /// /// /// Terrain atlas — one GL_TEXTURE_2D_ARRAY layer per terrain type /// (grass, dirt, sand, forest...), sourced from /// Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc. /// /// /// Alpha atlas — one GL_TEXTURE_2D_ARRAY layer per blend mask, /// sourced from CornerTerrainMaps / SideTerrainMaps / RoadMaps in the /// same TexMerge. Used by the fragment shader to blend up to three /// terrain overlays and two roads on top of a base cell texture. /// /// /// The alpha atlas is built but not yet sampled by any shader — that wiring /// lands in Phase 3c.4 along with the shader rewrite. /// public sealed class TerrainAtlas : IDisposable { /// /// Retail's category-scoped detail surface, resolved from one /// TexMerge.TerrainDesc entry. The texture-table slot samples a /// one-layer 2-D array so it uses the same backend-neutral table contract /// as every other world texture. /// /// /// Retail's detail-stage sampler: ACRender::SetDetailSurfaceInternal /// (0x006b6280) sets WRAP/WRAP addressing and LINEAR/LINEAR/LINEAR filtering /// on the detail stage. The LINEAR mip chain is retail's only distance /// attenuation for built meshes (VM1), so this constant is load-bearing and /// pinned by TerrainAtlasDetailTextureTests. /// internal static readonly GpuSamplerDescription DetailSamplerDescription = GpuSamplerDescription.WorldRepeat; internal readonly record struct RetailDetailTextureBinding( GpuTextureSlot TextureSlot, float Tiling, uint SurfaceTextureId, uint RenderSurfaceId, int Width, int Height) { public bool IsAvailable => TextureSlot.IsAssigned && SurfaceTextureId != 0 && RenderSurfaceId != 0; } private sealed record DetailTextureResource( IGpuTexture Texture, RetailDetailTextureBinding Binding); public IReadOnlyDictionary TerrainTypeToLayer { get; } public int LayerCount { get; } /// /// UV repeat count for each terrain-array layer. Retail forwards /// TerrainTex::tex_tiling to both base and merged terrain textures /// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0). /// public IReadOnlyList TilingByLayer { get; } // --- Alpha atlas (new in Phase 3c.2) --- public int AlphaLayerCount { get; } /// Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries). public IReadOnlyList CornerAlphaLayers { get; } /// Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries). public IReadOnlyList SideAlphaLayers { get; } /// Layer indices in the alpha atlas for RoadMaps (variable count). public IReadOnlyList RoadAlphaLayers { get; } // --- Parallel TCode/RCode arrays (added in Phase 3c.4 for BuildSurface) --- /// TCode for each CornerTerrainMap, parallel to . public IReadOnlyList CornerAlphaTCodes { get; } /// TCode for each SideTerrainMap, parallel to . public IReadOnlyList SideAlphaTCodes { get; } /// RCode for each RoadMap, parallel to . public IReadOnlyList RoadAlphaRCodes { get; } /// /// Retail detail category 1. DrawBuilding is the only live object /// path that consumes it; ordinary scenery and terrain do not. /// internal RetailDetailTextureBinding BuildingDetailTexture { get; } /// /// Retail detail category 2. DrawEnvCell consumes it for interior /// cell shells. /// internal RetailDetailTextureBinding EnvironmentDetailTexture { get; } /// /// Campaign V slice V6i-2: both arrays are s the /// device created, and both slots were registered at build time, so /// is a field read rather than a residency /// negotiation. The GL construction path this used to sit alongside is /// deleted as of Campaign V slice V11. /// private sealed class RhiArrays( IGpuDevice device, IGpuTexture terrain, IGpuTexture alpha, IGpuSampler alphaSampler) { public IGpuDevice Device { get; } = device; public IGpuTexture Terrain { get; } = terrain; public IGpuTexture Alpha { get; } = alpha; public IGpuSampler AlphaSampler { get; } = alphaSampler; public IGpuSampler? TerrainSampler { get; set; } public IGpuTexture? BuildingDetailTexture { get; set; } public IGpuTexture? EnvironmentDetailTexture { get; set; } public GpuTextureSlot TerrainSlot { get; set; } = GpuTextureSlot.Unassigned; public GpuTextureSlot AlphaSlot { get; set; } = GpuTextureSlot.Unassigned; } private readonly RhiArrays _rhi; /// /// The device-table slots for the terrain and alpha arrays. Registered once /// at build time and re-registered only by , /// which changes the sampler. /// internal (GpuTextureSlot Terrain, GpuTextureSlot Alpha) TextureSlots => (_rhi.TerrainSlot, _rhi.AlphaSlot); /// /// Retail's terrain arrays are trilinear-filtered with the highest anisotropy /// the quality preset allows; lowers it. /// private const float RetailMaxAnisotropy = 16f; private TerrainAtlas( IGpuDevice device, IGpuTexture terrain, IGpuTexture alpha, IGpuSampler alphaSampler, IReadOnlyDictionary map, int layerCount, IReadOnlyList tilingByLayer, int alphaLayerCount, IReadOnlyList cornerLayers, IReadOnlyList sideLayers, IReadOnlyList roadLayers, IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes, DetailTextureResource? buildingDetail, DetailTextureResource? environmentDetail) { _rhi = new RhiArrays(device, terrain, alpha, alphaSampler); TerrainTypeToLayer = map; LayerCount = layerCount; TilingByLayer = tilingByLayer; AlphaLayerCount = alphaLayerCount; CornerAlphaLayers = cornerLayers; SideAlphaLayers = sideLayers; RoadAlphaLayers = roadLayers; CornerAlphaTCodes = cornerTCodes; SideAlphaTCodes = sideTCodes; RoadAlphaRCodes = roadRCodes; _rhi.BuildingDetailTexture = buildingDetail?.Texture; _rhi.EnvironmentDetailTexture = environmentDetail?.Texture; BuildingDetailTexture = buildingDetail?.Binding ?? default; EnvironmentDetailTexture = environmentDetail?.Binding ?? default; _rhi.AlphaSlot = device.RegisterTexture(alpha, alphaSampler); ApplyAnisotropic(RetailMaxAnisotropy); } /// /// Campaign V slice V6i-2: the decode both construction paths share. /// Splitting it out is what keeps the CPU logic single while the upload /// forks — plan §3.1's rule. Nothing here touches a graphics API. /// private readonly record struct TerrainLayerDecode( Dictionary DecodedByType, Dictionary TilingByType, int MaxWidth, int MaxHeight); private static TerrainLayerDecode DecodeTerrainLayers( IDatReaderWriter dats, IReadOnlyList terrainDesc) { var decodedByType = new Dictionary(); var tilingByType = new Dictionary(); int maxW = 1, maxH = 1; foreach (var tmtd in terrainDesc) { uint typeKey = (uint)tmtd.TerrainType; if (decodedByType.ContainsKey(typeKey)) continue; // Retail terrain composition repeats this surface exactly // TerrainTex::tex_tiling times in each axis before applying the // cell-scale alpha mask. Preserve the dat field alongside the // decoded surface while layer assignment is still type-keyed. tilingByType[typeKey] = tmtd.TerrainTex.TexTiling; var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId; var st = dats.Get(surfaceTextureId); if (st is null || st.Textures.Count == 0) { Console.WriteLine($"WARN: TerrainType {tmtd.TerrainType} SurfaceTexture 0x{surfaceTextureId:X8} missing"); decodedByType[typeKey] = DecodedTexture.Magenta; continue; } // Retail ImgTex::GetSurfaceDID (0x0053F0E0) returns source level // zero unless Render::ShouldDropHighDetail explicitly selects // level one. acdream currently has no low-detail quality mode, so // index zero is the retail high-detail source. var rs = dats.Get((uint)st.Textures[0]); if (rs is null) { decodedByType[typeKey] = DecodedTexture.Magenta; continue; } Palette? palette = rs.DefaultPaletteId != 0 ? dats.Get(rs.DefaultPaletteId) : null; var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette); decodedByType[typeKey] = decoded; if (decoded.Width > maxW) maxW = decoded.Width; if (decoded.Height > maxH) maxH = decoded.Height; } return new TerrainLayerDecode(decodedByType, tilingByType, maxW, maxH); } /// /// Slice V6i-2: the alpha-map decode, shared by both construction paths for /// the same reason is. /// private sealed record AlphaLayerDecode( List Decoded, List CornerLayers, List SideLayers, List RoadLayers, List CornerTCodes, List SideTCodes, List RoadRCodes, int MaxWidth, int MaxHeight); private static AlphaLayerDecode DecodeAlphaLayers( IDatReaderWriter dats, DatReaderWriter.Types.TexMerge texMerge) { var decoded = new List(); var cornerLayers = new List(); var sideLayers = new List(); var roadLayers = new List(); var cornerTCodes = new List(); var sideTCodes = new List(); var roadRCodes = new List(); foreach (var entry in texMerge.CornerTerrainMaps) { if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex)) { cornerLayers.Add((byte)decoded.Count); cornerTCodes.Add(entry.TCode); decoded.Add(dtex); } else { Console.WriteLine($"WARN: CornerTerrainMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode"); } } foreach (var entry in texMerge.SideTerrainMaps) { if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex)) { sideLayers.Add((byte)decoded.Count); sideTCodes.Add(entry.TCode); decoded.Add(dtex); } else { Console.WriteLine($"WARN: SideTerrainMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode"); } } foreach (var entry in texMerge.RoadMaps) { if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex)) { roadLayers.Add((byte)decoded.Count); roadRCodes.Add(entry.RCode); decoded.Add(dtex); } else { Console.WriteLine($"WARN: RoadMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode"); } } // Alpha maps should all be uniform size (WorldBuilder asserts 512×512). // Fall back to the max observed so a stray mismatch doesn't crash us. int decodedMaxW = 1, decodedMaxH = 1; foreach (var d in decoded) { if (d.Width > decodedMaxW) decodedMaxW = d.Width; if (d.Height > decodedMaxH) decodedMaxH = d.Height; } return new AlphaLayerDecode( decoded, cornerLayers, sideLayers, roadLayers, cornerTCodes, sideTCodes, roadRCodes, decodedMaxW, decodedMaxH); } /// /// Builds both arrays through . The terrain array /// gets a full mip chain ( blits /// it, because RGBA8 is a legal blit destination) and a repeat/anisotropic /// sampler; the alpha array is single-level and clamped, matching what the /// deleted GL path's texture parameters said. /// internal static TerrainAtlas BuildBackendNeutral(IGpuDevice device, IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(dats); var region = dats.Get(0x13000000u) ?? throw new InvalidOperationException("Region dat id 0x13000000 missing"); var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge; var terrainDesc = texMerge?.TerrainDesc; Dictionary decodedByType; Dictionary tilingByType; int maxW, maxH; if (terrainDesc is null || terrainDesc.Count == 0) { Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer"); decodedByType = new Dictionary { [0u] = WhitePixel() }; tilingByType = []; maxW = 1; maxH = 1; } else { TerrainLayerDecode decode = DecodeTerrainLayers(dats, terrainDesc); decodedByType = decode.DecodedByType; tilingByType = decode.TilingByType; maxW = decode.MaxWidth; maxH = decode.MaxHeight; } AlphaLayerDecode alpha = texMerge is null ? new AlphaLayerDecode([], [], [], [], [], [], [], 1, 1) : DecodeAlphaLayers(dats, texMerge); List alphaDecoded = alpha.Decoded; int alphaLayerCount = Math.Max(1, alphaDecoded.Count); int alphaW = alphaDecoded.Count == 0 ? 1 : alpha.MaxWidth; int alphaH = alphaDecoded.Count == 0 ? 1 : alpha.MaxHeight; int layerCount = decodedByType.Count; int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor(maxW, maxH); IGpuTexture? terrainTexture = null; IGpuTexture? alphaTexture = null; IGpuSampler? detailSampler = null; DetailTextureResource? buildingDetail = null; DetailTextureResource? environmentDetail = null; try { terrainTexture = device.CreateTexture(new GpuTextureDescription( "terrain-atlas", GpuTextureKind.Texture2DArray, GpuTextureFormat.Rgba8Unorm, maxW, maxH, layerCount, mipLevels)); var map = new Dictionary(layerCount); int layerIdx = 0; foreach (var kvp in decodedByType) { byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH); terrainTexture.Upload(0, layerIdx, buffer); map[kvp.Key] = (uint)layerIdx; layerIdx++; } // A.5 T19's mip chain, built by the device rather than by // glGenerateMipmap. RGBA8 blits, so this is the GPU path. terrainTexture.GenerateMipChain(); var tilingByLayer = TerrainTextureTilingTable.Build( map.Select(entry => (entry.Value, tilingByType.TryGetValue(entry.Key, out uint repeatCount) ? repeatCount : 1u))); alphaTexture = device.CreateTexture(new GpuTextureDescription( "terrain-alpha-atlas", GpuTextureKind.Texture2DArray, GpuTextureFormat.Rgba8Unorm, alphaW, alphaH, alphaLayerCount, MipLevelCount: 1)); if (alphaDecoded.Count == 0) { Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback"); alphaTexture.Upload(0, 0, [0xFF, 0xFF, 0xFF, 0xFF]); } else { for (int i = 0; i < alphaDecoded.Count; i++) alphaTexture.Upload(0, i, ResizeRgba8Nearest(alphaDecoded[i], alphaW, alphaH)); } IGpuSampler alphaSampler = device.CreateSampler(GpuSamplerDescription.WorldClamp with { MipFilter = GpuMipFilter.None, }); // Retail LScape::SetDetailTexturing category indices: // 1 = building, 2 = environment/EnvCell. // ChangeRegion and the only reachable SmartBox caller keep // landscape (0) and object (3) disabled, so do not create or expose // those categories here. detailSampler = device.CreateSampler(DetailSamplerDescription); if (terrainDesc is { Count: > 1 }) { buildingDetail = TryCreateDetailTexture( device, dats, detailSampler, terrainDesc[1], "building"); } if (terrainDesc is { Count: > 2 }) { environmentDetail = TryCreateDetailTexture( device, dats, detailSampler, terrainDesc[2], "environment"); } Console.WriteLine( $"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} ({mipLevels} mip levels)"); Console.WriteLine( $"AlphaAtlas: {alphaLayerCount} layers at {alphaW}x{alphaH} " + $"(corners={alpha.CornerLayers.Count}, sides={alpha.SideLayers.Count}, " + $"roads={alpha.RoadLayers.Count})"); return new TerrainAtlas( device, terrainTexture, alphaTexture, alphaSampler, map, layerCount, tilingByLayer, alphaLayerCount, alpha.CornerLayers, alpha.SideLayers, alpha.RoadLayers, alpha.CornerTCodes, alpha.SideTCodes, alpha.RoadRCodes, buildingDetail, environmentDetail); } catch { DisposeDetailTextureResource(device, environmentDetail); DisposeDetailTextureResource(device, buildingDetail); alphaTexture?.Dispose(); terrainTexture?.Dispose(); throw; } } private static DetailTextureResource? TryCreateDetailTexture( IGpuDevice device, IDatReaderWriter dats, IGpuSampler sampler, DatReaderWriter.Types.TMTerrainDesc terrain, string categoryName) { uint surfaceTextureId = (uint)terrain.TerrainTex.DetailTextureId; if (surfaceTextureId == 0) return null; SurfaceTexture? surfaceTexture = dats.Get(surfaceTextureId); if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) { Console.WriteLine( $"WARN: retail {categoryName} detail SurfaceTexture " + $"0x{surfaceTextureId:X8} missing"); return null; } uint renderSurfaceId = (uint)surfaceTexture.Textures[0]; RenderSurface? renderSurface = dats.Get(renderSurfaceId); if (renderSurface is null) { Console.WriteLine( $"WARN: retail {categoryName} detail RenderSurface " + $"0x{renderSurfaceId:X8} missing"); return null; } Palette? palette = renderSurface.DefaultPaletteId != 0 ? dats.Get(renderSurface.DefaultPaletteId) : null; DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface( renderSurface, palette); if (ReferenceEquals(decoded, DecodedTexture.Magenta)) { Console.WriteLine( $"WARN: retail {categoryName} detail RenderSurface " + $"0x{renderSurfaceId:X8} failed to decode"); return null; } int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor( decoded.Width, decoded.Height); IGpuTexture? texture = null; GpuTextureSlot slot = GpuTextureSlot.Unassigned; try { texture = device.CreateTexture(new GpuTextureDescription( $"retail-detail-{categoryName}", GpuTextureKind.Texture2DArray, GpuTextureFormat.Rgba8Unorm, decoded.Width, decoded.Height, LayerCount: 1, MipLevelCount: mipLevels)); texture.Upload(0, 0, decoded.Rgba8); texture.GenerateMipChain(); slot = device.RegisterTexture(texture, sampler); var binding = new RetailDetailTextureBinding( slot, terrain.TerrainTex.DetailTexTiling, surfaceTextureId, renderSurfaceId, decoded.Width, decoded.Height); Console.WriteLine( $"Retail detail {categoryName}: SurfaceTexture " + $"0x{surfaceTextureId:X8} -> RenderSurface " + $"0x{renderSurfaceId:X8}, {decoded.Width}x{decoded.Height}, " + $"tiling={binding.Tiling}"); return new DetailTextureResource(texture, binding); } catch { if (slot.IsAssigned) device.ReleaseTextureSlot(slot); texture?.Dispose(); throw; } } private static void DisposeDetailTextureResource( IGpuDevice device, DetailTextureResource? resource) { if (resource is null) return; if (resource.Binding.IsAvailable) device.ReleaseTextureSlot(resource.Binding.TextureSlot); resource.Texture.Dispose(); } private static DecodedTexture WhitePixel() => new([0xFF, 0xFF, 0xFF, 0xFF], 1, 1); /// /// Slice V6i-2: the backend-neutral anisotropy change. GL mutates a texture /// parameter; Vulkan bakes filtering into the sampler, so the level change /// is a new sampler and a re-registration of the terrain slot. The /// superseded slot is released in the same step, exactly as the bindless arm /// does when a handle changes. /// private void ApplyAnisotropic(float level) { RhiArrays rhi = _rhi!; IGpuSampler sampler = rhi.Device.CreateSampler(GpuSamplerDescription.WorldRepeat with { MaxAnisotropy = Math.Max(1f, level), }); if (ReferenceEquals(rhi.TerrainSampler, sampler) && rhi.TerrainSlot.IsAssigned) return; if (rhi.TerrainSlot.IsAssigned) rhi.Device.ReleaseTextureSlot(rhi.TerrainSlot); rhi.TerrainSampler = sampler; rhi.TerrainSlot = rhi.Device.RegisterTexture(rhi.Terrain, sampler); } private static bool TryDecodeAlphaMap(IDatReaderWriter dats, uint surfaceTextureId, out DecodedTexture decoded) { decoded = DecodedTexture.Magenta; var st = dats.Get(surfaceTextureId); if (st is null || st.Textures.Count == 0) return false; var rs = dats.Get((uint)st.Textures[0]); if (rs is null) return false; // Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha // format) or the more generic PFID_A8; terrain blending alpha masks // MUST use isAdditive=true so R=G=B=A=val — the terrain fragment shader // reads .r for the blend weight. Palette is not used. var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true); if (ReferenceEquals(d, DecodedTexture.Magenta)) return false; decoded = d; return true; } private static byte[] ResizeRgba8Nearest(DecodedTexture src, int dstW, int dstH) { if (src.Width == dstW && src.Height == dstH) return src.Rgba8; var dst = new byte[dstW * dstH * 4]; for (int y = 0; y < dstH; y++) { int srcY = y * src.Height / dstH; for (int x = 0; x < dstW; x++) { int srcX = x * src.Width / dstW; int si = (srcY * src.Width + srcX) * 4; int di = (y * dstW + x) * 4; dst[di + 0] = src.Rgba8[si + 0]; dst[di + 1] = src.Rgba8[si + 1]; dst[di + 2] = src.Rgba8[si + 2]; dst[di + 3] = src.Rgba8[si + 3]; } } return dst; } /// /// Update terrain-array anisotropy at runtime (called by /// when /// the user changes Quality preset mid-session). Idempotent — calling with /// the same level as the current setting is safe and produces no visual /// change. /// public void SetAnisotropic(int level) { ApplyAnisotropic(level); Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x"); } private bool _disposed; public void Dispose() { if (_disposed) return; _disposed = true; // Slice V4t's teardown rule remains load-bearing: GameWindowLifetime // disposes the device before this atlas, so normal teardown must not // call back into its texture table. Build/registration failure paths // above still release detail slots while the device is known alive. _rhi.EnvironmentDetailTexture?.Dispose(); _rhi.BuildingDetailTexture?.Dispose(); _rhi.Alpha.Dispose(); _rhi.Terrain.Dispose(); } }