using System; using DatReaderWriter.Enums; namespace AcDream.Core.Meshing; /// /// Which of a retail CPolygon's two surface/UV records a /// reads: the positive side /// (pos_surface / pos_uv_indices) or the negative side /// (neg_surface / neg_uv_indices). This is retail's "side /// ordinal" from the emission-loop branch table in /// D3DPolyRender::ConstructMesh @0x0059DFA0 /// (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md /// §3.4): side ordinal 0 always reads pos_surface/positive UVs, /// side ordinal 1 (the ST_BOTH negative candidate) always reads /// neg_surface/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. /// public enum CellStructPolygonSurfaceSide { /// Reads pos_surface and pos_uv_indices. Positive = 0, /// Reads neg_surface and neg_uv_indices. Negative = 1, } /// /// One construction candidate emitted by retail's /// D3DPolyRender::ConstructMesh @0x0059DFA0 for a single /// CPolygon, 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). /// /// /// Which surface index field (pos_surface/neg_surface) this /// candidate's triangles are attributed to — the source-surface-array-index /// subset owner per contract §3.6. /// /// /// Which UV-index array (pos_uv_indices/neg_uv_indices) this /// candidate reads. Equal to 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 /// CPolygon::UnPack @0x00538650 aliases /// neg_uv_indices = pos_uv_indices for ST_DOUBLE 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. /// /// /// 0 for a polygon's first emitted copy, 1 for ST_DOUBLE'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). /// /// /// +1 or -1. copyVert @0x0059C080 multiplies the authored vertex /// normal by this value (pseudo-C 424791-424793). /// /// /// True selects the reversed triangle-fan index order /// [t+2, t+1, 0] instead of the forward order [0, t+1, t+2] /// (contract §3.4; see ). /// Retail reverses on nonzero COPY ordinal, not on side ordinal: the /// ST_BOTH negative candidate (side ordinal 1, copy ordinal 0) is /// NOT reversed, only ST_DOUBLE's second copy is. Do not normalize /// this to the more intuitive "negative side is reversed" shape. /// public readonly record struct CellStructSideCandidate( CellStructPolygonSurfaceSide SurfaceSlot, CellStructPolygonSurfaceSide UvSlot, int CopyOrdinal, int NormalSign, bool ReverseWinding); /// /// Retail's exact CPolygon::sides_type → construction-candidate /// mapping, per-surface mask computation, and UV-absence rule, ported from /// D3DPolyRender::ConstructMesh @0x0059DFA0, /// CPolygon::UnPack @0x00538650, and copyVert @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 (Surface.Type & 6) != 0 /// admission test are a later chunk's responsibility — see /// for that predicate. /// /// /// /// Raw sides_type input, not DatReaderWriter.Enums.CullMode. /// The DRW field Polygon.SidesType is typed CullMode, but its /// member names (Landblock=0, None=1, Clockwise=2, /// CounterClockwise=3) do NOT read as ST_SINGLE/ST_DOUBLE/ /// ST_BOTH — they are a generic, reused enum. Decompiling /// DatReaderWriter.Types.Polygon.Unpack (ilspycmd against /// Chorizite.DatReaderWriter 2.1.7, verified 2026-09-02) shows /// SidesType = (CullMode)reader.ReadInt32(); — 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; NegUVIndices is read only when /// SidesType == CullMode.Clockwise (raw 2), matching contract §2.3 /// point 2 ("`neg_uv_indices` only when `sides_type == 2`") exactly. A /// caller therefore passes (int)poly.SidesType to /// and gets the exact retail branch — this /// method intentionally takes a raw instead of /// CullMode so callers are not misled by the enum's names. /// /// 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), }; /// /// Maps a raw retail CPolygon::sides_type 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. /// /// /// Any value other than 1 or 2 takes the single-side shape. That is /// retail's literal branching, not a guess: ConstructMesh /// initializes both loop bounds to 1 and widens the side bound only on /// sides_type == 2 and the copy bound only on /// sides_type == 1 (pseudo-C 426837-426842 and 427058-427066; /// Ghidra iStack_44 = 1; local_5c = 1; if (*piVar6 == 2) ...; /// if (*piVar6 == 1) ...). 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. /// public static ReadOnlySpan GetCandidates(int rawSidesType) => rawSidesType switch { 1 => DoubleCandidates, 2 => BothCandidates, _ => SingleCandidates, }; /// /// True for the three retail-defined SidesType values /// (ST_SINGLE=0, ST_DOUBLE=1, ST_BOTH=2, /// acclient.h:7372). Anything else is authored-data corruption that /// still renders as retail would (single /// side); callers use this only to report the anomaly. /// public static bool IsRetailDefinedSidesType(int rawSidesType) => rawSidesType is 0 or 1 or 2; /// /// Retail's exact triangle-fan vertex index order for one triangle /// within a fan, per contract §3.4's "Fan index order" column: forward /// [0, t+1, t+2], or — when is /// set (an ST_DOUBLE second copy) — reversed [t+2, t+1, 0] /// (pseudo-C 427140-427145). 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. /// public static (int A, int B, int C) TriangleFanIndices(int triangleIndex, bool reverseWinding) => reverseWinding ? (triangleIndex + 2, triangleIndex + 1, 0) : (0, triangleIndex + 1, triangleIndex + 2); /// /// Whether 's UV-index array is absent for /// this polygon, per contract §3.5: CPolygon::UnPack /// @0x00538650 skips allocating/reading pos_uv_indices when /// (stippling & NO_POS_UVS) != 0 (bit 4) and /// neg_uv_indices when (stippling & NO_NEG_UVS) != 0 /// (bit 8) — see §2.3. Absence means copyVert @0x0059C080 writes /// UV index/coordinates of zero (pseudo-C 424797-424829); it never /// removes the candidate. Callers must construct the candidate from /// regardless of this result and only use /// it to pick the zero-UV fallback path. /// 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; /// /// Retail's initial per-surface mask byte /// (MeshBuffer::isStippledOrAlphaedMask), derived solely from the /// surface's own raw Type 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 — is the /// separate per-polygon update layered on top of it. /// 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; } /// /// 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 /// stippling byte, reinterpreted as a SIGNED byte, is greater /// than zero (a signed SETG comparison, not a raw-nonzero test). /// This is deliberately broader than the low two stipple-side bits: /// every defined nonzero value — including /// NoPos/NoNeg — 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 /// is /// (the /// ST_BOTH negative candidate) and this method leaves /// untouched, even for a positive raw /// stippling value — retail never ORs this bit into the negative /// surface's mask. /// /// /// is backed by /// an unsigned (Chorizite.DatReaderWriter 2.1.7, /// verified by reflection 2026-09-02), unlike retail's signed /// char stippling (contract §2.1). This method performs the /// signed reinterpretation internally so callers can pass /// poly.Stippling directly without knowing about the signed-byte /// nuance. /// 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; } }