using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; using AcDream.Content; using Chorizite.Core.Render.Enums; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Lib.IO; using DatReaderWriter.Types; using Microsoft.Extensions.Logging.Abstractions; using RetailCullMode = DatReaderWriter.Enums.CullMode; 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; [Fact] public void PrepareMeshData_CarriesAuthoredDrawingBspSphereInsteadOfVertexAabbSphere() { var authored = new Sphere { Origin = new Vector3(-0.25f, 0.125f, 0.5f), Radius = 3.125f, }; GfxObj gfxObj = BuildQuadGfxObj(SolidSurfaceId, noPos: true); gfxObj.DrawingBSP = new DrawingBSPTree { Root = new DrawingBSPNode { BoundingSphere = authored }, }; var dats = new FakeMeshExtractorDats(); dats.RegisterRootGfxObj(GfxObjId, gfxObj); dats.Register(SolidSurfaceId, new Surface { Type = SurfaceType.Base1Solid, ColorValue = new ColorARGB { Alpha = 255, Red = 12, Green = 34, Blue = 56, }, }); var extractor = new MeshExtractor( dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData? mesh = extractor.PrepareMeshData( GfxObjId, isSetup: false); Assert.NotNull(mesh); Assert.NotNull(mesh!.SelectionSphere); Assert.Equal(authored.Origin, mesh.SelectionSphere.Origin); Assert.Equal(authored.Radius, mesh.SelectionSphere.Radius); } /// /// 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); } [Theory] [InlineData(PixelFormat.PFID_DXT1, TextureFormat.DXT1, 8)] [InlineData(PixelFormat.PFID_DXT3, TextureFormat.DXT3, 16)] [InlineData(PixelFormat.PFID_DXT5, TextureFormat.DXT5, 16)] public void PrepareMeshData_UneditedDxtSurface_PreservesNativeBlocks( PixelFormat sourceFormat, TextureFormat expectedFormat, int sourceBytes) { var dats = new FakeMeshExtractorDats(); byte[] blocks = Enumerable.Range(0, sourceBytes).Select(i => (byte)i).ToArray(); RegisterTexturedQuad(dats, SurfaceType.Base1Image, sourceFormat, blocks); var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareMeshData(GfxObjId, isSetup: false)); KeyValuePair<(int Width, int Height, TextureFormat Format), List> group = Assert.Single(mesh.TextureBatches); Assert.Equal(expectedFormat, group.Key.Format); TextureBatchData batch = Assert.Single(group.Value); Assert.Same(blocks, batch.TextureData); Assert.Null(batch.UploadPixelFormat); Assert.Null(batch.UploadPixelType); } [Fact] public void PrepareMeshData_DxtClipMap_DecodesForSurfaceLocalAlphaEdit() { var dats = new FakeMeshExtractorDats(); RegisterTexturedQuad( dats, SurfaceType.Base1Image | SurfaceType.Base1ClipMap, PixelFormat.PFID_DXT1, new byte[8]); var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareMeshData(GfxObjId, isSetup: false)); KeyValuePair<(int Width, int Height, TextureFormat Format), List> group = Assert.Single(mesh.TextureBatches); Assert.Equal(TextureFormat.RGBA8, group.Key.Format); Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length); } [Fact] public void PrepareMeshData_TranslucentDxt_DecodesForAlphaScale() { var dats = new FakeMeshExtractorDats(); RegisterTexturedQuad( dats, SurfaceType.Base1Image, PixelFormat.PFID_DXT1, new byte[8], translucency: 0.25f); var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareMeshData(GfxObjId, isSetup: false)); KeyValuePair<(int Width, int Height, TextureFormat Format), List> group = Assert.Single(mesh.TextureBatches); Assert.Equal(TextureFormat.RGBA8, group.Key.Format); Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length); } /// /// OH2/S1 chunk-2 (2026-09-02): these two winding tests used to register /// an UNTEXTURED (Base1Solid) surface. Under the exact contract, an /// untextured surface slot is constructed but never EMITTED (built- /// EnvCell admission, contract §4) — so the winding assertions need a /// TEXTURED surface to have a batch to inspect at all. Geometry/winding /// output is independent of texturing, so switching to Base1Image does /// not change what these tests actually pin (verified by hand against /// CellStructSideCandidates.TriangleFanIndices before this change). /// private static void RegisterCellTexturedSurface(FakeMeshExtractorDats dats) { 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 }, }); } [Fact] public void PrepareCellStructMeshData_LandblockSide_PreservesRetailTriangleFanWinding() { var dats = new FakeMeshExtractorDats(); RegisterCellTexturedSurface(dats); var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, BuildQuadCellStruct(RetailCullMode.Landblock), surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); // Contract §3.6 + EnvCellRenderer.Rhi.cs's // ResolveRetailCellShellCullMode: cell shells now always draw // fixed D3DCULL_CW; the authored sides_type (ST_SINGLE here) no // longer flows into CullMode. Assert.Equal(RetailCullMode.Clockwise, batch.CullMode); Assert.True(batch.IsCellShell); Assert.Equal(0, batch.SourceSurfaceIndex); Assert.Equal([0, 1, 2, 0, 2, 3], batch.Indices); Assert.Equal(4, mesh.Vertices.Length); Assert.All(mesh.Vertices, vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal)); } [Fact] public void PrepareCellStructMeshData_NoneSide_ExpandsReversedFaceExactlyLikeRetailConstructMesh() { var dats = new FakeMeshExtractorDats(); RegisterCellTexturedSurface(dats); var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, BuildQuadCellStruct(RetailCullMode.None), surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); Assert.Equal(RetailCullMode.Clockwise, batch.CullMode); Assert.True(batch.IsCellShell); Assert.Equal(0, batch.SourceSurfaceIndex); Assert.Equal( [0, 1, 2, 0, 2, 3, 6, 5, 4, 7, 6, 4], batch.Indices); Assert.Equal(8, mesh.Vertices.Length); Assert.All(mesh.Vertices.Take(4), vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal)); Assert.All(mesh.Vertices.Skip(4), vertex => Assert.Equal(-Vector3.UnitZ, vertex.Normal)); } [Fact] public void PrepareCellStructMeshData_BothSide_NegativeCandidateIsNotWindingReversed() { // Contract §3.4's binding, counterintuitive fact: ST_BOTH's negative // candidate changes the SIDE ordinal, not the COPY ordinal, so its // fan order stays FORWARD even though its normal is negated. Only // ST_DOUBLE's second COPY reverses winding. Two DISTINCT surface // slots (0 and 1) so this also exercises "two subsets, one per // side" for ST_BOTH. var dats = new FakeMeshExtractorDats(); dats.Register(0x0800000Au, new Surface { Type = SurfaceType.Base1Image, OrigTextureId = SurfaceTextureId }); dats.Register(0x08000014u, 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[] { 1, 2, 3, 255 }, }); var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, }, }, Polygons = new Dictionary { // Raw sides_type 2 == ST_BOTH. DatReaderWriter names this // CullMode member "Clockwise" too, but on Polygon.SidesType // it means ST_BOTH, NOT a GPU cull state (see // CellStructSideCandidates' remarks) -- unrelated to the // FIXED batch.CullMode this method now writes. [0] = new() { SidesType = RetailCullMode.Clockwise, PosSurface = 0, NegSurface = 1, VertexIds = [0, 1, 2, 3] }, }, }; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, cellStruct, surfaceOverrides: [10, 20], Matrix4x4.Identity, CancellationToken.None)); List batches = mesh.TextureBatches.Values.SelectMany(b => b).OrderBy(b => b.SourceSurfaceIndex).ToList(); Assert.Equal(2, batches.Count); TextureBatchData positive = batches[0]; Assert.Equal(0, positive.SourceSurfaceIndex); Assert.Equal([0, 1, 2, 0, 2, 3], positive.Indices); TextureBatchData negative = batches[1]; Assert.Equal(1, negative.SourceSurfaceIndex); // NOT reversed: forward fan order over the negative-lane vertices. Assert.Equal([4, 5, 6, 4, 6, 7], negative.Indices); Assert.All(mesh.Vertices.Take(4), vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal)); Assert.All(mesh.Vertices.Skip(4), vertex => Assert.Equal(-Vector3.UnitZ, vertex.Normal)); } [Fact] public void PrepareCellStructMeshData_UntexturedSlot_ConstructedButNotEmitted() { // Contract §4: RenderDeviceD3D::DrawEnvCell (arg4=1) admits a // subset iff (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0. A // Base1Solid surface fails that test, so the slot is fully // constructed (mask/ownership facts exist) but the mesh must carry // ZERO texture batches for it. var dats = new FakeMeshExtractorDats(); dats.Register(SolidSurfaceId, new Surface { Type = SurfaceType.Base1Solid, ColorValue = new ColorARGB { Alpha = 255, Red = 64, Green = 96, Blue = 128 }, }); var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, BuildQuadCellStruct(RetailCullMode.Landblock), surfaceOverrides: [1], Matrix4x4.Identity, CancellationToken.None)); Assert.Empty(mesh.TextureBatches); } [Fact] public void PrepareCellStructMeshData_TexturedNoPos_ReadsVertexUvSlotZero() { // Contract §3.5 as arbitrated on the PDB-paired binary (2026-09-02, // ConstructMesh @0x0059E683-0x0059E693): when the polygon's UV-index // array is absent (NoPos) the caller ZEROES THE INDEX (xor ebx,ebx) // and copyVert @0x0059C080 reads the vertex's own UV slot 0 like any // other index. This fixture carries a NON-zero UV at slot 0 to prove // the read-through; the zero-coordinate cases are the two tests // below (no vertex UV array; out-of-range index). var dats = new FakeMeshExtractorDats(); RegisterCellTexturedSurface(dats); var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, }, }, Polygons = new Dictionary { [0] = new() { SidesType = RetailCullMode.Landblock, Stippling = StipplingType.NoPos, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2, 3] }, }, }; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); Assert.False(batch.Key.IsSolid); Assert.Equal(4, mesh.Vertices.Length); Assert.All(mesh.Vertices, vertex => Assert.Equal(new Vector2(0.5f, 0.5f), vertex.UV)); } [Fact] public void PrepareCellStructMeshData_TexturedNoPos_VertexWithoutUvArray_EmitsZeroUVs() { // copyVert @0x0059C080 zeroes the coordinate when the vertex has no // UV array (edi[4] == 0) — the only absent-array case that yields // (0,0). Same polygon shape as the read-through test, no vertex UVs. var dats = new FakeMeshExtractorDats(); RegisterCellTexturedSurface(dats); var cellStruct = BuildQuadCellStruct(RetailCullMode.Landblock); cellStruct.Polygons[0].Stippling = StipplingType.NoPos; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); Assert.Equal(4, mesh.Vertices.Length); Assert.All(mesh.Vertices, vertex => Assert.Equal(Vector2.Zero, vertex.UV)); } [Fact] public void PrepareCellStructMeshData_OutOfRangeUvIndex_EmitsZeroUVs_NotSlotZero() { // copyVert @0x0059C080: index >= the vertex's uv count -> zero // coordinate. Retail never clamps to slot 0. Each vertex has one // UV (slot 0, non-zero) and the polygon asks for slot 5. var dats = new FakeMeshExtractorDats(); RegisterCellTexturedSurface(dats); var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, }, }, Polygons = new Dictionary { [0] = new() { SidesType = RetailCullMode.Landblock, Stippling = default, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2, 3], PosUVIndices = [5, 5, 5, 5] }, }, }; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); Assert.Equal(4, mesh.Vertices.Length); Assert.All(mesh.Vertices, vertex => Assert.Equal(Vector2.Zero, vertex.UV)); } [Fact] public void PrepareCellStructMeshData_TwoSlotsSameSurfaceDid_RemainTwoDistinctSubsets() { // Contract §3.6 point 2: the subset owner is the SOURCE // SURFACE-ARRAY INDEX, not the resolved Surface DID -- two // different slots that happen to resolve to the SAME override // value (and therefore the same Surface DID) must stay two // separate subsets. var dats = new FakeMeshExtractorDats(); RegisterCellTexturedSurface(dats); var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, [4] = new() { Origin = new Vector3(2, 0, 0), Normal = Vector3.UnitZ }, [5] = new() { Origin = new Vector3(3, 0, 0), Normal = Vector3.UnitZ }, [6] = new() { Origin = new Vector3(3, 1, 0), Normal = Vector3.UnitZ }, }, }, Polygons = new Dictionary { [0] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2, 3] }, [1] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 1, NegSurface = -1, VertexIds = [4, 5, 6] }, }, }; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); // Both slots resolve to override value 2 -> the SAME Surface DID. ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, cellStruct, surfaceOverrides: [2, 2], Matrix4x4.Identity, CancellationToken.None)); List batches = mesh.TextureBatches.Values.SelectMany(b => b).OrderBy(b => b.SourceSurfaceIndex).ToList(); Assert.Equal(2, batches.Count); Assert.Equal(0, batches[0].SourceSurfaceIndex); Assert.Equal(1, batches[1].SourceSurfaceIndex); Assert.Equal(batches[0].Key.SurfaceId, batches[1].Key.SurfaceId); Assert.NotSame(batches[0], batches[1]); } [Fact] public void PrepareCellStructMeshData_DifferentStipplingOnOneSlot_StaysOneSubsetWithAggregatedMask() { // Contract §3.6 point 3 + §3.2: two polygons on the SAME slot with // DIFFERENT stippling values must remain ONE subset, and the // slot's final mask is the OR of every polygon's positive-surface // stippling bit. var dats = new FakeMeshExtractorDats(); RegisterCellTexturedSurface(dats); var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, }, }, Polygons = new Dictionary { [0] = new() { SidesType = RetailCullMode.Landblock, Stippling = StipplingType.None, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2] }, [1] = new() { SidesType = RetailCullMode.Landblock, Stippling = StipplingType.Positive, PosSurface = 0, NegSurface = -1, VertexIds = [0, 2, 3] }, }, }; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); // Base1Image alone carries no alpha/clip/translucent bits -> initial // mask 0; the second polygon's nonzero (signed-positive) stippling // ORs bit 0 in. The first polygon's None (0) stippling does not. Assert.Equal((byte)1, batch.RetailSurfaceMask); Assert.Equal(6, batch.Indices.Count); // both triangles landed in the one subset } [Fact] public void PrepareCellStructMeshData_DifferentSidesValuesOnOneSlot_StaysOneSubset() { // Contract §3.6 point 3: two polygons on the SAME slot with // DIFFERENT sides_type values (ST_SINGLE and ST_DOUBLE here) must // stay one subset, in source polygon order. var dats = new FakeMeshExtractorDats(); RegisterCellTexturedSurface(dats); var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, }, }, Polygons = new Dictionary { [0] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2] }, [1] = new() { SidesType = RetailCullMode.None, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2] }, }, }; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); Assert.Equal(0, batch.SourceSurfaceIndex); // poly0 (ST_SINGLE): one forward triangle. poly1 (ST_DOUBLE): one // forward + one reversed copy. 3 triangles total, in polygon order. Assert.Equal(9, batch.Indices.Count); } [Fact] public void PrepareCellStructMeshData_SubsetOrder_IsAscendingSurfaceIndexAcrossTextureFormats() { // Contract §3.6 point 1/5: subset order is ascending SOURCE SURFACE // INDEX, independent of the (Width,Height,Format) storage // grouping. Two slots resolve to DIFFERENT texture dimensions (so // they land in different TextureBatches dictionary buckets), and // the higher slot number is authored/processed FIRST. var dats = new FakeMeshExtractorDats(); dats.Register(0x08000005u, new Surface { Type = SurfaceType.Base1Image, OrigTextureId = 0x05000005u }); dats.Register(0x05000005u, new SurfaceTexture { Textures = new List> { 0x06000005u } }); dats.Register(0x06000005u, new RenderSurface { Width = 8, Height = 8, Format = PixelFormat.PFID_A8R8G8B8, SourceData = new byte[8 * 8 * 4] }); dats.Register(0x08000002u, 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[] { 1, 2, 3, 255 } }); var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, }, }, Polygons = new Dictionary { // Higher slot (5, the wide texture) authored/iterated FIRST. [0] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 5, NegSurface = -1, VertexIds = [0, 1, 2] }, [1] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 2, NegSurface = -1, VertexIds = [0, 1, 2] }, }, }; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( id: 1, cellStruct, surfaceOverrides: [0, 0, 2, 0, 0, 5], Matrix4x4.Identity, CancellationToken.None)); // At least two distinct (Width,Height,Format) buckets prove this // isn't accidentally passing because everything landed in one list. Assert.True(mesh.TextureBatches.Count >= 2); List order = CellSurfaceSubsets.InAscendingSurfaceOrder(mesh) .Select(b => b.SourceSurfaceIndex) .ToList(); Assert.Equal([2, 5], order); } private static void RegisterTexturedQuad( FakeMeshExtractorDats dats, SurfaceType surfaceType, PixelFormat pixelFormat, byte[] sourceData, float translucency = 0.0f) { dats.RegisterRootGfxObj(GfxObjId, BuildQuadGfxObj(TexturedSurfaceId, noPos: false)); dats.Register(TexturedSurfaceId, new Surface { Type = surfaceType, OrigTextureId = SurfaceTextureId, Translucency = translucency, }); dats.Register(SurfaceTextureId, new SurfaceTexture { Textures = new List> { RenderSurfaceId }, }); dats.Register(RenderSurfaceId, new RenderSurface { Width = 4, Height = 4, Format = pixelFormat, SourceData = sourceData, }); } private static CellStruct BuildQuadCellStruct(RetailCullMode sidesType) => new() { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, }, }, Polygons = new Dictionary { [0] = new() { SidesType = sidesType, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2, 3], }, }, }; /// 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() { } } }