using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; using AcDream.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Lib.IO; using DatReaderWriter.Types; using Microsoft.Extensions.Logging.Abstractions; namespace AcDream.Content.Tests; /// /// #426 (2026-08-23, the Holtburg windmill axle 0x010010CE, 8 polygons all /// NoPos + Base1Solid): MeshExtractor.PrepareGfxObjMeshData used to gate the /// positive side on !Stippling.NoPos, dropping every solid-colour /// polygon on every object client-wide (NoPos means "no positive UVs" — /// acclient.h:7386 — not "no positive face"). These tests exercise the fix /// through the PUBLIC PrepareMeshData entry point against a synthetic, /// entirely in-memory dat graph (no installed DAT directory required — this /// lane stays hermetic), using a hand-rolled / /// pair in the same shape as /// DatResolutionPrecedenceTests' ResolutionSource/StubDatabase. /// public sealed class MeshExtractorSolidFaceExtractionTests { private const uint GfxObjId = 0x01000001u; private const uint SolidSurfaceId = 0x08000001u; private const uint TexturedSurfaceId = 0x08000002u; private const uint SurfaceTextureId = 0x05000001u; private const uint RenderSurfaceId = 0x06000001u; /// /// One quad polygon, NoPos + Base1Solid: the exact shape of the windmill /// axle's own polygons. Must now extract to 4 vertices / 6 indices in a /// single batch flagged solid, instead of the pre-#426 0-vertex mesh. /// [Fact] public void PrepareMeshData_NoPosSolidQuad_EmitsSolidBatchWithFourVerticesAndSixIndices() { var dats = new FakeMeshExtractorDats(); dats.RegisterRootGfxObj(GfxObjId, BuildQuadGfxObj(SolidSurfaceId, noPos: true)); var color = new ColorARGB { Alpha = 255, Red = 12, Green = 34, Blue = 56 }; dats.Register(SolidSurfaceId, new Surface { Type = SurfaceType.Base1Solid, ColorValue = color, }); var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData? mesh = extractor.PrepareMeshData(GfxObjId, isSetup: false); Assert.NotNull(mesh); Assert.Equal(4, mesh!.Vertices.Length); List batches = mesh.TextureBatches.Values.Single(); TextureBatchData batch = Assert.Single(batches); Assert.Equal(6, batch.Indices.Count); Assert.True(batch.Key.IsSolid); // GetOrCreateSolidColorTexture bakes the surface's own ColorValue. Assert.Equal((byte)color.Red, batch.TextureData[0]); Assert.Equal((byte)color.Green, batch.TextureData[1]); Assert.Equal((byte)color.Blue, batch.TextureData[2]); Assert.Equal((byte)color.Alpha, batch.TextureData[3]); } /// /// Same NoPos quad, but the positive surface is TEXTURED /// (Base1Image) rather than solid. NoPos still means "no UVs on this /// polygon's wire data" regardless of what the surface is — the emitted /// vertices fall back to UV (0,0), and the batch must be classified /// non-solid (IsSolid == false) so it decodes the real texture instead /// of baking a flat colour fill. /// [Fact] public void PrepareMeshData_NoPosTexturedQuad_EmitsWithZeroUVsAndIsSolidFalse() { var dats = new FakeMeshExtractorDats(); dats.RegisterRootGfxObj(GfxObjId, BuildQuadGfxObj(TexturedSurfaceId, noPos: true)); dats.Register(TexturedSurfaceId, new Surface { Type = SurfaceType.Base1Image, OrigTextureId = SurfaceTextureId, }); dats.Register(SurfaceTextureId, new SurfaceTexture { Textures = new List> { RenderSurfaceId }, }); dats.Register(RenderSurfaceId, new RenderSurface { Width = 1, Height = 1, Format = PixelFormat.PFID_A8R8G8B8, SourceData = new byte[] { 10, 20, 30, 255 }, }); var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData? mesh = extractor.PrepareMeshData(GfxObjId, isSetup: false); Assert.NotNull(mesh); Assert.Equal(4, mesh!.Vertices.Length); Assert.All(mesh.Vertices, v => Assert.Equal(Vector2.Zero, v.UV)); List batches = mesh.TextureBatches.Values.Single(); TextureBatchData batch = Assert.Single(batches); Assert.Equal(6, batch.Indices.Count); Assert.False(batch.Key.IsSolid); } /// One quad (4 verts), single polygon, PosSurface referencing . private static GfxObj BuildQuadGfxObj(uint surfaceId, bool noPos) { return new GfxObj { Surfaces = { surfaceId }, VertexArray = new VertexArray { Vertices = { // No UVs at all — matches a real NoPos polygon's vertices, // which carry no UV entries because nothing samples them. [0] = new SWVertex { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, [1] = new SWVertex { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, [2] = new SWVertex { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, [3] = new SWVertex { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, }, }, Polygons = { [0] = new Polygon { Stippling = noPos ? StipplingType.NoPos : default, PosSurface = 0, NegSurface = -1, VertexIds = { 0, 1, 2, 3 }, }, }, }; } /// /// Minimal in-memory for MeshExtractor: /// resolves exactly one "root" GfxObj through Portal (the only path /// PrepareMeshData needs for TryResolvePreferred's default /// implementation), plus arbitrary typed lookups (Surface, /// SurfaceTexture, RenderSurface) also through Portal. /// private sealed class FakeMeshExtractorDats : IDatReaderWriter { private readonly Dictionary _portalObjects = new(); private uint _rootId; public FakeMeshExtractorDats() => Portal = new FakeDatDatabase(_portalObjects); public void RegisterRootGfxObj(uint id, GfxObj gfxObj) { _rootId = id; _portalObjects[id] = gfxObj; } public void Register(uint id, T obj) where T : IDBObj => _portalObjects[id] = obj; public string SourceDirectory => string.Empty; public IDatDatabase Portal { get; } public IDatDatabase Cell => EmptyDatDatabase.Instance; public ReadOnlyDictionary CellRegions { get; } = new(new Dictionary()); public IDatDatabase HighRes => EmptyDatDatabase.Instance; public IDatDatabase Language => EmptyDatDatabase.Instance; public IDatDatabase Local => EmptyDatDatabase.Instance; public ReadOnlyDictionary RegionFileMap { get; } = new(new Dictionary()); public int PortalIteration => 0; public int CellIteration => 0; public int HighResIteration => 0; public int LanguageIteration => 0; public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) { bytesRead = 0; return false; } public IEnumerable GetAllIdsOfType() where T : IDBObj => Array.Empty(); public IEnumerable ResolveId(uint id) => id == _rootId ? new[] { new IDatReaderWriter.IdResolution(Portal, DBObjType.GfxObj) } : Array.Empty(); public bool TrySave(T obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException(); public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException(); [return: MaybeNull] public T Get(uint fileId) where T : IDBObj => Portal.TryGet(fileId, out var value) ? value : default; public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj => Portal.TryGet(fileId, out value); public void Dispose() { } } private sealed class FakeDatDatabase : IDatDatabase { private readonly Dictionary _objects; public FakeDatDatabase(Dictionary objects) => _objects = objects; // Never dereferenced by MeshExtractor's own code paths (only // RetailPhysicsScriptLoader's ctor reads it, into a NULLABLE field // it never touches unless a physics-script emitter is loaded, which // these tests never trigger). public DatDatabase Db => null!; public int Iteration => 0; public IEnumerable GetAllIdsOfType() where T : IDBObj => Array.Empty(); public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj { if (_objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed) { value = typed; return true; } value = default; return false; } public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) { value = null; return false; } public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) { bytesRead = 0; return false; } public bool TrySave(T obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException(); public void Dispose() { } } private sealed class EmptyDatDatabase : IDatDatabase { public static readonly EmptyDatDatabase Instance = new(); public DatDatabase Db => null!; public int Iteration => 0; public IEnumerable GetAllIdsOfType() where T : IDBObj => Array.Empty(); public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj { value = default; return false; } public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) { value = null; return false; } public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) { bytesRead = 0; return false; } public bool TrySave(T obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException(); public void Dispose() { } } }