Campaign OVERHAUL S1 review fixes (two Opus lens reviews, findings verified by the lead against the decomp): - a raw sides_type outside 0/1/2 constructs retail's default single-side shape (ConstructMesh @0x0059DFA0 loop bounds default to 1) instead of dropping the polygon; still counted as a data anomaly (corpus has none); - the positive-surface stippling mask OR runs once per polygon before the degenerate-fan guard, as retail's count loop does (pseudo-C 426859-426866); - untextured slots keep their mask accounting but bake no texture and no vertices (contract §9 item 3); the dev pak shrinks by 114 KB; - failed surface-override / Surface / texture-dependency lookups are attempted and logged once per slot, not once per candidate; - cell-shell batches upload in ascending source surface index, retail's built-EnvCell subset draw order (ConstructMesh attribute-range scan, DrawMesh @0x0059D4A0); ordinary GfxObj meshes keep storage order; - CellMesh.HasDrawableGeometry documented as the admission rule without texture-dependency resolution (a conservative superset of emission); - the stippling/surface equivalence sweep's cell half is pinned at zero again; InAscendingSurfaceOrder is marked bake/upload/test-only; - plan §5: reviewer findings are verified by the lead, one skeptic at most for a blocking finding, never more than five agents per step. Three new Content tests pin the mask hoist, the vertex-free untextured slot, and the single-side fallback. Content 213/213, Core Meshing and Conformance green, App hermetic 6,757/6,757, Release build 0/0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
991 lines
44 KiB
C#
991 lines
44 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #426 (2026-08-23, the Holtburg windmill axle 0x010010CE, 8 polygons all
|
|
/// NoPos + Base1Solid): MeshExtractor.PrepareGfxObjMeshData used to gate the
|
|
/// positive side on <c>!Stippling.NoPos</c>, 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 <see cref="IDatReaderWriter"/> /
|
|
/// <see cref="IDatDatabase"/> pair in the same shape as
|
|
/// DatResolutionPrecedenceTests' ResolutionSource/StubDatabase.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<TextureBatchData> 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]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<QualifiedDataId<RenderSurface>> { 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<TextureBatchData> 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<ObjectMeshData>(
|
|
extractor.PrepareMeshData(GfxObjId, isSetup: false));
|
|
|
|
KeyValuePair<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> 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<ObjectMeshData>(
|
|
extractor.PrepareMeshData(GfxObjId, isSetup: false));
|
|
|
|
KeyValuePair<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> 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<ObjectMeshData>(
|
|
extractor.PrepareMeshData(GfxObjId, isSetup: false));
|
|
|
|
KeyValuePair<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> group =
|
|
Assert.Single(mesh.TextureBatches);
|
|
Assert.Equal(TextureFormat.RGBA8, group.Key.Format);
|
|
Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private static void RegisterCellTexturedSurface(FakeMeshExtractorDats dats)
|
|
{
|
|
dats.Register(TexturedSurfaceId, new Surface
|
|
{
|
|
Type = SurfaceType.Base1Image,
|
|
OrigTextureId = SurfaceTextureId,
|
|
});
|
|
dats.Register(SurfaceTextureId, new SurfaceTexture
|
|
{
|
|
Textures = new List<QualifiedDataId<RenderSurface>> { 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<ObjectMeshData>(
|
|
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<ObjectMeshData>(
|
|
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<QualifiedDataId<RenderSurface>> { 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<ushort, SWVertex>
|
|
{
|
|
[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<ushort, Polygon>
|
|
{
|
|
// 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<ObjectMeshData>(
|
|
extractor.PrepareCellStructMeshData(
|
|
id: 1, cellStruct, surfaceOverrides: [10, 20], Matrix4x4.Identity, CancellationToken.None));
|
|
|
|
List<TextureBatchData> 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<ObjectMeshData>(
|
|
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<ushort, SWVertex>
|
|
{
|
|
[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<ushort, Polygon>
|
|
{
|
|
[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<ObjectMeshData>(
|
|
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<ObjectMeshData>(
|
|
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<ushort, SWVertex>
|
|
{
|
|
[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<ushort, Polygon>
|
|
{
|
|
[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<ObjectMeshData>(
|
|
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_DegeneratePolygon_StillOrsPositiveSurfaceMask()
|
|
{
|
|
// ConstructMesh @0x0059DFA0 count loop (pseudo-C 426859-426866): the
|
|
// positive-surface stippling OR runs once per polygon BEFORE the
|
|
// num_pts branch, so a two-vertex polygon that builds no fan still
|
|
// stamps bit 1 on its positive slot. Slot 0 gets its geometry from
|
|
// the textured quad (stippling 0, so no bit from it) and its mask bit
|
|
// only from the degenerate polygon.
|
|
var dats = new FakeMeshExtractorDats();
|
|
RegisterCellTexturedSurface(dats);
|
|
var cellStruct = BuildQuadCellStruct(RetailCullMode.Landblock);
|
|
cellStruct.Polygons[1] = new Polygon
|
|
{
|
|
SidesType = RetailCullMode.Landblock,
|
|
Stippling = StipplingType.NoPos,
|
|
PosSurface = 0,
|
|
NegSurface = -1,
|
|
VertexIds = [0, 1],
|
|
};
|
|
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
|
|
|
|
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
|
extractor.PrepareCellStructMeshData(
|
|
id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None));
|
|
|
|
TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value);
|
|
Assert.Equal(6, batch.Indices.Count);
|
|
Assert.Equal(1, batch.RetailSurfaceMask);
|
|
}
|
|
|
|
[Fact]
|
|
public void PrepareCellStructMeshData_UntexturedSlot_EmitsNoVertices()
|
|
{
|
|
// An untextured slot fails built-EnvCell admission (contract §4).
|
|
// Retail constructs its geometry and never draws it; the prepared
|
|
// package does not carry vertices that can never draw (contract §9
|
|
// item 3). Slot 0 is textured, slot 1 is Base1Solid: only the four
|
|
// vertices of the textured quad may reach the output.
|
|
var dats = new FakeMeshExtractorDats();
|
|
RegisterCellTexturedSurface(dats);
|
|
dats.Register(SolidSurfaceId, new Surface
|
|
{
|
|
Type = SurfaceType.Base1Solid,
|
|
ColorValue = new ColorARGB { Alpha = 255, Red = 64, Green = 96, Blue = 128 },
|
|
});
|
|
var cellStruct = BuildQuadCellStruct(RetailCullMode.Landblock);
|
|
cellStruct.Polygons[1] = new Polygon
|
|
{
|
|
SidesType = RetailCullMode.Landblock,
|
|
PosSurface = 1,
|
|
NegSurface = -1,
|
|
VertexIds = [0, 1, 2, 3],
|
|
};
|
|
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
|
|
|
|
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
|
extractor.PrepareCellStructMeshData(
|
|
id: 1, cellStruct, surfaceOverrides: [2, 1], Matrix4x4.Identity, CancellationToken.None));
|
|
|
|
TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value);
|
|
Assert.Equal(0, batch.SourceSurfaceIndex);
|
|
Assert.Equal(4, mesh.Vertices.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void PrepareCellStructMeshData_SidesValueOutsideRetailSet_ConstructsSingleSideFan()
|
|
{
|
|
// ConstructMesh @0x0059DFA0 initializes both loop bounds to 1 and
|
|
// widens them only on an exact 1 / 2, so an anomalous raw value
|
|
// draws as ST_SINGLE rather than vanishing (S1 review finding).
|
|
var dats = new FakeMeshExtractorDats();
|
|
RegisterCellTexturedSurface(dats);
|
|
var cellStruct = BuildQuadCellStruct((RetailCullMode)7);
|
|
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
|
|
|
|
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
|
extractor.PrepareCellStructMeshData(
|
|
id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None));
|
|
|
|
TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value);
|
|
Assert.Equal(6, batch.Indices.Count);
|
|
Assert.Equal(4, mesh.Vertices.Length);
|
|
}
|
|
|
|
[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<ushort, SWVertex>
|
|
{
|
|
[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<ushort, Polygon>
|
|
{
|
|
[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<ObjectMeshData>(
|
|
extractor.PrepareCellStructMeshData(
|
|
id: 1, cellStruct, surfaceOverrides: [2, 2], Matrix4x4.Identity, CancellationToken.None));
|
|
|
|
List<TextureBatchData> 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<ushort, SWVertex>
|
|
{
|
|
[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<ushort, Polygon>
|
|
{
|
|
[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<ObjectMeshData>(
|
|
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<ushort, SWVertex>
|
|
{
|
|
[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<ushort, Polygon>
|
|
{
|
|
[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<ObjectMeshData>(
|
|
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<QualifiedDataId<RenderSurface>> { 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<QualifiedDataId<RenderSurface>> { 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<ushort, SWVertex>
|
|
{
|
|
[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<ushort, Polygon>
|
|
{
|
|
// 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<ObjectMeshData>(
|
|
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<int> 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<QualifiedDataId<RenderSurface>> { 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<ushort, SWVertex>
|
|
{
|
|
[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<ushort, Polygon>
|
|
{
|
|
[0] = new()
|
|
{
|
|
SidesType = sidesType,
|
|
PosSurface = 0,
|
|
NegSurface = -1,
|
|
VertexIds = [0, 1, 2, 3],
|
|
},
|
|
},
|
|
};
|
|
|
|
/// <summary>One quad (4 verts), single polygon, PosSurface referencing <paramref name="surfaceId"/>.</summary>
|
|
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 },
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal in-memory <see cref="IDatReaderWriter"/> 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.
|
|
/// </summary>
|
|
private sealed class FakeMeshExtractorDats : IDatReaderWriter
|
|
{
|
|
private readonly Dictionary<uint, IDBObj> _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<T>(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<uint, IDatDatabase> CellRegions { get; } =
|
|
new(new Dictionary<uint, IDatDatabase>());
|
|
public IDatDatabase HighRes => EmptyDatDatabase.Instance;
|
|
public IDatDatabase Language => EmptyDatDatabase.Instance;
|
|
public IDatDatabase Local => EmptyDatDatabase.Instance;
|
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
|
new(new Dictionary<uint, uint>());
|
|
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<uint> GetAllIdsOfType<T>() where T : IDBObj => Array.Empty<uint>();
|
|
|
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
|
id == _rootId
|
|
? new[] { new IDatReaderWriter.IdResolution(Portal, DBObjType.GfxObj) }
|
|
: Array.Empty<IDatReaderWriter.IdResolution>();
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
[return: MaybeNull]
|
|
public T Get<T>(uint fileId) where T : IDBObj =>
|
|
Portal.TryGet<T>(fileId, out var value) ? value : default;
|
|
|
|
public bool TryGet<T>(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<uint, IDBObj> _objects;
|
|
|
|
public FakeDatDatabase(Dictionary<uint, IDBObj> 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<uint> GetAllIdsOfType<T>() where T : IDBObj => Array.Empty<uint>();
|
|
|
|
public bool TryGet<T>(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>(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<uint> GetAllIdsOfType<T>() where T : IDBObj => Array.Empty<uint>();
|
|
|
|
public bool TryGet<T>(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>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|