fix(content): S1 review round - retail default sides shape, mask hoist, upload order

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>
This commit is contained in:
Erik 2026-09-02 19:37:25 +02:00
parent 0840d5fb77
commit e2543d0ef0
10 changed files with 265 additions and 73 deletions

View file

@ -150,12 +150,14 @@ public sealed class CellStructSurfaceConstructionInstalledDatTests {
if (poly.VertexIds.Count < 3) continue;
polygons++;
// A raw sides value outside 0/1/2 is an authored-data
// anomaly, counted here; retail (and the port) still
// construct its single-side shape (ConstructMesh
// @0x0059DFA0 loop bounds default to 1).
if (!CellStructSideCandidates.IsRetailDefinedSidesType((int)poly.SidesType))
unknownSidesType++;
ReadOnlySpan<CellStructSideCandidate> polyCandidates =
CellStructSideCandidates.GetCandidates((int)poly.SidesType);
if (polyCandidates.Length == 0) {
unknownSidesType++;
continue;
}
foreach (var candidate in polyCandidates) {
candidates++;

View file

@ -497,6 +497,91 @@ public sealed class MeshExtractorSolidFaceExtractionTests
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()
{

View file

@ -176,8 +176,14 @@ public sealed class StipplingSurfaceEquivalenceTests
foreach (var v in aViolations.Take(20)) _out.WriteLine($" {v}");
_out.WriteLine($"(b) building untextured-but-not-NoPos (load-bearing): {bViolations.Count}");
foreach (var v in bViolations.Take(20)) _out.WriteLine($" {v}");
_out.WriteLine($"(c) cell NoPos-but-not-portal-poly (REPORT ONLY, not asserted): {cellPortalPolyMismatches.Count}");
_out.WriteLine($"(c) cell NoPos-but-not-portal-poly (content-shape pin, not a draw rule): {cellPortalPolyMismatches.Count}");
foreach (var v in cellPortalPolyMismatches.Take(20)) _out.WriteLine($" {v}");
// Pinned as a content-shape golden (S1 review, 2026-09-02): zero on
// the installed DATs recorded in the OH2 contract §1 table. It is not
// a draw rule any more (cells resolve the Surface's own Type), but a
// change here means the authored content or this sweep changed and
// deserves a look rather than a silent console line.
Assert.Empty(cellPortalPolyMismatches);
Assert.Empty(aViolations);
Assert.Empty(bViolations);

View file

@ -79,11 +79,19 @@ public class CellStructSideCandidatesTests
[InlineData(3)]
[InlineData(-1)]
[InlineData(99)]
public void UnknownSidesValue_YieldsNoCandidates(int rawSidesType)
public void UnknownSidesValue_FallsBackToRetailSingleSideShape(int rawSidesType)
{
// ConstructMesh @0x0059DFA0 initializes both loop bounds to 1 and
// widens them only on an exact 1 / 2 match, so any other raw value
// constructs the single positive fan (S1 review finding, 2026-09-02).
var candidates = CellStructSideCandidates.GetCandidates(rawSidesType);
Assert.True(candidates.IsEmpty);
var single = Assert.Single(candidates.ToArray());
Assert.Equal(CellStructPolygonSurfaceSide.Positive, single.SurfaceSlot);
Assert.Equal(0, single.CopyOrdinal);
Assert.Equal(1, single.NormalSign);
Assert.False(single.ReverseWinding);
Assert.False(CellStructSideCandidates.IsRetailDefinedSidesType(rawSidesType));
}
// ---- exact fan index order (§3.4 "Fan index order" column) ----