using System;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using System.Threading;
using AcDream.Content;
using Chorizite.Core.Render.Enums;
using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering.Wb;
///
/// Campaign V slice V6i-2: the mesh pipeline no longer names a backend.
///
/// Plan §5.5.10 recorded the blocker as a fact about types — "WbMeshAdapter
/// owns an OpenGLGraphicsDevice, so it is not constructible on Vulkan" — which is
/// why NullWbMeshAdapter existed. §5.5.12 item 6 measured how wide the
/// dependency really was: a GL context, the retirement queue, the instance VBO,
/// and two capability flags. This suite proves the interface at that surface is
/// load-bearing rather than cosmetic, by building the object graph against a
/// device that has NO GL context at all.
///
/// It originally proved construction and nothing more, back when the
/// upload bodies were still raw GL and the world renderers still bound a GL
/// handle table. Campaign V slice V11 deleted both along with the rest of the
/// raw-GL arm (and the interface's own Gl member, which nothing read any
/// more once they were gone) — the arena-build and upload tests below now cover
/// what those slices only asserted would eventually fail loudly.
///
public sealed class MeshPipelineDeviceSeamTests
{
/// A device with the mesh pipeline's whole surface and no GL behind it.
private sealed class ContextFreeMeshPipelineDevice(
IGpuResourceRetirementQueue retirement,
bool modernPath = false)
: IMeshPipelineDevice
{
public IGpuResourceRetirementQueue ResourceRetirement { get; } = retirement;
public uint InstanceVBO => 0;
public bool HasBindless => modernPath;
public bool HasOpenGL43 => modernPath;
public bool HasPendingWork => false;
public int ProcessedQueues { get; private set; }
public void ProcessQueue() => ProcessedQueues++;
public void Dispose()
{
}
}
private static ObjectMeshManager Build(
RecordingGpuDevice device,
bool modernPath = false) =>
new(
new ContextFreeMeshPipelineDevice(device.Retirement, modernPath),
device,
new NullPreparedAssetSource(),
NullLogger.Instance);
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose()
{
}
}
///
/// The whole point. Before this slice the constructor downcast the RHI device
/// to GlGpuDevice, so this threw before running a statement.
///
[Fact]
public void TheMeshPipelineConstructsAgainstADeviceWithNoGlContext()
{
using var device = new RecordingGpuDevice();
using ObjectMeshManager manager = Build(device);
Assert.False(manager.IsDisposed);
}
///
/// The one branch the texture stack keeps: a GL pair yields the GL arm, and
/// anything else yields the RHI arm. Selection happens once, at construction.
///
[Fact]
public void TheArrayFactorySelectsTheRhiArmWithoutAGlPair()
{
using var device = new RecordingGpuDevice();
IWorldTextureArrayFactory arrays = IWorldTextureArrayFactory.For(
new ContextFreeMeshPipelineDevice(device.Retirement),
device,
NullLogger.Instance);
Assert.IsType(arrays);
using IWorldTextureArray array =
arrays.CreateClampedArray(TextureFormat.RGBA8, 32, 32, 2);
Assert.IsType(array);
}
///
/// The seam's whole value is that it is NARROW — six members measured out
/// of a 760-line class (seven until Campaign V slice V11 deleted the unread
/// Gl member). A later slice that quietly widens it back out would
/// re-couple the mesh pipeline to a backend without any other gate noticing,
/// so the member set is pinned rather than described.
///
[Fact]
public void TheDeviceSeamStaysAtTheMeasuredSurface()
{
string[] members =
[
.. typeof(IMeshPipelineDevice)
.GetMembers()
// Property accessors are the same members under another name.
.Where(member => member is not System.Reflection.MethodInfo
{
IsSpecialName: true,
})
.Select(member => member.Name)
.Order(StringComparer.Ordinal),
];
Assert.Equal(
[
"HasBindless",
"HasOpenGL43",
"HasPendingWork",
"InstanceVBO",
"ProcessQueue",
"ResourceRetirement",
],
members);
}
///
/// Construction touched no GL object at all. The shared mesh arena is the
/// only one the constructor would build, and it is gated on the two
/// capability flags the interface carries — so a device reporting neither
/// leaves it absent rather than dereferencing a null context.
///
[Fact]
public void ConstructionBuildsNoGlObject()
{
using var device = new RecordingGpuDevice();
using ObjectMeshManager manager = Build(device);
Assert.Null(manager.GlobalBuffer);
// Read-only policy queries still answer, which is what lets streaming
// residence accounting keep running on a backend with no world draws.
Assert.Equal((0, 0, 0), manager.GetPendingTextureUpdateStats());
}
///
/// Campaign V slice V6i-3. V6i-2 could only prove construction, because the
/// arena's own body still spoke GL — a device reporting the modern-path
/// capabilities and no context would have dereferenced a null one. It now
/// builds, and what it publishes is the contract's handle rather than a raw
/// name: no vertex array, two live stores.
///
[Fact]
public void TheModernArenaBuildsWithoutAGlContext()
{
using var device = new RecordingGpuDevice();
using ObjectMeshManager manager = Build(device, modernPath: true);
GlobalMeshBuffer arena = Assert.IsType(manager.GlobalBuffer);
Assert.True(arena.HasStores);
Assert.NotNull(arena.VertexStore);
Assert.NotNull(arena.IndexStore);
}
///
/// And it UPLOADS. The vertex and index bytes land in the stores a pass
/// encoder binds, at the offsets the allocator handed out — which is the
/// whole of what a draw needs from this class and the thing V6i-2 could not
/// claim.
///
[Fact]
public void AMeshUploadsIntoTheArenaWithoutAGlContext()
{
using var device = new RecordingGpuDevice();
using ObjectMeshManager manager = Build(device, modernPath: true);
GlobalMeshBuffer arena = manager.GlobalBuffer!;
var vertices = new VertexPositionNormalTexture[3];
vertices[0].Position = new System.Numerics.Vector3(1f, 2f, 3f);
vertices[2].Position = new System.Numerics.Vector3(7f, 8f, 9f);
ushort[] indices = [0, 1, 2];
GlobalMeshAllocation allocation = arena.UploadMesh(
vertices,
indices,
[(0, indices.Length)]);
Assert.Equal(3, allocation.Vertices.Length);
Assert.Equal(3, allocation.Indices.Length);
Assert.Equal(1, arena.UploadCount);
Span readback = stackalloc byte[3 * VertexPositionNormalTexture.Size];
arena.VertexStore!.Read(
(long)allocation.Vertices.Offset * VertexPositionNormalTexture.Size,
readback);
var uploaded = System.Runtime.InteropServices.MemoryMarshal
.Cast(readback);
Assert.Equal(new System.Numerics.Vector3(1f, 2f, 3f), uploaded[0].Position);
Assert.Equal(new System.Numerics.Vector3(7f, 8f, 9f), uploaded[2].Position);
Span indexBytes = stackalloc byte[3 * sizeof(ushort)];
arena.IndexStore!.Read((long)allocation.Indices.Offset * sizeof(ushort), indexBytes);
Assert.Equal(
indices,
System.Runtime.InteropServices.MemoryMarshal.Cast(indexBytes).ToArray());
}
///
/// Campaign OVERHAUL S1 (G1 FAIL 2026-09-02). Cell-shell batches upload in
/// ascending source surface index (retail's built-EnvCell subset order,
/// ConstructMesh @0x0059DFA0 / DrawMesh @0x0059D4A0), which differs from
/// the (Width,Height,Format) storage order the index segments used to be
/// filled in. The first fix ordered only the batch list, so batch i took
/// segment i's index range from a different batch: magenta walls,
/// stretched textures, missing faces in every dungeon. This pins that each
/// uploaded batch's FirstIndex points at ITS OWN indices in the arena, with
/// a fixture whose storage order is the reverse of its surface order.
///
[Fact]
public void CellShellBatchesKeepTheirOwnIndexRangesWhenReorderedBySurfaceIndex()
{
using var device = new RecordingGpuDevice();
using ObjectMeshManager manager = Build(device, modernPath: true);
GlobalMeshBuffer arena = manager.GlobalBuffer!;
static AcDream.Content.TextureBatchData CellBatch(int slot, uint surfaceId, int size, ushort[] indices) => new()
{
Key = new AcDream.Content.TextureKey { SurfaceId = surfaceId },
TextureData = new byte[size * size * 4],
Indices = [.. indices],
IsCellShell = true,
SourceSurfaceIndex = slot,
CullMode = DatReaderWriter.Enums.CullMode.Clockwise,
};
// Two storage groups (two texture sizes) so dictionary insertion order
// puts slot 5 first; retail order is slot 2 first.
var mesh = new AcDream.Content.ObjectMeshData
{
ObjectId = 0x1_0000_0000UL | 0xF4180104UL,
Vertices = new VertexPositionNormalTexture[6],
TextureBatches =
{
[(8, 8, Chorizite.Core.Render.Enums.TextureFormat.RGBA8)] = [CellBatch(5, 0x08000005u, 8, [0, 1, 2])],
[(16, 16, Chorizite.Core.Render.Enums.TextureFormat.RGBA8)] = [CellBatch(2, 0x08000002u, 16, [3, 4, 5])],
},
};
ObjectRenderData data = Assert.IsType(manager.UploadMeshData(mesh));
Assert.Equal(2, data.Batches.Count);
Assert.Equal(0x08000002u, data.Batches[0].Key.SurfaceId); // ascending surface index wins
Assert.Equal(0x08000005u, data.Batches[1].Key.SurfaceId);
foreach (ObjectRenderBatch batch in data.Batches)
{
ushort[] expected = batch.Key.SurfaceId == 0x08000002u ? [3, 4, 5] : [0, 1, 2];
var bytes = new byte[batch.IndexCount * sizeof(ushort)];
arena.IndexStore!.Read((long)batch.FirstIndex * sizeof(ushort), bytes);
Assert.Equal(
expected,
System.Runtime.InteropServices.MemoryMarshal.Cast(bytes).ToArray());
}
}
///
/// #429 allocation gate (I1 style). Completing a prepared mesh on the
/// render thread must allocate near its retained pick-copy size
/// (CPUPositions + CPUIndices), not multiples of it. The regression this
/// pins: the upload conversion ran LINQ chains — a per-batch
/// Indices.ToArray() plus an unsized SelectMany().ToArray()
/// — that materialized every index three-plus times in transient garbage
/// per completed mesh, on the render thread, up to the per-frame upload
/// budget.
///
[Fact]
public void AWarmedMeshCompletionAllocatesNearItsRetainedCopySize()
{
using var device = new RecordingGpuDevice();
using ObjectMeshManager manager = Build(device, modernPath: true);
// Warm: an identically shaped mesh grows the arena, the atlas family,
// and every pool the completion path touches.
Assert.NotNull(manager.UploadMeshData(
CreateLargeMeshData(0x0100AA01u, surfaceSeed: 0x08000000u)));
ObjectMeshData meshData =
CreateLargeMeshData(0x0100AA02u, surfaceSeed: 0x08001000u);
long before = GC.GetAllocatedBytesForCurrentThread();
ObjectRenderData? uploaded = manager.UploadMeshData(meshData);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.NotNull(uploaded);
long retained =
(long)uploaded!.CPUIndices.Length * sizeof(ushort)
+ (long)uploaded.CPUPositions.Length * 3 * sizeof(float);
// Sanity: the fixture is actually index-heavy enough to discriminate.
Assert.True(retained >= 480_000, $"fixture retained only {retained} bytes");
// The LINQ regression allocates over 3x the index bytes and fails
// this bound by more than a megabyte.
long bound = retained + retained / 2 + 128 * 1024;
Assert.True(
allocated < bound,
$"A warmed mesh completion allocated {allocated} bytes "
+ $"(retained copies {retained}, bound {bound}).");
}
private static ObjectMeshData CreateLargeMeshData(ulong id, uint surfaceSeed)
{
const int vertexCount = 1024;
const int batchCount = 4;
const int indicesPerBatch = 60_000;
var data = new ObjectMeshData
{
ObjectId = id,
Vertices = new VertexPositionNormalTexture[vertexCount],
};
var batches = new System.Collections.Generic.List(batchCount);
for (int b = 0; b < batchCount; b++)
{
var indices = new System.Collections.Generic.List(indicesPerBatch);
for (int i = 0; i < indicesPerBatch; i++)
indices.Add((ushort)((i + b) % vertexCount));
batches.Add(new TextureBatchData
{
Key = new TextureKey { SurfaceId = surfaceSeed + (uint)b },
TextureData = new byte[8 * 8 * 4],
Indices = indices,
});
}
data.TextureBatches[(8, 8, TextureFormat.RGBA8)] = batches;
return data;
}
///
/// The production Vulkan implementation of the seam, checked against the
/// same surface. Its two capability flags answer true because what they
/// gate is the modern path, which Vulkan supplies unconditionally — see the
/// type's own documentation for why the GL-shaped names survive.
///
[Fact]
public void TheVulkanMeshPipelineDeviceReportsTheModernPath()
{
using var device = new RecordingGpuDevice();
using var vulkanDevice =
new AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice(device.Retirement);
Assert.True(vulkanDevice.HasBindless);
Assert.True(vulkanDevice.HasOpenGL43);
Assert.False(vulkanDevice.HasPendingWork);
Assert.Equal(0u, vulkanDevice.InstanceVBO);
Assert.Same(device.Retirement, vulkanDevice.ResourceRetirement);
}
}