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
{
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; }
///
/// 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 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)
{
_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.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;
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,
});
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);
}
catch
{
alphaTexture?.Dispose();
terrainTexture?.Dispose();
throw;
}
}
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");
}
public void Dispose()
{
// Slice V4t's teardown rule: the device dies with its callers, so the
// table entries are not released here — deferring through a
// possibly-disposed retirement queue would turn a clean shutdown into a
// throw. The images themselves route through the device's retirement
// queue, which is what IGpuTexture.Dispose does.
_rhi.Alpha.Dispose();
_rhi.Terrain.Dispose();
}
}