using System;
using System.Linq;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using Chorizite.Core.Render.Enums;
namespace AcDream.App.Tests.Rendering.Wb;
///
/// Campaign V slice V6i-2: the backend-neutral world texture array, driven
/// against .
///
/// These assert the two things the GL array and this one genuinely have to
/// agree on — what a well-formed layer payload is, and that every atlas ends up
/// addressable from a shader through both address modes — plus the one thing
/// they deliberately do NOT share: how a mip chain is produced. Vulkan cannot
/// blit into a compressed image, so a BC array's levels come from
/// BlockCompressionMipChain while an RGBA8 array's come from the device's
/// blit. Getting that backwards would compile, run, and render a texture with
/// undefined mips.
///
public sealed class RhiWorldTextureArrayTests
{
private const int Extent = 64;
private static byte[] Rgba(int width, int height)
{
var pixels = new byte[width * height * 4];
Array.Fill(pixels, (byte)0xFF);
return pixels;
}
private static byte[] Bc1(int width, int height) =>
new byte[Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8];
[Fact]
public void AnUncompressedArrayLetsTheDeviceBlitItsMipChain()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 4);
array.UpdateLayer(2, Rgba(Extent, Extent), null, null);
Assert.Equal(1, array.PendingUpdateCount);
long generated = array.ProcessDirtyUpdates();
RecordingGpuTexture texture = LastCreatedTexture(device);
Assert.True(texture.MipChainGenerated);
// Exactly one upload: level 0 of the layer that was staged. Every other
// level is the device's blit.
Assert.Equal([(0, 2, Extent * Extent * 4)], texture.Uploads);
Assert.True(generated > 0);
Assert.Equal(0, array.PendingUpdateCount);
}
[Fact]
public void ABlockCompressedArrayUploadsACpuBuiltChainAndNeverBlits()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.DXT1, Extent, Extent, 2);
array.UpdateLayer(0, Bc1(Extent, Extent), null, null);
array.ProcessDirtyUpdates();
RecordingGpuTexture texture = LastCreatedTexture(device);
Assert.False(texture.MipChainGenerated);
// 64x64 is seven levels; level 0 was staged and 1..6 were encoded, all
// for the one layer that was written.
Assert.Equal(7, texture.Uploads.Count);
Assert.All(texture.Uploads, upload => Assert.Equal(0, upload.Layer));
Assert.Equal([0, 1, 2, 3, 4, 5, 6], texture.Uploads.Select(u => u.MipLevel));
}
[Fact]
public void EveryArrayIsAddressableThroughBothAddressModes()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
GpuTextureSlot wrap = array.ResolveSlot(wrapping: true);
GpuTextureSlot clamp = array.ResolveSlot(wrapping: false);
Assert.True(wrap.IsAssigned);
Assert.True(clamp.IsAssigned);
Assert.NotEqual(wrap, clamp);
GpuSamplerDescription[] samplers =
[
.. device.Calls.OfType()
.Where(registration => registration.TextureName.StartsWith("world-atlas", StringComparison.Ordinal))
.Select(registration => registration.Sampler),
];
// Campaign V slice V7: both address modes, and BOTH anisotropic. The GL
// array asks for GL_TEXTURE_MAX_ANISOTROPY = the driver's maximum on
// every world atlas, unconditionally; asking for 1 here made Vulkan
// sample a coarser mip than GL on every grazing-angle surface, which the
// first GL-versus-Vulkan pair saw as blurred roof shingles and distant
// scenery. `with { MaxAnisotropy = 1f }` recovers the campaign's shared
// description so this stays an assertion about ONE dimension.
Assert.Contains(GpuSamplerDescription.WorldClamp, samplers.Select(Isotropic));
Assert.Contains(GpuSamplerDescription.WorldRepeat, samplers.Select(Isotropic));
Assert.All(samplers, sampler => Assert.True(
sampler.MaxAnisotropy > 1f,
$"world atlas sampler {sampler.AddressU} asked for anisotropy "
+ $"{sampler.MaxAnisotropy}; the GL arm always asks for the device maximum."));
static GpuSamplerDescription Isotropic(GpuSamplerDescription sampler) =>
sampler with { MaxAnisotropy = 1f };
}
///
/// The slot pair must come back to the device, or a session that churns
/// atlases exhausts the table's fixed capacity — the leak-with-an-end V4t
/// introduced when it capped the table.
///
[Fact]
public void DisposalReturnsBothSlotsAndTheImage()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
int before = device.LiveTextureSlotCount;
IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
Assert.Equal(before + 2, device.LiveTextureSlotCount);
array.Dispose();
Assert.Equal(before, device.LiveTextureSlotCount);
Assert.True(LastCreatedTexture(device).IsDisposed);
Assert.True(array.IsPhysicalRetirementComplete);
Assert.True(array.HasDurableDisposeOwnership);
// ReleaseTextureSlots after Dispose is idempotent: the retiring-atlas
// path calls it once physical retirement completes, which is necessarily
// after disposal.
array.ReleaseTextureSlots();
Assert.Equal(before, device.LiveTextureSlotCount);
}
///
/// The GL array rejects a payload whose length contradicts the format. The
/// RHI array reuses that validator rather than writing a second one, so a
/// mis-sized layer fails the same way on both arms.
///
[Fact]
public void AMisSizedLayerIsRejectedByTheSharedValidator()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
Assert.Throws(() => array.UpdateLayer(0, new byte[16], null, null));
Assert.Equal(0, array.PendingUpdateCount);
}
///
/// The formats with no member of GpuTextureFormat fail loudly at
/// creation rather than silently substituting. A8 in particular needs the
/// component swizzle the GL array applies, which lives in a Vulkan image view
/// and is not part of the pinned texture description.
///
[Theory]
[InlineData(TextureFormat.A8)]
[InlineData(TextureFormat.RGB8)]
[InlineData(TextureFormat.Rgba32f)]
public void AFormatWithNoRhiEquivalentIsRefusedAtCreation(TextureFormat format)
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
Assert.Throws(
() => arrays.CreateClampedArray(format, Extent, Extent, 1));
}
///
/// Both arms meter the same bytes, because the eviction budget that reads
/// this number is shared policy above the seam.
///
[Fact]
public void AllocatedBytesMatchTheSharedMipChainAccounting()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 3);
Assert.Equal(
TextureAtlasManager.CalculateMipChainBytes(Extent, Extent, TextureFormat.RGBA8) * 3,
array.TotalSizeInBytes);
}
private static RecordingGpuTexture LastCreatedTexture(RecordingGpuDevice device) =>
device.CreatedTextures.Count > 0
? device.CreatedTextures[^1]
: throw new InvalidOperationException("The device created no texture.");
}