acdream/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.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

173 lines
9.4 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;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Meshing;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Conformance;
/// <summary>
/// #119 diagnostic dump (2026-06-11): GfxObjs 0x010002B4 and 0x010008A8 hit
/// `[up-null] upload returned null — caching EMPTY render data (permanently
/// invisible)` at startup (t5-gate-launch.log:33-34); the old tower shows
/// missing stair parts (visible in retail — user axiom). UploadGfxObjMeshData
/// returns null only when the PREPARE phase produced ZERO vertices
/// (ObjectMeshManager.UploadGfxObjMeshData's empty-vertices guard), so the
/// upload is innocent — some extraction
/// gate dropped every polygon. This dump prints the raw dat facts per polygon
/// and replicates MeshExtractor.PrepareGfxObjMeshData's gates (moved from
/// ObjectMeshManager in MP1a)
/// so the zeroing gate reads directly off the output.
///
/// #426 (2026-08-23) UPDATE: the gate replica below now mirrors the FIXED
/// extraction rule — the positive side is added whenever PosSurface is a
/// valid index, regardless of Stippling.NoPos (NoPos means "no positive
/// UVs", not "no positive face"; see RetailUntexturedSurfacePolicy). Run
/// against the installed DAT, both of #119's original objects now gate
/// DRAWS on every single polygon (0x010002B4: 9/9 polys; 0x010008A8: 1/1
/// poly — both all-NoPos+Base1Solid, wouldAddPos == polygon count) — they
/// are NEITHER all-degenerate NOR all-invalid-surface-index; they were
/// dropped for being all-solid, and #426 now extracts that geometry. The
/// #119 filing's "retail never draws untextured subsets" conclusion was
/// simply wrong (see #426's ISSUES.md entry) — it never held for these two
/// objects specifically. Whether their solid faces end up VISIBLE on
/// screen (vs. skipped again at draw time by RetailUntexturedSubsetPolicy,
/// if either object turns out to be a building-shell part) is a separate,
/// unverified question this dump does not answer.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class Issue119UpNullGfxObjDumpTests
{
private readonly ITestOutputHelper _out;
public Issue119UpNullGfxObjDumpTests(ITestOutputHelper output) => _out = output;
public static readonly TheoryData<uint> UpNullIds = new() { 0x010002B4u, 0x010008A8u };
[Theory]
[MemberData(nameof(UpNullIds))]
[Trait("Purpose", "Diagnostic")]
public void DumpUpNullGfxObj(uint id)
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
using var dats = new DatCollection(datDir, DatAccessType.Read);
Assert.True(dats.Portal.TryGet<GfxObj>(id, out var gfx) && gfx is not null,
$"GfxObj 0x{id:X8} not in portal dat");
_out.WriteLine($"=== GfxObj 0x{id:X8} ===");
_out.WriteLine($"Flags={gfx!.Flags} Polygons={gfx.Polygons.Count} Vertices={gfx.VertexArray.Vertices.Count} Surfaces={gfx.Surfaces.Count}");
_out.WriteLine($"DrawingBSP={(gfx.DrawingBSP?.Root is null ? "NONE" : "present")} PhysicsBSP={(gfx.PhysicsBSP?.Root is null ? "NONE" : "present")}");
for (int i = 0; i < gfx.Surfaces.Count; i++)
{
uint sid = gfx.Surfaces[i];
string stype = dats.Portal.TryGet<Surface>(sid, out var surf) && surf is not null
? surf.Type.ToString()
: "MISSING";
_out.WriteLine($" surface[{i}] = 0x{sid:X8} type={stype}");
}
// Replicate the extraction gates (PrepareGfxObjMeshData):
// pos added whenever PosSurface is a valid index — #426
// (2026-08-23): NoPos means "no positive UVs", not "no positive
// face"; ordinary objects draw untextured (solid) subsets the same
// as textured ones (RetailUntexturedSurfacePolicy).
// neg added when Negative || Both || (!NoNeg && SidesType==Clockwise)
// surface index must be in [0, Surfaces.Count)
int wouldAddPos = 0, wouldAddNeg = 0, degenerate = 0;
var gateHistogram = new Dictionary<string, int>();
foreach (var (pid, poly) in gfx.Polygons.OrderBy(kv => kv.Key))
{
string gate;
if (poly.VertexIds.Count < 3) { degenerate++; gate = "degenerate(<3 verts)"; }
else
{
bool pos = poly.PosSurface >= 0 && poly.PosSurface < gfx.Surfaces.Count;
bool neg = (poly.Stippling.HasFlag(StipplingType.Negative)
|| poly.Stippling.HasFlag(StipplingType.Both)
|| (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise))
&& poly.NegSurface >= 0 && poly.NegSurface < gfx.Surfaces.Count;
if (pos) wouldAddPos++;
if (neg) wouldAddNeg++;
gate = pos || neg ? "DRAWS" : $"DROPPED stip={poly.Stippling} sides={poly.SidesType} posSurf={poly.PosSurface} negSurf={poly.NegSurface}";
}
gateHistogram.TryGetValue(gate, out int c);
gateHistogram[gate] = c + 1;
_out.WriteLine(FormattableString.Invariant(
$" poly[{pid}] verts={poly.VertexIds.Count} stip={poly.Stippling} sides={poly.SidesType} posSurf={poly.PosSurface} negSurf={poly.NegSurface} gate={gate}"));
}
_out.WriteLine($"--- summary: wouldAddPos={wouldAddPos} wouldAddNeg={wouldAddNeg} degenerate={degenerate} of {gfx.Polygons.Count} polys ---");
foreach (var (gate, count) in gateHistogram.OrderByDescending(kv => kv.Value))
_out.WriteLine($" {count,3} × {gate}");
}
/// <summary>
/// #119 second fact: does the extraction drop any polys that retail WOULD
/// draw (textured, non-solid surface) on a building-shell model? Run on the
/// Holtburg meeting-hall shell 0x010014C3 (the #113-saga tower whose stairs
/// are "regular shell polys" — render digest user axiom). A non-zero
/// "DROPPED but textured" count names the extraction as the stairs-miss
/// mechanism; zero exonerates the per-poly gates.
///
/// #426 (2026-08-23) UPDATE: post-fix, the POS side can only be dropped
/// when PosSurface itself is out of range — a textured pos-side surface
/// with a valid index is now ALWAYS emitted (that's the whole point of
/// #426). So this test's remaining bite is almost entirely the NEG-side
/// gate (unchanged by #426 — a textured NEG surface can still legitimately
/// be dropped when none of the three double-sided conditions hold). Kept
/// under its original name/assertion (zero textured drops) because that
/// invariant is still exactly what we want to hold; `SurfaceIsTextured`
/// now calls the shared RetailUntexturedSurfacePolicy predicate instead of
/// a bare Base1Solid check, so this test and the extraction it's checking
/// literally cannot drift on what "textured" means.
/// </summary>
[Theory]
[InlineData(0x010014C3u)]
public void ShellModel_NoTexturedPolyIsDropped(uint id)
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
using var dats = new DatCollection(datDir, DatAccessType.Read);
Assert.True(dats.Portal.TryGet<GfxObj>(id, out var gfx) && gfx is not null,
$"GfxObj 0x{id:X8} not in portal dat");
bool SurfaceIsTextured(short idx)
{
if (idx < 0 || idx >= gfx!.Surfaces.Count) return false;
if (!dats.Portal.TryGet<Surface>(gfx.Surfaces[idx], out var surf) || surf is null) return false;
return !RetailUntexturedSurfacePolicy.IsUntextured(surf.Type);
}
int draws = 0;
var droppedTextured = new List<string>();
foreach (var (pid, poly) in gfx!.Polygons.OrderBy(kv => kv.Key))
{
if (poly.VertexIds.Count < 3) continue;
bool pos = poly.PosSurface >= 0 && poly.PosSurface < gfx.Surfaces.Count;
bool neg = (poly.Stippling.HasFlag(StipplingType.Negative)
|| poly.Stippling.HasFlag(StipplingType.Both)
|| (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise))
&& poly.NegSurface >= 0 && poly.NegSurface < gfx.Surfaces.Count;
if (pos || neg) { draws++; continue; }
// dropped by the gates — would retail have drawn a textured side?
if (SurfaceIsTextured(poly.PosSurface) || SurfaceIsTextured(poly.NegSurface))
droppedTextured.Add(FormattableString.Invariant(
$"poly[{pid}] stip={poly.Stippling} sides={poly.SidesType} posSurf={poly.PosSurface} negSurf={poly.NegSurface}"));
}
_out.WriteLine($"GfxObj 0x{id:X8}: polys={gfx.Polygons.Count} drawnByGates={draws} droppedTextured={droppedTextured.Count}");
foreach (var line in droppedTextured)
_out.WriteLine($" {line}");
Assert.True(droppedTextured.Count == 0,
$"{droppedTextured.Count} textured polys are dropped by the extraction gates on 0x{id:X8} — see output");
}
}