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>
278 lines
14 KiB
C#
278 lines
14 KiB
C#
using System;
|
|
using DatReaderWriter.Enums;
|
|
|
|
namespace AcDream.Core.Meshing;
|
|
|
|
/// <summary>
|
|
/// Which of a retail <c>CPolygon</c>'s two surface/UV records a
|
|
/// <see cref="CellStructSideCandidate"/> reads: the positive side
|
|
/// (<c>pos_surface</c> / <c>pos_uv_indices</c>) or the negative side
|
|
/// (<c>neg_surface</c> / <c>neg_uv_indices</c>). This is retail's "side
|
|
/// ordinal" from the emission-loop branch table in
|
|
/// <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0
|
|
/// (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
|
/// §3.4): side ordinal 0 always reads <c>pos_surface</c>/positive UVs,
|
|
/// side ordinal 1 (the <c>ST_BOTH</c> negative candidate) always reads
|
|
/// <c>neg_surface</c>/negative UVs. The two never diverge in the retail
|
|
/// branch table, so one enum answers both "which side of the polygon"
|
|
/// and "which struct field to read" for a given candidate.
|
|
/// </summary>
|
|
public enum CellStructPolygonSurfaceSide
|
|
{
|
|
/// <summary>Reads <c>pos_surface</c> and <c>pos_uv_indices</c>.</summary>
|
|
Positive = 0,
|
|
|
|
/// <summary>Reads <c>neg_surface</c> and <c>neg_uv_indices</c>.</summary>
|
|
Negative = 1,
|
|
}
|
|
|
|
/// <summary>
|
|
/// One construction candidate emitted by retail's
|
|
/// <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0 for a single
|
|
/// <c>CPolygon</c>, per the exact branch table at
|
|
/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
|
/// §3.4 (pseudo-C 427047-427194, confirmed instruction-for-instruction in
|
|
/// Ghidra). A candidate is a pure description of "build one triangle fan
|
|
/// from this polygon, sourced/wound/signed this way" — it does not resolve
|
|
/// a DAT Surface and does not decide draw admission (OH2 §9 item 1).
|
|
/// </summary>
|
|
/// <param name="SurfaceSlot">
|
|
/// Which surface index field (<c>pos_surface</c>/<c>neg_surface</c>) this
|
|
/// candidate's triangles are attributed to — the source-surface-array-index
|
|
/// subset owner per contract §3.6.
|
|
/// </param>
|
|
/// <param name="UvSlot">
|
|
/// Which UV-index array (<c>pos_uv_indices</c>/<c>neg_uv_indices</c>) this
|
|
/// candidate reads. Equal to <see cref="SurfaceSlot"/> in every row of the
|
|
/// retail branch table (§3.4), but kept as an independently named fact
|
|
/// because the contract names it as its own table column and because
|
|
/// <c>CPolygon::UnPack</c> @0x00538650 aliases
|
|
/// <c>neg_uv_indices = pos_uv_indices</c> for <c>ST_DOUBLE</c> via a
|
|
/// separate code path from the surface-index alias (§2.3 point 3) — the
|
|
/// two facts happen to coincide, they are not definitionally the same
|
|
/// field.
|
|
/// </param>
|
|
/// <param name="CopyOrdinal">
|
|
/// 0 for a polygon's first emitted copy, 1 for <c>ST_DOUBLE</c>'s second
|
|
/// (duplicated, reversed) copy. Ghidra confirms the emission loop reuses a
|
|
/// dead pseudo-C parameter name for this ordinal — reading it as the
|
|
/// original function argument inverts the winding conclusion (contract §1).
|
|
/// </param>
|
|
/// <param name="NormalSign">
|
|
/// +1 or -1. <c>copyVert</c> @0x0059C080 multiplies the authored vertex
|
|
/// normal by this value (pseudo-C 424791-424793).
|
|
/// </param>
|
|
/// <param name="ReverseWinding">
|
|
/// True selects the reversed triangle-fan index order
|
|
/// <c>[t+2, t+1, 0]</c> instead of the forward order <c>[0, t+1, t+2]</c>
|
|
/// (contract §3.4; see <see cref="CellStructSideCandidates.TriangleFanIndices"/>).
|
|
/// Retail reverses on nonzero COPY ordinal, not on side ordinal: the
|
|
/// <c>ST_BOTH</c> negative candidate (side ordinal 1, copy ordinal 0) is
|
|
/// NOT reversed, only <c>ST_DOUBLE</c>'s second copy is. Do not normalize
|
|
/// this to the more intuitive "negative side is reversed" shape.
|
|
/// </param>
|
|
public readonly record struct CellStructSideCandidate(
|
|
CellStructPolygonSurfaceSide SurfaceSlot,
|
|
CellStructPolygonSurfaceSide UvSlot,
|
|
int CopyOrdinal,
|
|
int NormalSign,
|
|
bool ReverseWinding);
|
|
|
|
/// <summary>
|
|
/// Retail's exact <c>CPolygon::sides_type</c> → construction-candidate
|
|
/// mapping, per-surface mask computation, and UV-absence rule, ported from
|
|
/// <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0,
|
|
/// <c>CPolygon::UnPack</c> @0x00538650, and <c>copyVert</c> @0x0059C080
|
|
/// (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md,
|
|
/// binding verbatim). This is the OH2/S1 chunk-1 "smallest exact
|
|
/// implementation boundary" (contract §9 item 1): pure, allocation-free,
|
|
/// no DAT access, no draw-admission decision. Content-layer surface
|
|
/// resolution and the built-EnvCell <c>(Surface.Type & 6) != 0</c>
|
|
/// admission test are a later chunk's responsibility — see
|
|
/// <see cref="RetailUntexturedSurfacePolicy"/> for that predicate.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Raw <c>sides_type</c> input, not <c>DatReaderWriter.Enums.CullMode</c>.</b>
|
|
/// The DRW field <c>Polygon.SidesType</c> is typed <c>CullMode</c>, but its
|
|
/// member names (<c>Landblock</c>=0, <c>None</c>=1, <c>Clockwise</c>=2,
|
|
/// <c>CounterClockwise</c>=3) do NOT read as <c>ST_SINGLE</c>/<c>ST_DOUBLE</c>/
|
|
/// <c>ST_BOTH</c> — they are a generic, reused enum. Decompiling
|
|
/// <c>DatReaderWriter.Types.Polygon.Unpack</c> (ilspycmd against
|
|
/// Chorizite.DatReaderWriter 2.1.7, verified 2026-09-02) shows
|
|
/// <c>SidesType = (CullMode)reader.ReadInt32();</c> — a direct,
|
|
/// unremapped cast of the raw dat int32. So the underlying integer values
|
|
/// line up with retail exactly (0/1/2 = SINGLE/DOUBLE/BOTH) even though the
|
|
/// member NAMES do not; <c>NegUVIndices</c> is read only when
|
|
/// <c>SidesType == CullMode.Clockwise</c> (raw 2), matching contract §2.3
|
|
/// point 2 ("`neg_uv_indices` only when `sides_type == 2`") exactly. A
|
|
/// caller therefore passes <c>(int)poly.SidesType</c> to
|
|
/// <see cref="GetCandidates"/> and gets the exact retail branch — this
|
|
/// method intentionally takes a raw <see cref="int"/> instead of
|
|
/// <c>CullMode</c> so callers are not misled by the enum's names.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class CellStructSideCandidates
|
|
{
|
|
// ST_SINGLE (raw sides_type 0): one candidate, positive surface,
|
|
// positive normal, forward winding. Contract §3.4 row 1.
|
|
private static readonly CellStructSideCandidate[] SingleCandidates =
|
|
{
|
|
new(CellStructPolygonSurfaceSide.Positive, CellStructPolygonSurfaceSide.Positive, CopyOrdinal: 0, NormalSign: 1, ReverseWinding: false),
|
|
};
|
|
|
|
// ST_DOUBLE (raw sides_type 1): positive surface twice — first copy
|
|
// forward/positive-normal, second copy reversed/negative-normal.
|
|
// Contract §3.4 rows 2-3; the second row is the "counterintuitive"
|
|
// reverse-on-copy-ordinal fact (§3.4 last paragraph).
|
|
private static readonly CellStructSideCandidate[] DoubleCandidates =
|
|
{
|
|
new(CellStructPolygonSurfaceSide.Positive, CellStructPolygonSurfaceSide.Positive, CopyOrdinal: 0, NormalSign: 1, ReverseWinding: false),
|
|
new(CellStructPolygonSurfaceSide.Positive, CellStructPolygonSurfaceSide.Positive, CopyOrdinal: 1, NormalSign: -1, ReverseWinding: true),
|
|
};
|
|
|
|
// ST_BOTH (raw sides_type 2): positive surface (forward/+normal), then
|
|
// negative surface (forward/-normal — NOT reversed). Contract §3.4 rows
|
|
// 4-5; the negative row is the "binding" fact that ST_BOTH changes the
|
|
// side ordinal, not the copy ordinal, so its winding stays forward.
|
|
private static readonly CellStructSideCandidate[] BothCandidates =
|
|
{
|
|
new(CellStructPolygonSurfaceSide.Positive, CellStructPolygonSurfaceSide.Positive, CopyOrdinal: 0, NormalSign: 1, ReverseWinding: false),
|
|
new(CellStructPolygonSurfaceSide.Negative, CellStructPolygonSurfaceSide.Negative, CopyOrdinal: 0, NormalSign: -1, ReverseWinding: false),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Maps a raw retail <c>CPolygon::sides_type</c> value (0 = ST_SINGLE,
|
|
/// 1 = ST_DOUBLE, 2 = ST_BOTH; acclient.h:7372) to its ordered
|
|
/// construction candidates per contract §3.4. Allocation-free: each
|
|
/// branch returns a span over a static readonly array.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Any value other than 1 or 2 takes the single-side shape. That is
|
|
/// retail's literal branching, not a guess: <c>ConstructMesh</c>
|
|
/// initializes both loop bounds to 1 and widens the side bound only on
|
|
/// <c>sides_type == 2</c> and the copy bound only on
|
|
/// <c>sides_type == 1</c> (pseudo-C 426837-426842 and 427058-427066;
|
|
/// Ghidra <c>iStack_44 = 1; local_5c = 1; if (*piVar6 == 2) ...;
|
|
/// if (*piVar6 == 1) ...</c>). The installed corpus contains only 0/1/2
|
|
/// (pinned by the S1 installed-DAT scan); callers should still count a
|
|
/// raw value outside that set as a data anomaly for diagnostics, but the
|
|
/// geometry it produces is retail's, so no divergence row is needed.
|
|
/// </remarks>
|
|
public static ReadOnlySpan<CellStructSideCandidate> GetCandidates(int rawSidesType) => rawSidesType switch
|
|
{
|
|
1 => DoubleCandidates,
|
|
2 => BothCandidates,
|
|
_ => SingleCandidates,
|
|
};
|
|
|
|
/// <summary>
|
|
/// True for the three retail-defined <c>SidesType</c> values
|
|
/// (<c>ST_SINGLE</c>=0, <c>ST_DOUBLE</c>=1, <c>ST_BOTH</c>=2,
|
|
/// acclient.h:7372). Anything else is authored-data corruption that
|
|
/// <see cref="GetCandidates"/> still renders as retail would (single
|
|
/// side); callers use this only to report the anomaly.
|
|
/// </summary>
|
|
public static bool IsRetailDefinedSidesType(int rawSidesType) => rawSidesType is 0 or 1 or 2;
|
|
|
|
/// <summary>
|
|
/// Retail's exact triangle-fan vertex index order for one triangle
|
|
/// within a fan, per contract §3.4's "Fan index order" column: forward
|
|
/// <c>[0, t+1, t+2]</c>, or — when <paramref name="reverseWinding"/> is
|
|
/// set (an <c>ST_DOUBLE</c> second copy) — reversed <c>[t+2, t+1, 0]</c>
|
|
/// (pseudo-C 427140-427145). <paramref name="triangleIndex"/> is the
|
|
/// 0-based triangle ordinal within the fan (t = 0 .. num_pts-3); the
|
|
/// returned tuple gives fan-relative vertex indices, not absolute
|
|
/// vertex ids.
|
|
/// </summary>
|
|
public static (int A, int B, int C) TriangleFanIndices(int triangleIndex, bool reverseWinding) =>
|
|
reverseWinding
|
|
? (triangleIndex + 2, triangleIndex + 1, 0)
|
|
: (0, triangleIndex + 1, triangleIndex + 2);
|
|
|
|
/// <summary>
|
|
/// Whether <paramref name="candidate"/>'s UV-index array is absent for
|
|
/// this polygon, per contract §3.5: <c>CPolygon::UnPack</c>
|
|
/// @0x00538650 skips allocating/reading <c>pos_uv_indices</c> when
|
|
/// <c>(stippling & NO_POS_UVS) != 0</c> (bit 4) and
|
|
/// <c>neg_uv_indices</c> when <c>(stippling & NO_NEG_UVS) != 0</c>
|
|
/// (bit 8) — see §2.3. Absence means <c>copyVert</c> @0x0059C080 writes
|
|
/// UV index/coordinates of zero (pseudo-C 424797-424829); it never
|
|
/// removes the candidate. Callers must construct the candidate from
|
|
/// <see cref="GetCandidates"/> regardless of this result and only use
|
|
/// it to pick the zero-UV fallback path.
|
|
/// </summary>
|
|
public static bool IsUvAbsent(CellStructSideCandidate candidate, StipplingType stippling)
|
|
{
|
|
var absenceBit = candidate.UvSlot == CellStructPolygonSurfaceSide.Positive
|
|
? StipplingType.NoPos
|
|
: StipplingType.NoNeg;
|
|
return (stippling & absenceBit) != 0;
|
|
}
|
|
|
|
// Alpha-family surfaces take mask precedence over clip-map, which takes
|
|
// precedence over translucent. Contract §3.2: "if (type & 0x10300) != 0:
|
|
// mask = 2" — 0x10300 = Additive(0x10000) | InvAlpha(0x200) | Alpha(0x100).
|
|
private const SurfaceType AlphaFamilyMask =
|
|
SurfaceType.Alpha | SurfaceType.InvAlpha | SurfaceType.Additive;
|
|
|
|
/// <summary>
|
|
/// Retail's initial per-surface mask byte
|
|
/// (<c>MeshBuffer::isStippledOrAlphaedMask</c>), derived solely from the
|
|
/// surface's own raw <c>Type</c> flags, per contract §3.2
|
|
/// (pseudo-C 426788-426818) with this exact branch precedence:
|
|
/// alpha/invalpha/additive (mask 2) is checked first, then clip-map
|
|
/// (mask 8), then translucent (mask 4); anything else starts at 0.
|
|
/// This is a per-surface fact, computed once per surface before any
|
|
/// polygon is processed — <see cref="ApplyStipplingMaskBit"/> is the
|
|
/// separate per-polygon update layered on top of it.
|
|
/// </summary>
|
|
public static int InitialSurfaceMask(SurfaceType type)
|
|
{
|
|
if ((type & AlphaFamilyMask) != 0) return 2;
|
|
if ((type & SurfaceType.Base1ClipMap) != 0) return 8;
|
|
if ((type & SurfaceType.Translucent) != 0) return 4;
|
|
return 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's per-polygon mask update, per contract §3.2
|
|
/// (pseudo-C 426864-426871): for each polygon, retail ORs bit 1 into
|
|
/// ONLY the positive surface's mask when the polygon's raw
|
|
/// <c>stippling</c> byte, reinterpreted as a SIGNED byte, is greater
|
|
/// than zero (a signed <c>SETG</c> comparison, not a raw-nonzero test).
|
|
/// This is deliberately broader than the low two stipple-side bits:
|
|
/// every defined nonzero <see cref="StipplingType"/> value — including
|
|
/// <c>NoPos</c>/<c>NoNeg</c> — is positive as a signed byte and sets
|
|
/// bit 0; only corrupt raw values in 0x80..0xFF (negative as a signed
|
|
/// byte) do not. The update is unconditionally aimed at the POSITIVE
|
|
/// surface regardless of which specific stippling bits are set — it is
|
|
/// not "positive-vs-negative-stippling" semantics, it is "which surface
|
|
/// slot does this candidate own": pass a candidate whose
|
|
/// <see cref="CellStructSideCandidate.SurfaceSlot"/> is
|
|
/// <see cref="CellStructPolygonSurfaceSide.Negative"/> (the
|
|
/// <c>ST_BOTH</c> negative candidate) and this method leaves
|
|
/// <paramref name="currentMask"/> untouched, even for a positive raw
|
|
/// stippling value — retail never ORs this bit into the negative
|
|
/// surface's mask.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="DatReaderWriter.Types.Polygon.Stippling"/> is backed by
|
|
/// an unsigned <see cref="byte"/> (Chorizite.DatReaderWriter 2.1.7,
|
|
/// verified by reflection 2026-09-02), unlike retail's signed
|
|
/// <c>char stippling</c> (contract §2.1). This method performs the
|
|
/// signed reinterpretation internally so callers can pass
|
|
/// <c>poly.Stippling</c> directly without knowing about the signed-byte
|
|
/// nuance.
|
|
/// </remarks>
|
|
public static int ApplyStipplingMaskBit(
|
|
int currentMask,
|
|
CellStructPolygonSurfaceSide candidateSurfaceSlot,
|
|
StipplingType stippling)
|
|
{
|
|
if (candidateSurfaceSlot != CellStructPolygonSurfaceSide.Positive) return currentMask;
|
|
|
|
var signedStippling = unchecked((sbyte)(byte)stippling);
|
|
return signedStippling > 0 ? currentMask | 1 : currentMask;
|
|
}
|
|
}
|