Campaign OVERHAUL S1 chunk A. Retail's D3DPolyRender::ConstructMesh @0x0059DFA0 is ported as one pure Core descriptor plus the Content extraction that consumes it: - side candidates come only from sides_type (0/1/2); NoPos/NoNeg mean UV-array absence only and never suppress a side (CPolygon::UnPack @0x00538650); - ST_DOUBLE's second copy is reversed with a negative normal; ST_BOTH's negative side has a negative normal and forward fan order (reverse is on the copy ordinal, not the side ordinal); - an absent UV-index array is UV index 0 (ConstructMesh @0x0059E691 xor ebx,ebx, arbitrated on the PDB-paired binary); copyVert @0x0059C080 zeroes coordinates only for a negative or out-of-range index or a vertex without UVs, never by clamping to slot 0; - the subset owner is the source surface-array index, emitted in ascending slot order with retail's per-slot mask (2 > 8 > 4 precedence, positive surface OR on signed stippling > 0); - built-EnvCell admission is (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 after surface resolution (DrawEnvCell @0x0059F170 -> DrawMesh @0x0059D4A0 arg4=1); untextured slots are constructed but not emitted; - cell batches carry SourceSurfaceIndex, RetailSurfaceMask, RawSurfaceType, IsCellShell, and fixed clockwise raster cull (RenderMeshSubset @0x0059CA10); authored sides_type is no longer stored as GPU cull. Prepared-mesh serializer gains the four fields; bake recipe 7 -> 8 with a FullRebuild migration; pak format stays 2 (pinned). Ordinary GfxObj extraction is unchanged. AP-234's register row and CellMesh unification land in chunk B. Core: 32 descriptor tests. Content: 170/170. Bake: 18/18. Launcher.Core: 365/365 (Lane!=Linux). Solution Release build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
322 lines
12 KiB
C#
322 lines
12 KiB
C#
using AcDream.Core.Meshing;
|
|
using DatReaderWriter.Enums;
|
|
|
|
namespace AcDream.Core.Tests.Meshing;
|
|
|
|
/// <summary>
|
|
/// Pins the pure parts of
|
|
/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
|
/// §10.1 for the OH2/S1 chunk-1 descriptor: candidate construction
|
|
/// (§3.4), UV absence (§3.5), and the per-surface mask (§3.2/§3.3).
|
|
/// DAT resolution, subset aggregation, and draw admission are a later
|
|
/// chunk (contract §9) and are out of scope here.
|
|
/// </summary>
|
|
public class CellStructSideCandidatesTests
|
|
{
|
|
// ---- §3.4 candidate construction: exact count, order, sign, winding ----
|
|
|
|
[Fact]
|
|
public void SidesSingle_YieldsOnePositiveForwardCandidate()
|
|
{
|
|
var candidates = CellStructSideCandidates.GetCandidates(0).ToArray();
|
|
|
|
var candidate = Assert.Single(candidates);
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, candidate.SurfaceSlot);
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, candidate.UvSlot);
|
|
Assert.Equal(0, candidate.CopyOrdinal);
|
|
Assert.Equal(1, candidate.NormalSign);
|
|
Assert.False(candidate.ReverseWinding);
|
|
}
|
|
|
|
[Fact]
|
|
public void SidesDouble_YieldsTwoPositiveCandidates_SecondCopyReversedWithNegativeNormal()
|
|
{
|
|
var candidates = CellStructSideCandidates.GetCandidates(1).ToArray();
|
|
|
|
Assert.Equal(2, candidates.Length);
|
|
|
|
var first = candidates[0];
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, first.SurfaceSlot);
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, first.UvSlot);
|
|
Assert.Equal(0, first.CopyOrdinal);
|
|
Assert.Equal(1, first.NormalSign);
|
|
Assert.False(first.ReverseWinding);
|
|
|
|
var second = candidates[1];
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, second.SurfaceSlot);
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, second.UvSlot);
|
|
Assert.Equal(1, second.CopyOrdinal);
|
|
Assert.Equal(-1, second.NormalSign);
|
|
Assert.True(second.ReverseWinding);
|
|
}
|
|
|
|
[Fact]
|
|
public void SidesBoth_YieldsPositiveThenNegativeCandidate_NegativeSideNotReversed()
|
|
{
|
|
var candidates = CellStructSideCandidates.GetCandidates(2).ToArray();
|
|
|
|
Assert.Equal(2, candidates.Length);
|
|
|
|
var positive = candidates[0];
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, positive.SurfaceSlot);
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, positive.UvSlot);
|
|
Assert.Equal(0, positive.CopyOrdinal);
|
|
Assert.Equal(1, positive.NormalSign);
|
|
Assert.False(positive.ReverseWinding);
|
|
|
|
// The counterintuitive, binding fact from contract §3.4: ST_BOTH's
|
|
// negative candidate gets a negative normal but is NOT index-reversed
|
|
// (retail reverses on nonzero COPY ordinal, not side ordinal).
|
|
var negative = candidates[1];
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Negative, negative.SurfaceSlot);
|
|
Assert.Equal(CellStructPolygonSurfaceSide.Negative, negative.UvSlot);
|
|
Assert.Equal(0, negative.CopyOrdinal);
|
|
Assert.Equal(-1, negative.NormalSign);
|
|
Assert.False(negative.ReverseWinding);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(3)]
|
|
[InlineData(-1)]
|
|
[InlineData(99)]
|
|
public void UnknownSidesValue_YieldsNoCandidates(int rawSidesType)
|
|
{
|
|
var candidates = CellStructSideCandidates.GetCandidates(rawSidesType);
|
|
|
|
Assert.True(candidates.IsEmpty);
|
|
}
|
|
|
|
// ---- exact fan index order (§3.4 "Fan index order" column) ----
|
|
|
|
[Theory]
|
|
[InlineData(0, 0, 1, 2)]
|
|
[InlineData(1, 0, 2, 3)]
|
|
[InlineData(5, 0, 6, 7)]
|
|
public void ForwardWinding_ProducesForwardFanIndices(int triangleIndex, int a, int b, int c)
|
|
{
|
|
var (fa, fb, fc) = CellStructSideCandidates.TriangleFanIndices(triangleIndex, reverseWinding: false);
|
|
|
|
Assert.Equal((a, b, c), (fa, fb, fc));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 2, 1, 0)]
|
|
[InlineData(1, 3, 2, 0)]
|
|
[InlineData(5, 7, 6, 0)]
|
|
public void ReversedWinding_ProducesReversedFanIndices(int triangleIndex, int a, int b, int c)
|
|
{
|
|
var (fa, fb, fc) = CellStructSideCandidates.TriangleFanIndices(triangleIndex, reverseWinding: true);
|
|
|
|
Assert.Equal((a, b, c), (fa, fb, fc));
|
|
}
|
|
|
|
// ---- §3.5 UV absence: candidate survives, UV becomes zero ----
|
|
|
|
[Fact]
|
|
public void NoPosStippling_MakesPositiveSlotCandidateUvAbsent_ButCandidateStillPresent()
|
|
{
|
|
var candidates = CellStructSideCandidates.GetCandidates(0).ToArray();
|
|
var candidate = Assert.Single(candidates);
|
|
|
|
Assert.True(CellStructSideCandidates.IsUvAbsent(candidate, StipplingType.NoPos));
|
|
}
|
|
|
|
[Fact]
|
|
public void NoNegStippling_MakesNegativeSlotCandidateUvAbsent_ButCandidateStillPresent()
|
|
{
|
|
var candidates = CellStructSideCandidates.GetCandidates(2).ToArray();
|
|
var negative = candidates[1];
|
|
|
|
// The candidate exists regardless of NoNeg — GetCandidates() and
|
|
// IsUvAbsent() are deliberately decoupled so absence can never drop
|
|
// a construction candidate (contract §3.5, §9 item 1).
|
|
Assert.True(CellStructSideCandidates.IsUvAbsent(negative, StipplingType.NoNeg));
|
|
}
|
|
|
|
[Fact]
|
|
public void NoNegStippling_DoesNotMakePositiveSlotCandidateUvAbsent()
|
|
{
|
|
var candidates = CellStructSideCandidates.GetCandidates(2).ToArray();
|
|
var positive = candidates[0];
|
|
|
|
Assert.False(CellStructSideCandidates.IsUvAbsent(positive, StipplingType.NoNeg));
|
|
}
|
|
|
|
[Fact]
|
|
public void NoUvBits_LeavesUvPresent()
|
|
{
|
|
var candidates = CellStructSideCandidates.GetCandidates(0).ToArray();
|
|
var candidate = Assert.Single(candidates);
|
|
|
|
Assert.False(CellStructSideCandidates.IsUvAbsent(candidate, StipplingType.None));
|
|
}
|
|
|
|
// ---- §3.2 per-surface initial mask: exact precedence ----
|
|
|
|
[Fact]
|
|
public void ClipMapSurface_HasInitialMaskEight()
|
|
{
|
|
Assert.Equal(8, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Base1ClipMap));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlphaSurface_HasInitialMaskTwo()
|
|
{
|
|
Assert.Equal(2, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Alpha));
|
|
}
|
|
|
|
[Fact]
|
|
public void InvAlphaSurface_HasInitialMaskTwo()
|
|
{
|
|
Assert.Equal(2, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.InvAlpha));
|
|
}
|
|
|
|
[Fact]
|
|
public void AdditiveSurface_HasInitialMaskTwo()
|
|
{
|
|
Assert.Equal(2, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Additive));
|
|
}
|
|
|
|
[Fact]
|
|
public void TranslucentSurface_HasInitialMaskFour()
|
|
{
|
|
Assert.Equal(4, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Translucent));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlphaFamily_TakesPrecedenceOverClipMap()
|
|
{
|
|
var type = SurfaceType.Alpha | SurfaceType.Base1ClipMap;
|
|
|
|
Assert.Equal(2, CellStructSideCandidates.InitialSurfaceMask(type));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClipMap_TakesPrecedenceOverTranslucent()
|
|
{
|
|
var type = SurfaceType.Base1ClipMap | SurfaceType.Translucent;
|
|
|
|
Assert.Equal(8, CellStructSideCandidates.InitialSurfaceMask(type));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlainSolidSurface_HasInitialMaskZero()
|
|
{
|
|
Assert.Equal(0, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Base1Solid));
|
|
}
|
|
|
|
// ---- untextured surfaces are constructed as candidates; this layer
|
|
// never conflates construction with the (later, out-of-scope) built-
|
|
// EnvCell (Surface.Type & 6) != 0 draw-admission test ----
|
|
|
|
[Fact]
|
|
public void UntexturedSolidSurfaceType_IsUntextured_ButCandidateIsStillConstructed()
|
|
{
|
|
// 0x1 = Base1Solid only.
|
|
var type = (SurfaceType)0x1;
|
|
Assert.True(RetailUntexturedSurfacePolicy.IsUntextured(type));
|
|
|
|
// GetCandidates never takes a Surface.Type — construction and
|
|
// draw admission are independent per contract §9 item 1.
|
|
var candidates = CellStructSideCandidates.GetCandidates(0);
|
|
Assert.False(candidates.IsEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public void UntexturedSolidTranslucentSurfaceType_IsUntextured_ButCandidateIsStillConstructed()
|
|
{
|
|
// 0x11 = Base1Solid | Translucent — the canonical cathedral NoPos
|
|
// surface type from contract §10.3.
|
|
var type = (SurfaceType)0x11;
|
|
Assert.True(RetailUntexturedSurfacePolicy.IsUntextured(type));
|
|
Assert.Equal(4, CellStructSideCandidates.InitialSurfaceMask(type));
|
|
|
|
var candidates = CellStructSideCandidates.GetCandidates(0);
|
|
Assert.False(candidates.IsEmpty);
|
|
}
|
|
|
|
// ---- §3.2 per-polygon stippling mask update: signed-byte SETG,
|
|
// aimed only at the positive surface ----
|
|
|
|
[Fact]
|
|
public void PositiveStippling_OrsBitOneOnThePositiveSurfaceCandidate()
|
|
{
|
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
|
currentMask: 0,
|
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
|
stippling: StipplingType.Positive);
|
|
|
|
Assert.Equal(1, mask);
|
|
}
|
|
|
|
[Fact]
|
|
public void NegativeStippling_LeavesTheNegativeSurfaceCandidateMaskUnchanged()
|
|
{
|
|
// StipplingType.Negative (raw 2) is still positive as a signed
|
|
// byte, but retail ORs this bit only into the POSITIVE surface's
|
|
// mask (contract §3.2) — a candidate whose SurfaceSlot is Negative
|
|
// never receives it, regardless of the stippling value.
|
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
|
currentMask: 4,
|
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Negative,
|
|
stippling: StipplingType.Negative);
|
|
|
|
Assert.Equal(4, mask);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoPosOrNoNegStippling_StillOrsBitOneOnThePositiveSurfaceCandidate()
|
|
{
|
|
// "Deliberately broader than the low two stipple-side bits": every
|
|
// defined nonzero StipplingType value, including NoPos/NoNeg, is
|
|
// positive as a signed byte (contract §3.2).
|
|
var maskFromNoPos = CellStructSideCandidates.ApplyStipplingMaskBit(
|
|
currentMask: 0,
|
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
|
stippling: StipplingType.NoPos);
|
|
var maskFromNoNeg = CellStructSideCandidates.ApplyStipplingMaskBit(
|
|
currentMask: 0,
|
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
|
stippling: StipplingType.NoNeg);
|
|
|
|
Assert.Equal(1, maskFromNoPos);
|
|
Assert.Equal(1, maskFromNoNeg);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroStippling_DoesNotOrBitOne()
|
|
{
|
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
|
currentMask: 0,
|
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
|
stippling: StipplingType.None);
|
|
|
|
Assert.Equal(0, mask);
|
|
}
|
|
|
|
[Fact]
|
|
public void CorruptRawStipplingValue_NegativeAsSignedByte_DoesNotOrBitOne()
|
|
{
|
|
// Raw 0x80..0xFF is negative as a signed byte, so the SETG check
|
|
// fails even though the raw unsigned byte is nonzero.
|
|
var stippling = (StipplingType)0x80;
|
|
|
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
|
currentMask: 0,
|
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
|
stippling: stippling);
|
|
|
|
Assert.Equal(0, mask);
|
|
}
|
|
|
|
[Fact]
|
|
public void PreservesUnrelatedMaskBits_WhenOringBitOne()
|
|
{
|
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
|
currentMask: 8,
|
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
|
stippling: StipplingType.Positive);
|
|
|
|
Assert.Equal(9, mask);
|
|
}
|
|
}
|