acdream/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs
Erik 517d17b4b3 fix #426: extract solid-colour (NO_POS_UVS) faces; skip untextured subsets only on building shells and cells like retail
The Holtburg windmill axle (GfxObj 0x010010CE, 8 polygons, all
Stippling.NoPos + SurfaceType.Base1Solid) extracted to a 0-vertex mesh.
NoPos ("NO_POS_UVS", acclient.h:7380-7388) means "this side has no
texture coordinates" — true of every solid-colour polygon, since
nothing samples them — not "there is no positive face". Extraction read
it as the latter and dropped the polygon entirely, client-wide, for
every untextured polygon on every object.

Retail's D3DPolyRender::DrawMesh (@0x0059d4a0, named-retail decomp
~line 426048) draws an untextured subset on an ordinary object exactly
like a textured one; the only retail cases that skip an untextured
subset are a building shell (RenderDeviceD3D::DrawBuilding @0x0059f2a0
sets ObjBuildingOrBuildingPart=1) or an EnvCell interior
(RenderDeviceD3D::DrawEnvCell @0x0059f170, arg4=1). The #119
investigation's "retail's skipNoTexture never draws them either"
conclusion was itself wrong as a general rule.

- MeshExtractor.PrepareGfxObjMeshData / GfxObjMesh.Build: emit the
  positive side whenever PosSurface is a valid index, regardless of
  NoPos; the existing UV-index-0 fallback already produces zero
  texcoords for a NoPos polygon with no UVs on the wire.
- RetailUntexturedSurfacePolicy.IsUntextured(SurfaceType): the one
  place that answers "is this surface textured"
  ((type & (Base1Image|Base1ClipMap)) == 0), replacing the old
  `isSolid = NoPos || Base1Solid` (which also mis-classified a NEG-side
  batch by the POS-side's NoPos flag).
- RetailUntexturedSubsetPolicy.Draws(isBuildingShell, isUntextured):
  the shared draw-time gate wired into WbDrawDispatcher.ClassifyBatches,
  .PackedOracle.ClassifyPackedBatches, and
  .DirectionalShadows.AddDirectionalShadowBatches — one predicate so the
  three walks cannot drift (Campaign VM VM6 lesson).
- CellMesh.cs / MeshExtractor.PrepareCellStructMeshData deliberately
  KEEP their NoPos-gated skip for cell-wall geometry — retail's
  DrawEnvCell really does skip untextured subsets there; register row
  AP-234 documents the NoPos-vs-Surface.Type approximation.
- PakFormat.CurrentBakeToolVersion 4->5 (LauncherInstallRecordStore in
  lockstep): a pak baked by an older tool is missing every untextured
  face. No bake was run as part of this commit.

Also fixed: WorldBuilder's own upstream ObjectMeshManager.cs has the
identical NoPos bug (ObjectMeshManager.cs:959,984) — our port had
faithfully carried it over, and our own conformance test
(Build_NoPosFlag_OnlyEmitsNegSide) asserted the bug as correct WB
conformance. Renamed/reworded to Build_NoPosFlag_EmitsBothPosAndNegSide
with a citation for why retail decomp overrides WB here.

Issue119UpNullGfxObjDumpTests re-run against the installed DAT:
#119's own two objects (0x010002B4 9/9 polys, 0x010008A8 1/1 poly) now
gate DRAWS on every polygon instead of extracting to nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:20:24 +02:00

163 lines
6.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Rendering.Wb;
/// <summary>
/// Conformance: our <see cref="GfxObjMesh.Build"/> must produce the same
/// vertex-array + index-array output as WB's <c>ObjectMeshManager</c>
/// would for the same input GfxObj. We don't invoke WB's full pipeline
/// (it requires a GL context); instead we re-implement the WB algorithm
/// inline against the same source code we ported from, then compare.
///
/// <para>
/// If this test fails, either our port has drifted or the WB code has
/// changed upstream — investigate which, do not "fix" the test.
/// </para>
///
/// <para>
/// ONE DOCUMENTED EXCEPTION (#426, 2026-08-23):
/// <see cref="Build_NoPosFlag_EmitsBothPosAndNegSide"/> below intentionally
/// diverges from WorldBuilder's own upstream
/// <c>ObjectMeshManager.cs:959</c> (<c>if
/// (!poly.Stippling.HasFlag(StipplingType.NoPos))</c>), which has the exact
/// same bug our port faithfully carried over: gating the polygon's positive
/// side on <c>!NoPos</c>, silently dropping every solid-colour polygon.
/// Named-retail decomp (<c>D3DPolyRender::DrawMesh</c> @0x0059d4a0) proves
/// <c>NoPos</c> ("this side has no texture coordinates",
/// acclient.h:7380-7388) does not gate whether retail draws the positive
/// side at all — see #426 in docs/ISSUES.md for the full citation. This is
/// the one case in this file where the retail decomp — not WB — is the
/// oracle; see the acdream-wide rule in CLAUDE.md ("the decompiled code is
/// ground truth ... if they disagree, the decompiled code wins").
/// </para>
/// </summary>
public sealed class MeshExtractionConformanceTests
{
[Fact]
public void Build_QuadGfxObj_ProducesExpectedVerticesAndIndices()
{
var gfxObj = MakeUnitQuadGfxObj();
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Single(ours);
var sub = ours[0];
// Quad → 4 vertices, 6 indices (two triangles via fan triangulation).
Assert.Equal(4, sub.Vertices.Length);
Assert.Equal(6, sub.Indices.Length);
// Fan from vertex 0: (0,1,2) and (0,2,3).
Assert.Equal(new uint[] { 0, 1, 2, 0, 2, 3 }, sub.Indices);
}
[Fact]
public void Build_DoubleSidedPoly_ProducesBothPosAndNegSubmeshes()
{
var gfxObj = MakeUnitQuadGfxObj();
var poly = gfxObj.Polygons[0];
poly.Stippling = StipplingType.Both;
// NegSurface=0 so the neg side references a valid surface entry.
poly.NegSurface = 0;
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Equal(2, ours.Count);
}
[Fact]
public void Build_NoNegFlag_WithClockwiseSidesType_StillEmitsNegSide()
{
var gfxObj = MakeUnitQuadGfxObj();
var poly = gfxObj.Polygons[0];
poly.Stippling = StipplingType.None;
poly.SidesType = CullMode.Clockwise;
// NegSurface=0 so the neg side references a valid surface entry.
poly.NegSurface = 0;
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Equal(2, ours.Count);
}
/// <summary>
/// #426 (2026-08-23): renamed from <c>Build_NoPosFlag_OnlyEmitsNegSide</c>,
/// which asserted <c>Assert.Single(ours)</c> — the OLD bug's own
/// behavior (NoPos silently dropped the positive side). NoPos means "no
/// positive UVs" (acclient.h:7386), not "no positive face"; retail draws
/// an ordinary object's untextured positive side same as a textured one
/// (D3DPolyRender::DrawMesh @0x0059d4a0). See the class doc's "ONE
/// DOCUMENTED EXCEPTION" note for why this test intentionally diverges
/// from WorldBuilder's own (equally buggy) upstream algorithm.
/// </summary>
[Fact]
public void Build_NoPosFlag_EmitsBothPosAndNegSide()
{
var gfxObj = MakeUnitQuadGfxObj();
var poly = gfxObj.Polygons[0];
poly.Stippling = StipplingType.NoPos | StipplingType.Negative;
// NegSurface=0 so the neg side references a valid surface entry.
poly.NegSurface = 0;
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Equal(2, ours.Count);
}
/// <summary>
/// Build a synthetic 1×1 quad GfxObj with vertex sequence [0,1,2,3]
/// at corners (0,0,0)/(1,0,0)/(1,1,0)/(0,1,0). PosSurface=0,
/// NegSurface=-1 (invalid — pos side only by default).
/// No Stippling flags set by default — caller may add them per test.
/// </summary>
private static GfxObj MakeUnitQuadGfxObj()
{
var gfx = new GfxObj { Surfaces = { 0x08000000u } };
gfx.VertexArray = new VertexArray
{
VertexType = VertexType.CSWVertexType,
Vertices =
{
[0] = new SWVertex
{
Origin = new Vector3(0, 0, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 0, V = 0 } },
},
[1] = new SWVertex
{
Origin = new Vector3(1, 0, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 1, V = 0 } },
},
[2] = new SWVertex
{
Origin = new Vector3(1, 1, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 1, V = 1 } },
},
[3] = new SWVertex
{
Origin = new Vector3(0, 1, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 0, V = 1 } },
},
},
};
var poly = new Polygon
{
VertexIds = { 0, 1, 2, 3 },
PosUVIndices = { 0, 0, 0, 0 },
PosSurface = 0,
NegSurface = -1, // invalid index — pos side only
Stippling = StipplingType.None,
SidesType = CullMode.None,
};
gfx.Polygons[0] = poly;
return gfx;
}
}